Application kernel for long-lived Python apps: one entry point, typed config, constructor DI, service lifecycle, PyInstaller-safe paths.
Project description
nexus-kit
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 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:
# 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 bystop_graceseconds (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-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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nexus_kit-0.4.2.tar.gz.
File metadata
- Download URL: nexus_kit-0.4.2.tar.gz
- Upload date:
- Size: 18.9 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f38f9bda6ce132ab5fd4f7afadfcf8c9589fd93f6b482afb22eae10e8e54ca2f
|
|
| MD5 |
7ba8ac403ff956dcc2547032e8ef52e6
|
|
| BLAKE2b-256 |
fcc770212fb20b63695ede2302ea54162770fcbd5bddf858f12e19a261a2a8f7
|
File details
Details for the file nexus_kit-0.4.2-py3-none-any.whl.
File metadata
- Download URL: nexus_kit-0.4.2-py3-none-any.whl
- Upload date:
- Size: 18.7 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
840c2b8c07d4dd5aa48224d3c21941f260932ba669ff705f11b1db9d6433d888
|
|
| MD5 |
6389731978246877ad2a57faf6b335ff
|
|
| BLAKE2b-256 |
2db256658ec2583f5afa4bbe2852b22095c61b2bdc5532fb137933f8bfdecaaf
|