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 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,4 @@
1
+ from nexus_kit.impl.container_injector import ContainerInjector
2
+ from nexus_kit.impl.service_runner import ServiceRunner
3
+
4
+ __all__ = ["ContainerInjector", "ServiceRunner"]
@@ -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,5 @@
1
+ from nexus_kit.logging.log_formatter import LogFormatter
2
+ from nexus_kit.logging.named_logger import NamedLogger
3
+ from nexus_kit.logging.stdout_handler import StdoutHandler
4
+
5
+ __all__ = ["LogFormatter", "NamedLogger", "StdoutHandler"]
@@ -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
+ [![ci](https://github.com/Astislav/nexus/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nexus-kit = nexus_kit.cli:main
@@ -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.