feat: always include Context.BASE fixtures when loading/listing by context (#337)

This commit is contained in:
d3vyce
2026-07-04 01:08:03 +02:00
committed by GitHub
parent 44ba5bdd4b
commit 9cb4c1474f
6 changed files with 79 additions and 15 deletions
+10 -2
View File
@@ -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`.
@@ -24,7 +24,7 @@ logger = get_logger()
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)
+10 -6
View File
@@ -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 @@ class FixtureRegistry:
@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 @@ class FixtureRegistry:
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 @@ class FixtureRegistry:
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 @@ class FixtureRegistry:
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()
+2 -2
View File
@@ -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:
+26 -1
View File
@@ -277,6 +277,10 @@ class TestFixturesCli:
'@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 @@ class TestFixturesCli:
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 @@ class TestFixturesCli:
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
+28 -1
View File
@@ -266,7 +266,34 @@ class TestFixtureRegistry:
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: