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..ddffd101 --- /dev/null +++ b/docs/adr/035-config-subclass-without-dataclass.md @@ -0,0 +1,50 @@ +# 035. Config subclasses work without `@dataclass` + +Date: 2026-10-23 + +## Problem + +`env()` returns a `dataclasses.Field` with a `default_factory`, so it only becomes a value when the class is processed by `@dataclass`. `Config` relies on every subclass being decorated. A plain subclass is never processed: + +```python +class DatabaseConfig(Config): + default: str = env("DB_CONNECTION", "sqlite") + +DatabaseConfig().default +DatabaseConfig.default +``` + +Both lookups fall back to the class attribute, which is the raw `Field`. The value is never read from the environment or cast. + +## Alternatives + +1. Make `env()` return a descriptor instead of a `Field`. This breaks dataclass configs, which need a `Field` to get a per-instance `default_factory`, and changes `value()` and the `_ConfigMeta` lookups. +2. Apply `dataclass` lazily, the first time a class is instantiated or subclassed. Instances work, but class-level access (`DatabaseConfig.default`) on a class that was never instantiated still returns the `Field`, and the lazy path needs a lock, MRO walks and rules for which classes to process. +3. Apply `dataclass` to every subclass when it is defined, in `Config.__init_subclass__`. Class-level access works immediately. The risk is a subclass that is also decorated with `@dataclass`: it is processed twice, and the second pass reads the defaults through `getattr`, which returns resolved `env()` values instead of `Field` objects, freezing them at import time and rejecting mutable `default_factory` results. + +## Decision + +Option 3, in `support/config.py`: + +- `Config.__init_subclass__` applies `dataclass(eq=False)` to every subclass that declares its own annotations, and records each class attribute the pass added, replaced or deleted, keeping copies of the original `Field` objects. It uses `frozen=True` when the nearest dataclass ancestor is frozen, because dataclasses forbid a non-frozen child of a frozen parent. A subclass without its own annotations (a method-only or mixin base) stays a plain class, so a `@dataclass(frozen=True)` child of it still works, and its annotated children are processed normally. A class that already has its own `__dataclass_fields__` (the class `@dataclass(slots=True)` rebuilds) is skipped. +- An explicit `@dataclass` re-processes the class harmlessly. `dataclass` starts every pass by setting `__dataclass_params__` on the class, so `_ConfigMeta.__setattr__` uses that write to undo the automatic pass: each recorded attribute that still holds the value the pass left is deleted or set back to its original, and fresh copies of the original `Field` objects are put back. Attributes set between definition and the decorator (by another decorator or by assignment) are untouched. The decorator then processes the class as if it ran alone, so its options (`eq`, `frozen`, `kw_only`, ...) apply and `env()` values stay live. The undo runs only once, on the first pass after definition. +- `Config` is decorated with `typing.dataclass_transform(eq_default=False, field_specifiers=(field, Field))`, so type checkers treat undecorated subclasses as dataclasses. +- `Config` itself is not a dataclass. An empty dataclass base would make every `@dataclass(frozen=True)` subclass fail with `cannot inherit frozen dataclass from a non-frozen one`. +- `eq=False` keeps the identity equality and hashing undecorated subclasses had before. +- `_ConfigMeta` keeps resolving `env()` arguments in `__call__` and resolving `default_factory` fields on class-level access in `__getattr__`. The lazy processing, its lock and its class-selection rules are removed. + +Every undecorated subclass that declares annotations now follows dataclass rules at definition time: a bare annotation (`name: str`) is a required argument, a plain mutable default (`opts: dict = {}`) raises `ValueError`, and an inherited custom `__init__` is replaced by the generated one, exactly as with `@dataclass`. Use `ClassVar` for shared class state and `field(default_factory=...)` for mutable defaults. + +## Risks + +The undo relies on `dataclasses` setting `__dataclass_params__` on the class before it reads the field defaults. This is an implementation detail, verified on Python 3.12 to 3.15. If a future version changes the order, an explicit `@dataclass` would read resolved `env()` values and freeze them; `test_explicit_dataclass_reprocessing_keeps_env_live` is the canary that fails first. + +## Validation + +- Static access: `AppConfig.name` on an undecorated class that was never instantiated returns the resolved value, for both undecorated and decorated classes, and a monkeypatched variable is read on the next access. `Config.get("app.name")` returns the value for a registered instance or class, and reflects an overridden variable. +- An undecorated subclass is a dataclass at definition: fallback value, variable value, `int`/`bool` casting, keyword arguments, and the environment being read on every instantiation. +- Explicit `@dataclass`: attributes set by an inner decorator or after definition survive, fields are collected once, `env()` stays live on the class and on instances, `default_factory` containers are per instance, and `frozen`, `kw_only` and `eq` from the decorator apply. +- Inheritance: undecorated parent and child, a `@dataclass` child of an undecorated parent, and undecorated children of a frozen parent are frozen. +- `Config` and annotation-free bases are not dataclasses, a `@dataclass(frozen=True)` child of an annotation-free base works, its `env()` children have static access, and undecorated instances stay hashable. +- Storage: an undecorated `MemoryDiskConfig` in an undecorated storage config resolves through `StorageManager`. +- Full suite without Postgres and basedpyright pass. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 7b657eb1..58fe175d 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,5 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` 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 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 5bb03380..916889f9 100644 --- a/fastapi_startkit/src/fastapi_startkit/support/config.py +++ b/fastapi_startkit/src/fastapi_startkit/support/config.py @@ -1,10 +1,43 @@ -from dataclasses import Field, MISSING -from typing import TypeVar, cast +from copy import copy +from dataclasses import Field, MISSING, dataclass, field +from typing import TypeVar, cast, dataclass_transform +from weakref import WeakKeyDictionary from fastapi_startkit.environment import value T = TypeVar("T") +_DATACLASS_PARAMS = "__dataclass_params__" +_OWN_ANNOTATION_KEYS = ("__annotations__", "__annotate_func__") + +_Changes = dict[str, tuple[object, object]] +_automatic_changes: WeakKeyDictionary[type, _Changes] = WeakKeyDictionary() + + +def _snapshot(klass: type) -> dict[str, object]: + return { + name: copy(attribute) if isinstance(attribute, Field) else attribute + for name, attribute in klass.__dict__.items() + } + + +def _changes(before: dict[str, object], klass: type) -> _Changes: + after = dict(klass.__dict__) + return { + name: (after.get(name, MISSING), before.get(name, MISSING)) + for name in before.keys() | after.keys() + if after.get(name, MISSING) is not before.get(name, MISSING) + } + + +def _declares_annotations(klass: type) -> bool: + return any(key in klass.__dict__ for key in _OWN_ANNOTATION_KEYS) + + +def _has_frozen_base(klass: type) -> bool: + params = getattr(klass, _DATACLASS_PARAMS, None) + return params is not None and params.frozen + class _ConfigMeta(type): def __call__(cls: type[T], *args: object, **kwargs: object) -> T: @@ -12,16 +45,40 @@ def __call__(cls: type[T], *args: object, **kwargs: object) -> T: resolved_kwargs = {name: value(argument) for name, argument in kwargs.items()} return cast(T, type.__call__(cls, *resolved_args, **resolved_kwargs)) + def __setattr__(cls, name: str, attribute: object) -> None: + if name == _DATACLASS_PARAMS: + changes = _automatic_changes.pop(cls, None) + if changes is not None: + _ConfigMeta.undo_automatic_pass(cls, changes) + super().__setattr__(name, attribute) + + @staticmethod + def undo_automatic_pass(klass: type, changes: _Changes) -> None: + for name, (after, before) in changes.items(): + if klass.__dict__.get(name, MISSING) is not after: + continue + if before is MISSING: + type.__delattr__(klass, name) + else: + type.__setattr__(klass, name, copy(before) if isinstance(before, Field) else before) + def __getattr__(cls, name: str) -> object: try: fields = cast(dict[str, Field[object]], type.__getattribute__(cls, "__dataclass_fields__")) except AttributeError: raise AttributeError(f"{cls.__name__!r} has no attribute {name!r}") from None - field = fields.get(name) - if field is not None and field.default_factory is not MISSING: - return field.default_factory() + declared = fields.get(name) + if declared is not None and declared.default_factory is not MISSING: + return declared.default_factory() raise AttributeError(f"{cls.__name__!r} has no attribute {name!r}") +@dataclass_transform(eq_default=False, field_specifiers=(field, Field)) class Config(metaclass=_ConfigMeta): - pass + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if "__dataclass_fields__" in cls.__dict__ or not _declares_annotations(cls): + return + before = _snapshot(cls) + dataclass(eq=False, frozen=_has_frozen_base(cls))(cls) + _automatic_changes[cls] = _changes(before, cls) 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..363e43c4 --- /dev/null +++ b/fastapi_startkit/tests/support/test_config_static_access.py @@ -0,0 +1,59 @@ +from dataclasses import Field, dataclass + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.configuration import Config +from fastapi_startkit.configuration.config import Config as ConfigFacade +from fastapi_startkit.container.container import Container +from fastapi_startkit.environment import env + + +@pytest.fixture +def app(tmp_path): + original = Container._instance + yield Application(base_path=tmp_path, env="testing") + Container._instance = original + + +class StaticAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) + + +@dataclass +class DecoratedStaticAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) + + +def test_class_level_access_without_instantiating(monkeypatch): + class FreshAppConfig(Config): + name: str = env("STATIC_APP_NAME", "Startkit") + port: int = env("STATIC_APP_PORT", 8000) + + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + assert not isinstance(FreshAppConfig.name, Field) + assert (FreshAppConfig.name, FreshAppConfig.port) == ("Startkit", 8000) + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + monkeypatch.setenv("STATIC_APP_PORT", "9000") + assert (FreshAppConfig.name, FreshAppConfig.port) == ("Overridden", 9000) + + +@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) +def test_facade_reads_registered_config_instance(app, monkeypatch, config_class): + monkeypatch.delenv("STATIC_APP_NAME", raising=False) + monkeypatch.delenv("STATIC_APP_PORT", raising=False) + app.make("config").set("app", config_class()) + assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Startkit", 8000) + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + monkeypatch.setenv("STATIC_APP_PORT", "9000") + app.make("config").set("app", config_class()) + assert (ConfigFacade.get("app.name"), ConfigFacade.get("app.port")) == ("Overridden", 9000) + + +@pytest.mark.parametrize("config_class", [StaticAppConfig, DecoratedStaticAppConfig]) +def test_facade_reads_registered_config_class(app, monkeypatch, config_class): + monkeypatch.setenv("STATIC_APP_NAME", "Overridden") + app.make("config").set("app", config_class) + assert ConfigFacade.get("app.name") == "Overridden" 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..425dbe5b --- /dev/null +++ b/fastapi_startkit/tests/support/test_config_without_dataclass.py @@ -0,0 +1,272 @@ +from dataclasses import FrozenInstanceError, dataclass, field, fields, is_dataclass + +import pytest + +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): + default: str = env("PLAIN_DB_CONNECTION", "sqlite") + port: int = env("PLAIN_DB_PORT", 5432) + 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" + + +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" + assert Child.host == "example.test" + + +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"}) + + 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().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 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") + + @dataclass + class Child(Parent): + port: int = env("PLAIN_CHILD_PORT", 80) + + monkeypatch.setenv("PLAIN_PARENT_HOST", "example.test") + assert [declared.name for declared 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() + assert not is_dataclass(Config) + 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_undecorated_child_of_frozen_parent_is_frozen(): + @dataclass(frozen=True) + class Frozen(Config): + host: str = "localhost" + + class Child(Frozen): + pass + + child = Child() + assert child.host == "localhost" + with pytest.raises(FrozenInstanceError): + child.host = "changed" + + +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_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) + + +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/"}