Skip to main content

A framework-agnostic settings loader for Litestar apps: composable sources, deterministic merge order, zero hidden precedence.

Project description

litestar-settings

Python License

Typed settings for Litestar apps, built on msgspec.Struct - like pydantic-settings, but msgspec instead of Pydantic.

Features

  • Framework-agnostic core. litestar_settings.core has no dependency on Litestar; a settings schema is just a msgspec.Struct.
  • Composable sources. EnvSource, DotEnvSource, TomlSource, YamlSource, SecretsDirSource, MappingSource - each reads from one place and returns a raw nested dict.
  • 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 Struct exactly once via msgspec.convert().
  • Secret[T] fields. Wrap secret-bearing fields (api_key: Secret[str]) so they never leak into repr()/logs by accident - built automatically, no dec_hook wiring needed.
  • Thin Litestar plugin. SettingsPlugin builds the Struct once, 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_url within 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


Download files

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

Source Distribution

litestar_settings-0.1.0.tar.gz (65.1 kB view details)

Uploaded Source

Built Distribution

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

litestar_settings-0.1.0-py3-none-any.whl (16.0 kB view details)

Uploaded Python 3

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

Hashes for litestar_settings-0.1.0.tar.gz
Algorithm Hash digest
SHA256 51b15f45e23e375d37fce37d3ae50b9d09842e0082c4cf0ae15354e30a2b0c65
MD5 e355999d1d185b95c3dee6c44c4ec2f8
BLAKE2b-256 0764aef3091efb427112a0367d8077d44a0a85330b264fe41a16ec682c88a828

See more details on using hashes here.

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

Hashes for litestar_settings-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a9ee1e7e110d9a9a6cb1933c215446f209c871bf93b63fe96129eedda0eb68b7
MD5 e63c55a79e1ef33b6358fcef3cd9ee18
BLAKE2b-256 48f1ac551277542cf6159c617153bffbf290b89a6d6f1f7fcfa9b28fa4199e56

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