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-kit

PyPI Python CI License: MIT

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, import nexus_kit.

Source on GitHub · Issues · Releases

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

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

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.3.tar.gz (20.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.3-py3-none-any.whl (19.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: nexus_kit-0.4.3.tar.gz
  • Upload date:
  • Size: 20.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.3.tar.gz
Algorithm Hash digest
SHA256 3028e8b9607e20221fdc7bb7d6964142a7addc7906763a45b93f685b9457df0f
MD5 5acc8c5811c9e2c9f2a8789ff1db3e5d
BLAKE2b-256 3daa718e64057e745f7c1542b6cc61a5db1562b7af6210b04ca18580b38a8606

See more details on using hashes here.

File details

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

File metadata

  • Download URL: nexus_kit-0.4.3-py3-none-any.whl
  • Upload date:
  • Size: 19.1 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.3-py3-none-any.whl
Algorithm Hash digest
SHA256 df7c5b6bba1b25103d6f9f5b647671e39cfdc9bab5674715035027db2ed2d201
MD5 14cc64bcdc9495bcdde894a6f713eef7
BLAKE2b-256 557f77878023c3c1f4506fe2d9863972dd6af6a0467b93b0a1a517932ce50408

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