diff --git a/benchmarks/test_guard_lifecycle.py b/benchmarks/test_guard_lifecycle.py index ea63e95e..457dbf16 100644 --- a/benchmarks/test_guard_lifecycle.py +++ b/benchmarks/test_guard_lifecycle.py @@ -39,7 +39,7 @@ def test_g6_build_child_container(benchmark): def test_g6b_build_child_container_auto_scope(benchmark): - # Default path: no explicit scope -> auto-increment via _next_deeper. G6 passes an explicit + # Default path: no explicit scope -> auto-increment via next_deeper. G6 passes an explicit # scope and never exercises it; this guards the memoized auto-increment step against regressing. app = Container(scope=Scope.APP, groups=[BuildGroup]) app.open() diff --git a/modern_di/_scope_algebra.py b/modern_di/_scope_algebra.py new file mode 100644 index 00000000..d9d9bacd --- /dev/null +++ b/modern_di/_scope_algebra.py @@ -0,0 +1,25 @@ +"""Walk the members of a scope enum: which scopes are deeper than a given one.""" + +import enum + + +def deeper_members(scope: enum.IntEnum) -> list[enum.IntEnum]: + """Members of ``scope``'s own enum that are deeper than it, shallowest first.""" + return sorted(member for member in type(scope) if member > scope) + + +# Keyed by the enum type as well as the member: `IntEnum` members hash by integer value, so +# two custom scopes reusing a value (TENANT=6 in one enum, 6 in another) would collide. +_next_deeper_memo: dict[tuple[type[enum.IntEnum], enum.IntEnum], enum.IntEnum | None] = {} + + +def next_deeper(scope: enum.IntEnum) -> enum.IntEnum | None: + """Return the next deeper member, or None when ``scope`` is the deepest. + + None rather than ``MaxScopeReachedError``: ``exceptions`` imports this module. + """ + key = (type(scope), scope) + if key not in _next_deeper_memo: + members = deeper_members(scope) + _next_deeper_memo[key] = members[0] if members else None + return _next_deeper_memo[key] diff --git a/modern_di/container.py b/modern_di/container.py index 5ab85cb4..70220fdd 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -4,6 +4,7 @@ import typing from modern_di import exceptions, types +from modern_di._scope_algebra import next_deeper from modern_di.dependency_graph import DependencyGraph, build_cycle_error, collect_errors, redirect_hops from modern_di.group import Group from modern_di.providers.abstract import AbstractProvider @@ -13,7 +14,7 @@ from modern_di.registries.overrides_registry import OverrideHandle from modern_di.registries.providers_registry import ProvidersRegistry from modern_di.resolver_compiler import STEP_ERRORS -from modern_di.scope import Scope, _next_deeper +from modern_di.scope import Scope def _handle_recursion_error( @@ -126,7 +127,7 @@ def build_child_container( :class:`~modern_di.exceptions.InvalidChildScopeError` when ``scope`` is not deeper. """ if scope is None: - scope = _next_deeper(self.scope) + scope = next_deeper(self.scope) if scope is None: raise exceptions.MaxScopeReachedError(parent_scope=self.scope) diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index 31484a7c..f0c602e4 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -10,7 +10,6 @@ from typing import NamedTuple from modern_di import exceptions -from modern_di.exceptions.rendering import redirect_steps from modern_di.providers.abstract import AbstractProvider @@ -76,8 +75,12 @@ def effective_scope(provider: "AbstractProvider[typing.Any]", container: "Contai def redirect_hops( provider: "AbstractProvider[typing.Any]", container: "Container" ) -> "list[exceptions.ResolutionStep]": - """Return the chain steps for the redirects between ``provider`` and its terminal, terminal excluded.""" - return redirect_steps(terminal_chain(provider, container)) + """Return the chain steps for the redirects between ``provider`` and its terminal, terminal excluded. + + A redirect owns no lifetime of its own, so each hop is drawn at the scope the terminal resolves at. + """ + *hops, terminal = terminal_chain(provider, container) + return [p._resolution_step(terminal.scope) for p in hops] # noqa: SLF001 def build_cycle_error( @@ -94,12 +97,7 @@ def build_cycle_error( rotated = [*ring[lead:], *ring[:lead]] canonical = [*rotated, rotated[0]] return exceptions.CircularDependencyError( - steps=[ - exceptions.ResolutionStep( - scope=effective_scope(p, container), name=p.display_name, location=p.definition_site - ) - for p in canonical - ] + steps=[p._resolution_step(effective_scope(p, container)) for p in canonical] # noqa: SLF001 ) diff --git a/modern_di/exceptions/base.py b/modern_di/exceptions/base.py index aed24cdf..e8f58e36 100644 --- a/modern_di/exceptions/base.py +++ b/modern_di/exceptions/base.py @@ -2,7 +2,7 @@ import typing -from modern_di.exceptions.rendering import ResolutionStep, _render_chain +from modern_di.exceptions.rendering import ResolutionStep, render_chain _TROUBLESHOOTING_BASE_URL = "https://modern-di.modern-python.org/troubleshooting" @@ -61,7 +61,7 @@ def _render_body(self) -> str: lines = [ "Cannot resolve dependency chain:", - *_render_chain(self.dependency_path), + *render_chain(self.dependency_path), f" caused by: {self._base_message}", ] return "\n".join(lines) diff --git a/modern_di/exceptions/container.py b/modern_di/exceptions/container.py index ff423898..9b47d7ed 100644 --- a/modern_di/exceptions/container.py +++ b/modern_di/exceptions/container.py @@ -2,10 +2,10 @@ import enum +from modern_di._scope_algebra import deeper_members from modern_di.exceptions.base import ModernDIError from modern_di.exceptions.rendering import ResolutionStep from modern_di.exceptions.resolution import ResolutionError -from modern_di.scope import _deeper_members class ContainerError(ModernDIError): @@ -26,7 +26,7 @@ def __init__(self, *, parent_scope: enum.IntEnum, child_scope: enum.IntEnum) -> self.child_scope = child_scope # Derived, not handed over: the allowed scopes are a pure function of the parent's # own enum class, so a raise site has nothing to add. - self.allowed_scopes = [member.name for member in _deeper_members(parent_scope)] + self.allowed_scopes = [member.name for member in deeper_members(parent_scope)] super().__init__( f"Scope of child container cannot be {child_scope.name} if parent scope is {parent_scope.name} " f"(child scope value must be strictly greater than parent scope value). " diff --git a/modern_di/exceptions/registration.py b/modern_di/exceptions/registration.py index 96ceaf8b..673423cd 100644 --- a/modern_di/exceptions/registration.py +++ b/modern_di/exceptions/registration.py @@ -5,7 +5,7 @@ from modern_di import suggester from modern_di.exceptions.base import ModernDIError -from modern_di.exceptions.rendering import _render_chain, _render_suggestion_lines, provider_step, redirect_steps +from modern_di.exceptions.rendering import render_chain, render_suggestion_lines if typing.TYPE_CHECKING: @@ -144,7 +144,7 @@ def __init__( creator_name = getattr(creator, "__name__", repr(creator)) parts = [ f"Factory kwargs contain unknown key(s) not in {creator_name} signature:", - *_render_suggestion_lines(self.suggestions), + *render_suggestion_lines(self.suggestions), f"Known parameters: {known_keys}", ] super().__init__("\n".join(parts)) @@ -205,14 +205,14 @@ def dep_terminal(self) -> "AbstractProvider[typing.Any]": return self.dep_chain[-1] def _render_body(self) -> str: + effective_scope = self.dep_terminal.scope steps = [ - provider_step(self.provider, self.provider.scope), - *redirect_steps(self.dep_chain), - provider_step(self.dep_terminal, self.dep_terminal.scope), + self.provider._resolution_step(), # noqa: SLF001 + *(p._resolution_step(effective_scope) for p in self.dep_chain), # noqa: SLF001 ] lines = [ "Provider at a deeper scope reached through this chain:", - *_render_chain(steps), + *render_chain(steps), f" caused by: {RuntimeError.__str__(self)}", ] return "\n".join(lines) diff --git a/modern_di/exceptions/rendering.py b/modern_di/exceptions/rendering.py index 2788427b..321c8412 100644 --- a/modern_di/exceptions/rendering.py +++ b/modern_di/exceptions/rendering.py @@ -2,15 +2,10 @@ import dataclasses import enum -import typing from modern_di import suggester -if typing.TYPE_CHECKING: - from modern_di.providers.abstract import AbstractProvider - - SUGGESTION_HEADER = "Did you mean:" @@ -19,7 +14,7 @@ class ResolutionStep: """One entry in a chain-shaped error: a provider, as this module needs to draw it. Used both for a :class:`ResolutionError`'s ``dependency_path`` and for a - :class:`CircularDependencyError`'s cycle, so both render through ``_render_chain``. + :class:`CircularDependencyError`'s cycle, so both render through ``render_chain``. Attributes: scope: the scope of the provider at this step of the chain. @@ -33,21 +28,7 @@ class ResolutionStep: location: str | None = None -def provider_step(provider: "AbstractProvider[typing.Any]", scope: enum.IntEnum) -> ResolutionStep: - """Draw `provider` as a chain step at `scope`.""" - return ResolutionStep(scope=scope, name=provider.display_name, location=provider.definition_site) - - -def redirect_steps(chain: "list[AbstractProvider[typing.Any]]") -> list[ResolutionStep]: - """Draw every hop of a redirect chain except its terminal, at the scope the terminal resolves at. - - A redirect owns no lifetime of its own, so its declared scope is a default it never resolves at. - """ - scope = chain[-1].scope - return [provider_step(p, scope) for p in chain[:-1]] - - -def _render_chain(steps: "list[ResolutionStep]") -> list[str]: +def render_chain(steps: "list[ResolutionStep]") -> list[str]: """Draw a provider chain as an indented arrow tree, one line per step. The single home of the chain glyphs — used by every chain-shaped error, so a @@ -62,7 +43,7 @@ def _render_chain(steps: "list[ResolutionStep]") -> list[str]: return lines -def _render_suggestion_lines(suggestions: "list[suggester.Suggestion]") -> list[str]: +def render_suggestion_lines(suggestions: "list[suggester.Suggestion]") -> list[str]: """Draw each suggestion as a bullet. The single home of the suggestion glyphs.""" lines = [] for suggestion in suggestions: @@ -76,8 +57,8 @@ def _scope_detail(scope: enum.IntEnum | None) -> str | None: return None if scope is None else f"scope={scope.name}" -def _render_suggestions(suggestions: "list[suggester.Suggestion]") -> str: +def render_suggestions(suggestions: "list[suggester.Suggestion]") -> str: """Render the full ``Did you mean:`` block, or an empty string when there is nothing to suggest.""" if not suggestions: return "" - return "\n".join([SUGGESTION_HEADER, *_render_suggestion_lines(suggestions)]) + return "\n".join([SUGGESTION_HEADER, *render_suggestion_lines(suggestions)]) diff --git a/modern_di/exceptions/resolution.py b/modern_di/exceptions/resolution.py index 09198eb8..072b6f03 100644 --- a/modern_di/exceptions/resolution.py +++ b/modern_di/exceptions/resolution.py @@ -5,7 +5,7 @@ from modern_di import suggester from modern_di.exceptions.base import DependencyPathMixin, ModernDIError -from modern_di.exceptions.rendering import ResolutionStep, _render_chain, _render_suggestions +from modern_di.exceptions.rendering import ResolutionStep, render_chain, render_suggestions class ResolutionError(DependencyPathMixin, ModernDIError): @@ -36,7 +36,7 @@ def __init__( self.provider_type = provider_type self.suggestions = suggestions or [] message = f"Provider of type {provider_type} is not registered in providers registry." - if block := _render_suggestions(self.suggestions): + if block := render_suggestions(self.suggestions): message += "\n" + block super().__init__(message) @@ -95,7 +95,7 @@ def __init__( # noqa: PLR0913 f"Argument {parameter_name} has no usable type annotation, so it cannot be resolved by type. " f"Pass it via the kwargs parameter or add a type annotation. {building}" ) - if block := _render_suggestions(self.suggestions): + if block := render_suggestions(self.suggestions): message += "\n" + block super().__init__(message) @@ -150,16 +150,14 @@ class CircularDependencyError(ResolutionError): def __init__(self, *, steps: list[ResolutionStep]) -> None: self.steps = steps - rendered = "\n".join(_render_chain(steps)) + rendered = "\n".join(render_chain(steps)) super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.") def prepend_step(self, *steps: ResolutionStep) -> None: - """No-op: the canonical cycle (set at construction) is already self-contained. + """No-op: the canonical cycle set at construction already names every provider in the loop. - Every provider in the loop is named by ``steps``, so an outer resolution frame has nothing - to add — accumulating a breadcrumb would only repeat the same nodes. This also keeps the two - resolve paths identical: the interpreted path unwinds through intermediate ``resolve_provider`` - frames (each would otherwise prepend a step), while the compiled path converts once at the top. + An outer resolver frame that catches the error has nothing to add, and prepending its step would + only repeat a node of the cycle. """ @property diff --git a/modern_di/providers/abstract.py b/modern_di/providers/abstract.py index 50e2067a..c172a7f2 100644 --- a/modern_di/providers/abstract.py +++ b/modern_di/providers/abstract.py @@ -80,6 +80,12 @@ def definition_site(self) -> str | None: """``module:line`` of the provider's declaration when known; None by default (no creator).""" return None + def _resolution_step(self, scope: enum.IntEnum | None = None) -> exceptions.ResolutionStep: + """Return this provider as a chain step at ``scope``, its own scope by default.""" + return exceptions.ResolutionStep( + scope=self.scope if scope is None else scope, name=self.display_name, location=self.definition_site + ) + def _get_dependencies(self, container: "Container") -> dict[str, "AbstractProvider[typing.Any]"]: # noqa: ARG002 return {} diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 391019e7..37a73881 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -162,9 +162,6 @@ def _compute_definition_site(self) -> str | None: return None return f"{module}:{lineno}" - def _resolution_step(self) -> exceptions.ResolutionStep: - return exceptions.ResolutionStep(scope=self.scope, name=self.display_name, location=self.definition_site) - def _argument_resolution_error( self, *, arg_name: str, item: SignatureItem, registry: "ProvidersRegistry" ) -> exceptions.ArgumentResolutionError: diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index 21765742..c1a8f4d9 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -6,13 +6,14 @@ redirect hops it skipped. Every other provider type compiles to a small closure. An overridden provider compiles to its override value, so the resolvers never consult the overrides registry; applying an override drops the compiled resolvers instead (see -``ProvidersRegistry.drop_resolvers``). Why a template and not shared helpers: every all-Python -single-copy design measured 25-80% slower (docs/introduction/performance.md). +``ProvidersRegistry.drop_resolvers``). Why a template and not shared helpers: see +docs/adr/0001-resolver-hot-path-generated-source.md. The template reaches into `Container._lock`/`_scope_map` and `CacheRegistry._items` to stay within that frame budget. No linter sees the template, so those reaches are outside every suppression here. """ +import enum import functools import itertools import linecache @@ -20,7 +21,6 @@ from modern_di import exceptions, types from modern_di.dependency_graph import redirect_hops -from modern_di.exceptions.rendering import provider_step from modern_di.providers.abstract import AbstractProvider from modern_di.providers.alias import Alias from modern_di.providers.container_provider import container_provider @@ -58,8 +58,7 @@ def compile_resolver(provider: "AbstractProvider[typing.Any]", registry: "Provid raise TypeError(msg) -_TRANSIENT = """\ -def resolve(container): +_NAVIGATE = """\ if container.scope == scope: target = container else: @@ -68,32 +67,9 @@ def resolve(container): target = _navigate(container, scope, resolution_step) if target._closed: raise ContainerClosedError(container_scope=target.scope) - try: -{build} - except ContextValueNotSetError as exc: - name = [*edges][arg_lines[exc.__traceback__.tb_lineno]] - if not exc.dependency_path: - exc.name_parameter(name) - exc.prepend_step(resolution_step(), *redirect_hops(edges[name], target)) - raise - except _STEP_ERRORS as exc: - name = [*edges][arg_lines[exc.__traceback__.tb_lineno]] - exc.prepend_step(resolution_step(), *redirect_hops(edges[name], target)) - raise - try: - return creator({args}) - except TypeError as exc: - error = CreatorCallError.from_type_error(creator=creator, exc=exc, resolution_step=resolution_step) - if error is None: - raise - raise error from exc - except _STEP_ERRORS as exc: - exc.prepend_step(resolution_step()) - raise """ -_CACHED = """\ -def build(target): +_BUILD_ARGUMENTS = """\ try: {build} except ContextValueNotSetError as exc: @@ -106,11 +82,11 @@ def build(target): name = [*edges][arg_lines[exc.__traceback__.tb_lineno]] exc.prepend_step(resolution_step(), *redirect_hops(edges[name], target)) raise - return {built} +""" -def create(built): +_CALL_CREATOR = """\ try: - return creator({star}built) + return creator({args}) except TypeError as exc: error = CreatorCallError.from_type_error(creator=creator, exc=exc, resolution_step=resolution_step) if error is None: @@ -119,16 +95,18 @@ def create(built): except _STEP_ERRORS as exc: exc.prepend_step(resolution_step()) raise +""" -def resolve(container): - if container.scope == scope: - target = container - else: - target = container._scope_map.get(scope) - if target is None: - target = _navigate(container, scope, resolution_step) - if target._closed: - raise ContainerClosedError(container_scope=target.scope) +_TRANSIENT = "def resolve(container):\n" + _NAVIGATE + _BUILD_ARGUMENTS + _CALL_CREATOR + +_CACHED = ( + "def build(target):\n" + + _BUILD_ARGUMENTS + + " return {built}\n\ndef create(built):\n" + + _CALL_CREATOR + + "\ndef resolve(container):\n" + + _NAVIGATE + + """\ cache_registry = target._cache_registry cache_item = cache_registry._items.get(pid) if cache_item is None: @@ -141,24 +119,32 @@ def resolve(container): cache_registry.mark_created(cache_item) return value """ +) -def _source(arity: int, names: tuple[str, ...] | None, static: bool, cached: bool) -> str: - """Generate the resolver source for one shape: the parts of a Factory that decide it.""" +def _source(arity: int, names: tuple[str, ...] | None, static: bool, cached: bool) -> tuple[str, dict[int, int]]: + """Generate the resolver source for one shape, and map each resolver call's line to its argument index.""" if names is None: - build = "\n".join(f" a{i} = r{i}(target)" for i in range(arity)) or " pass" + build = [f" a{i} = r{i}(target)" for i in range(arity)] or [" pass"] + call_offset = 0 args = ", ".join(f"a{i}" for i in range(arity)) built = "(" + "".join(f"a{i}, " for i in range(arity)) + ")" star = "*" else: calls = [f" {name!r}: r{i}(target)," for i, name in enumerate(names)] - build = "\n".join([" kwargs = {", *calls, " }"]) + build = [" kwargs = {", *calls, " }"] + call_offset = 1 if static: - build += "\n kwargs.update(static)" + build.append(" kwargs.update(static)") args, built, star = "**kwargs", "kwargs", "**" if cached: - return _CACHED.format(build=build, built=built, star=star) - return _TRANSIENT.format(build=build, args=args) + template, args = _CACHED, f"{star}built" + else: + template = _TRANSIENT + # `{build}` must be the first multi-line placeholder: the lines before it are counted as-is. + build_line = template[: template.index("{build}")].count("\n") + 1 + arg_lines = {build_line + call_offset + i: i for i in range(arity)} + return template.format(build="\n".join(build), built=built, args=args), arg_lines _shape_ids = itertools.count() @@ -166,20 +152,10 @@ def _source(arity: int, names: tuple[str, ...] | None, static: bool, cached: boo @functools.cache def _code(arity: int, names: tuple[str, ...] | None, static: bool, cached: bool) -> "tuple[CodeType, dict[int, int]]": - """Compile one shape's source, so factories of one shape share a code object. - - Also map each resolver call's line to its argument index. - """ - source = _source(arity, names, static, cached) + """Compile one shape's source, so factories of one shape share a code object.""" + source, arg_lines = _source(arity, names, static, cached) filename = f"" - lines = source.splitlines(keepends=True) - linecache.cache[filename] = (len(source), None, lines, filename) - arg_lines = { - lineno: i - for lineno, line in enumerate(lines, start=1) - for i in range(arity) - if line.rstrip("\n").endswith((f" r{i}(target)", f" r{i}(target),")) - } + linecache.cache[filename] = (len(source), None, source.splitlines(keepends=True), filename) return compile(source, filename, "exec"), arg_lines @@ -253,7 +229,7 @@ def _compile_alias(a: "Alias[typing.Any]", registry: "ProvidersRegistry") -> "Re def resolve(_: "Container") -> typing.Any: error = exceptions.AliasSourceNotRegisteredError(source_type=source_type) - error.prepend_step(provider_step(a, a.scope)) + error.prepend_step(a._resolution_step()) raise error return resolve @@ -267,7 +243,7 @@ def _compile_context_provider(cp: "ContextProvider[typing.Any]") -> "Resolver": scope = cp.scope context_type = cp.context_type default = cp.default - resolution_step = functools.partial(provider_step, cp, scope) + resolution_step = cp._resolution_step def resolve(container: "Container") -> typing.Any: if container.scope == scope: @@ -291,7 +267,7 @@ def resolve(container: "Container") -> typing.Any: def _navigate( container: "Container", - scope: typing.Any, + scope: enum.IntEnum, resolution_step: "typing.Callable[[], exceptions.ResolutionStep]", ) -> "Container": """Miss path for a scope absent from `_scope_map`; the scope error carries this provider's resolution step.""" diff --git a/modern_di/scope.py b/modern_di/scope.py index 4a118498..8d29438f 100644 --- a/modern_di/scope.py +++ b/modern_di/scope.py @@ -14,25 +14,3 @@ class Scope(enum.IntEnum): REQUEST = 3 ACTION = 4 STEP = 5 - - -def _deeper_members(scope: enum.IntEnum) -> list[enum.IntEnum]: - """Members of ``scope``'s own enum that are deeper than it, shallowest first.""" - return sorted(member for member in type(scope) if member > scope) - - -# Keyed by the enum type as well as the member: `IntEnum` members hash by integer value, so -# two custom scopes reusing a value (TENANT=6 in one enum, 6 in another) would collide. -_next_deeper_memo: dict[tuple[type[enum.IntEnum], enum.IntEnum], enum.IntEnum | None] = {} - - -def _next_deeper(scope: enum.IntEnum) -> enum.IntEnum | None: - """Return the next deeper member, or None when ``scope`` is the deepest. - - None rather than ``MaxScopeReachedError``: ``exceptions`` imports this module. - """ - key = (type(scope), scope) - if key not in _next_deeper_memo: - members = _deeper_members(scope) - _next_deeper_memo[key] = members[0] if members else None - return _next_deeper_memo[key] diff --git a/tests/test_custom_scope.py b/tests/test_custom_scope.py index d049f899..43ae09ae 100644 --- a/tests/test_custom_scope.py +++ b/tests/test_custom_scope.py @@ -2,18 +2,21 @@ import dataclasses import enum import pathlib +import types +import typing import pytest +import modern_di._scope_algebra import modern_di.scope from modern_di import Container, Group, Scope, providers +from modern_di._scope_algebra import deeper_members, next_deeper from modern_di.exceptions import ( InvalidChildScopeError, MaxScopeReachedError, ScopeNotInitializedError, ScopeSkippedError, ) -from modern_di.scope import _deeper_members, _next_deeper class MyScope(enum.IntEnum): @@ -106,22 +109,22 @@ def test_scope_algebra_answers_deeper_members_for_any_int_enum() -> None: algebra expressed as methods on `Scope` would apply to the five built-in members and nothing else. Free functions are what make custom scopes work at all. """ - assert _deeper_members(MyScope.TENANT) == [MyScope.BACKGROUND_JOB] - assert _deeper_members(MyScope.BACKGROUND_JOB) == [] - assert _deeper_members(Scope.ACTION) == [Scope.STEP] + assert deeper_members(MyScope.TENANT) == [MyScope.BACKGROUND_JOB] + assert deeper_members(MyScope.BACKGROUND_JOB) == [] + assert deeper_members(Scope.ACTION) == [Scope.STEP] def test_scope_algebra_next_deeper_is_the_shallowest_deeper_member() -> None: - """INVARIANT: `_next_deeper` returns the shallowest deeper member of the provider's own enum. + """INVARIANT: `next_deeper` returns the shallowest deeper member of the provider's own enum. Not `value + 1` -- a non-contiguous custom enum (`TENANT=6, JOB=10`) must derive `JOB` from - `TENANT`. Returning `None` at the deepest member (rather than raising) is what keeps `scope.py` + `TENANT`. Returning `None` at the deepest member (rather than raising) is what keeps `_scope_algebra.py` from importing `exceptions`. """ - assert _next_deeper(GappedScope.TENANT) is GappedScope.BACKGROUND_JOB - assert _next_deeper(Scope.APP) is Scope.SESSION - assert _next_deeper(GappedScope.BACKGROUND_JOB) is None - assert _next_deeper(Scope.STEP) is None + assert next_deeper(GappedScope.TENANT) is GappedScope.BACKGROUND_JOB + assert next_deeper(Scope.APP) is Scope.SESSION + assert next_deeper(GappedScope.BACKGROUND_JOB) is None + assert next_deeper(Scope.STEP) is None def test_caching_isolated_across_tenant_containers() -> None: @@ -174,12 +177,12 @@ def test_auto_derive_at_deepest_gapped_scope_raises_max() -> None: def test_next_deeper_memo_does_not_collide_across_enums_sharing_a_value() -> None: - # _next_deeper is memoized. IntEnum members compare/hash by integer value, so MyScope.TENANT + # next_deeper is memoized. IntEnum members compare/hash by integer value, so MyScope.TENANT # and GappedScope.TENANT (both == 6) would collide under a bare-member cache key — the memo # keys on (type, member) to keep each enum's own answer. Both orders, to catch either the # first or second call being served a foreign result. - assert _next_deeper(MyScope.TENANT) is MyScope.BACKGROUND_JOB # 6 -> 7 (contiguous) - assert _next_deeper(GappedScope.TENANT) is GappedScope.BACKGROUND_JOB # 6 -> 10 (gapped), not 7 + assert next_deeper(MyScope.TENANT) is MyScope.BACKGROUND_JOB # 6 -> 7 (contiguous) + assert next_deeper(GappedScope.TENANT) is GappedScope.BACKGROUND_JOB # 6 -> 10 (gapped), not 7 def test_build_child_container_rejects_zero_valued_custom_scope() -> None: @@ -216,16 +219,17 @@ def _module_level_imports(source: str) -> set[str]: return imported -def test_scope_module_imports_only_enum() -> None: - """INVARIANT: `modern_di/scope.py` imports nothing but `enum`. +@pytest.mark.parametrize("module", [modern_di.scope, modern_di._scope_algebra]) +def test_scope_modules_import_only_enum(module: types.ModuleType) -> None: + """INVARIANT: `modern_di/scope.py` and `modern_di/_scope_algebra.py` import nothing but `enum`. - `exceptions/container.py` imports `_deeper_members` to derive `InvalidChildScopeError.allowed_scopes`, - so a `scope.py` that imported `exceptions` would cycle. That is why `_next_deeper` returns `None` + `exceptions/container.py` imports `deeper_members` to derive `InvalidChildScopeError.allowed_scopes`, + so a scope module that imported `exceptions` would cycle. That is why `next_deeper` returns `None` at the deepest member instead of raising `MaxScopeReachedError` itself. """ - source = pathlib.Path(modern_di.scope.__file__).read_text(encoding="utf-8") + source = pathlib.Path(typing.cast("str", module.__file__)).read_text(encoding="utf-8") imported = _module_level_imports(source) - assert imported == {"enum"}, f"scope.py grew imports: {sorted(imported)}" + assert imported == {"enum"}, f"{module.__name__} grew imports: {sorted(imported)}" # Prove the extractor itself would catch a relative import of the forbidden dependency -- the # assertion above is only trustworthy if this branch is real, not a no-op. diff --git a/tests/test_dependency_path.py b/tests/test_dependency_path.py index d3ae7bb0..af9d8651 100644 --- a/tests/test_dependency_path.py +++ b/tests/test_dependency_path.py @@ -284,7 +284,7 @@ def test_validate_and_runtime_name_the_same_chain_for_one_scope_violation() -> N Broken by any error that reports a redirect-mediated violation from only one end of the chain: naming the bound type without its terminal, or the terminal without the hops that - reached it. The two paths share `_render_chain` precisely so a reader who hits one and + reached it. The two paths share `render_chain` precisely so a reader who hits one and then the other is not told two different stories about the same graph. """ assert _validate_chain_names() == _runtime_chain_names() diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index dd056e36..9b47e76f 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -21,7 +21,7 @@ def test_error_without_docs_slug_renders_body_unchanged() -> None: def test_render_chain_is_a_pure_function_of_its_steps() -> None: # The drawer is exercisable without a container, a cycle, or a raise — it is the # single home of the indent-and-arrow glyphs, shared by every chain-shaped error. - assert rendering._render_chain([_step("A"), _step("B", location="app:31"), _step("A")]) == [ + assert rendering.render_chain([_step("A"), _step("B", location="app:31"), _step("A")]) == [ " APP A", " APP └─> B (app:31)", " APP └─> A", @@ -29,7 +29,7 @@ def test_render_chain_is_a_pure_function_of_its_steps() -> None: def test_render_chain_aligns_the_scope_column_to_the_widest_name() -> None: - lines = rendering._render_chain([_step("A"), _step("B", scope=Scope.REQUEST)]) + lines = rendering.render_chain([_step("A"), _step("B", scope=Scope.REQUEST)]) assert lines == [" APP A", " REQUEST └─> B"] @@ -52,13 +52,13 @@ def test_render_chain_aligns_the_scope_column_to_the_widest_name() -> None: ) def test_render_suggestion_lines_covers_every_format(suggestion: suggester.Suggestion, expected: str) -> None: # One bullet format for all three suggestion kinds — the registry no longer owns half of it. - assert rendering._render_suggestion_lines([suggestion]) == [expected] + assert rendering.render_suggestion_lines([suggestion]) == [expected] def test_render_suggestions_prepends_the_header_and_is_empty_when_there_is_nothing_to_say() -> None: - assert rendering._render_suggestions([]) == "" + assert rendering.render_suggestions([]) == "" assert ( - rendering._render_suggestions([suggester.Suggestion(name="Repository", reason="similar name", scope=Scope.APP)]) + rendering.render_suggestions([suggester.Suggestion(name="Repository", reason="similar name", scope=Scope.APP)]) == "Did you mean:\n - Repository (similar name, scope=APP)" )