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    """A parameter is reachable as Python takes it (spec: "Reflected
218    commands"): operands fill the positional parameters in signature
219    order — the required ones of every call on the path, then the
220    defaulted ones of the first call, where surplus operands go —
221    and `--name=` fills the parameter so named wherever it stands;
222    positional-only is an operand alone, keyword-only an option
223    alone; `*args` admits surplus operands, `**kwargs` any option."""
224    start, segs = _plan(root, classes, path, dynamic)
225    operands, options, seen = [], [], set()
226    deferred = []
227    variadic = open_ = False
228    first = True
229    if start == "input":
230        operands.append(("self", "the input: the receiver of the method", True))
231    for s in segs:
232        if s.kind in ("class", "function", "method"):
233            if s.params is None:
234                variadic = open_ = True
235                first = False
236                continue
237            leading = _first_positional(s)
238            for p in s.params:
239                if p.kind == p.VAR_POSITIONAL:
240                    variadic = True
241                    continue
242                if p.kind == p.VAR_KEYWORD:
243                    open_ = True
244                    continue
245                required = p.default is p.empty
246                d = p.default
247                shown = "" if required or repr(d).startswith("<") else repr(d)
248                doc = f"default {shown}" if shown else ""
249                if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
250                    if required:
251                        operands.append((p.name, "", True))
252                    elif first and p is leading and p.name in _DATA_NAMES:
253                        # A class or function whose first parameter is
254                        # its data, defaulted (`DataFrame(data=None)`):
255                        # the input's slot, ahead of the rest.
256                        operands.append((p.name, doc, False))
257                    elif first:
258                        deferred.append((p.name, doc, False))
259                if p.kind != p.POSITIONAL_ONLY:
260                    long = p.name.replace("_", "-")
261                    if long not in seen:
262                        seen.add(long)
263                        options.append((long, doc))
264            first = False
265        elif s.kind == "unknown":
266            variadic = open_ = True
267    # The slot the input fills: the receiver of a method; else the
268    # first parameter named as data (`X` in `cross_val_score(estimator,
269    # X, y)`, `data` of a constructor), else the first — judged before
270    # the first call's defaulted positionals join the operands.
271    input_at = 0
272    if start != "input":
273        input_at = next((i for i, (n, _, _) in enumerate(operands) if n in _DATA_NAMES), 0)
274    operands += deferred
275    last = segs[-1]
276    if last.kind == "module":
277        raise AttributeError(
278            f"'{path}' is a module of {root}, not a command: name a function or a class in it ({path}.<name>)")
279    doc = _first_line(last.obj)
280    returns = ""
281    if last.kind in ("function", "method") and last.params is not None:
282        try:
283            ret = inspect.signature(last.obj).return_annotation
284            if ret is not inspect.Signature.empty:
285                returns = ret if isinstance(ret, str) else getattr(ret, "__name__", str(ret))
286        except (TypeError, ValueError):
287            pass
288    elif last.kind == "class":
289        returns = last.obj.__name__
290    # A path that opens with a class may take the input as the
291    # instance itself (a fitted estimator before `.predict(X)`).
292    calls = [s for s in segs if s.kind in ("class", "function", "method", "unknown")]
293    instance = start != "input" and bool(calls) and calls[0].kind == "class"
294    return {"operands": operands, "options": options, "variadic": variadic,
295            "open": open_, "doc": doc, "returns": returns.strip("'"),
296            "input": input_at, "instance": instance}
297
298def _numeric(v):
299    # A boolean reads 1 for true, 0 for false (the table rule), so a
300    # boolean field is a column.
301    if isinstance(v, bool):
302        return 1.0 if v else 0.0
303    if isinstance(v, (int, float)):
304        return float(v)
305    if hasattr(v, "magnitude"):
306        return float(v.magnitude)
307    if isinstance(v, str):
308        try:
309            return float(v.replace(",", ""))
310        except ValueError:
311            return None
312    return None
313
314def _read(v):
315    """A value by its numeric reading (the table rule): numeric text
316    as the number it reads as (an integer form exact), a quantity by
317    its magnitude, a boolean as 1 or 0, anything else as it is."""
318    if isinstance(v, bool):
319        return 1 if v else 0
320    if isinstance(v, str):
321        t = v.strip()
322        try:
323            return int(t)
324        except ValueError:
325            pass
326        try:
327            return float(t)
328        except ValueError:
329            return v
330    if hasattr(v, "magnitude"):
331        return v.magnitude
332    return v
333
334def _isnum(v):
335    return isinstance(v, (int, float)) and not isinstance(v, bool)
336
337_scalar = _read
338
339def _array(x):
340    import numpy as np
341    import datetime as dt
342    if isinstance(x, list):
343        if x and all(isinstance(v, dt.datetime) for v in x):
344            return np.array([v.replace(tzinfo=None) if v.tzinfo else v for v in x], dtype="datetime64[ns]")
345        if x and all(isinstance(v, dt.timedelta) for v in x):
346            return np.array(x, dtype="timedelta64[ns]")
347        if x and all(isinstance(v, list) for v in x):
348            return np.array([_array(v) for v in x])
349        read = [_read(v) for v in x]
350        if read and all(v is None or _isnum(v) for v in read):
351            if any(v is None for v in read):
352                return np.array([np.nan if v is None else float(v) for v in read], dtype=float)
353            return np.array(read)
354        return np.array(read)
355    return x
356
357def _matrix(records):
358    """The table rule for an array: the numeric fields of the first
359    record are the columns, the first text field the label (dropped:
360    an array has no names; pd: keeps them)."""
361    import numpy as np
362    first = records[0]
363    cols = [k for k, v in first.items() if _numeric(v) is not None]
364    label = next((k for k, v in first.items() if isinstance(v, str) and _numeric(v) is None), None)
365    if not cols:
366        raise TypeError("no numeric field in the first record; nothing to make a matrix of")
367    out = []
368    for i, r in enumerate(records):
369        row = []
370        for c in cols:
371            v = _numeric(r.get(c))
372            if v is None:
373                who = r.get(label, i + 1) if label else i + 1
374                raise TypeError(f"row {who}: field '{c}' is {'null (a hole in the matrix)' if r.get(c) is None else 'not numeric'}")
375            row.append(v)
376        out.append(row)
377    return np.array(out, dtype=float)
378
379def _series(values, index=None):
380    """A column: numeric when every non-null value reads as a number
381    (the table rule), text otherwise. A null crosses as pd.NA in a
382    nullable dtype (Float64, Int64, boolean, string), never as NaN,
383    so it comes back as null; a nan is a float like any other."""
384    import pandas as pd
385    read = [_read(v) for v in values]
386    present = [v for v in read if v is not None]
387    if any(v is None for v in read):
388        if present and all(_isnum(v) for v in present):
389            dtype = "Int64" if all(isinstance(v, int) for v in present) else "Float64"
390            return pd.array([pd.NA if v is None else v for v in read], dtype=dtype)
391        return pd.array([pd.NA if v is None else str(v) for v in read], dtype="string")
392    if present and all(_isnum(v) for v in present):
393        return pd.Series(read, index=index)
394    return pd.Series(read, index=index, dtype=object) if index is not None else pd.Series(read, dtype=object)
395
396def _numeric_columns(df):
397    """Every object column through `_series`: numeric readings,
398    nulls as pd.NA in the nullable dtypes."""
399    import pandas as pd
400    for c in df.columns:
401        col = df[c]
402        # a boolean column reads 1 and 0 (the table rule)
403        if str(col.dtype) == "bool":
404            df[c] = col.astype("int64")
405            continue
406        if str(col.dtype) == "boolean":
407            df[c] = col.astype("Int64")
408            continue
409        # pandas 3 infers its `str` dtype for text; it reads as
410        # object does.
411        if col.dtype != object and str(col.dtype) not in ("str", "string"):
412            continue
413        s = _series(col.tolist())
414        df[c] = s if not isinstance(s, pd.Series) else s.values
415    return df
416
417def _frame(x):
418    import pandas as pd
419    if isinstance(x, list):
420        if x and all(isinstance(r, dict) for r in x):
421            df = pd.DataFrame(x)
422            label = next((k for k, v in x[0].items() if isinstance(v, str) and _numeric(v) is None), None)
423            if label is not None and label in df.columns:
424                df = df.set_index(label)
425            return _numeric_columns(df)
426        if x and all(isinstance(r, list) for r in x):
427            return _numeric_columns(pd.DataFrame([[_read(v) for v in r] for r in x]))
428        s = _series(x)
429        return s if isinstance(s, pd.Series) else pd.Series(s)
430    if isinstance(x, dict):
431        return pd.Series({k: _read(v) for k, v in x.items()})
432    return x
433
434def _construct(x, mode):
435    if mode == "none" or not isinstance(x, (list, dict)):
436        return x
437    # A record is a one-row table by the table rule (the user's
438    # ruling on constructors as operands): a frame of one row, or
439    # the matrix of its numeric fields.
440    if isinstance(x, dict):
441        x = [x]
442    if mode == "frame":
443        return _frame(x)
444    if x and all(isinstance(r, dict) for r in x):
445        return _matrix(x)
446    return _array(x)
447
448_DATA_NAMES = {"data", "object", "a", "x", "X", "values", "arr", "obj", "shape"}
449
450def _required(s, named=()):
451    """How many operands a call takes as its required positional
452    parameters, those filled by name left out."""
453    if s.params is None:
454        return 0
455    return sum(1 for p in s.params
456               if p.default is p.empty and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
457               and p.name not in named)
458
459_plans = {}
460
461def _first_positional(s):
462    if not s.params:
463        return None
464    return next((p for p in s.params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)), None)
465
466def _takes_data(s):
467    """Whether a class's constructor takes data first (`DataFrame(data)`,
468    `ndarray(shape)`); an estimator's (`GaussianNB(priors)`) does not."""
469    first = _first_positional(s)
470    return s.params is None or (first is not None and first.name in _DATA_NAMES)
471
472def _call(root, classes, path, construct, dynamic, args, kwargs, input_at=None):
473    with contextlib.redirect_stdout(sys.stderr):
474        key = (root, tuple(classes), path)
475        if key not in _plans:
476            _plans[key] = _plan(root, classes, path, dynamic)
477        start, segs = _plans[key]
478        # Every operand keeps the kind its expression yields and a list
479        # is read as the input is (the table rule): a list of records
480        # a matrix or frame, numeric readings a vector, a library
481        # value itself.
482        queue = [_construct(a, construct) for a in args]
483        # Where the input stands in the queue, followed through the
484        # pops; None once a call has taken it.
485        def taken(k):
486            nonlocal input_at
487            if input_at is not None:
488                input_at = None if input_at < k else input_at - k
489        calls = [s for s in segs if s.kind in ("class", "function", "method", "unknown")]
490        receiver = None
491        if start == "input":
492            if not queue:
493                raise TypeError(f"{path}: the receiver is missing (nothing on the pipe)")
494            receiver = queue.pop(0)
495            taken(1)
496        pos = {id(s): [] for s in segs}
497        instance = None
498        if calls and calls[0].kind == "class" and queue:
499            cls = calls[0].obj
500            if inspect.isclass(cls) and isinstance(queue[0], cls):
501                # The input is an instance of the class on the path (a
502                # fitted estimator, a frame): the receiver of its
503                # methods, not constructed again.
504                instance = queue.pop(0)
505                taken(1)
506            else:
507                # A class first on the path whose first parameter is its
508                # data (`pd:Series.rolling.mean(12)`, `np:ndarray`) is
509                # constructed from the input; an estimator's constructor
510                # (KMeans(n_clusters)) is not, and the input reaches its
511                # method.
512                first = _first_positional(calls[0])
513                if first is not None and first.name in _DATA_NAMES and first.default is not first.empty:
514                    pos[id(calls[0])].append(queue.pop(0))
515                    taken(1)
516        # An option fills the parameter so named on the first call
517        # that declares it, else one taking **kwargs; a required
518        # parameter it fills is not taken from the operands.
519        kw = {id(s): {} for s in segs}
520        for k, v in kwargs.items():
521            target = None
522            for s in calls:
523                if s.params is None or any(p.name == k for p in s.params):
524                    target = s
525                    break
526            if target is None:
527                for s in calls:
528                    if s.params is not None and any(p.kind == p.VAR_KEYWORD for p in s.params):
529                        target = s
530                        break
531            if target is None:
532                raise TypeError(f"{path}: no parameter named '{k.replace('_', '-')}'")
533            kw[id(target)][k] = v
534        for s in calls:
535            need = 0 if (instance is not None and s is calls[0]) else _required(s, kw[id(s)])
536            pos[id(s)], queue = pos[id(s)] + queue[:need], queue[need:]
537            taken(need)
538        if queue:
539            if not calls:
540                raise TypeError(f"{path}: {len(queue)} operand(s) given, none taken (an attribute)")
541            # The input passing a bare constructor that takes no data
542            # (an estimator, built from its options alone) is not
543            # handed to it: the instance is the result.
544            if (input_at is not None and input_at < len(queue) and calls[0].kind == "class"
545                    and instance is None and not _takes_data(calls[0])):
546                del queue[input_at]
547            pos[id(calls[0])] += queue
548        cur = receiver if start == "input" else importlib.import_module(root)
549        for s in segs:
550            a = _attr(cur, s.name)
551            if s.kind == "unknown":
552                _withhold(a, s.name, path, root)
553            if s.kind == "module":
554                cur = a
555            elif s.kind == "class" and instance is not None and s is calls[0]:
556                cur = instance
557            elif s.kind in ("class", "function", "method") or (s.kind == "unknown" and callable(a)):
558                cur = a(*pos[id(s)], **kw[id(s)])
559            elif pos[id(s)] or kw[id(s)]:
560                # An attribute given arguments: a callable instance
561                # (`stats.norm(0; 1)` freezes a distribution).
562                if not callable(a):
563                    raise TypeError(f"{path}: '{s.name}' is an attribute and takes no arguments")
564                cur = a(*pos[id(s)], **kw[id(s)])
565            else:
566                cur = a
567        return cur
568"#;
569
570static PY: PyGlue = PyGlue::new(GLUE, "quarb_reflect.py", "quarb_reflect");
571
572/// The reflection of one package: its root module, the classes
573/// whose methods a bare name may be (`DataFrame`, `Series` for
574/// `pd:`), and how the input is constructed from a plain value
575/// (`ndarray`, `frame`, or `none` to pass it as it is).
576pub struct Reflective {
577    root: &'static str,
578    classes: &'static [&'static str],
579    construct: &'static str,
580    /// Whether the receiver's attributes are its data (a frame's
581    /// columns: `pd:x.mean`), so a name the package and the classes
582    /// lack is the receiver's, resolved during the run.
583    dynamic: bool,
584    cache: OnceLock<Mutex<HashMap<String, &'static Synopsis>>>,
585}
586
587fn leak(s: String) -> &'static str {
588    Box::leak(s.into_boxed_str())
589}
590
591impl Reflective {
592    pub const fn new(
593        root: &'static str,
594        classes: &'static [&'static str],
595        construct: &'static str,
596        dynamic: bool,
597    ) -> Reflective {
598        Reflective {
599            root,
600            classes,
601            construct,
602            dynamic,
603            cache: OnceLock::new(),
604        }
605    }
606
607    /// The package's name, for messages.
608    pub fn root(&self) -> &'static str {
609        self.root
610    }
611
612    /// The synopsis of a dotted name, read from the package once
613    /// and kept for the process; `Err` is the refusal when the
614    /// package has no such name (its nearest names included).
615    pub fn resolve(&self, name: &str) -> Result<&'static Synopsis, String> {
616        let cache = self.cache.get_or_init(|| Mutex::new(HashMap::new()));
617        if let Some(s) = cache.lock().expect("synopsis cache").get(name) {
618            return Ok(s);
619        }
620        let syn = Python::attach(|py| -> Result<Synopsis, String> {
621            let m = PY.module(py).map_err(|e| e.to_string())?;
622            let d = m
623                .getattr("_synopsis")
624                .and_then(|f| f.call1((self.root, self.classes, name, self.dynamic)))
625                .map_err(|e| e.value(py).to_string())?;
626            let d = d.cast::<PyDict>().map_err(|e| e.to_string())?;
627            let get = |k: &str| d.get_item(k).ok().flatten();
628            let operands: Vec<(String, String, bool)> = get("operands")
629                .and_then(|v| v.extract().ok())
630                .unwrap_or_default();
631            let options: Vec<(String, String)> = get("options")
632                .and_then(|v| v.extract().ok())
633                .unwrap_or_default();
634            let variadic: bool = get("variadic")
635                .and_then(|v| v.extract().ok())
636                .unwrap_or(false);
637            let open: bool = get("open").and_then(|v| v.extract().ok()).unwrap_or(false);
638            let doc: String = get("doc")
639                .and_then(|v| v.extract().ok())
640                .unwrap_or_default();
641            let returns: String = get("returns")
642                .and_then(|v| v.extract().ok())
643                .unwrap_or_default();
644            let input_at: usize = get("input").and_then(|v| v.extract().ok()).unwrap_or(0);
645            let instance: bool = get("instance")
646                .and_then(|v| v.extract().ok())
647                .unwrap_or(false);
648            let mut ops: Vec<OperandSpec> = operands
649                .into_iter()
650                .map(|(n, d, required)| OperandSpec {
651                    name: leak(n),
652                    doc: leak(d),
653                    values: &[],
654                    required,
655                })
656                .collect();
657            if variadic {
658                ops.push(OperandSpec {
659                    name: "...",
660                    doc: "further positional arguments, as the function takes them",
661                    values: &[],
662                    required: false,
663                });
664            }
665            let mut opts: Vec<OptionSpec> = options
666                .into_iter()
667                .map(|(long, d)| OptionSpec {
668                    short: None,
669                    long: leak(long),
670                    takes_value: true,
671                    doc: leak(d),
672                })
673                .collect();
674            if open {
675                opts.push(OptionSpec {
676                    short: None,
677                    long: "*",
678                    takes_value: true,
679                    doc: "any other keyword argument the function takes",
680                });
681            }
682            Ok(Synopsis {
683                name: leak(name.to_string()),
684                doc: leak(doc),
685                pipes: Pipe::Both,
686                operands: Box::leak(ops.into_boxed_slice()),
687                options: Box::leak(opts.into_boxed_slice()),
688                input: if instance {
689                    InputSlot::OperandOrSelf(input_at)
690                } else {
691                    InputSlot::Operand(input_at)
692                },
693                returns: leak(if returns.is_empty() {
694                    "a value, or a library value".to_string()
695                } else {
696                    returns
697                }),
698            })
699        })?;
700        let syn: &'static Synopsis = Box::leak(Box::new(syn));
701        cache
702            .lock()
703            .expect("synopsis cache")
704            .insert(name.to_string(), syn);
705        Ok(syn)
706    }
707
708    /// Walk the name with the call's values; the result as a value
709    /// (plain data) or a library value (anything else).
710    pub fn call(&self, syn: &Synopsis, name: &str, call: LibCall) -> Result<Value, LibFailure> {
711        let (positional, keyword, input_at) = placed(syn, call);
712        Python::attach(|py| -> Result<Value, LibFailure> {
713            let failed = |e: PyErr| LibFailure::Failed(e.value(py).to_string());
714            let m = PY.module(py).map_err(failed)?;
715            let (args, kwargs) = python_arguments(py, &positional, &keyword).map_err(failed)?;
716            let out = m
717                .getattr("_call")
718                .map_err(failed)?
719                .call1((
720                    self.root,
721                    self.classes,
722                    name,
723                    self.construct,
724                    self.dynamic,
725                    args,
726                    kwargs,
727                    input_at,
728                ))
729                .map_err(failed)?;
730            crate::py_to_value(&out).map_err(failed)
731        })
732    }
733}