kickdown 0.4.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run *)"
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ * @vklokov
@@ -0,0 +1,35 @@
1
+ name: PR Verify
2
+ on:
3
+ pull_request:
4
+ types:
5
+ - opened
6
+ - reopened
7
+ - synchronize
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
16
+ with:
17
+ enable-cache: true
18
+
19
+ - name: Run Ruff
20
+ uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 #v3.6.1
21
+
22
+ - name: Run ty
23
+ run: uv run ty check
24
+
25
+
26
+ test:
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ - name: Install uv
31
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
32
+ with:
33
+ enable-cache: true
34
+ - name: Run Pytest
35
+ run: uv run pytest -q
@@ -0,0 +1,19 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ tmp/
13
+
14
+ _client.py
15
+ _server.py
16
+
17
+ .mypy_cache
18
+ .pytest_cache
19
+ .ruff_cache
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,11 @@
1
+ {
2
+ "editor.tabSize": 2,
3
+ "editor.insertSpaces": true,
4
+ "editor.detectIndentation": true,
5
+ "editor.renderWhitespace": "all",
6
+ "editor.formatOnSave": true,
7
+ "python.defaultInterpreterPath": "./.venv/bin/python",
8
+ "[python]": {
9
+ "editor.defaultFormatter": "charliermarsh.ruff",
10
+ },
11
+ }
@@ -0,0 +1,138 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - 2026-09-17
4
+
5
+ ### Changed
6
+ - Rewritten around an arbitrary number of queues instead of a single configured queue
7
+ - Workers (`Performable`) now declare their own `queue` and `operation`; the server derives the set of polled queues from the registered workers automatically
8
+ - Queues are polled in round-robin order with equal frequency — no priority between queues at this stage
9
+ - Workers are keyed by `(queue, operation)`, so the same `operation` name can be reused safely across different queues
10
+ - `Client.enqueue` now takes a single `Task` (carrying `queue`, `operation`, `params`, `jid`, `retry_count`) instead of a worker class and payload dict
11
+ - `Client.pending(queue)` / `Client.stats(queue)` moved onto the queue handle: `client.queue(name).pending()` / `.stats()`
12
+ - `Client` is now fully async (`enqueue`, `pending`, `close`), and supports `async with` instead of a sync context manager
13
+ - `Server` no longer takes a `Config` object; it's constructed directly with `redis_url` and `concurrency`
14
+ - Retries use a hardcoded exponential backoff (`1s * 1.5 ** attempt`) instead of a per-task `backoff_coefficient` — a failed task is retried with `retry_count` decremented and `attempt` incremented until `retry_count` reaches `0`
15
+ - Retries are no longer awaited in memory: the failed task is stored in a scheduled sorted set in Redis and picked up when due, so it survives a process restart and does not hold a concurrency slot for the duration of the delay
16
+ - On shutdown, the server now waits for in-flight tasks (including ones mid-retry) to finish before closing the Redis connection
17
+
18
+ ### Added
19
+ - A default logger (plain `logging.Logger`, text output to stdout) on `Client` and `Server` — no custom logger interface/protocol; swap it by assigning `server.logger = ...` / `client.logger = ...`
20
+ - `Client` logs when a task is accepted (enqueued)
21
+ - `Server` logs process start/shutdown (with the polled queues and concurrency), each task's start/completion, retries, permanent failures, and unrecognized operations — all messages include the task's `jid`
22
+ - `Server` always starts a small HTTP server (`web_port`, default `3030`) with `/live` and `/ready` for liveness/readiness probes; `/ready` checks Redis connectivity
23
+ - Per-queue `processed`/`failed` counters (`Stats` model) via `client.queue(name).stats()`; the consumer increments them on task completion, permanent failure (retries exhausted), and unrecognized operations
24
+ - `GET /admin` renders an HTML dashboard: total processed/failed/scheduled counters, and a per-queue table of pending/scheduled/in-flight counts (in-flight is a placeholder until tasks are tracked while they run); optionally gated behind HTTP Basic Auth via `Server(admin_username=..., admin_password=...)` (no auth if either is left unset)
25
+ - `Queue.purge()` drops every pending task in a queue and returns how many were dropped (scheduled tasks and counters are untouched); not exposed over HTTP
26
+ - `Server.enqueue(task)` pushes a task using the server's own Redis connection, so workers/hooks can schedule tasks without a separate `Client`
27
+ - A `Scheduler` loop runs inside every `Server`, moving due tasks from each queue's `kickdown:scheduled:{name}` set back into the queue (atomically, via a Lua script); a server only sweeps the queues it has workers for
28
+ - `Queue`, a handle bound to one queue name, carrying every per-queue operation (`push`, `pending`, `length`, `scheduled`, `stats`, `purge`, counters); obtained via `Client.queue(name)` / `Server.queue(name)`, and `Server.queues` now returns `Queue` objects instead of names
29
+
30
+ ### Removed
31
+ - The old `Loggable` protocol and the custom `Logger` wrapper class (replaced by the stdlib logger above)
32
+ - `Server.uptime()` / `Status` and the `humanize` dependency
33
+
34
+ ---
35
+
36
+ ## 0.3.3 - 2026-06-19
37
+
38
+ ### Changed
39
+ - Pinned `redis` to the 7.x line (`~=7.4`) to avoid an unintended upgrade to `redis` 8.x; previously `>=7.4.0` allowed any newer major version
40
+
41
+ ---
42
+
43
+ ## 0.3.2 - 2026-06-03
44
+
45
+ ### Added
46
+ - Job execution duration is now tracked and logged (in seconds) on job completion
47
+
48
+ ---
49
+
50
+ ## 0.3.1 - 2026-05-26
51
+
52
+ ### Added
53
+ - Lifecycle hooks: `Server.on_startup` and `Server.on_shutdown` register async callbacks for server startup and shutdown
54
+ - Usable as decorators or called directly with a callable argument
55
+ - Startup hooks run after the Redis connection is verified, before the consumer starts
56
+ - Shutdown hooks run after the consumer is stopped, before the Redis connection is closed
57
+ - Exceptions in hooks are logged and do not crash the server
58
+
59
+ ---
60
+
61
+ ## 0.3.0 - 2026-05-21
62
+
63
+ ### Added
64
+ - Retry mechanism for failed jobs
65
+ - `Client.enqueue` accepts `retry_count` (default: `1`) and `backoff_coefficient` (default: `1.5`)
66
+ - Both values are stored on the `Job` model and travel with the job through the queue
67
+ - On failure, the consumer re-enqueues the job with an exponential backoff delay (`backoff_coefficient ** attempt` seconds) until retries are exhausted
68
+ - Once all retries are exhausted the job is marked failed as before
69
+ - Jobs log a warning on each retry attempt and an error only when permanently failed
70
+
71
+ ---
72
+
73
+ ## 0.2.8 - 2026-05-20
74
+
75
+ ### Changed
76
+ - `Server.add_worker` now accepts a worker **instance** (`Performable`) — the same instance is reused across all jobs of that type
77
+ - `Client.enqueue` accepts a worker **class** (`type[Performable]`) — no instantiation cost at enqueue time, only the class name is needed to build the job
78
+ - Healthcheck rewritten with FastAPI + uvicorn, replacing the manual raw TCP/HTTP server
79
+ - `GET /live` — always returns 200, used as a liveness probe
80
+ - `GET /ready` — checks consumer heartbeat and Redis connectivity (`store.ping`); returns 503 with reason on failure
81
+ - Job IDs switched from `uuid4` (stdlib) to `uuid7` (`uuid_extensions`), providing time-sortable identifiers
82
+
83
+ ### Fixed
84
+ - `config.queue` was logged as a bound method object; now called correctly as `config.queue()`
85
+
86
+ ### Dependencies
87
+ - Added `fastapi>=0.136.1`
88
+ - Added `uvicorn>=0.34.0`
89
+
90
+ ---
91
+
92
+ ## 0.2.6 - 2026-05-20
93
+
94
+ ### Changed
95
+ - All Redis operations extracted into a new internal `Store` class (`kickdown/store.py`)
96
+ - Redis key names (`kickdown:queue:*`, `kickdown:stats:*`) are now centralized in `Store`
97
+ - `Consumer` and `Client` no longer interact with Redis directly — all calls go through `Store`
98
+ - `asyncio.to_thread` wrapping for blocking Redis calls moved from `Consumer` into `Store`
99
+
100
+ ---
101
+
102
+ ## 0.2.5 - 2026-05-20
103
+
104
+ ### Changed
105
+ - Workers are now registered on `Server` via `add_worker()` instead of being passed to `Config`
106
+ - `Server` lazily initializes the consumer on `start()`, decoupling construction from Redis connection
107
+ - `Consumer` receives workers directly rather than reading them from `Config`
108
+
109
+ ### Added
110
+ - `Server.uptime()` returns a human-readable duration (e.g. `"1 day, 4 hours"`) via the `humanize` library
111
+ - `Status` response includes an `uptime` field
112
+ - `Server.start()` raises `RuntimeError` if no workers have been registered
113
+
114
+ ### Fixed
115
+ - `Consumer.is_ok` converted from a method to a property, fixing incorrect truthy evaluation in `status()`
116
+
117
+ ---
118
+
119
+ ## 0.2.4 — 2026-05-19
120
+
121
+ ### Changed
122
+ - Queue names are now raw identifiers (e.g. `"default"`, `"emails"`); the full Redis key is constructed internally as `kickdown:queue:{name}`
123
+ - Stats counters moved from a Redis hash to separate keys (`kickdown:stats:processed`, `kickdown:stats:failed`), incremented with `INCR`
124
+
125
+ ---
126
+
127
+ ## 0.2.3 — 2026-05-19
128
+
129
+ ### Added
130
+ - `Client.stats()` returns a `Stats` model with cumulative `processed` and `failed` job counts
131
+ - Consumer increments `kickdown.stats.processed` / `kickdown.stats.failed` in Redis after each job completes or raises
132
+
133
+ ---
134
+
135
+ ## 0.2.2 — 2026-05-19
136
+
137
+ ### Added
138
+ - `Client.pending()` returns a list of jobs currently waiting in the queue (non-destructive)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladimir Klokov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.5
2
+ Name: kickdown
3
+ Version: 0.4.0a1
4
+ Summary: A Redis-backed background job queue for Python
5
+ Project-URL: Homepage, https://github.com/vklokov/kickdown
6
+ Project-URL: Repository, https://github.com/vklokov/kickdown
7
+ Project-URL: Issues, https://github.com/vklokov/kickdown/issues
8
+ Project-URL: Changelog, https://github.com/vklokov/kickdown/blob/main/CHANGELOG.md
9
+ Author-email: Vladimir Klokov <klokov.dev@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asyncio,background,jobs,queue,redis,tasks,worker
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: fastapi>=0.136.1
24
+ Requires-Dist: pydantic>=2.13.4
25
+ Requires-Dist: redis~=7.4
26
+ Requires-Dist: uuid7>=0.1.0
27
+ Requires-Dist: uvicorn>=0.34.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # kickdown
31
+
32
+ A Redis-backed background job queue for Python.
33
+
34
+ ## Usage
35
+
36
+ ### Defining a worker
37
+
38
+ Implement the `Performable` protocol: declare which queue a worker consumes
39
+ from and which `operation` name identifies it, plus the async `perform` method.
40
+
41
+ ```python
42
+ class SendEmailWorker:
43
+ queue = "emails"
44
+ operation = "send_email"
45
+
46
+ async def perform(self, payload: dict) -> None:
47
+ recipient = payload["to"]
48
+ # ... send email
49
+ ```
50
+
51
+ Any number of queues is supported — a worker's `queue` attribute is what
52
+ determines which Redis list it consumes from. The server automatically polls
53
+ every queue that has at least one registered worker.
54
+
55
+ ### Running the server
56
+
57
+ ```python
58
+ import asyncio
59
+ from kickdown import Server
60
+ from your_workers import SendEmailWorker, ExportReportWorker
61
+
62
+ server = Server(
63
+ redis_url="redis://localhost:6379",
64
+ concurrency=5, # optional, default: 1 - max tasks processed concurrently
65
+ )
66
+ server.add_workers(SendEmailWorker(), ExportReportWorker())
67
+
68
+ asyncio.run(server.run())
69
+ ```
70
+
71
+ `add_workers` accepts any number of `Performable` instances. Workers are
72
+ resolved by their `(queue, operation)` pair, so the same `operation` name can
73
+ be reused safely across different queues.
74
+
75
+ Queues are polled with equal frequency in round-robin order — there is
76
+ currently no notion of priority between queues.
77
+
78
+ ### Enqueueing jobs
79
+
80
+ ```python
81
+ from kickdown import Client, Task
82
+
83
+ client = Client(redis_url="redis://localhost:6379")
84
+
85
+ task = Task(
86
+ queue="emails",
87
+ operation="send_email",
88
+ params={"to": "user@example.com"},
89
+ )
90
+ jid = await client.enqueue(task)
91
+ ```
92
+
93
+ `enqueue` returns the job ID (`jid`) that can be used for tracing. It is
94
+ generated automatically (a time-sortable `uuid7`) if not set explicitly on
95
+ the `Task`.
96
+
97
+ `Client` can also be used as an async context manager, which closes the
98
+ underlying Redis connection on exit:
99
+
100
+ ```python
101
+ async with Client(redis_url="redis://localhost:6379") as client:
102
+ await client.enqueue(task)
103
+ ```
104
+
105
+ `Server` can enqueue tasks too, using the same Redis connection — handy for
106
+ a worker that needs to schedule a follow-up task, or for enqueueing from a
107
+ startup hook, without opening a separate `Client`:
108
+
109
+ ```python
110
+ jid = await server.enqueue(task)
111
+ ```
112
+
113
+ #### Retries
114
+
115
+ `retry_count` (default `1`) on `Task` sets how many times a failed task is
116
+ retried before being dropped. On failure, the consumer re-enqueues the task
117
+ with `retry_count` decremented by one and `attempt` incremented by one, after
118
+ an exponentially growing delay. Once `retry_count` reaches `0` the task is
119
+ dropped.
120
+
121
+ The delay is `1s * 1.5 ** attempt` — both the base delay and the backoff
122
+ coefficient are fixed in the library and cannot be configured per task:
123
+
124
+ | Retry | Delay |
125
+ | ----- | ----- |
126
+ | 1st | 1.0s |
127
+ | 2nd | 1.5s |
128
+ | 3rd | 2.3s |
129
+
130
+ A retried task is not held in memory while it waits: it goes into the queue's
131
+ scheduled sorted set in Redis (`kickdown:scheduled:{name}`, scored by its due
132
+ timestamp), and a scheduler loop running inside every server moves due tasks
133
+ back into the queue. So a retry survives a process restart, and the
134
+ concurrency slot is freed immediately instead of being blocked for the whole
135
+ delay.
136
+
137
+ A server only sweeps the queues it has workers for, which is also the only
138
+ place its own retries can land.
139
+
140
+ Note that a task is still lost if the process dies *while its worker is
141
+ running* — closing that window is the next step (an in-flight list per
142
+ consumer plus a reaper).
143
+
144
+ Queue names are raw identifiers (e.g. `"default"`, `"emails"`). The client
145
+ constructs the full Redis key internally as `kickdown:queue:{name}`.
146
+
147
+ ### Inspecting a queue
148
+
149
+ `client.queue(name)` returns a `Queue` handle — every per-queue operation
150
+ lives on it, so the queue name is given once instead of on every call:
151
+
152
+ ```python
153
+ emails = client.queue("emails")
154
+
155
+ # Tasks waiting to be processed (non-destructive)
156
+ tasks = await emails.pending()
157
+ count = await emails.length()
158
+
159
+ # Tasks waiting for their retry delay to elapse
160
+ retries = await emails.scheduled()
161
+
162
+ # Cumulative processed/failed counters
163
+ stats = await emails.stats()
164
+ print(stats.processed, stats.failed)
165
+ ```
166
+
167
+ Enqueueing stays on the client (`client.enqueue(task)`): a `Task` already
168
+ carries its own `queue`, and that field remains the single source of truth
169
+ for routing.
170
+
171
+ `purge()` drops every task waiting in the queue and returns how many were
172
+ dropped. It does not touch scheduled tasks or the counters, and there is no
173
+ undo:
174
+
175
+ ```python
176
+ dropped = await emails.purge()
177
+ ```
178
+
179
+ `processed` counts tasks whose worker completed successfully; `failed`
180
+ counts tasks that were permanently dropped (retries exhausted, or no
181
+ worker registered for the task's `operation`).
182
+
183
+ ### Lifecycle hooks
184
+
185
+ Register async callbacks to run on server startup and shutdown — useful for
186
+ initialising shared resources like database pools.
187
+
188
+ ```python
189
+ server = Server(redis_url=...)
190
+
191
+ @server.on_startup
192
+ async def init_db():
193
+ app.db = await asyncpg.create_pool(DATABASE_URL)
194
+
195
+ @server.on_shutdown
196
+ async def close_db():
197
+ await app.db.close()
198
+ ```
199
+
200
+ Both methods can also be called directly instead of used as decorators:
201
+
202
+ ```python
203
+ server.on_startup(init_db)
204
+ server.on_shutdown(close_db)
205
+ ```
206
+
207
+ Startup hooks run after the Redis connection is verified, before the
208
+ consumer starts. Shutdown hooks run after the consumer stops (whether by
209
+ `SIGTERM`/`SIGINT` or an unexpected error), before the Redis connection is
210
+ closed.
211
+
212
+ ### Logging
213
+
214
+ `Client` and `Server` each expose a plain `logging.Logger` as `.logger`,
215
+ pre-configured to write text to stdout — there's no custom logger
216
+ interface to implement. To use your own logger (different format, handler,
217
+ sink, etc.), just assign it:
218
+
219
+ ```python
220
+ import logging
221
+
222
+ server.logger = logging.getLogger("myapp.kickdown")
223
+ client.logger = logging.getLogger("myapp.kickdown")
224
+ ```
225
+
226
+ `Client` logs when a task is accepted. `Server` logs process start/shutdown
227
+ (with the polled queues and concurrency), and each task's start,
228
+ completion, retries and permanent failures — every task-related message
229
+ includes the task's `jid`.
230
+
231
+ ### Healthcheck
232
+
233
+ `Server` always starts a small HTTP server for liveness/readiness probes,
234
+ on the port given by `web_port` (default `3030`):
235
+
236
+ ```python
237
+ server = Server(redis_url=..., web_port=3030)
238
+ ```
239
+
240
+ ```
241
+ GET /live -> 200 {"status": "ok"} # process is up
242
+ GET /ready -> 200 {"status": "ok"} # Redis reachable
243
+ -> 503 {"status": "redis unavailable"} # Redis unreachable
244
+ ```
245
+
246
+ ### Admin page
247
+
248
+ `GET /admin` renders an HTML dashboard: a summary block with total
249
+ processed/failed counters (aggregated across all queues, from the same
250
+ counters as `client.stats()`), and a table of every polled queue with its
251
+ current pending count (how many tasks are physically waiting in it).
252
+
253
+ By default it's open to anyone who can reach the port. To require HTTP
254
+ Basic Auth, set both `admin_username` and `admin_password`:
255
+
256
+ ```python
257
+ server = Server(
258
+ redis_url=...,
259
+ admin_username="alice",
260
+ admin_password="secret",
261
+ )
262
+ ```
263
+
264
+ If either is left unset, `/admin` requires no credentials.
265
+
266
+ ## Scaling
267
+
268
+ `Server.run()` uses a single asyncio event loop with an `asyncio.Semaphore`
269
+ to cap concurrent task execution within the process — this is a good fit
270
+ since `Performable.perform` is a coroutine. To scale across CPU cores or
271
+ machines, run multiple `Server` processes against the same Redis instance;
272
+ each process independently pops from the shared queues, so Redis balances
273
+ the work between them without any extra coordination.