fastapi-fsp 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/.gitignore +2 -0
- fastapi_fsp-0.1.1/.pre-commit-config.yaml +21 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/PKG-INFO +4 -4
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/fastapi_fsp/fsp.py +82 -10
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/pyproject.toml +5 -4
- fastapi_fsp-0.1.1/tests/conftest_async.py +40 -0
- fastapi_fsp-0.1.1/tests/main_async.py +51 -0
- fastapi_fsp-0.1.1/tests/test_fsp_async.py +180 -0
- fastapi_fsp-0.1.1/tests/test_fsp_filters_sync.py +144 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/uv.lock +113 -1
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/.github/workflows/ci.yml +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/.github/workflows/release.yml +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/LICENSE +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/PROJECT.md +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/README.md +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/fastapi_fsp/__init__.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/fastapi_fsp/models.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/main.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/pytest.ini +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/tests/__init__.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/tests/conftest.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/tests/main.py +0 -0
- {fastapi_fsp-0.1.0 → fastapi_fsp-0.1.1}/tests/test_fsp.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
# Use a ruff-pre-commit version compatible with your ruff version
|
|
4
|
+
# You can update this as needed.
|
|
5
|
+
rev: v0.6.9
|
|
6
|
+
hooks:
|
|
7
|
+
- id: ruff
|
|
8
|
+
args: ["--fix", "--exit-non-zero-on-fix"]
|
|
9
|
+
- id: ruff-format
|
|
10
|
+
|
|
11
|
+
# Run tests on commit. This may be slower, but ensures quality.
|
|
12
|
+
- repo: local
|
|
13
|
+
hooks:
|
|
14
|
+
- id: pytest
|
|
15
|
+
name: pytest
|
|
16
|
+
entry: pytest
|
|
17
|
+
language: system
|
|
18
|
+
pass_filenames: false
|
|
19
|
+
types: [python]
|
|
20
|
+
stages: [pre-commit]
|
|
21
|
+
args: ["-q"]
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/fromej-dev/fastapi-fsp
|
|
6
|
+
Project-URL: Repository, https://github.com/fromej-dev/fastapi-fsp
|
|
7
|
+
Project-URL: Issues, https://github.com/fromej-dev/fastapi-fsp/issues
|
|
8
8
|
Author-email: Evert Jan Stamhuis <ej@fromejdevelopment.nl>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
@@ -3,7 +3,8 @@ from typing import Annotated, Any, List, Optional
|
|
|
3
3
|
|
|
4
4
|
from fastapi import Depends, HTTPException, Query, Request, status
|
|
5
5
|
from sqlalchemy import Select, func
|
|
6
|
-
from sqlmodel import Session, select
|
|
6
|
+
from sqlmodel import Session, not_, select
|
|
7
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
7
8
|
|
|
8
9
|
from fastapi_fsp.models import (
|
|
9
10
|
Filter,
|
|
@@ -26,7 +27,7 @@ def _parse_filters(
|
|
|
26
27
|
if not fields:
|
|
27
28
|
return None
|
|
28
29
|
filters: List[Filter] = []
|
|
29
|
-
if not (len(fields) == len(operators) == len(values)):
|
|
30
|
+
if operators is None or values is None or not (len(fields) == len(operators) == len(values)):
|
|
30
31
|
raise HTTPException(
|
|
31
32
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
32
33
|
detail="Mismatched filter parameters.",
|
|
@@ -72,11 +73,24 @@ class FSPManager:
|
|
|
72
73
|
)
|
|
73
74
|
).all()
|
|
74
75
|
|
|
76
|
+
async def paginate_async(self, query: Select, session: AsyncSession) -> Any:
|
|
77
|
+
result = await session.exec(
|
|
78
|
+
query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
|
|
79
|
+
self.pagination.per_page
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return result.all()
|
|
83
|
+
|
|
75
84
|
def _count_total(self, query: Select, session: Session) -> int:
|
|
76
85
|
# Count the total rows of the given query (with filters/sort applied) ignoring pagination
|
|
77
86
|
count_query = select(func.count()).select_from(query.subquery())
|
|
78
87
|
return session.exec(count_query).one()
|
|
79
88
|
|
|
89
|
+
async def _count_total_async(self, query: Select, session: AsyncSession) -> int:
|
|
90
|
+
count_query = select(func.count()).select_from(query.subquery())
|
|
91
|
+
result = await session.exec(count_query)
|
|
92
|
+
return result.one()
|
|
93
|
+
|
|
80
94
|
def _apply_filters(self, query: Select) -> Select:
|
|
81
95
|
# Helper: build a map of column name -> column object from the select statement
|
|
82
96
|
try:
|
|
@@ -152,7 +166,7 @@ class FSPManager:
|
|
|
152
166
|
elif op == "like":
|
|
153
167
|
query = query.where(column.like(str(raw_value)))
|
|
154
168
|
elif op == "not_like":
|
|
155
|
-
query = query.where(
|
|
169
|
+
query = query.where(not_(column.like(str(raw_value))))
|
|
156
170
|
elif op == "ilike":
|
|
157
171
|
pattern = str(raw_value)
|
|
158
172
|
if ilike_supported(column):
|
|
@@ -162,15 +176,15 @@ class FSPManager:
|
|
|
162
176
|
elif op == "not_ilike":
|
|
163
177
|
pattern = str(raw_value)
|
|
164
178
|
if ilike_supported(column):
|
|
165
|
-
query = query.where(
|
|
179
|
+
query = query.where(not_(column.ilike(pattern)))
|
|
166
180
|
else:
|
|
167
|
-
query = query.where(
|
|
181
|
+
query = query.where(not_(func.lower(column).like(pattern.lower())))
|
|
168
182
|
elif op == "in":
|
|
169
183
|
vals = [coerce_value(column, v) for v in split_values(raw_value)]
|
|
170
184
|
query = query.where(column.in_(vals))
|
|
171
185
|
elif op == "not_in":
|
|
172
186
|
vals = [coerce_value(column, v) for v in split_values(raw_value)]
|
|
173
|
-
query = query.where(
|
|
187
|
+
query = query.where(not_(column.in_(vals)))
|
|
174
188
|
elif op == "between":
|
|
175
189
|
vals = split_values(raw_value)
|
|
176
190
|
if len(vals) != 2:
|
|
@@ -185,13 +199,22 @@ class FSPManager:
|
|
|
185
199
|
query = query.where(column.is_not(None))
|
|
186
200
|
elif op == "starts_with":
|
|
187
201
|
pattern = f"{str(raw_value)}%"
|
|
188
|
-
|
|
202
|
+
if ilike_supported(column):
|
|
203
|
+
query = query.where(column.ilike(pattern))
|
|
204
|
+
else:
|
|
205
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
189
206
|
elif op == "ends_with":
|
|
190
207
|
pattern = f"%{str(raw_value)}"
|
|
191
|
-
|
|
208
|
+
if ilike_supported(column):
|
|
209
|
+
query = query.where(column.ilike(pattern))
|
|
210
|
+
else:
|
|
211
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
192
212
|
elif op == "contains":
|
|
193
213
|
pattern = f"%{str(raw_value)}%"
|
|
194
|
-
|
|
214
|
+
if ilike_supported(column):
|
|
215
|
+
query = query.where(column.ilike(pattern))
|
|
216
|
+
else:
|
|
217
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
195
218
|
else:
|
|
196
219
|
# Unknown operator: skip
|
|
197
220
|
continue
|
|
@@ -221,7 +244,6 @@ class FSPManager:
|
|
|
221
244
|
|
|
222
245
|
def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
|
|
223
246
|
query = self._apply_filters(query)
|
|
224
|
-
|
|
225
247
|
query = self._apply_sort(query)
|
|
226
248
|
|
|
227
249
|
total_items = self._count_total(query, session)
|
|
@@ -267,3 +289,53 @@ class FSPManager:
|
|
|
267
289
|
prev=prev_url,
|
|
268
290
|
),
|
|
269
291
|
)
|
|
292
|
+
|
|
293
|
+
async def generate_response_async(
|
|
294
|
+
self, query: Select, session: AsyncSession
|
|
295
|
+
) -> PaginatedResponse[Any]:
|
|
296
|
+
query = self._apply_filters(query)
|
|
297
|
+
query = self._apply_sort(query)
|
|
298
|
+
|
|
299
|
+
total_items = await self._count_total_async(query, session)
|
|
300
|
+
per_page = self.pagination.per_page
|
|
301
|
+
current_page = self.pagination.page
|
|
302
|
+
total_pages = max(1, math.ceil(total_items / per_page)) if total_items is not None else 1
|
|
303
|
+
|
|
304
|
+
data_page = await self.paginate_async(query, session)
|
|
305
|
+
|
|
306
|
+
# Build links based on current URL, replacing/adding page and per_page parameters
|
|
307
|
+
url = self.request.url
|
|
308
|
+
first_url = str(url.include_query_params(page=1, per_page=per_page))
|
|
309
|
+
last_url = str(url.include_query_params(page=total_pages, per_page=per_page))
|
|
310
|
+
next_url = (
|
|
311
|
+
str(url.include_query_params(page=current_page + 1, per_page=per_page))
|
|
312
|
+
if current_page < total_pages
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
315
|
+
prev_url = (
|
|
316
|
+
str(url.include_query_params(page=current_page - 1, per_page=per_page))
|
|
317
|
+
if current_page > 1
|
|
318
|
+
else None
|
|
319
|
+
)
|
|
320
|
+
self_url = str(url.include_query_params(page=current_page, per_page=per_page))
|
|
321
|
+
|
|
322
|
+
return PaginatedResponse(
|
|
323
|
+
data=data_page,
|
|
324
|
+
meta=Meta(
|
|
325
|
+
pagination=Pagination(
|
|
326
|
+
total_items=total_items,
|
|
327
|
+
per_page=per_page,
|
|
328
|
+
current_page=current_page,
|
|
329
|
+
total_pages=total_pages,
|
|
330
|
+
),
|
|
331
|
+
filters=self.filters,
|
|
332
|
+
sort=self.sorting,
|
|
333
|
+
),
|
|
334
|
+
links=Links(
|
|
335
|
+
self=self_url,
|
|
336
|
+
first=first_url,
|
|
337
|
+
last=last_url,
|
|
338
|
+
next=next_url,
|
|
339
|
+
prev=prev_url,
|
|
340
|
+
),
|
|
341
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-fsp"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -30,9 +30,9 @@ dependencies = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.urls]
|
|
33
|
-
Homepage = "https://github.com/
|
|
34
|
-
Repository = "https://github.com/
|
|
35
|
-
Issues = "https://github.com/
|
|
33
|
+
Homepage = "https://github.com/fromej-dev/fastapi-fsp"
|
|
34
|
+
Repository = "https://github.com/fromej-dev/fastapi-fsp"
|
|
35
|
+
Issues = "https://github.com/fromej-dev/fastapi-fsp/issues"
|
|
36
36
|
|
|
37
37
|
[dependency-groups]
|
|
38
38
|
dev = [
|
|
@@ -44,6 +44,7 @@ dev = [
|
|
|
44
44
|
"pytest-cov>=6.2.1",
|
|
45
45
|
"pytest-mock>=3.14.1",
|
|
46
46
|
"ruff>=0.5.0",
|
|
47
|
+
"pre-commit>=3.7.0",
|
|
47
48
|
]
|
|
48
49
|
|
|
49
50
|
[tool.ruff]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
6
|
+
from sqlmodel import SQLModel
|
|
7
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
8
|
+
from starlette.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
from tests.main_async import app, get_session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(name="async_engine")
|
|
14
|
+
def async_engine_fixture(tmp_path) -> AsyncGenerator[AsyncEngine, None]:
|
|
15
|
+
db_path = tmp_path / "test_async.db"
|
|
16
|
+
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
|
|
17
|
+
|
|
18
|
+
async def _setup():
|
|
19
|
+
async with engine.begin() as conn:
|
|
20
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
21
|
+
|
|
22
|
+
asyncio.run(_setup())
|
|
23
|
+
try:
|
|
24
|
+
yield engine
|
|
25
|
+
finally:
|
|
26
|
+
asyncio.run(engine.dispose())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(name="client_async")
|
|
30
|
+
def client_async_fixture(async_engine: AsyncEngine):
|
|
31
|
+
async def _get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
32
|
+
async with AsyncSession(async_engine, expire_on_commit=False) as session:
|
|
33
|
+
yield session
|
|
34
|
+
|
|
35
|
+
app.dependency_overrides[get_session] = _get_session
|
|
36
|
+
client = TestClient(app)
|
|
37
|
+
try:
|
|
38
|
+
yield client
|
|
39
|
+
finally:
|
|
40
|
+
app.dependency_overrides.clear()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, FastAPI
|
|
5
|
+
from fastapi_fsp.fsp import FSPManager
|
|
6
|
+
from fastapi_fsp.models import PaginatedResponse
|
|
7
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
8
|
+
from sqlmodel import SQLModel, select
|
|
9
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
10
|
+
|
|
11
|
+
from tests.main import Hero, HeroPublic
|
|
12
|
+
|
|
13
|
+
sqlite_file_name = "database.db"
|
|
14
|
+
sqlite_url = f"sqlite+aiosqlite:///{sqlite_file_name}"
|
|
15
|
+
|
|
16
|
+
connect_args = {"check_same_thread": False}
|
|
17
|
+
engine = create_async_engine(sqlite_url, echo=True, connect_args=connect_args)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def create_db_and_tables():
|
|
21
|
+
async with engine.begin() as conn:
|
|
22
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
26
|
+
async_session = AsyncSession(engine, expire_on_commit=False)
|
|
27
|
+
try:
|
|
28
|
+
yield async_session
|
|
29
|
+
finally:
|
|
30
|
+
await async_session.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def lifespan(app: FastAPI):
|
|
35
|
+
# Code to run on startup
|
|
36
|
+
print("Creating database and tables...")
|
|
37
|
+
await create_db_and_tables()
|
|
38
|
+
yield
|
|
39
|
+
# Code to run on shutdown (if any)
|
|
40
|
+
print("Application shutdown.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = FastAPI(lifespan=lifespan)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.get("/heroes_async/", response_model=PaginatedResponse[HeroPublic])
|
|
47
|
+
async def read_heroes_async(
|
|
48
|
+
*, session: AsyncSession = Depends(get_session), fsp: FSPManager = Depends(FSPManager)
|
|
49
|
+
):
|
|
50
|
+
heroes = select(Hero)
|
|
51
|
+
return await fsp.generate_response_async(heroes, session)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
4
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
5
|
+
from starlette.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from tests.main_async import Hero
|
|
8
|
+
|
|
9
|
+
pytest_plugins = ("tests.conftest_async",)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _seed(async_engine: AsyncEngine):
|
|
13
|
+
async with AsyncSession(async_engine, expire_on_commit=False) as session:
|
|
14
|
+
session.add_all(
|
|
15
|
+
[
|
|
16
|
+
Hero(name="Deadpond", secret_name="Dive Wilson", age=None),
|
|
17
|
+
Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48),
|
|
18
|
+
Hero(name="ALPHA", secret_name="Alpha Secret", age=10),
|
|
19
|
+
Hero(name="beta", secret_name="Beta Secret", age=20),
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
await session.commit()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_async_full_operator_coverage(async_engine: AsyncEngine, client_async: TestClient):
|
|
26
|
+
asyncio.run(_seed(async_engine))
|
|
27
|
+
|
|
28
|
+
client = client_async
|
|
29
|
+
|
|
30
|
+
# Basic pagination
|
|
31
|
+
r = client.get("/heroes_async/?page=1&per_page=2")
|
|
32
|
+
assert r.status_code == 200
|
|
33
|
+
js = r.json()
|
|
34
|
+
assert len(js["data"]) == 2
|
|
35
|
+
assert js["meta"]["pagination"]["total_items"] == 4
|
|
36
|
+
assert js["links"]["self"].endswith("page=1&per_page=2")
|
|
37
|
+
|
|
38
|
+
# eq / ne
|
|
39
|
+
assert (
|
|
40
|
+
client.get("/heroes_async/?field=name&operator=eq&value=Deadpond").json()["data"][0]["name"]
|
|
41
|
+
== "Deadpond"
|
|
42
|
+
)
|
|
43
|
+
names_ne = [
|
|
44
|
+
h["name"]
|
|
45
|
+
for h in client.get("/heroes_async/?field=name&operator=ne&value=Deadpond").json()["data"]
|
|
46
|
+
]
|
|
47
|
+
assert "Deadpond" not in names_ne
|
|
48
|
+
|
|
49
|
+
# gt / gte / lt / lte on age
|
|
50
|
+
assert set(
|
|
51
|
+
[
|
|
52
|
+
h["name"]
|
|
53
|
+
for h in client.get("/heroes_async/?field=age&operator=gt&value=15").json()["data"]
|
|
54
|
+
]
|
|
55
|
+
) == {"beta", "Rusty-Man"}
|
|
56
|
+
assert set(
|
|
57
|
+
[
|
|
58
|
+
h["name"]
|
|
59
|
+
for h in client.get("/heroes_async/?field=age&operator=gte&value=20").json()["data"]
|
|
60
|
+
]
|
|
61
|
+
) == {"Rusty-Man", "beta"}
|
|
62
|
+
assert [
|
|
63
|
+
h["name"]
|
|
64
|
+
for h in client.get("/heroes_async/?field=age&operator=lt&value=15").json()["data"]
|
|
65
|
+
] == ["ALPHA"]
|
|
66
|
+
assert set(
|
|
67
|
+
[
|
|
68
|
+
h["name"]
|
|
69
|
+
for h in client.get("/heroes_async/?field=age&operator=lte&value=20").json()["data"]
|
|
70
|
+
]
|
|
71
|
+
) == {"ALPHA", "beta"}
|
|
72
|
+
|
|
73
|
+
# like / not_like (case-sensitive) on name
|
|
74
|
+
assert [
|
|
75
|
+
h["name"]
|
|
76
|
+
for h in client.get("/heroes_async/?field=name&operator=like&value=A%").json()["data"]
|
|
77
|
+
] == ["ALPHA"]
|
|
78
|
+
not_like_a = [
|
|
79
|
+
h["name"]
|
|
80
|
+
for h in client.get("/heroes_async/?field=name&operator=not_like&value=A%").json()["data"]
|
|
81
|
+
]
|
|
82
|
+
assert "ALPHA" not in not_like_a
|
|
83
|
+
|
|
84
|
+
# ilike / not_ilike (case-insensitive)
|
|
85
|
+
assert set(
|
|
86
|
+
[
|
|
87
|
+
h["name"]
|
|
88
|
+
for h in client.get("/heroes_async/?field=name&operator=ilike&value=a%").json()["data"]
|
|
89
|
+
]
|
|
90
|
+
) == {"ALPHA"}
|
|
91
|
+
not_ilike_beta = [
|
|
92
|
+
h["name"]
|
|
93
|
+
for h in client.get("/heroes_async/?field=name&operator=not_ilike&value=%eta").json()[
|
|
94
|
+
"data"
|
|
95
|
+
]
|
|
96
|
+
]
|
|
97
|
+
assert "beta" not in not_ilike_beta
|
|
98
|
+
|
|
99
|
+
# in / not_in
|
|
100
|
+
in_names = client.get("/heroes_async/?field=name&operator=in&value=Deadpond,ALPHA").json()[
|
|
101
|
+
"data"
|
|
102
|
+
]
|
|
103
|
+
assert set([h["name"] for h in in_names]) == {"Deadpond", "ALPHA"}
|
|
104
|
+
not_in_names = client.get(
|
|
105
|
+
"/heroes_async/?field=name&operator=not_in&value=Deadpond,ALPHA"
|
|
106
|
+
).json()["data"]
|
|
107
|
+
assert "Deadpond" not in [h["name"] for h in not_in_names]
|
|
108
|
+
assert "ALPHA" not in [h["name"] for h in not_in_names]
|
|
109
|
+
|
|
110
|
+
# between
|
|
111
|
+
between_ages = [
|
|
112
|
+
h["name"]
|
|
113
|
+
for h in client.get("/heroes_async/?field=age&operator=between&value=15,48").json()["data"]
|
|
114
|
+
]
|
|
115
|
+
assert set(between_ages) == {"beta", "Rusty-Man"}
|
|
116
|
+
|
|
117
|
+
# is_null / is_not_null
|
|
118
|
+
assert [
|
|
119
|
+
h["name"]
|
|
120
|
+
for h in client.get("/heroes_async/?field=age&operator=is_null&value=").json()["data"]
|
|
121
|
+
] == ["Deadpond"]
|
|
122
|
+
assert set(
|
|
123
|
+
[
|
|
124
|
+
h["name"]
|
|
125
|
+
for h in client.get("/heroes_async/?field=age&operator=is_not_null&value=").json()[
|
|
126
|
+
"data"
|
|
127
|
+
]
|
|
128
|
+
]
|
|
129
|
+
) == {"ALPHA", "beta", "Rusty-Man"}
|
|
130
|
+
|
|
131
|
+
# starts_with / ends_with / contains (case-insensitive behavior via ilike fallback)
|
|
132
|
+
assert set(
|
|
133
|
+
[
|
|
134
|
+
h["name"]
|
|
135
|
+
for h in client.get("/heroes_async/?field=name&operator=starts_with&value=a").json()[
|
|
136
|
+
"data"
|
|
137
|
+
]
|
|
138
|
+
]
|
|
139
|
+
) == {"ALPHA"}
|
|
140
|
+
assert set(
|
|
141
|
+
[
|
|
142
|
+
h["name"]
|
|
143
|
+
for h in client.get("/heroes_async/?field=name&operator=ends_with&value=a").json()[
|
|
144
|
+
"data"
|
|
145
|
+
]
|
|
146
|
+
]
|
|
147
|
+
) == {"ALPHA", "beta"}
|
|
148
|
+
assert set(
|
|
149
|
+
[
|
|
150
|
+
h["name"]
|
|
151
|
+
for h in client.get("/heroes_async/?field=name&operator=contains&value=us").json()[
|
|
152
|
+
"data"
|
|
153
|
+
]
|
|
154
|
+
]
|
|
155
|
+
) == {"Rusty-Man"}
|
|
156
|
+
|
|
157
|
+
# sort invalid column ignored
|
|
158
|
+
js = client.get("/heroes_async/?sort_by=unknown&order=asc").json()
|
|
159
|
+
assert js["meta"]["sort"] is not None # request reflected
|
|
160
|
+
|
|
161
|
+
# malformed between should not filter out anything
|
|
162
|
+
names = [
|
|
163
|
+
h["name"]
|
|
164
|
+
for h in client.get("/heroes_async/?field=age&operator=between&value=10").json()["data"]
|
|
165
|
+
]
|
|
166
|
+
assert set(names) == {"Deadpond", "Rusty-Man", "ALPHA", "beta"}
|
|
167
|
+
|
|
168
|
+
# unknown field filter ignored
|
|
169
|
+
names = [
|
|
170
|
+
h["name"]
|
|
171
|
+
for h in client.get("/heroes_async/?field=unknown&operator=eq&value=x").json()["data"]
|
|
172
|
+
]
|
|
173
|
+
assert set(names) == {"Deadpond", "Rusty-Man", "ALPHA", "beta"}
|
|
174
|
+
|
|
175
|
+
# pagination validation errors (422) and mismatched filter params (400)
|
|
176
|
+
assert client.get("/heroes_async/?page=0").status_code == 422
|
|
177
|
+
assert client.get("/heroes_async/?per_page=101").status_code == 422
|
|
178
|
+
# mismatched: missing value
|
|
179
|
+
resp = client.get("/heroes_async/?field=name&operator=eq")
|
|
180
|
+
assert resp.status_code == 400
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from fastapi.testclient import TestClient
|
|
2
|
+
from sqlmodel import Session
|
|
3
|
+
|
|
4
|
+
from tests.main import Hero
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def seed(session: Session):
|
|
8
|
+
session.add_all(
|
|
9
|
+
[
|
|
10
|
+
Hero(name="Deadpond", secret_name="Dive Wilson", age=None),
|
|
11
|
+
Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48),
|
|
12
|
+
Hero(name="ALPHA", secret_name="Alpha Secret", age=10),
|
|
13
|
+
Hero(name="beta", secret_name="Beta Secret", age=20),
|
|
14
|
+
]
|
|
15
|
+
)
|
|
16
|
+
session.commit()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_full_operator_coverage_sync(session: Session, client: TestClient):
|
|
20
|
+
seed(session)
|
|
21
|
+
|
|
22
|
+
# Basic pagination
|
|
23
|
+
r = client.get("/heroes/?page=1&per_page=2")
|
|
24
|
+
assert r.status_code == 200
|
|
25
|
+
js = r.json()
|
|
26
|
+
assert len(js["data"]) == 2
|
|
27
|
+
assert js["meta"]["pagination"]["total_items"] == 4
|
|
28
|
+
|
|
29
|
+
# eq / ne
|
|
30
|
+
assert (
|
|
31
|
+
client.get("/heroes/?field=name&operator=eq&value=Deadpond").json()["data"][0]["name"]
|
|
32
|
+
== "Deadpond"
|
|
33
|
+
)
|
|
34
|
+
names_ne = [
|
|
35
|
+
h["name"]
|
|
36
|
+
for h in client.get("/heroes/?field=name&operator=ne&value=Deadpond").json()["data"]
|
|
37
|
+
]
|
|
38
|
+
assert "Deadpond" not in names_ne
|
|
39
|
+
|
|
40
|
+
# gt / gte / lt / lte on age
|
|
41
|
+
assert set(
|
|
42
|
+
[h["name"] for h in client.get("/heroes/?field=age&operator=gt&value=15").json()["data"]]
|
|
43
|
+
) == {"beta", "Rusty-Man"}
|
|
44
|
+
assert set(
|
|
45
|
+
[h["name"] for h in client.get("/heroes/?field=age&operator=gte&value=20").json()["data"]]
|
|
46
|
+
) == {"Rusty-Man", "beta"}
|
|
47
|
+
assert [
|
|
48
|
+
h["name"] for h in client.get("/heroes/?field=age&operator=lt&value=15").json()["data"]
|
|
49
|
+
] == ["ALPHA"]
|
|
50
|
+
assert set(
|
|
51
|
+
[h["name"] for h in client.get("/heroes/?field=age&operator=lte&value=20").json()["data"]]
|
|
52
|
+
) == {"ALPHA", "beta"}
|
|
53
|
+
|
|
54
|
+
# like / not_like
|
|
55
|
+
assert [
|
|
56
|
+
h["name"] for h in client.get("/heroes/?field=name&operator=like&value=A%").json()["data"]
|
|
57
|
+
] == ["ALPHA"]
|
|
58
|
+
not_like_a = [
|
|
59
|
+
h["name"]
|
|
60
|
+
for h in client.get("/heroes/?field=name&operator=not_like&value=A%").json()["data"]
|
|
61
|
+
]
|
|
62
|
+
assert "ALPHA" not in not_like_a
|
|
63
|
+
|
|
64
|
+
# ilike / not_ilike
|
|
65
|
+
assert set(
|
|
66
|
+
[
|
|
67
|
+
h["name"]
|
|
68
|
+
for h in client.get("/heroes/?field=name&operator=ilike&value=a%").json()["data"]
|
|
69
|
+
]
|
|
70
|
+
) == {"ALPHA"}
|
|
71
|
+
not_ilike_beta = [
|
|
72
|
+
h["name"]
|
|
73
|
+
for h in client.get("/heroes/?field=name&operator=not_ilike&value=%eta").json()["data"]
|
|
74
|
+
]
|
|
75
|
+
assert "beta" not in not_ilike_beta
|
|
76
|
+
|
|
77
|
+
# in / not_in
|
|
78
|
+
in_names = client.get("/heroes/?field=name&operator=in&value=Deadpond,ALPHA").json()["data"]
|
|
79
|
+
assert set([h["name"] for h in in_names]) == {"Deadpond", "ALPHA"}
|
|
80
|
+
not_in_names = client.get("/heroes/?field=name&operator=not_in&value=Deadpond,ALPHA").json()[
|
|
81
|
+
"data"
|
|
82
|
+
]
|
|
83
|
+
assert "Deadpond" not in [h["name"] for h in not_in_names]
|
|
84
|
+
assert "ALPHA" not in [h["name"] for h in not_in_names]
|
|
85
|
+
|
|
86
|
+
# between
|
|
87
|
+
between_ages = [
|
|
88
|
+
h["name"]
|
|
89
|
+
for h in client.get("/heroes/?field=age&operator=between&value=15,48").json()["data"]
|
|
90
|
+
]
|
|
91
|
+
assert set(between_ages) == {"beta", "Rusty-Man"}
|
|
92
|
+
|
|
93
|
+
# is_null / is_not_null
|
|
94
|
+
assert [
|
|
95
|
+
h["name"] for h in client.get("/heroes/?field=age&operator=is_null&value=").json()["data"]
|
|
96
|
+
] == ["Deadpond"]
|
|
97
|
+
assert set(
|
|
98
|
+
[
|
|
99
|
+
h["name"]
|
|
100
|
+
for h in client.get("/heroes/?field=age&operator=is_not_null&value=").json()["data"]
|
|
101
|
+
]
|
|
102
|
+
) == {"ALPHA", "beta", "Rusty-Man"}
|
|
103
|
+
|
|
104
|
+
# starts_with / ends_with / contains
|
|
105
|
+
assert set(
|
|
106
|
+
[
|
|
107
|
+
h["name"]
|
|
108
|
+
for h in client.get("/heroes/?field=name&operator=starts_with&value=a").json()["data"]
|
|
109
|
+
]
|
|
110
|
+
) == {"ALPHA"}
|
|
111
|
+
assert set(
|
|
112
|
+
[
|
|
113
|
+
h["name"]
|
|
114
|
+
for h in client.get("/heroes/?field=name&operator=ends_with&value=a").json()["data"]
|
|
115
|
+
]
|
|
116
|
+
) == {"ALPHA", "beta"}
|
|
117
|
+
assert set(
|
|
118
|
+
[
|
|
119
|
+
h["name"]
|
|
120
|
+
for h in client.get("/heroes/?field=name&operator=contains&value=us").json()["data"]
|
|
121
|
+
]
|
|
122
|
+
) == {"Rusty-Man"}
|
|
123
|
+
|
|
124
|
+
# sort invalid column ignored (should not error)
|
|
125
|
+
js = client.get("/heroes/?sort_by=unknown&order=asc").json()
|
|
126
|
+
assert js["meta"]["sort"] is not None
|
|
127
|
+
|
|
128
|
+
# malformed between should not filter
|
|
129
|
+
names = [
|
|
130
|
+
h["name"] for h in client.get("/heroes/?field=age&operator=between&value=10").json()["data"]
|
|
131
|
+
]
|
|
132
|
+
assert set(names) == {"Deadpond", "Rusty-Man", "ALPHA", "beta"}
|
|
133
|
+
|
|
134
|
+
# unknown field filter ignored
|
|
135
|
+
names = [
|
|
136
|
+
h["name"] for h in client.get("/heroes/?field=unknown&operator=eq&value=x").json()["data"]
|
|
137
|
+
]
|
|
138
|
+
assert set(names) == {"Deadpond", "Rusty-Man", "ALPHA", "beta"}
|
|
139
|
+
|
|
140
|
+
# validation and failure scenarios
|
|
141
|
+
assert client.get("/heroes/?page=0").status_code == 422
|
|
142
|
+
assert client.get("/heroes/?per_page=101").status_code == 422
|
|
143
|
+
# mismatched filter params -> 400
|
|
144
|
+
assert client.get("/heroes/?field=name&operator=eq").status_code == 400
|
|
@@ -60,6 +60,15 @@ wheels = [
|
|
|
60
60
|
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
|
|
61
61
|
]
|
|
62
62
|
|
|
63
|
+
[[package]]
|
|
64
|
+
name = "cfgv"
|
|
65
|
+
version = "3.4.0"
|
|
66
|
+
source = { registry = "https://pypi.org/simple" }
|
|
67
|
+
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
|
|
68
|
+
wheels = [
|
|
69
|
+
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
|
|
70
|
+
]
|
|
71
|
+
|
|
63
72
|
[[package]]
|
|
64
73
|
name = "colorama"
|
|
65
74
|
version = "0.4.6"
|
|
@@ -133,6 +142,15 @@ wheels = [
|
|
|
133
142
|
{ url = "https://files.pythonhosted.org/packages/bb/78/983efd23200921d9edb6bd40512e1aa04af553d7d5a171e50f9b2b45d109/coverage-7.10.4-py3-none-any.whl", hash = "sha256:065d75447228d05121e5c938ca8f0e91eed60a1eb2d1258d42d5084fecfc3302", size = 208365, upload-time = "2025-08-17T00:26:41.479Z" },
|
|
134
143
|
]
|
|
135
144
|
|
|
145
|
+
[[package]]
|
|
146
|
+
name = "distlib"
|
|
147
|
+
version = "0.4.0"
|
|
148
|
+
source = { registry = "https://pypi.org/simple" }
|
|
149
|
+
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
|
150
|
+
wheels = [
|
|
151
|
+
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
|
152
|
+
]
|
|
153
|
+
|
|
136
154
|
[[package]]
|
|
137
155
|
name = "fastapi"
|
|
138
156
|
version = "0.116.1"
|
|
@@ -149,7 +167,7 @@ wheels = [
|
|
|
149
167
|
|
|
150
168
|
[[package]]
|
|
151
169
|
name = "fastapi-fsp"
|
|
152
|
-
version = "0.1.
|
|
170
|
+
version = "0.1.1"
|
|
153
171
|
source = { editable = "." }
|
|
154
172
|
dependencies = [
|
|
155
173
|
{ name = "fastapi" },
|
|
@@ -161,6 +179,7 @@ dev = [
|
|
|
161
179
|
{ name = "aiosqlite" },
|
|
162
180
|
{ name = "build" },
|
|
163
181
|
{ name = "httpx" },
|
|
182
|
+
{ name = "pre-commit" },
|
|
164
183
|
{ name = "pytest" },
|
|
165
184
|
{ name = "pytest-asyncio" },
|
|
166
185
|
{ name = "pytest-cov" },
|
|
@@ -179,6 +198,7 @@ dev = [
|
|
|
179
198
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
|
180
199
|
{ name = "build", specifier = ">=1.3.0" },
|
|
181
200
|
{ name = "httpx", specifier = ">=0.28.1" },
|
|
201
|
+
{ name = "pre-commit", specifier = ">=3.7.0" },
|
|
182
202
|
{ name = "pytest", specifier = ">=8.4.1" },
|
|
183
203
|
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
|
|
184
204
|
{ name = "pytest-cov", specifier = ">=6.2.1" },
|
|
@@ -186,6 +206,15 @@ dev = [
|
|
|
186
206
|
{ name = "ruff", specifier = ">=0.5.0" },
|
|
187
207
|
]
|
|
188
208
|
|
|
209
|
+
[[package]]
|
|
210
|
+
name = "filelock"
|
|
211
|
+
version = "3.19.1"
|
|
212
|
+
source = { registry = "https://pypi.org/simple" }
|
|
213
|
+
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
|
|
214
|
+
wheels = [
|
|
215
|
+
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
|
|
216
|
+
]
|
|
217
|
+
|
|
189
218
|
[[package]]
|
|
190
219
|
name = "greenlet"
|
|
191
220
|
version = "3.2.4"
|
|
@@ -256,6 +285,15 @@ wheels = [
|
|
|
256
285
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
257
286
|
]
|
|
258
287
|
|
|
288
|
+
[[package]]
|
|
289
|
+
name = "identify"
|
|
290
|
+
version = "2.6.13"
|
|
291
|
+
source = { registry = "https://pypi.org/simple" }
|
|
292
|
+
sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" }
|
|
293
|
+
wheels = [
|
|
294
|
+
{ url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" },
|
|
295
|
+
]
|
|
296
|
+
|
|
259
297
|
[[package]]
|
|
260
298
|
name = "idna"
|
|
261
299
|
version = "3.10"
|
|
@@ -274,6 +312,15 @@ wheels = [
|
|
|
274
312
|
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
|
275
313
|
]
|
|
276
314
|
|
|
315
|
+
[[package]]
|
|
316
|
+
name = "nodeenv"
|
|
317
|
+
version = "1.9.1"
|
|
318
|
+
source = { registry = "https://pypi.org/simple" }
|
|
319
|
+
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
|
|
320
|
+
wheels = [
|
|
321
|
+
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
|
322
|
+
]
|
|
323
|
+
|
|
277
324
|
[[package]]
|
|
278
325
|
name = "packaging"
|
|
279
326
|
version = "25.0"
|
|
@@ -283,6 +330,15 @@ wheels = [
|
|
|
283
330
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
|
284
331
|
]
|
|
285
332
|
|
|
333
|
+
[[package]]
|
|
334
|
+
name = "platformdirs"
|
|
335
|
+
version = "4.3.8"
|
|
336
|
+
source = { registry = "https://pypi.org/simple" }
|
|
337
|
+
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
|
|
338
|
+
wheels = [
|
|
339
|
+
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
|
340
|
+
]
|
|
341
|
+
|
|
286
342
|
[[package]]
|
|
287
343
|
name = "pluggy"
|
|
288
344
|
version = "1.6.0"
|
|
@@ -292,6 +348,22 @@ wheels = [
|
|
|
292
348
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
293
349
|
]
|
|
294
350
|
|
|
351
|
+
[[package]]
|
|
352
|
+
name = "pre-commit"
|
|
353
|
+
version = "4.3.0"
|
|
354
|
+
source = { registry = "https://pypi.org/simple" }
|
|
355
|
+
dependencies = [
|
|
356
|
+
{ name = "cfgv" },
|
|
357
|
+
{ name = "identify" },
|
|
358
|
+
{ name = "nodeenv" },
|
|
359
|
+
{ name = "pyyaml" },
|
|
360
|
+
{ name = "virtualenv" },
|
|
361
|
+
]
|
|
362
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
|
|
363
|
+
wheels = [
|
|
364
|
+
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
|
|
365
|
+
]
|
|
366
|
+
|
|
295
367
|
[[package]]
|
|
296
368
|
name = "pydantic"
|
|
297
369
|
version = "2.11.7"
|
|
@@ -421,6 +493,32 @@ wheels = [
|
|
|
421
493
|
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
|
|
422
494
|
]
|
|
423
495
|
|
|
496
|
+
[[package]]
|
|
497
|
+
name = "pyyaml"
|
|
498
|
+
version = "6.0.2"
|
|
499
|
+
source = { registry = "https://pypi.org/simple" }
|
|
500
|
+
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
|
501
|
+
wheels = [
|
|
502
|
+
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
|
|
503
|
+
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
|
|
504
|
+
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
|
|
505
|
+
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
|
|
506
|
+
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
|
|
507
|
+
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
|
|
508
|
+
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
|
|
509
|
+
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
|
|
510
|
+
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
|
|
511
|
+
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
|
512
|
+
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
|
513
|
+
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
|
514
|
+
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
|
515
|
+
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
|
516
|
+
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
|
517
|
+
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
|
518
|
+
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
|
519
|
+
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
|
520
|
+
]
|
|
521
|
+
|
|
424
522
|
[[package]]
|
|
425
523
|
name = "ruff"
|
|
426
524
|
version = "0.12.9"
|
|
@@ -531,3 +629,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7
|
|
|
531
629
|
wheels = [
|
|
532
630
|
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
|
533
631
|
]
|
|
632
|
+
|
|
633
|
+
[[package]]
|
|
634
|
+
name = "virtualenv"
|
|
635
|
+
version = "20.34.0"
|
|
636
|
+
source = { registry = "https://pypi.org/simple" }
|
|
637
|
+
dependencies = [
|
|
638
|
+
{ name = "distlib" },
|
|
639
|
+
{ name = "filelock" },
|
|
640
|
+
{ name = "platformdirs" },
|
|
641
|
+
]
|
|
642
|
+
sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" }
|
|
643
|
+
wheels = [
|
|
644
|
+
{ url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" },
|
|
645
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|