From 964a83c3a33c4229aeb9bb7b7612e4a61a2284b2 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Fri, 3 Jul 2026 18:57:12 -0400 Subject: [PATCH] feat: always include Context.BASE fixtures when loading/listing by context --- docs/module/fixtures.md | 12 ++++++-- src/fastapi_toolsets/cli/commands/fixtures.py | 6 ++-- src/fastapi_toolsets/fixtures/registry.py | 16 ++++++---- src/fastapi_toolsets/fixtures/utils.py | 4 +-- tests/test_cli.py | 27 ++++++++++++++++- tests/test_fixtures.py | 29 ++++++++++++++++++- 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/docs/module/fixtures.md b/docs/module/fixtures.md index da0823d8..fa6e6cf4 100644 --- a/docs/module/fixtures.md +++ b/docs/module/fixtures.md @@ -65,6 +65,13 @@ Both functions return a `dict[str, list[...]]` mapping each fixture name to the A fixture with no `contexts` defined takes `Context.BASE` by default. +`Context.BASE` fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation: + +```python +# also loads any Context.BASE fixtures, even though only TESTING is requested +await load_fixtures_by_context(session, fixtures, Context.TESTING) +``` + ### Custom contexts Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected. @@ -80,6 +87,7 @@ class AppContext(str, Enum): def staging_data(): return [Config(key="feature_x", enabled=True)] +# loads staging_data plus any Context.BASE fixtures await load_fixtures_by_context(session, fixtures, AppContext.STAGING) ``` @@ -108,8 +116,8 @@ def users(): def users(): return [User(id=2, username="tester")] -# loads both admin and tester -await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING) +# loads both admin and tester (Context.BASE is included automatically) +await load_fixtures_by_context(session, fixtures, Context.TESTING) ``` Registering two variants with overlapping context sets raises `ValueError`. diff --git a/src/fastapi_toolsets/cli/commands/fixtures.py b/src/fastapi_toolsets/cli/commands/fixtures.py index f7cc43ca..c479a9bc 100644 --- a/src/fastapi_toolsets/cli/commands/fixtures.py +++ b/src/fastapi_toolsets/cli/commands/fixtures.py @@ -24,7 +24,7 @@ def list_fixtures( ctx: typer.Context, context: Annotated[ - Context | None, + str | None, typer.Option( "--context", "-c", @@ -56,7 +56,7 @@ def list_fixtures( async def load( ctx: typer.Context, contexts: Annotated[ - list[Context] | None, + list[str] | None, typer.Argument(help="Contexts to load."), ] = None, strategy: Annotated[ @@ -76,7 +76,7 @@ async def load( registry = get_fixtures_registry() db_context = get_db_context() - context_list = contexts or [Context.BASE] + context_list = contexts or [Context.BASE.value] ordered = registry.resolve_context_dependencies(*context_list) diff --git a/src/fastapi_toolsets/fixtures/registry.py b/src/fastapi_toolsets/fixtures/registry.py index c94a3b61..629d63c6 100644 --- a/src/fastapi_toolsets/fixtures/registry.py +++ b/src/fastapi_toolsets/fixtures/registry.py @@ -17,6 +17,11 @@ def _normalize_contexts( return [c.value if isinstance(c, Enum) else c for c in contexts] +def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]: + """Normalize *contexts* for filtering, always including Context.BASE.""" + return set(_normalize_contexts(contexts)) | {Context.BASE.value} + + @dataclass class Fixture: """A fixture definition with metadata.""" @@ -67,8 +72,6 @@ def users(): @fixtures.register(contexts=[Context.TESTING]) def users(): return [User(id=2, username="tester")] - # load_fixtures_by_context(..., Context.BASE, Context.TESTING) - # → loads both User(admin) and User(tester) under the "users" name ``` """ @@ -200,8 +203,9 @@ def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]: Args: name: Fixture name. *contexts: If given, only return variants whose context set - intersects with these values. Both :class:`Context` enum - values and plain strings are accepted. + intersects with these values (:class:`Context.BASE` variants + are always included). Both :class:`Context` enum values and + plain strings are accepted. Returns: List of matching :class:`Fixture` objects (may be empty when a @@ -215,7 +219,7 @@ def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]: variants = self._fixtures[name] if not contexts: return list(variants) - context_values = set(_normalize_contexts(contexts)) + context_values = _context_filter_values(contexts) return [v for v in variants if set(v.contexts) & context_values] def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]: @@ -297,7 +301,7 @@ def field(self, name: str, attr_name: str, value: Any, *, field: str = "id") -> def get_by_context(self, *contexts: str | Enum) -> list[Fixture]: """Get fixtures for specific contexts.""" - context_values = set(_normalize_contexts(contexts)) + context_values = _context_filter_values(contexts) return [ f for variants in self._fixtures.values() diff --git a/src/fastapi_toolsets/fixtures/utils.py b/src/fastapi_toolsets/fixtures/utils.py index a034d228..9ea29343 100644 --- a/src/fastapi_toolsets/fixtures/utils.py +++ b/src/fastapi_toolsets/fixtures/utils.py @@ -383,8 +383,8 @@ async def load_fixtures_by_context( Args: session: Database session registry: Fixture registry - *contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``, - or plain strings for custom contexts) + *contexts: Contexts to load (e.g., ``Context.TESTING``, or plain + strings for custom contexts) strategy: How to handle existing records Returns: diff --git a/tests/test_cli.py b/tests/test_cli.py index 6c172f5b..26b5f161 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -277,6 +277,10 @@ def cli_env(self, tmp_path, monkeypatch): '@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n' "def users():\n" ' return [{"id": 1, "name": "alice", "role_id": 1}]\n' + "\n" + '@registry.register(contexts=["staging"])\n' + "def staging_only():\n" + ' return [{"id": 3, "name": "staging-user"}]\n' ) # Create db module @@ -316,7 +320,7 @@ def test_fixtures_list(self, cli_env): assert result.exit_code == 0 assert "roles" in result.output assert "users" in result.output - assert "Total: 2 fixture(s)" in result.output + assert "Total: 3 fixture(s)" in result.output def test_fixtures_list_with_context(self, cli_env): """fixtures list --context filters by context.""" @@ -338,6 +342,27 @@ def test_fixtures_load_dry_run(self, cli_env): assert "roles" in result.output assert "[Dry run - no changes made]" in result.output + def test_fixtures_list_with_custom_context(self, cli_env): + """fixtures list --context accepts contexts outside the Context enum, and + always includes base fixtures alongside the requested context.""" + tmp_path, cli = cli_env + result = runner.invoke(cli, ["fixtures", "list", "--context", "staging"]) + + assert result.exit_code == 0 + assert "staging_only" in result.output + assert "roles" in result.output + assert "Total: 2 fixture(s)" in result.output + + def test_fixtures_load_custom_context_dry_run(self, cli_env): + """fixtures load accepts a custom context argument outside the Context enum, + and always loads base fixtures alongside it.""" + tmp_path, cli = cli_env + result = runner.invoke(cli, ["fixtures", "load", "staging", "--dry-run"]) + + assert result.exit_code == 0 + assert "staging_only" in result.output + assert "roles" in result.output + def test_fixtures_load_invalid_strategy(self, cli_env): """fixtures load with invalid strategy shows error.""" tmp_path, cli = cli_env diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 2122aa7f..61b7a264 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -266,7 +266,34 @@ def prod_data(): testing_fixtures = registry.get_by_context(Context.TESTING) names = {f.name for f in testing_fixtures} - assert names == {"test_data"} + assert names == {"test_data", "base_data"} + + def test_get_by_context_always_includes_base(self): + """Context.BASE fixtures load even for a fully custom context.""" + registry = FixtureRegistry() + + @registry.register(contexts=[Context.BASE]) + def base_data(): + return [] + + @registry.register(contexts=["staging"]) + def staging_data(): + return [] + + names = {f.name for f in registry.get_by_context("staging")} + assert names == {"staging_data", "base_data"} + + def test_get_load_variants_falls_back_to_all_when_context_has_no_match(self): + """get_load_variants returns every variant if none match the requested + context (and none are Context.BASE either).""" + registry = FixtureRegistry() + + @registry.register(contexts=["staging"]) + def env_data(): + return [] + + variants = registry.get_load_variants("env_data", "production") + assert [v.contexts for v in variants] == [["staging"]] class TestIncludeRegistry: