From e58dd9d74f83029282e8c45f079c3a41ac615364 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 23 Oct 2026 18:07:24 -0700 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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__", {}) From 7d421d2f218f671d175cdf8e9398460b5207b5b5 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 14:39:00 -0700 Subject: [PATCH 13/15] Add static access tests for Config subclasses --- .../support/test_config_static_access.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 fastapi_startkit/tests/support/test_config_static_access.py diff --git a/fastapi_startkit/tests/support/test_config_static_access.py b/fastapi_startkit/tests/support/test_config_static_access.py new file mode 100644 index 00000000..90f69077 --- /dev/null +++ b/fastapi_startkit/tests/support/test_config_static_access.py @@ -0,0 +1,84 @@ +from dataclasses import Field, dataclass + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.configuration import Config +from fastapi_startkit.configuration.config import Config as ConfigFacade +from fastapi_startkit.container.container import Container +from fastapi_startkit.environment import env + + +@pytest.fixture +def app(tmp_path): + original = Container._instance + yield Application(base_path=tmp_path, env="testing") + Container._instance = original + + +class StaticAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) + + +@dataclass +class DecoratedStaticAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) + + +def test_class_level_access_without_instantiating(monkeypatch): + class FreshAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + assert not isinstance(FreshAppConfig.name, Field) + assert FreshAppConfig.name == "Startkit" + + +def test_class_level_access_on_undecorated_config(monkeypatch): + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + monkeypatch.delenv("STATIC_APP_PORT", raising=False) + assert StaticAppConfig.name == "Startkit" + assert StaticAppConfig.port == 8000 + + +def test_class_level_access_on_decorated_config(monkeypatch): + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + monkeypatch.delenv("STATIC_APP_PORT", raising=False) + assert DecoratedStaticAppConfig.name == "Startkit" + assert DecoratedStaticAppConfig.port == 8000 + + +def test_class_level_access_reads_overridden_environment(monkeypatch): + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + monkeypatch.setenv("STATIC_APP_PORT", "9000") + assert StaticAppConfig.name == "Overridden" + assert StaticAppConfig.port == 9000 + assert DecoratedStaticAppConfig.name == "Overridden" + assert DecoratedStaticAppConfig.port == 9000 + + +@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) +def test_facade_reads_registered_config_instance(app, monkeypatch, config_class): + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + monkeypatch.delenv("STATIC_APP_PORT", raising=False) + app.make("config").set("app", config_class()) + assert ConfigFacade.get("app.name") == "Startkit" + assert ConfigFacade.get("app.port") == 8000 + + +@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) +def test_facade_reads_registered_config_class(app, monkeypatch, config_class): + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + app.make("config").set("app", config_class) + assert ConfigFacade.get("app.name") == "Startkit" + + +@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) +def test_facade_reads_overridden_environment(app, monkeypatch, config_class): + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + monkeypatch.setenv("STATIC_APP_PORT", "9000") + app.make("config").set("app", config_class()) + assert ConfigFacade.get("app.name") == "Overridden" + assert ConfigFacade.get("app.port") == 9000 From 7015a7f1096968d416ba73a3e1326e2d29841b49 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 14:48:34 -0700 Subject: [PATCH 14/15] Make Config subclasses dataclasses at definition time Config.__init_subclass__ applies dataclass to every subclass so class-level access resolves env() values without an instance. An explicit @dataclass restores the original namespace before re-processing, so its options apply and env() fields stay live. Drops the lazy first-instantiation path and the plain-default, ClassVar, custom __init__ and container-copy handling. --- .../035-config-subclass-without-dataclass.md | 72 ++--- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 117 ++------ .../support/future_annotation_configs.py | 20 -- .../support/test_config_without_dataclass.py | 272 +++--------------- 5 files changed, 85 insertions(+), 398 deletions(-) delete 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 355d1ae1..4443833c 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -11,72 +11,36 @@ class DatabaseConfig(Config): default: str = env("DB_CONNECTION", "sqlite") DatabaseConfig().default +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. +Both lookups fall back to the class attribute, which is the raw `Field`. The value is never read from the environment or cast. ## Alternatives 1. Make `env()` return a descriptor instead of a `Field`. This breaks dataclass configs, which need a `Field` to get a per-instance `default_factory`, and changes `value()` and the `_ConfigMeta` lookups. -2. Apply `dataclass` 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. +2. Apply `dataclass` lazily, the first time a class is instantiated or subclassed. Instances work, but class-level access (`DatabaseConfig.default`) on a class that was never instantiated still returns the `Field`, and the lazy path needs a lock, MRO walks and rules for which classes to process. +3. Apply `dataclass` to every subclass when it is defined, in `Config.__init_subclass__`. Class-level access works immediately. The risk is a subclass that is also decorated with `@dataclass`: it is processed twice, and the second pass reads the defaults through `getattr`, which returns resolved `env()` values instead of `Field` objects, freezing them at import time and rejecting mutable `default_factory` results. ## Decision Option 3, in `support/config.py`: -- `_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__`, 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. +- `Config.__init_subclass__` records the class namespace, with copies of its `Field` objects, and applies `dataclass(eq=False)` to the new class. It uses `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. A class that already has its own `__dataclass_fields__` (the class `@dataclass(slots=True)` rebuilds) is skipped. +- An explicit `@dataclass` re-processes the class harmlessly. `dataclass` starts every pass by setting `__dataclass_params__` on the class, so `_ConfigMeta.__setattr__` uses that write to restore the recorded namespace: attributes added by the automatic pass are deleted and fresh copies of the original `Field` objects are put back. The decorator then processes the class as if it ran alone, so its options (`eq`, `frozen`, `kw_only`, ...) apply and `env()` values stay live. The namespace is restored only once, on the first pass after definition. +- `Config` is decorated with `typing.dataclass_transform(eq_default=False, field_specifiers=(field, Field))`, so type checkers treat undecorated subclasses as dataclasses. +- `Config` itself is not a dataclass. An empty dataclass base would make every `@dataclass(frozen=True)` subclass fail with `cannot inherit frozen dataclass from a non-frozen one`. +- `eq=False` keeps the identity equality and hashing undecorated subclasses had before. +- `_ConfigMeta` keeps resolving `env()` arguments in `__call__` and resolving `default_factory` fields on class-level access in `__getattr__`. The lazy processing, its lock and its class-selection rules are removed. -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 `@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. - -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: 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. -- 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: - -```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 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: - -- `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`. +Every undecorated subclass now follows dataclass rules at definition time: a bare annotation (`name: str`) is a required argument, a plain mutable default (`opts: dict = {}`) raises `ValueError`, and an inherited custom `__init__` is replaced by the generated one, exactly as with `@dataclass`. Use `ClassVar` for shared class state and `field(default_factory=...)` for mutable defaults. ## 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 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. +- Static access: `AppConfig.name` on an undecorated class that was never instantiated returns the resolved value, for both undecorated and decorated classes, and a monkeypatched variable is read on the next access. `Config.get("app.name")` returns the value for a registered instance or class, and reflects an overridden variable. +- An undecorated subclass is a dataclass at definition: fallback value, variable value, `int`/`bool` casting, keyword arguments, and the environment being read on every instantiation. +- Explicit `@dataclass`: fields are collected once, `env()` stays live on the class and on instances, `default_factory` containers are per instance, and `frozen`, `kw_only` and `eq` from the decorator apply. +- Inheritance: undecorated parent and child, a `@dataclass` child of an undecorated parent, and undecorated children of a frozen parent are frozen. +- `Config` is not a dataclass, and undecorated instances stay hashable. +- Storage: an undecorated `MemoryDiskConfig` in an undecorated storage config resolves through `StorageManager`. +- Full suite without Postgres and basedpyright pass. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index f8c76a95..dab10620 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 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`. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses are dataclasses at definition | An undecorated `Config` subclass kept `env()`'s raw `Field`, so `DatabaseConfig().default` and `DatabaseConfig.default` returned the `Field`. `Config.__init_subclass__` now applies `dataclass(eq=False)` (frozen under a frozen parent) when the class is defined, and `Config` carries `dataclass_transform`; `Config` itself stays a plain class. An explicit `@dataclass` re-processes harmlessly: its first `__dataclass_params__` write makes `_ConfigMeta.__setattr__` restore the pre-dataclass namespace with fresh `Field` copies, so the decorator's options apply and `env()` stays live. The lazy first-instantiation path and its lock are removed. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index db49c52a..d69d52c1 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,107 +1,47 @@ -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 copy import copy +from dataclasses import Field, MISSING, dataclass, field +from typing import TypeVar, cast, dataclass_transform +from weakref import WeakKeyDictionary from fastapi_startkit.environment import value -if sys.version_info >= (3, 14): - from annotationlib import Format, get_annotations - - def _own_annotations(klass: type) -> Mapping[str, object]: - return get_annotations(klass, format=Format.FORWARDREF) - -else: - - def _own_annotations(klass: type) -> Mapping[str, object]: - return klass.__dict__.get("__annotations__", {}) - - T = TypeVar("T") -_dataclass_lock = Lock() -_COPIED_DEFAULT_TYPES = (dict, list, set) - +_DATACLASS_PARAMS = "__dataclass_params__" +_pre_dataclass_namespaces: WeakKeyDictionary[type, dict[str, object]] = WeakKeyDictionary() -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 _copy_fields(namespace: dict[str, object]) -> dict[str, object]: + return { + name: copy(attribute) if isinstance(attribute, Field) else attribute for name, attribute in namespace.items() + } -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 +def _has_frozen_base(klass: type) -> bool: + params = getattr(klass, _DATACLASS_PARAMS, None) + return params is not None and params.frozen class _ConfigMeta(type): def __call__(cls: type[T], *args: object, **kwargs: object) -> T: - _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(classes: tuple[type, ...]) -> None: - 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.needs_dataclass(klass): - _ConfigMeta.copy_container_defaults(klass) - dataclass(eq=False, frozen=_ConfigMeta.has_frozen_base(klass))(klass) + def __setattr__(cls, name: str, attribute: object) -> None: + if name == _DATACLASS_PARAMS: + namespace = _pre_dataclass_namespaces.pop(cls, None) + if namespace is not None: + _ConfigMeta.restore_namespace(cls, namespace) + super().__setattr__(name, attribute) @staticmethod - def needs_dataclass(klass: type) -> bool: - if not isinstance(klass, _ConfigMeta) or "__dataclass_fields__" in klass.__dict__: - return False - if _ConfigMeta.has_custom_init(klass): - return False - if _ConfigMeta.declares_field(klass): - return True - 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 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 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: - params = getattr(klass, "__dataclass_params__", None) - return params is not None and params.frozen + def restore_namespace(klass: type, namespace: dict[str, object]) -> None: + for name in [name for name in klass.__dict__ if name not in namespace]: + type.__delattr__(klass, name) + for name, attribute in _copy_fields(namespace).items(): + if klass.__dict__.get(name, MISSING) is not namespace[name]: + type.__setattr__(klass, name, attribute) def __getattr__(cls, name: str) -> object: try: @@ -114,7 +54,12 @@ def __getattr__(cls, name: str) -> object: raise AttributeError(f"{cls.__name__!r} has no attribute {name!r}") +@dataclass_transform(eq_default=False, field_specifiers=(field, Field)) class Config(metaclass=_ConfigMeta): def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) - _ConfigMeta.ensure_dataclass(cls.__mro__[1:]) + if "__dataclass_fields__" in cls.__dict__: + return + namespace = _copy_fields(dict(cls.__dict__)) + dataclass(eq=False, frozen=_has_frozen_base(cls))(cls) + _pre_dataclass_namespaces[cls] = namespace diff --git a/fastapi_startkit/tests/support/future_annotation_configs.py b/fastapi_startkit/tests/support/future_annotation_configs.py deleted file mode 100644 index 9843dbdc..00000000 --- a/fastapi_startkit/tests/support/future_annotation_configs.py +++ /dev/null @@ -1,20 +0,0 @@ -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 155a0403..90445be6 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -1,18 +1,11 @@ -import time -import typing -from concurrent.futures import ThreadPoolExecutor -from dataclasses import Field, FrozenInstanceError, InitVar, dataclass, field, fields -from threading import Barrier, Lock -from typing import ClassVar +from dataclasses import FrozenInstanceError, dataclass, field, fields, is_dataclass import pytest -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 -from tests.support.future_annotation_configs import FutureEnvRegistry, FutureRegistry def test_reported_database_config_without_dataclass(monkeypatch): @@ -31,6 +24,14 @@ class PlainDatabaseConfig(Config): debug: bool = env("PLAIN_DB_DEBUG", False) +def test_subclass_is_a_dataclass_at_definition(): + class Defined(Config): + host: str = env("PLAIN_DEFINED_HOST", "localhost") + + assert "__dataclass_fields__" in Defined.__dict__ + assert [declared.name for declared in fields(Defined)] == ["host"] + + def test_fallback_is_returned_when_variable_is_unset(monkeypatch): monkeypatch.delenv("PLAIN_DB_CONNECTION", raising=False) assert PlainDatabaseConfig().default == "sqlite" @@ -65,18 +66,33 @@ class Child(Parent): child = Child() assert (child.host, child.port) == ("example.test", 80) assert Parent().host == "example.test" + assert Child.host == "example.test" -def test_decorated_class_is_not_processed_again(monkeypatch): +def test_explicit_dataclass_reprocessing_keeps_env_live(monkeypatch): @dataclass class Decorated(Config): host: str = env("PLAIN_DECORATED_HOST", "localhost") + connections: dict[str, str] = field(default_factory=lambda: {"default": "sqlite"}) - original = Decorated.__dataclass_fields__ monkeypatch.setenv("PLAIN_DECORATED_HOST", "example.test") + assert [declared.name for declared in fields(Decorated)] == ["host", "connections"] + assert Decorated.host == "example.test" assert Decorated().host == "example.test" - assert Decorated.__dataclass_fields__ is original - assert [field.name for field in fields(Decorated)] == ["host"] + assert Decorated().connections is not Decorated().connections + + +def test_explicit_dataclass_options_replace_automatic_ones(): + @dataclass(frozen=True, kw_only=True) + class Decorated(Config): + host: str = env("PLAIN_OPTIONS_HOST", "localhost") + + config = Decorated(host="example.test") + assert config == Decorated(host="example.test") + with pytest.raises(TypeError): + Decorated("positional") + with pytest.raises(FrozenInstanceError): + config.host = "changed" def test_decorated_child_inherits_undecorated_parent_fields(monkeypatch): @@ -88,7 +104,7 @@ 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 [declared.name for declared in fields(Child)] == ["host", "port"] assert (Child().host, Child().port) == ("example.test", 80) assert Child.host == "example.test" @@ -98,8 +114,7 @@ class Plain(Config): host: str = env("PLAIN_BASE_HOST", "localhost") Plain() - Config() - assert "__dataclass_fields__" not in Config.__dict__ + assert not is_dataclass(Config) assert Config.__hash__ is object.__hash__ first, second = Config(), Config() assert first != second @@ -128,37 +143,7 @@ class Live(Config): 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) - - -def test_undecorated_child_of_frozen_parent_is_left_alone(): +def test_undecorated_child_of_frozen_parent_is_frozen(): @dataclass(frozen=True) class Frozen(Config): host: str = "localhost" @@ -166,8 +151,10 @@ class Frozen(Config): class Child(Frozen): pass - assert "__dataclass_fields__" not in Child.__dict__ - assert Child().host == "localhost" + child = Child() + assert child.host == "localhost" + with pytest.raises(FrozenInstanceError): + child.host = "changed" def test_env_child_of_frozen_parent_is_frozen(monkeypatch): @@ -185,195 +172,6 @@ class Child(Frozen): child.port = 1 -def test_plain_mutable_default_is_left_alone_and_shared(): - class Shared(Config): - registry: dict[str, int] = {} - - 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): - class Mixed(Config): - host: str = env("PLAIN_MIXED_BODY_HOST", "localhost") - opts: dict[str, str] = {} - - 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_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" - 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(): - 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") From 52b485d04c75cc19158835a4c7ab1ce24490a7df Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 15:14:47 -0700 Subject: [PATCH 15/15] Undo only the automatic Config dataclass pass and skip annotation-free bases An explicit @dataclass now reverts just the attributes the automatic pass changed, so attributes set by other decorators or after definition survive. Subclasses without their own annotations stay plain, so frozen dataclass children of mixin bases keep working. Static-access tests are deduplicated. --- .../035-config-subclass-without-dataclass.md | 14 +++-- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/support/config.py | 49 +++++++++++----- .../support/test_config_static_access.py | 43 +++----------- .../support/test_config_without_dataclass.py | 56 +++++++++++++++++++ 5 files changed, 109 insertions(+), 55 deletions(-) diff --git a/docs/adr/035-config-subclass-without-dataclass.md b/docs/adr/035-config-subclass-without-dataclass.md index 4443833c..ddffd101 100644 --- a/docs/adr/035-config-subclass-without-dataclass.md +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -26,21 +26,25 @@ Both lookups fall back to the class attribute, which is the raw `Field`. The val Option 3, in `support/config.py`: -- `Config.__init_subclass__` records the class namespace, with copies of its `Field` objects, and applies `dataclass(eq=False)` to the new class. It uses `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. A class that already has its own `__dataclass_fields__` (the class `@dataclass(slots=True)` rebuilds) is skipped. -- An explicit `@dataclass` re-processes the class harmlessly. `dataclass` starts every pass by setting `__dataclass_params__` on the class, so `_ConfigMeta.__setattr__` uses that write to restore the recorded namespace: attributes added by the automatic pass are deleted and fresh copies of the original `Field` objects are put back. The decorator then processes the class as if it ran alone, so its options (`eq`, `frozen`, `kw_only`, ...) apply and `env()` values stay live. The namespace is restored only once, on the first pass after definition. +- `Config.__init_subclass__` applies `dataclass(eq=False)` to every subclass that declares its own annotations, and records each class attribute the pass added, replaced or deleted, keeping copies of the original `Field` objects. It uses `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. A subclass without its own annotations (a method-only or mixin base) stays a plain class, so a `@dataclass(frozen=True)` child of it still works, and its annotated children are processed normally. A class that already has its own `__dataclass_fields__` (the class `@dataclass(slots=True)` rebuilds) is skipped. +- An explicit `@dataclass` re-processes the class harmlessly. `dataclass` starts every pass by setting `__dataclass_params__` on the class, so `_ConfigMeta.__setattr__` uses that write to undo the automatic pass: each recorded attribute that still holds the value the pass left is deleted or set back to its original, and fresh copies of the original `Field` objects are put back. Attributes set between definition and the decorator (by another decorator or by assignment) are untouched. The decorator then processes the class as if it ran alone, so its options (`eq`, `frozen`, `kw_only`, ...) apply and `env()` values stay live. The undo runs only once, on the first pass after definition. - `Config` is decorated with `typing.dataclass_transform(eq_default=False, field_specifiers=(field, Field))`, so type checkers treat undecorated subclasses as dataclasses. - `Config` itself is not a dataclass. An empty dataclass base would make every `@dataclass(frozen=True)` subclass fail with `cannot inherit frozen dataclass from a non-frozen one`. - `eq=False` keeps the identity equality and hashing undecorated subclasses had before. - `_ConfigMeta` keeps resolving `env()` arguments in `__call__` and resolving `default_factory` fields on class-level access in `__getattr__`. The lazy processing, its lock and its class-selection rules are removed. -Every undecorated subclass now follows dataclass rules at definition time: a bare annotation (`name: str`) is a required argument, a plain mutable default (`opts: dict = {}`) raises `ValueError`, and an inherited custom `__init__` is replaced by the generated one, exactly as with `@dataclass`. Use `ClassVar` for shared class state and `field(default_factory=...)` for mutable defaults. +Every undecorated subclass that declares annotations now follows dataclass rules at definition time: a bare annotation (`name: str`) is a required argument, a plain mutable default (`opts: dict = {}`) raises `ValueError`, and an inherited custom `__init__` is replaced by the generated one, exactly as with `@dataclass`. Use `ClassVar` for shared class state and `field(default_factory=...)` for mutable defaults. + +## Risks + +The undo relies on `dataclasses` setting `__dataclass_params__` on the class before it reads the field defaults. This is an implementation detail, verified on Python 3.12 to 3.15. If a future version changes the order, an explicit `@dataclass` would read resolved `env()` values and freeze them; `test_explicit_dataclass_reprocessing_keeps_env_live` is the canary that fails first. ## Validation - Static access: `AppConfig.name` on an undecorated class that was never instantiated returns the resolved value, for both undecorated and decorated classes, and a monkeypatched variable is read on the next access. `Config.get("app.name")` returns the value for a registered instance or class, and reflects an overridden variable. - An undecorated subclass is a dataclass at definition: fallback value, variable value, `int`/`bool` casting, keyword arguments, and the environment being read on every instantiation. -- Explicit `@dataclass`: fields are collected once, `env()` stays live on the class and on instances, `default_factory` containers are per instance, and `frozen`, `kw_only` and `eq` from the decorator apply. +- Explicit `@dataclass`: attributes set by an inner decorator or after definition survive, fields are collected once, `env()` stays live on the class and on instances, `default_factory` containers are per instance, and `frozen`, `kw_only` and `eq` from the decorator apply. - Inheritance: undecorated parent and child, a `@dataclass` child of an undecorated parent, and undecorated children of a frozen parent are frozen. -- `Config` is not a dataclass, and undecorated instances stay hashable. +- `Config` and annotation-free bases are not dataclasses, a `@dataclass(frozen=True)` child of an annotation-free base works, its `env()` children have static access, and undecorated instances stay hashable. - Storage: an undecorated `MemoryDiskConfig` in an undecorated storage config resolves through `StorageManager`. - Full suite without Postgres and basedpyright pass. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index dd61cf4d..58fe175d 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,5 +35,5 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` 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 are dataclasses at definition | An undecorated `Config` subclass kept `env()`'s raw `Field`, so `DatabaseConfig().default` and `DatabaseConfig.default` returned the `Field`. `Config.__init_subclass__` now applies `dataclass(eq=False)` (frozen under a frozen parent) when the class is defined, and `Config` carries `dataclass_transform`; `Config` itself stays a plain class. An explicit `@dataclass` re-processes harmlessly: its first `__dataclass_params__` write makes `_ConfigMeta.__setattr__` restore the pre-dataclass namespace with fresh `Field` copies, so the decorator's options apply and `env()` stays live. The lazy first-instantiation path and its lock are removed. | +| [035](035-config-subclass-without-dataclass.md) | 2026-10-23 | Config subclasses are dataclasses at definition | An undecorated `Config` subclass kept `env()`'s raw `Field`, so `DatabaseConfig().default` and `DatabaseConfig.default` returned the `Field`. `Config.__init_subclass__` now applies `dataclass(eq=False)` (frozen under a frozen parent) when a subclass with its own annotations is defined, and `Config` carries `dataclass_transform`; `Config` itself stays a plain class. An explicit `@dataclass` re-processes harmlessly: its first `__dataclass_params__` write makes `_ConfigMeta.__setattr__` undo only what the automatic pass changed, with fresh `Field` copies, so the decorator's options apply and `env()` stays live. The lazy first-instantiation path and its lock are removed. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | diff --git a/fastapi_startkit/src/fastapi_startkit/support/config.py b/fastapi_startkit/src/fastapi_startkit/support/config.py index d69d52c1..916889f9 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -8,15 +8,32 @@ T = TypeVar("T") _DATACLASS_PARAMS = "__dataclass_params__" -_pre_dataclass_namespaces: WeakKeyDictionary[type, dict[str, object]] = WeakKeyDictionary() +_OWN_ANNOTATION_KEYS = ("__annotations__", "__annotate_func__") +_Changes = dict[str, tuple[object, object]] +_automatic_changes: WeakKeyDictionary[type, _Changes] = WeakKeyDictionary() -def _copy_fields(namespace: dict[str, object]) -> dict[str, object]: + +def _snapshot(klass: type) -> dict[str, object]: return { - name: copy(attribute) if isinstance(attribute, Field) else attribute for name, attribute in namespace.items() + name: copy(attribute) if isinstance(attribute, Field) else attribute + for name, attribute in klass.__dict__.items() } +def _changes(before: dict[str, object], klass: type) -> _Changes: + after = dict(klass.__dict__) + return { + name: (after.get(name, MISSING), before.get(name, MISSING)) + for name in before.keys() | after.keys() + if after.get(name, MISSING) is not before.get(name, MISSING) + } + + +def _declares_annotations(klass: type) -> bool: + return any(key in klass.__dict__ for key in _OWN_ANNOTATION_KEYS) + + def _has_frozen_base(klass: type) -> bool: params = getattr(klass, _DATACLASS_PARAMS, None) return params is not None and params.frozen @@ -30,18 +47,20 @@ def __call__(cls: type[T], *args: object, **kwargs: object) -> T: def __setattr__(cls, name: str, attribute: object) -> None: if name == _DATACLASS_PARAMS: - namespace = _pre_dataclass_namespaces.pop(cls, None) - if namespace is not None: - _ConfigMeta.restore_namespace(cls, namespace) + changes = _automatic_changes.pop(cls, None) + if changes is not None: + _ConfigMeta.undo_automatic_pass(cls, changes) super().__setattr__(name, attribute) @staticmethod - def restore_namespace(klass: type, namespace: dict[str, object]) -> None: - for name in [name for name in klass.__dict__ if name not in namespace]: - type.__delattr__(klass, name) - for name, attribute in _copy_fields(namespace).items(): - if klass.__dict__.get(name, MISSING) is not namespace[name]: - type.__setattr__(klass, name, attribute) + def undo_automatic_pass(klass: type, changes: _Changes) -> None: + for name, (after, before) in changes.items(): + if klass.__dict__.get(name, MISSING) is not after: + continue + if before is MISSING: + type.__delattr__(klass, name) + else: + type.__setattr__(klass, name, copy(before) if isinstance(before, Field) else before) def __getattr__(cls, name: str) -> object: try: @@ -58,8 +77,8 @@ def __getattr__(cls, name: str) -> object: class Config(metaclass=_ConfigMeta): def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) - if "__dataclass_fields__" in cls.__dict__: + if "__dataclass_fields__" in cls.__dict__ or not _declares_annotations(cls): return - namespace = _copy_fields(dict(cls.__dict__)) + before = _snapshot(cls) dataclass(eq=False, frozen=_has_frozen_base(cls))(cls) - _pre_dataclass_namespaces[cls] = namespace + _automatic_changes[cls] = _changes(before, cls) diff --git a/fastapi_startkit/tests/support/test_config_static_access.py b/fastapi_startkit/tests/support/test_config_static_access.py index 90f69077..363e43c4 100644 --- a/fastapi_startkit/tests/support/test_config_static_access.py +++ b/fastapi_startkit/tests/support/test_config_static_access.py @@ -30,33 +30,14 @@ class DecoratedStaticAppConfig(Config): def test_class_level_access_without_instantiating(monkeypatch): class FreshAppConfig(Config): name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) monkeypatch.delenv("STATIC_APP_NAME", raising=False) assert not isinstance(FreshAppConfig.name, Field) - assert FreshAppConfig.name == "Startkit" - - -def test_class_level_access_on_undecorated_config(monkeypatch): - monkeypatch.delenv("STATIC_APP_NAME", raising=False) - monkeypatch.delenv("STATIC_APP_PORT", raising=False) - assert StaticAppConfig.name == "Startkit" - assert StaticAppConfig.port == 8000 - - -def test_class_level_access_on_decorated_config(monkeypatch): - monkeypatch.delenv("STATIC_APP_NAME", raising=False) - monkeypatch.delenv("STATIC_APP_PORT", raising=False) - assert DecoratedStaticAppConfig.name == "Startkit" - assert DecoratedStaticAppConfig.port == 8000 - - -def test_class_level_access_reads_overridden_environment(monkeypatch): + assert (FreshAppConfig.name, FreshAppConfig.port) == ("Startkit", 8000) monkeypatch.setenv("STATIC_APP_NAME", "Overridden") monkeypatch.setenv("STATIC_APP_PORT", "9000") - assert StaticAppConfig.name == "Overridden" - assert StaticAppConfig.port == 9000 - assert DecoratedStaticAppConfig.name == "Overridden" - assert DecoratedStaticAppConfig.port == 9000 + assert (FreshAppConfig.name, FreshAppConfig.port) == ("Overridden", 9000) @pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) @@ -64,21 +45,15 @@ def test_facade_reads_registered_config_instance(app, monkeypatch, config_class) monkeypatch.delenv("STATIC_APP_NAME", raising=False) monkeypatch.delenv("STATIC_APP_PORT", raising=False) app.make("config").set("app", config_class()) - assert ConfigFacade.get("app.name") == "Startkit" - assert ConfigFacade.get("app.port") == 8000 + assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Startkit", 8000) + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + monkeypatch.setenv("STATIC_APP_PORT", "9000") + app.make("config").set("app", config_class()) + assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Overridden", 9000) @pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) def test_facade_reads_registered_config_class(app, monkeypatch, config_class): - monkeypatch.delenv("STATIC_APP_NAME", raising=False) - app.make("config").set("app", config_class) - assert ConfigFacade.get("app.name") == "Startkit" - - -@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) -def test_facade_reads_overridden_environment(app, monkeypatch, config_class): monkeypatch.setenv("STATIC_APP_NAME", "Overridden") - monkeypatch.setenv("STATIC_APP_PORT", "9000") - app.make("config").set("app", config_class()) + app.make("config").set("app", config_class) assert ConfigFacade.get("app.name") == "Overridden" - assert ConfigFacade.get("app.port") == 9000 diff --git a/fastapi_startkit/tests/support/test_config_without_dataclass.py b/fastapi_startkit/tests/support/test_config_without_dataclass.py index 90445be6..425dbe5b 100644 --- a/fastapi_startkit/tests/support/test_config_without_dataclass.py +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -95,6 +95,62 @@ class Decorated(Config): config.host = "changed" +def tag(klass): + klass.marker = 1 + return klass + + +def test_explicit_dataclass_keeps_attributes_set_by_inner_decorator(monkeypatch): + @dataclass + @tag + class Tagged(Config): + host: str = env("PLAIN_TAGGED_HOST", "localhost") + + monkeypatch.setenv("PLAIN_TAGGED_HOST", "example.test") + assert Tagged.marker == 1 + assert (Tagged.host, Tagged().host) == ("example.test", "example.test") + + +def test_explicit_dataclass_keeps_attributes_set_after_definition(monkeypatch): + class Extended(Config): + host: str = env("PLAIN_EXTENDED_HOST", "localhost") + + Extended.extra = 1 + Extended = dataclass(Extended) + + monkeypatch.setenv("PLAIN_EXTENDED_HOST", "example.test") + assert Extended.extra == 1 + assert Extended().host == "example.test" + + +class EmptyBase(Config): + def describe(self) -> str: + return type(self).__name__ + + +def test_base_without_annotations_is_left_plain(): + assert "__dataclass_fields__" not in EmptyBase.__dict__ + + +def test_frozen_child_of_base_without_annotations(): + @dataclass(frozen=True) + class Frozen(EmptyBase): + host: str = "localhost" + + config = Frozen() + assert (config.host, config.describe()) == ("localhost", "Frozen") + with pytest.raises(FrozenInstanceError): + config.host = "changed" + + +def test_child_of_base_without_annotations_has_static_access(monkeypatch): + class Child(EmptyBase): + host: str = env("PLAIN_EMPTY_BASE_HOST", "localhost") + + monkeypatch.setenv("PLAIN_EMPTY_BASE_HOST", "example.test") + assert (Child.host, Child().host) == ("example.test", "example.test") + + def test_decorated_child_inherits_undecorated_parent_fields(monkeypatch): class Parent(Config): host: str = env("PLAIN_PARENT_HOST", "localhost")