Skip to main content

quarb_pyvalue/
glue.rs

1//! The shared plumbing of a command library that wraps a Python
2//! package: a page of Python glue compiled once into a module, the
3//! input slot filled from the synopsis, operands as positional
4//! arguments and options as keyword arguments, the result mapped
5//! back, a Python exception as a failed call, and the importability
6//! check behind a library's run-time requirement.
7//!
8//! The glue module exports `_call(name, args, kwargs)`; what it does
9//! with a list input (map it item by item, or take it as the array)
10//! is the library's own business, declared in its synopsis.
11
12use pyo3::prelude::*;
13use pyo3::types::{PyDict, PyModule, PyTuple};
14use quarb::Value;
15use quarb::library::{InputSlot, LibCall, LibFailure, Synopsis};
16use std::ffi::CString;
17use std::sync::OnceLock;
18
19/// A Python module from source, compiled on first use and kept for
20/// the process.
21pub struct PyGlue {
22    source: &'static str,
23    file: &'static str,
24    name: &'static str,
25    module: OnceLock<Py<PyModule>>,
26}
27
28impl PyGlue {
29    pub const fn new(source: &'static str, file: &'static str, name: &'static str) -> Self {
30        PyGlue {
31            source,
32            file,
33            name,
34            module: OnceLock::new(),
35        }
36    }
37
38    pub(crate) fn module<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
39        if let Some(m) = self.module.get() {
40            return Ok(m.bind(py).clone());
41        }
42        // The foreign-value helpers first, importable by name from
43        // any glue.
44        crate::foreign::register(py)?;
45        let m = PyModule::from_code(
46            py,
47            &CString::new(self.source).expect("glue has no NUL"),
48            &CString::new(self.file).expect("file name has no NUL"),
49            &CString::new(self.name).expect("module name has no NUL"),
50        )?;
51        let _ = self.module.set(m.clone().unbind());
52        Ok(m)
53    }
54
55    /// Run the glue's `_call(name, args, kwargs)` with the call's
56    /// values: the input in the synopsis' slot (the first positional
57    /// unless `$_` placed it among the written operands), the
58    /// operands in order, the options by their long name. A Python
59    /// exception is a failed call carrying its text.
60    pub fn call(&self, syn: &Synopsis, name: &str, call: LibCall) -> Result<Value, LibFailure> {
61        let (positional, keyword) = arguments(syn, call);
62        Python::attach(|py| -> Result<Value, LibFailure> {
63            let failed = |e: PyErr| LibFailure::Failed(format!("{e}"));
64            let m = self.module(py).map_err(failed)?;
65            let (args, kwargs) = python_arguments(py, &positional, &keyword).map_err(failed)?;
66            let out = m
67                .getattr("_call")
68                .map_err(failed)?
69                .call1((name, args, kwargs))
70                .map_err(failed)?;
71            crate::py_to_value(&out).map_err(failed)
72        })
73    }
74}
75
76/// The call's values in Python's shape: the input in the synopsis'
77/// slot (the first positional unless `$_` placed it among the
78/// written operands), the operands in order, the options by their
79/// long name with hyphens as underscores, a switch as `True`.
80pub(crate) fn arguments(syn: &Synopsis, call: LibCall) -> (Vec<Value>, Vec<(String, Value)>) {
81    let (positional, keyword, _) = placed(syn, call);
82    (positional, keyword)
83}
84
85/// The call's values in Python's order, and where among the
86/// positional ones the input landed: at its slot, or first (the
87/// receiver) when every operand of an `OperandOrSelf` synopsis is
88/// written.
89pub(crate) fn placed(
90    syn: &Synopsis,
91    call: LibCall,
92) -> (Vec<Value>, Vec<(String, Value)>, Option<usize>) {
93    let mut positional: Vec<Value> = call.operands;
94    let mut keyword: Vec<(String, Value)> = Vec::new();
95    let mut at = None;
96    if let Some(input) = call.input {
97        match syn.input {
98            InputSlot::Operand(i) => {
99                let i = i.min(positional.len());
100                positional.insert(i, input);
101                at = Some(i);
102            }
103            InputSlot::OperandOrSelf(i) => {
104                let fixed = syn.operands.iter().filter(|o| o.name != "...").count();
105                let i = if positional.len() >= fixed {
106                    0
107                } else {
108                    i.min(positional.len())
109                };
110                positional.insert(i, input);
111                at = Some(i);
112            }
113            InputSlot::Option(long) => keyword.push((long.to_string(), input)),
114            // the context itself (the graph library): the engine
115            // reads it, nothing crosses to the callee
116            InputSlot::Context => {}
117        }
118    }
119    for (long, value) in call.options {
120        keyword.push((long.replace('-', "_"), value.unwrap_or(Value::Bool(true))));
121    }
122    (positional, keyword, at)
123}
124
125pub(crate) fn python_arguments<'py>(
126    py: Python<'py>,
127    positional: &[Value],
128    keyword: &[(String, Value)],
129) -> PyResult<(Bound<'py, PyTuple>, Bound<'py, PyDict>)> {
130    let args = PyTuple::new(
131        py,
132        positional
133            .iter()
134            .map(|v| crate::value_to_py(py, v))
135            .collect::<PyResult<Vec<_>>>()?,
136    )?;
137    let kwargs = PyDict::new(py);
138    for (k, v) in keyword {
139        kwargs.set_item(k, crate::value_to_py(py, v)?)?;
140    }
141    Ok((args, kwargs))
142}
143
144/// Whether every named module imports; the error names the first
145/// that does not and the pip line that installs the packages.
146pub fn importable(modules: &[&str], pip: &str) -> Result<(), String> {
147    Python::attach(|py| {
148        // The packages beside the wheel and in the active virtualenv
149        // count, so the path is extended before the first import.
150        crate::foreign::extend_sys_path(py).map_err(|e| e.to_string())?;
151        for m in modules {
152            py.import(*m).map_err(|e| {
153                format!(
154                    "the Python package {m} is not importable ({e}); install it: pip install {pip}"
155                )
156            })?;
157        }
158        Ok(())
159    })
160}