Skip to main content

spvirit/
source.rs

1//! Python-defined dynamic [`Source`] support.
2//!
3//! Lets Python code implement a PVAccess *source* — the same abstraction
4//! used internally by the built-in store — so Python users can publish
5//! arbitrary PV names computed on the fly, proxy other systems, add access
6//! control, and so on.
7//!
8//! # Python API
9//!
10//! A *source* is any Python object that implements the duck-typed methods
11//! below.  All methods may be either plain functions or `async def`
12//! coroutines — the adapter detects awaitables automatically.
13//!
14//! ```text
15//! class MySource:
16//!     def claim(self, name): ...      # -> PvInfo | dict | None
17//!     def get(self, name): ...        # -> NtScalar | ... | None
18//!     def put(self, name, value): ... # -> dict[str, NtPayload] | list | None
19//!     def names(self): ...            # -> Iterable[str]
20//!     def rpc(self, name, args): ...  # optional -> NtPayload
21//!     def subscribe(self, name): ...  # optional (ignored; use notifier)
22//!     def on_start(self, notifier): ...# optional: receive PyNotifier
23//! ```
24//!
25//! Register via `ServerBuilder.add_source(label, order, source)` before
26//! build, or `Server.add_source(label, order, source)` after build.
27
28use std::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31
32use pyo3::prelude::*;
33use pyo3::types::{PyDict, PyList, PyTuple};
34use tokio::sync::mpsc;
35
36use spvirit_codec::spvd_decode::{DecodedValue, FieldDesc, FieldType, StructureDesc, TypeCode};
37use spvirit_server::monitor::MonitorRegistry;
38use spvirit_server::pvstore::{PvInfo, Source};
39use spvirit_types::NtPayload;
40
41use crate::convert::decoded_to_py;
42use crate::nt::{nt_payload_to_py, py_to_nt_payload};
43use crate::runtime::RUNTIME;
44
45// ─── Type-string parsing ─────────────────────────────────────────────────────
46
47fn parse_type_code(s: &str) -> Option<TypeCode> {
48    Some(match s {
49        "boolean" | "bool" => TypeCode::Boolean,
50        "byte" | "int8" | "i8" => TypeCode::Int8,
51        "short" | "int16" | "i16" => TypeCode::Int16,
52        "int" | "int32" | "i32" => TypeCode::Int32,
53        "long" | "int64" | "i64" => TypeCode::Int64,
54        "ubyte" | "uint8" | "u8" => TypeCode::UInt8,
55        "ushort" | "uint16" | "u16" => TypeCode::UInt16,
56        "uint" | "uint32" | "u32" => TypeCode::UInt32,
57        "ulong" | "uint64" | "u64" => TypeCode::UInt64,
58        "float" | "float32" | "f32" => TypeCode::Float32,
59        "double" | "float64" | "f64" => TypeCode::Float64,
60        _ => return None,
61    })
62}
63
64/// Parse a type string like `"double"`, `"int"`, `"string"`, `"double[]"`,
65/// `"string[]"`, or `"any"` into a [`FieldType`].
66fn parse_field_type(s: &str) -> PyResult<FieldType> {
67    let trimmed = s.trim();
68    // array?
69    if let Some(base) = trimmed.strip_suffix("[]") {
70        let base = base.trim();
71        if base == "string" || base == "str" {
72            return Ok(FieldType::StringArray);
73        }
74        if let Some(tc) = parse_type_code(base) {
75            return Ok(FieldType::ScalarArray(tc));
76        }
77        return Err(pyo3::exceptions::PyValueError::new_err(format!(
78            "unknown array element type: {base:?}"
79        )));
80    }
81    if trimmed == "string" || trimmed == "str" {
82        return Ok(FieldType::String);
83    }
84    if trimmed == "any" || trimmed == "variant" {
85        return Ok(FieldType::Variant);
86    }
87    if let Some(tc) = parse_type_code(trimmed) {
88        return Ok(FieldType::Scalar(tc));
89    }
90    Err(pyo3::exceptions::PyValueError::new_err(format!(
91        "unknown field type: {trimmed:?}"
92    )))
93}
94
95/// Build a [`StructureDesc`] from a Python `dict[str, str]` fields map.
96fn dict_to_structure_desc(
97    struct_id: Option<String>,
98    fields: &Bound<'_, PyDict>,
99) -> PyResult<StructureDesc> {
100    let mut desc_fields = Vec::with_capacity(fields.len());
101    for (key, val) in fields.iter() {
102        let name: String = key.extract()?;
103        let type_str: String = val.extract()?;
104        let field_type = parse_field_type(&type_str)?;
105        desc_fields.push(FieldDesc { name, field_type });
106    }
107    Ok(StructureDesc {
108        struct_id,
109        fields: desc_fields,
110    })
111}
112
113// ─── PyPvInfo ────────────────────────────────────────────────────────────────
114
115/// Describes a PV claimed by a Python source.  Returned from `claim()`.
116///
117/// ```python
118/// return spvirit.PvInfo.nt_scalar("double", writable=True)
119/// return spvirit.PvInfo("epics:nt/NTScalar:1.0", {"value": "double"}, writable=True)
120/// ```
121#[pyclass(name = "PvInfo")]
122#[derive(Clone)]
123pub struct PyPvInfo {
124    pub inner: PvInfo,
125}
126
127#[pymethods]
128impl PyPvInfo {
129    /// Build a PvInfo for a generic structure.
130    ///
131    /// `fields` is a `{field_name: type_str}` dict where type strings are
132    /// like `"double"`, `"int"`, `"string"`, `"double[]"`, or `"any"`.
133    #[new]
134    #[pyo3(signature = (struct_id, fields, writable=false))]
135    fn new(struct_id: String, fields: &Bound<'_, PyDict>, writable: bool) -> PyResult<Self> {
136        let desc = dict_to_structure_desc(Some(struct_id), fields)?;
137        Ok(Self {
138            inner: PvInfo {
139                descriptor: desc,
140                writable,
141            },
142        })
143    }
144
145    /// Build a PvInfo for an `NTScalar` of the given scalar type.
146    #[staticmethod]
147    #[pyo3(signature = (type_str, writable=false))]
148    fn nt_scalar(type_str: &str, writable: bool) -> PyResult<Self> {
149        let field_type = parse_field_type(type_str)?;
150        Ok(Self {
151            inner: PvInfo {
152                descriptor: StructureDesc {
153                    struct_id: Some("epics:nt/NTScalar:1.0".to_string()),
154                    fields: vec![FieldDesc {
155                        name: "value".to_string(),
156                        field_type,
157                    }],
158                },
159                writable,
160            },
161        })
162    }
163
164    /// Build a PvInfo for an `NTScalarArray` of the given element type
165    /// (pass the element type, e.g. `"double"`, NOT `"double[]"`).
166    #[staticmethod]
167    #[pyo3(signature = (element_type, writable=false))]
168    fn nt_scalar_array(element_type: &str, writable: bool) -> PyResult<Self> {
169        let array_spec = format!("{element_type}[]");
170        let field_type = parse_field_type(&array_spec)?;
171        Ok(Self {
172            inner: PvInfo {
173                descriptor: StructureDesc {
174                    struct_id: Some("epics:nt/NTScalarArray:1.0".to_string()),
175                    fields: vec![FieldDesc {
176                        name: "value".to_string(),
177                        field_type,
178                    }],
179                },
180                writable,
181            },
182        })
183    }
184
185    /// True if the PV accepts writes from clients.
186    #[getter]
187    fn writable(&self) -> bool {
188        self.inner.writable
189    }
190
191    /// Structure type ID of the claimed PV, or None.
192    #[getter]
193    fn struct_id(&self) -> Option<String> {
194        self.inner.descriptor.struct_id.clone()
195    }
196
197    fn __repr__(&self) -> String {
198        format!(
199            "PvInfo(struct_id={:?}, fields={}, writable={})",
200            self.inner.descriptor.struct_id,
201            self.inner.descriptor.fields.len(),
202            self.inner.writable
203        )
204    }
205}
206
207/// Extract a `PvInfo` from a Python object (either `PyPvInfo` or a dict).
208fn py_to_pv_info(obj: &Bound<'_, PyAny>) -> PyResult<PvInfo> {
209    if let Ok(info) = obj.downcast::<PyPvInfo>() {
210        return Ok(info.borrow().inner.clone());
211    }
212    if let Ok(dict) = obj.downcast::<PyDict>() {
213        let struct_id: Option<String> = match dict.get_item("struct_id")? {
214            Some(v) if !v.is_none() => Some(v.extract()?),
215            _ => None,
216        };
217        let writable: bool = match dict.get_item("writable")? {
218            Some(v) if !v.is_none() => v.extract()?,
219            _ => false,
220        };
221        let fields_obj = dict.get_item("fields")?.ok_or_else(|| {
222            pyo3::exceptions::PyValueError::new_err("PvInfo dict missing 'fields'")
223        })?;
224        let fields = fields_obj.downcast::<PyDict>().map_err(|_| {
225            pyo3::exceptions::PyTypeError::new_err("PvInfo 'fields' must be a dict")
226        })?;
227        let desc = dict_to_structure_desc(struct_id, fields)?;
228        return Ok(PvInfo {
229            descriptor: desc,
230            writable,
231        });
232    }
233    Err(pyo3::exceptions::PyTypeError::new_err(
234        "expected PvInfo instance or dict with 'struct_id'/'fields'/'writable'",
235    ))
236}
237
238// ─── PyNotifier ──────────────────────────────────────────────────────────────
239
240/// Handle for publishing monitor updates to subscribed PVAccess clients.
241///
242/// Passed to sources via `source.on_start(notifier)`.  Call `notify(name, nt)`
243/// from any Python thread to push a new value to all clients subscribed via
244/// monitor.
245#[pyclass(name = "Notifier")]
246#[derive(Clone)]
247pub struct PyNotifier {
248    registry: Arc<MonitorRegistry>,
249}
250
251impl PyNotifier {
252    pub fn new(registry: Arc<MonitorRegistry>) -> Self {
253        Self { registry }
254    }
255}
256
257#[pymethods]
258impl PyNotifier {
259    /// Publish a monitor update for `pv_name` with the given NT payload.
260    ///
261    /// Safe to call from any Python thread, including from inside a
262    /// source callback that is already running on the Tokio runtime.
263    fn notify(&self, py: Python<'_>, pv_name: String, nt: &Bound<'_, PyAny>) -> PyResult<()> {
264        let payload = py_to_nt_payload(nt)?;
265        let registry = self.registry.clone();
266        py.allow_threads(|| {
267            // If we're already inside the runtime, fire-and-forget spawn;
268            // otherwise use the shared runtime's block_on.
269            if let Ok(handle) = tokio::runtime::Handle::try_current() {
270                handle.spawn(async move {
271                    registry.notify_monitors(&pv_name, &payload).await;
272                });
273            } else {
274                RUNTIME.block_on(async move {
275                    registry.notify_monitors(&pv_name, &payload).await;
276                });
277            }
278        });
279        Ok(())
280    }
281
282    fn __repr__(&self) -> String {
283        "Notifier(<MonitorRegistry>)".to_string()
284    }
285}
286
287// ─── PySourceAdapter — Source trait impl ─────────────────────────────────────
288
289pub struct PySourceAdapter {
290    obj: Arc<PyObject>,
291}
292
293impl PySourceAdapter {
294    pub fn new(obj: PyObject) -> Self {
295        Self { obj: Arc::new(obj) }
296    }
297
298    /// If the user's Python object has an `on_start(notifier)` method, call it.
299    pub fn invoke_on_start(&self, notifier: PyNotifier) {
300        let obj = self.obj.clone();
301        Python::with_gil(|py| {
302            let b = obj.bind(py);
303            if let Ok(method) = b.getattr("on_start") {
304                let py_notifier = match notifier.into_pyobject(py) {
305                    Ok(n) => n,
306                    Err(e) => {
307                        tracing::error!("Notifier.into_pyobject: {e}");
308                        return;
309                    }
310                };
311                if let Err(e) = method.call1((py_notifier,)) {
312                    tracing::error!("source.on_start error: {e}");
313                }
314            }
315        });
316    }
317}
318
319/// Get (or lazily create) the shared asyncio event loop running on a
320/// dedicated background Python thread.
321///
322/// We can't call `asyncio.run(coro)` directly from a Tokio worker — the
323/// nested `run_until_complete` deadlocks because the Tokio worker is
324/// holding the GIL while the selector waits. Instead we run one long-lived
325/// event loop on its own thread and submit coroutines via
326/// `asyncio.run_coroutine_threadsafe`.
327fn asyncio_loop(py: Python<'_>) -> PyResult<PyObject> {
328    static LOOP: std::sync::OnceLock<PyObject> = std::sync::OnceLock::new();
329    if let Some(l) = LOOP.get() {
330        return Ok(l.clone_ref(py));
331    }
332    let asyncio = py.import("asyncio")?;
333    let loop_obj: PyObject = asyncio.getattr("new_event_loop")?.call0()?.unbind();
334    let loop_for_thread = loop_obj.clone_ref(py);
335    std::thread::Builder::new()
336        .name("spvirit-asyncio".into())
337        .spawn(move || {
338            Python::with_gil(|py| {
339                let l = loop_for_thread.bind(py);
340                // run_forever releases the GIL while blocking in the selector.
341                if let Err(e) = l.call_method0("run_forever") {
342                    tracing::error!("asyncio loop exited: {}", e);
343                }
344            });
345        })
346        .expect("spawn asyncio thread");
347    // Give the loop a moment to actually start running so that
348    // run_coroutine_threadsafe submissions are picked up.
349    // (asyncio.run_coroutine_threadsafe is safe even before run_forever,
350    // but we want the first submission to not race with loop startup.)
351    let out = loop_obj.clone_ref(py);
352    let _ = LOOP.set(loop_obj);
353    Ok(out)
354}
355
356/// Call a Python method that may be sync or async; if the return value is a
357/// coroutine, submit it to the shared asyncio loop and block on the result.
358async fn call_py_await(
359    obj: Arc<PyObject>,
360    method: &'static str,
361    build_args: impl for<'py> FnOnce(Python<'py>) -> PyResult<Bound<'py, PyTuple>> + Send,
362) -> PyResult<PyObject> {
363    // Phase 1: under the GIL, invoke the method. If sync, return the value.
364    //          If async, schedule the coroutine on the shared asyncio loop
365    //          and hand back the concurrent.futures.Future.
366    enum Outcome {
367        Value(PyObject),
368        Future(PyObject),
369    }
370    let outcome: Outcome = Python::with_gil(|py| -> PyResult<Outcome> {
371        let args = build_args(py)?;
372        let ret = obj.call_method1(py, method, args)?;
373        let bound = ret.bind(py);
374        let is_awaitable = bound.hasattr("__await__").unwrap_or(false);
375        if !is_awaitable {
376            return Ok(Outcome::Value(ret));
377        }
378        let loop_obj = asyncio_loop(py)?;
379        let asyncio = py.import("asyncio")?;
380        let fut = asyncio
381            .getattr("run_coroutine_threadsafe")?
382            .call1((bound, loop_obj.bind(py)))?;
383        Ok(Outcome::Future(fut.unbind()))
384    })?;
385    match outcome {
386        Outcome::Value(v) => Ok(v),
387        Outcome::Future(fut) => {
388            // Phase 2: block on .result(). `result()` uses a threading
389            // Condition that releases the GIL while waiting, letting the
390            // asyncio thread acquire it to run the coroutine.
391            Python::with_gil(|py| -> PyResult<PyObject> { Ok(fut.call_method0(py, "result")?) })
392        }
393    }
394}
395
396fn log_err(method: &str, e: impl std::fmt::Display) {
397    tracing::error!("PySource.{}: {}", method, e);
398}
399
400impl Source for PySourceAdapter {
401    fn claim<'a>(
402        &'a self,
403        name: &str,
404    ) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + 'a>> {
405        let obj = self.obj.clone();
406        let name = name.to_string();
407        Box::pin(async move {
408            let ret = match call_py_await(obj, "claim", move |py| {
409                PyTuple::new(py, &[name.into_pyobject(py)?.into_any()])
410            })
411            .await
412            {
413                Ok(r) => r,
414                Err(e) => {
415                    log_err("claim", e);
416                    return None;
417                }
418            };
419            Python::with_gil(|py| {
420                let b = ret.bind(py);
421                if b.is_none() {
422                    return None;
423                }
424                match py_to_pv_info(b) {
425                    Ok(info) => Some(info),
426                    Err(e) => {
427                        log_err("claim", e);
428                        None
429                    }
430                }
431            })
432        })
433    }
434
435    fn get<'a>(
436        &'a self,
437        name: &str,
438    ) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + 'a>> {
439        let obj = self.obj.clone();
440        let name = name.to_string();
441        Box::pin(async move {
442            let ret = match call_py_await(obj, "get", move |py| {
443                PyTuple::new(py, &[name.into_pyobject(py)?.into_any()])
444            })
445            .await
446            {
447                Ok(r) => r,
448                Err(e) => {
449                    log_err("get", e);
450                    return None;
451                }
452            };
453            Python::with_gil(|py| {
454                let b = ret.bind(py);
455                if b.is_none() {
456                    return None;
457                }
458                match py_to_nt_payload(b) {
459                    Ok(p) => Some(p),
460                    Err(e) => {
461                        log_err("get", e);
462                        None
463                    }
464                }
465            })
466        })
467    }
468
469    fn put<'a>(
470        &'a self,
471        name: &str,
472        value: &DecodedValue,
473    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + 'a>> {
474        let obj = self.obj.clone();
475        let name = name.to_string();
476        let value = value.clone();
477        Box::pin(async move {
478            // Build Python args under the GIL, converting the DecodedValue.
479            let name_for_call = name.clone();
480            let ret = call_py_await(obj, "put", move |py| {
481                let v = decoded_to_py(py, &value);
482                PyTuple::new(
483                    py,
484                    &[
485                        name_for_call.into_pyobject(py)?.into_any(),
486                        v.into_bound(py),
487                    ],
488                )
489            })
490            .await
491            .map_err(|e| format!("{e}"))?;
492
493            // Parse the return value.  Accept:
494            //   None                                -> no propagation
495            //   NtPayload wrapper                   -> [(name, payload)]
496            //   dict[str, NtPayload]                -> each entry
497            //   list[tuple[str, NtPayload]]         -> each entry
498            Python::with_gil(|py| -> Result<Vec<(String, NtPayload)>, String> {
499                let b = ret.bind(py);
500                if b.is_none() {
501                    return Ok(Vec::new());
502                }
503                // Try NT payload directly.
504                if let Ok(p) = py_to_nt_payload(b) {
505                    return Ok(vec![(name.clone(), p)]);
506                }
507                // dict?
508                if let Ok(d) = b.downcast::<PyDict>() {
509                    let mut out = Vec::with_capacity(d.len());
510                    for (k, v) in d.iter() {
511                        let key: String = k.extract().map_err(|e| format!("put dict key: {e}"))?;
512                        let payload = py_to_nt_payload(&v)
513                            .map_err(|e| format!("put dict value for '{key}': {e}"))?;
514                        out.push((key, payload));
515                    }
516                    return Ok(out);
517                }
518                // iterable of (name, payload)?
519                if let Ok(list) = b.downcast::<PyList>() {
520                    let mut out = Vec::with_capacity(list.len());
521                    for item in list.iter() {
522                        let t = item
523                            .downcast::<PyTuple>()
524                            .map_err(|_| "put list item must be (name, payload)".to_string())?;
525                        if t.len() != 2 {
526                            return Err("put list tuple must have 2 elements".to_string());
527                        }
528                        let key: String = t
529                            .get_item(0)
530                            .and_then(|x| x.extract())
531                            .map_err(|e| format!("{e}"))?;
532                        let payload = t
533                            .get_item(1)
534                            .map_err(|e| format!("{e}"))
535                            .and_then(|x| py_to_nt_payload(&x).map_err(|e| format!("{e}")))?;
536                        out.push((key, payload));
537                    }
538                    return Ok(out);
539                }
540                Err(format!(
541                    "put() must return None, NtPayload, dict, or list of tuples; got {}",
542                    b.get_type()
543                        .name()
544                        .map(|n| n.to_string())
545                        .unwrap_or_default()
546                ))
547            })
548        })
549    }
550
551    fn subscribe<'a>(
552        &'a self,
553        _name: &str,
554    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + 'a>> {
555        // Python sources publish monitor updates via `notifier.notify()` — no
556        // Source-level subscribe channel needed.
557        Box::pin(async { None })
558    }
559
560    fn rpc<'a>(
561        &'a self,
562        name: &str,
563        args: &DecodedValue,
564    ) -> Pin<Box<dyn Future<Output = Result<NtPayload, String>> + Send + 'a>> {
565        let obj = self.obj.clone();
566        let name = name.to_string();
567        let args = args.clone();
568        Box::pin(async move {
569            // If the Python object doesn't define rpc, fall back to an error.
570            let has_rpc = Python::with_gil(|py| obj.bind(py).hasattr("rpc").unwrap_or(false));
571            if !has_rpc {
572                return Err("RPC not supported".to_string());
573            }
574            let ret = call_py_await(obj, "rpc", move |py| {
575                let args_py = decoded_to_py(py, &args);
576                PyTuple::new(
577                    py,
578                    &[name.into_pyobject(py)?.into_any(), args_py.into_bound(py)],
579                )
580            })
581            .await
582            .map_err(|e| format!("{e}"))?;
583            Python::with_gil(|py| -> Result<NtPayload, String> {
584                py_to_nt_payload(ret.bind(py)).map_err(|e| format!("{e}"))
585            })
586        })
587    }
588
589    fn names<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + 'a>> {
590        let obj = self.obj.clone();
591        Box::pin(async move {
592            // `names` may not be defined (we accept that too).
593            let has = Python::with_gil(|py| obj.bind(py).hasattr("names").unwrap_or(false));
594            if !has {
595                return Vec::new();
596            }
597            let ret = match call_py_await(obj, "names", |py| Ok(PyTuple::empty(py))).await {
598                Ok(r) => r,
599                Err(e) => {
600                    log_err("names", e);
601                    return Vec::new();
602                }
603            };
604            Python::with_gil(|py| {
605                let b = ret.bind(py);
606                if b.is_none() {
607                    return Vec::new();
608                }
609                match b.extract::<Vec<String>>() {
610                    Ok(v) => v,
611                    Err(e) => {
612                        log_err("names", e);
613                        Vec::new()
614                    }
615                }
616            })
617        })
618    }
619}
620
621// ─── Module registration ─────────────────────────────────────────────────────
622
623pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
624    m.add_class::<PyPvInfo>()?;
625    m.add_class::<PyNotifier>()?;
626    Ok(())
627}
628
629// Re-export `nt_payload_to_py` for tests/external callers — keeps it used.
630#[allow(dead_code)]
631pub(crate) fn _ensure_used(py: Python<'_>, p: NtPayload) -> PyObject {
632    nt_payload_to_py(py, p)
633}