Metadata-Version: 2.4
Name: nexus-kit
Version: 0.5.2
Summary: Application kernel for long-lived Python apps: one entry point, typed config, constructor DI, service lifecycle, PyInstaller-safe paths.
Project-URL: Homepage, https://github.com/Astislav/nexus
Project-URL: Repository, https://github.com/Astislav/nexus
Project-URL: Issues, https://github.com/Astislav/nexus/issues
Project-URL: Changelog, https://github.com/Astislav/nexus/blob/master/nexus-kit/CHANGELOG.md
Author-email: Astislav Bozhevolnov <astislav@gmail.com>
License: MIT
License-File: LICENSE
Keywords: application,bootstrap,dependency-injection,di,framework
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.12
Requires-Dist: injector>=0.24.0
Requires-Dist: pydantic-settings>=2.14.2
Description-Content-Type: text/markdown

# nexus-kit

[![PyPI](https://img.shields.io/pypi/v/nexus-kit)](https://pypi.org/project/nexus-kit/)
[![Python](https://img.shields.io/pypi/pyversions/nexus-kit)](https://pypi.org/project/nexus-kit/)
[![CI](https://github.com/Astislav/nexus/actions/workflows/ci.yml/badge.svg)](https://github.com/Astislav/nexus/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/Astislav/nexus/blob/master/LICENSE)

A minimal application kernel for long-lived Python apps: one entry point,
typed config, constructor DI, logger channels, service lifecycle — and paths
that survive PyInstaller.

Install [`nexus-kit`](https://pypi.org/project/nexus-kit/), import `nexus_kit`.

[Source on GitHub](https://github.com/Astislav/nexus) ·
[Issues](https://github.com/Astislav/nexus/issues) ·
[Releases](https://github.com/Astislav/nexus/releases)

**Built for composite, long-lived apps**: a Qt desk driving hardware, a
pygame game, a daemon, a server where HTTP is just one service among
workers, sockets and a device fleet. Every app gets the same shape —
four-line `main.py`, typed `.env` config, one `DI_CONFIG` dict, services
started in order and stopped in reverse (guaranteed), and a `freeze`/
`build` path to a shippable executable. *Not* for short scripts (a module
with functions is already DI) and not for apps living happily inside
FastAPI/Django conventions.

**Why this exists, who it's for, and the honest “is this even pythonic?”
conversation → [the repository landing
page](https://github.com/Astislav/nexus#readme).** This page is the
kernel reference: install, bootstrap, and every contract.

## Install

```bash
# uv
uv add nexus-kit

# pip
pip install nexus-kit
```

Requires Python 3.12+. Ships with [injector](https://injector.readthedocs.io/) and
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) —
no extras, everything works out of the box.

## Bootstrap a new app

```bash
nexus-kit new my-app
cd my-app

# install dependencies:
uv sync          # uv
pip install -e . # pip

python main.py
# [ticker] started (every 0.7s)
# [my-app] running for 3.0s — Ctrl+C to stop early
# tick #1
# tick #2
# tick #3
# tick #4
# [ticker] stopped after 4 ticks
```

And when it's time to ship it as a single executable:

```bash
nexus-kit freeze          # once: generate app.spec
nexus-kit build           # every release: clean build → dist/my-app  (.exe on Windows)
```

See [Freezing your app](#freezing-your-app-pyinstaller) for what goes
inside the executable vs next to it.

## What you get

```
my-app/
├── main.py                          # entry point — the whole bootstrap, 4 lines
├── pyproject.toml
├── .env                             # local config (gitignored, never ships by default)
├── .env.example                     # operator template — `build` ships it next to the executable
└── app/
    ├── application.py               # SERVICES + ServiceRunner around the main loop
    ├── config/
    │   ├── di.py                    # DI_CONFIG = {Interface: Implementation}
    │   └── environment.py           # typed fields read from .env
    └── services/
        ├── ticker.py                # worker thread with clean start/stop (ServiceInterface)
        ├── reporter_interface.py    # a swappable seam
        └── console_reporter.py     # its default implementation
```

## How it fits together

```python
# main.py — the whole bootstrap
env       = Environment(Root.external(".env"))  # 1. load typed config
container = ContainerInjector(DI_CONFIG)         # 2. wire up services
container.set(Environment, env)                  # 3. make config injectable
Application(env, container).run()                # 4. start the app
```

| File | Role |
|------|------|
| `app/config/environment.py` | Declare config fields — read from `.env` automatically |
| `app/config/di.py` | Register services — `{Interface: Implementation}` |
| `app/application.py` | Entry point — resolve services, own the `run()` lifecycle |

## Environment

`EnvironmentInterface` is a [Pydantic BaseSettings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) subclass.
Add typed fields — they are read from `.env` automatically:

```python
# app/config/environment.py
from nexus_kit.interfaces import EnvironmentInterface

class Environment(EnvironmentInterface):
    APP_NAME: str = "my-app"
    DEBUG: bool = False
    DB_URL: str = "sqlite:///data.db"
```

`.env` is passed at startup via `Root.external(".env")` (see below):

```python
env = Environment(Root.external(".env"))
```

Fields can be overridden at runtime with environment variables — Pydantic picks them up automatically.

`Environment` is also bound into the container at startup, so services can inject it directly:

```python
# main.py (generated by `nexus-kit new`)
env = Environment(Root.external(".env"))
container = ContainerInjector(DI_CONFIG)
container.set(Environment, env)   # ← makes env injectable
Application(env, container).run()
```

This means any service can receive config via `@inject` without going through `Application`:

```python
from injector import inject, singleton
from app.config.environment import Environment

@singleton
class DatabaseService:
    @inject
    def __init__(self, env: Environment) -> None:
        self._url = env.DB_URL
```

## Paths

`Root` resolves paths correctly in both development and PyInstaller-bundled executables.

```python
from nexus_kit import Root

# next to the executable (or next to main.py in dev) — user data, configs, output
config = Root.external(".env")
db     = Root.external("data", "app.db")

# inside the bundle (or next to main.py in dev) — shipped assets, templates
html   = Root.internal("templates", "report.html")
```

| Method | Dev (plain Python) | Bundled (PyInstaller) |
|--------|--------------------|-----------------------|
| `Root.external(...)` | `dir(main.py) / path` | `dir(executable) / path` |
| `Root.internal(...)` | `dir(main.py) / path` | `_MEIPASS / path` |

In dev the anchor is the entry script's directory (not the current working
directory), so launching `python d:/apps/game/main.py` from anywhere — an IDE,
a task scheduler, a shortcut — resolves the same paths as running it in place.

Use `external` for anything the user owns (configs, databases, output files).
Use `internal` for assets you ship inside the bundle (templates, images, default configs).

## Freezing your app (PyInstaller)

`Root` is one half of the packaging story; the CLI is the other:

```bash
cd my-app
nexus-kit freeze          # once: generate app.spec (executable name = directory name)
nexus-kit build           # every release: clean build → dist/my-app  (.exe on Windows)
```

- **`freeze`** generates **`app.spec`** — with a `BUNDLED` list for data you
  ship *inside* the executable (read via `Root.internal(...)`) — and fixes
  `.gitignore`. The spec is source: commit it, grow its `BUNDLED` and
  `hiddenimports` lists as your app grows.
- **`build`** cleans `build/`+`dist/`, runs PyInstaller, then copies the
  EXTERNAL files *next to* the binary — where `Root.external(...)` looks in
  a frozen build: `resources/` (if present) and `.env.example` as an
  operator template. Your real **`.env` never ships by default** — use
  `nexus-kit build --env` to ship it deliberately (appliance-style deploys).

One command, every platform — no `.bat`/`.sh` to keep in sync.
Reproducibility: add PyInstaller to your dev group (`uv add --dev
pyinstaller`) so `uv.lock` pins its exact version; without it, `build`
falls back to `uv run --with "pyinstaller>=6,<7"`. Frozen targets need
Windows 10+ or any modern Linux/macOS (the Python 3.12 floor). The whole
path — scaffold → freeze → build → run the executable with `.env` beside
it — is exercised by this repo's CI on Windows, Linux and macOS on every
push.

### Deployment is an artifact, not a pipeline

`--env` is not an escape hatch — for a whole class of software it is the
point. The automation you (or your AI assistant) built in an afternoon
and want to hand to the team *today*, without knowing whether it will
stick: the right amount of deploy infrastructure for that bet is zero.
`nexus-kit build --env` → `dist/` holds the executable plus its config →
zip it, send it. No Docker, no registry, no pipeline; the machine running
it doesn't even need Python. (`--env` ships real secrets — for machines
you'd trust with them anyway; the default ships the scaffolded
`.env.example` instead.) If the tool takes root, graduate deliberately:
pin PyInstaller, add CI. If it dies, you delete a folder — not a
deployment.

Honest scope: this is handover distribution, not fleet management — no
code signing/notarization, no auto-updates, no rollback, and PyInstaller
builds are per-OS (build on the platform you target).

## Logging

`NamedLogger` is a base for typed, DI-injectable logger channels — subclass
it, set `name`, and inject the subclass by type. No string-keyed
`logging.getLogger(...)` calls scattered through the codebase:

```python
# app/loggers.py
from injector import singleton
from nexus_kit.logging import NamedLogger

@singleton
class SessionLogger(NamedLogger):
    name = "app.session"

@singleton
class SenderLogger(NamedLogger):
    name = "app.sender"
```

```python
# app/core/session_manager.py
from injector import inject, singleton
from app.loggers import SessionLogger

@singleton
class SessionManager:
    @inject
    def __init__(self, log: SessionLogger) -> None:
        self._log = log

    def start(self) -> None:
        self._log.info("Session manager started")
```

Each subclass gets its own `StdoutHandler` (console, one shared instance)
wired up automatically — no duplicate-handler bugs, no manual `addHandler`.

**Custom format** — *where* logs go (`StdoutHandler`) and *how they look*
(`LogFormatter`) are separate, like in stdlib `logging`. Subclass
`LogFormatter` and rebind it — no need to touch the handler:

```python
# app/loggers.py
from nexus_kit.logging import LogFormatter

class JsonFormatter(LogFormatter):
    format_string = '{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}'
```

```python
# app/config/di.py
DI_CONFIG = {
    LogFormatter: JsonFormatter,
    ...
}
```

**Extra handlers** (e.g. forwarding logs to a UI widget) — override `__init__`
and add the handler after calling `super().__init__(handler)`:

```python
@singleton
class SessionLogger(NamedLogger):
    name = "app.session"

    @inject
    def __init__(self, handler: StdoutHandler, ui_handler: LogViewHandler) -> None:
        super().__init__(handler)
        self.addHandler(ui_handler)
```

## Services & lifecycle

`ServiceInterface` + `ServiceRunner` manage long-lived services: started in
declaration order, stopped in reverse — guaranteed, even when startup or the
app body crashes.

```python
# a service — sync or async, the runner handles both
from injector import singleton
from nexus_kit.interfaces import ServiceInterface

@singleton
class Database(ServiceInterface):
    async def start(self) -> None: ...   # open the pool
    async def stop(self) -> None: ...    # close the pool (must be idempotent)
```

```python
# app/application.py — async app (uvicorn, workers)
from nexus_kit.impl import ServiceRunner

class Application(ApplicationInterface):
    SERVICES = [Database, WebhookDispatcher, HttpApiService]  # startup order

    def run(self) -> None:
        asyncio.run(self._serve())

    async def _serve(self) -> None:
        async with ServiceRunner(self._container, self.SERVICES):
            await self._container.get(HttpApiService).wait()
        # leaving the block stops everything in reverse order
```

Sync apps (pygame, Qt with worker threads) use the plain context manager:

```python
    def run(self) -> None:
        with ServiceRunner(self._container, self.SERVICES):
            self._main_loop()
```

Guarantees:

- start in order, stop in reverse — on normal exit, exception, Ctrl+C;
- crash-safe startup: if the N-th `start()` fails, that service's own
  `stop()` is still called (write `stop()` to tolerate a partially
  initialized state), then the already started N-1 are stopped in reverse
  and the error re-raises;
- one failing `stop()` doesn't block the rest — it is logged and teardown
  continues;
- in the async context each **async** `stop()` is bounded by `stop_grace`
  seconds (default 10), then cancelled; a **sync** `stop()` runs inline and
  is not bounded — offloading it to a thread would break thread-affine
  teardown (Qt, COM).

The runner installs **no signal handlers** — who triggers the exit is your
app's business (uvicorn's own handlers, Qt's `aboutToQuit`, or your own).

## Add a service

**1. Define an interface (a swappable seam):**

```python
# app/services/reporter_interface.py
from abc import ABC, abstractmethod

class ReporterInterface(ABC):
    @abstractmethod
    def report(self, tick: int) -> None: ...
```

**2. Implement it:**

```python
# app/services/console_reporter.py
from injector import singleton
from app.services.reporter_interface import ReporterInterface

@singleton
class ConsoleReporter(ReporterInterface):
    def report(self, tick: int) -> None:
        print(f"tick #{tick}")
```

**3. Register in DI:**

```python
# app/config/di.py
from app.services.console_reporter import ConsoleReporter
from app.services.reporter_interface import ReporterInterface

DI_CONFIG = {
    ReporterInterface: ConsoleReporter,
}
```

**4. Inject it — by type, into a constructor, no string keys:**

```python
# app/services/ticker.py
from injector import inject, singleton
from nexus_kit.interfaces import ServiceInterface

@singleton
class Ticker(ServiceInterface):
    @inject
    def __init__(self, env: Environment, reporter: ReporterInterface) -> None:
        self._interval = env.TICK_SECONDS
        self._reporter = reporter
```

Swapping `ConsoleReporter` for a file writer, an HTTP pusher or a Qt widget
is a one-line change in `DI_CONFIG` — nothing else moves.

## What nexus-kit provides

| Symbol | Import | Description |
|--------|--------|-------------|
| `ApplicationInterface` | `nexus_kit.interfaces` | Bootstrap contract: `__init__(env, container)` + `run()` |
| `ContainerInterface` | `nexus_kit.interfaces` | DI contract: `get(cls)` + `set(cls, value)` |
| `EnvironmentInterface` | `nexus_kit.interfaces` | Typed config base (Pydantic BaseSettings) |
| `ServiceInterface` | `nexus_kit.interfaces` | Long-lived service contract: `start()` + `stop()`, sync or async |
| `Root` | `nexus_kit` | Path util for dev and PyInstaller-bundled environments |
| `ContainerInjector` | `nexus_kit.impl` | `ContainerInterface` impl via [injector](https://injector.readthedocs.io/) |
| `ServiceRunner` | `nexus_kit.impl` | Ordered start / guaranteed reverse-order stop (`with` / `async with`) |
| `NamedLogger` | `nexus_kit.logging` | Base for typed, DI-injectable logger channels |
| `StdoutHandler` | `nexus_kit.logging` | Shared console handler — *where* logs go |
| `LogFormatter` | `nexus_kit.logging` | Default log line format — *how* logs look; subclass to customize |

## What nexus-kit does NOT provide

Domain logic, UI, data access — those belong in your app.

## For AI assistants

The full framework guide ships inside the wheel:
[`.ai/guide.md`](https://github.com/Astislav/nexus/blob/master/nexus-kit/.ai/guide.md)
— API, conventions, lifecycle guarantees, what NOT to do. In a consumer app it is
not read from the repo; it lives in a local **atlas** under `.nexus-kit/`:

- `.nexus-kit/map.md` — a small, always-on index: one line per package with a
  *read-this-when* cue and a pointer to its full guide.
- `.nexus-kit/guides/<pkg>.md` — the full guide per package, read **on demand**.

Mount only the map in your own AGENTS.md — a plain `Read .nexus-kit/map.md`
instruction that works in any agent (the standard AGENTS.md convention, no editor
lock-in). Progressive disclosure, the same shape as Skills: more satellites don't
bloat the context. `nexus-kit new` sets the mount up for fresh apps.

## Keeping the AI guides current — and staying in control

The atlas is (re)built by a command **you** run, deliberately:

```bash
uv run nexus-kit update-ai-guides   # after adding, upgrading or removing a nexus-kit package
```

It reads the guide of each allowlisted nexus-kit package installed in your
`.venv` and rewrites `.nexus-kit/`. Why *you* run it, and why that matters:

- **Nothing runs it automatically**, and nothing your agent reads — a guide, the
  map, AGENTS.md — tells your agent to run it. So the set of instructions your
  agent reads changes only when you decide. (The tooling never edits your
  AGENTS.md, and never touches CLAUDE.md or any editor-specific file.)
- **It's committed and diff-reviewable.** A guide's text lands in your agent
  verbatim — the atlas is a delivery channel, not a filter. Because `.nexus-kit/`
  is committed, every change lands in a diff you can review (new guidance, or
  anything threatening). The command writes the atlas in place, so nothing forces
  review before your agent next reads it — the guard is that it changes only when
  you run the command.
- **The allowlist limits which sources are read.** `update-ai-guides` reads a
  guide only from packages whose dist name is in `_ALLOWED_GUIDE_PACKAGES` (baked
  into the kernel), so an unrelated or rogue `nexus-kit-evil` package's guide is
  never assembled. That is about *sources*, not safety — any allowlisted guide is
  injected as-is, and installing any package already runs its code, so vet
  dependencies like any code.
- **CI**: `uv run nexus-kit update-ai-guides --check` fails if `.nexus-kit/` is
  stale, without writing anything. (`guides` is a short alias for the command.)

## License

MIT © Astislav Bozhevolnov
