mirror of
https://github.com/d3vyce/taskiq-deduplication.git
synced 2026-08-05 11:24:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce6b373f43 |
@@ -11,7 +11,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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@v7
|
uses: codecov/codecov-action@v6
|
||||||
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@v7
|
uses: codecov/codecov-action@v6
|
||||||
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@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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,7 +53,6 @@ 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,7 +53,6 @@ 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.
|
||||||
|
|||||||
+1
-51
@@ -17,14 +17,12 @@ broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
|||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `redis_url` | `str \| RedisDsn` | — | Redis connection URL passed to `Redis.from_url`. Accepts a plain string or a pydantic [`RedisDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.RedisDsn). |
|
| `redis_url` | `str` | — | Redis connection URL passed to `Redis.from_url`. |
|
||||||
| `default_deduplication` | `bool` | `True` | Whether deduplication is enabled for all tasks by default. Set `False` to opt-in per task instead of opting out. |
|
| `default_deduplication` | `bool` | `True` | Whether deduplication is enabled for all tasks by default. Set `False` to opt-in per task instead of opting out. |
|
||||||
| `default_ttl` | `int` | `300` | Default lock TTL in seconds. Overridden per task with the `deduplication_ttl` label. |
|
| `default_ttl` | `int` | `300` | Default lock TTL in seconds. Overridden per task with the `deduplication_ttl` label. |
|
||||||
| `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(
|
||||||
@@ -39,31 +37,6 @@ 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
|
||||||
@@ -103,25 +76,6 @@ 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.
|
||||||
@@ -178,10 +132,6 @@ 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
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "taskiq-deduplication"
|
name = "taskiq-deduplication"
|
||||||
version = "1.1.0"
|
version = "1.0.5"
|
||||||
description = "Redis-backed deduplication middleware for Taskiq"
|
description = "Redis-backed deduplication middleware for Taskiq"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -27,7 +27,6 @@ classifiers = [
|
|||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2.0.0",
|
|
||||||
"redis>=7.0.0",
|
"redis>=7.0.0",
|
||||||
"taskiq>=0.12.0",
|
"taskiq>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""Redis-backed deduplication middleware for Taskiq."""
|
"""Redis-backed deduplication middleware for Taskiq."""
|
||||||
|
|
||||||
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||||
from .schedule import RedisDeduplicationScheduleSource
|
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.0.5"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuplicateTaskError",
|
"DuplicateTaskError",
|
||||||
"RedisDeduplicationMiddleware",
|
"RedisDeduplicationMiddleware",
|
||||||
"RedisDeduplicationScheduleSource",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,18 +2,15 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Awaitable, cast
|
||||||
|
|
||||||
from pydantic import RedisDsn
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from taskiq import TaskiqMessage, TaskiqResult
|
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,
|
||||||
@@ -30,29 +27,7 @@ _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):
|
||||||
@@ -64,27 +39,20 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
on completion or error.
|
on completion or error.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
redis_url: Redis connection URL (``str`` or ``RedisDsn``) passed to
|
redis_url: Redis connection URL passed to ``Redis.from_url``.
|
||||||
``Redis.from_url``.
|
|
||||||
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__(
|
||||||
self,
|
self,
|
||||||
redis_url: str | RedisDsn,
|
redis_url: str,
|
||||||
default_deduplication: bool = True,
|
default_deduplication: bool = True,
|
||||||
default_ttl: int = 300,
|
default_ttl: int = 300,
|
||||||
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
|
||||||
@@ -92,22 +60,17 @@ 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(self.redis_url)
|
||||||
try:
|
try:
|
||||||
await client.ping()
|
await cast(Awaitable[bool], 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()
|
||||||
@@ -131,40 +94,25 @@ 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()
|
||||||
|
|
||||||
def _build_deduplication_key(self, message: TaskiqMessage) -> str | None:
|
def _build_deduplication_key(self, message: TaskiqMessage) -> str | None:
|
||||||
return self._build_key(message.task_name, message.labels, message.kwargs)
|
explicit_key: str | None = message.labels.get(DEDUP_EXPLICIT_KEY_LABEL)
|
||||||
|
|
||||||
def _build_key(
|
|
||||||
self, task_name: str, labels: dict[str, Any], kwargs: dict[str, Any]
|
|
||||||
) -> str | None:
|
|
||||||
explicit_key: str | None = labels.get(DEDUP_EXPLICIT_KEY_LABEL)
|
|
||||||
if explicit_key is not None:
|
if explicit_key is not None:
|
||||||
return f"{self.key_prefix}:{explicit_key}"
|
return f"{self.key_prefix}:{explicit_key}"
|
||||||
|
|
||||||
key_fields = parse_list_label(
|
key_fields = parse_list_label(
|
||||||
labels.get(DEDUP_KEY_FIELDS_LABEL), DEDUP_KEY_FIELDS_LABEL
|
message.labels.get(DEDUP_KEY_FIELDS_LABEL), DEDUP_KEY_FIELDS_LABEL
|
||||||
)
|
)
|
||||||
if key_fields is not None:
|
kwargs = (
|
||||||
missing = [field for field in key_fields if field not in kwargs]
|
{k: v for k, v in message.kwargs.items() if k in key_fields}
|
||||||
if missing:
|
if key_fields is not None
|
||||||
logger.warning(
|
else message.kwargs
|
||||||
"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.",
|
|
||||||
task_name,
|
|
||||||
missing,
|
|
||||||
)
|
)
|
||||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k in key_fields}
|
|
||||||
else:
|
|
||||||
filtered_kwargs = kwargs
|
|
||||||
try:
|
try:
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"task": task_name, "kwargs": filtered_kwargs},
|
{"task": message.task_name, "kwargs": kwargs},
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
)
|
)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -177,31 +125,6 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
labels.get(DEDUP_LABEL), self.default_deduplication, DEDUP_LABEL
|
labels.get(DEDUP_LABEL), self.default_deduplication, DEDUP_LABEL
|
||||||
)
|
)
|
||||||
|
|
||||||
def _require_redis(self) -> Redis:
|
|
||||||
if self._redis is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"RedisDeduplicationMiddleware.startup() was never called."
|
|
||||||
)
|
|
||||||
return self._redis
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _decode_task_id(value: bytes | str) -> str:
|
|
||||||
return value.decode() if isinstance(value, bytes) else value
|
|
||||||
|
|
||||||
async def _peek(
|
|
||||||
self, task_name: str, labels: dict[str, Any], kwargs: dict[str, Any]
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
if not self._is_enabled(labels):
|
|
||||||
return None
|
|
||||||
redis = self._require_redis()
|
|
||||||
key = self._build_key(task_name, labels, kwargs)
|
|
||||||
if key is None:
|
|
||||||
return None
|
|
||||||
holder_task_id = await redis.get(key)
|
|
||||||
if holder_task_id is None:
|
|
||||||
return None
|
|
||||||
return key, self._decode_task_id(holder_task_id)
|
|
||||||
|
|
||||||
def _get_ttl(self, labels: dict[str, Any]) -> int:
|
def _get_ttl(self, labels: dict[str, Any]) -> int:
|
||||||
return parse_int_label(
|
return parse_int_label(
|
||||||
labels.get(DEDUP_TTL_LABEL, self.default_ttl),
|
labels.get(DEDUP_TTL_LABEL, self.default_ttl),
|
||||||
@@ -210,21 +133,18 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _release_if_owned(self, key: str, task_id: str) -> None:
|
async def _release_if_owned(self, key: str, task_id: str) -> None:
|
||||||
redis = self._require_redis()
|
if self._redis is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"RedisDeduplicationMiddleware.startup() was never called."
|
||||||
|
)
|
||||||
if self._release_script is None:
|
if self._release_script is None:
|
||||||
self._release_script = redis.register_script(RELEASE_LUA_SCRIPT)
|
self._release_script = self._redis.register_script(RELEASE_LUA_SCRIPT)
|
||||||
released = await check_and_delete(self._release_script, key, task_id)
|
released = await check_and_delete(self._release_script, key, task_id)
|
||||||
if released:
|
if released:
|
||||||
logger.debug("Released lock %s", key)
|
logger.debug("Released lock %s", key)
|
||||||
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:
|
|
||||||
redis = self._require_redis()
|
|
||||||
if self._refresh_script is None:
|
|
||||||
self._refresh_script = 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)
|
||||||
@@ -237,7 +157,10 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
if not self._is_enabled(message.labels):
|
if not self._is_enabled(message.labels):
|
||||||
return message
|
return message
|
||||||
|
|
||||||
redis = self._require_redis()
|
if self._redis is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"RedisDeduplicationMiddleware.startup() was never called."
|
||||||
|
)
|
||||||
key = self._build_deduplication_key(message)
|
key = self._build_deduplication_key(message)
|
||||||
self._cache_key(message, key)
|
self._cache_key(message, key)
|
||||||
if key is None:
|
if key is None:
|
||||||
@@ -250,80 +173,21 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
|||||||
ttl = self._get_ttl(message.labels)
|
ttl = self._get_ttl(message.labels)
|
||||||
|
|
||||||
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 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 redis.get(key)
|
|
||||||
if holder_task_id is not None:
|
|
||||||
holder_task_id = self._decode_task_id(holder_task_id)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Duplicate task %s dropped (key=%s, holder_task_id=%s).",
|
"Duplicate task %s dropped (key=%s).",
|
||||||
message.task_name,
|
message.task_name,
|
||||||
key,
|
key,
|
||||||
holder_task_id,
|
|
||||||
)
|
)
|
||||||
raise DuplicateTaskError(
|
raise DuplicateTaskError(
|
||||||
task_name=message.task_name,
|
f"Task {message.task_name!r} with the same arguments is already queued or running."
|
||||||
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:
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from taskiq import ScheduledTask, ScheduleSource
|
|
||||||
from taskiq.exceptions import ScheduledTaskCancelledError
|
|
||||||
from taskiq.utils import maybe_awaitable
|
|
||||||
|
|
||||||
from .middleware import RedisDeduplicationMiddleware
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RedisDeduplicationScheduleSource(ScheduleSource):
|
|
||||||
"""Skips scheduled firings whose fingerprint is already locked.
|
|
||||||
|
|
||||||
Wraps a ``ScheduleSource`` and peeks the lock in ``pre_send``, raising
|
|
||||||
``ScheduledTaskCancelledError`` on a hit so the scheduler skips the firing
|
|
||||||
cleanly instead of raising ``DuplicateTaskError`` out of ``kiq()``. The
|
|
||||||
atomic acquire/release lifecycle stays owned by ``middleware``.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
source: The wrapped ``ScheduleSource``.
|
|
||||||
middleware: The ``RedisDeduplicationMiddleware`` instance registered
|
|
||||||
on the broker. Must be the same instance, so both share one Redis
|
|
||||||
connection and configuration. Its ``startup()`` must have run
|
|
||||||
before ``pre_send()`` is invoked.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
source: ScheduleSource,
|
|
||||||
middleware: RedisDeduplicationMiddleware,
|
|
||||||
) -> None:
|
|
||||||
self.source = source
|
|
||||||
self.middleware = middleware
|
|
||||||
|
|
||||||
async def startup(self) -> None:
|
|
||||||
await self.source.startup()
|
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
|
||||||
await self.source.shutdown()
|
|
||||||
|
|
||||||
async def get_schedules(self) -> list[ScheduledTask]:
|
|
||||||
return await self.source.get_schedules()
|
|
||||||
|
|
||||||
async def add_schedule(self, schedule: ScheduledTask) -> None:
|
|
||||||
await self.source.add_schedule(schedule)
|
|
||||||
|
|
||||||
async def delete_schedule(self, schedule_id: str) -> None:
|
|
||||||
await self.source.delete_schedule(schedule_id)
|
|
||||||
|
|
||||||
async def post_send(self, task: ScheduledTask) -> None:
|
|
||||||
await maybe_awaitable(self.source.post_send(task))
|
|
||||||
|
|
||||||
async def pre_send(self, task: ScheduledTask) -> None:
|
|
||||||
await maybe_awaitable(self.source.pre_send(task))
|
|
||||||
|
|
||||||
try:
|
|
||||||
held = await self.middleware._peek(task.task_name, task.labels, task.kwargs)
|
|
||||||
except RuntimeError:
|
|
||||||
logger.error(
|
|
||||||
"RedisDeduplicationMiddleware.startup() was never called; "
|
|
||||||
"cannot deduplicate scheduled task %s.",
|
|
||||||
task.task_name,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if held is None:
|
|
||||||
return
|
|
||||||
key, holder_task_id = held
|
|
||||||
logger.warning(
|
|
||||||
"Duplicate scheduled task %s skipped before dispatch "
|
|
||||||
"(key=%s, holder_task_id=%s).",
|
|
||||||
task.task_name,
|
|
||||||
key,
|
|
||||||
holder_task_id,
|
|
||||||
)
|
|
||||||
raise ScheduledTaskCancelledError()
|
|
||||||
@@ -12,14 +12,6 @@ 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*.
|
||||||
@@ -36,23 +28,6 @@ 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
|
||||||
|
|||||||
+4
-32
@@ -1,9 +1,9 @@
|
|||||||
|
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
|
||||||
from taskiq import ScheduledTask, TaskiqMessage, TaskiqResult
|
from taskiq import TaskiqMessage, TaskiqResult
|
||||||
|
|
||||||
from taskiq_deduplication import RedisDeduplicationMiddleware
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,18 +18,11 @@ async def fake_redis():
|
|||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def middleware(fake_redis):
|
|
||||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
|
||||||
mw._redis = fake_redis
|
|
||||||
return mw
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
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 client.ping()
|
await cast(Awaitable[bool], 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")
|
||||||
@@ -66,24 +59,3 @@ def make_result():
|
|||||||
)
|
)
|
||||||
|
|
||||||
return _make
|
return _make
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def make_scheduled_task():
|
|
||||||
def _make(
|
|
||||||
task_name="my_task",
|
|
||||||
schedule_id="schedule-1",
|
|
||||||
labels=None,
|
|
||||||
kwargs=None,
|
|
||||||
cron="* * * * *",
|
|
||||||
):
|
|
||||||
return ScheduledTask(
|
|
||||||
task_name=task_name,
|
|
||||||
schedule_id=schedule_id,
|
|
||||||
labels=labels or {},
|
|
||||||
args=[],
|
|
||||||
kwargs=kwargs or {},
|
|
||||||
cron=cron,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _make
|
|
||||||
|
|||||||
@@ -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 REFRESH_LUA_SCRIPT, RELEASE_LUA_SCRIPT
|
from taskiq_deduplication.utils import RELEASE_LUA_SCRIPT
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -15,7 +15,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -69,31 +68,6 @@ 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
|
||||||
|
|||||||
+7
-192
@@ -1,7 +1,6 @@
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import RedisDsn, TypeAdapter
|
|
||||||
|
|
||||||
from taskiq_deduplication import DuplicateTaskError, RedisDeduplicationMiddleware
|
from taskiq_deduplication import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||||
from taskiq_deduplication.middleware import (
|
from taskiq_deduplication.middleware import (
|
||||||
@@ -12,6 +11,13 @@ from taskiq_deduplication.middleware import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def middleware(fake_redis):
|
||||||
|
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||||
|
mw._redis = fake_redis
|
||||||
|
return mw
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultBuildDeduplicationKey:
|
class TestDefaultBuildDeduplicationKey:
|
||||||
def test_same_kwargs_same_key(self, middleware, make_message):
|
def test_same_kwargs_same_key(self, middleware, make_message):
|
||||||
m1 = make_message(kwargs={"a": 1, "b": 2})
|
m1 = make_message(kwargs={"a": 1, "b": 2})
|
||||||
@@ -139,34 +145,6 @@ 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})
|
||||||
@@ -363,17 +341,6 @@ class TestLifecycle:
|
|||||||
mock_from_url.assert_called_once_with("redis://localhost")
|
mock_from_url.assert_called_once_with("redis://localhost")
|
||||||
assert mw._redis is mock_client
|
assert mw._redis is mock_client
|
||||||
|
|
||||||
async def test_startup_accepts_redis_dsn(self):
|
|
||||||
dsn = TypeAdapter(RedisDsn).validate_python("redis://localhost:6379/0")
|
|
||||||
mw = RedisDeduplicationMiddleware(redis_url=dsn)
|
|
||||||
with patch("redis.asyncio.Redis.from_url") as mock_from_url:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.register_script = MagicMock()
|
|
||||||
mock_from_url.return_value = mock_client
|
|
||||||
await mw.startup()
|
|
||||||
mock_from_url.assert_called_once_with("redis://localhost:6379/0")
|
|
||||||
assert mw._redis is mock_client
|
|
||||||
|
|
||||||
async def test_shutdown_closes_redis_client(self):
|
async def test_shutdown_closes_redis_client(self):
|
||||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
@@ -598,33 +565,6 @@ 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(
|
||||||
@@ -752,131 +692,6 @@ 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: ""})
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from taskiq import InMemoryBroker, ScheduleSource, TaskiqScheduler
|
|
||||||
from taskiq.exceptions import ScheduledTaskCancelledError
|
|
||||||
|
|
||||||
from taskiq_deduplication import RedisDeduplicationMiddleware
|
|
||||||
from taskiq_deduplication.middleware import (
|
|
||||||
DEDUP_EXPLICIT_KEY_LABEL,
|
|
||||||
DEDUP_KEY_FIELDS_LABEL,
|
|
||||||
DEDUP_LABEL,
|
|
||||||
)
|
|
||||||
from taskiq_deduplication.schedule import RedisDeduplicationScheduleSource
|
|
||||||
|
|
||||||
|
|
||||||
class FakeScheduleSource(ScheduleSource):
|
|
||||||
def __init__(self):
|
|
||||||
self.startup_called = False
|
|
||||||
self.shutdown_called = False
|
|
||||||
self.schedules_to_return = []
|
|
||||||
self.added = []
|
|
||||||
self.deleted = []
|
|
||||||
self.pre_send_calls = []
|
|
||||||
self.post_send_calls = []
|
|
||||||
self.pre_send_raises = None
|
|
||||||
|
|
||||||
async def startup(self):
|
|
||||||
self.startup_called = True
|
|
||||||
|
|
||||||
async def shutdown(self):
|
|
||||||
self.shutdown_called = True
|
|
||||||
|
|
||||||
async def get_schedules(self):
|
|
||||||
return self.schedules_to_return
|
|
||||||
|
|
||||||
async def add_schedule(self, schedule):
|
|
||||||
self.added.append(schedule)
|
|
||||||
|
|
||||||
async def delete_schedule(self, schedule_id):
|
|
||||||
self.deleted.append(schedule_id)
|
|
||||||
|
|
||||||
async def pre_send(self, task):
|
|
||||||
self.pre_send_calls.append(task)
|
|
||||||
if self.pre_send_raises is not None:
|
|
||||||
raise self.pre_send_raises
|
|
||||||
|
|
||||||
async def post_send(self, task):
|
|
||||||
self.post_send_calls.append(task)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_source():
|
|
||||||
return FakeScheduleSource()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def wrapper(fake_source, middleware):
|
|
||||||
return RedisDeduplicationScheduleSource(fake_source, middleware)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDelegation:
|
|
||||||
async def test_startup_delegates(self, wrapper, fake_source):
|
|
||||||
await wrapper.startup()
|
|
||||||
assert fake_source.startup_called
|
|
||||||
|
|
||||||
async def test_shutdown_delegates(self, wrapper, fake_source):
|
|
||||||
await wrapper.shutdown()
|
|
||||||
assert fake_source.shutdown_called
|
|
||||||
|
|
||||||
async def test_get_schedules_delegates(
|
|
||||||
self, wrapper, fake_source, make_scheduled_task
|
|
||||||
):
|
|
||||||
task = make_scheduled_task()
|
|
||||||
fake_source.schedules_to_return = [task]
|
|
||||||
assert await wrapper.get_schedules() == [task]
|
|
||||||
|
|
||||||
async def test_add_schedule_delegates(
|
|
||||||
self, wrapper, fake_source, make_scheduled_task
|
|
||||||
):
|
|
||||||
task = make_scheduled_task()
|
|
||||||
await wrapper.add_schedule(task)
|
|
||||||
assert fake_source.added == [task]
|
|
||||||
|
|
||||||
async def test_delete_schedule_delegates(self, wrapper, fake_source):
|
|
||||||
await wrapper.delete_schedule("some-id")
|
|
||||||
assert fake_source.deleted == ["some-id"]
|
|
||||||
|
|
||||||
async def test_post_send_delegates(self, wrapper, fake_source, make_scheduled_task):
|
|
||||||
task = make_scheduled_task()
|
|
||||||
await wrapper.post_send(task)
|
|
||||||
assert fake_source.post_send_calls == [task]
|
|
||||||
|
|
||||||
async def test_pre_send_delegates(self, wrapper, fake_source, make_scheduled_task):
|
|
||||||
task = make_scheduled_task()
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
assert fake_source.pre_send_calls == [task]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreSend:
|
|
||||||
async def test_no_lock_held_passes(self, wrapper, make_scheduled_task):
|
|
||||||
task = make_scheduled_task()
|
|
||||||
result = await wrapper.pre_send(task)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
async def test_wrapped_source_cancellation_propagates(
|
|
||||||
self, wrapper, fake_source, make_scheduled_task
|
|
||||||
):
|
|
||||||
fake_source.pre_send_raises = ScheduledTaskCancelledError()
|
|
||||||
with pytest.raises(ScheduledTaskCancelledError):
|
|
||||||
await wrapper.pre_send(make_scheduled_task())
|
|
||||||
|
|
||||||
async def test_lock_held_raises_scheduled_task_cancelled_error(
|
|
||||||
self, wrapper, middleware, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
await middleware.pre_send(make_message(task_name="my_task", kwargs={"a": 1}))
|
|
||||||
task = make_scheduled_task(task_name="my_task", kwargs={"a": 1})
|
|
||||||
with pytest.raises(ScheduledTaskCancelledError):
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
|
|
||||||
async def test_peek_does_not_acquire_or_mutate(
|
|
||||||
self, wrapper, middleware, fake_redis, make_scheduled_task
|
|
||||||
):
|
|
||||||
task = make_scheduled_task(task_name="my_task", kwargs={"a": 1})
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
key = middleware._build_key(task.task_name, task.labels, task.kwargs)
|
|
||||||
assert not await fake_redis.exists(key)
|
|
||||||
assert task.labels == {}
|
|
||||||
assert task.kwargs == {"a": 1}
|
|
||||||
|
|
||||||
async def test_peek_is_read_only_when_lock_held(
|
|
||||||
self, wrapper, middleware, fake_redis, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
held_msg = make_message(task_name="my_task", task_id="holder", kwargs={"a": 1})
|
|
||||||
await middleware.pre_send(held_msg)
|
|
||||||
key = middleware._build_deduplication_key(held_msg)
|
|
||||||
ttl_before = await fake_redis.ttl(key)
|
|
||||||
holder_before = await fake_redis.get(key)
|
|
||||||
|
|
||||||
task = make_scheduled_task(task_name="my_task", kwargs={"a": 1})
|
|
||||||
with pytest.raises(ScheduledTaskCancelledError):
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
|
|
||||||
# The peek must not have re-set the key (TTL untouched) or changed
|
|
||||||
# its owner.
|
|
||||||
assert await fake_redis.get(key) == holder_before
|
|
||||||
assert await fake_redis.ttl(key) <= ttl_before
|
|
||||||
|
|
||||||
async def test_deduplication_disabled_label_bypasses_peek(
|
|
||||||
self, wrapper, middleware, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
await middleware.pre_send(make_message(task_name="my_task", kwargs={"a": 1}))
|
|
||||||
task = make_scheduled_task(
|
|
||||||
task_name="my_task", kwargs={"a": 1}, labels={DEDUP_LABEL: False}
|
|
||||||
)
|
|
||||||
await wrapper.pre_send(task) # should not raise
|
|
||||||
|
|
||||||
async def test_deduplication_key_label_respected(
|
|
||||||
self, wrapper, middleware, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
await middleware.pre_send(
|
|
||||||
make_message(kwargs={"a": 1}, labels={DEDUP_EXPLICIT_KEY_LABEL: "fixed"})
|
|
||||||
)
|
|
||||||
task = make_scheduled_task(
|
|
||||||
kwargs={"a": 999}, labels={DEDUP_EXPLICIT_KEY_LABEL: "fixed"}
|
|
||||||
)
|
|
||||||
with pytest.raises(ScheduledTaskCancelledError):
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
|
|
||||||
async def test_deduplication_key_fields_label_respected(
|
|
||||||
self, wrapper, middleware, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
await middleware.pre_send(
|
|
||||||
make_message(
|
|
||||||
kwargs={"a": 1, "b": 2},
|
|
||||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a"]},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
task = make_scheduled_task(
|
|
||||||
kwargs={"a": 1, "b": 999},
|
|
||||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a"]},
|
|
||||||
)
|
|
||||||
with pytest.raises(ScheduledTaskCancelledError):
|
|
||||||
await wrapper.pre_send(task)
|
|
||||||
|
|
||||||
async def test_non_serializable_kwargs_skips_peek_silently(
|
|
||||||
self, wrapper, make_scheduled_task, caplog
|
|
||||||
):
|
|
||||||
task = make_scheduled_task(kwargs={"dt": object()})
|
|
||||||
with caplog.at_level(logging.WARNING, logger="taskiq_deduplication.schedule"):
|
|
||||||
await wrapper.pre_send(task) # should not raise
|
|
||||||
assert not any("non-JSON-serializable" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
async def test_pre_send_without_middleware_startup_raises_runtime_error(
|
|
||||||
self, fake_source, make_scheduled_task
|
|
||||||
):
|
|
||||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
|
||||||
w = RedisDeduplicationScheduleSource(fake_source, mw)
|
|
||||||
with pytest.raises(RuntimeError, match="startup"):
|
|
||||||
await w.pre_send(make_scheduled_task())
|
|
||||||
|
|
||||||
async def test_pre_send_without_middleware_startup_logs_before_raising(
|
|
||||||
self, fake_source, make_scheduled_task, caplog
|
|
||||||
):
|
|
||||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
|
||||||
w = RedisDeduplicationScheduleSource(fake_source, mw)
|
|
||||||
with caplog.at_level(logging.ERROR, logger="taskiq_deduplication.schedule"):
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
await w.pre_send(make_scheduled_task())
|
|
||||||
assert any("startup" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
async def test_different_kwargs_both_pass(self, wrapper, make_scheduled_task):
|
|
||||||
await wrapper.pre_send(make_scheduled_task(kwargs={"x": 1}))
|
|
||||||
await wrapper.pre_send(make_scheduled_task(kwargs={"x": 2}))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerIntegration:
|
|
||||||
async def test_second_firing_cancelled_without_uncaught_exception(
|
|
||||||
self, middleware, fake_source, make_message, make_scheduled_task
|
|
||||||
):
|
|
||||||
broker = InMemoryBroker().with_middlewares(middleware)
|
|
||||||
wrapper = RedisDeduplicationScheduleSource(fake_source, middleware)
|
|
||||||
scheduler = TaskiqScheduler(broker=broker, sources=[wrapper])
|
|
||||||
|
|
||||||
task_name = "my_task"
|
|
||||||
|
|
||||||
# Simulate a still-running first firing by holding the lock directly.
|
|
||||||
await middleware.pre_send(
|
|
||||||
make_message(task_name=task_name, task_id="holder", kwargs={})
|
|
||||||
)
|
|
||||||
|
|
||||||
scheduled = make_scheduled_task(task_name=task_name, kwargs={})
|
|
||||||
|
|
||||||
# Must be cancelled gracefully by on_ready()'s own except clause, not
|
|
||||||
# raise DuplicateTaskError out of kiq() into an uncaught exception.
|
|
||||||
await scheduler.on_ready(wrapper, scheduled)
|
|
||||||
@@ -790,7 +790,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocstrings-python"
|
name = "mkdocstrings-python"
|
||||||
version = "2.0.4"
|
version = "2.0.3"
|
||||||
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/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1412,39 +1412,39 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "8.0.1"
|
version = "7.4.0"
|
||||||
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/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.17"
|
version = "0.15.16"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1485,10 +1485,9 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "taskiq-deduplication"
|
name = "taskiq-deduplication"
|
||||||
version = "1.1.0"
|
version = "1.0.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "taskiq" },
|
{ name = "taskiq" },
|
||||||
]
|
]
|
||||||
@@ -1521,7 +1520,6 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
|
||||||
{ name = "redis", specifier = ">=7.0.0" },
|
{ name = "redis", specifier = ">=7.0.0" },
|
||||||
{ name = "taskiq", specifier = ">=0.12.0" },
|
{ name = "taskiq", specifier = ">=0.12.0" },
|
||||||
]
|
]
|
||||||
@@ -1617,27 +1615,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.54"
|
version = "0.0.51"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" },
|
||||||
{ 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/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" },
|
||||||
{ 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/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" },
|
||||||
{ 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/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" },
|
||||||
{ 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/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" },
|
||||||
{ 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/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" },
|
||||||
{ 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/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" },
|
||||||
{ 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/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" },
|
||||||
{ 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/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" },
|
||||||
{ 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/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" },
|
||||||
{ 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/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" },
|
||||||
{ 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/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" },
|
||||||
{ 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/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" },
|
||||||
{ 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/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" },
|
||||||
{ 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/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" },
|
||||||
{ 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/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" },
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,7 +1833,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.45"
|
version = "0.0.43"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1847,18 +1845,18 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
{ name = "tomli" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user