continuo-python-runtime 0.8.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.
- continuo_python_runtime/contract/kinds.py +12 -5
- continuo_python_runtime/contract/loader.py +82 -1
- continuo_python_runtime/contract/merge.py +11 -2
- continuo_python_runtime/contract/model.py +7 -0
- continuo_python_runtime/harness.py +100 -4
- continuo_python_runtime/shape.py +95 -0
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/METADATA +77 -13
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/RECORD +12 -11
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/WHEEL +0 -0
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/entry_points.txt +0 -0
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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
|
-
|
|
5
|
-
``RULES``; nothing that parses a contract
|
|
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(
|
|
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, ``
|
|
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
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
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
|
+
)
|
{continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: continuo-python-runtime
|
|
3
|
-
Version: 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.
|
|
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
|
|
@@ -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
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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,62 @@ 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
|
+
## 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
|
+
|
|
194
255
|
## The script API
|
|
195
256
|
|
|
196
257
|
A node script is a Python file with exactly one required entry point:
|
|
@@ -277,8 +338,8 @@ text.
|
|
|
277
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. |
|
|
278
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. |
|
|
279
340
|
| `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,
|
|
281
|
-
| `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. |
|
|
282
343
|
|
|
283
344
|
## Engine selection
|
|
284
345
|
|
|
@@ -286,18 +347,21 @@ A domain repo picks its warehouse engine by which base image it builds
|
|
|
286
347
|
`FROM`:
|
|
287
348
|
|
|
288
349
|
```dockerfile
|
|
289
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.
|
|
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
|
|
290
353
|
# or
|
|
291
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-
|
|
354
|
+
FROM ghcr.io/carolsimone/continuo-python-runtime-duckdb:v0.9.0
|
|
292
355
|
```
|
|
293
356
|
|
|
294
357
|
The engine is part of the image **name**; the tag is the bare version, so
|
|
295
358
|
Continuo's Helm chart can pin an image as `<name>:vX.Y.Z@sha256:<digest>`.
|
|
296
359
|
|
|
297
360
|
Each image bakes in exactly one `WarehouseAdapter` for that engine — the
|
|
298
|
-
pinned PyPI version of the `continuo-postgres-adapter
|
|
299
|
-
`continuo-trino-adapter` package built from this
|
|
300
|
-
|
|
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
|
|
301
365
|
`continuo_engine.adapters` entry-point group (entry names `postgres` /
|
|
302
366
|
`trino` / `duckdb`). The runtime discovers it via `discover_adapter()` at run time, so a
|
|
303
367
|
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=
|
|
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=
|
|
15
|
-
continuo_python_runtime/contract/loader.py,sha256=
|
|
16
|
-
continuo_python_runtime/contract/merge.py,sha256=
|
|
17
|
-
continuo_python_runtime/contract/model.py,sha256=
|
|
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.
|
|
27
|
-
continuo_python_runtime-0.
|
|
28
|
-
continuo_python_runtime-0.
|
|
29
|
-
continuo_python_runtime-0.
|
|
30
|
-
continuo_python_runtime-0.
|
|
31
|
-
continuo_python_runtime-0.
|
|
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,,
|
|
File without changes
|
{continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{continuo_python_runtime-0.8.0.dist-info → continuo_python_runtime-0.9.0.dist-info}/licenses/NOTICE
RENAMED
|
File without changes
|