Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Objects are instantiated only when accessed, using `__getattr__` to intercept at
Strings like `{name}` in spec values are converted to `_WiredRef` markers during parsing, then resolved to actual objects at instantiation time. See `constants.py` for delimiter patterns.

**Thread Safety Model:**
Uses optimistic per-attribute locking (`dict[str, threading.Lock]`) with a global fallback lock. Thread-local state tracks the resolving stack for circular dependency detection. See `threads.py` for `ThreadSafeMixin` implementation.
Uses optimistic per-attribute locking (`dict[str, threading.Lock]`) with a global fallback lock. Thread-local state stores lock-related per-thread state (mode and held locks) used by the `ThreadSafeMixin`; see `threads.py` for implementation details.

**Compilation Equivalence:**
Generated code must behave identically to runtime `Wiring`. The compiler generates accessor methods that replicate lazy loading, caching, and optional async/thread-safe behavior.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ reuse:
--license ISC \
--recursive .

format: reuse
format:
Comment thread
alganet marked this conversation as resolved.
uv run black apywire tests
uv run isort apywire tests

Expand Down
57 changes: 57 additions & 0 deletions apywire/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,65 @@ class CircularWiringError(WiringError):

This class is a `WiringError` subtype for simpler programmatic
handling of wiring-specific failures.

Utility: construct a helpful message from a dependency mapping and
unprocessed nodes when a topological sort fails.
"""

@classmethod
def from_unprocessed(
cls, dependencies: dict[str, set[str]], unprocessed: list[str]
) -> "CircularWiringError":
"""Create a CircularWiringError that includes a cycle when possible.

Args:
dependencies: Full mapping of node -> dependencies
unprocessed: List of nodes left unprocessed by topological sort

Returns:
CircularWiringError instance with a helpful message.
"""
all_nodes = set(dependencies.keys())

# Use DFS limited to unprocessed nodes to find any back-edge cycle path
visited: set[str] = set()
stack: list[str] = []
on_stack: set[str] = set()

def dfs(node: str) -> list[str] | None:
visited.add(node)
stack.append(node)
on_stack.add(node)
for nbr in dependencies.get(node, set()):
if nbr not in all_nodes:
continue
if nbr not in visited:
res = dfs(nbr)
if res:
return res
elif nbr in on_stack:
try:
idx = stack.index(nbr)
except ValueError:
idx = 0
return stack[idx:] + [nbr]
stack.pop()
on_stack.remove(node)
return None

for start in unprocessed:
if start not in visited:
cyc = dfs(start)
if cyc:
return cls(
f"Circular dependency detected: {
', '.join(unprocessed)
}; "
f"cycle: {' -> '.join(cyc)}"
)

return cls(f"Circular dependency detected: {', '.join(unprocessed)}")


class LockUnavailableError(RuntimeError):
"""Exception raised when a per-attribute lock cannot be acquired in
Expand Down
109 changes: 21 additions & 88 deletions apywire/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@
from apywire.constants import (
PLACEHOLDER_END,
PLACEHOLDER_START,
SPEC_KEY_DELIMITER,
)
from apywire.exceptions import CircularWiringError
from apywire.runtime import _Constructor
from apywire.wiring import Spec, _ConstantValue, _SpecMapping
from apywire.wiring import Spec, SpecParser, _ConstantValue, _SpecMapping

# Parameter sentinel values
_PARAM_EMPTY: object = inspect.Parameter.empty # type: ignore[misc]
Expand All @@ -37,7 +35,7 @@
_VAR_KEYWORD: object = inspect.Parameter.VAR_KEYWORD # type: ignore[misc]


class Generator:
class Generator(SpecParser):
"""Generates apywire specs from class introspection.

This class inspects class constructors to automatically generate
Expand Down Expand Up @@ -73,7 +71,6 @@ def generate(cls, *entries: str) -> Spec:

Raises:
ValueError: If entry format is invalid
CircularWiringError: If circular dependencies detected

Example:
>>> spec = Generator.generate("datetime.datetime now")
Expand All @@ -96,9 +93,13 @@ def _process_entry(cls, entry: str, spec: Spec, visited: set[str]) -> None:
spec: Spec dict to populate
visited: Set of already-visited class keys to detect cycles
"""
module_name, class_name, instance_name, factory_method = (
cls._parse_entry(entry)
)
parsed = cls._parse_request_string(entry)
if parsed is None:
raise ValueError(
f"Invalid entry format '{entry}': "
f"expected 'module.Class name'"
)
module_name, class_name, instance_name, factory_method = parsed

# Build the spec key
type_path = f"{module_name}.{class_name}"
Expand All @@ -108,9 +109,9 @@ def _process_entry(cls, entry: str, spec: Spec, visited: set[str]) -> None:

# Check for circular dependency
if spec_key in visited:
raise CircularWiringError(
f"Circular dependency detected: '{spec_key}'"
)
# Cycle detected: stop recursion. The runtime container will
# handle the cycle (or raise error) when resolving.
return
visited.add(spec_key)

# Import the class
Expand All @@ -136,8 +137,10 @@ def _process_entry(cls, entry: str, spec: Spec, visited: set[str]) -> None:
else:
spec[spec_key] = {}
return
except (ValueError, TypeError):
# Some built-in classes don't have inspectable signatures
except (ValueError, TypeError, RecursionError):
# Some built-in classes (or pathological annotations) don't have
# inspectable signatures; fall back to an empty entry to avoid
# infinite recursion.
spec[spec_key] = {}
return

Expand Down Expand Up @@ -174,60 +177,13 @@ def _process_entry(cls, entry: str, spec: Spec, visited: set[str]) -> None:

if not is_dependency:
# For non-dependency params, set a constant value
if has_default and cls._is_constant(param_default):
if has_default and cls._is_spec_constant(param_default):
spec[const_name] = cast(_ConstantValue, param_default)
else:
spec[const_name] = default_value

spec[spec_key] = cast(_SpecMapping, params)

@classmethod
def _parse_entry(cls, entry: str) -> tuple[str, str, str, str | None]:
"""Parse an entry string into components.

Args:
entry: Entry string like "module.Class name" or
"module.Class name.factoryMethod"

Returns:
Tuple of (module_name, class_name, instance_name, factory_method)

Raises:
ValueError: If entry format is invalid
"""
if SPEC_KEY_DELIMITER not in entry:
raise ValueError(
f"Invalid entry format '{entry}': "
f"expected 'module.Class name'"
)

type_str, name_part = entry.rsplit(SPEC_KEY_DELIMITER, 1)
parts = type_str.split(".")

if len(parts) < 2:
raise ValueError(
f"Invalid entry format '{entry}': "
f"missing module qualification"
)

module_name = ".".join(parts[:-1])
class_name = parts[-1]

# Check for factory method
factory_method: str | None = None
if "." in name_part:
instance_name, factory_method = name_part.split(".", 1)
else:
instance_name = name_part

if factory_method and "." in factory_method:
raise ValueError(
f"invalid generator '{name_part}': nested factory methods "
f"are not supported."
)

return module_name, class_name, instance_name, factory_method

@classmethod
def _get_default_for_type(
cls,
Expand Down Expand Up @@ -338,33 +294,10 @@ def _generate_dependency(

# Recursively process (will add to spec)
try:
cls._process_entry(entry, spec, visited.copy())
except (CircularWiringError, AttributeError, ModuleNotFoundError):
# If circular or can't find module, just reference it
# Propagate the same visited set so that cycles are detected
cls._process_entry(entry, spec, visited)
except (AttributeError, ModuleNotFoundError):
# If can't find module, just reference it
pass

return None, True

@classmethod
def _is_constant(cls, value: object) -> bool:
"""Check if a value is a valid constant for a spec.

Args:
value: Value to check

Returns:
True if value can be stored as a spec constant
"""
if value is None or value is ...:
return True
if isinstance(value, (str, bytes, bool, int, float, complex)):
return True
if isinstance(value, (list, tuple, dict)):
# Check contents recursively
if isinstance(value, dict):
return all(
cls._is_constant(k) and cls._is_constant(v)
for k, v in value.items()
)
return all(cls._is_constant(v) for v in value)
return False
108 changes: 42 additions & 66 deletions apywire/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

from apywire.constants import PLACEHOLDER_REGEX, SYNTHETIC_CONST
from apywire.exceptions import (
CircularWiringError,
UnknownPlaceholderError,
WiringError,
)
Expand Down Expand Up @@ -94,12 +93,6 @@ def _init_thread_safety(
self, max_lock_attempts, lock_retry_sleep
)

def _get_resolving_stack(self) -> list[str]:
"""Return the resolving stack for the current context."""
if self._thread_safe:
return ThreadSafeMixin._get_resolving_stack(self)
return self._resolving_stack

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.
Expand Down Expand Up @@ -185,71 +178,54 @@ def _instantiate_impl(self, name: str) -> _RuntimeValue:
"module.Class instance.from_date"), the factory method is called
instead of the class constructor.
"""
# Check for circular dependencies
stack = self._get_resolving_stack()
if name in stack:
raise CircularWiringError(
f"Circular wiring dependency detected: "
f"{' -> '.join(stack)} -> {name}"
if name in self._values:
return self._values[name]

if name not in self._parsed:
# Should have been caught by __getattr__ or _resolve_runtime,
# but just in case.
raise UnknownPlaceholderError(
f"Unknown placeholder '{name}' referenced."
)

stack.append(name)
try:
if name in self._values:
return self._values[name]
entry = self._parsed[name]

if name not in self._parsed:
# Should have been caught by __getattr__ or _resolve_runtime,
# but just in case.
raise UnknownPlaceholderError(
f"Unknown placeholder '{name}' referenced."
)
# Check for synthetic auto-promoted constant
if entry.module_name == SYNTHETIC_CONST and entry.class_name == "str":
# This is an auto-promoted constant with string interpolation
value = self._format_string_constant(entry.data, context=name)
self._values[name] = value
return value

entry = self._parsed[name]

# Check for synthetic auto-promoted constant
if (
entry.module_name == SYNTHETIC_CONST
and entry.class_name == "str"
):
# This is an auto-promoted constant with string interpolation
value = self._format_string_constant(entry.data, context=name)
self._values[name] = value
return value

module = importlib.import_module(entry.module_name)
cls = cast(_Constructor, getattr(module, entry.class_name))

# If a factory method is specified, get it from the class
if entry.factory_method:
constructor = cast(
_Constructor, getattr(cls, entry.factory_method)
)
else:
constructor = cls
module = importlib.import_module(entry.module_name)
cls = cast(_Constructor, getattr(module, entry.class_name))

# Resolve arguments
kwargs = self._resolve_runtime(entry.data, context=name)
# If a factory method is specified, get it from the class
if entry.factory_method:
constructor = cast(
_Constructor, getattr(cls, entry.factory_method)
)
else:
constructor = cls

try:
if isinstance(kwargs, dict):
# Separate positional args (int keys) from keyword args
# (str keys)
pos_args, kwargs_dict = self._separate_args_kwargs(kwargs)
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

return instance
finally:
stack.pop()
# 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)
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

return instance

def _resolve_runtime(
self,
Expand Down
Loading