croniq-runner 0.1.0__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.
- croniq_runner-0.1.0/.gitignore +14 -0
- croniq_runner-0.1.0/CHANGELOG.md +27 -0
- croniq_runner-0.1.0/PKG-INFO +210 -0
- croniq_runner-0.1.0/README.md +173 -0
- croniq_runner-0.1.0/pyproject.toml +96 -0
- croniq_runner-0.1.0/src/croniq_runner/__init__.py +48 -0
- croniq_runner-0.1.0/src/croniq_runner/_client.py +119 -0
- croniq_runner-0.1.0/src/croniq_runner/_context.py +117 -0
- croniq_runner-0.1.0/src/croniq_runner/_errors.py +24 -0
- croniq_runner-0.1.0/src/croniq_runner/_identity.py +57 -0
- croniq_runner-0.1.0/src/croniq_runner/_log_writer.py +228 -0
- croniq_runner-0.1.0/src/croniq_runner/_options.py +75 -0
- croniq_runner-0.1.0/src/croniq_runner/_otel.py +41 -0
- croniq_runner-0.1.0/src/croniq_runner/_protocol.py +99 -0
- croniq_runner-0.1.0/src/croniq_runner/_runner.py +408 -0
- croniq_runner-0.1.0/src/croniq_runner/py.typed +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Python runner SDK are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
6
|
+
the package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of `croniq-runner` for Python 3.11+.
|
|
13
|
+
- Async-first runner (`Runner.run`) over `httpx.AsyncClient`.
|
|
14
|
+
- Pydantic v2 DTOs mirroring `openapi.yaml` snake_case wire format.
|
|
15
|
+
- Streaming `LogWriter` backed by a bounded `asyncio.Queue` with batching
|
|
16
|
+
(32 events / 200 ms / max 100 per POST) and drain-before-ack guarantee.
|
|
17
|
+
- Server-side cancellation via `PollResponse.cancel` honoured per-execution.
|
|
18
|
+
- Lease-renewal heartbeat at `renew_interval` while a handler is in flight.
|
|
19
|
+
- Self-registration via `POST /v1/jobs/register` for handlers declared with
|
|
20
|
+
a `schedule=` argument.
|
|
21
|
+
- Authentication: `Authorization: ApiKey <key>` (preferred) or
|
|
22
|
+
`Authorization: Bearer <token>`.
|
|
23
|
+
- Conformance binding under `tests/conformance/` driving the language-agnostic
|
|
24
|
+
YAML suite at [`sdks/conformance/cases/`](../conformance/cases) — one pytest
|
|
25
|
+
per case, runs against `pytest-httpserver`.
|
|
26
|
+
- Optional OpenTelemetry tracing via the `croniq-runner[otel]` extra; spans
|
|
27
|
+
emitted around each execution when `opentelemetry-api` is importable.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: croniq-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python runner SDK for Croniq — distributed job scheduling that just works.
|
|
5
|
+
Project-URL: Homepage, https://github.com/nuetzliches/croniq
|
|
6
|
+
Project-URL: Repository, https://github.com/nuetzliches/croniq
|
|
7
|
+
Project-URL: Issues, https://github.com/nuetzliches/croniq/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/nuetzliches/croniq/blob/main/sdks/python/README.md
|
|
9
|
+
Author: Croniq Contributors
|
|
10
|
+
License-Expression: MIT OR Apache-2.0
|
|
11
|
+
Keywords: async,cron,croniq,runner,scheduler
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: pydantic>=2.6
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-httpserver>=1.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
33
|
+
Provides-Extra: otel
|
|
34
|
+
Requires-Dist: opentelemetry-api>=1.24; extra == 'otel'
|
|
35
|
+
Requires-Dist: opentelemetry-sdk>=1.24; extra == 'otel'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# Croniq Runner SDK for Python
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/croniq-runner/)
|
|
41
|
+
[](#license)
|
|
42
|
+
|
|
43
|
+
Build job execution runners for [Croniq](https://github.com/nuetzliches/croniq) in async Python. The SDK polls a Croniq server for work, dispatches your handlers, streams structured logs back, and reports completion — idiomatic `asyncio` + `httpx` + Pydantic v2.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install croniq-runner
|
|
49
|
+
# Optional: OpenTelemetry tracing
|
|
50
|
+
pip install "croniq-runner[otel]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Python 3.11+ required (`asyncio.TaskGroup`, `tomllib`).
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from croniq_runner import Runner, RunnerOptions
|
|
60
|
+
|
|
61
|
+
async def hello(ctx):
|
|
62
|
+
ctx.logger.info("hello from %s (attempt %d)", ctx.job_key, ctx.attempt)
|
|
63
|
+
await ctx.log("emitting a structured event", fields={"customer": "acme"})
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
runner = Runner(RunnerOptions(
|
|
67
|
+
server_url="http://localhost:4000",
|
|
68
|
+
api_key="croniq_...",
|
|
69
|
+
capabilities=["billing"],
|
|
70
|
+
tags=["lang=python", "env=dev"],
|
|
71
|
+
max_inflight=5,
|
|
72
|
+
))
|
|
73
|
+
runner.add_handler("hello:world", hello)
|
|
74
|
+
await runner.run()
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Stop the runner with `Ctrl-C` or by calling `runner.request_drain()` from
|
|
80
|
+
another coroutine — in-flight handlers get up to `drain_timeout_ms` to finish
|
|
81
|
+
before the loop returns.
|
|
82
|
+
|
|
83
|
+
## Features
|
|
84
|
+
|
|
85
|
+
- **`asyncio`-first** — every public coroutine returns awaitably; no sync surface.
|
|
86
|
+
- **Pydantic v2 DTOs** mirroring `openapi.yaml` snake_case wire format.
|
|
87
|
+
- **Two-tier logging**:
|
|
88
|
+
- `ctx.logger` — standard `logging.Logger` scoped with `execution_id`, `job_key`, `runner_id`, `attempt`.
|
|
89
|
+
- `ctx.log_writer` — streaming channel backed by a bounded `asyncio.Queue` with batching (32 events / 200 ms / max 100 per POST), drained before the ack.
|
|
90
|
+
- **Server-side cancellation** — `PollResponse.cancel` is honoured via `ctx.cancellation` (an `asyncio.Event`). Handlers should `await ctx.cancellation.wait()` between checkpoints, or just use `await asyncio.sleep(...)` — the runner cancels the underlying task when the event fires.
|
|
91
|
+
- **Lease renewal** — periodic `POST /v1/work/renew` heartbeat for each in-flight execution.
|
|
92
|
+
- **Self-registration** — pass `schedule="5m"` to `add_handler` and the runner POSTs to `/v1/jobs/register` on startup.
|
|
93
|
+
- **OpenTelemetry** — opt-in via the `[otel]` extra; spans wrap each handler invocation when `opentelemetry-api` is importable. Zero dependency otherwise.
|
|
94
|
+
|
|
95
|
+
## Capabilities vs Tags
|
|
96
|
+
|
|
97
|
+
A common pitfall: **don't put implementation details into capabilities**. Capabilities drive server-side job routing (`require`/`prefer` in the Croniqfile). Tags are filter-only — they show up in the UI and operational views but don't influence routing.
|
|
98
|
+
|
|
99
|
+
| Good capability | Bad capability |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `billing`, `reporting`, `gpu`, `sandboxed` | `python`, `linux-x64`, `dotnet` |
|
|
102
|
+
|
|
103
|
+
If your runner is Python-based, that belongs in **tags** (`lang=python`, `platform=linux-x64`), not capabilities — so a future Rust- or .NET-runner with the same business capabilities can take over without rewriting Croniqfile entries.
|
|
104
|
+
|
|
105
|
+
## Handler API
|
|
106
|
+
|
|
107
|
+
A handler is any `async def fn(ctx: ExecutionContext) -> None`. The `ctx`
|
|
108
|
+
exposes:
|
|
109
|
+
|
|
110
|
+
| Attribute | Meaning |
|
|
111
|
+
|-----------|---------|
|
|
112
|
+
| `execution_id` | Server-assigned execution identifier |
|
|
113
|
+
| `job_key` | E.g. `"billing:invoice"` |
|
|
114
|
+
| `attempt` | 1-based attempt counter (incremented on retry) |
|
|
115
|
+
| `metadata` | Raw `dict` from the server (job-specific schema) |
|
|
116
|
+
| `timeout` | `datetime.timedelta` declared by the server |
|
|
117
|
+
| `runner_id`, `runner_tags` | This runner's identity |
|
|
118
|
+
| `cancellation` | `asyncio.Event` — fires on host shutdown or server-initiated cancel |
|
|
119
|
+
| `logger` | `logging.Logger` pre-scoped with execution identifiers |
|
|
120
|
+
| `log_writer` | Streaming `LogWriter` (created lazily on first access) |
|
|
121
|
+
|
|
122
|
+
Two ways to control the ack failure message:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from croniq_runner import HandlerError
|
|
126
|
+
|
|
127
|
+
async def my_handler(ctx):
|
|
128
|
+
if not data_available():
|
|
129
|
+
raise HandlerError("upstream feed unavailable") # ack.error = "upstream feed unavailable"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Any other exception's `str(exc)` is forwarded as the error message.
|
|
133
|
+
|
|
134
|
+
## Configuration
|
|
135
|
+
|
|
136
|
+
| Option | Default | Meaning |
|
|
137
|
+
|--------|---------|---------|
|
|
138
|
+
| `server_url` | `http://localhost:4000` | Croniq server base URL |
|
|
139
|
+
| `runner_id` | resolved at start | Stable runner identifier — see resolution order in `_identity.py` |
|
|
140
|
+
| `api_key` / `bearer_token` | `None` | Auth header (`ApiKey` preferred when both set) |
|
|
141
|
+
| `capabilities` | `[]` | Capabilities advertised to the server |
|
|
142
|
+
| `tags` | `[]` | Free-form `key=value` tags |
|
|
143
|
+
| `max_inflight` | `5` | Concurrent in-flight executions |
|
|
144
|
+
| `poll_timeout_ms` | `35_000` | Per-request long-poll timeout |
|
|
145
|
+
| `renew_interval_ms` | `15_000` | Lease-renewal heartbeat interval |
|
|
146
|
+
| `drain_timeout_ms` | `30_000` | Wait budget for handlers on shutdown |
|
|
147
|
+
| `poll_retry_delay_ms` | `5_000` | Back-off after a failed poll |
|
|
148
|
+
| `capacity_backoff_ms` | `500` | Idle delay at `max_inflight` |
|
|
149
|
+
| `log_writer` | `LogWriterOptions()` | Streaming-log tunables |
|
|
150
|
+
|
|
151
|
+
## Streaming logs example
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from croniq_runner import LogLevel
|
|
155
|
+
|
|
156
|
+
async def long_job(ctx):
|
|
157
|
+
async with ctx.log_writer as writer: # type: ignore[reportInvalidUsage]
|
|
158
|
+
async for line in slow_generator():
|
|
159
|
+
await writer.write(line, level=LogLevel.INFO)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
You don't actually need `async with` — the runner drains the writer before
|
|
163
|
+
the ack regardless. Calling `aclose()` yourself just lets you control *when*
|
|
164
|
+
the drain happens (e.g. before a downstream API call that should see the
|
|
165
|
+
events first).
|
|
166
|
+
|
|
167
|
+
## Conformance suite
|
|
168
|
+
|
|
169
|
+
The Python binding for the [language-agnostic conformance
|
|
170
|
+
suite](../conformance/README.md) lives under `tests/conformance/`. Every
|
|
171
|
+
case is one `pytest` parameter; run them with:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
pip install -e ".[dev]"
|
|
175
|
+
pytest tests/conformance
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The cases live at `sdks/conformance/cases/*.yaml` and are loaded by file —
|
|
179
|
+
adding a new YAML automatically adds a new test.
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
python -m venv .venv && source .venv/bin/activate
|
|
185
|
+
pip install -e ".[dev,otel]"
|
|
186
|
+
ruff check .
|
|
187
|
+
mypy
|
|
188
|
+
pytest
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Releasing
|
|
192
|
+
|
|
193
|
+
Releases run via [`.github/workflows/python-sdk-release.yml`](../../.github/workflows/python-sdk-release.yml) and upload to PyPI through Trusted Publishing (OIDC — no API token in the repo).
|
|
194
|
+
|
|
195
|
+
1. Bump `version = "X.Y.Z"` in [`pyproject.toml`](pyproject.toml).
|
|
196
|
+
2. Add a `## [X.Y.Z]` section to [`CHANGELOG.md`](CHANGELOG.md).
|
|
197
|
+
3. Commit, push to `main`.
|
|
198
|
+
4. Tag and push:
|
|
199
|
+
```sh
|
|
200
|
+
git tag python-sdk-vX.Y.Z
|
|
201
|
+
git push origin python-sdk-vX.Y.Z
|
|
202
|
+
```
|
|
203
|
+
5. The workflow builds, verifies the tag matches `pyproject.toml`, publishes to PyPI, then attaches the wheel + sdist to a GitHub Release.
|
|
204
|
+
|
|
205
|
+
One-time PyPI setup (project owner): add a Pending Publisher on [pypi.org](https://pypi.org/manage/account/publishing/) with project `croniq-runner`, owner `nuetzliches`, repository `croniq`, workflow `python-sdk-release.yml`, environment `pypi`.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
Dual-licensed under MIT or Apache-2.0, matching the rest of the Croniq
|
|
210
|
+
repository.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Croniq Runner SDK for Python
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/croniq-runner/)
|
|
4
|
+
[](#license)
|
|
5
|
+
|
|
6
|
+
Build job execution runners for [Croniq](https://github.com/nuetzliches/croniq) in async Python. The SDK polls a Croniq server for work, dispatches your handlers, streams structured logs back, and reports completion — idiomatic `asyncio` + `httpx` + Pydantic v2.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install croniq-runner
|
|
12
|
+
# Optional: OpenTelemetry tracing
|
|
13
|
+
pip install "croniq-runner[otel]"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Python 3.11+ required (`asyncio.TaskGroup`, `tomllib`).
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio
|
|
22
|
+
from croniq_runner import Runner, RunnerOptions
|
|
23
|
+
|
|
24
|
+
async def hello(ctx):
|
|
25
|
+
ctx.logger.info("hello from %s (attempt %d)", ctx.job_key, ctx.attempt)
|
|
26
|
+
await ctx.log("emitting a structured event", fields={"customer": "acme"})
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
runner = Runner(RunnerOptions(
|
|
30
|
+
server_url="http://localhost:4000",
|
|
31
|
+
api_key="croniq_...",
|
|
32
|
+
capabilities=["billing"],
|
|
33
|
+
tags=["lang=python", "env=dev"],
|
|
34
|
+
max_inflight=5,
|
|
35
|
+
))
|
|
36
|
+
runner.add_handler("hello:world", hello)
|
|
37
|
+
await runner.run()
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Stop the runner with `Ctrl-C` or by calling `runner.request_drain()` from
|
|
43
|
+
another coroutine — in-flight handlers get up to `drain_timeout_ms` to finish
|
|
44
|
+
before the loop returns.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **`asyncio`-first** — every public coroutine returns awaitably; no sync surface.
|
|
49
|
+
- **Pydantic v2 DTOs** mirroring `openapi.yaml` snake_case wire format.
|
|
50
|
+
- **Two-tier logging**:
|
|
51
|
+
- `ctx.logger` — standard `logging.Logger` scoped with `execution_id`, `job_key`, `runner_id`, `attempt`.
|
|
52
|
+
- `ctx.log_writer` — streaming channel backed by a bounded `asyncio.Queue` with batching (32 events / 200 ms / max 100 per POST), drained before the ack.
|
|
53
|
+
- **Server-side cancellation** — `PollResponse.cancel` is honoured via `ctx.cancellation` (an `asyncio.Event`). Handlers should `await ctx.cancellation.wait()` between checkpoints, or just use `await asyncio.sleep(...)` — the runner cancels the underlying task when the event fires.
|
|
54
|
+
- **Lease renewal** — periodic `POST /v1/work/renew` heartbeat for each in-flight execution.
|
|
55
|
+
- **Self-registration** — pass `schedule="5m"` to `add_handler` and the runner POSTs to `/v1/jobs/register` on startup.
|
|
56
|
+
- **OpenTelemetry** — opt-in via the `[otel]` extra; spans wrap each handler invocation when `opentelemetry-api` is importable. Zero dependency otherwise.
|
|
57
|
+
|
|
58
|
+
## Capabilities vs Tags
|
|
59
|
+
|
|
60
|
+
A common pitfall: **don't put implementation details into capabilities**. Capabilities drive server-side job routing (`require`/`prefer` in the Croniqfile). Tags are filter-only — they show up in the UI and operational views but don't influence routing.
|
|
61
|
+
|
|
62
|
+
| Good capability | Bad capability |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `billing`, `reporting`, `gpu`, `sandboxed` | `python`, `linux-x64`, `dotnet` |
|
|
65
|
+
|
|
66
|
+
If your runner is Python-based, that belongs in **tags** (`lang=python`, `platform=linux-x64`), not capabilities — so a future Rust- or .NET-runner with the same business capabilities can take over without rewriting Croniqfile entries.
|
|
67
|
+
|
|
68
|
+
## Handler API
|
|
69
|
+
|
|
70
|
+
A handler is any `async def fn(ctx: ExecutionContext) -> None`. The `ctx`
|
|
71
|
+
exposes:
|
|
72
|
+
|
|
73
|
+
| Attribute | Meaning |
|
|
74
|
+
|-----------|---------|
|
|
75
|
+
| `execution_id` | Server-assigned execution identifier |
|
|
76
|
+
| `job_key` | E.g. `"billing:invoice"` |
|
|
77
|
+
| `attempt` | 1-based attempt counter (incremented on retry) |
|
|
78
|
+
| `metadata` | Raw `dict` from the server (job-specific schema) |
|
|
79
|
+
| `timeout` | `datetime.timedelta` declared by the server |
|
|
80
|
+
| `runner_id`, `runner_tags` | This runner's identity |
|
|
81
|
+
| `cancellation` | `asyncio.Event` — fires on host shutdown or server-initiated cancel |
|
|
82
|
+
| `logger` | `logging.Logger` pre-scoped with execution identifiers |
|
|
83
|
+
| `log_writer` | Streaming `LogWriter` (created lazily on first access) |
|
|
84
|
+
|
|
85
|
+
Two ways to control the ack failure message:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from croniq_runner import HandlerError
|
|
89
|
+
|
|
90
|
+
async def my_handler(ctx):
|
|
91
|
+
if not data_available():
|
|
92
|
+
raise HandlerError("upstream feed unavailable") # ack.error = "upstream feed unavailable"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Any other exception's `str(exc)` is forwarded as the error message.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
| Option | Default | Meaning |
|
|
100
|
+
|--------|---------|---------|
|
|
101
|
+
| `server_url` | `http://localhost:4000` | Croniq server base URL |
|
|
102
|
+
| `runner_id` | resolved at start | Stable runner identifier — see resolution order in `_identity.py` |
|
|
103
|
+
| `api_key` / `bearer_token` | `None` | Auth header (`ApiKey` preferred when both set) |
|
|
104
|
+
| `capabilities` | `[]` | Capabilities advertised to the server |
|
|
105
|
+
| `tags` | `[]` | Free-form `key=value` tags |
|
|
106
|
+
| `max_inflight` | `5` | Concurrent in-flight executions |
|
|
107
|
+
| `poll_timeout_ms` | `35_000` | Per-request long-poll timeout |
|
|
108
|
+
| `renew_interval_ms` | `15_000` | Lease-renewal heartbeat interval |
|
|
109
|
+
| `drain_timeout_ms` | `30_000` | Wait budget for handlers on shutdown |
|
|
110
|
+
| `poll_retry_delay_ms` | `5_000` | Back-off after a failed poll |
|
|
111
|
+
| `capacity_backoff_ms` | `500` | Idle delay at `max_inflight` |
|
|
112
|
+
| `log_writer` | `LogWriterOptions()` | Streaming-log tunables |
|
|
113
|
+
|
|
114
|
+
## Streaming logs example
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from croniq_runner import LogLevel
|
|
118
|
+
|
|
119
|
+
async def long_job(ctx):
|
|
120
|
+
async with ctx.log_writer as writer: # type: ignore[reportInvalidUsage]
|
|
121
|
+
async for line in slow_generator():
|
|
122
|
+
await writer.write(line, level=LogLevel.INFO)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
You don't actually need `async with` — the runner drains the writer before
|
|
126
|
+
the ack regardless. Calling `aclose()` yourself just lets you control *when*
|
|
127
|
+
the drain happens (e.g. before a downstream API call that should see the
|
|
128
|
+
events first).
|
|
129
|
+
|
|
130
|
+
## Conformance suite
|
|
131
|
+
|
|
132
|
+
The Python binding for the [language-agnostic conformance
|
|
133
|
+
suite](../conformance/README.md) lives under `tests/conformance/`. Every
|
|
134
|
+
case is one `pytest` parameter; run them with:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
pip install -e ".[dev]"
|
|
138
|
+
pytest tests/conformance
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The cases live at `sdks/conformance/cases/*.yaml` and are loaded by file —
|
|
142
|
+
adding a new YAML automatically adds a new test.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
python -m venv .venv && source .venv/bin/activate
|
|
148
|
+
pip install -e ".[dev,otel]"
|
|
149
|
+
ruff check .
|
|
150
|
+
mypy
|
|
151
|
+
pytest
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Releasing
|
|
155
|
+
|
|
156
|
+
Releases run via [`.github/workflows/python-sdk-release.yml`](../../.github/workflows/python-sdk-release.yml) and upload to PyPI through Trusted Publishing (OIDC — no API token in the repo).
|
|
157
|
+
|
|
158
|
+
1. Bump `version = "X.Y.Z"` in [`pyproject.toml`](pyproject.toml).
|
|
159
|
+
2. Add a `## [X.Y.Z]` section to [`CHANGELOG.md`](CHANGELOG.md).
|
|
160
|
+
3. Commit, push to `main`.
|
|
161
|
+
4. Tag and push:
|
|
162
|
+
```sh
|
|
163
|
+
git tag python-sdk-vX.Y.Z
|
|
164
|
+
git push origin python-sdk-vX.Y.Z
|
|
165
|
+
```
|
|
166
|
+
5. The workflow builds, verifies the tag matches `pyproject.toml`, publishes to PyPI, then attaches the wheel + sdist to a GitHub Release.
|
|
167
|
+
|
|
168
|
+
One-time PyPI setup (project owner): add a Pending Publisher on [pypi.org](https://pypi.org/manage/account/publishing/) with project `croniq-runner`, owner `nuetzliches`, repository `croniq`, workflow `python-sdk-release.yml`, environment `pypi`.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
Dual-licensed under MIT or Apache-2.0, matching the rest of the Croniq
|
|
173
|
+
repository.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "croniq-runner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python runner SDK for Croniq — distributed job scheduling that just works."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT OR Apache-2.0"
|
|
12
|
+
authors = [{ name = "Croniq Contributors" }]
|
|
13
|
+
keywords = ["croniq", "cron", "scheduler", "runner", "async"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: System :: Distributed Computing",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"pydantic>=2.6",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
otel = ["opentelemetry-api>=1.24", "opentelemetry-sdk>=1.24"]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
"pytest-httpserver>=1.1",
|
|
37
|
+
"pyyaml>=6.0",
|
|
38
|
+
"ruff>=0.6",
|
|
39
|
+
"mypy>=1.10",
|
|
40
|
+
"types-PyYAML",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/nuetzliches/croniq"
|
|
45
|
+
Repository = "https://github.com/nuetzliches/croniq"
|
|
46
|
+
Issues = "https://github.com/nuetzliches/croniq/issues"
|
|
47
|
+
Documentation = "https://github.com/nuetzliches/croniq/blob/main/sdks/python/README.md"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/croniq_runner"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src/croniq_runner", "README.md", "CHANGELOG.md", "pyproject.toml"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py311"
|
|
58
|
+
src = ["src", "tests"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
# Pragmatic default: pyflakes + pycodestyle + isort + bugbear + simplify.
|
|
62
|
+
# Add rule families piecemeal — broad selects produce noise that drowns
|
|
63
|
+
# real findings on the first run.
|
|
64
|
+
select = ["E", "F", "I", "B", "SIM", "UP", "W"]
|
|
65
|
+
ignore = [
|
|
66
|
+
"E501", # line length: tracked by formatter, not linter
|
|
67
|
+
"B008", # function call in argument default (httpx clients are fine)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint.per-file-ignores]
|
|
71
|
+
"tests/**" = ["B011"] # asserts in tests are fine
|
|
72
|
+
|
|
73
|
+
[tool.mypy]
|
|
74
|
+
python_version = "3.11"
|
|
75
|
+
strict = true
|
|
76
|
+
warn_unused_ignores = true
|
|
77
|
+
warn_redundant_casts = true
|
|
78
|
+
disallow_untyped_decorators = false # pytest fixtures
|
|
79
|
+
files = ["src/croniq_runner"]
|
|
80
|
+
|
|
81
|
+
[[tool.mypy.overrides]]
|
|
82
|
+
module = ["pytest_httpserver.*", "yaml.*"]
|
|
83
|
+
ignore_missing_imports = true
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
testpaths = ["tests"]
|
|
87
|
+
asyncio_mode = "auto"
|
|
88
|
+
filterwarnings = [
|
|
89
|
+
"error",
|
|
90
|
+
# opentelemetry-api on Python 3.11 calls importlib.metadata.entry_points().values()
|
|
91
|
+
# at import time, which trips a DeprecationWarning fixed upstream in 3.12. The
|
|
92
|
+
# warning is harmless and out of our control — let it through so the OTel extra
|
|
93
|
+
# remains usable on the minimum supported Python.
|
|
94
|
+
"ignore:SelectableGroups dict interface is deprecated:DeprecationWarning",
|
|
95
|
+
]
|
|
96
|
+
addopts = "-ra --strict-markers --strict-config"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Async Python runner SDK for Croniq.
|
|
2
|
+
|
|
3
|
+
See https://github.com/nuetzliches/croniq for the server, OpenAPI spec, and
|
|
4
|
+
SDKs in other languages.
|
|
5
|
+
|
|
6
|
+
Public surface — every other symbol is an implementation detail and may move
|
|
7
|
+
without a major-version bump:
|
|
8
|
+
|
|
9
|
+
Runner — the poll/dispatch/ack loop
|
|
10
|
+
RunnerOptions — runner configuration
|
|
11
|
+
ExecutionContext— handed to each handler
|
|
12
|
+
LogLevel — string enum mirroring the server's log-level set
|
|
13
|
+
LogWriter — streaming log channel (use via `ExecutionContext.log_writer`)
|
|
14
|
+
WorkEvent — structured log event for `LogWriter.write`
|
|
15
|
+
HandlerError — handler raises this to control the failure message
|
|
16
|
+
|
|
17
|
+
Quick start::
|
|
18
|
+
|
|
19
|
+
from croniq_runner import Runner, RunnerOptions
|
|
20
|
+
|
|
21
|
+
async def hello(ctx):
|
|
22
|
+
ctx.logger.info("hello from %s", ctx.job_key)
|
|
23
|
+
|
|
24
|
+
runner = Runner(RunnerOptions(server_url="http://localhost:4000",
|
|
25
|
+
api_key="croniq_..."))
|
|
26
|
+
runner.add_handler("hello:world", hello)
|
|
27
|
+
await runner.run()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from croniq_runner._context import ExecutionContext
|
|
31
|
+
from croniq_runner._errors import HandlerError
|
|
32
|
+
from croniq_runner._log_writer import LogLevel, LogWriter
|
|
33
|
+
from croniq_runner._options import LogWriterOptions, RunnerOptions
|
|
34
|
+
from croniq_runner._protocol import WorkEvent
|
|
35
|
+
from croniq_runner._runner import Runner
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ExecutionContext",
|
|
39
|
+
"HandlerError",
|
|
40
|
+
"LogLevel",
|
|
41
|
+
"LogWriter",
|
|
42
|
+
"LogWriterOptions",
|
|
43
|
+
"Runner",
|
|
44
|
+
"RunnerOptions",
|
|
45
|
+
"WorkEvent",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""HTTP client over the Croniq Runner API.
|
|
2
|
+
|
|
3
|
+
Wraps ``httpx.AsyncClient`` with snake_case JSON encoding and auth header
|
|
4
|
+
injection. Per-request timeouts (notably for the long-poll on
|
|
5
|
+
``/v1/work/poll``) are explicit on each call so the client-level timeout can
|
|
6
|
+
stay generous.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from croniq_runner._options import RunnerOptions
|
|
19
|
+
from croniq_runner._protocol import (
|
|
20
|
+
AckRequest,
|
|
21
|
+
PollRequest,
|
|
22
|
+
PollResponse,
|
|
23
|
+
RegisterJobRequest,
|
|
24
|
+
RegisterJobResponse,
|
|
25
|
+
RenewRequest,
|
|
26
|
+
WorkEvent,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger("croniq_runner.client")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CroniqClient:
|
|
33
|
+
"""Async wire client. Owns the underlying :class:`httpx.AsyncClient`."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, options: RunnerOptions, *, http: httpx.AsyncClient | None = None) -> None:
|
|
36
|
+
self._options = options
|
|
37
|
+
# 35 s long-poll plus a small head-room; per-call timeouts override.
|
|
38
|
+
default_timeout = httpx.Timeout(
|
|
39
|
+
connect=10.0,
|
|
40
|
+
read=40.0,
|
|
41
|
+
write=10.0,
|
|
42
|
+
pool=10.0,
|
|
43
|
+
)
|
|
44
|
+
self._http = http or httpx.AsyncClient(
|
|
45
|
+
base_url=options.server_url.rstrip("/"),
|
|
46
|
+
timeout=default_timeout,
|
|
47
|
+
headers=self._auth_headers(),
|
|
48
|
+
)
|
|
49
|
+
self._owns_client = http is None
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _dump(payload: Any) -> dict[str, Any]:
|
|
53
|
+
"""Pydantic dump matching the server's snake_case + omit-None convention."""
|
|
54
|
+
return payload.model_dump(mode="json", exclude_none=True) # type: ignore[no-any-return]
|
|
55
|
+
|
|
56
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
57
|
+
if self._options.api_key:
|
|
58
|
+
return {"Authorization": f"ApiKey {self._options.api_key}"}
|
|
59
|
+
if self._options.bearer_token:
|
|
60
|
+
return {"Authorization": f"Bearer {self._options.bearer_token}"}
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> CroniqClient:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: type[BaseException] | None,
|
|
69
|
+
exc: BaseException | None,
|
|
70
|
+
tb: TracebackType | None,
|
|
71
|
+
) -> None:
|
|
72
|
+
await self.aclose()
|
|
73
|
+
|
|
74
|
+
async def aclose(self) -> None:
|
|
75
|
+
if self._owns_client:
|
|
76
|
+
await self._http.aclose()
|
|
77
|
+
|
|
78
|
+
async def poll(self, request: PollRequest, *, timeout_ms: int) -> PollResponse:
|
|
79
|
+
# Long-poll: the server may hold the connection for up to ~poll_timeout
|
|
80
|
+
# before returning an empty body. Allow a small read head-room so a
|
|
81
|
+
# graceful 200 doesn't race the client-side timeout.
|
|
82
|
+
timeout = httpx.Timeout(
|
|
83
|
+
connect=10.0,
|
|
84
|
+
read=timeout_ms / 1000.0 + 5.0,
|
|
85
|
+
write=10.0,
|
|
86
|
+
pool=10.0,
|
|
87
|
+
)
|
|
88
|
+
resp = await self._http.post("/v1/work/poll", json=self._dump(request), timeout=timeout)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
return PollResponse.model_validate(resp.json())
|
|
91
|
+
|
|
92
|
+
async def ack(self, request: AckRequest) -> None:
|
|
93
|
+
resp = await self._http.post("/v1/work/ack", json=self._dump(request))
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
|
|
96
|
+
async def renew(self, request: RenewRequest) -> None:
|
|
97
|
+
resp = await self._http.post("/v1/work/renew", json=self._dump(request))
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
|
|
100
|
+
async def push_events(self, execution_id: str, events: list[WorkEvent]) -> None:
|
|
101
|
+
if not events:
|
|
102
|
+
return
|
|
103
|
+
body = [self._dump(ev) for ev in events]
|
|
104
|
+
path = f"/v1/work/{quote(execution_id, safe='')}/events"
|
|
105
|
+
resp = await self._http.post(path, json=body)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
|
|
108
|
+
async def register_job(self, request: RegisterJobRequest) -> RegisterJobResponse | None:
|
|
109
|
+
resp = await self._http.post("/v1/jobs/register", json=self._dump(request))
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
# Some server versions return 200 with no body; treat empty as None.
|
|
112
|
+
if not resp.content:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
return RegisterJobResponse.model_validate(resp.json())
|
|
116
|
+
except ValueError:
|
|
117
|
+
# Body wasn't JSON / didn't match — non-fatal, we already got the 2xx.
|
|
118
|
+
_log.debug("register_job: unexpected response body (%s)", resp.text[:200])
|
|
119
|
+
return None
|