diff --git a/CHANGELOG.md b/CHANGELOG.md index eee35b6..0ac34df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.7.0] - 2026-07-26 + +### Changed +- **Behaviour:** a parameter annotated `cfn.Config` is no longer resolved before the target is called. A target that already had such a parameter and relied on `instantiate()` building the config passed to it now receives the config itself, with no error — both are ordinary values. Annotate the parameter with the type it is built into (or leave it unannotated) to keep the previous behaviour. +- **Performance:** `instantiate()` reads the target's signature on every call, since a target is free to change what it declares and an answer kept from an earlier reading is wrong in a way nothing reveals. Building a config node now costs tens of microseconds where it cost single-digit microseconds, and a couple of hundred for a target whose annotations are postponed (`from __future__ import annotations`) and have to be resolved as text. This applies to every config, not only those with an annotated parameter. +- `override()`, `override_data()` and `Config.__call__()` take `self` positionally, so `self` is usable as an override key. Previously `override_data(**{'self': ...})` — reachable wherever the keys come from outside the process — raised `TypeError` instead of being applied or reported as a `ConfigError`. + +### Added +- A target parameter annotated `cfn.Config` now receives the stored config itself instead of its instantiation (#38). This covers targets that must build a config later, more than once, or with overrides they only learn at runtime — a server applying per-connection overrides, for instance — which previously forced a hand-rolled dict of configs keyed by string. The declaration lives in the target's signature, so callers keep passing ordinary values: `.override()` (including dotted keys reaching into the config), `--help` and `get_required_args` all keep working on it. `Config | None`, `Optional[Config]`, `Annotated[Config, ...]` and string annotations (`from __future__ import annotations`) are recognised too; a non-config value on such a parameter is passed through untouched, and configs nested in containers (`list[Config]`) still resolve as before. + ## [0.6.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index a58daf1..2f59da1 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,60 @@ def app(db): return App(reader=Reader(db), writer=Writer(db)) ``` +### Receiving a config instead of an object + +Resolving every argument before calling the target is the right default, but some targets +need the **config itself** — because they build it later, more than once, or with overrides +they only learn at runtime. A parameter annotated `cfn.Config` declares exactly that: it +receives the config stored in that slot, unresolved. + +```python +# One pipeline config, plus named variants of it. +pipeline = cfn.Config(Pipeline, codec=h264, model_path='base') +droid = pipeline.override(codec=hevc, model_path='GEAR-Dreams/DreamZero-DROID') + +@cfn.config(pipeline=pipeline, host='0.0.0.0', port=8000) +def serve(pipeline: cfn.Config, host: str, port: int): + # `pipeline` is a Config, so the server can rebuild it per connection with the + # overrides that connection asks for. An instantiated pipeline is frozen — there + # would be nothing left to tune. + PolicyServer(pipeline, host=host, port=port).serve() + +cfn.cli({'': serve, 'droid': serve.override(pipeline=droid)}) +``` + +```python +class PolicyServer: + def on_connection(self, params: dict): # params decoded from the request + return self._pipeline_cfg.override_data(**params).instantiate() +``` + +The declaration lives in the **signature**, not at the call site: whether a target wants a +config or a built object is a property of the target, not of each call. Callers keep passing +ordinary values and writing ordinary overrides, so nothing else changes — from Python or +from the command line: + +```bash +python serve.py droid --pipeline.codec.fps=10 # dotted override reaches into it +python serve.py droid --pipeline=@my.pipelines.custom # swaps the whole config +python serve.py droid --help # shows what is inside the pipeline +``` + +`get_required_args` (and so `--help`) still reports required arguments nested inside such a +parameter: the config must be fully specified before whoever holds it instantiates it. + +A few details: + +- `cfn.Config | None` (and `Optional[cfn.Config]`) work too, so the parameter can default to + `None`. `Annotated[cfn.Config, ...]` is recognised as well, as are string annotations + (`from __future__ import annotations`). +- The annotation turns resolution *off* for that argument; it does not demand a `Config`. A + value that is not a config is passed through untouched. +- Only a whole argument can be handed over. A `list[cfn.Config]` annotation is not special — + configs inside containers are still built. +- The target receives the config stored in the slot, not a copy. Derive from it with + `.override()` / `.override_data()`, which copy, rather than mutating it in place. + ## 🌍 Real-World Examples ### Robotics Hardware Configuration @@ -629,6 +683,8 @@ Main configuration class that stores a callable and its arguments. - `copy() -> Config`: Deep copy the configuration - `__call__(**kwargs) -> Any`: `override` config with `**kwargs` and `instantiate` it. **Note:** only keyword specified arguments are supported. +**Target annotations:** a target parameter annotated `cfn.Config` (or `cfn.Config | None`) receives the stored config unresolved instead of what it builds — see [Receiving a config instead of an object](#receiving-a-config-instead-of-an-object). + #### `@config` Decorator ```python @cfn.config # No override, just turn function into config. diff --git a/configuronic/_annotations.py b/configuronic/_annotations.py new file mode 100644 index 0000000..40fc6df --- /dev/null +++ b/configuronic/_annotations.py @@ -0,0 +1,585 @@ +"""Reading a target's signature to find the parameters that ask for a config itself. + +A parameter annotated with a marker class — :class:`configuronic.Config` — declares that +the target wants that object rather than what it builds. Answering "is this annotation the +marker?" is the whole job of this module, and it is harder than it looks: an annotation may +be source text (``from __future__ import annotations``), a name bound only in the class +body it was written in, a forward reference naming its own module, or a ``type`` alias +standing for something else entirely. + +Two rules shape everything here. + +**Resolve an annotation only in the namespace that wrote it.** A stringified annotation is +just text, and the same text means different things in different modules. So the target is +followed to the callable that actually declares the parameter — through ``functools`` +wrappers, to the class body a method was written in, to the base a method was inherited +from — and the annotation is resolved there, not wherever it is convenient. A name that +does not resolve where it was written is unresolved, not something a sibling namespace +happens to define. + +**Never fail, and never run user code to find out.** Classifying a parameter is something +a config does on its way to calling the target, so it must not be what breaks the call. An +annotation can be any object at all, with a hostile ``__eq__`` or ``__getattribute__``; a +target can define ``__signature__`` as a property that raises. Anything that goes wrong +while classifying means "not a marker annotation", never an exception. Annotations are also +resolved one parameter at a time, and only for parameters a config actually supplies, so +evaluating an unrelated annotation cannot import a module or raise on a caller's behalf. + +Both rules are why this is not :func:`typing.get_type_hints`, which resolves every +parameter at once, raises on a name it cannot resolve, and does not see class-body scope. +""" + +from __future__ import annotations + +import functools +import inspect +import sys +from collections.abc import Callable, Mapping +from types import MappingProxyType, UnionType +from typing import Annotated, Any, ForwardRef, NamedTuple, TypeVar, Union, get_args, get_origin + +try: # `type X = ...` aliases (PEP 695), Python 3.12+ + from typing import TypeAliasType +except ImportError: # pragma: no cover - exercised on 3.10 and 3.11 + TypeAliasType = None + +_UNRESOLVED = object() + +# One shared empty scope, so that two sources with nothing local to them are the same +# scope rather than two — which is what :meth:`_Resolution.scope` compares. +_NO_LOCALS: Mapping[str, Any] = MappingProxyType({}) + + +def _partial_method_of(func: Any) -> functools.partialmethod | None: + """The `functools.partialmethod` behind an unbound accessor, if this is one. + + Reaching a ``partialmethod`` through its class hands back a plain function generated + inside ``functools``, which keeps a reference to the real method — under + ``_partialmethod`` up to 3.12 and ``__partialmethod__`` from 3.13. Whichever name it + carries, that reference is where its parameters, and their annotations, come from. + """ + for attribute in ('__partialmethod__', '_partialmethod'): + candidate = getattr(func, attribute, None) + if isinstance(candidate, functools.partialmethod): + return candidate + return None + + +def _signature_chain(func: Any) -> list[Any]: + """Every callable :func:`inspect.signature` passes through on its way to the parameters. + + Through ``functools.partial`` and ``functools.wraps`` wrappers and the function a + ``functools.partialmethod`` generates, none of whose modules know anything about the + wrapped function's names — stopping, as it does, at a callable that declares its own + ``__signature__``, since the parameters then come from the wrapper rather than from + what it wraps. The last link is where the parameters come from; the ones before it + still hold what the target was when it was handed over, such as a binding to an + instance that the function it decorates knows nothing about. + """ + # `inspect.unwrap` guards against a circular `__wrapped__` chain and so does the + # `__signature__` stop below, but a loop that never ends would hang `instantiate()`. + # The objects are held, not just their ids: a hop can mint a transient (a bound method, + # a partial built by a property), and a freed id is reused straight away — the loop + # would then stop at a callable it has never actually seen. + seen: list[Any] = [] + while not any(func is visited for visited in seen): + seen.append(func) + if hasattr(func, '__signature__'): + break + elif isinstance(func, functools.partial): + func = func.func + elif (partial_method := _partial_method_of(func)) is not None: + func = partial_method.func + elif hasattr(func, '__wrapped__'): + func = func.__wrapped__ + else: + break + return seen + + +def _unwrap_signature_source(func: Any) -> Any: + """The callable at the end of the chain — the one that declares the parameters.""" + return _signature_chain(func)[-1] + + +class _AnnotationSource(NamedTuple): + """A namespace an annotation may have been written in, and what its callable declares. + + ``localns`` is the body of the class the callable was defined in, if any: a name bound + there is in scope for an annotation written there, which is how the same annotation + resolves in a module that does *not* postpone evaluation. + + ``rival`` says this callable is one of several that could have supplied the signature — + a class' metaclass ``__call__``, ``__new__`` and ``__init__` are rivals, and which of + them wins is CPython's business. Sources that are not rivals are the same declaration + reached in more than one way, such as a callable object and its class' ``__call__``: + they cannot contradict each other, they can only be more or less specific. + """ + + globalns: dict[str, Any] + localns: Mapping[str, Any] + parameters: dict[str, inspect.Parameter] + rival: bool = False + + +def _declared_parameters(func: Any) -> dict[str, inspect.Parameter]: + try: + return dict(inspect.signature(func).parameters) + except (TypeError, ValueError): + return {} + + +def _defining_class(cls: type, method: str) -> type | None: + """The class in `cls`'s MRO whose body defines `method` — where its annotations live.""" + for klass in inspect.getmro(cls): + if method in vars(klass): + return klass + return None + + +def _class_namespace(cls: type) -> Mapping[str, Any]: + """The body of `cls` as a scope, under the spellings an annotation there would use. + + A name beginning with two underscores is mangled where it is written — ``__Alias`` in + the body of ``Factory`` is stored as ``_Factory__Alias`` — while a postponed + annotation keeps the source text, so resolving it needs the name as written. Mangling + is by the class the body belongs to, with the leading underscores of its own name + dropped, and a class named only of underscores mangles nothing. + """ + stripped = cls.__name__.lstrip('_') + if not stripped: + return vars(cls) + + namespace = dict(vars(cls)) + prefix = f'_{stripped}__' + for name, value in vars(cls).items(): + if name.startswith(prefix): + namespace[f'__{name[len(prefix) :]}'] = value + return namespace + + +def _owning_class(func: Any) -> type | None: + """The class body `func` was written in, for a function that carries no binding. + + A bound method names what it is bound to, but a static method — or any function + reached through the class that defines it — is a plain function, and only its + qualified name says where it was written. + """ + qualname = getattr(func, '__qualname__', '') + globalns = getattr(func, '__globals__', None) + if globalns is None or '.' not in qualname: + return None + + owner: Any = globalns + for part in qualname.split('.')[:-1]: + if part == '': + return None # defined inside a function: that scope is gone, nothing to recover + try: + owner = owner[part] if owner is globalns else getattr(owner, part) + except (KeyError, AttributeError): + return None + return owner if inspect.isclass(owner) else None + + +def _annotation_sources(target: Any) -> list[_AnnotationSource]: + """Where a stringified annotation of `target` may have been written. + + An annotation has to be resolved where it was written, so follow the target to the + callable that declares the parameters. For a class, the reported parameters come from + a declared ``__signature__``, from a metaclass ``__call__``, from ``__new__`` or from + ``__init__``, by rules that depend on which of them the class defines itself — so + offer them all, most specific first, along with what each declares, and let + :func:`_scopes_for` pick. Each can be decorated in turn, so each is followed the same + way. For a callable object the parameters come from its class' ``__call__``, which may + be inherited from a base in another module; the object itself keeps no globals and + falls back to the module its own class came from. + """ + chain = _signature_chain(target) + func = chain[-1] + # Only a class can have rival declarations: one of its candidates supplied the + # signature and the others did not. The two a non-class target offers are the same + # declaration reached differently. + rivals = inspect.isclass(func) + if rivals: + # Each candidate is paired with the class body it was written in — the one that + # defines it, which for an inherited method is a base rather than `func` itself. + candidates = [ + (type(func).__call__, _defining_class(type(func), '__call__')), + (func.__new__, _defining_class(func, '__new__')), + (func.__init__, _defining_class(func, '__init__')), + ] + # A declared signature does not outrank the other three so much as end the question: + # `inspect.signature` reports it, and nothing about what a constructor declares can + # change that. So it goes first and the rivalry is over — the rest stay only as + # namespaces to fall back on. Being a statement in a class body, the body that binds + # `__signature__` is where its annotations were written: the class' own body, or its + # metaclass', which is where the attribute is found when the class defines none. + # Only a real `Signature` counts, since that is the test `inspect.signature` itself + # applies: `__signature__ = None` means the parameters come from the constructor + # after all, and answering for them here would be answering in the wrong body. + if isinstance(getattr(func, '__signature__', None), inspect.Signature): + declared_in = _defining_class(func, '__signature__') + declared_in = declared_in if declared_in is not None else _defining_class(type(func), '__signature__') + if declared_in is not None: + candidates.insert(0, (func, declared_in)) + rivals = False + else: + # A method was written in a class body too. A bound one names what it is bound to; + # a static method (or any function reached through its class) has only its + # qualified name to say where it came from. The binding can sit anywhere in the + # chain rather than at its end — a decorated bound method leads through + # `__wrapped__` to the undecorated function, which knows nothing of the instance — + # so it is looked for from the target inwards. + bound_to = next((link.__self__ for link in chain if hasattr(link, '__self__')), None) + owner = bound_to if inspect.isclass(bound_to) else type(bound_to) if bound_to is not None else None + written_in = _defining_class(owner, getattr(func, '__name__', '')) if owner is not None else None + written_in = written_in if written_in is not None else _owning_class(func) + if isinstance(getattr(func, '__signature__', None), inspect.Signature): + # The parameters come from a declared signature. For anything but a function + # that is a statement in a class body — a callable object's, say — and that + # body is where its annotations were written; a function carries its own. + declared_in = _defining_class(type(func), '__signature__') + written_in = declared_in if declared_in is not None else written_in + candidates = [(type(func).__call__, _defining_class(type(func), '__call__')), (func, written_in)] + + sources: list[_AnnotationSource] = [] + seen: list[tuple[dict[str, Any], type | None]] = [] + for candidate, defined_in in candidates: + candidate = _unwrap_signature_source(candidate) + globalns = getattr(candidate, '__globals__', None) + if globalns is None: + # No globals of its own (a class, a C callable), so the enclosing scope is the + # module of the body it was written in — which is not the candidate's own + # module when that body belongs to a base or to a metaclass. + globalns = getattr(inspect.getmodule(defined_in if defined_in is not None else candidate), '__dict__', None) + if not globalns: + continue + # Compare on what the namespaces are *taken from*: `vars()` hands back a new + # mappingproxy every call, so comparing the mappings by identity never matches. + if any(globalns is known_globals and defined_in is known_class for known_globals, known_class in seen): + continue + seen.append((globalns, defined_in)) + localns = _class_namespace(defined_in) if defined_in is not None else _NO_LOCALS + sources.append(_AnnotationSource(globalns, localns, _declared_parameters(candidate), rivals)) + return sources + + +def _same_annotation(declared: Any, reported: Any) -> bool: + """Is this the same annotation, without running whatever `__eq__` it may define? + + An annotation is any object, and comparing two of them can execute arbitrary code — or + return something that is not a boolean — while we are only reading a signature. The + annotation object itself is carried through to the reported parameter, so identity + answers this; the string form gets a real comparison because it is the one case where + equal-but-distinct objects are plausible, and comparing two strings is safe. + """ + if declared is reported: + return True + return isinstance(declared, str) and isinstance(reported, str) and declared == reported + + +def _scopes_for(sources: list[_AnnotationSource], param: inspect.Parameter) -> list[list[_AnnotationSource]]: + """The scopes that get to answer for this parameter, every one of which must agree. + + Only one of the candidates wrote this parameter, so one that declares it — same name, + same annotation — is the one whose namespace applies, and a name it cannot resolve is + unresolved rather than something a sibling namespace happens to define. Usually exactly + one declares it, and it answers alone. + + When several *rivals* declare it identically, which of them the signature came from is + not knowable from here: the rules are CPython's, they turn on what the class defines + versus inherits, and restating them would risk resolving an ordinary annotation in the + wrong module. So each answers on its own and the parameter only asks for the marker if + they agree. Disagreement means the answer would rest on which candidate was guessed, so + the config is built instead — what happens for every parameter that does not ask, and + the safer way to be wrong: a target handed an object it did not expect fails where it + is called, while one handed a config it did not expect fails somewhere inside itself. + Sources that are not rivals cannot disagree in that sense, so the most specific answers + and the rest stand behind it. + + When none of them declares it (a callable with an explicit ``__signature__``, say) + there is nothing to go on, so they answer together, the first to resolve it winning. + """ + declaring = [ + source + for source in sources + if (declared := source.parameters.get(param.name)) is not None + and _same_annotation(declared.annotation, param.annotation) + ] + if not declaring: + return [sources] + if len(declaring) > 1 and all(source.rival for source in declaring): + return [[source] for source in declaring] + return [[declaring[0]]] + + +def _module_source(module: Any) -> list[_AnnotationSource]: + """The namespace of the module a `ForwardRef` names, if it names one. + + The attribute holds whatever was handed to `ForwardRef` — the module, or its name, + which is what `typing.get_type_hints` looks up in `sys.modules`. + """ + if isinstance(module, str): + module = sys.modules.get(module) + namespace = getattr(module, '__dict__', None) + return [_AnnotationSource(namespace, _NO_LOCALS, {})] if namespace else [] + + +def _resolve_string_annotation(annotation: str, sources: list[_AnnotationSource]) -> Any: + """Evaluate a stringified annotation, or return `_UNRESOLVED` if no namespace can.""" + for source in sources: + try: + return eval(annotation, source.globalns, source.localns) + except Exception: + continue + return _UNRESOLVED + + +def _type_alias(annotation: Any) -> Any | None: + """The `type` alias (PEP 695) this annotation stands for, if it stands for one. + + An alias used bare (``Deferred``) is the alias object itself; one used with arguments + (``Deferred[Config]``) is a generic alias over it, and what it stands for is only + reachable through the alias it wraps. + """ + if TypeAliasType is None: + return None + if isinstance(annotation, TypeAliasType): + return annotation + origin = get_origin(annotation) + return origin if isinstance(origin, TypeAliasType) else None + + +class _Resolution(NamedTuple): + """Following one annotation to whatever it finally stands for. + + ``marker`` is the class a parameter asks for by being annotated with it. ``sources`` + are the namespaces a stringified annotation may be resolved in, from + :func:`_scopes_for`. ``followed`` carries what has already been followed on the way + here — annotation strings, and the identities of ``type`` aliases and type parameters + — because any of them can be written in terms of itself, and nothing that comes round + again is the marker. ``bound`` carries what the arguments of a specialized alias + (``Deferred[Config]``) bind its type parameters to, so a parameter met inside the + alias' value stands for what was passed for it. + """ + + marker: type + sources: list[_AnnotationSource] + followed: frozenset[Any] = frozenset() + bound: Mapping[Any, Any] = MappingProxyType({}) + + def scope(self) -> frozenset[tuple[int, int]]: + """What identifies the namespaces a string would be resolved in from here. + + A spelling means one thing in one scope and something else in another, so it is + only the pair that says whether this has been resolved before. Following an alias + brings the module it was written in along, which is a different scope; the same + text met there is a different name, not a cycle. + + Both halves of each namespace count. A class body and the module around it share + their globals while binding the same name to different things, so recording only + the globals would make those one scope and a name resolved in each of them a cycle. + """ + return frozenset((id(source.globalns), id(source.localns)) for source in self.sources) + + def following(self, step: Any, binding: Any = (), wrote_it: Any = ()) -> _Resolution: + """The same resolution, one step further along. + + ``binding`` is what a specialized alias binds its type parameters to. ``wrote_it`` + is the namespace the step was written in, if the step names one — what is reached + through it was written there too, so that namespace answers first from here on. + """ + return self._replace( + followed=self.followed | {step}, + bound={**self.bound, **dict(binding)}, + sources=[*wrote_it, *self.sources], + ) + + def wants_marker(self, annotation: Any) -> bool: + """Does this annotation ask for the marker itself? + + True for the bare marker and for a union that contains it (``Config | None``), in + either case optionally wrapped in ``Annotated`` or reached through a ``type`` + alias. Containers of configs (``list[Config]``) are deliberately excluded: only a + whole argument can be handed over unresolved, not selected items inside one. + """ + if annotation is inspect.Parameter.empty: + return False + + # Under `from __future__ import annotations` (or when the annotation is quoted) we + # get source text such as 'cfn.Config'. Evaluate it where it was written — what + # `typing.get_type_hints` does, but one parameter at a time, so an unresolvable + # forward reference on an unrelated parameter cannot hide a perfectly good + # annotation here. The decision is then made on the object, so every spelling + # works, including an alias (`from configuronic import Config as C`). + # + # Resolving can yield another string: a quoted annotation in a module that also + # postpones evaluation is stored as the *source text* of the quoted expression, so + # `pipeline: 'cfn.Config'` arrives as "'cfn.Config'". Keep going until it is not a + # string any more — but never revisit one, since module-level names can be defined + # in terms of each other (`A = 'B'`, `B = 'A'`), and no cycle of them is a Config. + # + # A `type X = ...` alias (PEP 695) hides what it stands for behind `__value__`, + # which is evaluated on access and may be the alias itself, so it is followed the + # same way. Given arguments (`type Deferred[T] = T | None` used as + # `Deferred[Config]`), what its value is written in terms of are its type + # parameters, so those are followed too. + state = self + while True: + if isinstance(annotation, ForwardRef | str): + text = annotation.__forward_arg__ if isinstance(annotation, ForwardRef) else annotation + here = (text, state.scope()) + if here in state.followed: + return False + # A `ForwardRef` may name the module it was written in, which is then where + # it is resolved — `typing.get_type_hints` honours that, and so does this. + declared_in = getattr(annotation, '__forward_module__', None) if annotation is not text else None + state = state.following(here, wrote_it=_module_source(declared_in)) + annotation = _resolve_string_annotation(text, state.sources) + if annotation is _UNRESOLVED: + return False + elif (alias := _type_alias(annotation)) is not None: + if id(alias) in state.followed: + return False + # The value was written where the alias was, which is not where it is + # used: `type Lazy = 'C'` names something its importer need not have. + state = state.following( + id(alias), + zip(alias.__type_params__, get_args(annotation), strict=False), + _module_source(getattr(alias, '__module__', None)), + ) + try: + annotation = alias.__value__ + except Exception: + return False + elif isinstance(annotation, TypeVar) and annotation in state.bound: + if id(annotation) in state.followed: + return False + substituted = state.bound[annotation] + state = state.following(id(annotation)) + annotation = substituted + else: + break + + # An annotation can be any object, so ask typing what this one is rather than + # reading attributes off it: something carrying a `__metadata__` is not `Annotated`. + origin = get_origin(annotation) + + if origin is Annotated: # Annotated[Config, ...] + return state.wants_marker(annotation.__origin__) + + if annotation is state.marker: + return True + + if origin is Union or origin is UnionType: + return any(state.wants_marker(arg) for arg in get_args(annotation)) + + return False + + +class _Declaration: + """Which of a target's parameters ask for the marker itself, answered on demand. + + A parameter's annotation is only resolved when a config actually supplies that + parameter. Resolving one means evaluating whatever expression was written there, which + can import a module or run code of its own, and a config has no business causing that + for parameters it never sets. Answers are remembered for as long as this object lives, + which is the one lookup it was made for. + + Reading a signature must never be what breaks `instantiate()`: a target free to define + `__signature__`, `__getattr__` or `__class__` however it likes can make introspection + raise anything at all, and a parameter that cannot be classified simply does not ask + for the marker. + """ + + __slots__ = ( + '_answered', + '_keywords', + '_marker', + '_positional', + '_sources', + '_target', + '_var_keyword', + '_var_positional', + ) + + def __init__(self, target: Any, marker: type): + self._target = target + self._marker = marker + self._sources: list[_AnnotationSource] | None = None + self._answered: dict[str, bool] = {} + self._positional: list[inspect.Parameter] = [] + self._keywords: dict[str, inspect.Parameter] = {} + self._var_positional: inspect.Parameter | None = None + self._var_keyword: inspect.Parameter | None = None + + try: + parameters = inspect.signature(target).parameters + except Exception: + # Builtins and other C callables often have no introspectable signature; an + # exotic one can raise anything. Then nothing is classified, so nothing asks. + return + + for name, param in parameters.items(): + if param.kind is inspect.Parameter.VAR_POSITIONAL: + self._var_positional = param + elif param.kind is inspect.Parameter.VAR_KEYWORD: + self._var_keyword = param + else: + if param.kind is not inspect.Parameter.KEYWORD_ONLY: + self._positional.append(param) + if param.kind is not inspect.Parameter.POSITIONAL_ONLY: + self._keywords[name] = param + + def positional(self, index: int) -> bool: + """Does the parameter filled by positional argument `index` ask for the marker?""" + if index < len(self._positional): + return self._asks(self._positional[index]) + return self._var_positional is not None and self._asks(self._var_positional) + + def keyword(self, name: str) -> bool: + """Does the parameter named `name` — or the `**kwargs` collecting it — ask for it?""" + param = self._keywords.get(name, self._var_keyword) + return param is not None and self._asks(param) + + def _asks(self, param: inspect.Parameter) -> bool: + answer = self._answered.get(param.name) + if answer is None: + try: + if param.annotation is inspect.Parameter.empty: + # Nothing was declared, so there is nothing to resolve and no reason to + # go looking for the namespaces it would have been resolved in. + answer = False + else: + if self._sources is None: + self._sources = _annotation_sources(self._target) + # Every scope that could have written the parameter has to agree, so an + # answer that rests on which candidate was guessed is declined. + answer = all( + _Resolution(self._marker, scope).wants_marker(param.annotation) + for scope in _scopes_for(self._sources, param) + ) + except Exception: + answer = False + self._answered[param.name] = answer + return answer + + +def declarations_for(marker: type) -> Callable[[Any], _Declaration]: + """A lookup of which of a target's parameters ask for `marker` itself. + + The marker is a parameter rather than an import so that this module stays a reader of + signatures, with nothing to say about what the class it looks for means. + + Nothing is remembered between lookups. Reading a signature and resolving annotations + costs far more than instantiating a small config, and holding the answer would be the + obvious trade — but a target is free to change what it declares, and an answer kept + from before that is silently wrong in a way nothing reveals. Answers are remembered + for the length of one lookup, so a target with several such parameters reads its + signature once, and no longer. + """ + + def declaration_for(target: Any) -> _Declaration: + return _Declaration(target, marker) + + return declaration_for diff --git a/configuronic/config.py b/configuronic/config.py index b044a4c..d2daa0e 100644 --- a/configuronic/config.py +++ b/configuronic/config.py @@ -10,6 +10,8 @@ import yaml +from . import _annotations + INSTANTIATE_PREFIX = '@' RELATIVE_PATH_PREFIX = '.' @@ -362,7 +364,7 @@ def __init__(self, target, *args, **kwargs): self._creator_module = _get_creator_module() - def override(self, **overrides) -> Config: + def override(self, /, **overrides) -> Config: """ Create a new Config with updated parameters. @@ -423,7 +425,7 @@ def override(self, **overrides) -> Config: return overriden_cfg - def override_data(self, **overrides) -> Config: + def override_data(self, /, **overrides) -> Config: """ Like :meth:`override`, but values are interpreted strictly as data. @@ -535,6 +537,20 @@ def instantiate(self) -> Any: the dependents from it, rather than pointing several slots at the same sub-config. + A target can opt out of resolution for a particular argument by annotating that + parameter :class:`Config`: it then receives the stored config itself, not what it + builds. That is for targets that need to build it later, more than once, or with + overrides they only learn at runtime — a server applying per-request overrides, + for instance. Everything else about the argument is unchanged: it is an ordinary + config, so ``.override()`` (including dotted keys reaching into it) and ``--help`` + keep working. + + Such an argument is the one thing here that is *not* built afresh: the target is + handed the config held in that slot, the same object on every call, so treat it as + read-only and derive from it with ``.override()`` / ``.override_data()``, which + copy. That is deliberate — the config is what the target asked for, and copying it + on the way past would only be undone by the first override the target applies. + Returns: The instantiated target function. @@ -572,11 +588,24 @@ def _instantiate_value(value, key, path): else: raise ConfigError(f'Error instantiating "{path}{key}": {e}') from e + # A parameter annotated `Config` declares that the target wants the config object + # itself rather than what it builds — because it instantiates it later, more than + # once, or with overrides it only learns at runtime. Whether a target wants a config + # or an object is a property of the target, so the declaration lives in its + # signature: callers keep writing ordinary values and ordinary overrides. + wants_config = _config_parameters(self.target) + # Recursively instantiate any Config objects in args - instantiated_args = [_instantiate_value(arg, key, path) for key, arg in enumerate(self.args)] + instantiated_args = [ + arg if wants_config.positional(index) else _instantiate_value(arg, index, path) + for index, arg in enumerate(self.args) + ] # Recursively instantiate any Config objects in kwargs - instantiated_kwargs = {key: _instantiate_value(value, key, path) for key, value in self.kwargs.items()} + instantiated_kwargs = { + key: value if wants_config.keyword(key) else _instantiate_value(value, key, path) + for key, value in self.kwargs.items() + } return self.target(*instantiated_args, **instantiated_kwargs) @@ -619,7 +648,7 @@ def _copy(self): cfg._creator_module = self._creator_module return cfg - def __call__(self, **kwargs): + def __call__(self, /, **kwargs): """ Override the config with the given kwargs and instantiate the config. @@ -645,6 +674,12 @@ def __call__(self, **kwargs): return self.override(**kwargs).instantiate() +# Which of a target's parameters ask for the config itself rather than what it builds, read +# from the target's signature. `Config` is handed over rather than imported there, so that +# reading signatures stays a job with nothing to say about what a config means. +_config_parameters = _annotations.declarations_for(Config) + + def config(**kwargs) -> Callable[[Callable], Config]: """ Decorator to create a Config object. diff --git a/pyproject.toml b/pyproject.toml index 8a9ec23..77e3c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "configuronic" -version = "0.6.0" +version = "0.7.0" description = "Simple yet powerful \"Configuration as Code\" library" readme = "README.md" license = {file = "LICENSE.md"} diff --git a/tests/support_package/lazy_alias_shadow.py b/tests/support_package/lazy_alias_shadow.py new file mode 100644 index 0000000..533df45 --- /dev/null +++ b/tests/support_package/lazy_alias_shadow.py @@ -0,0 +1,13 @@ +"""A module naming an imported alias `C` — the very spelling that alias' value quotes. + +`C` is the alias here and `Config` in the module the alias came from, so the same text +appears twice on the way to the answer while meaning something different each time. +""" + +from __future__ import annotations + +from tests.support_package.lazy_annotations import QUOTED_VALUE_ALIAS as C + + +def shadowed_spelling(pipeline: C): + return pipeline diff --git a/tests/support_package/lazy_annotations.py b/tests/support_package/lazy_annotations.py new file mode 100644 index 0000000..9bcf2ad --- /dev/null +++ b/tests/support_package/lazy_annotations.py @@ -0,0 +1,218 @@ +"""Targets whose annotations reach configuronic as source text. + +``from __future__ import annotations`` turns every annotation in this module into a +string, which is the shape configuronic must still recognise when deciding that a +parameter wants the ``Config`` itself (issue #38). +""" + +from __future__ import annotations + +import functools +import typing + +import configuronic as cfn +from configuronic import Config +from configuronic import Config as C +from tests.support_package import lazy_broken_metaclass, lazy_sibling_namespace, lazy_wrappers + + +class Pipeline: + def __init__(self, fps: int = 30): + self.fps = fps + + +def aliased_spelling(pipeline: cfn.Config, host: str = 'localhost'): + return pipeline, host + + +def bare_spelling(pipeline: Config, host: str = 'localhost'): + return pipeline, host + + +def quoted_spelling(pipeline: 'cfn.Config', host: str = 'localhost'): # noqa: UP037 - the redundant quotes are the point + """Quoted *and* postponed: the annotation is stored as the text ``"'cfn.Config'"``.""" + return pipeline, host + + +def alias_spelling(pipeline: C, host: str = 'localhost'): + """The class under a local alias: nothing about the annotation says "Config".""" + return pipeline, host + + +def optional_spelling(pipeline: Config | None = None): + return pipeline + + +def with_unresolvable_neighbour(pipeline: cfn.Config, other: NeverDefined = None): # noqa: F821 + """A parameter whose annotation cannot be resolved must not hide this one's.""" + return pipeline, other + + +def resolving_target(pipeline: Pipeline): + return pipeline + + +# A `type` alias whose value is a forward reference to a name only this module binds. The +# module that annotates with it need not have heard of `C`. +QUOTED_VALUE_ALIAS = typing.TypeAliasType('QuotedValue', 'C') if hasattr(typing, 'TypeAliasType') else None + + +# Module-level names defined in terms of each other: resolving one leads back to it. +CYCLIC_ALIAS = 'OTHER_CYCLIC_ALIAS' +OTHER_CYCLIC_ALIAS = 'CYCLIC_ALIAS' + + +def cyclic_alias_spelling(pipeline: CYCLIC_ALIAS): + return pipeline + + +class Server: + """A class target whose `__init__` annotations are postponed too.""" + + def __init__(self, pipeline: cfn.Config, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + +class ClassBodyAlias: + """The alias is bound in the class body, which is where the annotation was written.""" + + Alias = cfn.Config + + def __init__(self, pipeline: Alias, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + def build(self, pipeline: Alias): + """A bound method target — the alias is in this class body too.""" + return pipeline + + @staticmethod + def make(pipeline: Alias): + """A static method target: a plain function, reached through the class.""" + return pipeline + + +class UnresolvableInit: + """Its constructor names something no scope it can see binds — deliberately broken.""" + + def __init__(self, pipeline: Alias): # noqa: F821 - unresolvable here, deliberately + self.pipeline = pipeline + + +class PrivateClassBodyAlias: + """The alias is private, so the class body holds it under a name nothing else uses.""" + + __Alias = cfn.Config + + def __init__(self, pipeline: __Alias, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + +class InheritsClassBodyAlias(ClassBodyAlias): + """Inherits the annotated `__init__`; the alias is in the base's body, not this one.""" + + +def make_local_alias_holder(): + """An instance of a class defined inside a function, whose body binds the alias. + + Once this call has returned there is no way to reach the class from a qualified name, + so a target has to carry the binding to it — which a decorator must not lose. + """ + + class LocalAlias: + Alias = cfn.Config + + @lazy_wrappers.passthrough + def build(self, pipeline: Alias): + return pipeline + + return LocalAlias() + + +class PartialFactory: + """Reached through the class, `configured` is a function generated inside functools.""" + + def build(self, extra: str, pipeline: C): + return pipeline, extra + + configured = functools.partialmethod(build, 'bound-extra') + + +class CallableShadowingTheAlias: + """Binds `C` in its body to the alias whose own value quotes the module's `C`. + + The two spellings are the same text in scopes that share their globals, so telling + them apart takes more than the module they are resolved against. + """ + + C = QUOTED_VALUE_ALIAS + + def __call__(self, pipeline: C): + return pipeline + + +class CallableBase: + """A callable base class, subclassed in modules that never heard of the name `C`.""" + + def __call__(self, pipeline: C, host: str = 'localhost'): + return pipeline, host + + +class DecoratedInit: + """A class whose `__init__` is decorated from a module that knows nothing about `cfn`.""" + + @lazy_wrappers.passthrough + def __init__(self, pipeline: cfn.Config, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + +class Factory: + """A class whose signature comes from `__new__`; its `__init__` is `object.__init__`.""" + + def __new__(cls, pipeline: cfn.Config, host: str = 'localhost'): + instance = super().__new__(cls) + instance.pipeline = pipeline + instance.host = host + return instance + + +class Building(type): + def __call__(cls, pipeline: cfn.Config, host: str = 'localhost'): + return pipeline, host + + +class Built(metaclass=Building): + """A class whose signature comes from its metaclass' `__call__`.""" + + +class BuiltByBrokenMeta(metaclass=lazy_broken_metaclass.BrokenAnnotationMeta): + """`C` means `Config` here, but `pipeline` was written in the metaclass' module.""" + + def __init__(self, other: C): + self.other = other + + +class ForwardingMeta(type): + """Declares the same parameter as the constructor it forwards to, meaning the same.""" + + def __call__(cls, pipeline: C, host: str = 'localhost'): + return super().__call__(pipeline, host) + + +class BuiltByForwardingMeta(metaclass=ForwardingMeta): + """Two rival declarations of `pipeline: C`, both written where `C` is `Config`.""" + + def __init__(self, pipeline: C, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + +class BuiltByMetaDeclaringTheSameParameter(metaclass=lazy_sibling_namespace.SameParameterMeta): + """Declares `pipeline: C` too — identically to the metaclass that wrote the signature.""" + + def __init__(self, pipeline: C, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host diff --git a/tests/support_package/lazy_broken_metaclass.py b/tests/support_package/lazy_broken_metaclass.py new file mode 100644 index 0000000..c1763e6 --- /dev/null +++ b/tests/support_package/lazy_broken_metaclass.py @@ -0,0 +1,13 @@ +"""A metaclass whose `__call__` annotation cannot be resolved in its own module. + +The class built with it lives in a module where that same name *does* mean `Config`, which +is where "resolve an annotation only where it was written" becomes visible: the annotation +is broken, and a sibling namespace must not answer for it. +""" + +from __future__ import annotations + + +class BrokenAnnotationMeta(type): + def __call__(cls, pipeline: C, host: str = 'localhost'): # noqa: F821 - unresolvable here, deliberately + return pipeline, host diff --git a/tests/support_package/lazy_declared_signature.py b/tests/support_package/lazy_declared_signature.py new file mode 100644 index 0000000..b5a3801 --- /dev/null +++ b/tests/support_package/lazy_declared_signature.py @@ -0,0 +1,90 @@ +"""Targets that declare their own `__signature__`. + +`inspect.signature` stops unwrapping at a `__signature__` and reports the parameters +declared there, so their annotations belong where the declaration was written — this +module for the wrapper below, and the class body for the class further down. +""" + +import functools +import inspect + +from configuronic import Config as C +from tests.support_package import lazy_annotations, lazy_signature_metaclass + + +def with_declared_signature(func): + """The wrapped function's module knows nothing about the name `C`.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + wrapper.__signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='C') + ]) + return wrapper + + +class TakesAPipeline: + """Provides the constructor, and annotates nothing.""" + + def __init__(self, pipeline): + self.pipeline = pipeline + + +class DeclaresItsOwnSignature(TakesAPipeline): + """Inherits that constructor, so only the signature declared here has the parameters. + + `Alias` is bound in this class body — the same body the declaration is written in, and + the only scope in which its annotation means anything. + """ + + Alias = C + + __signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='Alias') + ]) + + +class BuiltByMetaclassDeclaringInItsBody(TakesAPipeline, metaclass=lazy_signature_metaclass.DeclaresInItsBody): + """Declares no signature of its own, so the one found through the metaclass is used. + + The name that signature annotates with is bound in the metaclass body, and nothing in + *this* module binds it. + """ + + +class BuiltByMetaclassDeclaringFromItsModule(TakesAPipeline, metaclass=lazy_signature_metaclass.DeclaresFromItsModule): + """The same, with the name bound at module level beside the metaclass.""" + + +class DeclaresOverUnresolvableInit(lazy_annotations.UnresolvableInit): + """Declares its own signature, and inherits an `__init__` declaring the same thing. + + The inherited constructor spells `pipeline: Alias` where nothing binds `Alias`; this + body does bind it. Only one of the two is what `inspect.signature` reports, and it is + not the constructor. + """ + + Alias = C + + __signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='Alias') + ]) + + +class CallableDeclaringItsSignature: + """A callable object whose class declares the signature, naming a name bound here. + + The declaration is a statement in this body, so this body is the scope it was written + against — the instance itself carries no namespace at all. + """ + + Alias = C + + __signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='Alias') + ]) + + def __call__(self, pipeline): + return pipeline diff --git a/tests/support_package/lazy_rival_declarations.py b/tests/support_package/lazy_rival_declarations.py new file mode 100644 index 0000000..3250e0e --- /dev/null +++ b/tests/support_package/lazy_rival_declarations.py @@ -0,0 +1,18 @@ +"""A class whose constructor cannot resolve what the metaclass building it can. + +The mirror of `lazy_sibling_namespace`: there the metaclass' annotation is the broken one, +here it is the class'. Which of the two supplied the signature is CPython's business, so +neither ordering may be what decides the answer. +""" + +from __future__ import annotations + +from tests.support_package import lazy_annotations + + +class BuiltByResolvableMeta(metaclass=lazy_annotations.ForwardingMeta): + """`C` is `Config` where the metaclass wrote `pipeline: C`, and nothing here.""" + + def __init__(self, pipeline: C, host: str = 'localhost'): # noqa: F821 - unresolvable here, deliberately + self.pipeline = pipeline + self.host = host diff --git a/tests/support_package/lazy_sibling_namespace.py b/tests/support_package/lazy_sibling_namespace.py new file mode 100644 index 0000000..8439540 --- /dev/null +++ b/tests/support_package/lazy_sibling_namespace.py @@ -0,0 +1,13 @@ +"""A metaclass whose `__call__` declares the same parameter as the class' `__init__`. + +Both spell the annotation `C`, but only the class' module binds that name — so this is +where "the namespace that wrote the parameter answers for it" has to hold even when a +sibling candidate declares an identical-looking one. +""" + +from __future__ import annotations + + +class SameParameterMeta(type): + def __call__(cls, pipeline: C, host: str = 'localhost'): # noqa: F821 - unresolvable here, deliberately + return pipeline, host diff --git a/tests/support_package/lazy_signature_metaclass.py b/tests/support_package/lazy_signature_metaclass.py new file mode 100644 index 0000000..a5689e6 --- /dev/null +++ b/tests/support_package/lazy_signature_metaclass.py @@ -0,0 +1,27 @@ +"""Metaclasses that declare the signature of the classes they build. + +`inspect.signature` finds `__signature__` through the metaclass when the class itself does +not define one, so the annotations in it were written *here* — in a metaclass body, or at +module level beside it. The classes built by these live in another module, which binds +neither name. +""" + +import inspect + +import configuronic as cfn + +ModuleAlias = cfn.Config + + +class DeclaresInItsBody(type): + Alias = cfn.Config + + __signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='Alias') + ]) + + +class DeclaresFromItsModule(type): + __signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation='ModuleAlias') + ]) diff --git a/tests/support_package/lazy_wrappers.py b/tests/support_package/lazy_wrappers.py new file mode 100644 index 0000000..49c5f27 --- /dev/null +++ b/tests/support_package/lazy_wrappers.py @@ -0,0 +1,21 @@ +"""A decorator that lives away from the functions it wraps. + +`functools.wraps` keeps the wrapper's own globals while `inspect.signature` reports the +wrapped function's parameters, so a wrapped target's string annotations have to be +resolved where they were written. This module deliberately never imports configuronic. +""" + +import functools + + +def passthrough(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def echo(pipeline): + """Unannotated, so a wrapper's declared signature is the only one there is.""" + return pipeline diff --git a/tests/test_lazy_config_params.py b/tests/test_lazy_config_params.py new file mode 100644 index 0000000..98b088f --- /dev/null +++ b/tests/test_lazy_config_params.py @@ -0,0 +1,910 @@ +"""Tests for parameters that receive a `Config` instead of its instantiation. + +See issue #38: a target that has to build a config later — more than once, or with +overrides it only learns at runtime — says so by annotating that parameter `cfn.Config`. +Everything else stays as it was: the stored value is an ordinary `Config`, so overrides +and traversal keep working. +""" + +import functools +import inspect +import typing +from typing import Annotated, Optional + +import pytest + +import configuronic as cfn +from tests.support_package import ( + lazy_alias_shadow, + lazy_annotations, + lazy_declared_signature, + lazy_rival_declarations, + lazy_wrappers, +) + + +class Codec: + def __init__(self, fps: int = 30): + self.fps = fps + + +class Pipeline: + def __init__(self, codec: Codec, model_path: str = 'base'): + self.codec = codec + self.model_path = model_path + + +class Config: + """A class that happens to be named `Config` but is not configuronic's.""" + + def __init__(self, value: int = 0): + self.value = value + + +pipeline_cfg = cfn.Config(Pipeline, codec=cfn.Config(Codec)) + + +@cfn.config(pipeline=pipeline_cfg, host='localhost') +def serve(pipeline: cfn.Config, host: str): + return pipeline, host + + +# --- the annotation decides ----------------------------------------------------------- + + +def test_annotated_parameter_receives_the_config(): + received, host = serve.instantiate() + + assert isinstance(received, cfn.Config) + assert received.target is Pipeline + assert host == 'localhost' + + +def test_unannotated_parameter_still_resolves(): + @cfn.config(pipeline=pipeline_cfg) + def unannotated(pipeline): + return pipeline + + assert isinstance(unannotated.instantiate(), Pipeline) + + +def test_differently_annotated_parameter_still_resolves(): + @cfn.config(pipeline=pipeline_cfg) + def annotated_with_target(pipeline: Pipeline): + return pipeline + + assert isinstance(annotated_with_target.instantiate(), Pipeline) + + +def test_other_parameters_of_the_same_target_still_resolve(): + @cfn.config(lazy=pipeline_cfg, eager=pipeline_cfg) + def both(lazy: cfn.Config, eager): + return lazy, eager + + lazy, eager = both.instantiate() + + assert isinstance(lazy, cfn.Config) + assert isinstance(eager, Pipeline) + + +def test_unrelated_class_named_config_is_not_lazy(): + # The dispatch is on configuronic's Config, not on the name. + @cfn.config(thing=cfn.Config(Config, value=7)) + def unrelated(thing: Config): + return thing + + instance = unrelated.instantiate() + + assert isinstance(instance, Config) + assert instance.value == 7 + + +def test_unrelated_class_named_config_is_not_lazy_when_quoted(): + @cfn.config(thing=cfn.Config(Config, value=7)) + def unrelated(thing: 'Config'): + return thing + + assert isinstance(unrelated.instantiate(), Config) + + +def test_quoted_annotation_is_recognised(): + @cfn.config(pipeline=pipeline_cfg) + def quoted(pipeline: 'cfn.Config'): + return pipeline + + assert isinstance(quoted.instantiate(), cfn.Config) + + +def test_optional_annotation_is_recognised(): + @cfn.config(pipeline=pipeline_cfg) + def optional(pipeline: cfn.Config | None = None): + return pipeline + + @cfn.config(pipeline=pipeline_cfg) + def typing_optional(pipeline: Optional[cfn.Config] = None): # noqa: UP045 - the typing spelling is the point + return pipeline + + assert isinstance(optional.instantiate(), cfn.Config) + assert isinstance(typing_optional.instantiate(), cfn.Config) + + +def test_annotated_wrapper_is_recognised(): + @cfn.config(pipeline=pipeline_cfg) + def documented(pipeline: Annotated[cfn.Config, 'built per request']): + return pipeline + + assert isinstance(documented.instantiate(), cfn.Config) + + +def test_container_annotation_keeps_resolving(): + # v1 hands over whole arguments only; a list of configs is still built, as before. + @cfn.config(pipelines=[pipeline_cfg, pipeline_cfg]) + def many(pipelines: list[cfn.Config]): + return pipelines + + assert [type(item) for item in many.instantiate()] == [Pipeline, Pipeline] + + +def test_non_config_value_on_a_lazy_parameter_passes_through(): + @cfn.config(pipeline=None) + def nothing_stored(pipeline: cfn.Config | None): + return pipeline + + @cfn.config(pipeline='not-a-config') + def plain_value(pipeline: cfn.Config): + return pipeline + + assert nothing_stored.instantiate() is None + assert plain_value.instantiate() == 'not-a-config' + + +# --- string annotations (PEP 563) ----------------------------------------------------- + + +def test_postponed_annotation_with_alias_spelling(): + cfg = cfn.Config(lazy_annotations.aliased_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + received, _ = cfg.instantiate() + + assert isinstance(received, cfn.Config) + + +def test_postponed_annotation_with_bare_spelling(): + cfg = cfn.Config(lazy_annotations.bare_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + received, _ = cfg.instantiate() + + assert isinstance(received, cfn.Config) + + +def test_postponed_quoted_annotation(): + # Quoting an annotation in a module that also postpones evaluation stores the *source + # text* of the quoted expression, so it takes one more round to resolve. + cfg = cfn.Config(lazy_annotations.quoted_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + received, _ = cfg.instantiate() + + assert isinstance(received, cfn.Config) + + +def test_postponed_annotation_on_a_class_target(): + server = cfn.Config(lazy_annotations.Server, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(server.pipeline, cfn.Config) + assert isinstance(server.pipeline.instantiate(), lazy_annotations.Pipeline) + + +def test_postponed_alias_spelling(): + # `from configuronic import Config as C`: the annotation reads 'C', so only evaluating + # it — rather than pattern-matching the source text — can tell what it means. + cfg = cfn.Config(lazy_annotations.alias_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + received, _ = cfg.instantiate() + + assert isinstance(received, cfn.Config) + + +def test_postponed_annotation_on_a_class_with_a_decorated_init(): + # `inspect.signature` reports the wrapped `__init__`'s parameters, so the annotation + # belongs to the module that wrote it, not to the decorator's. + server = cfn.Config(lazy_annotations.DecoratedInit, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(server.pipeline, cfn.Config) + + +def test_postponed_annotation_on_a_class_built_by_new(): + factory = cfn.Config(lazy_annotations.Factory, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(factory.pipeline, cfn.Config) + assert isinstance(factory.pipeline.instantiate(), lazy_annotations.Pipeline) + + +def test_postponed_annotation_on_a_class_built_by_a_metaclass(): + received, host = cfn.Config(lazy_annotations.Built, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(received, cfn.Config) + assert host == 'localhost' + + +def test_cyclic_alias_spelling_is_not_lazy(): + # Module-level names defined in terms of each other resolve to each other forever. + # No such cycle is a Config, and finding that out must not blow the stack. + cfg = cfn.Config(lazy_annotations.cyclic_alias_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), lazy_annotations.Pipeline) + + +def test_postponed_annotation_using_a_class_body_alias(): + # The name is bound in the class body, so it is in scope where the annotation was + # written — as it plainly is when the module does not postpone evaluation. + server = cfn.Config(lazy_annotations.ClassBodyAlias, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(server.pipeline, cfn.Config) + + +def test_postponed_annotation_using_a_private_class_body_alias(): + # A private name is mangled where it is written, so the class body holds + # `_PrivateClassBodyAlias__Alias` while the postponed annotation still reads + # `__Alias`. Without postponed evaluation the same source resolves. + target = lazy_annotations.PrivateClassBodyAlias + server = cfn.Config(target, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(server.pipeline, cfn.Config) + + +def test_postponed_annotation_using_an_inherited_class_body_alias(): + # The `__init__` is inherited, so the body that binds the alias is the base's. + cfg = cfn.Config(lazy_annotations.InheritsClassBodyAlias, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate().pipeline, cfn.Config) + + +def test_partialmethod_target_resolves_the_methods_annotations(): + # Through the class it is a function generated inside functools, which knows nothing + # about the names the real method's annotations use; bound, it is a plain partial. + factory = lazy_annotations.PartialFactory() + + unbound, extra = cfn.Config(lazy_annotations.PartialFactory.configured, factory, pipeline_cfg).instantiate() + bound, _ = cfn.Config(factory.configured, pipeline_cfg).instantiate() + + assert isinstance(unbound, cfn.Config) + assert isinstance(bound, cfn.Config) + assert extra == 'bound-extra' + + +def test_static_method_target_resolves_a_class_body_alias(): + # A static method is a plain function with no binding, so only its qualified name says + # which class body it was written in. + cfg = cfn.Config(lazy_annotations.ClassBodyAlias.make, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +def test_bound_method_target_resolves_a_class_body_alias(): + holder = lazy_annotations.ClassBodyAlias(pipeline=None) + + cfg = cfn.Config(holder.build, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +def test_decorated_bound_method_keeps_what_it_is_bound_to(): + # The class is defined inside a function, so its body is reachable only through the + # binding the target carries — and a decorator leads through `__wrapped__` to a + # function that knows nothing about the instance. + holder = lazy_annotations.make_local_alias_holder() + + cfg = cfn.Config(holder.build, pipeline=cfn.Config(lazy_annotations.Pipeline)) + # Wrapped again, so the binding sits in the middle of the chain rather than at its head. + partially_applied = cfn.Config(functools.partial(holder.build), pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), cfn.Config) + assert isinstance(partially_applied.instantiate(), cfn.Config) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_type_alias_is_followed(): + deferred = typing.TypeAliasType('Deferred', cfn.Config) + maybe_deferred = typing.TypeAliasType('MaybeDeferred', cfn.Config | None) + + @cfn.config(pipeline=pipeline_cfg) + def via_alias(pipeline: deferred): + return pipeline + + @cfn.config(pipeline=pipeline_cfg) + def via_optional_alias(pipeline: maybe_deferred): + return pipeline + + assert isinstance(via_alias.instantiate(), cfn.Config) + assert isinstance(via_optional_alias.instantiate(), cfn.Config) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_type_alias_whose_value_is_a_forward_reference_resolves_where_it_was_written(): + # `type QuotedValue = 'C'` hands back the string `'C'`, which names something bound in + # the module the alias was written in — not in this one, which only imported the alias. + @cfn.config(pipeline=pipeline_cfg) + def via_quoted_alias(pipeline: lazy_annotations.QUOTED_VALUE_ALIAS): + return pipeline + + assert isinstance(via_quoted_alias.instantiate(), cfn.Config) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_alias_value_reusing_the_annotation_spelling_is_not_a_cycle(): + # `pipeline: C` is stored as the text 'C', which here names an alias whose own value is + # the text 'C' — the name it means in the module it was written in. The same spelling + # twice, in two scopes, is two names rather than a cycle. + cfg = cfn.Config(lazy_alias_shadow.shadowed_spelling, pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_repeated_spelling_in_a_class_body_and_its_module_is_not_a_cycle(): + # `pipeline: C` resolves in the class body to an alias whose value is the text 'C', + # which means the module's `C`. The two scopes share their globals and differ only in + # what is local to them, so that is what has to tell the second lookup from a cycle. + cfg = cfn.Config(lazy_annotations.CallableShadowingTheAlias(), pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_specialized_type_alias_is_followed(): + # `type Deferred[T] = T | None` used as `Deferred[Config]` is a generic alias over the + # alias, not the alias itself, and what it stands for is written in terms of `T`. + parameter = typing.TypeVar('T') + identity = typing.TypeAliasType('Identity', parameter, type_params=(parameter,)) + deferred = typing.TypeAliasType('Deferred', parameter | None, type_params=(parameter,)) + marked = typing.TypeAliasType('Marked', typing.Annotated[parameter, 'note'], type_params=(parameter,)) + + @cfn.config(pipeline=pipeline_cfg) + def via_identity(pipeline: identity[cfn.Config]): + return pipeline + + @cfn.config(pipeline=pipeline_cfg) + def via_optional(pipeline: deferred[cfn.Config]): + return pipeline + + @cfn.config(pipeline=pipeline_cfg) + def via_annotated(pipeline: marked[cfn.Config]): + return pipeline + + @cfn.config(pipeline=pipeline_cfg) + def specialized_with_something_else(pipeline: identity[int]): + return pipeline + + assert isinstance(via_identity.instantiate(), cfn.Config) + assert isinstance(via_optional.instantiate(), cfn.Config) + assert isinstance(via_annotated.instantiate(), cfn.Config) + assert isinstance(specialized_with_something_else.instantiate(), Pipeline) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_specialized_type_alias_of_a_container_is_not_lazy(): + # `Boxed[Config]` stands for `list[Config]`: a container of configs resolves as before, + # since only a whole argument can be handed over unresolved. + parameter = typing.TypeVar('T') + boxed = typing.TypeAliasType('Boxed', list[parameter], type_params=(parameter,)) + + @cfn.config(pipeline=[pipeline_cfg]) + def via_container_alias(pipeline: boxed[cfn.Config]): + return pipeline + + assert [type(item) for item in via_container_alias.instantiate()] == [Pipeline] + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_type_parameter_bound_to_itself_is_not_lazy(): + # Specializing an alias with its own type parameter binds `T` to `T`; substituting it + # must stop rather than spin. + parameter = typing.TypeVar('T') + self_bound = typing.TypeAliasType('SelfBound', parameter, type_params=(parameter,)) + + @cfn.config(pipeline=pipeline_cfg) + def via_self_bound(pipeline: self_bound[parameter]): + return pipeline + + assert isinstance(via_self_bound.instantiate(), Pipeline) + + +@pytest.mark.skipif(not hasattr(typing, 'TypeAliasType'), reason='`type X = ...` aliases are 3.12+') +def test_self_referential_type_alias_is_not_lazy(): + # `type SelfRef = SelfRef` evaluates to itself; following it must stop, not recurse. + # Written through exec so the 3.12 syntax never reaches the parser on older versions. + namespace: dict = {} + exec('type SelfRef = SelfRef', namespace) # noqa: S102 - the point is the 3.12-only syntax + self_referential = namespace['SelfRef'] + + @cfn.config(pipeline=pipeline_cfg) + def via_self_reference(pipeline: self_referential): + return pipeline + + assert isinstance(via_self_reference.instantiate(), Pipeline) + + +def test_annotation_is_not_resolved_in_a_sibling_namespace(): + # `pipeline` is written in the metaclass' module and cannot be resolved there. The + # class' own module binds that name to `Config`, but it did not write the parameter, + # so it does not get to answer for it: the annotation is simply unresolved. + cfg = cfn.Config(lazy_annotations.BuiltByBrokenMeta, pipeline=pipeline_cfg) + + received, _ = cfg.instantiate() + + assert isinstance(received, Pipeline) + + +def test_rival_declarations_that_disagree_do_not_answer(): + # Two candidates declare `pipeline: C` identically and mean different things by it: + # one resolves `C` to `Config`, the other cannot resolve it at all. Which of them the + # signature came from is CPython's business, so neither gets to decide — the config is + # built, as it is for any parameter that does not ask for one. + # + # Both orderings, because the answer must not depend on the order candidates are tried: + # the metaclass is the broken one in the first, the class' own `__init__` in the second. + metaclass_broken = cfn.Config(lazy_annotations.BuiltByMetaDeclaringTheSameParameter, pipeline=pipeline_cfg) + constructor_broken = cfn.Config(lazy_rival_declarations.BuiltByResolvableMeta, pipeline=pipeline_cfg) + + received, _ = metaclass_broken.instantiate() + + assert isinstance(received, Pipeline) + assert isinstance(constructor_broken.instantiate().pipeline, Pipeline) + + +def test_rival_declarations_that_agree_still_answer(): + # A metaclass forwarding to the constructor declares the same parameter, written in the + # same module and meaning the same thing. There is nothing to disagree about, so this + # is not a case for declining. + built = cfn.Config(lazy_annotations.BuiltByForwardingMeta, pipeline=pipeline_cfg).instantiate() + + assert isinstance(built.pipeline, cfn.Config) + + +def test_forward_reference_resolves_in_the_module_it_names(): + forward = typing.ForwardRef('C', module=lazy_annotations.__name__) + + @cfn.config(pipeline=pipeline_cfg) + def via_forward_ref(pipeline: forward): + return pipeline + + assert isinstance(via_forward_ref.instantiate(), cfn.Config) + + +def test_postponed_optional_annotation(): + cfg = cfn.Config(lazy_annotations.optional_spelling, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +def test_unresolvable_annotation_on_another_parameter_is_ignored(): + # Resolution is per parameter, so a forward reference that cannot be resolved + # elsewhere in the signature does not disable the feature for this one. + cfg = cfn.Config(lazy_annotations.with_unresolvable_neighbour, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + received, other = cfg.instantiate() + + assert isinstance(received, cfn.Config) + assert other is None + + +def test_postponed_non_config_annotation_still_resolves(): + cfg = cfn.Config(lazy_annotations.resolving_target, pipeline=cfn.Config(lazy_annotations.Pipeline)) + + assert isinstance(cfg.instantiate(), lazy_annotations.Pipeline) + + +def test_forward_reference_inside_a_union_is_recognised(): + @cfn.config(pipeline=pipeline_cfg) + def forward(pipeline: Optional['cfn.Config'] = None): # noqa: UP045 - a bare `'cfn.Config' | None` is a TypeError + return pipeline + + assert isinstance(forward.instantiate(), cfn.Config) + + +def test_annotation_that_cannot_be_resolved_is_not_lazy(): + # An annotation naming Config but unresolvable is treated as "not a Config annotation", + # never as an error: instantiate() must not start failing over a bad forward reference. + @cfn.config(pipeline=pipeline_cfg) + def unresolvable(pipeline: 'NoSuchConfig'): # noqa: F821 - deliberately undefined + return pipeline + + assert isinstance(unresolvable.instantiate(), Pipeline) + + +def test_annotations_of_parameters_the_config_does_not_set_are_left_alone(): + # Resolving an annotation evaluates whatever expression was written there. A config + # that never sets that parameter has no business causing that. + evaluated = [] + + class Watched: + def __getattr__(self, name): + evaluated.append(name) + raise AttributeError(name) + + watched = Watched() + namespace = {'watched': watched, 'cfn': cfn} + exec( # noqa: S102 - postponed annotations in a module we build here + 'from __future__ import annotations\n' + 'def target(untouched: watched.Thing = None, pipeline: cfn.Config = None):\n' + ' return pipeline\n', + namespace, + ) + + received = cfn.Config(namespace['target'], pipeline=pipeline_cfg).instantiate() + + assert isinstance(received, cfn.Config) + assert evaluated == [] + + +def test_introspection_that_raises_does_not_break_instantiate(): + # A target is free to define `__signature__` however it likes; reading it must not be + # what breaks building a config. + class Hostile: + @property + def __signature__(self): + raise RuntimeError('signature is not available') + + def __call__(self, pipeline): + return pipeline + + assert isinstance(cfn.Config(Hostile(), pipeline=pipeline_cfg).instantiate(), Pipeline) + + +def test_annotation_object_is_never_compared(): + # An annotation is any object, and reading a signature must not run its `__eq__`. + class Hostile: + def __eq__(self, other): + raise AssertionError('annotations must not be compared') + + hostile = Hostile() + + @cfn.config(pipeline=pipeline_cfg) + def annotated_with_an_object(pipeline: hostile): + return pipeline + + assert isinstance(annotated_with_an_object.instantiate(), Pipeline) + + +def test_annotation_carrying_metadata_is_not_mistaken_for_annotated(): + # An annotation can be any object. One that happens to carry `__metadata__` is not + # `Annotated`, and reading `__origin__` off it must not break instantiate(). + class Marked: + __metadata__ = ('not really Annotated',) + + @cfn.config(pipeline=pipeline_cfg) + def marked(pipeline: Marked): + return pipeline + + assert isinstance(marked.instantiate(), Pipeline) + + +def test_inherited_call_resolves_in_its_defining_module(): + # `__call__` comes from a base class in another module, and its annotation uses a name + # only that module has. + class Server(lazy_annotations.CallableBase): + pass + + received, _ = cfn.Config(Server(), pipeline=pipeline_cfg).instantiate() + + assert isinstance(received, cfn.Config) + + +def test_callable_object_target_resolves_its_annotations(): + # A target that is neither a function nor a class has no __globals__, so the + # annotation is resolved in the module its class came from. + class Server: + def __call__(self, pipeline: 'cfn.Config'): + return pipeline + + assert isinstance(cfn.Config(Server(), pipeline=pipeline_cfg).instantiate(), cfn.Config) + + +# --- overriding a lazy parameter ------------------------------------------------------ + + +def test_override_swaps_the_config(): + other = cfn.Config(Pipeline, codec=cfn.Config(Codec, fps=60), model_path='other') + + received, _ = serve.override(pipeline=other).instantiate() + + assert received.instantiate().model_path == 'other' + + +def test_dotted_override_reaches_into_the_lazy_parameter(): + received, _ = serve.override(**{'pipeline.codec.fps': 10}).instantiate() + + assert received.instantiate().codec.fps == 10 + + +def test_import_string_override_swaps_the_config(): + # `--pipeline=@module.other_pipeline` on the CLI: resolved at override time, handed + # over unresolved at instantiate time. + received, _ = serve.override(pipeline='@tests.test_lazy_config_params.pipeline_cfg').instantiate() + + assert received.target is Pipeline + + +def test_override_leaves_the_base_untouched(): + variant = serve.override(**{'pipeline.codec.fps': 10}) + + assert variant.instantiate()[0].instantiate().codec.fps == 10 + assert serve.instantiate()[0].instantiate().codec.fps == 30 + + +def test_variants_receive_independent_configs(): + fast = serve.override(**{'pipeline.codec.fps': 60}) + slow = serve.override(**{'pipeline.codec.fps': 5}) + + fast_received, _ = fast.instantiate() + slow_received, _ = slow.instantiate() + + assert fast_received is not slow_received + assert fast_received.instantiate().codec.fps == 60 + assert slow_received.instantiate().codec.fps == 5 + + +def test_received_config_is_the_stored_one(): + # Handed over as stored — not a copy — so the target sees exactly what `--help` shows. + received, _ = serve.instantiate() + + assert received is serve.kwargs['pipeline'] + + +# --- what the target does with it ------------------------------------------------------ + + +def test_target_can_instantiate_the_received_config_more_than_once(): + received, _ = serve.instantiate() + + first = received.instantiate() + second = received.instantiate() + + assert isinstance(first, Pipeline) + assert first is not second + + +def test_target_can_apply_per_call_overrides(): + # The motivating case: a server applying per-connection overrides to one pipeline. + received, _ = serve.instantiate() + + fast = received.override(**{'codec.fps': 120}).instantiate() + slow = received.override(**{'codec.fps': 1}).instantiate() + + assert (fast.codec.fps, slow.codec.fps) == (120, 1) + assert received.instantiate().codec.fps == 30 + + +def test_override_data_on_the_received_config_still_refuses_imports(): + received, _ = serve.instantiate() + + assert received.override_data(**{'codec.fps': 10}).instantiate().codec.fps == 10 + + try: + received.override_data(codec='@os.system') + except cfn.ImportNotAllowedError as e: + assert e.key == 'codec' + else: + raise AssertionError('override_data accepted an import string') + + +def test_lazy_config_is_visible_in_the_string_form(): + # What `--help` prints: the contents of the pipeline, not an opaque object. + printed = str(serve) + + assert 'codec' in printed + assert 'fps' not in printed # only set values are printed; fps keeps its default + + assert 'fps' in str(serve.override(**{'pipeline.codec.fps': 10})) + + +def test_get_required_args_reports_args_nested_in_a_lazy_parameter(): + cfg = cfn.Config(serve.target, pipeline=cfn.Config(Pipeline), host='localhost') + + assert cfn.get_required_args(cfg) == ['pipeline.codec'] + + +# --- targets other than plain functions ------------------------------------------------ + + +def test_class_target_receives_the_config(): + class Server: + def __init__(self, pipeline: cfn.Config, host: str = 'localhost'): + self.pipeline = pipeline + self.host = host + + server = cfn.Config(Server, pipeline=pipeline_cfg).instantiate() + + assert isinstance(server.pipeline, cfn.Config) + assert isinstance(server.pipeline.instantiate(), Pipeline) + + +def test_positional_argument_is_handed_over(): + def positional(pipeline: cfn.Config, eager: Pipeline): + return pipeline, eager + + received, eager = cfn.Config(positional, pipeline_cfg, pipeline_cfg).instantiate() + + assert isinstance(received, cfn.Config) + assert isinstance(eager, Pipeline) + + +def test_var_positional_is_handed_over(): + def collect(*pipelines: cfn.Config): + return pipelines + + received = cfn.Config(collect, pipeline_cfg, pipeline_cfg).instantiate() + + assert [isinstance(item, cfn.Config) for item in received] == [True, True] + + +def test_var_keyword_is_handed_over(): + def collect(**pipelines: cfn.Config): + return pipelines + + received = cfn.Config(collect, left=pipeline_cfg, right=pipeline_cfg).instantiate() + + assert sorted(received) == ['left', 'right'] + assert all(isinstance(item, cfn.Config) for item in received.values()) + + +def test_keyword_only_parameter_is_handed_over(): + def keyword_only(*, pipeline: cfn.Config): + return pipeline + + assert isinstance(cfn.Config(keyword_only, pipeline=pipeline_cfg).instantiate(), cfn.Config) + + +def test_target_without_an_introspectable_signature_still_instantiates(): + # C callables often refuse `inspect.signature`; nothing is annotated there anyway. + assert cfn.Config(dict, a=1).instantiate() == {'a': 1} + + +def test_partial_target_resolves_the_wrapped_functions_annotations(): + # `inspect.signature` reports the wrapped function's parameters, so its annotations + # must be resolved where they were written, not in functools. + target = functools.partial(lazy_annotations.aliased_spelling, host='127.0.0.1') + + received, host = cfn.Config(target, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(received, cfn.Config) + assert host == '127.0.0.1' + + +def test_partial_target_shifts_positional_slots(): + def prefixed(prefix: str, pipeline: cfn.Config): + return prefix, pipeline + + prefix, received = cfn.Config(functools.partial(prefixed, 'p'), pipeline_cfg).instantiate() + + assert prefix == 'p' + assert isinstance(received, cfn.Config) + + +def test_wraps_decorated_target_resolves_the_wrapped_functions_annotations(): + target = lazy_wrappers.passthrough(lazy_annotations.aliased_spelling) + + received, _ = cfn.Config(target, pipeline=cfn.Config(lazy_annotations.Pipeline)).instantiate() + + assert isinstance(received, cfn.Config) + + +def test_declared_signature_wins_over_the_wrapped_function(): + # `inspect.signature` stops at a `__signature__`, so the parameters — and the names + # their annotations use — belong to the wrapper, not to what it wraps. + target = lazy_declared_signature.with_declared_signature(lazy_wrappers.echo) + + assert isinstance(cfn.Config(target, pipeline=pipeline_cfg).instantiate(), cfn.Config) + + +def test_class_declaring_its_own_signature_resolves_its_class_body_alias(): + # The class inherits its constructor, so the parameters come from the signature it + # declares — a statement in its own body, which is what binds the alias they name. + cfg = cfn.Config(lazy_declared_signature.DeclaresItsOwnSignature, pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate().pipeline, cfn.Config) + + +def test_signature_set_to_none_leaves_the_constructor_to_answer(): + # `inspect.signature` reads `__signature__ = None` as "no explicit signature" and falls + # through to the constructor, so the parameters — and the body that wrote them — are + # still the base's. This class declares nothing and must not answer for them. + class InheritsWithoutDeclaringOne(lazy_annotations.ClassBodyAlias): + __signature__ = None + + cfg = cfn.Config(InheritsWithoutDeclaringOne, pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate().pipeline, cfn.Config) + + +def test_declared_signature_is_not_outvoted_by_the_constructor_it_replaces(): + # The class declares `(pipeline: 'Alias')` and inherits an `__init__` declaring the + # same parameter with the same text, which resolves to nothing where it was written. + # A declared signature is not one candidate among several: it is what + # `inspect.signature` reports, so nothing a constructor declares can outvote it. + cfg = cfn.Config(lazy_declared_signature.DeclaresOverUnresolvableInit, pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate().pipeline, cfn.Config) + + +def test_callable_object_declaring_its_signature_resolves_in_its_class_body(): + # The instance carries no namespace of its own, but the declaration is a statement in + # its class' body, and that is where the name it uses is bound. + cfg = cfn.Config(lazy_declared_signature.CallableDeclaringItsSignature(), pipeline=pipeline_cfg) + + assert isinstance(cfg.instantiate(), cfn.Config) + + +def test_metaclass_declared_signature_resolves_where_the_metaclass_wrote_it(): + # A class that declares no signature of its own gets one through its metaclass, so the + # annotations belong to the metaclass' body — and to its module, which is not the + # module the class itself lives in. + from_body = cfn.Config(lazy_declared_signature.BuiltByMetaclassDeclaringInItsBody, pipeline=pipeline_cfg) + from_module = cfn.Config(lazy_declared_signature.BuiltByMetaclassDeclaringFromItsModule, pipeline=pipeline_cfg) + + assert isinstance(from_body.instantiate().pipeline, cfn.Config) + assert isinstance(from_module.instantiate().pipeline, cfn.Config) + + +def test_circular_wrapper_chain_does_not_spin(): + def target(pipeline: 'cfn.Config'): + return pipeline + + # `inspect.signature` stops unwrapping at a `__signature__`, so it accepts this target + # and hands us annotations to resolve — while the chain to the function that wrote them + # is circular. + target.__signature__ = inspect.signature(target) + target.__wrapped__ = target + + assert isinstance(cfn.Config(target, pipeline=pipeline_cfg).instantiate(), cfn.Config) + + +def test_unhashable_target_is_handled(): + # The per-target declaration is cached, and an unhashable target cannot be a cache key. + class Server: + __hash__ = None + + def __call__(self, pipeline: cfn.Config, eager: Pipeline): + return pipeline, eager + + received, eager = cfn.Config(Server(), pipeline=pipeline_cfg, eager=pipeline_cfg).instantiate() + + assert isinstance(received, cfn.Config) + assert isinstance(eager, Pipeline) + + +def test_equal_targets_with_different_signatures_are_not_confused(): + # The cache is keyed by identity: targets that compare equal can still report + # different signatures, and each must get its own declaration. + class Server: + def __init__(self, lazy: bool): + annotation = cfn.Config if lazy else Pipeline + self.__signature__ = inspect.Signature([ + inspect.Parameter('pipeline', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation) + ]) + + def __call__(self, pipeline): + return pipeline + + def __eq__(self, other): + return isinstance(other, Server) + + def __hash__(self): + return 0 + + lazy_target = cfn.Config(Server(lazy=True), pipeline=pipeline_cfg) + eager_target = cfn.Config(Server(lazy=False), pipeline=pipeline_cfg) + + assert isinstance(lazy_target.instantiate(), cfn.Config) + assert isinstance(eager_target.instantiate(), Pipeline) + + +def test_lazy_parameter_inside_a_nested_config(): + @cfn.config(server=cfn.Config(serve.target, pipeline=pipeline_cfg, host='localhost')) + def app(server): + return server + + received, _ = app.instantiate() + + assert isinstance(received, cfn.Config) + assert received.target is Pipeline diff --git a/tests/test_untrusted_overrides.py b/tests/test_untrusted_overrides.py index dc8a910..aec951a 100644 --- a/tests/test_untrusted_overrides.py +++ b/tests/test_untrusted_overrides.py @@ -246,6 +246,16 @@ def test_override_data_rejection_leaves_base_config_untouched(): assert instance.camera.name == 'default' +def test_override_data_accepts_a_key_named_self(): + # An untrusted caller supplies the keys, and 'self' used to collide with the bound + # method's own parameter — a TypeError escaping the ConfigError the caller catches. + # It is an ordinary override key like any other. + env = _env_config().override_data(**{'self': 1}) + + assert env.kwargs['self'] == 1 + assert _env_config().override(**{'self': 1}).kwargs['self'] == 1 + + def test_override_data_still_reports_unknown_keys(): with pytest.raises(cfn.ConfigError) as exc_info: _env_config().override_data(**{'nonexistent.fps': 10})