Skip to content

Commit 3220823

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

17 files changed

Lines changed: 1712 additions & 131 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ reuse:
1919
--license ISC \
2020
--recursive .
2121

22-
format: reuse
22+
format:
2323
uv run black apywire tests
2424
uv run isort apywire tests
2525

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: 85 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,31 @@
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__(
65+
spec, thread_safe=thread_safe, allow_partial=allow_partial
66+
)
67+
self.allow_partial = allow_partial
68+
4369
def _astify(self, obj: _ResolvedValue, aio: bool = False) -> ast.expr:
4470
"""Convert a Python object (possibly a `_WiredRef`) to AST.
4571
@@ -358,6 +384,32 @@ def _compile_property(
358384

359385
call = ast.Call(func=module_attr, args=args, keywords=kwargs)
360386

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

363415
# Create reusable components
@@ -366,10 +418,10 @@ def _compile_property(
366418
# Build the assignment that sets the cache value; different
367419
# behavior is required for async vs sync callers.
368420
if not aio:
369-
assign_cache = self._create_cache_assignment(cache_attr, call)
421+
assign_cache = self._create_cache_assignment(cache_attr, inst_expr)
370422
else:
371423
# Async path: compute local values and call in executor.
372-
lambda_expr = self._create_lambda_function(call)
424+
lambda_expr = self._create_lambda_function(inst_expr)
373425
pre_statements.append(self._create_loop_assignment())
374426
run_call = self._create_executor_call(lambda_expr)
375427
assign_cache = self._create_cache_assignment(cache_attr, run_call)
@@ -384,7 +436,7 @@ def _compile_property(
384436
)
385437
elif not aio and thread_safe:
386438
# Build sync thread-safe version using helper mixin
387-
maker = self._create_lambda_function(call)
439+
maker = self._create_lambda_function(inst_expr)
388440
call_inst = ast.Call(
389441
func=ast.Attribute(
390442
value=ast.Name(id="self", ctx=ast.Load()),
@@ -610,6 +662,11 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
610662
# When compiling thread_safe, import thread-safety primitives
611663
modules.add("apywire.threads")
612664
modules.add("apywire.exceptions")
665+
# If compiler is configured to support partial construction, ensure
666+
# runtime helper is available in generated module.
667+
if self.allow_partial:
668+
modules.add("apywire.partial")
669+
modules.add("apywire.exceptions")
613670
for module in sorted(modules):
614671
if module == "apywire.threads":
615672
# Import ThreadSafeMixin from threads
@@ -633,15 +690,21 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
633690
level=0,
634691
)
635692
)
693+
elif module == "apywire.partial":
694+
# Import partial helper module used by compiled code
695+
imp = ast.Import(names=[ast.alias(name="apywire.partial")])
696+
body.append(imp)
636697
else:
637698
body.append(ast.Import(names=[ast.alias(name=module)]))
638699

639700
# Build class body
640701
class_body: list[ast.stmt] = []
641-
# When thread safe, add __init__ that calls helper mixin init
702+
# Add __init__ if needed to support thread-safety init and/or
703+
# partial-construction flag propagation.
704+
init_body: list[ast.stmt] = []
705+
needs_init = False
642706
if thread_safe:
643-
# class __init__
644-
init_body: list[ast.stmt] = []
707+
needs_init = True
645708
# compiled class will inherit from the mixin and just call
646709
# `_init_thread_safety` from its constructor
647710
init_body.append(
@@ -657,6 +720,22 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
657720
),
658721
)
659722
)
723+
if self.allow_partial:
724+
needs_init = True
725+
# Set instance flag so compiled container behaves like runtime
726+
init_body.append(
727+
ast.Assign(
728+
targets=[
729+
ast.Attribute(
730+
value=ast.Name(id="self", ctx=ast.Load()),
731+
attr="allow_partial",
732+
ctx=ast.Store(),
733+
)
734+
],
735+
value=ast.Constant(value=True),
736+
)
737+
)
738+
if needs_init:
660739
init_def = ast.FunctionDef(
661740
name="__init__",
662741
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)