mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 11:19:56 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8176bc6454 | ||
|
|
e09b911277 | ||
|
|
f7ecb76e8d
|
||
|
|
ef269833b9
|
||
|
|
610b3e1ab4
|
@@ -373,6 +373,16 @@ Or via the dependency to narrow which fields are exposed as query parameters:
|
||||
params = UserCrud.offset_paginate_params(search_fields=[Post.title])
|
||||
```
|
||||
|
||||
`search_fields`, `facet_fields` and `order_fields` follow the same override rule
|
||||
everywhere they are accepted — `offset_paginate`, `cursor_paginate`,
|
||||
`paginate` and the matching `*_paginate_params` dependencies:
|
||||
|
||||
| Passed | Effect |
|
||||
| --- | --- |
|
||||
| omitted or `None` | Use the class-level declaration |
|
||||
| `[]` | Disable this feature for this call |
|
||||
| `[...]` | Use exactly these fields (the primary key is **not** prepended — that only happens for the class-level `searchable_fields`) |
|
||||
|
||||
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||
|
||||
```python
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "5.1.1"
|
||||
version = "5.1.2"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -24,4 +24,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "5.1.1"
|
||||
__version__ = "5.1.2"
|
||||
|
||||
@@ -270,32 +270,17 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return cls.default_load_options
|
||||
|
||||
@classmethod
|
||||
def _capture_pk_values(
|
||||
cls: type[Self], instance: DeclarativeBase
|
||||
) -> dict[str, Any]:
|
||||
"""Capture PK values off instance — call before commit expires attributes."""
|
||||
return {
|
||||
cast(str, col.key): getattr(instance, cast(str, col.key))
|
||||
for col in cls.model.__mapper__.primary_key
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def _reload_with_options_by_pk(
|
||||
cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
|
||||
async def _reload_with_options(
|
||||
cls: type[Self], session: AsyncSession, instance: DeclarativeBase
|
||||
) -> ModelType:
|
||||
"""Re-query by previously captured PK values, with default_load_options applied."""
|
||||
# Only called when cls.default_load_options is set (see call sites).
|
||||
"""Re-query instance by PK with default_load_options applied."""
|
||||
mapper = cls.model.__mapper__
|
||||
pk_filters = [
|
||||
getattr(cls.model, key) == value for key, value in pk_values.items()
|
||||
getattr(cls.model, cast(str, col.key))
|
||||
== getattr(instance, cast(str, col.key))
|
||||
for col in mapper.primary_key
|
||||
]
|
||||
q = select(cls.model).where(and_(*pk_filters))
|
||||
q = q.execution_options(populate_existing=True)
|
||||
q = q.options(*cast(Sequence[ExecutableOption], cls.default_load_options))
|
||||
result = await session.execute(q)
|
||||
item = result.unique().scalar_one_or_none()
|
||||
if item is None: # pragma: no cover — row was just flushed in this transaction
|
||||
raise NotFoundError()
|
||||
return cast(ModelType, item)
|
||||
return await cls.get(session, filters=pk_filters)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_m2m(
|
||||
@@ -401,13 +386,29 @@ class AsyncCrud(Generic[ModelType]):
|
||||
own_filters=own_filters,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _resolve_search_fields(
|
||||
cls: type[Self],
|
||||
search_fields: Sequence[SearchFieldType] | None,
|
||||
) -> Sequence[SearchFieldType] | None:
|
||||
"""Return search_fields if given, otherwise fall back to the class-level default."""
|
||||
return search_fields if search_fields is not None else cls.searchable_fields
|
||||
|
||||
@classmethod
|
||||
def _resolve_order_fields(
|
||||
cls: type[Self],
|
||||
order_fields: Sequence[OrderFieldType] | None,
|
||||
) -> Sequence[OrderFieldType] | None:
|
||||
"""Return order_fields if given, otherwise fall back to the class-level default."""
|
||||
return order_fields if order_fields is not None else cls.order_fields
|
||||
|
||||
@classmethod
|
||||
def _resolve_search_columns(
|
||||
cls: type[Self],
|
||||
search_fields: Sequence[SearchFieldType] | None,
|
||||
) -> list[str] | None:
|
||||
"""Return search column keys, or None if no searchable fields configured."""
|
||||
fields = search_fields if search_fields is not None else cls.searchable_fields
|
||||
fields = cls._resolve_search_fields(search_fields)
|
||||
if not fields:
|
||||
return None
|
||||
return search_field_keys(fields)
|
||||
@@ -418,7 +419,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order_fields: Sequence[OrderFieldType] | None,
|
||||
) -> list[str] | None:
|
||||
"""Return sort column keys, or None if no order fields configured."""
|
||||
fields = order_fields if order_fields is not None else cls.order_fields
|
||||
fields = cls._resolve_order_fields(order_fields)
|
||||
if not fields:
|
||||
return None
|
||||
return sorted(facet_keys(fields))
|
||||
@@ -497,9 +498,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order_field_map: dict[str, OrderFieldType] | None = None
|
||||
order_valid_keys: list[str] | None = None
|
||||
if order:
|
||||
resolved_order = (
|
||||
order_fields if order_fields is not None else cls.order_fields
|
||||
)
|
||||
resolved_order = cls._resolve_order_fields(order_fields)
|
||||
if resolved_order:
|
||||
keys = facet_keys(resolved_order)
|
||||
order_field_map = dict(zip(keys, resolved_order))
|
||||
@@ -525,8 +524,21 @@ class AsyncCrud(Generic[ModelType]):
|
||||
]
|
||||
)
|
||||
|
||||
fixed: dict[str, Any] = {
|
||||
**pagination_fixed,
|
||||
"search_fields": (cls._resolve_search_fields(search_fields) or [])
|
||||
if search
|
||||
else [],
|
||||
"facet_fields": (cls._resolve_facet_fields(facet_fields) or [])
|
||||
if filter
|
||||
else [],
|
||||
"order_fields": (cls._resolve_order_fields(order_fields) or [])
|
||||
if order
|
||||
else [],
|
||||
}
|
||||
|
||||
async def dependency(**kwargs: Any) -> dict[str, Any]:
|
||||
result: dict[str, Any] = dict(pagination_fixed)
|
||||
result: dict[str, Any] = dict(fixed)
|
||||
for name in pagination_param_names:
|
||||
result[name] = kwargs[name]
|
||||
|
||||
@@ -849,14 +861,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
session.add(db_model)
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
await session.refresh(db_model)
|
||||
if cls.default_load_options:
|
||||
db_model = await cls._reload_with_options(session, db_model)
|
||||
result = cast(ModelType, db_model)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(result))
|
||||
@@ -1233,15 +1240,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
await session.refresh(db_model)
|
||||
if cls.default_load_options:
|
||||
db_model = await cls._reload_with_options(session, db_model)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(db_model))
|
||||
return db_model
|
||||
|
||||
@@ -466,6 +466,30 @@ class TestDefaultLoadOptionsIntegration:
|
||||
assert updated.role is not None
|
||||
assert updated.role.name == "admin"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_does_not_expire_already_loaded_relationships(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""create()'s reload must not blow away loaded state on related objects."""
|
||||
UserWithDefaultLoad = CrudFactory(
|
||||
User, default_load_options=[selectinload(User.role)]
|
||||
)
|
||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||
role = await RoleCrud.get(
|
||||
db_session,
|
||||
filters=[Role.id == role.id],
|
||||
load_options=[selectinload(Role.users)],
|
||||
)
|
||||
assert role.users == []
|
||||
|
||||
await UserWithDefaultLoad.create(
|
||||
db_session,
|
||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||
)
|
||||
|
||||
# must not trigger a lazy load
|
||||
assert role.users == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_load_options_overrides_default_load_options(
|
||||
self, db_session: AsyncSession
|
||||
|
||||
@@ -2543,6 +2543,16 @@ class TestOrderParamsViaConsolidated:
|
||||
assert len(result.data) == 2
|
||||
|
||||
|
||||
def _fully_declared_user_crud():
|
||||
"""A CRUD class declaring all three field sets, as a real app would."""
|
||||
return CrudFactory(
|
||||
User,
|
||||
searchable_fields=[User.username],
|
||||
facet_fields=[User.email],
|
||||
order_fields=[User.username],
|
||||
)
|
||||
|
||||
|
||||
class TestOffsetPaginateParamsSchema:
|
||||
"""Tests for AsyncCrud.offset_paginate_params()."""
|
||||
|
||||
@@ -2612,6 +2622,9 @@ class TestOffsetPaginateParamsSchema:
|
||||
"items_per_page": 10,
|
||||
"include_total": False,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -2655,6 +2668,42 @@ class TestOffsetPaginateParamsSchema:
|
||||
assert "search" not in param_names
|
||||
assert "search_column" not in param_names
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"search": False, "filter": False, "order": False},
|
||||
{"search_fields": [], "facet_fields": [], "order_fields": []},
|
||||
],
|
||||
ids=["flags", "empty-overrides"],
|
||||
)
|
||||
async def test_disabled_features_clear_response_metadata(
|
||||
self, db_session: AsyncSession, kwargs
|
||||
):
|
||||
"""Disabling a feature on one endpoint also drops it from the response."""
|
||||
await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io"))
|
||||
Crud = _fully_declared_user_crud()
|
||||
dep = Crud.offset_paginate_params(**kwargs)
|
||||
params = await dep(page=1, items_per_page=10)
|
||||
result = await Crud.offset_paginate(db_session, **params, schema=UserRead)
|
||||
assert result.search_columns is None
|
||||
assert result.order_columns is None
|
||||
assert result.filter_attributes is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enabled_features_keep_response_metadata(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""The declared class defaults still reach the response when left enabled."""
|
||||
await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io"))
|
||||
Crud = _fully_declared_user_crud()
|
||||
dep = Crud.offset_paginate_params()
|
||||
params = await dep(page=1, items_per_page=10)
|
||||
result = await Crud.offset_paginate(db_session, **params, schema=UserRead)
|
||||
assert result.search_columns == ["id", "username"]
|
||||
assert result.order_columns == ["username"]
|
||||
assert result.filter_attributes == {"email": ["b@x.io"]}
|
||||
|
||||
def test_filter_enabled_but_no_facet_fields(self):
|
||||
"""filter=True with no facet_fields silently skips filter params."""
|
||||
dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False)
|
||||
@@ -2727,6 +2776,9 @@ class TestCursorPaginateParamsSchema:
|
||||
"cursor": None,
|
||||
"items_per_page": 5,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -2837,6 +2889,9 @@ class TestPaginateParamsSchema:
|
||||
"items_per_page": 10,
|
||||
"include_total": True,
|
||||
"include_facets": True,
|
||||
"search_fields": [],
|
||||
"facet_fields": [],
|
||||
"order_fields": [],
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -315,7 +315,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "5.1.1"
|
||||
version = "5.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -1404,7 +1404,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.51"
|
||||
version = "0.0.57"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1416,18 +1416,18 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/f4/fa40086c46a2e59e3d9239031f76623622e60e0d79f3df1282df2797a5c4/zensical-0.0.57.tar.gz", hash = "sha256:25fcbdf89a57153cc3ad1108a89d17c7226da5d3c551a8839c69cbd9c472a9d8", size = 4000458, upload-time = "2026-08-21T20:43:49.5Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/49c37dc65105d1ca4a8b600a02c84ece00218d2293b2630611c620185ca3/zensical-0.0.57-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98867d1a6ea2c57f1ebcf4902f61601f427350f2df0c04e30cfac8ba6163cd29", size = 12888507, upload-time = "2026-08-21T20:43:20.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f7/54539984418de11387bbace39a744195555d32c98c95bf4d112b432548f5/zensical-0.0.57-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0d7935d77d73a279545052e05d89d31960f30c1f33f53933f4c101fa271aee74", size = 12778169, upload-time = "2026-08-21T20:43:22.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/16/74aa60aa4cfecd5bd31ce60cb6a092cb56f1bc1aaadcc173463861ea4eb5/zensical-0.0.57-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7046d433511d97aa603915f0f6792d15b7f839793abc2b66ab7b7ff753ecff5", size = 13230823, upload-time = "2026-08-21T20:43:25.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d1/742d2487dd65dd18277daebcd37db56d5bd4a2408df02bde703ef8fb7b64/zensical-0.0.57-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab85c5066b95e3a877cf8971e4ce30abb1ca1459fbfcc631f0a5a2bab56351a4", size = 13170523, upload-time = "2026-08-21T20:43:27.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/6f/12b570775d344f1a3d77e26d4ae0160bcac9e41ca38f7135352ccdf9b2c8/zensical-0.0.57-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f13d1b57ad3c8b8634933a93ea870ebac11245fe0c968d27fd2a059ee1c6311", size = 13549941, upload-time = "2026-08-21T20:43:29.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/4e/436e6fc76674244c084ef7f6f17dc5ff85c76b15aef77c48b703fd0a2dda/zensical-0.0.57-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:021dd8fb70d1816cd012684fcf45d32b8f88a0cd28b7cbe71e5f8564f6d5764d", size = 13210086, upload-time = "2026-08-21T20:43:32.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/52/20f3aeda9af1090f24241670a5cc20fff7494545fea9f5fa094c82f3dbdf/zensical-0.0.57-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7e10f3c27fdc3eac3a9ae6ddcd87f3f00edc9f332050923313c95537961bfadd", size = 13408253, upload-time = "2026-08-21T20:43:34.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f2/2b18ba2f19674dbfcf745f3b66e005cc8efa66a1bcaba5e1b4f79467868a/zensical-0.0.57-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:78c85fee55c5aac3bdf8157e980c56397dca835167a5577c5429b5eb24ed990c", size = 13446689, upload-time = "2026-08-21T20:43:36.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ba/68cdba447a9097e5f97742eef046020c6fa42d82972849b3a46a0718e890/zensical-0.0.57-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:478d252e1924f3876e72cf7806967cb62e50d86eddb3da04bf43e882b532fa1b", size = 13598580, upload-time = "2026-08-21T20:43:38.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/89/6358a4df272328bed5bea90b04d43e73758bc45ff058c5cb2665e1147314/zensical-0.0.57-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66a9ca6b5f625b2a2b215eec2f3c72843a92d5d512042045ac6351d5dee9b339", size = 13557609, upload-time = "2026-08-21T20:43:40.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e1/8831301a24f736743e3788f09ea048918b0bdcea4aaa90f7770a433d6eec/zensical-0.0.57-cp310-abi3-win32.whl", hash = "sha256:f0fe3dc27ca7dc4e168eddd0fe5b0f4d44e311fd4e0019241e289819e445203c", size = 12446805, upload-time = "2026-08-21T20:43:43.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/3f/5d0ecd77d9ce962fdfde22dec036f4257a43ef6dbd55fb5c05fd294985ad/zensical-0.0.57-cp310-abi3-win_amd64.whl", hash = "sha256:a756834025c1c54e806e943be6d8df1048d0f8bcf6086e958568407a070a2572", size = 12716781, upload-time = "2026-08-21T20:43:45.273Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user