From e58dd9d74f83029282e8c45f079c3a41ac615364 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 23 Oct 2026 18:07:24 -0700 Subject: [PATCH 01/12] Resolve env() fields on Config subclasses without @dataclass Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- .../035-config-subclass-without-dataclass.md | 34 +++++++++++ docs/adr/AGENTS.md | 1 + .../src/fastapi_startkit/support/config.py | 9 ++- .../support/test_config_without_dataclass.py | 58 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 docs/adr/035-config-subclass-without-dataclass.md create mode 100644 fastapi_startkit/tests/support/test_config_without_dataclass.py diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md new file mode 100644 index 00000000..c20e393b --- /dev/null +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -0,0 +1,34 @@ +# 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 +``` + +`Config` has no `__init__` of its own, so the instance has no attribute and the lookup falls 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` eagerly in `__init_subclass__`. Subclasses that are also decorated with `@dataclass` would be processed twice; the first pass deletes the `Field` class attributes, so the second pass turns every `env()` field into a required argument. +3. Apply `dataclass` lazily in `_ConfigMeta.__call__`, the first time an unprocessed class is instantiated. Decorators have already run by then, so decorated classes are untouched. + +## Decision + +Option 3. Before instantiating, `_ConfigMeta.__call__` walks the class MRO from the base down and applies `dataclass` to every `Config` class whose own `__dict__` has no `__dataclass_fields__`. Undecorated parents are processed first so their `env()` fields are inherited as fields. Decorated classes, `Config.get` dotted access and `Configuration.set` (which uses `dataclasses.fields`) behave as before. Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. + +Class-level access (`DatabaseConfig.default`) on an undecorated class that has never been instantiated still returns the `Field`, because intercepting class attribute reads would also intercept `@dataclass`'s own reads of the class. Instantiating once, or decorating the class, gives resolved class-level access. + +## Validation + +- Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, an undecorated parent with an undecorated child, and a decorated class that is still processed exactly once. +- The reported repro prints `'sqlite'`. +- Full suite without Postgres passes with the coverage threshold. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index c13f7f1b..119549c0 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,3 +35,4 @@ 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:` 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 `:tag::key`, entries at `:sha1(ids):`, 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 work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. `_ConfigMeta.__call__` now applies `dataclass` lazily, at first instantiation, to every unprocessed `Config` class in the MRO; decorated classes are untouched and values are cast by the existing `env()` rules. Class-level access on a never-instantiated undecorated class still returns the `Field`. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index 5bb03380..af384047 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,4 +1,4 @@ -from dataclasses import Field, MISSING +from dataclasses import Field, MISSING, dataclass from typing import TypeVar, cast from fastapi_startkit.environment import value @@ -8,10 +8,17 @@ class _ConfigMeta(type): def __call__(cls: type[T], *args: object, **kwargs: object) -> T: + _ConfigMeta.ensure_dataclass(cls) 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)) + @staticmethod + def ensure_dataclass(cls: type) -> None: + for klass in reversed(cls.__mro__): + if isinstance(klass, _ConfigMeta) and "__dataclass_fields__" not in klass.__dict__: + dataclass(klass) + def __getattr__(cls, name: str) -> object: try: fields = cast(dict[str, Field[object]], type.__getattribute__(cls, "__dataclass_fields__")) diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py new file mode 100644 index 00000000..638f3420 --- /dev/null +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass, fields + +from fastapi_startkit.configuration import Config +from fastapi_startkit.environment import env + + +class PlainDatabaseConfig(Config): + default: str = env("PLAIN_DB_CONNECTION", "sqlite") + port: int = env("PLAIN_DB_PORT", 5432) + debug: bool = env("PLAIN_DB_DEBUG", False) + + +def test_fallback_is_returned_when_variable_is_unset(monkeypatch): + monkeypatch.delenv("PLAIN_DB_CONNECTION", raising=False) + assert PlainDatabaseConfig().default == "sqlite" + + +def test_variable_value_is_returned_when_set(monkeypatch): + monkeypatch.setenv("PLAIN_DB_CONNECTION", "pgsql") + assert PlainDatabaseConfig().default == "pgsql" + + +def test_values_are_cast_to_default_type(monkeypatch): + monkeypatch.setenv("PLAIN_DB_PORT", "6543") + monkeypatch.setenv("PLAIN_DB_DEBUG", "true") + config = PlainDatabaseConfig() + assert config.port == 6543 + assert config.debug is True + + +def test_keyword_arguments_override_environment(monkeypatch): + monkeypatch.setenv("PLAIN_DB_CONNECTION", "pgsql") + assert PlainDatabaseConfig(default="mysql").default == "mysql" + + +def test_undecorated_parent_fields_are_inherited(monkeypatch): + class Parent(Config): + host: str = env("PLAIN_PARENT_HOST", "localhost") + + class Child(Parent): + port: int = env("PLAIN_CHILD_PORT", 80) + + monkeypatch.setenv("PLAIN_PARENT_HOST", "example.test") + child = Child() + assert (child.host, child.port) == ("example.test", 80) + assert Parent().host == "example.test" + + +def test_decorated_class_is_not_processed_again(monkeypatch): + @dataclass + class Decorated(Config): + host: str = env("PLAIN_DECORATED_HOST", "localhost") + + original = Decorated.__dataclass_fields__ + monkeypatch.setenv("PLAIN_DECORATED_HOST", "example.test") + assert Decorated().host == "example.test" + assert Decorated.__dataclass_fields__ is original + assert [field.name for field in fields(Decorated)] == ["host"] From 28736968fd19ab26a8a3b650d172dd9c5d7a29d1 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 23 Oct 2026 18:21:01 -0700 Subject: [PATCH 02/12] Process undecorated Config ancestors at subclass time and keep Config base plain Decorated children now inherit fields from undecorated parents, Config itself is never a dataclass, automatic processing keeps identity hashing, and a lock prevents concurrent double processing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- .../035-config-subclass-without-dataclass.md | 20 +++-- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 25 ++++-- .../support/test_config_without_dataclass.py | 85 ++++++++++++++++++- 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index c20e393b..9aa2bd67 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -18,17 +18,27 @@ DatabaseConfig().default ## 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` eagerly in `__init_subclass__`. Subclasses that are also decorated with `@dataclass` would be processed twice; the first pass deletes the `Field` class attributes, so the second pass turns every `env()` field into a required argument. -3. Apply `dataclass` lazily in `_ConfigMeta.__call__`, the first time an unprocessed class is instantiated. Decorators have already run by then, so decorated classes are untouched. +2. Apply `dataclass` eagerly to every subclass in `__init_subclass__`. A subclass that is also decorated is then processed twice. The first pass deletes the `default_factory` class attributes, so the second pass reads them with `getattr`, which reaches `_ConfigMeta.__getattr__` and gets the resolved value. Every `env()` field is frozen to its value at import time, and any `field(default_factory=...)` returning a dict or list fails with `ValueError: mutable default ... use default_factory`. This was checked on Python 3.15. +3. Apply `dataclass` lazily, the first time an unprocessed class is instantiated or subclassed. By then the class's own decorator, if any, has already run, so no class is processed twice. ## Decision -Option 3. Before instantiating, `_ConfigMeta.__call__` walks the class MRO from the base down and applies `dataclass` to every `Config` class whose own `__dict__` has no `__dataclass_fields__`. Undecorated parents are processed first so their `env()` fields are inherited as fields. Decorated classes, `Config.get` dotted access and `Configuration.set` (which uses `dataclasses.fields`) behave as before. Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. +Option 3, in `support/config.py`: -Class-level access (`DatabaseConfig.default`) on an undecorated class that has never been instantiated still returns the `Field`, because intercepting class attribute reads would also intercept `@dataclass`'s own reads of the class. Instantiating once, or decorating the class, gives resolved class-level access. +- `_ConfigMeta.__call__` processes every unprocessed class in the instantiated class's MRO, base first, before building the instance. +- `Config.__init_subclass__` processes the new class's unprocessed ancestors before its own decorator runs. A `@dataclass` child of an undecorated parent therefore inherits the parent's fields; otherwise the parent would be processed after the child and its `Field` class attributes deleted from under the child. +- A class counts as unprocessed when it is a `Config` subclass, is not `Config` itself, and has no `__dataclass_fields__` in its own `__dict__`. `Config` is never made a dataclass, so it keeps identity equality and `object.__hash__`. +- Automatic processing uses `dataclass(eq=False)`, so undecorated subclasses keep the identity equality and hashing they had before. Classes the user decorates follow normal `@dataclass` rules. +- Processing runs under a module lock with a re-check inside it, so concurrent first instantiations process a class once. + +Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. Decorated classes, `Config.get` dotted access and `Configuration.set` behave as before. + +Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that has been neither instantiated nor subclassed still returns the `Field`. No hook runs between class creation and a following decorator, so processing the leaf earlier would bring back the double processing of option 2. ## Validation -- Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, an undecorated parent with an undecorated child, and a decorated class that is still processed exactly once. +- Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, and the environment being read again on every instantiation. +- Inheritance: an undecorated parent with an undecorated child, and a `@dataclass` child of an undecorated parent that keeps the parent's fields. +- `Config` is never a dataclass, undecorated subclass instances stay hashable, a decorated class is processed exactly once, and eight threads instantiating a new class at the same time process it once. - The reported repro prints `'sqlite'`. - Full suite without Postgres passes with the coverage threshold. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 119549c0..82362e57 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ 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:` 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 `:tag::key`, entries at `:sha1(ids):`, 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 work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. `_ConfigMeta.__call__` now applies `dataclass` lazily, at first instantiation, to every unprocessed `Config` class in the MRO; decorated classes are untouched and values are cast by the existing `env()` rules. Class-level access on a never-instantiated undecorated class still returns the `Field`. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Unprocessed `Config` classes now get `dataclass(eq=False)` lazily: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index af384047..2358d6f2 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,23 +1,34 @@ from dataclasses import Field, MISSING, dataclass +from threading import Lock from typing import TypeVar, cast from fastapi_startkit.environment import value T = TypeVar("T") +_dataclass_lock = Lock() + class _ConfigMeta(type): def __call__(cls: type[T], *args: object, **kwargs: object) -> T: - _ConfigMeta.ensure_dataclass(cls) + _ConfigMeta.ensure_dataclass(cls.__mro__) 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)) @staticmethod - def ensure_dataclass(cls: type) -> None: - for klass in reversed(cls.__mro__): - if isinstance(klass, _ConfigMeta) and "__dataclass_fields__" not in klass.__dict__: - dataclass(klass) + def ensure_dataclass(classes: tuple[type, ...]) -> None: + pending = [klass for klass in reversed(classes) if _ConfigMeta.is_undecorated(klass)] + if not pending: + return + with _dataclass_lock: + for klass in pending: + if _ConfigMeta.is_undecorated(klass): + dataclass(eq=False)(klass) + + @staticmethod + def is_undecorated(klass: type) -> bool: + return isinstance(klass, _ConfigMeta) and klass is not Config and "__dataclass_fields__" not in klass.__dict__ def __getattr__(cls, name: str) -> object: try: @@ -31,4 +42,6 @@ def __getattr__(cls, name: str) -> object: class Config(metaclass=_ConfigMeta): - pass + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + _ConfigMeta.ensure_dataclass(cls.__mro__[1:]) diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py index 638f3420..cbd60b64 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -1,5 +1,9 @@ -from dataclasses import dataclass, fields +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field, fields +from threading import Barrier +import fastapi_startkit.support.config as config_module from fastapi_startkit.configuration import Config from fastapi_startkit.environment import env @@ -56,3 +60,82 @@ class Decorated(Config): assert Decorated().host == "example.test" assert Decorated.__dataclass_fields__ is original assert [field.name for field in fields(Decorated)] == ["host"] + + +def test_decorated_child_inherits_undecorated_parent_fields(monkeypatch): + class Parent(Config): + host: str = env("PLAIN_PARENT_HOST", "localhost") + + @dataclass + class Child(Parent): + port: int = env("PLAIN_CHILD_PORT", 80) + + monkeypatch.setenv("PLAIN_PARENT_HOST", "example.test") + assert [field.name for field in fields(Child)] == ["host", "port"] + assert (Child().host, Child().port) == ("example.test", 80) + assert Child.host == "example.test" + + +def test_config_base_is_never_a_dataclass(): + class Plain(Config): + host: str = env("PLAIN_BASE_HOST", "localhost") + + Plain() + Config() + assert "__dataclass_fields__" not in Config.__dict__ + assert Config.__hash__ is object.__hash__ + first, second = Config(), Config() + assert first != second + assert hash(first) != hash(second) + + +def test_undecorated_subclass_instances_stay_hashable(): + class Hashable(Config): + host: str = env("PLAIN_HASH_HOST", "localhost") + + first, second = Hashable(), Hashable() + assert len({first, second, Config()}) == 3 + assert first != second + + +def test_environment_is_read_on_every_instantiation(monkeypatch): + class Live(Config): + connections: dict[str, str] = field(default_factory=lambda: {"default": "sqlite"}) + host: str = env("PLAIN_LIVE_HOST", "localhost") + + monkeypatch.setenv("PLAIN_LIVE_HOST", "first") + first = Live() + monkeypatch.setenv("PLAIN_LIVE_HOST", "second") + second = Live() + assert (first.host, second.host) == ("first", "second") + assert first.connections is not second.connections + + +def test_concurrent_first_instantiation_decorates_once(monkeypatch): + class Concurrent(Config): + connections: dict[str, str] = field(default_factory=lambda: {"default": "sqlite"}) + host: str = env("PLAIN_CONCURRENT_HOST", "localhost") + + calls: list[type] = [] + original = config_module.dataclass + + def counting_dataclass(**options: bool): + def decorate(klass: type) -> type: + calls.append(klass) + time.sleep(0.01) + return original(**options)(klass) + + return decorate + + monkeypatch.setattr(config_module, "dataclass", counting_dataclass) + barrier = Barrier(8) + + def build(_: int) -> Concurrent: + barrier.wait() + return Concurrent() + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(build, range(8))) + + assert calls == [Concurrent] + assert all(result.host == "localhost" for result in results) From 44b70c22b8344e46b4d4b83b9dcfec5d21236920 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 11:59:41 -0700 Subject: [PATCH 03/12] Only auto-process Config subclasses that declare Field defaults Classes without env() or field() defaults behave as on main, so plain mutable defaults, bare annotations and children of frozen dataclasses keep working. Children of frozen dataclass parents are processed as frozen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- .../035-config-subclass-without-dataclass.md | 6 +- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 19 +++-- .../support/test_config_without_dataclass.py | 77 ++++++++++++++++++- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 9aa2bd67..72273cfd 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -27,8 +27,9 @@ Option 3, in `support/config.py`: - `_ConfigMeta.__call__` processes every unprocessed class in the instantiated class's MRO, base first, before building the instance. - `Config.__init_subclass__` processes the new class's unprocessed ancestors before its own decorator runs. A `@dataclass` child of an undecorated parent therefore inherits the parent's fields; otherwise the parent would be processed after the child and its `Field` class attributes deleted from under the child. -- A class counts as unprocessed when it is a `Config` subclass, is not `Config` itself, and has no `__dataclass_fields__` in its own `__dict__`. `Config` is never made a dataclass, so it keeps identity equality and `object.__hash__`. -- Automatic processing uses `dataclass(eq=False)`, so undecorated subclasses keep the identity equality and hashing they had before. Classes the user decorates follow normal `@dataclass` rules. +- A class is processed only when it is a `Config` subclass, has no `__dataclass_fields__` in its own `__dict__`, and its own `__dict__` holds a `dataclasses.Field` (an `env()` or `field()` default). Every other class is left exactly as before, so plain mutable defaults (`opts: dict = {}`), bare annotations (`name: str`) and undecorated children of frozen dataclasses keep working, and `Config` itself, which holds no `Field`, is never a dataclass. +- A skipped class in the middle of the hierarchy does not block inheritance. Its processed ancestor's `__init__` and `__dataclass_fields__` are inherited, so instantiating it resolves the ancestor's `env()` fields, and a processed descendant collects the ancestor's fields through it. The skipped class's own annotations are not dataclass fields; its plain class attributes are still read through the class. +- Automatic processing uses `dataclass(eq=False)`, so undecorated subclasses keep the identity equality and hashing they had before, and `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. Classes the user decorates follow normal `@dataclass` rules. - Processing runs under a module lock with a re-check inside it, so concurrent first instantiations process a class once. Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. Decorated classes, `Config.get` dotted access and `Configuration.set` behave as before. @@ -40,5 +41,6 @@ Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that - Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, and the environment being read again on every instantiation. - Inheritance: an undecorated parent with an undecorated child, and a `@dataclass` child of an undecorated parent that keeps the parent's fields. - `Config` is never a dataclass, undecorated subclass instances stay hashable, a decorated class is processed exactly once, and eight threads instantiating a new class at the same time process it once. +- Classes without a `Field` are untouched: an undecorated child of a frozen dataclass, a plain `dict` default and a bare annotation behave as before. An `env()` child of a frozen parent is processed as frozen. In a mixed hierarchy (`env()` base, plain middle class with a bare annotation and a dict default, `env()` leaf) only the base and leaf are processed, and the middle class still resolves the base's fields. - The reported repro prints `'sqlite'`. - Full suite without Postgres passes with the coverage threshold. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 82362e57..2783f14d 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ 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:` 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 `:tag::key`, entries at `:sha1(ids):`, 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 work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Unprocessed `Config` classes now get `dataclass(eq=False)` lazily: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Undecorated `Config` classes whose own body holds a `Field` (an `env()` or `field()` default) now get `dataclass(eq=False)` lazily, frozen when the dataclass parent is frozen; all other classes are untouched: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index 2358d6f2..43c2fc58 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -18,17 +18,26 @@ def __call__(cls: type[T], *args: object, **kwargs: object) -> T: @staticmethod def ensure_dataclass(classes: tuple[type, ...]) -> None: - pending = [klass for klass in reversed(classes) if _ConfigMeta.is_undecorated(klass)] + pending = [klass for klass in reversed(classes) if _ConfigMeta.needs_dataclass(klass)] if not pending: return with _dataclass_lock: for klass in pending: - if _ConfigMeta.is_undecorated(klass): - dataclass(eq=False)(klass) + if _ConfigMeta.needs_dataclass(klass): + dataclass(eq=False, frozen=_ConfigMeta.has_frozen_base(klass))(klass) @staticmethod - def is_undecorated(klass: type) -> bool: - return isinstance(klass, _ConfigMeta) and klass is not Config and "__dataclass_fields__" not in klass.__dict__ + def needs_dataclass(klass: type) -> bool: + return ( + isinstance(klass, _ConfigMeta) + and "__dataclass_fields__" not in klass.__dict__ + and any(isinstance(attribute, Field) for attribute in klass.__dict__.values()) + ) + + @staticmethod + def has_frozen_base(klass: type) -> bool: + params = getattr(klass, "__dataclass_params__", None) + return params is not None and params.frozen def __getattr__(cls, name: str) -> object: try: diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py index cbd60b64..c00b5aa2 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -1,8 +1,10 @@ import time from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field, fields +from dataclasses import FrozenInstanceError, dataclass, field, fields from threading import Barrier +import pytest + import fastapi_startkit.support.config as config_module from fastapi_startkit.configuration import Config from fastapi_startkit.environment import env @@ -139,3 +141,76 @@ def build(_: int) -> Concurrent: assert calls == [Concurrent] assert all(result.host == "localhost" for result in results) + + +def test_undecorated_child_of_frozen_parent_is_left_alone(): + @dataclass(frozen=True) + class Frozen(Config): + host: str = "localhost" + + class Child(Frozen): + pass + + assert "__dataclass_fields__" not in Child.__dict__ + assert Child().host == "localhost" + + +def test_env_child_of_frozen_parent_is_frozen(monkeypatch): + @dataclass(frozen=True) + class Frozen(Config): + host: str = "localhost" + + class Child(Frozen): + port: int = env("PLAIN_FROZEN_PORT", 80) + + monkeypatch.setenv("PLAIN_FROZEN_PORT", "8080") + child = Child() + assert (child.host, child.port) == ("localhost", 8080) + with pytest.raises(FrozenInstanceError): + child.port = 1 + + +def test_plain_mutable_default_is_left_alone(): + class Options(Config): + opts: dict[str, str] = {} + + assert "__dataclass_fields__" not in Options.__dict__ + assert Options().opts == {} + + +def test_bare_annotation_is_left_alone(): + class Named(Config): + name: str + + assert "__dataclass_fields__" not in Named.__dict__ + assert not hasattr(Named(), "name") + + +def test_mixed_inheritance_processes_only_env_classes(monkeypatch): + class Base(Config): + host: str = env("PLAIN_MIXED_HOST", "localhost") + + class Middle(Base): + name: str + opts: dict[str, str] = {} + + class Leaf(Middle): + port: int = env("PLAIN_MIXED_PORT", 80) + + monkeypatch.setenv("PLAIN_MIXED_HOST", "example.test") + leaf = Leaf() + assert "__dataclass_fields__" not in Middle.__dict__ + assert [field.name for field in fields(Leaf)] == ["host", "port"] + assert (leaf.host, leaf.port, leaf.opts) == ("example.test", 80, {}) + assert Middle().host == "example.test" + + +def test_undecorated_child_without_env_inherits_resolved_parent(monkeypatch): + class Parent(Config): + host: str = env("PLAIN_INHERIT_HOST", "localhost") + + class Child(Parent): + timeout: int = 5 + + monkeypatch.setenv("PLAIN_INHERIT_HOST", "example.test") + assert (Child().host, Child().timeout) == ("example.test", 5) From 41fca3bb8b94a31437671b0cf472ed2c393f333a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 12:00:03 -0700 Subject: [PATCH 04/12] Document mixed env and mutable default bodies in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 72273cfd..3c095700 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -34,6 +34,8 @@ Option 3, in `support/config.py`: Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. Decorated classes, `Config.get` dotted access and `Configuration.set` behave as before. +A single class body that mixes `env()` fields with a plain mutable default (`opts: dict = {}`) is processed and raises the usual dataclass `ValueError`; before this change such a class returned raw `Field`s, so it never worked. Use `field(default_factory=dict)` there. + Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that has been neither instantiated nor subclassed still returns the `Field`. No hook runs between class creation and a following decorator, so processing the leaf earlier would bring back the double processing of option 2. ## Validation From 0e727f712d3ae309b9c1cfc1c8f4b3150082b4c9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 12:03:47 -0700 Subject: [PATCH 05/12] Note frozen dataclass children of undecorated env() parents in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 3c095700..881dc48a 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -36,6 +36,8 @@ Values are cast by the existing `env()` rules: the default's type decides the ca A single class body that mixes `env()` fields with a plain mutable default (`opts: dict = {}`) is processed and raises the usual dataclass `ValueError`; before this change such a class returned raw `Field`s, so it never worked. Use `field(default_factory=dict)` there. +A `@dataclass(frozen=True)` child of an undecorated `env()` parent raises `TypeError: cannot inherit frozen dataclass from a non-frozen one` at class definition, because `__init_subclass__` processes the parent as non-frozen before the child's decorator runs and cannot see it. Put `@dataclass(frozen=True)` on the parent instead. + Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that has been neither instantiated nor subclassed still returns the `Field`. No hook runs between class creation and a following decorator, so processing the leaf earlier would bring back the double processing of option 2. ## Validation From 7faa62a1a9180c7df1afe57e9e35623e4a4cd4c4 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:00:33 -0700 Subject: [PATCH 06/12] Process plain-default Config subclasses without @dataclass Configs whose annotations all have defaults, such as a custom storage disk, were skipped because only classes holding a Field were processed, so they rejected keyword arguments and never converted to dicts. Unhashable plain defaults are now copied per instance instead of raising. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- .../035-config-subclass-without-dataclass.md | 22 +++++- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 41 +++++++--- .../support/test_config_without_dataclass.py | 75 ++++++++++++++++++- 4 files changed, 123 insertions(+), 17 deletions(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 881dc48a..bbcec914 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -27,24 +27,38 @@ Option 3, in `support/config.py`: - `_ConfigMeta.__call__` processes every unprocessed class in the instantiated class's MRO, base first, before building the instance. - `Config.__init_subclass__` processes the new class's unprocessed ancestors before its own decorator runs. A `@dataclass` child of an undecorated parent therefore inherits the parent's fields; otherwise the parent would be processed after the child and its `Field` class attributes deleted from under the child. -- A class is processed only when it is a `Config` subclass, has no `__dataclass_fields__` in its own `__dict__`, and its own `__dict__` holds a `dataclasses.Field` (an `env()` or `field()` default). Every other class is left exactly as before, so plain mutable defaults (`opts: dict = {}`), bare annotations (`name: str`) and undecorated children of frozen dataclasses keep working, and `Config` itself, which holds no `Field`, is never a dataclass. +- A class is processed when it is a `Config` subclass, has no `__dataclass_fields__` in its own `__dict__`, and either its own `__dict__` holds a `dataclasses.Field` (an `env()` or `field()` default) or it declares annotations that all have defaults (`driver: str = "memory"`). Every other class is left exactly as before, so bare annotations (`name: str`) and undecorated children of frozen dataclasses with no annotations of their own keep working, and `Config` itself, which declares nothing, is never a dataclass. Own annotations are read with `annotationlib.get_annotations(format=FORWARDREF)` on Python 3.14+ and from `__dict__["__annotations__"]` before that. +- Before processing, a plain default whose type is unhashable (`opts: dict = {}`, lists, sets) becomes `field(default_factory=partial(deepcopy, default))`, the case `@dataclass` itself rejects. Each instance gets its own copy instead of sharing the class's object. - A skipped class in the middle of the hierarchy does not block inheritance. Its processed ancestor's `__init__` and `__dataclass_fields__` are inherited, so instantiating it resolves the ancestor's `env()` fields, and a processed descendant collects the ancestor's fields through it. The skipped class's own annotations are not dataclass fields; its plain class attributes are still read through the class. - Automatic processing uses `dataclass(eq=False)`, so undecorated subclasses keep the identity equality and hashing they had before, and `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. Classes the user decorates follow normal `@dataclass` rules. - Processing runs under a module lock with a re-check inside it, so concurrent first instantiations process a class once. Values are cast by the existing `env()` rules: the default's type decides the cast, so `env("DB_PORT", 5432)` returns an `int`. Decorated classes, `Config.get` dotted access and `Configuration.set` behave as before. -A single class body that mixes `env()` fields with a plain mutable default (`opts: dict = {}`) is processed and raises the usual dataclass `ValueError`; before this change such a class returned raw `Field`s, so it never worked. Use `field(default_factory=dict)` there. - A `@dataclass(frozen=True)` child of an undecorated `env()` parent raises `TypeError: cannot inherit frozen dataclass from a non-frozen one` at class definition, because `__init_subclass__` processes the parent as non-frozen before the child's decorator runs and cannot see it. Put `@dataclass(frozen=True)` on the parent instead. Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that has been neither instantiated nor subclassed still returns the `Field`. No hook runs between class creation and a following decorator, so processing the leaf earlier would bring back the double processing of option 2. +## Bug fix: plain-default configs without `@dataclass` + +The first version processed only classes whose own body held a `Field`. A config with only plain defaults, such as a custom storage disk, was skipped once `@dataclass` was removed: + +```python +class MemoryDiskConfig(Config): + driver: str = "memory" + prefix: str = "" + +MemoryDiskConfig(prefix="app/") # TypeError: MemoryDiskConfig() takes no arguments +``` + +`MemoryDiskConfig()` also stayed a plain object, so `Configuration.set` and `Dataclass.to_dict` left it unconverted and `StorageManager.get_config_options(...).get("driver")` failed. The rule now also processes classes whose annotations all have defaults, and unhashable plain defaults are copied per instance, so a single body that mixes `env()` fields with `opts: dict = {}` no longer raises `ValueError` either. + ## Validation - Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, and the environment being read again on every instantiation. - Inheritance: an undecorated parent with an undecorated child, and a `@dataclass` child of an undecorated parent that keeps the parent's fields. - `Config` is never a dataclass, undecorated subclass instances stay hashable, a decorated class is processed exactly once, and eight threads instantiating a new class at the same time process it once. -- Classes without a `Field` are untouched: an undecorated child of a frozen dataclass, a plain `dict` default and a bare annotation behave as before. An `env()` child of a frozen parent is processed as frozen. In a mixed hierarchy (`env()` base, plain middle class with a bare annotation and a dict default, `env()` leaf) only the base and leaf are processed, and the middle class still resolves the base's fields. +- An undecorated child of a frozen dataclass with no annotations of its own and a bare annotation are untouched. A plain `dict` default is copied per instance, also when it shares a body with `env()` fields. A plain-default class without `env()` accepts keyword arguments, converts with `Dataclass.to_dict` and keeps class-level access. +- Storage custom driver: an undecorated `MemoryDiskConfig` in an undecorated `StorageConfig` resolves through `StorageManager.disk("memory")` and `disk()`, and reads `env()` values. An `env()` child of a frozen parent is processed as frozen. In a mixed hierarchy (`env()` base, plain middle class with a bare annotation and a dict default, `env()` leaf) only the base and leaf are processed, and the middle class still resolves the base's fields. - The reported repro prints `'sqlite'`. - Full suite without Postgres passes with the coverage threshold. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 2783f14d..7c6a7fa3 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ 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:` 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 `:tag::key`, entries at `:sha1(ids):`, 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 work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Undecorated `Config` classes whose own body holds a `Field` (an `env()` or `field()` default) now get `dataclass(eq=False)` lazily, frozen when the dataclass parent is frozen; all other classes are untouched: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Undecorated `Config` classes whose own body holds a `Field` (an `env()` or `field()` default) or whose annotations all have defaults now get `dataclass(eq=False)` lazily, frozen when the dataclass parent is frozen, with unhashable plain defaults copied per instance; classes with bare annotations are untouched. Bug fix: the first rule required a `Field`, so plain-default configs like a custom storage disk failed without `@dataclass`: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index 43c2fc58..d73d82e4 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,9 +1,23 @@ -from dataclasses import Field, MISSING, dataclass +from copy import deepcopy +from dataclasses import Field, MISSING, dataclass, field +from functools import partial from threading import Lock from typing import TypeVar, cast from fastapi_startkit.environment import value +try: + from annotationlib import Format, get_annotations + + def _own_annotations(klass: type) -> dict[str, object]: + return get_annotations(klass, format=Format.FORWARDREF) + +except ImportError: + + def _own_annotations(klass: type) -> dict[str, object]: + return klass.__dict__.get("__annotations__", {}) + + T = TypeVar("T") _dataclass_lock = Lock() @@ -24,15 +38,24 @@ def ensure_dataclass(classes: tuple[type, ...]) -> None: with _dataclass_lock: for klass in pending: if _ConfigMeta.needs_dataclass(klass): + _ConfigMeta.copy_unhashable_defaults(klass) dataclass(eq=False, frozen=_ConfigMeta.has_frozen_base(klass))(klass) @staticmethod def needs_dataclass(klass: type) -> bool: - return ( - isinstance(klass, _ConfigMeta) - and "__dataclass_fields__" not in klass.__dict__ - and any(isinstance(attribute, Field) for attribute in klass.__dict__.values()) - ) + if not isinstance(klass, _ConfigMeta) or "__dataclass_fields__" in klass.__dict__: + return False + if any(isinstance(attribute, Field) for attribute in klass.__dict__.values()): + return True + names = _own_annotations(klass) + return bool(names) and all(name in klass.__dict__ for name in names) + + @staticmethod + def copy_unhashable_defaults(klass: type) -> None: + for name in _own_annotations(klass): + default = klass.__dict__.get(name, MISSING) + if default is not MISSING and not isinstance(default, Field) and type(default).__hash__ is None: + setattr(klass, name, field(default_factory=partial(deepcopy, default))) @staticmethod def has_frozen_base(klass: type) -> bool: @@ -44,9 +67,9 @@ def __getattr__(cls, name: str) -> object: 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}") diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py index c00b5aa2..32398988 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -8,6 +8,18 @@ import fastapi_startkit.support.config as config_module from fastapi_startkit.configuration import Config from fastapi_startkit.environment import env +from fastapi_startkit.storage.storage import StorageManager +from fastapi_startkit.support.dataclass import Dataclass + + +def test_reported_database_config_without_dataclass(monkeypatch): + class DatabaseConfig(Config): + default: str = env("DB_CONNECTION", "sqlite") + + monkeypatch.delenv("DB_CONNECTION", raising=False) + assert DatabaseConfig().default == "sqlite" + monkeypatch.setenv("DB_CONNECTION", "mysql") + assert DatabaseConfig().default == "mysql" class PlainDatabaseConfig(Config): @@ -170,12 +182,36 @@ class Child(Frozen): child.port = 1 -def test_plain_mutable_default_is_left_alone(): +def test_plain_mutable_default_is_copied_per_instance(): class Options(Config): + opts: dict[str, list[str]] = {"tags": []} + + first, second = Options(), Options() + first.opts["tags"].append("changed") + assert second.opts == {"tags": []} + assert Options(opts={"tags": ["given"]}).opts == {"tags": ["given"]} + + +def test_env_fields_with_plain_mutable_default_in_same_body(monkeypatch): + class Mixed(Config): + host: str = env("PLAIN_MIXED_BODY_HOST", "localhost") opts: dict[str, str] = {} - assert "__dataclass_fields__" not in Options.__dict__ - assert Options().opts == {} + monkeypatch.setenv("PLAIN_MIXED_BODY_HOST", "example.test") + first, second = Mixed(), Mixed() + assert (first.host, first.opts) == ("example.test", {}) + assert first.opts is not second.opts + + +def test_plain_defaults_without_env_are_processed(): + class Plain(Config): + driver: str = "memory" + prefix: str = "" + + config = Plain(prefix="app/") + assert (config.driver, config.prefix) == ("memory", "app/") + assert Dataclass.to_dict(config) == {"driver": "memory", "prefix": "app/"} + assert Plain.driver == "memory" def test_bare_annotation_is_left_alone(): @@ -214,3 +250,36 @@ class Child(Parent): monkeypatch.setenv("PLAIN_INHERIT_HOST", "example.test") assert (Child().host, Child().timeout) == ("example.test", 5) + + +class MemoryDriver: + def __init__(self) -> None: + self.options: dict[str, object] = {} + + def set_options(self, options: dict[str, object]) -> "MemoryDriver": + self.options = options + return self + + +class MemoryDiskConfig(Config): + driver: str = "memory" + prefix: str = env("MEMORY_DISK_PREFIX", "") + + +class CustomStorageConfig(Config): + default: str = env("FILESYSTEM_DISK", "memory") + disks: dict[str, Config] = field(default_factory=lambda: {"memory": MemoryDiskConfig(prefix="app/")}) + + +def test_storage_custom_disk_config_without_dataclass(monkeypatch): + monkeypatch.delenv("FILESYSTEM_DISK", raising=False) + manager = StorageManager(None, Dataclass.to_dict(CustomStorageConfig())) + manager.add_driver("memory", MemoryDriver()) + + assert manager.disk("memory").options == {"driver": "memory", "prefix": "app/"} + assert manager.disk().options == {"driver": "memory", "prefix": "app/"} + + +def test_storage_custom_disk_reads_environment(monkeypatch): + monkeypatch.setenv("MEMORY_DISK_PREFIX", "env/") + assert Dataclass.to_dict(MemoryDiskConfig()) == {"driver": "memory", "prefix": "env/"} From 8cfe8195ba01154fda324c0715f4ee6e514f85f0 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:13:00 -0700 Subject: [PATCH 07/12] Limit plain-default Config processing to hashable defaults Skip ClassVar and InitVar annotations in real and string form, leave classes with unhashable plain defaults or a custom __init__ as on main, and copy only exact dict/list/set defaults in Field-bearing classes, shallowly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- .../035-config-subclass-without-dataclass.md | 18 ++- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 64 ++++++-- .../support/future_annotation_configs.py | 20 +++ .../support/test_config_without_dataclass.py | 151 ++++++++++++++++-- 5 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 fastapi_startkit/tests/support/future_annotation_configs.py diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index bbcec914..97e52718 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -27,8 +27,9 @@ Option 3, in `support/config.py`: - `_ConfigMeta.__call__` processes every unprocessed class in the instantiated class's MRO, base first, before building the instance. - `Config.__init_subclass__` processes the new class's unprocessed ancestors before its own decorator runs. A `@dataclass` child of an undecorated parent therefore inherits the parent's fields; otherwise the parent would be processed after the child and its `Field` class attributes deleted from under the child. -- A class is processed when it is a `Config` subclass, has no `__dataclass_fields__` in its own `__dict__`, and either its own `__dict__` holds a `dataclasses.Field` (an `env()` or `field()` default) or it declares annotations that all have defaults (`driver: str = "memory"`). Every other class is left exactly as before, so bare annotations (`name: str`) and undecorated children of frozen dataclasses with no annotations of their own keep working, and `Config` itself, which declares nothing, is never a dataclass. Own annotations are read with `annotationlib.get_annotations(format=FORWARDREF)` on Python 3.14+ and from `__dict__["__annotations__"]` before that. -- Before processing, a plain default whose type is unhashable (`opts: dict = {}`, lists, sets) becomes `field(default_factory=partial(deepcopy, default))`, the case `@dataclass` itself rejects. Each instance gets its own copy instead of sharing the class's object. +- A class is processed when it is a `Config` subclass, has no `__dataclass_fields__` in its own `__dict__`, its `__init__` does not come from a custom, non-dataclass class in its MRO, and either its own `__dict__` holds a `dataclasses.Field` (an `env()` or `field()` default) or it declares field annotations that all have hashable defaults (`driver: str = "memory"`). Every other class is left exactly as on main, so bare annotations (`name: str`), plain mutable or unhashable defaults (`registry: dict = {}`, an object holding a `Lock`), custom `__init__` methods and undecorated children of frozen dataclasses with no annotations of their own keep working, and `Config` itself, which declares nothing, is never a dataclass. Own annotations are read with `annotationlib.get_annotations(format=FORWARDREF)` on Python 3.14+ and from `__dict__["__annotations__"]` before that. +- `ClassVar` and `InitVar` annotations are not counted as fields, in both real and string form (`from __future__ import annotations`). A string is resolved against the class's module the way `@dataclass` resolves it, so `"ClassVar[...]"` and `"typing.ClassVar[...]"` count only when the name resolves to `typing.ClassVar`; an unresolved string is treated as a field, which leaves a class with a mutable default unprocessed instead of failing. +- In a class that holds a `Field`, a plain default whose type is exactly `dict`, `list` or `set` becomes `field(default_factory=default.copy)`, the case `@dataclass` itself rejects. The copy is shallow: each instance gets its own container, but the objects inside it are shared. No other default is copied. - A skipped class in the middle of the hierarchy does not block inheritance. Its processed ancestor's `__init__` and `__dataclass_fields__` are inherited, so instantiating it resolves the ancestor's `env()` fields, and a processed descendant collects the ancestor's fields through it. The skipped class's own annotations are not dataclass fields; its plain class attributes are still read through the class. - Automatic processing uses `dataclass(eq=False)`, so undecorated subclasses keep the identity equality and hashing they had before, and `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. Classes the user decorates follow normal `@dataclass` rules. - Processing runs under a module lock with a re-check inside it, so concurrent first instantiations process a class once. @@ -51,14 +52,23 @@ class MemoryDiskConfig(Config): MemoryDiskConfig(prefix="app/") # TypeError: MemoryDiskConfig() takes no arguments ``` -`MemoryDiskConfig()` also stayed a plain object, so `Configuration.set` and `Dataclass.to_dict` left it unconverted and `StorageManager.get_config_options(...).get("driver")` failed. The rule now also processes classes whose annotations all have defaults, and unhashable plain defaults are copied per instance, so a single body that mixes `env()` fields with `opts: dict = {}` no longer raises `ValueError` either. +`MemoryDiskConfig()` also stayed a plain object, so `Configuration.set` and `Dataclass.to_dict` left it unconverted and `StorageManager.get_config_options(...).get("driver")` failed. The rule now also processes classes whose annotations all have defaults, and a body that mixes `env()` fields with `opts: dict = {}` no longer raises `ValueError` either. + +The first version of this fix processed any class whose annotations all had defaults and deep-copied unhashable defaults. That changed classes that work on main: + +- `registry: ClassVar[dict] = {}` was counted as a field and raised `ValueError`/`TypeError`, also next to `env()` fields and under string annotations. +- A shared class-level `registry: dict = {}` was copied for every instance, so mutations through the class were lost. +- A class with its own `__init__` and `opts: dict = {}` got a generated `__init__` that replaced it. +- An unhashable object that cannot be copied, such as one holding a `threading.Lock`, failed in `deepcopy`. + +The plain-default branch is now limited to hashable defaults in classes without a custom `__init__`, `ClassVar`/`InitVar` are skipped, and only exact `dict`/`list`/`set` defaults in `Field`-bearing classes are copied, shallowly. A class with a custom `__init__` is left as on main even when it uses `env()`: its `env()` attributes stay raw `Field` objects unless its `__init__` sets them, so such a class should either set them itself or use `@dataclass`. ## Validation - Tests for an undecorated subclass: the fallback value without the variable, the variable's value when set, `int` and `bool` casting, explicit keyword arguments, and the environment being read again on every instantiation. - Inheritance: an undecorated parent with an undecorated child, and a `@dataclass` child of an undecorated parent that keeps the parent's fields. - `Config` is never a dataclass, undecorated subclass instances stay hashable, a decorated class is processed exactly once, and eight threads instantiating a new class at the same time process it once. -- An undecorated child of a frozen dataclass with no annotations of its own and a bare annotation are untouched. A plain `dict` default is copied per instance, also when it shares a body with `env()` fields. A plain-default class without `env()` accepts keyword arguments, converts with `Dataclass.to_dict` and keeps class-level access. +- An undecorated child of a frozen dataclass with no annotations of its own and a bare annotation are untouched. A plain `dict` default alone is left unprocessed and shared through the class; next to `env()` fields it is copied shallowly per instance. `ClassVar` defaults next to plain and `env()` fields, string `ClassVar`/`InitVar` annotations, a module using `from __future__ import annotations` and an unresolvable string annotation are covered, on Python 3.15, 3.13 and 3.12. Classes with a custom `__init__` (own or inherited) and classes with an unhashable object default holding a `Lock` are left as on main. A plain-default class without `env()` accepts keyword arguments, converts with `Dataclass.to_dict` and keeps class-level access. - Storage custom driver: an undecorated `MemoryDiskConfig` in an undecorated `StorageConfig` resolves through `StorageManager.disk("memory")` and `disk()`, and reads `env()` values. An `env()` child of a frozen parent is processed as frozen. In a mixed hierarchy (`env()` base, plain middle class with a bare annotation and a dict default, `env()` leaf) only the base and leaf are processed, and the middle class still resolves the base's fields. - The reported repro prints `'sqlite'`. - Full suite without Postgres passes with the coverage threshold. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 7c6a7fa3..f8c76a95 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ 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:` 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 `:tag::key`, entries at `:sha1(ids):`, 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 work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Undecorated `Config` classes whose own body holds a `Field` (an `env()` or `field()` default) or whose annotations all have defaults now get `dataclass(eq=False)` lazily, frozen when the dataclass parent is frozen, with unhashable plain defaults copied per instance; classes with bare annotations are untouched. Bug fix: the first rule required a `Field`, so plain-default configs like a custom storage disk failed without `@dataclass`: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses work without `@dataclass` | A `Config` subclass without `@dataclass` kept `env()`'s raw `Field` as a class attribute, so `DatabaseConfig().default` returned the `Field`. Undecorated `Config` classes whose own body holds a `Field` (an `env()` or `field()` default) or whose field annotations all have hashable defaults now get `dataclass(eq=False)` lazily, frozen when the dataclass parent is frozen; `ClassVar`/`InitVar` (real or string) are skipped, and only exact `dict`/`list`/`set` defaults in `Field`-bearing classes are copied, shallowly. Classes with bare annotations, unhashable plain defaults or a custom `__init__` are left as on main. Bug fix: the first rule required a `Field`, so plain-default configs like a custom storage disk failed without `@dataclass`: `_ConfigMeta.__call__` processes the MRO at first instantiation, and `Config.__init_subclass__` processes undecorated ancestors so `@dataclass` children inherit their fields. `Config` itself is never a dataclass, undecorated instances stay hashable, and a lock keeps concurrent first instantiations from processing a class twice. Eager processing was rejected because a second `@dataclass` pass freezes `env()` values and fails on mutable defaults. Class-level access on an undecorated leaf that was never instantiated or subclassed still returns the `Field`. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index d73d82e4..4d8438ff 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,8 +1,7 @@ -from copy import deepcopy -from dataclasses import Field, MISSING, dataclass, field -from functools import partial +import sys +from dataclasses import Field, InitVar, MISSING, dataclass, field from threading import Lock -from typing import TypeVar, cast +from typing import ClassVar, TypeVar, cast, get_origin from fastapi_startkit.environment import value @@ -21,6 +20,32 @@ def _own_annotations(klass: type) -> dict[str, object]: T = TypeVar("T") _dataclass_lock = Lock() +_COPIED_DEFAULT_TYPES = (dict, list, set) + + +def _resolve_string_annotation(klass: type, annotation: str) -> object: + module = sys.modules.get(klass.__module__) + namespace = vars(module) if module is not None else {} + owner, _, name = annotation.split("[", 1)[0].strip().rpartition(".") + if owner: + return getattr(namespace.get(owner), name, None) + return namespace.get(name) + + +def _is_pseudo_field(klass: type, annotation: object) -> bool: + if isinstance(annotation, str): + annotation = _resolve_string_annotation(klass, annotation) + return ( + isinstance(annotation, InitVar) or annotation is InitVar or (get_origin(annotation) or annotation) is ClassVar + ) + + +def _field_names(klass: type) -> list[str]: + return [name for name, annotation in _own_annotations(klass).items() if not _is_pseudo_field(klass, annotation)] + + +def _is_hashable(default: object) -> bool: + return type(default).__hash__ is not None class _ConfigMeta(type): @@ -38,24 +63,39 @@ def ensure_dataclass(classes: tuple[type, ...]) -> None: with _dataclass_lock: for klass in pending: if _ConfigMeta.needs_dataclass(klass): - _ConfigMeta.copy_unhashable_defaults(klass) + _ConfigMeta.copy_container_defaults(klass) dataclass(eq=False, frozen=_ConfigMeta.has_frozen_base(klass))(klass) @staticmethod def needs_dataclass(klass: type) -> bool: if not isinstance(klass, _ConfigMeta) or "__dataclass_fields__" in klass.__dict__: return False - if any(isinstance(attribute, Field) for attribute in klass.__dict__.values()): + if _ConfigMeta.has_custom_init(klass): + return False + if _ConfigMeta.declares_field(klass): return True - names = _own_annotations(klass) - return bool(names) and all(name in klass.__dict__ for name in names) + defaults = [klass.__dict__.get(name, MISSING) for name in _field_names(klass)] + return bool(defaults) and all(default is not MISSING and _is_hashable(default) for default in defaults) + + @staticmethod + def declares_field(klass: type) -> bool: + return any(isinstance(attribute, Field) for attribute in klass.__dict__.values()) @staticmethod - def copy_unhashable_defaults(klass: type) -> None: - for name in _own_annotations(klass): + def has_custom_init(klass: type) -> bool: + for base in klass.__mro__: + if "__init__" in base.__dict__: + return base is not object and "__dataclass_fields__" not in base.__dict__ + return False + + @staticmethod + def copy_container_defaults(klass: type) -> None: + if not _ConfigMeta.declares_field(klass): + return + for name in _field_names(klass): default = klass.__dict__.get(name, MISSING) - if default is not MISSING and not isinstance(default, Field) and type(default).__hash__ is None: - setattr(klass, name, field(default_factory=partial(deepcopy, default))) + if type(default) in _COPIED_DEFAULT_TYPES: + setattr(klass, name, field(default_factory=cast(dict[object, object], default).copy)) @staticmethod def has_frozen_base(klass: type) -> bool: diff --git a/fastapi_startkit/tests/support/future_annotation_configs.py b/fastapi_startkit/tests/support/future_annotation_configs.py new file mode 100644 index 00000000..9843dbdc --- /dev/null +++ b/fastapi_startkit/tests/support/future_annotation_configs.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import typing +from dataclasses import InitVar +from typing import ClassVar + +from fastapi_startkit.configuration import Config +from fastapi_startkit.environment import env + + +class FutureRegistry(Config): + registry: ClassVar[dict[str, int]] = {} + aliases: typing.ClassVar[list[str]] = [] + seed: InitVar[int] = 0 + driver: str = "memory" + + +class FutureEnvRegistry(Config): + registry: ClassVar[dict[str, int]] = {} + host: str = env("FUTURE_CLASSVAR_HOST", "localhost") diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py index 32398988..155a0403 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -1,7 +1,9 @@ import time +import typing from concurrent.futures import ThreadPoolExecutor -from dataclasses import FrozenInstanceError, dataclass, field, fields -from threading import Barrier +from dataclasses import Field, FrozenInstanceError, InitVar, dataclass, field, fields +from threading import Barrier, Lock +from typing import ClassVar import pytest @@ -10,6 +12,7 @@ from fastapi_startkit.environment import env from fastapi_startkit.storage.storage import StorageManager from fastapi_startkit.support.dataclass import Dataclass +from tests.support.future_annotation_configs import FutureEnvRegistry, FutureRegistry def test_reported_database_config_without_dataclass(monkeypatch): @@ -182,14 +185,132 @@ class Child(Frozen): child.port = 1 -def test_plain_mutable_default_is_copied_per_instance(): - class Options(Config): - opts: dict[str, list[str]] = {"tags": []} +def test_plain_mutable_default_is_left_alone_and_shared(): + class Shared(Config): + registry: dict[str, int] = {} - first, second = Options(), Options() - first.opts["tags"].append("changed") - assert second.opts == {"tags": []} - assert Options(opts={"tags": ["given"]}).opts == {"tags": ["given"]} + first = Shared() + Shared.registry["key"] = 1 + assert "__dataclass_fields__" not in Shared.__dict__ + assert (Shared.registry, Shared().registry, first.registry) == ({"key": 1}, {"key": 1}, {"key": 1}) + + +def test_classvar_mutable_default_next_to_plain_fields(): + class Registry(Config): + registry: ClassVar[dict[str, int]] = {} + driver: str = "memory" + + first = Registry(driver="other") + Registry.registry["key"] = 1 + assert [field.name for field in fields(Registry)] == ["driver"] + assert (first.driver, Registry().driver) == ("other", "memory") + assert first.registry is Registry.registry + + +def test_classvar_mutable_default_next_to_env_fields(monkeypatch): + class Registry(Config): + registry: ClassVar[dict[str, int]] = {} + host: str = env("PLAIN_CLASSVAR_HOST", "localhost") + + monkeypatch.setenv("PLAIN_CLASSVAR_HOST", "example.test") + assert Registry().host == "example.test" + assert Registry().registry is Registry.registry + + +def test_string_classvar_and_initvar_annotations_are_skipped(): + class Registry(Config): + registry: "ClassVar[dict[str, int]]" = {} + aliases: "typing.ClassVar[list[str]]" = [] + seed: "InitVar[int]" = 0 + driver: str = "memory" + + instance = Registry(seed=1) + assert [field.name for field in fields(Registry)] == ["driver"] + assert instance.registry is Registry.registry + + +def test_future_annotations_classvar_and_initvar_are_skipped(): + registry = FutureRegistry(driver="other") + assert [field.name for field in fields(FutureRegistry)] == ["driver"] + assert (registry.driver, registry.registry is FutureRegistry.registry) == ("other", True) + + +def test_future_annotations_classvar_next_to_env_field(monkeypatch): + monkeypatch.setenv("FUTURE_CLASSVAR_HOST", "example.test") + assert FutureEnvRegistry().host == "example.test" + assert FutureEnvRegistry().registry is FutureEnvRegistry.registry + + +def test_unresolvable_string_classvar_is_left_alone(): + class Registry(Config): + aliases: "unknown_module.ClassVar[list[str]]" = [] + driver: str = "memory" + + assert Registry().aliases == [] + assert "__dataclass_fields__" not in Registry.__dict__ + + +def test_classvar_alone_is_left_alone(): + class Registry(Config): + registry: ClassVar[dict[str, int]] = {} + + assert Registry().registry == {} + assert "__dataclass_fields__" not in Registry.__dict__ + + +def test_custom_init_is_left_alone(): + class Custom(Config): + opts: dict[str, int] = {} + driver: str = "memory" + + def __init__(self) -> None: + pass + + assert (Custom().opts, Custom().driver) == ({}, "memory") + assert "__dataclass_fields__" not in Custom.__dict__ + + +def test_custom_init_with_env_field_is_left_alone(): + class Custom(Config): + host: str = env("PLAIN_CUSTOM_INIT_HOST", "localhost") + + def __init__(self) -> None: + pass + + assert "__dataclass_fields__" not in Custom.__dict__ + assert isinstance(Custom.__dict__["host"], Field) + + +def test_child_of_custom_init_parent_keeps_parent_init(): + class Parent(Config): + def __init__(self) -> None: + self.ready = True + + class Child(Parent): + driver: str = "memory" + + child = Child() + assert (child.ready, child.driver) == (True, "memory") + assert "__dataclass_fields__" not in Child.__dict__ + + +class Holder: + def __init__(self) -> None: + self.lock = Lock() + + def __eq__(self, other: object) -> bool: + return isinstance(other, Holder) + + +def test_unhashable_object_default_is_shared_not_copied(): + holder = Holder() + + class WithClient(Config): + client: Holder = holder + driver: str = "memory" + + assert WithClient().client is holder + assert "__dataclass_fields__" not in WithClient.__dict__ def test_env_fields_with_plain_mutable_default_in_same_body(monkeypatch): @@ -203,6 +324,18 @@ class Mixed(Config): assert first.opts is not second.opts +def test_container_default_next_to_env_field_is_copied_shallowly(): + holder = Holder() + + class Mixed(Config): + host: str = env("PLAIN_SHALLOW_HOST", "localhost") + clients: dict[str, Holder] = {"main": holder} + + first, second = Mixed(), Mixed() + assert first.clients is not second.clients + assert first.clients["main"] is holder + + def test_plain_defaults_without_env_are_processed(): class Plain(Config): driver: str = "memory" From 6180bcd53e16e87d54469e8dddd8a1328d4280b4 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:19:01 -0700 Subject: [PATCH 08/12] Note class-level overrides after processing in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 97e52718..eee710dd 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -40,6 +40,8 @@ A `@dataclass(frozen=True)` child of an undecorated `env()` parent raises `TypeE Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that has been neither instantiated nor subclassed still returns the `Field`. No hook runs between class creation and a following decorator, so processing the leaf earlier would bring back the double processing of option 2. +Once a plain-default class has been processed, its defaults are baked into the generated `__init__`. A class-level override made after the first instantiation or subclassing (`MemoryDiskConfig.driver = "redis"`, or `monkeypatch.setattr` on the class) changes what the class attribute returns but is ignored by new instances, which still get `"memory"`. This is standard `@dataclass` behaviour; pass the value as a keyword argument instead (`MemoryDiskConfig(driver="redis")`). + ## Bug fix: plain-default configs without `@dataclass` The first version processed only classes whose own body held a `Field`. A config with only plain defaults, such as a custom storage disk, was skipped once `@dataclass` was removed: From aec11d594182e357bedc859b8dffe2137252f9f8 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:19:23 -0700 Subject: [PATCH 09/12] Document container-default edges for env() configs in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index eee710dd..1ba5ea61 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -42,6 +42,12 @@ Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that Once a plain-default class has been processed, its defaults are baked into the generated `__init__`. A class-level override made after the first instantiation or subclassing (`MemoryDiskConfig.driver = "redis"`, or `monkeypatch.setattr` on the class) changes what the class attribute returns but is ignored by new instances, which still get `"memory"`. This is standard `@dataclass` behaviour; pass the value as a keyword argument instead (`MemoryDiskConfig(driver="redis")`). +For a class that holds a `Field` and also has a plain container default, the copied default has three edges. None of them is a regression, because such a body raised `ValueError` before: + +- `registry: dict = {}` becomes a `default_factory`, so class-level `Cls.registry` returns a new dict on every access and writes through the class are lost. Keep shared state in a `ClassVar`. +- The copy is shallow, so nested containers (`{"tags": []}`) are shared between instances. Use `field(default_factory=...)` when nested values must be independent. +- Only exact `dict`, `list` and `set` are copied. A subclass default such as `OrderedDict()` is passed to `@dataclass` unchanged and raises `ValueError: mutable default ... use default_factory`. Use `field(default_factory=OrderedDict)`. + ## Bug fix: plain-default configs without `@dataclass` The first version processed only classes whose own body held a `Field`. A config with only plain defaults, such as a custom storage disk, was skipped once `@dataclass` was removed: From 41f784b9d010c0394b95ceb448ca32430575a027 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:19:46 -0700 Subject: [PATCH 10/12] Compare container-default edges against main in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 1ba5ea61..f51f371c 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -42,7 +42,7 @@ Class-level access (`DatabaseConfig.default`) on an undecorated leaf class that Once a plain-default class has been processed, its defaults are baked into the generated `__init__`. A class-level override made after the first instantiation or subclassing (`MemoryDiskConfig.driver = "redis"`, or `monkeypatch.setattr` on the class) changes what the class attribute returns but is ignored by new instances, which still get `"memory"`. This is standard `@dataclass` behaviour; pass the value as a keyword argument instead (`MemoryDiskConfig(driver="redis")`). -For a class that holds a `Field` and also has a plain container default, the copied default has three edges. None of them is a regression, because such a body raised `ValueError` before: +For a class that holds a `Field` and also has a plain container default, the copied default has three edges. None of them is a regression: on main these classes were never processed, so their `env()` fields returned raw `Field` objects and the feature did not work for them at all: - `registry: dict = {}` becomes a `default_factory`, so class-level `Cls.registry` returns a new dict on every access and writes through the class are lost. Keep shared state in a `ClassVar`. - The copy is shallow, so nested containers (`{"tags": []}`) are shared between instances. Use `field(default_factory=...)` when nested values must be independent. From e0075ac44d5e6357207f15e0eb74e746e79f526d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 17:20:01 -0700 Subject: [PATCH 11/12] Clarify the bug-fix baseline in ADR 035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1 --- docs/adr/035-config-subclass-without-dataclass.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index f51f371c..355d1ae1 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -60,7 +60,7 @@ class MemoryDiskConfig(Config): MemoryDiskConfig(prefix="app/") # TypeError: MemoryDiskConfig() takes no arguments ``` -`MemoryDiskConfig()` also stayed a plain object, so `Configuration.set` and `Dataclass.to_dict` left it unconverted and `StorageManager.get_config_options(...).get("driver")` failed. The rule now also processes classes whose annotations all have defaults, and a body that mixes `env()` fields with `opts: dict = {}` no longer raises `ValueError` either. +`MemoryDiskConfig()` also stayed a plain object, so `Configuration.set` and `Dataclass.to_dict` left it unconverted and `StorageManager.get_config_options(...).get("driver")` failed. The rule now also processes classes whose annotations all have defaults, and a body that mixes `env()` fields with `opts: dict = {}` no longer raises the `ValueError` the first version raised (on main its `env()` fields returned raw `Field` objects). The first version of this fix processed any class whose annotations all had defaults and deep-copied unhashable defaults. That changed classes that work on main: From 95421b598bd64c91e185083163dcd428b1c8e3a3 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:26:12 -0700 Subject: [PATCH 12/12] Guard annotationlib import with a version check for type checking Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww --- fastapi_startkit/src/fastapi_startkit/support/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index 4d8438ff..db49c52a 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,19 +1,20 @@ import sys +from collections.abc import Mapping from dataclasses import Field, InitVar, MISSING, dataclass, field from threading import Lock from typing import ClassVar, TypeVar, cast, get_origin from fastapi_startkit.environment import value -try: +if sys.version_info >= (3, 14): from annotationlib import Format, get_annotations - def _own_annotations(klass: type) -> dict[str, object]: + def _own_annotations(klass: type) -> Mapping[str, object]: return get_annotations(klass, format=Format.FORWARDREF) -except ImportError: +else: - def _own_annotations(klass: type) -> dict[str, object]: + def _own_annotations(klass: type) -> Mapping[str, object]: return klass.__dict__.get("__annotations__", {})