Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
50 changes: 50 additions & 0 deletions docs/adr/035-config-subclass-without-dataclass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 035. Config subclasses work without `@dataclass`

Date: 2026-10-23

## Problem

`env()` returns a `dataclasses.Field` with a `default_factory`, so it only becomes a value when the class is processed by `@dataclass`. `Config` relies on every subclass being decorated. A plain subclass is never processed:

```python
class DatabaseConfig(Config):
default: str = env("DB_CONNECTION", "sqlite")

DatabaseConfig().default
DatabaseConfig.default
```

Both lookups fall back to the class attribute, which is the raw `Field`. The value is never read from the environment or cast.

## Alternatives

1. Make `env()` return a descriptor instead of a `Field`. This breaks dataclass configs, which need a `Field` to get a per-instance `default_factory`, and changes `value()` and the `_ConfigMeta` lookups.
2. Apply `dataclass` lazily, the first time a class is instantiated or subclassed. Instances work, but class-level access (`DatabaseConfig.default`) on a class that was never instantiated still returns the `Field`, and the lazy path needs a lock, MRO walks and rules for which classes to process.
3. Apply `dataclass` to every subclass when it is defined, in `Config.__init_subclass__`. Class-level access works immediately. The risk is a subclass that is also decorated with `@dataclass`: it is processed twice, and the second pass reads the defaults through `getattr`, which returns resolved `env()` values instead of `Field` objects, freezing them at import time and rejecting mutable `default_factory` results.

## Decision

Option 3, in `support/config.py`:

- `Config.__init_subclass__` applies `dataclass(eq=False)` to every subclass that declares its own annotations, and records each class attribute the pass added, replaced or deleted, keeping copies of the original `Field` objects. It uses `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. A subclass without its own annotations (a method-only or mixin base) stays a plain class, so a `@dataclass(frozen=True)` child of it still works, and its annotated children are processed normally. A class that already has its own `__dataclass_fields__` (the class `@dataclass(slots=True)` rebuilds) is skipped.
- An explicit `@dataclass` re-processes the class harmlessly. `dataclass` starts every pass by setting `__dataclass_params__` on the class, so `_ConfigMeta.__setattr__` uses that write to undo the automatic pass: each recorded attribute that still holds the value the pass left is deleted or set back to its original, and fresh copies of the original `Field` objects are put back. Attributes set between definition and the decorator (by another decorator or by assignment) are untouched. The decorator then processes the class as if it ran alone, so its options (`eq`, `frozen`, `kw_only`, ...) apply and `env()` values stay live. The undo runs only once, on the first pass after definition.
- `Config` is decorated with `typing.dataclass_transform(eq_default=False, field_specifiers=(field, Field))`, so type checkers treat undecorated subclasses as dataclasses.
- `Config` itself is not a dataclass. An empty dataclass base would make every `@dataclass(frozen=True)` subclass fail with `cannot inherit frozen dataclass from a non-frozen one`.
- `eq=False` keeps the identity equality and hashing undecorated subclasses had before.
- `_ConfigMeta` keeps resolving `env()` arguments in `__call__` and resolving `default_factory` fields on class-level access in `__getattr__`. The lazy processing, its lock and its class-selection rules are removed.

Every undecorated subclass that declares annotations now follows dataclass rules at definition time: a bare annotation (`name: str`) is a required argument, a plain mutable default (`opts: dict = {}`) raises `ValueError`, and an inherited custom `__init__` is replaced by the generated one, exactly as with `@dataclass`. Use `ClassVar` for shared class state and `field(default_factory=...)` for mutable defaults.

## Risks

The undo relies on `dataclasses` setting `__dataclass_params__` on the class before it reads the field defaults. This is an implementation detail, verified on Python 3.12 to 3.15. If a future version changes the order, an explicit `@dataclass` would read resolved `env()` values and freeze them; `test_explicit_dataclass_reprocessing_keeps_env_live` is the canary that fails first.

## Validation

- Static access: `AppConfig.name` on an undecorated class that was never instantiated returns the resolved value, for both undecorated and decorated classes, and a monkeypatched variable is read on the next access. `Config.get("app.name")` returns the value for a registered instance or class, and reflects an overridden variable.
- An undecorated subclass is a dataclass at definition: fallback value, variable value, `int`/`bool` casting, keyword arguments, and the environment being read on every instantiation.
- Explicit `@dataclass`: attributes set by an inner decorator or after definition survive, fields are collected once, `env()` stays live on the class and on instances, `default_factory` containers are per instance, and `frozen`, `kw_only` and `eq` from the decorator apply.
- Inheritance: undecorated parent and child, a `@dataclass` child of an undecorated parent, and undecorated children of a frozen parent are frozen.
- `Config` and annotation-free bases are not dataclasses, a `@dataclass(frozen=True)` child of an annotation-free base works, its `env()` children have static access, and undecorated instances stay hashable.
- Storage: an undecorated `MemoryDiskConfig` in an undecorated storage config resolves through `StorageManager`.
- Full suite without Postgres and basedpyright pass.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ Read this index first, then open the records relevant to the change. Before impl
| [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:<name>` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `<prefix>:tag:<name>:key`, entries at `<prefix>:sha1(ids):<key>`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. |
| [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. |
| [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |
| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses are dataclasses at definition | An undecorated `Config` subclass kept `env()`'s raw `Field`, so `DatabaseConfig().default` and `DatabaseConfig.default` returned the `Field`. `Config.__init_subclass__` now applies `dataclass(eq=False)` (frozen under a frozen parent) when a subclass with its own annotations is defined, and `Config` carries `dataclass_transform`; `Config` itself stays a plain class. An explicit `@dataclass` re-processes harmlessly: its first `__dataclass_params__` write makes `_ConfigMeta.__setattr__` undo only what the automatic pass changed, with fresh `Field` copies, so the decorator's options apply and `env()` stays live. The lazy first-instantiation path and its lock are removed. |
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
69 changes: 63 additions & 6 deletions fastapi_startkit/src/fastapi_startkit/support/config.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,84 @@
from dataclasses import Field, MISSING
from typing import TypeVar, cast
from copy import copy
from dataclasses import Field, MISSING, dataclass, field
from typing import TypeVar, cast, dataclass_transform
from weakref import WeakKeyDictionary

from fastapi_startkit.environment import value

T = TypeVar("T")

_DATACLASS_PARAMS = "__dataclass_params__"
_OWN_ANNOTATION_KEYS = ("__annotations__", "__annotate_func__")

_Changes = dict[str, tuple[object, object]]
_automatic_changes: WeakKeyDictionary[type, _Changes] = WeakKeyDictionary()


def _snapshot(klass: type) -> dict[str, object]:
return {
name: copy(attribute) if isinstance(attribute, Field) else attribute
for name, attribute in klass.__dict__.items()
}


def _changes(before: dict[str, object], klass: type) -> _Changes:
after = dict(klass.__dict__)
return {
name: (after.get(name, MISSING), before.get(name, MISSING))
for name in before.keys() | after.keys()
if after.get(name, MISSING) is not before.get(name, MISSING)
}


def _declares_annotations(klass: type) -> bool:
return any(key in klass.__dict__ for key in _OWN_ANNOTATION_KEYS)


def _has_frozen_base(klass: type) -> bool:
params = getattr(klass, _DATACLASS_PARAMS, None)
return params is not None and params.frozen


class _ConfigMeta(type):
def __call__(cls: type[T], *args: object, **kwargs: object) -> T:
resolved_args = [value(argument) for argument in args]
resolved_kwargs = {name: value(argument) for name, argument in kwargs.items()}
return cast(T, type.__call__(cls, *resolved_args, **resolved_kwargs))

def __setattr__(cls, name: str, attribute: object) -> None:
if name == _DATACLASS_PARAMS:
changes = _automatic_changes.pop(cls, None)
if changes is not None:
_ConfigMeta.undo_automatic_pass(cls, changes)
super().__setattr__(name, attribute)

@staticmethod
def undo_automatic_pass(klass: type, changes: _Changes) -> None:
for name, (after, before) in changes.items():
if klass.__dict__.get(name, MISSING) is not after:
continue
if before is MISSING:
type.__delattr__(klass, name)
else:
type.__setattr__(klass, name, copy(before) if isinstance(before, Field) else before)

def __getattr__(cls, name: str) -> object:
try:
fields = cast(dict[str, Field[object]], type.__getattribute__(cls, "__dataclass_fields__"))
except AttributeError:
raise AttributeError(f"{cls.__name__!r} has no attribute {name!r}") from None
field = fields.get(name)
if field is not None and field.default_factory is not MISSING:
return field.default_factory()
declared = fields.get(name)
if declared is not None and declared.default_factory is not MISSING:
return declared.default_factory()
raise AttributeError(f"{cls.__name__!r} has no attribute {name!r}")


@dataclass_transform(eq_default=False, field_specifiers=(field, Field))
class Config(metaclass=_ConfigMeta):
pass
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
if "__dataclass_fields__" in cls.__dict__ or not _declares_annotations(cls):
return
before = _snapshot(cls)
dataclass(eq=False, frozen=_has_frozen_base(cls))(cls)
_automatic_changes[cls] = _changes(before, cls)
59 changes: 59 additions & 0 deletions fastapi_startkit/tests/support/test_config_static_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from dataclasses import Field, dataclass

import pytest

from fastapi_startkit.application import Application
from fastapi_startkit.configuration import Config
from fastapi_startkit.configuration.config import Config as ConfigFacade
from fastapi_startkit.container.container import Container
from fastapi_startkit.environment import env


@pytest.fixture
def app(tmp_path):
original = Container._instance
yield Application(base_path=tmp_path, env="testing")
Container._instance = original


class StaticAppConfig(Config):
name: str = env("STATIC_APP_NAME", "Startkit")
port: int = env("STATIC_APP_PORT", 8000)


@dataclass
class DecoratedStaticAppConfig(Config):
name: str = env("STATIC_APP_NAME", "Startkit")
port: int = env("STATIC_APP_PORT", 8000)


def test_class_level_access_without_instantiating(monkeypatch):
class FreshAppConfig(Config):
name: str = env("STATIC_APP_NAME", "Startkit")
port: int = env("STATIC_APP_PORT", 8000)

monkeypatch.delenv("STATIC_APP_NAME", raising=False)
assert not isinstance(FreshAppConfig.name, Field)
assert (FreshAppConfig.name, FreshAppConfig.port) == ("Startkit", 8000)
monkeypatch.setenv("STATIC_APP_NAME", "Overridden")
monkeypatch.setenv("STATIC_APP_PORT", "9000")
assert (FreshAppConfig.name, FreshAppConfig.port) == ("Overridden", 9000)


@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig])
def test_facade_reads_registered_config_instance(app, monkeypatch, config_class):
monkeypatch.delenv("STATIC_APP_NAME", raising=False)
monkeypatch.delenv("STATIC_APP_PORT", raising=False)
app.make("config").set("app", config_class())
assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Startkit", 8000)
monkeypatch.setenv("STATIC_APP_NAME", "Overridden")
monkeypatch.setenv("STATIC_APP_PORT", "9000")
app.make("config").set("app", config_class())
assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Overridden", 9000)


@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig])
def test_facade_reads_registered_config_class(app, monkeypatch, config_class):
monkeypatch.setenv("STATIC_APP_NAME", "Overridden")
app.make("config").set("app", config_class)
assert ConfigFacade.get("app.name") == "Overridden"
Loading
Loading