A framework-agnostic settings loader for Litestar apps: composable sources, deterministic merge order, zero hidden precedence.
Project description
litestar-settings
Typed settings for Litestar apps, built on msgspec.Struct - like
pydantic-settings, but msgspec instead of Pydantic.
Features
- Framework-agnostic core.
litestar_settings.corehas no dependency on Litestar; a settings schema is just amsgspec.Struct. - Composable sources.
EnvSource,DotEnvSource,TomlSource,YamlSource,SecretsDirSource,MappingSource- each reads from one place and returns a raw nesteddict. - Explicit merge order. You pass
load_settings(cls, sources)an ordered list; later sources override earlier ones, recursively for nested dicts. No hidden "env beats file beats default" precedence. - One validation pass. All sources are merged into a single raw mapping first; that mapping is
converted into your
Structexactly once viamsgspec.convert(). Secret[T]fields. Wrap secret-bearing fields (api_key: Secret[str]) so they never leak intorepr()/logs by accident - built automatically, nodec_hookwiring needed.- Thin Litestar plugin.
SettingsPluginbuilds theStructonce, exposes it through DI, and fails app boot - not the first request - if the merged config doesn't satisfy the schema. - No hot-reload. Settings load once, at startup - see Why no hot-reload.
Install
pip install litestar-settings # msgspec + python-dotenv, incl. DotEnvSource
pip install 'litestar-settings[yaml]' # adds YamlSource (ruamel.yaml)
pip install 'litestar-settings[litestar]' # adds the Litestar plugin's dependency
TOML support needs no extra - it uses the stdlib tomllib (Python 3.11+).
Quick start
import msgspec
from litestar_settings import DotEnvSource, EnvSource, MappingSource, TomlSource, load_settings
class DBSettings(msgspec.Struct):
host: str
port: int = 5432
class Settings(msgspec.Struct, kw_only=True):
debug: bool = False
db: DBSettings
settings = load_settings(
Settings,
[
TomlSource("settings.toml"), # 1. defaults / base config
DotEnvSource(".env", prefix="APP_"), # 2. local dev overrides
EnvSource(prefix="APP_"), # 3. real environment wins
MappingSource({"debug": True}), # 4. explicit override, e.g. from a CLI flag
],
)
Sources are applied left to right; each later source deep-merges onto the result of the ones
before it. Nested keys work through the same __ convention across EnvSource, DotEnvSource,
and SecretsDirSource:
APP_DEBUG=true
APP_DB__HOST=localhost
APP_DB__PORT=6543
becomes {"debug": "true", "db": {"host": "localhost", "port": "6543"}}, which
load_settings then converts (lax by default, so "6543" becomes 6543: int).
Pass strict=True to load_settings to disable that coercion and require exact types.
Usage
Secrets files (Docker/Kubernetes style)
from litestar_settings import SecretsDirSource
# /run/secrets/db__password -> {"db": {"password": "<file contents>"}}
SecretsDirSource("/run/secrets")
Secret fields
msgspec.Struct's default repr() prints every field verbatim - including secrets. Wrap
secret-bearing fields in Secret[T] so they don't end up in logs or tracebacks by accident:
import msgspec
from litestar_settings import Secret, load_settings, TomlSource
class Settings(msgspec.Struct, kw_only=True):
api_key: Secret[str]
db_port: Secret[int] = Secret(5432)
settings = load_settings(Settings, [TomlSource("settings.toml")])
print(settings) # Settings(api_key=Secret('**********'), db_port=Secret('**********'))
settings.api_key.get_secret_value() # the real value
load_settings() builds Secret[...] fields automatically - the wrapped type (str, int, ...)
still goes through the normal msgspec.convert() coercion, so Secret[int] from an env var string
works the same as a plain int field would. No dec_hook wiring needed; pass your own dec_hook
for other custom types and it's consulted after Secret.
Secret only guards repr()/str() - it doesn't stop you from serializing the wrapped value
elsewhere (e.g. msgspec.json.encode(settings.api_key.get_secret_value())), and encoding a
Secret-typed field back to JSON needs its own enc_hook since msgspec doesn't know the type.
Custom sources
Source is a structural Protocol - any object with a zero-arg load() method returning a raw
nested mapping satisfies it, no subclassing required:
from litestar_settings import RawMapping
class RedisSource:
def __init__(self, client: Redis, key: str) -> None:
self._client = client
self._key = key
def load(self) -> RawMapping:
return json.loads(self._client.get(self._key) or "{}")
RawMapping is just dict[str, object], exported for readability - using the plain dict[str, object] annotation works identically.
Litestar usage
from __future__ import annotations
import msgspec
from litestar import Litestar, get
from litestar.config.app import AppConfig
from litestar.di import NamedDependency
from litestar_settings import EnvSource, TomlSource
from litestar_settings.plugin import SettingsPlugin
class Settings(msgspec.Struct, kw_only=True):
debug: bool = False
api_key: str
def apply_app_config(settings: Settings, app_config: AppConfig) -> AppConfig:
app_config.debug = settings.debug
return app_config
settings_plugin = SettingsPlugin(
Settings,
[TomlSource("settings.toml"), EnvSource(prefix="APP_")],
apply_app_config=apply_app_config, # optional - see below
)
@get("/status")
async def status(settings: NamedDependency[Settings]) -> dict:
return {"debug": settings.debug}
app = Litestar(route_handlers=[status], plugins=[settings_plugin])
Fail-fast timing
By default SettingsPlugin builds Settings lazily behind a cached factory and forces that
first build from an on_startup hook - so a missing api_key fails ASGI lifespan startup
(litestar run, AsyncTestClient(app=app), ...), not the first request that happens to depend
on settings. Constructing Litestar(...) itself never fails on bad config; importing the app
module stays cheap even if the environment isn't fully set up yet (useful for litestar routes,
schema generation, etc.).
Pass apply_app_config when you want native AppConfig fields (debug, openapi_config, ...)
derived from Settings. Since AppConfig must be finalized synchronously inside on_app_init,
this switches the plugin to build Settings eagerly at that point instead of at on_startup -
still fail-fast, just earlier (at Litestar(...) construction time).
Custom dependency key
SettingsPlugin(Settings, [...], dependency_key="app_settings")
@get("/status")
async def status(app_settings: NamedDependency[Settings]) -> dict: ...
Design
Core is framework-agnostic. litestar_settings.core has no dependency on Litestar and no
magic layered on top of typing. A settings schema is just a msgspec.Struct. Its only required
dependencies are msgspec and python-dotenv (for DotEnvSource); YamlSource and the Litestar
plugin are behind extras - see Install.
Sources are plain objects with a load() method. EnvSource, DotEnvSource, TomlSource,
YamlSource, SecretsDirSource, MappingSource - each reads from one place and returns a raw
nested dict. None of them validate or coerce anything.
Merge order is an explicit list, not hidden precedence. You pass load_settings(cls, sources)
an ordered list; later sources override earlier ones, recursively for nested dicts. There is no
built-in "env beats file beats default" rule baked into the source types themselves.
One convert() pass at the end. All sources are merged into a single raw mapping first; that
mapping is converted into your Struct exactly once via msgspec.convert(). Sources never touch
msgspec at all.
Secret[T] is built into that one pass. load_settings() composes a dec_hook that
recognizes Secret[...] field types and wraps them automatically; a dec_hook you pass yourself
is consulted for anything Secret doesn't handle. See Secret fields.
Litestar layer is a thin wrapper on top. SettingsPlugin builds the Struct once, exposes it
through DI (Provide(get_settings, use_cache=True)), and fails app boot - not the first request -
if the merged config doesn't satisfy the schema.
Load-once at startup, no hot-reload. See Why no hot-reload.
Why no hot-reload
Settings load once, at startup. There's no file watcher, no signal handler, no live-reload path.
- Predictability. If config could change under a running process, two reads of
settings.db_urlwithin the same request could theoretically disagree. That's a class of bug worth not having. - Fail-fast stays simple. With validation only at startup, the service either boots with a valid config or doesn't boot. Hot-reload forces a decision about what to do with an invalid config arriving mid-flight (roll back? crash? ignore the bad value?) - a separate problem this library doesn't try to solve.
- Most settings don't need it. DB URLs, secret keys, uptime-scoped feature flags - a restart
(
systemd restart, a new container) is cheap and makes the reload story trivial.
If you need live-updated values (e.g. a feature flag toggled without a restart), that's a different problem - a small polling/pubsub-backed accessor - and deliberately out of scope here.
Development
uv sync --all-extras
uv run ruff check
uv run ruff format --check
uv run pyrefly check
uv run pyrefly coverage check
uv run pytest
License
BSD 3-Clause. See LICENSE.
Project details
Release history Release notifications | RSS feed
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 litestar_settings-0.1.0.tar.gz.
File metadata
- Download URL: litestar_settings-0.1.0.tar.gz
- Upload date:
- Size: 65.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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 |
51b15f45e23e375d37fce37d3ae50b9d09842e0082c4cf0ae15354e30a2b0c65
|
|
| MD5 |
e355999d1d185b95c3dee6c44c4ec2f8
|
|
| BLAKE2b-256 |
0764aef3091efb427112a0367d8077d44a0a85330b264fe41a16ec682c88a828
|
File details
Details for the file litestar_settings-0.1.0-py3-none-any.whl.
File metadata
- Download URL: litestar_settings-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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 |
a9ee1e7e110d9a9a6cb1933c215446f209c871bf93b63fe96129eedda0eb68b7
|
|
| MD5 |
e63c55a79e1ef33b6358fcef3cd9ee18
|
|
| BLAKE2b-256 |
48f1ac551277542cf6159c617153bffbf290b89a6d6f1f7fcfa9b28fa4199e56
|