Skip to main content

spvirit/
channel.rs

1//! Reusable PVA channel exposed to Python.
2//!
3//! A `Channel` holds a single established TCP connection to a PVA server
4//! for a single PV name and lets callers perform repeated `get` / `put` /
5//! `monitor` / `introspect` operations over it.  Provides both sync and
6//! async (`_async`) variants.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Duration;
11
12use pyo3::prelude::*;
13use tokio::io::AsyncWriteExt;
14use tokio::sync::Mutex;
15
16use spvirit_client::client::{
17    ChannelConn, encode_get_request, encode_monitor_request, encode_put_request, ensure_status_ok,
18    establish_channel,
19};
20use spvirit_client::pva_client::decode_init_introspection;
21use spvirit_client::transport::{read_packet, read_until};
22use spvirit_client::types::{PvGetError, PvGetOptions};
23use spvirit_codec::epics_decode::{PvaPacket, PvaPacketCommand};
24use spvirit_codec::spvd_decode::PvdDecoder;
25use spvirit_codec::spvd_encode::encode_pv_request;
26use spvirit_codec::spvirit_encode::encode_control_message;
27
28use crate::codec::PyStructureDesc;
29use crate::convert::{decoded_to_py, py_to_json};
30use crate::errors::to_py_err;
31use crate::runtime::{block_on_py, future_into_py};
32
33const PVA_VERSION: u8 = 2;
34const QOS_INIT: u8 = 0x08;
35
36/// Build the pvRequest body for a channel operation.  An empty `fields`
37/// slice yields the canonical "all fields" pvRequest bytes; otherwise we
38/// delegate to [`encode_pv_request`] (which supports dotted nested paths).
39fn build_pv_request(fields: &[String], is_be: bool) -> Vec<u8> {
40    if fields.is_empty() {
41        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
42    } else {
43        let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
44        encode_pv_request(&refs, is_be)
45    }
46}
47
48/// Normalise a user-supplied `fields` argument into a `Vec<String>`.
49///
50/// Accepts `None` -> empty, a single string (treated as a one-entry list;
51/// this preserves backwards compatibility with the previous `field: str`
52/// kwarg), or an iterable of strings.
53pub(crate) fn normalize_fields(py: Python<'_>, fields: Option<PyObject>) -> PyResult<Vec<String>> {
54    let Some(obj) = fields else {
55        return Ok(Vec::new());
56    };
57    let bound = obj.bind(py);
58    if let Ok(s) = bound.extract::<String>() {
59        return Ok(vec![s]);
60    }
61    bound.extract::<Vec<String>>()
62}
63
64/// Shared mutable state for a `Channel`.  Option-ized so `close()` can
65/// drop the TCP stream while leaving the Python handle alive.
66struct ChannelState {
67    conn: Option<ChannelConn>,
68    pv_name: String,
69    timeout: Duration,
70    next_ioid: u32,
71}
72
73impl ChannelState {
74    fn alloc_ioid(&mut self) -> u32 {
75        let v = self.next_ioid;
76        self.next_ioid = self.next_ioid.wrapping_add(1).max(1);
77        v
78    }
79
80    fn conn_mut(&mut self) -> Result<&mut ChannelConn, PvGetError> {
81        self.conn
82            .as_mut()
83            .ok_or_else(|| PvGetError::Protocol("channel is closed".to_string()))
84    }
85}
86
87/// Persistent TCP channel to one PV on one server. Create with
88/// `Channel.connect()`; supports repeated get/put/monitor/introspect plus
89/// raw-frame access, and can be used as a context manager.
90#[pyclass(name = "Channel", module = "spvirit.lowlevel")]
91pub struct PyChannel {
92    state: Arc<Mutex<ChannelState>>,
93}
94
95fn parse_addr(addr: &str) -> PyResult<SocketAddr> {
96    addr.parse()
97        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}")))
98}
99
100async fn do_connect(
101    pv_name: String,
102    server_addr: SocketAddr,
103    timeout: Duration,
104) -> Result<ChannelState, PvGetError> {
105    let mut opts = PvGetOptions::new(pv_name.clone());
106    opts.timeout = timeout;
107    opts.server_addr = Some(server_addr);
108    let conn = establish_channel(server_addr, &opts).await?;
109    Ok(ChannelState {
110        conn: Some(conn),
111        pv_name,
112        timeout,
113        next_ioid: 1,
114    })
115}
116
117async fn run_get(
118    state: Arc<Mutex<ChannelState>>,
119    fields: Vec<String>,
120) -> Result<
121    (
122        String,
123        spvirit_codec::spvd_decode::DecodedValue,
124        Vec<u8>,
125        Vec<u8>,
126    ),
127    PvGetError,
128> {
129    let mut guard = state.lock().await;
130    let timeout = guard.timeout;
131    let ioid = guard.alloc_ioid();
132    let pv_name = guard.pv_name.clone();
133    let conn = guard.conn_mut()?;
134    let is_be = conn.is_be;
135    let version = conn.version;
136    let sid = conn.sid;
137
138    let pv_request = if fields.is_empty() {
139        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
140    } else {
141        build_pv_request(&fields, is_be)
142    };
143
144    let get_init = encode_get_request(sid, ioid, QOS_INIT, &pv_request, version, is_be);
145    conn.stream.write_all(&get_init).await?;
146
147    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
148        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
149    })
150    .await?;
151    let desc = decode_init_introspection(&init_resp, "GET")?;
152
153    let get_data = encode_get_request(sid, ioid, 0x00, &[], version, is_be);
154    conn.stream.write_all(&get_data).await?;
155
156    let data_resp = read_until(&mut conn.stream, timeout, |cmd| {
157        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && op.subcmd == 0x00)
158    })
159    .await?;
160    let mut pkt = PvaPacket::new(&data_resp);
161    let cmd = pkt
162        .decode_payload()
163        .ok_or_else(|| PvGetError::Protocol("get data decode failed".to_string()))?;
164    match cmd {
165        PvaPacketCommand::Op(mut op) => {
166            op.decode_with_field_desc(&desc, is_be);
167            let value = op
168                .decoded_value
169                .ok_or_else(|| PvGetError::Decode("no decoded value".to_string()))?;
170            Ok((pv_name, value, data_resp, op.body))
171        }
172        _ => Err(PvGetError::Protocol(
173            "unexpected get data response".to_string(),
174        )),
175    }
176}
177
178async fn run_put(
179    state: Arc<Mutex<ChannelState>>,
180    json_val: serde_json::Value,
181    fields: Vec<String>,
182) -> Result<(), PvGetError> {
183    let mut guard = state.lock().await;
184    let timeout = guard.timeout;
185    let ioid = guard.alloc_ioid();
186    let conn = guard.conn_mut()?;
187    let is_be = conn.is_be;
188    let sid = conn.sid;
189
190    let pv_request = build_pv_request(&fields, is_be);
191    let init = encode_put_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
192    conn.stream.write_all(&init).await?;
193
194    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
195        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
196    })
197    .await?;
198    let desc = decode_init_introspection(&init_resp, "PUT")?;
199
200    let payload = spvirit_client::put_encode::encode_put_payload(&desc, &json_val, is_be)
201        .map_err(|e| PvGetError::Protocol(format!("put encode: {e}")))?;
202    let req = encode_put_request(sid, ioid, 0x00, &payload, PVA_VERSION, is_be);
203    conn.stream.write_all(&req).await?;
204
205    let resp = read_until(&mut conn.stream, timeout, |cmd| {
206        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && op.ioid == ioid && op.subcmd == 0x00)
207    })
208    .await?;
209    ensure_status_ok(&resp, is_be, "PUT")?;
210    Ok(())
211}
212
213async fn run_introspect(
214    state: Arc<Mutex<ChannelState>>,
215) -> Result<spvirit_codec::spvd_decode::StructureDesc, PvGetError> {
216    // Cheapest way: do GET INIT only and return the introspection.
217    let mut guard = state.lock().await;
218    let timeout = guard.timeout;
219    let ioid = guard.alloc_ioid();
220    let conn = guard.conn_mut()?;
221    let is_be = conn.is_be;
222    let version = conn.version;
223    let sid = conn.sid;
224
225    let pv_request = vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00];
226    let init = encode_get_request(sid, ioid, QOS_INIT, &pv_request, version, is_be);
227    conn.stream.write_all(&init).await?;
228    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
229        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
230    })
231    .await?;
232    decode_init_introspection(&init_resp, "GET")
233}
234
235/// Monitor loop running against the persistent channel.  Invokes
236/// `callback(decoded_value_py)` inside the GIL; stops when the callback
237/// returns a falsy value or raises.
238async fn run_monitor(
239    state: Arc<Mutex<ChannelState>>,
240    callback: PyObject,
241    fields: Vec<String>,
242    cb_err: &mut Option<PyErr>,
243) -> Result<(), PvGetError> {
244    let mut guard = state.lock().await;
245    let timeout = guard.timeout;
246    let ioid = guard.alloc_ioid();
247    let conn = guard.conn_mut()?;
248    let is_be = conn.is_be;
249    let sid = conn.sid;
250
251    let decoder = PvdDecoder::new(is_be);
252    let pv_request = build_pv_request(&fields, is_be);
253    let init = encode_monitor_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
254    conn.stream.write_all(&init).await?;
255
256    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
257        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 13 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
258    })
259    .await?;
260    let field_desc = decode_init_introspection(&init_resp, "MONITOR")?;
261
262    let start = encode_monitor_request(sid, ioid, 0x44, &[], PVA_VERSION, is_be);
263    conn.stream.write_all(&start).await?;
264
265    let mut echo_interval = tokio::time::interval(Duration::from_secs(10));
266    echo_interval.tick().await; // skip immediate tick
267    let mut echo_token: u32 = 1;
268
269    loop {
270        tokio::select! {
271            _ = echo_interval.tick() => {
272                let msg = encode_control_message(false, is_be, PVA_VERSION, 3, echo_token);
273                echo_token = echo_token.wrapping_add(1);
274                let _ = conn.stream.write_all(&msg).await;
275            }
276            res = read_packet(&mut conn.stream, timeout) => {
277                let bytes = match res {
278                    Ok(b) => b,
279                    Err(PvGetError::Timeout(_)) => continue,
280                    Err(e) => return Err(e),
281                };
282                let mut pkt = PvaPacket::new(&bytes);
283                if let Some(PvaPacketCommand::Op(op)) = pkt.decode_payload() {
284                    if op.command == 13 && op.ioid == ioid && op.subcmd == 0x00 {
285                        let payload = &bytes[8..];
286                        let pos = 5;
287                        if let Some((decoded, _)) =
288                            decoder.decode_structure_with_bitset(&payload[pos..], &field_desc)
289                        {
290                            let keep_going = Python::with_gil(|py| {
291                                let v = decoded_to_py(py, &decoded);
292                                match callback.call1(py, (v,)) {
293                                    Ok(ret) => ret.extract::<bool>(py).unwrap_or(true),
294                                    Err(e) => {
295                                        *cb_err = Some(e);
296                                        false
297                                    }
298                                }
299                            });
300                            if !keep_going {
301                                return Ok(());
302                            }
303                        }
304                    }
305                }
306            }
307        }
308    }
309}
310
311#[pymethods]
312impl PyChannel {
313    /// Connect to `server_addr` and establish a channel for `pv_name`.
314    #[staticmethod]
315    #[pyo3(signature = (pv_name, server_addr, timeout=5.0))]
316    fn connect(
317        py: Python<'_>,
318        pv_name: String,
319        server_addr: String,
320        timeout: f64,
321    ) -> PyResult<PyChannel> {
322        let sa = parse_addr(&server_addr)?;
323        let dur = Duration::from_secs_f64(timeout);
324        let state = block_on_py(py, do_connect(pv_name, sa, dur)).map_err(to_py_err)?;
325        Ok(PyChannel {
326            state: Arc::new(Mutex::new(state)),
327        })
328    }
329
330    /// Async variant of [`connect`].
331    #[staticmethod]
332    #[pyo3(signature = (pv_name, server_addr, timeout=5.0))]
333    fn connect_async<'py>(
334        py: Python<'py>,
335        pv_name: String,
336        server_addr: String,
337        timeout: f64,
338    ) -> PyResult<Bound<'py, PyAny>> {
339        let sa = parse_addr(&server_addr)?;
340        let dur = Duration::from_secs_f64(timeout);
341        future_into_py(py, async move {
342            let state = do_connect(pv_name, sa, dur).await.map_err(to_py_err)?;
343            Ok(PyChannel {
344                state: Arc::new(Mutex::new(state)),
345            })
346        })
347    }
348
349    /// Name of the PV this channel is bound to.
350    #[getter]
351    fn pv_name(&self, py: Python<'_>) -> String {
352        py.allow_threads(|| {
353            let guard = self.state.blocking_lock();
354            guard.pv_name.clone()
355        })
356    }
357
358    /// True while the underlying TCP connection is open.
359    #[getter]
360    fn is_open(&self, py: Python<'_>) -> bool {
361        py.allow_threads(|| {
362            let guard = self.state.blocking_lock();
363            guard.conn.is_some()
364        })
365    }
366
367    /// Server address as `"ip:port"`, or None when closed.
368    #[getter]
369    fn server_addr(&self, py: Python<'_>) -> Option<String> {
370        py.allow_threads(|| {
371            let guard = self.state.blocking_lock();
372            guard.conn.as_ref().map(|c| c.server_addr.to_string())
373        })
374    }
375
376    /// Server-assigned channel ID, or None when closed.
377    #[getter]
378    fn sid(&self, py: Python<'_>) -> Option<u32> {
379        py.allow_threads(|| {
380            let guard = self.state.blocking_lock();
381            guard.conn.as_ref().map(|c| c.sid)
382        })
383    }
384
385    /// Fetch the current value (blocking). Returns a `GetResult` whose
386    /// `.value` is a dict mirroring the NT structure.
387    #[pyo3(signature = (fields=None))]
388    fn get(
389        &self,
390        py: Python<'_>,
391        fields: Option<PyObject>,
392    ) -> PyResult<crate::client::PyGetResult> {
393        let state = self.state.clone();
394        let fields = normalize_fields(py, fields)?;
395        let (pv_name, value, raw_pva, raw_pvd) =
396            block_on_py(py, run_get(state, fields)).map_err(to_py_err)?;
397        let py_val = decoded_to_py(py, &value);
398        Ok(crate::client::PyGetResult::new(
399            pv_name, py_val, raw_pva, raw_pvd,
400        ))
401    }
402
403    /// Async variant of `get`; returns an awaitable resolving to a `GetResult`.
404    #[pyo3(signature = (fields=None))]
405    fn get_async<'py>(
406        &self,
407        py: Python<'py>,
408        fields: Option<PyObject>,
409    ) -> PyResult<Bound<'py, PyAny>> {
410        let state = self.state.clone();
411        let fields = normalize_fields(py, fields)?;
412        future_into_py(py, async move {
413            let (pv_name, value, raw_pva, raw_pvd) =
414                run_get(state, fields).await.map_err(to_py_err)?;
415            Python::with_gil(|py| {
416                let py_val = decoded_to_py(py, &value);
417                Ok(Py::new(
418                    py,
419                    crate::client::PyGetResult::new(pv_name, py_val, raw_pva, raw_pvd),
420                )?)
421            })
422        })
423    }
424
425    /// Write a value (blocking).
426    ///
427    /// `fields` selects which pvRequest fields are targeted. Defaults to
428    /// `["value"]` when omitted. A single string is still accepted for
429    /// backwards compatibility with earlier versions of this binding.
430    #[pyo3(signature = (value, fields=None))]
431    fn put(&self, py: Python<'_>, value: PyObject, fields: Option<PyObject>) -> PyResult<()> {
432        let state = self.state.clone();
433        let json = py_to_json(value.bind(py))?;
434        let fields = normalize_fields(py, fields)?;
435        block_on_py(py, run_put(state, json, fields)).map_err(to_py_err)
436    }
437
438    /// Async variant of `put`; returns an awaitable.
439    #[pyo3(signature = (value, fields=None))]
440    fn put_async<'py>(
441        &self,
442        py: Python<'py>,
443        value: PyObject,
444        fields: Option<PyObject>,
445    ) -> PyResult<Bound<'py, PyAny>> {
446        let state = self.state.clone();
447        let json = py_to_json(value.bind(py))?;
448        let fields = normalize_fields(py, fields)?;
449        future_into_py(py, async move {
450            run_put(state, json, fields).await.map_err(to_py_err)?;
451            Ok(())
452        })
453    }
454
455    /// Retrieve the PV's structure description (INIT exchange only).
456    fn introspect(&self, py: Python<'_>) -> PyResult<PyStructureDesc> {
457        let state = self.state.clone();
458        let desc = block_on_py(py, run_introspect(state)).map_err(to_py_err)?;
459        Ok(PyStructureDesc::from_inner(desc))
460    }
461
462    /// Async variant of `introspect`; returns an awaitable.
463    fn introspect_async<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
464        let state = self.state.clone();
465        future_into_py(py, async move {
466            let desc = run_introspect(state).await.map_err(to_py_err)?;
467            Python::with_gil(|py| Py::new(py, PyStructureDesc::from_inner(desc)))
468        })
469    }
470
471    /// Subscribe and block (GIL released between updates) until
472    /// `callback(value)` returns False or raises — a raised exception stops
473    /// the monitor and propagates to the caller.
474    ///
475    /// `fields` restricts the subscription to the given dotted paths.
476    #[pyo3(signature = (callback, fields=None))]
477    fn monitor(
478        &self,
479        py: Python<'_>,
480        callback: PyObject,
481        fields: Option<PyObject>,
482    ) -> PyResult<()> {
483        let state = self.state.clone();
484        let fields = normalize_fields(py, fields)?;
485        let mut cb_err: Option<PyErr> = None;
486        let result = py.allow_threads(|| {
487            crate::runtime::RUNTIME.block_on(run_monitor(state, callback, fields, &mut cb_err))
488        });
489        if let Some(e) = cb_err {
490            return Err(e);
491        }
492        result.map_err(to_py_err)
493    }
494
495    /// Close the underlying TCP stream.  Subsequent operations raise
496    /// ProtocolError.
497    fn close(&self, py: Python<'_>) -> PyResult<()> {
498        py.allow_threads(|| {
499            let mut guard = self.state.blocking_lock();
500            guard.conn.take();
501        });
502        Ok(())
503    }
504
505    fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
506        slf
507    }
508
509    #[pyo3(signature = (_exc_type=None, _exc=None, _tb=None))]
510    fn __exit__(
511        &self,
512        py: Python<'_>,
513        _exc_type: Option<PyObject>,
514        _exc: Option<PyObject>,
515        _tb: Option<PyObject>,
516    ) -> PyResult<bool> {
517        self.close(py)?;
518        Ok(false)
519    }
520
521    fn __repr__(&self, py: Python<'_>) -> String {
522        py.allow_threads(|| {
523            let guard = self.state.blocking_lock();
524            let addr = guard
525                .conn
526                .as_ref()
527                .map(|c| c.server_addr.to_string())
528                .unwrap_or_else(|| "<closed>".to_string());
529            format!("Channel(pv_name={:?}, server={})", guard.pv_name, addr)
530        })
531    }
532
533    /// Read the next PVA frame off the wire and return a `Packet`.
534    #[pyo3(signature = (timeout=None))]
535    fn read_packet(
536        &self,
537        py: Python<'_>,
538        timeout: Option<f64>,
539    ) -> PyResult<crate::packet::PyPacket> {
540        let state = self.state.clone();
541        let override_timeout = timeout.map(Duration::from_secs_f64);
542        let bytes = block_on_py(py, async move {
543            let mut guard = state.lock().await;
544            let t = override_timeout.unwrap_or(guard.timeout);
545            let conn = guard.conn_mut()?;
546            read_packet(&mut conn.stream, t).await
547        })
548        .map_err(to_py_err)?;
549        Ok(crate::packet::PyPacket::from_bytes(bytes))
550    }
551
552    /// Async variant of `read_packet`; returns an awaitable.
553    #[pyo3(signature = (timeout=None))]
554    fn read_packet_async<'py>(
555        &self,
556        py: Python<'py>,
557        timeout: Option<f64>,
558    ) -> PyResult<Bound<'py, PyAny>> {
559        let state = self.state.clone();
560        let override_timeout = timeout.map(Duration::from_secs_f64);
561        future_into_py(py, async move {
562            let bytes = {
563                let mut guard = state.lock().await;
564                let t = override_timeout.unwrap_or(guard.timeout);
565                let conn = guard.conn_mut().map_err(to_py_err)?;
566                read_packet(&mut conn.stream, t).await.map_err(to_py_err)?
567            };
568            Python::with_gil(|py| Py::new(py, crate::packet::PyPacket::from_bytes(bytes)))
569        })
570    }
571
572    /// Read frames until `predicate(packet)` returns truthy, then return
573    /// that packet.  `predicate` may be any Python callable.
574    #[pyo3(signature = (predicate, timeout=None, max_frames=None))]
575    fn read_until(
576        &self,
577        py: Python<'_>,
578        predicate: PyObject,
579        timeout: Option<f64>,
580        max_frames: Option<usize>,
581    ) -> PyResult<crate::packet::PyPacket> {
582        let state = self.state.clone();
583        let override_timeout = timeout.map(Duration::from_secs_f64);
584        let max = max_frames.unwrap_or(usize::MAX);
585        let mut seen = 0usize;
586        loop {
587            if seen >= max {
588                return Err(pyo3::exceptions::PyRuntimeError::new_err(
589                    "read_until: max_frames reached",
590                ));
591            }
592            seen += 1;
593            let bytes = {
594                let state = state.clone();
595                block_on_py(py, async move {
596                    let mut guard = state.lock().await;
597                    let t = override_timeout.unwrap_or(guard.timeout);
598                    let conn = guard.conn_mut()?;
599                    read_packet(&mut conn.stream, t).await
600                })
601                .map_err(to_py_err)?
602            };
603            let pkt = crate::packet::PyPacket::from_bytes(bytes);
604            let (matched, pkt_back) = Python::with_gil(|py| -> PyResult<(bool, _)> {
605                let pkt_obj = Py::new(py, pkt)?;
606                let result = predicate.call1(py, (pkt_obj.clone_ref(py),))?;
607                let matched = result.extract::<bool>(py).unwrap_or(false);
608                Ok((matched, pkt_obj))
609            })?;
610            if matched {
611                // Move the inner PyPacket back out.
612                let pkt: crate::packet::PyPacket = Python::with_gil(|py| -> PyResult<_> {
613                    let bound = pkt_back.bind(py);
614                    let data: Vec<u8> = bound.borrow().raw().to_vec();
615                    Ok(crate::packet::PyPacket::from_bytes(data))
616                })?;
617                return Ok(pkt);
618            }
619        }
620    }
621}
622
623/// Register the `spvirit.lowlevel` submodule and add `Channel`.
624pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
625    let py = parent.py();
626    let m = PyModule::new(py, "lowlevel")?;
627    m.add_class::<PyChannel>()?;
628    m.add_class::<crate::packet::PyPacket>()?;
629    crate::discovery::register(&m)?;
630    parent.add_submodule(&m)?;
631    py.import("sys")?
632        .getattr("modules")?
633        .set_item("spvirit.lowlevel", &m)?;
634    Ok(())
635}