Skip to content

Commit 7497a17

Browse files
committed
Merge branch 'master' of github.com:maldoinc/wireup
2 parents 847e8d2 + b0b02d0 commit 7497a17

3 files changed

Lines changed: 152 additions & 5 deletions

File tree

test/unit/test_container_creation.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import wireup
1111
from wireup._annotations import Inject, abstract, injectable
1212
from wireup.errors import WireupError
13+
from wireup.ioc import registry_validation
14+
from wireup.ioc.types import get_container_object_id
1315

1416
from test.unit.services.no_annotations.random.random_service import RandomService
1517

@@ -208,6 +210,131 @@ def make_foo_no_dependency() -> Foo:
208210
wireup.create_sync_container(injectables=[make_foo, make_bar, make_foo_no_dependency])
209211

210212

213+
def test_validates_container_walks_shared_dependencies_once(monkeypatch: pytest.MonkeyPatch) -> None:
214+
@wireup.injectable
215+
class Leaf: ...
216+
217+
@wireup.injectable
218+
@dataclass
219+
class Shared:
220+
leaf: Leaf
221+
222+
@wireup.injectable
223+
@dataclass
224+
class Foo:
225+
shared: Shared
226+
227+
@wireup.injectable
228+
@dataclass
229+
class Bar:
230+
shared: Shared
231+
232+
@wireup.injectable
233+
@dataclass
234+
class Baz:
235+
foo: Foo
236+
bar: Bar
237+
shared: Shared
238+
239+
reached: list[type] = []
240+
descended: list[type] = []
241+
walk = registry_validation.assert_valid_resolution_path
242+
243+
def recording_walk(**kwargs) -> None:
244+
dependency = kwargs["dependency"]
245+
reached.append(dependency.klass)
246+
object_id = get_container_object_id(dependency.klass, dependency.qualifier_value)
247+
# Dependencies known to be cycle-free return immediately, only record the rest.
248+
if object_id not in kwargs["known_cycle_free_objects"]:
249+
descended.append(dependency.klass)
250+
walk(**kwargs)
251+
252+
monkeypatch.setattr(registry_validation, "assert_valid_resolution_path", recording_walk)
253+
wireup.create_sync_container(injectables=[Leaf, Shared, Foo, Bar, Baz])
254+
255+
assert reached.count(Shared) > 1
256+
# Every dependency in the graph is walked exactly once no matter how many paths reach it.
257+
assert descended.count(Shared) == 1
258+
assert descended.count(Leaf) == 1
259+
assert descended.count(Foo) == 1
260+
assert descended.count(Bar) == 1
261+
262+
263+
def test_validates_container_raises_when_cycle_is_behind_a_walked_dependency() -> None:
264+
class Shared: ...
265+
266+
class Foo:
267+
def __init__(self, shared, bar): ...
268+
269+
class Bar:
270+
def __init__(self, shared, foo): ...
271+
272+
@wireup.injectable
273+
def make_shared() -> Shared:
274+
return Shared()
275+
276+
# Walking 'shared' clears it, the cycle behind 'bar' must still be found.
277+
@wireup.injectable
278+
def make_foo(shared: Shared, bar: Bar) -> Foo:
279+
return Foo(shared, bar)
280+
281+
@wireup.injectable
282+
def make_bar(shared: Shared, foo: Foo) -> Bar:
283+
return Bar(shared, foo)
284+
285+
with pytest.raises(
286+
WireupError,
287+
match=re.escape(
288+
f"Circular dependency detected for {Bar!r} (created via {make_bar.__module__}.{make_bar.__name__})"
289+
f"\n -> {Foo!r} (created via {make_foo.__module__}.{make_foo.__name__})"
290+
f"\n -> {Bar!r} (created via {make_bar.__module__}.{make_bar.__name__})"
291+
" ! Cycle here"
292+
),
293+
):
294+
wireup.create_sync_container(injectables=[make_shared, make_foo, make_bar])
295+
296+
297+
def test_validates_container_does_not_reuse_walked_dependencies_between_containers() -> None:
298+
class Foo:
299+
def __init__(self, bar): ...
300+
301+
class Bar:
302+
def __init__(self, foo): ...
303+
304+
class Baz:
305+
def __init__(self, foo): ...
306+
307+
@wireup.injectable
308+
def make_foo() -> Foo:
309+
return Foo(None)
310+
311+
@wireup.injectable
312+
def make_baz(foo: Foo) -> Baz:
313+
return Baz(foo)
314+
315+
# Foo is walked and recorded cycle-free here, which says nothing about another container's graph.
316+
wireup.create_sync_container(injectables=[make_foo, make_baz])
317+
318+
@wireup.injectable
319+
def make_cyclical_foo(bar: Bar) -> Foo:
320+
return Foo(bar)
321+
322+
@wireup.injectable
323+
def make_bar(foo: Foo) -> Bar:
324+
return Bar(foo)
325+
326+
with pytest.raises(
327+
WireupError,
328+
match=re.escape(
329+
f"Circular dependency detected for {Bar!r} (created via {make_bar.__module__}.{make_bar.__name__})"
330+
f"\n -> {Foo!r} (created via {make_cyclical_foo.__module__}.{make_cyclical_foo.__name__})"
331+
f"\n -> {Bar!r} (created via {make_bar.__module__}.{make_bar.__name__})"
332+
" ! Cycle here"
333+
),
334+
):
335+
wireup.create_sync_container(injectables=[make_cyclical_foo, make_bar])
336+
337+
211338
def test_lifetimes_match_factories() -> None:
212339
class ScopedService: ...
213340

test/unit/test_inject_from_container.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
from test.unit.services.no_annotations.random.random_service import RandomService
1919
from test.unit.services.with_annotations.services import Foo, FooImpl, OtherFooImpl, random_service_factory
2020

21+
# CPython renders id() as uppercase hex on Windows and lowercase elsewhere.
22+
HEX_ADDR = r"0x[0-9a-fA-F]+"
23+
2124

2225
async def test_injects_targets(container: Container) -> None:
2326
class NotManagedByWireup: ...
@@ -149,7 +152,7 @@ class NotManagedByWireup: ...
149152
with pytest.raises(
150153
WireupError,
151154
match=(
152-
r"Parameter 'not_managed_by_wireup' of <function .*test_raises_on_unknown_service.*\._ at 0x[0-9a-f]+> "
155+
rf"Parameter 'not_managed_by_wireup' of <function .*test_raises_on_unknown_service.*\._ at {HEX_ADDR}> "
153156
+ re.escape(f"has an unknown dependency on {NotManagedByWireup!r}{expected_qualifier_str}.")
154157
),
155158
):
@@ -164,7 +167,7 @@ async def test_raises_on_unknown_parameter(container: Container) -> None:
164167
with pytest.raises(
165168
WireupError,
166169
match=(
167-
r"Parameter 'not_managed_by_wireup' of <function .*test_raises_on_unknown_parameter.*\._ at 0x[0-9a-f]+> "
170+
rf"Parameter 'not_managed_by_wireup' of <function .*test_raises_on_unknown_parameter.*\._ at {HEX_ADDR}> "
168171
+ re.escape("depends on an unknown Wireup config key 'invalid'.")
169172
),
170173
):

wireup/ioc/registry_validation.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@
1313

1414
if TYPE_CHECKING:
1515
from wireup.ioc.registry import ContainerRegistry, InjectableFactory
16-
from wireup.ioc.types import Qualifier
16+
from wireup.ioc.types import ContainerObjectIdentifier, Qualifier
1717

1818

1919
def validate_registry(registry: ContainerRegistry) -> None:
2020
"""Assert that all required dependencies exist for this registry instance."""
21+
# Dependencies are shared between injectables, so the same subtree is reachable via many paths.
22+
# Remember the ones already known to be cycle-free to avoid walking them again.
23+
# Only valid for this run as a different registry describes a different graph.
24+
known_cycle_free_objects: set[ContainerObjectIdentifier] = set()
25+
2126
for obj_id, injectable_factory in registry.factories.items():
2227
if isinstance(obj_id, tuple):
2328
impl, impl_qualifier = obj_id
@@ -54,6 +59,7 @@ def validate_registry(registry: ContainerRegistry) -> None:
5459
dependencies=registry.dependencies,
5560
dependency=dependency,
5661
path=[],
62+
known_cycle_free_objects=known_cycle_free_objects,
5763
)
5864

5965
for name in unknown_dependencies_with_default:
@@ -118,18 +124,26 @@ def assert_dependency_exists(
118124
raise WireupError(msg)
119125

120126

121-
def assert_valid_resolution_path(
127+
def assert_valid_resolution_path( # noqa: PLR0913
122128
*,
123129
interfaces: dict[type, dict[Qualifier | None, type]],
124130
factories: dict[Any, InjectableFactory],
125131
dependencies: dict[Any, dict[str, AnnotatedParameter]],
126132
dependency: AnnotatedParameter,
127133
path: list[tuple[AnnotatedParameter, Any]],
134+
known_cycle_free_objects: set[ContainerObjectIdentifier],
128135
) -> None:
129136
"""Assert that the resolution path for a dependency does not create a cycle."""
130137
if dependency.klass in interfaces or dependency.is_parameter:
131138
return
132-
dependency_injectable_factory = factories[get_container_object_id(dependency.klass, dependency.qualifier_value)]
139+
object_id = get_container_object_id(dependency.klass, dependency.qualifier_value)
140+
141+
# A dependency whose subtree came back clean cannot lead to a cycle. Were it part of one,
142+
# the walk would have come back to it while it was still on the current path.
143+
if object_id in known_cycle_free_objects:
144+
return
145+
146+
dependency_injectable_factory = factories[object_id]
133147
new_path: list[tuple[AnnotatedParameter, Any]] = [*path, (dependency, dependency_injectable_factory)]
134148

135149
if any(p.klass == dependency.klass and p.qualifier_value == dependency.qualifier_value for p, _ in path):
@@ -154,4 +168,7 @@ def stringify_dependency(p: AnnotatedParameter, factory: Any) -> str:
154168
dependencies=dependencies,
155169
dependency=next_dependency,
156170
path=new_path,
171+
known_cycle_free_objects=known_cycle_free_objects,
157172
)
173+
174+
known_cycle_free_objects.add(object_id)

0 commit comments

Comments
 (0)