idunn 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- idunn/__init__.py +34 -0
- idunn/app/__init__.py +6 -0
- idunn/app/decorators.py +137 -0
- idunn/app/idunn.py +85 -0
- idunn/domain/__init__.py +33 -0
- idunn/domain/adapter_declaration.py +18 -0
- idunn/domain/adapter_metadata.py +28 -0
- idunn/domain/errors.py +29 -0
- idunn/domain/lifecycle_enum.py +10 -0
- idunn/domain/port_binding.py +23 -0
- idunn/domain/registration_key.py +12 -0
- idunn/domain/report.py +21 -0
- idunn/internal/__init__.py +21 -0
- idunn/internal/auto_discovery.py +202 -0
- idunn/internal/decorator_support.py +59 -0
- idunn/internal/inversion_mapper.py +152 -0
- idunn/internal/inversion_resolver.py +168 -0
- idunn/internal/inversion_validator.py +66 -0
- idunn/py.typed +0 -0
- idunn/util/__init__.py +11 -0
- idunn/util/environment.py +43 -0
- idunn/util/meta_singleton.py +28 -0
- idunn/util/qualified_name.py +14 -0
- idunn-0.0.1.dist-info/METADATA +630 -0
- idunn-0.0.1.dist-info/RECORD +27 -0
- idunn-0.0.1.dist-info/WHEEL +4 -0
- idunn-0.0.1.dist-info/licenses/LICENSE +21 -0
idunn/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Idunn: tiny constructor-time dependency inversion for Python.
|
|
2
|
+
|
|
3
|
+
The entire public surface lives here: the three decorators (:func:`Port`,
|
|
4
|
+
:func:`Adapter`, :func:`Invert`), the :class:`Idunn` container, the
|
|
5
|
+
:class:`LifecycleEnum` you hand to ``@Adapter``, and the :class:`IdunnError`
|
|
6
|
+
exception hierarchy you catch. Everything else is internal plumbing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .app import Adapter, Idunn, Invert, Port
|
|
10
|
+
from .domain import (
|
|
11
|
+
AdapterNotFoundError,
|
|
12
|
+
DiscoveryError,
|
|
13
|
+
IdunnError,
|
|
14
|
+
InjectionCycleError,
|
|
15
|
+
InvalidAdapterError,
|
|
16
|
+
InvalidPortError,
|
|
17
|
+
LifecycleEnum,
|
|
18
|
+
MissingTypeHintError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
'Adapter',
|
|
23
|
+
'AdapterNotFoundError',
|
|
24
|
+
'DiscoveryError',
|
|
25
|
+
'Idunn',
|
|
26
|
+
'IdunnError',
|
|
27
|
+
'InjectionCycleError',
|
|
28
|
+
'InvalidAdapterError',
|
|
29
|
+
'InvalidPortError',
|
|
30
|
+
'Invert',
|
|
31
|
+
'LifecycleEnum',
|
|
32
|
+
'MissingTypeHintError',
|
|
33
|
+
'Port',
|
|
34
|
+
]
|
idunn/app/__init__.py
ADDED
idunn/app/decorators.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The @Port, @Adapter, and @Invert decorators."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
from typing import Any, TypeVar, get_type_hints, overload, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from idunn.domain import AdapterDeclaration, InvalidAdapterError, InvalidPortError, LifecycleEnum
|
|
9
|
+
from idunn.internal import DecoratorSupport
|
|
10
|
+
|
|
11
|
+
from .idunn import Idunn
|
|
12
|
+
|
|
13
|
+
T = TypeVar('T', bound=type[Any])
|
|
14
|
+
Init = Callable[..., None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def Port(cls: T) -> T:
|
|
18
|
+
"""Mark a Protocol as an Idunn port."""
|
|
19
|
+
if not getattr(cls, '_is_protocol', False):
|
|
20
|
+
message = f'@Port can only be applied to Protocol classes: {cls.__qualname__}'
|
|
21
|
+
raise InvalidPortError(message)
|
|
22
|
+
|
|
23
|
+
cls.__idunn_port__ = True
|
|
24
|
+
return runtime_checkable(cls)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def Adapter(
|
|
28
|
+
port: type[Any],
|
|
29
|
+
*,
|
|
30
|
+
key: str | None = None,
|
|
31
|
+
lifecycle: LifecycleEnum | str = LifecycleEnum.TRANSIENT,
|
|
32
|
+
envs: Iterable[str] | str | None = None,
|
|
33
|
+
) -> Callable[[T], T]:
|
|
34
|
+
"""Declare a concrete class as an adapter for a port.
|
|
35
|
+
|
|
36
|
+
Without ``key`` the adapter is *unkeyed*: it answers plain ``@Invert`` injection
|
|
37
|
+
— exactly one unkeyed adapter may be active per port in any environment.
|
|
38
|
+
|
|
39
|
+
With ``key`` the adapter is *opt-in*: it answers only ``@Invert(keys={...})`` and
|
|
40
|
+
is ignored by unkeyed resolution, so it never competes with the plain
|
|
41
|
+
implementation.
|
|
42
|
+
"""
|
|
43
|
+
if not getattr(port, '__idunn_port__', False):
|
|
44
|
+
message = f'Adapter port is not marked with @Port: {port.__qualname__}'
|
|
45
|
+
raise InvalidPortError(message)
|
|
46
|
+
|
|
47
|
+
lifecycle_value = LifecycleEnum(lifecycle)
|
|
48
|
+
env_values = DecoratorSupport.normalize_envs(envs)
|
|
49
|
+
|
|
50
|
+
def decorate(cls: T) -> T:
|
|
51
|
+
if not isinstance(cls, type):
|
|
52
|
+
message = f'@Adapter can only be applied to classes: {cls!r}'
|
|
53
|
+
raise InvalidAdapterError(message)
|
|
54
|
+
declaration = AdapterDeclaration(
|
|
55
|
+
port=port,
|
|
56
|
+
key=key,
|
|
57
|
+
lifecycle=lifecycle_value,
|
|
58
|
+
envs=env_values,
|
|
59
|
+
)
|
|
60
|
+
cls.__idunn_adapter__ = True
|
|
61
|
+
cls.__idunn_adapter_declaration__ = declaration
|
|
62
|
+
return cls
|
|
63
|
+
|
|
64
|
+
return decorate
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@overload
|
|
68
|
+
def Invert(init: Init) -> Init: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def Invert(
|
|
73
|
+
init: Mapping[str, type[Any]] | None = ...,
|
|
74
|
+
*,
|
|
75
|
+
keys: Mapping[str, str] | None = ...,
|
|
76
|
+
) -> Callable[[Init], Init]: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def Invert(
|
|
80
|
+
init: Init | Mapping[str, type[Any]] | None = None,
|
|
81
|
+
*,
|
|
82
|
+
keys: Mapping[str, str] | None = None,
|
|
83
|
+
) -> Init | Callable[[Init], Init]:
|
|
84
|
+
"""Auto-inject registered adapters for ``@Port``-typed constructor parameters.
|
|
85
|
+
|
|
86
|
+
Decorate a constructor; every parameter whose type hint is a ``@Port`` is
|
|
87
|
+
resolved from the process-wide :class:`Idunn` container at construction time
|
|
88
|
+
and assigned to ``self.<name>`` (the body may override; a caller-supplied
|
|
89
|
+
argument always wins). Constructing the decorated object is the sanctioned way
|
|
90
|
+
to start an object graph — there is no public ``resolve``.
|
|
91
|
+
|
|
92
|
+
A parameter typed ``Port | None`` (or any ``@Port`` parameter with a default) is
|
|
93
|
+
*optional*: if no adapter is active it falls back to the default / ``None``
|
|
94
|
+
instead of raising. Pick a *keyed* adapter with ``@Invert(keys={'param': 'name'})``,
|
|
95
|
+
choosing right at the point of use.
|
|
96
|
+
|
|
97
|
+
Forms::
|
|
98
|
+
|
|
99
|
+
@Invert # infer ports from the type hints
|
|
100
|
+
@Invert(keys={'basket': 'golden'}) # pick keyed adapters
|
|
101
|
+
@Invert({'basket': AppleBasketPort}) # explicit param -> port (no annotation)
|
|
102
|
+
"""
|
|
103
|
+
explicit_ports: dict[str, type[Any]] = {}
|
|
104
|
+
if isinstance(init, Mapping):
|
|
105
|
+
explicit_ports = dict(init)
|
|
106
|
+
init = None
|
|
107
|
+
key_map: dict[str, str] = dict(keys) if keys is not None else {}
|
|
108
|
+
|
|
109
|
+
def decorate(func: Init) -> Init:
|
|
110
|
+
signature = inspect.signature(func)
|
|
111
|
+
hints = get_type_hints(func)
|
|
112
|
+
discovered, optional_params = DecoratorSupport.extract_port_parameters(hints)
|
|
113
|
+
port_params: dict[str, type[Any]] = {**explicit_ports, **discovered}
|
|
114
|
+
|
|
115
|
+
@functools.wraps(func)
|
|
116
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
117
|
+
supplied = signature.bind_partial(self, *args, **kwargs).arguments
|
|
118
|
+
container = Idunn()
|
|
119
|
+
for name, port in port_params.items():
|
|
120
|
+
key = key_map.get(name)
|
|
121
|
+
parameter = signature.parameters[name]
|
|
122
|
+
optional = name in optional_params or parameter.default is not inspect.Parameter.empty
|
|
123
|
+
if name in supplied:
|
|
124
|
+
value = supplied[name] # a caller-supplied argument always wins
|
|
125
|
+
elif optional and not container._has(port, key):
|
|
126
|
+
# optional dep with no active adapter: fall back to the default (or None)
|
|
127
|
+
value = None if parameter.default is inspect.Parameter.empty else parameter.default
|
|
128
|
+
kwargs[name] = value
|
|
129
|
+
else:
|
|
130
|
+
value = container._inject(port, key) # required, or an adapter is available
|
|
131
|
+
kwargs[name] = value
|
|
132
|
+
setattr(self, name, value) # the port always lands on self.<name>
|
|
133
|
+
func(self, *args, **kwargs)
|
|
134
|
+
|
|
135
|
+
return wrapper
|
|
136
|
+
|
|
137
|
+
return decorate if init is None else decorate(init)
|
idunn/app/idunn.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Idunn: the process-wide facade over the inversion engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from idunn.domain import AdapterMetadata, LifecycleEnum, PortBinding, ReportMap
|
|
9
|
+
from idunn.internal import AutoDiscovery, InversionMapper, InversionResolver
|
|
10
|
+
from idunn.util import Environment, MetaSingleton, QualifiedName
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Idunn(metaclass=MetaSingleton):
|
|
14
|
+
"""Process-wide singleton facade: discover a graph, then let ``@Invert`` wire it.
|
|
15
|
+
|
|
16
|
+
``Idunn()`` always returns the same instance. Application code touches it exactly
|
|
17
|
+
once — ``autodiscover()`` at startup (plus ``reset()`` for test isolation); everything
|
|
18
|
+
else (registration, selection, construction) lives behind it in
|
|
19
|
+
:class:`~idunn.internal.InversionMapper` / :class:`~idunn.internal.InversionResolver`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, environment: str | None = None) -> None:
|
|
23
|
+
"""Bind to the resolved environment and create an empty engine."""
|
|
24
|
+
self._environment: str = Environment.current(environment).name
|
|
25
|
+
self._mapper: InversionMapper = InversionMapper()
|
|
26
|
+
self._resolver: InversionResolver = InversionResolver(self._mapper)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def environment(self) -> str:
|
|
30
|
+
"""Active environment used for adapter filtering."""
|
|
31
|
+
return self._environment
|
|
32
|
+
|
|
33
|
+
def autodiscover(
|
|
34
|
+
self,
|
|
35
|
+
root_package: str,
|
|
36
|
+
*,
|
|
37
|
+
port_package_names: Iterable[str] | None = None,
|
|
38
|
+
adapter_package_names: Iterable[str] | None = None,
|
|
39
|
+
strict: bool = True,
|
|
40
|
+
) -> ReportMap:
|
|
41
|
+
"""Import bounded port/adapter modules under ``root_package`` and register them."""
|
|
42
|
+
return AutoDiscovery().autodiscover(
|
|
43
|
+
mapper=self._mapper,
|
|
44
|
+
root_package=root_package,
|
|
45
|
+
port_package_names=frozenset(port_package_names) if port_package_names is not None else None,
|
|
46
|
+
adapter_package_names=(
|
|
47
|
+
frozenset(adapter_package_names) if adapter_package_names is not None else None
|
|
48
|
+
),
|
|
49
|
+
strict=strict,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def reset(self, *, environment: str | None = None) -> Idunn:
|
|
53
|
+
"""Clear all registrations and instances and rebind the environment; returns self."""
|
|
54
|
+
self._environment = Environment.current(environment).name
|
|
55
|
+
self._mapper.clear()
|
|
56
|
+
self._resolver.clear()
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def describe(self) -> str:
|
|
60
|
+
"""Return a readable snapshot of the active port→adapter bindings."""
|
|
61
|
+
lines = [f'Environment: {self._environment}']
|
|
62
|
+
for binding in self._mapper.bindings(environment=self._environment):
|
|
63
|
+
lines.extend(self._describe_binding(binding=binding))
|
|
64
|
+
return '\n'.join(lines)
|
|
65
|
+
|
|
66
|
+
def _inject(self, port: type[Any], key: str | None = None) -> Any:
|
|
67
|
+
"""Resolve a port for ``@Invert`` (the only sanctioned construction trigger)."""
|
|
68
|
+
return self._resolver.resolve(port=port, key=key, environment=self._environment)
|
|
69
|
+
|
|
70
|
+
def _has(self, port: type[Any], key: str | None = None) -> bool:
|
|
71
|
+
"""Whether an active adapter exists for ``port`` (drives ``@Invert`` optional deps)."""
|
|
72
|
+
return self._mapper.find(port=port, key=key, environment=self._environment) is not None
|
|
73
|
+
|
|
74
|
+
def _describe_binding(self, *, binding: PortBinding) -> list[str]:
|
|
75
|
+
selected = QualifiedName.of(binding.selected.adapter) if binding.selected else '<none>'
|
|
76
|
+
lines = ['', QualifiedName.of(binding.port), f' selected: {selected}']
|
|
77
|
+
lines.extend(self._describe_adapter(metadata=metadata) for metadata in binding.adapters)
|
|
78
|
+
return lines
|
|
79
|
+
|
|
80
|
+
def _describe_adapter(self, *, metadata: AdapterMetadata) -> str:
|
|
81
|
+
flag = ' singleton' if metadata.lifecycle == LifecycleEnum.SINGLETON else ''
|
|
82
|
+
return (
|
|
83
|
+
f' - {QualifiedName.of(metadata.adapter)} '
|
|
84
|
+
f'key={metadata.key!r} envs={metadata.environment_label()}{flag}'
|
|
85
|
+
)
|
idunn/domain/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Domain layer: declarations, metadata, errors, lifecycle, and value types."""
|
|
2
|
+
|
|
3
|
+
from .adapter_declaration import AdapterDeclaration
|
|
4
|
+
from .adapter_metadata import AdapterMetadata
|
|
5
|
+
from .errors import (
|
|
6
|
+
AdapterNotFoundError,
|
|
7
|
+
DiscoveryError,
|
|
8
|
+
IdunnError,
|
|
9
|
+
InjectionCycleError,
|
|
10
|
+
InvalidAdapterError,
|
|
11
|
+
InvalidPortError,
|
|
12
|
+
MissingTypeHintError,
|
|
13
|
+
)
|
|
14
|
+
from .lifecycle_enum import LifecycleEnum
|
|
15
|
+
from .port_binding import PortBinding
|
|
16
|
+
from .registration_key import RegistrationKey
|
|
17
|
+
from .report import ReportMap
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
'AdapterDeclaration',
|
|
21
|
+
'AdapterMetadata',
|
|
22
|
+
'AdapterNotFoundError',
|
|
23
|
+
'DiscoveryError',
|
|
24
|
+
'IdunnError',
|
|
25
|
+
'InjectionCycleError',
|
|
26
|
+
'InvalidAdapterError',
|
|
27
|
+
'InvalidPortError',
|
|
28
|
+
'LifecycleEnum',
|
|
29
|
+
'MissingTypeHintError',
|
|
30
|
+
'PortBinding',
|
|
31
|
+
'RegistrationKey',
|
|
32
|
+
'ReportMap',
|
|
33
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Declaration captured by the @Adapter decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .lifecycle_enum import LifecycleEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class AdapterDeclaration:
|
|
13
|
+
"""Configuration captured by the @Adapter decorator."""
|
|
14
|
+
|
|
15
|
+
port: type[Any]
|
|
16
|
+
key: str | None
|
|
17
|
+
lifecycle: LifecycleEnum
|
|
18
|
+
envs: frozenset[str] | None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Metadata stored for adapter declarations and registrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .lifecycle_enum import LifecycleEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class AdapterMetadata:
|
|
13
|
+
"""Describes one registered adapter."""
|
|
14
|
+
|
|
15
|
+
adapter: type[Any]
|
|
16
|
+
port: type[Any]
|
|
17
|
+
key: str | None
|
|
18
|
+
lifecycle: LifecycleEnum
|
|
19
|
+
envs: frozenset[str] | None
|
|
20
|
+
order: int
|
|
21
|
+
|
|
22
|
+
def is_available_in(self, environment: str) -> bool:
|
|
23
|
+
"""Return whether this adapter is active in an environment."""
|
|
24
|
+
return self.envs is None or environment in self.envs
|
|
25
|
+
|
|
26
|
+
def environment_label(self) -> str:
|
|
27
|
+
"""Return a readable environment label."""
|
|
28
|
+
return ','.join(sorted(self.envs)) if self.envs is not None else '*'
|
idunn/domain/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Idunn exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IdunnError(Exception):
|
|
5
|
+
"""Base error for Idunn."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidPortError(IdunnError):
|
|
9
|
+
"""Raised when @Port is applied to a non-Protocol class."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidAdapterError(IdunnError):
|
|
13
|
+
"""Raised when an adapter registration is invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdapterNotFoundError(IdunnError):
|
|
17
|
+
"""Raised when no active adapter is registered for a requested port."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiscoveryError(IdunnError):
|
|
21
|
+
"""Raised when Idunn autodiscovery fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MissingTypeHintError(IdunnError):
|
|
25
|
+
"""Raised when constructor injection needs a type hint that is missing."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InjectionCycleError(IdunnError):
|
|
29
|
+
"""Raised when constructor dependency resolution loops back on itself."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Structured snapshot of one port's adapter bindings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .adapter_metadata import AdapterMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class PortBinding:
|
|
13
|
+
"""One port, every adapter registered for it, and the one active in an environment.
|
|
14
|
+
|
|
15
|
+
Built by :class:`~idunn.internal.InversionMapper` as the single source of truth
|
|
16
|
+
for introspection (it backs ``Idunn().describe()``). ``selected`` is the adapter an
|
|
17
|
+
unkeyed resolve would return in the snapshot environment, or ``None`` when every
|
|
18
|
+
adapter for the port is keyed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
port: type[Any]
|
|
22
|
+
selected: AdapterMetadata | None
|
|
23
|
+
adapters: tuple[AdapterMetadata, ...]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Hashable key used by the inversion table."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class RegistrationKey:
|
|
9
|
+
"""Identifies one adapter binding for one port and optional key."""
|
|
10
|
+
|
|
11
|
+
port: type[Any]
|
|
12
|
+
key: str | None = None
|
idunn/domain/report.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Typed structure returned by Idunn autodiscovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportMap(TypedDict):
|
|
9
|
+
"""Summary of one autodiscovery run: modules imported and classes registered.
|
|
10
|
+
|
|
11
|
+
``registered_ports`` / ``registered_adapters`` hold fully-qualified class
|
|
12
|
+
names (``module.QualName``), not class objects, so the report stays a plain
|
|
13
|
+
serialisable dict of strings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
root_package: str
|
|
17
|
+
imported_port_modules: tuple[str, ...]
|
|
18
|
+
imported_adapter_modules: tuple[str, ...]
|
|
19
|
+
imported_modules: tuple[str, ...]
|
|
20
|
+
registered_ports: tuple[str, ...]
|
|
21
|
+
registered_adapters: tuple[str, ...]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Internal layer: discovery, the inversion engine, and decorator-support helpers.
|
|
2
|
+
|
|
3
|
+
These power the :class:`~idunn.app.Idunn` facade — the catalog (``InversionMapper``),
|
|
4
|
+
the construction engine (``InversionResolver``), bounded autodiscovery (``AutoDiscovery``),
|
|
5
|
+
and the ``DecoratorSupport`` helpers. The classes reached across the package boundary are
|
|
6
|
+
re-exported here (e.g. for ``idunn.app``); the stateless ``InversionValidator`` is used
|
|
7
|
+
only via sibling imports within ``idunn.internal`` and is **not** re-exported. None of
|
|
8
|
+
these are part of Idunn's public API — ``idunn/__init__`` deliberately does not expose them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .auto_discovery import AutoDiscovery
|
|
12
|
+
from .decorator_support import DecoratorSupport
|
|
13
|
+
from .inversion_mapper import InversionMapper
|
|
14
|
+
from .inversion_resolver import InversionResolver
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'AutoDiscovery',
|
|
18
|
+
'DecoratorSupport',
|
|
19
|
+
'InversionMapper',
|
|
20
|
+
'InversionResolver',
|
|
21
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Bounded discovery for decorated Idunn ports and adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from idunn.domain import DiscoveryError, ReportMap
|
|
12
|
+
from idunn.util import QualifiedName
|
|
13
|
+
|
|
14
|
+
from .inversion_mapper import InversionMapper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AutoDiscovery:
|
|
18
|
+
"""Imports bounded port/adapter modules and registers decorated classes."""
|
|
19
|
+
|
|
20
|
+
DEFAULT_PORT_PACKAGE_NAMES = frozenset({'port', 'ports'})
|
|
21
|
+
DEFAULT_ADAPTER_PACKAGE_NAMES = frozenset({'adapter', 'adapters'})
|
|
22
|
+
|
|
23
|
+
def autodiscover(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
mapper: InversionMapper,
|
|
27
|
+
root_package: str | ModuleType,
|
|
28
|
+
port_package_names: frozenset[str] | None = None,
|
|
29
|
+
adapter_package_names: frozenset[str] | None = None,
|
|
30
|
+
strict: bool = True,
|
|
31
|
+
) -> ReportMap:
|
|
32
|
+
"""Discover decorated ports first, then decorated adapters."""
|
|
33
|
+
normalized_port_names = self._normalized_names(
|
|
34
|
+
names=port_package_names,
|
|
35
|
+
fallback=self.DEFAULT_PORT_PACKAGE_NAMES,
|
|
36
|
+
)
|
|
37
|
+
normalized_adapter_names = self._normalized_names(
|
|
38
|
+
names=adapter_package_names,
|
|
39
|
+
fallback=self.DEFAULT_ADAPTER_PACKAGE_NAMES,
|
|
40
|
+
)
|
|
41
|
+
root_module = self._import_root(root_package=root_package)
|
|
42
|
+
port_module_names = self._candidate_module_names(
|
|
43
|
+
root_module=root_module,
|
|
44
|
+
package_names=normalized_port_names,
|
|
45
|
+
)
|
|
46
|
+
adapter_module_names = self._candidate_module_names(
|
|
47
|
+
root_module=root_module,
|
|
48
|
+
package_names=normalized_adapter_names,
|
|
49
|
+
)
|
|
50
|
+
port_modules = self._import_modules(
|
|
51
|
+
module_names=port_module_names,
|
|
52
|
+
strict=strict,
|
|
53
|
+
module_kind='port',
|
|
54
|
+
)
|
|
55
|
+
registered_ports = self._register_decorated_ports(mapper=mapper, modules=port_modules)
|
|
56
|
+
adapter_modules = self._import_modules(
|
|
57
|
+
module_names=adapter_module_names,
|
|
58
|
+
strict=strict,
|
|
59
|
+
module_kind='adapter',
|
|
60
|
+
)
|
|
61
|
+
additional_ports = self._register_decorated_ports(mapper=mapper, modules=adapter_modules)
|
|
62
|
+
registered_adapters = self._register_decorated_adapters(
|
|
63
|
+
mapper=mapper,
|
|
64
|
+
modules=adapter_modules,
|
|
65
|
+
)
|
|
66
|
+
imported_port_modules = tuple(module.__name__ for module in port_modules)
|
|
67
|
+
imported_adapter_modules = tuple(module.__name__ for module in adapter_modules)
|
|
68
|
+
return {
|
|
69
|
+
'root_package': root_module.__name__,
|
|
70
|
+
'imported_port_modules': imported_port_modules,
|
|
71
|
+
'imported_adapter_modules': imported_adapter_modules,
|
|
72
|
+
'imported_modules': (*imported_port_modules, *imported_adapter_modules),
|
|
73
|
+
'registered_ports': tuple(
|
|
74
|
+
QualifiedName.of(cls) for cls in (*registered_ports, *additional_ports)
|
|
75
|
+
),
|
|
76
|
+
'registered_adapters': tuple(QualifiedName.of(cls) for cls in registered_adapters),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def _normalized_names(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
names: frozenset[str] | None,
|
|
83
|
+
fallback: frozenset[str],
|
|
84
|
+
) -> frozenset[str]:
|
|
85
|
+
source = names if names is not None else fallback
|
|
86
|
+
return frozenset(item.lower() for item in source)
|
|
87
|
+
|
|
88
|
+
def _import_root(self, *, root_package: str | ModuleType) -> ModuleType:
|
|
89
|
+
return importlib.import_module(root_package) if isinstance(root_package, str) else root_package
|
|
90
|
+
|
|
91
|
+
def _candidate_module_names(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
root_module: ModuleType,
|
|
95
|
+
package_names: frozenset[str],
|
|
96
|
+
) -> tuple[str, ...]:
|
|
97
|
+
names: list[str] = []
|
|
98
|
+
if self._has_named_part(module_name=root_module.__name__, package_names=package_names):
|
|
99
|
+
names.append(root_module.__name__)
|
|
100
|
+
|
|
101
|
+
root_path = getattr(root_module, '__path__', None)
|
|
102
|
+
if root_path is not None:
|
|
103
|
+
for path_entry in root_path:
|
|
104
|
+
names.extend(
|
|
105
|
+
self._candidate_module_names_from_path(
|
|
106
|
+
root_module=root_module,
|
|
107
|
+
root_path=Path(path_entry),
|
|
108
|
+
package_names=package_names,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return tuple(dict.fromkeys(sorted(names)))
|
|
113
|
+
|
|
114
|
+
def _candidate_module_names_from_path(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
root_module: ModuleType,
|
|
118
|
+
root_path: Path,
|
|
119
|
+
package_names: frozenset[str],
|
|
120
|
+
) -> tuple[str, ...]:
|
|
121
|
+
names: list[str] = []
|
|
122
|
+
if root_path.exists():
|
|
123
|
+
for python_file in root_path.rglob('*.py'):
|
|
124
|
+
module_name = self._module_name_for_file(
|
|
125
|
+
root_module=root_module,
|
|
126
|
+
root_path=root_path,
|
|
127
|
+
python_file=python_file,
|
|
128
|
+
)
|
|
129
|
+
if self._has_named_part(module_name=module_name, package_names=package_names):
|
|
130
|
+
names.append(module_name)
|
|
131
|
+
return tuple(names)
|
|
132
|
+
|
|
133
|
+
def _module_name_for_file(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
root_module: ModuleType,
|
|
137
|
+
root_path: Path,
|
|
138
|
+
python_file: Path,
|
|
139
|
+
) -> str:
|
|
140
|
+
relative_path = python_file.relative_to(root_path)
|
|
141
|
+
parts = list(relative_path.with_suffix('').parts)
|
|
142
|
+
if parts[-1] == '__init__':
|
|
143
|
+
parts = parts[:-1]
|
|
144
|
+
dotted_suffix = '.'.join(parts)
|
|
145
|
+
return f'{root_module.__name__}.{dotted_suffix}' if dotted_suffix else root_module.__name__
|
|
146
|
+
|
|
147
|
+
def _has_named_part(self, *, module_name: str, package_names: frozenset[str]) -> bool:
|
|
148
|
+
parts = module_name.split('.')
|
|
149
|
+
return bool(set(parts).intersection(package_names))
|
|
150
|
+
|
|
151
|
+
def _import_modules(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
module_names: tuple[str, ...],
|
|
155
|
+
strict: bool,
|
|
156
|
+
module_kind: str,
|
|
157
|
+
) -> tuple[ModuleType, ...]:
|
|
158
|
+
modules: list[ModuleType] = []
|
|
159
|
+
for module_name in module_names:
|
|
160
|
+
try:
|
|
161
|
+
modules.append(importlib.import_module(module_name))
|
|
162
|
+
except Exception as exc: # noqa: BLE001
|
|
163
|
+
if strict:
|
|
164
|
+
message = f'Idunn failed to import {module_kind} module {module_name!r}: {exc}'
|
|
165
|
+
raise DiscoveryError(message) from exc
|
|
166
|
+
# non-strict: skip the unimportable module and continue discovery
|
|
167
|
+
return tuple(modules)
|
|
168
|
+
|
|
169
|
+
def _register_decorated_ports(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
mapper: InversionMapper,
|
|
173
|
+
modules: tuple[ModuleType, ...],
|
|
174
|
+
) -> tuple[type[Any], ...]:
|
|
175
|
+
registered: list[type[Any]] = []
|
|
176
|
+
for module in modules:
|
|
177
|
+
for candidate in self._decorated_classes(module=module, marker='__idunn_port__'):
|
|
178
|
+
if mapper.register_port(candidate):
|
|
179
|
+
registered.append(candidate)
|
|
180
|
+
return tuple(registered)
|
|
181
|
+
|
|
182
|
+
def _register_decorated_adapters(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
mapper: InversionMapper,
|
|
186
|
+
modules: tuple[ModuleType, ...],
|
|
187
|
+
) -> tuple[type[Any], ...]:
|
|
188
|
+
registered: list[type[Any]] = []
|
|
189
|
+
for module in modules:
|
|
190
|
+
for candidate in self._decorated_classes(module=module, marker='__idunn_adapter__'):
|
|
191
|
+
if mapper.register_adapter(candidate):
|
|
192
|
+
registered.append(candidate)
|
|
193
|
+
return tuple(registered)
|
|
194
|
+
|
|
195
|
+
def _decorated_classes(self, *, module: ModuleType, marker: str) -> tuple[type[Any], ...]:
|
|
196
|
+
# Own-__dict__ check: an adapter subclassing its port *inherits* __idunn_port__,
|
|
197
|
+
# so getattr would mis-detect adapters as ports. The flag lives on the decorated class only.
|
|
198
|
+
return tuple(
|
|
199
|
+
candidate
|
|
200
|
+
for _, candidate in inspect.getmembers(module, inspect.isclass)
|
|
201
|
+
if candidate.__module__ == module.__name__ and marker in candidate.__dict__
|
|
202
|
+
)
|