continuo-engine-contract 0.7.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.
@@ -0,0 +1 @@
1
+ """Contract for the Continuo warehouse engines: the WarehouseAdapter port and the result-block wire format."""
@@ -0,0 +1,50 @@
1
+ """The contract's physical-layout ``config`` guard.
2
+
3
+ A node's ``config`` block is written in the ACTIVE ENGINE's own vocabulary
4
+ (postgres: ``indexes``; trino: ``partitioning``/``sorted_by``/…) rather than
5
+ under an engine namespace: the contract is engine-bound anyway, so there is no
6
+ "someone else's namespace" to excuse a key the engine does not know. An
7
+ unrecognized key is therefore an authoring error and must reject the node — a
8
+ config the author believes is applied but is silently dropped would ship an
9
+ unindexed, unpartitioned table to production behind a green release gate.
10
+
11
+ Adapters call :func:`ensure_known_keys` before building any DDL.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Mapping
16
+
17
+
18
+ def ensure_known_keys(
19
+ config: object, known: Iterable[str], engine: str, where: str = "config"
20
+ ) -> None:
21
+ """Raise ValueError unless *config* is a mapping whose keys all lie in *known*.
22
+
23
+ Parameters
24
+ ----------
25
+ config
26
+ The physical-layout block as it arrived from the validation spec.
27
+ Anything that is not a mapping is rejected rather than coerced.
28
+ known
29
+ The keys this engine's adapter recognizes. May be empty.
30
+ engine
31
+ Engine name, quoted into the message so an author reading a failed
32
+ release gate knows which vocabulary was expected.
33
+ where
34
+ Label for the block under inspection, so a nested caller (an index
35
+ entry, say) can point the message at the right part of the config.
36
+
37
+ Raises
38
+ ------
39
+ ValueError
40
+ If *config* is not a mapping, or carries any key outside *known*.
41
+ """
42
+ if not isinstance(config, Mapping):
43
+ raise ValueError(f"{where} must be a mapping, got {type(config).__name__}")
44
+ known_set = set(known)
45
+ unknown = sorted(repr(key) for key in config if key not in known_set)
46
+ if unknown:
47
+ raise ValueError(
48
+ f"unrecognized {where} key(s) {', '.join(unknown)} for engine {engine!r}; "
49
+ f"known keys: {', '.join(sorted(known_set)) or '(none)'}"
50
+ )
@@ -0,0 +1,189 @@
1
+ """The warehouse-engine adapter port and entry-point discovery.
2
+
3
+ One port covers both roles an engine plays in Continuo:
4
+
5
+ * **validation DDL** — a release gate builds each node as an EMPTY table in the
6
+ candidate schema and bind-checks its compiled SQL (``ensure_schema``,
7
+ ``drop_schema``, ``build_empty_from_sql``, ``build_empty_from_columns``,
8
+ ``clone_empty_from_prod``, ``check_binds``);
9
+ * **python-node data-plane I/O** — the runtime harness reads declared inputs and
10
+ writes a node's result (``fetch``, ``ensure_table``, ``load``).
11
+
12
+ Both roles are served by the same connection to the same warehouse, so they are
13
+ one implementation per engine rather than two. Engine packages register a single
14
+ ``continuo_engine.adapters`` entry point; each image installs exactly one, so
15
+ discovery — not configuration — selects the adapter.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+ from importlib.metadata import entry_points
21
+ from typing import TYPE_CHECKING
22
+
23
+ ENTRY_POINT_GROUP = "continuo_engine.adapters"
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ import pyarrow # type: ignore[import-untyped]
27
+
28
+
29
+ class AdapterDiscoveryError(Exception):
30
+ """Installed adapter plugins do not resolve to exactly one usable engine."""
31
+
32
+
33
+ class WarehouseAdapter(ABC):
34
+ """Port for everything Continuo asks of one warehouse engine.
35
+
36
+ stdout is a parsed channel: the caller prints one sentinel-framed result
37
+ block (see ``result.py``) as its last stdout line. Adapters must log
38
+ diagnostics via the stdlib ``logging`` module (captured from the pod log)
39
+ rather than printing them, must never write to stdout, and must never emit
40
+ the ``===CONTINUO_VALIDATION_RESULT_BEGIN/END===`` marker strings
41
+ themselves.
42
+ """
43
+
44
+ @classmethod
45
+ @abstractmethod
46
+ def required_env(cls) -> list[str]:
47
+ """Names of env vars that must be non-empty before connecting."""
48
+
49
+ @classmethod
50
+ @abstractmethod
51
+ def from_env(cls) -> "WarehouseAdapter":
52
+ """Construct a connected adapter from environment variables."""
53
+
54
+ @abstractmethod
55
+ def ensure_schema(self, schema: str) -> None:
56
+ """Idempotently create *schema*; safe under concurrent callers."""
57
+
58
+ @abstractmethod
59
+ def drop_schema(self, schema: str) -> None:
60
+ """Idempotently drop *schema* and everything in it; no-op if absent.
61
+
62
+ Symmetric teardown for :meth:`ensure_schema`. The executor schedules this
63
+ as a one-shot engine-image op once a validation reaches its terminal result,
64
+ so the control plane never connects to the warehouse itself. *schema* is
65
+ opaque and mapped to the engine's own dialect.
66
+ """
67
+
68
+ @abstractmethod
69
+ def build_empty_from_sql(self, schema: str, table: str, compiled_sql: str) -> None:
70
+ """Create ``schema.table`` empty, shaped by the compiled SELECT."""
71
+
72
+ @abstractmethod
73
+ def clone_empty_from_prod(self, candidate_schema: str, prod_schema: str, table: str) -> None:
74
+ """Create ``candidate_schema.table`` empty, shaped like ``prod_schema.table``."""
75
+
76
+ @abstractmethod
77
+ def build_empty_from_columns(
78
+ self, schema: str, table: str, columns: list[dict], config: dict
79
+ ) -> None:
80
+ """Create ``schema.table`` empty from declared typed columns.
81
+
82
+ Rebuild semantics (drop-then-create), matching the other build ops.
83
+ Each column dict carries ``name`` (an identifier — implementations
84
+ MUST quote it with their engine's identifier escaping, never
85
+ interpolate it raw), ``type`` (a string from the contract's type
86
+ grammar — see :mod:`continuo_engine_contract.types`;
87
+ implementations MUST validate before interpolating into DDL), and
88
+ optional ``nullable`` (default True). Bare typed CREATE TABLE is
89
+ engine-portable by design — no dialect families.
90
+
91
+ *config* is the node's physical-layout block — indexes, sort keys,
92
+ partitioning — written in THIS engine's own vocabulary and applied to
93
+ the created table (postgres: ``indexes``; trino: ``partitioning``,
94
+ ``sorted_by``, ``format``, ``format_version``). It is deliberately not
95
+ engine-namespaced, so implementations MUST fail closed on any key they
96
+ do not recognize: call
97
+ :func:`continuo_engine_contract.config.ensure_known_keys` with
98
+ this engine's known-key set, and validate every value, BEFORE emitting
99
+ any DDL — a malformed config must never leave a half-built table
100
+ behind. Identifiers and property values taken from *config* MUST be
101
+ escaped or allowlisted before interpolation, exactly as the column
102
+ path already does. An empty *config* MUST emit the same bare
103
+ ``CREATE TABLE`` as contract 0.4.0 — no trailing clause, no property
104
+ list, no index statements.
105
+ """
106
+
107
+ @abstractmethod
108
+ def check_binds(self, sql: str) -> None:
109
+ """Verify *sql* binds against the current schema state without scanning data.
110
+
111
+ EXPLAIN/prepare-class mechanism per engine. Raises on missing
112
+ tables/columns or type errors; returns None when the query binds.
113
+
114
+ *sql* is a single read query, and implementations MUST establish that
115
+ by calling :func:`continuo_engine_contract.sql.ensure_single_read`
116
+ with their engine's dialect **before** building anything to send.
117
+ Wrapping the read in a subquery is not a substitute: a read that closes
118
+ the wrap's own parenthesis and reopens it after an injected statement
119
+ leaves the wrap balanced, and a driver that batches ``;``-separated
120
+ statements then executes the injected statement for real.
121
+ """
122
+
123
+ @abstractmethod
124
+ def fetch(self, sql: str) -> "pyarrow.Table":
125
+ """Execute one declared read and return the result as Arrow."""
126
+
127
+ @abstractmethod
128
+ def ensure_table(
129
+ self, schema: str, table: str, columns: list[dict], *, config: dict
130
+ ) -> None:
131
+ """CREATE TABLE IF NOT EXISTS with the typed DDL compiled from columns.
132
+
133
+ Each column dict carries ``name`` (an identifier — implementations
134
+ MUST quote it with their engine's identifier escaping, never
135
+ interpolate it raw), ``type`` (a string from the contract's type
136
+ grammar — see :mod:`continuo_engine_contract.types`;
137
+ implementations MUST validate before interpolating into DDL), and
138
+ ``nullable`` (bool).
139
+
140
+ *config* is the node's physical-layout block in this engine's own
141
+ vocabulary (e.g. ``indexes``), applied when the table is created;
142
+ callers pass ``{}`` for none. It is keyword-only: the harness passes
143
+ it as ``config=`` unconditionally, and a positional-friendly
144
+ signature would let a caller regress to positional passing unnoticed.
145
+ Implementations MUST fail closed on any config key they do not
146
+ recognize, and MUST validate config values before interpolating
147
+ anything into DDL — the same discipline
148
+ :meth:`build_empty_from_columns` follows.
149
+ """
150
+
151
+ @abstractmethod
152
+ def load(self, schema: str, table: str, data: "pyarrow.Table") -> None:
153
+ """Atomically replace the table's contents with *data*."""
154
+
155
+ @abstractmethod
156
+ def close(self) -> None:
157
+ """Release the underlying connection."""
158
+
159
+
160
+ def discover_adapter() -> tuple[str, type[WarehouseAdapter]]:
161
+ """Return ``(engine_name, adapter_class)`` from the single installed plugin.
162
+
163
+ Raises
164
+ ------
165
+ AdapterDiscoveryError
166
+ If zero or multiple adapters are installed, if the entry point fails to
167
+ import, or if it does not resolve to a WarehouseAdapter subclass.
168
+ """
169
+ eps = list(entry_points(group=ENTRY_POINT_GROUP))
170
+ if not eps:
171
+ raise AdapterDiscoveryError(
172
+ f"no engine adapter installed (entry-point group {ENTRY_POINT_GROUP!r} is "
173
+ f"empty); install exactly one engine package"
174
+ )
175
+ if len(eps) > 1:
176
+ names = ", ".join(sorted(ep.name for ep in eps))
177
+ raise AdapterDiscoveryError(
178
+ f"multiple engine adapters installed ({names}); an image must install exactly one"
179
+ )
180
+ ep = eps[0]
181
+ try:
182
+ cls = ep.load()
183
+ except Exception as exc:
184
+ raise AdapterDiscoveryError(f"failed to load adapter entry point {ep.name!r}: {exc}") from exc
185
+ if not (isinstance(cls, type) and issubclass(cls, WarehouseAdapter)):
186
+ raise AdapterDiscoveryError(
187
+ f"entry point {ep.name!r} does not resolve to a WarehouseAdapter subclass"
188
+ )
189
+ return ep.name, cls
@@ -0,0 +1,34 @@
1
+ """Cross-language contract for one validation node's structured result.
2
+
3
+ validation_runner.py prints exactly one sentinel-framed block on stdout as its LAST
4
+ output. The k8s-controller extracts the block from the pod log, uploads the JSON to
5
+ S3, and surfaces it as run_results_uri. The remediation classifier reads status +
6
+ message deterministically, falling back to the text log when the block is absent.
7
+
8
+ status uses dbt's RunStatus vocabulary (success | error | fail | skipped).
9
+ """
10
+ import json
11
+
12
+ SENTINEL_BEGIN = "===CONTINUO_VALIDATION_RESULT_BEGIN==="
13
+ SENTINEL_END = "===CONTINUO_VALIDATION_RESULT_END==="
14
+
15
+ SCHEMA_VERSION = 1
16
+
17
+
18
+ def result_block(status: str, message: str = "", failures: int = 0, unique_id: str = "") -> str:
19
+ """Return the sentinel-framed, single-line-JSON block for one node.
20
+
21
+ The JSON is compact (no embedded newlines) so the consumer can split the
22
+ block out of a multi-line pod log by marker lines alone.
23
+ """
24
+ body = json.dumps(
25
+ {
26
+ "schema_version": SCHEMA_VERSION,
27
+ "status": status,
28
+ "message": message or "",
29
+ "failures": int(failures or 0),
30
+ "unique_id": unique_id or "",
31
+ },
32
+ separators=(",", ":"),
33
+ )
34
+ return f"{SENTINEL_BEGIN}\n{body}\n{SENTINEL_END}"
@@ -0,0 +1,86 @@
1
+ """The contract's single-read gate for :meth:`WarehouseAdapter.check_binds`.
2
+
3
+ ``check_binds`` accepts a single read query, raises on any bind failure, and
4
+ scans zero data. Enforcing "a single read" by wrapping the text in a subquery
5
+ and letting the engine's parser object is not enough: a read that closes the
6
+ wrap's own parenthesis and reopens it after an injected statement leaves the
7
+ wrap balanced, and an engine whose driver batches ``;``-separated statements
8
+ (psycopg2's simple-query protocol does) then executes the injected statement
9
+ for real. So the shape is decided here, by parsing, before any text reaches an
10
+ engine.
11
+
12
+ This lives in the contract rather than in each adapter because the rule is
13
+ engine-independent and every adapter — including third-party ones — needs the
14
+ same one.
15
+ """
16
+ import logging
17
+
18
+ import sqlglot
19
+
20
+ from sqlglot import exp
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # The expression types a legitimate read parses to. Validated against the full
25
+ # set of read shapes a spec may declare (plain/star selects, WHERE/ORDER
26
+ # BY/LIMIT, CTEs, UNION ALL, GROUP BY, joins, FROM-less selects, VALUES,
27
+ # duplicate output names, trailing semicolons, trailing line comments). Narrow
28
+ # it further and legitimate reads start getting rejected.
29
+ _READ_TYPES = (exp.Select, exp.Union, exp.Values, exp.Subquery)
30
+
31
+ # Rejection messages quote the offending read, and ride back to the controller
32
+ # inside the runner's result block — so bound every variable-length part of
33
+ # them, both the read itself and any parser detail appended to it.
34
+ _MAX_EXCERPT = 200
35
+
36
+
37
+ def _excerpt(text: str) -> str:
38
+ """Render *text* for an error message: whitespace collapsed, length bounded."""
39
+ collapsed = " ".join(text.split())
40
+ if len(collapsed) > _MAX_EXCERPT:
41
+ return f"{collapsed[:_MAX_EXCERPT]}... (truncated)"
42
+ return collapsed
43
+
44
+
45
+ def ensure_single_read(sql: str, dialect: str | None = None) -> None:
46
+ """Raise ValueError unless *sql* is exactly one read query.
47
+
48
+ Parameters
49
+ ----------
50
+ sql
51
+ The declared read, as written in the spec.
52
+ dialect
53
+ A sqlglot dialect name (e.g. ``"postgres"``, ``"trino"``). Adapters
54
+ pass their engine's dialect; ``None`` parses with sqlglot's default.
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ If *sql* does not parse, parses to anything other than exactly one
60
+ statement, or parses to a statement that is not a query. Every
61
+ rejection is a ValueError — a sqlglot ``ParseError`` never escapes, so
62
+ callers need not import sqlglot to handle a rejected read.
63
+ """
64
+ try:
65
+ parsed = sqlglot.parse(sql, dialect=dialect)
66
+ except sqlglot.ParseError as exc:
67
+ raise ValueError(
68
+ f"check_binds accepts a single read query, and this read does not parse "
69
+ f"as {dialect or 'SQL'}: {_excerpt(sql)} [{_excerpt(str(exc))}]"
70
+ ) from exc
71
+
72
+ statements = [statement for statement in parsed if statement is not None]
73
+ if len(statements) != 1:
74
+ raise ValueError(
75
+ f"check_binds accepts exactly one read query, but this read parses as "
76
+ f"{len(statements)} statements: {_excerpt(sql)}"
77
+ )
78
+
79
+ statement = statements[0]
80
+ if not isinstance(statement, _READ_TYPES):
81
+ raise ValueError(
82
+ f"check_binds accepts a single read query, but this read is a "
83
+ f"{statement.key.upper()} statement: {_excerpt(sql)}"
84
+ )
85
+
86
+ logger.debug("read accepted as a single query by the %s gate", dialect or "default")
@@ -0,0 +1,23 @@
1
+ """The contract's SQL column-type grammar.
2
+
3
+ The matched text is interpolated directly into engine DDL by the adapters, so
4
+ this is an injection guard, not just validation — anything outside this shape
5
+ is rejected outright. Kept byte-identical to the runtime adapters' grammar in
6
+ continuo-python-runtime so both planes accept the same type set.
7
+ """
8
+ import re
9
+
10
+ TYPE_RE = re.compile(
11
+ r"^("
12
+ r"BIGINT|INT|INTEGER|DOUBLE PRECISION|TEXT|TIMESTAMP|DATE|BOOLEAN|"
13
+ r"(NUMERIC|DECIMAL)\(\d+,\s*\d+\)|"
14
+ r"(VARCHAR|CHAR)\(\d+\)"
15
+ r")\Z",
16
+ re.IGNORECASE | re.ASCII,
17
+ )
18
+
19
+
20
+ def validate_column_type(type_str: str) -> None:
21
+ """Raise ValueError unless *type_str* matches the contract's SQL type grammar."""
22
+ if not TYPE_RE.match(type_str):
23
+ raise ValueError(f"unsupported or invalid SQL type: {type_str!r}")
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: continuo-engine-contract
3
+ Version: 0.7.0
4
+ Summary: The contract a Continuo warehouse engine must satisfy: the WarehouseAdapter port and the result-block wire format.
5
+ Author: Simone Carolini
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Programming Language :: Python :: 3.14
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: sqlglot==30.15.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # continuo-engine-contract
13
+
14
+ The contract shared by the Continuo python runtime — the node harness and the
15
+ validation runner alike — and every engine adapter library. Five modules:
16
+
17
+ | Module | What it holds |
18
+ |---|---|
19
+ | `continuo_engine_contract.port` | The `WarehouseAdapter` port — one port covering both the data plane (`fetch`/`ensure_table`/`load`) and validation (engine-specific empty-table DDL) — plus entry-point discovery |
20
+ | `continuo_engine_contract.types` | The SQL column-type grammar that DDL-emitting methods validate against |
21
+ | `continuo_engine_contract.sql` | `ensure_single_read`, the parse-backed gate `check_binds` runs before it lets a declared read near an engine |
22
+ | `continuo_engine_contract.config` | `ensure_known_keys`, the fail-closed gate `build_empty_from_columns` runs before it lets a node's physical-layout `config` block reach DDL |
23
+ | `continuo_engine_contract.result` | The sentinel-framed result-block wire format the runner emits and the k8s-controller parses |
24
+
25
+ Each `continuo-python-runtime-<engine>` library depends on this package and
26
+ implements the port.
27
+
28
+ `ensure_single_read` is why this package has a runtime dependency —
29
+ [sqlglot](https://pypi.org/project/sqlglot/), pinned exactly — where it
30
+ previously had none. Deciding "is this one read query?" means parsing it, and
31
+ that rule is engine-independent: every adapter needs the same one, so it lives
32
+ here rather than being reimplemented per engine. Adapters call it with their
33
+ own dialect (`"postgres"`, `"trino"`, …) and it raises `ValueError` on
34
+ anything that is not exactly one query — no sqlglot exception escapes, so
35
+ callers never import sqlglot to handle a rejected read.
36
+
37
+ ## A contract, not a service
38
+
39
+ This package runs nothing of its own — it isn't a service and it isn't the Job
40
+ either. It's the interface both callers agree on: `continuo-python-runtime`
41
+ (the node harness and the validation runner that call it) and every
42
+ `continuo-python-runtime-<engine>` adapter package (which implements it). The
43
+ whole contract is the twelve `WarehouseAdapter` methods (`required_env`,
44
+ `from_env`, `ensure_schema`, `drop_schema`, `build_empty_from_sql`,
45
+ `clone_empty_from_prod`, `build_empty_from_columns`, `check_binds`, `fetch`,
46
+ `ensure_table`, `load`, `close`) plus the sentinel result-block format, both
47
+ reached through the single `continuo_engine.adapters` entry-point group that
48
+ `discover_adapter` resolves against — and the three shared guards
49
+ implementations are required to run rather than reinvent:
50
+ `types.validate_column_type` before interpolating a type into DDL,
51
+ `sql.ensure_single_read` before letting a declared read reach an engine, and
52
+ `config.ensure_known_keys` before building any DDL from a node's
53
+ physical-layout `config` block, to reject keys this engine doesn't recognize.
54
+
55
+ Because the port *is* the contract, this package is very unlikely to need
56
+ changes — new warehouse support is a new adapter package implementing the
57
+ existing methods, not a change here. Touching this file means bumping a
58
+ version that the co-located runtime and adapters in this workspace consume
59
+ immediately, and that external adapter authors pin from PyPI, so treat it as
60
+ a deliberate, coordinated change. The `result` module is stricter still: its
61
+ wire format is frozen against Continuo's Go parser (`pkg/validationresult`)
62
+ and must not change from this side at all.
@@ -0,0 +1,9 @@
1
+ continuo_engine_contract/__init__.py,sha256=mXee3I24TblD9SlhBH3nV3NaYqY4J3rxXPUsgoY1YT8,111
2
+ continuo_engine_contract/config.py,sha256=qhin-NIydq-jufSOc4wQ-77DrdHHPawamsqW910XZhc,2059
3
+ continuo_engine_contract/port.py,sha256=FnKHyVSyJyGlKsncsd9xWJxmL2v1BxQQFVJmJGe2_os,8560
4
+ continuo_engine_contract/result.py,sha256=yuRRJIsyt76PYsnkmMt_RHbh0bs79qZHafy77y3IBFo,1284
5
+ continuo_engine_contract/sql.py,sha256=WaZvxvZCQPiPV4pN5Hjk4neMlSAwm9z1RLwKHjxSAi0,3515
6
+ continuo_engine_contract/types.py,sha256=WrFIZugxap0R6KqazV9EBJ_-JMCw3jeS0QtrjiT1BV0,825
7
+ continuo_engine_contract-0.7.0.dist-info/METADATA,sha256=F0o3SkMmA3YU6bSS1fXg5bDOMTKYZAjFviFdSTepcd0,3788
8
+ continuo_engine_contract-0.7.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ continuo_engine_contract-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any