Bundle the first-party Python scripting plugin

This commit is contained in:
Felix Riestra committed 2026-09-19 23:16:27 +02:00
1 parent 889042c1f1
commit de51c1a17f
26 files changed
+11459 -27

No files matched your search

@@ -0,0 +1,133 @@
"""Thin Python view over OCS host API v7 entity transactions and coverage."""
class _Entity:
def __init__(self, document, handle):
object.__setattr__(self, "_document", document)
object.__setattr__(self, "handle", handle)
def _data(self):
data = ocs.entity_descriptor(self.handle)
if data is None:
raise KeyError("entity %s is absent or outside the Python schema" % self.handle)
return data
def __getattr__(self, name):
data = self._data()
if name not in data:
raise AttributeError("property %s is not in the Python entity schema" % name)
pending = self._document._pending or {}
value = pending.get(self.handle, {}).get(name, data[name])
if isinstance(value, dict) and all(axis in value for axis in ("x", "y")):
return tuple(value[axis] for axis in ("x", "y", "z") if axis in value)
return value
@property
def coverage(self):
return ocs.entity_coverage(self._data()["kind"])
@property
def snapshot(self):
"""Detached copy of all serialized CAD fields for this entity."""
data = ocs.entity_snapshot(self.handle)
if data is None:
raise KeyError("entity %s is absent" % self.handle)
return data[self._data()["kind"]]
def __setattr__(self, name, value):
if self._document._pending is None:
raise RuntimeError("entity writes require doc.transaction(...)")
data = self._data()
can_edit = name in self.coverage["editable"]
if not can_edit or name not in data or name in ("kind", "handle", "_editable"):
raise AttributeError("property %s is not editable through this schema" % name)
old = data[name]
if isinstance(old, dict) and all(axis in old for axis in ("x", "y")):
axes = tuple(axis for axis in ("x", "y", "z") if axis in old)
if not isinstance(value, (tuple, list)) or len(value) != len(axes):
raise ValueError("%s expects %s coordinates" % (name, len(axes)))
value = dict(zip(axes, value))
self._document._pending.setdefault(self.handle, {})[name] = value
class _Entities:
def __init__(self, document):
self._document = document
def __getitem__(self, handle):
entity = _Entity(self._document, int(handle))
entity._data()
return entity
def __iter__(self):
for handle in ocs.entity_handles():
yield self[handle]
def __len__(self):
return len(ocs.entity_handles())
class _Transaction:
def __init__(self, document, label):
self.document, self.label = document, label
def __enter__(self):
if self.document._pending is not None:
raise RuntimeError("nested document transactions are unsupported")
self.document._pending = {}
return self.document
def __exit__(self, exc_type, exc, tb):
updates = self.document._pending
self.document._pending = None
if exc_type is None and updates:
ocs.update_many(self.label, [dict(handle=handle, **patch) for handle, patch in updates.items()])
return False
class _Document:
def __init__(self):
self._pending = None
self.entities = _Entities(self)
def transaction(self, label):
return _Transaction(self, label)
def create_entity(self, kind, **properties):
"""Create one mapped entity and return its live document descriptor."""
if "kind" in properties or "handle" in properties:
raise ValueError("kind and handle are managed by create_entity")
handle = ocs.add(dict(kind=kind, **properties))
return self.entities[handle]
def delete_entity(self, entity):
"""Delete an entity by descriptor or handle."""
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
ocs.remove_entity(handle)
def coverage(self, kind=None):
if kind is not None:
return ocs.entity_coverage(kind)
return {name: ocs.entity_coverage(name) for name in ocs.entity_kinds()}
def poll_events(self):
return ocs.poll_events(ocs.tab_id())
def request_point(self, prompt="Pick a point"):
return ocs.request_input(prompt, False)
def request_entity(self, prompt="Pick an entity"):
return ocs.request_input(prompt, True)
def poll_input(self, token):
return ocs.poll_input(token)
@property
def selection(self):
return [self.entities[handle] for handle in ocs.selection()]
@selection.setter
def selection(self, entities):
ocs.select([entity.handle if isinstance(entity, _Entity) else int(entity) for entity in entities])
ocs.active_document = _Document()
+223
View File
@@ -0,0 +1,223 @@
//! Bounded, tab-scoped notifications retained between fresh Python runs.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use ocs_plugin_api::host::HostNotification;
static EVENTS: Mutex<Option<HashMap<u64, TabEvents>>> = Mutex::new(None);
const CAPACITY_PER_TAB: usize = 256;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Event {
Drawing {
tab_id: u64,
version: u64,
},
Selection {
tab_id: u64,
handles: Vec<u64>,
},
Command {
tab_id: u64,
command: Option<String>,
},
Overflow {
tab_id: u64,
dropped: u64,
},
}
impl Event {
fn tab_id(&self) -> u64 {
match self {
Event::Drawing { tab_id, .. }
| Event::Selection { tab_id, .. }
| Event::Command { tab_id, .. }
| Event::Overflow { tab_id, .. } => *tab_id,
}
}
}
#[derive(Default)]
struct TabEvents {
queue: VecDeque<Event>,
dropped: u64,
}
pub(crate) fn observe(notification: &HostNotification) {
if let HostNotification::DocumentTabClosed { tab_id } = notification {
if let Ok(mut lock) = EVENTS.lock() {
if let Some(tabs) = lock.as_mut() {
tabs.remove(tab_id);
}
}
return;
}
let event = match notification {
#[cfg(feature = "experimental-host-model")]
HostNotification::DrawingChanged { tab_id, epoch } => Event::Drawing {
tab_id: *tab_id,
version: *epoch,
},
HostNotification::SelectionChangedV4 { tab_id, handles } => Event::Selection {
tab_id: *tab_id,
handles: handles.iter().map(|h| h.value()).collect(),
},
#[cfg(feature = "experimental-host-model")]
HostNotification::CommandStateChanged { tab_id, command } => Event::Command {
tab_id: *tab_id,
command: command.clone(),
},
_ => return,
};
if let Ok(mut lock) = EVENTS.lock() {
let tab = lock
.get_or_insert_with(HashMap::new)
.entry(event.tab_id())
.or_default();
if tab.queue.len() == CAPACITY_PER_TAB {
tab.queue.pop_front();
tab.dropped += 1;
}
tab.queue.push_back(event);
}
}
pub(crate) fn drain_for(tab_id: u64) -> Vec<Event> {
let Ok(mut lock) = EVENTS.lock() else {
return Vec::new();
};
let Some(tabs) = lock.as_mut() else {
return Vec::new();
};
let Some(tab) = tabs.remove(&tab_id) else {
return Vec::new();
};
let mut events = Vec::with_capacity(tab.queue.len() + usize::from(tab.dropped > 0));
if tab.dropped > 0 {
events.push(Event::Overflow {
tab_id,
dropped: tab.dropped,
});
}
events.extend(tab.queue);
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polling_one_tab_preserves_other_tabs_events() {
observe(&HostNotification::DrawingChanged {
tab_id: 101,
epoch: 1,
});
observe(&HostNotification::DrawingChanged {
tab_id: 102,
epoch: 2,
});
assert_eq!(
drain_for(101),
vec![Event::Drawing {
tab_id: 101,
version: 1
}]
);
assert_eq!(
drain_for(102),
vec![Event::Drawing {
tab_id: 102,
version: 2
}]
);
}
#[test]
fn noisy_tab_reports_its_own_overflow_without_evicting_another_tab() {
observe(&HostNotification::DrawingChanged {
tab_id: 201,
epoch: 7,
});
for epoch in 0..=CAPACITY_PER_TAB as u64 {
observe(&HostNotification::DrawingChanged { tab_id: 202, epoch });
}
assert_eq!(
drain_for(201),
vec![Event::Drawing {
tab_id: 201,
version: 7
}]
);
let events = drain_for(202);
assert_eq!(events.len(), CAPACITY_PER_TAB + 1);
assert_eq!(
events[0],
Event::Overflow {
tab_id: 202,
dropped: 1
}
);
assert_eq!(
events[1],
Event::Drawing {
tab_id: 202,
version: 1
}
);
}
#[test]
fn closing_tab_discards_its_pending_events() {
observe(&HostNotification::DrawingChanged {
tab_id: 301,
epoch: 3,
});
observe(&HostNotification::DocumentTabClosed { tab_id: 301 });
assert!(drain_for(301).is_empty());
}
#[test]
fn drawing_selection_and_command_events_keep_delivery_order() {
use ocs_plugin_api::host::Handle;
observe(&HostNotification::DrawingChanged {
tab_id: 401,
epoch: 9,
});
observe(&HostNotification::SelectionChangedV4 {
tab_id: 401,
handles: vec![Handle::new(17)],
});
observe(&HostNotification::CommandStateChanged {
tab_id: 401,
command: Some("LINE".into()),
});
observe(&HostNotification::CommandStateChanged {
tab_id: 401,
command: None,
});
assert_eq!(
drain_for(401),
vec![
Event::Drawing {
tab_id: 401,
version: 9
},
Event::Selection {
tab_id: 401,
handles: vec![17]
},
Event::Command {
tab_id: 401,
command: Some("LINE".into())
},
Event::Command {
tab_id: 401,
command: None
},
]
);
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Thread-local scratch slot that gives the `ocs` module's native functions
//! access to the `HostApi` for the currently running `PY_EVAL` call.
//!
//! RustPython native functions registered via `#[pymodule]` are plain free
//! functions — `add_native_module` takes a `&'static PyModuleDef`, so there is
//! no per-call closure environment to capture `host: &mut dyn HostApi` in.
//! This is the standard pattern for bridging an embedder's per-call context
//! into embedded callbacks (the same shape as Lua userdata or a C callback's
//! `void *ctx`): stash a pointer for the duration of one interpreter run, and
//! clear it before the run's borrow of `host` ends.
use std::cell::Cell;
use ocs_plugin_api::host::HostApi;
thread_local! {
static CURRENT_HOST: Cell<Option<*mut dyn HostApi>> = const { Cell::new(None) };
static UNDO_LABEL: Cell<&'static str> = const { Cell::new("") };
static UNDO_STARTED: Cell<bool> = const { Cell::new(false) };
}
/// Holds the thread-local host pointer for its lifetime; clears it on drop.
/// Must not outlive the `&mut dyn HostApi` borrow it was constructed from.
pub(crate) struct HostGuard;
impl HostGuard {
/// `undo_label` is the label a later `ensure_undo_started()` call (from
/// an `ocs` write function) will pass to `host.push_undo` — see its doc
/// comment for why this is one group per script, not one per call.
pub(crate) fn set(host: &mut dyn HostApi, undo_label: &'static str) -> Self {
// SAFETY: erases `host`'s borrow lifetime so the fat pointer fits in a
// `'static` thread_local slot (same layout either way: two words,
// data + vtable). Sound only because `Drop` clears the slot before
// this borrow's real lifetime ends — see `with_host`'s safety note.
let ptr: *mut dyn HostApi =
unsafe { std::mem::transmute::<&mut dyn HostApi, *mut dyn HostApi>(host) };
CURRENT_HOST.with(|cell| cell.set(Some(ptr)));
UNDO_LABEL.with(|cell| cell.set(undo_label));
UNDO_STARTED.with(|cell| cell.set(false));
HostGuard
}
}
impl Drop for HostGuard {
fn drop(&mut self) {
CURRENT_HOST.with(|cell| cell.set(None));
}
}
/// Call from an `ocs` write function (`add_line`, ...) right before it
/// mutates the document. Starts exactly one `host.push_undo` group per
/// `PY_EVAL`/`PY_RUN` call, however many entities the script adds — a script
/// that adds ten lines should undo as one step, not ten. A no-op for a
/// script that never writes, so a pure `PY_EVAL 1 + 1` doesn't clutter the
/// undo stack.
pub(crate) fn ensure_undo_started(host: &mut dyn HostApi) {
UNDO_STARTED.with(|started| {
if !started.get() {
host.push_undo(UNDO_LABEL.with(|label| label.get()));
started.set(true);
}
});
}
/// Run `f` with the `HostApi` active for the current `PY_EVAL` call, if any.
/// Returns `None` outside of a `HostGuard`-covered call (a script should never
/// observe this in practice, since the guard spans the whole interpreter run).
///
/// Not reentrant: `f` must not call `with_host` again itself, since that would
/// materialize two live `&mut dyn HostApi` referring to the same host at once.
/// Fine for Phase 1.2's `selection()`/`get()`, which don't call each other.
pub(crate) fn with_host<R>(f: impl FnOnce(&mut dyn HostApi) -> R) -> Option<R> {
CURRENT_HOST.with(|cell| {
let ptr = cell.get()?;
// SAFETY: `ptr` is only ever set by `HostGuard::set`, which is created
// from a live `&mut dyn HostApi` and cleared (via `Drop`) before that
// borrow's scope ends. The interpreter run this pointer is valid for
// happens entirely on this thread, within that same scope.
Some(f(unsafe { &mut *ptr }))
})
}
+159
View File
@@ -0,0 +1,159 @@
//! Results of host-driven point/entity picks, retained across Python runs.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use ocs_plugin_api::host::{CommandStep, Handle, InteractiveCommand};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static RESULTS: Mutex<Option<HashMap<u64, (u64, PickResult)>>> = Mutex::new(None);
#[derive(Clone)]
pub(crate) enum PickResult {
Pending,
Point([f64; 3]),
Entity { handle: u64, point: [f64; 3] },
Cancelled,
}
pub(crate) struct PickCommand {
id: u64,
tab_id: u64,
prompt: String,
entity: bool,
}
pub(crate) fn begin(tab_id: u64, prompt: String, entity: bool) -> (u64, PickCommand) {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut lock) = RESULTS.lock() {
let results = lock.get_or_insert_with(HashMap::new);
if results.len() >= 1024 {
if let Some(oldest) = results.keys().min().copied() {
results.remove(&oldest);
}
}
results.insert(id, (tab_id, PickResult::Pending));
}
(
id,
PickCommand {
id,
tab_id,
prompt,
entity,
},
)
}
pub(crate) fn take(tab_id: u64, id: u64) -> Option<PickResult> {
let mut lock = RESULTS.lock().ok()?;
let results = lock.as_mut()?;
let (owner, result) = results.get(&id)?;
if *owner != tab_id {
return None;
}
if matches!(result, PickResult::Pending) {
return Some(PickResult::Pending);
}
results.remove(&id).map(|(_, result)| result)
}
impl PickCommand {
fn finish(&self, result: PickResult) {
if let Ok(mut lock) = RESULTS.lock() {
lock.get_or_insert_with(HashMap::new)
.insert(self.id, (self.tab_id, result));
}
}
}
impl Drop for PickCommand {
fn drop(&mut self) {
if let Ok(mut lock) = RESULTS.lock() {
if let Some((owner, PickResult::Pending)) = lock.as_mut().and_then(|r| r.get(&self.id))
{
if *owner == self.tab_id {
lock.as_mut()
.unwrap()
.insert(self.id, (self.tab_id, PickResult::Cancelled));
}
}
}
}
}
impl InteractiveCommand for PickCommand {
fn prompt(&self) -> String {
self.prompt.clone()
}
fn needs_object_pick(&self) -> bool {
self.entity
}
fn on_point(&mut self, point: [f64; 3]) -> CommandStep {
if self.entity {
return CommandStep::NeedPoint;
}
self.finish(PickResult::Point(point));
CommandStep::Done
}
fn on_object_pick(&mut self, handle: Handle, point: [f64; 3]) -> CommandStep {
if !self.entity {
return CommandStep::NeedPoint;
}
self.finish(PickResult::Entity {
handle: handle.value(),
point,
});
CommandStep::Done
}
fn on_enter(&mut self) -> CommandStep {
self.finish(PickResult::Cancelled);
CommandStep::Cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_and_entity_picks_complete_tokens() {
let (point_token, mut point) = begin(7, "Point".into(), false);
assert!(matches!(take(7, point_token), Some(PickResult::Pending)));
assert!(take(8, point_token).is_none());
assert!(matches!(point.on_point([1.0, 2.0, 3.0]), CommandStep::Done));
assert!(take(8, point_token).is_none());
assert!(matches!(
take(7, point_token),
Some(PickResult::Point([1.0, 2.0, 3.0]))
));
let (entity_token, mut entity) = begin(8, "Entity".into(), true);
assert!(entity.needs_object_pick());
assert!(matches!(
entity.on_object_pick(Handle::new(17), [2.0, 0.0, 0.0]),
CommandStep::Done
));
assert!(matches!(
take(8, entity_token),
Some(PickResult::Entity { handle: 17, .. })
));
}
#[test]
fn abandoned_pick_reports_cancellation() {
let (token, command) = begin(7, "Point".into(), false);
drop(command);
assert!(take(8, token).is_none());
assert!(matches!(take(7, token), Some(PickResult::Cancelled)));
}
#[test]
fn enter_cancels_pick_without_returning_a_point() {
let (token, mut command) = begin(9, "Pick".into(), false);
assert!(matches!(command.on_enter(), CommandStep::Cancel));
assert!(matches!(take(9, token), Some(PickResult::Cancelled)));
drop(command);
assert!(take(9, token).is_none());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Python scripting plugin for Open CAD Studio.
//!
//! Phase 1.2 of `ROADMAP.md`: a read-only `ocs` module (`ocs.selection()`,
//! `ocs.get(handle)`) plus `PY_EVAL <expr>` to evaluate one inline Python
//! expression. Phase 1.3: 2D write access (`ocs.add_line`/`ocs.add_circle`)
//! plus `PY_RUN <path.py>` to run a script file. The experimental
//! `ocs.command("...")` / `ocs.select(handles)` functions remain disabled in
//! the bundled build because nested command replay can hang.
#[cfg(feature = "experimental-host-model")]
mod event_cache;
mod host_ctx;
#[cfg(feature = "experimental-host-model")]
mod input_cache;
mod ocs_module;
mod selection_cache;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, HostNotification};
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
pub(crate) const PLUGIN_ID: &str = "opencad.python";
// Keep these fields in sync with `plugin.toml`.
static MANIFEST: PluginManifest = PluginManifest {
id: PLUGIN_ID,
name: "Python Scripting",
version: env!("CARGO_PKG_VERSION"),
description: "Embedded Python scripting (RustPython) for Open CAD Studio.",
api_version: ApiVersion::CURRENT,
ribbon_order: 60,
xdata_apps: &[],
command_prefixes: &["PY_"],
};
/// The ribbon tab.
struct PythonModule;
impl CadModule for PythonModule {
fn id(&self) -> &'static str {
"opencad_python"
}
fn title(&self) -> &'static str {
"Python Scripting"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| {
vec![RibbonGroup {
title: "Tools",
tools: vec![
RibbonItem::LargeTool(ToolDef {
id: "PY_EVAL",
label: "Eval 1+1",
icon: IconKind::Glyph("★"),
event: ModuleEvent::Command("PY_EVAL 1 + 1".to_string()),
}),
RibbonItem::LargeTool(ToolDef {
id: "PY_RUN",
label: "Run Script…",
icon: IconKind::Glyph("▶"),
event: ModuleEvent::PluginFileDialog {
command: "PY_RUN".to_string(),
title: "Run Python Script".to_string(),
filter_name: "Python Scripts".to_string(),
extensions: vec!["py".to_string()],
},
}),
],
}]
})
}
}
/// The plugin entry point.
struct PythonPlugin;
impl BuiltinPlugin for PythonPlugin {
fn manifest(&self) -> &'static PluginManifest {
&MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(PythonModule)
}
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
if let Some(rest) = cmd.strip_prefix("PY_EVAL") {
let source = rest.trim();
if source.is_empty() {
host.push_error("PY_EVAL: expected a Python expression, e.g. PY_EVAL 1 + 1");
} else {
run_eval(host, source);
}
return true;
}
if let Some(rest) = cmd.strip_prefix("PY_RUN") {
let path = rest.trim();
if path.is_empty() {
host.push_error("PY_RUN: expected a file path, e.g. PY_RUN script.py");
} else {
run_file(host, path);
}
return true;
}
false
}
/// The runner's own event loop drains `HostApi::try_recv_notification()`
/// *before* any `Dispatch` request reaches `dispatch()` — calling it from
/// there always sees an empty queue. This callback is the only place a
/// V4+ plugin actually observes notifications; see `selection_cache.rs`.
fn on_notification(&mut self, _command_id: Option<u64>, notification: HostNotification) {
#[cfg(feature = "experimental-host-model")]
event_cache::observe(&notification);
match notification {
HostNotification::SelectionChanged { handles } => selection_cache::set(handles),
HostNotification::SelectionChangedV4 { handles, .. } => selection_cache::set(handles),
_ => {}
}
}
}
/// Evaluate one Python expression against a fresh interpreter (no session
/// persistence yet — that's a Phase 3 open question) and report the result or
/// error via the command line. A script bug must never look like a silent
/// no-op, so both paths always push something.
fn run_eval(host: &mut dyn HostApi, source: &str) {
let guard = host_ctx::HostGuard::set(host, "PY_EVAL");
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
let outcome = interp.enter(|vm| -> Result<(String, String), String> {
let output = install_output_capture(vm)?;
let scope = vm.new_scope_with_builtins();
// Pre-bind `ocs` in scope so a one-expression `PY_EVAL` can call
// `ocs.selection()` directly — `Mode::Eval` only accepts a single
// expression, so a script has no way to run an `import` statement
// itself.
let ocs = vm
.import("ocs", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
scope
.globals
.set_item("ocs", ocs, vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
#[cfg(feature = "experimental-host-model")]
install_document_model(vm, &scope)?;
let code_obj = vm
.compile(
source,
rustpython_vm::compiler::Mode::Eval,
"<py_eval>".to_string(),
)
.map_err(|err| err.to_string())?;
let result = match vm.run_code_obj(code_obj, scope) {
Ok(result) => result
.str(vm)
.map(|s| s.to_string())
.map_err(|exc| exception_to_string(vm, &exc)),
Err(exc) => Err(exception_to_string(vm, &exc)),
}?;
Ok((result, captured_output(vm, &output)?))
});
drop(guard);
match outcome {
Ok((text, printed)) => {
if !printed.is_empty() {
host.push_output(&printed);
}
host.push_output(&text);
}
Err(err) => host.push_error(&err),
}
}
/// Run a `.py` file (`PY_RUN <path>`) — same interpreter setup as `PY_EVAL`,
/// but `Mode::Exec` (statements, not a single expression) since a file can
/// hold a whole script. Reports errors the same way as `PY_EVAL`; on success
/// reports that the script ran rather than a value (`Mode::Exec` doesn't
/// produce one).
fn run_file(host: &mut dyn HostApi, path: &str) {
let source = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) => {
host.push_error(&format!("PY_RUN: cannot read {path}: {err}"));
return;
}
};
let guard = host_ctx::HostGuard::set(host, "PY_RUN");
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
let outcome = interp.enter(|vm| -> Result<String, String> {
let output = install_output_capture(vm)?;
let scope = vm.new_scope_with_builtins();
let ocs = vm
.import("ocs", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
scope
.globals
.set_item("ocs", ocs, vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
#[cfg(feature = "experimental-host-model")]
install_document_model(vm, &scope)?;
let code_obj = vm
.compile(
&source,
rustpython_vm::compiler::Mode::Exec,
path.to_string(),
)
.map_err(|err| err.to_string())?;
vm.run_code_obj(code_obj, scope)
.map_err(|exc| exception_to_string(vm, &exc))?;
captured_output(vm, &output)
});
drop(guard);
match outcome {
Ok(printed) => {
if !printed.is_empty() {
host.push_output(&printed);
}
host.push_output(&format!("PY_RUN: {path} completed"));
}
Err(err) => host.push_error(&err),
}
}
#[cfg(feature = "experimental-host-model")]
fn install_document_model(
vm: &rustpython_vm::VirtualMachine,
scope: &rustpython_vm::scope::Scope,
) -> Result<(), String> {
let code = vm
.compile(
include_str!("document_model.py"),
rustpython_vm::compiler::Mode::Exec,
"<ocs_document_model>".to_owned(),
)
.map_err(|error| error.to_string())?;
vm.run_code_obj(code, scope.clone())
.map_err(|exc| exception_to_string(vm, &exc))?;
Ok(())
}
#[cfg(all(test, feature = "experimental-host-model"))]
#[test]
fn document_model_bootstraps_in_rustpython() {
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
interp.enter(|vm| {
let scope = vm.new_scope_with_builtins();
let ocs = vm.import("ocs", 0).unwrap();
scope.globals.set_item("ocs", ocs, vm).unwrap();
install_document_model(vm, &scope).unwrap();
let code = vm
.compile(
"ocs.active_document.transaction('Move line').label",
rustpython_vm::compiler::Mode::Eval,
"<model_test>".to_owned(),
)
.unwrap();
let label = vm.run_code_obj(code, scope).unwrap();
assert_eq!(label.str(vm).unwrap().to_string(), "Move line");
});
}
/// RustPython is built without its stdio feature, so sys.stdout/stderr are
/// None by default. Give each fresh interpreter a memory stream for print().
fn install_output_capture(
vm: &rustpython_vm::VirtualMachine,
) -> Result<rustpython_vm::PyObjectRef, String> {
let sys = vm
.import("sys", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
let io = vm
.import("_io", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
let output = vm
.call_method(&io, "StringIO", ())
.map_err(|exc| exception_to_string(vm, &exc))?;
sys.set_attr("stdout", output.clone(), vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
sys.set_attr("stderr", output.clone(), vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
Ok(output)
}
fn captured_output(
vm: &rustpython_vm::VirtualMachine,
output: &rustpython_vm::PyObjectRef,
) -> Result<String, String> {
vm.call_method(output, "getvalue", ())
.and_then(|value| value.str(vm))
.map(|value| value.to_string().trim_end_matches('\n').to_owned())
.map_err(|exc| exception_to_string(vm, &exc))
}
/// `"<TypeName>: <message>"`, the same shape `str(exception)` plus its type
/// name produces in CPython — no full traceback yet (Phase 1.2 is minimal;
/// see the roadmap's error-handling note).
fn exception_to_string(
vm: &rustpython_vm::VirtualMachine,
exc: &rustpython_vm::builtins::PyBaseExceptionRef,
) -> String {
let exc_obj: rustpython_vm::PyObjectRef = exc.clone().into();
let type_name = exc_obj.class().name().to_string();
let message = exc_obj.str(vm).map(|s| s.to_string()).unwrap_or_default();
format!("{type_name}: {message}")
}
// Emit the C-ABI symbols the host loader looks for.
ocs_plugin_api::export_plugin!(PythonPlugin);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
//! Process-wide cache of the most recently observed selection.
//!
//! `HostApi` has no synchronous "give me the current selection" call — only
//! the best-effort `SelectionChanged` / `SelectionChangedV4` notification.
//! Naively calling `HostApi::try_recv_notification()` from inside `dispatch`
//! doesn't work: `ocs_plugin_api::runner`'s own V4 event loop
//! (`run_v4` in `runner.rs`) drains that queue itself on every iteration,
//! forwarding each notification to `BuiltinPlugin::on_notification` *before*
//! a `Dispatch` request ever reaches `dispatch()` — so by the time `dispatch`
//! runs, the queue is always empty. The only way to observe notifications is
//! to override `on_notification` and stash what's needed here; `dispatch`
//! (and the `ocs.selection()` pyfunction) then just reads this cache.
//!
//! A plain process-wide static is fine here: one plugin process hosts exactly
//! one `PythonPlugin`, so this is equivalent to an instance field, but a
//! static is far simpler to reach from the `#[pymodule]` native functions in
//! `ocs_module.rs`, which have no path back to the `BuiltinPlugin` instance.
use std::sync::Mutex;
use ocs_plugin_api::host::Handle;
static CACHE: Mutex<Vec<Handle>> = Mutex::new(Vec::new());
pub(crate) fn set(handles: Vec<Handle>) {
if let Ok(mut cache) = CACHE.lock() {
*cache = handles;
}
}
pub(crate) fn get() -> Vec<Handle> {
CACHE.lock().map(|cache| cache.clone()).unwrap_or_default()
}