doppy-di 0.1.0__tar.gz

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.
@@ -0,0 +1,235 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ .refact
10
+
11
+ node_modules
12
+
13
+ ecosystem.md
14
+ plan.md
15
+ source.md
16
+ *.lock
17
+ !uv.lock
18
+
19
+ .clinerules
20
+
21
+ !commitlint.config.cjs
22
+ !package.json
23
+ !.releaserc.json
24
+
25
+ # Distribution / packaging
26
+ .Python
27
+ build/
28
+ develop-eggs/
29
+ dist/
30
+ downloads/
31
+ eggs/
32
+ .eggs/
33
+ lib/
34
+ lib64/
35
+ parts/
36
+ sdist/
37
+ var/
38
+ wheels/
39
+ share/python-wheels/
40
+ *.egg-info/
41
+ .installed.cfg
42
+ *.egg
43
+ MANIFEST
44
+
45
+ # PyInstaller
46
+ # Usually these files are written by a python script from a template
47
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
48
+ *.manifest
49
+ *.spec
50
+
51
+ # Installer logs
52
+ pip-log.txt
53
+ pip-delete-this-directory.txt
54
+
55
+ # Unit test / coverage reports
56
+ htmlcov/
57
+ .tox/
58
+ .nox/
59
+ .coverage
60
+ .coverage.*
61
+ .cache
62
+ nosetests.xml
63
+ coverage.xml
64
+ *.cover
65
+ *.py.cover
66
+ .hypothesis/
67
+ .pytest_cache/
68
+ cover/
69
+
70
+ # Translations
71
+ *.mo
72
+ *.pot
73
+
74
+ # Django stuff:
75
+ *.log
76
+ local_settings.py
77
+ db.sqlite3
78
+ db.sqlite3-journal
79
+
80
+ # Flask stuff:
81
+ instance/
82
+ .webassets-cache
83
+
84
+ # Scrapy stuff:
85
+ .scrapy
86
+
87
+ # Sphinx documentation
88
+ docs/_build/
89
+
90
+ # PyBuilder
91
+ .pybuilder/
92
+ target/
93
+
94
+ # Jupyter Notebook
95
+ .ipynb_checkpoints
96
+
97
+ # IPython
98
+ profile_default/
99
+ ipython_config.py
100
+
101
+ # pyenv
102
+ # For a library or package, you might want to ignore these files since the code is
103
+ # intended to run in multiple environments; otherwise, check them in:
104
+ # .python-version
105
+
106
+ # pipenv
107
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
108
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
109
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
110
+ # install all needed dependencies.
111
+ # Pipfile.lock
112
+
113
+ # UV
114
+ .uv-cache/
115
+
116
+ # poetry
117
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
118
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
119
+ # commonly ignored for libraries.
120
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
121
+ # poetry.lock
122
+ # poetry.toml
123
+
124
+ # pdm
125
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
126
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
127
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
128
+ # pdm.lock
129
+ # pdm.toml
130
+ .pdm-python
131
+ .pdm-build/
132
+
133
+ # pixi
134
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
135
+ # pixi.lock
136
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
137
+ # in the .venv directory. It is recommended not to include this directory in version control.
138
+ .pixi
139
+
140
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
141
+ __pypackages__/
142
+
143
+ # Celery stuff
144
+ celerybeat-schedule
145
+ celerybeat.pid
146
+
147
+ # Redis
148
+ *.rdb
149
+ *.aof
150
+ *.pid
151
+
152
+ # RabbitMQ
153
+ mnesia/
154
+ rabbitmq/
155
+ rabbitmq-data/
156
+
157
+ # ActiveMQ
158
+ activemq-data/
159
+
160
+ # SageMath parsed files
161
+ *.sage.py
162
+
163
+ # Environments
164
+ .env
165
+ .envrc
166
+ .venv
167
+ env/
168
+ venv/
169
+ ENV/
170
+ env.bak/
171
+ venv.bak/
172
+
173
+ # Spyder project settings
174
+ .spyderproject
175
+ .spyproject
176
+
177
+ # Rope project settings
178
+ .ropeproject
179
+
180
+ # mkdocs documentation
181
+ /site
182
+
183
+ # mypy
184
+ .mypy_cache/
185
+ .dmypy.json
186
+ dmypy.json
187
+
188
+ # Pyre type checker
189
+ .pyre/
190
+
191
+ # pytype static type analyzer
192
+ .pytype/
193
+
194
+ # Cython debug symbols
195
+ cython_debug/
196
+
197
+ # PyCharm
198
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
199
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
200
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
201
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
202
+ # .idea/
203
+
204
+ # Abstra
205
+ # Abstra is an AI-powered process automation framework.
206
+ # Ignore directories containing user credentials, local state, and settings.
207
+ # Learn more at https://abstra.io/docs
208
+ .abstra/
209
+
210
+ # Visual Studio Code
211
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
212
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
213
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
214
+ # you could uncomment the following to ignore the entire vscode folder
215
+ # .vscode/
216
+ # Temporary file for partial code execution
217
+ tempCodeRunnerFile.py
218
+
219
+ # Ruff stuff:
220
+ .ruff_cache/
221
+
222
+ # PyPI configuration file
223
+ .pypirc
224
+
225
+ # Marimo
226
+ marimo/_static/
227
+ marimo/_lsp/
228
+ __marimo__/
229
+
230
+ # Streamlit
231
+ .streamlit/secrets.toml
232
+
233
+
234
+ #Ignore vscode AI rules
235
+ .github\instructions\codacy.instructions.md
doppy_di-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MaxGoryunov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: doppy-di
3
+ Version: 0.1.0
4
+ Summary: Minimal dependency injection container
5
+ Project-URL: Homepage, https://github.com/MaxGoryunov/doppy-di
6
+ Project-URL: Repository, https://github.com/MaxGoryunov/doppy-di
7
+ Author: doppy maintainers
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy<2,>=1.16; extra == 'dev'
20
+ Requires-Dist: nox>=2025.5.1; extra == 'dev'
21
+ Requires-Dist: pre-commit>=4.2; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=6.2; extra == 'dev'
23
+ Requires-Dist: pytest>=8.4; extra == 'dev'
24
+ Requires-Dist: ruff>=0.12; extra == 'dev'
25
+ Requires-Dist: uv>=0.11; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # python-dic
@@ -0,0 +1 @@
1
+ # python-dic
@@ -0,0 +1,359 @@
1
+ """Core dependency injection container.
2
+
3
+ This module provides immutable rule descriptions and a minimal container with
4
+ explicit rule registration.
5
+
6
+ Example:
7
+ >>> builder = ContainerBuilder()
8
+ >>> builder.service("answer", lambda: 42)
9
+ >>> container = builder.build()
10
+ >>> container.get("answer")
11
+ 42
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from types import TracebackType
18
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Type, Union
19
+
20
+
21
+ class KeyProtocol(Protocol):
22
+ """A protocol for custom hashable keys."""
23
+
24
+ def __hash__(self) -> int: ...
25
+
26
+ def __eq__(self, other: object) -> bool: ...
27
+
28
+
29
+ Key = Union[str, type, KeyProtocol]
30
+ Lifetime = str
31
+
32
+
33
+ class CycleError(Exception):
34
+ """Raised when the rule graph contains a dependency cycle.
35
+
36
+ Example:
37
+ >>> raise CycleError(["a", "b", "a"])
38
+ """
39
+
40
+ def __init__(self, path: List[Key]) -> None:
41
+ self.path = tuple(path)
42
+ super().__init__(f"Cycle detected: {' -> '.join(map(repr, path))}")
43
+
44
+
45
+ class NestedRuleError(Exception):
46
+ """Raised when a nested rule validation fails.
47
+
48
+ Example:
49
+ >>> raise NestedRuleError("service", "db", "field mismatch")
50
+ """
51
+
52
+ def __init__(self, parent: Key, child: str, reason: str) -> None:
53
+ self.parent = parent
54
+ self.child = child
55
+ self.reason = reason
56
+ super().__init__(f"Nested rule error: {parent!r}.{child} - {reason}")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Rule:
61
+ """Immutable service rule.
62
+
63
+ Args:
64
+ key: Registration key.
65
+ make: Factory callable.
66
+ lifetime: Service lifetime.
67
+ deps: Dependency keys.
68
+
69
+ Example:
70
+ >>> rule = Rule("answer", lambda: 42, "singleton", ())
71
+ >>> rule.key
72
+ 'answer'
73
+ """
74
+
75
+ key: Key
76
+ make: Callable[..., Any]
77
+ lifetime: Lifetime = "transient"
78
+ deps: Tuple[Key, ...] = ()
79
+
80
+
81
+ class RuleSet:
82
+ """Immutable-by-convention rule storage and dependency graph.
83
+
84
+ Example:
85
+ >>> rules = RuleSet()
86
+ >>> rules.add("x", Rule("x", lambda: 1))
87
+ >>> rules.find("x").key
88
+ 'x'
89
+ """
90
+
91
+ __slots__ = ("graph", "map")
92
+
93
+ def __init__(
94
+ self,
95
+ rules_map: Optional[Dict[Key, Rule]] = None,
96
+ graph: Optional[Dict[Key, Tuple[Key, ...]]] = None,
97
+ ) -> None:
98
+ self.map = dict(rules_map or {})
99
+ self.graph = dict(graph or {})
100
+
101
+ def add(self, key: Key, rule: Rule) -> None:
102
+ """Add a rule and validate graph cycles."""
103
+ self.map[key] = rule
104
+ self.graph[key] = tuple(rule.deps)
105
+ self._check_cycle(key)
106
+
107
+ def find(self, key: Key) -> Rule:
108
+ """Return a rule by key."""
109
+ return self.map[key]
110
+
111
+ def has(self, key: Key) -> bool:
112
+ """Check whether a key is registered."""
113
+ return key in self.map
114
+
115
+ def deps_of(self, key: Key) -> Tuple[Key, ...]:
116
+ """Return direct dependencies for a key."""
117
+ return self.graph.get(key, ())
118
+
119
+ def keys(self) -> Tuple[Key, ...]:
120
+ """Return registered keys."""
121
+ return tuple(self.map.keys())
122
+
123
+ def _check_cycle(self, start: Key) -> None:
124
+ """Check graph cycles from the given start node."""
125
+ stack: List[Key] = []
126
+ on_stack: set[Key] = set()
127
+ visited: set[Key] = set()
128
+
129
+ def dfs(node: Key) -> None:
130
+ if node in on_stack:
131
+ raise CycleError([*stack, node])
132
+ if node in visited:
133
+ return
134
+ visited.add(node)
135
+ on_stack.add(node)
136
+ stack.append(node)
137
+ for dep in self.graph.get(node, ()):
138
+ if dep in self.map:
139
+ dfs(dep)
140
+ stack.pop()
141
+ on_stack.remove(node)
142
+
143
+ dfs(start)
144
+
145
+
146
+ class ResolveContext:
147
+ """Resolution context used during object creation.
148
+
149
+ Example:
150
+ >>> builder = ContainerBuilder()
151
+ >>> builder.service("x", lambda: 1)
152
+ >>> c = builder.build()
153
+ >>> ctx = ResolveContext(c)
154
+ >>> ctx.get("x")
155
+ 1
156
+ """
157
+
158
+ __slots__ = ("container", "scope")
159
+
160
+ def __init__(self, container: Container, scope: Optional[Scope] = None) -> None:
161
+ self.container = container
162
+ self.scope = scope or container
163
+
164
+ def get(self, key: Key) -> Any:
165
+ return self.scope.get(key) if isinstance(self.scope, Scope) else self.container.get(key)
166
+
167
+
168
+ class OverrideContext:
169
+ """Context manager for temporary overrides.
170
+
171
+ Example:
172
+ >>> builder = ContainerBuilder()
173
+ >>> builder.value("x", 1)
174
+ >>> c = builder.build()
175
+ >>> with c.override("x", 2):
176
+ ... c.get("x")
177
+ 2
178
+ """
179
+
180
+ __slots__ = ("container", "had_old", "key", "old", "value")
181
+
182
+ def __init__(self, container: Container, key: Key, value: Any) -> None:
183
+ self.container = container
184
+ self.key = key
185
+ self.value = value
186
+ self.old: Any = None
187
+ self.had_old = False
188
+
189
+ def __enter__(self) -> OverrideContext:
190
+ if self.key in self.container.single:
191
+ self.old = self.container.single[self.key]
192
+ self.had_old = True
193
+ self.container.single[self.key] = self.value
194
+ return self
195
+
196
+ def __exit__(
197
+ self,
198
+ exc_type: Optional[Type[BaseException]],
199
+ exc_val: Optional[BaseException],
200
+ exc_tb: Optional[TracebackType],
201
+ ) -> None:
202
+ if self.had_old:
203
+ self.container.single[self.key] = self.old
204
+ else:
205
+ self.container.single.pop(self.key, None)
206
+
207
+
208
+ class Scope:
209
+ """Scope-local cache over a container.
210
+
211
+ Example:
212
+ >>> builder = ContainerBuilder()
213
+ >>> builder.service("x", lambda: object(), lifetime="transient")
214
+ >>> c = builder.build()
215
+ >>> with c.scope("req") as s:
216
+ ... a = s.get("x")
217
+ ... b = s.get("x")
218
+ ... a is b
219
+ True
220
+ """
221
+
222
+ __slots__ = ("cache", "container", "name")
223
+
224
+ def __init__(self, container: Container, name: str) -> None:
225
+ self.container = container
226
+ self.name = name
227
+ self.cache: Dict[Key, Any] = {}
228
+
229
+ def get(self, key: Key) -> Any:
230
+ if key in self.cache:
231
+ return self.cache[key]
232
+ obj = self.container.get(key)
233
+ self.cache[key] = obj
234
+ return obj
235
+
236
+ def __enter__(self) -> Scope:
237
+ return self
238
+
239
+ def __exit__(
240
+ self,
241
+ exc_type: Optional[Type[BaseException]],
242
+ exc_val: Optional[BaseException],
243
+ exc_tb: Optional[TracebackType],
244
+ ) -> None:
245
+ self.cache.clear()
246
+
247
+
248
+ class Container:
249
+ """Runtime container with singleton cache.
250
+
251
+ Example:
252
+ >>> builder = ContainerBuilder()
253
+ >>> builder.service("answer", lambda: 42, lifetime="singleton")
254
+ >>> container = builder.build()
255
+ >>> container.get("answer")
256
+ 42
257
+ """
258
+
259
+ __slots__ = ("config", "scopes", "single")
260
+
261
+ def __init__(self, config: ContainerConfig) -> None:
262
+ self.config = config
263
+ self.single: Dict[Key, Any] = {}
264
+ self.scopes: Dict[str, Scope] = {}
265
+
266
+ def get(self, key: Key) -> Any:
267
+ if key in self.single:
268
+ return self.single[key]
269
+
270
+ rule = self.config.ruleset.find(key)
271
+ ctx = ResolveContext(self)
272
+ args = [ctx.get(dep) for dep in rule.deps]
273
+ obj = rule.make(*args)
274
+
275
+ if rule.lifetime == "singleton":
276
+ self.single[key] = obj
277
+ self._cache_nested_aliases(key, obj)
278
+
279
+ return obj
280
+
281
+ def _cache_nested_aliases(self, key: Key, obj: Any) -> None:
282
+ for alias in self.config.ruleset.map:
283
+ if not isinstance(alias, tuple) or len(alias) != 2 or alias[0] != key:
284
+ continue
285
+ child = alias[1]
286
+ if isinstance(child, str) and hasattr(obj, child):
287
+ self.single[alias] = getattr(obj, child)
288
+
289
+ def has(self, key: Key) -> bool:
290
+ return self.config.ruleset.has(key)
291
+
292
+ def scope(self, name: str) -> Scope:
293
+ if name in self.scopes:
294
+ return self.scopes[name]
295
+ scope = Scope(self, name)
296
+ self.scopes[name] = scope
297
+ return scope
298
+
299
+ def override(self, key: Key, value: Any) -> OverrideContext:
300
+ return OverrideContext(self, key, value)
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class ContainerConfig:
305
+ """Immutable container configuration.
306
+
307
+ Example:
308
+ >>> rules = RuleSet()
309
+ >>> config = ContainerConfig(rules)
310
+ >>> isinstance(config.ruleset, RuleSet)
311
+ True
312
+ """
313
+
314
+ ruleset: RuleSet
315
+
316
+
317
+ class ContainerBuilder:
318
+ """Builder for a container.
319
+
320
+ Example:
321
+ >>> builder = ContainerBuilder()
322
+ >>> builder.value("x", 1)
323
+ >>> c = builder.build()
324
+ >>> c.get("x")
325
+ 1
326
+ """
327
+
328
+ __slots__ = ("rules",)
329
+
330
+ def __init__(self) -> None:
331
+ self.rules = RuleSet()
332
+
333
+ def service(
334
+ self,
335
+ key: Key,
336
+ make: Callable[..., Any],
337
+ lifetime: Lifetime = "transient",
338
+ deps: Optional[List[Key]] = None,
339
+ ) -> None:
340
+ rule = Rule(key=key, make=make, lifetime=lifetime, deps=tuple(deps or ()))
341
+ self.rules.add(key, rule)
342
+
343
+ def value(self, key: Key, value: Any) -> None:
344
+ def make_value() -> Any:
345
+ return value
346
+
347
+ self.rules.add(
348
+ key,
349
+ Rule(key=key, make=make_value, lifetime="singleton", deps=()),
350
+ )
351
+
352
+ def alias(self, key: Key, target: Key) -> None:
353
+ self.rules.add(
354
+ key,
355
+ Rule(key=key, make=lambda: None, lifetime="transient", deps=(target,)),
356
+ )
357
+
358
+ def build(self) -> Container:
359
+ return Container(ContainerConfig(self.rules))
@@ -0,0 +1,29 @@
1
+ """Devkit package for optional runtime layers and validation."""
2
+
3
+ from container import ResolveContext
4
+
5
+ from .logging import LoggingContainer
6
+ from .nested import NestedPolicy, NestedRules, SameObjectPolicy, SameValuePolicy
7
+ from .policy import (
8
+ ChildrenFirstPolicy,
9
+ OrderPolicy,
10
+ ParentFirstPolicy,
11
+ UnorderedPolicy,
12
+ )
13
+ from .validation import ValidatingContainer, ValidationRule, ValidationRunner
14
+
15
+ __all__ = [
16
+ "ChildrenFirstPolicy",
17
+ "LoggingContainer",
18
+ "NestedPolicy",
19
+ "NestedRules",
20
+ "OrderPolicy",
21
+ "ParentFirstPolicy",
22
+ "ResolveContext",
23
+ "SameObjectPolicy",
24
+ "SameValuePolicy",
25
+ "UnorderedPolicy",
26
+ "ValidatingContainer",
27
+ "ValidationRule",
28
+ "ValidationRunner",
29
+ ]
@@ -0,0 +1,58 @@
1
+ """Logging layer for container access.
2
+
3
+ Example:
4
+ >>> from container import ContainerBuilder
5
+ >>> builder = ContainerBuilder()
6
+ >>> builder.value("x", 1)
7
+ >>> base = builder.build()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable
14
+
15
+ from container import Container, Key, OverrideContext, Scope
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class LoggingContainer:
20
+ """Log container operations while preserving the same API.
21
+
22
+ Example:
23
+ >>> events = []
24
+ >>> def log(msg):
25
+ ... events.append(msg)
26
+ >>> from container import ContainerBuilder
27
+ >>> builder = ContainerBuilder()
28
+ >>> builder.value("x", 1)
29
+ >>> base = builder.build()
30
+ >>> wrapped = LoggingContainer(base, log)
31
+ >>> wrapped.get("x")
32
+ 1
33
+ """
34
+
35
+ wrapped: Container
36
+ log: Callable[[str], None]
37
+
38
+ def get(self, key: Key) -> Any:
39
+ self.log(f"get({key!r})")
40
+ try:
41
+ obj = self.wrapped.get(key)
42
+ self.log(f"get({key!r}) -> ok")
43
+ return obj
44
+ except Exception as exc:
45
+ self.log(f"get({key!r}) -> error: {exc.__class__.__name__}")
46
+ raise
47
+
48
+ def has(self, key: Key) -> bool:
49
+ self.log(f"has({key!r})")
50
+ return self.wrapped.has(key)
51
+
52
+ def scope(self, name: str) -> Scope:
53
+ self.log(f"scope({name!r})")
54
+ return self.wrapped.scope(name)
55
+
56
+ def override(self, key: Key, value: Any) -> OverrideContext:
57
+ self.log(f"override({key!r})")
58
+ return self.wrapped.override(key, value)
@@ -0,0 +1,108 @@
1
+ """Nested-rule validation helpers.
2
+
3
+ Example:
4
+ >>> from container import ContainerBuilder
5
+ >>> builder = ContainerBuilder()
6
+ >>> builder.value("x", 1)
7
+ >>> nested = NestedRules()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Protocol
14
+
15
+ from container import Container, Key, NestedRuleError, Rule, RuleSet
16
+
17
+
18
+ class NestedPolicy(Protocol):
19
+ """Policy used to compare nested objects."""
20
+
21
+ def check(self, nested: Any, resolved: Any) -> bool:
22
+ """Return True if nested object is valid."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SameObjectPolicy:
27
+ """Check object identity for nested values.
28
+
29
+ Example:
30
+ >>> policy = SameObjectPolicy()
31
+ >>> policy.check(object(), object())
32
+ False
33
+ """
34
+
35
+ def check(self, nested: Any, resolved: Any) -> bool:
36
+ return nested is resolved
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SameValuePolicy:
41
+ """Check value equality for nested values.
42
+
43
+ Example:
44
+ >>> policy = SameValuePolicy()
45
+ >>> policy.check(1, 1)
46
+ True
47
+ """
48
+
49
+ def check(self, nested: Any, resolved: Any) -> bool:
50
+ return bool(nested == resolved)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class NestedEntry:
55
+ """Describe one nested registration.
56
+
57
+ Example:
58
+ >>> entry = NestedEntry("service", "repo")
59
+ >>> entry.parent
60
+ 'service'
61
+ """
62
+
63
+ parent: Key
64
+ child: str
65
+
66
+
67
+ class NestedRules:
68
+ """Track nested relations and validate resolved objects.
69
+
70
+ Example:
71
+ >>> nested = NestedRules()
72
+ >>> rule = Rule(("service", "repo"), lambda repo: repo)
73
+ >>> nested.add_nested("service", "repo", rule, RuleSet())
74
+ """
75
+
76
+ __slots__ = ("map", "same_policy")
77
+
78
+ def __init__(self) -> None:
79
+ self.map: Dict[Key, List[str]] = {}
80
+ self.same_policy: NestedPolicy = SameValuePolicy()
81
+
82
+ def add_nested(self, parent: Key, child: str, rule: Rule, ruleset: RuleSet) -> None:
83
+ nested_key = (parent, child)
84
+ ruleset.add(nested_key, rule)
85
+ self.map.setdefault(parent, [])
86
+ if child not in self.map[parent]:
87
+ self.map[parent].append(child)
88
+
89
+ def children_of(self, parent: Key) -> List[str]:
90
+ return list(self.map.get(parent, []))
91
+
92
+ def validate_nested(self, parent: Key, container: Container, parent_obj: Any = None) -> None:
93
+ children = self.children_of(parent)
94
+ if not children:
95
+ return
96
+
97
+ if parent_obj is None:
98
+ parent_obj = container.get(parent)
99
+ for child in children:
100
+ nested_key = (parent, child)
101
+ nested_obj = container.get(nested_key)
102
+
103
+ if not hasattr(parent_obj, child):
104
+ raise NestedRuleError(parent, child, f"field {child!r} not found")
105
+
106
+ resolved = getattr(parent_obj, child)
107
+ if not self.same_policy.check(nested_obj, resolved):
108
+ raise NestedRuleError(parent, child, "nested validation failed")
@@ -0,0 +1,91 @@
1
+ """Resolution order policies for container layers.
2
+
3
+ Example:
4
+ >>> from container import ContainerBuilder
5
+ >>> builder = ContainerBuilder()
6
+ >>> builder.value("x", 1)
7
+ >>> container = builder.build()
8
+ >>> policy = UnorderedPolicy()
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, List, Optional, Protocol
15
+
16
+ from container import Key, ResolveContext, RuleSet
17
+
18
+
19
+ class OrderPolicy(Protocol):
20
+ """A strategy for controlling resolution order."""
21
+
22
+ def before_resolve(self, key: Key, ruleset: RuleSet, ctx: ResolveContext) -> None:
23
+ """Run before object resolution."""
24
+
25
+ def after_resolve(self, key: Key, obj: Any, ruleset: RuleSet, ctx: ResolveContext) -> None:
26
+ """Run after object resolution."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class UnorderedPolicy:
31
+ """Policy with no extra resolution ordering.
32
+
33
+ Example:
34
+ >>> policy = UnorderedPolicy()
35
+ >>> isinstance(policy, UnorderedPolicy)
36
+ True
37
+ """
38
+
39
+ def before_resolve(self, key: Key, ruleset: RuleSet, ctx: ResolveContext) -> None:
40
+ return None
41
+
42
+ def after_resolve(self, key: Key, obj: Any, ruleset: RuleSet, ctx: ResolveContext) -> None:
43
+ return None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ChildrenFirstPolicy:
48
+ """Resolve nested children before the parent.
49
+
50
+ Example:
51
+ >>> policy = ChildrenFirstPolicy(nested={"service": ["repo"]})
52
+ >>> isinstance(policy, ChildrenFirstPolicy)
53
+ True
54
+ """
55
+
56
+ nested: Dict[Key, List[str]]
57
+
58
+ def __init__(self, nested: Optional[Dict[Key, List[str]]] = None) -> None:
59
+ object.__setattr__(self, "nested", dict(nested or {}))
60
+
61
+ def before_resolve(self, key: Key, ruleset: RuleSet, ctx: ResolveContext) -> None:
62
+ for child_name in self.nested.get(key, []):
63
+ child_key = (key, child_name)
64
+ ctx.get(child_key)
65
+
66
+ def after_resolve(self, key: Key, obj: Any, ruleset: RuleSet, ctx: ResolveContext) -> None:
67
+ return None
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ParentFirstPolicy:
72
+ """Resolve parent first, then optionally inspect children.
73
+
74
+ Example:
75
+ >>> policy = ParentFirstPolicy(nested={"service": ["repo"]})
76
+ >>> isinstance(policy, ParentFirstPolicy)
77
+ True
78
+ """
79
+
80
+ nested: Dict[Key, List[str]]
81
+
82
+ def __init__(self, nested: Optional[Dict[Key, List[str]]] = None) -> None:
83
+ object.__setattr__(self, "nested", dict(nested or {}))
84
+
85
+ def before_resolve(self, key: Key, ruleset: RuleSet, ctx: ResolveContext) -> None:
86
+ return None
87
+
88
+ def after_resolve(self, key: Key, obj: Any, ruleset: RuleSet, ctx: ResolveContext) -> None:
89
+ for child_name in self.nested.get(key, []):
90
+ child_key = (key, child_name)
91
+ ctx.get(child_key)
@@ -0,0 +1,100 @@
1
+ """Validation layer for container resolution.
2
+
3
+ Example:
4
+ >>> from container import ContainerBuilder
5
+ >>> builder = ContainerBuilder()
6
+ >>> builder.value("x", 1)
7
+ >>> container = builder.build()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, List, Optional, Protocol
14
+
15
+ from container import Container, Key, OverrideContext, ResolveContext, Scope
16
+
17
+ from .nested import NestedRules
18
+ from .policy import OrderPolicy
19
+
20
+
21
+ class ValidationRule(Protocol):
22
+ """A validation rule executed after resolution."""
23
+
24
+ def check(self, container: Container, key: Key, obj: Any) -> None:
25
+ """Validate resolved object."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ValidationRunner:
30
+ """Run a list of validation rules.
31
+
32
+ Example:
33
+ >>> runner = ValidationRunner()
34
+ >>> len(runner.rules)
35
+ 0
36
+ """
37
+
38
+ rules: tuple[ValidationRule, ...]
39
+
40
+ def __init__(self, rules: Optional[List[ValidationRule]] = None) -> None:
41
+ object.__setattr__(self, "rules", tuple(rules or ()))
42
+
43
+ def add(self, rule: ValidationRule) -> None:
44
+ object.__setattr__(self, "rules", (*self.rules, rule))
45
+
46
+ def run(self, container: Container, key: Key, obj: Any) -> None:
47
+ for rule in self.rules:
48
+ rule.check(container, key, obj)
49
+
50
+
51
+ class ValidatingContainer:
52
+ """Container view that applies order policy and validation.
53
+
54
+ Example:
55
+ >>> from container import ContainerBuilder
56
+ >>> builder = ContainerBuilder()
57
+ >>> builder.value("x", 1)
58
+ >>> base = builder.build()
59
+ >>> wrapped = ValidatingContainer(base, UnorderedPolicy(), ValidationRunner())
60
+ >>> wrapped.get("x")
61
+ 1
62
+ """
63
+
64
+ __slots__ = ("nested", "policy", "validator", "wrapped")
65
+
66
+ def __init__(
67
+ self,
68
+ wrapped: Container,
69
+ policy: OrderPolicy,
70
+ validator: Optional[ValidationRunner] = None,
71
+ nested: Optional[NestedRules] = None,
72
+ ) -> None:
73
+ self.wrapped = wrapped
74
+ self.policy = policy
75
+ self.validator = validator or ValidationRunner()
76
+ self.nested = nested
77
+
78
+ def get(self, key: Key) -> Any:
79
+ ctx = ResolveContext(self.wrapped)
80
+ ruleset = self.wrapped.config.ruleset
81
+
82
+ self.policy.before_resolve(key, ruleset, ctx)
83
+ obj = self.wrapped.get(key)
84
+ self.policy.after_resolve(key, obj, ruleset, ctx)
85
+
86
+ self.validator.run(self.wrapped, key, obj)
87
+
88
+ if self.nested is not None and key in self.nested.map:
89
+ self.nested.validate_nested(key, self.wrapped, obj)
90
+
91
+ return obj
92
+
93
+ def has(self, key: Key) -> bool:
94
+ return self.wrapped.has(key)
95
+
96
+ def scope(self, name: str) -> Scope:
97
+ return self.wrapped.scope(name)
98
+
99
+ def override(self, key: Key, value: Any) -> OverrideContext:
100
+ return self.wrapped.override(key, value)
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "doppy-di"
7
+ version = "0.1.0"
8
+ description = "Minimal dependency injection container"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "doppy maintainers" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/MaxGoryunov/doppy-di"
26
+ Repository = "https://github.com/MaxGoryunov/doppy-di"
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "mypy>=1.16,<2",
31
+ "nox>=2025.5.1",
32
+ "pre-commit>=4.2",
33
+ "pytest>=8.4",
34
+ "pytest-cov>=6.2",
35
+ "ruff>=0.12",
36
+ "uv>=0.11",
37
+ ]
38
+
39
+ [tool.hatch.build]
40
+ include = [
41
+ "container.py",
42
+ "devkit/**/*.py",
43
+ ]
44
+
45
+ [tool.ruff]
46
+ target-version = "py39"
47
+ line-length = 100
48
+
49
+ [tool.ruff.lint]
50
+ select = ["A", "B", "C4", "E", "F", "I", "N", "PT", "RUF", "SIM", "UP", "W"]
51
+ ignore = ["UP006", "UP007", "UP035", "UP037", "UP045"]
52
+
53
+ [tool.mypy]
54
+ python_version = "3.10"
55
+ strict = true
56
+ warn_unreachable = true
57
+ files = ["container.py", "devkit"]
58
+ exclude = ['(^|/)noxfile\.py$', '\.venv/', 'venv/', 'tests/']
59
+
60
+ [[tool.mypy.overrides]]
61
+ module = "noxfile"
62
+ ignore_errors = true
63
+
64
+ [tool.pytest.ini_options]
65
+ addopts = "--cov=container --cov=devkit --cov-report=term-missing"
66
+ testpaths = ["tests"]
67
+
68
+ [tool.coverage.run]
69
+ branch = true
70
+ source = ["container", "devkit"]
71
+
72
+ [tool.commitizen]
73
+ name = "cz_conventional_commits"
74
+ version = "0.1.0"
75
+ tag_format = "v$version"
76
+ update_changelog_on_bump = true
77
+ major_version_zero = true