# 03 — Configuration: `BaseAppSettings` + atomic loader

`mgf.common.config` ships the base + helpers that every consumer
project's settings class is built from. The library's OWN
settings (`MgfSettings`) is a subclass — see
[`04_settings.md`](04_settings.md) for that.

> **Read first:** [`library_common.md`](library_common.md).

---

## Motivation

Every Python service rolls its own settings layer: typed shape,
env-var loader, file loader, atomic save, schema versioning,
defaults. The shapes are mostly the same. `BaseAppSettings`
standardizes the shape so consumer projects inherit a
production-tested base instead of re-deriving it.

Built on `pydantic-settings` for the typed parts; adds atomic
file write (rule CF-04), cross-OS path resolution (XDG /
macOS / Windows), schema versioning (rule CF-03), and
re-exports the pydantic primitives consumers need so they
don't `import pydantic` for normal authoring.

---

## Goal

A consumer's `Settings` class is one declaration:

```python
from mgf.common.config import BaseAppSettings, Field

class AppSettings(BaseAppSettings, env_prefix="MYAPP_"):
    database_url: str = "sqlite:///./app.db"
    request_timeout_seconds: int = Field(default=30, ge=1, le=300)
```

Loading reads env > file > defaults. Saving is atomic.
Subsclasses can compose with `MgfSettings` (the library's own
settings) — see [`04_settings.md`](04_settings.md) §"Composing
with your app".

---

## Quick start

### Define settings

```python
from mgf.common.config import BaseAppSettings, Field

class AppSettings(BaseAppSettings, env_prefix="MYAPP_"):
    database_url: str = "sqlite:///./app.db"
    request_timeout_seconds: int = Field(default=30, ge=1, le=300)
    feature_flags: dict[str, bool] = Field(default_factory=dict)
```

### Load + save

```python
from mgf.common.config import (
    default_config_path,
    load_settings,
    save_settings,
)

# OS-aware default path:
# Linux: ~/.config/myapp/config.yaml
# macOS: ~/Library/Application Support/myapp/config.yaml
# Windows: %APPDATA%\myapp\config.yaml
path = default_config_path(app="myapp")

if path.exists():
    settings = load_settings(AppSettings, path=path)
else:
    settings = AppSettings()  # defaults + env

# Save atomically (temp + rename + chmod 0o600):
save_settings(settings, path)
```

### Env-var overrides

```bash
export MYAPP_DATABASE_URL="postgresql://prod-db/myapp"
export MYAPP_REQUEST_TIMEOUT_SECONDS=120
```

---

## Public API

| Name | What it does |
|---|---|
| [`BaseAppSettings`](../../PUBLIC_API.md#mgfcommonconfig) | pydantic-settings base. Subclass with `class S(BaseAppSettings, env_prefix="X_")`. |
| [`settings_config(**overrides)`](../../PUBLIC_API.md#mgfcommonconfig) | Build a `SettingsConfigDict` with the recommended baseline. The legacy `make_settings_config` alias is deprecated. |
| [`load_settings(cls, *, path, migrate=None)`](../../PUBLIC_API.md#mgfcommonconfig) | Build typed Settings from disk + env + defaults. Optional schema-bump callback. |
| [`save_settings(cfg, path)`](../../PUBLIC_API.md#mgfcommonconfig) | Atomic write (temp + rename + 0o600). |
| [`platform_dir(kind)`](../../PUBLIC_API.md#mgfcommonconfig) | Cross-OS path: `"config"` / `"state"` / `"data"`. |
| [`default_config_path(app=None)`](../../PUBLIC_API.md#mgfcommonconfig) | OS-aware config path. Pass `app=` explicitly outside a bootstrap. |
| [`expand_path(v)`](../../PUBLIC_API.md#mgfcommonconfig) | Validator helper: `~` and `$VAR` expansion (lazy per CF-04). |
| [`PathKind`](../../PUBLIC_API.md#mgfcommonconfig) | `Literal["config", "state", "data"]`. |

Pydantic re-exports (closes CB-04 — no `import pydantic` needed
for normal authoring):
[`BaseModel`](../../PUBLIC_API.md#mgfcommonconfig),
[`ConfigDict`](../../PUBLIC_API.md#mgfcommonconfig),
[`Field`](../../PUBLIC_API.md#mgfcommonconfig),
[`AliasChoices`](../../PUBLIC_API.md#mgfcommonconfig),
[`field_validator`](../../PUBLIC_API.md#mgfcommonconfig),
[`model_validator`](../../PUBLIC_API.md#mgfcommonconfig),
[`ValidationError`](../../PUBLIC_API.md#mgfcommonconfig).

Code:
[`src/mgf/common/config/`](../../src/mgf/common/config/).

---

## Patterns

### Schema versioning (CF-03)

```python
class AppSettings(BaseAppSettings, env_prefix="MYAPP_"):
    CURRENT_VERSION: ClassVar[int] = 2
    version: int = 2
    field_renamed_in_v2: str = "default"

def _migrate_v1_to_v2(data: dict) -> dict:
    if data.get("version", 1) == 1:
        data["field_renamed_in_v2"] = data.pop("old_field_name", "default")
        data["version"] = 2
    return data

settings = load_settings(AppSettings, path=path, migrate=_migrate_v1_to_v2)
```

Rule CF-03 details in
[`docs/standards/CONFIGURATION.md`](../standards/CONFIGURATION.md).

### Field renames without breaking (CF-11)

```python
from mgf.common.config import AliasChoices, Field

class AppSettings(BaseAppSettings, env_prefix="MYAPP_"):
    timeout_seconds: int = Field(
        default=30,
        validation_alias=AliasChoices("timeout_seconds", "timeout"),
    )
```

`MYAPP_TIMEOUT=60` and `MYAPP_TIMEOUT_SECONDS=60` both work.
Old name retained one MINOR cycle; remove at next MAJOR.

### Discriminated unions (CF-12)

For backend-variant fields:

```python
from mgf.common.config import BaseModel, ConfigDict, Field
from typing import Annotated, Literal, Union

class S3StorageConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    backend: Literal["s3"] = "s3"
    bucket: str

class FilesystemStorageConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    backend: Literal["fs"] = "fs"
    root: str

class AppSettings(BaseAppSettings, env_prefix="MYAPP_"):
    storage: Annotated[
        Union[S3StorageConfig, FilesystemStorageConfig],
        Field(discriminator="backend"),
    ]
```

The vault module ships this pattern — see
[`07_vault.md`](07_vault.md).

---

## Links

- **Public API:** [`PUBLIC_API.md` `mgf.common.config` section](../../PUBLIC_API.md#mgfcommonconfig)
- **Engineering standards:**
  [`CONFIGURATION.md`](../standards/CONFIGURATION.md) (CF-01..CF-12)
- **Examples:** [`examples/02_config/`](../../examples/02_config/)
  — three layers
- **Code:**
  [`src/mgf/common/config/_settings.py`](../../src/mgf/common/config/_settings.py),
  [`_loader.py`](../../src/mgf/common/config/_loader.py),
  [`_paths.py`](../../src/mgf/common/config/_paths.py)
- **Related docs:**
  - [`04_settings.md`](04_settings.md) — `MgfSettings` (the
    library's own subclass) + composition pattern
