mirror of
https://github.com/d3vyce/taskiq-deduplication.git
synced 2026-08-05 11:24:08 +00:00
Compare commits
12
Commits
77f9746719
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002ebd2d47
|
||
|
|
7326f4693f | ||
|
|
764288c924
|
||
|
|
3b4fab3e70 | ||
|
|
c8162bf24a | ||
|
|
92a9834ffb | ||
|
|
b3f1ec3a4e | ||
|
|
239ed2128d | ||
|
|
34a2685a5b | ||
|
|
04094fe577 | ||
|
|
1fe1b72451 | ||
|
|
2c2a3c89f1 |
@@ -11,7 +11,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
name: Lint (Ruff)
|
name: Lint (Ruff)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
name: Type Check (ty)
|
name: Type Check (ty)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v6
|
- uses: actions/configure-pages@v6
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Redis-backed deduplication middleware for Taskiq that prevents duplicate tasks f
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add taskiq-deduplication
|
uv add "taskiq-deduplication"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -53,6 +53,7 @@ except DuplicateTaskError:
|
|||||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
||||||
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
||||||
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
||||||
|
- **Lock heartbeat** — a background task re-extends the lock TTL while the task runs, so long-running tasks keep their lock instead of expiring mid-execution and admitting a duplicate.
|
||||||
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
||||||
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
||||||
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ except DuplicateTaskError:
|
|||||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
||||||
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
||||||
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
||||||
|
- **Lock heartbeat** — a background task re-extends the lock TTL while the task runs, so long-running tasks keep their lock instead of expiring mid-execution and admitting a duplicate.
|
||||||
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
||||||
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
||||||
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
|||||||
| `key_prefix` | `str` | `"taskiq:deduplication"` | Prefix for all Redis lock keys. |
|
| `key_prefix` | `str` | `"taskiq:deduplication"` | Prefix for all Redis lock keys. |
|
||||||
| `startup_retries` | `int` | `3` | Number of connection attempts during broker startup. |
|
| `startup_retries` | `int` | `3` | Number of connection attempts during broker startup. |
|
||||||
| `startup_retry_delay` | `float` | `1.0` | Base delay in seconds between retries (exponential backoff: delay × 2^n). |
|
| `startup_retry_delay` | `float` | `1.0` | Base delay in seconds between retries (exponential backoff: delay × 2^n). |
|
||||||
|
| `heartbeat` | `bool` | `True` | Whether to periodically re-extend the lock TTL while the task runs (see [Long-running tasks](#long-running-tasks-and-the-heartbeat)). |
|
||||||
|
| `heartbeat_interval` | `float \| None` | `None` | Seconds between heartbeat refreshes. When `None`, defaults to a third of the task's TTL (1s floor). |
|
||||||
|
|
||||||
```python
|
```python
|
||||||
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
||||||
@@ -37,6 +39,31 @@ broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Long-running tasks and the heartbeat
|
||||||
|
|
||||||
|
The lock is created with a TTL so a crashed worker cannot leak it forever. Without
|
||||||
|
any refresh, a task that runs longer than its TTL would let the lock expire
|
||||||
|
**mid-execution**, allowing a duplicate to be dispatched.
|
||||||
|
|
||||||
|
To prevent this, the middleware starts a background **heartbeat** in `pre_execute`
|
||||||
|
that re-extends the lock TTL while the task runs (atomically, only if the lock is
|
||||||
|
still owned by the running task). It is cancelled when the task completes or fails.
|
||||||
|
This means you do **not** need to size `default_ttl` to your slowest task — the TTL
|
||||||
|
only needs to outlive a single heartbeat interval; it acts purely as a safety net
|
||||||
|
for worker crashes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
RedisDeduplicationMiddleware(
|
||||||
|
redis_url="redis://localhost:6379",
|
||||||
|
default_ttl=60, # safety-net TTL; refreshed every ~20s while running
|
||||||
|
heartbeat_interval=20, # optional; defaults to default_ttl / 3
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you disable the heartbeat (`heartbeat=False`), the invariant **TTL must exceed
|
||||||
|
the slowest task** applies: set `default_ttl` (or the per-task `deduplication_ttl`
|
||||||
|
label) above your worst-case task duration, or duplicates may slip through.
|
||||||
|
|
||||||
## Startup resilience
|
## Startup resilience
|
||||||
|
|
||||||
On startup the middleware verifies the Redis connection with a `PING`. If Redis is
|
On startup the middleware verifies the Redis connection with a `PING`. If Redis is
|
||||||
@@ -76,6 +103,25 @@ except DuplicateTaskError:
|
|||||||
pass # task is already queued or running
|
pass # task is already queued or running
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`DuplicateTaskError` carries structured attributes describing the collision:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
await my_task.kiq(user_id=42)
|
||||||
|
except DuplicateTaskError as err:
|
||||||
|
logger.info(
|
||||||
|
"Skipped %s; already held by %s (key=%s)",
|
||||||
|
err.task_name,
|
||||||
|
err.holder_task_id,
|
||||||
|
err.key,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `task_name` — name of the task that was rejected.
|
||||||
|
- `key` — Redis lock key whose owner caused the rejection.
|
||||||
|
- `holder_task_id` — `task_id` of the task currently holding the lock, or `None`
|
||||||
|
if it could not be retrieved.
|
||||||
|
|
||||||
## Per-task label overrides
|
## Per-task label overrides
|
||||||
|
|
||||||
Labels can be set at the task level (applied to every call) or at call time.
|
Labels can be set at the task level (applied to every call) or at call time.
|
||||||
@@ -132,6 +178,10 @@ async def send_welcome_email(user_id: int, locale: str) -> None:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If a listed field is absent from a task's kwargs, it is dropped from the
|
||||||
|
fingerprint and a warning is logged, since this can make genuinely different
|
||||||
|
calls collide on the same lock.
|
||||||
|
|
||||||
## Opting out per task
|
## Opting out per task
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "taskiq-deduplication"
|
name = "taskiq-deduplication"
|
||||||
version = "1.0.5"
|
version = "1.1.0"
|
||||||
description = "Redis-backed deduplication middleware for Taskiq"
|
description = "Redis-backed deduplication middleware for Taskiq"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||||
|
|
||||||
__version__ = "1.0.5"
|
__version__ = "1.1.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuplicateTaskError",
|
"DuplicateTaskError",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, cast
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import RedisDsn
|
from pydantic import RedisDsn
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
@@ -10,8 +10,10 @@ from taskiq import TaskiqMessage, TaskiqResult
|
|||||||
from taskiq.abc.middleware import TaskiqMiddleware
|
from taskiq.abc.middleware import TaskiqMiddleware
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
REFRESH_LUA_SCRIPT,
|
||||||
RELEASE_LUA_SCRIPT,
|
RELEASE_LUA_SCRIPT,
|
||||||
check_and_delete,
|
check_and_delete,
|
||||||
|
check_and_refresh,
|
||||||
parse_bool_label,
|
parse_bool_label,
|
||||||
parse_int_label,
|
parse_int_label,
|
||||||
parse_list_label,
|
parse_list_label,
|
||||||
@@ -28,7 +30,29 @@ _CACHED_KEY_LABEL = "__taskiq_dedup_cached_key"
|
|||||||
|
|
||||||
|
|
||||||
class DuplicateTaskError(Exception):
|
class DuplicateTaskError(Exception):
|
||||||
"""Raised when a task with identical name and kwargs is already queued or running."""
|
"""Raised when a task with identical name and kwargs is already queued or running.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
task_name: Name of the task that was rejected.
|
||||||
|
key: Redis lock key whose owner caused the rejection.
|
||||||
|
holder_task_id: ``task_id`` of the task currently holding the lock, or
|
||||||
|
``None`` if it could not be retrieved (e.g. the lock was released
|
||||||
|
between the failed acquisition and the lookup).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
task_name: str,
|
||||||
|
key: str,
|
||||||
|
holder_task_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.task_name = task_name
|
||||||
|
self.key = key
|
||||||
|
self.holder_task_id = holder_task_id
|
||||||
|
super().__init__(
|
||||||
|
f"Task {task_name!r} with the same arguments is already queued or "
|
||||||
|
f"running (key={key!r}, holder_task_id={holder_task_id!r})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||||
@@ -45,6 +69,10 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
default_deduplication: Whether deduplication is enabled by default.
|
default_deduplication: Whether deduplication is enabled by default.
|
||||||
default_ttl: Default lock TTL in seconds.
|
default_ttl: Default lock TTL in seconds.
|
||||||
key_prefix: Prefix for all Redis lock keys.
|
key_prefix: Prefix for all Redis lock keys.
|
||||||
|
heartbeat: Whether to periodically re-extend the lock TTL during task
|
||||||
|
execution so long-running tasks keep their lock.
|
||||||
|
heartbeat_interval: Seconds between heartbeat refreshes. When ``None`` it
|
||||||
|
defaults to a third of the task's TTL (with a 1s floor).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -55,6 +83,8 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
key_prefix: str = "taskiq:deduplication",
|
key_prefix: str = "taskiq:deduplication",
|
||||||
startup_retries: int = 3,
|
startup_retries: int = 3,
|
||||||
startup_retry_delay: float = 1.0,
|
startup_retry_delay: float = 1.0,
|
||||||
|
heartbeat: bool = True,
|
||||||
|
heartbeat_interval: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.redis_url = redis_url
|
self.redis_url = redis_url
|
||||||
self.default_deduplication = default_deduplication
|
self.default_deduplication = default_deduplication
|
||||||
@@ -62,17 +92,22 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
self.key_prefix = key_prefix
|
self.key_prefix = key_prefix
|
||||||
self.startup_retries = startup_retries
|
self.startup_retries = startup_retries
|
||||||
self.startup_retry_delay = startup_retry_delay
|
self.startup_retry_delay = startup_retry_delay
|
||||||
|
self.heartbeat = heartbeat
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
self._redis: Redis | None = None
|
self._redis: Redis | None = None
|
||||||
self._release_script: Any = None
|
self._release_script: Any = None
|
||||||
|
self._refresh_script: Any = None
|
||||||
|
self._heartbeats: dict[str, asyncio.Task[None]] = {}
|
||||||
|
|
||||||
async def startup(self) -> None:
|
async def startup(self) -> None:
|
||||||
last_error: BaseException | None = None
|
last_error: BaseException | None = None
|
||||||
for attempt in range(self.startup_retries):
|
for attempt in range(self.startup_retries):
|
||||||
client = Redis.from_url(str(self.redis_url))
|
client = Redis.from_url(str(self.redis_url))
|
||||||
try:
|
try:
|
||||||
await cast(Awaitable[bool], client.ping())
|
await client.ping()
|
||||||
self._redis = client
|
self._redis = client
|
||||||
self._release_script = self._redis.register_script(RELEASE_LUA_SCRIPT)
|
self._release_script = self._redis.register_script(RELEASE_LUA_SCRIPT)
|
||||||
|
self._refresh_script = self._redis.register_script(REFRESH_LUA_SCRIPT)
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@@ -96,6 +131,8 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
) from last_error
|
) from last_error
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
|
for task_id in list(self._heartbeats):
|
||||||
|
await self._cancel_heartbeat(task_id)
|
||||||
if self._redis is not None:
|
if self._redis is not None:
|
||||||
await self._redis.aclose()
|
await self._redis.aclose()
|
||||||
|
|
||||||
@@ -107,11 +144,19 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
key_fields = parse_list_label(
|
key_fields = parse_list_label(
|
||||||
message.labels.get(DEDUP_KEY_FIELDS_LABEL), DEDUP_KEY_FIELDS_LABEL
|
message.labels.get(DEDUP_KEY_FIELDS_LABEL), DEDUP_KEY_FIELDS_LABEL
|
||||||
)
|
)
|
||||||
kwargs = (
|
if key_fields is not None:
|
||||||
{k: v for k, v in message.kwargs.items() if k in key_fields}
|
missing = [field for field in key_fields if field not in message.kwargs]
|
||||||
if key_fields is not None
|
if missing:
|
||||||
else message.kwargs
|
logger.warning(
|
||||||
)
|
"Task %s requested deduplication_key_fields %r but they are "
|
||||||
|
"absent from kwargs; they are dropped from the fingerprint, which "
|
||||||
|
"may cause distinct calls to collide.",
|
||||||
|
message.task_name,
|
||||||
|
missing,
|
||||||
|
)
|
||||||
|
kwargs = {k: v for k, v in message.kwargs.items() if k in key_fields}
|
||||||
|
else:
|
||||||
|
kwargs = message.kwargs
|
||||||
try:
|
try:
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"task": message.task_name, "kwargs": kwargs},
|
{"task": message.task_name, "kwargs": kwargs},
|
||||||
@@ -147,6 +192,15 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Skipped release of lock %s: not owned by this task", key)
|
logger.debug("Skipped release of lock %s: not owned by this task", key)
|
||||||
|
|
||||||
|
async def _refresh_if_owned(self, key: str, task_id: str, ttl: int) -> bool:
|
||||||
|
if self._redis is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"RedisDeduplicationMiddleware.startup() was never called."
|
||||||
|
)
|
||||||
|
if self._refresh_script is None:
|
||||||
|
self._refresh_script = self._redis.register_script(REFRESH_LUA_SCRIPT)
|
||||||
|
return await check_and_refresh(self._refresh_script, key, task_id, ttl)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_cached_key(message: TaskiqMessage) -> str | None:
|
def _get_cached_key(message: TaskiqMessage) -> str | None:
|
||||||
return message.labels.get(_CACHED_KEY_LABEL)
|
return message.labels.get(_CACHED_KEY_LABEL)
|
||||||
@@ -177,19 +231,78 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
logger.debug("Acquiring lock %s for task %s", key, message.task_name)
|
logger.debug("Acquiring lock %s for task %s", key, message.task_name)
|
||||||
acquired = await self._redis.set(key, message.task_id, ex=ttl, nx=True)
|
acquired = await self._redis.set(key, message.task_id, ex=ttl, nx=True)
|
||||||
if not acquired:
|
if not acquired:
|
||||||
|
holder_task_id = await self._redis.get(key)
|
||||||
|
if isinstance(holder_task_id, bytes):
|
||||||
|
holder_task_id = holder_task_id.decode()
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Duplicate task %s dropped (key=%s).",
|
"Duplicate task %s dropped (key=%s, holder_task_id=%s).",
|
||||||
message.task_name,
|
message.task_name,
|
||||||
key,
|
key,
|
||||||
|
holder_task_id,
|
||||||
)
|
)
|
||||||
raise DuplicateTaskError(
|
raise DuplicateTaskError(
|
||||||
f"Task {message.task_name!r} with the same arguments is already queued or running."
|
task_name=message.task_name,
|
||||||
|
key=key,
|
||||||
|
holder_task_id=holder_task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Lock %s acquired for task %s", key, message.task_name)
|
logger.debug("Lock %s acquired for task %s", key, message.task_name)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
def _get_heartbeat_interval(self, ttl: int) -> float:
|
||||||
|
if self.heartbeat_interval is not None:
|
||||||
|
return self.heartbeat_interval
|
||||||
|
return max(ttl / 3, 1.0)
|
||||||
|
|
||||||
|
async def _heartbeat_loop(
|
||||||
|
self, key: str, task_id: str, ttl: int, interval: float
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
refreshed = await self._refresh_if_owned(key, task_id, ttl)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to refresh lock %s: %s", key, exc)
|
||||||
|
continue
|
||||||
|
if refreshed:
|
||||||
|
logger.debug("Refreshed lock %s (ttl=%ds)", key, ttl)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Lock %s no longer owned by task %s; stopping heartbeat.",
|
||||||
|
key,
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def pre_execute(self, message: TaskiqMessage) -> TaskiqMessage:
|
||||||
|
if not self.heartbeat:
|
||||||
|
return message
|
||||||
|
# The cached key is set by pre_send() only when deduplication is enabled.
|
||||||
|
key = self._get_cached_key(message)
|
||||||
|
if key is None:
|
||||||
|
return message
|
||||||
|
ttl = self._get_ttl(message.labels)
|
||||||
|
interval = self._get_heartbeat_interval(ttl)
|
||||||
|
self._heartbeats[message.task_id] = asyncio.create_task(
|
||||||
|
self._heartbeat_loop(key, message.task_id, ttl, interval)
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def _cancel_heartbeat(self, task_id: str) -> None:
|
||||||
|
task = self._heartbeats.pop(task_id, None)
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _release_lock(self, message: TaskiqMessage) -> None:
|
async def _release_lock(self, message: TaskiqMessage) -> None:
|
||||||
|
await self._cancel_heartbeat(message.task_id)
|
||||||
# The cached key is set by pre_send() only when deduplication is enabled.
|
# The cached key is set by pre_send() only when deduplication is enabled.
|
||||||
key = self._get_cached_key(message)
|
key = self._get_cached_key(message)
|
||||||
if key is None:
|
if key is None:
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ else
|
|||||||
end
|
end
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
REFRESH_LUA_SCRIPT = """
|
||||||
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('expire', KEYS[1], ARGV[2])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def check_and_delete(script: Any, key: str, owner: str) -> bool:
|
async def check_and_delete(script: Any, key: str, owner: str) -> bool:
|
||||||
"""Delete *key* only if its value equals *owner*.
|
"""Delete *key* only if its value equals *owner*.
|
||||||
@@ -28,6 +36,23 @@ async def check_and_delete(script: Any, key: str, owner: str) -> bool:
|
|||||||
return bool(released)
|
return bool(released)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_refresh(script: Any, key: str, owner: str, ttl: int) -> bool:
|
||||||
|
"""Extend *key*'s TTL to *ttl* only if its value equals *owner*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script: Pre-registered Lua script object (from ``Redis.register_script``).
|
||||||
|
key: Lock key to refresh.
|
||||||
|
owner: Expected value of the key (task_id).
|
||||||
|
ttl: New TTL in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the TTL was extended, False if the key is missing or owned by
|
||||||
|
another task.
|
||||||
|
"""
|
||||||
|
refreshed: int = await script(keys=[key], args=[owner, ttl])
|
||||||
|
return bool(refreshed)
|
||||||
|
|
||||||
|
|
||||||
def parse_bool_label(value: Any, default: bool, label_name: str = "") -> bool:
|
def parse_bool_label(value: Any, default: bool, label_name: str = "") -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
from typing import Awaitable, cast
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import fakeredis.aioredis
|
import fakeredis.aioredis
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
@@ -22,7 +20,7 @@ async def fake_redis():
|
|||||||
async def real_redis():
|
async def real_redis():
|
||||||
client = Redis.from_url("redis://localhost:6379/15")
|
client = Redis.from_url("redis://localhost:6379/15")
|
||||||
try:
|
try:
|
||||||
await cast(Awaitable[bool], client.ping())
|
await client.ping()
|
||||||
except Exception:
|
except Exception:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
pytest.skip("Redis not available at localhost:6379")
|
pytest.skip("Redis not available at localhost:6379")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ In CI a Redis service is started before the test step.
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from taskiq_deduplication import DuplicateTaskError, RedisDeduplicationMiddleware
|
from taskiq_deduplication import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||||
from taskiq_deduplication.utils import RELEASE_LUA_SCRIPT
|
from taskiq_deduplication.utils import REFRESH_LUA_SCRIPT, RELEASE_LUA_SCRIPT
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -15,6 +15,7 @@ def mw(real_redis):
|
|||||||
middleware = RedisDeduplicationMiddleware(redis_url="redis://localhost:6379/15")
|
middleware = RedisDeduplicationMiddleware(redis_url="redis://localhost:6379/15")
|
||||||
middleware._redis = real_redis
|
middleware._redis = real_redis
|
||||||
middleware._release_script = real_redis.register_script(RELEASE_LUA_SCRIPT)
|
middleware._release_script = real_redis.register_script(RELEASE_LUA_SCRIPT)
|
||||||
|
middleware._refresh_script = real_redis.register_script(REFRESH_LUA_SCRIPT)
|
||||||
return middleware
|
return middleware
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +69,31 @@ async def test_ttl_is_applied(mw, real_redis, make_message):
|
|||||||
assert 0 < ttl <= mw.default_ttl
|
assert 0 < ttl <= mw.default_ttl
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_heartbeat_keeps_long_running_lock_alive(
|
||||||
|
mw, real_redis, make_message, make_result
|
||||||
|
):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from taskiq_deduplication.middleware import DEDUP_TTL_LABEL
|
||||||
|
|
||||||
|
# 1s TTL with a sub-second heartbeat: without refresh the lock would expire.
|
||||||
|
mw.heartbeat_interval = 0.2
|
||||||
|
msg = make_message(labels={DEDUP_TTL_LABEL: 1})
|
||||||
|
await mw.pre_send(msg)
|
||||||
|
key = mw._build_deduplication_key(msg)
|
||||||
|
await mw.pre_execute(msg)
|
||||||
|
try:
|
||||||
|
# outlive the original TTL; the heartbeat should keep the lock present
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
assert await real_redis.exists(key)
|
||||||
|
with pytest.raises(DuplicateTaskError):
|
||||||
|
await mw.pre_send(make_message(labels={DEDUP_TTL_LABEL: 1}))
|
||||||
|
finally:
|
||||||
|
await mw.post_execute(msg, make_result())
|
||||||
|
assert not await real_redis.exists(key)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_explicit_key_end_to_end(mw, real_redis, make_message, make_result):
|
async def test_explicit_key_end_to_end(mw, real_redis, make_message, make_result):
|
||||||
from taskiq_deduplication.middleware import DEDUP_EXPLICIT_KEY_LABEL
|
from taskiq_deduplication.middleware import DEDUP_EXPLICIT_KEY_LABEL
|
||||||
|
|||||||
@@ -146,6 +146,34 @@ class TestPreSend:
|
|||||||
with pytest.raises(DuplicateTaskError):
|
with pytest.raises(DuplicateTaskError):
|
||||||
await middleware.pre_send(make_message())
|
await middleware.pre_send(make_message())
|
||||||
|
|
||||||
|
async def test_duplicate_error_carries_structured_attributes(
|
||||||
|
self, middleware, make_message
|
||||||
|
):
|
||||||
|
holder = make_message(task_id="holder-task")
|
||||||
|
await middleware.pre_send(holder)
|
||||||
|
key = middleware._build_deduplication_key(holder)
|
||||||
|
with pytest.raises(DuplicateTaskError) as exc_info:
|
||||||
|
await middleware.pre_send(make_message(task_id="loser-task"))
|
||||||
|
err = exc_info.value
|
||||||
|
assert err.task_name == "my_task"
|
||||||
|
assert err.key == key
|
||||||
|
assert err.holder_task_id == "holder-task"
|
||||||
|
assert key in str(err)
|
||||||
|
|
||||||
|
async def test_duplicate_error_holder_none_when_lock_released_in_race(
|
||||||
|
self, make_message
|
||||||
|
):
|
||||||
|
# The lock is released between the failed SET NX and the GET lookup, so
|
||||||
|
# GET returns None and holder_task_id is left unset.
|
||||||
|
redis = AsyncMock()
|
||||||
|
redis.set.return_value = False
|
||||||
|
redis.get.return_value = None
|
||||||
|
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||||
|
mw._redis = redis
|
||||||
|
with pytest.raises(DuplicateTaskError) as exc_info:
|
||||||
|
await mw.pre_send(make_message())
|
||||||
|
assert exc_info.value.holder_task_id is None
|
||||||
|
|
||||||
async def test_deduplication_disabled_label(self, middleware, make_message):
|
async def test_deduplication_disabled_label(self, middleware, make_message):
|
||||||
msg1 = make_message(labels={DEDUP_LABEL: False})
|
msg1 = make_message(labels={DEDUP_LABEL: False})
|
||||||
msg2 = make_message(labels={DEDUP_LABEL: False})
|
msg2 = make_message(labels={DEDUP_LABEL: False})
|
||||||
@@ -577,6 +605,33 @@ class TestLabelTypeParsing:
|
|||||||
assert any("{'a': 1}" in r.message for r in caplog.records)
|
assert any("{'a': 1}" in r.message for r in caplog.records)
|
||||||
assert key is not None
|
assert key is not None
|
||||||
|
|
||||||
|
async def test_missing_key_fields_warns(self, middleware, make_message, caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
msg = make_message(
|
||||||
|
kwargs={"order_id": 1},
|
||||||
|
labels={DEDUP_KEY_FIELDS_LABEL: ["user_id", "order_id"]},
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="taskiq_deduplication.middleware"):
|
||||||
|
key = middleware._build_deduplication_key(msg)
|
||||||
|
assert any("user_id" in r.message for r in caplog.records)
|
||||||
|
# the field that is present must not be reported as missing
|
||||||
|
assert not any("order_id" in r.message for r in caplog.records)
|
||||||
|
assert key is not None
|
||||||
|
|
||||||
|
async def test_present_key_fields_do_not_warn(
|
||||||
|
self, middleware, make_message, caplog
|
||||||
|
):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
msg = make_message(
|
||||||
|
kwargs={"user_id": 1, "order_id": 2},
|
||||||
|
labels={DEDUP_KEY_FIELDS_LABEL: ["user_id"]},
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="taskiq_deduplication.middleware"):
|
||||||
|
middleware._build_deduplication_key(msg)
|
||||||
|
assert not any("absent from kwargs" in r.message for r in caplog.records)
|
||||||
|
|
||||||
def test_stringified_key_fields_parsed_correctly(self, middleware, make_message):
|
def test_stringified_key_fields_parsed_correctly(self, middleware, make_message):
|
||||||
# taskiq's prepare_label() stringifies ["a", "b"] → "['a', 'b']" before pre_send
|
# taskiq's prepare_label() stringifies ["a", "b"] → "['a', 'b']" before pre_send
|
||||||
m1 = make_message(
|
m1 = make_message(
|
||||||
@@ -704,6 +759,131 @@ class TestTTLExpiry:
|
|||||||
await middleware.pre_send(make_message())
|
await middleware.pre_send(make_message())
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat:
|
||||||
|
async def test_pre_execute_starts_heartbeat(self, middleware, make_message):
|
||||||
|
msg = make_message()
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
assert msg.task_id in middleware._heartbeats
|
||||||
|
await middleware._cancel_heartbeat(msg.task_id)
|
||||||
|
|
||||||
|
async def test_pre_execute_noop_when_heartbeat_disabled(
|
||||||
|
self, fake_redis, make_message
|
||||||
|
):
|
||||||
|
mw = RedisDeduplicationMiddleware(
|
||||||
|
redis_url="redis://localhost", heartbeat=False
|
||||||
|
)
|
||||||
|
mw._redis = fake_redis
|
||||||
|
msg = make_message()
|
||||||
|
await mw.pre_send(msg)
|
||||||
|
await mw.pre_execute(msg)
|
||||||
|
assert msg.task_id not in mw._heartbeats
|
||||||
|
|
||||||
|
async def test_pre_execute_noop_without_cached_key(self, middleware, make_message):
|
||||||
|
# deduplication disabled -> pre_send never caches a key
|
||||||
|
msg = make_message(labels={DEDUP_LABEL: False})
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
assert msg.task_id not in middleware._heartbeats
|
||||||
|
|
||||||
|
async def test_heartbeat_refreshes_ttl(self, middleware, fake_redis, make_message):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# short ttl, tiny heartbeat interval so the lock would expire without refresh
|
||||||
|
middleware.heartbeat_interval = 0.05
|
||||||
|
msg = make_message(labels={DEDUP_TTL_LABEL: 1})
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
key = middleware._build_deduplication_key(msg)
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
try:
|
||||||
|
# let several heartbeats elapse — longer than the original 1s ttl
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
assert await fake_redis.exists(key)
|
||||||
|
ttl = await fake_redis.ttl(key)
|
||||||
|
assert 0 < ttl <= 1
|
||||||
|
finally:
|
||||||
|
await middleware._cancel_heartbeat(msg.task_id)
|
||||||
|
|
||||||
|
async def test_release_lock_cancels_heartbeat(
|
||||||
|
self, middleware, fake_redis, make_message, make_result
|
||||||
|
):
|
||||||
|
msg = make_message()
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
assert msg.task_id in middleware._heartbeats
|
||||||
|
await middleware.post_execute(msg, make_result())
|
||||||
|
assert msg.task_id not in middleware._heartbeats
|
||||||
|
key = middleware._build_deduplication_key(msg)
|
||||||
|
assert not await fake_redis.exists(key)
|
||||||
|
|
||||||
|
async def test_heartbeat_stops_when_lock_lost(
|
||||||
|
self, middleware, fake_redis, make_message
|
||||||
|
):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
middleware.heartbeat_interval = 0.05
|
||||||
|
msg = make_message(labels={DEDUP_TTL_LABEL: 1})
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
key = middleware._build_deduplication_key(msg)
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
# another task steals the key
|
||||||
|
await fake_redis.set(key, "other-task", ex=10)
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
task = middleware._heartbeats.get(msg.task_id)
|
||||||
|
# heartbeat loop should have returned on its own
|
||||||
|
assert task is None or task.done()
|
||||||
|
await middleware._cancel_heartbeat(msg.task_id)
|
||||||
|
|
||||||
|
async def test_shutdown_cancels_heartbeats(self, fake_redis, make_message):
|
||||||
|
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||||
|
mw._redis = fake_redis
|
||||||
|
msg = make_message()
|
||||||
|
await mw.pre_send(msg)
|
||||||
|
await mw.pre_execute(msg)
|
||||||
|
assert msg.task_id in mw._heartbeats
|
||||||
|
await mw.shutdown()
|
||||||
|
assert not mw._heartbeats
|
||||||
|
|
||||||
|
async def test_default_heartbeat_interval_is_third_of_ttl(self, middleware):
|
||||||
|
assert middleware._get_heartbeat_interval(300) == 100.0
|
||||||
|
assert middleware._get_heartbeat_interval(1) == 1.0
|
||||||
|
|
||||||
|
async def test_explicit_heartbeat_interval_overrides(self, fake_redis):
|
||||||
|
mw = RedisDeduplicationMiddleware(
|
||||||
|
redis_url="redis://localhost", heartbeat_interval=5.0
|
||||||
|
)
|
||||||
|
mw._redis = fake_redis
|
||||||
|
assert mw._get_heartbeat_interval(300) == 5.0
|
||||||
|
|
||||||
|
async def test_refresh_if_owned_raises_without_redis(self, middleware):
|
||||||
|
middleware._redis = None
|
||||||
|
with pytest.raises(RuntimeError, match="startup"):
|
||||||
|
await middleware._refresh_if_owned("some-key", "some-task", 60)
|
||||||
|
|
||||||
|
async def test_heartbeat_continues_after_refresh_error(
|
||||||
|
self, middleware, make_message, caplog
|
||||||
|
):
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
middleware.heartbeat_interval = 0.02
|
||||||
|
# first refresh raises, subsequent ones succeed; the loop must survive
|
||||||
|
middleware._refresh_if_owned = AsyncMock(
|
||||||
|
side_effect=[ConnectionError("boom"), True, True, True]
|
||||||
|
)
|
||||||
|
msg = make_message()
|
||||||
|
await middleware.pre_send(msg)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="taskiq_deduplication.middleware"):
|
||||||
|
await middleware.pre_execute(msg)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
task = middleware._heartbeats.get(msg.task_id)
|
||||||
|
# loop swallowed the error and kept running
|
||||||
|
assert task is not None and not task.done()
|
||||||
|
await middleware._cancel_heartbeat(msg.task_id)
|
||||||
|
assert any("Failed to refresh lock" in r.message for r in caplog.records)
|
||||||
|
assert middleware._refresh_if_owned.call_count >= 2
|
||||||
|
|
||||||
|
|
||||||
class TestExplicitKeyEdgeCases:
|
class TestExplicitKeyEdgeCases:
|
||||||
def test_empty_string_key_produces_prefix_only_key(self, middleware, make_message):
|
def test_empty_string_key_produces_prefix_only_key(self, middleware, make_message):
|
||||||
m = make_message(labels={DEDUP_EXPLICIT_KEY_LABEL: ""})
|
m = make_message(labels={DEDUP_EXPLICIT_KEY_LABEL: ""})
|
||||||
|
|||||||
@@ -790,7 +790,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocstrings-python"
|
name = "mkdocstrings-python"
|
||||||
version = "2.0.3"
|
version = "2.0.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
@@ -798,9 +798,9 @@ dependencies = [
|
|||||||
{ name = "mkdocstrings" },
|
{ name = "mkdocstrings" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1412,39 +1412,39 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "7.4.0"
|
version = "8.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.16"
|
version = "0.15.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1485,7 +1485,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "taskiq-deduplication"
|
name = "taskiq-deduplication"
|
||||||
version = "1.0.5"
|
version = "1.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
@@ -1617,27 +1617,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.44"
|
version = "0.0.54"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/f4/fbb120226e4f239652525a664bad976a23fea58c646d1323f2296fee8a61/ty-0.0.44.tar.gz", hash = "sha256:5886229830ab77022842a1c55d2ef57405621a91fc465969fa6d538661898173", size = 5803665, upload-time = "2026-06-05T03:33:48.612Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ae/c6/2ea90406d82cf82b0a68725130da2cc9de161bfa883c7dee4f0d94dbf3ce/ty-0.0.54.tar.gz", hash = "sha256:b6b3cfe174f27744413c898b2488ca52ea76070637095de131698e506b455055", size = 6009000, upload-time = "2026-06-25T17:53:17.098Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/c6/b5b8c4762efb4d85401652658786506867553ecfc2beac3bcf361a15937f/ty-0.0.44-py3-none-linux_armv6l.whl", hash = "sha256:272d31e7ad49b1dc5e8465a9fe700354e14c755b40d9c75f08f031d786903df3", size = 11607267, upload-time = "2026-06-05T03:33:27.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/26/a83e688e108a29a8630d7075bae47b72614cfa275c88b3166575ca0a8af0/ty-0.0.54-py3-none-linux_armv6l.whl", hash = "sha256:0365ea133d6b028952c22e6412a00da9739bc3b538df3c0a61972bf64d8558f1", size = 11616164, upload-time = "2026-06-25T17:52:29.174Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/5c/f4b405570737f44ab0fc4214117fe43353f8f0825a1823d9e99e9c8e57be/ty-0.0.44-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b92c4ddd7a3daf2049715edec9dc70cf6fd31a5a318ee647258f90dd75495eed", size = 11382826, upload-time = "2026-06-05T03:33:54.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/89/9f3374f9eef0267aed5efbd5544e47cfa6affc94236d929e878a780cf7d7/ty-0.0.54-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0de0cf48918609a3996cc2a4e18e8028fc4d2446bb82df822e9737f53bb9afee", size = 11351250, upload-time = "2026-06-25T17:52:32.181Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/aa/fb9835aa492b148d7754cb4c3db07f31a7e2e09f0d8e0e8e297f01125dd2/ty-0.0.44-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d42cfd84a690f6654b2a4f0515027c21b692cf2512d32e6433f754893a95609", size = 10809741, upload-time = "2026-06-05T03:33:33.22Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/11/e0e3f542a6de0e0dd5f503215527f8d9fc141426bd52dbe98fce927ff831/ty-0.0.54-py3-none-macosx_11_0_arm64.whl", hash = "sha256:57c7c6c1fcd2aa29cd40117142c0d45a2b0e6c42817ef58ea8dcb5f59d6ea802", size = 10872162, upload-time = "2026-06-25T17:52:35.206Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/f5/0b20ba6b66837a5a37bab7f74ac0732c66e766b5f0b2d55b30816b15f348/ty-0.0.44-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc47ae87e4cb7db2a9166bb23b78a905c3626e523296ec5bccf36b5e89bda6b", size = 11318153, upload-time = "2026-06-05T03:34:09.403Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/b0/fa369840f1ccd391971f693a8f86c445eb1a0fad61a6e0e773f0871a7a6f/ty-0.0.54-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc179be5a070c5dc7785a5e108eece2ab216978a91f91e11661308749a6c28ee", size = 11419826, upload-time = "2026-06-25T17:52:37.942Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/bb/b82ea730774a4f950f06d355fbc120d51eac7da23b57fc79ef6ff7c79cbb/ty-0.0.44-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46d867e80f16f421ac72c9a85240dbf050d62d9b3fbd10a8b5b082fb21679e0b", size = 11403108, upload-time = "2026-06-05T03:33:57.745Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/5f/8fbf9b7147cf08cc96cc9f84b9699ae7c7a0a1eb0a4c3d912261351b1cbb/ty-0.0.54-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a0a764e2261f9292adf14bbc91b9d7ef7ee7c00b8f3d9f2563f19ae3605109b", size = 11411732, upload-time = "2026-06-25T17:52:41.124Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/41/e2c83856165291049c702eda4e2ef3d3ebd875e8a0a77b8cc4ef3156aa1c/ty-0.0.44-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:411f5de0f96a4e4e5cccc3e0d55954c41f6a99ee6ca1fe5a7226cbc68406e053", size = 11944815, upload-time = "2026-06-05T03:34:15.793Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/ce/53aa78828f98ce396d5cc0f0878d990c0d70f6bf7704105232399315952f/ty-0.0.54-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2259e1a1f744a5f20f079dcc4061c1464001c75f255aaca7316e422f8f5e5e09", size = 12039318, upload-time = "2026-06-25T17:52:43.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/95/1fa6a101eb9d5bec042b87e5ca9c8fc349b75961beca6306f95af5cd5539/ty-0.0.44-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b15f01ecb4e2b46c05a1769293f9d32c3d4a1e4e7dfccf37c604d705dc3e3f4", size = 12476121, upload-time = "2026-06-05T03:33:51.529Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/05/5654d4d20376a73cd2e9df8178515c466ff9b2bda15748ee5810c955a265/ty-0.0.54-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8674617617399d4fdd568b3e3a8f87a913df525c80691d732b0744b571b341c8", size = 12625562, upload-time = "2026-06-25T17:52:46.578Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/6a/da4b45b1229d39207c6140681c2aaf4f5691bcb1dc830b84450ca25c8f57/ty-0.0.44-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd32b7467af509c99c0244c2226a4e4c03400699003ec33373282ab931654d9", size = 12091340, upload-time = "2026-06-05T03:33:36.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/f7/982be4abde816c821c6aeaba133dc34b5346eebbb29dadbf77b8ee46dad9/ty-0.0.54-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e949f7bbfa80a9fa00968924028aa673a505faa234b0b9d1241b7d34373eae14", size = 12175175, upload-time = "2026-06-25T17:52:49.176Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/c7/e1c9260ea5188195962ff1214ace418b5d69187e8fa7c0a1ec4994b8071b/ty-0.0.44-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503a585f4007387c3afc58bae23a7ca1b9f236cbdb1a881dc36110655ceb1937", size = 11986201, upload-time = "2026-06-05T03:34:00.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/8b/371fa3d121a1d9c8806a997ec5aa7b6a91912044d936594e6cf04d197471/ty-0.0.54-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a0ce5af9eefe636888377d8ab8ea817239ed96de64fede62c4e5331ae16db5c", size = 11945921, upload-time = "2026-06-25T17:52:51.983Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/f9/312bb112da9b1a7da295bb0426be85e72ad48da4e4266c36d77256b4058d/ty-0.0.44-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d28bcfa83243d77c2316944e8cf197f73597bf17d1ddc047d0b10a762531252", size = 12168475, upload-time = "2026-06-05T03:33:30.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/76/cab1ff8f6029b657b3fed59add1a2f188df6af26b6d649a6c789139dd88d/ty-0.0.54-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ad329d1942e23ee428948e673b2a50249f27e164220075b0a2ef8169029c8423", size = 12269965, upload-time = "2026-06-25T17:52:54.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/de/64978d603f6c3e5dd7cb97eca2214567d8ad0c85fa4a7435b7852ae4b779/ty-0.0.44-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:56fd2dd0192def189715b25f5338f6222fb827884dc34111e50aa1c4e061cee5", size = 11292937, upload-time = "2026-06-05T03:34:06.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/2f/c14b36cacacf7cc5ae40c862c34c07fadeba85cb93fd8f328cdac2029270/ty-0.0.54-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6e43124e6ac4cb9702d99facb28c6b2f2574c8a4b8f59eae2632eef4962e3439", size = 11370742, upload-time = "2026-06-25T17:52:57.233Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/63/a625d8a3c71dcaa01988d330f849c465fe72ead4b0bbab44fe4bd6e672b5/ty-0.0.44-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7f8d990489032de1984e73c159f3e760d754cf83a602b874827d943821f63595", size = 11421560, upload-time = "2026-06-05T03:33:23.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/1b/9e55bb273d3ea8efe165a73e34ae92220593e4dbfe665b8283be9d3eadea/ty-0.0.54-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:994ede70fc1b6f0efc29d0f1fc0d819630bed1d2610eac2e9dabb2840f3f3bf9", size = 11432346, upload-time = "2026-06-25T17:52:59.898Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/96/61aeba0e629b0c91bd316ff94d00e38817ec493ae4316f39508988daa287/ty-0.0.44-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f61ffe72996a755432922fe90b28db593f572eb5cbf48e3ef4e67b282533d1b0", size = 11580282, upload-time = "2026-06-05T03:34:03.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/16/402e8d2b1ab1020a25774f22ec0ba4378617758197278c1ddbc093b64854/ty-0.0.54-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b1d048df26abb433acc5b3bcef214167c7dc9e3341f8f200a3c59702516a0ed0", size = 11707341, upload-time = "2026-06-25T17:53:02.678Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/f7/256e1538ce21cab67b381201444c42454de69d310059c4929d92a0ee9c48/ty-0.0.44-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2b237a143bac4f30cec9257d45f01e72da97030a80a09a2b69cfef065f09c37f", size = 12085723, upload-time = "2026-06-05T03:33:45.953Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/ca/220599403e1dc864109402c88e22e8793108ec3be41576cd044772023d94/ty-0.0.54-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:651e90094d41c4add3d616e0f2c1c881c6700a2ae3ac191c023c665050ff2cdc", size = 12053427, upload-time = "2026-06-25T17:53:05.983Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/76/ec3957c10872643a98db7a7895101ad89c5b7cba4bc6c4aebbbfc91756cc/ty-0.0.44-py3-none-win32.whl", hash = "sha256:6a24586c65419223ac5bab4822d49ab493a5d19ea2a897514284c232b9d6166a", size = 10892978, upload-time = "2026-06-05T03:34:12.603Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/a9/f08df843e3d4422184a847829eb103c49a6118eed7024969a1a706de1c5e/ty-0.0.54-py3-none-win32.whl", hash = "sha256:7e876c9b5130afc6b6e46035a2eecca9b563b78249030296e1089922428d5415", size = 11044018, upload-time = "2026-06-25T17:53:09.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/7d/ba24050432196e7d7f03945e5c379951593c48e04e5c5d5275cfc4624791/ty-0.0.44-py3-none-win_amd64.whl", hash = "sha256:8cccb27e348c89a9733fbad1b2efadfbad79b107c7e52adb52dfd8a70156a38d", size = 11987058, upload-time = "2026-06-05T03:33:42.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/26/230719333d79fae599e3c2da5ac35e36786cdd6c4b4f2a5d5450ce171dc7/ty-0.0.54-py3-none-win_amd64.whl", hash = "sha256:f478476f3222807b4d92f15c5298a8c242f2ed17c724da75f4a8c6b522b4f29a", size = 12107765, upload-time = "2026-06-25T17:53:12.2Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/34/16ec3f1fec75292d9c56a8b5fef037ceaba85a5c30562206c1a245a00a67/ty-0.0.44-py3-none-win_arm64.whl", hash = "sha256:58049504e7a12bf1957f24a5384a332c94d5590127083a80db5e5a1bed34190b", size = 11329961, upload-time = "2026-06-05T03:33:39.427Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/68/b5fcb35ceebffab3e8898bd25e51d0bb77a0c43c50b7a8e3fae56125e4f5/ty-0.0.54-py3-none-win_arm64.whl", hash = "sha256:01ab9eb8c0802d35ae73fa08e4e037963250ee5ee6aa7ed8c8b994e5495db5ef", size = 11498510, upload-time = "2026-06-25T17:53:14.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,7 +1835,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.43"
|
version = "0.0.45"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1847,18 +1847,18 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
{ name = "tomli" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/85/ec45162e7824a8f879d887ef0774ee65926bf7d1064e2eebccc7eaee3378/zensical-0.0.43.tar.gz", hash = "sha256:dc2d3804ff562795c1024130e0c3ce79736467930729dda314f096d0e35b98c8", size = 3932396, upload-time = "2026-05-19T09:44:07.418Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/d1/ecb1889fd2208b2d577e6ff952d9bee201302eec7966b5b61cc64adfd8f5/zensical-0.0.45.tar.gz", hash = "sha256:315bce4ab0470338dd3588add38fb325f840856c375722e6802bd58a06446266", size = 3935947, upload-time = "2026-06-09T11:23:32.349Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/c2/55e0709607ae41c266987c3b91a1a9702b37fbbef0d07eddfe5e25c2d823/zensical-0.0.43-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:17c335362b6bac3a50178181694a964f6d9f0c516fc532129ba5a0a5c4103fb6", size = 12706531, upload-time = "2026-05-19T09:43:32.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/fd/6b84115e3bbe6b76ebb1265e8ff2161c0bc88dcd6499eaf29c61a66421e9/zensical-0.0.45-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c4cb2e11132f02ae824e246e016e073458e12e9de1eaf86fd39f01890d41204c", size = 12698844, upload-time = "2026-06-09T11:22:56.537Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/64/ce8627bc5ea30556162b29b041fe97d6a6aef2a87b51f12def628e4fa608/zensical-0.0.43-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8fe97f185194215f6193af45a17d2b30ebd72c8113e3650f2d7d6767b9c2206", size = 12563012, upload-time = "2026-05-19T09:43:35.962Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/dc/4ddf05d77c1455c32cb26da71f2a19d355927a45a3db5b26fb258a07ce8f/zensical-0.0.45-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:799a01de2102b5f731744ad31bdbc464d0c07d484e67ba148f6923679afa6ce6", size = 12571590, upload-time = "2026-06-09T11:23:00.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/d1/533bc9454f0e06b3d9d8bd2e7ac405308c3d4dee6572acab98f0ed6d1c07/zensical-0.0.43-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c4c85978c765b3e7f347e8102dfe1373d4bbe4229d7008b6bdbf352f1fbcd7f", size = 12947599, upload-time = "2026-05-19T09:43:38.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/53/60c6cc7b2ce8b1a83eb87bff3f7289447995552fd9a30ca76ffba22ca9d5/zensical-0.0.45-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6201e79ea8a64bd3ced3f05ef4b1529da0e675d67b1395987c0ba942e4e10dc4", size = 12939590, upload-time = "2026-06-09T11:23:02.721Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/a0/94f47d6fb592997be7ab9526938c929f0199adf2637c3c2b2b9b2101b28e/zensical-0.0.43-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90d7c06ffd07b2bdf78bef041d541baba8a3ea51fd2dd84dbdbc5b0229076524", size = 12904911, upload-time = "2026-05-19T09:43:42.434Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/1e/e9217ed75dba323a6f9a4eee28eb40416eff99932cd0ee6c394bf07b9ead/zensical-0.0.45-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:854aaf500e4a3ce64adea1faa7a1820c7cf9a4f66be1043e4e9ba727fe9cf2b5", size = 12911669, upload-time = "2026-06-09T11:23:05.407Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/fb/1db3ad9a86ff772f74a8bc60ad5b447aa02a158e70f94adacf50bdd5c40f/zensical-0.0.43-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60022f4a6b95e46ec0023f51052fcd491743b3ebd08c0066b22a5cf1e741fecd", size = 13269386, upload-time = "2026-05-19T09:43:45.387Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/3c/6fc9fe2334bb4460a8a8d732e23a30d2ddc2ecf63c2eb3487d9e7405e70d/zensical-0.0.45-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a80c57fd50fc60415914388286ac10a7d8b6f70b8ca7235597d09fb12c3171b0", size = 13267643, upload-time = "2026-06-09T11:23:07.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/ee/b24fd0f94885519d851c35615b086d069a1077b0198021a56755395a4633/zensical-0.0.43-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e278eb948a0b7545d50609d713c7c27e366dade4523ff73a311a5d5f136518a", size = 12999364, upload-time = "2026-05-19T09:43:48.549Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/f9/5696114af4ede5f1bd01e641a4ff24ee8ca49810bfaa28e5be12d930c0ef/zensical-0.0.45-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c3510f69e08b6ed8bb9596fc9393e4687f90394aa0ef2d6118b1375ad97be5", size = 12972147, upload-time = "2026-06-09T11:23:12.069Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/78/401ccd7afd9d2690f81b5319b7f1eed05108154ce20e4207053914518c1c/zensical-0.0.43-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b85e5ab99fbda13823e67c43a4be6e5ebda6600602969c6575e143f20ac203fd", size = 13124392, upload-time = "2026-05-19T09:43:50.965Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/9e/5c6acde480c43f8c993b13260925df8db31d51ab8a9977618e9efdd98d45/zensical-0.0.45-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01c484bb2ee85e98e21e24b397ff52ffc31101f7485935eee5d3afa6cca6cc08", size = 13117360, upload-time = "2026-06-09T11:23:15.155Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/b3/9af6eba5826b0ef143fc8308bd1e219e221441e307a958e39f824ba9ab53/zensical-0.0.43-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:751385accc92cccfd4560dabed7c423870686ef6ede244a67e5c96286af25e8f", size = 13177538, upload-time = "2026-05-19T09:43:53.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/31/ea21f102049b35a8fe5218c5331857a15eeb60deb1bb21823a4c0701e274/zensical-0.0.45-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3654b708830303759e866a58a60c483cd2a1c56a44acdaae5bbb341a3f40ebce", size = 13185593, upload-time = "2026-06-09T11:23:18.166Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/6b/cd090bd6659d32692487206469988ee84d41aa6de4cdf9e380f847da90e2/zensical-0.0.43-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:dd3ff5bfa6e65cf3d2550dc639c3da2a3bfa11087b83d57e06623c4c1607d583", size = 13327086, upload-time = "2026-05-19T09:43:56.8Z" },
|
{ url = "https://files.pythonhosted.org/packages/b4/97/6ded39fe27fa8a292d17d9af713b018e4919315233b60fa4b4b0aca737a6/zensical-0.0.45-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4da1c37eca1474b487def0ef40d7ac2aff31a9d7a029cb7479ef7c354437361", size = 13326882, upload-time = "2026-06-09T11:23:21.027Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/5b/ac2555354b5a53cb9c2c942811905c47be0b9f5603d3c1328ee8564333eb/zensical-0.0.43-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85055a115b12f49c6ab194dcf04f966fc06b690ed6a8ddddd819929fc5f340e6", size = 13284645, upload-time = "2026-05-19T09:43:59.329Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/80/075975032a9e20f319c0134f8ca659d295ee4908f15ab212702a2728247f/zensical-0.0.45-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f8a1966c186feebd3b795f9d420000bfd582e16eefdd9bc7a286d878faabae52", size = 13253961, upload-time = "2026-06-09T11:23:23.99Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/c6/1688ec6e5be15e3ab367d7804753291bfbdff3109b06e20c19ce30a7129c/zensical-0.0.43-cp310-abi3-win32.whl", hash = "sha256:8a75ddd4bb3cd3c4a8e71d2ebae44c5611fd636c1d355c6124dd96e2f9c52838", size = 12256740, upload-time = "2026-05-19T09:44:02.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/6a/0eab0eb311af6a07cde15ca58d5d720cbfa02cd509e4c7fb5fa20cda0b46/zensical-0.0.45-cp310-abi3-win32.whl", hash = "sha256:a1dd63a5efb8d0e5f2fadf862f02771a279dc5cbe9a982700194650065758f01", size = 12257083, upload-time = "2026-06-09T11:23:26.769Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/a8/d967e70eac810a7e9eb8c5150d6d02848a1f42260f42977c71debed3cb02/zensical-0.0.43-cp310-abi3-win_amd64.whl", hash = "sha256:03a9d1744a6394ad66c355d6f1de04cfd92efa525b0b94bf6dbf6971c5cd2c6b", size = 12496166, upload-time = "2026-05-19T09:44:04.915Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/cd/b117e749c60b1d1e16b8450db1355f69f38376f783b8c6c8815202988933/zensical-0.0.45-cp310-abi3-win_amd64.whl", hash = "sha256:1f2c0e69839ce4274bde34d18139d3b0d96bbf02b245ada46243590c9eedebc1", size = 12498335, upload-time = "2026-06-09T11:23:29.702Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user