Skip to content

Commit f3a40e6

Browse files
committed
Add partials to support circular references
1 parent d2d0a7a commit f3a40e6

12 files changed

Lines changed: 1515 additions & 40 deletions

apywire/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,5 @@ def main(argv: list[str] | None = None) -> int:
192192
return func(args)
193193

194194

195-
if __name__ == "__main__":
195+
if __name__ == "__main__": # pragma: no cover - CLI entrypoint
196196
sys.exit(main())

apywire/compiler.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SYNTHETIC_CONST,
2020
)
2121
from apywire.wiring import (
22+
Spec,
2223
WiringBase,
2324
_ConstantValue,
2425
_ResolvedSpecMapping,
@@ -40,6 +41,29 @@
4041
class WiringCompiler(WiringBase):
4142
"""Wiring container with compilation support."""
4243

44+
# Explicit attribute added for type checkers
45+
allow_partial: bool
46+
"""Wiring container with compilation support."""
47+
48+
def __init__(
49+
self,
50+
spec: Spec,
51+
*,
52+
thread_safe: bool = False,
53+
allow_partial: bool = False,
54+
) -> None:
55+
"""Initialize compiler with optional partial-construction support.
56+
57+
Args:
58+
spec: wiring spec
59+
thread_safe: pack thread-safe behavior into generated code
60+
allow_partial: If True, the generated code will use a small helper
61+
module to support partial-construction placeholders for
62+
circular dependencies.
63+
"""
64+
super().__init__(spec, thread_safe=thread_safe)
65+
self.allow_partial = allow_partial
66+
4367
def _astify(self, obj: _ResolvedValue, aio: bool = False) -> ast.expr:
4468
"""Convert a Python object (possibly a `_WiredRef`) to AST.
4569
@@ -358,6 +382,32 @@ def _compile_property(
358382

359383
call = ast.Call(func=module_attr, args=args, keywords=kwargs)
360384

385+
# If enabled, wrap the call in a helper that preserves partial
386+
# construction semantics for compiled code.
387+
if self.allow_partial:
388+
helper_call = ast.Call(
389+
func=ast.Attribute(
390+
value=ast.Name(id="apywire.partial", ctx=ast.Load()),
391+
attr="_compiled_instantiate_with_partial",
392+
ctx=ast.Load(),
393+
),
394+
args=[
395+
ast.Name(id="self", ctx=ast.Load()),
396+
ast.Constant(value=name),
397+
self._create_lambda_function(call),
398+
ast.Attribute(
399+
value=ast.Name(id=module_name, ctx=ast.Load()),
400+
attr=class_name,
401+
ctx=ast.Load(),
402+
),
403+
ast.Constant(value=bool(factory_method)),
404+
],
405+
keywords=[],
406+
)
407+
inst_expr = helper_call
408+
else:
409+
inst_expr = call
410+
361411
cache_attr = f"{CACHE_ATTR_PREFIX}{name}"
362412

363413
# Create reusable components
@@ -366,10 +416,10 @@ def _compile_property(
366416
# Build the assignment that sets the cache value; different
367417
# behavior is required for async vs sync callers.
368418
if not aio:
369-
assign_cache = self._create_cache_assignment(cache_attr, call)
419+
assign_cache = self._create_cache_assignment(cache_attr, inst_expr)
370420
else:
371421
# Async path: compute local values and call in executor.
372-
lambda_expr = self._create_lambda_function(call)
422+
lambda_expr = self._create_lambda_function(inst_expr)
373423
pre_statements.append(self._create_loop_assignment())
374424
run_call = self._create_executor_call(lambda_expr)
375425
assign_cache = self._create_cache_assignment(cache_attr, run_call)
@@ -384,7 +434,7 @@ def _compile_property(
384434
)
385435
elif not aio and thread_safe:
386436
# Build sync thread-safe version using helper mixin
387-
maker = self._create_lambda_function(call)
437+
maker = self._create_lambda_function(inst_expr)
388438
call_inst = ast.Call(
389439
func=ast.Attribute(
390440
value=ast.Name(id="self", ctx=ast.Load()),
@@ -610,6 +660,11 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
610660
# When compiling thread_safe, import thread-safety primitives
611661
modules.add("apywire.threads")
612662
modules.add("apywire.exceptions")
663+
# If compiler is configured to support partial construction, ensure
664+
# runtime helper is available in generated module.
665+
if self.allow_partial:
666+
modules.add("apywire.partial")
667+
modules.add("apywire.exceptions")
613668
for module in sorted(modules):
614669
if module == "apywire.threads":
615670
# Import ThreadSafeMixin from threads
@@ -633,15 +688,21 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
633688
level=0,
634689
)
635690
)
691+
elif module == "apywire.partial":
692+
# Import partial helper module used by compiled code
693+
imp = ast.Import(names=[ast.alias(name="apywire.partial")])
694+
body.append(imp)
636695
else:
637696
body.append(ast.Import(names=[ast.alias(name=module)]))
638697

639698
# Build class body
640699
class_body: list[ast.stmt] = []
641-
# When thread safe, add __init__ that calls helper mixin init
700+
# Add __init__ if needed to support thread-safety init and/or
701+
# partial-construction flag propagation.
702+
init_body: list[ast.stmt] = []
703+
needs_init = False
642704
if thread_safe:
643-
# class __init__
644-
init_body: list[ast.stmt] = []
705+
needs_init = True
645706
# compiled class will inherit from the mixin and just call
646707
# `_init_thread_safety` from its constructor
647708
init_body.append(
@@ -657,6 +718,22 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
657718
),
658719
)
659720
)
721+
if self.allow_partial:
722+
needs_init = True
723+
# Set instance flag so compiled container behaves like runtime
724+
init_body.append(
725+
ast.Assign(
726+
targets=[
727+
ast.Attribute(
728+
value=ast.Name(id="self", ctx=ast.Load()),
729+
attr="allow_partial",
730+
ctx=ast.Store(),
731+
)
732+
],
733+
value=ast.Constant(value=True),
734+
)
735+
)
736+
if needs_init:
660737
init_def = ast.FunctionDef(
661738
name="__init__",
662739
args=_PROPERTY_ARGS,

apywire/exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ class CircularWiringError(WiringError):
3434
"""
3535

3636

37+
class PartialConstructionAccessError(WiringError):
38+
"""Raised when attempting to access or call an object that is still in
39+
partial construction (placeholder inserted but not yet finalized).
40+
41+
This error indicates the wiring is in a transient state and the user
42+
attempted to use the placeholder before it was bound to the final
43+
instance.
44+
"""
45+
46+
47+
class PartialConstructionError(WiringError):
48+
"""Raised when partial construction cannot be completed successfully.
49+
50+
Examples include factories that bypass `__new__` and return a different
51+
instance than the skeleton allocated by the wiring system.
52+
"""
53+
54+
3755
class LockUnavailableError(RuntimeError):
3856
"""Exception raised when a per-attribute lock cannot be acquired in
3957
optimistic (non-blocking) mode and the code must fall back to a global

0 commit comments

Comments
 (0)