Runtime harness, contract tooling, and CI lint for Continuo python nodes.
This repo is what domain data teams (marketing, finance, …) template from to
ship a python node into Continuo: write a contract + a run(ctx) script,
push to main, and CI does the rest — lint, validate, merge, build, publish,
and register the release with Continuo.
Five artifacts come out of this repository:
- The
continuo-python-runtimePyPI package — thecontinuo-runtimeCLI (validate/merge/hash/lint/run/validation-op) and the harness library (conform(),RunContext, the error taxonomy) that domain repos install. - The
continuo-engine-contractPyPI package — theWarehouseAdapterport, the contract schema, the shared SQL/type/config guards, and the sentinel result-block format. Adapter authors outside this repo pin it. - The two engine-adapter PyPI packages (
continuo-postgres-adapter,continuo-trino-adapter) — oneWarehouseAdapterimplementation per warehouse engine, each published independently under the same tag. A domain repo normally never installs these directly (the engine image already has the matching one baked in); they exist as standalone PyPI packages for the "build your own container" shape (see below) and for third-party adapter authors to reference. - Per-engine base images, one per warehouse engine
(
continuo-python-runtime-postgres,continuo-python-runtime-trino), that domain repos buildFROM. Each image bakes in the runtime and a singleWarehouseAdapterfor that engine, and serves both roles that adapter has: the node harness (ENTRYPOINT ["continuo-runtime"],CMD ["run"]) and the validation runner (continuo-runtime validation-op). template/— a copy-ready domain repo:Dockerfile,contracts/,scripts/, and therelease.ymlCI/CD workflow.
One vX.Y.Z git tag releases all of it: publish-pypi.yml builds all four
PyPI distributions into a single dist/ and publishes them together, and
images.yml builds and pushes both engine images — each installing its
matching pinned adapter version from that same release — multi-arch under
the same tag.
This repository owns the entire python-node surface: the engine contract, both
engine adapters, the validation runner, and the node harness. The former
continuo-validation repository was merged in — there is no longer a separate
validation-side port, adapter class, entry-point group, or image. One
WarehouseAdapter per engine serves both the data plane (fetch /
ensure_table / load) and validation (ensure_schema / drop_schema /
build_empty_from_sql / build_empty_from_columns / clone_empty_from_prod /
check_binds), and one image per engine runs both roles.
| Package (distribution name) | Module | Lives in | Role |
|---|---|---|---|
continuo-python-runtime |
continuo_python_runtime |
this repo (root) | Harness (CLI, conform(), RunContext, error taxonomy) and the validation runner (continuo-runtime validation-op). Published to PyPI. |
continuo-engine-contract |
continuo_engine_contract |
this repo, contract/ |
The WarehouseAdapter port, contract schema, the SQL/type/config guards adapters must run, and the result-block format. Published to PyPI. |
continuo-postgres-adapter |
continuo_postgres_adapter |
this repo, adapters/postgres/ |
PostgresAdapter — one class, both roles. Published to PyPI. |
continuo-trino-adapter |
continuo_trino_adapter |
this repo, adapters/trino/ |
TrinoAdapter — one class, both roles, for Trino/Iceberg. Published to PyPI. |
All four are uv workspace members ([tool.uv.workspace] in the root
pyproject.toml), so uv sync --all-packages --all-groups at the repo root
installs everything for local development.
All four packages in the table above are published to PyPI, under the
same vX.Y.Z tag. The two engine images then install the matching pinned
adapter version from PyPI — Dockerfile.postgres installs
continuo-postgres-adapter==X.Y.Z, Dockerfile.trino installs
continuo-trino-adapter==X.Y.Z — rather than building it from this repo's
source tree, so each image still ships exactly one adapter and the runtime
still discovers it through the continuo_engine.adapters entry-point group
at run time. The image name (continuo-python-runtime-<engine>) and the
adapter's pip distribution name (continuo-<engine>-adapter) are two
different artifacts of the same adapter — same engine, same version, same
runtime behavior, different packaging; see "Build your own container" below
for a build shape that installs the pip package directly instead of FROM
the image. All four packages are still built, type-checked, and tested by CI
on every change.
continuo_engine_contract.result writes a sentinel-framed JSON block as the
last line of stdout, and Continuo's Go side parses it byte-for-byte
(pkg/validationresult in the continuo repository). That wire format — the
sentinel markers, the framing, and the field names inside the block — is
frozen. Reuse it; never change it from this side alone. A change here that
the Go parser has not been taught is a production outage, not a refactor.
- Copy
template/into a new repository. - Edit
template/.github/workflows/release.ymland setSERVICEto your service name (one service name per domain repo). - Configure repository variables in GitHub (Settings → Secrets and
variables → Actions):
REGISTRY(your Docker registry),BUCKET(your S3 bucket for contract artifacts),RELEASE_ENDPOINT(the release webhook endpoint).RELEASE_ENDPOINTis the base URL of the Continuo API (no/releasessuffix) — the workflow appends/releasesitself. - Configure repository secrets:
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYfor the S3 upload. The template workflow pushes the built image to GHCR using the workflow's ownGITHUB_TOKEN(grantedpackages: write) — no registry secret is needed for that. If you pointREGISTRYat a different or private registry, add your owndocker loginstep torelease.yml. - Write a contract file under
contracts/(seetemplate/contracts/example.yml) and a script underscripts/that implementsrun(ctx)(seetemplate/scripts/example.py). A node that only needs to land a csv file needs no script at all — seetemplate/contracts/example_csv.ymland "Node kinds" below. - Push to
main. Therelease.ymlworkflow lints the scripts, validates and merges the contracts, builds and pushes the image, uploads the merged contract to S3, and POSTs the release.
validate / merge / hash now hand every declared read to a real SQL
parser (sqlglot, via continuo_engine_contract.sql.ensure_single_read)
instead of scanning it for a leading SELECT/WITH. Two things follow for a
repo written before this, on its next release: SQL a driver would accept but
a parser will not — most commonly a driver-specific bind placeholder like
psycopg2's %(name)s, which ctx.read(name) could never have used anyway —
now fails validation, and engine-specific syntax (postgres ~, @>, …)
needs --dialect <engine>, which a repo should be passing regardless since
Continuo bind-checks every read in the install's own warehouse dialect. Run
the pre-flight check once before your next release; it reports every affected
read at once:
continuo-runtime validate contracts/ --dialect postgres # or trinoThe runtime image does not re-run this gate, so a read that passes here is
not re-judged under a different grammar in production. See
docs/boundary-contract.md §13.1.
A contract node's kind: field selects how the node produces its rows.
Every rule below (extra_columns, output_columns, "Conform rules") applies
to both kinds identically — kind only changes how the pre-conform table is
produced, never how it is checked or written.
python-model(the default; the field may be omitted) — a script node. It requiresscript:and areads:map of one or more named SQL queries, as described in "The script API" below.python-csv— a contract-only node: it has no script and itsreads:map must be exactly{csv: <uri>}, where the uri iss3://bucket/keyor anhttps://url (http://is rejected at validate time, not run time). The harness fetches the file, parses it with RFC 4180 defaults, and feeds the result straight intoconform()— declaredoutput_columnstypes decide the warehouse schema, not whatever pyarrow infers from the csv. Because there is no script,script:is a forbidden key for this kind;continuo-runtime validate/merge/lintreject one that sets it. The csv's header row must contain every declared output column (checked again, independently, at release time before promotion); columns present in the header but not declared are governed by the sameextra_columnspolicy as a script node's output —raise(default) fails the run,warndrops them and logs a warning. Seetemplate/contracts/example_csv.yml.
A node script is a Python file with exactly one required entry point:
def run(ctx):
...-
ctxis aRunContext(continuo_python_runtime.context.RunContext). Its only method isctx.read(name), wherenameis one of the read names declared under the node'sreads:map in the contract — reading anything else raisesReadError. Each declared read is fetched once and memoized;ctx.read(name)returns apyarrow.Table. -
run(ctx)can return anything Arrow-convertible: apyarrow.Tableas-is, a pandasDataFrame(converted viapa.Table.from_pandas(..., preserve_index=False)), or any object implementing the Arrow C stream protocol (__arrow_c_stream__) — for example a polars DataFrame. Returning anything else raisesScriptError. -
The dataframe library is your choice. No dataframe library is baked into the base image — the package's own runtime dependencies are
pyarrow,PyYAML, andcontinuo-engine-contract. Add whatever you script against (pandas, polars, …) as aRUN pip installline in your ownDockerfile, on top of the base image. The base image runs as non-root (uid 65532), which pip cannot install under, so switch to root for the install and back afterward —USER root, then theRUN pip installline, thenUSER 65532:65532— since the executor's pod spec expects the image to end at uid 65532. Seetemplate/Dockerfilefor the pattern. -
Scripts do not import warehouse drivers, write raw SQL literals, or call data-access methods directly —
continuo-runtime lintrejects those:- forbidden driver imports (
psycopg2/sqlalchemy/trino/etc.), - SQL string literals (in plain strings, f-strings, and
+concatenation), - forbidden data-access calls (
execute/read_sql/etc.), including ones reached via afrom ... importalias (e.g.from pandas import read_sql as rsthen callingrs(...)), - private/protected attribute access (
obj._x) — except onself/cls, so a script's own class-private helpers (self._helper()) aren't flagged.
The SQL-literal rule is best-effort: docstrings are exempt, but other prose may still occasionally match. The hard guarantees enforced by lint are the driver-import and data-access-call rules, together with the fact that
RunContextonly exposesctx.read()— all warehouse access goes through it. - forbidden driver imports (
-
Scripts may import shared in-repo helpers. Before executing a script the harness puts the repo root (
APP_ROOT) and the script's own directory onsys.path, so bothimport helpers(a sibling of the script) andfrom lib.shared import ...(anywhere under the repo root) work, including from insiderun(). Every helper a script reaches transitively is folded intoshared_code_hash, so editing one re-fingerprints the node — but the hash does not put the file in the image:COPYevery directory your scripts import from in yourDockerfile, or the release is valid and the node dies withModuleNotFoundErroron its first run. Because the repo root precedes the standard library onsys.path, avoid naming a top-level module after a stdlib one (types.py,json.py,logging.py, …). -
The harness — not the script — performs the write. It calls
conform()on whateverrun()returned and issues the only INSERT; the script never writes directly.
conform() (continuo_python_runtime/conform.py) enforces the node's
declared output_columns on the table run() returned, in this order:
| Check | Behavior |
|---|---|
| Duplicate columns | Any duplicate column name in the returned table always raises ConformError. |
| Extra columns | Governed by the node's extra_columns policy: raise (default) fails the run; warn drops the undeclared column(s) and logs a warning. |
| Missing columns | Any declared column absent from the returned table always raises ConformError. |
| Column order | The table is reselected into the declared column order. |
| Strict cast | Each column is cast to its declared Arrow type with safe=True. Casts pyarrow's safe=True would silently accept but that are not value-lossless are rejected before the cast even runs: floating → decimal (rounds to scale), non-boolean → boolean (coerces truthiness), timestamp → date (drops time-of-day). Any other cast failure also raises ConformError. |
| Not-null | A column declared nullable: false that contains any null raises ConformError. |
| VARCHAR/CHAR length | A column declared VARCHAR(n)/CHAR(n) whose longest value exceeds n raises ConformError. |
Every runtime failure is one of five HarnessError subclasses
(continuo_python_runtime/errors.py). The sentinel result block's message
is prefixed <ErrorClass>: so failures can be triaged without parsing free
text.
| Class | Meaning | Typical fix target |
|---|---|---|
ContractError |
Contract missing or invalid, node not found for NODE_ID, or the declared script is missing/unreachable. |
The contract yaml or the script: path. |
ReadError |
ctx.read() was called with an undeclared name, or a declared read failed at the warehouse. |
The reads: map, or the upstream query/warehouse access. |
ScriptError |
run() raised, has no callable run, or returned a value that isn't Arrow-convertible. |
The node script. |
ConformError |
Structural mismatch (extra/missing/duplicate columns), a strict-cast failure, a not-null violation, or a VARCHAR/CHAR overflow. | The script's output shape, or the output_columns declaration. |
LoadError |
Adapter construction failed, or the DDL/INSERT failed at the warehouse during the write. | Warehouse connectivity/permissions, or the target table. |
A domain repo picks its warehouse engine by which base image it builds
FROM:
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.4.1
# or
FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.4.1The engine is part of the image name; the tag is the bare version, so
Continuo's Helm chart can pin an image as <name>:vX.Y.Z@sha256:<digest>.
Each image bakes in exactly one WarehouseAdapter for that engine — the
pinned PyPI version of the continuo-postgres-adapter or
continuo-trino-adapter package built from this repo's adapters/postgres/
or adapters/trino/ source (see the table above) — registered under the
continuo_engine.adapters entry-point group (entry names postgres /
trino). The runtime discovers it via discover_adapter() at run time, so a
single image serves every node in the service and the release-time
validation Job for it. The executor injects the warehouse connection as
environment variables (engine-native, e.g.
POSTGRES_HOST/POSTGRES_DB/POSTGRES_USER) plus the node-selection
environment (NODE_ID, TABLE_NAME, TARGET_SCHEMA, and optionally
CONTRACT_DIR/APP_ROOT) that continuo-runtime run reads to dispatch the
right node's script.
A domain repo does not have to build FROM the published engine image.
template/ ships two Dockerfiles for the two build shapes (see
template/README.md § "Choosing a base" for the full comparison):
- Shape 1 —
template/Dockerfile—FROMthe published engine image (continuo-python-runtime-<engine>), as shown above. Simplest; the image already has the runtime and adapter installed and pinned. - Shape 2 —
template/Dockerfile.pip— your own base image, installingcontinuo-python-runtimeand onecontinuo-<engine>-adapterfrom PyPI via a hash-lockedrequirements.lock(template/requirements.lock). Use this when you must control the base image yourself.
Both shapes end up running the same runtime and the same adapter version; which one you pick only changes who controls the base OS layer underneath them.
docs/superpowers/specs/2026-07-31-python-runtime-design.md— this repo's design.docs/boundary-contract.md— the parent design's boundary contract (§13): the five surfaces (S3 artifact,content_hash, the release call, the runtime image, and the domain repo's CI/CD) that this repo implements.