nexus-kit 0.4.0__py3-none-any.whl
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.
- nexus_kit/__init__.py +11 -0
- nexus_kit/cli.py +295 -0
- nexus_kit/impl/__init__.py +4 -0
- nexus_kit/impl/container_injector.py +25 -0
- nexus_kit/impl/service_runner.py +121 -0
- nexus_kit/interfaces/__init__.py +11 -0
- nexus_kit/interfaces/application.py +16 -0
- nexus_kit/interfaces/container.py +13 -0
- nexus_kit/interfaces/environment.py +10 -0
- nexus_kit/interfaces/service.py +19 -0
- nexus_kit/logging/__init__.py +5 -0
- nexus_kit/logging/log_formatter.py +24 -0
- nexus_kit/logging/named_logger.py +47 -0
- nexus_kit/logging/stdout_handler.py +17 -0
- nexus_kit/root.py +31 -0
- nexus_kit-0.4.0.dist-info/METADATA +425 -0
- nexus_kit-0.4.0.dist-info/RECORD +20 -0
- nexus_kit-0.4.0.dist-info/WHEEL +4 -0
- nexus_kit-0.4.0.dist-info/entry_points.txt +2 -0
- nexus_kit-0.4.0.dist-info/licenses/LICENSE +21 -0
nexus_kit/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from nexus_kit.interfaces.application import ApplicationInterface
|
|
2
|
+
from nexus_kit.interfaces.container import ContainerInterface
|
|
3
|
+
from nexus_kit.interfaces.environment import EnvironmentInterface
|
|
4
|
+
from nexus_kit.root import Root
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ApplicationInterface",
|
|
8
|
+
"ContainerInterface",
|
|
9
|
+
"EnvironmentInterface",
|
|
10
|
+
"Root",
|
|
11
|
+
]
|
nexus_kit/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_TEMPLATES: dict[str, str] = {
|
|
7
|
+
"main.py": """\
|
|
8
|
+
import faulthandler
|
|
9
|
+
|
|
10
|
+
from app.application import Application
|
|
11
|
+
from app.config.di import DI_CONFIG
|
|
12
|
+
from app.config.environment import Environment
|
|
13
|
+
from nexus_kit import Root
|
|
14
|
+
from nexus_kit.impl import ContainerInjector
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
faulthandler.enable(all_threads=True)
|
|
18
|
+
|
|
19
|
+
env = Environment(Root.external(".env"))
|
|
20
|
+
container = ContainerInjector(DI_CONFIG)
|
|
21
|
+
container.set(Environment, env)
|
|
22
|
+
Application(env, container).run()
|
|
23
|
+
""",
|
|
24
|
+
"pyproject.toml": """\
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[project]
|
|
30
|
+
name = "{{APP_NAME}}"
|
|
31
|
+
version = "0.1.0"
|
|
32
|
+
requires-python = ">=3.12"
|
|
33
|
+
dependencies = [
|
|
34
|
+
"nexus-kit~={{NEXUS_REF}}",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["app"]
|
|
39
|
+
""",
|
|
40
|
+
".gitignore": """\
|
|
41
|
+
.venv/
|
|
42
|
+
__pycache__/
|
|
43
|
+
*.pyc
|
|
44
|
+
.env
|
|
45
|
+
dist/
|
|
46
|
+
build/
|
|
47
|
+
*.spec
|
|
48
|
+
.pytest_cache/
|
|
49
|
+
.ruff_cache/
|
|
50
|
+
.idea/
|
|
51
|
+
""",
|
|
52
|
+
".env": """\
|
|
53
|
+
APP_NAME={{APP_NAME}}
|
|
54
|
+
TICK_SECONDS=0.7
|
|
55
|
+
RUN_SECONDS=3
|
|
56
|
+
""",
|
|
57
|
+
"app/__init__.py": "",
|
|
58
|
+
"app/application.py": """\
|
|
59
|
+
import time
|
|
60
|
+
|
|
61
|
+
from nexus_kit.impl import ServiceRunner
|
|
62
|
+
from nexus_kit.interfaces import ApplicationInterface, ContainerInterface
|
|
63
|
+
|
|
64
|
+
from app.config.environment import Environment
|
|
65
|
+
from app.services.ticker import Ticker
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Application(ApplicationInterface):
|
|
69
|
+
SERVICES = [Ticker] # startup order; stopped in reverse on any exit
|
|
70
|
+
|
|
71
|
+
def __init__(self, environment: Environment, container: ContainerInterface) -> None:
|
|
72
|
+
self._env = environment
|
|
73
|
+
self._container = container
|
|
74
|
+
|
|
75
|
+
def run(self) -> None:
|
|
76
|
+
with ServiceRunner(self._container, self.SERVICES):
|
|
77
|
+
print(f"[{self._env.APP_NAME}] running for {self._env.RUN_SECONDS}s — Ctrl+C to stop early")
|
|
78
|
+
try:
|
|
79
|
+
# Replace with your main loop: server.wait(), app.exec(), game loop
|
|
80
|
+
time.sleep(self._env.RUN_SECONDS)
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
pass
|
|
83
|
+
# leaving the block stopped the ticker and joined its thread — nothing orphaned
|
|
84
|
+
""",
|
|
85
|
+
"app/config/__init__.py": "",
|
|
86
|
+
"app/config/di.py": """\
|
|
87
|
+
# Register your swappable seams here: {Interface: Implementation}
|
|
88
|
+
from app.services.console_reporter import ConsoleReporter
|
|
89
|
+
from app.services.reporter_interface import ReporterInterface
|
|
90
|
+
|
|
91
|
+
DI_CONFIG = {
|
|
92
|
+
ReporterInterface: ConsoleReporter,
|
|
93
|
+
}
|
|
94
|
+
""",
|
|
95
|
+
"app/config/environment.py": """\
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
from injector import singleton
|
|
99
|
+
|
|
100
|
+
from nexus_kit.interfaces import EnvironmentInterface
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@singleton
|
|
104
|
+
class Environment(EnvironmentInterface):
|
|
105
|
+
# Add your config fields here — they are read from .env automatically
|
|
106
|
+
APP_NAME: str = "{{APP_NAME}}"
|
|
107
|
+
TICK_SECONDS: float = 0.7
|
|
108
|
+
RUN_SECONDS: float = 3.0
|
|
109
|
+
|
|
110
|
+
def __init__(self, env_path: Path) -> None:
|
|
111
|
+
super().__init__(_env_file=env_path)
|
|
112
|
+
""",
|
|
113
|
+
"app/services/__init__.py": "",
|
|
114
|
+
"app/services/ticker.py": """\
|
|
115
|
+
import threading
|
|
116
|
+
|
|
117
|
+
from injector import inject, singleton
|
|
118
|
+
|
|
119
|
+
from nexus_kit.interfaces import ServiceInterface
|
|
120
|
+
|
|
121
|
+
from app.config.environment import Environment
|
|
122
|
+
from app.services.reporter_interface import ReporterInterface
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# The worker-thread skeleton every long-lived app ends up hand-rolling:
|
|
126
|
+
# stop Event + bounded join. Swap the loop body for your poller, device
|
|
127
|
+
# monitor or queue drainer. Not in DI_CONFIG: a concrete @singleton
|
|
128
|
+
# service resolves itself; its dependencies arrive via @inject.
|
|
129
|
+
@singleton
|
|
130
|
+
class Ticker(ServiceInterface):
|
|
131
|
+
@inject
|
|
132
|
+
def __init__(self, env: Environment, reporter: ReporterInterface) -> None:
|
|
133
|
+
self._interval = env.TICK_SECONDS
|
|
134
|
+
self._reporter = reporter
|
|
135
|
+
self._ticks = 0
|
|
136
|
+
self._stop_event = threading.Event()
|
|
137
|
+
self._thread: threading.Thread | None = None
|
|
138
|
+
|
|
139
|
+
def start(self) -> None:
|
|
140
|
+
print(f"[ticker] started (every {self._interval}s)")
|
|
141
|
+
self._thread = threading.Thread(target=self._run, name="ticker", daemon=True)
|
|
142
|
+
self._thread.start()
|
|
143
|
+
|
|
144
|
+
def stop(self) -> None: # must be idempotent
|
|
145
|
+
self._stop_event.set()
|
|
146
|
+
if self._thread is not None:
|
|
147
|
+
self._thread.join(timeout=5.0)
|
|
148
|
+
self._thread = None
|
|
149
|
+
print(f"[ticker] stopped after {self._ticks} ticks")
|
|
150
|
+
|
|
151
|
+
def _run(self) -> None:
|
|
152
|
+
while not self._stop_event.wait(self._interval):
|
|
153
|
+
self._ticks += 1
|
|
154
|
+
self._reporter.report(self._ticks)
|
|
155
|
+
""",
|
|
156
|
+
"app/services/reporter_interface.py": """\
|
|
157
|
+
from abc import ABC, abstractmethod
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ReporterInterface(ABC):
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def report(self, tick: int) -> None: ...
|
|
163
|
+
""",
|
|
164
|
+
"app/services/console_reporter.py": """\
|
|
165
|
+
from injector import singleton
|
|
166
|
+
|
|
167
|
+
from app.services.reporter_interface import ReporterInterface
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@singleton
|
|
171
|
+
class ConsoleReporter(ReporterInterface):
|
|
172
|
+
def report(self, tick: int) -> None:
|
|
173
|
+
print(f"tick #{tick}")
|
|
174
|
+
""",
|
|
175
|
+
"CLAUDE.md": """\
|
|
176
|
+
# CLAUDE.md
|
|
177
|
+
|
|
178
|
+
Guidance for Claude Code (and other AI assistants) working in this repository.
|
|
179
|
+
|
|
180
|
+
This app is built on the **nexus** framework. Its API, the bootstrap pattern and the
|
|
181
|
+
gotchas are in `.ai/nexus-kit.md` — read it before touching DI, config or the composition
|
|
182
|
+
root (`app/config/di.py`). Keep this file thin: put engineering discipline in `.ai/`
|
|
183
|
+
and only repo-specific facts here.
|
|
184
|
+
""",
|
|
185
|
+
".ai/nexus-kit.md": """\
|
|
186
|
+
# Nexus — quick reference (how to build an app on this framework)
|
|
187
|
+
|
|
188
|
+
Compact cheat-sheet for the **nexus** framework (PyPI dist `nexus-kit`, imports as
|
|
189
|
+
`nexus`; github.com/Astislav/nexus), pinned to **~={{NEXUS_REF}}**. For depth: the
|
|
190
|
+
framework's own `.ai/guide.md`, or the installed source at
|
|
191
|
+
`.venv/Lib/site-packages/nexus_kit/`.
|
|
192
|
+
|
|
193
|
+
## What it is
|
|
194
|
+
|
|
195
|
+
A tiny application bootstrap: a **DI container** (wraps `injector`) + a **config base**
|
|
196
|
+
(wraps `pydantic-settings`) + **logging** + **`Root`** (paths) + the `nexus-kit new` CLI.
|
|
197
|
+
No domain, HTTP or DB.
|
|
198
|
+
|
|
199
|
+
## Public API
|
|
200
|
+
|
|
201
|
+
| Symbol | Import | Role |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `ApplicationInterface` | `nexus_kit.interfaces` | run contract: `__init__(env, container)` + `run()` |
|
|
204
|
+
| `ContainerInterface` | `nexus_kit.interfaces` | DI contract: `get(cls)`, `set(cls, value)` |
|
|
205
|
+
| `EnvironmentInterface` | `nexus_kit.interfaces` | typed config base (pydantic BaseSettings + `@singleton`) |
|
|
206
|
+
| `ServiceInterface` | `nexus_kit.interfaces` | long-lived service: `start()`/`stop()` (sync or async, `stop()` idempotent) |
|
|
207
|
+
| `Root` | `nexus` | paths: `Root.internal(*p)` (bundled assets) / `Root.external(*p)` (files next to the exe — or next to `main.py` in dev: `.env`, db) |
|
|
208
|
+
| `ContainerInjector` | `nexus_kit.impl` | concrete container; constructor takes `DI_CONFIG: dict[Type, Impl]` |
|
|
209
|
+
| `ServiceRunner` | `nexus_kit.impl` | ordered start / guaranteed reverse-order stop: `with`/`async with ServiceRunner(container, SERVICES)` around the app body |
|
|
210
|
+
| `NamedLogger` / `StdoutHandler` / `LogFormatter` | `nexus_kit.logging` | DI-injectable logging |
|
|
211
|
+
|
|
212
|
+
**Gotcha:** `@singleton`, `@inject`, `Injector` come from the `injector` package, NOT
|
|
213
|
+
from nexus-kit (`from injector import inject, singleton`). Nexus never re-exports them.
|
|
214
|
+
|
|
215
|
+
**Dependencies:** `injector` and `pydantic-settings` are core dependencies of nexus —
|
|
216
|
+
no extras, everything works out of the box.
|
|
217
|
+
|
|
218
|
+
## Bootstrap (`main.py`)
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
env = Environment(Root.external(".env")) # 1. config
|
|
222
|
+
container = ContainerInjector(DI_CONFIG) # 2. wiring
|
|
223
|
+
container.set(Environment, env) # 3. env is NOT auto-bound — bind it by hand
|
|
224
|
+
Application(env, container).run() # 4. start
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- `DI_CONFIG` (composition root) is a `dict{Interface: Impl}`; register only swappable
|
|
228
|
+
seams — `@singleton @inject` services are built by the container from their constructors.
|
|
229
|
+
- **Do not bind a class to itself.** Interfaces carry the `Interface` suffix; implementations don't.
|
|
230
|
+
- Long-lived services are `@singleton`, dependencies come via an `@inject` constructor.
|
|
231
|
+
- Logging: subclass `NamedLogger` (class attr `name`), inject by type; change the format
|
|
232
|
+
by rebinding `LogFormatter` in `DI_CONFIG`.
|
|
233
|
+
|
|
234
|
+
## Lifecycle (`ServiceRunner`)
|
|
235
|
+
|
|
236
|
+
Long-lived services implement `ServiceInterface` — `start()`/`stop()`, sync or async,
|
|
237
|
+
`stop()` must be idempotent. `Application` lists them in `SERVICES` (startup order) and
|
|
238
|
+
wraps the app body in the runner; see `app/services/ticker.py` for the pattern
|
|
239
|
+
(worker thread + stop Event + bounded join):
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
class Application(ApplicationInterface):
|
|
243
|
+
SERVICES = [Database, Poller, HttpApiService] # startup order
|
|
244
|
+
|
|
245
|
+
def run(self) -> None:
|
|
246
|
+
with ServiceRunner(self._container, self.SERVICES): # async app: `async with`
|
|
247
|
+
self._main_loop()
|
|
248
|
+
# leaving the block stops everything in reverse — on return, exception, Ctrl+C
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Guarantees: crash-safe startup (a failed `start()` rolls back the already-started
|
|
252
|
+
services and re-raises); a failing `stop()` is logged, teardown continues; async stops
|
|
253
|
+
are bounded by `stop_grace` (default 10s), then cancelled. The runner never grabs
|
|
254
|
+
signals — the exit is triggered by uvicorn / Qt `aboutToQuit` / your own code. Services
|
|
255
|
+
do NOT go into `DI_CONFIG` (concrete `@singleton` classes resolve themselves).
|
|
256
|
+
|
|
257
|
+
## What nexus does NOT provide (you hand-roll these)
|
|
258
|
+
|
|
259
|
+
Signal handling (`ServiceRunner` never grabs SIGINT/SIGTERM — exit is triggered by
|
|
260
|
+
uvicorn, Qt `aboutToQuit`, or your own code); a background-service/worker base;
|
|
261
|
+
a repository/DB layer; a test harness; HTTP/routing/retries.
|
|
262
|
+
""",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main() -> None:
|
|
267
|
+
if len(sys.argv) < 3 or sys.argv[1] != "new":
|
|
268
|
+
print("Usage: nexus-kit new <app-name>")
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
|
|
271
|
+
app_name = sys.argv[2]
|
|
272
|
+
root = Path(app_name)
|
|
273
|
+
|
|
274
|
+
if root.exists():
|
|
275
|
+
print(f"Error: '{app_name}' already exists")
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
root.mkdir()
|
|
279
|
+
|
|
280
|
+
nexus_ref = version("nexus-kit")
|
|
281
|
+
for rel_path, content in _TEMPLATES.items():
|
|
282
|
+
path = root / rel_path
|
|
283
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
content = content.replace("{{APP_NAME}}", app_name).replace("{{NEXUS_REF}}", nexus_ref)
|
|
285
|
+
path.write_text(content, encoding="utf-8")
|
|
286
|
+
|
|
287
|
+
print(f"Created {app_name}/")
|
|
288
|
+
print(f"")
|
|
289
|
+
print(f" cd {app_name}")
|
|
290
|
+
print(f"")
|
|
291
|
+
print(f" # install dependencies:")
|
|
292
|
+
print(f" uv sync # uv")
|
|
293
|
+
print(f" pip install -e . # pip")
|
|
294
|
+
print(f"")
|
|
295
|
+
print(f" python main.py")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any, Mapping, Type, TypeVar
|
|
2
|
+
|
|
3
|
+
from injector import Injector, singleton
|
|
4
|
+
|
|
5
|
+
from nexus_kit.interfaces.container import ContainerInterface
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@singleton
|
|
9
|
+
class ContainerInjector(ContainerInterface):
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
def __init__(self, bindings: Mapping[Type[Any], Any]):
|
|
13
|
+
self.__injector = Injector()
|
|
14
|
+
self.__injector.binder.bind(Injector, to=self.__injector)
|
|
15
|
+
self.__injector.binder.bind(ContainerInterface, to=self)
|
|
16
|
+
|
|
17
|
+
for cls, value in bindings.items():
|
|
18
|
+
self.set(cls, value)
|
|
19
|
+
|
|
20
|
+
def get(self, cls: Type[T]) -> T:
|
|
21
|
+
return self.__injector.get(cls)
|
|
22
|
+
|
|
23
|
+
def set(self, cls: Type[T], value: Any) -> "ContainerInterface":
|
|
24
|
+
self.__injector.binder.bind(cls, to=value)
|
|
25
|
+
return self
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Sequence, Type
|
|
5
|
+
|
|
6
|
+
from nexus_kit.interfaces.container import ContainerInterface
|
|
7
|
+
from nexus_kit.interfaces.service import ServiceInterface
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServiceRunner:
|
|
11
|
+
"""Starts services in order, stops them in reverse — guaranteed.
|
|
12
|
+
|
|
13
|
+
Sync app (pygame, Qt worker threads):
|
|
14
|
+
|
|
15
|
+
runner = ServiceRunner(container, SERVICES)
|
|
16
|
+
with runner:
|
|
17
|
+
main_loop()
|
|
18
|
+
|
|
19
|
+
Async app (asyncio servers):
|
|
20
|
+
|
|
21
|
+
async with ServiceRunner(container, SERVICES) as runner:
|
|
22
|
+
await http.wait()
|
|
23
|
+
|
|
24
|
+
Startup is crash-safe: if the N-th service fails to start, the already
|
|
25
|
+
started N-1 are stopped in reverse order and the error is re-raised.
|
|
26
|
+
Teardown runs on any exit — normal return, exception, Ctrl+C — and keeps
|
|
27
|
+
going past individual stop() failures (they are logged, not raised, so
|
|
28
|
+
every service gets its chance to shut down).
|
|
29
|
+
|
|
30
|
+
The runner does NOT install signal handlers: who triggers the exit is
|
|
31
|
+
the application's business (uvicorn's own handlers, Qt's aboutToQuit,
|
|
32
|
+
or your own). In the async context each async stop() is bounded by
|
|
33
|
+
`stop_grace` seconds, then cancelled; a SYNC stop() runs inline and is
|
|
34
|
+
not bounded — deliberately, because offloading it to a thread would
|
|
35
|
+
silently break thread-affine teardown (Qt, COM). If the surrounding
|
|
36
|
+
task is cancelled mid-teardown, the remaining services are still
|
|
37
|
+
stopped before the cancellation is re-raised.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
container: ContainerInterface,
|
|
43
|
+
services: Sequence[Type[ServiceInterface]],
|
|
44
|
+
stop_grace: float = 10.0,
|
|
45
|
+
logger: logging.Logger | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._container = container
|
|
48
|
+
self._service_types = list(services)
|
|
49
|
+
self._stop_grace = stop_grace
|
|
50
|
+
self._log = logger or logging.getLogger("nexus.services")
|
|
51
|
+
self._started: list[ServiceInterface] = []
|
|
52
|
+
|
|
53
|
+
# --- sync context ---
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> "ServiceRunner":
|
|
56
|
+
for cls in self._service_types:
|
|
57
|
+
try:
|
|
58
|
+
service = self._container.get(cls)
|
|
59
|
+
if inspect.iscoroutinefunction(service.start) or inspect.iscoroutinefunction(service.stop):
|
|
60
|
+
raise TypeError(f"{cls.__name__} is async — use 'async with' instead of 'with'")
|
|
61
|
+
service.start()
|
|
62
|
+
except BaseException:
|
|
63
|
+
self.stop_all()
|
|
64
|
+
raise
|
|
65
|
+
self._started.append(service)
|
|
66
|
+
self._log.info("started %s", cls.__name__)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc_info) -> None:
|
|
70
|
+
self.stop_all()
|
|
71
|
+
|
|
72
|
+
def stop_all(self) -> None:
|
|
73
|
+
while self._started:
|
|
74
|
+
service = self._started.pop()
|
|
75
|
+
try:
|
|
76
|
+
service.stop()
|
|
77
|
+
self._log.info("stopped %s", type(service).__name__)
|
|
78
|
+
except Exception:
|
|
79
|
+
self._log.exception("%s.stop() failed", type(service).__name__)
|
|
80
|
+
|
|
81
|
+
# --- async context ---
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> "ServiceRunner":
|
|
84
|
+
for cls in self._service_types:
|
|
85
|
+
try:
|
|
86
|
+
service = self._container.get(cls)
|
|
87
|
+
result = service.start()
|
|
88
|
+
if inspect.isawaitable(result):
|
|
89
|
+
await result
|
|
90
|
+
except BaseException:
|
|
91
|
+
await self.stop_all_async()
|
|
92
|
+
raise
|
|
93
|
+
self._started.append(service)
|
|
94
|
+
self._log.info("started %s", cls.__name__)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
98
|
+
await self.stop_all_async()
|
|
99
|
+
|
|
100
|
+
async def stop_all_async(self) -> None:
|
|
101
|
+
# Cancellation of the surrounding task must not abandon the remaining
|
|
102
|
+
# services half-stopped: swallow CancelledError per service, finish
|
|
103
|
+
# the teardown, then re-raise it once.
|
|
104
|
+
cancelled = False
|
|
105
|
+
while self._started:
|
|
106
|
+
service = self._started.pop()
|
|
107
|
+
name = type(service).__name__
|
|
108
|
+
try:
|
|
109
|
+
result = service.stop()
|
|
110
|
+
if inspect.isawaitable(result):
|
|
111
|
+
await asyncio.wait_for(asyncio.ensure_future(result), self._stop_grace)
|
|
112
|
+
self._log.info("stopped %s", name)
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
cancelled = True
|
|
115
|
+
self._log.warning("cancelled while stopping %s — finishing the remaining teardown", name)
|
|
116
|
+
except TimeoutError:
|
|
117
|
+
self._log.error("%s.stop() exceeded the %.1fs grace period and was cancelled", name, self._stop_grace)
|
|
118
|
+
except Exception:
|
|
119
|
+
self._log.exception("%s.stop() failed", name)
|
|
120
|
+
if cancelled:
|
|
121
|
+
raise asyncio.CancelledError
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from nexus_kit.interfaces.application import ApplicationInterface
|
|
2
|
+
from nexus_kit.interfaces.container import ContainerInterface
|
|
3
|
+
from nexus_kit.interfaces.environment import EnvironmentInterface
|
|
4
|
+
from nexus_kit.interfaces.service import ServiceInterface
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ApplicationInterface",
|
|
8
|
+
"ContainerInterface",
|
|
9
|
+
"EnvironmentInterface",
|
|
10
|
+
"ServiceInterface",
|
|
11
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from nexus_kit.interfaces.container import ContainerInterface
|
|
6
|
+
from nexus_kit.interfaces.environment import EnvironmentInterface
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApplicationInterface(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def __init__(
|
|
12
|
+
self, environment: EnvironmentInterface, container: ContainerInterface
|
|
13
|
+
): ...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def run(self): ...
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Type, TypeVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContainerInterface(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get(self, cls: Type[T]) -> T: ...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def set(self, cls: Type[Any], value: Any) -> "ContainerInterface": ...
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from injector import singleton
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@singleton
|
|
6
|
+
class EnvironmentInterface(BaseSettings):
|
|
7
|
+
# utf-8-sig: Windows editors (Notepad) save .env with a BOM — without this the
|
|
8
|
+
# first key silently becomes 'KEY' and validation fails with a cryptic error.
|
|
9
|
+
# extra="ignore": .env files often carry keys for other tools (compose, direnv).
|
|
10
|
+
model_config = SettingsConfigDict(env_file_encoding="utf-8-sig", extra="ignore")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ServiceInterface(ABC):
|
|
5
|
+
"""Contract for a long-lived service managed by ServiceRunner.
|
|
6
|
+
|
|
7
|
+
Both methods may be sync or async — the runner awaits the result when it
|
|
8
|
+
is awaitable. In a sync runner context (`with runner:`) async services
|
|
9
|
+
are rejected.
|
|
10
|
+
|
|
11
|
+
stop() must be idempotent: the runner may call it after a failed start()
|
|
12
|
+
or more than once during teardown.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def start(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def stop(self) -> None: ...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from injector import singleton
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@singleton
|
|
8
|
+
class LogFormatter(logging.Formatter):
|
|
9
|
+
"""Default log line format.
|
|
10
|
+
|
|
11
|
+
Subclass and override `format_string`, then bind the subclass in place of
|
|
12
|
+
LogFormatter to change how every handler renders a record — this is
|
|
13
|
+
independent of *where* logs go (see StdoutHandler):
|
|
14
|
+
|
|
15
|
+
class JsonFormatter(LogFormatter):
|
|
16
|
+
format_string = '{"ts":"%(asctime)s","level":"%(levelname)s",...}'
|
|
17
|
+
|
|
18
|
+
DI_CONFIG = {LogFormatter: JsonFormatter}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
format_string: ClassVar[str] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
super().__init__(self.format_string)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from injector import inject
|
|
5
|
+
|
|
6
|
+
from nexus_kit.logging.stdout_handler import StdoutHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NamedLogger(logging.Logger):
|
|
10
|
+
"""Base for a typed, DI-injectable logger channel.
|
|
11
|
+
|
|
12
|
+
Subclass and set `name` (and optionally `level`) — nexus wires the
|
|
13
|
+
handler for you. Each subclass is its own container-resolvable type:
|
|
14
|
+
inject it by type annotation, no string-keyed lookups.
|
|
15
|
+
|
|
16
|
+
@singleton
|
|
17
|
+
class SessionLogger(NamedLogger):
|
|
18
|
+
name = "app.session"
|
|
19
|
+
|
|
20
|
+
class SessionManager:
|
|
21
|
+
@inject
|
|
22
|
+
def __init__(self, log: SessionLogger): ...
|
|
23
|
+
|
|
24
|
+
To add another handler (e.g. a Qt signal sink for a log-view widget),
|
|
25
|
+
override `__init__` and call `super().__init__(handler)` first:
|
|
26
|
+
|
|
27
|
+
@singleton
|
|
28
|
+
class SessionLogger(NamedLogger):
|
|
29
|
+
name = "app.session"
|
|
30
|
+
|
|
31
|
+
@inject
|
|
32
|
+
def __init__(self, handler: StdoutHandler, qt_handler: QtLogHandler):
|
|
33
|
+
super().__init__(handler)
|
|
34
|
+
self.addHandler(qt_handler)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: ClassVar[str]
|
|
38
|
+
level: ClassVar[int] = logging.INFO
|
|
39
|
+
|
|
40
|
+
@inject
|
|
41
|
+
def __init__(self, handler: StdoutHandler):
|
|
42
|
+
# Read the class attribute before Logger.__init__ shadows `self.level`
|
|
43
|
+
# with an instance attribute (it defaults to NOTSET if not passed here).
|
|
44
|
+
level = self.level
|
|
45
|
+
super().__init__(self.name, level)
|
|
46
|
+
self.addHandler(handler)
|
|
47
|
+
self.propagate = False
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from injector import inject, singleton
|
|
5
|
+
|
|
6
|
+
from nexus_kit.logging.log_formatter import LogFormatter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@singleton
|
|
10
|
+
class StdoutHandler(logging.StreamHandler):
|
|
11
|
+
"""Writes log records to the console. Formatting is a separate concern —
|
|
12
|
+
see LogFormatter."""
|
|
13
|
+
|
|
14
|
+
@inject
|
|
15
|
+
def __init__(self, formatter: LogFormatter):
|
|
16
|
+
super().__init__(sys.stdout)
|
|
17
|
+
self.setFormatter(formatter)
|
nexus_kit/root.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Root:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def internal(*relative_parts: str) -> str:
|
|
8
|
+
if hasattr(sys, "_MEIPASS"):
|
|
9
|
+
base_dir = Path(sys._MEIPASS)
|
|
10
|
+
else:
|
|
11
|
+
base_dir = Root._dev_base()
|
|
12
|
+
return str(base_dir.joinpath(*relative_parts))
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def external(*relative_parts: str) -> str:
|
|
16
|
+
if hasattr(sys, "_MEIPASS"):
|
|
17
|
+
base_dir = Path(sys.executable).resolve().parent
|
|
18
|
+
else:
|
|
19
|
+
base_dir = Root._dev_base()
|
|
20
|
+
return str(base_dir.joinpath(*relative_parts))
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _dev_base() -> Path:
|
|
24
|
+
# Anchor to the entry script's directory, not cwd: launching
|
|
25
|
+
# `python d:/apps/game/main.py` from another directory (IDE, task
|
|
26
|
+
# scheduler, shortcut) must still find .env next to main.py —
|
|
27
|
+
# matching the frozen build, which anchors to the exe.
|
|
28
|
+
main_file = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
29
|
+
if main_file:
|
|
30
|
+
return Path(main_file).resolve().parent
|
|
31
|
+
return Path.cwd() # REPL, python -c
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexus-kit
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Application kernel for long-lived Python apps: one entry point, typed config, constructor DI, service lifecycle, PyInstaller-safe paths.
|
|
5
|
+
Project-URL: Repository, https://github.com/Astislav/nexus
|
|
6
|
+
Author-email: Astislav Bozhevolnov <astislav@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: application,bootstrap,dependency-injection,di,framework
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: injector>=0.24.0
|
|
20
|
+
Requires-Dist: pydantic-settings>=2.13.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# nexus
|
|
24
|
+
|
|
25
|
+
[](https://github.com/Astislav/nexus/actions/workflows/ci.yml)
|
|
26
|
+
|
|
27
|
+
A minimal application kernel for long-lived Python apps: one entry point,
|
|
28
|
+
typed config, constructor DI, logger channels, service lifecycle — and paths
|
|
29
|
+
that survive PyInstaller.
|
|
30
|
+
|
|
31
|
+
PyPI dist **`nexus-kit`**, imports as **`nexus_kit`**, CLI command **`nexus-kit`**.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
If you build long-lived Python apps **outside a web framework's cradle** — a
|
|
36
|
+
Qt tool driving hardware, a pygame game, a Windows daemon, an API server
|
|
37
|
+
where uvicorn is just one service among many — you end up hand-rolling the
|
|
38
|
+
same bootstrap in every repo: an entry point, `.env` parsing, wiring services
|
|
39
|
+
together, logging setup, ordered start/stop, and the `sys._MEIPASS` dance for
|
|
40
|
+
frozen builds.
|
|
41
|
+
|
|
42
|
+
nexus is that bootstrap extracted once and turned into a convention. Every
|
|
43
|
+
app gets the same shape: `main.py` is four lines, config is a typed class,
|
|
44
|
+
services declare their dependencies in constructors, long-lived services
|
|
45
|
+
start in order and stop in reverse — guaranteed. Your fifth app looks like
|
|
46
|
+
your first, and anyone (human or AI assistant) who has seen one has seen
|
|
47
|
+
them all.
|
|
48
|
+
|
|
49
|
+
## Who it's for
|
|
50
|
+
|
|
51
|
+
- You ship Python as **PyInstaller executables** and are tired of path bugs
|
|
52
|
+
that only appear after freezing.
|
|
53
|
+
- You maintain **several apps** — web and not — and want them all shaped the
|
|
54
|
+
same instead of each inventing its own bootstrap.
|
|
55
|
+
- You want **constructor injection without magic**: one explicit
|
|
56
|
+
`{Interface: Implementation}` dict, no string keys, no globals, no
|
|
57
|
+
auto-scanning.
|
|
58
|
+
- Your app has **services that must start in order and stop cleanly** —
|
|
59
|
+
DB pools, pollers, device monitors, an embedded HTTP server.
|
|
60
|
+
|
|
61
|
+
## Who it's NOT for
|
|
62
|
+
|
|
63
|
+
- **Short scripts.** A module with functions is already dependency
|
|
64
|
+
injection. This would be ceremony.
|
|
65
|
+
- **Apps living happily inside FastAPI/Django conventions.** Their lifespan
|
|
66
|
+
and DI are enough; nexus solves the world outside that cradle.
|
|
67
|
+
- **Teams that want a mainstream stack.** This is an opinionated personal
|
|
68
|
+
kernel: conventions over ecosystem, no Stack Overflow answers.
|
|
69
|
+
|
|
70
|
+
## What it is, honestly
|
|
71
|
+
|
|
72
|
+
Opinionated glue — not invention. Config is stock
|
|
73
|
+
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/),
|
|
74
|
+
DI is stock [injector](https://injector.readthedocs.io/); nexus adds the
|
|
75
|
+
parts nobody packages: the `Root` path resolver for frozen builds, typed
|
|
76
|
+
logger channels, the `ServiceRunner` lifecycle, a scaffolder, and the
|
|
77
|
+
convention that ties them together. Extracted from real production apps
|
|
78
|
+
(a Qt device farm, a WhatsApp gateway, analytics services), not designed in
|
|
79
|
+
a vacuum.
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# uv
|
|
85
|
+
uv add nexus-kit
|
|
86
|
+
|
|
87
|
+
# pip
|
|
88
|
+
pip install nexus-kit
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires Python 3.12+. Ships with [injector](https://injector.readthedocs.io/) and
|
|
92
|
+
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) —
|
|
93
|
+
no extras, everything works out of the box.
|
|
94
|
+
|
|
95
|
+
## Bootstrap a new app
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
nexus-kit new my-app
|
|
99
|
+
cd my-app
|
|
100
|
+
|
|
101
|
+
# install dependencies:
|
|
102
|
+
uv sync # uv
|
|
103
|
+
pip install -e . # pip
|
|
104
|
+
|
|
105
|
+
python main.py
|
|
106
|
+
# [ticker] started (every 0.7s)
|
|
107
|
+
# [my-app] running for 3.0s — Ctrl+C to stop early
|
|
108
|
+
# tick #1
|
|
109
|
+
# tick #2
|
|
110
|
+
# tick #3
|
|
111
|
+
# tick #4
|
|
112
|
+
# [ticker] stopped after 4 ticks
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## What you get
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
my-app/
|
|
119
|
+
├── main.py # entry point — the whole bootstrap, 4 lines
|
|
120
|
+
├── pyproject.toml
|
|
121
|
+
├── .env
|
|
122
|
+
└── app/
|
|
123
|
+
├── application.py # SERVICES + ServiceRunner around the main loop
|
|
124
|
+
├── config/
|
|
125
|
+
│ ├── di.py # DI_CONFIG = {Interface: Implementation}
|
|
126
|
+
│ └── environment.py # typed fields read from .env
|
|
127
|
+
└── services/
|
|
128
|
+
├── ticker.py # worker thread with clean start/stop (ServiceInterface)
|
|
129
|
+
├── reporter_interface.py # a swappable seam
|
|
130
|
+
└── console_reporter.py # its default implementation
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## How it fits together
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# main.py — the whole bootstrap
|
|
137
|
+
env = Environment(Root.external(".env")) # 1. load typed config
|
|
138
|
+
container = ContainerInjector(DI_CONFIG) # 2. wire up services
|
|
139
|
+
container.set(Environment, env) # 3. make config injectable
|
|
140
|
+
Application(env, container).run() # 4. start the app
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| File | Role |
|
|
144
|
+
|------|------|
|
|
145
|
+
| `app/config/environment.py` | Declare config fields — read from `.env` automatically |
|
|
146
|
+
| `app/config/di.py` | Register services — `{Interface: Implementation}` |
|
|
147
|
+
| `app/application.py` | Entry point — resolve services, own the `run()` lifecycle |
|
|
148
|
+
|
|
149
|
+
## Environment
|
|
150
|
+
|
|
151
|
+
`EnvironmentInterface` is a [Pydantic BaseSettings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) subclass.
|
|
152
|
+
Add typed fields — they are read from `.env` automatically:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# app/config/environment.py
|
|
156
|
+
from nexus_kit.interfaces import EnvironmentInterface
|
|
157
|
+
|
|
158
|
+
class Environment(EnvironmentInterface):
|
|
159
|
+
APP_NAME: str = "my-app"
|
|
160
|
+
DEBUG: bool = False
|
|
161
|
+
DB_URL: str = "sqlite:///data.db"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`.env` is passed at startup via `Root.external(".env")` (see below):
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
env = Environment(Root.external(".env"))
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Fields can be overridden at runtime with environment variables — Pydantic picks them up automatically.
|
|
171
|
+
|
|
172
|
+
`Environment` is also bound into the container at startup, so services can inject it directly:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# main.py (generated by `nexus-kit new`)
|
|
176
|
+
env = Environment(Root.external(".env"))
|
|
177
|
+
container = ContainerInjector(DI_CONFIG)
|
|
178
|
+
container.set(Environment, env) # ← makes env injectable
|
|
179
|
+
Application(env, container).run()
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
This means any service can receive config via `@inject` without going through `Application`:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from injector import inject, singleton
|
|
186
|
+
from app.config.environment import Environment
|
|
187
|
+
|
|
188
|
+
@singleton
|
|
189
|
+
class DatabaseService:
|
|
190
|
+
@inject
|
|
191
|
+
def __init__(self, env: Environment) -> None:
|
|
192
|
+
self._url = env.DB_URL
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Paths
|
|
196
|
+
|
|
197
|
+
`Root` resolves paths correctly in both development and PyInstaller-bundled executables.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from nexus_kit import Root
|
|
201
|
+
|
|
202
|
+
# next to the .exe (or next to main.py in dev) — user data, configs, output
|
|
203
|
+
config = Root.external(".env")
|
|
204
|
+
db = Root.external("data", "app.db")
|
|
205
|
+
|
|
206
|
+
# inside the bundle (or next to main.py in dev) — shipped assets, templates
|
|
207
|
+
html = Root.internal("templates", "report.html")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Method | Dev (plain Python) | Bundled (PyInstaller) |
|
|
211
|
+
|--------|--------------------|-----------------------|
|
|
212
|
+
| `Root.external(...)` | `dir(main.py) / path` | `dir(exe) / path` |
|
|
213
|
+
| `Root.internal(...)` | `dir(main.py) / path` | `_MEIPASS / path` |
|
|
214
|
+
|
|
215
|
+
In dev the anchor is the entry script's directory (not the current working
|
|
216
|
+
directory), so launching `python d:/apps/game/main.py` from anywhere — an IDE,
|
|
217
|
+
a task scheduler, a shortcut — resolves the same paths as running it in place.
|
|
218
|
+
|
|
219
|
+
Use `external` for anything the user owns (configs, databases, output files).
|
|
220
|
+
Use `internal` for assets you ship inside the bundle (templates, images, default configs).
|
|
221
|
+
|
|
222
|
+
## Logging
|
|
223
|
+
|
|
224
|
+
`NamedLogger` is a base for typed, DI-injectable logger channels — subclass
|
|
225
|
+
it, set `name`, and inject the subclass by type. No string-keyed
|
|
226
|
+
`logging.getLogger(...)` calls scattered through the codebase:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
# app/loggers.py
|
|
230
|
+
from injector import singleton
|
|
231
|
+
from nexus_kit.logging import NamedLogger
|
|
232
|
+
|
|
233
|
+
@singleton
|
|
234
|
+
class SessionLogger(NamedLogger):
|
|
235
|
+
name = "app.session"
|
|
236
|
+
|
|
237
|
+
@singleton
|
|
238
|
+
class SenderLogger(NamedLogger):
|
|
239
|
+
name = "app.sender"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# app/core/session_manager.py
|
|
244
|
+
from injector import inject, singleton
|
|
245
|
+
from app.loggers import SessionLogger
|
|
246
|
+
|
|
247
|
+
@singleton
|
|
248
|
+
class SessionManager:
|
|
249
|
+
@inject
|
|
250
|
+
def __init__(self, log: SessionLogger) -> None:
|
|
251
|
+
self._log = log
|
|
252
|
+
|
|
253
|
+
def start(self) -> None:
|
|
254
|
+
self._log.info("Session manager started")
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Each subclass gets its own `StdoutHandler` (console, one shared instance)
|
|
258
|
+
wired up automatically — no duplicate-handler bugs, no manual `addHandler`.
|
|
259
|
+
|
|
260
|
+
**Custom format** — *where* logs go (`StdoutHandler`) and *how they look*
|
|
261
|
+
(`LogFormatter`) are separate, like in stdlib `logging`. Subclass
|
|
262
|
+
`LogFormatter` and rebind it — no need to touch the handler:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
# app/loggers.py
|
|
266
|
+
from nexus_kit.logging import LogFormatter
|
|
267
|
+
|
|
268
|
+
class JsonFormatter(LogFormatter):
|
|
269
|
+
format_string = '{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}'
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
# app/config/di.py
|
|
274
|
+
DI_CONFIG = {
|
|
275
|
+
LogFormatter: JsonFormatter,
|
|
276
|
+
...
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Extra handlers** (e.g. forwarding logs to a UI widget) — override `__init__`
|
|
281
|
+
and add the handler after calling `super().__init__(handler)`:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
@singleton
|
|
285
|
+
class SessionLogger(NamedLogger):
|
|
286
|
+
name = "app.session"
|
|
287
|
+
|
|
288
|
+
@inject
|
|
289
|
+
def __init__(self, handler: StdoutHandler, ui_handler: LogViewHandler) -> None:
|
|
290
|
+
super().__init__(handler)
|
|
291
|
+
self.addHandler(ui_handler)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Services & lifecycle
|
|
295
|
+
|
|
296
|
+
`ServiceInterface` + `ServiceRunner` manage long-lived services: started in
|
|
297
|
+
declaration order, stopped in reverse — guaranteed, even when startup or the
|
|
298
|
+
app body crashes.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# a service — sync or async, the runner handles both
|
|
302
|
+
from injector import singleton
|
|
303
|
+
from nexus_kit.interfaces import ServiceInterface
|
|
304
|
+
|
|
305
|
+
@singleton
|
|
306
|
+
class Database(ServiceInterface):
|
|
307
|
+
async def start(self) -> None: ... # open the pool
|
|
308
|
+
async def stop(self) -> None: ... # close the pool (must be idempotent)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
# app/application.py — async app (uvicorn, workers)
|
|
313
|
+
from nexus_kit.impl import ServiceRunner
|
|
314
|
+
|
|
315
|
+
class Application(ApplicationInterface):
|
|
316
|
+
SERVICES = [Database, WebhookDispatcher, HttpApiService] # startup order
|
|
317
|
+
|
|
318
|
+
def run(self) -> None:
|
|
319
|
+
asyncio.run(self._serve())
|
|
320
|
+
|
|
321
|
+
async def _serve(self) -> None:
|
|
322
|
+
async with ServiceRunner(self._container, self.SERVICES):
|
|
323
|
+
await self._container.get(HttpApiService).wait()
|
|
324
|
+
# leaving the block stops everything in reverse order
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Sync apps (pygame, Qt with worker threads) use the plain context manager:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
def run(self) -> None:
|
|
331
|
+
with ServiceRunner(self._container, self.SERVICES):
|
|
332
|
+
self._main_loop()
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Guarantees:
|
|
336
|
+
|
|
337
|
+
- start in order, stop in reverse — on normal exit, exception, Ctrl+C;
|
|
338
|
+
- crash-safe startup: if the N-th `start()` fails, the already started N-1
|
|
339
|
+
are stopped in reverse and the error re-raises;
|
|
340
|
+
- one failing `stop()` doesn't block the rest — it is logged and teardown
|
|
341
|
+
continues;
|
|
342
|
+
- in the async context each `stop()` is bounded by `stop_grace` seconds
|
|
343
|
+
(default 10), then cancelled.
|
|
344
|
+
|
|
345
|
+
The runner installs **no signal handlers** — who triggers the exit is your
|
|
346
|
+
app's business (uvicorn's own handlers, Qt's `aboutToQuit`, or your own).
|
|
347
|
+
|
|
348
|
+
## Add a service
|
|
349
|
+
|
|
350
|
+
**1. Define an interface (a swappable seam):**
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
# app/services/reporter_interface.py
|
|
354
|
+
from abc import ABC, abstractmethod
|
|
355
|
+
|
|
356
|
+
class ReporterInterface(ABC):
|
|
357
|
+
@abstractmethod
|
|
358
|
+
def report(self, tick: int) -> None: ...
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**2. Implement it:**
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
# app/services/console_reporter.py
|
|
365
|
+
from injector import singleton
|
|
366
|
+
from app.services.reporter_interface import ReporterInterface
|
|
367
|
+
|
|
368
|
+
@singleton
|
|
369
|
+
class ConsoleReporter(ReporterInterface):
|
|
370
|
+
def report(self, tick: int) -> None:
|
|
371
|
+
print(f"tick #{tick}")
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**3. Register in DI:**
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
# app/config/di.py
|
|
378
|
+
from app.services.console_reporter import ConsoleReporter
|
|
379
|
+
from app.services.reporter_interface import ReporterInterface
|
|
380
|
+
|
|
381
|
+
DI_CONFIG = {
|
|
382
|
+
ReporterInterface: ConsoleReporter,
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**4. Inject it — by type, into a constructor, no string keys:**
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
# app/services/ticker.py
|
|
390
|
+
from injector import inject, singleton
|
|
391
|
+
from nexus_kit.interfaces import ServiceInterface
|
|
392
|
+
|
|
393
|
+
@singleton
|
|
394
|
+
class Ticker(ServiceInterface):
|
|
395
|
+
@inject
|
|
396
|
+
def __init__(self, env: Environment, reporter: ReporterInterface) -> None:
|
|
397
|
+
self._interval = env.TICK_SECONDS
|
|
398
|
+
self._reporter = reporter
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Swapping `ConsoleReporter` for a file writer, an HTTP pusher or a Qt widget
|
|
402
|
+
is a one-line change in `DI_CONFIG` — nothing else moves.
|
|
403
|
+
|
|
404
|
+
## What nexus provides
|
|
405
|
+
|
|
406
|
+
| Symbol | Import | Description |
|
|
407
|
+
|--------|--------|-------------|
|
|
408
|
+
| `ApplicationInterface` | `nexus_kit.interfaces` | Bootstrap contract: `__init__(env, container)` + `run()` |
|
|
409
|
+
| `ContainerInterface` | `nexus_kit.interfaces` | DI contract: `get(cls)` + `set(cls, value)` |
|
|
410
|
+
| `EnvironmentInterface` | `nexus_kit.interfaces` | Typed config base (Pydantic BaseSettings) |
|
|
411
|
+
| `ServiceInterface` | `nexus_kit.interfaces` | Long-lived service contract: `start()` + `stop()`, sync or async |
|
|
412
|
+
| `Root` | `nexus` | Path util for dev and PyInstaller-bundled environments |
|
|
413
|
+
| `ContainerInjector` | `nexus_kit.impl` | `ContainerInterface` impl via [injector](https://injector.readthedocs.io/) |
|
|
414
|
+
| `ServiceRunner` | `nexus_kit.impl` | Ordered start / guaranteed reverse-order stop (`with` / `async with`) |
|
|
415
|
+
| `NamedLogger` | `nexus_kit.logging` | Base for typed, DI-injectable logger channels |
|
|
416
|
+
| `StdoutHandler` | `nexus_kit.logging` | Shared console handler — *where* logs go |
|
|
417
|
+
| `LogFormatter` | `nexus_kit.logging` | Default log line format — *how* logs look; subclass to customize |
|
|
418
|
+
|
|
419
|
+
## What nexus does NOT provide
|
|
420
|
+
|
|
421
|
+
Domain logic, UI, data access — those belong in your app.
|
|
422
|
+
|
|
423
|
+
## License
|
|
424
|
+
|
|
425
|
+
MIT © Astislav Bozhevolnov
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
nexus_kit/__init__.py,sha256=-1KM26cOcLWd5Ek3hVctw8ISWLbbCzPAeFPdJERPn-g,335
|
|
2
|
+
nexus_kit/cli.py,sha256=pZ4-Zj_eh_l0c8RBXJNUWP_o0aKBwf5OH4pkN_Ov49I,10316
|
|
3
|
+
nexus_kit/root.py,sha256=kwI-XvBEJkTTfEwlyqqtvG0Gn8ka2Xlv1naqAhx9B4E,1096
|
|
4
|
+
nexus_kit/impl/__init__.py,sha256=3POHfUhY1BNJIX8t_Ya8JLJNCDPdwvQWl2WKBu8FYWc,170
|
|
5
|
+
nexus_kit/impl/container_injector.py,sha256=-qel07QA9dwSzRTxyupNtWW7SYjEYJl1hYY271Ze_Ws,757
|
|
6
|
+
nexus_kit/impl/service_runner.py,sha256=SIz9ZbYXcGuPdjk0ePjyNoMUZKY4l2qgW8gBln2Tg_I,4729
|
|
7
|
+
nexus_kit/interfaces/__init__.py,sha256=sGu_V7nFWHTu5lbPswL8citGQpxKCAWxhVYvjZBDUiU,373
|
|
8
|
+
nexus_kit/interfaces/application.py,sha256=Gtonr8WSf6KdvJQsMFLryzcdQbN8DKQpy2C-JwEENkA,412
|
|
9
|
+
nexus_kit/interfaces/container.py,sha256=ccAHftSrWVhQiPe1mSVPogm_vHw5G7uQgAC1m0Y1GQA,284
|
|
10
|
+
nexus_kit/interfaces/environment.py,sha256=VGZ_p_96OjV72hC3SNHvJl8zvyR4CCn6Hhfgkhm8gy0,489
|
|
11
|
+
nexus_kit/interfaces/service.py,sha256=zQ5GmvKeSiHcbGkPoXIBaaSlI5ov3zXSBYFMZVVO58E,539
|
|
12
|
+
nexus_kit/logging/__init__.py,sha256=1I8RGmoNsSG9Dhi2VA950DikkQzvHsLv5LI182n_5ZM,231
|
|
13
|
+
nexus_kit/logging/log_formatter.py,sha256=bECiDsCwDmPmOCDzB6sU7MP7lemXPMQdiYcZsSzT97o,711
|
|
14
|
+
nexus_kit/logging/named_logger.py,sha256=OJckDJTccokSqVH8kR20UCpVO-d6h4jILXQRb9bdDyU,1487
|
|
15
|
+
nexus_kit/logging/stdout_handler.py,sha256=vAs07ERztc6l7emv-IYYXdgYdtQkkZo3Z9nwFq8Zp7A,421
|
|
16
|
+
nexus_kit-0.4.0.dist-info/METADATA,sha256=OfOyuYliu0NT0JpGQPfg5PelmElm9cLF5xcO4JKl4vQ,14258
|
|
17
|
+
nexus_kit-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
18
|
+
nexus_kit-0.4.0.dist-info/entry_points.txt,sha256=3S_rWsts3k29bGQLbGURdHe_N7ikKpZ4UhToUJYxLyc,49
|
|
19
|
+
nexus_kit-0.4.0.dist-info/licenses/LICENSE,sha256=bm7WZS8juoGVn6UIVyZC3mx0lSbqoyJzt3iCLTkMpE4,1077
|
|
20
|
+
nexus_kit-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Astislav Bozhevolnov
|
|
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.
|