fastapi-fsp 0.1.1__tar.gz → 0.1.2__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 (24) hide show
  1. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/PKG-INFO +15 -5
  2. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/README.md +14 -4
  3. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/fastapi_fsp/fsp.py +82 -13
  4. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/pyproject.toml +1 -1
  5. fastapi_fsp-0.1.2/tests/test_fsp_filters_indexed_sync.py +46 -0
  6. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/uv.lock +8 -2
  7. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/.github/workflows/ci.yml +0 -0
  8. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/.github/workflows/release.yml +0 -0
  9. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/.gitignore +0 -0
  10. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/.pre-commit-config.yaml +0 -0
  11. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/LICENSE +0 -0
  12. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/PROJECT.md +0 -0
  13. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/fastapi_fsp/__init__.py +0 -0
  14. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/fastapi_fsp/models.py +0 -0
  15. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/main.py +0 -0
  16. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/pytest.ini +0 -0
  17. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/__init__.py +0 -0
  18. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/conftest.py +0 -0
  19. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/conftest_async.py +0 -0
  20. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/main.py +0 -0
  21. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/main_async.py +0 -0
  22. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/test_fsp.py +0 -0
  23. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/test_fsp_async.py +0 -0
  24. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.2}/tests/test_fsp_filters_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-fsp
3
- Version: 0.1.1
3
+ Version: 0.1.2
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
@@ -109,7 +109,9 @@ Sorting:
109
109
  - sort_by: the field name, e.g., `name`
110
110
  - order: `asc` or `desc`
111
111
 
112
- Filtering (repeatable sets; arrays are supported by sending multiple parameters):
112
+ Filtering (two supported formats):
113
+
114
+ 1) Simple (triplets repeated in the query string):
113
115
  - field: the field/column name, e.g., `name`
114
116
  - operator: one of
115
117
  - eq, ne
@@ -122,17 +124,25 @@ Filtering (repeatable sets; arrays are supported by sending multiple parameters)
122
124
  - contains, starts_with, ends_with (translated to LIKE patterns)
123
125
  - value: raw string value (or list-like comma-separated depending on operator)
124
126
 
125
- Examples:
127
+ Examples (simple format):
126
128
  - `?field=name&operator=eq&value=Deadpond`
127
129
  - `?field=age&operator=between&value=18,30`
128
130
  - `?field=name&operator=in&value=Deadpond,Rusty-Man`
129
131
  - `?field=name&operator=contains&value=man`
132
+ - Chain multiple filters by repeating the triplet: `?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust`
133
+
134
+ 2) Indexed format (useful for clients that handle arrays of objects):
135
+ - Use keys like `filters[0][field]`, `filters[0][operator]`, `filters[0][value]`, then increment the index for additional filters (`filters[1][...]`, etc.).
130
136
 
131
- You can chain multiple filters by repeating the triplet:
137
+ Example (indexed format):
132
138
  ```
133
- ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
139
+ ?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
134
140
  ```
135
141
 
142
+ Notes:
143
+ - Both formats are equivalent; the indexed format takes precedence if present.
144
+ - 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.
145
+
136
146
  ## Response model
137
147
 
138
148
  ```
@@ -84,7 +84,9 @@ Sorting:
84
84
  - sort_by: the field name, e.g., `name`
85
85
  - order: `asc` or `desc`
86
86
 
87
- Filtering (repeatable sets; arrays are supported by sending multiple parameters):
87
+ Filtering (two supported formats):
88
+
89
+ 1) Simple (triplets repeated in the query string):
88
90
  - field: the field/column name, e.g., `name`
89
91
  - operator: one of
90
92
  - eq, ne
@@ -97,17 +99,25 @@ Filtering (repeatable sets; arrays are supported by sending multiple parameters)
97
99
  - contains, starts_with, ends_with (translated to LIKE patterns)
98
100
  - value: raw string value (or list-like comma-separated depending on operator)
99
101
 
100
- Examples:
102
+ Examples (simple format):
101
103
  - `?field=name&operator=eq&value=Deadpond`
102
104
  - `?field=age&operator=between&value=18,30`
103
105
  - `?field=name&operator=in&value=Deadpond,Rusty-Man`
104
106
  - `?field=name&operator=contains&value=man`
107
+ - Chain multiple filters by repeating the triplet: `?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust`
108
+
109
+ 2) Indexed format (useful for clients that handle arrays of objects):
110
+ - Use keys like `filters[0][field]`, `filters[0][operator]`, `filters[0][value]`, then increment the index for additional filters (`filters[1][...]`, etc.).
105
111
 
106
- You can chain multiple filters by repeating the triplet:
112
+ Example (indexed format):
107
113
  ```
108
- ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
114
+ ?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
109
115
  ```
110
116
 
117
+ Notes:
118
+ - Both formats are equivalent; the indexed format takes precedence if present.
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
+
111
121
  ## Response model
112
122
 
113
123
  ```
@@ -2,6 +2,7 @@ import math
2
2
  from typing import Annotated, Any, List, Optional
3
3
 
4
4
  from fastapi import Depends, HTTPException, Query, Request, status
5
+ from pydantic import ValidationError
5
6
  from sqlalchemy import Select, func
6
7
  from sqlmodel import Session, not_, select
7
8
  from sqlmodel.ext.asyncio.session import AsyncSession
@@ -19,22 +20,90 @@ from fastapi_fsp.models import (
19
20
  )
20
21
 
21
22
 
22
- def _parse_filters(
23
- fields: Optional[List[str]] = Query(None, alias="field"),
24
- operators: Optional[List[FilterOperator]] = Query(None, alias="operator"),
25
- values: Optional[List[str]] = Query(None, alias="value"),
26
- ) -> List[Filter] | None:
27
- if not fields:
28
- return None
29
- filters: List[Filter] = []
30
- if operators is None or values is None or not (len(fields) == len(operators) == len(values)):
23
+ def _parse_one_filter_at(i: int, field: str, operator: str, value: str) -> Filter:
24
+ try:
25
+ filter_ = Filter(field=field, operator=FilterOperator(operator), value=value)
26
+ except ValidationError as e:
27
+ raise HTTPException(
28
+ status_code=status.HTTP_400_BAD_REQUEST,
29
+ detail=f"Invalid filter at index {i}: {str(e)}",
30
+ ) from e
31
+ except ValueError as e:
32
+ raise HTTPException(
33
+ status_code=status.HTTP_400_BAD_REQUEST,
34
+ detail=f"Invalid operator '{operator}' at index {i}.",
35
+ ) from e
36
+ return filter_
37
+
38
+
39
+ def _parse_array_of_filters(
40
+ fields: List[str], operators: List[str], values: List[str]
41
+ ) -> List[Filter]:
42
+ # Validate that we have matching lengths
43
+ if not (len(fields) == len(operators) == len(values)):
31
44
  raise HTTPException(
32
45
  status_code=status.HTTP_400_BAD_REQUEST,
33
- detail="Mismatched filter parameters.",
46
+ detail="Mismatched filter parameters in array format.",
34
47
  )
35
- for field, operator, value in zip(fields, operators, values):
36
- filters.append(Filter(field=field, operator=operator, value=value))
37
- return filters
48
+ return [
49
+ _parse_one_filter_at(i, field, operator, value)
50
+ for i, (field, operator, value) in enumerate(zip(fields, operators, values))
51
+ ]
52
+
53
+
54
+ def _parse_filters(
55
+ request: Request,
56
+ ) -> Optional[List[Filter]]:
57
+ """
58
+ Parse filters from query parameters supporting two formats:
59
+ 1. Indexed format:
60
+ ?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
61
+ 2. Simple format:
62
+ ?field=age&operator=gte&value=18&field=name&operator=ilike&value=joy
63
+ """
64
+ query_params = request.query_params
65
+ filters = []
66
+
67
+ # Try indexed format first: filters[0][field], filters[0][operator], etc.
68
+ i = 0
69
+ while True:
70
+ field_key = f"filters[{i}][field]"
71
+ operator_key = f"filters[{i}][operator]"
72
+ value_key = f"filters[{i}][value]"
73
+
74
+ field = query_params.get(field_key)
75
+ operator = query_params.get(operator_key)
76
+ value = query_params.get(value_key)
77
+
78
+ # If we don't have a field at this index, break the loop
79
+ if field is None:
80
+ break
81
+
82
+ # Validate that we have all required parts
83
+ if operator is None or value is None:
84
+ raise HTTPException(
85
+ status_code=status.HTTP_400_BAD_REQUEST,
86
+ detail=f"Incomplete filter at index {i}. Missing operator or value.",
87
+ )
88
+
89
+ filters.append(_parse_one_filter_at(i, field, operator, value))
90
+ i += 1
91
+
92
+ # If we found indexed filters, return them
93
+ if filters:
94
+ return filters
95
+
96
+ # Fall back to simple format: field, operator, value
97
+ filters = _parse_array_of_filters(
98
+ query_params.getlist("field"),
99
+ query_params.getlist("operator"),
100
+ query_params.getlist("value"),
101
+ )
102
+ if filters:
103
+ return filters
104
+
105
+ # No filters found
106
+ return None
38
107
 
39
108
 
40
109
  def _parse_sort(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-fsp"
7
- version = "0.1.1"
7
+ version = "0.1.2"
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,46 @@
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_indexed_single_filter_eq(session: Session, client: TestClient):
20
+ seed(session)
21
+ r = client.get(
22
+ "/heroes/?filters[0][field]=name&filters[0][operator]=eq&filters[0][value]=Deadpond"
23
+ )
24
+ assert r.status_code == 200
25
+ js = r.json()
26
+ assert len(js["data"]) == 1
27
+ assert js["data"][0]["name"] == "Deadpond"
28
+
29
+
30
+ def test_indexed_multiple_filters_combined(session: Session, client: TestClient):
31
+ seed(session)
32
+ # age >= 18 AND name ILIKE '%eta'
33
+ r = client.get(
34
+ "/heroes/?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18"
35
+ "&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=%25eta"
36
+ )
37
+ assert r.status_code == 200
38
+ names = [h["name"] for h in r.json()["data"]]
39
+ # Only 'beta' is age >= 18 and ends with 'eta'
40
+ assert set(names) == {"beta"}
41
+
42
+
43
+ def test_indexed_incomplete_filter_returns_400(session: Session, client: TestClient):
44
+ seed(session)
45
+ r = client.get("/heroes/?filters[0][field]=age&filters[0][operator]=gte")
46
+ assert r.status_code == 400
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
@@ -167,7 +167,7 @@ wheels = [
167
167
 
168
168
  [[package]]
169
169
  name = "fastapi-fsp"
170
- version = "0.1.1"
170
+ version = "0.1.2"
171
171
  source = { editable = "." }
172
172
  dependencies = [
173
173
  { name = "fastapi" },
@@ -229,6 +229,8 @@ wheels = [
229
229
  { 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" },
230
230
  { 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" },
231
231
  { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
232
+ { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
233
+ { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
232
234
  { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
233
235
  { 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" },
234
236
  { 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" },
@@ -238,6 +240,8 @@ wheels = [
238
240
  { 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" },
239
241
  { 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" },
240
242
  { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
243
+ { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
244
+ { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
241
245
  { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
242
246
  { 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" },
243
247
  { 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" },
@@ -245,6 +249,8 @@ wheels = [
245
249
  { 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" },
246
250
  { 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" },
247
251
  { 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" },
252
+ { 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" },
253
+ { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
248
254
  { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
249
255
  ]
250
256
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes