diff --git a/.gitignore b/.gitignore index 53f70cb3..1fa55280 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,6 @@ venv.bak/ .vscode # Notebook playground -vf.ipynb \ No newline at end of file +vf.ipynb +# epythet regenerates the Sphinx scaffold on every build +docsrc/ diff --git a/README.md b/README.md index c570c96a..de8f4843 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,48 @@ Core tools for minting code. For human readers: [Documentation here.](https://i2mint.github.io/i2/) -For AI agents, and humans that use them: this repo ships -[agent skills](https://github.com/i2mint/i2/tree/master/.claude/skills) — -focused, task-oriented guides that tell a coding agent when and how to reach -for `i2`'s tools: - -- [i2-signatures](https://github.com/i2mint/i2/tree/master/.claude/skills/i2-signatures) - — introspect signatures and bind a call's `*args, **kwargs` to named arguments. -- [i2-sig-arithmetic](https://github.com/i2mint/i2/tree/master/.claude/skills/i2-sig-arithmetic) - — build, merge (`+`/`-`) and edit function signatures. -- [i2-wrapper](https://github.com/i2mint/i2/tree/master/.claude/skills/i2-wrapper) - — wrap functions to transform their interface, inputs and output. -- [i2-castgraph](https://github.com/i2mint/i2/tree/master/.claude/skills/i2-castgraph) - — route data through a graph of type/representation conversions. -- [i2-multi-object](https://github.com/i2mint/i2/tree/master/.claude/skills/i2-multi-object) - — compose a fixed set of functions or context managers (`Pipe`, `FuncFanout`, …). + +## For AI agents + +`i2` ships tooling for coding agents. If you are one, start here. + +**Skills** ([Agent Skills](https://agentskills.io) format), for any agent host. + +| Skill | Use it to | +|---|---| +| `i2-castgraph` | routing data through a graph of type/representation conversions with i2.castgraph's TransformationGraph — register pairwise transformers | +| `i2-multi-object` | composing and combining a fixed collection of functions | +| `i2-sig-arithmetic` | building, merging, and editing function signatures with i2's Sig — signature "+/-" arithmetic | +| `i2-signatures` | introspecting function signatures and binding call arguments to parameter names with i2's Sig class — turning an arbitrary | +| `i2-wrapper` | wrapping functions to transform their interface | + +**The documentation, machine-readable**: [`llms.txt`](https://i2mint.github.io/i2/llms.txt) indexes every page; [`i2.md`](https://i2mint.github.io/i2/i2.md) is the whole documentation in one file; every page has a `.md` twin; [`objects.inv`](https://i2mint.github.io/i2/objects.inv) maps symbols to URLs. The full list, with install lines, is on the site's [For AI agents](https://i2mint.github.io/i2/ai-agents.html) page. + +If you identify as a dinosaur, the rest of this README is written for you, starting at [Key Modules Overview](#key-modules-overview). + + +## Install + +``` +pip install i2 +``` + +The smallest useful thing: read, edit and re-apply a function's signature with `Sig`. + +```python +from i2 import Sig + +def f(a, b=2, *, c=3): + return a + b * c + +Sig(f) # +Sig(f).names # ['a', 'b', 'c'] +g = Sig(f).ch_names(a="x")(f) # f itself, with its signature rewritten (g is f) +Sig(g) # +g(1) # 7 +``` + +The flat, single-file view of the whole documentation is [i2.md](https://i2mint.github.io/i2/i2.md). ## Key Modules Overview diff --git a/i2/_deprecated.py b/i2/_deprecated.py index f918ab13..ac3d4ac2 100644 --- a/i2/_deprecated.py +++ b/i2/_deprecated.py @@ -11,10 +11,10 @@ class Command: - """A class that holds a `(caller, args, kwargs)` triple and allows one to execute - `caller(*args, **kwargs)` + """A class that holds a ``(caller, args, kwargs)`` triple and allows one to execute + ``caller(*args, **kwargs)``. - :param func: A callable that will be called with (*args, **kwargs) argument. + :param func: A callable that will be called with ``(*args, **kwargs)`` argument. :param args: The positional arguments to call the func with. :param kwargs: The keyword arguments to call the func with. @@ -60,7 +60,6 @@ class Command: >>> c() hello, world Calling (*('hello', 'world'), **{'sep': ', '}) with result: None - """ def __init__(self, func, *args, **kwargs): @@ -89,7 +88,6 @@ def curried(cls, func, **kw_defaults): Command(foo, 'hi') >>> ff() 'hihi' - """ sig = Sig(func) sig = sig.ch_defaults(**kw_defaults) @@ -225,7 +223,6 @@ def commands_dict( 7 >>> d[formula1]() 13 - """ if callable(funcs): funcs = [funcs] diff --git a/i2/base.py b/i2/base.py index b31fac18..a034567a 100644 --- a/i2/base.py +++ b/i2/base.py @@ -1,7 +1,14 @@ -""" -Tools to provide meta-interfaces of python objects. -See also: - py2misc/py2store/tree_store.py +"""Tools to provide meta-interfaces ("mints") of python objects. + +A mint is a ``Mapping`` view of the (meta-)information describing the interface of +an object: for a callable, its parameters (name, kind, default, annotation) and its +return annotation. + +Main entry points: + +- ``Mint``: mint of any python object +- ``MintOfCallable``: mint of a callable, with parameter information +- ``ParametersMint``: mint of the parameters of a callable """ import inspect @@ -14,6 +21,8 @@ class NotFoundType: + """Type of the ``not_found`` sentinel: falsy, repr ``NotFound``.""" + def __bool__(self): return False @@ -29,6 +38,7 @@ def __repr__(self): def is_not_empty(obj): + """False for ``inspect.Parameter.empty`` and ``NotFoundType`` instances, True otherwise.""" if obj is inspect_is_empty or isinstance(obj, NotFoundType): return False else: @@ -52,6 +62,7 @@ def _property_names_of(obj): # see `i2.signatures.name_of_obj`. # TODO: Analyse usages and reroute to i2.signatures.name_of_obj instead. def name_of_obj(o): + """Deprecated alias of ``i2.signatures.name_of_obj`` (emits a ``DeprecationWarning``).""" from warnings import warn warn( @@ -65,6 +76,8 @@ def name_of_obj(o): class AttrFromKey: + """Expose the keys of a mapping as attributes (``obj.k`` reads ``d[k]``).""" + def __init__(self, d): self._d = d @@ -75,6 +88,8 @@ def __getattr__(self, k): class KeyFromAttr: + """Expose the attributes of an object as mapping keys (``obj[k]`` reads ``getattr(d, k)``).""" + def __init__(self, d): self._d = d @@ -119,6 +134,12 @@ def __getstate__(self): class ParameterMint: + """Mint of one parameter: its name, kind, default and annotation (and position, if given). + + Accepts an ``inspect.Parameter``-like object or a mapping with those keys; missing + attributes are set to ``inspect.Parameter.empty``. + """ + _attrs = ["name", "kind", "default", "annotation"] def __init__(self, param, position=None): @@ -342,6 +363,8 @@ def module_name(self): class MintOfCallableMixin: + """Mint attributes computed from a callable's signature (parameters, return annotation, doc).""" + @lazyprop def _signature(self): """Here's some doc""" @@ -381,6 +404,8 @@ def annotation_of(self): class MintOfDocMixin: + """Placeholder mixin for parsed-docstring mint attributes (not implemented yet).""" + @lazyprop def _parsed_doc(self): return "Not yet implemented (correctly)" diff --git a/i2/castgraph.py b/i2/castgraph.py index 53e6cdd7..7e20478d 100644 --- a/i2/castgraph.py +++ b/i2/castgraph.py @@ -1,7 +1,4 @@ """ -castgraph -========= - A lightweight transformation service for Python that solves the "stable role, unstable representation" problem: a resource has a consistent semantic role (e.g., configuration, text, structured record) but appears in many forms @@ -9,15 +6,17 @@ representations. castgraph organizes transformations as a graph of "kinds" (data representations) and routes requests through the best available path. -Key concepts ------------- +**Key concepts** + + - **Kind**: Any hashable identifier for a data representation (type, string, custom marker) - **Transformation**: An edge in the graph that converts one kind to another - **Kind Predicate (isa)**: A function that determines if an object is of a kind - **TransformationGraph**: The main registry with graph-oriented interface -Solution patterns ------------------ +**Solution patterns** + + - **Type Converter / Conversion Service**: central registry mapping (FromKind, ToKind) to transformer functions. - **Adapter**: each edge adapts one representation to another. - **Strategy**: routing/selection among multiple possible transformations via cost/priority. @@ -25,8 +24,8 @@ - **DDD Anti-Corruption Layer (ACL)**: keep external formats outside the core domain. - **Typeclass / Multimethod idiom**: dispatch based on (source kind, target kind). -Minimal example (new kind-based interface) -------------------------------------------- +**Minimal example (new kind-based interface)** + Use the new TransformationGraph with flexible kinds (not limited to types). >>> from i2.castgraph import TransformationGraph @@ -44,8 +43,8 @@ >>> result["x"] 1 -Legacy example (type-based interface) --------------------------------------- +**Legacy example (type-based interface)** + The old ConversionRegistry interface still works but is deprecated. >>> from i2.castgraph import ConversionRegistry @@ -71,9 +70,11 @@ >>> isinstance(out, Record) and out["x"] == 1 True -Main tools ----------- +**Main tools** + + - **TransformationGraph**: the main graph-based registry (recommended). + - `.add_node(kind, isa=None)`: add a kind with optional predicate. - `.add_edge(src, dst, func, cost=1.0)`: add a transformation edge. - `.register_edge(src, dst, cost=1.0)`: decorator to add an edge. @@ -86,6 +87,7 @@ - `.kinds()`: get all registered kinds. - **ConversionRegistry**: DEPRECATED - use TransformationGraph instead. + - `.register(From, To, cost=1.0)`: DEPRECATED - use `.register_edge()` instead. - `.convert(obj, ToType, context=None)`: DEPRECATED - use `.transform()` instead. @@ -93,8 +95,9 @@ - **KindMatch**: Truthy result from kind predicates that can carry metadata. - **ConversionError**: raised when no route exists between kinds. -Design guidelines ------------------ +**Design guidelines** + + - Define a single TransformationGraph per bounded context; keep edges local. - Prefer small, testable transformer functions with explicit kinds. - Use a canonical domain kind as a **hub** when many formats interoperate. @@ -106,8 +109,8 @@ - Write doctests on each transformer to lock behavior and invariants. - Use bare hashables (types, strings) as kinds; Kind class is optional. -Migration guide ---------------- +**Migration guide** + Old code using ConversionRegistry:: reg = ConversionRegistry() @@ -130,14 +133,15 @@ def transform_func(obj, ctx): ... def transform_func(obj, ctx): ... result = graph.transform(obj, 'dst_format') -Design heritage ---------------- +**Design heritage** + castgraph is a composition of well-known patterns centered on a **Type Converter / Conversion Service**, with **Adapter** edges and **Strategy**-based route selection. At system boundaries, it complements DDD’s **Anti-Corruption Layer** and can employ an integration **Canonical Data Model** to curb O(n²) pairwise mappings. Its (FromType, ToType) dispatch style mirrors **typeclass/multimethod** idioms. For background reading, see: + - .NET TypeConverter: https://learn.microsoft.com/dotnet/api/system.componentmodel.typeconverter - Spring ConversionService: https://docs.spring.io/spring-framework/reference/core/validation/convert.html - Apache Camel Type Converter: https://camel.apache.org/manual/type-converter.html @@ -147,13 +151,12 @@ def transform_func(obj, ctx): ... - Canonical Data Model: https://www.enterpriseintegrationpatterns.com/patterns/messaging/CanonicalDataModel.html - PEP 443 singledispatch: https://peps.python.org/pep-0443/ -Related -------- +**Related** + - Issue that sparked this implementation: https://github.com/i2mint/i2/issues/79 - Computational path resolution: https://github.com/i2mint/meshed/discussions/71 - Subsuming concept - "routing": https://github.com/i2mint/i2/discussions/68 - """ from __future__ import annotations @@ -445,6 +448,7 @@ class TransformationGraph: A "kind" is any hashable identifier for a data representation - it can be a type, a string, or any custom marker. The graph supports: + - Flexible kind system (not limited to Python types) - Graph-oriented interface (add_node, add_edge) - Pluggable kind detection via predicates @@ -453,6 +457,7 @@ class TransformationGraph: - Caching of paths and (optionally) results Design notes: + - Each transformer has signature: func(obj, context) -> transformed_obj - Identity edges are implicit (K -> K) with cost 0 - If multiple routes exist, the minimum total cost path is chosen @@ -490,6 +495,7 @@ def add_node( Examples -------- + >>> graph = TransformationGraph() >>> graph.add_node('text', isa=lambda x: isinstance(x, str)) >>> graph.add_node(int) # Type implies isinstance check @@ -538,6 +544,7 @@ def add_edge( Examples -------- + >>> graph = TransformationGraph() >>> def text_to_int(s, ctx): return int(s) >>> graph.add_edge('text', int, text_to_int) @@ -579,6 +586,7 @@ def register_edge( Examples -------- + >>> graph = TransformationGraph() >>> @graph.register_edge('text', int) ... def text_to_int(s, ctx): return int(s) @@ -636,6 +644,7 @@ def get_transformer( Examples -------- + >>> graph = TransformationGraph() >>> @graph.register_edge(str, int) ... def str_to_int(s, ctx): return int(s) @@ -691,6 +700,7 @@ def transform( Examples -------- + >>> graph = TransformationGraph() >>> @graph.register_edge(str, int) ... def str_to_int(s, ctx): return int(s) @@ -767,6 +777,7 @@ def transform_any( Examples -------- + >>> graph = TransformationGraph() >>> graph.add_node('text', isa=lambda x: isinstance(x, str)) >>> @graph.register_edge('text', int) @@ -801,6 +812,7 @@ def set_kind_detector(self, detector: Callable[[Any], Hashable | None]) -> None: Examples -------- + >>> graph = TransformationGraph() >>> def my_detector(obj): ... if isinstance(obj, str) and obj.startswith('{"'): @@ -828,6 +840,7 @@ def detect_kind(self, obj: Any) -> Hashable | None: Examples -------- + >>> graph = TransformationGraph() >>> graph.add_node('text', isa=lambda x: isinstance(x, str)) >>> graph.detect_kind("hello") @@ -877,6 +890,7 @@ def reachable_from(self, kind: Hashable | Kind) -> set[Hashable]: Examples -------- + >>> graph = TransformationGraph() >>> # ... register transformations ... >>> reachable = graph.reachable_from('text') @@ -913,6 +927,7 @@ def sources_for(self, kind: Hashable | Kind) -> set[Hashable]: Examples -------- + >>> graph = TransformationGraph() >>> # ... register transformations ... >>> sources = graph.sources_for(int) @@ -937,6 +952,7 @@ def kinds(self) -> set[Hashable]: Examples -------- + >>> graph = TransformationGraph() >>> graph.add_node('text') >>> graph.add_node(int) @@ -980,6 +996,7 @@ def func(arg): ... Examples -------- + >>> graph = TransformationGraph() >>> graph.add_node('text', isa=lambda x: isinstance(x, str)) >>> graph.add_node(int) @@ -1096,6 +1113,7 @@ def register( Examples -------- + >>> import warnings >>> graph = TransformationGraph() >>> with warnings.catch_warnings(): @@ -1125,6 +1143,7 @@ def convert( Examples -------- + >>> import warnings >>> graph = TransformationGraph() >>> @graph.register_edge(str, int) @@ -1233,12 +1252,14 @@ class ConversionRegistry: DEPRECATED: Use TransformationGraph instead. A graph-based registry of converters between Python types with: + - registration decorator - shortest-path (by total cost) routing - MRO-aware fallback for source types - caching of paths and (optionally) results Design notes: + - Each converter has signature: func(obj, context) -> converted_obj - Identity edges are implicit (T -> T) with cost 0. - If multiple routes exist, the minimum total cost path is chosen. @@ -1267,6 +1288,7 @@ def register( Example ------- + >>> reg = ConversionRegistry() >>> class A: ... >>> class B: ... @@ -1276,7 +1298,8 @@ def register( >>> isinstance(reg.convert(A(), B), B) True - # Can infer types from annotations: + Types can be inferred from annotations: + >>> class X: ... >>> class Y: ... >>> @reg.register() @@ -1338,6 +1361,7 @@ def convert( Examples -------- + >>> reg = ConversionRegistry() >>> class X: ... >>> class Y: ... @@ -1352,6 +1376,7 @@ def convert( True MRO fallback: if a converter is registered for a base class, it applies to a subclass. + >>> class Base: ... >>> class Sub(Base): ... >>> class Out: ... @@ -1477,11 +1502,8 @@ def register( *, cost: float = 1.0, ) -> Callable[[Converter], Converter]: - """ - Decorator to register a converter function. + """Decorator to register a converter function. - Example - ------- >>> reg = ConversionRegistry() >>> class A: ... >>> class B: ... @@ -1491,7 +1513,8 @@ def register( >>> isinstance(reg.convert(A(), B), B) True - # Can infer types from annotations: + Types can be inferred from annotations: + >>> class X: ... >>> class Y: ... >>> @reg.register() @@ -1553,6 +1576,7 @@ def convert( Examples -------- + >>> reg = ConversionRegistry() >>> class X: ... >>> class Y: ... @@ -1567,6 +1591,7 @@ def convert( True MRO fallback: if a converter is registered for a base class, it applies to a subclass. + >>> class Base: ... >>> class Sub(Base): ... >>> class Out: ... diff --git a/i2/chain_map.py b/i2/chain_map.py index 8826f9b1..4bddcefe 100644 --- a/i2/chain_map.py +++ b/i2/chain_map.py @@ -16,7 +16,7 @@ def is_iterable(x): """Similar in nature to :func:`callable`, ``is_iterable`` returns - ``True`` if an object is `iterable`_, ``False`` if not. + ``True`` if an object is iterable, ``False`` if not. >>> is_iterable([]) True diff --git a/i2/deco.py b/i2/deco.py index 32ab3828..f1c04412 100644 --- a/i2/deco.py +++ b/i2/deco.py @@ -20,6 +20,7 @@ def identity(obj: T) -> T: + """Return the input unchanged.""" return obj @@ -34,6 +35,7 @@ def identity(obj: T) -> T: # Overwrites the imported functools.wraps def wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES): + """Copy of ``functools.wraps`` (kept local: it avoids a Jupyter tab-completion issue).""" return partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated) @@ -79,9 +81,10 @@ class FuncFactory: In essence, ``FuncFactory`` is equivalent to: - ``` - FuncFactory = lambda func: lambda *args, **kwargs: partial(func, *args, **kwargs) - ``` + .. code-block:: text + + FuncFactory = lambda func: lambda *args, **kwargs: partial(func, *args, **kwargs) + but more convenient and helpful. For one, it doesn't use ``lambda``, so is picklable. It also has a more helpful signature: @@ -130,14 +133,14 @@ class FuncFactory: >>> get_42() 42 - Note: A convenience property has been added to implement this recipe: + Note: + A convenience property has been added to implement this recipe: >>> get_42, get_hello = map(FuncFactory.func_returning_obj, (42, 'hello')) >>> get_42() 42 >>> get_hello() 'hello' - """ def __init__(self, func, *, include=(), exclude=()): @@ -178,6 +181,7 @@ def __init__(self, func, *, include=(), exclude=()): @classmethod def wrap(cls, include=(), exclude=()): + """Return a ``FuncFactory`` constructor with ``include`` and ``exclude`` fixed.""" return partial(cls, include=include, exclude=exclude) def _process_args_and_kwargs(self, args, kwargs): @@ -203,6 +207,7 @@ def func_returning_obj(cls, obj): return cls(identity)(obj) def to_jdict(self): + """Return a ``{"func": ...}`` dict from which ``from_jdict`` rebuilds the factory.""" return {"func": self.func} @classmethod @@ -246,9 +251,10 @@ def double_up_as_factory(decorator_func): That is, from a decorator that is defined do ``wrapped_func = decorator(func, **params)``, make it also be able to do ``wrapped_func = decorator(**params)(func)``. - Note: You'll only be able to do this if all but the first argument are keyword-only, - and the first argument (the function to decorate) has a default of ``None`` (this is for your own good). - This is validated before making the "double up as factory" decorator. + Note: + You'll only be able to do this if all but the first argument are keyword-only, + and the first argument (the function to decorate) has a default of ``None`` (this is for your own good). + This is validated before making the "double up as factory" decorator. >>> @double_up_as_factory ... def decorator(func=None, *, multiplier=2): @@ -340,7 +346,6 @@ def double_up_as_factory(decorator_func): with ``TypeError: rename() got multiple values for argument 'func'``. If a decorator needs an argument with the same name as its first parameter, don't use ``double_up_as_factory``. - """ def validated_wrapped_param_name(decorator_func): @@ -371,7 +376,8 @@ def validated_wrapped_param_name(decorator_func): def _conditional_arg_trans(func=None, **condition_and_trans_of_argname): """ - See also: ensure_iterable_args, for a more complex example of how to use it. + See also: + ensure_iterable_args, for a more complex example of how to use it. """ @wraps(func) @@ -430,6 +436,7 @@ def ensure_iterable_args(func=None, **condition_of_argname): def transparently_wrapped(func): + """Wrap ``func`` so it is called with its positional arguments packed in one tuple.""" @wraps(func) def transparently_wrapped_func(*args, **kwargs): return func(args, **kwargs) @@ -442,6 +449,7 @@ def mk_args_kwargs_merger(func): Make a function that will return a dict containing all {argname: argval} pairs from a function's call. That is, it merges all non-keyword arguments with the keyword-arguments, with the right name, so that the arguments can be handled more uniformly. + :param func: The function that will be called, whose signature should be looked at to make the merging function :return: A function merge_args_and_kwargs(args, kwargs) that can be used to merge arguments @@ -494,6 +502,7 @@ def kwargs_for_func(*funcs, **kwargs): def assert_attrs(attrs): """ Asserts, at construction time, that the class contains a specific set of attributes + :param attrs: An attribute name (string) or a list of attribute names whose existence needs to be enforced. :return: A class decorator that will enforce the existence of the attrs when an instance is made @@ -542,7 +551,23 @@ def get_instance(*args, **kw): def preprocess_arguments(pre): - """Apply a function to args, kwargs and use the transformed in the decorated function""" + """Make a decorator that lets ``pre`` rewrite the ``(args, kwargs)`` of every call. + + ``pre(*args, **kwargs)`` must return an ``(args, kwargs)`` pair; the wrapped + function is then called with that pair. + + >>> @preprocess_arguments(lambda *args, **kwargs: ( + ... tuple(int(a) for a in args), {k: int(v) for k, v in kwargs.items()} + ... )) + ... def add(a, b): + ... return a + b + >>> add("1", b="2") + 3 + + See Also: + ``preprocess``: ``pre`` returns a single value that becomes the only argument. + ``transform_args``: transform named arguments one by one. + """ def decorator(func): def wrapper(*args, **kwargs): @@ -555,6 +580,22 @@ def wrapper(*args, **kwargs): def preprocess(pre): + """Make a decorator that feeds the wrapped function the output of ``pre``. + + The wrapped function receives a single argument: ``pre(*args, **kwargs)``, computed + from whatever the caller passed. + + >>> @preprocess(int) + ... def double(x): + ... return 2 * x + >>> double("21") + 42 + + See Also: + ``postprocess``: apply a function to the output instead. + ``preprocess_arguments``: ``pre`` returns the ``(args, kwargs)`` pair to call the + wrapped function with, instead of a single value. + """ def decorator(func): if inspect.ismethod(func): @@ -590,7 +631,8 @@ def _return_annotation_of(func): return Parameter.empty -class OutputPostProcessingError(RuntimeError): ... +class OutputPostProcessingError(RuntimeError): + """Raised by ``postprocess`` when the post-processing function fails.""" def postprocess(post, caught_post_errors=(Exception,), verbose_error_message=False): @@ -605,14 +647,15 @@ def postprocess(post, caught_post_errors=(Exception,), verbose_error_message=Fal >>> sum_range(4) 6 - Note: The decorator also sticks the return annotation of the post function on the wrapped one. + Note: + The decorator also sticks the return annotation of the post function on the wrapped one. Use cases: - Changing a generator into a container returning function - In many situations, writing a generator is simpler than writing a function - that accumulates a list or a dict etc. - So here, you just write the generator and tag this decorator on top, to get the same effect. + In many situations, writing a generator is simpler than writing a function + that accumulates a list or a dict etc. + So here, you just write the generator and tag this decorator on top, to get the same effect. >>> from inspect import signature >>> @postprocess(dict) @@ -645,8 +688,7 @@ def postprocess(post, caught_post_errors=(Exception,), verbose_error_message=Fal >>> assert t == [0, 1] - Using a function that does a lot to make several functions that do less. - (e.g. Extracting/making a python object from a function returning a raw http response_ - + (e.g. Extracting/making a python object from a function returning a raw http response) """ def decorator(func): @@ -694,10 +736,13 @@ def input_output_decorator(preprocess=None, postprocess=None): Makes a decorator that preprocesses inputs and postprocesses outputs. Use it if you want to transform the input of a function or method before calling it, or if you want to transform the returned value before returning it. + :param preprocess: Function to be applied to input :param postprocess: Function to be applied to output :return: a decorator that preprocesses inputs and postprocesses outputs - See also: preprocess and postprocess decorators if you need only to pre or post process! + + See also: + preprocess and postprocess decorators if you need only to pre or post process! >>> # Examples with "normal functions" >>> def f(x=3): @@ -751,11 +796,13 @@ def input_output_decorator(preprocess=None, postprocess=None): This is not what you'd expect: The doc of the class, not the function # >>> + """ + # Not a doctest (kept as a note): # >>> f.static_method = input_output_decorator(preprocess=lambda x: '"' + x + '"', # ... postprocess=lambda x: x + '!!!')(f.static_method) # >>> print(ff.static_method('big', 'eyes')) + # # What big "eyes" you have!!! - """ def decorator(func): if preprocess and postprocess: @@ -789,8 +836,10 @@ def transform_args(dflt_trans_func=None, /, **trans_func_for_arg): Make a decorator that transforms function arguments before calling the function. Works with plain functions and bounded methods. For example: + * original argument: a relative path --> used argument: a full path * original argument: a pickle filepath --> used argument: the loaded object + :param rootdir: rootdir to be used for all name arguments of target function :param name_arg: the position (int) or argument name of the argument containing the name :return: a decorator @@ -881,6 +930,7 @@ def transform_args_wrapper(*args, **kwargs): def wrap_method_output(wrapper_func): + """Make a method decorator that applies ``wrapper_func`` to the method's output.""" def _wrap_output(wrapped): @wraps(wrapped) def _wrapped(self, *args, **kwargs): @@ -976,6 +1026,8 @@ def class_wrapper(cls): def mk_input_and_output_method_wrapper(method_output_trans=None, **arg_trans): + """Make a method decorator transforming named arguments (``arg_trans``) and, if + given, the output (``method_output_trans``).""" def wrap_method(method_func): wrapped_method = transform_args(**arg_trans)(method_func) if method_output_trans is not None: @@ -989,6 +1041,8 @@ def wrap_method(method_func): def transform_class_method_input_and_output( cls, method, method_output_trans=None, **arg_trans ): + """Replace ``cls.method`` in place with a version whose named arguments are + transformed by ``arg_trans`` and whose output by ``method_output_trans``.""" wrapped_method = transform_args(**arg_trans)(getattr(cls, method)) if method_output_trans is not None: setattr( @@ -1030,6 +1084,7 @@ def wrap_class_methods_input_and_output( * transform_class_method_input_and_output: The function that is called for every method we wrap. In the following, we will show two examples. + - The first is a toy example to demonstrate the basic functionality. - The second demonstrates a more involved case, but is still a silly example. - The third demonstrates more the type of application we'd use wrap_class_methods_input_and_output for in real life. @@ -1278,6 +1333,8 @@ def add_method(obj, method_func, method_name=None, class_name=None): def transform_instance_method_input_and_output( obj, method, method_output_trans=None, **arg_trans ): + """Instance-level counterpart of ``transform_class_method_input_and_output``; + experimental (it emits a warning saying so).""" from warnings import warn warn("Not sure transform_instance_method_input_and_output works yet") @@ -1298,6 +1355,11 @@ def wrap_instance_methods( _raise_error_if_non_existent_method=True, **method_trans_spec, ): + """Make a function that wraps the named methods of an instance, as + ``wrap_class_methods_input_and_output`` does for a class (experimental). + + ``_return_a_copy_of_the_class`` is accepted for symmetry but not used. + """ def obj_wrapper(obj): for method, method_trans in method_trans_spec.items(): if hasattr(obj, method): @@ -1318,6 +1380,7 @@ def obj_wrapper(obj): def mk_method_trans_spec_from_methods_specs_dict(methods_specs_dict): """ Utility to make inputs for wrap_class_methods_input_and_output more easily. + :param methods_specs_dict: a dict where keys are method names (either a single string, or a tuple of strings) values are the trans_spec dicts that should be associated to those methods @@ -1369,6 +1432,7 @@ def _call_signature(func: Callable, args: Args, kwargs: Kwargs) -> str: """ A util to make a string representation of a call of a function func with given args and kwargs. Meant to be the default mk_log_str of mk_call_logger. + :param func: A callable :param args: A tuple of positional arguments :param kwargs: A dict of key=val arguments @@ -1395,6 +1459,7 @@ def mk_call_logger( ): """ Makes a decorator that logs each call to the wrapped function. + :param logger: The actual function that logs stuff. Default is print. The "stuff" it logs is given by the what_to_log argument (a function). :param what_to_log: A function taking inputs (func, args, kwargs) of the call, and returning something to log diff --git a/i2/doc_mint.py b/i2/doc_mint.py index 13cfa41a..a00c2ea7 100644 --- a/i2/doc_mint.py +++ b/i2/doc_mint.py @@ -19,24 +19,27 @@ def find_in_params( :param query: The query to search for. :param params: The list of parameter specifications. Params can be provided in two formats: + - A function, from which the params will be extracted from the docstring. - A list of dictionaries where each dictionary specifies a parameter, containing: + - name: The name of the parameter (str). - default: The default value of the parameter (any, optional). - annotation: The type annotation for the parameter (str, optional). - description: A description of the parameter (str). + If a callable is provided, it will be used to generate the list of parameter specifications. :param search_in: The fields to search in each parameter specification. :return: A list of parameter specifications that match the query. Examples: - >>> params = [ - ... {"name": "x", "default": 1, "annotation": "int", "description": "An integer value."}, - ... {"name": "y", "default": None, "annotation": "str", "description": "An optional string."}, - ... ] - >>> find_in_params('int', params) - [{'name': 'x', 'default': 1, 'annotation': 'int', 'description': 'An integer value.'}] + >>> params = [ + ... {"name": "x", "default": 1, "annotation": "int", "description": "An integer value."}, + ... {"name": "y", "default": None, "annotation": "str", "description": "An optional string."}, + ... ] + >>> find_in_params('int', params) + [{'name': 'x', 'default': 1, 'annotation': 'int', 'description': 'An integer value.'}] """ if isinstance(search_in, str): @@ -58,7 +61,7 @@ def indent_lines(string: str, indent: str) -> str: :param indent: The string to use for indentation. :return: The indented string. - Examples: + **Examples** >>> print(indent_lines('This is a test.\nAnother line.', ' ' * 8)) This is a test. @@ -77,7 +80,7 @@ def most_common_indent(string: str, ignore_first_line=True) -> str: because of the way python strings appear in code. :return: The most common indentation string. - Examples: + **Examples** >>> most_common_indent(' This is a test.\n Another line.') ' ' @@ -95,8 +98,9 @@ def inject_docstring_content(to_inject, *, position=-1, indent=True): r""" Inject content into the docstring of a function. - Note: If you use the decorator on a string, it will assume that string is the doc - string you want to transform and return the modified string directly. + Note: + If you use the decorator on a string, it will assume that string is the doc + string you want to transform and return the modified string directly. :param to_inject: The content to inject. :param position: The position in the docstring to inject the content. @@ -108,7 +112,7 @@ def inject_docstring_content(to_inject, *, position=-1, indent=True): If a string, it will use that specific string. :return: A decorator that injects the content into the docstring of the decorated function. - Examples: + **Examples** >>> @inject_docstring_content('This is a test.') ... def test_func(): @@ -179,17 +183,19 @@ def params_to_docstring( :param params: A list of dictionaries where each dictionary specifies a parameter. Each dictionary should contain: + - name: The name of the parameter (str). - default: The default value of the parameter (any, optional). - annotation: The type annotation for the parameter (str, optional). - description: A description of the parameter (str). + :param doc_style: The style of the docstring to generate. One of 'numpy', 'google', or 'rest'. :param take_name_of_types: Whether to use the name of the type as the annotation (bool). :param quote_string_defaults: Whether to quote string defaults (bool). :return: A formatted docstring (str). - Examples: + **Examples** >>> params = [ ... {"name": "x", "default": 1, "annotation": "int", "description": "An integer value."}, @@ -307,7 +313,7 @@ def string_param_to_obj(string_to_object_mapping: dict, string=None): :param string_to_object_mapping: A mapping from string representations to objects. :return: The object corresponding to the string representation. - Examples: + **Examples** >>> string_to_object_mapping = { ... 'None': None, @@ -357,6 +363,12 @@ def string_param_to_obj(string_to_object_mapping: dict, string=None): def literal_eval_converter(s: str, max_length=_MAX_LENGTH_FOR_LITERAL_EVAL): + """Evaluate ``s`` as a Python literal, or return None when it is not one (or is + longer than ``max_length``, or contains a newline or ``;``). + + >>> literal_eval_converter("[1, 2]"), literal_eval_converter("foo") + ([1, 2], None) + """ if len(s) > max_length: # Restrict long strings for extra safety return None elif "\n" in s or "\r" in s or ";" in s: # extra safety @@ -370,6 +382,7 @@ def literal_eval_converter(s: str, max_length=_MAX_LENGTH_FOR_LITERAL_EVAL): def register_converter(converter): """ Register a new converter. A converter can be: + - A dict: { "None": None, "int": int, ... } - A callable: lambda s: attempt to parse s and return object or None """ @@ -382,6 +395,14 @@ def register_converter(converter): def convert_string(s: str, converters: list[dict | Callable]) -> object: + """Convert ``s`` with the first converter that matches (a dict containing ``s`` as a + key, or a callable returning something other than None); return ``s`` if none does. + + >>> convert_string("None", dflt_str_to_obj_converters), convert_string("3.5", dflt_str_to_obj_converters) + (None, 3.5) + >>> convert_string("hello", dflt_str_to_obj_converters) + 'hello' + """ for converter in converters: # If converter is a dict if isinstance(converter, dict): @@ -415,12 +436,13 @@ def docstring_to_params( :param doc_style: The style of the docstring to parse. One of 'numpy', 'google', or 'rest'. :return: A list of parameter specifications, where each specification is a dictionary containing: + - name: The name of the parameter (str). - default: The default value of the parameter (str, optional). - annotation: The type annotation for the parameter (str, optional). - description: A description of the parameter (str). - Examples: + **Examples** >>> docstring = ''' ... Parameters @@ -830,7 +852,6 @@ def split_text_and_doctests(doc_string: str): >>> str(block) ' >>> 2 + 3\n 5\n >>> t = 5\n >>> tt = 10\n' - """ def is_string(item): @@ -893,11 +914,21 @@ def non_doctest_lines(doc): def strip_comments(code): + r"""Remove whole-line ``#`` comments from ``code`` (inline comments are kept). + + >>> strip_comments("# header\nx = 1 # set x\n") + 'x = 1 # set x\n' + """ code = str(code) return comment_strip_p.sub("", code) def mk_example_wants_callback(source_want_func: Callable[[str, str], Callable]): + """Make a ``doctest.Example`` callback from a ``(source, want) -> str`` function. + + The callback returns the example's source untouched when the example expects no + output. + """ def example_wants_callback(example, *args, **kwargs): want = example.want.strip() if want: @@ -910,6 +941,11 @@ def example_wants_callback(example, *args, **kwargs): def split_line_comments(s): + """Split a single line into its code and its ``#`` comment (empty if none). + + >>> split_line_comments("f(1) # a comment") + ('f(1) ', ' a comment') + """ t = s.split("#") if len(t) == 1: comment = "" @@ -950,7 +986,19 @@ def _output_prefix(source, want, prefix="# OUTPUT: "): output_prefix = mk_example_wants_callback(_output_prefix) +output_prefix.__doc__ = ( + "Render a ``doctest.Example`` as its source followed by a ``# OUTPUT:`` line.\n\n" + " >>> import doctest\n" + " >>> output_prefix(doctest.Example(source='1 + 1\\n', want='2\\n'))\n" + " '1 + 1\\n# OUTPUT: 2\\n'\n" +) assert_wants = mk_example_wants_callback(_assert_wants) +assert_wants.__doc__ = ( + "Render a ``doctest.Example`` as an ``assert`` comparing its source to its want.\n\n" + " >>> import doctest\n" + " >>> assert_wants(doctest.Example(source='1 + 1\\n', want='2\\n'))\n" + " 'assert (1 + 1) == 2 #'\n" +) # def example_to_doctest_string(source, want): # want.replace() @@ -960,6 +1008,7 @@ def _output_prefix(source, want, prefix="# OUTPUT: "): def doctest_string_trans_lines( doctest_obj: doctest.DocTest, example_callback=assert_wants ): + """Yield ``example_callback(example)`` for each example of a ``doctest.DocTest``.""" for example in doctest_obj.examples: yield example_callback(example) @@ -994,6 +1043,7 @@ def doctest_string(obj, example_callback=assert_wants, recurse=True): def doctest_string_print(obj, example_callback=assert_wants, recurse=True): """ Extract the doctests found in given object. + :param obj: Object (module, class, function, etc.) you want to extract doctests from. :param recurse: Whether the process should find doctests in the attributes of the object, recursively. :return: A string containing the doctests, with output lines prefixed by '# Output:' @@ -1009,6 +1059,7 @@ def old_doctest_string( ): """ Extract the doctests found in given object. + :param obj: Object (module, class, function, etc.) you want to extract doctests from. :param output_prefix: :param recurse: Whether the process should find doctests in the attributes of the object, recursively. diff --git a/i2/errors.py b/i2/errors.py index 08d3a645..4dfa33d6 100644 --- a/i2/errors.py +++ b/i2/errors.py @@ -8,19 +8,19 @@ class DataError(Exception): - pass + """Base class for errors about the data itself.""" class DuplicateRecordError(DataError): - pass + """A ``DataError`` for a record that already exists.""" class NotFoundError(DataError): - pass + """A ``DataError`` for a record that does not exist.""" class AuthorizationError(Exception): - pass + """Base class for errors about what the caller is allowed to do.""" class OverwritesNotAllowed(AuthorizationError): @@ -46,11 +46,11 @@ def for_keys(cls, keys): class ForbiddenError(AuthorizationError): - pass + """An ``AuthorizationError`` for an operation that is not allowed.""" class InputError(Exception): - pass + """Raised for invalid input.""" class ModuleNotFoundIgnore: @@ -72,12 +72,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): def log_and_return(msg, logger=print): + """Pass ``msg`` to ``logger`` (``print`` by default) and return it.""" logger(msg) return msg class InterruptWithBlock(BaseException): - """To be used to interrupt the march of a with""" + """Raise inside a ``with`` block to leave it early; pair with ``HandleExceptions``. + + >>> with HandleExceptions({InterruptWithBlock: "stopped early"}) as h: + ... raise InterruptWithBlock() + ... print("never printed") + stopped early + >>> h.exit_value + 'stopped early' + """ # Note: Can be extended to have more precise handled conditions and callbacks @@ -162,8 +171,9 @@ class HandleExceptions(AbstractContextManager): before condition The with block was interrupted early. - Tip: If you need to do stuff with an exception, but reraise it, you can - still do that in your callback. Just say `raise` at the end of the callback! + Tip: + If you need to do stuff with an exception, but reraise it, you can + still do that in your callback. Just say `raise` at the end of the callback! >>> def print_and_raise(msg): ... print(msg) @@ -210,9 +220,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise def exited_with_handled_exception(self): + """Whether the last ``with`` block ended on an exception listed in ``on_error``.""" return hasattr(self, "exit_value") def initialize(self): + """Forget the outcome of a previous ``with`` block (done on every ``__enter__``).""" if hasattr(self, "exit_value"): delattr(self, "exit_value") self.exited_with_exception = None diff --git a/i2/footprints.py b/i2/footprints.py index ea135af3..6df692df 100644 --- a/i2/footprints.py +++ b/i2/footprints.py @@ -23,6 +23,7 @@ def module_if_string(x): + """Import ``x`` if it is a module name string; otherwise return it as is.""" if isinstance(x, str): return __import__(x) else: @@ -33,6 +34,7 @@ def module_if_string(x): def dunders_diff(x, y): + """The set of dunder names ``x`` has and ``y`` does not (module names are imported).""" return dunders(x) - dunders(y) @@ -101,8 +103,10 @@ def trace_class_decorator( names_and_sigs=tuple(_dflt_methods.items()), method_factory=_dflt_method_factory, ): - """A decorator that adds methods to a class that trace the operations that are - performed on an instance of that class. + """Add tracing methods to ``cls``, each appending ``(name, *args)`` to the instance's ``.trace``. + + By default the methods are the operator, dict and reflected-operator dunders, made + by ``method_factory(name, sig)``; each returns the instance so calls can be chained. """ for name, sig in dict(names_and_sigs).items(): setattr(cls, name, method_factory(name, sig)) @@ -112,7 +116,7 @@ def trace_class_decorator( @trace_class_decorator class MethodTrace: - """A class that can be used to trace the methods that are called on it. + """Record the operator dunders applied to an instance, as ``(name, *args)`` tuples in ``.trace``. See: https://github.com/i2mint/i2/issues/56 for more details. @@ -135,7 +139,6 @@ class MethodTrace: ... ('__getitem__', 42), ('__setitem__', 42, 'mol'), ('__invert__',) ... ] >>> - """ def __init__(self): @@ -182,6 +185,12 @@ def get_class_that_defined_method(method): def cls_and_method_name_of_method(method): + """The ``(class, name)`` pair of a method, bound method or property. + + >>> from i2.tests.footprints_test import A + >>> cls_and_method_name_of_method(A().target_method) == (A, "target_method") + True + """ if isinstance(method, property): return get_class_that_defined_method(method.fget), name_of_obj(method.fget) return get_class_that_defined_method(method), name_of_obj(method) @@ -216,6 +225,12 @@ def get_class_that_defined_method(method): def cls_and_method_name_of_method(method): + """The ``(class, name)`` pair of a method, bound method or property. + + >>> from i2.tests.footprints_test import A + >>> cls_and_method_name_of_method(A().target_method) == (A, "target_method") + True + """ if isinstance(method, property): return get_class_that_defined_method(method.fget), name_of_obj(method.fget) return get_class_that_defined_method(method), name_of_obj(method) @@ -285,6 +300,7 @@ def _alt_cls_and_method_name_of_method( def list_func_calls(fn): """ Extracts functions and methods called from fn + :param fn: reference to function or method :return: a list of functions or methods names """ @@ -302,6 +318,7 @@ def list_func_calls(fn): def attr_list(root, func_name): """ Extracts attributes from ast tree processing only func_name function or method + :param root: root node of ast tree :param func_name: name of the function :return: a list of attributes names @@ -499,7 +516,6 @@ def ensure_ast(o, src_code=None) -> ast.AST: ... a = 10 ... ''' >>> assert isinstance(ensure_ast('MyClass', src_code), ast.AST) - """ if isinstance(o, ast.AST): @@ -510,6 +526,8 @@ def ensure_ast(o, src_code=None) -> ast.AST: class AttributeVisitor(ast.NodeVisitor): + """Collect, in ``.attributes``, the attribute names accessed on ``object_name`` in an AST.""" + def __init__(self, object_name): self.object_name = object_name self.attributes = set() @@ -541,7 +559,6 @@ def accessed_attributes(func, object_name=None): ... return a + func(x, self, y) ... >>> assert accessed_attributes(foo, 'self') == {'method', 'prop'} - """ if object_name is None: object_name = next(iter(Sig(func)), None) @@ -593,16 +610,17 @@ def init_argument_names(cls, *, no_error_action=None) -> list[str]: >>> init_argument_names(DataClass) ['x', 'y'] - Note: Some builtin types don't have signatures, so we get: + Note: + Some builtin types don't have signatures, so we get: + + .. code-block:: text + + ValueError: no signature found for builtin type ... - ``` - ValueError: no signature found for builtin type ... - ``` By default, we handle this by returning an empty list, but a callable no_error_action will call that function and return its result. Anything else will result in raising the error. - """ try: return Sig(cls).names @@ -654,7 +672,6 @@ def attribute_dependencies( :param exclude_names: A list of names to exclude from the analysis or a function that returns such a list given the class :return: A generator of (method_name, accessed_attributes) pairs - """ for func in _get_class_attributes(cls, filt=filt, exclude_names=exclude_names): with skip_signature_errors: @@ -686,50 +703,48 @@ def attrs_used_by_method(method, *, src_code=None): Args: method: The method (object) to analyze + src_code: The source code in which the method's class is defined, when + ``inspect`` cannot retrieve it (for example in a notebook). Returns: A list of attribute names (of the class or instance thereof) that are accessed in the code of the said method. - Example: - - Consider the method `A.target_method` coming from the following code in - `i2.tests.footprints_test`: - ```python - def func(obj): - \"\"\"Accesses attributes 'a' and 'b' of obj\"\"\" - return obj.a + obj.b - - class A: - e = 2 - - def __init__(self, a=1, b=0, c=1, d=10): - self.a = a - self.b = b - self.c = c - self.d = d - - def target_method(self, x): - \"\"\"Accesses ['a', 'b', 'c', 'e']\"\"\" - t = func(self) # and this function will access some attributes! - tt = self.other_method(t) - return x * tt / self.e - - def other_method(self, x=1): - \"\"\"Accesses ['c', 'e']\"\"\" - w = self.c * 2 # c is accessed first - return self.e + self.c * x - w # and c is accessed again - - @classmethod - def a_class_method(cls, y): - \"\"\"Accesses ['e']\"\"\" - return cls.e + y - ``` - + Consider the method ``A.target_method`` coming from the following code in + ``i2.tests.footprints_test``:: + + def func(obj): + \"\"\"Accesses attributes 'a' and 'b' of obj\"\"\" + return obj.a + obj.b + + class A: + e = 2 + + def __init__(self, a=1, b=0, c=1, d=10): + self.a = a + self.b = b + self.c = c + self.d = d + + def target_method(self, x): + \"\"\"Accesses ['a', 'b', 'c', 'e']\"\"\" + t = func(self) # and this function will access some attributes! + tt = self.other_method(t) + return x * tt / self.e + + def other_method(self, x=1): + \"\"\"Accesses ['c', 'e']\"\"\" + w = self.c * 2 # c is accessed first + return self.e + self.c * x - w # and c is accessed again + + @classmethod + def a_class_method(cls, y): + \"\"\"Accesses ['e']\"\"\" + return cls.e + y + """ # TODO: Stopped working in 3.12 (worked in 3.10). See why # >>> from i2.tests.footprints_test import A # >>> assert attrs_used_by_method(A.target_method) == {'a', 'b', 'c', 'e'} - - """ + # return _attrs_used_by_method( *cls_and_method_name_of_method(method), src_code=src_code ) @@ -751,6 +766,22 @@ def get_source(obj: object) -> str: # TODO: Break into two functions (one doing the work of the loop for a single method) # TODO: Routing pattern. Extract conditional logic to make it parametrizable def object_dependencies(obj, *, get_source=get_source): + """Map each method of a class (or of an instance's class) to the attributes it reads. + + Attributes accessed through the method's first argument (usually ``self``) count; + attributes that are only assigned to do not. Members for which ``get_source`` raises + ``TypeError`` (builtins, descriptors) are skipped. + + >>> class C: + ... def __init__(self): + ... self.a = 1 + ... def m(self): + ... return self.a + self.helper() + ... def helper(self): + ... return 2 + >>> object_dependencies(C)["m"] == {"a", "helper"} + True + """ import ast import inspect import textwrap @@ -837,10 +868,10 @@ def dict_to_graph( suffix: str = "", display: bool | Callable = False, ) -> str: - """A function to convert a dictionary to a graphviz string. + """Convert a ``{node: neighbours}`` dictionary to a graphviz (or mermaid) graph string. - You privide a graph in the form of a ``{from_node: to_nodes, ...`` or - ``{to_node: from_nodes, ...``` dictionary, and will get a graphviz string. + You provide a graph in the form of a ``{from_node: to_nodes, ...}`` or + ``{to_node: from_nodes, ...}`` dictionary, and will get a graphviz string. You can use this to visualize a graph (e.g. a dependency graph) in a jupyter notebook. :param graph: The graph, in the form of a to convert to graphviz. @@ -893,7 +924,6 @@ def dict_to_graph( C --> D; C --> E; C --> F; - """ # TODO: Could make these specs open-closed (routing pattern) if kind == "graphviz": diff --git a/i2/io_trans.py b/i2/io_trans.py index 03c70206..a4c643d2 100644 --- a/i2/io_trans.py +++ b/i2/io_trans.py @@ -5,7 +5,6 @@ wrapped function itself. Output value tranformers can be conditioned on argument value and the wrapped function. - """ from dataclasses import dataclass @@ -50,6 +49,7 @@ def identity_func(x): + """Return the input unchanged.""" return x @@ -223,6 +223,7 @@ def out_trans(self, argval, func): def pickle_out_trans(self, argval, func): + """Output transformer that pickles the value (``pickle.dumps``).""" return dumps(argval) diff --git a/i2/itypes.py b/i2/itypes.py index c6b38066..831b2d83 100644 --- a/i2/itypes.py +++ b/i2/itypes.py @@ -29,7 +29,6 @@ def _arg_name_and_val_dict(func, *args, **kwargs): >>> def foo(x, /, y, *, z=3): ... >>> _arg_name_and_val_dict(foo, 1, 2, z=4) {'x': 1, 'y': 2, 'z': 4} - """ b = signature(func).bind(*args, **kwargs) b.apply_defaults() @@ -89,7 +88,6 @@ def validate_literal(func): Traceback (most recent call last): ... ValueError: 4 is an invalid value for x. Values should be one of the following: (1, 2, 3) - """ @wraps(func) @@ -110,7 +108,6 @@ def iterable_to_literal(iterable: Iterable): >>> iterable_to_literal([1, 2, 3]) typing.Literal[1, 2, 3] - """ return Literal.__getitem__(tuple(iterable)) @@ -132,8 +129,11 @@ def new_type( doc: Optional string to put in __doc__ attribute aka: Optional set (or any iterable) to put in _aka attribute, meant to list names the variables of this type often appear as. + assign_to_globals: If True, also bind the new type to ``name`` in the + globals of the ``i2.itypes`` module (not the caller's). - Returns: None + Returns: + The new type. >>> from typing import Any, Union, List >>> MyType = new_type('MyType', int) @@ -169,7 +169,7 @@ class HasAttrs: >>> assert isinstance([1, 2, 3], SizedAndAppendable) # lists have both a length and an append >>> assert not isinstance((1, 2, 3), SizedAndAppendable) # tuples don't have an append - [Python Protocols](https://www.python.org/dev/peps/pep-0544/) are a way to be able to do + `Python Protocols `_ are a way to be able to do "behavior typing" (my bad terminology). Basically, if you want your static analyzer (the swingles in your IDE, or linter validation process...) @@ -195,7 +195,6 @@ class HasAttrs: >>> assert isinstance(a, HasAttrs["method"]) >>> assert isinstance(a, HasAttrs["method", "prop"]) >>> assert not isinstance(a, HasAttrs["method", "prop", "this_attr_does_not_exist"]) - """ def __class_getitem__(self, attr_names): @@ -226,6 +225,7 @@ class HasAttrs(Protocol): # TODO: Complete scary hack: Find another way (see uses) def is_a_new_type(typ): + """Whether ``typ`` is a ``typing.NewType`` (checked through its ``__qualname__`` and ``__supertype__``).""" return ( callable(typ) and getattr(typ, "__qualname__", "").startswith("NewType") @@ -234,6 +234,7 @@ def is_a_new_type(typ): def typ_name(typ): + """The name of a typing generic (its ``_name``) or of a NewType (its ``__name__``).""" if is_a_new_type(typ): return typ.__name__ else: @@ -257,7 +258,7 @@ def is_callable_kind(typ): def input_and_output_types(typ: type): - """Returns the input and output types + """The ``(input_types, output_type)`` pair of a parametrized ``typing.Callable``. >>> from typing import Callable, Tuple >>> input_types, output_type = input_and_output_types(Callable[[float, int], str]) @@ -278,7 +279,6 @@ def input_and_output_types(typ: type): Traceback (most recent call last): ... AssertionError: Can only be used on a Callable[[...],...] kind: typing.Callable - """ if is_a_new_type(typ): return input_and_output_types(typ.__supertype__) @@ -289,6 +289,12 @@ def input_and_output_types(typ: type): def dot_string_of_callable_typ(typ): + """A ``inputs -> Callable -> output`` string, with typing-generic names, for a parametrized Callable. + + >>> from typing import Callable, List, Dict + >>> dot_string_of_callable_typ(Callable[[List, Dict], List]) + 'List,Dict -> Callable -> List' + """ input_types, output_type = input_and_output_types(typ) return ( ",".join(map(typ_name, input_types)) @@ -298,6 +304,7 @@ def dot_string_of_callable_typ(typ): def dot_strings_of_callable_types(*typs, func_shape="box"): + """Yield, for each parametrized Callable, its ``dot_string_of_callable_typ`` line and a node-shape line.""" for typ in typs: yield dot_string_of_callable_typ(typ) yield f'{typ_name(typ)} [shape="{func_shape}"]' @@ -365,7 +372,6 @@ class ObjectClassifier: >>> classifier.matching_kind(obj, assert_unique=False) 'str' - """ def __init__(self, verifiers: dict[KT, Callable[[ObjectType], bool]]): diff --git a/i2/key_path.py b/i2/key_path.py index 6dbaabe6..2422f7f1 100644 --- a/i2/key_path.py +++ b/i2/key_path.py @@ -38,7 +38,6 @@ def obj_to_str_path( Note, thought, that ``str_path_to_obj`` might not work as an inverse for custom ``name_of_obj`` and ``path_of_module`` functions. You may have to write your own inverse function in this case. - """ return sep.join((path_of_module(obj), name_of_obj(obj))) @@ -73,6 +72,7 @@ def str_path_to_obj(str_path: str, *, sep="."): def trans_generator_output(trans): + """Make a decorator that applies ``trans`` to every item a generator function yields.""" def decorator(func): @wraps(func) def wrapped(*args, **kwargs): @@ -84,7 +84,7 @@ def wrapped(*args, **kwargs): class NoDefault: - pass + """Type of the ``NO_DFLT`` sentinel (no default value given).""" NO_DFLT = NoDefault() @@ -219,6 +219,7 @@ def __init__( ): """ Initialize a KeyPathMap. + :param store: Your mapping, or the type of your mapping. :param key_type: The type of the keys :param node_type: The node type (typically the same as the store type @@ -499,6 +500,7 @@ def items(self, d, key_path_prefix=None): store types. That is, if a value is a store_type, it won't generate a yield, but rather, will be iterated through recursively. + :param d: input store :param key_path_so_far: string to be prepended to all key paths (for use in recursion, not meant for direct use) :return: a (key_path, val) iterator @@ -544,6 +546,7 @@ def keys(self, d): def getitem(self, d, key_path, default_val=None): """ getting with a key list or "."-separated string + :param d: dict-like :param key_path: list or "."-separated string of keys :return: @@ -558,6 +561,7 @@ def getitem(self, d, key_path, default_val=None): def setitem(self, d, key_path, val): """ setting with a key list or "."-separated string + :param d: dict :param key_path: list or "."-separated string of keys :param val: value to assign @@ -621,6 +625,7 @@ def extract_key_paths( ): """ getting with a key list or "."-separated string + :param d: dict-like :param key_path: list or "."-separated string of keys :param field_naming: 'full' (default) will use key_path strings as is, leaf will only use the last dot item diff --git a/i2/multi_object.py b/i2/multi_object.py index f01cc135..5702cf20 100644 --- a/i2/multi_object.py +++ b/i2/multi_object.py @@ -7,19 +7,19 @@ - ``FuncFanout``: To apply multiple functions to the same inputs - ``FlexFuncFanout``: Like `FuncFanout` but where the application of inputs is flexible. + That is, the functions "draw" their inputs from the a common pool, but don't choke if there are extra unrecognized arguments. - ``ParallelFuncs``: To make a dict-to-dict function, applying a specific function for -each input key (putting the result in that key in the output. + each input key (putting the result in that key in the output. For context managers you have: - ``ContextFanout``: To hold multiple context managers as one (entering and exiting -together) - -![image](https://user-images.githubusercontent.com/1906276/138004878-bfe17115-c25f-4d22-9740-0fef983507c0.png) + together) +.. image:: https://user-images.githubusercontent.com/1906276/138004878-bfe17115-c25f-4d22-9740-0fef983507c0.png """ from typing import Union, Any, TypeVar @@ -98,7 +98,6 @@ def truncate_string_with_marker( '12---90' >>> truncate_string('supercalifragilisticexpialidocious') 'su---us' - """ middle_marker_len = len(middle_marker) if len(s) <= left_limit + right_limit: @@ -141,7 +140,7 @@ def uniquely_named_objects( :param exclude_names: Names that can't be used :param obj_to_name: Function that tries to get/make a name from an object :param name_for_position: A ``{position_idx: name,...}`` mapping that instructs - ``uniquely_named_objects`` to use a specific name for a given position. + ``uniquely_named_objects`` to use a specific name for a given position. >>> from functools import partial >>> objects = [map, [1], [1, 2], lambda x: x, partial(print, sep=",")] @@ -189,23 +188,24 @@ def uniquely_named_objects( See what ``uniquely_named_objects`` offers as parametrization: - You can provide an exclusion list (though the handing of a conflict is hardcoded - and questionable) + and questionable) - You can provide a ``obj_to_name`` function to control the naming of objects. + One trick to be aware of if objects have unique hashes: Make a ``d = {obj: name,...}`` mapping and specify ``obj_to_name=d.get``. - Any controllable way to decide on a name based on the position of the function - in the iterable (this could be useful!) + in the iterable (this could be useful!) What you DO NOT have: - Any way to choose names non-myopically: An object’s name cannot “see” the - objects around it to decide on a name (it can only see the names use by those behind - it through ``exclude_names``). + objects around it to decide on a name (it can only see the names use by those behind + it through ``exclude_names``). - Any “retries” or “alternative naming logic” if a chosen name conflicts with - ``exclude_names`` + ``exclude_names`` """ _exclude_names = set(exclude_names) for i, obj in enumerate(objects): @@ -383,6 +383,7 @@ def __setstate__(self, state): def iterable_of_callables_validation(funcs: Iterable[Callable]): + """Raise ``TypeError`` unless ``funcs`` is an iterable whose elements are all callable.""" if not isinstance(funcs, Iterable): raise TypeError(f"Not an iterable: {funcs}") elif not all(callable(xx) for xx in funcs): @@ -391,7 +392,18 @@ def iterable_of_callables_validation(funcs: Iterable[Callable]): class MultiFunc(MultiObj): - """A MultiObj, but specialized to contain callable objects only""" + """A ``MultiObj`` that only accepts callables; the base of ``Pipe``, ``FuncFanout`` and friends. + + >>> mf = MultiFunc(len, up=str.upper) + >>> list(mf) + ['len', 'up'] + >>> mf.up("a") + 'A' + >>> MultiFunc(len, 3) + Traceback (most recent call last): + ... + TypeError: These were not callable: [3] + """ def __init__(self, *unnamed_funcs, **named_funcs): # The basic MultiObj initialization @@ -441,6 +453,7 @@ class Pipe(MultiFunc): '10' Notes: + - Pipe instances don't have a __name__ etc. So some expectations of normal functions are not met. - Pipe instance are pickalable (as long as the functions that compose them are) @@ -607,7 +620,6 @@ def pipes_are_equal(p1, p2, *, func_equality=eq, verbose=False): ... Pipe(lambda x: x), Pipe(lambda x: x), func_equality=source_equality ... ) True - """ p1, p2 = map(flatten_pipe, (p1, p2)) if len(p1) != len(p2): @@ -692,7 +704,6 @@ class FuncFanout(MultiFunc): >>> f = Pipe(m, partial(map, itemgetter(1)), tuple) >>> f(10) (12, 20, 'I am groot') - """ def call_generator(self, *args, **kwargs): @@ -724,18 +735,23 @@ class FlexFuncFanout(MultiFunc): Therefore ``FlexFuncFanout`` holds a "normalized" form of the functions; namely one that handles such things as postion only and varargs. - # TODO: Make this work! - # Right now raises: TypeError: formula1() got some positional-only arguments passed as keyword arguments: 'w' - # >>> assert formula1(1, x=2, z=3) == mf1.normalized_funcs[formula1](**kwargs_for_func[formula1]) + Not yet working (to do; right now it raises ``TypeError: formula1() got some + positional-only arguments passed as keyword arguments: 'w'``):: + + # >>> assert formula1(1, x=2, z=3) == mf1.normalized_funcs[formula1](**kwargs_for_func[formula1]) + + Note: + In the following, it looks like ``FlexFuncFanout`` instances return dicts whose keys are strings. + This is not the case. - Note: In the following, it looks like ``FlexFuncFanout`` instances return dicts whose keys are strings. - This is not the case. The keys are functions: The same functions that were input. The reason for not using functions is that when printed, they include their hash, which invalidates the doctests. - # >>> def print_dict(d): # just a util for this doctest - # ... from pprint import pprint - # ... pprint({k.__name__: d[k] for k in sorted(d, key=lambda x: x.__name__)}) + :: + + # >>> def print_dict(d): # just a util for this doctest + # ... from pprint import pprint + # ... pprint({k.__name__: d[k] for k in sorted(d, key=lambda x: x.__name__)}) >>> mf1 = FlexFuncFanout(formula1, mult=mult, addition=add) >>> assert mf1.kwargs_for_func(w=1, x=2, z=3, a=4, b=5) == { @@ -758,7 +774,6 @@ class FlexFuncFanout(MultiFunc): ... 'mult': {'x': 2}, ... 'addition': {'a': 4, 'b': 5}, ... 'mysum': {'args': (7, 8), 'kwargs': {'a': 42}}} - """ # FIXME: TODO: This does indeed change the signature, but not the functionality (position only still raise errors!) @@ -808,9 +823,9 @@ class ParallelFuncs(MultiFunc): :param spec: A map between a name (str) and a function associated to that name :return: A function that takes a dict as an (multi-channel) input and a dict as a - (multi-channel) output + (multi-channel) output - Q: Why can I specify the specs both with named_funcs_dict and **named_funcs? + Q: Why can I specify the specs both with ``named_funcs_dict`` and ``**named_funcs``? A: Look at the ``dict(...)`` interface. You see the same thing there. Different reason though (here we assert that the keys don't overlap). Usually named_funcs is more convenient, but if you need to use keys that are not @@ -850,7 +865,6 @@ class ParallelFuncs(MultiFunc): {'a': '3', 'b': [12, 12, 12]} #{'a': '(1, 2)', 'b': [(3, 4), (3, 4), (3, 4)]} - """ def _key_output_gen(self, d: dict): @@ -889,15 +903,17 @@ class ContextFanout(MultiObj): In python 3.10+ you can bundle contexts together by specifying a tuple of context managers, as such: - ```python - with (open('file.txt'), another_context_manager): - ... - ``` + .. code-block:: python + + with (open('file.txt'), another_context_manager): + ... + But + - Python will complain if one of the members of the tuple is not a context manager. - A tuple of context managers is not a context manager itself, it's just understood - by the with (in python 3.10+). + by the with (in python 3.10+). As an example, let's take two objects. One is a context manager, the other not. @@ -930,11 +946,15 @@ class ContextFanout(MultiObj): ... pass open close - + """ + # Not yet working (kept as notes, not doctests): # Further, know that within the context's scope, a `ContextFanout` + # # instance will have the context managers it contains available, and having the + # # value it is supposed to have "under context". # + # # >>> with ContextFanout(not_a_context_manager, some_context_manager(2)) as (f, m): # ... print(f(10)) # @@ -950,23 +970,30 @@ class ContextFanout(MultiObj): # >>> with c: # ... # inside the context, c indeed has the attribute, and it has the expected value # ... assert c.some_context_manager == 'x + 1 = 3' + # # open + # # close + # # >>> # outside the context, c doesn't have the some_context_manager attribute any more again # >>> assert not hasattr(c, 'some_context_manager') # + # # If you don't specify a name for a given context manager, you'll still have access + # # to it via a hidden attribute ("_i" where i is the index of the object when + # # the `ContextFanout` instance was made. # + # # >>> c = ContextFanout(some_context_manager(10), not_a_context_manager) # >>> with c: # ... assert c._0 == 'x + 1 = 11' + # # open + # # close - """ - def __enter__(self): for name, obj in self.items(): if hasattr(obj, "__enter__"): diff --git a/i2/routing_forest.py b/i2/routing_forest.py index 16736769..a08444c1 100644 --- a/i2/routing_forest.py +++ b/i2/routing_forest.py @@ -147,7 +147,6 @@ >>> assert list(rf(8)) == ['default_mod_5', 'even'] >>> assert list(rf(10)) == ['zero_mod_5', 'even'] >>> - """ from itertools import chain @@ -164,6 +163,7 @@ def return_sentinel(obj: Any, sentinel: Any = None): def identity(obj: Any): + """Return the input unchanged (the default leaf function).""" return obj @@ -264,7 +264,6 @@ class KeyFuncMapping(DelegateToMappingAttrMixin, MutableMapping): 'url' >>> nested('jazz.wav') 'audio' - """ mapping: MutableMapping @@ -479,7 +478,6 @@ class FeatCondNode(RoutingNode): >>> assert list(fcn(4)) == ['four_mod_5'] >>> assert list(fcn(5)) == ['zero_mod_5'] >>> assert list(fcn(6)) == ['one_mod_5'] - """ feat: Featurizer @@ -504,8 +502,6 @@ def from_feature_val_map(cls, feat, feat_cond_thens: FeatCondThenMap): # >>> assert list(fvn(1)) == ['one_mod_3'] # >>> assert list(fvn(2)) == ['two_mod_3'] # - - """ feat_cond_map = dict(feat_cond_thens) feat_cond_thens = tuple( @@ -580,8 +576,8 @@ class to a situation where the cond function of feat_cond_thens is equality, :param switch: A function returning the feature of an object we want to switch on :param cases: The mapping from feature to RoutingNode that should be yield for that - feature. It is often a dict, but only requirement is that it implements the - ``cases.get(val, default)`` method. + feature. It is often a dict, but only requirement is that it implements the + ``cases.get(val, default)`` method. :param default: Default RoutingNode to yield if no >>> rf = RoutingForest([ @@ -618,6 +614,7 @@ def __iter__(self): def wrap_leafs_with_final_node(x): + """Yield the items of ``x``, wrapping those that are not ``RoutingNode`` in ``FinalNode``.""" for xx in x: if isinstance(xx, RoutingNode): yield xx @@ -626,6 +623,7 @@ def wrap_leafs_with_final_node(x): def test_routing_forest(): + """Exercise the routing nodes end to end (kept here as a runnable example).""" def could_be_int(obj): if isinstance(obj, int): b = True diff --git a/i2/signatures.py b/i2/signatures.py index 6e566578..3df5101d 100644 --- a/i2/signatures.py +++ b/i2/signatures.py @@ -8,15 +8,14 @@ - merge two or more signatures - - - give a function a specific signature (with a choice of validations) - get an equivalent function with a different order of arguments - get an equivalent function with a subset of arguments (like partial) - - get an equivalent function but with variadic *args and/or **kwargs replaced with - non-variadic args (tuple) and kwargs (dict) + - get an equivalent function but with variadic ``*args`` and/or ``**kwargs`` replaced with + non-variadic args (tuple) and kwargs (dict) - make an f(a) function in to a f(a, b=None) function with b ignored @@ -79,7 +78,6 @@ - PO = Parameter.POSITIONAL_ONLY - KO = Parameter.KEYWORD_ONLY - """ from inspect import Signature, Parameter, signature, unwrap @@ -113,6 +111,7 @@ def deprecation_of(func, old_name): + """Wrap ``func`` so that calling it warns that ``old_name`` is deprecated in favour of ``func``.""" @wraps(func) def wrapper(*args, **kwargs): from warnings import warn @@ -214,7 +213,6 @@ def validate_signature(func: Callable) -> Callable: ... i2.signatures.InvalidSignature: Invalid signature for function : non-default argument follows default a rgument - """ try: Sig(func) # to get errors if the signature is not valid @@ -311,7 +309,6 @@ def name_of_obj( >>> alt = partial(name_of_obj, base_name_of_obj=attrgetter('__qualname__')) >>> alt(Signature.replace) 'Signature.replace' - """ try: return base_name_of_obj(o) @@ -335,6 +332,12 @@ def name_of_obj( def ensure_callable(obj: SignatureAble): + """Return ``obj`` if callable, else an empty function carrying the signature ``obj`` specifies. + + >>> f = ensure_callable(["a", ("b", 2)]) + >>> Sig(f) + + """ if isinstance(obj, Callable): return obj else: @@ -350,6 +353,18 @@ def f(*args, **kwargs): def ensure_signature(obj: SignatureAble) -> Signature: + """Make an ``inspect.Signature`` from a signature, callable, parameter, iterable of + parameter specs, or ``None`` (empty signature). Unlike ``Sig``, a signature string is + not accepted. + + >>> ensure_signature(["a", "b"]) + + >>> ensure_signature(None) + + + :raises TypeError: If ``obj`` is none of the above (a signature string raises + ``ValueError`` instead, from ``ensure_param``). + """ if isinstance(obj, Signature): return obj elif isinstance(obj, Callable): @@ -374,6 +389,14 @@ def ensure_signature(obj: SignatureAble) -> Signature: def ensure_param(p): + """Make a ``Param`` from a parameter, a name, a ``(name, default[, annotation])`` tuple, + or a dict of ``Param`` keyword arguments. + + >>> ensure_param("x"), ensure_param(("x", 1, int)), ensure_param({"name": "y", "default": 2}) + (, , ) + + :raises TypeError: If ``p`` is none of the above. + """ if isinstance(p, Parameter): return p elif isinstance(p, dict): @@ -436,6 +459,7 @@ def _add_optional_keywords(sig, kwarg_and_defaults, kwarg_annotations=None): '(x, y, *, z=3, verbose: bool = False)' Note: + - Annotations for the additional keywords are optional. - All additional keywords are added as keyword-only arguments. """ @@ -498,7 +522,6 @@ def ensure_params(obj: ParamsAble = None): >>> ensure_params() # equivalent to ensure_params(None) [] - """ # obj = inspect.unwrap(obj, stop=(lambda f: hasattr(f, "__signature__"))) @@ -578,8 +601,9 @@ def extract_arguments( dirty details. Returns an (param_args, param_kwargs, remaining_kwargs) tuple where + - param_args are the values of kwargs that are PO (POSITION_ONLY) as defined by - params, + params, - param_kwargs are those names that are both in params and not in param_args, and - remaining_kwargs are the remaining. @@ -588,16 +612,19 @@ def extract_arguments( but you have a kwargs dict of arguments in your hand. You can't just to `func( **kwargs)`. But you can (now) do - ``` - args, kwargs, remaining = extract_arguments(kwargs, func) # extract from kwargs - what you need for func - # ... check if remaing is empty (or not, depending on your paranoia), and then - call the func: - func(*args, **kwargs) - ``` + + .. code-block:: text + + args, kwargs, remaining = extract_arguments(kwargs, func) # extract from kwargs + what you need for func + # ... check if remaing is empty (or not, depending on your paranoia), and then + call the func: + func(*args, **kwargs) + (And if you doing that a lot: Do put it in a decorator!) - See Also: extract_arguments.without_remainding + See Also: + extract_arguments.without_remainding The most frequent case you'll encounter is when there's no POSITION_ONLY args, your param_args will be empty @@ -642,9 +669,11 @@ def extract_arguments( This is because we don't want to assume that all the kwargs can actually be included in a call to the function behind the params. Instead, the user can chose whether to include the remainder by doing a: - ``` - param_kwargs.update(remaining_kwargs) - ``` + + .. code-block:: text + + param_kwargs.update(remaining_kwargs) + et voilà. That said, we do understand that it may be a common pattern, so we'll do that @@ -667,11 +696,13 @@ def extract_arguments( If you're expecting no remainder you might want to just get the args and kwargs ( not this third - expected-to-be-empty remainder). You have two ways to do that, specifying: + expected-to-be-empty remainder). You have two ways to do that, specifying:: + `what_to_do_with_remainding='ignore'`, which will just return the (args, kwargs) pair `what_to_do_with_remainding='assert_empty'`, which will do the same, but first assert the remainder is empty + We suggest to use `functools.partial` to configure the `argument_argument` you need. >>> from functools import partial @@ -731,8 +762,10 @@ def extract_arguments( 'ignore': function will return `param_args`, `param_kwargs` 'assert_empty': function will assert that `remaining_kwargs` is empty and then return `param_args`, `param_kwargs` - :param include_all_when_var_keywords_in_params=False, - :param assert_no_missing_position_only_args=False, + :param include_all_when_var_keywords_in_params: If True, and the params have a + VAR_KEYWORD kind, include all kwargs in ``param_kwargs`` (nothing remains). + :param assert_no_missing_position_only_args: If True, raise an ``AssertionError`` + when a POSITION_ONLY param has no matching kwarg. :param kwargs: The kwargs to extract the args from :return: A (param_args, param_kwargs, remaining_kwargs) tuple. """ @@ -797,6 +830,7 @@ def extract_arguments( def function_caller(func, args, kwargs): + """Call ``func(*args, **kwargs)``; the default "caller" of the wrapping tools.""" return func(*args, **kwargs) @@ -859,10 +893,12 @@ def __lt__(self, other) -> bool: def param_has_default_or_is_var_kind(p: Parameter): + """Whether the parameter is optional in a call: it has a default or is variadic.""" return p.default is not p.empty or p.kind in var_param_kinds def parameter_to_dict(p: Parameter) -> dict: + """The ``name``, ``kind``, ``default`` and ``annotation`` of a parameter, as a dict.""" return dict(name=p.name, kind=p.kind, default=p.default, annotation=p.annotation) @@ -886,10 +922,12 @@ def _names_of_kind(sig): def maybe_first(items): + """The first item of ``items``, or ``None`` if there is none.""" return next(iter(items), None) def name_of_var_kw_argument(sig): + """The name of the VAR_KEYWORD parameter of ``sig``, or ``None`` if it has none.""" var_kw_list = [param.name for param in sig.params if param.kind == VK] result = maybe_first(var_kw_list) return result @@ -906,6 +944,7 @@ def _map_action_on_cond(kvs, cond, expand): def expand_nested_key(d, k): + """Items of ``d``, except that a lone ``{k: {k: ...}}`` nesting is unwrapped first.""" for key in d: if key == k and isinstance(d[k], dict) and k in d[k]: pass @@ -923,6 +962,7 @@ def expand_nested_key(d, k): def flatten_if_var_kw(kvs, var_kw_name): + """Yield ``(key, value)`` pairs, replacing a ``(var_kw_name, {var_kw_name: d})`` pair by the items of ``d``.""" cond = lambda k: k == var_kw_name expand = lambda k: k.items() # expand = lambda k: k.values() @@ -935,14 +975,15 @@ def flatten_if_var_kw(kvs, var_kw_name): class Sig(Signature, Mapping): """A subclass of inspect.Signature that has a lot of extra api sugar, such as + - making a signature for a variety of input types (callable, - iterable of callables, parameter lists, strings, etc.) + iterable of callables, parameter lists, strings, etc.) - has a dict-like interface - signature merging (with operator interfaces) - quick access to signature data - positional/keyword argument mapping. - # Positional/Keyword argument mapping + **Positional/Keyword argument mapping** In python, arguments can be positional (args) or keyword (kwargs). ... sometimes both, sometimes a single one is imposed. @@ -958,12 +999,13 @@ class Sig(Signature, Mapping): Two of the base methods for dealing with positional (args) and keyword (kwargs) inputs are: + - `map_arguments`: Map some args/kwargs input to a keyword-only - expression of the inputs. This is useful if you need to do some processing - based on the argument names. + expression of the inputs. This is useful if you need to do some processing + based on the argument names. - `mk_args_and_kwargs`: Translate a fully keyword expression of some - inputs into an (args, kwargs) pair that can be used to call the function. - (Remember, your function can have constraints, so you may need to do this. + inputs into an (args, kwargs) pair that can be used to call the function. + (Remember, your function can have constraints, so you may need to do this. The usual pattern of use of these methods is to use `map_arguments` to map all the inputs to their corresponding name, do what needs to be done with @@ -974,7 +1016,7 @@ class Sig(Signature, Mapping): `call_forgivingly`, `tuple_the_args`, `map_arguments_from_variadics`, `extract_args_and_kwargs`, `source_arguments`, and `source_args_and_kwargs`. - # Making a signature + **Making a signature** You can construct a `Sig` object from a callable, @@ -1063,7 +1105,6 @@ class Sig(Signature, Mapping): ... ... >>> inspect.signature(some_func) - """ # Adding parameter kinds as class attributes for usage convenience @@ -1082,16 +1123,19 @@ def __init__( __validate_parameters__=True, ): """Initialize a Sig instance. - See Also: `ensure_params` to see what kind of objects you can make `Sig`s with. + + See Also: + `ensure_params` to see what kind of objects you can make `Sig`s with. :param obj: A ParamsAble object, which could be: + - a callable, - and iterable of Parameter instances - an iterable of strings (representing annotation-less, default-less) - argument names, + argument names, - tuples: (argname, default) or (argname, default, annotation), - dicts: ``{'name': REQUIRED,...}`` with optional `kind`, `default` and - `annotation` fields + `annotation` fields - None (which will produce an argument-less Signature) >>> Sig(["a", "b", "c"]) @@ -1185,17 +1229,19 @@ def wrap( signature (not a callable). Also, where as both write to the input func's `__signature__` attribute, here we also write to + - `__defaults__` and `__kwdefaults__`, extracting these from `__signature__` - (functools.wraps doesn't do that at the time of writing this - (see https://github.com/python/cpython/pull/21379)). + (functools.wraps doesn't do that at the time of writing this + (see https://github.com/python/cpython/pull/21379)). - `__annotations__` (also extracted from `__signature__`) - does not write to `__module__`, `__name__`, `__qualname__`, `__doc__` - (because again, we're basinig the injecton on a signature, not a function, - so we have no name, doc, etc...) + (because again, we're basinig the injecton on a signature, not a function, + so we have no name, doc, etc...) - WARNING: The fact that you've modified the signature of your function doesn't - mean that the decorated function will work as expected (or even work at all). - See below for examples. + WARNING: + The fact that you've modified the signature of your function doesn't + mean that the decorated function will work as expected (or even work at all). + See below for examples. >>> def f(w, /, x: float = 1, y=2, z: int = 3): ... return w + x * y ** z @@ -1206,6 +1252,7 @@ def wrap( >>> assert 8 == f(0) == f(0, 1) == f(0, 1, 2) == f(0, 1, 2, 3) Now let's create a very similar function to f, but where: + - w is not position-only - x annot is int instead of float, and doesn't have a default - z's default changes to 10 @@ -1254,8 +1301,6 @@ def wrap( Traceback (most recent call last): ... TypeError: f() takes from 0 to 3 positional arguments but 4 were given - - TODO: Give more explanations why this is. """ # TODO: Should we make copy_function=False the default, @@ -1325,7 +1370,6 @@ def sig_or_default(cls, obj, default_signature=DFLT_SIGNATURE): >>> str(Sig.sig_or_default(time.time, Sig(lambda: ...))) '()' - """ try: # (try to) return cls(obj) if obj is callable: @@ -1365,7 +1409,6 @@ def sig_or_none(cls, obj): >>> robust_has_signature(print) True - """ return cls.sig_or_default(obj, default_signature=None) @@ -1425,16 +1468,17 @@ def to_signature_kwargs(self): 'return_annotation': } Note that this does NOT return: - ``` - {'parameters': self.parameters, - 'return_annotation': self.return_annotation} - ``` + + .. code-block:: text + + {'parameters': self.parameters, + 'return_annotation': self.return_annotation} + which would not actually work as keyword arguments of ``Signature``. Yeah, I know. Don't ask me, ask the authors of `Signature`! Instead, `parammeters` will be ``list(self.parameters.values())``, which does work. - """ return { "parameters": list(self.parameters.values()), @@ -1449,7 +1493,6 @@ def to_simple_signature(self): ... ... >>> Sig(f).to_simple_signature() - """ return Signature(**self.to_signature_kwargs()) @@ -1491,6 +1534,11 @@ def from_objs( return_annotation=empty, **name_and_dflts, ): + """Merge signatures of several objects into one; ``name=default`` kwargs add PK params. + + >>> Sig.from_objs(lambda a: None, ["b"], c=2) + + """ objs = list(objs) for name, default in name_and_dflts.items(): objs.append([{"name": name, "kind": PK, "default": default}]) @@ -1508,6 +1556,7 @@ def from_objs( @classmethod def from_params(cls, params): + """Make a ``Sig`` from a ``Parameter`` or an iterable of parameter specs.""" if isinstance(params, Parameter): params = (params,) return cls(params) @@ -1528,10 +1577,12 @@ def params(self): @property def names(self): + """The parameter names, in signature order.""" return list(self.keys()) @property def kinds(self): + """A ``{name: kind}`` dict of the parameters.""" return {p.name: p.kind for p in self.values()} @property @@ -1567,6 +1618,12 @@ def annotations(self): } def detail_names_by_kind(self): + """Names grouped by kind: ``(po_names, pk_names, vp_name, ko_names, vk_name)``, the + variadic ones as a single name or ``None``. + + >>> Sig(lambda a, /, b, *args, c=1, **kw: None).detail_names_by_kind() + (('a',), ('b',), 'args', ('c',), 'kw') + """ return ( self.names_of_kind[PO], self.names_of_kind[PK], @@ -1609,7 +1666,6 @@ def get_names(self, spec, *, conserve_sig_order=True, allow_excess=False): >>> sig.get_names(['a', 'c', 'e', 'g', 'h'], allow_excess=True) ('a', 'c', 'e') - """ if isinstance(spec, str): spec = spec.split() @@ -1688,7 +1744,8 @@ def names_for_kind(self, kind): @property def has_var_kinds(self): - """ + """Whether the signature has a VAR_POSITIONAL or VAR_KEYWORD parameter. + >>> Sig(lambda x, *, y: None).has_var_kinds False >>> Sig(lambda x, *y: None).has_var_kinds @@ -1712,6 +1769,7 @@ def index_of_var_positional(self): @property def var_positional_name(self): + """The name of the VAR_POSITIONAL parameter, or ``None`` if there is none.""" idx = self.index_of_var_positional if idx is not None: return self.names[idx] @@ -1737,7 +1795,6 @@ def index_of_var_keyword(self): And if there's none... >>> assert Sig(lambda a, *args, b=1: 0).index_of_var_keyword is None - """ last_arg_idx = len(self) - 1 if last_arg_idx != -1: @@ -1747,6 +1804,7 @@ def index_of_var_keyword(self): @property def var_keyword_name(self): + """The name of the VAR_KEYWORD parameter, or ``None`` if there is none.""" idx = self.index_of_var_keyword if idx is not None: return self.names[idx] @@ -1785,11 +1843,13 @@ def required_names(self): def n_required(self): """The number of required arguments. A required argument is one that doesn't have a default, nor is VAR_POSITIONAL - (*args) or VAR_KEYWORD (**kwargs). - Note: Sometimes a minimum number of arguments in VAR_POSITIONAL and - VAR_KEYWORD are in fact required, - but we can't see this from the signature, so we can't tell you about that! You - do the math. + (``*args``) or VAR_KEYWORD (``**kwargs``). + + Note: + Sometimes a minimum number of arguments in VAR_POSITIONAL and + VAR_KEYWORD are in fact required, + but we can't see this from the signature, so we can't tell you about that! You + do the math. >>> f = lambda a00, /, a11, a12, *a23, a34, a35=1, a36='two', **a47: None >>> Sig(f).n_required @@ -1799,10 +1859,12 @@ def n_required(self): @property def positional_names(self): + """Names of the parameters that can be given positionally (PO and PK kinds).""" return self.names_of_kind[PO] + self.names_of_kind[PK] @property def keyword_names(self): + """Names of the parameters that can be given by keyword (PK and KO kinds).""" return self.names_of_kind[PK] + self.names_of_kind[KO] def _transform_params(self, changes_for_name: dict): @@ -1819,8 +1881,9 @@ def _transform_params(self, changes_for_name: dict): def modified(self, /, _allow_reordering=False, **changes_for_name): """Returns a modified (new) signature object. - Note: This function doesn't modify the signature, but creates a modified copy - of the signature. + Note: + This function doesn't modify the signature, but creates a modified copy + of the signature. IMPORTANT WARNING: This is an advanced feature. Avoid wrapping a function with a modified signature, as this may not have the intended effect. @@ -1832,6 +1895,7 @@ def modified(self, /, _allow_reordering=False, **changes_for_name): >>> assert sig.kinds['pka'] == PK Let's make a signature that is the same as sig, except that + - `poa` is given a PO (POSITIONAL_ONLY) kind insteadk of PK - `koa` is given a default of None - the signature is given a return_annotation of str @@ -1859,7 +1923,6 @@ def modified(self, /, _allow_reordering=False, **changes_for_name): On the other hand, if you decorate a function with a sig that adds or modifies defaults, these defaults will actually be used (unlike with `functools.wraps`). - """ new_return_annotation = changes_for_name.pop( "return_annotation", self.return_annotation @@ -1910,8 +1973,7 @@ def ch_param_attrs( >>> special_foo(5) # should be 5 + 2 * 3 == 11 11 - - + """ # TODO: Would like to make this work (reordering) # Now, if you want to set a default for a but not b and c for example, you'll # get complaints: @@ -1939,8 +2001,7 @@ def ch_param_attrs( # # >>> another_foo(2, 3) # should be 10 + (2 * 3) = # 16 - - """ + # if not param_attr in param_attributes: raise ValueError( @@ -1967,6 +2028,13 @@ def ch_param_attrs( # ch_annotations = partialmethod(ch_param_attrs, param_attr="annotation") def ch_names(self, /, **changes_for_name): + """Rename parameters, given as ``old_name=new_name`` pairs. + + >>> Sig(lambda a, b: None).ch_names(a="x") + + + :raises ValueError: If a name to change is not in the signature. + """ argnames_not_in_sig = changes_for_name.keys() - self.keys() if argnames_not_in_sig: raise ValueError( @@ -1975,19 +2043,23 @@ def ch_names(self, /, **changes_for_name): return self.ch_param_attrs("name", **changes_for_name) def ch_kinds(self, /, _allow_reordering=True, **changes_for_name): + """Change parameter kinds, given as ``name=kind`` pairs (see ``ch_param_attrs``).""" return self.ch_param_attrs( "kind", _allow_reordering=_allow_reordering, **changes_for_name ) def ch_kinds_to_position_or_keyword(self): + """Return the signature with all non-variadic kinds set to POSITIONAL_OR_KEYWORD.""" return all_pk_signature(self) def ch_defaults(self, /, _allow_reordering=True, **changes_for_name): + """Change parameter defaults, given as ``name=default`` pairs (see ``ch_param_attrs``).""" return self.ch_param_attrs( "default", _allow_reordering=_allow_reordering, **changes_for_name ) def ch_annotations(self, /, **changes_for_name): + """Change parameter annotations, given as ``name=annotation`` pairs (see ``ch_param_attrs``).""" return self.ch_param_attrs("annotation", **changes_for_name) def add_optional_keywords( @@ -2010,7 +2082,6 @@ def add_optional_keywords( >>> str(Sig(foo)) '(a, *, c: int = 2, d=3, b=1, **kwargs)' - """ # Resolve arguments ( to be able to use this method as a decorator) @@ -2049,7 +2120,7 @@ def merge_with_sig( :param sig: The signature to merge with. :param ch_to_all_pk: Whether to change all kinds of both signatures to PK ( - POSITIONAL_OR_KEYWORD) + POSITIONAL_OR_KEYWORD) :return: >>> def func(a=None, *, b=1, c=2): @@ -2081,7 +2152,6 @@ def merge_with_sig( >>> s.merge_with_sig([("d", 3), ("e", 4)], ch_to_all_pk=True) - """ if ch_to_all_pk: _self = Sig(all_pk_signature(self)) @@ -2188,17 +2258,18 @@ def __add__(self, sig: ParamsAble): before hand) Important Notes: + - The resulting Sig will loose it's return_annotation if it had one. - This is to avoid making too many assumptions about how the sig sum will be - used. - If a return_annotation is needed (say, for composition, the last - return_annotation - summed), one can subclass Sig and overwrite __add__ + This is to avoid making too many assumptions about how the sig sum will be + used. + If a return_annotation is needed (say, for composition, the last + return_annotation + summed), one can subclass Sig and overwrite __add__ - POSITION_ONLY and KEYWORD_ONLY kinds will be replaced by - POSITIONAL_OR_KEYWORD kind. - This is to simplify the interface and code. - If the user really wants to maintain those kinds, they can replace them back - after the fact. + POSITIONAL_OR_KEYWORD kind. + This is to simplify the interface and code. + If the user really wants to maintain those kinds, they can replace them back + after the fact. >>> def f(w, /, x: float = 1, y=1, *, z: int = 1): ... ... @@ -2321,6 +2392,11 @@ def __radd__(self, sig: ParamsAble): return sig.merge_with_sig(self) def remove_names(self, names): + """A new ``Sig`` without the given parameter names. + + >>> Sig(lambda a, /, b, *args, c=1, **kw: None).remove_names(["b", "c"]) + + """ names = {p.name for p in ensure_params(names)} new_params = { name: p for name, p in self.parameters.items() if name not in names @@ -2367,7 +2443,6 @@ def _chain_params_of_signatures(*sigs): ... ) ... ) '[, , , , ]' - """ already_merged_names = set() for s in sigs: @@ -2406,6 +2481,13 @@ def normalize_kind( argname_to_default=None, allow_reordering=False, ): + """A new ``Sig`` with every parameter set to ``kind`` (POSITIONAL_OR_KEYWORD by + default), except those whose kind is in ``except_kinds`` (the variadic kinds by + default). + + >>> Sig(lambda a, /, b, *, c: None).normalize_kind() + + """ except_kinds = except_kinds or set() if add_defaults_if_necessary: if argname_to_default is None: @@ -2468,16 +2550,17 @@ def map_arguments( will extract their needs from it. That's where `Sig.map_arguments_from_variadics(*args, **kwargs)` is needed. + :param args: The args the function will be called with. :param kwargs: The kwargs the function will be called with. :param apply_defaults: (bool) Whether to apply signature defaults to the - non-specified argument names + non-specified argument names :param allow_partial: (bool) True iff you want to allow partial signature - fulfillment. + fulfillment. :param allow_excess: (bool) Set to True iff you want to allow extra kwargs - items to be ignored. + items to be ignored. :param ignore_kind: (bool) Set to True iff you want to ignore the position and - keyword only kinds, + keyword only kinds, in order to be able to accept args and kwargs in such a way that there can be cross-over (args that are supposed to be keyword only, and kwargs that are supposed @@ -2619,19 +2702,20 @@ def mk_args_and_kwargs( ignore_kind=False, args_limit: int | None = 0, ) -> tuple[tuple, dict]: - """Extract args and kwargs such that func(*args, **kwargs) can be called, + """Extract args and kwargs such that ``func(*args, **kwargs)`` can be called, where func has instance's signature. :param arguments: The {param_name: arg_val,...} dict to process :param args_limit: How "far" in the params should args (positional arguments) be searched for. + - args_limit==0: Take the minimum number possible of args (positional - arguments). Only those that are position only or before a var-positional. + arguments). Only those that are position only or before a var-positional. - args_limit is None: Take the maximum number of args (positional arguments). - The only kwargs (keyword arguments) you should have are keyword-only - and var-keyword arguments. + The only kwargs (keyword arguments) you should have are keyword-only + and var-keyword arguments. - args_limit positive integer: Take the args_limit first argument names - (of signature) as args, and the rest as kwargs. + (of signature) as args, and the rest as kwargs. >>> def foo(w, /, x: float, y=1, *, z: int = 1): ... return ((w + x) * y) ** z @@ -2661,11 +2745,12 @@ def mk_args_and_kwargs( The `args_limit` begs explanation. Consider the signature of `def foo(w, /, x: float, y=1, *, z: int = 1): ...` for instance. We could call the function with the following (args, kwargs) pairs: + - ((1,), {'x': 2, 'y': 3, 'z': 4}) - ((1, 2), {'y': 3, 'z': 4}) - ((1, 2, 3), {'z': 4}) - The two other combinations (empty args or empty kwargs) are not valid - because of the / and * constraints. + The two other combinations (empty args or empty kwargs) are not valid + because of the / and * constraints. But when asked for an (args, kwargs) pair, which of the three valid options should be returned? This is what the `args_limit` argument controls. @@ -3064,8 +3149,6 @@ def source_arguments( ... 4, x=3, y=2, extra="keywords", are="ignored", _apply_defaults=True ... ) {'w': 4, 'x': 3, 'y': 2, 'z': 'ZZ'} - - """ return self.map_arguments( args, @@ -3195,7 +3278,6 @@ def inject_into_keyword_variadic(self): >>> Sig(sauce) - """ return replace_kwargs_using(self) @@ -3209,7 +3291,6 @@ def _fill_defaults_and_annotations(sig1: Sig, sig2: Sig): ... Sig('(a, /, b: str, *, c=3)'), Sig('(a: float, b: int = 2, c=300)') ... ) - """ def filled_properties_of_sig1(): @@ -3306,6 +3387,7 @@ def mk_sig_from_args(*args_without_default, **args_with_defaults): def _remove_variadics_from_sig(sig, ch_variadic_keyword_to_keyword=True): """Remove variadics from signature + >>> def foo(a, *args, bar, **kwargs): ... return f"{a=}, {args=}, {bar=}, {kwargs=}" >>> sig = Sig(foo) @@ -3331,8 +3413,8 @@ def _remove_variadics_from_sig(sig, ch_variadic_keyword_to_keyword=True): >>> assert sig == Sig(func) - If you only want the variadic positional to be handled, but leave leave any - VARIADIC_KEYWORD kinds (**kwargs) alone, you can do so by setting + If you only want the variadic positional to be handled, but leave any + VARIADIC_KEYWORD kinds (``**kwargs``) alone, you can do so by setting `ch_variadic_keyword_to_keyword=False`. >>> def foo(a, *args, bar=None, **kwargs): @@ -3371,10 +3453,11 @@ def call_forgivingly(func, *args, **kwargs): Call function on given args and kwargs, but only taking what the function needs (not choking if they're extras variables) - Tip: If you into trouble because your kwargs has a 'func' key, - (which would then clash with the ``func`` param of call_forgivingly), then - use `_call_forgivingly` instead, specifying args and kwargs as tuple and - dict. + Tip: + If you into trouble because your kwargs has a 'func' key, + (which would then clash with the ``func`` param of call_forgivingly), then + use `_call_forgivingly` instead, specifying args and kwargs as tuple and + dict. >>> def foo(a, b: int = 0, c=None) -> int: ... return "foo", (a, b, c) @@ -3392,20 +3475,19 @@ def call_forgivingly(func, *args, **kwargs): ... return x, args1, y, kwargs1 >>> call_forgivingly(bar, 1, 2, 3, y=4, z=5) (1, (2, 3), 4, {'z': 5}) - + """ + # Cases not yet handled (not doctests): # >>> def bar(x, y=1, **kwargs1): # ... return x, y, kwargs1 # >>> call_forgivingly(bar, 1, 2, 3, y=4, z=5) # (1, 4, {'z': 5}) - + # # >>> call_forgivingly(bar, 1, 2, 3, y=4, z=5) - + # # >>> def bar(x, *args1, y=1): # ... return x, args1, y # >>> call_forgivingly(bar, 1, 2, 3, y=4, z=5) # (1, (2, 3), {'z': 5}) - - """ return _call_forgivingly(func, args, kwargs) @@ -3501,7 +3583,6 @@ def call_somewhat_forgivingly( Traceback (most recent call last): ... TypeError: got an unexpected keyword argument 'this_argname' - """ enforce_sig = Sig(enforce_sig or func) # Validate that args and kwargs are compatible with enforce_sig @@ -3510,6 +3591,7 @@ def call_somewhat_forgivingly( def convert_to_PK(kinds): + """A ``{name: POSITIONAL_OR_KEYWORD}`` dict for every name in ``kinds`` (a ``kinds_modifier``).""" return {name: PK for name in kinds} @@ -3536,8 +3618,8 @@ def kind_forgiving_func(func, kinds_modifier=convert_to_PK): >>> isinstance_of_str(42) False - See also: ``i2.signatures.all_pk_signature`` - + See also: + ``i2.signatures.all_pk_signature`` """ sig = Sig(func) kinds_modif = kinds_modifier(sig.kinds) @@ -3568,10 +3650,12 @@ def use_interface(interface_sig): `g` should do, but doesn't use `a`, and doesn't even have it in it's arguments. The solution to this is to _adapt_ `f` to the `g` interface: - ``` - def my_g(a, b): - return f(a) - ``` + + .. code-block:: text + + def my_g(a, b): + return f(a) + and use `my_g`. >>> f = lambda a: a * 11 @@ -3639,7 +3723,6 @@ def has_signature(obj, robust=False): If robust is set to True, `has_signature` will use `Sig` to get the signature, so will return True in most cases. - """ if robust: return bool(Sig.sig_or_none(obj)) @@ -3673,7 +3756,7 @@ def all_pk_signature(callable_or_signature: Callable | Signature): >>> all_pk_signature(signature(foo)) - But note that the variadic arguments *args and **kwargs remain variadic: + But note that the variadic arguments ``*args`` and ``**kwargs`` remain variadic: >>> all_pk_signature(signature(bar)) @@ -3687,8 +3770,8 @@ def all_pk_signature(callable_or_signature: Callable | Signature): >>> sig.name 'bar' - See also: ``i2.signatures.kind_forgiving_func`` - + See also: + ``i2.signatures.kind_forgiving_func`` """ if isinstance(callable_or_signature, Signature): @@ -3720,6 +3803,11 @@ def changed_params(): def normalized_func(func): + """Wrap ``func`` so its call arguments are re-bound through ``func``'s own signature. + + Work in progress: the wrapper has a ``(*args, **kwargs)`` signature and the tests + that would let it relax argument kinds are marked ``xfail``. + """ sig = Sig(func) def argument_values_tuple(args, kwargs): @@ -3761,13 +3849,13 @@ def _func(*args, **kwargs): def ch_variadics_to_non_variadic_kind(func, *, ch_variadic_keyword_to_keyword=True): - """A decorator that will change a VAR_POSITIONAL (*args) argument to a tuple (args) - argument of the same name. + """Replace a function's variadic parameters with a tuple and a dict parameter of + the same names, returning an equivalent function. Essentially, given a `func(a, *b, c, **d)` function want to get a `new_func(a, b=(), c=None, d={})` that has the same functionality (in fact, calls the original `func` function behind the scenes), but without - where the variadic arguments *b and **d are replaced with a `b` expecting an + where the variadic arguments ``*b`` and ``**d`` are replaced with a `b` expecting an iterable (e.g. tuple/list) and `d` expecting a `dict` to contain the desired inputs. @@ -3818,8 +3906,8 @@ def ch_variadics_to_non_variadic_kind(func, *, ch_variadic_keyword_to_keyword=Tr >>> ch_variadics_to_non_variadic_kind(func) == func True - If you only want the variadic positional to be handled, but leave leave any - VARIADIC_KEYWORD kinds (**kwargs) alone, you can do so by setting + If you only want the variadic positional to be handled, but leave any + VARIADIC_KEYWORD kinds (``**kwargs``) alone, you can do so by setting `ch_variadic_keyword_to_keyword=False`. If you'll need to use `ch_variadics_to_non_variadic_kind` in such a way repeatedly, we suggest you use `functools.partial` to not have to specify this @@ -3836,10 +3924,6 @@ def ch_variadics_to_non_variadic_kind(func, *, ch_variadic_keyword_to_keyword=Tr >>> foo(1, (2, 3), bar=4, hello="world") "a=1, args=(2, 3), bar=4, kwargs={'hello': 'world'}" - - - - """ if func is None: return partial( @@ -3942,7 +4026,6 @@ def ch_func_to_all_pk(func): (PK: Positional_or_keyword) :param func: A callable - :return: >>> def f(a, /, b, *, c=None, **kwargs): ... return a + b * c @@ -3963,15 +4046,18 @@ def ch_func_to_all_pk(func): >>> gg = ch_func_to_all_pk(g) >>> print(Sig(gg)) (x, y=1, args=(), **kwargs) - + """ + # Not yet handled (not doctests): # >>> def h(x, *y, z): # ... print(f"{x=}, {y=}, {z=}") # >>> h(1, 2, 3, z=4) + # # x=1, y=(2, 3), z=4 + # # >>> hh = ch_func_to_all_pk(h) # >>> hh(1, (2, 3), z=4) + # # x=1, y=(2, 3), z=4 - """ # _func = tuple_the_args(func) # sig = Sig(_func) @@ -4003,7 +4089,15 @@ def __func(*args, **kwargs): def copy_func(f): - """Copy a function (not sure it works with all types of callables)""" + """Copy a function (not sure it works with all types of callables). + + >>> def h(x, y=2): + ... return x + y + >>> h.an_attr = 42 + >>> hc = copy_func(h) + >>> hc(1), hc.__name__, hc.an_attr, hc is h + (3, 'h', 42, False) + """ g = FunctionType( f.__code__, f.__globals__, @@ -4020,6 +4114,11 @@ def copy_func(f): # TODO: Similar to other function in this module -- merge. def params_of(obj: HasParams): + """The list of ``Parameter`` objects of a signature, a name-to-parameter mapping, or a callable. + + >>> params_of(lambda a, b=1: None) + [, ] + """ if isinstance(obj, Signature): obj = list(obj.parameters.values()) elif isinstance(obj, Mapping): @@ -4071,7 +4170,8 @@ def common_and_diff_argnames(func1: callable, func2: callable) -> dict: func1: First function func2: Second function - Returns: A dict with fields 'common', 'func1_not_func2', and 'func2_not_func1' + Returns: + A dict with fields 'common', 'func1_not_func2', and 'func2_not_func1' >>> def f(t, h, i, n, k): ... ... @@ -4108,9 +4208,9 @@ def set_signature_of_func( Args: func: Function whose signature you want to set - signature: A list of parameter specifications. This could be an - inspect.Parameter object or anything that - the mk_param function can resolve into an inspect.Parameter object. + parameters: A list of parameter specifications: ``inspect.Parameter`` objects + or anything ``ensure_param`` can resolve into one. + return_annotation: Passed on to inspect.Signature. __validate_parameters__: Passed on to inspect.Signature. @@ -4144,7 +4244,6 @@ def set_signature_of_func( ... ) >>> inspect.signature(foo) str> - """ sig = Sig( parameters, @@ -4188,10 +4287,10 @@ def sig_to_dataclass( >>> K = sig_to_dataclass(Sig(foo), cls_name='K') >>> K = sig_to_dataclass(Sig(foo).params, cls_name='K') - Note: ``cls_name`` is not required (we'll try to figure out a good default for you), - but it's advised to only use this convenience in extreme mode. - Choosing your own name might make for a safer future if you're reusing your class. - + Note: + ``cls_name`` is not required (we'll try to figure out a good default for you), + but it's advised to only use this convenience in extreme mode. + Choosing your own name might make for a safer future if you're reusing your class. """ from dataclasses import make_dataclass @@ -4259,7 +4358,6 @@ def replace_kwargs_using(sig: SignatureAble): ... return b * c + apple(a, **sauce_kwargs) >>> Sig(sauce) - """ def decorator(targ_func): @@ -4378,7 +4476,6 @@ def _robust_signature_of_callable(callable_obj: Callable) -> Signature: ... ... >>> _robust_signature_of_callable(map) - """ # The curated tables are keyed by *name*, which is only a sound key for the # C-level builtins they were written for. Consulting them for a callable that @@ -4450,7 +4547,6 @@ def resolve_function(obj: T) -> T | Callable: True >>> isinstance(inspect.getsource(resolve_function(C.partial_func)), str) True - """ if isinstance(obj, cached_property): return obj.func @@ -4467,7 +4563,7 @@ def resolve_function(obj: T) -> T | Callable: def dict_of_attribute_signatures(cls: type) -> dict[str, Signature]: """ - A function that extracts the signatures of all callable attributes of a class. + Extract the signatures of all callable attributes of a class, as a ``{name: signature}`` dict. :param cls: The class that holds the the ``(name, func)`` pairs we want to extract. :return: A dict of ``(name, signature(func))`` pairs extracted from class. @@ -4503,15 +4599,13 @@ def filter(function, iterable, /): """filter(function or None, iterable) --> filter object""" def map(func, iterable, /, *iterables): - """map(func, *iterables) --> map object""" + """``map(func, *iterables) --> map object``""" def print(*value, sep=" ", end="\n", file=sys.stdout, flush=False): - """print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)""" + r"""``print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)``""" def zip(*iterables): - """ - zip(*iterables) --> A zip object yielding tuples until an input is exhausted. - """ + """``zip(*iterables) --> A zip object yielding tuples until an input is exhausted.``""" def bool(x: Any, /) -> bool: ... @@ -4711,7 +4805,7 @@ def param_for_kind( with_default=False, annotation=Parameter.empty, ): - """Function to easily and flexibly make inspect.Parameter objects for testing. + """Make an ``inspect.Parameter`` of a given kind, with a generated name and default if not given (handy in tests). It's annoying to have to compose parameters from scratch to testing things. This tool should help making it less annoying. @@ -4787,6 +4881,7 @@ def param_for_kind( def compare_signatures(func1, func2, signature_comparator: SignatureComparator = eq): + """Compare the ``Sig`` of two callables with ``signature_comparator`` (equality by default).""" return signature_comparator(Sig(func1), Sig(func2)) @@ -4794,6 +4889,7 @@ def compare_signatures(func1, func2, signature_comparator: SignatureComparator = def mk_func_comparator_based_on_signature_comparator( signature_comparator: SignatureComparator, ) -> CallableComparator: + """Make a function comparator that compares two callables through their signatures.""" return partial(compare_signatures, signature_comparator=signature_comparator) @@ -4889,7 +4985,6 @@ def param_comparator( True See https://github.com/i2mint/i2/issues/50#issuecomment-1381686812 for discussion. - """ return aggreg( ( @@ -4917,13 +5012,13 @@ def dflt1_is_empty_or_dflt2_is_not(dflt1, dflt2): without specifying p, but func2 couldn't. So to avoid this situation, we use dflt1_is_empty_or_dflt2_is_not as the default - """ return dflt1 is empty or dflt2 is not empty # TODO: Implement annotation compatibility def ignore_any_differences(x, y): + """A comparator that always returns ``True`` (used to ignore a parameter attribute).""" return True @@ -4948,6 +5043,7 @@ def ignore_any_differences(x, y): def return_tuple(x, y): + """A comparator that returns the ``(x, y)`` pair itself instead of a verdict.""" return x, y @@ -4955,6 +5051,7 @@ def return_tuple(x, y): def param_attribute_dict(name_kind_default_annotation: Iterable[Comparison]) -> dict: + """Zip four comparison results into a ``{name, kind, default, annotation}`` dict (an ``aggreg``).""" keys = ["name", "kind", "default", "annotation"] return {key: value for key, value in zip(keys, name_kind_default_annotation)} @@ -5017,20 +5114,27 @@ def defaults_are_the_same_when_not_empty(dflt1, dflt2): """ Check if two defaults are the same when they are not empty. - # >>> defaults_are_the_same_when_not_empty(1, 1) - # True - # >>> defaults_are_the_same_when_not_empty(1, 2) - # False - # >>> defaults_are_the_same_when_not_empty(1, None) - # False - # >>> defaults_are_the_same_when_not_empty(1, Parameter.empty) - # True + >>> defaults_are_the_same_when_not_empty(1, 1) + True + >>> defaults_are_the_same_when_not_empty(1, 2) + False + >>> defaults_are_the_same_when_not_empty(1, None) + False + >>> defaults_are_the_same_when_not_empty(1, Parameter.empty) + True """ return dflt1 is empty or dflt2 is empty or dflt1 == dflt2 def postprocess(egress: Callable): - """A decorator that will process the output of the wrapped function with egress""" + """Make a decorator that applies ``egress`` to the output of the wrapped function. + + >>> @postprocess(list) + ... def r(n): + ... return range(n) + >>> r(3) + [0, 1, 2] + """ # Note: Vendorized version equivalent ones in i2.deco and i2.wrapper def postprocessed(func): @@ -5234,7 +5338,7 @@ def validate_param_compatibility(): @dataclass class SigPair: """ - Class that operates on a pair of signatures. + Compare two signatures: shared and missing names, and per-parameter differences. For example, offers methods to compare two signatures in various ways. @@ -5289,7 +5393,6 @@ class SigPair: >>> SigPair(three, pigs).are_call_compatible() False - """ sig1: Callable | Sig diff --git a/i2/util.py b/i2/util.py index 5695c572..f22355b8 100644 --- a/i2/util.py +++ b/i2/util.py @@ -27,21 +27,34 @@ def asis(x: T) -> T: - """the identity function: f(x) := x (takes only one argument, and returns it)""" + """The identity function: f(x) := x (takes only one argument, and returns it). + + >>> asis(3) + 3 + """ return x def return_false(*args, **kwargs): - """a function that returns True (takes any number of arguments)""" + """Return False, whatever the arguments. + + >>> return_false(1, x=2) + False + """ return False def return_true(*args, **kwargs): - """a function that returns False (takes any number of arguments)""" - return False + """Return True, whatever the arguments. + + >>> return_true(1, x=2) + True + """ + return True def return_none(*args, **kwargs) -> None: + """Return None, whatever the arguments.""" return None @@ -96,7 +109,6 @@ def name_of_obj( >>> alt = partial(name_of_obj, base_name_of_obj=attrgetter('__qualname__')) >>> alt(Signature.replace) 'Signature.replace' - """ try: return base_name_of_obj(o) @@ -205,7 +217,7 @@ class ConditionalExceptionCatcher: :param prevent_propagation: Whether to prevent the exception from propagating. Defaults to ``True``. - Example: + .. rubric:: Example >>> exception_catcher = ConditionalExceptionCatcher( ... ValueError, lambda e: e.args[0] == 'foo', handlers=print @@ -223,7 +235,6 @@ class ConditionalExceptionCatcher: Traceback (most recent call last): ... ValueError: bar - """ def __init__( @@ -274,7 +285,7 @@ class AttributeMapping(SimpleNamespace, Mapping[str, Any]): Useful when you want mapping interface but don't need mutation. - Examples: + .. rubric:: Examples >>> ns = AttributeMapping(x=10, y=20) >>> ns.x @@ -315,7 +326,7 @@ class AttributeMutableMapping(AttributeMapping, MutableMapping[str, Any]): Extends AttributeMapping with mutation capabilities, ensuring proper error handling and protocol compliance. - Examples: + .. rubric:: Examples >>> ns = AttributeMutableMapping(apple=1, banana=2) >>> ns.apple @@ -373,10 +384,13 @@ def FileLikeObject(file, *, io_cls=io.BytesIO, open_mode="rb"): bytes of a file, or an open file pointer. Args: - file (str, bytes, io.IOBase): The file path, bytes of a file, or an open file pointer. + file: The file path, bytes of a file, or an open file pointer. + io_cls: Accepted for interface compatibility; not used by the current + implementation (bytes are always wrapped in ``io.BytesIO``). + open_mode: The mode ``open`` is called with when ``file`` is a path. Yields: - io.IOBase: A file-like object. + A file-like object. """ if isinstance(file, str): # If file is a string, open the file and yield the file pointer @@ -415,24 +429,20 @@ def copy_func( :param func: The function to be copied. :param copy_dict: Indicates whether to copy the ``__dict__`` attribute of the - function. Defaults to ``True``. - :param code: The value to be use as the ``__code__`` attribute of the copy. - :param globals_: The value to be use as the ``__globals__`` attribute of the copy. + function (any attributes set on the function instance). Defaults to ``True``. + :param code: The value to be used as the ``__code__`` attribute of the copy. + :param globals\\_: The value to be used as the ``__globals__`` attribute of the copy. :return: A shallow copy of the function. Note that it should always work with proper functions and attempts to do the best job it can with other callables, but there are no guarantees on how - ``copy_function`` will behave with custom callables. + ``copy_func`` will behave with custom callables. If these custom callables don't have a `__code__` attribute, the copy will fail. Furthermore, if the custom callable doesn't have ``__globals__``, the empty dictionary will be used as the globals. We provide a ``code`` and ``globals`` argument to allow the user to provide the ``__code__`` and ``__globals__`` attributes of the function to be copied. - - :param func: The function to be copied. Must be a function, not just any method or callable. - :param copy_dict: Also copy any attributes set on the function instance. Defaults to ``True``. - :return: A shallow copy of the function. """ from types import FunctionType @@ -459,6 +469,7 @@ class OverwritesForbidden(ValueError): def is_lambda(func): + """Whether ``func`` is a lambda (its ``__name__`` is ``""``).""" return getattr(func, "__name__", None) == "" @@ -467,7 +478,6 @@ def lambda_code(lambda_func) -> str: """Extract code of expression from lambda function. For lambda code-extraction see: https://stackoverflow.com/questions/73980648/how-to-transform-a-lambda-function-into-a-pickle-able-function - """ func_str = str(inspect.getsourcelines(lambda_func)[0]) return func_str.strip("['\\n']").split(" = ")[1] @@ -488,7 +498,6 @@ class PicklableLambda: For lambda code-extraction see: https://stackoverflow.com/questions/73980648/how-to-transform-a-lambda-function-into-a-pickle-able-function - """ def __init__(self, func, name=None): @@ -545,7 +554,6 @@ def ensure_identifiers( Traceback (most recent call last): ... ValueError: too_long isn't an identifier according toless_than_6_chars - """ for obj in objs: for identifier in get_identfiers(obj): @@ -599,7 +607,7 @@ def insert_name_based_objects_in_scope( ... ) >>> insert_namedtuples_in_locals('foo bar', 'baz') - And now ``foo` exists! + And now ``foo`` exists! >>> 'foo' in locals() True @@ -630,7 +638,6 @@ class LiteralVal: 42 >>> t() 42 - """ def __init__(self, val): @@ -652,6 +659,15 @@ def get_val(self): def dflt_idx_preprocessor(obj, idx): + """Get ``idx`` from ``obj``: by item for ints, digit strings and Mappings, else by attribute. + + The default ``getter`` of ``path_extractor``. + + >>> dflt_idx_preprocessor({"a": 1}, "a"), dflt_idx_preprocessor([10, 20], "1") + (1, 20) + + :raises KeyError: If ``idx`` is neither an item nor an attribute of ``obj``. + """ if isinstance(idx, str) and str.isdigit(idx): idx = int(idx) if isinstance(idx, int) or isinstance(obj, Mapping): @@ -742,7 +758,7 @@ def path_extractor(tree, path, getter=dflt_idx_preprocessor, *, path_sep="."): # Note: Specialization of path_extractor for Mappings def dp_get(d, dot_path): """Get stuff from a dict (or any Mapping), using dot_paths (i.e. 'foo.bar' instead of - ['foo']['bar']) + ['foo']['bar']). >>> d = {'foo': {'bar': 2, 'alice': 'bob'}, 3: {'pi': 3.14}} >>> assert dp_get(d, 'foo') == {'bar': 2, 'alice': 'bob'} @@ -797,7 +813,7 @@ def __get__(self, instance, cls): class FrozenHashError(TypeError): - pass + """Raised (and cached) when a ``frozendict`` holds an unhashable value and is hashed.""" class FrozenDict(dict): @@ -811,7 +827,6 @@ class FrozenDict(dict): Because FrozenDict is a :class:`dict` subtype, it automatically works everywhere a dict would, including JSON serialization. - """ __slots__ = ("_hash",) @@ -876,6 +891,8 @@ def _raise_frozen_typeerror(self, *a, **kw): class NoDefault: + """Type of the ``no_default`` sentinel, marking the absence of a default value.""" + def __repr__(self): return "no_default" @@ -884,6 +901,8 @@ def __repr__(self): class imdict(dict): + """A dict whose mutating methods raise ``TypeError``, hashable by identity.""" + def __hash__(self): return id(self) @@ -901,12 +920,26 @@ def _immutable(self, *args, **kws): def inject_method(self, method_function, method_name=None): """ - Inject a method into an object instance. - - method_function could be: - * a function - * a {method_name: function, ...} dict (for multiple injections) - * a list of functions or (function, method_name) pairs + Inject a method into an object instance (binding the function to it). + + ``method_function`` can be: + + * a function (the method name is ``method_name``, or the function's name) + * a ``{method_name: function, ...}`` dict (for multiple injections) + * a list of functions or ``(function, method_name)`` pairs + + Returns the instance, mutated. + + >>> class A: ... + >>> a = A() + >>> def greet(self, name): + ... return f"hi {name} from {type(self).__name__}" + >>> _ = inject_method(a, greet) + >>> a.greet("bob") + 'hi bob from A' + >>> _ = inject_method(a, {"shout": lambda self, s: s.upper()}) + >>> a.shout("x") + 'X' """ if isinstance(method_function, function_type): if method_name is None: @@ -931,12 +964,18 @@ def inject_method(self, method_function, method_name=None): def get_function_body(func): """ - Get the body of a function as a string. - - :param func: The function to get the body of. - - :return: The body of the function as a string. - + Get the body of a function as a (dedented) string, from its source code. + + Decorator lines and the ``def`` line(s) are dropped. Requires the source to be + available through ``inspect`` (not the case for functions defined in a REPL). + + >>> def f(x): + ... y = x + 1 + ... return y * 2 + >>> print(get_function_body(f)) + y = x + 1 + return y * 2 + """ source_lines = inspect.getsourcelines(func)[0] source_lines = itertools.dropwhile(lambda x: x.startswith("@"), source_lines) @@ -958,11 +997,11 @@ def get_function_body(func): class ExistingArgument(ValueError): - pass + """Raised by ``FunctionBuilder.add_arg`` when the argument name is already taken.""" class MissingArgument(ValueError): - pass + """Raised by ``FunctionBuilder.remove_arg`` when the argument is not in the function.""" def _default_sentinel_repr_method(self): @@ -990,8 +1029,9 @@ def mk_sentinel( :param name: The name of your sentinel. Will be used for ``__name__`` attribute. :param boolean_value: The boolean value that the sentinel instance should resolve to. - :param repr_: The method or string that should be used for the repr. - :param module: + :param repr\\_: The method or string that should be used for the repr. + :param module: The ``__module__`` to give the sentinel's class (needed for + pickling). By default it is taken from the calling frame's ``__name__``. :return: A sentinel instance >>> Empty = mk_sentinel('Empty') @@ -1086,7 +1126,6 @@ def mk_sentinel( Thanks: Inspired greately from the ``make_sentinel`` function of ``boltons``: See https://boltons.readthedocs.io/. - """ class Sentinel: @@ -1198,7 +1237,8 @@ class FunctionBuilder: creating new functions, either based on existing functions or from scratch. - Note: Based on https://boltons.readthedocs.io + Note: + Based on https://boltons.readthedocs.io Values are passed in at construction or set as attributes on the instance. For creating a new function based of an existing one, @@ -1256,7 +1296,6 @@ class FunctionBuilder: can be mutated as necessary. .. _Docstring: https://en.wikipedia.org/wiki/Docstring#Python - """ _argspec_defaults = { @@ -1482,7 +1521,6 @@ def remove_arg(self, arg_name): arg_name (str): The name of the argument to remove. Raises a :exc:`ValueError` if the argument is not present. - """ args = self.args d_dict = self.get_defaults_dict() @@ -1520,6 +1558,10 @@ def _compile(self, src, execdict): def deprecation_of(func, old_name): + """Wrap ``func`` so that each call emits a DeprecationWarning naming ``old_name``. + + Bind the result to the old name to keep it importable while pointing users to ``func``. + """ @wraps(func) def wrapper(*args, **kwargs): from warnings import warn diff --git a/i2/wrapper.py b/i2/wrapper.py index a8bb86aa..56a8383c 100644 --- a/i2/wrapper.py +++ b/i2/wrapper.py @@ -60,7 +60,6 @@ │ ▼ *inner_args, **inner_kwargs - """ from functools import wraps, partial @@ -110,7 +109,11 @@ def identity(x): - """Transparent function, returning what's been input""" + """Return the input unchanged. + + >>> identity('x') + 'x' + """ return x @@ -319,13 +322,15 @@ class Wrap(_Wrap): wrapped function to determine the return annotation of the ``Wrap`` instance :param name: Name to give the wrapper (will use wrapped func name by default) :param preserve_signature: Controls signature preservation from the wrapped function. - - 'auto' (default): Automatically preserve if ingress has (*args, **kwargs) signature + + - 'auto' (default): Automatically preserve if ingress has ``(*args, **kwargs)`` signature - True: Always preserve signature from func (copies __signature__) - False: Don't preserve (use ingress's natural signature) When signature is preserved, both __signature__ and __annotations__ are copied from func to the wrapper, ensuring type checkers and IDEs see the original signature. + :return: A callable instance wrapping ``func`` Some examples: @@ -409,14 +414,12 @@ class Wrap(_Wrap): explicitly (as in the examples above), but through a factory -- a function that will be called on ``func`` to produce the ingress that should be used to wrap it. - Common Patterns and Best Practices - ----------------------------------- + **Common Patterns and Best Practices** - Pattern 1: Transform inputs while preserving signature - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pattern 1: Transform inputs while preserving signature** By default (with preserve_signature='auto'), Wrap automatically preserves - signatures when your ingress uses (*args, **kwargs): + signatures when your ingress uses ``(*args, **kwargs)``: >>> def uppercase_args(func): ... def ingress(*args, **kwargs): @@ -431,8 +434,7 @@ class Wrap(_Wrap): >>> greet("alice") # Signature preserved, input transformed 'Hello, ALICE!' - Pattern 2: Keep return annotation with transparent egress - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pattern 2: Keep return annotation with transparent egress** When using an egress that doesn't transform the type, return annotations are automatically preserved: @@ -451,8 +453,7 @@ class Wrap(_Wrap): >>> result 10 - Pattern 3: Validation without transformation - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pattern 3: Validation without transformation** Use ingress for validation without modifying arguments: @@ -470,8 +471,7 @@ class Wrap(_Wrap): >>> multiply(2, 3) 6 - Pattern 4: Error handling and logging - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pattern 4: Error handling and logging** Wrap both ends for comprehensive error handling: @@ -489,10 +489,9 @@ class Wrap(_Wrap): Common Pitfalls and Solutions ------------------------------ - Pitfall 1: Losing signatures with explicit non-generic ingress - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pitfall 1: Losing signatures with explicit non-generic ingress** - If your ingress doesn't use (*args, **kwargs), the auto mode won't preserve + If your ingress doesn't use ``(*args, **kwargs)``, the auto mode won't preserve the signature. Use preserve_signature=True explicitly: >>> # WRONG: Signature lost with non-generic ingress @@ -510,8 +509,7 @@ class Wrap(_Wrap): >>> wrapped = Wrap(my_func, ingress=ingress, preserve_signature=True) >>> # Now signature is correctly (x: int, y: int = 5) -> int - Pitfall 2: Type-changing egress without annotation - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pitfall 2: Type-changing egress without annotation** If your egress changes the output type, annotate it. Otherwise, the function's original return type will be preserved, creating incorrect type hints: @@ -529,8 +527,7 @@ class Wrap(_Wrap): >>> isinstance(calc(5), str) True - Pitfall 3: Forgetting to return (args, kwargs) from ingress - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pitfall 3: Forgetting to return (args, kwargs) from ingress** Ingress MUST return a tuple of (args, kwargs) for the wrapped function: @@ -544,8 +541,7 @@ class Wrap(_Wrap): ... # Do any processing here ... return args, kwargs # CORRECT - Pitfall 4: Modifying mutable arguments in place - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + **Pitfall 4: Modifying mutable arguments in place** Be careful when modifying arguments - changes affect the original objects: @@ -563,12 +559,13 @@ class Wrap(_Wrap): **Signature Preservation (v3.0):** Currently, preserve_signature defaults to 'auto' which only preserves - signatures for generic (*args, **kwargs) ingress functions. In v3.0, + signatures for generic ``(*args, **kwargs)`` ingress functions. In v3.0, we may change the default to True to always preserve signatures unless explicitly disabled. This matches user expectations that decorators should preserve signatures by default. To prepare for this change: + - If you want current behavior: explicitly set preserve_signature='auto' - If you want v3.0 behavior: explicitly set preserve_signature=True - If you never want preservation: explicitly set preserve_signature=False @@ -576,7 +573,6 @@ class Wrap(_Wrap): .. seealso:: ``wrap`` function. - """ def __init__( @@ -683,11 +679,11 @@ def wrap( The ingress function serves two purposes: - Redefining the signature (i.e. the argument names, kinds, defaults, - and annotations (not including the return annotation, which is taken care of by the - egress argument). + and annotations (not including the return annotation, which is taken care of by the + egress argument). - Telling the wrapper how to get from that interface to the interface of the - wrapped function. + wrapped function. If we also wanted to add a return_annotation, we could do so via an ``egress`` function argument: @@ -742,7 +738,6 @@ def wrap( ``Wrap`` class. ``Wrapx`` class. - """ if _should_use_wrapx(func, ingress, egress, caller): return Wrapx(func, ingress, egress, caller=caller, name=name) @@ -933,7 +928,6 @@ def __init__( Namely, ``kwargs_trans`` must be able to handle outputs of ``outer_sig.map_arguments`` and itself output kwargs that can be handled by ``inner_sig.mk_args_and_kwargs``. - """ self.inner_sig = Sig(inner_sig) @@ -995,7 +989,6 @@ def name_map(cls, wrapped, **old_to_new_name): >>> wrapped_f = ingress.wrap(f) >>> wrapped_f(1, 2, y=3, Zee=4) '(w:=1) + (x:=2) * (y:=3) ** (z:=4) == 163' - """ new_to_old_name = {v: k for k, v in old_to_new_name.items()} assert len(new_to_old_name) == len( @@ -1034,7 +1027,6 @@ def items_with_mapped_keys(d: dict, key_mapper): ... {'a': 'Ay', 'd': 'Dee'}) ... ) {'Ay': 1, 'b': 2, 'c': 3, 'Dee': 4} - """ for k, v in d.items(): # key_mapper.get(k, k) will give the new key name if present, @@ -1043,6 +1035,11 @@ def items_with_mapped_keys(d: dict, key_mapper): def invert_map(d: dict): + """Swap keys and values of a mapping, raising ``ValueError`` if values are not unique. + + >>> invert_map({'a': 1, 'b': 2}) + {1: 'a', 2: 'b'} + """ new_d = {v: k for k, v in d.items()} if len(new_d) == len(d): return new_d @@ -1051,6 +1048,7 @@ def invert_map(d: dict): def parameters_to_dict(parameters): + """Map each parameter name to its ``parameter_to_dict`` (name, kind, default, annotation) dict.""" return {name: parameter_to_dict(param) for name, param in parameters.items()} @@ -1084,7 +1082,7 @@ def _handle_ingress_class_inputs( # TODO: See what this adds over ``Ingress`` class. Consider merging or reusing. class InnerMapIngress: - """A class to help build ingresses systematically by mapping the inner signature. + """Build an ingress from the wrapped function's signature and a spec of changes to it. *Systematically*, i.e. "according to a fixed plan/system" is what it's about here. As we'll see below, if you need to write a particular adapter for a specific case, @@ -1102,8 +1100,8 @@ class InnerMapIngress: The parameter kind restrictions are taken care of automatically. :param _allow_reordering: Whether we want to allow reordering of variables :param in_to_out_sig_changes: The ``inner_name=dict_of_changes_for_that_name`` - pairs, the ``dict_of_changes_for_that_name`` is a ``dict`` with keys being valid - ``inspect.Parameter`` + pairs, the ``dict_of_changes_for_that_name`` is a ``dict`` with keys being valid + ``inspect.Parameter`` Consider the following function that has a position only, a keyword only, two arguments with annotations, and three with a default. @@ -1120,7 +1118,7 @@ class InnerMapIngress: - where ``y`` was named ``you`` instead, and has an annotation (``int``). - where the default of ``z`` was ``10`` instead of ``3``, and doesn't have an - annotation. + annotation. In order to get a version of this function we wanted (more lenient kinds, with some annotations and a default change), we can use the ingress function: @@ -1153,19 +1151,19 @@ class InnerMapIngress: ... z=dict(kind=PK, default=10, annotation=empty), ... ) - Note: + **Note** - Only the changes we wish to make to the parameters are mentioned. - You could also define the parameters explicitly by simply listing all three - of the dimensions (kind, annotation, and default) + You could also define the parameters explicitly by simply listing all three + of the dimensions (kind, annotation, and default) - Three? But a ``Parameter`` object has four; what about the name? - Indeed, you can use name as well, more on that later. + Indeed, you can use name as well, more on that later. - Note that in order to specify that you want no default, or no annotation, - you cannot use ``None`` since ``None`` is both a valid default and a valid - annotation; So instead you need to use ``Parameter.empty`` (conveniently - assigned to a constant named ``empty`` in the ``wrapping`` module. + you cannot use ``None`` since ``None`` is both a valid default and a valid + annotation; So instead you need to use ``Parameter.empty`` (conveniently + assigned to a constant named ``empty`` in the ``wrapping`` module. Now see that all arguments are ``POSITIONAL_OR_KEYWORD``, ``x`` and ``y`` are ``int``, and default of ``z`` is 10: @@ -1184,8 +1182,6 @@ class InnerMapIngress: ... == directly_defined_ingress(0,1,2,3) ... == ((0,), {'x': 1, 'y': 2, 'z': 3}) ... ) - - """ def __init__( @@ -1245,12 +1241,12 @@ def from_signature(cls, inner_sig, outer_sig, _allow_reordering=False): independently. :param inner_sig: The signature of wrapped, inner function (or the inner - function itself) + function itself) :param outer_sig: The desired outer signature. Can also use a function (will - only take it's signature though). + only take it's signature though). :param _allow_reordering: Whether to allow ``outer_sig`` to reorder arguments. :return: An ingress that will allow one to use a function having the - ``inner_sig`` signature to + ``inner_sig`` signature to Say we wanted to get a version of the function: @@ -1279,7 +1275,6 @@ def from_signature(cls, inner_sig, outer_sig, _allow_reordering=False): >>> assert h(1,2,3,4) == g(1,2,3,4) == 1 + 2 * 3 ** 4 >>> >>> assert h(w=1,x=2,y=3,z=4) == g(1,2,3,4) == 1 + 2 * 3 ** 4 # w keyword arg! - """ outer_sig = Sig(outer_sig) return cls( @@ -1291,6 +1286,12 @@ def from_signature(cls, inner_sig, outer_sig, _allow_reordering=False): # TODO: Fits global pattern -- merge class ArgNameMappingIngress: + """Ingress that renames parameters: called with outer names, returns inner (args, kwargs). + + Unless ``conserve_kind=True``, all parameter kinds of the outer signature become + POSITIONAL_OR_KEYWORD. ``mk_ingress_from_name_mapper`` is the function form. + """ + def __init__(self, inner_sig, *, conserve_kind=False, **outer_name_for_inner_name): self.inner_sig = Sig(inner_sig) self.outer_sig = self.inner_sig.ch_names(**outer_name_for_inner_name) @@ -1314,6 +1315,21 @@ def __call__(self, *ingress_args, **ingress_kwargs): def mk_ingress_from_name_mapper(func, name_mapper: Mapping, *, conserve_kind=False): + """Make an ingress that renames ``func``'s parameters (``{inner_name: outer_name}``). + + >>> def foo(a, b: int, c=7): + ... return (a, b, c) + >>> ingress = mk_ingress_from_name_mapper(foo, dict(a='aa', c='cc')) + >>> Sig(ingress) + + >>> ingress(1, b=2, cc=3) + ((), {'a': 1, 'b': 2, 'c': 3}) + >>> wrap(foo, ingress=ingress)(1, 2, cc=3) + (1, 2, 3) + + By default the outer signature loses positional-only and keyword-only kinds; + ``conserve_kind=True`` keeps them. + """ return ArgNameMappingIngress(func, conserve_kind=conserve_kind, **name_mapper) @@ -1322,6 +1338,7 @@ def mk_ingress_from_name_mapper(func, name_mapper: Mapping, *, conserve_kind=Fal def apply_func_on_cond(func, cond, k, v): + """Return ``func(v)`` if ``cond(k, v)`` is true, else ``v`` unchanged.""" if cond(k, v): return func(v) else: @@ -1329,10 +1346,12 @@ def apply_func_on_cond(func, cond, k, v): def modify_dict_on_cond(d, cond, func): + """Copy ``d``, applying ``func`` to the values whose ``(key, value)`` satisfy ``cond``.""" return {k: apply_func_on_cond(func, cond, k, v) for k, v in d.items()} def convert_VK_to_KO(kinds): + """In a ``{name: kind}`` dict, replace VAR_KEYWORD kinds with KEYWORD_ONLY.""" cond = lambda k, v: v == VK func = lambda v: KO @@ -1343,6 +1362,16 @@ def convert_VK_to_KO(kinds): def wrap_from_sig(func, new_sig): + """Give ``func`` the signature ``new_sig``, calling it with only the arguments it takes. + + >>> def f(x, y=1): + ... return x + y + >>> h = wrap_from_sig(f, Sig('(x, y=1, z=0)')) + >>> Sig(h) + + >>> h(1, 2, 3), h(1, z=5) + (3, 2) + """ from i2 import call_somewhat_forgivingly @wraps(func) @@ -1375,6 +1404,7 @@ def ch_names(func=None, **old_to_new_name): '(w:=1) + (x:=2) * (y:=3) ** (z:=4) == 163' Can also be used as a factory: + >>> @ch_names(a='alpha', g='gamma') ... def foo(a, b, g=1): ... return a + b * g @@ -1429,7 +1459,6 @@ def include_exclude(func=None, *, include=None, exclude=None): >>> bar('B', 'A') a='A',b='B',c='C',d='D' - """ return wrap(func, ingress=include_exclude_ingress_factory(func, include, exclude)) @@ -1467,7 +1496,6 @@ def rm_params( Traceback (most recent call last): ... AssertionError: Some of the params you want to remove don't have defaults: {'x'} - """ if isinstance(params_to_remove, str): params_to_remove = params_to_remove.split() @@ -1494,10 +1522,19 @@ def rm_params( def arg_val_converter(func, **conversion_for_arg): + """Wrap ``func`` so that the given arguments are converted (``name=converter``) before the call. + + >>> def f(x, y=1): + ... return x + y + >>> g = arg_val_converter(f, x=int) + >>> g('2', 3) + 5 + """ return Wrap(func, ingress=ArgValConverterIngress(func, **conversion_for_arg)) def arg_val_converter_ingress(func, __strict=True, **conversion_for_arg): + """Function form of ``ArgValConverterIngress``: an ingress converting the named arguments.""" sig = Sig(func) if __strict: conversion_names_that_are_not_func_args = conversion_for_arg.keys() - sig.names @@ -1519,6 +1556,14 @@ def ingress(*args, **kwargs): # TODO: Fits global pattern -- merge class ArgValConverterIngress: + """Ingress with ``func``'s signature that applies ``name=converter`` functions to arguments. + + Names that are not parameters of ``func`` are rejected with an ``AssertionError`` + at construction (the ``__strict`` parameter cannot be passed by keyword from outside + the class, since the name is mangled; use ``arg_val_converter_ingress`` to switch it + off). + """ + def __init__(self, func, __strict=True, **conversion_for_arg): sig = Sig(func) if __strict: @@ -1546,6 +1591,11 @@ def __call__(self, *args, **kwargs): def convert_dict_values(to_convert: dict, key_to_conversion_function: dict): + """Yield ``(key, value)`` pairs of ``to_convert``, converting the values whose key has a function. + + >>> dict(convert_dict_values({'x': '2', 'y': 3}, {'x': int})) + {'x': 2, 'y': 3} + """ for k, v in to_convert.items(): if k in key_to_conversion_function: conversion_func = key_to_conversion_function[k] @@ -1562,10 +1612,12 @@ def _alt_convert_dict_values(to_convert: dict, key_to_conversion_function: dict) def params_used_in_funcs(funcs): + """The set of parameter names of all the given functions.""" return {name for func in funcs for name in Sig(func).names} def required_params_used_in_funcs(funcs): + """The set of names that are required (no default) in at least one of the given functions.""" return {name for func in funcs for name in Sig(func).required_names} @@ -1671,6 +1723,15 @@ def camelize(s): def kwargs_trans_to_extract_args_from_attrs( outer_kwargs: dict, attr_names=(), obj_param="self" ): + """Pop the ``obj_param`` object out of ``outer_kwargs`` and source ``attr_names`` from its attributes. + + Mutates ``outer_kwargs`` (the object is popped). Explicit kwargs win over attributes. + + >>> class O: pass + >>> o = O(); o.a = 1; o.b = 2 + >>> kwargs_trans_to_extract_args_from_attrs({'self': o, 'c': 3}, attr_names=('a', 'b')) + {'a': 1, 'b': 2, 'c': 3} + """ self = outer_kwargs.pop(obj_param) arguments_extracted_from_obj = {name: getattr(self, name) for name in attr_names} # The kwargs we need are the union of the extracted arguments with the remaining outer_kwargs @@ -1680,6 +1741,7 @@ def kwargs_trans_to_extract_args_from_attrs( # TODO: kind lost here, only 3.10 offers dataclasses with some control over kind: # See: https://stackoverflow.com/questions/49908182/how-to-make-keyword-only-fields-with-dataclasses def param_to_dataclass_field_tuple(param: Parameter): + """The ``(name, annotation, default)`` tuple ``dataclasses.make_dataclass`` expects for a field.""" return param.name, param.annotation, param.default @@ -1770,7 +1832,6 @@ def func_to_method_func( >>> instance = Klass(1, 3) >>> instance.method_func(2, d='hello') 'hello: 9' - """ # get a signature object for func sig = Sig(func) @@ -1896,6 +1957,8 @@ def bind_funcs_object_attrs( class PickleHelperMixin: + """Mixin whose ``__reduce__`` pickles an instance by its ``__name__`` (a global reference).""" + def __reduce__(self): return self.__name__ @@ -1935,6 +1998,7 @@ def bind_funcs_object_attrs_old( ): """Transform one or several functions into a class that contains them as methods sourcing specific arguments from the instance's attributes. + >>> from inspect import signature >>> from dataclasses import dataclass >>> @@ -2119,94 +2183,93 @@ def _all_kinds_are_keyword_only_or_variadic_keyword(sig): # TODO: Factor out more common parts with Wrap and reuse (possibly through _Wrap) class Wrapx(_Wrap): - def __init__(self, func, ingress=None, egress=None, *, caller=None, name=None): - """An extended wrapping object that allows more complex wrapping mechanisms. - - :param func: The wrapped function - :param ingress: The incoming data transformer. It determines the argument properties - (name, kind, default and annotation) as well as the actual input of the - wrapped function. - :param egress: The outgoing data transformer. It also takes precedence over the - wrapped function to determine the return annotation of the ``Wrap`` instance - :param caller: A caller defines what it means to call the ``func`` on the - arguments it is given. It should be of the form - ``caller(func, args, kwargs, *, ...extra_keyword_only_params)``. - By default, the caller will simply return ``func(*args, **kwargs)``. - :param name: Name to give the wrapper (will use wrapped func name by default) - - :return: A callable instance wrapping ``func`` - - >>> from inspect import signature - >>> - >>> def func(x, y): - ... return x + y - ... - >>> def save_on_output_egress(v, *, k, s): - ... s[k] = v - ... return v - ... - >>> save_on_output = Wrapx(func, egress=save_on_output_egress) - >>> # TODO: should be `(x, y, *, k, s)` --> Need to work on the merge for this. - >>> str(signature(save_on_output)) - '(x, y, k, s)' - >>> - >>> store = dict() - >>> save_on_output(1, 2, k='save_here', s=store) - 3 - >>> assert save_on_output(1, 2, k='save_here', s=store) == 3 == func(1, 2) - >>> store # see what's in the store now! - {'save_here': 3} - - A caller is meant to control the way the function is called. - It is given the ``func`` and the ``func_args`` and ``func_kwargs`` - (whatever the ingress function gives it, if present) and possibly additional - params and will return... well, what ever you tell it to. - - This can be used, for example, to call the function in a subprocess, - or on a remote system, differ computation (command pattern, for example, using - ``functools.partial``, or do what ever needs to have a view both on the function - and its inputs. - - Here, we will wrap the function so it will apply to an iterable of inputs - (of the first argument), returning a list of results - - >>> def func(x, y=2): - ... return x + y - ... - >>> def iterize(func, args, kwargs): - ... first_arg_val = next(iter(kwargs.values())) - ... return list(map(func, first_arg_val)) - ... - >>> iterized_func = Wrapx(func, caller=iterize) - >>> iterized_func([1, 2, 3, 4]) - [3, 4, 5, 6] - - Let's do the same as above, but allow other variables (here ``y``) to be input as - well. This takes a bit more work... - - >>> from functools import partial - >>> def _iterize_first_arg(func, args, kwargs): - ... first_arg_name = next(iter(kwargs)) - ... remaining_kwargs = { - ... k: v for k, v in kwargs.items() if k != first_arg_name - ... } - ... return list( - ... map(partial(func, **remaining_kwargs), kwargs[first_arg_name]) - ... ) - - Let's demo a different way of using Wrapx: Making a wrapper to apply at - function definition time - - >>> iterize_first_arg = partial(Wrapx, caller=_iterize_first_arg) - >>> @iterize_first_arg - ... def func(x, y): - ... return x + y - >>> - >>> func([1, 2, 3, 4], 10) - [11, 12, 13, 14] + """An extended wrapping object that allows more complex wrapping mechanisms. + :param func: The wrapped function + :param ingress: The incoming data transformer. It determines the argument properties + (name, kind, default and annotation) as well as the actual input of the + wrapped function. + :param egress: The outgoing data transformer. It also takes precedence over the + wrapped function to determine the return annotation of the ``Wrap`` instance + :param caller: A caller defines what it means to call the ``func`` on the + arguments it is given. It should be of the form + ``caller(func, args, kwargs, *, ...extra_keyword_only_params)``. + By default, the caller will simply return ``func(*args, **kwargs)``. + :param name: Name to give the wrapper (will use wrapped func name by default) - """ + :return: A callable instance wrapping ``func`` + + >>> from inspect import signature + >>> + >>> def func(x, y): + ... return x + y + ... + >>> def save_on_output_egress(v, *, k, s): + ... s[k] = v + ... return v + ... + >>> save_on_output = Wrapx(func, egress=save_on_output_egress) + >>> # TODO: should be `(x, y, *, k, s)` --> Need to work on the merge for this. + >>> str(signature(save_on_output)) + '(x, y, k, s)' + >>> + >>> store = dict() + >>> save_on_output(1, 2, k='save_here', s=store) + 3 + >>> assert save_on_output(1, 2, k='save_here', s=store) == 3 == func(1, 2) + >>> store # see what's in the store now! + {'save_here': 3} + + A caller is meant to control the way the function is called. + It is given the ``func`` and the ``func_args`` and ``func_kwargs`` + (whatever the ingress function gives it, if present) and possibly additional + params and will return... well, what ever you tell it to. + + This can be used, for example, to call the function in a subprocess, + or on a remote system, differ computation (command pattern, for example, using + ``functools.partial``, or do what ever needs to have a view both on the function + and its inputs. + + Here, we will wrap the function so it will apply to an iterable of inputs + (of the first argument), returning a list of results + + >>> def func(x, y=2): + ... return x + y + ... + >>> def iterize(func, args, kwargs): + ... first_arg_val = next(iter(kwargs.values())) + ... return list(map(func, first_arg_val)) + ... + >>> iterized_func = Wrapx(func, caller=iterize) + >>> iterized_func([1, 2, 3, 4]) + [3, 4, 5, 6] + + Let's do the same as above, but allow other variables (here ``y``) to be input as + well. This takes a bit more work... + + >>> from functools import partial + >>> def _iterize_first_arg(func, args, kwargs): + ... first_arg_name = next(iter(kwargs)) + ... remaining_kwargs = { + ... k: v for k, v in kwargs.items() if k != first_arg_name + ... } + ... return list( + ... map(partial(func, **remaining_kwargs), kwargs[first_arg_name]) + ... ) + + Let's demo a different way of using Wrapx: Making a wrapper to apply at + function definition time + + >>> iterize_first_arg = partial(Wrapx, caller=_iterize_first_arg) + >>> @iterize_first_arg + ... def func(x, y): + ... return x + y + >>> + >>> func([1, 2, 3, 4], 10) + [11, 12, 13, 14] + """ + + def __init__(self, func, ingress=None, egress=None, *, caller=None, name=None): super().__init__(func, ingress, egress, caller=caller, name=name) self.ingress, self.egress, self.caller, self.sig = _process_wrapx_params( func, ingress, egress, caller @@ -2332,7 +2395,7 @@ def partialx( """ Extends the functionality of builtin ``functools.partial`` with the ability to - - set ``__name__ `` + - set ``__name__`` - remove partialized arguments from signature @@ -2363,7 +2426,6 @@ def partialx( >>> g = partialx(f, a=1, _allow_reordering=True) >>> str(Sig(g)) '(*, b, a=1, c=3)' - """ f = partial(func, *args, **kwargs) if _rm_partialize: @@ -2393,7 +2455,6 @@ def move_params_to_the_end(func: Callable, names_to_move: Callable | Iterable[st >>> h = move_params_to_the_end(g, Sig(g).defaults) >>> assert str(Sig(g)) == '(a, *, b=4, c)' >>> assert str(Sig(h)) == '(a, *, c, b=4)' - """ if callable(names_to_move): names_to_move = names_to_move(func) @@ -2420,7 +2481,6 @@ def move_names_to_the_end(names, names_to_move_to_the_end): >>> names_to_move_to_the_end = 'c e' >>> move_names_to_the_end(names, names_to_move_to_the_end) ['a', 'd', 'c', 'e'] - """ if isinstance(names_to_move_to_the_end, str): names_to_move_to_the_end = names_to_move_to_the_end.split() @@ -2437,6 +2497,12 @@ def move_names_to_the_end(names, names_to_move_to_the_end): # IDEA: Could make SmartDefault have an out and __call__, working like a meshed.FuncNode @dataclass class SmartDefault: + """Placeholder default for a parameter whose value ``add_smart_defaults`` computes from the other arguments. + + Holds the function that computes the value and, if the parameter had one, its + original default (shown in the repr). + """ + func_computing_default: Callable original_default: Any = empty @@ -2499,7 +2565,6 @@ def complete_dict_applying_functions( ... _only_if_name_missing=False, _allow_overwrites=True ... ) {'a': 1, 'b': 10, 'c': 11, 'd': 22} - """ if _only_if_name_missing: @@ -2562,7 +2627,6 @@ def add_smart_defaults( 'aaaZ' >>> f - """ names_not_in_func_arguments = smart_defaults.keys() - Sig(func).names assert (