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 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
@@ -0,0 +1,6 @@
1
+ """Application layer: the @Port/@Adapter/@Invert decorators and the Idunn singleton."""
2
+
3
+ from .decorators import Adapter, Invert, Port
4
+ from .idunn import Idunn
5
+
6
+ __all__ = ['Adapter', 'Idunn', 'Invert', 'Port']
@@ -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
+ )
@@ -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,10 @@
1
+ """Adapter lifecycle choices."""
2
+
3
+ from enum import StrEnum, auto
4
+
5
+
6
+ class LifecycleEnum(StrEnum):
7
+ """Supported adapter lifecycles."""
8
+
9
+ SINGLETON = auto()
10
+ TRANSIENT = auto()
@@ -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
+ )