From 3220823f3abeda5d3305aab5f7f11697364c8ff2 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Thu, 18 Dec 2025 20:40:13 -0300 Subject: [PATCH] Add partials to support circular references --- Makefile | 2 +- apywire/__main__.py | 2 +- apywire/compiler.py | 91 ++++++- apywire/exceptions.py | 18 ++ apywire/partial.py | 368 +++++++++++++++++++++++++++ apywire/runtime.py | 268 ++++++++++++++++++-- apywire/threads.py | 42 ++-- apywire/wiring.py | 164 +++++++++---- tests/test_cli.py | 57 ++++- tests/test_compile_partial.py | 84 +++++++ tests/test_constant_placeholders.py | 8 +- tests/test_edge_cases.py | 173 ++++++++++++- tests/test_graph_detection.py | 71 ++++++ tests/test_partial_helper.py | 369 ++++++++++++++++++++++++++++ tests/test_single.py | 12 +- tests/test_threading.py | 111 +++++++-- uv.lock.license | 3 + 17 files changed, 1712 insertions(+), 131 deletions(-) create mode 100644 apywire/partial.py create mode 100644 tests/test_compile_partial.py create mode 100644 tests/test_graph_detection.py create mode 100644 tests/test_partial_helper.py create mode 100644 uv.lock.license diff --git a/Makefile b/Makefile index 795cc4e..2d0c20e 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ reuse: --license ISC \ --recursive . -format: reuse +format: uv run black apywire tests uv run isort apywire tests diff --git a/apywire/__main__.py b/apywire/__main__.py index a589df2..dcd15a6 100644 --- a/apywire/__main__.py +++ b/apywire/__main__.py @@ -192,5 +192,5 @@ def main(argv: list[str] | None = None) -> int: return func(args) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover - CLI entrypoint sys.exit(main()) diff --git a/apywire/compiler.py b/apywire/compiler.py index 6abe856..a5b0bae 100644 --- a/apywire/compiler.py +++ b/apywire/compiler.py @@ -19,6 +19,7 @@ SYNTHETIC_CONST, ) from apywire.wiring import ( + Spec, WiringBase, _ConstantValue, _ResolvedSpecMapping, @@ -40,6 +41,31 @@ class WiringCompiler(WiringBase): """Wiring container with compilation support.""" + # Explicit attribute added for type checkers + allow_partial: bool + """Wiring container with compilation support.""" + + def __init__( + self, + spec: Spec, + *, + thread_safe: bool = False, + allow_partial: bool = False, + ) -> None: + """Initialize compiler with optional partial-construction support. + + Args: + spec: wiring spec + thread_safe: pack thread-safe behavior into generated code + allow_partial: If True, the generated code will use a small helper + module to support partial-construction placeholders for + circular dependencies. + """ + super().__init__( + spec, thread_safe=thread_safe, allow_partial=allow_partial + ) + self.allow_partial = allow_partial + def _astify(self, obj: _ResolvedValue, aio: bool = False) -> ast.expr: """Convert a Python object (possibly a `_WiredRef`) to AST. @@ -358,6 +384,32 @@ def _compile_property( call = ast.Call(func=module_attr, args=args, keywords=kwargs) + # If enabled, wrap the call in a helper that preserves partial + # construction semantics for compiled code. + if self.allow_partial: + helper_call = ast.Call( + func=ast.Attribute( + value=ast.Name(id="apywire.partial", ctx=ast.Load()), + attr="_compiled_instantiate_with_partial", + ctx=ast.Load(), + ), + args=[ + ast.Name(id="self", ctx=ast.Load()), + ast.Constant(value=name), + self._create_lambda_function(call), + ast.Attribute( + value=ast.Name(id=module_name, ctx=ast.Load()), + attr=class_name, + ctx=ast.Load(), + ), + ast.Constant(value=bool(factory_method)), + ], + keywords=[], + ) + inst_expr = helper_call + else: + inst_expr = call + cache_attr = f"{CACHE_ATTR_PREFIX}{name}" # Create reusable components @@ -366,10 +418,10 @@ def _compile_property( # Build the assignment that sets the cache value; different # behavior is required for async vs sync callers. if not aio: - assign_cache = self._create_cache_assignment(cache_attr, call) + assign_cache = self._create_cache_assignment(cache_attr, inst_expr) else: # Async path: compute local values and call in executor. - lambda_expr = self._create_lambda_function(call) + lambda_expr = self._create_lambda_function(inst_expr) pre_statements.append(self._create_loop_assignment()) run_call = self._create_executor_call(lambda_expr) assign_cache = self._create_cache_assignment(cache_attr, run_call) @@ -384,7 +436,7 @@ def _compile_property( ) elif not aio and thread_safe: # Build sync thread-safe version using helper mixin - maker = self._create_lambda_function(call) + maker = self._create_lambda_function(inst_expr) call_inst = ast.Call( func=ast.Attribute( value=ast.Name(id="self", ctx=ast.Load()), @@ -610,6 +662,11 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str: # When compiling thread_safe, import thread-safety primitives modules.add("apywire.threads") modules.add("apywire.exceptions") + # If compiler is configured to support partial construction, ensure + # runtime helper is available in generated module. + if self.allow_partial: + modules.add("apywire.partial") + modules.add("apywire.exceptions") for module in sorted(modules): if module == "apywire.threads": # Import ThreadSafeMixin from threads @@ -633,15 +690,21 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str: level=0, ) ) + elif module == "apywire.partial": + # Import partial helper module used by compiled code + imp = ast.Import(names=[ast.alias(name="apywire.partial")]) + body.append(imp) else: body.append(ast.Import(names=[ast.alias(name=module)])) # Build class body class_body: list[ast.stmt] = [] - # When thread safe, add __init__ that calls helper mixin init + # Add __init__ if needed to support thread-safety init and/or + # partial-construction flag propagation. + init_body: list[ast.stmt] = [] + needs_init = False if thread_safe: - # class __init__ - init_body: list[ast.stmt] = [] + needs_init = True # compiled class will inherit from the mixin and just call # `_init_thread_safety` from its constructor init_body.append( @@ -657,6 +720,22 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str: ), ) ) + if self.allow_partial: + needs_init = True + # Set instance flag so compiled container behaves like runtime + init_body.append( + ast.Assign( + targets=[ + ast.Attribute( + value=ast.Name(id="self", ctx=ast.Load()), + attr="allow_partial", + ctx=ast.Store(), + ) + ], + value=ast.Constant(value=True), + ) + ) + if needs_init: init_def = ast.FunctionDef( name="__init__", args=_PROPERTY_ARGS, diff --git a/apywire/exceptions.py b/apywire/exceptions.py index ccc08c7..cf8489b 100644 --- a/apywire/exceptions.py +++ b/apywire/exceptions.py @@ -34,6 +34,24 @@ class CircularWiringError(WiringError): """ +class PartialConstructionAccessError(WiringError): + """Raised when attempting to access or call an object that is still in + partial construction (placeholder inserted but not yet finalized). + + This error indicates the wiring is in a transient state and the user + attempted to use the placeholder before it was bound to the final + instance. + """ + + +class PartialConstructionError(WiringError): + """Raised when partial construction cannot be completed successfully. + + Examples include factories that bypass `__new__` and return a different + instance than the skeleton allocated by the wiring system. + """ + + class LockUnavailableError(RuntimeError): """Exception raised when a per-attribute lock cannot be acquired in optimistic (non-blocking) mode and the code must fall back to a global diff --git a/apywire/partial.py b/apywire/partial.py new file mode 100644 index 0000000..f70ab7d --- /dev/null +++ b/apywire/partial.py @@ -0,0 +1,368 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +"""Helpers for partial construction used by compiled containers. + +This module contains a small helper that compiled accessors can call to +preserve partial-construction (skeleton) semantics without depending on +`apywire.runtime` internals. It is imported only when the compiler is +configured with `allow_partial=True` so compiled output does +not include unnecessary runtime dependencies. +""" + +from __future__ import annotations + +import threading +from typing import Callable, List, Optional, Protocol, cast + +from apywire.exceptions import CircularWiringError, PartialConstructionError + + +class _ContainerLike(Protocol): + _values: dict[str, object] | None + _resolving_stack: list[str] | None + allow_partial: bool + + +def _compiled_instantiate_with_partial( + container: object, + name: str, + constructor_callable: Callable[[], object], + expected_cls: type, + is_factory: bool, +) -> object: # pragma: no cover + # Exercised indirectly via compiler integration + """Helper used by compiled accessors to support partial construction. + + See runtime._compiled_instantiate_with_partial for detailed semantics. + """ + # Ensure resolving stack exists + stack_existing: object = getattr(container, "_resolving_stack", None) + if isinstance(stack_existing, list): + stack = cast(List[str], stack_existing) + else: + stack = [] + try: + setattr(container, "_resolving_stack", stack) + except Exception: # pragma: no cover - defensive + pass + + # Detect cycle; cast container for typing + container_obj = cast(_ContainerLike, container) + if name in stack: + if not container_obj.allow_partial: + msg = ( + "Circular wiring dependency detected: " + + " -> ".join(stack) + + " -> " + + name + ) + raise CircularWiringError(msg) + # Allocate skeleton and set partial markers + try: + # Use __new__ to allocate without invoking __init__ + new_fn = cast(Callable[[type], object], expected_cls.__new__) + skeleton: object = new_fn(expected_cls) + except Exception as e: + raise PartialConstructionError( + "failed to allocate skeleton for %r: %s" % (expected_cls, e) + ) from e + try: + setattr(skeleton, "_apywire_partial", True) + setattr(skeleton, "_apywire_event", threading.Event()) + setattr(skeleton, "_apywire_failure", None) + except Exception: # pragma: no cover - defensive + raise PartialConstructionError( + f"type {expected_cls} cannot be skeletonized" + ) + # Cache and return skeleton placeholder + values = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if values is not None: + values[name] = skeleton + else: + setattr(container, "_" + name, skeleton) + return skeleton + + stack.append(name) + try: + # If a skeleton was inserted by nested resolution, bind to it + cached: Optional[object] = None + values = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if values is not None: + cached = values.get(name) + else: + cached = cast( + Optional[object], getattr(container, "_" + name, None) + ) + + if cached is not None and cast( + bool, getattr(cached, "_apywire_partial", False) + ): + skeleton = cached + try: + if is_factory: + # Temporary override of __new__ + orig_new: Optional[object] = getattr( + expected_cls, "__new__", None + ) + + def _tmp_new( + _cls: type, *a: object, **k: object + ) -> object: + return skeleton + + setattr(expected_cls, "__new__", _tmp_new) + try: + ret: object = constructor_callable() + finally: + if orig_new is None: + try: + delattr(expected_cls, "__new__") + except Exception: # pragma: no cover - defensive + pass + else: + try: + setattr(expected_cls, "__new__", orig_new) + except Exception: + pass + + if ret is not skeleton: + exc = PartialConstructionError( + "factory for %r returned a different instance " + "than the skeleton" % (name,) + ) + try: + setattr(skeleton, "_apywire_failure", exc) + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + finally: + values_cleanup = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if ( + values_cleanup is not None + and values_cleanup.get(name) is skeleton + ): + del values_cleanup[name] + raise exc + # Success + try: + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + except Exception: # pragma: no cover - defensive + pass + return skeleton + else: + # Direct init + try: + init_attr: object = getattr( + expected_cls, "__init__", object.__init__ + ) + direct_init = cast( + Callable[[object], object | None], + init_attr, + ) + direct_init(skeleton) + except Exception as e: + exc = PartialConstructionError( + "failed to initialize skeleton for %r: %s" + % (name, e) + ) + try: + setattr(skeleton, "_apywire_failure", exc) + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + finally: + values_cleanup = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if ( + values_cleanup is not None + and values_cleanup.get(name) is skeleton + ): + del values_cleanup[name] + raise exc from e + try: + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + except Exception: # pragma: no cover - defensive + pass + return skeleton + except PartialConstructionError: + raise + except Exception as e: + exc = PartialConstructionError( + f"partial construction failed for '{name}': {e}" + ) + try: + setattr(skeleton, "_apywire_failure", exc) + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + finally: + values_cleanup = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if ( + values_cleanup is not None + and values_cleanup.get(name) is skeleton + ): + del values_cleanup[name] + raise exc from e + + # No skeleton present; call constructor normally + ret = constructor_callable() + # If nested resolution inserted a skeleton, validate and finalize it. + cached_now: Optional[object] = None + values_now = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if values_now is not None: + cached_now = values_now.get(name) + else: + cached_now = cast( + Optional[object], getattr(container, "_" + name, None) + ) + if cached_now is not None and cast( + bool, getattr(cached_now, "_apywire_partial", False) + ): + skeleton = cached_now + if ret is not skeleton: + # Retry factory with __new__ overridden to return skeleton + if is_factory: + orig_new = cast( + object, getattr(expected_cls, "__new__", None) + ) + + def _tmp_new( + _cls: type, *a: object, **k: object + ) -> object: + return skeleton + + setattr(expected_cls, "__new__", _tmp_new) + try: + retry_ret: object = constructor_callable() + finally: + if orig_new is None: + try: + delattr(expected_cls, "__new__") + except Exception: + pass + else: + try: + setattr(expected_cls, "__new__", orig_new) + except Exception: + pass + + if retry_ret is not skeleton: + exc = PartialConstructionError( + "factory for %r returned a different instance " + "than the skeleton" % (name,) + ) + try: + setattr(skeleton, "_apywire_failure", exc) + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + finally: + values_cleanup = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if ( + values_cleanup is not None + and values_cleanup.get(name) is skeleton + ): + del values_cleanup[name] + raise exc + # Success on retry + try: + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + except Exception: # pragma: no cover - defensive + pass + return skeleton + else: + exc = PartialConstructionError( + "factory for %r returned a different instance " + "than the skeleton" % (name,) + ) + try: + setattr(skeleton, "_apywire_failure", exc) + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + finally: + values_cleanup = cast( + Optional[dict[str, object]], + getattr(container_obj, "_values", None), + ) + if ( + values_cleanup is not None + and values_cleanup.get(name) is skeleton + ): + del values_cleanup[name] + raise exc + else: + try: + setattr(skeleton, "_apywire_partial", False) + ev = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if ev is not None: + ev.set() + except Exception: # pragma: no cover - defensive + pass + return skeleton + return ret + finally: + stack.pop() diff --git a/apywire/runtime.py b/apywire/runtime.py index 6610a61..b6a44fe 100644 --- a/apywire/runtime.py +++ b/apywire/runtime.py @@ -10,13 +10,15 @@ import asyncio import importlib import re +import threading from functools import cached_property from operator import itemgetter -from typing import Awaitable, Callable, Protocol, cast, final +from typing import Awaitable, Callable, Optional, Protocol, cast, final from apywire.constants import PLACEHOLDER_REGEX, SYNTHETIC_CONST from apywire.exceptions import ( CircularWiringError, + PartialConstructionError, UnknownPlaceholderError, WiringError, ) @@ -64,6 +66,7 @@ def __init__( thread_safe: bool = False, max_lock_attempts: int = 10, lock_retry_sleep: float = 0.01, + allow_partial: bool = False, ) -> None: """Initialize a WiringRuntime container. @@ -80,7 +83,10 @@ def __init__( thread_safe=thread_safe, max_lock_attempts=max_lock_attempts, lock_retry_sleep=lock_retry_sleep, + allow_partial=allow_partial, ) + # Retain allow_partial attribute for clarity + self.allow_partial = allow_partial if self._thread_safe: self._init_thread_safety(max_lock_attempts, lock_retry_sleep) @@ -100,9 +106,80 @@ def _get_resolving_stack(self) -> list[str]: return ThreadSafeMixin._get_resolving_stack(self) return self._resolving_stack + def _skeletonize_type(self, cls: _Constructor) -> object: + """Allocate a skeleton instance for the given class-like constructor. + + The skeleton is created via cls.__new__(cls) and marked as partial. + We attach a threading.Event to allow other threads to wait for + finalization. + """ + try: + # The call to __new__ can confuse the type checker; narrow via cast + skeleton: object = cast( + object, cls.__new__(cls) # type: ignore[arg-type] + ) + except Exception as e: + raise PartialConstructionError( + f"failed to allocate skeleton for {cls}: {e}" + ) from e + # Mark skeleton as partial and attach sync primitives + try: + setattr(skeleton, "_apywire_partial", True) + setattr(skeleton, "_apywire_event", threading.Event()) + setattr(skeleton, "_apywire_failure", None) + except Exception: + # If we cannot attach markers, treat as not skeletonizable + raise PartialConstructionError( + f"type {cls} cannot be skeletonized" + ) + return skeleton + + def _finalize_skeleton( + self, skeleton: object, exc: Exception | None = None + ) -> None: # pragma: no cover - defensive notification only + """Finalize or fail a previously allocated skeleton. + + If exc is None, the skeleton is marked finalized and waiters are + notified. Otherwise, the failure is recorded and waiters are + notified before cleanup is performed by the caller. + """ + if exc is not None: + try: + setattr(skeleton, "_apywire_failure", exc) + except Exception: # pragma: no cover - defensive + pass + try: + setattr(skeleton, "_apywire_partial", False) + event = cast( + Optional[threading.Event], + getattr(skeleton, "_apywire_event", None), + ) + if event is not None: + try: + event.set() + except Exception: # pragma: no cover - defensive + pass + except Exception: # pragma: no cover - defensive + # Best-effort: ignore finalization errors + pass + + def _cleanup_skeleton( + self, name: str, skeleton: object, exc: Exception | None = None + ) -> None: + """Finalize skeleton, record failure and remove it from cache. + + This is a convenience helper to centralize the cleanup policy used + when partial construction fails or a factory violates expectations. + """ + try: + self._finalize_skeleton(skeleton, exc) + finally: + if name in self._values and self._values[name] is skeleton: + del self._values[name] + def __getattr__(self, name: str) -> Accessor: """Return a callable accessor for the named wired object.""" - # If the name is in our parsed spec or constants, return an accessor. + # Return accessor for known names if name in self._parsed or name in self._values: return Accessor(self, name) raise AttributeError( @@ -188,11 +265,45 @@ def _instantiate_impl(self, name: str) -> _RuntimeValue: # Check for circular dependencies stack = self._get_resolving_stack() if name in stack: + # Opt-in partial construction: allocate a skeleton placeholder + if not self.allow_partial: + msg = ( + "Circular wiring dependency detected: " + + " -> ".join(stack) + + " -> " + + name + ) + raise CircularWiringError(msg) + + # Create and cache a skeleton placeholder for the declared type + if name in self._parsed: + entry = self._parsed[name] + # Only class-like types can be skeletonized + if ( + entry.module_name == SYNTHETIC_CONST + and entry.class_name == "str" + ): + raise CircularWiringError( + "Circular wiring dependency detected while resolving " + + repr(name) + ) + module = importlib.import_module(entry.module_name) + cls = cast(_Constructor, getattr(module, entry.class_name)) + skeleton = self._skeletonize_type(cls) + # Insert into cache. Use _set_cache if provided for + # thread-safe paths. + if hasattr(self, "_set_cache"): + self._set_cache(name, skeleton) + else: + self._values[name] = skeleton + return skeleton + # Fallback: no parsed entry - raise as before raise CircularWiringError( - f"Circular wiring dependency detected: " - f"{' -> '.join(stack)} -> {name}" + ( + "Circular wiring dependency detected while resolving " + + repr(name) + ) ) - stack.append(name) try: if name in self._values: @@ -231,21 +342,100 @@ def _instantiate_impl(self, name: str) -> _RuntimeValue: # Resolve arguments kwargs = self._resolve_runtime(entry.data, context=name) - try: - if isinstance(kwargs, dict): - # Separate positional args (int keys) from keyword args - # (str keys) - pos_args, kwargs_dict = self._separate_args_kwargs(kwargs) + # Separate positional args (int keys) from keyword args + # (str keys) if we received a dict of parameters + if isinstance(kwargs, dict): + pos_args, kwargs_dict = self._separate_args_kwargs(kwargs) + elif isinstance(kwargs, list): + pos_args = kwargs + kwargs_dict = {} + else: + pos_args = [kwargs] + kwargs_dict = {} + + # Check if a skeleton placeholder was previously inserted for this + # name (due to an inner circular reference). If present and still + # partial, bind or initialize it instead of creating a new object. + values = cast( + Optional[dict[str, object]], getattr(self, "_values", None) + ) + if values is not None: + cached = values.get(name) + else: + cached = None + if cached is not None and cast( + bool, getattr(cached, "_apywire_partial", False) + ): + skeleton = cached + try: + if entry.factory_method: + # Temporarily override cls.__new__ to return skeleton + orig_new = cast(object, getattr(cls, "__new__", None)) + + def _tmp_new( + _cls: type, *a: object, **k: object + ) -> object: + return skeleton + + setattr(cls, "__new__", _tmp_new) + try: + ret = constructor(*pos_args, **kwargs_dict) + finally: + # Restore original __new__ + if orig_new is None: + try: + delattr(cls, "__new__") + except Exception: + pass + else: + try: + setattr(cls, "__new__", orig_new) + except Exception: + pass + + if ret is not skeleton: + exc = PartialConstructionError( + "factory for %r returned a different instance " + "than the skeleton" % (name,) + ) + self._cleanup_skeleton(name, skeleton, exc) + raise exc + + # Success: finalize skeleton + self._finalize_skeleton(skeleton, None) + instance = skeleton + else: + # Direct class initialization on the skeleton + try: + cast(object, cls).__init__( # type: ignore[misc] + skeleton, *pos_args, **kwargs_dict + ) + except Exception as e: + exc = PartialConstructionError( + "failed to initialize skeleton for %r: %s" + % (name, e) + ) + self._cleanup_skeleton(name, skeleton, exc) + raise exc from e + # Finalize on success + self._finalize_skeleton(skeleton, None) + instance = skeleton + except PartialConstructionError: + raise + except Exception as e: + exc = PartialConstructionError( + "partial construction failed for %r: %s" % (name, e) + ) + self._cleanup_skeleton(name, skeleton, exc) + raise exc from e + else: + # No skeleton present; normal instantiation + try: instance = constructor(*pos_args, **kwargs_dict) - elif isinstance(kwargs, list): - # All positional arguments - instance = constructor(*kwargs) - else: - instance = constructor(kwargs) - except Exception as e: - raise WiringError( - f"failed to instantiate '{name}': {e}" - ) from e + except Exception as e: + raise WiringError( + f"failed to instantiate '{name}': {e}" + ) from e return instance finally: @@ -344,7 +534,21 @@ def __call__(self) -> object: # Fast path: EAFP pattern for cache lookup # Try to get cached value directly (faster than check + get) try: - return self._wiring._values[self._name] + val = self._wiring._values[self._name] + # If this is a partial skeleton, wait for finalization + if cast(bool, getattr(val, "_apywire_partial", False)): + event = cast( + Optional[threading.Event], + getattr(val, "_apywire_event", None), + ) + if event is not None: + event.wait() + failure = cast( + Optional[Exception], getattr(val, "_apywire_failure", None) + ) + if failure is not None: + raise failure + return val except KeyError: pass @@ -370,20 +574,36 @@ def __getattr__(self, name: str) -> Callable[[], Awaitable[object]]: and name not in self._wiring._values ): raise AttributeError( - f"'{type(self._wiring).__name__}' object has no attribute " - f"'{name}'" + "'%s' object has no attribute %r" + % (type(self._wiring).__name__, name) ) async def _get() -> object: # EAFP: Try cached value first + loop = asyncio.get_running_loop() try: - return self._wiring._values[name] + val = self._wiring._values[name] + if cast(bool, getattr(val, "_apywire_partial", False)): + event = cast( + Optional[threading.Event], + getattr(val, "_apywire_event", None), + ) + if event is not None: + # Run blocking wait in executor to avoid + # blocking the event loop + await loop.run_in_executor(None, event.wait) + failure = cast( + Optional[Exception], + getattr(val, "_apywire_failure", None), + ) + if failure is not None: + raise failure + return val except KeyError: pass # If not cached, run instantiation in executor to avoid blocking # the event loop. - loop = asyncio.get_running_loop() return await loop.run_in_executor( None, lambda: self._wiring._instantiate_attr( diff --git a/apywire/threads.py b/apywire/threads.py index 91ed277..f71876f 100644 --- a/apywire/threads.py +++ b/apywire/threads.py @@ -13,7 +13,7 @@ import threading import time -from typing import Callable, Literal, NoReturn, cast +from typing import Callable, Literal, NoReturn, Optional, cast from apywire.constants import CACHE_ATTR_PREFIX from apywire.exceptions import LockUnavailableError @@ -58,6 +58,7 @@ def _init_thread_safety( max_lock_attempts: maximum retry attempts in global mode lock_retry_sleep: sleep time between attempts """ + # pragma: no cover - exercised via WiringRuntime self._inst_lock = threading.RLock() self._attr_locks: dict[str, threading.RLock] = {} self._attr_locks_lock = threading.Lock() @@ -93,7 +94,23 @@ def _check_cache(self, name: str) -> tuple[bool, object | None]: if hasattr(self, "_values"): values_dict = cast(dict[str, object], getattr(self, "_values")) if name in values_dict: - return (True, values_dict[name]) + val = values_dict[name] + # If partial skeleton, wait then re-raise stored failure if any + if cast(bool, getattr(val, "_apywire_partial", False)): + event = cast( + Optional[threading.Event], + getattr(val, "_apywire_event", None), + ) + if event is not None: + event.wait() + failure = cast( + Optional[Exception], + getattr(val, "_apywire_failure", None), + ) + if failure is not None: + # Re-raise stored exception for waiters + raise failure + return (True, val) # Check cached attribute (compiled path) if hasattr(self, cache_attr): @@ -149,15 +166,13 @@ def _instantiate_attr( lock = self._get_attribute_lock(name) mode = self._local.mode if mode is None: - # Optimistic locking: Try to acquire per-attribute lock without - # blocking. If successful, instantiate in optimistic mode. - # If not, fall back to global lock. + # Try optimistic per-attribute lock; fall back to global if busy. if lock.acquire(blocking=False): try: found, value = self._check_cache(name) if found: return value - # Enter optimistic mode and track held locks + # Enter optimistic mode self._local.mode = "optimistic" held = self._get_held_locks() held.clear() @@ -165,22 +180,21 @@ def _instantiate_attr( try: inst = maker() except LockUnavailableError: - # On optimistic failure, release the lock and fall - # through to global path + # On LockUnavailableError, release and fallback lock.release() held.clear() except Exception as e: - # Release lock before propagating exception + # Release lock and wrap error lock.release() self._wrap_instantiation_error(e, name) else: - # Store in cache + # Cache value self._set_cache(name, inst) - # Release the optimistic held locks before returning + # Release optimistic locks self._release_held_locks() return inst finally: - # Clean up optimistic mode when exiting top-level call + # Exit optimistic mode if self._local.mode == "optimistic": self._local.mode = None @@ -234,7 +248,5 @@ def _instantiate_attr( self._set_cache(name, inst) return inst finally: - # Locks are intentionally not released here; they will be - # released by the top-level caller that initiated the - # instantiation chain + # Leave locks to top-level caller pass diff --git a/apywire/wiring.py b/apywire/wiring.py index a17c2b5..2bcee1c 100644 --- a/apywire/wiring.py +++ b/apywire/wiring.py @@ -117,6 +117,7 @@ def __init__( thread_safe: bool = False, max_lock_attempts: int = 10, lock_retry_sleep: float = 0.01, + allow_partial: bool = False, ) -> None: """Initialize a Wiring container. @@ -127,17 +128,33 @@ def __init__( (only when thread_safe=True). lock_retry_sleep: Sleep time in seconds between lock retries (only when thread_safe=True). + allow_partial: If True, partial-construction cycles are allowed. """ self._thread_safe = thread_safe self._max_lock_attempts = max_lock_attempts self._lock_retry_sleep = lock_retry_sleep + # Allow partial is an early flag needed when validating dependency + # graphs — store it before performing cycle checks. + self.allow_partial = allow_partial parsed: list[_UnresolvedParsedEntry] = [] consts: dict[str, _SpecMapping | _ConstantValue] = {} - # First pass: classify entries into wired classes or constants + # Build unified dependency mapping to detect cycles early (Kahn) + all_deps: dict[str, set[str]] = {} for key, value in spec.items(): entry = self._parse_spec_entry(key, value) + if entry is None: + name = key + else: + name = entry.name + + # Find placeholder names in the raw spec value; this works for + # both constants (string placeholders) and wired entries + placeholder_names = self._find_placeholder_names(value) + all_deps[name] = placeholder_names + + # Continue classification into wired classes or constants if entry is not None: parsed.append(entry) else: @@ -200,13 +217,45 @@ def __init__( else: const_with_const_refs[key] = value - # Topologically sort and expand constant-only refs + # Determine ordering for constant-only references while avoiding + # duplicate Kahn runs. The strict (non-allow_partial) mode must run + # Kahn on the full dependency graph to detect cycles early at + # container initialization time. When partial-construction is + # enabled, only the constant-only subset needs to be checked. + full_order: list[str] | None = None + if not self.allow_partial and all_deps: + # Strict mode: validate the entire dependency graph now (raise + # CircularWiringError if a cycle is present). Store the full + # ordering to reuse for constants below. + full_order = _topological_sort( + all_deps, error_prefix="Circular wiring dependency detected" + ) + + sorted_const_keys: list[str] if const_with_const_refs: const_deps = { k: self._find_placeholder_names(v) for k, v in const_with_const_refs.items() } - sorted_const_keys = self._topological_sort(const_deps) + if self.allow_partial: + # Only constants are relevant; a single Kahn is sufficient + sorted_const_keys = _topological_sort( + const_deps, + error_prefix="Circular wiring dependency detected", + ) + else: + # Strict mode: reuse the global order if available; otherwise + # fall back to sorting the constants subset (no wired entries). + if full_order is not None: + const_keys = set(const_with_const_refs.keys()) + sorted_const_keys = [ + n for n in full_order if n in const_keys + ] + else: + sorted_const_keys = _topological_sort( + const_deps, + error_prefix="Circular wiring dependency detected", + ) else: sorted_const_keys = [] @@ -229,6 +278,7 @@ def __init__( None, # No factory method cast(str | _WiredRef, self._resolve(value)), ) + if not self._thread_safe: # Non-thread-safe mode: use simple list for resolving stack self._resolving_stack: list[str] = [] @@ -365,56 +415,7 @@ def _find_placeholder_names(self, obj: _SpecValue) -> set[str]: names.update(self._find_placeholder_names(v)) return names - def _topological_sort( - self, dependencies: dict[str, set[str]] - ) -> list[str]: - """Topologically sort items by dependency order using Kahn's algorithm. - - Args: - dependencies: Mapping of item names to their dependencies - - Returns: - List of items in dependency order (dependencies first) - - Raises: - CircularWiringError: If circular dependencies detected - """ - # Calculate in-degree (number of dependencies) for each node - # Pre-convert to set for efficient intersection - all_nodes = set(dependencies.keys()) - in_degree: dict[str, int] = {} - for node, deps in dependencies.items(): - # Filter to only dependencies within this set - internal_deps = deps & all_nodes - in_degree[node] = len(internal_deps) - - # Start with nodes that have no dependencies (within this set) - queue: deque[str] = deque( - [node for node, degree in in_degree.items() if degree == 0] - ) - result: list[str] = [] - - while queue: - node = queue.popleft() - result.append(node) - - # Find nodes that depend on this node (within this set) - for other_node, deps in dependencies.items(): - if node in deps: - in_degree[other_node] -= 1 - if in_degree[other_node] == 0: - queue.append(other_node) - - # If we couldn't process all nodes, there's a cycle - if len(result) != len(dependencies): - # Find nodes in cycle for error message - unprocessed = [n for n in dependencies if n not in result] - raise CircularWiringError( - f"Circular dependency detected in constants: " - f"{', '.join(unprocessed)}" - ) - - return result + # `_find_wiredref_names` removed — unused. def _resolve_constant( self, value: _SpecValue, resolved: dict[str, _RuntimeValue] @@ -463,3 +464,60 @@ def _resolve_constant( elif isinstance(value, tuple): return tuple(self._resolve_constant(v, resolved) for v in value) return value + + +def _topological_sort( + dependencies: dict[str, set[str]], *, error_prefix: str +) -> list[str]: + """Topologically sort items by dependency order using Kahn's algorithm. + + This implementation builds a reverse adjacency map so each node can + efficiently iterate only over nodes that depend on it (O(V+E)). + + Args: + dependencies: Mapping of item names to their dependencies + error_prefix: Error message prefix used when a cycle is found + + Returns: + List of items in dependency order (dependencies first) + + Raises: + CircularWiringError: If circular dependencies detected + """ + # Consider only mapping nodes + all_nodes = set(dependencies.keys()) + + # Build in-degree counts and reverse adjacency mapping + in_degree: dict[str, int] = {node: 0 for node in all_nodes} + reverse_adj: dict[str, set[str]] = {node: set() for node in all_nodes} + + for node, deps in dependencies.items(): + internal_deps = deps & all_nodes + in_degree[node] = len(internal_deps) + for dep in internal_deps: + reverse_adj[dep].add(node) + + # Initialize queue with nodes that have no internal dependencies + queue: deque[str] = deque([n for n, d in in_degree.items() if d == 0]) + result: list[str] = [] + + while queue: + node = queue.popleft() + result.append(node) + + # Process dependents of this node + for dependent in reverse_adj.get(node, ()): + in_degree[dependent] -= 1 + if in_degree[dependent] == 0: + queue.append(dependent) + + # If we couldn't process all nodes, there's a cycle + if len(result) != len(all_nodes): + unprocessed = [n for n in all_nodes if n not in result] + raise CircularWiringError(f"{error_prefix}: {', '.join(unprocessed)}") + + return result + + +# Duplicate `_WiredRef` definition removed — the class is defined above near +# the top of the module to keep related logic together. diff --git a/tests/test_cli.py b/tests/test_cli.py index 2852d8f..725a3a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,14 +2,16 @@ # # SPDX-License-Identifier: ISC +import argparse import subprocess import sys from io import StringIO +from typing import cast from unittest.mock import patch import pytest -from apywire.__main__ import main +from apywire.__main__ import cmd_compile, cmd_generate, main @pytest.mark.parametrize( @@ -259,6 +261,59 @@ def test_cli_compile_parsing_errors( assert expected_err in stderr_output +def test_cli_generate_reports_generation_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fail(*args: object, **kwargs: object) -> list[str]: + raise RuntimeError("explode") + + monkeypatch.setattr("apywire.__main__.Generator.generate", _fail) + + with patch("sys.stderr", new_callable=StringIO) as mock_stderr: + result = main( + ["generate", "--format", "json", "collections.OrderedDict d"] + ) + + assert result == 1 + assert "Error generating specification" in mock_stderr.getvalue() + + +def test_cli_generate_unknown_format_returns_error() -> None: + args = argparse.Namespace( + format="yaml", entries=cast(list[str], ["collections.OrderedDict d"]) + ) + + with patch("sys.stderr", new_callable=StringIO) as mock_stderr: + result = cmd_generate(args) + + assert result == 1 + assert "Unknown format: yaml" in mock_stderr.getvalue() + + +def test_cli_compile_unknown_format_returns_error() -> None: + args = argparse.Namespace( + format="yaml", + aio=False, + thread_safe=False, + input_file="-", + ) + + with patch("sys.stdin", StringIO("{}")): + with patch("sys.stderr", new_callable=StringIO) as mock_stderr: + result = cmd_compile(args) + + assert result == 1 + assert "Unknown format: yaml" in mock_stderr.getvalue() + + +def test_cli_compile_missing_file_returns_error() -> None: + with patch("sys.stderr", new_callable=StringIO) as mock_stderr: + result = main(["compile", "--format", "json", "missing-file.json"]) + + assert result == 1 + assert "Error reading file" in mock_stderr.getvalue() + + def test_cli_generate_toml_write_error() -> None: """Test CLI error handling for TOML write when tomli_w is not available.""" # Temporarily disable tomli_w diff --git a/tests/test_compile_partial.py b/tests/test_compile_partial.py new file mode 100644 index 0000000..5f989ae --- /dev/null +++ b/tests/test_compile_partial.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +import sys +from types import ModuleType +from typing import Protocol, cast, runtime_checkable + +import apywire + + +@runtime_checkable +class CompPartialModule(Protocol): + """Protocol for dynamically created comp_partial module.""" + + A: type + B: type + + +@runtime_checkable +class ClassWithDep(Protocol): + """Protocol for classes with a dep attribute.""" + + dep: object + + +@runtime_checkable +class CompiledModule(Protocol): + """Protocol for compiled wiring module.""" + + def a(self) -> ClassWithDep: ... + def b(self) -> ClassWithDep: ... + + +def test_compile_partial_factory_identity_preserved_sync() -> None: + """Compiled non-async accessors preserve identity for + factory-based cycles. + """ + + class CompPartialModuleImpl(ModuleType): + A: type + B: type + + mod = CompPartialModuleImpl("comp_partial") + + class A: + def __init__(self, dep: object | None = None) -> None: + self.dep = dep + + @classmethod + def create(cls, dep: object | None = None) -> "A": + return cls(dep) + + class B: + def __init__(self, dep: object | None = None) -> None: + self.dep = dep + + @classmethod + def create(cls, dep: object | None = None) -> "B": + return cls(dep) + + mod.A = A + mod.B = B + sys.modules["comp_partial"] = mod + + try: + spec: apywire.Spec = { + "comp_partial.A a.create": {"dep": "{b}"}, + "comp_partial.B b.create": {"dep": "{a}"}, + } + compiler = apywire.WiringCompiler(spec, allow_partial=True) + code = compiler.compile(aio=False) + + execd: dict[str, object] = {} + exec(code, execd) + compiled = cast(CompiledModule, execd["compiled"]) + + a = compiled.a() + b = compiled.b() + + assert a.dep is b + assert b.dep is a + finally: + del sys.modules["comp_partial"] diff --git a/tests/test_constant_placeholders.py b/tests/test_constant_placeholders.py index 8fdf35c..a727dee 100644 --- a/tests/test_constant_placeholders.py +++ b/tests/test_constant_placeholders.py @@ -95,7 +95,7 @@ def test_circular_dependency_in_constants() -> None: with pytest.raises(apywire.CircularWiringError) as exc_info: apywire.Wiring(spec, thread_safe=False) - assert "Circular dependency detected in constants" in str(exc_info.value) + assert "Circular wiring dependency detected" in str(exc_info.value) def test_circular_with_wired_objects() -> None: @@ -115,11 +115,9 @@ def __init__(self, dep: object) -> None: "test_module.MyClass a": {"dep": "{b}"}, "test_module.MyClass b": {"dep": "{a}"}, } - wired = apywire.Wiring(spec, thread_safe=False) - - # Should raise when accessed, not at init + # Should raise at init time now (no lazy access errors) with pytest.raises(apywire.CircularWiringError): - wired.a() + apywire.Wiring(spec, thread_safe=False) finally: del sys.modules["test_module"] diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 1be3e3e..6eac6b2 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -4,15 +4,50 @@ import threading import time -from typing import cast +from typing import Protocol, cast from unittest.mock import MagicMock import pytest +import apywire from apywire.exceptions import LockUnavailableError, WiringError from apywire.threads import ThreadSafeMixin +class CycleModule(Protocol): + """Protocol for cyclemod module.""" + + A: type + B: type + + +class FactoryModule(Protocol): + """Protocol for factorymod module.""" + + A: type + B: type + + +class BadFactoryModule(Protocol): + """Protocol for badfactorymod module.""" + + Bad: type + + +class ClassWithDep(Protocol): + """Protocol for classes with a dep attribute.""" + + dep: object + a: object + b: object + + +class ClassWithRef(Protocol): + """Protocol for classes with a ref attribute.""" + + ref: object + + class MockContainer(ThreadSafeMixin): # Dynamic attributes for testing _bar: str @@ -402,9 +437,145 @@ def hold_lock_briefly() -> None: result = container._instantiate_attr("race_global", maker) assert result == "race_global_won" maker.assert_not_called() + t.join() +def test_runtime_identity_preserved_for_classes() -> None: + """Test that skeleton placeholders preserve identity for class cycles.""" + import sys + import types + + class CycleModuleImpl(types.ModuleType): + A: type + B: type + + mod = CycleModuleImpl("cyclemod") + + class A: + def __init__(self, b: object | None = None) -> None: + # store the reference to observe identity + self.b = b + + class B: + def __init__(self, a: object | None = None) -> None: + self.a = a + + mod.A = A + mod.B = B + sys.modules["cyclemod"] = mod + + from apywire import Wiring + + spec: apywire.Spec = { + "cyclemod.A a": {"b": "{b}"}, + "cyclemod.B b": {"a": "{a}"}, + } + + wired = Wiring(spec, allow_partial=True) + + a = cast(ClassWithDep, wired.a()) + b = cast(ClassWithDep, wired.b()) + + assert a.b is b + assert b.a is a + + +def test_factory_identity_preserved_with_temporary_new_override() -> None: + """Test that factory-based cycles preserve identity via + a temporary __new__ override. + """ + import sys + import types + + class FactoryModuleImpl(types.ModuleType): + A: type + B: type + + mod = FactoryModuleImpl("factorymod") + + class A: + def __init__(self, dep: object = None) -> None: + self.dep = dep + + @classmethod + def create(cls, dep: object = None) -> object: + return cls(dep) + + class B: + def __init__(self, dep: object = None) -> None: + self.dep = dep + + @classmethod + def create(cls, dep: object = None) -> object: + return cls(dep) + + mod.A = A + mod.B = B + sys.modules["factorymod"] = mod + + from apywire import Wiring + + spec: apywire.Spec = { + "factorymod.A a.create": {"dep": "{b}"}, + "factorymod.B b.create": {"dep": "{a}"}, + } + + wired = Wiring(spec, allow_partial=True) + + a = cast(ClassWithDep, wired.a()) + b = cast(ClassWithDep, wired.b()) + + assert a.dep is b + assert b.dep is a + + +def test_factory_violation_removes_skeleton_and_raises() -> None: + """If factory bypasses __new__ and returns a different object, error and + cleanup.""" + import sys + import types + + class BadFactoryModuleImpl(types.ModuleType): + Bad: type + + mod = BadFactoryModuleImpl("badfactorymod") + + class Bad: + def __init__(self, ref: object | None = None) -> None: + self.ref = ref + + @classmethod + def create(cls, ref: object | None = None) -> object: + # Bypass normal instantiation: return a new unrelated object + return object() + + mod.Bad = Bad + sys.modules["badfactorymod"] = mod + + from apywire import Wiring + from apywire.exceptions import PartialConstructionError + + spec: apywire.Spec = { + "badfactorymod.Bad bad.create": {"ref": "{other}"}, + "badfactorymod.Bad other": {"ref": "{bad}"}, + } + + wired = Wiring(spec, allow_partial=True) + + with pytest.raises(PartialConstructionError): + wired.bad() + + # Ensure skeleton removed from cache + if "bad" in wired._values: + bad_cached = wired._values["bad"] + # Check that skeleton is no longer marked as partial + partial_status = cast( + bool, getattr(bad_cached, "_apywire_partial", True) + ) + assert partial_status is False + + def test_instantiate_attr_global_exception() -> None: """Test exception handling in global mode.""" container = MockContainer() diff --git a/tests/test_graph_detection.py b/tests/test_graph_detection.py new file mode 100644 index 0000000..bb54ebe --- /dev/null +++ b/tests/test_graph_detection.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +from types import ModuleType +from typing import Protocol + +import pytest + +import apywire +from apywire.exceptions import CircularWiringError + + +class M(ModuleType): + def __init__(self) -> None: + super().__init__("m") + + +class HasB(Protocol): + b: object + + +class HasA(Protocol): + a: object + + +class A: + def __init__(self, b: HasB) -> None: + self.b = b + + +class B: + def __init__(self, a: HasA) -> None: + self.a = a + + +def test_detect_cycle_between_constant_and_wired() -> None: + # constant 'c' references 'w' and wired 'w' references 'c' -> cycle + spec: apywire.Spec = { + "c": "{w}", + "tests.test_graph_detection.A w": {"b": "{c}"}, + } + + with pytest.raises(CircularWiringError): + apywire.Wiring(spec) + + +def test_detect_cycle_mixed_wired_constants() -> None: + # Round-trip cycle across multiple entries + spec: apywire.Spec = { + "a": "{b}", + "tests.test_graph_detection.A b": {"b": "{c}"}, + "c": "{a}", + } + + with pytest.raises(CircularWiringError): + apywire.Wiring(spec) + + +def test_no_false_positive_for_external_placeholders() -> None: + # Placeholder references an external name (not in spec) should not + # cause a cycle detection. + spec: apywire.Spec = { + "a": "{b}", + "tests.test_graph_detection.A c": {"b": "foo"}, + } + + # Should not raise a circular-wiring error; resolving constants will + # surface a meaningful UnknownPlaceholderError for missing constant + with pytest.raises(apywire.UnknownPlaceholderError): + apywire.Wiring(spec) diff --git a/tests/test_partial_helper.py b/tests/test_partial_helper.py new file mode 100644 index 0000000..13b44f1 --- /dev/null +++ b/tests/test_partial_helper.py @@ -0,0 +1,369 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +import asyncio +import threading +from typing import Callable, cast + +import pytest + +import apywire +from apywire.exceptions import CircularWiringError, PartialConstructionError +from apywire.partial import _compiled_instantiate_with_partial +from apywire.runtime import Accessor, AioAccessor, _Constructor + + +class _DummyContainer: + def __init__( + self, + *, + allow_partial: bool = True, + values: dict[str, object] | None = None, + stack: list[str] | None = None, + ) -> None: + self._values = values + self._resolving_stack = stack + self.allow_partial = allow_partial + self._item: object | None = None + + +class _SimpleFactory: + _apywire_partial: bool + _apywire_event: threading.Event + _apywire_failure: Exception | None + + def __init__(self) -> None: + self.created = True + + @classmethod + def build(cls) -> "_SimpleFactory": + return cls() + + +class _ExplodingInit: + _apywire_partial: bool + _apywire_event: threading.Event + _apywire_failure: Exception | None + + def __init__(self) -> None: + raise RuntimeError("boom") + + +class _NoAttrs: + __slots__ = () + + def __call__( + self, *args: object, **kwargs: object + ) -> "_NoAttrs": # pragma: no cover + return self + + +class _LockedSlots: + __slots__ = () + + def __new__(cls) -> "_LockedSlots": + return super().__new__(cls) + + +class _Skeleton: + def __init__(self, failure: Exception | None = None) -> None: + self._apywire_partial = True + self._apywire_event = threading.Event() + self._apywire_failure = failure + + +def _make_skeleton( + ready: bool = False, failure: Exception | None = None +) -> _Skeleton: + skeleton = _Skeleton(failure) + if ready: + skeleton._apywire_event.set() + skeleton._apywire_partial = False + return skeleton + + +def test_compiled_partial_rejects_circular_without_opt_in() -> None: + container = _DummyContainer(allow_partial=False, values={}, stack=["item"]) + + with pytest.raises(CircularWiringError): + _compiled_instantiate_with_partial( + container, + "item", + lambda: _SimpleFactory.build(), + _SimpleFactory, + True, + ) + + +def test_compiled_partial_allocates_and_caches_skeleton() -> None: + container = _DummyContainer(values={}, stack=["item"]) + + skeleton = _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory.build, + _SimpleFactory, + True, + ) + + assert isinstance(skeleton, _SimpleFactory) + assert container._values == {"item": skeleton} + assert skeleton._apywire_partial is True + assert skeleton._apywire_event.is_set() is False + + container._resolving_stack = [] + finalized = _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory.build, + _SimpleFactory, + True, + ) + + assert finalized is skeleton + assert skeleton._apywire_partial is False + assert skeleton._apywire_event.is_set() is True + + +def test_compiled_partial_factory_mismatch_cleans_up() -> None: + container = _DummyContainer(values={}, stack=["item"]) + _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory.build, + _SimpleFactory, + True, + ) + container._resolving_stack = [] + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + lambda: object(), + _SimpleFactory, + True, + ) + + assert container._values == {} + + +def test_compiled_partial_direct_init_failure_removes_placeholder() -> None: + container = _DummyContainer(values={}, stack=["item"]) + _compiled_instantiate_with_partial( + container, + "item", + _ExplodingInit, + _ExplodingInit, + False, + ) + container._resolving_stack = [] + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + _ExplodingInit, + _ExplodingInit, + False, + ) + + assert container._values == {} + + +def test_compiled_partial_direct_init_success_with_attr_cache() -> None: + container = _DummyContainer(values=None, stack=["item"]) + _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory, + _SimpleFactory, + False, + ) + container._resolving_stack = [] + + result = _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory, + _SimpleFactory, + False, + ) + + assert isinstance(result, _SimpleFactory) + assert result is container._item + assert result._apywire_partial is False + + +def test_compiled_partial_new_failure_raises() -> None: + container = _DummyContainer(values={}, stack=["item"]) + + class NewBoom: + def __new__(cls) -> "NewBoom": + raise RuntimeError("nope") + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + lambda: NewBoom(), + NewBoom, + False, + ) + + +def test_compiled_partial_marker_injection_failure() -> None: + container = _DummyContainer(values={}, stack=["item"]) + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + _LockedSlots, + _LockedSlots, + False, + ) + + +def test_compiled_partial_cached_factory_generic_error_cleans_up() -> None: + container = _DummyContainer(values={}, stack=["item"]) + _compiled_instantiate_with_partial( + container, + "item", + _SimpleFactory.build, + _SimpleFactory, + True, + ) + container._resolving_stack = [] + + def _raise() -> object: + raise RuntimeError("bang") + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + _raise, + _SimpleFactory, + True, + ) + + assert container._values == {} + + +def test_compiled_partial_late_skeleton_mismatch_raises() -> None: + container = _DummyContainer(values={}, stack=[]) + late_skeleton = _make_skeleton() + container._values = {} + + def _constructor() -> object: + values = container._values + assert values is not None + values["item"] = late_skeleton + return _SimpleFactory() + + with pytest.raises(PartialConstructionError): + _compiled_instantiate_with_partial( + container, + "item", + _constructor, + _SimpleFactory, + False, + ) + + assert container._values == {} + + +def test_compiled_partial_normal_instantiation_without_skeleton() -> None: + container = _DummyContainer(values={}, stack=[]) + marker = object() + + ret = _compiled_instantiate_with_partial( + container, + "item", + lambda: marker, + _SimpleFactory, + False, + ) + + assert ret is marker + + +def test_runtime_skeletonize_type_errors_on_new_failure() -> None: + class NewFails: + def __new__(cls) -> "NewFails": + raise RuntimeError("fail new") + + def __call__(self, *args: object, **kwargs: object) -> object: + raise RuntimeError("never called") + + wiring = apywire.Wiring({}) + + bad_ctor = cast(_Constructor, NewFails) + + with pytest.raises(PartialConstructionError): + wiring._skeletonize_type(bad_ctor) + + +def test_runtime_skeletonize_type_errors_when_markers_forbidden() -> None: + wiring = apywire.Wiring({}) + + ctor = cast(_Constructor, _NoAttrs) + + with pytest.raises(PartialConstructionError): + wiring._skeletonize_type(ctor) + + +def test_runtime_finalize_records_failure_and_signals_event() -> None: + wiring = apywire.Wiring({}) + skeleton = _make_skeleton() + failure = ValueError("bad") + + wiring._finalize_skeleton(skeleton, failure) + + assert skeleton._apywire_failure is failure + assert skeleton._apywire_partial is False + assert skeleton._apywire_event.is_set() + + +def test_accessor_waits_and_raises_failure() -> None: + wiring = apywire.Wiring({}) + skeleton = _make_skeleton(failure=RuntimeError("fail")) + skeleton._apywire_event.set() + wiring._values["item"] = skeleton + accessor = Accessor(wiring, "item") + + with pytest.raises(RuntimeError): + accessor() + + +def test_aio_accessor_waits_and_propagates_failure() -> None: + wiring = apywire.Wiring({}) + skeleton = _make_skeleton(failure=RuntimeError("fail")) + skeleton._apywire_event.set() + wiring._values["item"] = skeleton + aio_accessor = AioAccessor(wiring) + + async def _call() -> None: + await aio_accessor.item() + + with pytest.raises(RuntimeError): + asyncio.run(_call()) + + +@pytest.mark.parametrize("constructor", [int, lambda: 42]) +def test_compiled_partial_handles_plain_construction( + constructor: Callable[[], object], +) -> None: + container = _DummyContainer(values={}, stack=[]) + + result = _compiled_instantiate_with_partial( + container, + "plain", + constructor, + int, + False, + ) + + assert result is not None diff --git a/tests/test_single.py b/tests/test_single.py index a19cdc8..0b000dd 100644 --- a/tests/test_single.py +++ b/tests/test_single.py @@ -7,6 +7,7 @@ from typing import Protocol, cast import black +import pytest import apywire @@ -649,14 +650,9 @@ def __init__(self) -> None: "mymod_circ.A a": {"b": "{b}"}, "mymod_circ.B b": {"a": "{a}"}, } - wired: apywire.Wiring = apywire.Wiring(spec, thread_safe=False) - try: - _ = wired.a() - assert ( - False - ), "Should have raised CircularWiringError for circular dependency" - except apywire.CircularWiringError as e: - assert "Circular wiring dependency detected" in str(e) + with pytest.raises(apywire.CircularWiringError) as exc_info: + apywire.Wiring(spec, thread_safe=False) + assert "Circular wiring dependency detected" in str(exc_info.value) finally: if "mymod_circ" in sys.modules: del sys.modules["mymod_circ"] diff --git a/tests/test_threading.py b/tests/test_threading.py index bf5afe4..5341b42 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -27,6 +27,19 @@ class AsyncSingletonProtocol(Protocol): def singleton(self) -> Awaitable[object]: ... +class ConcurrentFactoryModule(Protocol): + """Protocol for concurrent_factory module.""" + + A: type + B: type + + +class ClassWithDep(Protocol): + """Protocol for classes with a dep attribute.""" + + dep: object + + def test_thread_safe_singleton_instantiation() -> None: """Test that multiple threads instantiating singleton.""" @@ -385,12 +398,9 @@ def __init__(self) -> None: "mymod_circ_nonthreaded.A a": {"b": "{b}"}, "mymod_circ_nonthreaded.B b": {"a": "{a}"}, } - wired: apywire.Wiring = apywire.Wiring(spec, thread_safe=False) - try: - _ = wired.a() - assert False, "Should have raised CircularWiringError" - except apywire.CircularWiringError as e: - assert "Circular wiring dependency detected" in str(e) + with pytest.raises(apywire.CircularWiringError) as exc_info: + apywire.Wiring(spec, thread_safe=False) + assert "Circular wiring dependency detected" in str(exc_info.value) finally: if "mymod_circ_nonthreaded" in sys.modules: del sys.modules["mymod_circ_nonthreaded"] @@ -399,6 +409,75 @@ def __init__(self) -> None: def test_threaded_mode_circular_dependency() -> None: """Test that circular dependency detection works in threaded mode.""" + +def test_threaded_factory_single_init_and_waiters_receive_same_inst() -> None: + """Concurrent factory-based cyclic instantiation results + in single init and same instance for waiters. + """ + import sys + import types + + class ConcurrentFactoryModuleImpl(types.ModuleType): + A: type + B: type + + mod = ConcurrentFactoryModuleImpl("concurrent_factory") + + class ConcurrentA: + inst_count: int = 0 + + def __init__(self, dep: object | None = None) -> None: + type(self).inst_count += 1 + self.dep = dep + + @classmethod + def create(cls, dep: object | None = None) -> "ConcurrentA": + return cls(dep) + + class ConcurrentB: + inst_count: int = 0 + + def __init__(self, dep: object | None = None) -> None: + type(self).inst_count += 1 + self.dep = dep + + @classmethod + def create(cls, dep: object | None = None) -> "ConcurrentB": + return cls(dep) + + mod.A = ConcurrentA + mod.B = ConcurrentB + sys.modules["concurrent_factory"] = mod + + try: + spec: apywire.Spec = { + "concurrent_factory.A a.create": {"dep": "{b}"}, + "concurrent_factory.B b.create": {"dep": "{a}"}, + } + wired: apywire.Wiring = apywire.Wiring( + spec, thread_safe=True, allow_partial=True + ) + + results: list[object] = [] + + def worker() -> None: + results.append(wired.a()) + + threads: list[threading.Thread] = [] + for _ in range(10): + t = threading.Thread(target=worker) + threads.append(t) + t.start() + for t in threads: + t.join() + + assert ConcurrentA.inst_count == 1 + assert ConcurrentB.inst_count == 1 + assert all(r is results[0] for r in results) + finally: + if "concurrent_factory" in sys.modules: + del sys.modules["concurrent_factory"] + class A: def __init__(self, b: object) -> None: self.b = b @@ -407,25 +486,25 @@ class B: def __init__(self, a: object) -> None: self.a = a - class MockModule(ModuleType): + class CircThreadedModuleImpl(ModuleType): + A: type + B: type + def __init__(self) -> None: super().__init__("mymod_circ_threaded") self.A = A self.B = B - mod = MockModule() - sys.modules["mymod_circ_threaded"] = mod + circ_mod = CircThreadedModuleImpl() + sys.modules["mymod_circ_threaded"] = circ_mod try: - spec: apywire.Spec = { + circ_spec: apywire.Spec = { "mymod_circ_threaded.A a": {"b": "{b}"}, "mymod_circ_threaded.B b": {"a": "{a}"}, } - wired: apywire.Wiring = apywire.Wiring(spec, thread_safe=True) - try: - _ = wired.a() - assert False, "Should have raised CircularWiringError" - except apywire.CircularWiringError as e: - assert "Circular wiring dependency detected" in str(e) + with pytest.raises(apywire.CircularWiringError) as exc_info: + apywire.Wiring(circ_spec, thread_safe=True) + assert "Circular wiring dependency detected" in str(exc_info.value) finally: if "mymod_circ_threaded" in sys.modules: del sys.modules["mymod_circ_threaded"] diff --git a/uv.lock.license b/uv.lock.license new file mode 100644 index 0000000..2b1a571 --- /dev/null +++ b/uv.lock.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas + +SPDX-License-Identifier: ISC