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.
@@ -34,6 +34,8 @@ coverage.xml
34
34
  # IDEs and editors
35
35
  .vscode/
36
36
  .idea/
37
+ .idea/*
38
+ .idea/workspace.xml
37
39
  *.iml
38
40
 
39
41
  # OS-specific
@@ -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.0
3
+ Version: 0.1.1
4
4
  Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
5
- Project-URL: Homepage, https://github.com/your-org/fastapi-fsp
6
- Project-URL: Repository, https://github.com/your-org/fastapi-fsp
7
- Project-URL: Issues, https://github.com/your-org/fastapi-fsp/issues
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(~column.like(str(raw_value)))
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(~column.ilike(pattern))
179
+ query = query.where(not_(column.ilike(pattern)))
166
180
  else:
167
- query = query.where(~func.lower(column).like(pattern.lower()))
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(~column.in_(vals))
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
- query = query.where(column.like(pattern))
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
- query = query.where(column.like(pattern))
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
- query = query.where(column.like(pattern))
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.0"
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/your-org/fastapi-fsp"
34
- Repository = "https://github.com/your-org/fastapi-fsp"
35
- Issues = "https://github.com/your-org/fastapi-fsp/issues"
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.0"
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