Skip to main content

quarb_pyvalue/
reflect.rs

1//! A command library that reflects the Python package it wraps
2//! (spec: Command Libraries, "Reflected commands"): every function
3//! and method of the package is a command, its synopsis read from
4//! the function's own signature when the gate first meets the name,
5//! its documentation the first line of the function's docstring.
6//!
7//! A method is a function whose first parameter is the receiver, and
8//! the input fills it: `@| pd:describe` is `DataFrame.describe(frame)`.
9//! A dotted name walks the package: a module, an instance, a class
10//! (constructed on the way), a method, an attribute. Operands fill
11//! the required positional parameters of the path in order, the
12//! input the first named as data (`X`, `a`, `data`), else the first
13//! of them; surplus operands fill the first call's remaining
14//! positional parameters. An operand keeps the kind its expression
15//! yields, and a list is read as the input is (the table rule). An option goes to the first call
16//! on the path that declares the parameter, else to one taking
17//! `**kwargs`. What the walk cannot resolve before the run (the
18//! attribute of a result) is resolved during it, and the gate admits
19//! what it cannot check. An object exists only inside one call: a
20//! result that is plain data crosses as a value, any other result is
21//! a library value, kept as it is for the next command.
22
23use crate::glue::{PyGlue, placed, python_arguments};
24use pyo3::prelude::*;
25use pyo3::types::PyDict;
26use quarb::Value;
27use quarb::library::{InputSlot, LibCall, LibFailure, OperandSpec, OptionSpec, Pipe, Synopsis};
28use std::collections::HashMap;
29use std::sync::{Mutex, OnceLock};
30
31const GLUE: &str = r#"
32import contextlib, importlib, inspect, math, sys
33import quarb_foreign as _fg
34
35# What a method returns, where the signature does not say and the
36# next segment of a path needs it: pandas' intermediate objects,
37# the estimators that return themselves.
38_RETURNS = {
39    "pandas": {
40        "groupby": "pandas.core.groupby.generic.DataFrameGroupBy",
41        "rolling": "pandas.core.window.rolling.Rolling",
42        "expanding": "pandas.core.window.expanding.Expanding",
43        "ewm": "pandas.core.window.ewm.ExponentialMovingWindow",
44        "resample": "pandas.core.resample.Resampler",
45    },
46    "sklearn": {"fit": "self", "set_params": "self", "partial_fit": "self", "set_output": "self"},
47}
48
49def _import(name):
50    try:
51        return importlib.import_module(name)
52    except ImportError:
53        return None
54
55def _attr(cur, name):
56    try:
57        return getattr(cur, name)
58    except AttributeError:
59        if inspect.ismodule(cur):
60            m = _import(cur.__name__ + "." + name)
61            if m is not None:
62                return m
63        raise
64
65def _class_of(path):
66    mod, _, name = path.rpartition(".")
67    return getattr(importlib.import_module(mod), name)
68
69def _qual(o):
70    if inspect.ismodule(o):
71        return o.__name__
72    if inspect.isclass(o):
73        return o.__qualname__
74    return type(o).__qualname__
75
76def _nearest(o, name, limit=6):
77    import difflib
78    pool = [n for n in dir(o) if not n.startswith("_")]
79    close = difflib.get_close_matches(name, pool, n=limit, cutoff=0.6)
80    return f"; nearest: {', '.join(close)}" if close else ""
81
82def _routine(a):
83    """A function, method or ufunc: a segment that is called. A
84    callable instance (`stats.norm`) is an attribute, walked into."""
85    return inspect.isroutine(a) or (type(a).__module__ == "numpy" and type(a).__name__ == "ufunc")
86
87# A library command is pure (spec: Command Libraries): a reflected
88# name that reads or writes outside the query is withheld, by its
89# module (the packages' I/O trees), its name, or a parameter that
90# takes a file, a buffer or a connection.
91_IO_MODULES = {
92    "io", "_io", "npyio", "_npyio_impl", "_datasource", "memmap", "_memmap",
93    "datasets", "_datasets", "externals", "pickle", "_pickle", "pytables",
94    "clipboard", "sql", "parsers", "excel", "json", "formats", "feather_format",
95    "parquet", "orc", "stata", "sas", "spss", "xml", "html", "gbq",
96}
97_IO_NAMES = ("read_", "load", "save", "fetch", "dump", "open", "tofile", "fromfile",
98             "memmap", "to_clipboard", "to_sql", "to_gbq")
99_IO_PARAMS = {
100    "path", "path_or_buf", "path_or_buffer", "filepath_or_buffer", "file", "fname",
101    "filename", "file_name", "fid", "buf", "excel_writer", "con", "url", "io",
102    "data_home", "destination_table",
103}
104
105class Withheld(AttributeError):
106    pass
107
108def _withheld(a, name):
109    """Why `a` (reached as `name`) is withheld, or None."""
110    mod = a.__name__ if inspect.ismodule(a) else (getattr(a, "__module__", None) or "")
111    if any(p in _IO_MODULES for p in mod.split(".")[1:]):
112        return f"its module {mod}"
113    if name.startswith(_IO_NAMES):
114        return "its name"
115    if _routine(a) or inspect.isclass(a):
116        ps, _ = _params(a, False)
117        if ps and any(p.name in _IO_PARAMS for p in ps):
118            return "a file or connection parameter"
119    return None
120
121def _withhold(a, name, path, root):
122    why = _withheld(a, name)
123    if why is not None:
124        qual = f"{root}.{path}" if not path.startswith(root) else path
125        raise Withheld(f"'{path}' is withheld: {qual} reads or writes outside the query ({why}); a library command is pure, a file is a mount")
126
127def _params(f, drop_self):
128    try:
129        sig = inspect.signature(f)
130    except (TypeError, ValueError):
131        return None, None
132    ps = list(sig.parameters.values())
133    if drop_self and ps and ps[0].name in ("self", "cls"):
134        ps = ps[1:]
135    return ps, sig.return_annotation
136
137class Seg:
138    __slots__ = ("name", "kind", "params", "obj")
139
140def _plan(root, classes, path, dynamic):
141    """Resolve a dotted name from the root package, or from one of
142    the method classes, as far as the signatures allow. Where the
143    receiver's attributes are its data (`dynamic`: a frame's columns),
144    a name found nowhere is the receiver's, resolved during the run."""
145    rootmod = importlib.import_module(root)
146    names = path.split(".")
147    start = "module"
148    cur = rootmod
149    standing = False      # cur is a class standing for the receiver's type
150    try:
151        _attr(rootmod, names[0])
152    except AttributeError:
153        for c in classes:
154            cls = _class_of(c)
155            if hasattr(cls, names[0]):
156                cur, standing, start = cls, True, "input"
157                break
158        else:
159            if dynamic:
160                cur, start = None, "input"
161            else:
162                where = f"{root} and {', '.join(_class_of(c).__name__ for c in classes)} have" if classes else f"{root} has"
163                raise AttributeError(f"{where} no '{names[0]}'{_nearest(rootmod, names[0])}")
164    segs = []
165    for n in names:
166        s = Seg(); s.name = n; s.params = None; s.obj = None
167        if cur is None:
168            s.kind = "unknown"; segs.append(s); continue
169        try:
170            a = _attr(cur, n)
171        except AttributeError:
172            if standing:
173                # An attribute of the result (a fitted model's
174                # `components_`): resolved during the run.
175                s.kind = "unknown"; cur = None; segs.append(s); continue
176            raise AttributeError(f"{_qual(cur)} has no '{n}'{_nearest(cur, n)}")
177        s.obj = a
178        _withhold(a, n, path, root)
179        if inspect.ismodule(a):
180            s.kind = "module"; cur = a; standing = False
181        elif inspect.isclass(a):
182            s.kind = "class"; s.params, _ = _params(a, False); cur = a; standing = True
183        elif _routine(a):
184            unbound = standing and inspect.isclass(cur)
185            s.kind = "method" if unbound else "function"
186            s.params, ret = _params(a, unbound)
187            nxt = None
188            r = _RETURNS.get(root, {}).get(n)
189            if r == "self":
190                nxt = cur if inspect.isclass(cur) else type(cur)
191            elif r:
192                nxt = _class_of(r)
193            elif ret in ("Self", "'Self'") and inspect.isclass(cur):
194                nxt = cur
195            elif inspect.isclass(ret):
196                nxt = ret
197            cur, standing = nxt, nxt is not None
198        else:
199            s.kind = "attr"
200            if standing:
201                # A descriptor on the class: the instance's value is
202                # the run's (`DataFrame.index`).
203                cur, standing = None, False
204            else:
205                cur = a
206        segs.append(s)
207    return start, segs
208
209def _first_line(o):
210    doc = inspect.getdoc(o) if o is not None else None
211    if not doc:
212        return ""
213    line = doc.strip().splitlines()[0].strip()
214    return line.rstrip(".")
215
216def _synopsis(root, classes, path, dynamic):
217    start, segs = _plan(root, classes, path, dynamic)
218    operands, options, seen = [], [], set()
219    variadic = open_ = False
220    first = True
221    if start == "input":
222        operands.append(("self", "the input: the receiver of the method", True))
223    for s in segs:
224        if s.kind in ("class", "function", "method"):
225            if s.params is None:
226                variadic = open_ = True
227                first = False
228                continue
229            for p in s.params:
230                if p.kind == p.VAR_POSITIONAL:
231                    variadic = True
232                elif p.kind == p.VAR_KEYWORD:
233                    open_ = True
234                elif p.default is p.empty and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
235                    operands.append((p.name, "", True))
236                elif p.kind == p.POSITIONAL_ONLY:
237                    if first:
238                        variadic = True
239                else:
240                    if p.kind == p.POSITIONAL_OR_KEYWORD and first:
241                        variadic = True
242                    long = p.name.replace("_", "-")
243                    if long not in seen:
244                        seen.add(long)
245                        d = p.default
246                        shown = "" if d is p.empty or repr(d).startswith("<") else repr(d)
247                        options.append((long, f"default {shown}" if shown else ""))
248            first = False
249        elif s.kind == "unknown":
250            variadic = open_ = True
251    last = segs[-1]
252    if last.kind == "module":
253        raise AttributeError(
254            f"'{path}' is a module of {root}, not a command: name a function or a class in it ({path}.<name>)")
255    doc = _first_line(last.obj)
256    returns = ""
257    if last.kind in ("function", "method") and last.params is not None:
258        try:
259            ret = inspect.signature(last.obj).return_annotation
260            if ret is not inspect.Signature.empty:
261                returns = ret if isinstance(ret, str) else getattr(ret, "__name__", str(ret))
262        except (TypeError, ValueError):
263            pass
264    elif last.kind == "class":
265        returns = last.obj.__name__
266    # The slot the input fills: the receiver of a method; else the
267    # first required parameter named as data (`X` in
268    # `cross_val_score(estimator, X, y)`), else the first required.
269    input_at = 0
270    if start != "input":
271        input_at = next((i for i, (n, _, _) in enumerate(operands) if n in _DATA_NAMES), 0)
272    # A path that opens with a class may take the input as the
273    # instance itself (a fitted estimator before `.predict(X)`).
274    calls = [s for s in segs if s.kind in ("class", "function", "method", "unknown")]
275    instance = start != "input" and bool(calls) and calls[0].kind == "class"
276    return {"operands": operands, "options": options, "variadic": variadic,
277            "open": open_, "doc": doc, "returns": returns.strip("'"),
278            "input": input_at, "instance": instance}
279
280def _numeric(v):
281    # A boolean reads 1 for true, 0 for false (the table rule), so a
282    # boolean field is a column.
283    if isinstance(v, bool):
284        return 1.0 if v else 0.0
285    if isinstance(v, (int, float)):
286        return float(v)
287    if hasattr(v, "magnitude"):
288        return float(v.magnitude)
289    if isinstance(v, str):
290        try:
291            return float(v.replace(",", ""))
292        except ValueError:
293            return None
294    return None
295
296def _read(v):
297    """A value by its numeric reading (the table rule): numeric text
298    as the number it reads as (an integer form exact), a quantity by
299    its magnitude, a boolean as 1 or 0, anything else as it is."""
300    if isinstance(v, bool):
301        return 1 if v else 0
302    if isinstance(v, str):
303        t = v.strip()
304        try:
305            return int(t)
306        except ValueError:
307            pass
308        try:
309            return float(t)
310        except ValueError:
311            return v
312    if hasattr(v, "magnitude"):
313        return v.magnitude
314    return v
315
316def _isnum(v):
317    return isinstance(v, (int, float)) and not isinstance(v, bool)
318
319_scalar = _read
320
321def _array(x):
322    import numpy as np
323    import datetime as dt
324    if isinstance(x, list):
325        if x and all(isinstance(v, dt.datetime) for v in x):
326            return np.array([v.replace(tzinfo=None) if v.tzinfo else v for v in x], dtype="datetime64[ns]")
327        if x and all(isinstance(v, dt.timedelta) for v in x):
328            return np.array(x, dtype="timedelta64[ns]")
329        if x and all(isinstance(v, list) for v in x):
330            return np.array([_array(v) for v in x])
331        read = [_read(v) for v in x]
332        if read and all(v is None or _isnum(v) for v in read):
333            if any(v is None for v in read):
334                return np.array([np.nan if v is None else float(v) for v in read], dtype=float)
335            return np.array(read)
336        return np.array(read)
337    return x
338
339def _matrix(records):
340    """The table rule for an array: the numeric fields of the first
341    record are the columns, the first text field the label (dropped:
342    an array has no names; pd: keeps them)."""
343    import numpy as np
344    first = records[0]
345    cols = [k for k, v in first.items() if _numeric(v) is not None]
346    label = next((k for k, v in first.items() if isinstance(v, str) and _numeric(v) is None), None)
347    if not cols:
348        raise TypeError("no numeric field in the first record; nothing to make a matrix of")
349    out = []
350    for i, r in enumerate(records):
351        row = []
352        for c in cols:
353            v = _numeric(r.get(c))
354            if v is None:
355                who = r.get(label, i + 1) if label else i + 1
356                raise TypeError(f"row {who}: field '{c}' is {'null (a hole in the matrix)' if r.get(c) is None else 'not numeric'}")
357            row.append(v)
358        out.append(row)
359    return np.array(out, dtype=float)
360
361def _series(values, index=None):
362    """A column: numeric when every non-null value reads as a number
363    (the table rule), text otherwise. A null crosses as pd.NA in a
364    nullable dtype (Float64, Int64, boolean, string), never as NaN,
365    so it comes back as null; a nan is a float like any other."""
366    import pandas as pd
367    read = [_read(v) for v in values]
368    present = [v for v in read if v is not None]
369    if any(v is None for v in read):
370        if present and all(_isnum(v) for v in present):
371            dtype = "Int64" if all(isinstance(v, int) for v in present) else "Float64"
372            return pd.array([pd.NA if v is None else v for v in read], dtype=dtype)
373        return pd.array([pd.NA if v is None else str(v) for v in read], dtype="string")
374    if present and all(_isnum(v) for v in present):
375        return pd.Series(read, index=index)
376    return pd.Series(read, index=index, dtype=object) if index is not None else pd.Series(read, dtype=object)
377
378def _numeric_columns(df):
379    """Every object column through `_series`: numeric readings,
380    nulls as pd.NA in the nullable dtypes."""
381    import pandas as pd
382    for c in df.columns:
383        col = df[c]
384        # a boolean column reads 1 and 0 (the table rule)
385        if str(col.dtype) == "bool":
386            df[c] = col.astype("int64")
387            continue
388        if str(col.dtype) == "boolean":
389            df[c] = col.astype("Int64")
390            continue
391        if col.dtype != object:
392            continue
393        s = _series(col.tolist())
394        df[c] = s if not isinstance(s, pd.Series) else s.values
395    return df
396
397def _frame(x):
398    import pandas as pd
399    if isinstance(x, list):
400        if x and all(isinstance(r, dict) for r in x):
401            df = pd.DataFrame(x)
402            label = next((k for k, v in x[0].items() if isinstance(v, str) and _numeric(v) is None), None)
403            if label is not None and label in df.columns:
404                df = df.set_index(label)
405            return _numeric_columns(df)
406        if x and all(isinstance(r, list) for r in x):
407            return _numeric_columns(pd.DataFrame([[_read(v) for v in r] for r in x]))
408        s = _series(x)
409        return s if isinstance(s, pd.Series) else pd.Series(s)
410    if isinstance(x, dict):
411        return pd.Series({k: _read(v) for k, v in x.items()})
412    return x
413
414def _construct(x, mode):
415    if mode == "none" or not isinstance(x, (list, dict)):
416        return x
417    # A record is a one-row table by the table rule (the user's
418    # ruling on constructors as operands): a frame of one row, or
419    # the matrix of its numeric fields.
420    if isinstance(x, dict):
421        x = [x]
422    if mode == "frame":
423        return _frame(x)
424    if x and all(isinstance(r, dict) for r in x):
425        return _matrix(x)
426    return _array(x)
427
428_DATA_NAMES = {"data", "object", "a", "x", "X", "values", "arr", "obj", "shape"}
429
430def _required(s):
431    if s.params is None:
432        return 0
433    return sum(1 for p in s.params if p.default is p.empty and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD))
434
435_plans = {}
436
437def _first_positional(s):
438    if not s.params:
439        return None
440    return next((p for p in s.params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)), None)
441
442def _takes_data(s):
443    """Whether a class's constructor takes data first (`DataFrame(data)`,
444    `ndarray(shape)`); an estimator's (`GaussianNB(priors)`) does not."""
445    first = _first_positional(s)
446    return s.params is None or (first is not None and first.name in _DATA_NAMES)
447
448def _call(root, classes, path, construct, dynamic, args, kwargs, input_at=None):
449    with contextlib.redirect_stdout(sys.stderr):
450        key = (root, tuple(classes), path)
451        if key not in _plans:
452            _plans[key] = _plan(root, classes, path, dynamic)
453        start, segs = _plans[key]
454        # Every operand keeps the kind its expression yields and a list
455        # is read as the input is (the table rule): a list of records
456        # a matrix or frame, numeric readings a vector, a library
457        # value itself.
458        queue = [_construct(a, construct) for a in args]
459        # Where the input stands in the queue, followed through the
460        # pops; None once a call has taken it.
461        def taken(k):
462            nonlocal input_at
463            if input_at is not None:
464                input_at = None if input_at < k else input_at - k
465        calls = [s for s in segs if s.kind in ("class", "function", "method", "unknown")]
466        receiver = None
467        if start == "input":
468            if not queue:
469                raise TypeError(f"{path}: the receiver is missing (nothing on the pipe)")
470            receiver = queue.pop(0)
471            taken(1)
472        pos = {id(s): [] for s in segs}
473        instance = None
474        if calls and calls[0].kind == "class" and queue:
475            cls = calls[0].obj
476            if inspect.isclass(cls) and isinstance(queue[0], cls):
477                # The input is an instance of the class on the path (a
478                # fitted estimator, a frame): the receiver of its
479                # methods, not constructed again.
480                instance = queue.pop(0)
481                taken(1)
482            else:
483                # A class first on the path whose first parameter is its
484                # data (`pd:Series.rolling.mean(12)`, `np:ndarray`) is
485                # constructed from the input; an estimator's constructor
486                # (KMeans(n_clusters)) is not, and the input reaches its
487                # method.
488                first = _first_positional(calls[0])
489                if first is not None and first.name in _DATA_NAMES and first.default is not first.empty:
490                    pos[id(calls[0])].append(queue.pop(0))
491                    taken(1)
492        for s in calls:
493            need = 0 if (instance is not None and s is calls[0]) else _required(s)
494            pos[id(s)], queue = pos[id(s)] + queue[:need], queue[need:]
495            taken(need)
496        if queue:
497            if not calls:
498                raise TypeError(f"{path}: {len(queue)} operand(s) given, none taken (an attribute)")
499            # The input passing a bare constructor that takes no data
500            # (an estimator, built from its options alone) is not
501            # handed to it: the instance is the result.
502            if (input_at is not None and input_at < len(queue) and calls[0].kind == "class"
503                    and instance is None and not _takes_data(calls[0])):
504                del queue[input_at]
505            pos[id(calls[0])] += queue
506        kw = {id(s): {} for s in segs}
507        for k, v in kwargs.items():
508            target = None
509            for s in calls:
510                if s.params is None or any(p.name == k for p in s.params):
511                    target = s
512                    break
513            if target is None:
514                for s in calls:
515                    if s.params is not None and any(p.kind == p.VAR_KEYWORD for p in s.params):
516                        target = s
517                        break
518            if target is None:
519                raise TypeError(f"{path}: no parameter named '{k.replace('_', '-')}'")
520            kw[id(target)][k] = v
521        cur = receiver if start == "input" else importlib.import_module(root)
522        for s in segs:
523            a = _attr(cur, s.name)
524            if s.kind == "unknown":
525                _withhold(a, s.name, path, root)
526            if s.kind == "module":
527                cur = a
528            elif s.kind == "class" and instance is not None and s is calls[0]:
529                cur = instance
530            elif s.kind in ("class", "function", "method") or (s.kind == "unknown" and callable(a)):
531                cur = a(*pos[id(s)], **kw[id(s)])
532            elif pos[id(s)] or kw[id(s)]:
533                # An attribute given arguments: a callable instance
534                # (`stats.norm(0; 1)` freezes a distribution).
535                if not callable(a):
536                    raise TypeError(f"{path}: '{s.name}' is an attribute and takes no arguments")
537                cur = a(*pos[id(s)], **kw[id(s)])
538            else:
539                cur = a
540        return cur
541"#;
542
543static PY: PyGlue = PyGlue::new(GLUE, "quarb_reflect.py", "quarb_reflect");
544
545/// The reflection of one package: its root module, the classes
546/// whose methods a bare name may be (`DataFrame`, `Series` for
547/// `pd:`), and how the input is constructed from a plain value
548/// (`ndarray`, `frame`, or `none` to pass it as it is).
549pub struct Reflective {
550    root: &'static str,
551    classes: &'static [&'static str],
552    construct: &'static str,
553    /// Whether the receiver's attributes are its data (a frame's
554    /// columns: `pd:x.mean`), so a name the package and the classes
555    /// lack is the receiver's, resolved during the run.
556    dynamic: bool,
557    cache: OnceLock<Mutex<HashMap<String, &'static Synopsis>>>,
558}
559
560fn leak(s: String) -> &'static str {
561    Box::leak(s.into_boxed_str())
562}
563
564impl Reflective {
565    pub const fn new(
566        root: &'static str,
567        classes: &'static [&'static str],
568        construct: &'static str,
569        dynamic: bool,
570    ) -> Reflective {
571        Reflective {
572            root,
573            classes,
574            construct,
575            dynamic,
576            cache: OnceLock::new(),
577        }
578    }
579
580    /// The package's name, for messages.
581    pub fn root(&self) -> &'static str {
582        self.root
583    }
584
585    /// The synopsis of a dotted name, read from the package once
586    /// and kept for the process; `Err` is the refusal when the
587    /// package has no such name (its nearest names included).
588    pub fn resolve(&self, name: &str) -> Result<&'static Synopsis, String> {
589        let cache = self.cache.get_or_init(|| Mutex::new(HashMap::new()));
590        if let Some(s) = cache.lock().expect("synopsis cache").get(name) {
591            return Ok(s);
592        }
593        let syn = Python::attach(|py| -> Result<Synopsis, String> {
594            let m = PY.module(py).map_err(|e| e.to_string())?;
595            let d = m
596                .getattr("_synopsis")
597                .and_then(|f| f.call1((self.root, self.classes, name, self.dynamic)))
598                .map_err(|e| e.value(py).to_string())?;
599            let d = d.cast::<PyDict>().map_err(|e| e.to_string())?;
600            let get = |k: &str| d.get_item(k).ok().flatten();
601            let operands: Vec<(String, String, bool)> = get("operands")
602                .and_then(|v| v.extract().ok())
603                .unwrap_or_default();
604            let options: Vec<(String, String)> = get("options")
605                .and_then(|v| v.extract().ok())
606                .unwrap_or_default();
607            let variadic: bool = get("variadic").and_then(|v| v.extract().ok()).unwrap_or(false);
608            let open: bool = get("open").and_then(|v| v.extract().ok()).unwrap_or(false);
609            let doc: String = get("doc").and_then(|v| v.extract().ok()).unwrap_or_default();
610            let returns: String = get("returns").and_then(|v| v.extract().ok()).unwrap_or_default();
611            let input_at: usize = get("input").and_then(|v| v.extract().ok()).unwrap_or(0);
612            let instance: bool = get("instance").and_then(|v| v.extract().ok()).unwrap_or(false);
613            let mut ops: Vec<OperandSpec> = operands
614                .into_iter()
615                .map(|(n, d, required)| OperandSpec {
616                    name: leak(n),
617                    doc: leak(d),
618                    values: &[],
619                    required,
620                })
621                .collect();
622            if variadic {
623                ops.push(OperandSpec {
624                    name: "...",
625                    doc: "further positional arguments, as the function takes them",
626                    values: &[],
627                    required: false,
628                });
629            }
630            let mut opts: Vec<OptionSpec> = options
631                .into_iter()
632                .map(|(long, d)| OptionSpec {
633                    short: None,
634                    long: leak(long),
635                    takes_value: true,
636                    doc: leak(d),
637                })
638                .collect();
639            if open {
640                opts.push(OptionSpec {
641                    short: None,
642                    long: "*",
643                    takes_value: true,
644                    doc: "any other keyword argument the function takes",
645                });
646            }
647            Ok(Synopsis {
648                name: leak(name.to_string()),
649                doc: leak(doc),
650                pipes: Pipe::Both,
651                operands: Box::leak(ops.into_boxed_slice()),
652                options: Box::leak(opts.into_boxed_slice()),
653                input: if instance {
654                    InputSlot::OperandOrSelf(input_at)
655                } else {
656                    InputSlot::Operand(input_at)
657                },
658                returns: leak(if returns.is_empty() {
659                    "a value, or a library value".to_string()
660                } else {
661                    returns
662                }),
663            })
664        })?;
665        let syn: &'static Synopsis = Box::leak(Box::new(syn));
666        cache
667            .lock()
668            .expect("synopsis cache")
669            .insert(name.to_string(), syn);
670        Ok(syn)
671    }
672
673    /// Walk the name with the call's values; the result as a value
674    /// (plain data) or a library value (anything else).
675    pub fn call(&self, syn: &Synopsis, name: &str, call: LibCall) -> Result<Value, LibFailure> {
676        let (positional, keyword, input_at) = placed(syn, call);
677        Python::attach(|py| -> Result<Value, LibFailure> {
678            let failed = |e: PyErr| LibFailure::Failed(e.value(py).to_string());
679            let m = PY.module(py).map_err(failed)?;
680            let (args, kwargs) = python_arguments(py, &positional, &keyword).map_err(failed)?;
681            let out = m
682                .getattr("_call")
683                .map_err(failed)?
684                .call1((
685                    self.root,
686                    self.classes,
687                    name,
688                    self.construct,
689                    self.dynamic,
690                    args,
691                    kwargs,
692                    input_at,
693                ))
694                .map_err(failed)?;
695            crate::py_to_value(&out).map_err(failed)
696        })
697    }
698}