Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/module/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
```

Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 3 additions & 3 deletions src/fastapi_toolsets/cli/commands/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
def list_fixtures(
ctx: typer.Context,
context: Annotated[
Context | None,
str | None,
typer.Option(
"--context",
"-c",
Expand Down Expand Up @@ -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[
Expand All @@ -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)

Expand Down
16 changes: 10 additions & 6 deletions src/fastapi_toolsets/fixtures/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
```
"""

Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions src/fastapi_toolsets/fixtures/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 26 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand Down
29 changes: 28 additions & 1 deletion tests/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down