continuo-python-runtime 0.1.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,208 @@
1
+ """Container entrypoint: dispatches a single node's script and writes its output.
2
+
3
+ ``run_node`` is the sole write sink for a python-model node. It resolves the
4
+ node from the contract, loads and executes its script inside a
5
+ :class:`~continuo_python_runtime.context.RunContext`, conforms the result to
6
+ the declared schema, and writes it through the runtime adapter. Exactly one
7
+ sentinel-framed result block is printed to stdout per run; every other
8
+ diagnostic goes to stderr via ``logging``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import importlib.util
15
+ import logging
16
+ import os
17
+ import sys
18
+ import uuid
19
+ from collections.abc import Mapping
20
+ from pathlib import Path
21
+ from types import ModuleType
22
+ from typing import Any
23
+
24
+ from continuo_validation_contract.port import ( # type: ignore[import-untyped]
25
+ discover_runtime_adapter,
26
+ )
27
+ from continuo_validation_contract.result import result_block # type: ignore[import-untyped]
28
+
29
+ from continuo_python_runtime.conform import conform, to_arrow
30
+ from continuo_python_runtime.context import RunContext
31
+ from continuo_python_runtime.contract.loader import load_contract_dir
32
+ from continuo_python_runtime.contract.model import Node
33
+ from continuo_python_runtime.contract.paths import resolve_script_path
34
+ from continuo_python_runtime.errors import ContractError, HarnessError, LoadError, ScriptError
35
+
36
+ logger = logging.getLogger("continuo_python_runtime.harness")
37
+
38
+
39
+ def _require_env(env: Mapping[str, str], key: str) -> str:
40
+ """Return env[key], raising ContractError if it is missing or empty."""
41
+ value = env.get(key)
42
+ if not value:
43
+ raise ContractError(f"missing required environment variable {key!r}")
44
+ return value
45
+
46
+
47
+ def select_node(nodes: list[Node], node_id: str) -> Node:
48
+ """Select the node matching the trailing ``schema.table`` of ``node_id``.
49
+
50
+ ``node_id`` is split on ``.``; the trailing two segments are taken as
51
+ ``(schema, table)`` and matched against the declared nodes.
52
+
53
+ Raises:
54
+ ContractError: If ``node_id`` has fewer than 2 dot-separated
55
+ segments, or no declared node matches.
56
+ """
57
+ segments = node_id.split(".")
58
+ if len(segments) < 2:
59
+ raise ContractError(
60
+ f"node_id {node_id!r} must have at least 2 dot-separated segments (schema.table)"
61
+ )
62
+ schema, table = segments[-2], segments[-1]
63
+ for node in nodes:
64
+ if node.schema == schema and node.table == table:
65
+ return node
66
+
67
+ available = sorted(f"{n.schema}.{n.table}" for n in nodes)
68
+ raise ContractError(
69
+ f"no node matches {node_id!r} (schema={schema!r}, table={table!r}); "
70
+ f"available relations: {available}"
71
+ )
72
+
73
+
74
+ def load_script(node: Node, repo_root: Path) -> ModuleType:
75
+ """Import ``node.script`` (relative to ``repo_root``) and return the module.
76
+
77
+ Raises:
78
+ ContractError: If the script path is absolute, escapes the
79
+ repository root, or does not exist.
80
+ ScriptError: If the module has no callable ``run``.
81
+ """
82
+ script_path = resolve_script_path(node.script, repo_root, context=node.relation)
83
+
84
+ module_name = f"_continuo_node_{uuid.uuid4().hex}"
85
+ spec = importlib.util.spec_from_file_location(module_name, script_path)
86
+ if spec is None or spec.loader is None:
87
+ raise ContractError(f"cannot load script: {node.script}")
88
+
89
+ module = importlib.util.module_from_spec(spec)
90
+ try:
91
+ spec.loader.exec_module(module)
92
+ except Exception as exc:
93
+ raise ScriptError(f"script import failed: {exc}") from exc
94
+
95
+ if not callable(getattr(module, "run", None)):
96
+ raise ScriptError(f"script {node.script} has no callable 'run'")
97
+
98
+ return module
99
+
100
+
101
+ def build_adapter() -> Any:
102
+ """Discover and construct the single installed runtime adapter.
103
+
104
+ Raises:
105
+ LoadError: If any of the adapter's ``required_env()`` vars are unset
106
+ or empty in ``os.environ``.
107
+ """
108
+ _, cls = discover_runtime_adapter()
109
+ required = cls.required_env()
110
+ missing = [key for key in required if not os.environ.get(key)]
111
+ if missing:
112
+ raise LoadError(f"missing required warehouse env: {sorted(missing)}")
113
+ return cls.from_env()
114
+
115
+
116
+ def _execute_script(module: ModuleType, ctx: RunContext) -> Any:
117
+ """Run ``module.run(ctx)`` with stdout redirected to stderr.
118
+
119
+ Raises:
120
+ HarnessError: Propagated as-is (e.g. a ``ReadError`` from ``ctx.read``).
121
+ ScriptError: Wraps any other exception raised by user code.
122
+ """
123
+ with contextlib.redirect_stdout(sys.stderr):
124
+ try:
125
+ return module.run(ctx)
126
+ except HarnessError:
127
+ raise
128
+ except Exception as exc:
129
+ raise ScriptError(f"run() raised {exc.__class__.__name__}: {exc}") from exc
130
+
131
+
132
+ def run_node(env: Mapping[str, str], adapter: Any = None) -> int:
133
+ """Run a single node end-to-end and print exactly one sentinel result block.
134
+
135
+ Returns 0 on success, 1 on any :class:`HarnessError`.
136
+ """
137
+ node_id = env.get("NODE_ID") or ""
138
+ active_adapter: Any = None
139
+ try:
140
+ node_id = _require_env(env, "NODE_ID")
141
+ table_name = _require_env(env, "TABLE_NAME")
142
+ target_schema = _require_env(env, "TARGET_SCHEMA")
143
+
144
+ contract_dir = Path(env.get("CONTRACT_DIR") or "/app/contracts")
145
+ app_root = Path(env["APP_ROOT"]) if env.get("APP_ROOT") else contract_dir.parent
146
+
147
+ logger.info("running node %s -> %s.%s", node_id, target_schema, table_name)
148
+
149
+ nodes = load_contract_dir(contract_dir)
150
+ node = select_node(nodes, node_id)
151
+
152
+ with contextlib.redirect_stdout(sys.stderr):
153
+ module = load_script(node, app_root)
154
+
155
+ if adapter is not None:
156
+ active_adapter = adapter
157
+ else:
158
+ try:
159
+ active_adapter = build_adapter()
160
+ except HarnessError:
161
+ raise
162
+ except Exception as exc:
163
+ raise LoadError(f"adapter construction failed: {exc}") from exc
164
+
165
+ ctx = RunContext(node, active_adapter)
166
+ raw_result = _execute_script(module, ctx)
167
+
168
+ table = to_arrow(raw_result)
169
+ conformed = conform(table, node.output_columns, node.extra_columns)
170
+
171
+ columns = [
172
+ {"name": c.name, "type": c.type, "nullable": c.nullable}
173
+ for c in node.output_columns
174
+ ]
175
+ try:
176
+ active_adapter.ensure_table(target_schema, table_name, columns)
177
+ active_adapter.load(target_schema, table_name, conformed)
178
+ except HarnessError:
179
+ raise
180
+ except Exception as exc:
181
+ raise LoadError(f"failed to write {target_schema}.{table_name}: {exc}") from exc
182
+
183
+ print(result_block("success", message=f"rows={conformed.num_rows}", unique_id=node_id))
184
+ return 0
185
+ except HarnessError as err:
186
+ print(
187
+ result_block(
188
+ "error", message=err.sentinel_message(), failures=1, unique_id=node_id
189
+ )
190
+ )
191
+ return 1
192
+ except Exception as exc:
193
+ logger.exception("unexpected failure running node %s", node_id)
194
+ print(
195
+ result_block(
196
+ "error",
197
+ message=f"ScriptError: unexpected failure: {exc}",
198
+ failures=1,
199
+ unique_id=node_id,
200
+ )
201
+ )
202
+ return 1
203
+ finally:
204
+ if active_adapter is not None:
205
+ try:
206
+ active_adapter.close()
207
+ except Exception:
208
+ logger.warning("adapter.close() failed", exc_info=True)
@@ -0,0 +1,34 @@
1
+ import copy
2
+ import hashlib
3
+ import json
4
+
5
+
6
+ def canonical_entry(entry: dict) -> dict:
7
+ """
8
+ Deep-copy the entry, drop content_hash field, and whitespace-normalize
9
+ every value in reads via " ".join(sql.split()).
10
+ """
11
+ canonical = copy.deepcopy(entry)
12
+
13
+ # Drop content_hash if present
14
+ canonical.pop("content_hash", None)
15
+
16
+ # Whitespace-normalize every value in reads
17
+ if "reads" in canonical and isinstance(canonical["reads"], dict):
18
+ for key, value in canonical["reads"].items():
19
+ if isinstance(value, str):
20
+ canonical["reads"][key] = " ".join(value.split())
21
+
22
+ return canonical
23
+
24
+
25
+ def content_hash(entry: dict, script_bytes: bytes) -> str:
26
+ """
27
+ Compute content hash using the formula:
28
+ "sha256:" + sha256(json.dumps(canonical_entry(entry), sort_keys=True, separators=(",", ":")).encode() + b"\x00" + script_bytes).hexdigest()
29
+ """
30
+ canonical = canonical_entry(entry)
31
+ json_str = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
32
+ data = json_str.encode() + b"\x00" + script_bytes
33
+ hash_digest = hashlib.sha256(data).hexdigest()
34
+ return "sha256:" + hash_digest
@@ -0,0 +1,295 @@
1
+ """Script linting for forbidden imports, SQL literals, and data-access calls."""
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+
7
+ # Forbidden warehouse driver modules (check root module of imports)
8
+ FORBIDDEN_DRIVERS = {
9
+ "psycopg2",
10
+ "sqlalchemy",
11
+ "trino",
12
+ "snowflake",
13
+ "pyodbc",
14
+ "duckdb",
15
+ "sqlite3",
16
+ "pymysql",
17
+ "mysql",
18
+ "clickhouse_driver",
19
+ "pyhive",
20
+ }
21
+
22
+ # SQL pattern: detects select, insert, update, delete, create table statements
23
+ SQL_PATTERN = re.compile(
24
+ r"(?is)\b(select\s.+?\sfrom\s|insert\s+into\s|update\s.+?\sset\s|delete\s+from\s|create\s+table\s)"
25
+ )
26
+
27
+ # Forbidden data-access method calls
28
+ FORBIDDEN_CALLS = {"read_sql", "read_sql_query", "read_sql_table", "execute", "read_database"}
29
+
30
+
31
+ def _reconstruct_joined_str(node: ast.JoinedStr) -> tuple[str, set[int]]:
32
+ """Reconstruct text from a JoinedStr (f-string) by joining Constant parts
33
+ with space placeholders.
34
+
35
+ Returns the reconstructed text together with the ids of the Constant
36
+ nodes that were actually folded into it (the JoinedStr's direct literal
37
+ parts). Constants nested inside a ``FormattedValue`` expression (e.g.
38
+ ``f'{"select ... from ..."}'``) are NEVER consumed here — they are
39
+ replaced with a space placeholder and remain visible to the plain-Constant
40
+ pass in ``lint_source``.
41
+ """
42
+ parts = []
43
+ consumed: set[int] = set()
44
+ for value in node.values:
45
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
46
+ parts.append(value.value)
47
+ consumed.add(id(value))
48
+ else:
49
+ # FormattedValue or other node type - use space placeholder.
50
+ # Its contents (including any nested Constants) are intentionally
51
+ # left unconsumed.
52
+ parts.append(" ")
53
+ return "".join(parts), consumed
54
+
55
+
56
+ def _reconstruct_binop_concat(node: ast.BinOp) -> tuple[str, set[int]] | None:
57
+ """Reconstruct string from BinOp(Add) with string Constant/JoinedStr leaves.
58
+
59
+ Returns the reconstructed text together with the ids of the Constant
60
+ nodes actually folded into it: leaf string Constants and the literal
61
+ fragments of any JoinedStr operand (including nested JoinedStr/BinOp
62
+ fragments). Constants that live inside a FormattedValue expression are
63
+ never included, matching ``_reconstruct_joined_str``.
64
+ """
65
+ if not isinstance(node.op, ast.Add):
66
+ return None
67
+
68
+ # Collect all parts of the binary operation tree
69
+ def collect_parts(n: ast.expr) -> tuple[list[str], set[int]] | None:
70
+ if isinstance(n, ast.Constant) and isinstance(n.value, str):
71
+ return [n.value], {id(n)}
72
+ elif isinstance(n, ast.JoinedStr):
73
+ text, consumed = _reconstruct_joined_str(n)
74
+ return [text], consumed
75
+ elif isinstance(n, ast.BinOp) and isinstance(n.op, ast.Add):
76
+ left = collect_parts(n.left)
77
+ right = collect_parts(n.right)
78
+ if left is not None and right is not None:
79
+ return left[0] + right[0], left[1] | right[1]
80
+ return None
81
+ else:
82
+ return None
83
+
84
+ result = collect_parts(node)
85
+ if result is None:
86
+ return None
87
+ parts, consumed = result
88
+ return "".join(parts), consumed
89
+
90
+
91
+ def _docstring_constant_ids(tree: ast.Module) -> set[int]:
92
+ """Return ids of Constant nodes that are docstrings.
93
+
94
+ A str Constant is a docstring if it is the value of an ``ast.Expr``
95
+ statement appearing as the FIRST statement of a Module/ClassDef/
96
+ FunctionDef/AsyncFunctionDef body.
97
+ """
98
+ ids: set[int] = set()
99
+ for node in ast.walk(tree):
100
+ if isinstance(
101
+ node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
102
+ ):
103
+ body = node.body
104
+ if (
105
+ body
106
+ and isinstance(body[0], ast.Expr)
107
+ and isinstance(body[0].value, ast.Constant)
108
+ and isinstance(body[0].value.value, str)
109
+ ):
110
+ ids.add(id(body[0].value))
111
+ return ids
112
+
113
+
114
+ def lint_source(source: str, filename: str) -> list[str]:
115
+ """Lint a Python source code string for violations.
116
+
117
+ Returns a list of violations in the format:
118
+ "<filename>:<lineno>: <rule text>"
119
+
120
+ Rules:
121
+ - L1: forbidden warehouse driver import
122
+ - L2: SQL string literal (including in f-strings and concatenated strings)
123
+ - L3: forbidden data-access call (attribute or imported function)
124
+ - L4: private/protected attribute access (any ``ast.Attribute`` whose
125
+ ``attr`` starts with ``_``), closing bypasses like ``ctx._fetch``.
126
+ ``__name__``/``__main__`` are ``ast.Name`` nodes, not attributes, so
127
+ they are never matched and need no exemption. Exempted: attribute
128
+ access on ``self``/``cls`` (e.g. ``self._helper()``), so a script's own
129
+ class-private helpers aren't flagged.
130
+
131
+ Docstrings (the first statement of a Module/ClassDef/FunctionDef/
132
+ AsyncFunctionDef body, when it is a bare string Expr) are exempt from the
133
+ L2 constant pass, since prose commonly contains words like "select" and
134
+ "from". This is a best-effort, position-based exemption: the same prose
135
+ assigned to a variable is still flagged.
136
+ """
137
+ violations = []
138
+
139
+ # Try to parse the source code
140
+ try:
141
+ tree = ast.parse(source)
142
+ except SyntaxError as e:
143
+ return [f"{filename}:{e.lineno}: syntax error: {e.msg}"]
144
+
145
+ # Track imported data-access functions and their local aliases
146
+ imported_forbidden_calls: dict[str, int] = {} # {local_name: lineno}
147
+
148
+ # First pass: collect information about imports
149
+ for node in ast.walk(tree):
150
+ # L1: Check for forbidden imports
151
+ if isinstance(node, ast.Import):
152
+ for alias in node.names:
153
+ root_module = alias.name.split(".")[0]
154
+ if root_module in FORBIDDEN_DRIVERS:
155
+ violations.append(
156
+ f"{filename}:{node.lineno}: forbidden warehouse driver import '{root_module}'"
157
+ )
158
+
159
+ elif isinstance(node, ast.ImportFrom):
160
+ if node.module:
161
+ root_module = node.module.split(".")[0]
162
+ if root_module in FORBIDDEN_DRIVERS:
163
+ violations.append(
164
+ f"{filename}:{node.lineno}: forbidden warehouse driver import '{root_module}'"
165
+ )
166
+
167
+ # Track imports of forbidden data-access functions
168
+ if node.names:
169
+ for alias in node.names:
170
+ # alias.name is the original name, alias.asname is the local alias (or None)
171
+ if alias.name in FORBIDDEN_CALLS:
172
+ local_name = alias.asname if alias.asname else alias.name
173
+ imported_forbidden_calls[local_name] = node.lineno
174
+ violations.append(
175
+ f"{filename}:{node.lineno}: forbidden data-access import '{alias.name}'"
176
+ )
177
+
178
+ # Track constants consumed in compound expressions (to avoid double-reporting)
179
+ consumed_constants: set[int] = set()
180
+
181
+ # Docstrings are exempt from the L2 constant pass (prose false positives)
182
+ docstring_constants = _docstring_constant_ids(tree)
183
+
184
+ # Second pass: check for SQL and L3 violations
185
+ for node in ast.walk(tree):
186
+ # L2: Check for SQL in JoinedStr (f-strings)
187
+ if isinstance(node, ast.JoinedStr):
188
+ reconstructed, consumed = _reconstruct_joined_str(node)
189
+ if SQL_PATTERN.search(reconstructed):
190
+ snippet = reconstructed[:40]
191
+ if len(reconstructed) > 40:
192
+ snippet += "..."
193
+ violations.append(
194
+ f"{filename}:{node.lineno}: SQL string literal '{snippet}'"
195
+ )
196
+ # Only the literal fragments actually folded into the
197
+ # reconstruction are consumed; FormattedValue contents are not.
198
+ consumed_constants |= consumed
199
+
200
+ # L2: Check for SQL in BinOp concatenation
201
+ elif isinstance(node, ast.BinOp):
202
+ result = _reconstruct_binop_concat(node)
203
+ if result is not None:
204
+ reconstructed_text, consumed = result
205
+ if SQL_PATTERN.search(reconstructed_text):
206
+ snippet = reconstructed_text[:40]
207
+ if len(reconstructed_text) > 40:
208
+ snippet += "..."
209
+ violations.append(
210
+ f"{filename}:{node.lineno}: SQL string literal '{snippet}'"
211
+ )
212
+ # Only the leaf Constants actually folded into the
213
+ # reconstruction are consumed - never descendants of a
214
+ # FormattedValue expression.
215
+ consumed_constants |= consumed
216
+
217
+ # L2: Check for SQL in plain string literals (skip if consumed by
218
+ # compound reconstruction, or if it's a docstring)
219
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str):
220
+ if id(node) not in consumed_constants and id(node) not in docstring_constants:
221
+ if SQL_PATTERN.search(node.value):
222
+ snippet = node.value[:40]
223
+ if len(node.value) > 40:
224
+ snippet += "..."
225
+ violations.append(
226
+ f"{filename}:{node.lineno}: SQL string literal '{snippet}'"
227
+ )
228
+
229
+ # L3: Check for forbidden data-access calls
230
+ elif isinstance(node, ast.Call):
231
+ # Attribute access: pd.read_sql(...)
232
+ if isinstance(node.func, ast.Attribute):
233
+ if node.func.attr in FORBIDDEN_CALLS:
234
+ violations.append(
235
+ f"{filename}:{node.lineno}: forbidden data-access call '{node.func.attr}'"
236
+ )
237
+
238
+ # Name reference: read_sql_table(...) or rs(...) where rs is an alias
239
+ elif isinstance(node.func, ast.Name):
240
+ if node.func.id in imported_forbidden_calls:
241
+ violations.append(
242
+ f"{filename}:{node.lineno}: forbidden data-access call '{node.func.id}'"
243
+ )
244
+
245
+ # L4: Check for private/protected attribute access
246
+ elif isinstance(node, ast.Attribute):
247
+ is_self_or_cls = isinstance(node.value, ast.Name) and node.value.id in (
248
+ "self",
249
+ "cls",
250
+ )
251
+ if node.attr.startswith("_") and not is_self_or_cls:
252
+ violations.append(
253
+ f"{filename}:{node.lineno}: private attribute access '{node.attr}'"
254
+ )
255
+
256
+ return violations
257
+
258
+
259
+ def lint_paths(paths: list[Path]) -> list[str]:
260
+ """Lint Python files in the given paths.
261
+
262
+ For each path:
263
+ - If it's a directory, recursively find all .py files (sorted)
264
+ - If it's a file, lint it directly
265
+ - If path does not exist, record a violation instead of crashing
266
+
267
+ Returns aggregate violations in order.
268
+ """
269
+ violations = []
270
+
271
+ # Collect all Python files to lint
272
+ files_to_lint = []
273
+ for path in paths:
274
+ path = Path(path)
275
+ if not path.exists():
276
+ violations.append(f"{path}: path does not exist")
277
+ elif path.is_dir():
278
+ # Recursively find all .py files, sorted
279
+ py_files = sorted(path.rglob("*.py"))
280
+ files_to_lint.extend(py_files)
281
+ else:
282
+ # Single file
283
+ files_to_lint.append(path)
284
+
285
+ # Lint each file
286
+ for filepath in files_to_lint:
287
+ try:
288
+ source = filepath.read_text(encoding="utf-8")
289
+ except (OSError, UnicodeDecodeError) as exc:
290
+ violations.append(f"{filepath}: unreadable: {exc}")
291
+ continue
292
+ file_violations = lint_source(source, str(filepath))
293
+ violations.extend(file_violations)
294
+
295
+ return violations