rosenix 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.
Files changed (35) hide show
  1. rosenix-0.1.0/LICENSE +21 -0
  2. rosenix-0.1.0/PKG-INFO +115 -0
  3. rosenix-0.1.0/README.md +75 -0
  4. rosenix-0.1.0/pyproject.toml +84 -0
  5. rosenix-0.1.0/setup.cfg +4 -0
  6. rosenix-0.1.0/src/rosenix/__init__.py +9 -0
  7. rosenix-0.1.0/src/rosenix/kernel/__init__.py +13 -0
  8. rosenix-0.1.0/src/rosenix/kernel/config.py +68 -0
  9. rosenix-0.1.0/src/rosenix/kernel/container.py +203 -0
  10. rosenix-0.1.0/src/rosenix/kernel/lifecycle.py +77 -0
  11. rosenix-0.1.0/src/rosenix/kernel/registry.py +79 -0
  12. rosenix-0.1.0/src/rosenix/kits/__init__.py +12 -0
  13. rosenix-0.1.0/src/rosenix/kits/agent.py +116 -0
  14. rosenix-0.1.0/src/rosenix/kits/tool_decorator.py +95 -0
  15. rosenix-0.1.0/src/rosenix/kits/workflow.py +56 -0
  16. rosenix-0.1.0/src/rosenix/protocols/__init__.py +27 -0
  17. rosenix-0.1.0/src/rosenix/protocols/embedding.py +19 -0
  18. rosenix-0.1.0/src/rosenix/protocols/llm.py +71 -0
  19. rosenix-0.1.0/src/rosenix/protocols/memory.py +30 -0
  20. rosenix-0.1.0/src/rosenix/protocols/tool.py +36 -0
  21. rosenix-0.1.0/src/rosenix/protocols/vectorstore.py +31 -0
  22. rosenix-0.1.0/src/rosenix/providers/__init__.py +7 -0
  23. rosenix-0.1.0/src/rosenix/providers/local_provider.py +101 -0
  24. rosenix-0.1.0/src/rosenix/providers/openai_provider.py +75 -0
  25. rosenix-0.1.0/src/rosenix/py.typed +0 -0
  26. rosenix-0.1.0/src/rosenix/runtime/__init__.py +29 -0
  27. rosenix-0.1.0/src/rosenix/runtime/errors.py +44 -0
  28. rosenix-0.1.0/src/rosenix/runtime/events.py +88 -0
  29. rosenix-0.1.0/src/rosenix/runtime/executor.py +150 -0
  30. rosenix-0.1.0/src/rosenix/runtime/tracing.py +80 -0
  31. rosenix-0.1.0/src/rosenix.egg-info/PKG-INFO +115 -0
  32. rosenix-0.1.0/src/rosenix.egg-info/SOURCES.txt +33 -0
  33. rosenix-0.1.0/src/rosenix.egg-info/dependency_links.txt +1 -0
  34. rosenix-0.1.0/src/rosenix.egg-info/requires.txt +18 -0
  35. rosenix-0.1.0/src/rosenix.egg-info/top_level.txt +1 -0
rosenix-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
rosenix-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: rosenix
3
+ Version: 0.1.0
4
+ Summary: A modular, explicit, non-magical framework for building AI-powered software systems.
5
+ Author: Rosenix Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rosenix-project/rosenix
8
+ Project-URL: Repository, https://github.com/rosenix-project/rosenix
9
+ Project-URL: Issues, https://github.com/rosenix-project/rosenix/issues
10
+ Project-URL: Changelog, https://github.com/rosenix-project/rosenix/blob/main/CHANGELOG.md
11
+ Keywords: ai,agents,llm,framework,async,dependency-injection,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: pydantic>=2.6
25
+ Requires-Dist: typing-extensions>=4.10
26
+ Provides-Extra: openai
27
+ Requires-Dist: openai>=1.30; extra == "openai"
28
+ Provides-Extra: otel
29
+ Requires-Dist: opentelemetry-api>=1.24; extra == "otel"
30
+ Requires-Dist: opentelemetry-sdk>=1.24; extra == "otel"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
34
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
35
+ Requires-Dist: mypy>=1.10; extra == "dev"
36
+ Requires-Dist: ruff>=0.4; extra == "dev"
37
+ Requires-Dist: build>=1.2; extra == "dev"
38
+ Requires-Dist: twine>=5.0; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # Rosenix
42
+
43
+ [![CI](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml/badge.svg)](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+
46
+ A modular, explicit, non-magical framework for building AI-powered software
47
+ systems: assistants, agents, multi-agent systems, AI APIs, workflows, and
48
+ more — on one consistent architecture.
49
+
50
+ ## Philosophy
51
+
52
+ - **Nothing is magical, nothing is hidden.** No implicit prompt templates,
53
+ no hidden chains. Every behavior is a plain, typed, inspectable object.
54
+ - **Composition over inheritance.** Providers are `typing.Protocol`s, not
55
+ base classes — any object with the right shape works, no framework
56
+ coupling required.
57
+ - **Everything is a plugin.** LLMs, embeddings, vector stores, tools, and
58
+ memory backends all register through the same plugin system, including
59
+ third-party packages via `entry_points`.
60
+
61
+ ## Architecture (4 layers)
62
+
63
+ ```
64
+ Layer 4 kits/ Agent, Workflow, Tool decorator (opinionated)
65
+ Layer 3 runtime/ event bus, task executor, tracing (execution)
66
+ Layer 2 protocols/ LLMProvider, EmbeddingProvider, ... (contracts)
67
+ Layer 1 kernel/ DI container, plugin registry, config (foundation)
68
+ ```
69
+
70
+ Each layer only depends on the layers below it. Layer 1 has zero
71
+ AI-specific code — it's a generic application kernel, on purpose, so the
72
+ architecture is proven before AI complexity is added.
73
+
74
+ ## Quickstart
75
+
76
+ ```bash
77
+ pip install -e ".[dev,openai]"
78
+ ```
79
+
80
+ ```python
81
+ import asyncio
82
+ from rosenix.kernel.container import Container
83
+ from rosenix.providers.openai_provider import OpenAIProvider
84
+ from rosenix.kits.agent import Agent
85
+
86
+ async def main():
87
+ container = Container()
88
+ container.register(OpenAIProvider, lambda: OpenAIProvider(model="gpt-4o-mini"))
89
+
90
+ agent = Agent(llm=container.resolve(OpenAIProvider), name="assistant")
91
+ reply = await agent.run("Say hello in one sentence.")
92
+ print(reply)
93
+
94
+ asyncio.run(main())
95
+ ```
96
+
97
+ See `examples/basic_agent.py` for a full runnable example.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ pip install -e ".[dev,openai]"
103
+ pytest --cov=rosenix --cov-report=term-missing
104
+ mypy src/rosenix
105
+ ruff check src/ tests/ examples/
106
+ ```
107
+
108
+ See `CONTRIBUTING.md` for local setup details and current test-coverage
109
+ gaps that are good first contributions.
110
+
111
+ ## Project status
112
+
113
+ Pre-alpha (`0.x`). The architecture is stable; test coverage on
114
+ `kits/` and `providers/` is still being built out — see `ROADMAP.md`.
115
+ Not yet recommended for production use.
@@ -0,0 +1,75 @@
1
+ # Rosenix
2
+
3
+ [![CI](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml/badge.svg)](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ A modular, explicit, non-magical framework for building AI-powered software
7
+ systems: assistants, agents, multi-agent systems, AI APIs, workflows, and
8
+ more — on one consistent architecture.
9
+
10
+ ## Philosophy
11
+
12
+ - **Nothing is magical, nothing is hidden.** No implicit prompt templates,
13
+ no hidden chains. Every behavior is a plain, typed, inspectable object.
14
+ - **Composition over inheritance.** Providers are `typing.Protocol`s, not
15
+ base classes — any object with the right shape works, no framework
16
+ coupling required.
17
+ - **Everything is a plugin.** LLMs, embeddings, vector stores, tools, and
18
+ memory backends all register through the same plugin system, including
19
+ third-party packages via `entry_points`.
20
+
21
+ ## Architecture (4 layers)
22
+
23
+ ```
24
+ Layer 4 kits/ Agent, Workflow, Tool decorator (opinionated)
25
+ Layer 3 runtime/ event bus, task executor, tracing (execution)
26
+ Layer 2 protocols/ LLMProvider, EmbeddingProvider, ... (contracts)
27
+ Layer 1 kernel/ DI container, plugin registry, config (foundation)
28
+ ```
29
+
30
+ Each layer only depends on the layers below it. Layer 1 has zero
31
+ AI-specific code — it's a generic application kernel, on purpose, so the
32
+ architecture is proven before AI complexity is added.
33
+
34
+ ## Quickstart
35
+
36
+ ```bash
37
+ pip install -e ".[dev,openai]"
38
+ ```
39
+
40
+ ```python
41
+ import asyncio
42
+ from rosenix.kernel.container import Container
43
+ from rosenix.providers.openai_provider import OpenAIProvider
44
+ from rosenix.kits.agent import Agent
45
+
46
+ async def main():
47
+ container = Container()
48
+ container.register(OpenAIProvider, lambda: OpenAIProvider(model="gpt-4o-mini"))
49
+
50
+ agent = Agent(llm=container.resolve(OpenAIProvider), name="assistant")
51
+ reply = await agent.run("Say hello in one sentence.")
52
+ print(reply)
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ See `examples/basic_agent.py` for a full runnable example.
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ pip install -e ".[dev,openai]"
63
+ pytest --cov=rosenix --cov-report=term-missing
64
+ mypy src/rosenix
65
+ ruff check src/ tests/ examples/
66
+ ```
67
+
68
+ See `CONTRIBUTING.md` for local setup details and current test-coverage
69
+ gaps that are good first contributions.
70
+
71
+ ## Project status
72
+
73
+ Pre-alpha (`0.x`). The architecture is stable; test coverage on
74
+ `kits/` and `providers/` is still being built out — see `ROADMAP.md`.
75
+ Not yet recommended for production use.
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rosenix"
7
+ dynamic = ["version"]
8
+ description = "A modular, explicit, non-magical framework for building AI-powered software systems."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Rosenix Contributors" }]
13
+ keywords = ["ai", "agents", "llm", "framework", "async", "dependency-injection", "workflow"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "pydantic>=2.6",
27
+ "typing-extensions>=4.10",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/rosenix-project/rosenix"
32
+ Repository = "https://github.com/rosenix-project/rosenix"
33
+ Issues = "https://github.com/rosenix-project/rosenix/issues"
34
+ Changelog = "https://github.com/rosenix-project/rosenix/blob/main/CHANGELOG.md"
35
+
36
+ [project.optional-dependencies]
37
+ openai = ["openai>=1.30"]
38
+ otel = ["opentelemetry-api>=1.24", "opentelemetry-sdk>=1.24"]
39
+ dev = [
40
+ "pytest>=8.0",
41
+ "pytest-asyncio>=0.23",
42
+ "pytest-cov>=5.0",
43
+ "mypy>=1.10",
44
+ "ruff>=0.4",
45
+ "build>=1.2",
46
+ "twine>=5.0",
47
+ ]
48
+
49
+ # Entry-point group third parties use to register providers automatically.
50
+ # e.g. a pip-installed plugin package can declare:
51
+ # [project.entry-points."rosenix.providers"]
52
+ # myllm = "myllm_plugin:MyLLMProvider"
53
+ [project.entry-points."rosenix.providers"]
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["src"]
57
+
58
+ [tool.setuptools.package-data]
59
+ rosenix = ["py.typed"]
60
+
61
+ [tool.setuptools.dynamic]
62
+ version = { attr = "rosenix.__version__" }
63
+
64
+ [tool.pytest.ini_options]
65
+ asyncio_mode = "auto"
66
+ testpaths = ["tests"]
67
+
68
+ [tool.mypy]
69
+ python_version = "3.11"
70
+ warn_unused_ignores = true
71
+ show_error_codes = true
72
+
73
+ [tool.ruff]
74
+ target-version = "py311"
75
+ line-length = 110
76
+
77
+ [tool.ruff.lint]
78
+ select = ["E", "F", "I", "UP", "B", "SIM", "PYI"]
79
+
80
+ [tool.coverage.run]
81
+ source = ["rosenix"]
82
+
83
+ [tool.coverage.report]
84
+ show_missing = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """rosenix — a modular, explicit framework for building AI-powered systems.
2
+
3
+ Public surface is intentionally small. Import from submodules
4
+ (`rosenix.kernel`, `rosenix.protocols`, `rosenix.runtime`,
5
+ `rosenix.kits`) rather than relying on re-exports here, so it's always
6
+ obvious which layer a name comes from.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ """Layer 1: Kernel.
2
+
3
+ Generic application foundation with zero AI-specific code: dependency
4
+ injection, plugin registry, lifecycle management, and layered config.
5
+ Everything above this layer is built on top of these four primitives.
6
+ """
7
+
8
+ from rosenix.kernel.config import Config
9
+ from rosenix.kernel.container import Container
10
+ from rosenix.kernel.lifecycle import Lifecycle
11
+ from rosenix.kernel.registry import Registry
12
+
13
+ __all__ = ["Container", "Registry", "Lifecycle", "Config"]
@@ -0,0 +1,68 @@
1
+ """Layered, typed configuration.
2
+
3
+ Precedence (lowest to highest): defaults -> file -> environment variables
4
+ -> explicit overrides passed in code. Later layers win. Config is a
5
+ plain Pydantic model, so it's fully typed, validated, and serializable —
6
+ no untyped dict-of-dicts passed around the framework.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Generic, Self, TypeVar
15
+
16
+ from pydantic import BaseModel
17
+
18
+ ConfigT = TypeVar("ConfigT", bound=BaseModel)
19
+
20
+
21
+ class Config(Generic[ConfigT]):
22
+ """Builds a validated config object from layered sources.
23
+
24
+ `Config` is generic over the schema passed to `__init__`, so
25
+ `build()`'s return type is the *actual* schema type given —
26
+ `Config(AppConfig).build()` is statically known to return `AppConfig`,
27
+ not a bare `BaseModel`.
28
+
29
+ Example:
30
+ class AppConfig(BaseModel):
31
+ model: str = "gpt-4o-mini"
32
+ temperature: float = 0.7
33
+ max_retries: int = 3
34
+
35
+ config: AppConfig = (
36
+ Config(AppConfig)
37
+ .from_file("config.json") # optional, ignored if missing
38
+ .from_env(prefix="ROSENIX_") # ROSENIX_MODEL, etc.
39
+ .with_overrides(temperature=0.2) # explicit, wins over all
40
+ .build()
41
+ )
42
+ """
43
+
44
+ def __init__(self, schema: type[ConfigT]) -> None:
45
+ self._schema = schema
46
+ self._layers: dict[str, Any] = {}
47
+
48
+ def from_file(self, path: str | Path) -> Self:
49
+ p = Path(path)
50
+ if p.exists():
51
+ self._layers.update(json.loads(p.read_text()))
52
+ return self
53
+
54
+ def from_env(self, prefix: str = "") -> Self:
55
+ fields = self._schema.model_fields
56
+ for name in fields:
57
+ env_key = f"{prefix}{name}".upper()
58
+ if env_key in os.environ:
59
+ self._layers[name] = os.environ[env_key]
60
+ return self
61
+
62
+ def with_overrides(self, **overrides: Any) -> Self:
63
+ self._layers.update(overrides)
64
+ return self
65
+
66
+ def build(self) -> ConfigT:
67
+ """Validate accumulated layers against the schema and return it."""
68
+ return self._schema.model_validate(self._layers)
@@ -0,0 +1,203 @@
1
+ """Dependency injection container.
2
+
3
+ Resolves dependencies by type, with explicit lifetimes (singleton,
4
+ transient, scoped) and automatic constructor-argument resolution via
5
+ type hints. No decorators required on the classes being resolved —
6
+ registration is done at the container, not on the class itself, which
7
+ keeps the classes framework-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ from collections.abc import Callable, Iterator
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum, auto
17
+ from typing import Any, Self, TypeVar, get_type_hints
18
+
19
+ T = TypeVar("T")
20
+
21
+ Factory = Callable[..., T]
22
+
23
+
24
+ class Lifetime(Enum):
25
+ """How long a resolved instance should live."""
26
+
27
+ SINGLETON = auto() # one instance for the container's lifetime
28
+ TRANSIENT = auto() # a new instance every resolve()
29
+ SCOPED = auto() # one instance per active scope (see Container.scope())
30
+
31
+
32
+ class ContainerError(Exception):
33
+ """Base error for all container-related failures."""
34
+
35
+
36
+ class UnregisteredDependencyError(ContainerError):
37
+ """Raised when resolve() is asked for a type with no registration."""
38
+
39
+ def __init__(self, key: type) -> None:
40
+ super().__init__(
41
+ f"No registration found for {key!r}. "
42
+ f"Register it with container.register(...) before resolving."
43
+ )
44
+ self.key = key
45
+
46
+
47
+ class CircularDependencyError(ContainerError):
48
+ """Raised when resolving a type would require resolving itself."""
49
+
50
+ def __init__(self, chain: list[type]) -> None:
51
+ path = " -> ".join(t.__name__ for t in chain)
52
+ super().__init__(f"Circular dependency detected: {path}")
53
+ self.chain = chain
54
+
55
+
56
+ @dataclass
57
+ class _Registration:
58
+ factory: Factory
59
+ lifetime: Lifetime
60
+ instance: Any | None = field(default=None, repr=False)
61
+
62
+
63
+ class Container:
64
+ """A small, explicit dependency-injection container.
65
+
66
+ Example:
67
+ container = Container()
68
+ container.register(Clock, lambda: SystemClock(), lifetime=Lifetime.SINGLETON)
69
+ clock = container.resolve(Clock)
70
+
71
+ Constructor auto-wiring: if a registered factory is a plain class,
72
+ the container inspects its ``__init__`` type hints and resolves each
73
+ parameter from the container itself, recursively.
74
+ """
75
+
76
+ def __init__(self, parent: Container | None = None) -> None:
77
+ self._registrations: dict[type, _Registration] = {}
78
+ self._scoped_instances: dict[type, Any] = {}
79
+ self._resolving: list[type] = []
80
+ self._parent = parent
81
+ self._in_scope = False
82
+
83
+ # -- registration ----------------------------------------------------
84
+
85
+ def register(
86
+ self,
87
+ key: type[T],
88
+ factory: Factory | None = None,
89
+ *,
90
+ lifetime: Lifetime = Lifetime.SINGLETON,
91
+ ) -> Self:
92
+ """Register a factory (or the class itself) for `key`.
93
+
94
+ If `factory` is omitted, `key` is used as its own factory, and its
95
+ constructor arguments are auto-resolved from the container.
96
+ """
97
+ resolved_factory: Factory = factory if factory is not None else key
98
+ self._registrations[key] = _Registration(factory=resolved_factory, lifetime=lifetime)
99
+ return self
100
+
101
+ def register_instance(self, key: type[T], instance: T) -> Self:
102
+ """Register an already-constructed instance as a singleton."""
103
+ self._registrations[key] = _Registration(
104
+ factory=lambda: instance, lifetime=Lifetime.SINGLETON, instance=instance
105
+ )
106
+ return self
107
+
108
+ def is_registered(self, key: type) -> bool:
109
+ if key in self._registrations:
110
+ return True
111
+ if self._parent is not None:
112
+ return self._parent.is_registered(key)
113
+ return False
114
+
115
+ # -- resolution --------------------------------------------------------
116
+
117
+ def resolve(self, key: type[T]) -> T:
118
+ """Resolve an instance of `key`, constructing dependencies as needed."""
119
+ if key in self._resolving:
120
+ raise CircularDependencyError(self._resolving + [key])
121
+
122
+ registration = self._find_registration(key)
123
+ if registration is None:
124
+ raise UnregisteredDependencyError(key)
125
+
126
+ if registration.lifetime == Lifetime.SINGLETON and registration.instance is not None:
127
+ return registration.instance
128
+
129
+ if registration.lifetime == Lifetime.SCOPED and self._in_scope and key in self._scoped_instances:
130
+ return self._scoped_instances[key]
131
+
132
+ self._resolving.append(key)
133
+ try:
134
+ instance = self._instantiate(registration.factory)
135
+ finally:
136
+ self._resolving.pop()
137
+
138
+ if registration.lifetime == Lifetime.SINGLETON:
139
+ registration.instance = instance
140
+ elif registration.lifetime == Lifetime.SCOPED and self._in_scope:
141
+ self._scoped_instances[key] = instance
142
+
143
+ return instance
144
+
145
+ def _find_registration(self, key: type) -> _Registration | None:
146
+ if key in self._registrations:
147
+ return self._registrations[key]
148
+ if self._parent is not None:
149
+ return self._parent._find_registration(key)
150
+ return None
151
+
152
+ def _instantiate(self, factory: Factory) -> Any:
153
+ """Call `factory`, auto-resolving any parameters it declares."""
154
+ if not (inspect.isclass(factory) or inspect.isfunction(factory) or inspect.ismethod(factory)):
155
+ # e.g. a lambda or already-bound callable with no introspectable
156
+ # signature worth walking — just call it directly.
157
+ return factory()
158
+
159
+ try:
160
+ hints = get_type_hints(factory.__init__ if inspect.isclass(factory) else factory)
161
+ except (TypeError, NameError):
162
+ hints = {}
163
+
164
+ sig = inspect.signature(factory)
165
+ kwargs: dict[str, Any] = {}
166
+ for name, param in sig.parameters.items():
167
+ if name == "self":
168
+ continue
169
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
170
+ continue
171
+ annotation = hints.get(name, param.annotation)
172
+ if annotation is inspect.Parameter.empty:
173
+ if param.default is inspect.Parameter.empty:
174
+ raise ContainerError(
175
+ f"Cannot auto-wire {factory!r}: parameter '{name}' has no "
176
+ f"type hint and no default value."
177
+ )
178
+ continue
179
+ if self.is_registered(annotation):
180
+ kwargs[name] = self.resolve(annotation)
181
+ elif param.default is inspect.Parameter.empty:
182
+ raise UnregisteredDependencyError(annotation)
183
+
184
+ return factory(**kwargs)
185
+
186
+ # -- scoping -----------------------------------------------------------
187
+
188
+ @contextmanager
189
+ def scope(self) -> Iterator[Container]:
190
+ """Open a resolution scope for SCOPED-lifetime registrations.
191
+
192
+ Example:
193
+ with container.scope() as scoped:
194
+ a = scoped.resolve(RequestContext)
195
+ b = scoped.resolve(RequestContext) # same instance as `a`
196
+ # scope ends: scoped instances are discarded
197
+ """
198
+ child = Container(parent=self)
199
+ child._in_scope = True
200
+ try:
201
+ yield child
202
+ finally:
203
+ child._scoped_instances.clear()
@@ -0,0 +1,77 @@
1
+ """Application lifecycle: ordered async startup/shutdown hooks.
2
+
3
+ Providers, connection pools, and background tasks register hooks here
4
+ instead of doing work in `__init__`, so startup is explicit, ordered,
5
+ awaitable, and shutdown always runs (even on error) in reverse order —
6
+ the same pattern as an async context manager stack.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Self
13
+
14
+ Hook = Callable[[], Awaitable[None]]
15
+
16
+
17
+ class Lifecycle:
18
+ """Registers and runs startup/shutdown hooks in a predictable order.
19
+
20
+ Example:
21
+ lifecycle = Lifecycle()
22
+ lifecycle.on_startup(db.connect)
23
+ lifecycle.on_shutdown(db.disconnect)
24
+
25
+ async with lifecycle:
26
+ ... # app runs; db.connect() already awaited
27
+ # db.disconnect() awaited on exit, even if an exception occurred
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._startup_hooks: list[Hook] = []
32
+ self._shutdown_hooks: list[Hook] = []
33
+ self._started = False
34
+
35
+ def on_startup(self, hook: Hook) -> Hook:
36
+ """Register a hook to run on startup, in registration order.
37
+
38
+ Can be used as a plain call or as a decorator:
39
+ @lifecycle.on_startup
40
+ async def connect(): ...
41
+ """
42
+ self._startup_hooks.append(hook)
43
+ return hook
44
+
45
+ def on_shutdown(self, hook: Hook) -> Hook:
46
+ """Register a hook to run on shutdown, in *reverse* registration
47
+ order (mirrors context-manager unwind semantics).
48
+ """
49
+ self._shutdown_hooks.append(hook)
50
+ return hook
51
+
52
+ async def startup(self) -> None:
53
+ if self._started:
54
+ return
55
+ for hook in self._startup_hooks:
56
+ await hook()
57
+ self._started = True
58
+
59
+ async def shutdown(self) -> None:
60
+ if not self._started:
61
+ return
62
+ errors: list[Exception] = []
63
+ for hook in reversed(self._shutdown_hooks):
64
+ try:
65
+ await hook()
66
+ except Exception as exc: # noqa: BLE001 - collect, don't abort unwind
67
+ errors.append(exc)
68
+ self._started = False
69
+ if errors:
70
+ raise ExceptionGroup("errors during shutdown", errors)
71
+
72
+ async def __aenter__(self) -> Self:
73
+ await self.startup()
74
+ return self
75
+
76
+ async def __aexit__(self, *exc_info: object) -> None:
77
+ await self.shutdown()