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