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    #[getter]
186    fn writable(&self) -> bool {
187        self.inner.writable
188    }
189
190    #[getter]
191    fn struct_id(&self) -> Option<String> {
192        self.inner.descriptor.struct_id.clone()
193    }
194
195    fn __repr__(&self) -> String {
196        format!(
197            "PvInfo(struct_id={:?}, fields={}, writable={})",
198            self.inner.descriptor.struct_id,
199            self.inner.descriptor.fields.len(),
200            self.inner.writable
201        )
202    }
203}
204
205/// Extract a `PvInfo` from a Python object (either `PyPvInfo` or a dict).
206fn py_to_pv_info(obj: &Bound<'_, PyAny>) -> PyResult<PvInfo> {
207    if let Ok(info) = obj.downcast::<PyPvInfo>() {
208        return Ok(info.borrow().inner.clone());
209    }
210    if let Ok(dict) = obj.downcast::<PyDict>() {
211        let struct_id: Option<String> = match dict.get_item("struct_id")? {
212            Some(v) if !v.is_none() => Some(v.extract()?),
213            _ => None,
214        };
215        let writable: bool = match dict.get_item("writable")? {
216            Some(v) if !v.is_none() => v.extract()?,
217            _ => false,
218        };
219        let fields_obj = dict.get_item("fields")?.ok_or_else(|| {
220            pyo3::exceptions::PyValueError::new_err("PvInfo dict missing 'fields'")
221        })?;
222        let fields = fields_obj.downcast::<PyDict>().map_err(|_| {
223            pyo3::exceptions::PyTypeError::new_err("PvInfo 'fields' must be a dict")
224        })?;
225        let desc = dict_to_structure_desc(struct_id, fields)?;
226        return Ok(PvInfo {
227            descriptor: desc,
228            writable,
229        });
230    }
231    Err(pyo3::exceptions::PyTypeError::new_err(
232        "expected PvInfo instance or dict with 'struct_id'/'fields'/'writable'",
233    ))
234}
235
236// ─── PyNotifier ──────────────────────────────────────────────────────────────
237
238/// Handle for publishing monitor updates to subscribed PVAccess clients.
239///
240/// Passed to sources via `source.on_start(notifier)`.  Call `notify(name, nt)`
241/// from any Python thread to push a new value to all clients subscribed via
242/// monitor.
243#[pyclass(name = "Notifier")]
244#[derive(Clone)]
245pub struct PyNotifier {
246    registry: Arc<MonitorRegistry>,
247}
248
249impl PyNotifier {
250    pub fn new(registry: Arc<MonitorRegistry>) -> Self {
251        Self { registry }
252    }
253}
254
255#[pymethods]
256impl PyNotifier {
257    /// Publish a monitor update for `pv_name` with the given NT payload.
258    ///
259    /// Safe to call from any Python thread, including from inside a
260    /// source callback that is already running on the Tokio runtime.
261    fn notify(&self, py: Python<'_>, pv_name: String, nt: &Bound<'_, PyAny>) -> PyResult<()> {
262        let payload = py_to_nt_payload(nt)?;
263        let registry = self.registry.clone();
264        py.allow_threads(|| {
265            // If we're already inside the runtime, fire-and-forget spawn;
266            // otherwise use the shared runtime's block_on.
267            if let Ok(handle) = tokio::runtime::Handle::try_current() {
268                handle.spawn(async move {
269                    registry.notify_monitors(&pv_name, &payload).await;
270                });
271            } else {
272                RUNTIME.block_on(async move {
273                    registry.notify_monitors(&pv_name, &payload).await;
274                });
275            }
276        });
277        Ok(())
278    }
279
280    fn __repr__(&self) -> String {
281        "Notifier(<MonitorRegistry>)".to_string()
282    }
283}
284
285// ─── PySourceAdapter — Source trait impl ─────────────────────────────────────
286
287pub struct PySourceAdapter {
288    obj: Arc<PyObject>,
289}
290
291impl PySourceAdapter {
292    pub fn new(obj: PyObject) -> Self {
293        Self { obj: Arc::new(obj) }
294    }
295
296    /// If the user's Python object has an `on_start(notifier)` method, call it.
297    pub fn invoke_on_start(&self, notifier: PyNotifier) {
298        let obj = self.obj.clone();
299        Python::with_gil(|py| {
300            let b = obj.bind(py);
301            if let Ok(method) = b.getattr("on_start") {
302                let py_notifier = match notifier.into_pyobject(py) {
303                    Ok(n) => n,
304                    Err(e) => {
305                        tracing::error!("Notifier.into_pyobject: {e}");
306                        return;
307                    }
308                };
309                if let Err(e) = method.call1((py_notifier,)) {
310                    tracing::error!("source.on_start error: {e}");
311                }
312            }
313        });
314    }
315}
316
317/// Get (or lazily create) the shared asyncio event loop running on a
318/// dedicated background Python thread.
319///
320/// We can't call `asyncio.run(coro)` directly from a Tokio worker — the
321/// nested `run_until_complete` deadlocks because the Tokio worker is
322/// holding the GIL while the selector waits. Instead we run one long-lived
323/// event loop on its own thread and submit coroutines via
324/// `asyncio.run_coroutine_threadsafe`.
325fn asyncio_loop(py: Python<'_>) -> PyResult<PyObject> {
326    static LOOP: std::sync::OnceLock<PyObject> = std::sync::OnceLock::new();
327    if let Some(l) = LOOP.get() {
328        return Ok(l.clone_ref(py));
329    }
330    let asyncio = py.import("asyncio")?;
331    let loop_obj: PyObject = asyncio.getattr("new_event_loop")?.call0()?.unbind();
332    let loop_for_thread = loop_obj.clone_ref(py);
333    std::thread::Builder::new()
334        .name("spvirit-asyncio".into())
335        .spawn(move || {
336            Python::with_gil(|py| {
337                let l = loop_for_thread.bind(py);
338                // run_forever releases the GIL while blocking in the selector.
339                if let Err(e) = l.call_method0("run_forever") {
340                    tracing::error!("asyncio loop exited: {}", e);
341                }
342            });
343        })
344        .expect("spawn asyncio thread");
345    // Give the loop a moment to actually start running so that
346    // run_coroutine_threadsafe submissions are picked up.
347    // (asyncio.run_coroutine_threadsafe is safe even before run_forever,
348    // but we want the first submission to not race with loop startup.)
349    let out = loop_obj.clone_ref(py);
350    let _ = LOOP.set(loop_obj);
351    Ok(out)
352}
353
354/// Call a Python method that may be sync or async; if the return value is a
355/// coroutine, submit it to the shared asyncio loop and block on the result.
356async fn call_py_await(
357    obj: Arc<PyObject>,
358    method: &'static str,
359    build_args: impl for<'py> FnOnce(Python<'py>) -> PyResult<Bound<'py, PyTuple>> + Send,
360) -> PyResult<PyObject> {
361    // Phase 1: under the GIL, invoke the method. If sync, return the value.
362    //          If async, schedule the coroutine on the shared asyncio loop
363    //          and hand back the concurrent.futures.Future.
364    enum Outcome {
365        Value(PyObject),
366        Future(PyObject),
367    }
368    let outcome: Outcome = Python::with_gil(|py| -> PyResult<Outcome> {
369        let args = build_args(py)?;
370        let ret = obj.call_method1(py, method, args)?;
371        let bound = ret.bind(py);
372        let is_awaitable = bound.hasattr("__await__").unwrap_or(false);
373        if !is_awaitable {
374            return Ok(Outcome::Value(ret));
375        }
376        let loop_obj = asyncio_loop(py)?;
377        let asyncio = py.import("asyncio")?;
378        let fut = asyncio
379            .getattr("run_coroutine_threadsafe")?
380            .call1((bound, loop_obj.bind(py)))?;
381        Ok(Outcome::Future(fut.unbind()))
382    })?;
383    match outcome {
384        Outcome::Value(v) => Ok(v),
385        Outcome::Future(fut) => {
386            // Phase 2: block on .result(). `result()` uses a threading
387            // Condition that releases the GIL while waiting, letting the
388            // asyncio thread acquire it to run the coroutine.
389            Python::with_gil(|py| -> PyResult<PyObject> { Ok(fut.call_method0(py, "result")?) })
390        }
391    }
392}
393
394fn log_err(method: &str, e: impl std::fmt::Display) {
395    tracing::error!("PySource.{}: {}", method, e);
396}
397
398impl Source for PySourceAdapter {
399    fn claim<'a>(
400        &'a self,
401        name: &str,
402    ) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + 'a>> {
403        let obj = self.obj.clone();
404        let name = name.to_string();
405        Box::pin(async move {
406            let ret = match call_py_await(obj, "claim", move |py| {
407                PyTuple::new(py, &[name.into_pyobject(py)?.into_any()])
408            })
409            .await
410            {
411                Ok(r) => r,
412                Err(e) => {
413                    log_err("claim", e);
414                    return None;
415                }
416            };
417            Python::with_gil(|py| {
418                let b = ret.bind(py);
419                if b.is_none() {
420                    return None;
421                }
422                match py_to_pv_info(b) {
423                    Ok(info) => Some(info),
424                    Err(e) => {
425                        log_err("claim", e);
426                        None
427                    }
428                }
429            })
430        })
431    }
432
433    fn get<'a>(
434        &'a self,
435        name: &str,
436    ) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + 'a>> {
437        let obj = self.obj.clone();
438        let name = name.to_string();
439        Box::pin(async move {
440            let ret = match call_py_await(obj, "get", move |py| {
441                PyTuple::new(py, &[name.into_pyobject(py)?.into_any()])
442            })
443            .await
444            {
445                Ok(r) => r,
446                Err(e) => {
447                    log_err("get", e);
448                    return None;
449                }
450            };
451            Python::with_gil(|py| {
452                let b = ret.bind(py);
453                if b.is_none() {
454                    return None;
455                }
456                match py_to_nt_payload(b) {
457                    Ok(p) => Some(p),
458                    Err(e) => {
459                        log_err("get", e);
460                        None
461                    }
462                }
463            })
464        })
465    }
466
467    fn put<'a>(
468        &'a self,
469        name: &str,
470        value: &DecodedValue,
471    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + 'a>> {
472        let obj = self.obj.clone();
473        let name = name.to_string();
474        let value = value.clone();
475        Box::pin(async move {
476            // Build Python args under the GIL, converting the DecodedValue.
477            let name_for_call = name.clone();
478            let ret = call_py_await(obj, "put", move |py| {
479                let v = decoded_to_py(py, &value);
480                PyTuple::new(
481                    py,
482                    &[
483                        name_for_call.into_pyobject(py)?.into_any(),
484                        v.into_bound(py),
485                    ],
486                )
487            })
488            .await
489            .map_err(|e| format!("{e}"))?;
490
491            // Parse the return value.  Accept:
492            //   None                                -> no propagation
493            //   NtPayload wrapper                   -> [(name, payload)]
494            //   dict[str, NtPayload]                -> each entry
495            //   list[tuple[str, NtPayload]]         -> each entry
496            Python::with_gil(|py| -> Result<Vec<(String, NtPayload)>, String> {
497                let b = ret.bind(py);
498                if b.is_none() {
499                    return Ok(Vec::new());
500                }
501                // Try NT payload directly.
502                if let Ok(p) = py_to_nt_payload(b) {
503                    return Ok(vec![(name.clone(), p)]);
504                }
505                // dict?
506                if let Ok(d) = b.downcast::<PyDict>() {
507                    let mut out = Vec::with_capacity(d.len());
508                    for (k, v) in d.iter() {
509                        let key: String = k.extract().map_err(|e| format!("put dict key: {e}"))?;
510                        let payload = py_to_nt_payload(&v)
511                            .map_err(|e| format!("put dict value for '{key}': {e}"))?;
512                        out.push((key, payload));
513                    }
514                    return Ok(out);
515                }
516                // iterable of (name, payload)?
517                if let Ok(list) = b.downcast::<PyList>() {
518                    let mut out = Vec::with_capacity(list.len());
519                    for item in list.iter() {
520                        let t = item
521                            .downcast::<PyTuple>()
522                            .map_err(|_| "put list item must be (name, payload)".to_string())?;
523                        if t.len() != 2 {
524                            return Err("put list tuple must have 2 elements".to_string());
525                        }
526                        let key: String = t
527                            .get_item(0)
528                            .and_then(|x| x.extract())
529                            .map_err(|e| format!("{e}"))?;
530                        let payload = t
531                            .get_item(1)
532                            .map_err(|e| format!("{e}"))
533                            .and_then(|x| py_to_nt_payload(&x).map_err(|e| format!("{e}")))?;
534                        out.push((key, payload));
535                    }
536                    return Ok(out);
537                }
538                Err(format!(
539                    "put() must return None, NtPayload, dict, or list of tuples; got {}",
540                    b.get_type()
541                        .name()
542                        .map(|n| n.to_string())
543                        .unwrap_or_default()
544                ))
545            })
546        })
547    }
548
549    fn subscribe<'a>(
550        &'a self,
551        _name: &str,
552    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + 'a>> {
553        // Python sources publish monitor updates via `notifier.notify()` — no
554        // Source-level subscribe channel needed.
555        Box::pin(async { None })
556    }
557
558    fn rpc<'a>(
559        &'a self,
560        name: &str,
561        args: &DecodedValue,
562    ) -> Pin<Box<dyn Future<Output = Result<NtPayload, String>> + Send + 'a>> {
563        let obj = self.obj.clone();
564        let name = name.to_string();
565        let args = args.clone();
566        Box::pin(async move {
567            // If the Python object doesn't define rpc, fall back to an error.
568            let has_rpc = Python::with_gil(|py| obj.bind(py).hasattr("rpc").unwrap_or(false));
569            if !has_rpc {
570                return Err("RPC not supported".to_string());
571            }
572            let ret = call_py_await(obj, "rpc", move |py| {
573                let args_py = decoded_to_py(py, &args);
574                PyTuple::new(
575                    py,
576                    &[name.into_pyobject(py)?.into_any(), args_py.into_bound(py)],
577                )
578            })
579            .await
580            .map_err(|e| format!("{e}"))?;
581            Python::with_gil(|py| -> Result<NtPayload, String> {
582                py_to_nt_payload(ret.bind(py)).map_err(|e| format!("{e}"))
583            })
584        })
585    }
586
587    fn names<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + 'a>> {
588        let obj = self.obj.clone();
589        Box::pin(async move {
590            // `names` may not be defined (we accept that too).
591            let has = Python::with_gil(|py| obj.bind(py).hasattr("names").unwrap_or(false));
592            if !has {
593                return Vec::new();
594            }
595            let ret = match call_py_await(obj, "names", |py| Ok(PyTuple::empty(py))).await {
596                Ok(r) => r,
597                Err(e) => {
598                    log_err("names", e);
599                    return Vec::new();
600                }
601            };
602            Python::with_gil(|py| {
603                let b = ret.bind(py);
604                if b.is_none() {
605                    return Vec::new();
606                }
607                match b.extract::<Vec<String>>() {
608                    Ok(v) => v,
609                    Err(e) => {
610                        log_err("names", e);
611                        Vec::new()
612                    }
613                }
614            })
615        })
616    }
617}
618
619// ─── Module registration ─────────────────────────────────────────────────────
620
621pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
622    m.add_class::<PyPvInfo>()?;
623    m.add_class::<PyNotifier>()?;
624    Ok(())
625}
626
627// Re-export `nt_payload_to_py` for tests/external callers — keeps it used.
628#[allow(dead_code)]
629pub(crate) fn _ensure_used(py: Python<'_>, p: NtPayload) -> PyObject {
630    nt_payload_to_py(py, p)
631}