Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c1debad
docs(plan): add 26.5.0 upstream implementation plan
discreteds May 13, 2026
289c616
feat(profiles): add public lookup_class_var helper
discreteds May 13, 2026
af03b81
feat(profiles): add spec.py with renamed ProfileSpec and Missing
discreteds May 13, 2026
020be9c
feat(profiles): rename DescriptorProfile to Profile, add __spec__ att…
discreteds May 13, 2026
675a316
fix(tests): rename TestDescriptorProfile class to TestProfile
discreteds May 13, 2026
07dc1e7
fix(profiles): install __spec__ alias for __descriptor__-only classes
discreteds May 13, 2026
455dd71
feat(profiles): Registry constructor accepts spec_type/profile_type
discreteds May 13, 2026
74737c6
feat(profiles): @register supports argument-free form
discreteds May 13, 2026
bf6111b
fix(profiles): tidy @register dispatch and drift-catch message
discreteds May 13, 2026
43621bc
test(profiles): assert __spec__/__descriptor__ mirror on register
discreteds May 13, 2026
e6f5c09
feat(profiles): rename descriptor_invariants_for to spec_invariants_for
discreteds May 13, 2026
c32ea19
refactor(profiles): convert descriptor.py to compatibility shim
discreteds May 13, 2026
1a1ec82
feat(profiles): export new names from package root; deprecate old names
discreteds May 13, 2026
3ac8bac
feat(profiles): re-export new names at package top level
discreteds May 13, 2026
6032c72
test(profiles): cover all 26.5.0 deprecation paths
discreteds May 13, 2026
225ba49
docs(quickstart): use new profile/spec names
discreteds May 13, 2026
ae50d57
docs(advanced-usage): use new profile/spec names
discreteds May 13, 2026
5f65d14
docs: rename profile-descriptor-pattern.md to profile-spec-pattern.md
discreteds May 13, 2026
98e2882
docs(readme): use new profile/spec names; link to renamed pattern doc
discreteds May 13, 2026
eb30fa2
docs(profile-spec-pattern): fix get_spec -> get_descriptor
discreteds May 13, 2026
ccc9c6a
release: bump version to 26.5.0
discreteds May 13, 2026
64c2012
fix(profiles): exclude deprecated names from descriptor.py __all__
discreteds May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ settings = AppSettings(

### Declarative connection profiles

For database and service connections, the `DescriptorProfile` + `ProfileDescriptor` system provides typed, inspectable, driver-ready settings with automatic field installation, auth mode validation, and runtime lookup by name:
For database and service connections, the `Profile` + `ProfileSpec` system provides typed, inspectable, driver-ready settings with automatic field installation, auth mode validation, and runtime lookup by name:

```python
from mountainash_settings import (
DescriptorProfile, MISSING, ParameterSpec, ProfileDescriptor,
Profile, MISSING, ParameterSpec, ProfileSpec,
Registry, NoAuth, PasswordAuth,
)

POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
POSTGRESQL_SPEC = ProfileSpec(
name="postgresql",
provider_type="postgresql",
parameters=[
Expand All @@ -148,10 +148,11 @@ POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
)

DATABASES = Registry("databases")
register = DATABASES.decorator()

@DATABASES.decorator()(POSTGRESQL_DESCRIPTOR)
class PostgreSQLSettings(DescriptorProfile):
__descriptor__ = POSTGRESQL_DESCRIPTOR
@register
class PostgreSQLSettings(Profile):
__spec__ = POSTGRESQL_SPEC

settings = PostgreSQLSettings(
HOST="prod-db.example.com",
Expand All @@ -164,7 +165,7 @@ driver_kwargs = {**settings._default_kwargs(), **settings._auth_kwargs()}
# "user": "app_user", "password": "s3cr3t"}
```

See [docs/profile-descriptor-pattern.md](docs/profile-descriptor-pattern.md) for an explanation of when and why to use this pattern over a plain subclass.
See [docs/profile-spec-pattern.md](docs/profile-spec-pattern.md) for an explanation of when and why to use this pattern over a plain subclass.

### 13 typed auth modes

Expand All @@ -191,10 +192,10 @@ All authentication modes are validated pydantic models with `SecretStr` protecti
Drop one line into a test module to get parametrised pytest coverage for every descriptor in a registry — name conventions, field uniqueness, valid auth modes, and more:

```python
from mountainash_settings import descriptor_invariants_for
from mountainash_settings import spec_invariants_for
from my_package.settings import DATABASES

TestDatabaseInvariants = descriptor_invariants_for(DATABASES)
TestDatabaseInvariants = spec_invariants_for(DATABASES)
```

New profile registrations are covered automatically.
Expand All @@ -205,7 +206,7 @@ New profile registrations are covered automatically.
|---|---|
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step introduction — settings class, config files, templates, caching, secrets, profiles |
| [docs/advanced-usage.md](docs/advanced-usage.md) | SettingsParameters merging, auth modes reference, invariant tests, dynamic resolution |
| [docs/profile-descriptor-pattern.md](docs/profile-descriptor-pattern.md) | When and why to use ProfileDescriptor vs a plain subclass |
| [docs/profile-spec-pattern.md](docs/profile-spec-pattern.md) | When and why to use ProfileSpec vs a plain subclass |
| [examples/](examples/) | Working code: basic usage, path templating, smart merging, dynamic resolution |

## Development
Expand Down
42 changes: 21 additions & 21 deletions docs/advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,17 +235,17 @@ kwargs = auth_to_driver_kwargs(auth)

For the other seven modes (`OAuth1Auth`, `OAuth2AuthCodeAuth`, `AzureADAuth`, `WindowsAuth`, `KerberosAuth`, `CertificateAuth`, `ServiceAccountAuth`), your backend adapter is responsible for converting to driver kwargs. `auth_to_driver_kwargs()` will raise `KeyError` for these — that is intentional.

### Using auth in a `DescriptorProfile`
### Using auth in a `Profile`

When a profile has multiple `auth_modes`, pydantic uses the `kind` literal to discriminate:

```python
from mountainash_settings import (
DescriptorProfile, ParameterSpec, ProfileDescriptor, Registry,
Profile, ParameterSpec, ProfileSpec, Registry,
NoAuth, PasswordAuth, IAMAuth,
)

DESCRIPTOR = ProfileDescriptor(
REDSHIFT_SPEC = ProfileSpec(
name="redshift",
provider_type="redshift",
parameters=[
Expand All @@ -256,8 +256,8 @@ DESCRIPTOR = ProfileDescriptor(
auth_modes=[PasswordAuth, IAMAuth],
)

class RedshiftSettings(DescriptorProfile):
__descriptor__ = DESCRIPTOR
class RedshiftSettings(Profile):
__spec__ = REDSHIFT_SPEC

# Instantiate with the right auth — pydantic validates the kind discriminator
settings = RedshiftSettings(
Expand All @@ -278,13 +278,13 @@ driver_kwargs = {**settings._default_kwargs(), **settings._auth_kwargs()}
Every domain that builds a `Registry` gets free pytest coverage with one line:

```python
from mountainash_settings import descriptor_invariants_for
from mountainash_settings import spec_invariants_for
from my_package.settings import MY_REGISTRY

TestMyInvariants = descriptor_invariants_for(MY_REGISTRY)
TestMyInvariants = spec_invariants_for(MY_REGISTRY)
```

Drop this in any `test_*.py` file. Pytest collects it as a parametrised test class — every descriptor registered in `MY_REGISTRY` is tested automatically.
Drop this in any `test_*.py` file. Pytest collects it as a parametrised test class — every spec registered in `MY_REGISTRY` is tested automatically.

### What is checked

Expand All @@ -308,16 +308,16 @@ For each registered descriptor:

import pytest
from mountainash_settings import (
DescriptorProfile, MISSING, ParameterSpec, ProfileDescriptor,
Profile, MISSING, ParameterSpec, ProfileSpec,
Registry, NoAuth, PasswordAuth, IAMAuth,
descriptor_invariants_for,
spec_invariants_for,
)

# Build the registry under test
DATABASES = Registry("databases")
register = DATABASES.decorator()

POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
POSTGRESQL_SPEC = ProfileSpec(
name="postgresql",
provider_type="postgresql",
parameters=[
Expand All @@ -329,7 +329,7 @@ POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
auth_modes=[NoAuth, PasswordAuth],
)

REDSHIFT_DESCRIPTOR = ProfileDescriptor(
REDSHIFT_SPEC = ProfileSpec(
name="redshift",
provider_type="redshift",
parameters=[
Expand All @@ -340,17 +340,17 @@ REDSHIFT_DESCRIPTOR = ProfileDescriptor(
auth_modes=[PasswordAuth, IAMAuth],
)

@register(POSTGRESQL_DESCRIPTOR)
class PostgreSQLSettings(DescriptorProfile):
__descriptor__ = POSTGRESQL_DESCRIPTOR
@register
class PostgreSQLSettings(Profile):
__spec__ = POSTGRESQL_SPEC

@register(REDSHIFT_DESCRIPTOR)
class RedshiftSettings(DescriptorProfile):
__descriptor__ = REDSHIFT_DESCRIPTOR
@register
class RedshiftSettings(Profile):
__spec__ = REDSHIFT_SPEC

# This one line gives you parametrised invariant tests for BOTH descriptors.
# Add more descriptors to the registry — they are covered automatically.
TestDatabaseInvariants = descriptor_invariants_for(DATABASES)
# This one line gives you parametrised invariant tests for BOTH specs.
# Add more specs to the registry — they are covered automatically.
TestDatabaseInvariants = spec_invariants_for(DATABASES)
```

Running `pytest tests/unit/test_db_profiles.py` will produce one test per invariant per descriptor:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The ProfileDescriptor Pattern: Why and When
# The ProfileSpec Pattern: Why and When

The quickstart shows `DescriptorProfile` + `ProfileDescriptor` used to define a connection profile. This guide explains the design rationale — what you gain over a plain `MountainAshBaseSettings` subclass, and when the extra structure is worth it.
The quickstart shows `Profile` + `ProfileSpec` used to define a connection profile. This guide explains the design rationale — what you gain over a plain `MountainAshBaseSettings` subclass, and when the extra structure is worth it.

## The plain subclass approach

Expand All @@ -20,11 +20,11 @@ class PostgreSQLSettings(MountainAshBaseSettings):

This is fine for one profile in one application. The friction starts when you have many.

## What the ProfileDescriptor pattern adds
## What the ProfileSpec pattern adds

### 1. The descriptor is inspectable data, not just code
### 1. The spec is inspectable data, not just code

A `ProfileDescriptor` is a frozen dataclass — it's a value you can pass around, iterate over, and query programmatically, independently of any settings instance. Each `ParameterSpec` carries metadata that a plain pydantic field cannot express:
A `ProfileSpec` is a frozen dataclass — it's a value you can pass around, iterate over, and query programmatically, independently of any settings instance. Each `ParameterSpec` carries metadata that a plain pydantic field cannot express:

| `ParameterSpec` attribute | What it holds |
|---|---|
Expand All @@ -37,10 +37,10 @@ A `ProfileDescriptor` is a frozen dataclass — it's a value you can pass around

None of these exist in a plain pydantic `FieldInfo`. With a direct subclass you can define all the behaviour, but it is scattered across field annotations, validators, and `post_init()` methods with no single place to introspect it.

With a descriptor, you can query the shape of a profile without constructing an instance:
With a spec, you can query the shape of a profile without constructing an instance:

```python
for spec in POSTGRESQL_DESCRIPTOR.parameters:
for spec in POSTGRESQL_SPEC.parameters:
if spec.secret:
print(f" {spec.name} is a secret (driver key: {spec.driver_key})")
```
Expand All @@ -49,11 +49,11 @@ for spec in POSTGRESQL_DESCRIPTOR.parameters:

With a direct subclass, every profile you write re-declares its fields. Twelve database profiles means twelve copies of `HOST`, `PORT`, `DATABASE`, each with slightly different types or defaults — and it's easy for them to drift.

`DescriptorProfile.__pydantic_init_subclass__` reads the `__descriptor__` at class-creation time and installs pydantic fields automatically. The descriptor is the single source of truth. If `POSTGRESQL_DESCRIPTOR` says `PORT` defaults to `5432`, every subclass pointing at it gets that default — you cannot accidentally override it in the wrong place.
`Profile.__pydantic_init_subclass__` reads the `__spec__` at class-creation time and installs pydantic fields automatically. The spec is the single source of truth. If `POSTGRESQL_SPEC` says `PORT` defaults to `5432`, every subclass pointing at it gets that default — you cannot accidentally override it in the wrong place.

```python
# The descriptor is declared once
POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
# The spec is declared once
POSTGRESQL_SPEC = ProfileSpec(
name="postgresql",
provider_type="postgresql",
parameters=[
Expand All @@ -64,9 +64,9 @@ POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
auth_modes=[NoAuth, PasswordAuth],
)

# The class declares nothing — fields come from the descriptor
class PostgreSQLSettings(DescriptorProfile):
__descriptor__ = POSTGRESQL_DESCRIPTOR
# The class declares nothing — fields come from the spec
class PostgreSQLSettings(Profile):
__spec__ = POSTGRESQL_SPEC
```

### 3. The auth union is assembled automatically
Expand All @@ -81,7 +81,7 @@ class PostgreSQLSettings(MountainAshBaseSettings):
auth: Union[NoAuth, PasswordAuth] = Field(discriminator="kind")
```

The descriptor declares `auth_modes=[NoAuth, PasswordAuth]`, and `DescriptorProfile` assembles the discriminated union field automatically. Add a new valid auth mode to the descriptor; all subclasses pick it up. Remove one; it is rejected at validation time for every profile pointing at that descriptor.
The spec declares `auth_modes=[NoAuth, PasswordAuth]`, and `Profile` assembles the discriminated union field automatically. Add a new valid auth mode to the spec; all subclasses pick it up. Remove one; it is rejected at validation time for every profile pointing at that spec.

### 4. The `_default_kwargs()` method handles the naming mismatch

Expand All @@ -98,7 +98,7 @@ def to_driver_kwargs(self) -> dict:
}
```

`DescriptorProfile._default_kwargs()` generates this mapping from `ParameterSpec.driver_key` automatically. It also handles:
`Profile._default_kwargs()` generates this mapping from `ParameterSpec.driver_key` automatically. It also handles:

- **SecretStr unwrapping** — `spec.secret=True` fields are unwrapped via `.get_secret_value()` before emission
- **None skipping** — optional fields that were not set are omitted from the output dict
Expand All @@ -121,15 +121,15 @@ When the connection type is determined at runtime (e.g. read from a config file,
DATABASES = Registry("databases")
register = DATABASES.decorator()

@register(POSTGRESQL_DESCRIPTOR)
class PostgreSQLSettings(DescriptorProfile): ...
@register
class PostgreSQLSettings(Profile): ...

@register(REDSHIFT_DESCRIPTOR)
class RedshiftSettings(DescriptorProfile): ...
@register
class RedshiftSettings(Profile): ...

# Runtime dispatch from a string — no manual mapping needed
backend = config["backend"] # e.g. "postgresql"
descriptor = DATABASES.get_descriptor(backend)
spec = DATABASES.get_descriptor(backend)
cls = DATABASES.get_settings_class(backend)
settings = cls(HOST=..., DATABASE=..., auth=...)
```
Expand All @@ -140,35 +140,35 @@ This pattern is the foundation for packages like `mountainash-data` that let cal

With direct subclasses, testing that every profile satisfies the same structural rules requires either duplication (one test per class) or a manually maintained parametrised test.

`descriptor_invariants_for(REGISTRY)` returns a pytest class parametrised over every descriptor in the registry. Add a new profile; it is automatically covered. The invariants check things that are easy to get wrong — parameter names in the wrong case, duplicate `driver_key` values, empty `auth_modes`, missing `provider_type`.
`spec_invariants_for(REGISTRY)` returns a pytest class parametrised over every spec in the registry. Add a new profile; it is automatically covered. The invariants check things that are easy to get wrong — parameter names in the wrong case, duplicate `driver_key` values, empty `auth_modes`, missing `provider_type`.

```python
# tests/unit/test_database_profiles.py

from mountainash_settings import descriptor_invariants_for
from mountainash_settings import spec_invariants_for
from my_package.db.settings import DATABASES

TestDatabaseInvariants = descriptor_invariants_for(DATABASES)
# That's it. Every descriptor in DATABASES is now tested.
TestDatabaseInvariants = spec_invariants_for(DATABASES)
# That's it. Every spec in DATABASES is now tested.
```

## When to use each approach

| Situation | Use |
|---|---|
| One-off settings class for your own application | Plain `MountainAshBaseSettings` subclass |
| Several connection types in the same domain that must be lookable by name | `ProfileDescriptor` + `Registry` |
| You need to emit driver kwargs with field renaming, SecretStr unwrapping, or value transforms | `DescriptorProfile` (`_default_kwargs()`) |
| You want to introspect profile structure programmatically (schema generation, documentation, audit) | `ProfileDescriptor` — it's just data |
| You're building a library where downstream code registers profiles you don't control | `Registry` + `descriptor_invariants_for()` |
| The valid auth modes differ between backends | `ProfileDescriptor.auth_modes` discriminated union assembly |
| Several connection types in the same domain that must be lookable by name | `ProfileSpec` + `Registry` |
| You need to emit driver kwargs with field renaming, SecretStr unwrapping, or value transforms | `Profile` (`_default_kwargs()`) |
| You want to introspect profile structure programmatically (schema generation, documentation, audit) | `ProfileSpec` — it's just data |
| You're building a library where downstream code registers profiles you don't control | `Registry` + `spec_invariants_for()` |
| The valid auth modes differ between backends | `ProfileSpec.auth_modes` discriminated union assembly |

## What you give up

The pattern is not free. Compared to a plain subclass:

- **More indirection** — fields are installed at class-creation time via `__pydantic_init_subclass__`, which is invisible to a reader who just looks at the class body. IDE "go to definition" on a field like `HOST` will not lead anywhere useful.
- **More indirection** — fields are installed at class-creation time via `__pydantic_init_subclass__`, which is invisible to a reader who just looks at the class body. IDE "go to definition" on a field like `HOST` will not lead anywhere useful — the field comes from `__spec__`, not the class body.
- **Dynamic field installation is pydantic-version-sensitive** — it mutates `model_fields` and calls `model_rebuild(force=True)`, which is not officially documented by pydantic and may need adjustment on major pydantic upgrades.
- **Slightly more ceremony to set up** — you need a `ProfileDescriptor`, at least one `ParameterSpec` per field, a `Registry`, and a `DescriptorProfile` subclass, before you have a working settings class.
- **Slightly more ceremony to set up** — you need a `ProfileSpec`, at least one `ParameterSpec` per field, a `Registry`, and a `Profile` subclass, before you have a working settings class.

For a single application-specific settings class, none of this is worth it. For a library or any code where you manage more than two or three similar profiles, the structural guarantees pay for themselves quickly.
14 changes: 7 additions & 7 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,17 @@ For database or service connections, use the declarative profile system to defin

```python
from mountainash_settings import (
DescriptorProfile,
Profile,
MISSING,
ParameterSpec,
ProfileDescriptor,
ProfileSpec,
Registry,
NoAuth,
PasswordAuth,
)

# 1. Describe the connection
POSTGRESQL_DESCRIPTOR = ProfileDescriptor(
POSTGRESQL_SPEC = ProfileSpec(
name="postgresql",
provider_type="postgresql",
parameters=[
Expand All @@ -217,9 +217,9 @@ DATABASES = Registry("databases")
register = DATABASES.decorator()

# 3. Define the settings class
@register(POSTGRESQL_DESCRIPTOR)
class PostgreSQLSettings(DescriptorProfile):
__descriptor__ = POSTGRESQL_DESCRIPTOR
@register
class PostgreSQLSettings(Profile):
__spec__ = POSTGRESQL_SPEC
```

Pydantic fields, type validation, and SecretStr wrapping are installed automatically from the descriptor. No boilerplate.
Expand All @@ -241,5 +241,5 @@ kwargs = {**settings._default_kwargs(), **settings._auth_kwargs()}

- **Multiple config files and merging** — `SettingsParameters.merge()` for combining base and environment-specific parameters
- **Auth modes reference** — 13 typed auth modes: `PasswordAuth`, `TokenAuth`, `JWTAuth`, `OAuth2Auth`, `OAuth1Auth`, `OAuth2AuthCodeAuth`, `IAMAuth`, `AzureADAuth`, `WindowsAuth`, `KerberosAuth`, `CertificateAuth`, `ServiceAccountAuth`, `NoAuth`
- **Profile invariant tests** — `descriptor_invariants_for(REGISTRY)` gives automatic pytest coverage for every registered profile
- **Profile invariant tests** — `spec_invariants_for(REGISTRY)` gives automatic pytest coverage for every registered profile
- **Examples** — `examples/` directory contains working code for path templating, smart merging, and comprehensive patterns
Loading
Loading