urich 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 (41) hide show
  1. urich-0.1.0/LICENSE +21 -0
  2. urich-0.1.0/PKG-INFO +101 -0
  3. urich-0.1.0/README.md +73 -0
  4. urich-0.1.0/pyproject.toml +42 -0
  5. urich-0.1.0/setup.cfg +4 -0
  6. urich-0.1.0/src/urich/__init__.py +13 -0
  7. urich-0.1.0/src/urich/cli/__init__.py +3 -0
  8. urich-0.1.0/src/urich/cli/main.py +109 -0
  9. urich-0.1.0/src/urich/cli/templates.py +151 -0
  10. urich-0.1.0/src/urich/core/__init__.py +13 -0
  11. urich-0.1.0/src/urich/core/app.py +107 -0
  12. urich-0.1.0/src/urich/core/config.py +20 -0
  13. urich-0.1.0/src/urich/core/container.py +70 -0
  14. urich-0.1.0/src/urich/core/module.py +16 -0
  15. urich-0.1.0/src/urich/core/openapi.py +143 -0
  16. urich-0.1.0/src/urich/core/routing.py +32 -0
  17. urich-0.1.0/src/urich/ddd/__init__.py +4 -0
  18. urich-0.1.0/src/urich/ddd/commands.py +14 -0
  19. urich-0.1.0/src/urich/ddd/domain_module.py +146 -0
  20. urich-0.1.0/src/urich/discovery/__init__.py +4 -0
  21. urich-0.1.0/src/urich/discovery/discovery_module.py +36 -0
  22. urich-0.1.0/src/urich/discovery/protocol.py +29 -0
  23. urich-0.1.0/src/urich/domain/__init__.py +16 -0
  24. urich-0.1.0/src/urich/domain/aggregate.py +25 -0
  25. urich-0.1.0/src/urich/domain/entity.py +16 -0
  26. urich-0.1.0/src/urich/domain/events.py +47 -0
  27. urich-0.1.0/src/urich/domain/repository.py +21 -0
  28. urich-0.1.0/src/urich/domain/value_object.py +8 -0
  29. urich-0.1.0/src/urich/events/__init__.py +11 -0
  30. urich-0.1.0/src/urich/events/event_bus_module.py +40 -0
  31. urich-0.1.0/src/urich/events/outbox.py +62 -0
  32. urich-0.1.0/src/urich/events/protocol.py +18 -0
  33. urich-0.1.0/src/urich/rpc/__init__.py +4 -0
  34. urich-0.1.0/src/urich/rpc/protocol.py +18 -0
  35. urich-0.1.0/src/urich/rpc/rpc_module.py +106 -0
  36. urich-0.1.0/src/urich.egg-info/PKG-INFO +101 -0
  37. urich-0.1.0/src/urich.egg-info/SOURCES.txt +39 -0
  38. urich-0.1.0/src/urich.egg-info/dependency_links.txt +1 -0
  39. urich-0.1.0/src/urich.egg-info/entry_points.txt +2 -0
  40. urich-0.1.0/src/urich.egg-info/requires.txt +11 -0
  41. urich-0.1.0/src/urich.egg-info/top_level.txt +1 -0
urich-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Korolev Roman Urich
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.
urich-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: urich
3
+ Version: 0.1.0
4
+ Summary: Async DDD framework for microservices on Starlette
5
+ Project-URL: Homepage, https://github.com/KashN9sh/urich
6
+ Project-URL: Repository, https://github.com/KashN9sh/urich
7
+ Keywords: asgi,ddd,microservices,starlette,cqrs
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: starlette>=0.41.0
19
+ Requires-Dist: pydantic>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
23
+ Requires-Dist: httpx; extra == "dev"
24
+ Requires-Dist: uvicorn; extra == "dev"
25
+ Provides-Extra: cli
26
+ Requires-Dist: typer>=0.9.0; extra == "cli"
27
+ Dynamic: license-file
28
+
29
+ # Urich
30
+
31
+ Async DDD framework for microservices on Starlette. The application is composed from module objects via `app.register(module)` — similar to FastAPI with routers, but one consistent style for domain, events, RPC and discovery.
32
+
33
+ ## Idea
34
+
35
+ - **One object = one building block:** DomainModule, EventBusModule, OutboxModule, DiscoveryModule, RpcModule. All configured via fluent API and attached with `app.register(module)`.
36
+ - **DDD:** Bounded context as DomainModule with `.aggregate()`, `.repository()`, `.command()`, `.query()`, `.on_event()`. Commands and queries get HTTP routes automatically.
37
+ - **No lock-in:** Protocols (EventBus, ServiceDiscovery, RpcTransport) in core; implementations (Redis, Consul, HTTP+JSON) supplied by the user or optional out-of-the-box adapters.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install urich
43
+ # CLI for generating skeletons:
44
+ pip install "urich[cli]"
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ from urich import Application
51
+ from urich.ddd import DomainModule
52
+
53
+ # One object = full bounded context
54
+ from orders.module import orders_module
55
+
56
+ app = Application()
57
+ app.register(orders_module)
58
+
59
+ # Run: python -m uvicorn main:app --reload (or: pip install uvicorn && uvicorn main:app --reload)
60
+ ```
61
+
62
+ Routes by convention: `POST /orders/commands/create_order`, `GET /orders/queries/get_order`.
63
+
64
+ ## OpenAPI / Swagger
65
+
66
+ After registering all modules, call `app.openapi(title="My API", version="0.1.0")`. Then:
67
+
68
+ - **GET /openapi.json** — OpenAPI 3.0 spec
69
+ - **GET /docs** — Swagger UI
70
+
71
+ ```python
72
+ app = Application()
73
+ # ... app.register(module) ...
74
+ app.openapi(title="My API", version="0.1.0")
75
+ ```
76
+
77
+ ## CLI
78
+
79
+ ```bash
80
+ urich create-app myapp
81
+ cd myapp
82
+ urich add-context orders --dir .
83
+ urich add-aggregate orders Order --dir .
84
+ # In main.py: from orders.module import orders_module; app.register(orders_module)
85
+ ```
86
+
87
+ ## Module structure (DomainModule)
88
+
89
+ - **domain** — aggregate (AggregateRoot), domain events (DomainEvent).
90
+ - **application** — commands/queries (Command/Query), handlers (one per command/query).
91
+ - **infrastructure** — repository interface and implementation (e.g. in-memory for prototypes).
92
+ - **module.py** — one object `DomainModule("orders").aggregate(...).repository(...).command(...).query(...).on_event(...)`; register in the app with `app.register(orders_module)`.
93
+
94
+ ## Other modules
95
+
96
+ - **EventBusModule** — `.adapter(impl)` or `.in_memory()`; in container as EventBus.
97
+ - **OutboxModule** — `.storage(...)` and `.publisher(...)`; contracts in core.
98
+ - **DiscoveryModule** — `.static({"svc": "http://..."})` or `.adapter(impl)`; ServiceDiscovery protocol.
99
+ - **RpcModule** — `.server(path="/rpc")` and `.client(discovery=..., transport=...)`; optional JsonHttpRpcTransport (requires httpx).
100
+
101
+ Full composition example: `examples/ecommerce/main.py`.
urich-0.1.0/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Urich
2
+
3
+ Async DDD framework for microservices on Starlette. The application is composed from module objects via `app.register(module)` — similar to FastAPI with routers, but one consistent style for domain, events, RPC and discovery.
4
+
5
+ ## Idea
6
+
7
+ - **One object = one building block:** DomainModule, EventBusModule, OutboxModule, DiscoveryModule, RpcModule. All configured via fluent API and attached with `app.register(module)`.
8
+ - **DDD:** Bounded context as DomainModule with `.aggregate()`, `.repository()`, `.command()`, `.query()`, `.on_event()`. Commands and queries get HTTP routes automatically.
9
+ - **No lock-in:** Protocols (EventBus, ServiceDiscovery, RpcTransport) in core; implementations (Redis, Consul, HTTP+JSON) supplied by the user or optional out-of-the-box adapters.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install urich
15
+ # CLI for generating skeletons:
16
+ pip install "urich[cli]"
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from urich import Application
23
+ from urich.ddd import DomainModule
24
+
25
+ # One object = full bounded context
26
+ from orders.module import orders_module
27
+
28
+ app = Application()
29
+ app.register(orders_module)
30
+
31
+ # Run: python -m uvicorn main:app --reload (or: pip install uvicorn && uvicorn main:app --reload)
32
+ ```
33
+
34
+ Routes by convention: `POST /orders/commands/create_order`, `GET /orders/queries/get_order`.
35
+
36
+ ## OpenAPI / Swagger
37
+
38
+ After registering all modules, call `app.openapi(title="My API", version="0.1.0")`. Then:
39
+
40
+ - **GET /openapi.json** — OpenAPI 3.0 spec
41
+ - **GET /docs** — Swagger UI
42
+
43
+ ```python
44
+ app = Application()
45
+ # ... app.register(module) ...
46
+ app.openapi(title="My API", version="0.1.0")
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ urich create-app myapp
53
+ cd myapp
54
+ urich add-context orders --dir .
55
+ urich add-aggregate orders Order --dir .
56
+ # In main.py: from orders.module import orders_module; app.register(orders_module)
57
+ ```
58
+
59
+ ## Module structure (DomainModule)
60
+
61
+ - **domain** — aggregate (AggregateRoot), domain events (DomainEvent).
62
+ - **application** — commands/queries (Command/Query), handlers (one per command/query).
63
+ - **infrastructure** — repository interface and implementation (e.g. in-memory for prototypes).
64
+ - **module.py** — one object `DomainModule("orders").aggregate(...).repository(...).command(...).query(...).on_event(...)`; register in the app with `app.register(orders_module)`.
65
+
66
+ ## Other modules
67
+
68
+ - **EventBusModule** — `.adapter(impl)` or `.in_memory()`; in container as EventBus.
69
+ - **OutboxModule** — `.storage(...)` and `.publisher(...)`; contracts in core.
70
+ - **DiscoveryModule** — `.static({"svc": "http://..."})` or `.adapter(impl)`; ServiceDiscovery protocol.
71
+ - **RpcModule** — `.server(path="/rpc")` and `.client(discovery=..., transport=...)`; optional JsonHttpRpcTransport (requires httpx).
72
+
73
+ Full composition example: `examples/ecommerce/main.py`.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "urich"
7
+ version = "0.1.0"
8
+ description = "Async DDD framework for microservices on Starlette"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "starlette>=0.41.0",
13
+ "pydantic>=2.0",
14
+ ]
15
+ keywords = ["asgi", "ddd", "microservices", "starlette", "cqrs"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest", "pytest-asyncio", "httpx", "uvicorn"]
28
+ cli = ["typer>=0.9.0"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/KashN9sh/urich"
32
+ Repository = "https://github.com/KashN9sh/urich"
33
+
34
+ [project.scripts]
35
+ urich = "urich.cli.main:main"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+ testpaths = ["tests"]
urich-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """
2
+ Urich — async DDD framework for microservices.
3
+ Application is composed from module objects via app.register(module).
4
+ """
5
+ from urich.core import Application, Container, Module, HttpModule, Config
6
+
7
+ __all__ = [
8
+ "Application",
9
+ "Container",
10
+ "Module",
11
+ "HttpModule",
12
+ "Config",
13
+ ]
@@ -0,0 +1,3 @@
1
+ from urich.cli.main import app
2
+
3
+ __all__ = ["app"]
@@ -0,0 +1,109 @@
1
+ """
2
+ CLI for prototyping: create-app, add-context, add-aggregate.
3
+ Generated code composes a DomainModule and registers via app.register(module).
4
+ """
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import typer
9
+ except ImportError:
10
+ typer = None # type: ignore
11
+
12
+ from urich.cli import templates as T
13
+
14
+ app = typer.Typer(help="Urich CLI: create app and bounded context.")
15
+
16
+
17
+ def _ensure_typer():
18
+ if typer is None:
19
+ raise SystemExit("CLI requires typer: pip install 'urich[cli]' or pip install typer")
20
+
21
+
22
+ def _snake(name: str) -> str:
23
+ return "".join("_" + c.lower() if c.isupper() else c for c in name).lstrip("_")
24
+
25
+
26
+ @app.command()
27
+ def create_app(
28
+ name: str = typer.Argument(..., help="App name (folder)"),
29
+ directory: Path = typer.Option(Path("."), "--dir", "-d", help="Parent directory"),
30
+ ) -> None:
31
+ """Create app skeleton: folder, main.py, config.py."""
32
+ _ensure_typer()
33
+ root = directory / name
34
+ root.mkdir(parents=True, exist_ok=True)
35
+ (root / "main.py").write_text(T.MAIN_PY.format(first_context="orders"), encoding="utf-8")
36
+ (root / "config.py").write_text(
37
+ '"""Config (env/file)."""\n# from dataclasses import dataclass\n# settings = ...\n',
38
+ encoding="utf-8",
39
+ )
40
+ typer.echo(f"Created: {root}/ (main.py, config.py). Add context: urich add-context <name> --dir {root}")
41
+
42
+
43
+ @app.command()
44
+ def add_context(
45
+ name: str = typer.Argument(..., help="Bounded context name (e.g. orders)"),
46
+ directory: Path = typer.Option(Path("."), "--dir", "-d", help="App root directory"),
47
+ ) -> None:
48
+ """Add bounded context: folder with domain, application, infrastructure, module (skeleton)."""
49
+ _ensure_typer()
50
+ ctx_dir = directory / name
51
+ ctx_dir.mkdir(parents=True, exist_ok=True)
52
+ ctx_dir.joinpath("domain.py").write_text(
53
+ T.CONTEXT_SKELETON.format(context=name),
54
+ encoding="utf-8",
55
+ )
56
+ ctx_dir.joinpath("application.py").write_text(
57
+ T.CONTEXT_APPLICATION_SKELETON.format(context=name),
58
+ encoding="utf-8",
59
+ )
60
+ ctx_dir.joinpath("infrastructure.py").write_text(
61
+ T.CONTEXT_INFRASTRUCTURE_SKELETON.format(context=name),
62
+ encoding="utf-8",
63
+ )
64
+ ctx_dir.joinpath("module.py").write_text(
65
+ T.CONTEXT_MODULE_SKELETON.format(context=name),
66
+ encoding="utf-8",
67
+ )
68
+ typer.echo(f"Context «{name}»: {ctx_dir}/. Add aggregate: urich add-aggregate {name} <AggregateName> --dir {directory}")
69
+
70
+
71
+ @app.command()
72
+ def add_aggregate(
73
+ context: str = typer.Argument(..., help="Context name (folder)"),
74
+ aggregate: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. Order)"),
75
+ directory: Path = typer.Option(Path("."), "--dir", "-d", help="App root directory"),
76
+ ) -> None:
77
+ """Add aggregate to context: domain, application, infrastructure, module (DomainModule with command/query)."""
78
+ _ensure_typer()
79
+ ctx_dir = directory / context
80
+ if not ctx_dir.is_dir():
81
+ typer.echo(f"Context folder not found: {ctx_dir}. Run first: urich add-context {context} --dir {directory}", err=True)
82
+ raise typer.Exit(1)
83
+ agg_lower = _snake(aggregate)
84
+ ctx_dir.joinpath("domain.py").write_text(
85
+ T.DOMAIN_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
86
+ encoding="utf-8",
87
+ )
88
+ ctx_dir.joinpath("application.py").write_text(
89
+ T.APPLICATION_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
90
+ encoding="utf-8",
91
+ )
92
+ ctx_dir.joinpath("infrastructure.py").write_text(
93
+ T.INFRASTRUCTURE_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
94
+ encoding="utf-8",
95
+ )
96
+ ctx_dir.joinpath("module.py").write_text(
97
+ T.MODULE_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
98
+ encoding="utf-8",
99
+ )
100
+ typer.echo(f"Aggregate «{aggregate}» in «{context}»: {ctx_dir}/. In main.py: from {context}.module import {context}_module; app.register({context}_module)")
101
+
102
+
103
+ def main() -> None:
104
+ """Entry point for the urich console command."""
105
+ app()
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -0,0 +1,151 @@
1
+ """Skeleton templates for generating bounded context and aggregate."""
2
+
3
+ DOMAIN_PY = '''"""Domain {context}: aggregate and events."""
4
+ from dataclasses import dataclass
5
+ from urich.domain import AggregateRoot, DomainEvent
6
+
7
+
8
+ @dataclass
9
+ class {aggregate}Created(DomainEvent):
10
+ """Event: {aggregate} created."""
11
+ {aggregate_lower}_id: str
12
+ # add fields
13
+
14
+
15
+ class {aggregate}(AggregateRoot):
16
+ def __init__(self, id: str):
17
+ super().__init__(id=id)
18
+ self.raise_event({aggregate}Created({aggregate_lower}_id=id))
19
+ '''
20
+
21
+ APPLICATION_PY = '''"""Application layer: commands, queries, handlers."""
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from urich.ddd import Command, Query
26
+ from urich.domain import EventBus
27
+
28
+ from .domain import {aggregate}, {aggregate}Created
29
+ from .infrastructure import I{aggregate}Repository
30
+
31
+
32
+ @dataclass
33
+ class Create{aggregate}(Command):
34
+ {aggregate_lower}_id: str
35
+ # add fields
36
+
37
+
38
+ @dataclass
39
+ class Get{aggregate}(Query):
40
+ {aggregate_lower}_id: str
41
+
42
+
43
+ class Create{aggregate}Handler:
44
+ def __init__(self, repo: I{aggregate}Repository, event_bus: EventBus):
45
+ self._repo = repo
46
+ self._event_bus = event_bus
47
+
48
+ async def __call__(self, cmd: Create{aggregate}) -> str:
49
+ agg = {aggregate}(id=cmd.{aggregate_lower}_id)
50
+ await self._repo.add(agg)
51
+ for e in agg.collect_pending_events():
52
+ await self._event_bus.publish(e)
53
+ return agg.id
54
+
55
+
56
+ class Get{aggregate}Handler:
57
+ def __init__(self, repo: I{aggregate}Repository):
58
+ self._repo = repo
59
+
60
+ async def __call__(self, query: Get{aggregate}):
61
+ agg = await self._repo.get(query.{aggregate_lower}_id)
62
+ if agg is None:
63
+ return None
64
+ return {{"id": agg.id}}
65
+ '''
66
+
67
+ INFRASTRUCTURE_PY = '''"""Infrastructure: repository implementation."""
68
+ from __future__ import annotations
69
+
70
+ from typing import Optional
71
+ from urich.domain import Repository
72
+
73
+ from .domain import {aggregate}
74
+
75
+
76
+ class I{aggregate}Repository(Repository["{aggregate}"]):
77
+ pass
78
+
79
+
80
+ class {aggregate}RepositoryImpl(I{aggregate}Repository):
81
+ def __init__(self):
82
+ self._store: dict[str, {aggregate}] = {{}}
83
+
84
+ async def get(self, id: str) -> Optional[{aggregate}]:
85
+ return self._store.get(id)
86
+
87
+ async def add(self, aggregate: {aggregate}) -> None:
88
+ self._store[aggregate.id] = aggregate
89
+
90
+ async def save(self, aggregate: {aggregate}) -> None:
91
+ self._store[aggregate.id] = aggregate
92
+ '''
93
+
94
+ MODULE_PY = '''"""One object = bounded context «{context}»."""
95
+ from urich.ddd import DomainModule
96
+
97
+ from .domain import {aggregate}, {aggregate}Created
98
+ from .application import Create{aggregate}, Create{aggregate}Handler, Get{aggregate}, Get{aggregate}Handler
99
+ from .infrastructure import I{aggregate}Repository, {aggregate}RepositoryImpl
100
+
101
+
102
+ def on_{aggregate_lower}_created(event: {aggregate}Created) -> None:
103
+ """Handler: when {aggregate} is created."""
104
+ ...
105
+
106
+
107
+ {context}_module = (
108
+ DomainModule("{context}")
109
+ .aggregate({aggregate})
110
+ .repository(I{aggregate}Repository, {aggregate}RepositoryImpl)
111
+ .command(Create{aggregate}, Create{aggregate}Handler)
112
+ .query(Get{aggregate}, Get{aggregate}Handler)
113
+ .on_event({aggregate}Created, on_{aggregate_lower}_created)
114
+ )
115
+ '''
116
+
117
+ MAIN_PY = '''"""Entry point: app is composed from modules."""
118
+ from urich import Application
119
+
120
+ # from {first_context}.module import {first_context}_module
121
+
122
+ app = Application()
123
+ # app.register({first_context}_module)
124
+
125
+ # Run: uvicorn main:app --reload
126
+ '''
127
+
128
+ CONTEXT_SKELETON = '''"""Domain {context}."""
129
+ from urich.domain import AggregateRoot, DomainEvent
130
+
131
+ # Add aggregates and events or run: urich add-aggregate {context} <AggregateName>
132
+ '''
133
+
134
+ CONTEXT_APPLICATION_SKELETON = '''"""Application layer for context {context}."""
135
+ from urich.ddd import Command, Query
136
+
137
+ # Add commands, queries and handlers (or generate via add-aggregate)
138
+ '''
139
+
140
+ CONTEXT_INFRASTRUCTURE_SKELETON = '''"""Infrastructure for context {context}."""
141
+ from urich.domain import Repository
142
+
143
+ # Add repository interfaces and implementations
144
+ '''
145
+
146
+ CONTEXT_MODULE_SKELETON = '''"""Bounded context «{context}» — no aggregates yet."""
147
+ from urich.ddd import DomainModule
148
+
149
+ # Add .aggregate(), .repository(), .command(), .query(), .on_event() after add-aggregate
150
+ {context}_module = DomainModule("{context}")
151
+ '''
@@ -0,0 +1,13 @@
1
+ from urich.core.app import Application
2
+ from urich.core.container import Container
3
+ from urich.core.module import Module
4
+ from urich.core.routing import HttpModule
5
+ from urich.core.config import Config
6
+
7
+ __all__ = [
8
+ "Application",
9
+ "Container",
10
+ "Module",
11
+ "HttpModule",
12
+ "Config",
13
+ ]
@@ -0,0 +1,107 @@
1
+ """Application — Starlette wrapper; app is composed from modules via app.register(module)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from starlette.applications import Starlette
7
+ from starlette.routing import Route
8
+
9
+ from urich.core.container import Container
10
+ from urich.core.module import Module
11
+
12
+
13
+ class Application:
14
+ """
15
+ Application. Composed from modules via register(module).
16
+ Each module is an object with register_into(app).
17
+ """
18
+
19
+ def __init__(self, config: Any = None) -> None:
20
+ self._starlette = Starlette(routes=[])
21
+ self._modules: list[Module] = []
22
+ self._container = Container()
23
+ self._route_schemas: dict[tuple[str, str], dict[str, Any]] = {} # (path, method) -> OpenAPI op extras
24
+ if config is not None:
25
+ self._container.register_instance(type(config), config)
26
+ self._container.register_instance("config", config)
27
+
28
+ def register(self, module: Module) -> Application:
29
+ """Register a module (DomainModule, EventBusModule, routes, etc.). Returns self for chaining."""
30
+ module.register_into(self)
31
+ self._modules.append(module)
32
+ return self
33
+
34
+ def add_route(
35
+ self,
36
+ path: str,
37
+ endpoint: Any,
38
+ methods: list[str] | None = None,
39
+ *,
40
+ openapi_body_schema: dict[str, Any] | None = None,
41
+ openapi_parameters: list[dict[str, Any]] | None = None,
42
+ ) -> None:
43
+ """Add an HTTP route. Optional openapi_body_schema / openapi_parameters for Swagger."""
44
+ if methods is None:
45
+ methods = ["GET"]
46
+ route = Route(path, endpoint, methods=methods)
47
+ self._starlette.routes.append(route)
48
+ for method in methods:
49
+ key = (path, method.lower())
50
+ if key not in self._route_schemas:
51
+ self._route_schemas[key] = {}
52
+ if method.lower() == "get" and openapi_parameters is not None:
53
+ self._route_schemas[key]["parameters"] = openapi_parameters
54
+ if method.lower() == "post" and openapi_body_schema is not None:
55
+ self._route_schemas[key]["requestBody"] = {
56
+ "required": True,
57
+ "content": {"application/json": {"schema": openapi_body_schema}},
58
+ }
59
+
60
+ def mount(self, path: str, app: Starlette) -> None:
61
+ """Mount a sub-app at prefix. Called by modules from register_into."""
62
+ from starlette.routing import Mount
63
+ self._starlette.routes.append(Mount(path, app=app))
64
+
65
+ def openapi(
66
+ self,
67
+ *,
68
+ title: str = "API",
69
+ version: str = "0.1.0",
70
+ docs_path: str = "/docs",
71
+ openapi_path: str = "/openapi.json",
72
+ ) -> Application:
73
+ """Add OpenAPI spec and Swagger UI. Call after all modules are registered. Returns self."""
74
+ from urich.core.openapi import build_openapi_spec, SWAGGER_UI_HTML
75
+ from starlette.responses import HTMLResponse, JSONResponse
76
+
77
+ spec = build_openapi_spec(
78
+ self._starlette.routes,
79
+ title=title,
80
+ version=version,
81
+ route_schemas=self._route_schemas,
82
+ )
83
+ self._openapi_spec = spec # type: ignore[attr-defined]
84
+
85
+ async def openapi_endpoint(request: Any) -> Any:
86
+ return JSONResponse(spec)
87
+
88
+ async def docs_endpoint(request: Any) -> Any:
89
+ return HTMLResponse(SWAGGER_UI_HTML.replace("/openapi.json", openapi_path))
90
+
91
+ self.add_route(openapi_path, openapi_endpoint, methods=["GET"])
92
+ self.add_route(docs_path, docs_endpoint, methods=["GET"])
93
+ return self
94
+
95
+ @property
96
+ def container(self) -> Container:
97
+ """DI container: registration and resolution of dependencies."""
98
+ return self._container
99
+
100
+ @property
101
+ def starlette(self) -> Starlette:
102
+ """Underlying Starlette ASGI app (e.g. for middleware)."""
103
+ return self._starlette
104
+
105
+ async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
106
+ """ASGI: uvicorn.run(app) works directly."""
107
+ await self._starlette(scope, receive, send)
@@ -0,0 +1,20 @@
1
+ """Single config object: user passes it when creating the app; available via DI."""
2
+ import os
3
+ from typing import Any
4
+
5
+
6
+ class Config:
7
+ """
8
+ Application config. User creates their own class or instance
9
+ and passes to Application(config=...); then available via container.resolve(Config).
10
+ """
11
+
12
+ @classmethod
13
+ def load_from_env(cls, prefix: str = "APP_", **defaults: Any) -> dict[str, Any]:
14
+ """Load from os.environ with prefix and defaults. Returns dict for MyConfig(**Config.load_from_env())."""
15
+ result = dict(defaults)
16
+ for key, value in os.environ.items():
17
+ if key.startswith(prefix):
18
+ name = key[len(prefix):].lower()
19
+ result[name] = value
20
+ return result