continuo-python-runtime 0.8.0__py3-none-any.whl → 0.10.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,8 +42,15 @@ _ALLOWED_KEYS = {
40
42
  "config",
41
43
  "content_hash",
42
44
  "secret_ref",
45
+ "mode_type",
46
+ "primary_key",
47
+ "timeout_minutes",
43
48
  }
44
49
 
50
+ # Bounds of a node's declared task timeout, in minutes (one minute to one week).
51
+ MIN_TIMEOUT_MINUTES = 1
52
+ MAX_TIMEOUT_MINUTES = 10_080
53
+
45
54
  _REQUIRED_STRING_FIELDS = ("schema", "table", "owner", "schedule")
46
55
 
47
56
  _ALLOWED_OUTPUT_COLUMN_KEYS = {"name", "type", "nullable"}
@@ -140,6 +149,67 @@ def _validate_config(raw: Any, label: str) -> dict[str, Any]:
140
149
  return raw
141
150
 
142
151
 
152
+ def _validate_primary_key(
153
+ raw: dict[str, Any], mode_type: str, columns: list[Column], label: str
154
+ ) -> tuple[str, ...]:
155
+ """Validate ``primary_key`` against ``mode_type`` and the declared columns.
156
+
157
+ Required for ``upsert`` and rejected otherwise: a key on ``append`` or
158
+ ``replace`` would look enforced when it is not. Names keep their order,
159
+ which is what the wire entry carries. Each must be spelled exactly as an
160
+ output column (postgres quotes identifiers, so ``Rate_Date`` is not
161
+ ``rate_date``) declared ``nullable: false``, because a NULL key never
162
+ matches an existing row. Repeats are refused case-insensitively too, so a
163
+ key cannot name two columns that differ only in case. A floating-point
164
+ column is refused as well: the warehouse treats ``0.0`` and ``-0.0`` as
165
+ one key, so ``ensure_unique_key`` would reject the rows at write time --
166
+ failing here instead keeps an unwritable upsert out of the contract.
167
+ """
168
+ if mode_type != "upsert":
169
+ if "primary_key" in raw:
170
+ raise ContractError(
171
+ f"{label}: 'primary_key' is only allowed when mode_type is upsert "
172
+ f"(got {mode_type})"
173
+ )
174
+ return ()
175
+ if "primary_key" not in raw:
176
+ raise ContractError(f"{label}: 'primary_key' is required when mode_type is upsert")
177
+ raw_key = raw["primary_key"]
178
+ if not isinstance(raw_key, list) or not raw_key:
179
+ raise ContractError(
180
+ f"{label}: 'primary_key' must be a non-empty list of output column names"
181
+ )
182
+ by_name = {column.name: column for column in columns}
183
+ seen: set[str] = set()
184
+ for name in raw_key:
185
+ if not isinstance(name, str) or not name.strip():
186
+ raise ContractError(
187
+ f"{label}: 'primary_key' entry {name!r} must be a non-empty string"
188
+ )
189
+ folded = name.lower() # same folding as continuo's parser
190
+ if folded in seen:
191
+ raise ContractError(
192
+ f"{label}: 'primary_key' names {name!r} more than once "
193
+ "(names are compared case-insensitively)"
194
+ )
195
+ seen.add(folded)
196
+ column = by_name.get(name)
197
+ if column is None:
198
+ raise ContractError(
199
+ f"{label}: 'primary_key' column {name!r} is not in output_columns"
200
+ )
201
+ if column.nullable:
202
+ raise ContractError(
203
+ f"{label}: 'primary_key' column {name!r} must be declared nullable: false"
204
+ )
205
+ if parse_sql_type(column.type).base == "DOUBLE_PRECISION":
206
+ raise ContractError(
207
+ f"{label}: 'primary_key' column {name!r} is floating-point "
208
+ f"({column.type}); a floating-point column cannot be a primary key"
209
+ )
210
+ return tuple(raw_key)
211
+
212
+
143
213
  def parse_node(
144
214
  raw: dict[str, Any],
145
215
  source: str,
@@ -158,7 +228,8 @@ def parse_node(
158
228
  ``check_reads=False`` skips *only* that :func:`ensure_single_read` call —
159
229
  the read-shape gate — leaving every other rule here (required fields,
160
230
  criticality, the ``reads`` map's own shape, output-column types and
161
- uniqueness, ``config``, ``content_hash``) in force. It exists for
231
+ uniqueness, ``mode_type`` and ``primary_key``, ``config``,
232
+ ``content_hash``) in force. It exists for
162
233
  :func:`~continuo_python_runtime.harness.run_node`; see
163
234
  :func:`load_contract_dir` for why the runtime opts out.
164
235
  """
@@ -229,6 +300,27 @@ def parse_node(
229
300
  raise ContractError(f"{label}: 'secret_ref' is not allowed for kind {kind}")
230
301
  secret_ref = validate_secret_ref(raw["secret_ref"], label)
231
302
 
303
+ timeout_minutes = raw.get("timeout_minutes")
304
+ if "timeout_minutes" in raw and (
305
+ type(timeout_minutes) is not int
306
+ or not MIN_TIMEOUT_MINUTES <= timeout_minutes <= MAX_TIMEOUT_MINUTES
307
+ ):
308
+ raise ContractError(
309
+ f"{label}: 'timeout_minutes' must be an integer between "
310
+ f"{MIN_TIMEOUT_MINUTES} and {MAX_TIMEOUT_MINUTES}, got {timeout_minutes!r}"
311
+ )
312
+
313
+ mode_type = raw.get("mode_type", DEFAULT_MODE_TYPE)
314
+ if not isinstance(mode_type, str) or mode_type not in MODE_TYPES:
315
+ raise ContractError(
316
+ f"{label}: 'mode_type' must be one of {sorted(MODE_TYPES)}, got {mode_type!r}"
317
+ )
318
+ if mode_type not in rules.mode_types:
319
+ raise ContractError(
320
+ f"{label}: 'mode_type' {mode_type!r} is not allowed for kind {kind!r} "
321
+ f"(allowed: {sorted(rules.mode_types)})"
322
+ )
323
+
232
324
  raw_columns = raw.get("output_columns")
233
325
  if not isinstance(raw_columns, list) or not raw_columns:
234
326
  raise ContractError(f"{label}: 'output_columns' must be a non-empty list")
@@ -269,6 +361,8 @@ def parse_node(
269
361
  seen_names.add(name)
270
362
  columns.append(Column(name=name, type=col_type, nullable=nullable))
271
363
 
364
+ primary_key = _validate_primary_key(raw, mode_type, columns, label)
365
+
272
366
  description = raw.get("description", "")
273
367
  if not isinstance(description, str):
274
368
  raise ContractError(f"{label}: 'description' must be a string")
@@ -294,6 +388,9 @@ def parse_node(
294
388
  content_hash=content_hash,
295
389
  kind=kind,
296
390
  secret_ref=secret_ref,
391
+ mode_type=mode_type,
392
+ primary_key=primary_key,
393
+ timeout_minutes=timeout_minutes,
297
394
  )
298
395
 
299
396
 
@@ -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
 
@@ -161,6 +170,10 @@ def build_wire_contract(
161
170
 
162
171
  primary_bytes, member_bytes = _HASH_INPUTS[node.kind](node, repo_root)
163
172
  entry.update(hash_parts(entry, primary_bytes, member_bytes))
173
+ # The one wire key outside config_hash: it is written after the hashes
174
+ # are computed, so changing it alone never changes content_hash.
175
+ if node.timeout_minutes is not None:
176
+ entry["timeout_minutes"] = node.timeout_minutes
164
177
 
165
178
  wire_nodes.append(entry)
166
179
 
@@ -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,9 @@ 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, ...] = ()
48
+ timeout_minutes: int | None = None
41
49
 
42
50
  @property
43
51
  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
 
@@ -21,6 +21,10 @@ from json import dumps
21
21
 
22
22
  HASH_PART_FIELDS = ("source_hash", "shared_code_hash", "config_hash")
23
23
  CONTENT_HASH_FIELD = "content_hash"
24
+ # A wire key that is not part of the config_hash basis: it tunes how the node
25
+ # runs (the task timeout) and says nothing about what the node computes, so
26
+ # changing it alone is not a code change.
27
+ NON_HASHED_FIELDS = ("timeout_minutes",)
24
28
 
25
29
 
26
30
  def canonical_entry(entry: dict) -> dict:
@@ -28,13 +32,14 @@ def canonical_entry(entry: dict) -> dict:
28
32
 
29
33
  Drops ``content_hash`` and all three of ``HASH_PART_FIELDS`` from the
30
34
  basis, and whitespace-normalizes every ``str`` value in ``reads`` via
31
- ``" ".join(sql.split())``. Everything else is preserved verbatim,
32
- including ``config``.
35
+ ``" ".join(sql.split())``. ``timeout_minutes`` is the one other wire key
36
+ left out of the basis (``NON_HASHED_FIELDS``). Everything else is
37
+ preserved verbatim, including ``config``.
33
38
  """
34
39
  canonical = deepcopy(entry)
35
40
 
36
41
  canonical.pop(CONTENT_HASH_FIELD, None)
37
- for field in HASH_PART_FIELDS:
42
+ for field in (*HASH_PART_FIELDS, *NON_HASHED_FIELDS):
38
43
  canonical.pop(field, None)
39
44
 
40
45
  reads = canonical.get("reads")
@@ -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.8.0
3
+ Version: 0.10.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.1
16
16
  Requires-Dist: pyarrow==25.0.1
17
17
  Requires-Dist: pyyaml==6.0.3
18
18
  Requires-Dist: sqlglot==30.17.0
@@ -73,7 +73,8 @@ engine adapters, the validation runner, and the node harness. The former
73
73
  `continuo-validation` repository was merged in — there is no longer a separate
74
74
  validation-side port, adapter class, entry-point group, or image. One
75
75
  `WarehouseAdapter` per engine serves both the data plane (`fetch` /
76
- `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` /
77
78
  `build_empty_from_sql` / `build_empty_from_columns` / `clone_empty_from_prod` /
78
79
  `check_binds`), and one image per engine runs both roles.
79
80
 
@@ -121,9 +122,13 @@ the Go parser has not been taught is a production outage, not a refactor.
121
122
  service name (one service name per domain repo).
122
123
  3. Configure repository variables in GitHub (Settings → Secrets and
123
124
  variables → Actions): `REGISTRY` (your Docker registry), `BUCKET` (your
124
- S3 bucket for contract artifacts), `RELEASE_ENDPOINT` (the release
125
- webhook endpoint). `RELEASE_ENDPOINT` is the **base URL** of the Continuo
126
- 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.
127
132
  4. Configure repository secrets: `AWS_ACCESS_KEY_ID` and
128
133
  `AWS_SECRET_ACCESS_KEY` for the S3 upload. The template workflow pushes
129
134
  the built image to GHCR using the workflow's own `GITHUB_TOKEN` (granted
@@ -191,6 +196,71 @@ produced, never how it is checked or written.
191
196
  script reads with `os.environ[...]`. The contract never holds a secret value
192
197
  or env var name; the Secret itself is created in the cluster by the operator.
193
198
 
199
+ ### Task timeout
200
+
201
+ Any node may set `timeout_minutes:` — an integer from 1 to 10,080 (one week) —
202
+ to declare how long its task may run before continuo gives up on it. A value
203
+ outside that range, or one that is not an integer, fails
204
+ `continuo-runtime validate`/`merge`/`lint`. It is the one key that is not part
205
+ of `config_hash`, so changing only the timeout never changes the node's
206
+ `content_hash`.
207
+
208
+ ## Write modes
209
+
210
+ `mode_type:` chooses how the harness writes a node's table on each run:
211
+
212
+ | `mode_type` | Each run | Rows from earlier runs |
213
+ | --- | --- | --- |
214
+ | `replace` (default) | empties the table, inserts this run's rows | gone |
215
+ | `append` | inserts this run's rows | kept |
216
+ | `upsert` | inserts rows with a new key, overwrites rows whose key exists | kept unless overwritten; never deleted |
217
+
218
+ ```yaml
219
+ - schema: fx
220
+ table: fx_rates
221
+ kind: python-api
222
+ mode_type: upsert
223
+ primary_key: [rate_date, base, quote]
224
+ output_columns:
225
+ - {name: rate_date, type: DATE, nullable: false}
226
+ - {name: base, type: VARCHAR(3), nullable: false}
227
+ - {name: quote, type: VARCHAR(3), nullable: false}
228
+ - {name: rate, type: DECIMAL(12,6)}
229
+ ```
230
+
231
+ - `python-csv` nodes only `replace`.
232
+ - `primary_key` is required for `upsert` and rejected otherwise: a non-empty
233
+ list of output columns declared `nullable: false` (a NULL key never
234
+ matches), each spelled exactly as in `output_columns`, names unique
235
+ case-insensitively. A Postgres `replace` table that
236
+ wants uniqueness uses `config.indexes` with `unique: true`.
237
+ - Every write is atomic: all of the run's rows or none. An `append` or
238
+ `upsert` run with zero rows writes nothing (a `replace` run with zero rows
239
+ still empties the table).
240
+ - `append` is a plain insert: a run that committed but was reported failed
241
+ and is retried stores its rows twice. `upsert` is idempotent.
242
+ - An `upsert` run whose own rows repeat a key fails with `ConformError` and
243
+ writes nothing.
244
+ - Before an `append` or `upsert` the harness compares the live table with
245
+ `output_columns` — names case-insensitively, nullability exactly; order and
246
+ types are not compared — and fails with `LoadError` naming every missing
247
+ column, unexpected column and nullability mismatch. Changing an `append` or
248
+ `upsert` table (columns, types, nullability, key or mode) needs a new table
249
+ name (`_v2`) under a new contract; continuo rejects the release otherwise.
250
+ Adding a nullable column is no exception: the existing rows cannot be
251
+ backfilled.
252
+ - The installed engine must support the mode; all three shipped adapters do.
253
+ A bring-your-own adapter declares `AppendCapable` / `UpsertCapable` from
254
+ `continuo-engine-contract` (and can run its conformance suite); without them
255
+ an `append`/`upsert` node fails with `LoadError: <Adapter> does not support
256
+ <mode>` before its script runs.
257
+ - Engines: on Postgres the key is the table's `PRIMARY KEY`, created with the
258
+ table; DuckLake and Trino/Iceberg have no constraints, so the key is
259
+ guaranteed by the write path only. On Trino an `upsert` table needs Iceberg
260
+ `format_version` 2 (the default).
261
+ - `full_refresh` stays refused for every python node: a rebuild would destroy
262
+ an accumulating table.
263
+
194
264
  ## The script API
195
265
 
196
266
  A node script is a Python file with exactly one required entry point:
@@ -277,8 +347,8 @@ text.
277
347
  | `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. |
278
348
  | `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. |
279
349
  | `ScriptError` | `run()` raised, has no callable `run`, or returned a value that isn't Arrow-convertible. | The node script. |
280
- | `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. |
281
- | `LoadError` | Adapter construction failed, or the DDL/INSERT failed at the warehouse during the write. | Warehouse connectivity/permissions, or the target table. |
350
+ | `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. |
351
+ | `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. |
282
352
 
283
353
  ## Engine selection
284
354
 
@@ -286,18 +356,21 @@ A domain repo picks its warehouse engine by which base image it builds
286
356
  `FROM`:
287
357
 
288
358
  ```dockerfile
289
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.8.0
359
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.10.0
360
+ # or
361
+ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.10.0
290
362
  # or
291
- FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.8.0
363
+ FROM ghcr.io/carolsimone/continuo-python-runtime-duckdb:v0.10.0
292
364
  ```
293
365
 
294
366
  The engine is part of the image **name**; the tag is the bare version, so
295
367
  Continuo's Helm chart can pin an image as `<name>:vX.Y.Z@sha256:<digest>`.
296
368
 
297
369
  Each image bakes in exactly one `WarehouseAdapter` for that engine — the
298
- pinned PyPI version of the `continuo-postgres-adapter` or
299
- `continuo-trino-adapter` package built from this repo's `adapters/postgres/`
300
- or `adapters/trino/` source (see the table above) — registered under the
370
+ pinned PyPI version of the `continuo-postgres-adapter`,
371
+ `continuo-trino-adapter` or `continuo-duckdb-adapter` package built from this
372
+ repo's `adapters/postgres/`, `adapters/trino/` or `adapters/duckdb/` source
373
+ (see the table above) — registered under the
301
374
  `continuo_engine.adapters` entry-point group (entry names `postgres` /
302
375
  `trino` / `duckdb`). The runtime discovers it via `discover_adapter()` at run time, so a
303
376
  single image serves every node in the service and the release-time
@@ -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
10
- continuo_python_runtime/hashing.py,sha256=70iXQb0CmjGqniTPofXY6WapydQ7aK2XOk5dzpEf-zk,3354
9
+ continuo_python_runtime/harness.py,sha256=vm2wL7cOHT2uvrIgDZkNXAV7aT-6x7dQoCKirr41oJ0,16798
10
+ continuo_python_runtime/hashing.py,sha256=7lIkWOM50xt9syFNPNSTLG3T5qxqGnA6doHXPAub0Bg,3711
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=UqmN1RdtlebV3F-kwFcH5DDVbdxcKgnhGR7Sm6HCcMY,18999
17
+ continuo_python_runtime/contract/merge.py,sha256=DCyX9j2TIVgqPmKyFzU-dvy-VX526FZdfcS7-XWxO9c,8381
18
+ continuo_python_runtime/contract/model.py,sha256=uQs0hKkdu55XP6dcbxRFGrMsUe7lSV3QJk1UvOd_3Zo,1474
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.8.0.dist-info/METADATA,sha256=q2tb518F9n_Bo4LIzbhhGSGm5yJPxbrOkWJKJ6y9gxQ,19874
27
- continuo_python_runtime-0.8.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
28
- continuo_python_runtime-0.8.0.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
29
- continuo_python_runtime-0.8.0.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
30
- continuo_python_runtime-0.8.0.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
31
- continuo_python_runtime-0.8.0.dist-info/RECORD,,
27
+ continuo_python_runtime-0.10.0.dist-info/METADATA,sha256=3ji0upgqKfQevT-K1R15WiKqSt1XTkHfpbSaqQa33E8,23796
28
+ continuo_python_runtime-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
29
+ continuo_python_runtime-0.10.0.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
30
+ continuo_python_runtime-0.10.0.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
31
+ continuo_python_runtime-0.10.0.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
32
+ continuo_python_runtime-0.10.0.dist-info/RECORD,,