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