mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 23:02:29 +02:00
Compare commits
9 Commits
v2.4.3
...
44eb22800a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44eb22800a
|
||
|
|
104285c6e5 | ||
|
|
f5afbbe37f | ||
|
|
f4698bea8a | ||
|
|
5215b921ae | ||
|
9dad59e25d
|
|||
|
|
29326ab532 | ||
|
|
04afef7e33 | ||
|
|
666c621fda |
52
.github/workflows/docs.yml
vendored
52
.github/workflows/docs.yml
vendored
@@ -5,20 +5,15 @@ on:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -28,11 +23,40 @@ jobs:
|
||||
|
||||
- run: uv sync --group dev
|
||||
|
||||
- run: uv run zensical build --clean
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
- name: Deploy documentation
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||
|
||||
- uses: actions/deploy-pages@v5
|
||||
id: deployment
|
||||
# On new major: consolidate previous major's feature versions into vX
|
||||
PREV_MAJOR=$((MAJOR - 1))
|
||||
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||
|
||||
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
|
||||
|
||||
if [ -n "$LATEST_PREV_TAG" ]; then
|
||||
git checkout "$LATEST_PREV_TAG" -- docs/ src/ zensical.toml
|
||||
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
|
||||
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
|
||||
fi
|
||||
uv run mike deploy "v${PREV_MAJOR}"
|
||||
git checkout HEAD -- docs/ src/ zensical.toml
|
||||
fi
|
||||
|
||||
# Delete old feature versions
|
||||
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||
echo "Deleting $OLD_V"
|
||||
uv run mike delete "$OLD_V"
|
||||
done
|
||||
fi
|
||||
|
||||
uv run mike deploy --update-aliases "$DEPLOY_VERSION" stable
|
||||
uv run mike set-default stable
|
||||
git push origin gh-pages
|
||||
|
||||
@@ -474,7 +474,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
||||
"filter_attributes": {
|
||||
"status": ["active", "inactive"],
|
||||
"country": ["DE", "FR", "US"],
|
||||
"name": ["admin", "editor", "viewer"]
|
||||
"role__name": ["admin", "editor", "viewer"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -482,7 +482,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
||||
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||
|
||||
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||
Keys are normally the terminal `column.key` (e.g. `"name"` for `Role.name`). When two facet fields share the same column key (e.g. `(Build.project, Project.name)` and `(Build.os, Os.name)`), the relationship name is prepended automatically: `"project__name"` and `"os__name"`.
|
||||
Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`.
|
||||
|
||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||
|
||||
@@ -515,9 +515,9 @@ async def list_users(
|
||||
Both single-value and multi-value query parameters work:
|
||||
|
||||
```
|
||||
GET /users?status=active → filter_by={"status": ["active"]}
|
||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
|
||||
GET /users?status=active → filter_by={"status": ["active"]}
|
||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
@@ -38,18 +38,20 @@ By context with [`load_fixtures_by_context`](../reference/fixtures.md#fastapi_to
|
||||
from fastapi_toolsets.fixtures import load_fixtures_by_context
|
||||
|
||||
async with db_context() as session:
|
||||
await load_fixtures_by_context(session=session, registry=fixtures, context=Context.TESTING)
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
Directly with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||
Directly by name with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import load_fixtures
|
||||
|
||||
async with db_context() as session:
|
||||
await load_fixtures(session=session, registry=fixtures)
|
||||
await load_fixtures(session, fixtures, "roles", "test_users")
|
||||
```
|
||||
|
||||
Both functions return a `dict[str, list[...]]` mapping each fixture name to the list of loaded instances.
|
||||
|
||||
## Contexts
|
||||
|
||||
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values:
|
||||
@@ -58,10 +60,60 @@ async with db_context() as session:
|
||||
|---------|-------------|
|
||||
| `Context.BASE` | Core data required in all environments |
|
||||
| `Context.TESTING` | Data only loaded during tests |
|
||||
| `Context.DEVELOPMENT` | Data only loaded in development |
|
||||
| `Context.PRODUCTION` | Data only loaded in production |
|
||||
|
||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
|
||||
### Custom contexts
|
||||
|
||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class AppContext(str, Enum):
|
||||
STAGING = "staging"
|
||||
DEMO = "demo"
|
||||
|
||||
@fixtures.register(contexts=[AppContext.STAGING])
|
||||
def staging_data():
|
||||
return [Config(key="feature_x", enabled=True)]
|
||||
|
||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||
```
|
||||
|
||||
### Default context for a registry
|
||||
|
||||
Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registered in it:
|
||||
|
||||
```python
|
||||
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
||||
|
||||
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
||||
def test_orders():
|
||||
return [Order(id=1, total=99)]
|
||||
```
|
||||
|
||||
### Same fixture name, multiple context variants
|
||||
|
||||
The same fixture name may be registered under different (non-overlapping) context sets. When multiple contexts are loaded together, all matching variants are merged:
|
||||
|
||||
```python
|
||||
@fixtures.register(contexts=[Context.BASE])
|
||||
def users():
|
||||
return [User(id=1, username="admin")]
|
||||
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
|
||||
# loads both admin and tester
|
||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||
```
|
||||
|
||||
Registering two variants with overlapping context sets raises `ValueError`.
|
||||
|
||||
## Load strategies
|
||||
|
||||
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist:
|
||||
@@ -69,20 +121,44 @@ A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| `LoadStrategy.INSERT` | Insert only, fail on duplicates |
|
||||
| `LoadStrategy.UPSERT` | Insert or update on conflict |
|
||||
| `LoadStrategy.SKIP` | Skip rows that already exist |
|
||||
| `LoadStrategy.MERGE` | Insert or update on conflict (default) |
|
||||
| `LoadStrategy.SKIP_EXISTING` | Skip rows that already exist |
|
||||
|
||||
```python
|
||||
await load_fixtures_by_context(
|
||||
session, fixtures, Context.BASE, strategy=LoadStrategy.SKIP_EXISTING
|
||||
)
|
||||
```
|
||||
|
||||
## Merging registries
|
||||
|
||||
Split fixtures definitions across modules and merge them:
|
||||
Split fixture definitions across modules and merge them:
|
||||
|
||||
```python
|
||||
from myapp.fixtures.dev import dev_fixtures
|
||||
from myapp.fixtures.prod import prod_fixtures
|
||||
|
||||
fixtures = fixturesRegistry()
|
||||
fixtures = FixtureRegistry()
|
||||
fixtures.include_registry(registry=dev_fixtures)
|
||||
fixtures.include_registry(registry=prod_fixtures)
|
||||
```
|
||||
|
||||
Fixtures with the same name are allowed as long as their context sets do not overlap. Conflicting contexts raise `ValueError`.
|
||||
|
||||
## Looking up fixture instances
|
||||
|
||||
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
Raises `StopIteration` if no matching instance is found.
|
||||
|
||||
## Pytest integration
|
||||
|
||||
@@ -111,7 +187,6 @@ async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Rol
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||
|
||||
## CLI integration
|
||||
|
||||
@@ -80,8 +80,9 @@ tests = [
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
docs = [
|
||||
"mike",
|
||||
"mkdocstrings-python>=2.0.2",
|
||||
"zensical>=0.0.23",
|
||||
"zensical>=0.0.30",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -104,3 +105,6 @@ exclude_lines = [
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
mike = { git = "https://github.com/squidfunk/mike.git", tag = "2.2.0+zensical-0.1.0" }
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
@@ -151,7 +150,7 @@ def build_search_filters(
|
||||
|
||||
|
||||
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||
"""Return a key for each facet field, disambiguating duplicate column keys.
|
||||
"""Return a key for each facet field.
|
||||
|
||||
Args:
|
||||
facet_fields: Sequence of facet fields — either direct columns or
|
||||
@@ -160,22 +159,12 @@ def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||
Returns:
|
||||
A list of string keys, one per facet field, in the same order.
|
||||
"""
|
||||
raw: list[tuple[str, str | None]] = []
|
||||
keys: list[str] = []
|
||||
for field in facet_fields:
|
||||
if isinstance(field, tuple):
|
||||
rel = field[-2]
|
||||
column = field[-1]
|
||||
raw.append((column.key, rel.key))
|
||||
keys.append("__".join(el.key for el in field))
|
||||
else:
|
||||
raw.append((field.key, None))
|
||||
|
||||
counts = Counter(col_key for col_key, _ in raw)
|
||||
keys: list[str] = []
|
||||
for col_key, rel_key in raw:
|
||||
if counts[col_key] > 1 and rel_key is not None:
|
||||
keys.append(f"{rel_key}__{col_key}")
|
||||
else:
|
||||
keys.append(col_key)
|
||||
keys.append(field.key)
|
||||
return keys
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
@@ -12,6 +13,13 @@ from .enum import Context
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _normalize_contexts(
|
||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
||||
) -> list[str]:
|
||||
"""Convert a sequence of any Enum subclass and/or plain strings to a list of strings."""
|
||||
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
"""A fixture definition with metadata."""
|
||||
@@ -50,26 +58,51 @@ class FixtureRegistry:
|
||||
Post(id=1, title="Test", user_id=1),
|
||||
]
|
||||
```
|
||||
|
||||
Fixtures with the same name may be registered for **different** contexts.
|
||||
When multiple contexts are loaded together, their instances are merged:
|
||||
|
||||
```python
|
||||
@fixtures.register(contexts=[Context.BASE])
|
||||
def users():
|
||||
return [User(id=1, username="admin")]
|
||||
|
||||
@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
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contexts: list[str | Context] | None = None,
|
||||
contexts: list[str | Enum] | None = None,
|
||||
) -> None:
|
||||
self._fixtures: dict[str, Fixture] = {}
|
||||
self._fixtures: dict[str, list[Fixture]] = {}
|
||||
self._default_contexts: list[str] | None = (
|
||||
[c.value if isinstance(c, Context) else c for c in contexts]
|
||||
if contexts
|
||||
else None
|
||||
_normalize_contexts(contexts) if contexts else None
|
||||
)
|
||||
|
||||
def _validate_no_context_overlap(self, name: str, new_contexts: list[str]) -> None:
|
||||
"""Raise ``ValueError`` if any existing variant for *name* overlaps."""
|
||||
existing_variants = self._fixtures.get(name, [])
|
||||
new_set = set(new_contexts)
|
||||
for variant in existing_variants:
|
||||
if set(variant.contexts) & new_set:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' already exists in the current registry "
|
||||
f"with overlapping contexts. Use distinct context sets for "
|
||||
f"each variant of the same fixture name."
|
||||
)
|
||||
|
||||
def register(
|
||||
self,
|
||||
func: Callable[[], Sequence[DeclarativeBase]] | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
depends_on: list[str] | None = None,
|
||||
contexts: list[str | Context] | None = None,
|
||||
contexts: list[str | Enum] | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
"""Register a fixture function.
|
||||
|
||||
@@ -79,7 +112,8 @@ class FixtureRegistry:
|
||||
func: Fixture function returning list of model instances
|
||||
name: Fixture name (defaults to function name)
|
||||
depends_on: List of fixture names this depends on
|
||||
contexts: List of contexts this fixture belongs to
|
||||
contexts: List of contexts this fixture belongs to. Both
|
||||
:class:`Context` enum values and plain strings are accepted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -90,7 +124,6 @@ class FixtureRegistry:
|
||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||
def test_users():
|
||||
return [User(id=1, username="test", role_id=1)]
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
@@ -98,19 +131,20 @@ class FixtureRegistry:
|
||||
) -> Callable[[], Sequence[DeclarativeBase]]:
|
||||
fixture_name = name or cast(Any, fn).__name__
|
||||
if contexts is not None:
|
||||
fixture_contexts = [
|
||||
c.value if isinstance(c, Context) else c for c in contexts
|
||||
]
|
||||
fixture_contexts = _normalize_contexts(contexts)
|
||||
elif self._default_contexts is not None:
|
||||
fixture_contexts = self._default_contexts
|
||||
else:
|
||||
fixture_contexts = [Context.BASE.value]
|
||||
|
||||
self._fixtures[fixture_name] = Fixture(
|
||||
name=fixture_name,
|
||||
func=fn,
|
||||
depends_on=depends_on or [],
|
||||
contexts=fixture_contexts,
|
||||
self._validate_no_context_overlap(fixture_name, fixture_contexts)
|
||||
self._fixtures.setdefault(fixture_name, []).append(
|
||||
Fixture(
|
||||
name=fixture_name,
|
||||
func=fn,
|
||||
depends_on=depends_on or [],
|
||||
contexts=fixture_contexts,
|
||||
)
|
||||
)
|
||||
return fn
|
||||
|
||||
@@ -121,11 +155,14 @@ class FixtureRegistry:
|
||||
def include_registry(self, registry: "FixtureRegistry") -> None:
|
||||
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`.
|
||||
|
||||
Fixtures with the same name are allowed as long as their context sets
|
||||
do not overlap. Conflicting contexts raise :class:`ValueError`.
|
||||
|
||||
Args:
|
||||
registry: The `FixtureRegistry` to include
|
||||
|
||||
Raises:
|
||||
ValueError: If a fixture name already exists in the current registry
|
||||
ValueError: If a fixture name already exists with overlapping contexts
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -139,31 +176,73 @@ class FixtureRegistry:
|
||||
registry.include_registry(registry=dev_registry)
|
||||
```
|
||||
"""
|
||||
for name, fixture in registry._fixtures.items():
|
||||
if name in self._fixtures:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' already exists in the current registry"
|
||||
)
|
||||
self._fixtures[name] = fixture
|
||||
for name, variants in registry._fixtures.items():
|
||||
for fixture in variants:
|
||||
self._validate_no_context_overlap(name, fixture.contexts)
|
||||
self._fixtures.setdefault(name, []).append(fixture)
|
||||
|
||||
def get(self, name: str) -> Fixture:
|
||||
"""Get a fixture by name."""
|
||||
"""Get a fixture by name.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
ValueError: If the fixture has multiple context variants — use
|
||||
:meth:`get_variants` in that case.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
return self._fixtures[name]
|
||||
variants = self._fixtures[name]
|
||||
if len(variants) > 1:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||
f"Use get_variants('{name}') to retrieve them."
|
||||
)
|
||||
return variants[0]
|
||||
|
||||
def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Return all registered variants for *name*, optionally filtered by context.
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of matching :class:`Fixture` objects (may be empty when a
|
||||
context filter is applied and nothing matches).
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
variants = self._fixtures[name]
|
||||
if not contexts:
|
||||
return list(variants)
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
return [v for v in variants if set(v.contexts) & context_values]
|
||||
|
||||
def get_all(self) -> list[Fixture]:
|
||||
"""Get all registered fixtures."""
|
||||
return list(self._fixtures.values())
|
||||
"""Get all registered fixtures (all variants of all names)."""
|
||||
return [f for variants in self._fixtures.values() for f in variants]
|
||||
|
||||
def get_by_context(self, *contexts: str | Context) -> list[Fixture]:
|
||||
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Get fixtures for specific contexts."""
|
||||
context_values = {c.value if isinstance(c, Context) else c for c in contexts}
|
||||
return [f for f in self._fixtures.values() if set(f.contexts) & context_values]
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
return [
|
||||
f
|
||||
for variants in self._fixtures.values()
|
||||
for f in variants
|
||||
if set(f.contexts) & context_values
|
||||
]
|
||||
|
||||
def resolve_dependencies(self, *names: str) -> list[str]:
|
||||
"""Resolve fixture dependencies in topological order.
|
||||
|
||||
When a fixture name has multiple context variants, the union of all
|
||||
variants' ``depends_on`` lists is used.
|
||||
|
||||
Args:
|
||||
*names: Fixture names to resolve
|
||||
|
||||
@@ -185,9 +264,20 @@ class FixtureRegistry:
|
||||
raise ValueError(f"Circular dependency detected: {name}")
|
||||
|
||||
visiting.add(name)
|
||||
fixture = self.get(name)
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
for dep in fixture.depends_on:
|
||||
# Union of depends_on across all variants, preserving first-seen order.
|
||||
seen_deps: set[str] = set()
|
||||
all_deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen_deps:
|
||||
all_deps.append(dep)
|
||||
seen_deps.add(dep)
|
||||
|
||||
for dep in all_deps:
|
||||
visit(dep)
|
||||
|
||||
visiting.remove(name)
|
||||
@@ -199,7 +289,7 @@ class FixtureRegistry:
|
||||
|
||||
return resolved
|
||||
|
||||
def resolve_context_dependencies(self, *contexts: str | Context) -> list[str]:
|
||||
def resolve_context_dependencies(self, *contexts: str | Enum) -> list[str]:
|
||||
"""Resolve all fixtures for contexts with dependencies.
|
||||
|
||||
Args:
|
||||
@@ -209,7 +299,9 @@ class FixtureRegistry:
|
||||
List of fixture names in load order
|
||||
"""
|
||||
context_fixtures = self.get_by_context(*contexts)
|
||||
names = [f.name for f in context_fixtures]
|
||||
# Deduplicate names while preserving first-seen order (a name can
|
||||
# appear multiple times if it has variants in different contexts).
|
||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||
|
||||
all_deps: set[str] = set()
|
||||
for name in names:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Fixture loading utilities for database seeding."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -10,23 +13,163 @@ from ..db import get_transaction
|
||||
from ..logger import get_logger
|
||||
from ..types import ModelType
|
||||
from .enum import LoadStrategy
|
||||
from .registry import Context, FixtureRegistry
|
||||
from .registry import FixtureRegistry, _normalize_contexts
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
|
||||
"""Extract column values from a model instance, skipping unset server-default columns."""
|
||||
state = sa_inspect(instance)
|
||||
state_dict = state.dict
|
||||
result: dict[str, Any] = {}
|
||||
for prop in state.mapper.column_attrs:
|
||||
if prop.key not in state_dict:
|
||||
continue
|
||||
val = state_dict[prop.key]
|
||||
if val is None:
|
||||
col = prop.columns[0]
|
||||
|
||||
if col.server_default is not None or (
|
||||
col.default is not None and col.default.is_callable
|
||||
):
|
||||
continue
|
||||
result[prop.key] = val
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_rows(dicts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Ensure all row dicts share the same key set."""
|
||||
all_keys: set[str] = set().union(*dicts)
|
||||
return [{k: d.get(k) for k in all_keys} for d in dicts]
|
||||
|
||||
|
||||
def _group_by_type(
|
||||
instances: list[DeclarativeBase],
|
||||
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
||||
"""Group instances by their concrete model class, preserving insertion order."""
|
||||
groups: dict[type[DeclarativeBase], list[DeclarativeBase]] = {}
|
||||
for instance in instances:
|
||||
groups.setdefault(type(instance), []).append(instance)
|
||||
return list(groups.items())
|
||||
|
||||
|
||||
async def _batch_insert(
|
||||
session: AsyncSession,
|
||||
model_cls: type[DeclarativeBase],
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||
dicts = _normalize_rows([_instance_to_dict(i) for i in instances])
|
||||
await session.execute(pg_insert(model_cls).values(dicts))
|
||||
|
||||
|
||||
async def _batch_merge(
|
||||
session: AsyncSession,
|
||||
model_cls: type[DeclarativeBase],
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||
mapper = model_cls.__mapper__
|
||||
pk_names = [col.name for col in mapper.primary_key]
|
||||
pk_names_set = set(pk_names)
|
||||
non_pk_cols = [
|
||||
prop.key
|
||||
for prop in mapper.column_attrs
|
||||
if not any(col.name in pk_names_set for col in prop.columns)
|
||||
]
|
||||
|
||||
dicts = _normalize_rows([_instance_to_dict(i) for i in instances])
|
||||
stmt = pg_insert(model_cls).values(dicts)
|
||||
|
||||
inserted_keys = set(dicts[0]) if dicts else set()
|
||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||
|
||||
if update_cols:
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=pk_names,
|
||||
set_={col: stmt.excluded[col] for col in update_cols},
|
||||
)
|
||||
else:
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
||||
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
async def _batch_skip_existing(
|
||||
session: AsyncSession,
|
||||
model_cls: type[DeclarativeBase],
|
||||
instances: list[DeclarativeBase],
|
||||
) -> list[DeclarativeBase]:
|
||||
"""INSERT only rows that do not already exist; return the inserted ones."""
|
||||
mapper = model_cls.__mapper__
|
||||
pk_names = [col.name for col in mapper.primary_key]
|
||||
|
||||
no_pk: list[DeclarativeBase] = []
|
||||
with_pk_pairs: list[tuple[DeclarativeBase, Any]] = []
|
||||
for inst in instances:
|
||||
pk = _get_primary_key(inst)
|
||||
if pk is None:
|
||||
no_pk.append(inst)
|
||||
else:
|
||||
with_pk_pairs.append((inst, pk))
|
||||
|
||||
loaded: list[DeclarativeBase] = list(no_pk)
|
||||
if no_pk:
|
||||
await session.execute(
|
||||
pg_insert(model_cls).values(
|
||||
_normalize_rows([_instance_to_dict(i) for i in no_pk])
|
||||
)
|
||||
)
|
||||
|
||||
if with_pk_pairs:
|
||||
with_pk = [i for i, _ in with_pk_pairs]
|
||||
stmt = (
|
||||
pg_insert(model_cls)
|
||||
.values(_normalize_rows([_instance_to_dict(i) for i in with_pk]))
|
||||
.on_conflict_do_nothing(index_elements=pk_names)
|
||||
)
|
||||
result = await session.execute(stmt.returning(*mapper.primary_key))
|
||||
inserted_pks = {row[0] if len(pk_names) == 1 else tuple(row) for row in result}
|
||||
loaded.extend(inst for inst, pk in with_pk_pairs if pk in inserted_pks)
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
async def _load_ordered(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
ordered_names: list[str],
|
||||
strategy: LoadStrategy,
|
||||
contexts: tuple[str, ...] | None = None,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load fixtures in order."""
|
||||
"""Load fixtures in order using batch Core INSERT statements.
|
||||
|
||||
When *contexts* is provided only variants whose context set intersects with
|
||||
*contexts* are called for each name; their instances are concatenated.
|
||||
When *contexts* is ``None`` all variants of each name are loaded.
|
||||
"""
|
||||
results: dict[str, list[DeclarativeBase]] = {}
|
||||
|
||||
for name in ordered_names:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
variants = (
|
||||
registry.get_variants(name, *contexts)
|
||||
if contexts is not None
|
||||
else registry.get_variants(name)
|
||||
)
|
||||
|
||||
# Cross-context dependency fallback: if we're loading by context but
|
||||
# no variant matches (e.g. a "base"-only fixture required by a
|
||||
# "testing" fixture), load all available variants so the dependency
|
||||
# is satisfied.
|
||||
if contexts is not None and not variants:
|
||||
variants = registry.get_variants(name)
|
||||
|
||||
if not variants:
|
||||
results[name] = []
|
||||
continue
|
||||
|
||||
instances = [inst for v in variants for inst in v.func()]
|
||||
|
||||
if not instances:
|
||||
results[name] = []
|
||||
@@ -36,25 +179,17 @@ async def _load_ordered(
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
for instance in instances:
|
||||
if strategy == LoadStrategy.INSERT:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
elif strategy == LoadStrategy.MERGE:
|
||||
merged = await session.merge(instance)
|
||||
loaded.append(merged)
|
||||
|
||||
else: # LoadStrategy.SKIP_EXISTING
|
||||
pk = _get_primary_key(instance)
|
||||
if pk is not None:
|
||||
existing = await session.get(type(instance), pk)
|
||||
if existing is None:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
else:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
for model_cls, group in _group_by_type(instances):
|
||||
match strategy:
|
||||
case LoadStrategy.INSERT:
|
||||
await _batch_insert(session, model_cls, group)
|
||||
loaded.extend(group)
|
||||
case LoadStrategy.MERGE:
|
||||
await _batch_merge(session, model_cls, group)
|
||||
loaded.extend(group)
|
||||
case LoadStrategy.SKIP_EXISTING:
|
||||
inserted = await _batch_skip_existing(session, model_cls, group)
|
||||
loaded.extend(inserted)
|
||||
|
||||
results[name] = loaded
|
||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||
@@ -109,6 +244,8 @@ async def load_fixtures(
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load specific fixtures by name with dependencies.
|
||||
|
||||
All context variants of each requested fixture are loaded and merged.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
@@ -125,19 +262,27 @@ async def load_fixtures(
|
||||
async def load_fixtures_by_context(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
*contexts: str | Context,
|
||||
*contexts: str | Enum,
|
||||
strategy: LoadStrategy = LoadStrategy.MERGE,
|
||||
) -> dict[str, list[DeclarativeBase]]:
|
||||
"""Load all fixtures for specific contexts.
|
||||
|
||||
For each fixture name, only the variants whose context set intersects with
|
||||
*contexts* are loaded. When a name has variants in multiple of the
|
||||
requested contexts, their instances are merged before being inserted.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
|
||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||
or plain strings for custom contexts)
|
||||
strategy: How to handle existing records
|
||||
|
||||
Returns:
|
||||
Dict mapping fixture names to loaded instances
|
||||
"""
|
||||
context_strings = tuple(_normalize_contexts(contexts))
|
||||
ordered = registry.resolve_context_dependencies(*contexts)
|
||||
return await _load_ordered(session, registry, ordered, strategy)
|
||||
return await _load_ordered(
|
||||
session, registry, ordered, strategy, contexts=context_strings
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ _SESSION_CREATES = "_ft_creates"
|
||||
_SESSION_DELETES = "_ft_deletes"
|
||||
_SESSION_UPDATES = "_ft_updates"
|
||||
_SESSION_SAVEPOINT_DEPTH = "_ft_sp_depth"
|
||||
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
||||
|
||||
|
||||
class ModelEvent(str, Enum):
|
||||
@@ -60,11 +61,22 @@ def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
||||
"""Read currently-loaded column values into a plain dict."""
|
||||
state = sa_inspect(obj) # InstanceState
|
||||
state_dict = state.dict
|
||||
return {
|
||||
prop.key: state_dict[prop.key]
|
||||
for prop in state.mapper.column_attrs
|
||||
if prop.key in state_dict
|
||||
}
|
||||
snapshot: dict[str, Any] = {}
|
||||
for prop in state.mapper.column_attrs:
|
||||
if prop.key in state_dict:
|
||||
snapshot[prop.key] = state_dict[prop.key]
|
||||
elif (
|
||||
not state.expired
|
||||
and prop.strategy_key != _DEFERRED_STRATEGY_KEY
|
||||
and all(
|
||||
col.nullable
|
||||
and col.server_default is None
|
||||
and col.server_onupdate is None
|
||||
for col in prop.columns
|
||||
)
|
||||
):
|
||||
snapshot[prop.key] = None
|
||||
return snapshot
|
||||
|
||||
|
||||
def _get_watched_fields(cls: type) -> list[str] | None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
@@ -15,13 +16,8 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..db import (
|
||||
cleanup_tables as _cleanup_tables,
|
||||
create_database,
|
||||
create_db_context,
|
||||
)
|
||||
from ..db import cleanup_tables as _cleanup_tables
|
||||
from ..db import create_database
|
||||
|
||||
|
||||
async def cleanup_tables(
|
||||
@@ -269,15 +265,12 @@ async def create_db_session(
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(base.metadata.create_all)
|
||||
|
||||
# Create session using existing db context utility
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
|
||||
get_session = create_db_context(session_maker)
|
||||
|
||||
async with get_session() as session:
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
if cleanup:
|
||||
await cleanup_tables(session, base)
|
||||
await _cleanup_tables(session=session, base=base)
|
||||
|
||||
if drop_tables:
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -57,6 +57,7 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
email: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
notes: Mapped[str | None]
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("roles.id"), nullable=True
|
||||
)
|
||||
|
||||
@@ -646,7 +646,7 @@ class TestFacetsRelationship:
|
||||
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert set(result.filter_attributes["name"]) == {"admin", "editor"}
|
||||
assert set(result.filter_attributes["role__name"]) == {"admin", "editor"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_relationship_facet_none_excluded(self, db_session: AsyncSession):
|
||||
@@ -661,7 +661,7 @@ class TestFacetsRelationship:
|
||||
result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert result.filter_attributes["name"] == []
|
||||
assert result.filter_attributes["role__name"] == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_relationship_facet_deduplicates_join_with_search(
|
||||
@@ -689,7 +689,7 @@ class TestFacetsRelationship:
|
||||
)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert result.filter_attributes["name"] == ["admin"]
|
||||
assert result.filter_attributes["role__name"] == ["admin"]
|
||||
|
||||
|
||||
class TestFilterBy:
|
||||
@@ -755,7 +755,7 @@ class TestFilterBy:
|
||||
)
|
||||
|
||||
result = await UserRelFacetCrud.offset_paginate(
|
||||
db_session, filter_by={"name": "admin"}, schema=UserRead
|
||||
db_session, filter_by={"role__name": "admin"}, schema=UserRead
|
||||
)
|
||||
|
||||
assert isinstance(result.pagination, OffsetPagination)
|
||||
@@ -824,7 +824,7 @@ class TestFilterBy:
|
||||
|
||||
result = await UserRoleFacetCrud.offset_paginate(
|
||||
db_session,
|
||||
filter_by={"name": "admin", "id": str(admin.id)},
|
||||
filter_by={"role__name": "admin", "role__id": str(admin.id)},
|
||||
schema=UserRead,
|
||||
)
|
||||
|
||||
@@ -916,15 +916,15 @@ class TestFilterParamsSchema:
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"username", "email"}
|
||||
|
||||
def test_relationship_facet_uses_column_key(self):
|
||||
"""Relationship tuple uses the terminal column's key."""
|
||||
def test_relationship_facet_uses_full_chain_key(self):
|
||||
"""Relationship tuple uses the full chain joined by __ as the key."""
|
||||
import inspect
|
||||
|
||||
UserRoleCrud = CrudFactory(User, facet_fields=[(User.role, Role.name)])
|
||||
dep = UserRoleCrud.filter_params()
|
||||
|
||||
param_names = set(inspect.signature(dep).parameters)
|
||||
assert param_names == {"name"}
|
||||
assert param_names == {"role__name"}
|
||||
|
||||
def test_raises_when_no_facet_fields(self):
|
||||
"""ValueError raised when no facet_fields are configured or provided."""
|
||||
@@ -978,6 +978,22 @@ class TestFilterParamsSchema:
|
||||
keys = facet_keys([(rel_a, col_a), (rel_b, col_b)])
|
||||
assert keys == ["project__name", "os__name"]
|
||||
|
||||
def test_deep_chain_joins_all_segments(self):
|
||||
"""Three-element tuple produces all relation segments joined by __."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi_toolsets.crud.search import facet_keys
|
||||
|
||||
rel_a = MagicMock()
|
||||
rel_a.key = "role"
|
||||
rel_b = MagicMock()
|
||||
rel_b.key = "permission"
|
||||
col = MagicMock()
|
||||
col.key = "name"
|
||||
|
||||
keys = facet_keys([(rel_a, rel_b, col)])
|
||||
assert keys == ["role__permission__name"]
|
||||
|
||||
def test_unique_column_keys_kept_plain(self):
|
||||
"""Fields with unique column keys are not prefixed."""
|
||||
from fastapi_toolsets.crud.search import facet_keys
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestAppSessionDep:
|
||||
gen = get_db()
|
||||
session = await gen.__anext__()
|
||||
assert isinstance(session, AsyncSession)
|
||||
await session.close()
|
||||
await gen.aclose()
|
||||
|
||||
|
||||
class TestOffsetPagination:
|
||||
@@ -182,8 +182,7 @@ class TestOffsetPagination:
|
||||
body = resp.json()
|
||||
fa = body["filter_attributes"]
|
||||
assert set(fa["status"]) == {"draft", "published"}
|
||||
# "name" is unique across all facet fields — no prefix needed
|
||||
assert set(fa["name"]) == {"backend", "python"}
|
||||
assert set(fa["category__name"]) == {"backend", "python"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filter_attributes_scoped_to_filter(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for fastapi_toolsets.fixtures module."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,10 +14,22 @@ from fastapi_toolsets.fixtures import (
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
)
|
||||
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
|
||||
|
||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
||||
from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
|
||||
|
||||
from .conftest import IntRole, Permission, Role, RoleCrud, User, UserCrud
|
||||
|
||||
class AppContext(str, Enum):
|
||||
"""Example user-defined str+Enum context."""
|
||||
|
||||
STAGING = "staging"
|
||||
DEMO = "demo"
|
||||
|
||||
|
||||
class PlainEnumContext(Enum):
|
||||
"""Example user-defined plain Enum context (no str mixin)."""
|
||||
|
||||
STAGING = "staging"
|
||||
|
||||
|
||||
class TestContext:
|
||||
@@ -39,6 +52,86 @@ class TestContext:
|
||||
assert Context.TESTING.value == "testing"
|
||||
|
||||
|
||||
class TestCustomEnumContext:
|
||||
"""Custom Enum types are accepted wherever Context/str are expected."""
|
||||
|
||||
def test_cannot_subclass_context_with_members(self):
|
||||
"""Python prohibits extending an Enum that already has members."""
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
|
||||
STAGING = "staging"
|
||||
|
||||
def test_custom_enum_values_interchangeable_with_context(self):
|
||||
"""A custom enum with the same .value as a built-in Context member is
|
||||
treated as the same context — fixtures registered under one are found
|
||||
by the other."""
|
||||
|
||||
class AppContextFull(str, Enum):
|
||||
BASE = "base"
|
||||
STAGING = "staging"
|
||||
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def roles():
|
||||
return []
|
||||
|
||||
# AppContextFull.BASE has value "base" — same as Context.BASE
|
||||
fixtures = registry.get_by_context(AppContextFull.BASE)
|
||||
assert len(fixtures) == 1
|
||||
|
||||
def test_custom_enum_registry_default_contexts(self):
|
||||
"""FixtureRegistry(contexts=[...]) accepts a custom Enum."""
|
||||
registry = FixtureRegistry(contexts=[AppContext.STAGING])
|
||||
|
||||
@registry.register
|
||||
def data():
|
||||
return []
|
||||
|
||||
fixture = registry.get("data")
|
||||
assert fixture.contexts == ["staging"]
|
||||
|
||||
def test_custom_enum_resolve_context_dependencies(self):
|
||||
"""resolve_context_dependencies accepts a custom Enum context."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[AppContext.STAGING])
|
||||
def staging_roles():
|
||||
return []
|
||||
|
||||
order = registry.resolve_context_dependencies(AppContext.STAGING)
|
||||
assert "staging_roles" in order
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_custom_enum_e2e(self, db_session: AsyncSession):
|
||||
"""End-to-end: register with custom Enum, load with the same Enum."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[AppContext.STAGING])
|
||||
def staging_roles():
|
||||
return [Role(id=uuid.uuid4(), name="staging-admin")]
|
||||
|
||||
result = await load_fixtures_by_context(
|
||||
db_session, registry, AppContext.STAGING
|
||||
)
|
||||
assert len(result["staging_roles"]) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plain_enum_e2e(self, db_session: AsyncSession):
|
||||
"""End-to-end: register with plain Enum, load with the same Enum."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[PlainEnumContext.STAGING])
|
||||
def staging_roles():
|
||||
return [Role(id=uuid.uuid4(), name="plain-staging-admin")]
|
||||
|
||||
result = await load_fixtures_by_context(
|
||||
db_session, registry, PlainEnumContext.STAGING
|
||||
)
|
||||
assert len(result["staging_roles"]) == 1
|
||||
|
||||
|
||||
class TestLoadStrategy:
|
||||
"""Tests for LoadStrategy enum."""
|
||||
|
||||
@@ -407,6 +500,37 @@ class TestDependencyResolution:
|
||||
with pytest.raises(ValueError, match="Circular dependency"):
|
||||
registry.resolve_dependencies("a")
|
||||
|
||||
def test_resolve_raises_for_unknown_dependency(self):
|
||||
"""KeyError when depends_on references an unregistered fixture."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(depends_on=["ghost"])
|
||||
def users():
|
||||
return []
|
||||
|
||||
with pytest.raises(KeyError, match="ghost"):
|
||||
registry.resolve_dependencies("users")
|
||||
|
||||
def test_resolve_deduplicates_shared_depends_on_across_variants(self):
|
||||
"""A dep shared by two same-name variants appears only once in the order."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def roles():
|
||||
return []
|
||||
|
||||
@registry.register(depends_on=["roles"], contexts=[Context.BASE])
|
||||
def items():
|
||||
return []
|
||||
|
||||
@registry.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||
def items(): # noqa: F811
|
||||
return []
|
||||
|
||||
order = registry.resolve_dependencies("items")
|
||||
assert order.count("roles") == 1
|
||||
assert order.index("roles") < order.index("items")
|
||||
|
||||
def test_resolve_context_dependencies(self):
|
||||
"""Resolve all fixtures for a context with dependencies."""
|
||||
registry = FixtureRegistry()
|
||||
@@ -496,6 +620,52 @@ class TestLoadFixtures:
|
||||
count = await RoleCrud.count(db_session)
|
||||
assert count == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_does_not_overwrite_omitted_nullable_columns(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""MERGE must not clear nullable columns that the fixture didn't set.
|
||||
|
||||
When a fixture omits a nullable column (e.g. role_id or notes), a re-merge
|
||||
must leave the existing DB value untouched — not overwrite it with NULL.
|
||||
"""
|
||||
registry = FixtureRegistry()
|
||||
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
uid = uuid.uuid4()
|
||||
|
||||
# First load: user has role_id and notes set
|
||||
@registry.register
|
||||
def users():
|
||||
return [
|
||||
User(
|
||||
id=uid,
|
||||
username="alice",
|
||||
email="a@test.com",
|
||||
role_id=admin.id,
|
||||
notes="original",
|
||||
)
|
||||
]
|
||||
|
||||
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.MERGE)
|
||||
|
||||
# Second load: fixture omits role_id and notes
|
||||
registry2 = FixtureRegistry()
|
||||
|
||||
@registry2.register
|
||||
def users(): # noqa: F811
|
||||
return [User(id=uid, username="alice-updated", email="a@test.com")]
|
||||
|
||||
await load_fixtures(db_session, registry2, "users", strategy=LoadStrategy.MERGE)
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
row = (
|
||||
await db_session.execute(select(User).where(User.id == uid))
|
||||
).scalar_one()
|
||||
assert row.username == "alice-updated" # updated column changed
|
||||
assert row.role_id == admin.id # omitted → preserved
|
||||
assert row.notes == "original" # omitted → preserved
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
|
||||
"""Load fixtures with SKIP_EXISTING strategy."""
|
||||
@@ -795,3 +965,145 @@ class TestGetPrimaryKey:
|
||||
instance = Permission(subject="post") # action is None
|
||||
pk = _get_primary_key(instance)
|
||||
assert pk is None
|
||||
|
||||
|
||||
class TestRegistryGetVariants:
|
||||
"""Tests for FixtureRegistry.get and get_variants edge cases."""
|
||||
|
||||
def test_get_raises_value_error_for_multi_variant(self):
|
||||
"""get() raises ValueError when the fixture has multiple context variants."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def items():
|
||||
return []
|
||||
|
||||
@registry.register(contexts=[Context.TESTING])
|
||||
def items(): # noqa: F811
|
||||
return []
|
||||
|
||||
with pytest.raises(ValueError, match="get_variants"):
|
||||
registry.get("items")
|
||||
|
||||
def test_get_variants_raises_key_error_for_unknown(self):
|
||||
"""get_variants() raises KeyError for an unregistered name."""
|
||||
registry = FixtureRegistry()
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
registry.get_variants("no_such_fixture")
|
||||
|
||||
|
||||
class TestInstanceToDict:
|
||||
"""Unit tests for the _instance_to_dict helper."""
|
||||
|
||||
def test_explicit_values_included(self):
|
||||
"""All explicitly set column values appear in the result."""
|
||||
role_id = uuid.uuid4()
|
||||
instance = Role(id=role_id, name="admin")
|
||||
d = _instance_to_dict(instance)
|
||||
assert d["id"] == role_id
|
||||
assert d["name"] == "admin"
|
||||
|
||||
def test_callable_default_none_excluded(self):
|
||||
"""A column whose value is None but has a callable Python-side default
|
||||
(e.g. ``default=uuid.uuid4``) is excluded so the DB generates it."""
|
||||
instance = Role(id=None, name="admin")
|
||||
d = _instance_to_dict(instance)
|
||||
assert "id" not in d
|
||||
assert d["name"] == "admin"
|
||||
|
||||
def test_nullable_none_included(self):
|
||||
"""None on a nullable column with no default is kept (explicit NULL)."""
|
||||
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", role_id=None)
|
||||
d = _instance_to_dict(instance)
|
||||
assert "role_id" in d
|
||||
assert d["role_id"] is None
|
||||
|
||||
def test_nullable_str_no_default_omitted_not_in_dict(self):
|
||||
"""Mapped[str | None] with no default, not provided in constructor, is absent from dict."""
|
||||
instance = User(id=uuid.uuid4(), username="u", email="e@e.com")
|
||||
d = _instance_to_dict(instance)
|
||||
assert "notes" not in d
|
||||
|
||||
def test_nullable_str_no_default_explicit_none_included(self):
|
||||
"""Mapped[str | None] with no default, explicitly set to None, is included as NULL."""
|
||||
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes=None)
|
||||
d = _instance_to_dict(instance)
|
||||
assert "notes" in d
|
||||
assert d["notes"] is None
|
||||
|
||||
def test_nullable_str_no_default_with_value_included(self):
|
||||
"""Mapped[str | None] with no default and a value set is included normally."""
|
||||
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes="hello")
|
||||
d = _instance_to_dict(instance)
|
||||
assert d["notes"] == "hello"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nullable_str_no_default_insert_roundtrip(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Fixture loading works for models with Mapped[str | None] (no default).
|
||||
|
||||
Both the omitted-value (→ NULL) and explicit-None paths must insert without error.
|
||||
"""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
uid_a = uuid.uuid4()
|
||||
uid_b = uuid.uuid4()
|
||||
uid_c = uuid.uuid4()
|
||||
|
||||
@registry.register
|
||||
def users():
|
||||
return [
|
||||
User(
|
||||
id=uid_a, username="no_notes", email="a@test.com"
|
||||
), # notes omitted
|
||||
User(
|
||||
id=uid_b, username="null_notes", email="b@test.com", notes=None
|
||||
), # explicit None
|
||||
User(
|
||||
id=uid_c, username="has_notes", email="c@test.com", notes="hi"
|
||||
), # value set
|
||||
]
|
||||
|
||||
result = await load_fixtures(db_session, registry, "users")
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
rows = (
|
||||
(await db_session.execute(select(User).order_by(User.username)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_username = {r.username: r for r in rows}
|
||||
|
||||
assert by_username["no_notes"].notes is None
|
||||
assert by_username["null_notes"].notes is None
|
||||
assert by_username["has_notes"].notes == "hi"
|
||||
assert len(result["users"]) == 3
|
||||
|
||||
|
||||
class TestBatchMergeNonPkColumns:
|
||||
"""Batch MERGE on a model with no non-PK columns (PK-only table)."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_pk_only_model(self, db_session: AsyncSession):
|
||||
"""MERGE strategy on a PK-only model uses on_conflict_do_nothing."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register
|
||||
def permissions():
|
||||
return [
|
||||
Permission(subject="post", action="read"),
|
||||
Permission(subject="post", action="write"),
|
||||
]
|
||||
|
||||
result = await load_fixtures(
|
||||
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||
)
|
||||
assert len(result["permissions"]) == 2
|
||||
|
||||
# Run again — conflicts are silently ignored.
|
||||
result2 = await load_fixtures(
|
||||
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||
)
|
||||
assert len(result2["permissions"]) == 2
|
||||
|
||||
@@ -32,6 +32,7 @@ from fastapi_toolsets.models.watched import (
|
||||
_after_flush,
|
||||
_after_flush_postexec,
|
||||
_after_rollback,
|
||||
_snapshot_column_attrs,
|
||||
_task_error_handler,
|
||||
_upsert_changes,
|
||||
)
|
||||
@@ -213,20 +214,36 @@ class AttrAccessModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||
__tablename__ = "mixin_attr_access_models"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
callback_url: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
async def on_create(self) -> None:
|
||||
_attr_access_events.append(
|
||||
{"event": "create", "id": self.id, "name": self.name}
|
||||
{
|
||||
"event": "create",
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"callback_url": self.callback_url,
|
||||
}
|
||||
)
|
||||
|
||||
async def on_delete(self) -> None:
|
||||
_attr_access_events.append(
|
||||
{"event": "delete", "id": self.id, "name": self.name}
|
||||
{
|
||||
"event": "delete",
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"callback_url": self.callback_url,
|
||||
}
|
||||
)
|
||||
|
||||
async def on_update(self, changes: dict) -> None:
|
||||
_attr_access_events.append(
|
||||
{"event": "update", "id": self.id, "name": self.name}
|
||||
{
|
||||
"event": "update",
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"callback_url": self.callback_url,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1279,3 +1296,67 @@ class TestAttributeAccessInCallbacks:
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0]["id"], uuid.UUID)
|
||||
assert events[0]["name"] == "updated"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nullable_column_none_accessible_in_on_create(
|
||||
self, mixin_session_expire
|
||||
):
|
||||
"""Nullable column left as None is accessible in on_create without greenlet error."""
|
||||
obj = AttrAccessModel(name="no-url") # callback_url not set → None
|
||||
mixin_session_expire.add(obj)
|
||||
await mixin_session_expire.commit()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
events = [e for e in _attr_access_events if e["event"] == "create"]
|
||||
assert len(events) == 1
|
||||
assert events[0]["callback_url"] is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nullable_column_with_value_accessible_in_on_create(
|
||||
self, mixin_session_expire
|
||||
):
|
||||
"""Nullable column set to a value is accessible in on_create without greenlet error."""
|
||||
obj = AttrAccessModel(name="with-url", callback_url="https://example.com/hook")
|
||||
mixin_session_expire.add(obj)
|
||||
await mixin_session_expire.commit()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
events = [e for e in _attr_access_events if e["event"] == "create"]
|
||||
assert len(events) == 1
|
||||
assert events[0]["callback_url"] == "https://example.com/hook"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nullable_column_accessible_after_update_to_none(
|
||||
self, mixin_session_expire
|
||||
):
|
||||
"""Nullable column updated to None is accessible in on_update without greenlet error."""
|
||||
obj = AttrAccessModel(name="x", callback_url="https://example.com/hook")
|
||||
mixin_session_expire.add(obj)
|
||||
await mixin_session_expire.commit()
|
||||
await asyncio.sleep(0)
|
||||
_attr_access_events.clear()
|
||||
|
||||
obj.callback_url = None
|
||||
await mixin_session_expire.commit()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
events = [e for e in _attr_access_events if e["event"] == "update"]
|
||||
assert len(events) == 1
|
||||
assert events[0]["callback_url"] is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_nullable_column_not_inferred_as_none(
|
||||
self, mixin_session_expire
|
||||
):
|
||||
"""A nullable column with a real value that is expired (by a prior
|
||||
expire_on_commit) must not be inferred as None in the snapshot — its
|
||||
actual value is unknown without a DB refresh."""
|
||||
obj = AttrAccessModel(name="original", callback_url="https://example.com/hook")
|
||||
mixin_session_expire.add(obj)
|
||||
await mixin_session_expire.commit()
|
||||
# expire_on_commit fired → obj.state.expired=True, callback_url not in state.dict
|
||||
|
||||
snapshot = _snapshot_column_attrs(obj)
|
||||
|
||||
# callback_url has a real DB value but is expired — must not be snapshotted as None.
|
||||
assert "callback_url" not in snapshot
|
||||
|
||||
@@ -7,9 +7,10 @@ from fastapi import Depends, FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||
from fastapi_toolsets.pytest import (
|
||||
create_async_client,
|
||||
@@ -336,6 +337,55 @@ class TestCreateDbSession:
|
||||
result = await session.execute(select(Role))
|
||||
assert result.all() == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
||||
role_id = uuid.uuid4()
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||
# Simulate what _create_fixture_function does: insert via get_transaction
|
||||
# with no explicit commit afterward.
|
||||
async with get_transaction(session):
|
||||
role = Role(id=role_id, name="visible_to_other_session")
|
||||
session.add(role)
|
||||
|
||||
# The data must have been committed (begin/commit, not a savepoint),
|
||||
# so a separate engine/session can read it.
|
||||
other_engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
try:
|
||||
other_session_maker = async_sessionmaker(
|
||||
other_engine, expire_on_commit=False
|
||||
)
|
||||
async with other_session_maker() as other:
|
||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||
fetched = result.scalar_one_or_none()
|
||||
assert fetched is not None, (
|
||||
"Fixture data inserted via get_transaction() must be committed "
|
||||
"and visible to a separate session. If create_db_session uses "
|
||||
"create_db_context, auto-begin forces get_transaction() into "
|
||||
"savepoints instead of real commits."
|
||||
)
|
||||
assert fetched.name == "visible_to_other_session"
|
||||
finally:
|
||||
await other_engine.dispose()
|
||||
|
||||
# Cleanup
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as _:
|
||||
pass
|
||||
|
||||
|
||||
class TestDeprecatedCleanupTables:
|
||||
"""Tests for the deprecated cleanup_tables re-export in fastapi_toolsets.pytest."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_emits_deprecation_warning(self):
|
||||
"""cleanup_tables imported from fastapi_toolsets.pytest emits DeprecationWarning."""
|
||||
from fastapi_toolsets.pytest.utils import cleanup_tables
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||
with pytest.warns(DeprecationWarning, match="fastapi_toolsets.db"):
|
||||
await cleanup_tables(session, Base)
|
||||
|
||||
|
||||
class TestGetXdistWorker:
|
||||
"""Tests for _get_xdist_worker helper."""
|
||||
|
||||
65
uv.lock
generated
65
uv.lock
generated
@@ -285,6 +285,7 @@ dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "mike" },
|
||||
{ name = "mkdocstrings-python" },
|
||||
{ name = "prek" },
|
||||
{ name = "pytest" },
|
||||
@@ -296,6 +297,7 @@ dev = [
|
||||
{ name = "zensical" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mike" },
|
||||
{ name = "mkdocstrings-python" },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
@@ -328,6 +330,7 @@ dev = [
|
||||
{ name = "coverage", specifier = ">=7.0.0" },
|
||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||
{ name = "prek", specifier = ">=0.3.8" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
@@ -336,11 +339,12 @@ dev = [
|
||||
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.1.0" },
|
||||
{ name = "ty", specifier = ">=0.0.1a0" },
|
||||
{ name = "zensical", specifier = ">=0.0.23" },
|
||||
{ name = "zensical", specifier = ">=0.0.30" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||
{ name = "zensical", specifier = ">=0.0.23" },
|
||||
{ name = "zensical", specifier = ">=0.0.30" },
|
||||
]
|
||||
tests = [
|
||||
{ name = "coverage", specifier = ">=7.0.0" },
|
||||
@@ -604,6 +608,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mike"
|
||||
version = "2.2.0+zensical.0.1.0"
|
||||
source = { git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0#0f62791256ebeba60d20d2f1d8fe6ec3b7d1e2b3" }
|
||||
dependencies = [
|
||||
{ name = "jinja2" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "verspec" },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs"
|
||||
version = "1.6.1"
|
||||
@@ -890,6 +905,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
@@ -1262,6 +1286,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "verspec"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
@@ -1291,7 +1324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.29"
|
||||
version = "0.0.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1301,18 +1334,18 @@ dependencies = [
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/bd/5786ab618a60bd7469ab243a7fd2c9eecb0790c85c784abb8b97edb77a54/zensical-0.0.29.tar.gz", hash = "sha256:0d6282be7cb551e12d5806badf5e94c54a5e2f2cf07057a3e36d1eaf97c33ada", size = 3842641, upload-time = "2026-03-24T13:37:27.587Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/53/5e551f8912718816733a75adcb53a0787b2d2edca5869c156325aaf82e24/zensical-0.0.30.tar.gz", hash = "sha256:408b531683f6bcb6cc5ab928146d2c68afbc16fac4eda87ae3dd20af1498180f", size = 3844287, upload-time = "2026-03-28T17:55:52.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9c/8b681daa024abca9763017bec09ecee8008e110cae1254217c8dd22cc339/zensical-0.0.29-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:20ae0709ea14fce25ab33d0a82acdaf454a7a2e232a9ee20c019942205174476", size = 12311399, upload-time = "2026-03-24T13:36:53.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ae/4ebb4d8bb2ef0164d473698b92f11caf431fc436e1625524acd5641102ca/zensical-0.0.29-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:599af3ba66fcd0146d7019f3493ed3c316051fae6c4d5599bc59f3a8f4b8a6f0", size = 12191845, upload-time = "2026-03-24T13:36:56.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/35/67f89db06571a52283b3ecbe3bcf32fd3115ca50436b3ae177a948b83ea7/zensical-0.0.29-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eea7e48a00a71c0586e875079b5f83a070c33a147e52ad4383e4b63ab524332b", size = 12554105, upload-time = "2026-03-24T13:36:59.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/ac79e5d9c18b28557c9ff1c7c23d695fbdd82645d69bfe02292f46d935e7/zensical-0.0.29-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59a57db35542e98d2896b833de07d199320f8ada3b4e7ddccb7fe892292d8b74", size = 12498643, upload-time = "2026-03-24T13:37:02.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/70/5c22a96a69e0e91e569c26236918bb9bab1170f59b29ad04105ead64f199/zensical-0.0.29-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d42c2b2a96a80cf64c98ba7242f59ef95109914bd4c9499d7ebc12544663852c", size = 12854531, upload-time = "2026-03-24T13:37:04.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/25/e32237a8fcb0ceae1ef8e192e7f8db53b38f1e48f1c7cdbacd0a7b713892/zensical-0.0.29-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2fca39c5f6b1782c77cf6591cf346357cabee85ebdb956c5ddc0fd5169f3d9", size = 12596828, upload-time = "2026-03-24T13:37:07.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/74/89ac909cbb258903ea53802c184e4986c17ce0ba79b1c7f77b7e78a2dce3/zensical-0.0.29-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfc23a74ef672aa51088c080286319da1dc0b989cd5051e9e5e6d7d4abbc2fc1", size = 12732059, upload-time = "2026-03-24T13:37:11.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/31/2429de6a9328eed4acc7e9a3789f160294a15115be15f9870a0d02649302/zensical-0.0.29-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c9336d4e4b232e3c9a70e30258e916dd7e60c0a2a08c8690065e60350c302028", size = 12768542, upload-time = "2026-03-24T13:37:14.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8a/55588b2a1dcbe86dad0404506c9ba367a06c663b1ff47147c84d26f7510e/zensical-0.0.29-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:30661148f0681199f3b598cbeb1d54f5cba773e54ae840bac639250d85907b84", size = 12917991, upload-time = "2026-03-24T13:37:16.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/5d/653901f0d3a3ca72daebc62746a148797f4e422cc3a2b66a4e6718e4398f/zensical-0.0.29-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6a566ac1fd4bfac5d711a7bd1ae06666712127c2718daa5083c7bf3f107e8578", size = 12868392, upload-time = "2026-03-24T13:37:19.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/58/d7449bc88a174b98daa3f2fbdfbdac3493768a557d8987e88bdaa6c78b1a/zensical-0.0.29-cp310-abi3-win32.whl", hash = "sha256:a231a3a02a3851741dc4d2de8910b5c39fe81e55bf026d8edf4d803e91a922fb", size = 11905486, upload-time = "2026-03-24T13:37:22.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/09/3fd082d016497c4d26ff20f42a8be2cc91e27191c0c5f3cd6507827f666f/zensical-0.0.29-cp310-abi3-win_amd64.whl", hash = "sha256:7145c5504380a344b8cd4586da815cdde77ef4a42319fa4f35e78250f01985af", size = 12101510, upload-time = "2026-03-24T13:37:24.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/e3/ac0eb77a8a7f793613813de68bde26776d0da68d8041fa9eb8d0b986a449/zensical-0.0.30-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b67fca8bfcd71c94b331045a591bf6e24fe123a66fba94587aa3379faf521a16", size = 12313786, upload-time = "2026-03-28T17:55:18.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/6a/73e461dfa27d3bc415e48396f83a3287b43df2fd3361e25146bc86360aab/zensical-0.0.30-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8ceadfece1153edc26506e8ddf68d9818afe8517cf3bcdb6bfe4cb2793ae247b", size = 12186136, upload-time = "2026-03-28T17:55:21.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/bc/9022156b4c28c1b95209acb64319b1e5cd0af2e97035bdd461e58408cb46/zensical-0.0.30-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e100b2b654337ac5306ba12818f3c5336c66d0d34c593ef05e316c124a5819cb", size = 12556115, upload-time = "2026-03-28T17:55:24.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/29/9e8f5bd6d33b35f4c368ae8b13d431dc42b2de17ea6eccbd71d48122eba6/zensical-0.0.30-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdf641ffddaf21c6971b91a4426b81cd76271c5b1adb7176afcce3f1508328b1", size = 12498121, upload-time = "2026-03-28T17:55:27.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/e1/b8dfa0769050e62cd731358145fdeb67af35e322197bd7e7727250596e7b/zensical-0.0.30-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd909a0c2116e26190c7f3ec4fb55837c417b7a8d99ebf4f3deb26b07b97e49", size = 12854142, upload-time = "2026-03-28T17:55:30.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/11/62a36cfb81522b6108db8f9e96d36da8cccb306b02c15ad19e1b333fa7c8/zensical-0.0.30-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16fd2da09fe4e5cbec2ca74f31abc70f32f7330d56593b647e0a114bb329171a", size = 12598341, upload-time = "2026-03-28T17:55:32.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a4/8c7a6725fb226aa71d19209403d974e45f39d757e725f9558c6ed8d350a5/zensical-0.0.30-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:896b36eaef7fed5f8fc6f2c8264b2751aad63c2d66d3d8650e38481b6b4f6f7b", size = 12732307, upload-time = "2026-03-28T17:55:35.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a1/7858fb3f6ac67d7d24a8acbe834cbe26851d6bd151ece6fba3fc88b0f878/zensical-0.0.30-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a1f515ec67a0d0250e53846327bf0c69635a1f39749da3b04feb68431188d3c6", size = 12770962, upload-time = "2026-03-28T17:55:38.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b7/228298112a69d0b74e6e93041bffcf1fc96d03cf252be94a354f277d4789/zensical-0.0.30-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce33d1002438838a35fa43358a1f43d74f874586596d3d116999d3756cded00e", size = 12919256, upload-time = "2026-03-28T17:55:41.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c7/5b4ea036f7f7d84abf907f7f7a3e8420b054c89279c5273ca248d3bc9f48/zensical-0.0.30-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:029dad561568f4ae3056dde16a81012efd92c426d4eb7101f960f448c1168196", size = 12869760, upload-time = "2026-03-28T17:55:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/b4/77bef2132e43108db718ae014a5961fc511e88fc446c11f1c3483def429e/zensical-0.0.30-cp310-abi3-win32.whl", hash = "sha256:0105672850f053c326fba9fdd95adf60e9f90308f8cc1c08e3a00e15a8d5e90f", size = 11905658, upload-time = "2026-03-28T17:55:47.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/59/23b6c7ff062e2b299cc60e333095e853f9d38d1b5abe743c7b94c4ac432c/zensical-0.0.30-cp310-abi3-win_amd64.whl", hash = "sha256:b879dbf4c69d3ea41694bae33e1b948847e635dcbcd6ec8c522920833379dd48", size = 12101867, upload-time = "2026-03-28T17:55:50.083Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user