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.
- rosenix-0.1.0/LICENSE +21 -0
- rosenix-0.1.0/PKG-INFO +115 -0
- rosenix-0.1.0/README.md +75 -0
- rosenix-0.1.0/pyproject.toml +84 -0
- rosenix-0.1.0/setup.cfg +4 -0
- rosenix-0.1.0/src/rosenix/__init__.py +9 -0
- rosenix-0.1.0/src/rosenix/kernel/__init__.py +13 -0
- rosenix-0.1.0/src/rosenix/kernel/config.py +68 -0
- rosenix-0.1.0/src/rosenix/kernel/container.py +203 -0
- rosenix-0.1.0/src/rosenix/kernel/lifecycle.py +77 -0
- rosenix-0.1.0/src/rosenix/kernel/registry.py +79 -0
- rosenix-0.1.0/src/rosenix/kits/__init__.py +12 -0
- rosenix-0.1.0/src/rosenix/kits/agent.py +116 -0
- rosenix-0.1.0/src/rosenix/kits/tool_decorator.py +95 -0
- rosenix-0.1.0/src/rosenix/kits/workflow.py +56 -0
- rosenix-0.1.0/src/rosenix/protocols/__init__.py +27 -0
- rosenix-0.1.0/src/rosenix/protocols/embedding.py +19 -0
- rosenix-0.1.0/src/rosenix/protocols/llm.py +71 -0
- rosenix-0.1.0/src/rosenix/protocols/memory.py +30 -0
- rosenix-0.1.0/src/rosenix/protocols/tool.py +36 -0
- rosenix-0.1.0/src/rosenix/protocols/vectorstore.py +31 -0
- rosenix-0.1.0/src/rosenix/providers/__init__.py +7 -0
- rosenix-0.1.0/src/rosenix/providers/local_provider.py +101 -0
- rosenix-0.1.0/src/rosenix/providers/openai_provider.py +75 -0
- rosenix-0.1.0/src/rosenix/py.typed +0 -0
- rosenix-0.1.0/src/rosenix/runtime/__init__.py +29 -0
- rosenix-0.1.0/src/rosenix/runtime/errors.py +44 -0
- rosenix-0.1.0/src/rosenix/runtime/events.py +88 -0
- rosenix-0.1.0/src/rosenix/runtime/executor.py +150 -0
- rosenix-0.1.0/src/rosenix/runtime/tracing.py +80 -0
- rosenix-0.1.0/src/rosenix.egg-info/PKG-INFO +115 -0
- rosenix-0.1.0/src/rosenix.egg-info/SOURCES.txt +33 -0
- rosenix-0.1.0/src/rosenix.egg-info/dependency_links.txt +1 -0
- rosenix-0.1.0/src/rosenix.egg-info/requires.txt +18 -0
- 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
|
+
[](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml)
|
|
44
|
+
[](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.
|
rosenix-0.1.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Rosenix
|
|
2
|
+
|
|
3
|
+
[](https://github.com/rosenix-project/rosenix/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
rosenix-0.1.0/setup.cfg
ADDED
|
@@ -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()
|