The collection injection docs state:
Sequence[T] includes the default implementation, if present, plus any qualified implementations in registration order.
That holds when injectables are listed explicitly (injectables=[A, B]), which is what the unit tests do. It does not hold when a module is passed for scanning — and module scan is the usual setup for a larger project.
Reproduction
demo/services.py:
from typing import Protocol
import wireup
class Greeter(Protocol):
def hi(self) -> str: ...
@wireup.injectable(as_type=Greeter)
class Alpha:
def hi(self) -> str: return "alpha"
@wireup.injectable(as_type=Greeter, qualifier="beta")
class Beta:
def hi(self) -> str: return "beta"
@wireup.injectable(as_type=Greeter, qualifier="gamma")
class Gamma:
def hi(self) -> str: return "gamma"
@wireup.injectable(as_type=Greeter, qualifier="delta")
class Delta:
def hi(self) -> str: return "delta"
run.py:
from collections.abc import Sequence
import wireup
import demo.services as services
container = wireup.create_sync_container(injectables=[services])
print([g.hi() for g in container.get(Sequence[services.Greeter])])
Five consecutive runs on wireup 2.12.0, CPython 3.12:
['delta', 'gamma', 'beta', 'alpha']
['delta', 'alpha', 'beta', 'gamma']
['beta', 'alpha', 'delta', 'gamma']
['alpha', 'gamma', 'beta', 'delta']
['beta', 'alpha', 'gamma', 'delta']
Neither declaration order nor qualifier order is preserved, and the result differs between processes.
Cause
wireup/_discovery.py collects discovered targets into a set:
all_targets = {
m for module in injectable_modules for m in _find_objects_in_module(module, predicate=_is_valid_wireup_target)
}
for cls in all_targets:
...
_find_objects_in_module also returns a set. Iteration order over a set of classes follows object hashes, which derive from id() — so it varies with allocation addresses between processes.
ContainerRegistry.impls was deliberately changed from set to list to preserve order, but the ordering is already lost two levels upstream, before it reaches the registry.
Suggested fix
Preserve discovery order — e.g. an ordered de-duplication in discover_wireup_registrations and _find_objects_in_module (a dict used as an ordered set, plus sorting path.iterdir() for a stable file walk).
Sorting the directory walk matters separately: Path.iterdir() yields filesystem order, which is not guaranteed to be stable across platforms even once the set is gone.
Notes
Order does not matter for every consumer — I index the collection by type(instance), so it does not affect me. Filing it because the documented guarantee is the kind of thing people build on, and the failure is silent: it looks fine in tests that pass injectables explicitly.
Possibly related to #135, which reports other robustness issues in the same file.
The collection injection docs state:
That holds when injectables are listed explicitly (
injectables=[A, B]), which is what the unit tests do. It does not hold when a module is passed for scanning — and module scan is the usual setup for a larger project.Reproduction
demo/services.py:run.py:Five consecutive runs on wireup 2.12.0, CPython 3.12:
Neither declaration order nor qualifier order is preserved, and the result differs between processes.
Cause
wireup/_discovery.pycollects discovered targets into aset:_find_objects_in_modulealso returns aset. Iteration order over a set of classes follows object hashes, which derive fromid()— so it varies with allocation addresses between processes.ContainerRegistry.implswas deliberately changed fromsettolistto preserve order, but the ordering is already lost two levels upstream, before it reaches the registry.Suggested fix
Preserve discovery order — e.g. an ordered de-duplication in
discover_wireup_registrationsand_find_objects_in_module(adictused as an ordered set, plus sortingpath.iterdir()for a stable file walk).Sorting the directory walk matters separately:
Path.iterdir()yields filesystem order, which is not guaranteed to be stable across platforms even once thesetis gone.Notes
Order does not matter for every consumer — I index the collection by
type(instance), so it does not affect me. Filing it because the documented guarantee is the kind of thing people build on, and the failure is silent: it looks fine in tests that pass injectables explicitly.Possibly related to #135, which reports other robustness issues in the same file.