Skip to content

Commit 38d6e8f

Browse files
ItayTheDarclaude
andcommitted
feat(di): enforce NestJS-style module encapsulation at build time
Previously every provider from every module landed in one flat injector pool — a service in module A could inject a service from module B even when A never imported B and B never exported the service. Encapsulation was a documentation-only contract, not a check. Now PyNestContainer.build() runs a validation pass after cycle detection: - Each module gets a 'visible' set: own providers + own controllers + transitively-resolved exports of its imports (re-exports supported) + every provider from any @module(is_global=True) - Every consumer's __init__ annotations are walked; class-typed deps that are registered providers but not in the consumer's visible set raise ProviderNotExportedException listing every violation with a concrete fix ('add imports=[X] to A and exports=[Y] to X, or move Y into A'). Updated test_imported_module_providers_are_resolvable to declare the export it was implicitly relying on. Added 8 new tests covering: same-module deps, proper import+export, global modules, module re-exports, missing import, missing export, controller violations, and unrelated sibling modules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 941e3d4 commit 38d6e8f

5 files changed

Lines changed: 387 additions & 1 deletion

File tree

‎nest/common/exceptions.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ def __init__(self, message: str = "Non-injectable class detected"):
2424
super().__init__(message)
2525

2626

27+
class ProviderNotExportedException(Exception):
28+
"""Raised when a class depends on a provider that lives in another module
29+
which either isn't imported, or doesn't export that provider."""
30+
31+
def __init__(self, message: str = "Provider not visible across module boundary"):
32+
super().__init__(message)
33+
34+
2735
class HttpException(Exception):
2836
def __init__(self, message: str = "Internal Server Error", status_code: int = 500):
2937
self.message = message

‎nest/core/encapsulation.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""NestJS-style module encapsulation validation.
2+
3+
Enforces that a class can only inject providers visible to its owning module:
4+
its own providers, providers exported by imported modules (transitively through
5+
re-exports), and providers from globally-scoped modules.
6+
"""
7+
from __future__ import annotations
8+
9+
import inspect
10+
from typing import TYPE_CHECKING, Any, Dict, List, Set
11+
12+
from nest.common.exceptions import ProviderNotExportedException
13+
14+
if TYPE_CHECKING:
15+
from nest.core.pynest_container import ModuleRef
16+
17+
18+
_PRIMITIVE_TYPES = {
19+
int, str, float, bool, bytes, bytearray, complex,
20+
list, dict, tuple, set, frozenset, type(None),
21+
}
22+
23+
24+
def validate_module_encapsulation(modules: Dict[str, "ModuleRef"]) -> None:
25+
"""Validate that every cross-module dependency goes through proper imports/exports.
26+
27+
Raises ProviderNotExportedException listing every violation with a suggested fix.
28+
"""
29+
if not modules:
30+
return
31+
32+
# Map every provider token (and controller class) to the module that owns it.
33+
provider_owner: Dict[Any, "ModuleRef"] = {}
34+
for mref in modules.values():
35+
for desc in mref.compiled.provider_descriptors:
36+
provider_owner[desc.provide] = mref
37+
for ctrl in mref.compiled.controllers:
38+
provider_owner[ctrl] = mref
39+
40+
# Class → its ModuleRef (so we can look up imported modules by their metatype).
41+
metatype_to_ref: Dict[type, "ModuleRef"] = {
42+
mref.metatype: mref for mref in modules.values()
43+
}
44+
45+
def resolved_exports(mref: "ModuleRef", _seen: Set[str] = None) -> Set[Any]:
46+
"""Return the set of provider tokens that `mref` exposes to importers,
47+
following module re-exports recursively."""
48+
if _seen is None:
49+
_seen = set()
50+
if mref.token in _seen:
51+
return set()
52+
_seen = _seen | {mref.token}
53+
result: Set[Any] = set()
54+
for exp in mref.compiled.exports:
55+
# If the export is a Module class, re-export everything it exports.
56+
if isinstance(exp, type) and getattr(exp, "__is_module__", False):
57+
child = metatype_to_ref.get(exp)
58+
if child is not None:
59+
result |= resolved_exports(child, _seen)
60+
else:
61+
result.add(exp)
62+
return result
63+
64+
# Anything provided by an @Module(is_global=True) module is visible everywhere.
65+
global_providers: Set[Any] = set()
66+
for mref in modules.values():
67+
if getattr(mref.metatype, "__is_global__", False):
68+
for desc in mref.compiled.provider_descriptors:
69+
global_providers.add(desc.provide)
70+
71+
# Per-module visibility set: own providers + imported exports + globals.
72+
visible: Dict[str, Set[Any]] = {}
73+
for mref in modules.values():
74+
s: Set[Any] = set()
75+
for desc in mref.compiled.provider_descriptors:
76+
s.add(desc.provide)
77+
for ctrl in mref.compiled.controllers:
78+
s.add(ctrl)
79+
for imp in mref.compiled.imports:
80+
child = metatype_to_ref.get(imp)
81+
if child is not None:
82+
s |= resolved_exports(child)
83+
s |= global_providers
84+
visible[mref.token] = s
85+
86+
# Walk every consumer's __init__ signature and check each annotated dependency.
87+
errors: List[str] = []
88+
for mref in modules.values():
89+
consumers: List[type] = list(mref.compiled.controllers)
90+
for desc in mref.compiled.provider_descriptors:
91+
if desc.use_class is not None:
92+
consumers.append(desc.use_class)
93+
94+
for consumer_cls in consumers:
95+
try:
96+
sig = inspect.signature(consumer_cls.__init__)
97+
except (ValueError, TypeError):
98+
continue
99+
100+
for param_name, param in sig.parameters.items():
101+
if param_name == "self":
102+
continue
103+
ann = param.annotation
104+
if ann is inspect.Parameter.empty:
105+
continue
106+
107+
# Resolve string forward refs against known providers (best effort).
108+
if isinstance(ann, str):
109+
matches = [
110+
p for p in provider_owner
111+
if isinstance(p, type) and p.__name__ == ann
112+
]
113+
if len(matches) != 1:
114+
continue
115+
ann = matches[0]
116+
117+
if ann in _PRIMITIVE_TYPES:
118+
continue
119+
120+
# Only check tokens that are actually registered somewhere — unknown
121+
# types are someone else's problem (FastAPI body params, externals, etc).
122+
if ann not in provider_owner:
123+
continue
124+
125+
if ann not in visible[mref.token]:
126+
owner = provider_owner[ann]
127+
errors.append(_format_violation(consumer_cls, mref, ann, owner))
128+
129+
if errors:
130+
raise ProviderNotExportedException(
131+
"Module encapsulation violation(s) detected:\n\n"
132+
+ "\n\n".join(errors)
133+
)
134+
135+
136+
def _format_violation(consumer_cls: type, consumer_module, dep, owner_module) -> str:
137+
consumer_name = consumer_cls.__name__
138+
consumer_mod = consumer_module.metatype.__name__
139+
dep_name = getattr(dep, "__name__", str(dep))
140+
owner_mod = owner_module.metatype.__name__
141+
return (
142+
f" ✗ {consumer_name} (in module {consumer_mod}) depends on {dep_name},\n"
143+
f" but {dep_name} is provided by {owner_mod}.\n"
144+
f"\n"
145+
f" To fix, do BOTH:\n"
146+
f" 1. add 'imports=[{owner_mod}]' to {consumer_mod}\n"
147+
f" 2. add 'exports=[{dep_name}]' to {owner_mod}\n"
148+
f"\n"
149+
f" Or move {dep_name} into {consumer_mod} if it doesn't belong in {owner_mod}."
150+
)

‎nest/core/pynest_container.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from nest.common.module import CompiledModule, ModuleCompiler, ModuleTokenFactory
99
from nest.common.provider import InjectionToken, ProviderDescriptor
1010
from nest.core.dependency_graph import DependencyGraph
11+
from nest.core.encapsulation import validate_module_encapsulation
1112
from nest.core.injector_module import build_injector, _to_key
1213

1314

@@ -79,6 +80,7 @@ def build(self) -> None:
7980
Must be called once after all add_module() calls, before any get() calls.
8081
"""
8182
self._validate_dependency_graph()
83+
validate_module_encapsulation(self._modules)
8284

8385
# Controller classes need singleton bindings too so the injector can resolve them
8486
all_descriptors = self._all_descriptors + self._make_controller_descriptors()

0 commit comments

Comments
 (0)