fastapi-fsp 0.2.2__tar.gz → 0.2.3__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.
Files changed (33) hide show
  1. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/PKG-INFO +60 -1
  2. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/README.md +59 -0
  3. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/fsp.py +53 -5
  4. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/pyproject.toml +1 -1
  5. fastapi_fsp-0.2.3/tests/test_fsp_computed_fields.py +221 -0
  6. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/uv.lock +1 -4
  7. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.github/workflows/ci.yml +0 -0
  8. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.github/workflows/release.yml +0 -0
  9. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.gitignore +0 -0
  10. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.pre-commit-config.yaml +0 -0
  11. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/LICENSE +0 -0
  12. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/OPTIMIZATION_ANALYSIS.md +0 -0
  13. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/PROJECT.md +0 -0
  14. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/RECOMMENDATIONS.md +0 -0
  15. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmark_results_optimized.txt +0 -0
  16. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/__init__.py +0 -0
  17. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/benchmark_internals.py +0 -0
  18. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/benchmark_suite.py +0 -0
  19. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/__init__.py +0 -0
  20. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/models.py +0 -0
  21. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/main.py +0 -0
  22. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/pytest.ini +0 -0
  23. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/__init__.py +0 -0
  24. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/conftest.py +0 -0
  25. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/conftest_async.py +0 -0
  26. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/main.py +0 -0
  27. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/main_async.py +0 -0
  28. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp.py +0 -0
  29. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_async.py +0 -0
  30. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_filters_indexed_sync.py +0 -0
  31. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_filters_sync.py +0 -0
  32. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_optimizations.py +0 -0
  33. {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_strict_mode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-fsp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
5
5
  Project-URL: Homepage, https://github.com/fromej-dev/fastapi-fsp
6
6
  Project-URL: Repository, https://github.com/fromej-dev/fastapi-fsp
@@ -146,6 +146,65 @@ Notes:
146
146
  - Both formats are equivalent; the indexed format takes precedence if present.
147
147
  - If any filter is incomplete (missing operator or value in the indexed form, or mismatched counts of simple triplets), the API responds with HTTP 400.
148
148
 
149
+ ## Filtering on Computed Fields
150
+
151
+ You can filter (and sort) on SQLAlchemy `hybrid_property` fields that have a SQL expression defined. This enables filtering on calculated or derived values at the database level.
152
+
153
+ ### Defining a Computed Field
154
+
155
+ ```python
156
+ from typing import ClassVar, Optional
157
+ from sqlalchemy import func
158
+ from sqlalchemy.ext.hybrid import hybrid_property
159
+ from sqlmodel import Field, SQLModel
160
+
161
+ class HeroBase(SQLModel):
162
+ name: str = Field(index=True)
163
+ secret_name: str
164
+ age: Optional[int] = Field(default=None)
165
+ full_name: ClassVar[str] # Required: declare as ClassVar for Pydantic
166
+
167
+ @hybrid_property
168
+ def full_name(self) -> str:
169
+ """Python-level implementation (used on instances)."""
170
+ return f"{self.name}-{self.secret_name}"
171
+
172
+ @full_name.expression
173
+ def full_name(cls):
174
+ """SQL-level implementation (used in queries)."""
175
+ return func.concat(cls.name, "-", cls.secret_name)
176
+
177
+ class Hero(HeroBase, table=True):
178
+ id: Optional[int] = Field(default=None, primary_key=True)
179
+
180
+ class HeroPublic(HeroBase):
181
+ id: int
182
+ full_name: str # Include in response model
183
+ ```
184
+
185
+ ### Querying Computed Fields
186
+
187
+ Once defined, you can filter and sort on the computed field like any regular field:
188
+
189
+ ```
190
+ # Filter by computed field
191
+ GET /heroes/?field=full_name&operator=eq&value=Spider-Man
192
+ GET /heroes/?field=full_name&operator=ilike&value=%man
193
+ GET /heroes/?field=full_name&operator=contains&value=Spider
194
+
195
+ # Sort by computed field
196
+ GET /heroes/?sort_by=full_name&order=asc
197
+
198
+ # Combine with other filters
199
+ GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operator=gte&value=21
200
+ ```
201
+
202
+ ### Requirements
203
+
204
+ - The `hybrid_property` must have an `.expression` decorator that returns a valid SQL expression
205
+ - The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
206
+ - Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
207
+
149
208
  ## Response model
150
209
 
151
210
  ```
@@ -118,6 +118,65 @@ Notes:
118
118
  - Both formats are equivalent; the indexed format takes precedence if present.
119
119
  - If any filter is incomplete (missing operator or value in the indexed form, or mismatched counts of simple triplets), the API responds with HTTP 400.
120
120
 
121
+ ## Filtering on Computed Fields
122
+
123
+ You can filter (and sort) on SQLAlchemy `hybrid_property` fields that have a SQL expression defined. This enables filtering on calculated or derived values at the database level.
124
+
125
+ ### Defining a Computed Field
126
+
127
+ ```python
128
+ from typing import ClassVar, Optional
129
+ from sqlalchemy import func
130
+ from sqlalchemy.ext.hybrid import hybrid_property
131
+ from sqlmodel import Field, SQLModel
132
+
133
+ class HeroBase(SQLModel):
134
+ name: str = Field(index=True)
135
+ secret_name: str
136
+ age: Optional[int] = Field(default=None)
137
+ full_name: ClassVar[str] # Required: declare as ClassVar for Pydantic
138
+
139
+ @hybrid_property
140
+ def full_name(self) -> str:
141
+ """Python-level implementation (used on instances)."""
142
+ return f"{self.name}-{self.secret_name}"
143
+
144
+ @full_name.expression
145
+ def full_name(cls):
146
+ """SQL-level implementation (used in queries)."""
147
+ return func.concat(cls.name, "-", cls.secret_name)
148
+
149
+ class Hero(HeroBase, table=True):
150
+ id: Optional[int] = Field(default=None, primary_key=True)
151
+
152
+ class HeroPublic(HeroBase):
153
+ id: int
154
+ full_name: str # Include in response model
155
+ ```
156
+
157
+ ### Querying Computed Fields
158
+
159
+ Once defined, you can filter and sort on the computed field like any regular field:
160
+
161
+ ```
162
+ # Filter by computed field
163
+ GET /heroes/?field=full_name&operator=eq&value=Spider-Man
164
+ GET /heroes/?field=full_name&operator=ilike&value=%man
165
+ GET /heroes/?field=full_name&operator=contains&value=Spider
166
+
167
+ # Sort by computed field
168
+ GET /heroes/?sort_by=full_name&order=asc
169
+
170
+ # Combine with other filters
171
+ GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operator=gte&value=21
172
+ ```
173
+
174
+ ### Requirements
175
+
176
+ - The `hybrid_property` must have an `.expression` decorator that returns a valid SQL expression
177
+ - The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
178
+ - Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
179
+
121
180
  ## Response model
122
181
 
123
182
  ```
@@ -228,10 +228,54 @@ class FSPManager:
228
228
  if col_id not in self._type_cache:
229
229
  try:
230
230
  self._type_cache[col_id] = getattr(column.type, "python_type", None)
231
- except Exception:
231
+ except (AttributeError, NotImplementedError):
232
+ # For computed fields (hybrid_property, etc.), type inference may fail
232
233
  self._type_cache[col_id] = None
233
234
  return self._type_cache[col_id]
234
235
 
236
+ @staticmethod
237
+ def _get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
238
+ """
239
+ Try to get a column-like attribute from the query's entity.
240
+
241
+ This enables filtering/sorting on computed fields like hybrid_property
242
+ that have SQL expressions defined.
243
+
244
+ Args:
245
+ query: SQLAlchemy Select query
246
+ field: Name of the field/attribute to get
247
+
248
+ Returns:
249
+ Optional[ColumnElement]: The SQL expression if available, None otherwise
250
+ """
251
+ try:
252
+ # Get the entity class from the query
253
+ column_descriptions = query.column_descriptions
254
+ if not column_descriptions:
255
+ return None
256
+
257
+ entity = column_descriptions[0].get("entity")
258
+ if entity is None:
259
+ return None
260
+
261
+ # Get the attribute from the entity class
262
+ attr = getattr(entity, field, None)
263
+ if attr is None:
264
+ return None
265
+
266
+ # Check if it's directly usable as a ColumnElement (hybrid_property with expression)
267
+ # When accessing a hybrid_property on the class, it returns the SQL expression
268
+ if isinstance(attr, ColumnElement):
269
+ return attr
270
+
271
+ # Some expressions may need to call __clause_element__
272
+ if hasattr(attr, "__clause_element__"):
273
+ return attr.__clause_element__()
274
+
275
+ return None
276
+ except Exception:
277
+ return None
278
+
235
279
  def paginate(self, query: Select, session: Session) -> Any:
236
280
  """
237
281
  Execute pagination on a query.
@@ -591,6 +635,11 @@ class FSPManager:
591
635
  for f in filters:
592
636
  # filter of `filters` has been validated in the `_parse_filters`
593
637
  column = columns_map.get(f.field)
638
+
639
+ # Fall back to computed fields (hybrid_property, etc.) if not in columns_map
640
+ if column is None:
641
+ column = FSPManager._get_entity_attribute(query, f.field)
642
+
594
643
  if column is None:
595
644
  if self.strict_mode:
596
645
  available = ", ".join(sorted(columns_map.keys()))
@@ -635,11 +684,10 @@ class FSPManager:
635
684
  """
636
685
  if sorting and sorting.sort_by:
637
686
  column = columns_map.get(sorting.sort_by)
687
+
688
+ # Fall back to computed fields (hybrid_property, etc.) if not in columns_map
638
689
  if column is None:
639
- try:
640
- column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
641
- except Exception:
642
- pass
690
+ column = FSPManager._get_entity_attribute(query, sorting.sort_by)
643
691
 
644
692
  if column is None:
645
693
  if self.strict_mode:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-fsp"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,221 @@
1
+ """Tests for filtering on computed fields (hybrid_property, etc.)."""
2
+
3
+ from fastapi.testclient import TestClient
4
+ from sqlmodel import Session
5
+
6
+ from tests.main import Hero
7
+
8
+
9
+ class TestComputedFieldFiltering:
10
+ """Test suite for filtering on computed fields like hybrid_property."""
11
+
12
+ def test_filter_hybrid_property_eq(self, session: Session, client: TestClient):
13
+ """Test filtering hybrid_property with equality operator."""
14
+ hero_1 = Hero(name="Spider", secret_name="Man")
15
+ hero_2 = Hero(name="Bat", secret_name="Man")
16
+ session.add(hero_1)
17
+ session.add(hero_2)
18
+ session.commit()
19
+
20
+ # Filter by full_name (hybrid_property) with exact match
21
+ response = client.get("/heroes/?field=full_name&operator=eq&value=Spider-Man")
22
+ assert response.status_code == 200
23
+ data = response.json()["data"]
24
+ assert len(data) == 1
25
+ assert data[0]["full_name"] == "Spider-Man"
26
+ assert data[0]["name"] == "Spider"
27
+
28
+ def test_filter_hybrid_property_ilike(self, session: Session, client: TestClient):
29
+ """Test filtering hybrid_property with ILIKE operator."""
30
+ hero_1 = Hero(name="Spider", secret_name="Man")
31
+ hero_2 = Hero(name="Bat", secret_name="Girl")
32
+ hero_3 = Hero(name="Iron", secret_name="Man")
33
+ session.add_all([hero_1, hero_2, hero_3])
34
+ session.commit()
35
+
36
+ # Filter by full_name with ILIKE pattern (case-insensitive)
37
+ response = client.get("/heroes/?field=full_name&operator=ilike&value=%man")
38
+ assert response.status_code == 200
39
+ data = response.json()["data"]
40
+ assert len(data) == 2
41
+ full_names = {d["full_name"] for d in data}
42
+ assert full_names == {"Spider-Man", "Iron-Man"}
43
+
44
+ def test_filter_hybrid_property_contains(self, session: Session, client: TestClient):
45
+ """Test filtering hybrid_property with contains operator."""
46
+ hero_1 = Hero(name="Spider", secret_name="Man")
47
+ hero_2 = Hero(name="Bat", secret_name="Woman")
48
+ session.add_all([hero_1, hero_2])
49
+ session.commit()
50
+
51
+ # Filter by full_name containing "ider"
52
+ response = client.get("/heroes/?field=full_name&operator=contains&value=ider")
53
+ assert response.status_code == 200
54
+ data = response.json()["data"]
55
+ assert len(data) == 1
56
+ assert data[0]["full_name"] == "Spider-Man"
57
+
58
+ def test_filter_hybrid_property_starts_with(self, session: Session, client: TestClient):
59
+ """Test filtering hybrid_property with starts_with operator."""
60
+ hero_1 = Hero(name="Spider", secret_name="Man")
61
+ hero_2 = Hero(name="Bat", secret_name="Man")
62
+ session.add_all([hero_1, hero_2])
63
+ session.commit()
64
+
65
+ # Filter by full_name starting with "Bat"
66
+ response = client.get("/heroes/?field=full_name&operator=starts_with&value=Bat")
67
+ assert response.status_code == 200
68
+ data = response.json()["data"]
69
+ assert len(data) == 1
70
+ assert data[0]["full_name"] == "Bat-Man"
71
+
72
+ def test_filter_hybrid_property_ends_with(self, session: Session, client: TestClient):
73
+ """Test filtering hybrid_property with ends_with operator."""
74
+ hero_1 = Hero(name="Spider", secret_name="Man")
75
+ hero_2 = Hero(name="Bat", secret_name="Woman")
76
+ session.add_all([hero_1, hero_2])
77
+ session.commit()
78
+
79
+ # Filter by full_name ending with "Woman"
80
+ response = client.get("/heroes/?field=full_name&operator=ends_with&value=Woman")
81
+ assert response.status_code == 200
82
+ data = response.json()["data"]
83
+ assert len(data) == 1
84
+ assert data[0]["full_name"] == "Bat-Woman"
85
+
86
+ def test_filter_hybrid_property_ne(self, session: Session, client: TestClient):
87
+ """Test filtering hybrid_property with not-equals operator."""
88
+ hero_1 = Hero(name="Spider", secret_name="Man")
89
+ hero_2 = Hero(name="Bat", secret_name="Man")
90
+ session.add_all([hero_1, hero_2])
91
+ session.commit()
92
+
93
+ # Filter by full_name not equal to "Spider-Man"
94
+ response = client.get("/heroes/?field=full_name&operator=ne&value=Spider-Man")
95
+ assert response.status_code == 200
96
+ data = response.json()["data"]
97
+ assert len(data) == 1
98
+ assert data[0]["full_name"] == "Bat-Man"
99
+
100
+ def test_filter_hybrid_property_in(self, session: Session, client: TestClient):
101
+ """Test filtering hybrid_property with IN operator."""
102
+ hero_1 = Hero(name="Spider", secret_name="Man")
103
+ hero_2 = Hero(name="Bat", secret_name="Man")
104
+ hero_3 = Hero(name="Iron", secret_name="Man")
105
+ session.add_all([hero_1, hero_2, hero_3])
106
+ session.commit()
107
+
108
+ # Filter by full_name in a list
109
+ response = client.get("/heroes/?field=full_name&operator=in&value=Spider-Man,Iron-Man")
110
+ assert response.status_code == 200
111
+ data = response.json()["data"]
112
+ assert len(data) == 2
113
+ full_names = {d["full_name"] for d in data}
114
+ assert full_names == {"Spider-Man", "Iron-Man"}
115
+
116
+ def test_filter_hybrid_property_not_in(self, session: Session, client: TestClient):
117
+ """Test filtering hybrid_property with NOT IN operator."""
118
+ hero_1 = Hero(name="Spider", secret_name="Man")
119
+ hero_2 = Hero(name="Bat", secret_name="Man")
120
+ hero_3 = Hero(name="Iron", secret_name="Man")
121
+ session.add_all([hero_1, hero_2, hero_3])
122
+ session.commit()
123
+
124
+ # Filter by full_name not in a list
125
+ response = client.get("/heroes/?field=full_name&operator=not_in&value=Spider-Man,Iron-Man")
126
+ assert response.status_code == 200
127
+ data = response.json()["data"]
128
+ assert len(data) == 1
129
+ assert data[0]["full_name"] == "Bat-Man"
130
+
131
+ def test_filter_hybrid_property_combined_with_regular_field(
132
+ self, session: Session, client: TestClient
133
+ ):
134
+ """Test filtering with both hybrid_property and regular fields."""
135
+ hero_1 = Hero(name="Spider", secret_name="Man", age=25)
136
+ hero_2 = Hero(name="Bat", secret_name="Man", age=35)
137
+ hero_3 = Hero(name="Spider", secret_name="Woman", age=28)
138
+ session.add_all([hero_1, hero_2, hero_3])
139
+ session.commit()
140
+
141
+ # Filter by hybrid_property AND regular field
142
+ response = client.get(
143
+ "/heroes/?field=full_name&operator=starts_with&value=Spider"
144
+ "&field=age&operator=lt&value=30"
145
+ )
146
+ assert response.status_code == 200
147
+ data = response.json()["data"]
148
+ assert len(data) == 2
149
+ full_names = {d["full_name"] for d in data}
150
+ assert full_names == {"Spider-Man", "Spider-Woman"}
151
+
152
+ def test_filter_hybrid_property_with_sort_and_pagination(
153
+ self, session: Session, client: TestClient
154
+ ):
155
+ """Test filtering hybrid_property combined with sorting and pagination."""
156
+ heroes = [
157
+ Hero(name="A", secret_name="Hero"),
158
+ Hero(name="B", secret_name="Hero"),
159
+ Hero(name="C", secret_name="Hero"),
160
+ Hero(name="D", secret_name="Villain"),
161
+ ]
162
+ session.add_all(heroes)
163
+ session.commit()
164
+
165
+ # Filter + sort + paginate
166
+ response = client.get(
167
+ "/heroes/?field=full_name&operator=ends_with&value=Hero"
168
+ "&sort_by=full_name&order=desc"
169
+ "&page=1&per_page=2"
170
+ )
171
+ assert response.status_code == 200
172
+ data = response.json()
173
+ assert len(data["data"]) == 2
174
+ assert data["data"][0]["full_name"] == "C-Hero"
175
+ assert data["data"][1]["full_name"] == "B-Hero"
176
+ assert data["meta"]["pagination"]["total_items"] == 3
177
+
178
+ def test_filter_hybrid_property_indexed_format(self, session: Session, client: TestClient):
179
+ """Test filtering hybrid_property with indexed filter format."""
180
+ hero_1 = Hero(name="Spider", secret_name="Man", age=25)
181
+ hero_2 = Hero(name="Bat", secret_name="Man", age=35)
182
+ session.add_all([hero_1, hero_2])
183
+ session.commit()
184
+
185
+ # Use indexed filter format
186
+ response = client.get(
187
+ "/heroes/?"
188
+ "filters[0][field]=full_name&filters[0][operator]=eq&filters[0][value]=Spider-Man"
189
+ )
190
+ assert response.status_code == 200
191
+ data = response.json()["data"]
192
+ assert len(data) == 1
193
+ assert data[0]["full_name"] == "Spider-Man"
194
+
195
+ def test_filter_unknown_field_non_strict_mode(self, session: Session, client: TestClient):
196
+ """Test that unknown fields are silently skipped in non-strict mode."""
197
+ hero_1 = Hero(name="Spider", secret_name="Man")
198
+ session.add(hero_1)
199
+ session.commit()
200
+
201
+ # Filter by non-existent field - should be silently skipped
202
+ response = client.get("/heroes/?field=nonexistent_field&operator=eq&value=test")
203
+ assert response.status_code == 200
204
+ data = response.json()["data"]
205
+ # All heroes should be returned since filter is skipped
206
+ assert len(data) == 1
207
+
208
+ def test_meta_includes_computed_field_filters(self, session: Session, client: TestClient):
209
+ """Test that response meta includes the computed field filter info."""
210
+ hero_1 = Hero(name="Spider", secret_name="Man")
211
+ session.add(hero_1)
212
+ session.commit()
213
+
214
+ response = client.get("/heroes/?field=full_name&operator=eq&value=Spider-Man")
215
+ assert response.status_code == 200
216
+ meta = response.json()["meta"]
217
+ assert meta["filters"] is not None
218
+ assert len(meta["filters"]) == 1
219
+ assert meta["filters"][0]["field"] == "full_name"
220
+ assert meta["filters"][0]["operator"] == "eq"
221
+ assert meta["filters"][0]["value"] == "Spider-Man"
@@ -187,7 +187,7 @@ wheels = [
187
187
 
188
188
  [[package]]
189
189
  name = "fastapi-fsp"
190
- version = "0.2.2"
190
+ version = "0.2.3"
191
191
  source = { editable = "." }
192
192
  dependencies = [
193
193
  { name = "fastapi" },
@@ -246,7 +246,6 @@ wheels = [
246
246
  { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
247
247
  { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
248
248
  { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
249
- { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
250
249
  { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
251
250
  { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
252
251
  { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
@@ -257,7 +256,6 @@ wheels = [
257
256
  { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
258
257
  { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
259
258
  { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
260
- { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
261
259
  { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
262
260
  { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
263
261
  { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
@@ -268,7 +266,6 @@ wheels = [
268
266
  { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
269
267
  { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
270
268
  { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
271
- { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
272
269
  { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
273
270
  { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
274
271
  { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes