mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 19:29:55 +00:00
Compare commits
5
Commits
v5.1.0
...
d8d71a0566
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d71a0566 | ||
|
|
6dafd40277 | ||
|
|
1c806cccd9
|
||
|
|
1354f59bb4 | ||
|
|
23dc5c86b2 |
+1
-1
@@ -54,7 +54,7 @@ db = Database(
|
|||||||
|
|
||||||
## Committing before the response
|
## Committing before the response
|
||||||
|
|
||||||
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. The dependency commits only if the middleware did not: when a function-scoped dependency unwinds before the response, or when the response never passes through the middleware. Either way the request is committed exactly once.
|
||||||
|
|
||||||
The request is committed as a single transaction:
|
The request is committed as a single transaction:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ exclude = ["*.md"]
|
|||||||
extend-select = ["E712"]
|
extend-select = ["E712"]
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-bugbear]
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
extend-immutable-calls = ["fastapi.Depends"]
|
extend-immutable-calls = ["fastapi.Depends", "fastapi.Security"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ def build_search_filters(
|
|||||||
column = field
|
column = field
|
||||||
|
|
||||||
# Build the filter (cast to String only when needed, to preserve
|
# Build the filter (cast to String only when needed, to preserve
|
||||||
# pg_trgm GIN index usability on already-String columns)
|
# pg_trgm GIN index usability on already-String columns).
|
||||||
column_as_string = (
|
column_as_string = (
|
||||||
column if isinstance(column.type, String) else column.cast(String)
|
column
|
||||||
|
if isinstance(column.type, String) and not isinstance(column.type, Enum)
|
||||||
|
else column.cast(String)
|
||||||
)
|
)
|
||||||
if config.case_sensitive:
|
if config.case_sensitive:
|
||||||
filters.append(column_as_string.like(f"%{query}%"))
|
filters.append(column_as_string.like(f"%{query}%"))
|
||||||
|
|||||||
@@ -66,10 +66,8 @@ class _CommitOnResponseMiddleware:
|
|||||||
|
|
||||||
async def send_wrapper(message: Message) -> None:
|
async def send_wrapper(message: Message) -> None:
|
||||||
if message["type"] == "http.response.start":
|
if message["type"] == "http.response.start":
|
||||||
# ``scope["state"]`` is the same dict ``request.state`` writes
|
|
||||||
# to, so this is the session stashed by the dependency.
|
|
||||||
state = scope.get("state")
|
state = scope.get("state")
|
||||||
session = state.get(self.state_attr) if state else None
|
session = state.pop(self.state_attr, None) if state else None
|
||||||
if session is not None and session.in_transaction():
|
if session is not None and session.in_transaction():
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await send(message)
|
await send(message)
|
||||||
@@ -158,7 +156,6 @@ class Database:
|
|||||||
# Private, per-instance state attribute; cannot collide with another
|
# Private, per-instance state attribute; cannot collide with another
|
||||||
# Database or be mismatched against the middleware.
|
# Database or be mismatched against the middleware.
|
||||||
self._state_attr = f"_ft_db_session_{id(self):x}"
|
self._state_attr = f"_ft_db_session_{id(self):x}"
|
||||||
self._middleware_installed = False
|
|
||||||
self._disposed = False
|
self._disposed = False
|
||||||
|
|
||||||
async def _dispose(self) -> None:
|
async def _dispose(self) -> None:
|
||||||
@@ -206,7 +203,6 @@ class Database:
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
||||||
self._middleware_installed = True
|
|
||||||
|
|
||||||
inner_lifespan = app.router.lifespan_context
|
inner_lifespan = app.router.lifespan_context
|
||||||
|
|
||||||
@@ -243,10 +239,17 @@ class Database:
|
|||||||
return await UserCrud.get(session, [User.id == user_id])
|
return await UserCrud.get(session, [User.id == user_id])
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
borrowed = getattr(request.state, self._state_attr, None)
|
||||||
|
if borrowed is not None:
|
||||||
|
yield borrowed
|
||||||
|
return
|
||||||
async with self._open() as session:
|
async with self._open() as session:
|
||||||
setattr(request.state, self._state_attr, session)
|
setattr(request.state, self._state_attr, session)
|
||||||
yield session
|
yield session
|
||||||
if not self._middleware_installed and session.in_transaction():
|
if (
|
||||||
|
getattr(request.state, self._state_attr, None) is session
|
||||||
|
and session.in_transaction()
|
||||||
|
):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -388,6 +388,76 @@ class TestBuildSearchFilters:
|
|||||||
|
|
||||||
assert "CAST" in str(filters[0])
|
assert "CAST" in str(filters[0])
|
||||||
|
|
||||||
|
def test_casts_enum_column(self):
|
||||||
|
"""Enum subclasses String but maps to a native DB enum, which has no ILIKE."""
|
||||||
|
from fastapi_toolsets.crud.search import build_search_filters
|
||||||
|
|
||||||
|
filters, _ = build_search_filters(Order, "PEND", search_fields=[Order.status])
|
||||||
|
|
||||||
|
assert "CAST" in str(filters[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchEnumColumn:
|
||||||
|
"""Searching an enum column must reach the database, not just build SQL."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_int_backed_enum(self, db_session: AsyncSession):
|
||||||
|
"""Enum(int, Enum) stores names, so the cast makes 'PEND' match PENDING."""
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="a", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="b", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
search="PEND",
|
||||||
|
search_fields=[Order.status],
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].status is OrderStatus.PENDING
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_str_backed_enum(self, db_session: AsyncSession):
|
||||||
|
"""Same for Enum(str, Enum) — still a native DB enum, still needs the cast."""
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="a", status=OrderStatus.PENDING, color=Color.BLUE),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="b", status=OrderStatus.PENDING, color=Color.RED),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
search="BLU",
|
||||||
|
search_fields=[Order.color],
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].color is Color.BLUE
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_mixed_enum_and_string_columns(self, db_session: AsyncSession):
|
||||||
|
"""An enum column alongside a plain String column (the get_searchable_fields shape)."""
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="widget", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
search="widget",
|
||||||
|
search_fields=[Order.name, Order.status, Order.color],
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
|
|
||||||
class TestSearchConfig:
|
class TestSearchConfig:
|
||||||
"""Tests for SearchConfig options."""
|
"""Tests for SearchConfig options."""
|
||||||
|
|||||||
+128
-9
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI, Security
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from pydantic import PostgresDsn
|
from pydantic import PostgresDsn
|
||||||
@@ -287,23 +287,37 @@ class TestDatabaseDependency:
|
|||||||
break
|
break
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_skips_commit_when_middleware_installed(self, engine, session_maker):
|
async def test_second_resolution_borrows_session(self, engine):
|
||||||
"""With ``install()``, the dependency must NOT commit — the middleware owns it.
|
"""A second ``Depends(db)`` in one request reuses the stashed session."""
|
||||||
|
db = Database(engine=engine)
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
Here no middleware actually runs (we call the dependency directly), so the
|
owner_gen = db(request)
|
||||||
open transaction is rolled back on session close and nothing persists.
|
owner = await anext(owner_gen)
|
||||||
"""
|
borrower_gen = db(request)
|
||||||
|
assert await anext(borrower_gen) is owner
|
||||||
|
|
||||||
|
with pytest.raises(StopAsyncIteration): # teardown runs borrower-first
|
||||||
|
await anext(borrower_gen)
|
||||||
|
assert owner.in_transaction() # the borrower must not close what it borrowed
|
||||||
|
|
||||||
|
with pytest.raises(StopAsyncIteration):
|
||||||
|
await anext(owner_gen)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_commits_when_middleware_did_not_run(self, engine, session_maker):
|
||||||
|
"""``install()`` is per-``Database``, but the commit is per-request."""
|
||||||
db = Database(engine=engine)
|
db = Database(engine=engine)
|
||||||
db.install(FastAPI())
|
db.install(FastAPI())
|
||||||
|
|
||||||
async for session in db(_make_request()):
|
async for session in db(_make_request()):
|
||||||
role = Role(name="mw_owns_commit")
|
role = Role(name="mw_never_ran")
|
||||||
session.add(role)
|
session.add(role)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
async with session_maker() as verify:
|
||||||
result = await RoleCrud.first(verify, [Role.name == "mw_owns_commit"])
|
result = await RoleCrud.first(verify, [Role.name == "mw_never_ran"])
|
||||||
assert result is None
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseSession:
|
class TestDatabaseSession:
|
||||||
@@ -1523,6 +1537,55 @@ def _build_app(db: Database) -> FastAPI:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"id": str(role.id), "name": role.name}
|
return {"id": str(role.id), "name": role.name}
|
||||||
|
|
||||||
|
async def _scoped_writer(
|
||||||
|
body: RoleCreate, session: AsyncSession = Security(db, scopes=["roles:write"])
|
||||||
|
) -> int:
|
||||||
|
# Security scopes give this a different dependency cache key than the
|
||||||
|
# endpoint's plain ``Depends(db)``. Without borrowing it opens a second
|
||||||
|
# session, and whichever one the middleware does not hold is discarded.
|
||||||
|
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_sub"))
|
||||||
|
return id(session)
|
||||||
|
|
||||||
|
@app.post("/roles-two-cache-keys")
|
||||||
|
async def create_via_two_cache_keys(
|
||||||
|
body: RoleCreate,
|
||||||
|
sub_session_id: int = Depends(_scoped_writer),
|
||||||
|
session: AsyncSession = Depends(db),
|
||||||
|
) -> dict:
|
||||||
|
await RoleCrud.create(session, body)
|
||||||
|
return {"same_session": sub_session_id == id(session)}
|
||||||
|
|
||||||
|
async def _fn_writer(
|
||||||
|
body: RoleCreate, session: AsyncSession = Depends(db, scope="function")
|
||||||
|
) -> None:
|
||||||
|
# ``scope="function"`` unwinds before the response is sent, taking the
|
||||||
|
# session with it — so the commit cannot be left to the middleware.
|
||||||
|
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_fn"))
|
||||||
|
|
||||||
|
@app.post("/roles-function-scope")
|
||||||
|
async def create_with_function_scope(
|
||||||
|
body: RoleCreate,
|
||||||
|
boom: bool = False,
|
||||||
|
_: None = Depends(_fn_writer),
|
||||||
|
session: AsyncSession = Depends(db),
|
||||||
|
) -> dict:
|
||||||
|
await RoleCrud.create(session, body)
|
||||||
|
if boom:
|
||||||
|
raise RuntimeError("boom after write")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/roles-function-scope-borrower")
|
||||||
|
async def function_scope_borrows(
|
||||||
|
body: RoleCreate,
|
||||||
|
session: AsyncSession = Depends(db),
|
||||||
|
_: None = Depends(_fn_writer),
|
||||||
|
) -> dict:
|
||||||
|
# Flipped order: the request-scoped dependency owns the session and the
|
||||||
|
# function-scoped one borrows it. The borrower unwinds early but must not
|
||||||
|
# commit or close — the commit still belongs to the middleware.
|
||||||
|
await RoleCrud.create(session, body)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/roles-stream/{name}")
|
@app.get("/roles-stream/{name}")
|
||||||
async def stream_role(
|
async def stream_role(
|
||||||
name: str, session: AsyncSession = Depends(db)
|
name: str, session: AsyncSession = Depends(db)
|
||||||
@@ -1619,6 +1682,62 @@ class TestCommitIntegration:
|
|||||||
# The write made before the stream began is durably committed.
|
# The write made before the stream began is durably committed.
|
||||||
assert await _row_exists(session_maker, "streamed_role")
|
assert await _row_exists(session_maker, "streamed_role")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_two_cache_keys_share_one_session(self, engine, session_maker):
|
||||||
|
"""Two resolutions of ``Depends(db)`` in one request must share a session."""
|
||||||
|
app = _build_app(Database(engine=engine))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post("/roles-two-cache-keys", json={"name": "two_keys"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["same_session"] is True
|
||||||
|
assert await _row_exists(session_maker, "two_keys")
|
||||||
|
assert await _row_exists(session_maker, "two_keys_sub")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_function_scope_commits_before_response(self, engine, session_maker):
|
||||||
|
"""``scope="function"`` unwinds before response-start, so the dependency
|
||||||
|
commits on its way out instead of leaving it to the middleware."""
|
||||||
|
app = _build_app(Database(engine=engine))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post("/roles-function-scope", json={"name": "fn_scope"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert await _row_exists(session_maker, "fn_scope")
|
||||||
|
assert await _row_exists(session_maker, "fn_scope_fn")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_function_scope_borrower_leaves_commit_to_middleware(
|
||||||
|
self, engine, session_maker
|
||||||
|
):
|
||||||
|
"""A function-scoped *borrower* unwinds early but owns nothing."""
|
||||||
|
app = _build_app(Database(engine=engine))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/roles-function-scope-borrower", json={"name": "fn_borrow"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert await _row_exists(session_maker, "fn_borrow")
|
||||||
|
assert await _row_exists(session_maker, "fn_borrow_fn")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_function_scope_error_rolls_back(self, engine, session_maker):
|
||||||
|
"""The early commit must still not fire when the request fails."""
|
||||||
|
app = _build_app(Database(engine=engine))
|
||||||
|
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/roles-function-scope?boom=true", json={"name": "fn_ghost"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert not await _row_exists(session_maker, "fn_ghost")
|
||||||
|
assert not await _row_exists(session_maker, "fn_ghost_fn")
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multi_write_atomicity(self, engine, session_maker):
|
async def test_multi_write_atomicity(self, engine, session_maker):
|
||||||
"""When the 2nd write fails, the 1st must roll back too (one txn)."""
|
"""When the 2nd write fails, the 1st must roll back too (one txn)."""
|
||||||
|
|||||||
@@ -973,15 +973,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "11.0"
|
version = "11.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1307,27 +1307,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.64"
|
version = "0.0.74"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user