cdt-kit 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,25 @@
1
+ # secrets
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.pem
6
+ *.key
7
+ # python
8
+ __pycache__/
9
+ *.pyc
10
+ .venv/
11
+ venv/
12
+ # runtime
13
+ data/
14
+ logs/
15
+ *.log
16
+ *.tmp
17
+ *.bak
18
+ node_modules/
19
+ .DS_Store
20
+ .toolkit/
21
+ # build and test output
22
+ dist/
23
+ build/
24
+ *.egg-info/
25
+ .pytest_cache/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-28
4
+ - First release of the kit (claude-deploy-toolkit #422, sequenced in claude-deploy-toolkit #432 section 4): distribution
5
+ `cdt-kit`, import name `cdtkit`; framework-agnostic core plus the FastAPI adapter.
6
+ - `cdtkit.config`: `*_FILE` secrets (an empty file counts as unset), the database from `DATABASE_*` and
7
+ `DATABASE_PASSWORD_FILE`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES`.
8
+ - `cdtkit.migrations`: forward-only SQL files under an advisory lock with `schema_migrations` (compatible with
9
+ magic-minutes' table), the queue schema when missing, and the expand-only lint (`python -m cdtkit.migrations lint`).
10
+ - `cdtkit.jobs`: procrastinate with one queue per job type, `Backoff` (total attempts, exponential with jitter, capped),
11
+ `NoRow` ending a job as `skipped=no-row`, the final-failure hook; the worker process with heartbeat health; dead-worker
12
+ and stuck-job recovery; `prune_finished` after `failed_retention`.
13
+ - `cdtkit.http`: logfmt logging and health checks; `cdtkit.fastapi.health_router` for `/healthz`.
14
+ - `cdtkit.testing`: the Postgres fixtures (`cdt_postgres`, `cdt_database`) and the in-memory queue.
15
+ - The self-test stack (selftest and its testdb sidecar) and publishing to PyPI with Trusted Publishing (RUNBOOK.md).
cdt_kit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: cdt-kit
3
+ Version: 0.1.0
4
+ Summary: Server-side app kit of the Controlled Delivery Toolkit: config, migrations, jobs and HTTP basics
5
+ Project-URL: Source, https://github.com/fromerobr-ai/cdt-kit
6
+ Project-URL: Changelog, https://github.com/fromerobr-ai/cdt-kit/blob/main/CHANGELOG.md
7
+ Author: fromerobr-ai
8
+ Classifier: Framework :: FastAPI
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Database
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: psycopg<4,>=3.2
14
+ Provides-Extra: fastapi
15
+ Requires-Dist: fastapi>=0.115; extra == 'fastapi'
16
+ Provides-Extra: jobs
17
+ Requires-Dist: procrastinate<4,>=3.10; extra == 'jobs'
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
20
+ Requires-Dist: pytest>=8; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # cdt-kit
24
+
25
+ The server-side app kit of the Controlled Delivery Toolkit (CDT): a Python library every app pins for what the toolkit's
26
+ standard asks of its request path, so projects stop rebuilding it (claude-deploy-toolkit #422). Distribution `cdt-kit`
27
+ on PyPI, import name `cdtkit` (PyPI's `cdt` is another project's module).
28
+
29
+ 0.1.0 holds what the pilot, magic-minutes, needs first:
30
+
31
+ | Module | What it does |
32
+ |---|---|
33
+ | `cdtkit.config` | `*_FILE` secrets, the database from `DATABASE_*`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES` |
34
+ | `cdtkit.migrations` | forward-only SQL files at start (advisory lock, `schema_migrations`), the expand-only lint |
35
+ | `cdtkit.jobs` | procrastinate on the app's Postgres: one queue per job type, backoff with jitter, `NoRow` (`skipped=no-row`), the final-failure hook |
36
+ | `cdtkit.jobs.worker` | the worker process: one worker per queue, heartbeat health check, graceful stop |
37
+ | `cdtkit.jobs.recovery` | dead-worker and stuck-job recovery |
38
+ | `cdtkit.jobs.prune` | finished jobs removed after `failed_retention` (run from a declared schedule) |
39
+ | `cdtkit.http` | logfmt logging on stdout, the `/healthz` checks |
40
+ | `cdtkit.fastapi` | the FastAPI adapter: `health_router` |
41
+ | `cdtkit.testing` | test stand-ins: a Postgres (`cdt_postgres`, `cdt_database` fixtures) and the in-memory queue |
42
+
43
+ Identity, the account contract, legal, i18n and consent come in 0.2.0; `/metrics`, RFC 9457 problems, rate classes and
44
+ Idempotency-Key later (see #422).
45
+
46
+ ## Install and pin
47
+
48
+ ```
49
+ cdt-kit[jobs,fastapi]==0.1.0 # requirements.txt of an app; [test] for its test environment
50
+ ```
51
+
52
+ The core depends only on `psycopg` 3 (choose its C build in the app: `psycopg[binary]`); `jobs` adds procrastinate
53
+ 3.x, `fastapi` the adapter, `test` pytest and the fixtures. Python 3.11 or newer.
54
+
55
+ **Production pins only stable kit versions.** Every deployed kit candidate is published to PyPI at once, so its pilot
56
+ can test it before promotion; a version is stable once promoted (`RELEASES.md` of this repository, the GitHub Release
57
+ marked latest). An app may pin a candidate on staging; its production deployments pin a stable version. The toolkit's
58
+ rule `kit-version` (planned, claude-deploy-toolkit #423) will check it; until then it is a rule each app keeps.
59
+
60
+ Versions are semantic: a minor adds, a major breaks; anything deprecated keeps working for two minors with a warning.
61
+ At most one kit release per toolkit minor.
62
+
63
+ ## Use
64
+
65
+ Configuration, read once at start:
66
+
67
+ ```python
68
+ from cdtkit import config
69
+
70
+ db = config.database() # DATABASE_HOST/_PORT/_NAME/_USER + DATABASE_PASSWORD_FILE
71
+ conninfo = db.conninfo("web") # libpq string; repr(db) never shows the password
72
+ key = config.read_secret("KEYS_ENCRYPTION_KEY") # KEYS_ENCRYPTION_KEY_FILE, else the variable (local runs)
73
+ optional = config.read_secret("SMTP_PASSWORD", required=False) # "" when unset or an empty file
74
+ env_class, language = config.env_class(), config.language() # "staging"/"production", CDT_LANGUAGE
75
+ job_types = config.job_types(service="worker") # this worker's TOOLKIT_JOB_TYPES entry: {id: params}
76
+ ```
77
+
78
+ Missing or invalid configuration raises `config.ConfigError` (a `SystemExit`: the process stops with one line).
79
+
80
+ Migrations at start, in every process (web and workers may start together; one migrates, the others wait):
81
+
82
+ ```python
83
+ from cdtkit import migrations
84
+ migrations.migrate(conninfo, "app/migrations", queue_app=queue_app) # *.sql in name order, each once
85
+ ```
86
+
87
+ Each file runs in its own transaction and is recorded in `schema_migrations (name, applied_at)`; with a procrastinate
88
+ app, its schema is applied when missing. The lint reports contracting statements (drops, renames, type changes, new
89
+ `NOT NULL`, `UNIQUE` or `CHECK` on existing data):
90
+
91
+ ```
92
+ python -m cdtkit.migrations lint app/migrations [--after 0007_accounts_ui.sql] # exit 1 on findings
93
+ ```
94
+
95
+ A contract belongs in a cleanup release that declares it in `deploy/package.json` `"contracts"`; mark its line
96
+ `-- cdt: contract <reason>` and the lint accepts it.
97
+
98
+ Jobs (one queue per job type; parameters from `deploy/workers.json` through `TOOLKIT_JOB_TYPES`):
99
+
100
+ ```python
101
+ from cdtkit import jobs
102
+ from cdtkit.jobs import worker
103
+
104
+ DEFAULTS = {"convert": {"concurrency": 1, "max_attempts": 3, "backoff": "60s", "timeout_item": "3h"}}
105
+
106
+ async def convert(meeting_id):
107
+ meeting = load(meeting_id)
108
+ if meeting is None:
109
+ raise jobs.NoRow("meeting gone") # deleted while queued: the job ends, logged skipped=no-row, no retry
110
+ ...
111
+
112
+ app = jobs.make_app(conninfo, job_types, {"convert": convert}, DEFAULTS, pool_max=2, on_final_failure=mark_failed)
113
+ await jobs.defer_async(app, "convert", meeting_id=7) # web processes defer; payloads carry IDs only
114
+ await worker.run(app, ["convert"], job_types, DEFAULTS, ping=db_ping, on_given_up=mark_failed,
115
+ never_give_up=("account-delete",))
116
+ ```
117
+
118
+ - `max_attempts` counts runs in total (at most 25); the wait after the n-th failure is `backoff * 2^(n-1)`, capped at 6
119
+ hours, shortened by up to half at random (jitter).
120
+ - `on_final_failure(job_id, payload)` runs when the last attempt raises; `on_given_up` when the recovery gives a job up
121
+ (its worker died with no attempt left, or it ran past `timeout_item` plus 5 minutes). Both mark what the user sees.
122
+ - The worker's container health check: `python -m cdtkit.jobs.worker --health` (exit 0 while the heartbeat in
123
+ `/tmp/worker-heartbeat` is younger than 80 seconds; the heartbeat runs `ping` first).
124
+ - Finished jobs: successful ones are deleted at once; failed, cancelled and aborted ones after the entry's
125
+ `failed_retention` by `python -m cdtkit.jobs.prune --service worker`, which the app declares as a schedule
126
+ (`deploy/schedules.json`), never a loop in its code.
127
+
128
+ HTTP basics:
129
+
130
+ ```python
131
+ from cdtkit.http import Health
132
+ from cdtkit.http.logging import setup
133
+ from cdtkit.fastapi import health_router
134
+
135
+ setup() # logfmt on stdout, one line per event
136
+ app.include_router(health_router(Health(database=db.ping))) # GET /healthz: 200 "ok" or 503 "unavailable: database"
137
+ ```
138
+
139
+ Tests: with `cdt-kit[test]` installed the fixtures are there without imports:
140
+
141
+ ```python
142
+ def test_store(cdt_database): # a fresh empty database (a cdtkit.config.Database)
143
+ migrations.migrate(cdt_database.conninfo(), "app/migrations")
144
+ ```
145
+
146
+ They use the server named by `CDT_TEST_DATABASE_HOST` (`_PORT`, `_USER`, `_PASSWORD`, `_NAME`), else start a
147
+ throwaway cluster with the local `initdb`/`pg_ctl` (not as root), else skip. `cdtkit.testing.queue.memory_app`,
148
+ `drain` and `statuses` run job handlers on procrastinate's in-memory connector.
149
+
150
+ ## Layout
151
+
152
+ ```
153
+ src/cdtkit/ the library (docs/PROJECT_NOTES.md has the module map)
154
+ tests/ the test suite (pytest; Postgres tests use the fixtures above)
155
+ selftest/ the self-test stack's run script and the testdb sidecar's script
156
+ compose.yaml the self-test stack (below); Dockerfile its image
157
+ requirements-selftest.txt pinned dependencies of the self-test image (arm64 wheels checked)
158
+ .github/workflows/publish.yml publishing to PyPI
159
+ ```
160
+
161
+ ## The self-test stack
162
+
163
+ The toolkit deploys Compose stacks, and a library has no service, so cdt-kit's stack proves the kit on each server that
164
+ deploys it: `selftest` builds the wheel from the tree, installs it with the pinned dependencies and runs the test suite
165
+ once per build against `testdb`, a throwaway Postgres 18 on an internal network that stops (exit 0) after the run.
166
+ `selftest` then idles, healthy only when the tests passed, so a failing suite fails the deploy. The result is kept in
167
+ `data/selftest/` per build: a restart does not rerun the tests. It is a workaround until the toolkit offers a
168
+ check-only kind (claude-project-common#176); its two conformance exceptions point there.
169
+
170
+ Run it by hand on the primary: `cd /opt/stacks/cdt-kit && docker compose logs selftest`.
171
+
172
+ ## Publishing
173
+
174
+ `.github/workflows/publish.yml` publishes the wheel and the sdist to PyPI when the servers create the version's GitHub
175
+ Release, that is when the candidate is deployed on the primary; PyPI Trusted Publishing, no stored token. The one-time
176
+ setup on pypi.org is in `RUNBOOK.md`. The workflow's result shows on the release issue. A version that failed to
177
+ publish is published again with the workflow's "Run workflow" button and its tag.
178
+
179
+ Running the tests locally: `pip install -e .[jobs,fastapi,test]` then `pytest` (Postgres binaries on PATH or
180
+ `CDT_TEST_DATABASE_HOST`).
@@ -0,0 +1,158 @@
1
+ # cdt-kit
2
+
3
+ The server-side app kit of the Controlled Delivery Toolkit (CDT): a Python library every app pins for what the toolkit's
4
+ standard asks of its request path, so projects stop rebuilding it (claude-deploy-toolkit #422). Distribution `cdt-kit`
5
+ on PyPI, import name `cdtkit` (PyPI's `cdt` is another project's module).
6
+
7
+ 0.1.0 holds what the pilot, magic-minutes, needs first:
8
+
9
+ | Module | What it does |
10
+ |---|---|
11
+ | `cdtkit.config` | `*_FILE` secrets, the database from `DATABASE_*`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES` |
12
+ | `cdtkit.migrations` | forward-only SQL files at start (advisory lock, `schema_migrations`), the expand-only lint |
13
+ | `cdtkit.jobs` | procrastinate on the app's Postgres: one queue per job type, backoff with jitter, `NoRow` (`skipped=no-row`), the final-failure hook |
14
+ | `cdtkit.jobs.worker` | the worker process: one worker per queue, heartbeat health check, graceful stop |
15
+ | `cdtkit.jobs.recovery` | dead-worker and stuck-job recovery |
16
+ | `cdtkit.jobs.prune` | finished jobs removed after `failed_retention` (run from a declared schedule) |
17
+ | `cdtkit.http` | logfmt logging on stdout, the `/healthz` checks |
18
+ | `cdtkit.fastapi` | the FastAPI adapter: `health_router` |
19
+ | `cdtkit.testing` | test stand-ins: a Postgres (`cdt_postgres`, `cdt_database` fixtures) and the in-memory queue |
20
+
21
+ Identity, the account contract, legal, i18n and consent come in 0.2.0; `/metrics`, RFC 9457 problems, rate classes and
22
+ Idempotency-Key later (see #422).
23
+
24
+ ## Install and pin
25
+
26
+ ```
27
+ cdt-kit[jobs,fastapi]==0.1.0 # requirements.txt of an app; [test] for its test environment
28
+ ```
29
+
30
+ The core depends only on `psycopg` 3 (choose its C build in the app: `psycopg[binary]`); `jobs` adds procrastinate
31
+ 3.x, `fastapi` the adapter, `test` pytest and the fixtures. Python 3.11 or newer.
32
+
33
+ **Production pins only stable kit versions.** Every deployed kit candidate is published to PyPI at once, so its pilot
34
+ can test it before promotion; a version is stable once promoted (`RELEASES.md` of this repository, the GitHub Release
35
+ marked latest). An app may pin a candidate on staging; its production deployments pin a stable version. The toolkit's
36
+ rule `kit-version` (planned, claude-deploy-toolkit #423) will check it; until then it is a rule each app keeps.
37
+
38
+ Versions are semantic: a minor adds, a major breaks; anything deprecated keeps working for two minors with a warning.
39
+ At most one kit release per toolkit minor.
40
+
41
+ ## Use
42
+
43
+ Configuration, read once at start:
44
+
45
+ ```python
46
+ from cdtkit import config
47
+
48
+ db = config.database() # DATABASE_HOST/_PORT/_NAME/_USER + DATABASE_PASSWORD_FILE
49
+ conninfo = db.conninfo("web") # libpq string; repr(db) never shows the password
50
+ key = config.read_secret("KEYS_ENCRYPTION_KEY") # KEYS_ENCRYPTION_KEY_FILE, else the variable (local runs)
51
+ optional = config.read_secret("SMTP_PASSWORD", required=False) # "" when unset or an empty file
52
+ env_class, language = config.env_class(), config.language() # "staging"/"production", CDT_LANGUAGE
53
+ job_types = config.job_types(service="worker") # this worker's TOOLKIT_JOB_TYPES entry: {id: params}
54
+ ```
55
+
56
+ Missing or invalid configuration raises `config.ConfigError` (a `SystemExit`: the process stops with one line).
57
+
58
+ Migrations at start, in every process (web and workers may start together; one migrates, the others wait):
59
+
60
+ ```python
61
+ from cdtkit import migrations
62
+ migrations.migrate(conninfo, "app/migrations", queue_app=queue_app) # *.sql in name order, each once
63
+ ```
64
+
65
+ Each file runs in its own transaction and is recorded in `schema_migrations (name, applied_at)`; with a procrastinate
66
+ app, its schema is applied when missing. The lint reports contracting statements (drops, renames, type changes, new
67
+ `NOT NULL`, `UNIQUE` or `CHECK` on existing data):
68
+
69
+ ```
70
+ python -m cdtkit.migrations lint app/migrations [--after 0007_accounts_ui.sql] # exit 1 on findings
71
+ ```
72
+
73
+ A contract belongs in a cleanup release that declares it in `deploy/package.json` `"contracts"`; mark its line
74
+ `-- cdt: contract <reason>` and the lint accepts it.
75
+
76
+ Jobs (one queue per job type; parameters from `deploy/workers.json` through `TOOLKIT_JOB_TYPES`):
77
+
78
+ ```python
79
+ from cdtkit import jobs
80
+ from cdtkit.jobs import worker
81
+
82
+ DEFAULTS = {"convert": {"concurrency": 1, "max_attempts": 3, "backoff": "60s", "timeout_item": "3h"}}
83
+
84
+ async def convert(meeting_id):
85
+ meeting = load(meeting_id)
86
+ if meeting is None:
87
+ raise jobs.NoRow("meeting gone") # deleted while queued: the job ends, logged skipped=no-row, no retry
88
+ ...
89
+
90
+ app = jobs.make_app(conninfo, job_types, {"convert": convert}, DEFAULTS, pool_max=2, on_final_failure=mark_failed)
91
+ await jobs.defer_async(app, "convert", meeting_id=7) # web processes defer; payloads carry IDs only
92
+ await worker.run(app, ["convert"], job_types, DEFAULTS, ping=db_ping, on_given_up=mark_failed,
93
+ never_give_up=("account-delete",))
94
+ ```
95
+
96
+ - `max_attempts` counts runs in total (at most 25); the wait after the n-th failure is `backoff * 2^(n-1)`, capped at 6
97
+ hours, shortened by up to half at random (jitter).
98
+ - `on_final_failure(job_id, payload)` runs when the last attempt raises; `on_given_up` when the recovery gives a job up
99
+ (its worker died with no attempt left, or it ran past `timeout_item` plus 5 minutes). Both mark what the user sees.
100
+ - The worker's container health check: `python -m cdtkit.jobs.worker --health` (exit 0 while the heartbeat in
101
+ `/tmp/worker-heartbeat` is younger than 80 seconds; the heartbeat runs `ping` first).
102
+ - Finished jobs: successful ones are deleted at once; failed, cancelled and aborted ones after the entry's
103
+ `failed_retention` by `python -m cdtkit.jobs.prune --service worker`, which the app declares as a schedule
104
+ (`deploy/schedules.json`), never a loop in its code.
105
+
106
+ HTTP basics:
107
+
108
+ ```python
109
+ from cdtkit.http import Health
110
+ from cdtkit.http.logging import setup
111
+ from cdtkit.fastapi import health_router
112
+
113
+ setup() # logfmt on stdout, one line per event
114
+ app.include_router(health_router(Health(database=db.ping))) # GET /healthz: 200 "ok" or 503 "unavailable: database"
115
+ ```
116
+
117
+ Tests: with `cdt-kit[test]` installed the fixtures are there without imports:
118
+
119
+ ```python
120
+ def test_store(cdt_database): # a fresh empty database (a cdtkit.config.Database)
121
+ migrations.migrate(cdt_database.conninfo(), "app/migrations")
122
+ ```
123
+
124
+ They use the server named by `CDT_TEST_DATABASE_HOST` (`_PORT`, `_USER`, `_PASSWORD`, `_NAME`), else start a
125
+ throwaway cluster with the local `initdb`/`pg_ctl` (not as root), else skip. `cdtkit.testing.queue.memory_app`,
126
+ `drain` and `statuses` run job handlers on procrastinate's in-memory connector.
127
+
128
+ ## Layout
129
+
130
+ ```
131
+ src/cdtkit/ the library (docs/PROJECT_NOTES.md has the module map)
132
+ tests/ the test suite (pytest; Postgres tests use the fixtures above)
133
+ selftest/ the self-test stack's run script and the testdb sidecar's script
134
+ compose.yaml the self-test stack (below); Dockerfile its image
135
+ requirements-selftest.txt pinned dependencies of the self-test image (arm64 wheels checked)
136
+ .github/workflows/publish.yml publishing to PyPI
137
+ ```
138
+
139
+ ## The self-test stack
140
+
141
+ The toolkit deploys Compose stacks, and a library has no service, so cdt-kit's stack proves the kit on each server that
142
+ deploys it: `selftest` builds the wheel from the tree, installs it with the pinned dependencies and runs the test suite
143
+ once per build against `testdb`, a throwaway Postgres 18 on an internal network that stops (exit 0) after the run.
144
+ `selftest` then idles, healthy only when the tests passed, so a failing suite fails the deploy. The result is kept in
145
+ `data/selftest/` per build: a restart does not rerun the tests. It is a workaround until the toolkit offers a
146
+ check-only kind (claude-project-common#176); its two conformance exceptions point there.
147
+
148
+ Run it by hand on the primary: `cd /opt/stacks/cdt-kit && docker compose logs selftest`.
149
+
150
+ ## Publishing
151
+
152
+ `.github/workflows/publish.yml` publishes the wheel and the sdist to PyPI when the servers create the version's GitHub
153
+ Release, that is when the candidate is deployed on the primary; PyPI Trusted Publishing, no stored token. The one-time
154
+ setup on pypi.org is in `RUNBOOK.md`. The workflow's result shows on the release issue. A version that failed to
155
+ publish is published again with the workflow's "Run workflow" button and its tag.
156
+
157
+ Running the tests locally: `pip install -e .[jobs,fastapi,test]` then `pytest` (Postgres binaries on PATH or
158
+ `CDT_TEST_DATABASE_HOST`).
cdt_kit-0.1.0/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cdt-kit"
7
+ dynamic = ["version"]
8
+ description = "Server-side app kit of the Controlled Delivery Toolkit: config, migrations, jobs and HTTP basics"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "fromerobr-ai" }]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Framework :: FastAPI",
15
+ "Topic :: Database",
16
+ "Operating System :: POSIX :: Linux",
17
+ ]
18
+ # The core needs only psycopg 3; the driver's C build is the app's choice (psycopg[binary] or [c]).
19
+ dependencies = ["psycopg>=3.2,<4"]
20
+
21
+ [project.optional-dependencies]
22
+ jobs = ["procrastinate>=3.10,<4"]
23
+ fastapi = ["fastapi>=0.115"]
24
+ test = ["pytest>=8", "pytest-asyncio>=0.24"]
25
+
26
+ [project.urls]
27
+ Source = "https://github.com/fromerobr-ai/cdt-kit"
28
+ Changelog = "https://github.com/fromerobr-ai/cdt-kit/blob/main/CHANGELOG.md"
29
+
30
+ [project.entry-points.pytest11]
31
+ cdtkit = "cdtkit.testing.plugin"
32
+
33
+ [tool.hatch.version]
34
+ path = "src/cdtkit/__init__.py"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/cdtkit"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src/cdtkit", "tests", "README.md", "CHANGELOG.md", "VERSION"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ asyncio_mode = "auto"
45
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,7 @@
1
+ """cdt-kit: the server-side app kit of the Controlled Delivery Toolkit (CDT).
2
+
3
+ Modules (0.1.0): `cdtkit.config` (environment and `*_FILE` secrets), `cdtkit.migrations` (forward-only SQL files),
4
+ `cdtkit.jobs` (procrastinate on the app's Postgres), `cdtkit.http` (logfmt logging and the health check) and the
5
+ FastAPI adapter `cdtkit.fastapi`; `cdtkit.testing` holds the test stand-ins. The core imports no web framework.
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,118 @@
1
+ """Configuration the toolkit gives every app: read once at start from the environment and the secret files.
2
+
3
+ Values come from `config/defaults.env`, `.toolkit/vars.env` (rendered by the toolkit) and `.env`, through Compose's
4
+ `env_file`; every secret from the file named by `<NAME>_FILE` (the toolkit mounts `.toolkit/secrets/<NAME>`). The
5
+ database is given in parts (`DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, the password in
6
+ `DATABASE_PASSWORD_FILE`), never as a URL (standard, chapter "database").
7
+ """
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ ENV_CLASSES = ("staging", "production")
13
+
14
+
15
+ class ConfigError(SystemExit):
16
+ """A configuration the app cannot start with: the process stops with this message (no traceback)."""
17
+
18
+
19
+ def _env(env):
20
+ return os.environ if env is None else env
21
+
22
+
23
+ def read_secret(name, env=None, required=True):
24
+ """The value of secret NAME: the file named by NAME_FILE (toolkit secrets), else NAME itself (local runs, tests).
25
+
26
+ Surrounding whitespace is removed. An empty value counts as unset: an optional secret with no value on a server
27
+ arrives as an empty file (toolkit 2.111.0). Returns "" for an unset optional secret; raises ConfigError for an
28
+ unset required one or an unreadable file.
29
+ """
30
+ env = _env(env)
31
+ path = env.get(name + "_FILE")
32
+ if path:
33
+ try:
34
+ with open(path, encoding="utf-8") as f:
35
+ value = f.read().strip()
36
+ except OSError as e:
37
+ raise ConfigError("%s: cannot read %s (%s)" % (name, path, e.strerror))
38
+ else:
39
+ value = (env.get(name) or "").strip()
40
+ if not value and required:
41
+ raise ConfigError("%s: missing or empty (written by the toolkit into .toolkit/secrets/)" % name)
42
+ return value
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Database:
47
+ host: str
48
+ port: int
49
+ name: str
50
+ user: str
51
+ password: str
52
+
53
+ def conninfo(self, application_name=""):
54
+ """A libpq connection string; values are quoted, so a password with spaces or quotes stays one value."""
55
+ parts = [("host", self.host), ("port", str(self.port)), ("dbname", self.name), ("user", self.user),
56
+ ("password", self.password)]
57
+ if application_name:
58
+ parts.append(("application_name", application_name))
59
+ return " ".join("%s=%s" % (k, _quote(v)) for k, v in parts)
60
+
61
+ def __repr__(self): # never the password in a log line or a traceback
62
+ return "Database(host=%r, port=%d, name=%r, user=%r)" % (self.host, self.port, self.name, self.user)
63
+
64
+
65
+ def _quote(value):
66
+ if value and not any(c in value for c in " '\\"):
67
+ return value
68
+ return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
69
+
70
+
71
+ def database(env=None):
72
+ """The app's database from DATABASE_* and the password file; ConfigError names every missing variable."""
73
+ env = _env(env)
74
+ missing = [n for n in ("DATABASE_HOST", "DATABASE_NAME", "DATABASE_USER") if not (env.get(n) or "").strip()]
75
+ if missing:
76
+ raise ConfigError("missing configuration: %s" % ", ".join(missing))
77
+ raw_port = (env.get("DATABASE_PORT") or "").strip() or "5432"
78
+ if not raw_port.isdigit():
79
+ raise ConfigError("DATABASE_PORT must be a port number")
80
+ return Database(env["DATABASE_HOST"].strip(), int(raw_port), env["DATABASE_NAME"].strip(),
81
+ env["DATABASE_USER"].strip(), read_secret("DATABASE_PASSWORD", env))
82
+
83
+
84
+ def env_class(env=None, default="staging"):
85
+ """ENV_CLASS: "staging" or "production" (the toolkit writes it); `default` when unset (local runs, tests)."""
86
+ value = (_env(env).get("ENV_CLASS") or "").strip() or default
87
+ if value not in ENV_CLASSES and value != default:
88
+ raise ConfigError("ENV_CLASS must be one of %s" % ", ".join(ENV_CLASSES))
89
+ return value
90
+
91
+
92
+ def is_production(env=None):
93
+ return env_class(env) == "production"
94
+
95
+
96
+ def language(env=None):
97
+ """CDT_LANGUAGE, the deployment's default language (a BCP 47 tag); "" means the app's own default."""
98
+ return (_env(env).get("CDT_LANGUAGE") or "").strip()
99
+
100
+
101
+ def worker_entry(env=None, service="worker"):
102
+ """This worker service's entry of TOOLKIT_JOB_TYPES (deploy/workers.json, rendered by the toolkit):
103
+ {"queue", "failed_retention", "job_types": {id: params}}; {} when the variable is unset or names no such service."""
104
+ raw = (_env(env).get("TOOLKIT_JOB_TYPES") or "").strip()
105
+ if not raw:
106
+ return {}
107
+ try:
108
+ entry = json.loads(raw).get(service) or {}
109
+ except (ValueError, AttributeError):
110
+ raise ConfigError("TOOLKIT_JOB_TYPES is not valid JSON")
111
+ if not isinstance(entry, dict) or not isinstance(entry.get("job_types", {}), (dict, type(None))):
112
+ raise ConfigError("TOOLKIT_JOB_TYPES: the entry of %s is not an object" % service)
113
+ return entry
114
+
115
+
116
+ def job_types(env=None, service="worker"):
117
+ """The worker service's job types from TOOLKIT_JOB_TYPES: {id: params}; {} when unset."""
118
+ return worker_entry(env, service).get("job_types", {}) or {}
@@ -0,0 +1,35 @@
1
+ """The FastAPI adapter (the starter's framework): the health route of `cdtkit.http`.
2
+
3
+ from cdtkit.fastapi import health_router
4
+ app.include_router(health_router(Health(database=db.ping)))
5
+
6
+ GET /healthz answers 200 "ok" or 503 "unavailable: <checks>", plain text, never cached, outside the OpenAPI schema.
7
+ Sync checks run in the thread pool, so a slow database never blocks the event loop.
8
+ """
9
+ from fastapi import APIRouter
10
+ from fastapi.concurrency import run_in_threadpool
11
+ from fastapi.responses import PlainTextResponse
12
+ import inspect
13
+
14
+ from cdtkit.http import HEALTH_PATH, Health
15
+
16
+ NO_STORE = {"Cache-Control": "no-store"}
17
+
18
+
19
+ def _threaded(check):
20
+ if inspect.iscoroutinefunction(check):
21
+ return check
22
+ return lambda: run_in_threadpool(check)
23
+
24
+
25
+ def health_router(health=None, path=HEALTH_PATH):
26
+ """An APIRouter with the health route; `health` is a `cdtkit.http.Health` (none: always "ok")."""
27
+ checks = Health(**{name: _threaded(c) for name, c in (health.checks if health else {}).items()})
28
+ router = APIRouter()
29
+
30
+ @router.get(path, include_in_schema=False)
31
+ async def healthz():
32
+ ok, body = await checks.run()
33
+ return PlainTextResponse(body, status_code=200 if ok else 503, headers=NO_STORE)
34
+
35
+ return router
@@ -0,0 +1,39 @@
1
+ """The HTTP rules every app shares, framework-agnostic; `cdtkit.fastapi` adapts them to FastAPI.
2
+
3
+ 0.1.0: logfmt logging (`cdtkit.http.logging`) and the health check (`Health`, answered on `/healthz`, the path
4
+ deploy/web.json's `"health"` names). Later releases add `/metrics`, RFC 9457 problem details, rate-limit classes and the
5
+ Idempotency-Key store (claude-deploy-toolkit #422).
6
+ """
7
+ import inspect
8
+ import logging
9
+
10
+ log = logging.getLogger("cdtkit.http")
11
+ HEALTH_PATH = "/healthz"
12
+
13
+
14
+ class Health:
15
+ """Named checks (sync or async callables; a check passes when it returns without raising), e.g. the database's
16
+ `SELECT 1`. With no checks the process answers "ok" as soon as it serves requests."""
17
+
18
+ def __init__(self, **checks):
19
+ self.checks = dict(checks)
20
+
21
+ def add(self, name, check):
22
+ self.checks[name] = check
23
+ return self
24
+
25
+ async def run(self):
26
+ """(ok, body): body is "ok\\n", or "unavailable: <names>\\n" naming the failed checks (never their errors,
27
+ which can hold internals; those go to the log)."""
28
+ failed = []
29
+ for name, check in self.checks.items():
30
+ try:
31
+ result = check()
32
+ if inspect.isawaitable(result):
33
+ await result
34
+ except Exception as e:
35
+ failed.append(name)
36
+ log.warning("health check failed check=%s: %s", name, type(e).__name__)
37
+ if failed:
38
+ return False, "unavailable: %s\n" % ",".join(failed)
39
+ return True, "ok\n"