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.
- continuo_engine_contract/__init__.py +1 -0
- continuo_engine_contract/config.py +50 -0
- continuo_engine_contract/port.py +189 -0
- continuo_engine_contract/result.py +34 -0
- continuo_engine_contract/sql.py +86 -0
- continuo_engine_contract/types.py +23 -0
- continuo_engine_contract-0.7.0.dist-info/METADATA +62 -0
- continuo_engine_contract-0.7.0.dist-info/RECORD +9 -0
- continuo_engine_contract-0.7.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|