continuo-python-runtime 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

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.
@@ -1,8 +1,9 @@
1
1
  """Per-kind contract rules: which fields a node of each kind must carry.
2
2
 
3
3
  Each kind is one ``KindRules`` entry composed of a script policy, a reads rule,
4
- and whether it may name a Secret. Adding a kind means adding one entry to
5
- ``RULES``; nothing that parses a contract branches on the kind's name.
4
+ whether it may name a Secret, and which write modes it may declare. Adding a
5
+ kind means adding one entry to ``RULES``; nothing that parses a contract
6
+ branches on the kind's name.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
@@ -14,7 +15,7 @@ from typing import Any
14
15
  from continuo_engine_contract.sql import ensure_single_read # type: ignore[import-untyped]
15
16
  from sqlglot.errors import TokenError
16
17
 
17
- from continuo_python_runtime.contract.model import KINDS
18
+ from continuo_python_runtime.contract.model import DEFAULT_MODE_TYPE, KINDS, MODE_TYPES
18
19
  from continuo_python_runtime.csv_source import parse_csv_uri
19
20
  from continuo_python_runtime.errors import ContractError
20
21
 
@@ -108,12 +109,18 @@ class KindRules:
108
109
  script_required: bool
109
110
  reads: ReadsRule
110
111
  secret_allowed: bool = False
112
+ # The write modes a node of this kind may declare. A new kind gets replace
113
+ # only unless its entry opts into more.
114
+ mode_types: frozenset[str] = frozenset({DEFAULT_MODE_TYPE})
111
115
 
112
116
 
113
117
  RULES: dict[str, KindRules] = {
114
- "python-node": KindRules(script_required=True, reads=SqlReads()),
118
+ "python-node": KindRules(script_required=True, reads=SqlReads(), mode_types=MODE_TYPES),
119
+ # A csv drop is a snapshot of its source: replace only (spec D2).
115
120
  "python-csv": KindRules(script_required=False, reads=CsvRead()),
116
- "python-api": KindRules(script_required=True, reads=NoReads(), secret_allowed=True),
121
+ "python-api": KindRules(
122
+ script_required=True, reads=NoReads(), secret_allowed=True, mode_types=MODE_TYPES
123
+ ),
117
124
  }
118
125
 
119
126
 
@@ -17,7 +17,9 @@ from continuo_python_runtime.contract.kinds import rules_for
17
17
  from continuo_python_runtime.contract.model import (
18
18
  CRITICALITIES,
19
19
  DEFAULT_KIND,
20
+ DEFAULT_MODE_TYPE,
20
21
  EXTRA_COLUMNS_POLICIES,
22
+ MODE_TYPES,
21
23
  Column,
22
24
  Node,
23
25
  )
@@ -40,6 +42,8 @@ _ALLOWED_KEYS = {
40
42
  "config",
41
43
  "content_hash",
42
44
  "secret_ref",
45
+ "mode_type",
46
+ "primary_key",
43
47
  }
44
48
 
45
49
  _REQUIRED_STRING_FIELDS = ("schema", "table", "owner", "schedule")
@@ -140,6 +144,67 @@ def _validate_config(raw: Any, label: str) -> dict[str, Any]:
140
144
  return raw
141
145
 
142
146
 
147
+ def _validate_primary_key(
148
+ raw: dict[str, Any], mode_type: str, columns: list[Column], label: str
149
+ ) -> tuple[str, ...]:
150
+ """Validate ``primary_key`` against ``mode_type`` and the declared columns.
151
+
152
+ Required for ``upsert`` and rejected otherwise: a key on ``append`` or
153
+ ``replace`` would look enforced when it is not. Names keep their order,
154
+ which is what the wire entry carries. Each must be spelled exactly as an
155
+ output column (postgres quotes identifiers, so ``Rate_Date`` is not
156
+ ``rate_date``) declared ``nullable: false``, because a NULL key never
157
+ matches an existing row. Repeats are refused case-insensitively too, so a
158
+ key cannot name two columns that differ only in case. A floating-point
159
+ column is refused as well: the warehouse treats ``0.0`` and ``-0.0`` as
160
+ one key, so ``ensure_unique_key`` would reject the rows at write time --
161
+ failing here instead keeps an unwritable upsert out of the contract.
162
+ """
163
+ if mode_type != "upsert":
164
+ if "primary_key" in raw:
165
+ raise ContractError(
166
+ f"{label}: 'primary_key' is only allowed when mode_type is upsert "
167
+ f"(got {mode_type})"
168
+ )
169
+ return ()
170
+ if "primary_key" not in raw:
171
+ raise ContractError(f"{label}: 'primary_key' is required when mode_type is upsert")
172
+ raw_key = raw["primary_key"]
173
+ if not isinstance(raw_key, list) or not raw_key:
174
+ raise ContractError(
175
+ f"{label}: 'primary_key' must be a non-empty list of output column names"
176
+ )
177
+ by_name = {column.name: column for column in columns}
178
+ seen: set[str] = set()
179
+ for name in raw_key:
180
+ if not isinstance(name, str) or not name.strip():
181
+ raise ContractError(
182
+ f"{label}: 'primary_key' entry {name!r} must be a non-empty string"
183
+ )
184
+ folded = name.lower() # same folding as continuo's parser
185
+ if folded in seen:
186
+ raise ContractError(
187
+ f"{label}: 'primary_key' names {name!r} more than once "
188
+ "(names are compared case-insensitively)"
189
+ )
190
+ seen.add(folded)
191
+ column = by_name.get(name)
192
+ if column is None:
193
+ raise ContractError(
194
+ f"{label}: 'primary_key' column {name!r} is not in output_columns"
195
+ )
196
+ if column.nullable:
197
+ raise ContractError(
198
+ f"{label}: 'primary_key' column {name!r} must be declared nullable: false"
199
+ )
200
+ if parse_sql_type(column.type).base == "DOUBLE_PRECISION":
201
+ raise ContractError(
202
+ f"{label}: 'primary_key' column {name!r} is floating-point "
203
+ f"({column.type}); a floating-point column cannot be a primary key"
204
+ )
205
+ return tuple(raw_key)
206
+
207
+
143
208
  def parse_node(
144
209
  raw: dict[str, Any],
145
210
  source: str,
@@ -158,7 +223,8 @@ def parse_node(
158
223
  ``check_reads=False`` skips *only* that :func:`ensure_single_read` call —
159
224
  the read-shape gate — leaving every other rule here (required fields,
160
225
  criticality, the ``reads`` map's own shape, output-column types and
161
- uniqueness, ``config``, ``content_hash``) in force. It exists for
226
+ uniqueness, ``mode_type`` and ``primary_key``, ``config``,
227
+ ``content_hash``) in force. It exists for
162
228
  :func:`~continuo_python_runtime.harness.run_node`; see
163
229
  :func:`load_contract_dir` for why the runtime opts out.
164
230
  """
@@ -229,6 +295,17 @@ def parse_node(
229
295
  raise ContractError(f"{label}: 'secret_ref' is not allowed for kind {kind}")
230
296
  secret_ref = validate_secret_ref(raw["secret_ref"], label)
231
297
 
298
+ mode_type = raw.get("mode_type", DEFAULT_MODE_TYPE)
299
+ if not isinstance(mode_type, str) or mode_type not in MODE_TYPES:
300
+ raise ContractError(
301
+ f"{label}: 'mode_type' must be one of {sorted(MODE_TYPES)}, got {mode_type!r}"
302
+ )
303
+ if mode_type not in rules.mode_types:
304
+ raise ContractError(
305
+ f"{label}: 'mode_type' {mode_type} is not allowed for kind {kind} "
306
+ f"(allowed: {sorted(rules.mode_types)})"
307
+ )
308
+
232
309
  raw_columns = raw.get("output_columns")
233
310
  if not isinstance(raw_columns, list) or not raw_columns:
234
311
  raise ContractError(f"{label}: 'output_columns' must be a non-empty list")
@@ -269,6 +346,8 @@ def parse_node(
269
346
  seen_names.add(name)
270
347
  columns.append(Column(name=name, type=col_type, nullable=nullable))
271
348
 
349
+ primary_key = _validate_primary_key(raw, mode_type, columns, label)
350
+
272
351
  description = raw.get("description", "")
273
352
  if not isinstance(description, str):
274
353
  raise ContractError(f"{label}: 'description' must be a string")
@@ -294,6 +373,8 @@ def parse_node(
294
373
  content_hash=content_hash,
295
374
  kind=kind,
296
375
  secret_ref=secret_ref,
376
+ mode_type=mode_type,
377
+ primary_key=primary_key,
297
378
  )
298
379
 
299
380
 
@@ -7,7 +7,7 @@ import yaml
7
7
 
8
8
  from continuo_python_runtime.closure import resolve_closure
9
9
  from continuo_python_runtime.contract.loader import load_contract_dir
10
- from continuo_python_runtime.contract.model import CONTRACT_VERSION, Node
10
+ from continuo_python_runtime.contract.model import CONTRACT_VERSION, DEFAULT_MODE_TYPE, Node
11
11
  from continuo_python_runtime.contract.paths import resolve_script_path
12
12
  from continuo_python_runtime.errors import ContractError
13
13
  from continuo_python_runtime.hashing import hash_parts
@@ -20,7 +20,10 @@ def node_entry(node: Node) -> dict:
20
20
  Returns the node as a dict with all fields, output_columns as list of
21
21
  dicts. The entry carries no hash fields: build_wire_contract adds all
22
22
  four (source_hash, shared_code_hash, config_hash, content_hash). The
23
- nullable field is always present in output_columns.
23
+ nullable field is always present in output_columns. ``secret_ref``,
24
+ ``mode_type`` and ``primary_key`` appear only when they differ from their
25
+ default, so a node that does not use them has the entry (and hashes) it
26
+ had before they existed.
24
27
  """
25
28
  entry = {
26
29
  "schema": node.schema,
@@ -47,6 +50,12 @@ def node_entry(node: Node) -> dict:
47
50
  # only when set keeps every other node's config_hash unchanged.
48
51
  if node.secret_ref:
49
52
  entry["secret_ref"] = node.secret_ref
53
+ # Same reasoning: every replace node's entry, config_hash and content_hash
54
+ # stay byte-identical, so contract_version stays 1.
55
+ if node.mode_type != DEFAULT_MODE_TYPE:
56
+ entry["mode_type"] = node.mode_type
57
+ if node.mode_type == "upsert":
58
+ entry["primary_key"] = list(node.primary_key)
50
59
  return entry
51
60
 
52
61
 
@@ -8,6 +8,11 @@ CRITICALITIES = frozenset({"REGULATORY", "CORE", "SECONDARY"})
8
8
  EXTRA_COLUMNS_POLICIES = frozenset({"raise", "warn"})
9
9
  KINDS = frozenset({"python-node", "python-csv", "python-api"})
10
10
  DEFAULT_KIND = "python-node"
11
+ # How the harness writes a node's table on each run: replace empties it first,
12
+ # append keeps the rows of earlier runs, upsert overwrites rows whose
13
+ # primary_key exists and keeps the rest.
14
+ MODE_TYPES = frozenset({"append", "upsert", "replace"})
15
+ DEFAULT_MODE_TYPE = "replace"
11
16
  CONTRACT_VERSION = 1
12
17
 
13
18
 
@@ -38,6 +43,8 @@ class Node:
38
43
  content_hash: str | None = None
39
44
  kind: str = DEFAULT_KIND
40
45
  secret_ref: str = ""
46
+ mode_type: str = DEFAULT_MODE_TYPE
47
+ primary_key: tuple[str, ...] = ()
41
48
 
42
49
  @property
43
50
  def relation(self) -> str:
@@ -3,7 +3,8 @@
3
3
  ``run_node`` is the sole write sink for a python node. It resolves the
4
4
  node from the contract, loads and executes its script inside a
5
5
  :class:`~continuo_python_runtime.context.RunContext`, conforms the result to
6
- the declared schema, and writes it through the runtime adapter. Exactly one
6
+ the declared schema, and writes it through the runtime adapter with the
7
+ node's ``mode_type``: ``load`` (replace), ``append`` or ``upsert``. Exactly one
7
8
  sentinel-framed result block is printed to stdout per run; every other
8
9
  diagnostic goes to stderr via ``logging``.
9
10
  """
@@ -17,11 +18,15 @@ import os
17
18
  import sys
18
19
  import uuid
19
20
  from collections.abc import Callable, Mapping
21
+ from dataclasses import dataclass
20
22
  from pathlib import Path
21
23
  from types import ModuleType
22
24
  from typing import Any
23
25
 
26
+ from continuo_engine_contract.keys import DuplicateKeyError # type: ignore[import-untyped]
24
27
  from continuo_engine_contract.port import ( # type: ignore[import-untyped]
28
+ AppendCapable,
29
+ UpsertCapable,
25
30
  discover_adapter,
26
31
  )
27
32
  from continuo_engine_contract.result import result_block # type: ignore[import-untyped]
@@ -33,7 +38,14 @@ from continuo_python_runtime.contract.model import Node
33
38
  from continuo_python_runtime.contract.paths import resolve_script_path
34
39
  from continuo_python_runtime.csv_loader import produce_csv
35
40
  from continuo_python_runtime.csv_source import CsvSourceReader
36
- from continuo_python_runtime.errors import ContractError, HarnessError, LoadError, ScriptError
41
+ from continuo_python_runtime.errors import (
42
+ ConformError,
43
+ ContractError,
44
+ HarnessError,
45
+ LoadError,
46
+ ScriptError,
47
+ )
48
+ from continuo_python_runtime.shape import shape_diff
37
49
 
38
50
  logger = logging.getLogger("continuo_python_runtime.harness")
39
51
 
@@ -232,6 +244,75 @@ _PRODUCERS: dict[str, Callable[[Node, Any, Path, CsvSourceReader | None], Any]]
232
244
  }
233
245
 
234
246
 
247
+ def _write_replace(adapter: Any, schema: str, table: str, data: Any, key: tuple[str, ...]) -> None:
248
+ adapter.load(schema, table, data)
249
+
250
+
251
+ def _write_append(adapter: Any, schema: str, table: str, data: Any, key: tuple[str, ...]) -> None:
252
+ adapter.append(schema, table, data)
253
+
254
+
255
+ def _write_upsert(adapter: Any, schema: str, table: str, data: Any, key: tuple[str, ...]) -> None:
256
+ adapter.upsert(schema, table, data, key)
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class _Writer:
261
+ """How one ``mode_type`` is written.
262
+
263
+ ``capability`` is the engine-contract interface the adapter must declare
264
+ (``None``: every ``WarehouseAdapter`` can, through ``load``). Every
265
+ capability extends ``TableIntrospection``, so a capability-gated write is
266
+ also a shape-checked one.
267
+ """
268
+
269
+ capability: type | None
270
+ write: Callable[[Any, str, str, Any, tuple[str, ...]], None]
271
+
272
+
273
+ # One entry per mode_type, beside _PRODUCERS: a new mode is one capability
274
+ # interface in the engine contract plus one entry here.
275
+ _WRITERS: dict[str, _Writer] = {
276
+ "replace": _Writer(capability=None, write=_write_replace),
277
+ "append": _Writer(capability=AppendCapable, write=_write_append),
278
+ "upsert": _Writer(capability=UpsertCapable, write=_write_upsert),
279
+ }
280
+
281
+
282
+ def _writer_for(adapter: Any, node: Node) -> _Writer:
283
+ """The node's writer, if the adapter declares the capability its mode needs.
284
+
285
+ Called right after the config tripwire, before the script runs or the
286
+ warehouse is touched: an engine that cannot append fails in the first
287
+ second, not after the whole run.
288
+
289
+ Raises:
290
+ LoadError: ``<Adapter> does not support <mode>``.
291
+ """
292
+ writer = _WRITERS[node.mode_type]
293
+ if writer.capability is not None and not isinstance(adapter, writer.capability):
294
+ raise LoadError(f"{type(adapter).__name__} does not support {node.mode_type}")
295
+ return writer
296
+
297
+
298
+ def _check_shape(adapter: Any, node: Node, schema: str, table: str) -> None:
299
+ """Fail unless the live target table still has the node's declared shape.
300
+
301
+ Runs before an append or upsert only: those keep earlier rows, so a table
302
+ whose columns or nullability drifted (changed outside the contract, or
303
+ created before the release gate recorded shapes) must not be written. It
304
+ inspects the table actually written -- ``TARGET_SCHEMA.TABLE_NAME`` -- not
305
+ ``node.relation``.
306
+
307
+ Raises:
308
+ LoadError: Naming every missing column, unexpected column and
309
+ nullability mismatch.
310
+ """
311
+ diff = shape_diff(node.output_columns, adapter.table_columns(schema, table))
312
+ if diff:
313
+ raise LoadError(diff.message(f"{schema}.{table}"))
314
+
315
+
235
316
  def run_node(
236
317
  env: Mapping[str, str], adapter: Any = None, reader: CsvSourceReader | None = None
237
318
  ) -> int:
@@ -276,6 +357,7 @@ def run_node(
276
357
  raise LoadError(f"adapter construction failed: {exc}") from exc
277
358
 
278
359
  _validate_config_early(active_adapter, node)
360
+ writer = _writer_for(active_adapter, node)
279
361
 
280
362
  table = _PRODUCERS[node.kind](node, active_adapter, app_root, reader)
281
363
 
@@ -285,11 +367,25 @@ def run_node(
285
367
  {"name": c.name, "type": c.type, "nullable": c.nullable}
286
368
  for c in node.output_columns
287
369
  ]
370
+ # Spelled exactly as in output_columns (the loader enforces it). Empty
371
+ # for every mode but upsert, so primary_key reaches ensure_table only
372
+ # for upsert nodes.
373
+ key = node.primary_key
288
374
  try:
289
- active_adapter.ensure_table(target_schema, table_name, columns, config=node.config)
290
- active_adapter.load(target_schema, table_name, conformed)
375
+ if key:
376
+ active_adapter.ensure_table(
377
+ target_schema, table_name, columns, config=node.config, primary_key=key
378
+ )
379
+ else:
380
+ active_adapter.ensure_table(target_schema, table_name, columns, config=node.config)
381
+ if writer.capability is not None:
382
+ _check_shape(active_adapter, node, target_schema, table_name)
383
+ writer.write(active_adapter, target_schema, table_name, conformed, key)
291
384
  except HarnessError:
292
385
  raise
386
+ except DuplicateKeyError as exc:
387
+ # The script's rows repeat a key: its output is at fault, not the warehouse.
388
+ raise ConformError(str(exc)) from exc
293
389
  except Exception as exc:
294
390
  raise LoadError(f"failed to write {target_schema}.{table_name}: {exc}") from exc
295
391
 
@@ -0,0 +1,95 @@
1
+ """Compare a live table's columns with a node's declared output columns.
2
+
3
+ The harness runs this before every append or upsert: those modes keep earlier
4
+ rows, so writing into a table whose shape drifted from the contract would mix
5
+ two shapes in one table. Names are compared case-insensitively (Trino folds
6
+ names, DuckDB matches them case-insensitively) and nullability exactly. Order
7
+ is ignored, because every insert names its columns; types are ignored,
8
+ because engines report them in their own spelling (Trino and DuckDB drop a
9
+ VARCHAR's length) and there is no engine-to-contract type mapping. Type
10
+ changes are the release gate's job.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from typing import NamedTuple
17
+
18
+ from continuo_engine_contract.port import LiveColumn # type: ignore[import-untyped]
19
+
20
+ from continuo_python_runtime.contract.model import Column
21
+
22
+
23
+ class NullabilityDiff(NamedTuple):
24
+ """A column whose nullability differs: as declared vs as the live table has it."""
25
+
26
+ name: str
27
+ declared: bool
28
+ live: bool
29
+
30
+
31
+ def _yaml_bool(value: bool) -> str:
32
+ return "true" if value else "false"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ShapeDiff:
37
+ """What separates a live table from the declared columns; falsy when nothing does."""
38
+
39
+ missing: tuple[str, ...] = ()
40
+ unexpected: tuple[str, ...] = ()
41
+ nullability: tuple[NullabilityDiff, ...] = ()
42
+
43
+ def __bool__(self) -> bool:
44
+ return bool(self.missing or self.unexpected or self.nullability)
45
+
46
+ def message(self, target: str) -> str:
47
+ """One line naming every difference, for a LoadError about *target* (``schema.table``)."""
48
+ parts: list[str] = []
49
+ if self.missing:
50
+ parts.append(f"missing column(s) {list(self.missing)}")
51
+ if self.unexpected:
52
+ parts.append(f"unexpected column(s) {list(self.unexpected)}")
53
+ parts.extend(
54
+ f"column {diff.name} is nullable: {_yaml_bool(diff.live)} in the table "
55
+ f"but nullable: {_yaml_bool(diff.declared)} in the contract"
56
+ for diff in self.nullability
57
+ )
58
+ return (
59
+ f"table {target} does not match the contract's output_columns: {'; '.join(parts)}. "
60
+ "An append or upsert table keeps its rows, so its shape cannot change: "
61
+ "write to a new table (a _v2 name) under a new contract"
62
+ )
63
+
64
+
65
+ def shape_diff(
66
+ contract_columns: Sequence[Column], live_columns: Sequence[LiveColumn]
67
+ ) -> ShapeDiff:
68
+ """Compare the declared columns with a live table's.
69
+
70
+ Each declared column is paired with a live column of the exact same name
71
+ if there is one, else with one whose name matches case-insensitively, so
72
+ two live columns that differ only in case pair with the right declared
73
+ column.
74
+ """
75
+ remaining = list(live_columns)
76
+ missing: list[str] = []
77
+ nullability: list[NullabilityDiff] = []
78
+ for declared in contract_columns:
79
+ live = next((column for column in remaining if column.name == declared.name), None)
80
+ if live is None:
81
+ folded = declared.name.casefold()
82
+ live = next(
83
+ (column for column in remaining if column.name.casefold() == folded), None
84
+ )
85
+ if live is None:
86
+ missing.append(declared.name)
87
+ continue
88
+ remaining.remove(live)
89
+ if live.nullable != declared.nullable:
90
+ nullability.append(NullabilityDiff(declared.name, declared.nullable, live.nullable))
91
+ return ShapeDiff(
92
+ missing=tuple(missing),
93
+ unexpected=tuple(column.name for column in remaining),
94
+ nullability=tuple(nullability),
95
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: continuo-python-runtime
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Runtime harness, contract tooling, and CI lint for Continuo python nodes.
5
5
  Author: Simone Carolini
6
6
  Maintainer: Simone Carolini
@@ -12,7 +12,7 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
14
  Requires-Dist: boto3==1.43.85
15
- Requires-Dist: continuo-engine-contract==0.7.3
15
+ Requires-Dist: continuo-engine-contract==0.8.0
16
16
  Requires-Dist: pyarrow==25.0.1
17
17
  Requires-Dist: pyyaml==6.0.3
18
18
  Requires-Dist: sqlglot==30.17.0
@@ -41,15 +41,16 @@ Five artifacts come out of this repository:
41
41
  - **The `continuo-engine-contract` PyPI package** — the `WarehouseAdapter`
42
42
  port, the contract schema, the shared SQL/type/config guards, and the
43
43
  sentinel result-block format. Adapter authors outside this repo pin it.
44
- - **The two engine-adapter PyPI packages** (`continuo-postgres-adapter`,
45
- `continuo-trino-adapter`) — one `WarehouseAdapter` implementation per
44
+ - **The three engine-adapter PyPI packages** (`continuo-postgres-adapter`,
45
+ `continuo-trino-adapter`, `continuo-duckdb-adapter`) — one `WarehouseAdapter` implementation per
46
46
  warehouse engine, each published independently under the same tag. A
47
47
  domain repo normally never installs these directly (the engine image
48
48
  already has the matching one baked in); they exist as standalone PyPI
49
49
  packages for the "build your own container" shape (see below) and for
50
50
  third-party adapter authors to reference.
51
51
  - **Per-engine base images**, one per warehouse engine
52
- (`continuo-python-runtime-postgres`, `continuo-python-runtime-trino`), that
52
+ (`continuo-python-runtime-postgres`, `continuo-python-runtime-trino`,
53
+ `continuo-python-runtime-duckdb`), that
53
54
  domain repos build `FROM`. Each image bakes in the runtime and a single
54
55
  `WarehouseAdapter` for that engine, and serves both roles that adapter has:
55
56
  the node harness (`ENTRYPOINT ["continuo-runtime"]`, `CMD ["run"]`) and the
@@ -57,20 +58,23 @@ Five artifacts come out of this repository:
57
58
  - **`template/`** — a copy-ready domain repo: `Dockerfile`, `contracts/`,
58
59
  `scripts/`, and the `release.yml` CI/CD workflow.
59
60
 
60
- One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds all four
61
- PyPI distributions into a single `dist/` and publishes them together, and
62
- `images.yml` builds and pushes both engine images — each installing its
63
- matching pinned adapter version from that same release — multi-arch under
64
- the same tag.
61
+ One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds all five
62
+ PyPI distributions into a single `dist/`, verifies them (and full engine
63
+ images built from them) on amd64 and arm64, and only then publishes them
64
+ together. It then builds all three engine images natively per architecture —
65
+ each installing its matching pinned adapter version from that same release —
66
+ pulls and smoke-tests the exact pushed digests, and finally gives the verified
67
+ amd64 + arm64 digests one multi-arch tag. See `CONTRIBUTING.md` (Releasing).
65
68
 
66
69
  ### What this repo owns
67
70
 
68
- This repository owns the entire python-node surface: the engine contract, both
71
+ This repository owns the entire python-node surface: the engine contract, the three
69
72
  engine adapters, the validation runner, and the node harness. The former
70
73
  `continuo-validation` repository was merged in — there is no longer a separate
71
74
  validation-side port, adapter class, entry-point group, or image. One
72
75
  `WarehouseAdapter` per engine serves both the data plane (`fetch` /
73
- `ensure_table` / `load`) and validation (`ensure_schema` / `drop_schema` /
76
+ `ensure_table` / `load`, plus the opt-in `table_columns` / `append` / `upsert`
77
+ capabilities) and validation (`ensure_schema` / `drop_schema` /
74
78
  `build_empty_from_sql` / `build_empty_from_columns` / `clone_empty_from_prod` /
75
79
  `check_binds`), and one image per engine runs both roles.
76
80
 
@@ -80,16 +84,18 @@ validation-side port, adapter class, entry-point group, or image. One
80
84
  | `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. |
81
85
  | `continuo-postgres-adapter` | `continuo_postgres_adapter` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. Published to PyPI. |
82
86
  | `continuo-trino-adapter` | `continuo_trino_adapter` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. Published to PyPI. |
87
+ | `continuo-duckdb-adapter` | `continuo_duckdb_adapter` | this repo, `adapters/duckdb/` | `DuckDBAdapter` — one class, both roles, for DuckDB on a DuckLake (Postgres catalog, Parquet on S3). Published to PyPI. |
83
88
 
84
- All four are uv workspace members (`[tool.uv.workspace]` in the root
89
+ All five are uv workspace members (`[tool.uv.workspace]` in the root
85
90
  `pyproject.toml`), so `uv sync --all-packages --all-groups` at the repo root
86
91
  installs everything for local development.
87
92
 
88
- **All four packages in the table above are published to PyPI**, under the
89
- same `vX.Y.Z` tag. The two engine images then **install the matching pinned
93
+ **All five packages in the table above are published to PyPI**, under the
94
+ same `vX.Y.Z` tag. The three engine images then **install the matching pinned
90
95
  adapter version from PyPI** — `Dockerfile.postgres` installs
91
96
  `continuo-postgres-adapter==X.Y.Z`, `Dockerfile.trino` installs
92
- `continuo-trino-adapter==X.Y.Z` — rather than building it from this repo's
97
+ `continuo-trino-adapter==X.Y.Z` and `Dockerfile.duckdb` installs
98
+ `continuo-duckdb-adapter==X.Y.Z` — rather than building it from this repo's
93
99
  source tree, so each image still ships exactly one adapter and the runtime
94
100
  still discovers it through the `continuo_engine.adapters` entry-point group
95
101
  at run time. The image **name** (`continuo-python-runtime-<engine>`) and the
@@ -97,7 +103,7 @@ adapter's pip **distribution** name (`continuo-<engine>-adapter`) are two
97
103
  different artifacts of the same adapter — same engine, same version, same
98
104
  runtime behavior, different packaging; see "Build your own container" below
99
105
  for a build shape that installs the pip package directly instead of `FROM`
100
- the image. All four packages are still built, type-checked, and tested by CI
106
+ the image. All five packages are still built, type-checked, and tested by CI
101
107
  on every change.
102
108
 
103
109
  ### The result block is a frozen wire contract
@@ -116,9 +122,13 @@ the Go parser has not been taught is a production outage, not a refactor.
116
122
  service name (one service name per domain repo).
117
123
  3. Configure repository variables in GitHub (Settings → Secrets and
118
124
  variables → Actions): `REGISTRY` (your Docker registry), `BUCKET` (your
119
- S3 bucket for contract artifacts), `RELEASE_ENDPOINT` (the release
120
- webhook endpoint). `RELEASE_ENDPOINT` is the **base URL** of the Continuo
121
- API (no `/releases` suffix) — the workflow appends `/releases` itself.
125
+ S3 bucket for contract artifacts), `RELEASE_ENDPOINT` (the base
126
+ URL of your continuo install, `scheme://host[:port]` with no path, the origin of continuo's
127
+ `auth.publicUrl`). The
128
+ workflow calls `<RELEASE_ENDPOINT>/api/v1/releases` with its GitHub Actions
129
+ OIDC token, so the repository must be bound to your service in continuo's
130
+ `ciAuth.bindings` (see [Releasing from CI](https://github.com/carolsimone/continuo/blob/main/deploy/README.md#releasing-from-ci-github-actions)),
131
+ and an operator bootstraps the service's first release.
122
132
  4. Configure repository secrets: `AWS_ACCESS_KEY_ID` and
123
133
  `AWS_SECRET_ACCESS_KEY` for the S3 upload. The template workflow pushes
124
134
  the built image to GHCR using the workflow's own `GITHUB_TOKEN` (granted
@@ -149,7 +159,7 @@ the pre-flight check once before your next release; it reports every affected
149
159
  read at once:
150
160
 
151
161
  ```bash
152
- continuo-runtime validate contracts/ --dialect postgres # or trino
162
+ continuo-runtime validate contracts/ --dialect postgres # or trino, duckdb
153
163
  ```
154
164
 
155
165
  The runtime image does not re-run this gate, so a read that passes here is
@@ -186,6 +196,62 @@ produced, never how it is checked or written.
186
196
  script reads with `os.environ[...]`. The contract never holds a secret value
187
197
  or env var name; the Secret itself is created in the cluster by the operator.
188
198
 
199
+ ## Write modes
200
+
201
+ `mode_type:` chooses how the harness writes a node's table on each run:
202
+
203
+ | `mode_type` | Each run | Rows from earlier runs |
204
+ | --- | --- | --- |
205
+ | `replace` (default) | empties the table, inserts this run's rows | gone |
206
+ | `append` | inserts this run's rows | kept |
207
+ | `upsert` | inserts rows with a new key, overwrites rows whose key exists | kept unless overwritten; never deleted |
208
+
209
+ ```yaml
210
+ - schema: fx
211
+ table: fx_rates
212
+ kind: python-api
213
+ mode_type: upsert
214
+ primary_key: [rate_date, base, quote]
215
+ output_columns:
216
+ - {name: rate_date, type: DATE, nullable: false}
217
+ - {name: base, type: VARCHAR(3), nullable: false}
218
+ - {name: quote, type: VARCHAR(3), nullable: false}
219
+ - {name: rate, type: DECIMAL(12,6)}
220
+ ```
221
+
222
+ - `python-csv` nodes only `replace`.
223
+ - `primary_key` is required for `upsert` and rejected otherwise: a non-empty
224
+ list of output columns declared `nullable: false` (a NULL key never
225
+ matches), each spelled exactly as in `output_columns`, names unique
226
+ case-insensitively. A Postgres `replace` table that
227
+ wants uniqueness uses `config.indexes` with `unique: true`.
228
+ - Every write is atomic: all of the run's rows or none. An `append` or
229
+ `upsert` run with zero rows writes nothing (a `replace` run with zero rows
230
+ still empties the table).
231
+ - `append` is a plain insert: a run that committed but was reported failed
232
+ and is retried stores its rows twice. `upsert` is idempotent.
233
+ - An `upsert` run whose own rows repeat a key fails with `ConformError` and
234
+ writes nothing.
235
+ - Before an `append` or `upsert` the harness compares the live table with
236
+ `output_columns` — names case-insensitively, nullability exactly; order and
237
+ types are not compared — and fails with `LoadError` naming every missing
238
+ column, unexpected column and nullability mismatch. Changing an `append` or
239
+ `upsert` table (columns, types, nullability, key or mode) needs a new table
240
+ name (`_v2`) under a new contract; continuo rejects the release otherwise.
241
+ Adding a nullable column is no exception: the existing rows cannot be
242
+ backfilled.
243
+ - The installed engine must support the mode; all three shipped adapters do.
244
+ A bring-your-own adapter declares `AppendCapable` / `UpsertCapable` from
245
+ `continuo-engine-contract` (and can run its conformance suite); without them
246
+ an `append`/`upsert` node fails with `LoadError: <Adapter> does not support
247
+ <mode>` before its script runs.
248
+ - Engines: on Postgres the key is the table's `PRIMARY KEY`, created with the
249
+ table; DuckLake and Trino/Iceberg have no constraints, so the key is
250
+ guaranteed by the write path only. On Trino an `upsert` table needs Iceberg
251
+ `format_version` 2 (the default).
252
+ - `full_refresh` stays refused for every python node: a rebuild would destroy
253
+ an accumulating table.
254
+
189
255
  ## The script API
190
256
 
191
257
  A node script is a Python file with exactly one required entry point:
@@ -272,8 +338,8 @@ text.
272
338
  | `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. |
273
339
  | `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. |
274
340
  | `ScriptError` | `run()` raised, has no callable `run`, or returned a value that isn't Arrow-convertible. | The node script. |
275
- | `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. |
276
- | `LoadError` | Adapter construction failed, or the DDL/INSERT failed at the warehouse during the write. | Warehouse connectivity/permissions, or the target table. |
341
+ | `ConformError` | Structural mismatch (extra/missing/duplicate columns), a strict-cast failure, a not-null violation, a VARCHAR/CHAR overflow, or an `upsert` run whose rows repeat a primary key. | The script's output shape, or the `output_columns` declaration. |
342
+ | `LoadError` | Adapter construction failed, the installed engine does not support the node's `mode_type`, the live table's shape differs from `output_columns` (append/upsert), or the DDL/INSERT failed at the warehouse during the write. | Warehouse connectivity/permissions, the engine image, or the target table. |
277
343
 
278
344
  ## Engine selection
279
345
 
@@ -281,20 +347,23 @@ A domain repo picks its warehouse engine by which base image it builds
281
347
  `FROM`:
282
348
 
283
349
  ```dockerfile
284
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.7.0
350
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.9.0
351
+ # or
352
+ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.9.0
285
353
  # or
286
- FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.7.0
354
+ FROM ghcr.io/carolsimone/continuo-python-runtime-duckdb:v0.9.0
287
355
  ```
288
356
 
289
357
  The engine is part of the image **name**; the tag is the bare version, so
290
358
  Continuo's Helm chart can pin an image as `<name>:vX.Y.Z@sha256:<digest>`.
291
359
 
292
360
  Each image bakes in exactly one `WarehouseAdapter` for that engine — the
293
- pinned PyPI version of the `continuo-postgres-adapter` or
294
- `continuo-trino-adapter` package built from this repo's `adapters/postgres/`
295
- or `adapters/trino/` source (see the table above) — registered under the
361
+ pinned PyPI version of the `continuo-postgres-adapter`,
362
+ `continuo-trino-adapter` or `continuo-duckdb-adapter` package built from this
363
+ repo's `adapters/postgres/`, `adapters/trino/` or `adapters/duckdb/` source
364
+ (see the table above) — registered under the
296
365
  `continuo_engine.adapters` entry-point group (entry names `postgres` /
297
- `trino`). The runtime discovers it via `discover_adapter()` at run time, so a
366
+ `trino` / `duckdb`). The runtime discovers it via `discover_adapter()` at run time, so a
298
367
  single image serves every node in the service and the release-time
299
368
  validation Job for it. The executor injects the warehouse connection as
300
369
  environment variables (engine-native, e.g.
@@ -6,15 +6,16 @@ continuo_python_runtime/context.py,sha256=K-9XXYlQ4OZ250rCOq6EJNq7fg_D5sK6tOcu7D
6
6
  continuo_python_runtime/csv_loader.py,sha256=a5Hwu-YIzIiko8X1_vqShsnzjs2-OsKEUSCHLXg_dio,3346
7
7
  continuo_python_runtime/csv_source.py,sha256=7g8Tu6ieiJLvWH4Ep4nwYlOdGkjeePr12K5UHuO8TNc,4136
8
8
  continuo_python_runtime/errors.py,sha256=nfZzfBhQ2ajm7USAWKKaGY2uUlh8eNjNPNYYuaOJgZE,1032
9
- continuo_python_runtime/harness.py,sha256=vsoahI38OCaCEpg9Il7xcHYcsXp6A5cxY_fjCC8xGJk,13046
9
+ continuo_python_runtime/harness.py,sha256=vm2wL7cOHT2uvrIgDZkNXAV7aT-6x7dQoCKirr41oJ0,16798
10
10
  continuo_python_runtime/hashing.py,sha256=70iXQb0CmjGqniTPofXY6WapydQ7aK2XOk5dzpEf-zk,3354
11
11
  continuo_python_runtime/lint.py,sha256=M59UtCHHVQnJKNolIkvkRk_AgGAFvxD9zkKAMMK-7GI,12984
12
+ continuo_python_runtime/shape.py,sha256=C-5ClSxPYgoO9pUiGqw7pCnmlBbsEl-sIm7Twbf-H4o,3693
12
13
  continuo_python_runtime/types.py,sha256=_ssxNE2Lq5782n9_Ihs-u_6ybCCy8XaL6iYbE35tBug,5057
13
14
  continuo_python_runtime/contract/__init__.py,sha256=S79x6j_P0CmaSuCGzeWoK1U0507KvD2MZXwQl9DFWMM,37
14
- continuo_python_runtime/contract/kinds.py,sha256=Ht-hCT7R5A1tEnPlbGL-b9FyJQBvpDWrXK9PBt7yA28,4938
15
- continuo_python_runtime/contract/loader.py,sha256=9HsV7dJhCwudjOPXBapYFKiZtslyXIkiZ_WfeuLh5-E,14858
16
- continuo_python_runtime/contract/merge.py,sha256=Zw--_-v3ERE02Nu7qqLtbHa3Hr__0a1SF-2PRqUfXAY,7594
17
- continuo_python_runtime/contract/model.py,sha256=WB_Qw4koP8BEwuiAZwIEsKFQRa_6QHHBK4xi4eGvPQo,1082
15
+ continuo_python_runtime/contract/kinds.py,sha256=nirtluv9GU0sZzGF8_-qRIdbeCRtBul_ppNnr6CIEC0,5320
16
+ continuo_python_runtime/contract/loader.py,sha256=uDlOYN6V7KhYhHk2aK34MHxSo3Z8riZw3FZ31Xf1Aqk,18386
17
+ continuo_python_runtime/contract/merge.py,sha256=vN1hA0WSHbUnDyyobW1Z2YYZvU4a0eqwDLS8d_xNGQo,8124
18
+ continuo_python_runtime/contract/model.py,sha256=Dq95CuAcSopumNqP9SlnrxXKhDVeYA55JwBKorhYde4,1435
18
19
  continuo_python_runtime/contract/paths.py,sha256=SRn70pJkDAIpXw0IcMU2iNKPKvct8w_SZP2ec-lV91M,1458
19
20
  continuo_python_runtime/contract/secret_ref.py,sha256=96jh2G81clpabiEWGIsxbd9r5GKEGLb4Sa1-WJ9G59Q,1131
20
21
  continuo_python_runtime/csv_readers/__init__.py,sha256=9TKvjC37C77uRaJQZuO0--zVj2OvuNwpJE6nWx7j2S8,806
@@ -23,9 +24,9 @@ continuo_python_runtime/csv_readers/s3.py,sha256=zmvHs3TxlN2alPKrdaHzPyREmkve2nB
23
24
  continuo_python_runtime/validation/__init__.py,sha256=hz5oGXsoaUoygR0_pBby9PS6cEfrre518EE0HGB58eE,79
24
25
  continuo_python_runtime/validation/runner.py,sha256=7pbksf7MXfTNcdklCOkOXg9qfRiJtyI_dbgbL3a9LWk,14072
25
26
  continuo_python_runtime/validation/s3.py,sha256=q8nRrT9R7L1-M4dSlDnkL7CUJMPmq86MHY_UKYFlEf8,1785
26
- continuo_python_runtime-0.7.0.dist-info/METADATA,sha256=Uo2XMCZawYbKgpER3qDn-eF_0cKJUZTultaxxcBAhuQ,19281
27
- continuo_python_runtime-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
28
- continuo_python_runtime-0.7.0.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
29
- continuo_python_runtime-0.7.0.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
30
- continuo_python_runtime-0.7.0.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
31
- continuo_python_runtime-0.7.0.dist-info/RECORD,,
27
+ continuo_python_runtime-0.9.0.dist-info/METADATA,sha256=wUHVQEfqOyqKM7xdRqUXXsJE4AI1aDZQ3x9q3_9zon4,23389
28
+ continuo_python_runtime-0.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
29
+ continuo_python_runtime-0.9.0.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
30
+ continuo_python_runtime-0.9.0.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
31
+ continuo_python_runtime-0.9.0.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
32
+ continuo_python_runtime-0.9.0.dist-info/RECORD,,