continuo-python-runtime 0.1.0__py3-none-any.whl → 0.3.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.
@@ -30,11 +30,17 @@ def main(argv: list[str] | None = None) -> int:
30
30
  parser = argparse.ArgumentParser(prog="continuo-runtime")
31
31
  subparsers = parser.add_subparsers(dest="command", required=True)
32
32
 
33
+ dialect_help = (
34
+ "sqlglot dialect the reads are authored in (e.g. postgres, trino); "
35
+ "defaults to sqlglot's dialect-neutral parser."
36
+ )
37
+
33
38
  # validate subcommand
34
39
  validate_parser = subparsers.add_parser(
35
40
  "validate", help="Validate contract directory"
36
41
  )
37
42
  validate_parser.add_argument("contract_dir", help="Path to contract directory")
43
+ validate_parser.add_argument("--dialect", default=None, help=dialect_help)
38
44
 
39
45
  # merge subcommand
40
46
  merge_parser = subparsers.add_parser(
@@ -44,6 +50,7 @@ def main(argv: list[str] | None = None) -> int:
44
50
  merge_parser.add_argument("--service", required=True, help="Service name")
45
51
  merge_parser.add_argument("--repo-root", required=True, help="Repository root path")
46
52
  merge_parser.add_argument("--out", required=True, help="Output file path")
53
+ merge_parser.add_argument("--dialect", default=None, help=dialect_help)
47
54
 
48
55
  # hash subcommand
49
56
  hash_parser = subparsers.add_parser(
@@ -53,6 +60,7 @@ def main(argv: list[str] | None = None) -> int:
53
60
  hash_parser.add_argument(
54
61
  "--repo-root", required=True, help="Repository root path"
55
62
  )
63
+ hash_parser.add_argument("--dialect", default=None, help=dialect_help)
56
64
 
57
65
  # lint subcommand
58
66
  lint_parser = subparsers.add_parser(
@@ -65,19 +73,32 @@ def main(argv: list[str] | None = None) -> int:
65
73
  "run", help="Execute a node script (container entrypoint)"
66
74
  )
67
75
 
76
+ # validation-op subcommand
77
+ subparsers.add_parser(
78
+ "validation-op",
79
+ help="Execute one blue/green validation operation (container command; "
80
+ "driven by VALIDATION_OP and the engine Secret env)",
81
+ )
82
+
68
83
  args = parser.parse_args(argv)
69
84
 
70
85
  try:
71
86
  if args.command == "validate":
72
- return cmd_validate(args.contract_dir)
87
+ return cmd_validate(args.contract_dir, args.dialect)
73
88
  elif args.command == "merge":
74
- return cmd_merge(args.contract_dir, args.service, args.repo_root, args.out)
89
+ return cmd_merge(
90
+ args.contract_dir, args.service, args.repo_root, args.out, args.dialect
91
+ )
75
92
  elif args.command == "hash":
76
- return cmd_hash(args.contract_dir, args.repo_root)
93
+ return cmd_hash(args.contract_dir, args.repo_root, args.dialect)
77
94
  elif args.command == "lint":
78
95
  return cmd_lint(args.path)
79
96
  elif args.command == "run":
80
97
  return cmd_run()
98
+ elif args.command == "validation-op":
99
+ from continuo_python_runtime.validation.runner import main as validation_op_main
100
+ validation_op_main()
101
+ return 0
81
102
  except HarnessError as exc:
82
103
  logger.error("%s", exc)
83
104
  return 1
@@ -88,27 +109,35 @@ def main(argv: list[str] | None = None) -> int:
88
109
  return 0
89
110
 
90
111
 
91
- def cmd_validate(contract_dir: str) -> int:
112
+ def cmd_validate(contract_dir: str, dialect: str | None = None) -> int:
92
113
  """Validate contracts in a directory."""
93
- load_contract_dir(Path(contract_dir))
114
+ load_contract_dir(Path(contract_dir), dialect=dialect)
94
115
  return 0
95
116
 
96
117
 
97
- def cmd_merge(contract_dir: str, service: str, repo_root: str, out: str) -> int:
118
+ def cmd_merge(
119
+ contract_dir: str,
120
+ service: str,
121
+ repo_root: str,
122
+ out: str,
123
+ dialect: str | None = None,
124
+ ) -> int:
98
125
  """Merge contracts into a wire contract file."""
99
- doc = build_wire_contract(Path(contract_dir), Path(repo_root), service)
126
+ doc = build_wire_contract(Path(contract_dir), Path(repo_root), service, dialect=dialect)
100
127
  write_wire_contract(doc, Path(out))
101
128
  return 0
102
129
 
103
130
 
104
- def cmd_hash(contract_dir: str, repo_root: str) -> int:
131
+ def cmd_hash(contract_dir: str, repo_root: str, dialect: str | None = None) -> int:
105
132
  """Print relation and content hash for each node.
106
133
 
107
134
  Reuses build_wire_contract for consistent hashing and error handling.
108
135
  Service value is irrelevant to per-node hashes since entries don't include it.
109
136
  """
110
137
  # Reuse build_wire_contract for consistent hashing and error handling
111
- doc = build_wire_contract(Path(contract_dir), Path(repo_root), service="_hash")
138
+ doc = build_wire_contract(
139
+ Path(contract_dir), Path(repo_root), service="_hash", dialect=dialect
140
+ )
112
141
 
113
142
  # Print relation\thash from wire contract nodes (already sorted by relation)
114
143
  for node_entry in doc["nodes"]:
@@ -0,0 +1,268 @@
1
+ """Static in-repo import-closure resolver.
2
+
3
+ CI's ``content_hash`` is the sole change detector Continuo uses to decide
4
+ whether a node needs revalidation. Until this module existed, that hash
5
+ covered only the node's own script file, so a byte edit to a shared helper
6
+ module the script imports changed nothing — the node kept running against a
7
+ stale fingerprint in production. ``resolve_closure`` closes that gap: it
8
+ returns the transitive set of in-repo Python files a script reaches through
9
+ its ``import`` statements, so a later stage can fold their bytes into the
10
+ hash too.
11
+
12
+ That framing decides every ambiguous call in the algorithm below:
13
+ under-inclusion is a correctness bug (a stale node silently running in
14
+ production), while over-inclusion is merely a spurious revalidation.
15
+
16
+ - Search roots (rule 4) are the fixed, ordered pair ``(repo_root,
17
+ script_dir)`` — ``script_dir`` computed once from the seed script and
18
+ reused, unchanged, for every resolution in the traversal, never the
19
+ current file's own directory. This mirrors
20
+ :func:`continuo_python_runtime.harness.ensure_import_paths` exactly, which
21
+ puts that same pair on ``sys.path`` for the process lifetime: the static
22
+ model and the runtime model are provably the same list. A module importing
23
+ a sibling that is on neither root (e.g. ``lib/shared.py`` doing ``import
24
+ sibling`` for a file at ``lib/sibling.py``, when the script itself lives
25
+ elsewhere) raises ``ImportError`` at run time, so excluding that sibling
26
+ from the closure cannot hide a stale-node bug — that node cannot run at
27
+ all. When the script lives at ``repo_root`` itself, the pair collapses to
28
+ a single root so the same candidate is not probed twice.
29
+ - Within a search root, the package form (``<root>/a/b/c/__init__.py``) is
30
+ tried before the module-file form (``<root>/a/b/c.py``), matching
31
+ Python's own lookup order: when both exist, ``import a.b.c`` binds the
32
+ package, and the module file of the same name is never executed.
33
+ - ``from pkg import name`` (rule 3) is expanded to include both ``pkg`` and
34
+ ``pkg.name`` as candidate dotted names, because ``name`` may be a
35
+ submodule (a file) rather than an attribute of ``pkg`` — the two are
36
+ indistinguishable from the import statement's syntax alone. The same
37
+ applies to relative imports: ``from . import name`` always includes the
38
+ bare enclosing package as a candidate too, not only when the import also
39
+ names a submodule (``from .mod import name``) — real Python executes the
40
+ package's ``__init__.py`` either way, so treating the two forms
41
+ asymmetrically would under-include exactly the file this module exists to
42
+ stop missing. This choice leans deliberately toward over-inclusion:
43
+ ``name`` may turn out to be a plain attribute rather than a submodule, in
44
+ which case the extra candidate simply fails to resolve.
45
+
46
+ Dynamic-import constructs (``importlib``, ``builtins``, ``__builtins__``,
47
+ ``__import__``, ``exec``, ``eval``, ``.import_module``) are rejected outright
48
+ rather than degrading to "resolve what we can": whatever a script imports
49
+ through one of those, this static analysis cannot see, so the hash could
50
+ never be trusted to reflect it.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import ast
56
+ from collections import deque
57
+ from pathlib import Path
58
+
59
+ from continuo_python_runtime.errors import ContractError
60
+
61
+ _DYNAMIC_IMPORT_MODULES = frozenset({"importlib", "builtins"})
62
+ _DYNAMIC_IMPORT_NAMES = frozenset({"__import__", "exec", "eval", "__builtins__"})
63
+ _DYNAMIC_IMPORT_ATTRS = frozenset({"import_module", "__import__"})
64
+
65
+
66
+ def dynamic_import_violations(tree: ast.AST) -> list[tuple[int, str]]:
67
+ """Return `(lineno, construct)` for every dynamic-import construct in *tree*.
68
+
69
+ Flags, per AST node type:
70
+
71
+ - `ast.Import`: any alias whose root module (`name.split(".")[0]`) is
72
+ `importlib` or `builtins` — e.g. `import importlib.util`, `import
73
+ builtins`. `construct` is that root module name.
74
+ - `ast.ImportFrom`: if the root module is `importlib` or `builtins`,
75
+ one violation naming that module — and nothing else from the same
76
+ statement (one statement, one violation; its aliases are not also
77
+ inspected). Otherwise, any `alias.name` in `{"__import__", "exec",
78
+ "eval"}` is a violation named for that alias — this catches
79
+ re-exports of those names from a module that is neither `importlib`
80
+ nor `builtins` (e.g. `from somewhere import exec as e`), which no
81
+ other rule here would see.
82
+ - `ast.Name`: `id` in `{"__import__", "exec", "eval", "__builtins__"}` (a
83
+ bare reference or call). `__builtins__` is included because it is
84
+ injected into every module's globals by the harness's own loader
85
+ (`spec_from_file_location` + `exec_module`, see `harness.py`) as the
86
+ builtins *dict* — so `__builtins__['exec'](...)` and
87
+ `__builtins__['__import__'](...)` need no preceding `import builtins`
88
+ at all, and flagging the bare name catches every subscript form in one
89
+ rule.
90
+ - `ast.Attribute`: `attr` in `{"import_module", "__import__"}`.
91
+ Deliberately NOT `exec`/`eval`: those are legitimate method names on
92
+ arbitrary objects (pandas `DataFrame.eval`/`DataFrame.query`, used in
93
+ ordinary node scripts that return a dataframe), and flagging them here
94
+ would reject that legitimate user code. `builtins.exec` /
95
+ `builtins.eval` (attribute access on the `builtins` module after an
96
+ explicit `import builtins`) are still caught, because that `import` is
97
+ itself an `ast.Import` violation above — but `__builtins__['exec']`
98
+ needs no such import, which is exactly what the `ast.Name` rule above
99
+ exists to close.
100
+
101
+ `construct` is the offending name as written: "importlib", "builtins",
102
+ "__import__", "exec", "eval", "__builtins__", or "import_module". Sorted
103
+ by lineno, then construct.
104
+ """
105
+ violations: list[tuple[int, str]] = []
106
+ for node in ast.walk(tree):
107
+ if isinstance(node, ast.Import):
108
+ for alias in node.names:
109
+ root = alias.name.split(".")[0]
110
+ if root in _DYNAMIC_IMPORT_MODULES:
111
+ violations.append((node.lineno, root))
112
+ elif isinstance(node, ast.ImportFrom):
113
+ from_root = node.module.split(".")[0] if node.module is not None else None
114
+ if from_root in _DYNAMIC_IMPORT_MODULES:
115
+ violations.append((node.lineno, from_root))
116
+ continue # one statement, one violation - aliases not also checked
117
+ for alias in node.names:
118
+ if alias.name in _DYNAMIC_IMPORT_NAMES:
119
+ violations.append((node.lineno, alias.name))
120
+ elif isinstance(node, ast.Name):
121
+ if node.id in _DYNAMIC_IMPORT_NAMES:
122
+ violations.append((node.lineno, node.id))
123
+ elif isinstance(node, ast.Attribute):
124
+ if node.attr in _DYNAMIC_IMPORT_ATTRS:
125
+ violations.append((node.lineno, node.attr))
126
+ return sorted(violations)
127
+
128
+
129
+ def _package_components(importing_file: Path, repo_root: Path) -> tuple[str, ...]:
130
+ """Dotted-path components of *importing_file*'s parent directory,
131
+ relative to *repo_root* (empty tuple when the file lives at the root)."""
132
+ rel_parent = importing_file.parent.relative_to(repo_root)
133
+ if rel_parent == Path("."):
134
+ return ()
135
+ return rel_parent.parts
136
+
137
+
138
+ def _names_from_import(node: ast.Import) -> list[str]:
139
+ return [alias.name for alias in node.names]
140
+
141
+
142
+ def _names_from_import_from(
143
+ node: ast.ImportFrom, importing_file: Path, repo_root: Path
144
+ ) -> list[str]:
145
+ if node.level == 0:
146
+ # level == 0 always carries a module per the grammar (`from X import Y`).
147
+ base = node.module
148
+ assert base is not None
149
+ return [base] + [f"{base}.{alias.name}" for alias in node.names]
150
+
151
+ components = _package_components(importing_file, repo_root)
152
+ walk_up = node.level - 1
153
+ if walk_up > len(components):
154
+ return [] # would escape repo_root - cannot name an in-repo file
155
+
156
+ prefix_parts = components[: len(components) - walk_up]
157
+ prefix = ".".join(prefix_parts)
158
+ base = ".".join(part for part in (prefix, node.module) if part)
159
+
160
+ # Real Python executes the package's __init__.py for `from . import name`
161
+ # regardless of whether `name` is a submodule file or just an attribute
162
+ # defined inside __init__.py - the two are indistinguishable from the
163
+ # import statement's syntax alone, so the bare prefix/base is always a
164
+ # candidate (symmetric with the level == 0 branch above), not only when
165
+ # node.module is also present. Missing __init__.py here would be
166
+ # under-inclusion (a stale node in production); including it when it was
167
+ # already reachable is a no-op, and including it spuriously costs at
168
+ # most one revalidation - see the module docstring.
169
+ names = [base] if base else []
170
+ names.extend(f"{base}.{alias.name}" if base else alias.name for alias in node.names)
171
+ return names
172
+
173
+
174
+ def _collect_import_names(tree: ast.AST, importing_file: Path, repo_root: Path) -> list[str]:
175
+ names: list[str] = []
176
+ for node in ast.walk(tree):
177
+ if isinstance(node, ast.Import):
178
+ names.extend(_names_from_import(node))
179
+ elif isinstance(node, ast.ImportFrom):
180
+ names.extend(_names_from_import_from(node, importing_file, repo_root))
181
+ return names
182
+
183
+
184
+ def _resolve_name(
185
+ name: str, search_roots: tuple[Path, ...]
186
+ ) -> tuple[Path, Path, tuple[str, ...]] | None:
187
+ """Resolve a dotted module name to (root, resolved file, name parts).
188
+
189
+ Tries each root in *search_roots*, in order, and for each, the package
190
+ form `<root>/a/b/c/__init__.py` before the module-file form
191
+ `<root>/a/b/c.py` - matching Python's own package-before-module lookup
192
+ order (when both exist, `import a.b.c` binds the package and the module
193
+ file of the same name is never executed). Returns None when the name
194
+ resolves under no root (it's external).
195
+ """
196
+ parts = tuple(name.split("."))
197
+ for root in search_roots:
198
+ package_init = root.joinpath(*parts, "__init__.py")
199
+ if package_init.is_file():
200
+ return root, package_init.resolve(), parts
201
+ module_file = root.joinpath(*parts).with_suffix(".py")
202
+ if module_file.is_file():
203
+ return root, module_file.resolve(), parts
204
+ return None
205
+
206
+
207
+ def resolve_closure(script_path: Path, repo_root: Path) -> list[Path]:
208
+ """Return the transitive in-repo import closure of *script_path*.
209
+
210
+ Absolute, resolved paths, sorted, with *script_path* itself EXCLUDED (it is
211
+ `source_hash`). Files that do not resolve under *repo_root* — stdlib,
212
+ site-packages, anything installed — are not closure members: external deps
213
+ are the image's concern (`image_tag`), not the hash's.
214
+
215
+ Search roots are the fixed, ordered pair `(repo_root, script_dir)` -
216
+ `script_dir` is *script_path*'s own directory, computed once here and
217
+ reused for every resolution in the traversal, matching what
218
+ `harness.ensure_import_paths` puts on `sys.path` for the whole process.
219
+ When the script lives at *repo_root* itself the pair collapses to a
220
+ single root, so it is not probed twice.
221
+ """
222
+ repo_root = repo_root.resolve()
223
+ script_resolved = script_path.resolve()
224
+ script_dir = script_resolved.parent
225
+ search_roots = (repo_root,) if script_dir == repo_root else (repo_root, script_dir)
226
+
227
+ seen: set[Path] = {script_resolved}
228
+ queue: deque[Path] = deque([script_resolved])
229
+
230
+ while queue:
231
+ current = queue.popleft()
232
+ rel = current.relative_to(repo_root)
233
+
234
+ source = current.read_bytes()
235
+ try:
236
+ tree = ast.parse(source)
237
+ except SyntaxError as exc:
238
+ raise ContractError(f"{rel}: syntax error: {exc.msg}") from exc
239
+
240
+ violations = dynamic_import_violations(tree)
241
+ if violations:
242
+ lineno, construct = violations[0]
243
+ raise ContractError(
244
+ f"{rel}:{lineno}: dynamic import construct {construct!r} is not "
245
+ "allowed — the content hash cannot see it"
246
+ )
247
+
248
+ for name in _collect_import_names(tree, current, repo_root):
249
+ match = _resolve_name(name, search_roots)
250
+ if match is None:
251
+ continue
252
+ root, resolved_file, parts = match
253
+
254
+ candidates = [resolved_file]
255
+ for depth in range(1, len(parts)):
256
+ init_candidate = root.joinpath(*parts[:depth], "__init__.py")
257
+ if init_candidate.is_file():
258
+ candidates.append(init_candidate.resolve())
259
+
260
+ for candidate in candidates:
261
+ if not candidate.is_relative_to(repo_root):
262
+ continue # symlink escape
263
+ if candidate in seen:
264
+ continue
265
+ seen.add(candidate)
266
+ queue.append(candidate)
267
+
268
+ return sorted(seen - {script_resolved})
@@ -10,13 +10,13 @@ from continuo_python_runtime.contract.model import Node
10
10
  from continuo_python_runtime.errors import ReadError
11
11
 
12
12
  if TYPE_CHECKING:
13
- from continuo_validation_contract.port import RuntimeAdapter # type: ignore[import-untyped]
13
+ from continuo_engine_contract.port import WarehouseAdapter # type: ignore[import-untyped]
14
14
 
15
15
 
16
16
  class RunContext:
17
17
  """Manages declared read access for a node execution."""
18
18
 
19
- def __init__(self, node: Node, adapter: "RuntimeAdapter") -> None:
19
+ def __init__(self, node: Node, adapter: "WarehouseAdapter") -> None:
20
20
  """Initialize context with a node and adapter.
21
21
 
22
22
  Args: