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.
@@ -0,0 +1,14 @@
1
+ # Build / distribution
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+
6
+ # Caches
7
+ .venv/
8
+ __pycache__/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+
13
+ # Runtime artefacts
14
+ *.pyc
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/croniq-runner.svg)](https://pypi.org/project/croniq-runner/)
41
+ [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#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
+ [![PyPI](https://img.shields.io/pypi/v/croniq-runner.svg)](https://pypi.org/project/croniq-runner/)
4
+ [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#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