Skip to main content

Application kernel for long-lived Python apps: one entry point, typed config, constructor DI, service lifecycle, PyInstaller-safe paths.

Project description

nexus

ci

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.

PyPI dist nexus-kit, imports as nexus_kit, CLI command nexus-kit.

Why

If you build long-lived Python apps outside a web framework's cradle — a Qt tool driving hardware, a pygame game, a Windows daemon, an API server where uvicorn is just one service among many — you end up hand-rolling the same bootstrap in every repo: an entry point, .env parsing, wiring services together, logging setup, ordered start/stop, and the sys._MEIPASS dance for frozen builds.

nexus is that bootstrap extracted once and turned into a convention. Every app gets the same shape: main.py is four lines, config is a typed class, services declare their dependencies in constructors, long-lived services start in order and stop in reverse — guaranteed. Your fifth app looks like your first, and anyone (human or AI assistant) who has seen one has seen them all.

Who it's for

  • You ship Python as PyInstaller executables and are tired of path bugs that only appear after freezing.
  • You maintain several apps — web and not — and want them all shaped the same instead of each inventing its own bootstrap.
  • You want constructor injection without magic: one explicit {Interface: Implementation} dict, no string keys, no globals, no auto-scanning.
  • Your app has services that must start in order and stop cleanly — DB pools, pollers, device monitors, an embedded HTTP server.

Who it's NOT for

  • Short scripts. A module with functions is already dependency injection. This would be ceremony.
  • Apps living happily inside FastAPI/Django conventions. Their lifespan and DI are enough; nexus solves the world outside that cradle.
  • Teams that want a mainstream stack. This is an opinionated personal kernel: conventions over ecosystem, no Stack Overflow answers.

What it is, honestly

Opinionated glue — not invention. Config is stock pydantic-settings, DI is stock injector; nexus adds the parts nobody packages: the Root path resolver for frozen builds, typed logger channels, the ServiceRunner lifecycle, a scaffolder, and the convention that ties them together. Extracted from real production apps (a Qt device farm, a WhatsApp gateway, analytics services), not designed in a vacuum.

Install

# uv
uv add nexus-kit

# pip
pip install nexus-kit

Requires Python 3.12+. Ships with injector and pydantic-settings — no extras, everything works out of the box.

Bootstrap a new app

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

What you get

my-app/
├── main.py                          # entry point — the whole bootstrap, 4 lines
├── pyproject.toml
├── .env
└── 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

# 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 subclass. Add typed fields — they are read from .env automatically:

# 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):

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:

# 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:

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.

from nexus_kit import Root

# next to the .exe (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(exe) / 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).

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:

# 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"
# 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 formatwhere 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:

# 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"}'
# 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):

@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.

# 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)
# 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:

    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, 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 stop() is bounded by stop_grace seconds (default 10), then cancelled.

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):

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

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

2. Implement it:

# 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:

# 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:

# 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 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 Path util for dev and PyInstaller-bundled environments
ContainerInjector nexus_kit.impl ContainerInterface impl via injector
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 does NOT provide

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

License

MIT © Astislav Bozhevolnov

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nexus_kit-0.4.0.tar.gz (18.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nexus_kit-0.4.0-py3-none-any.whl (18.5 kB view details)

Uploaded Python 3

File details

Details for the file nexus_kit-0.4.0.tar.gz.

File metadata

  • Download URL: nexus_kit-0.4.0.tar.gz
  • Upload date:
  • Size: 18.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for nexus_kit-0.4.0.tar.gz
Algorithm Hash digest
SHA256 bb2fa1b2b4067d0cff8229f0c4989d4d03cd510062d8ae04893326af8c3d73dc
MD5 eaef7774304b919dc0907aa6c1a23566
BLAKE2b-256 967ae46b9fb63a445a4cf3e385e8e8cd2ed65f4ce60ed2d52bf8b52071535a3f

See more details on using hashes here.

File details

Details for the file nexus_kit-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: nexus_kit-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 18.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for nexus_kit-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cbd672f99e821987d759296b86dd6c71616aa20bbb672e19add8c201aed714d6
MD5 4c0f3cb29f0f9ed80c92830c23ffc966
BLAKE2b-256 39188dd1cf8b7c4f5d79440a5343d5ccda9fc2c4cb64d5d649930254c8b276fb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page