Metadata-Version: 2.4
Name: litestar-settings
Version: 0.1.0
Summary: A framework-agnostic settings loader for Litestar apps: composable sources, deterministic merge order, zero hidden precedence.
Project-URL: Homepage, https://github.com/dgavrilov/litestar-settings
Project-URL: Repository, https://github.com/dgavrilov/litestar-settings
Project-URL: Issues, https://github.com/dgavrilov/litestar-settings/issues
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: configuration,dotenv,litestar,msgspec,settings,toml,typed-settings,yaml
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Litestar
Classifier: Framework :: Litestar :: 2
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: msgspec>=0.17.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: litestar
Requires-Dist: litestar>=2.23.0; extra == 'litestar'
Requires-Dist: typing-extensions>=4.9.0; extra == 'litestar'
Provides-Extra: yaml
Requires-Dist: ruamel-yaml>=0.16.0; extra == 'yaml'
Description-Content-Type: text/markdown

# litestar-settings

[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org)
[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)

Typed settings for [Litestar](https://litestar.dev) 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](#why-no-hot-reload).

## Install

```bash
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

```python
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`:

```bash
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)

```python
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:

```python
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:

```python
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

```python
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

```python
SettingsPlugin(Settings, [...], dependency_key="app_settings")
```

```python
@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](#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](#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).

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

```bash
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](LICENSE).
