continuo-python-runtime 0.10.0__py3-none-any.whl → 0.12.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.
@@ -4,6 +4,7 @@ import argparse
4
4
  import logging
5
5
  import os
6
6
  import sys
7
+ from importlib import metadata
7
8
  from pathlib import Path
8
9
 
9
10
  from continuo_python_runtime.contract.loader import load_contract_dir
@@ -28,6 +29,11 @@ def main(argv: list[str] | None = None) -> int:
28
29
  )
29
30
 
30
31
  parser = argparse.ArgumentParser(prog="continuo-runtime")
32
+ parser.add_argument(
33
+ "--version",
34
+ action="version",
35
+ version=f"continuo-runtime {metadata.version('continuo-python-runtime')}",
36
+ )
31
37
  subparsers = parser.add_subparsers(dest="command", required=True)
32
38
 
33
39
  dialect_help = (
@@ -0,0 +1,73 @@
1
+ """The harness's start check: the image must hold what the release hashed.
2
+
3
+ continuo sets ``CONTENT_HASH`` to the ``content_hash`` the release promoted for
4
+ the node. The check recomputes the node's hash from the files in the image with
5
+ the helpers ``merge`` uses (``gather_hash_inputs`` and ``hash_parts``), so the
6
+ two can never disagree, and refuses to run a node whose image reproduces a
7
+ different one: the image holds other code, imports, assets or contract than the
8
+ release.
9
+ """
10
+
11
+ import logging
12
+ from collections.abc import Mapping
13
+ from importlib import metadata
14
+ from pathlib import Path
15
+
16
+ from continuo_python_runtime.contract.model import Node
17
+ from continuo_python_runtime.errors import ContractError, ImageContractMismatch
18
+ from continuo_python_runtime.hash_inputs import gather_hash_inputs
19
+ from continuo_python_runtime.hashing import hash_parts
20
+
21
+ logger = logging.getLogger("continuo_python_runtime.content_check")
22
+
23
+ _ENV_KEY = "CONTENT_HASH"
24
+
25
+
26
+ def check_image_contract(node: Node, app_root: Path, env: Mapping[str, str]) -> None:
27
+ """Fail unless the image reproduces the ``content_hash`` in ``CONTENT_HASH``.
28
+
29
+ An unset or empty ``CONTENT_HASH`` logs a warning and skips the check. Any
30
+ other value is compared as an exact string, so a value without the
31
+ ``sha256:`` prefix, in upper case or with surrounding whitespace is a
32
+ mismatch.
33
+
34
+ Raises:
35
+ ImageContractMismatch: The recomputed ``content_hash`` differs, or a
36
+ file the release hashed is missing from the image.
37
+ """
38
+ released = env.get(_ENV_KEY)
39
+ if not released:
40
+ logger.warning(
41
+ "%s is not set: skipping the check that the image holds the release's content for %s",
42
+ _ENV_KEY, node.relation,
43
+ )
44
+ return
45
+
46
+ try:
47
+ inputs = gather_hash_inputs(node, app_root)
48
+ parts = hash_parts(node, inputs.primary, inputs.units)
49
+ except ContractError as exc:
50
+ raise ImageContractMismatch(
51
+ _message(node, f"the image cannot reproduce {_ENV_KEY} {released}: {exc}")
52
+ ) from exc
53
+
54
+ if parts["content_hash"] != released:
55
+ detail = (
56
+ f"the image's content_hash {parts['content_hash']} "
57
+ f"(source_hash {parts['source_hash']}, "
58
+ f"shared_code_hash {parts['shared_code_hash']}, "
59
+ f"config_hash {parts['config_hash']}, "
60
+ f"units {sorted(inputs.units)})"
61
+ )
62
+ raise ImageContractMismatch(_message(node, f"{_ENV_KEY} {released} differs from {detail}"))
63
+
64
+ logger.info("image holds the release's content for %s (%s)", node.relation, released)
65
+
66
+
67
+ def _message(node: Node, detail: str) -> str:
68
+ return (
69
+ f"the image does not hold what the release hashed for {node.relation}: "
70
+ f"{detail}. "
71
+ f"This image runs runtime {metadata.version('continuo-python-runtime')}. "
72
+ "Fix: rebuild the image from the released commit, with CI on the runtime of the image's FROM tag."
73
+ )
@@ -112,14 +112,22 @@ class KindRules:
112
112
  # The write modes a node of this kind may declare. A new kind gets replace
113
113
  # only unless its entry opts into more.
114
114
  mode_types: frozenset[str] = frozenset({DEFAULT_MODE_TYPE})
115
+ # Whether a node of this kind may list the repository files its script reads.
116
+ assets_allowed: bool = False
115
117
 
116
118
 
117
119
  RULES: dict[str, KindRules] = {
118
- "python-node": KindRules(script_required=True, reads=SqlReads(), mode_types=MODE_TYPES),
120
+ "python-node": KindRules(
121
+ script_required=True, reads=SqlReads(), mode_types=MODE_TYPES, assets_allowed=True
122
+ ),
119
123
  # A csv drop is a snapshot of its source: replace only (spec D2).
120
124
  "python-csv": KindRules(script_required=False, reads=CsvRead()),
121
125
  "python-api": KindRules(
122
- script_required=True, reads=NoReads(), secret_allowed=True, mode_types=MODE_TYPES
126
+ script_required=True,
127
+ reads=NoReads(),
128
+ secret_allowed=True,
129
+ mode_types=MODE_TYPES,
130
+ assets_allowed=True,
123
131
  ),
124
132
  }
125
133
 
@@ -7,13 +7,14 @@ structural or semantic violation.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from pathlib import Path
10
+ import re
11
+ from pathlib import Path, PurePosixPath
11
12
  from typing import Any
12
13
 
13
14
  import yaml
14
15
  from sqlglot import Dialect
15
16
 
16
- from continuo_python_runtime.contract.kinds import rules_for
17
+ from continuo_python_runtime.contract.kinds import KindRules, rules_for
17
18
  from continuo_python_runtime.contract.model import (
18
19
  CRITICALITIES,
19
20
  DEFAULT_KIND,
@@ -45,6 +46,7 @@ _ALLOWED_KEYS = {
45
46
  "mode_type",
46
47
  "primary_key",
47
48
  "timeout_minutes",
49
+ "assets",
48
50
  }
49
51
 
50
52
  # Bounds of a node's declared task timeout, in minutes (one minute to one week).
@@ -210,6 +212,52 @@ def _validate_primary_key(
210
212
  return tuple(raw_key)
211
213
 
212
214
 
215
+ _DRIVE_PATH = re.compile(r"^[A-Za-z]:")
216
+
217
+
218
+ def _validate_assets(
219
+ raw: dict[str, Any], rules: KindRules, kind: str, label: str
220
+ ) -> tuple[str, ...]:
221
+ """Validate ``assets``: repo-relative paths of the files a script reads.
222
+
223
+ A kind without a script has nothing that reads a file, so it refuses the
224
+ key. Each path is written with forward slashes, relative to the repository
225
+ root and without a ``..`` segment; it is normalised (``./data//ref.json``
226
+ is ``data/ref.json``) and keeps its declared position. A repeat after
227
+ normalising is refused. Whether the file exists is checked where a
228
+ repository root is known.
229
+ """
230
+ if "assets" not in raw:
231
+ return ()
232
+ if not rules.assets_allowed:
233
+ raise ContractError(
234
+ f"{label}: 'assets' is not allowed for kind {kind} (it has no script to read files)"
235
+ )
236
+ raw_assets = raw["assets"]
237
+ if not isinstance(raw_assets, list):
238
+ raise ContractError(f"{label}: 'assets' must be a list of repository-relative paths")
239
+ assets: list[str] = []
240
+ for entry in raw_assets:
241
+ if not isinstance(entry, str) or not entry.strip():
242
+ raise ContractError(f"{label}: 'assets' entry {entry!r} must be a non-empty string")
243
+ if "\\" in entry:
244
+ raise ContractError(f"{label}: 'assets' entry {entry!r} must use forward slashes")
245
+ path = PurePosixPath(entry)
246
+ if path.is_absolute() or _DRIVE_PATH.match(entry):
247
+ raise ContractError(
248
+ f"{label}: 'assets' entry {entry!r} must be relative to the repository root"
249
+ )
250
+ if ".." in path.parts:
251
+ raise ContractError(f"{label}: 'assets' entry {entry!r} must not contain '..'")
252
+ normalised = path.as_posix()
253
+ if normalised == ".":
254
+ raise ContractError(f"{label}: 'assets' entry {entry!r} must name a file")
255
+ if normalised in assets:
256
+ raise ContractError(f"{label}: 'assets' names {normalised!r} more than once")
257
+ assets.append(normalised)
258
+ return tuple(assets)
259
+
260
+
213
261
  def parse_node(
214
262
  raw: dict[str, Any],
215
263
  source: str,
@@ -362,6 +410,7 @@ def parse_node(
362
410
  columns.append(Column(name=name, type=col_type, nullable=nullable))
363
411
 
364
412
  primary_key = _validate_primary_key(raw, mode_type, columns, label)
413
+ assets = _validate_assets(raw, rules, kind, label)
365
414
 
366
415
  description = raw.get("description", "")
367
416
  if not isinstance(description, str):
@@ -391,6 +440,7 @@ def parse_node(
391
440
  mode_type=mode_type,
392
441
  primary_key=primary_key,
393
442
  timeout_minutes=timeout_minutes,
443
+ assets=assets,
394
444
  )
395
445
 
396
446
 
@@ -1,15 +1,13 @@
1
1
  """Contract v1 merger: wire contract builder."""
2
2
 
3
- from collections.abc import Callable
4
3
  from pathlib import Path
5
4
 
6
5
  import yaml
7
6
 
8
- from continuo_python_runtime.closure import resolve_closure
9
7
  from continuo_python_runtime.contract.loader import load_contract_dir
10
8
  from continuo_python_runtime.contract.model import CONTRACT_VERSION, DEFAULT_MODE_TYPE, Node
11
- from continuo_python_runtime.contract.paths import resolve_script_path
12
9
  from continuo_python_runtime.errors import ContractError
10
+ from continuo_python_runtime.hash_inputs import gather_hash_inputs
13
11
  from continuo_python_runtime.hashing import hash_parts
14
12
  from continuo_python_runtime.lint import lint_source
15
13
 
@@ -21,9 +19,9 @@ def node_entry(node: Node) -> dict:
21
19
  dicts. The entry carries no hash fields: build_wire_contract adds all
22
20
  four (source_hash, shared_code_hash, config_hash, content_hash). The
23
21
  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.
22
+ ``mode_type``, ``primary_key`` and ``assets`` appear only when they differ
23
+ from their default, so the entry of a node that does not use them stays
24
+ minimal.
27
25
  """
28
26
  entry = {
29
27
  "schema": node.schema,
@@ -46,26 +44,24 @@ def node_entry(node: Node) -> dict:
46
44
  "extra_columns": node.extra_columns,
47
45
  "config": dict(node.config),
48
46
  }
49
- # Only a kind that may name a Secret ever carries one; emitting the key
50
- # only when set keeps every other node's config_hash unchanged.
47
+ # Only a kind that may name a Secret ever carries one; the key is emitted
48
+ # only when set.
51
49
  if node.secret_ref:
52
50
  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.
51
+ # Same reasoning: a replace node's entry carries no mode_type.
55
52
  if node.mode_type != DEFAULT_MODE_TYPE:
56
53
  entry["mode_type"] = node.mode_type
57
54
  if node.mode_type == "upsert":
58
55
  entry["primary_key"] = list(node.primary_key)
56
+ if node.assets:
57
+ entry["assets"] = list(node.assets)
59
58
  return entry
60
59
 
61
60
 
62
61
  def _lint_node_closure(
63
62
  node: Node,
64
63
  repo_root: Path,
65
- script_path: Path,
66
- script_bytes: bytes,
67
- closure: list[Path],
68
- member_bytes: list[bytes],
64
+ code_files: tuple[tuple[Path, bytes], ...],
69
65
  ) -> None:
70
66
  """Lint the node's script and every resolved closure member.
71
67
 
@@ -73,7 +69,8 @@ def _lint_node_closure(
73
69
  in-repo import closure — is held to the same lint rules
74
70
  (``continuo_python_runtime.lint.lint_source``: L1 forbidden driver
75
71
  imports, L2 SQL string literals, L3 forbidden data-access calls, L4
76
- private-attribute access, L5 dynamic-import constructs) that CI's
72
+ private-attribute access, L5 dynamic-import constructs, L6 constructs
73
+ that run code the hash cannot see) that CI's
77
74
  ``continuo-runtime lint`` applies to ``scripts/``. Enforcing it again
78
75
  here, at merge time, means a helper file outside ``scripts/`` — which
79
76
  the CI lint step never looks at — cannot become a side channel around
@@ -107,10 +104,8 @@ def _lint_node_closure(
107
104
  catches only ``HarnessError``/``OSError``.
108
105
  """
109
106
  repo_root_resolved = repo_root.resolve()
110
- files = [(script_path, script_bytes), *zip(closure, member_bytes, strict=True)]
111
-
112
107
  violations: list[str] = []
113
- for path, data in files:
108
+ for path, data in code_files:
114
109
  rel = path.relative_to(repo_root_resolved)
115
110
  try:
116
111
  text = data.decode("utf-8-sig")
@@ -125,30 +120,6 @@ def _lint_node_closure(
125
120
  )
126
121
 
127
122
 
128
- def _script_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
129
- """A script node's source is its script plus its in-repo import closure,
130
- every file of which is held to the closure lint."""
131
- script_path = resolve_script_path(node.script, repo_root, context=node.relation)
132
- script_bytes = script_path.read_bytes()
133
- closure = resolve_closure(script_path, repo_root)
134
- member_bytes = [member.read_bytes() for member in closure]
135
- _lint_node_closure(node, repo_root, script_path, script_bytes, closure, member_bytes)
136
- return script_bytes, member_bytes
137
-
138
-
139
- def _csv_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
140
- """A csv node has no script and no import closure: its source is the uri.
141
- New file content at the same uri is new data, not a new node version."""
142
- return node.reads["csv"].encode(), []
143
-
144
-
145
- _HASH_INPUTS: dict[str, Callable[[Node, Path], tuple[bytes, list[bytes]]]] = {
146
- "python-node": _script_hash_inputs,
147
- "python-csv": _csv_hash_inputs,
148
- "python-api": _script_hash_inputs,
149
- }
150
-
151
-
152
123
  def build_wire_contract(
153
124
  contract_dir: Path, repo_root: Path, service: str, *, dialect: str | None = None
154
125
  ) -> dict:
@@ -160,16 +131,26 @@ def build_wire_contract(
160
131
  .load_contract_dir` so every declared read is checked against that sqlglot
161
132
  dialect (``None`` -- the default -- uses sqlglot's dialect-neutral parser).
162
133
 
163
- Raises ContractError if any script file is missing.
134
+ Raises ContractError if any script file is missing, or if the repository
135
+ root holds a ``sitecustomize.py`` or ``usercustomize.py``: Python imports
136
+ either at startup, before the harness, so nothing in a node's import
137
+ closure reaches it.
164
138
  """
165
139
  nodes = load_contract_dir(contract_dir, dialect=dialect)
166
140
 
141
+ for hook in ("sitecustomize.py", "usercustomize.py"):
142
+ if (repo_root / hook).exists():
143
+ raise ContractError(
144
+ f"{hook} at the repository root runs before the harness and is not in the hash"
145
+ )
146
+
167
147
  wire_nodes = []
168
148
  for node in nodes:
169
149
  entry = node_entry(node)
170
150
 
171
- primary_bytes, member_bytes = _HASH_INPUTS[node.kind](node, repo_root)
172
- entry.update(hash_parts(entry, primary_bytes, member_bytes))
151
+ inputs = gather_hash_inputs(node, repo_root)
152
+ _lint_node_closure(node, repo_root, inputs.code_files)
153
+ entry.update(hash_parts(node, inputs.primary, inputs.units))
173
154
  # The one wire key outside config_hash: it is written after the hashes
174
155
  # are computed, so changing it alone never changes content_hash.
175
156
  if node.timeout_minutes is not None:
@@ -46,6 +46,7 @@ class Node:
46
46
  mode_type: str = DEFAULT_MODE_TYPE
47
47
  primary_key: tuple[str, ...] = ()
48
48
  timeout_minutes: int | None = None
49
+ assets: tuple[str, ...] = ()
49
50
 
50
51
  @property
51
52
  def relation(self) -> str:
@@ -36,3 +36,36 @@ def resolve_script_path(script: str, repo_root: Path, *, context: str) -> Path:
36
36
  raise ContractError(f"{context}: script not found: {script}")
37
37
 
38
38
  return script_path
39
+
40
+
41
+ def resolve_asset_path(asset: str, repo_root: Path, *, context: str) -> Path:
42
+ """Resolve ``asset`` (relative to ``repo_root``) and validate it.
43
+
44
+ Rejects absolute paths, paths that escape ``repo_root`` once symlinks
45
+ resolve, paths that do not exist and paths that are not a regular file.
46
+
47
+ Args:
48
+ asset: The asset path as declared in the contract, relative to
49
+ ``repo_root``.
50
+ repo_root: The repository root the asset path is resolved against.
51
+ context: A label prefixed to every error message (e.g. a node's
52
+ relation) to identify which node's asset failed.
53
+
54
+ Raises:
55
+ ContractError: If the path is absolute, escapes the repository root,
56
+ does not exist or is not a file.
57
+ """
58
+ if Path(asset).is_absolute():
59
+ raise ContractError(
60
+ f"{context}: asset path {asset!r} must be relative to the repository root"
61
+ )
62
+
63
+ asset_path = (repo_root / asset).resolve()
64
+ if not asset_path.is_relative_to(repo_root.resolve()):
65
+ raise ContractError(f"{context}: asset path {asset!r} escapes the repository root")
66
+ if not asset_path.exists():
67
+ raise ContractError(f"{context}: asset not found: {asset}")
68
+ if not asset_path.is_file():
69
+ raise ContractError(f"{context}: asset {asset!r} is not a file")
70
+
71
+ return asset_path
@@ -6,7 +6,13 @@ remediation classifier can key off it without parsing free text.
6
6
 
7
7
 
8
8
  class HarnessError(Exception):
9
- """Base for all runtime failures the harness converts to a sentinel block."""
9
+ """Base for all runtime failures the harness converts to a sentinel block.
10
+
11
+ ``reason`` is a machine-readable code the block carries beside the message;
12
+ empty for the errors that have none.
13
+ """
14
+
15
+ reason: str = ""
10
16
 
11
17
  @property
12
18
  def error_class(self) -> str:
@@ -20,6 +26,12 @@ class ContractError(HarnessError):
20
26
  """Contract missing, invalid, node not found, or script missing."""
21
27
 
22
28
 
29
+ class ImageContractMismatch(ContractError):
30
+ """The image does not hold the contract, script, imports or assets the release hashed."""
31
+
32
+ reason = "image_contract_mismatch"
33
+
34
+
23
35
  class ReadError(HarnessError):
24
36
  """Unknown read name, or a declared read failed at the warehouse."""
25
37
 
@@ -32,6 +32,7 @@ from continuo_engine_contract.port import ( # type: ignore[import-untyped]
32
32
  from continuo_engine_contract.result import result_block # type: ignore[import-untyped]
33
33
 
34
34
  from continuo_python_runtime.conform import conform, to_arrow
35
+ from continuo_python_runtime.content_check import check_image_contract
35
36
  from continuo_python_runtime.context import RunContext
36
37
  from continuo_python_runtime.contract.loader import load_contract_dir
37
38
  from continuo_python_runtime.contract.model import Node
@@ -345,6 +346,7 @@ def run_node(
345
346
  # Every other loader validation still runs at container start.
346
347
  nodes = load_contract_dir(contract_dir, check_reads=False)
347
348
  node = select_node(nodes, node_id)
349
+ check_image_contract(node, app_root, env)
348
350
 
349
351
  if adapter is not None:
350
352
  active_adapter = adapter
@@ -392,9 +394,21 @@ def run_node(
392
394
  print(result_block("success", message=f"rows={conformed.num_rows}", unique_id=node_id))
393
395
  return 0
394
396
  except HarnessError as err:
397
+ # Logged as well as printed: the executor cuts the result block out of
398
+ # the pod log before storing it, so the log holds only what is logged.
399
+ # The traceback is kept when the error wraps another exception (a
400
+ # driver error, the script's own exception), whose frames locate it.
401
+ logger.error(
402
+ "node %s failed: %s", node_id, err.sentinel_message(),
403
+ exc_info=err.__cause__ is not None,
404
+ )
395
405
  print(
396
406
  result_block(
397
- "error", message=err.sentinel_message(), failures=1, unique_id=node_id
407
+ "error",
408
+ message=err.sentinel_message(),
409
+ failures=1,
410
+ unique_id=node_id,
411
+ reason=err.reason,
398
412
  )
399
413
  )
400
414
  return 1
@@ -0,0 +1,63 @@
1
+ """The bytes each node kind hashes, gathered from the repository.
2
+
3
+ A script kind hashes its script, its in-repo import closure and the assets it
4
+ declares (a file that is both is one unit). A csv node has
5
+ no script and no closure: its source is its uri, so new file content at the same
6
+ uri is new data, not a new node version.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from continuo_python_runtime.closure import resolve_closure
14
+ from continuo_python_runtime.contract.model import Node
15
+ from continuo_python_runtime.contract.paths import resolve_asset_path, resolve_script_path
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class HashInputs:
20
+ """What a node's hash parts are computed from.
21
+
22
+ ``primary`` is the script's bytes (a csv node's uri, encoded). ``units`` maps
23
+ each closure file's and each declared asset's repo-relative POSIX path to
24
+ its bytes. ``code_files`` holds every file that executes for the node, the
25
+ script first, for the lint.
26
+ """
27
+
28
+ primary: bytes
29
+ units: dict[str, bytes]
30
+ code_files: tuple[tuple[Path, bytes], ...]
31
+
32
+
33
+ def _script_inputs(node: Node, repo_root: Path) -> HashInputs:
34
+ script_path = resolve_script_path(node.script, repo_root, context=node.relation)
35
+ script_bytes = script_path.read_bytes()
36
+ root = repo_root.resolve()
37
+ members = [(member, member.read_bytes()) for member in resolve_closure(script_path, repo_root)]
38
+ units = {member.relative_to(root).as_posix(): data for member, data in members}
39
+ # Assets are data the script reads, not code that runs, so they are hashed
40
+ # but not part of code_files (which the lint reads).
41
+ for asset in node.assets:
42
+ units[asset] = resolve_asset_path(asset, repo_root, context=node.relation).read_bytes()
43
+ return HashInputs(
44
+ primary=script_bytes,
45
+ units=units,
46
+ code_files=((script_path, script_bytes), *members),
47
+ )
48
+
49
+
50
+ def _csv_inputs(node: Node, repo_root: Path) -> HashInputs:
51
+ return HashInputs(node.reads["csv"].encode(), {}, ())
52
+
53
+
54
+ HASH_INPUTS: dict[str, Callable[[Node, Path], HashInputs]] = {
55
+ "python-node": _script_inputs,
56
+ "python-csv": _csv_inputs,
57
+ "python-api": _script_inputs,
58
+ }
59
+
60
+
61
+ def gather_hash_inputs(node: Node, repo_root: Path) -> HashInputs:
62
+ """Read the files *node* hashes, by its kind."""
63
+ return HASH_INPUTS[node.kind](node, repo_root)