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.
@@ -1,94 +1,93 @@
1
1
  """Content hashing: three independent parts folded into one content_hash.
2
2
 
3
- The three parts (``source_hash``, ``shared_code_hash``, ``config_hash``) are
4
- computed independently from already-gathered bytes/dicts and folded together
5
- with ``content_hash_fold``. This module is pure computation — no filesystem
6
- access, no knowledge of ``closure.py`` — so the known-vector tests can call
7
- ``content_hash_fold`` on bare strings with no entry or file involved.
8
-
9
- The fold formula and the ``shared_code_hash`` sorted-digest fold are wire
10
- contracts mirrored byte-for-byte from continuo's manifest-controller
11
- (``content_hash_fold``) and its dbt-side shared-code hashing. Every byte here
12
- is load-bearing: manifest-controller independently recomputes the fold from
13
- the parts this repo emits and rejects the release artifact if the two
14
- results differ.
3
+ A Python node's ``content_hash`` folds three parts: ``source_hash`` (the node's
4
+ own script bytes), ``shared_code_hash`` (the import closure, each file with its
5
+ repo-relative path) and ``config_hash`` (the node's code projection). The parts
6
+ are computed from already-gathered bytes and a parsed node, so this module does
7
+ no filesystem access and knows nothing about ``closure.py``.
8
+
9
+ ``shared_code_hash``, ``config_hash`` and the fold are computed by
10
+ continuo-contracts, the library continuo's topology-controller pins and
11
+ recomputes the config part with. Nothing here canonicalises or normalises: the
12
+ library does, so every producer of a hash writes the same bytes. The fold is
13
+ the three-part fold that older topology-controllers check on the wire, so an
14
+ entry this module writes passes their re-computation unchanged.
15
+
16
+ The library refuses a few ``config`` values the contract loader accepts (an
17
+ integer beyond 2**53 - 1, NaN or an infinity, nesting deeper than 99 levels, a
18
+ lone surrogate). Each refusal reaches the caller as a ``ContractError`` naming
19
+ the node.
15
20
  """
16
21
 
17
- from collections.abc import Iterable
18
- from copy import deepcopy
22
+ from collections.abc import Mapping
19
23
  from hashlib import sha256
20
- from json import dumps
21
24
 
22
- HASH_PART_FIELDS = ("source_hash", "shared_code_hash", "config_hash")
23
- CONTENT_HASH_FIELD = "content_hash"
24
- # A wire key that is not part of the config_hash basis: it tunes how the node
25
- # runs (the task timeout) and says nothing about what the node computes, so
26
- # changing it alone is not a code change.
27
- NON_HASHED_FIELDS = ("timeout_minutes",)
25
+ from continuo_contracts.errors import ContractsError
26
+ from continuo_contracts.hashing import fold3, shared_hash
27
+ from continuo_contracts.python import PythonColumn, PythonNode, projection_hash
28
28
 
29
+ from continuo_python_runtime.contract.model import Node
30
+ from continuo_python_runtime.errors import ContractError
29
31
 
30
- def canonical_entry(entry: dict) -> dict:
31
- """Deep-copy *entry*, drop the four hash fields, whitespace-normalize reads.
32
32
 
33
- Drops ``content_hash`` and all three of ``HASH_PART_FIELDS`` from the
34
- basis, and whitespace-normalizes every ``str`` value in ``reads`` via
35
- ``" ".join(sql.split())``. ``timeout_minutes`` is the one other wire key
36
- left out of the basis (``NON_HASHED_FIELDS``). Everything else is
37
- preserved verbatim, including ``config``.
38
- """
39
- canonical = deepcopy(entry)
40
-
41
- canonical.pop(CONTENT_HASH_FIELD, None)
42
- for field in (*HASH_PART_FIELDS, *NON_HASHED_FIELDS):
43
- canonical.pop(field, None)
44
-
45
- reads = canonical.get("reads")
46
- if isinstance(reads, dict):
47
- for key, value in reads.items():
48
- if isinstance(value, str):
49
- reads[key] = " ".join(value.split())
33
+ def source_hash(primary: bytes) -> str:
34
+ """Bare hex `sha256(primary)` — the node's own script bytes (a csv node's uri)."""
35
+ return sha256(primary).hexdigest()
50
36
 
51
- return canonical
52
37
 
38
+ def to_python_node(node: Node) -> PythonNode:
39
+ """The library's view of *node*: the fields its code projection is made of."""
40
+ return PythonNode(
41
+ schema=node.schema,
42
+ table=node.table,
43
+ kind=node.kind,
44
+ script=node.script,
45
+ reads=dict(node.reads),
46
+ output_columns=tuple(
47
+ PythonColumn(column.name, column.type, column.nullable)
48
+ for column in node.output_columns
49
+ ),
50
+ extra_columns=node.extra_columns,
51
+ config=dict(node.config),
52
+ secret_ref=node.secret_ref,
53
+ mode_type=node.mode_type,
54
+ primary_key=tuple(node.primary_key),
55
+ assets=node.assets,
56
+ )
53
57
 
54
- def canonical_json(entry: dict) -> str:
55
- """`json.dumps(canonical_entry(entry), sort_keys=True, separators=(",", ":"))`."""
56
- return dumps(canonical_entry(entry), sort_keys=True, separators=(",", ":"))
57
58
 
59
+ def config_hash(node: Node) -> str:
60
+ """Bare hex SHA-256 of the RFC 8785 bytes of the node's code projection."""
61
+ try:
62
+ return projection_hash(to_python_node(node))
63
+ except ContractsError as exc:
64
+ raise ContractError(str(exc)) from exc
58
65
 
59
- def source_hash(script_bytes: bytes) -> str:
60
- """Bare hex `sha256(script_bytes)` — the node's own script, byte-for-byte."""
61
- return sha256(script_bytes).hexdigest()
62
66
 
67
+ def shared_code_hash(units: Mapping[str, bytes]) -> str:
68
+ """`""` when there are no units, else bare hex over each unit's path and digest.
63
69
 
64
- def shared_code_hash(member_bytes: Iterable[bytes]) -> str:
65
- """`""` when the closure is empty, else bare hex of the sorted-digest fold."""
66
- unit_hashes = sorted(sha256(member).hexdigest() for member in member_bytes)
67
- if not unit_hashes:
68
- return ""
69
- return sha256("".join(unit_hashes).encode()).hexdigest()
70
-
71
-
72
- def config_hash(entry: dict) -> str:
73
- """Bare hex `sha256(canonical_json(entry).encode())`."""
74
- return sha256(canonical_json(entry).encode()).hexdigest()
70
+ *units* maps a repo-relative POSIX path to the file's bytes.
71
+ """
72
+ try:
73
+ return shared_hash(units)
74
+ except ContractsError as exc:
75
+ raise ContractError(str(exc)) from exc
75
76
 
76
77
 
77
78
  def content_hash_fold(source: str, shared: str, config: str) -> str:
78
- """`"sha256:" + sha256(f"{source}|{shared}|{config}".encode()).hexdigest()`."""
79
- return "sha256:" + sha256(f"{source}|{shared}|{config}".encode()).hexdigest()
79
+ """`"sha256:"` plus the hex SHA-256 of the three parts joined by `|`."""
80
+ return fold3(source, shared, config)
80
81
 
81
82
 
82
- def hash_parts(
83
- entry: dict, script_bytes: bytes, member_bytes: Iterable[bytes]
84
- ) -> dict[str, str]:
83
+ def hash_parts(node: Node, primary: bytes, units: Mapping[str, bytes]) -> dict[str, str]:
85
84
  """Return all four fields: the three parts plus their fold."""
86
- s = source_hash(script_bytes)
87
- sh = shared_code_hash(member_bytes)
88
- c = config_hash(entry)
85
+ source = source_hash(primary)
86
+ shared = shared_code_hash(units)
87
+ config = config_hash(node)
89
88
  return {
90
- "source_hash": s,
91
- "shared_code_hash": sh,
92
- "config_hash": c,
93
- "content_hash": content_hash_fold(s, sh, c),
89
+ "source_hash": source,
90
+ "shared_code_hash": shared,
91
+ "config_hash": config,
92
+ "content_hash": content_hash_fold(source, shared, config),
94
93
  }
@@ -1,5 +1,5 @@
1
- """Script linting for forbidden imports, SQL literals, data-access calls, and
2
- dynamic-import constructs."""
1
+ """Script linting for forbidden imports, SQL literals, data-access calls,
2
+ dynamic-import constructs, and code the content hash cannot see."""
3
3
 
4
4
  import ast
5
5
  import re
@@ -30,6 +30,16 @@ SQL_PATTERN = re.compile(
30
30
  # Forbidden data-access method calls
31
31
  FORBIDDEN_CALLS = {"read_sql", "read_sql_query", "read_sql_table", "execute", "read_database"}
32
32
 
33
+ # Modules whose import runs code the import closure never reaches.
34
+ _STARTUP_HOOK_MODULES = {"sitecustomize", "usercustomize"}
35
+ _STARTUP_HOOK_FILES = {f"{module}.py" for module in _STARTUP_HOOK_MODULES}
36
+
37
+ # ``list`` methods that change ``sys.path`` in place.
38
+ _PATH_MUTATORS = {"append", "insert", "extend", "remove", "pop", "clear", "reverse", "sort"}
39
+
40
+ # Names ``pickle`` exports that rebuild objects, and so run code, from bytes.
41
+ _PICKLE_LOADERS = {"load", "loads", "Unpickler"}
42
+
33
43
 
34
44
  def _reconstruct_joined_str(node: ast.JoinedStr) -> tuple[str, set[int]]:
35
45
  """Reconstruct text from a JoinedStr (f-string) by joining Constant parts
@@ -114,6 +124,166 @@ def _docstring_constant_ids(tree: ast.Module) -> set[int]:
114
124
  return ids
115
125
 
116
126
 
127
+ def _module_aliases(tree: ast.AST, module: str) -> set[str]:
128
+ """Local names bound to *module* by ``import module`` / ``import module as x``."""
129
+ return {
130
+ alias.asname or alias.name
131
+ for node in ast.walk(tree)
132
+ if isinstance(node, ast.Import)
133
+ for alias in node.names
134
+ if alias.name == module
135
+ }
136
+
137
+
138
+ def _from_imports(tree: ast.AST, module: str) -> dict[str, str]:
139
+ """``{local name: imported name}`` for every ``from module import name [as x]``."""
140
+ return {
141
+ alias.asname or alias.name: alias.name
142
+ for node in ast.walk(tree)
143
+ if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module == module
144
+ for alias in node.names
145
+ }
146
+
147
+
148
+ def _assignment_targets(node: ast.AST) -> list[ast.expr]:
149
+ """Every expression *node* (an assignment or ``del``) writes to, with
150
+ tuple and list targets unpacked."""
151
+ if isinstance(node, ast.Assign):
152
+ pending = list(node.targets)
153
+ elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
154
+ pending = [node.target]
155
+ elif isinstance(node, ast.Delete):
156
+ pending = list(node.targets)
157
+ else:
158
+ return []
159
+ targets: list[ast.expr] = []
160
+ while pending:
161
+ target = pending.pop()
162
+ if isinstance(target, (ast.Tuple, ast.List)):
163
+ pending.extend(target.elts)
164
+ elif isinstance(target, ast.Starred):
165
+ pending.append(target.value)
166
+ else:
167
+ targets.append(target)
168
+ return targets
169
+
170
+
171
+ def unhashed_code_violations(tree: ast.AST) -> list[tuple[int, str]]:
172
+ """Return ``(lineno, construct)`` for every construct in *tree* that runs
173
+ code the content hash does not cover.
174
+
175
+ The hash folds the script and the files it imports, found by reading
176
+ imports. A node that executes anything else, or changes where imports
177
+ resolve, can change its behaviour without changing its hash. Flagged
178
+ constructs, by name:
179
+
180
+ - ``runpy``: any import of it, which runs a file by path.
181
+ - ``sys.path``: a call that mutates it (``append``, ``insert``, ``extend``,
182
+ ``remove``, ``pop``, ``clear``, ``reverse``, ``sort``), an assignment,
183
+ augmented assignment or ``del`` of it or of a subscript of it, and
184
+ ``site.addsitedir``. ``import sys as s`` and ``from sys import path``
185
+ are followed.
186
+ - ``sitecustomize`` and ``usercustomize``: any import of either.
187
+ - ``compile``: a call of the builtin, by its bare name (a ``compile``
188
+ imported from a module, such as ``re.compile``, is not the builtin).
189
+ - ``FunctionType``: ``types.FunctionType`` and the name imported from
190
+ ``types``.
191
+ - ``pickle.load``, ``pickle.loads`` and ``pickle.Unpickler``: attributes of
192
+ ``pickle`` or of an alias of it, and the names imported from it.
193
+
194
+ Names are resolved by the file's imports alone, not by scope or by
195
+ data flow: ``p = sys.path`` followed by ``p.append(...)`` is not seen.
196
+
197
+ Sorted by lineno, then construct.
198
+ """
199
+ sys_names = _module_aliases(tree, "sys")
200
+ site_names = _module_aliases(tree, "site")
201
+ types_names = _module_aliases(tree, "types")
202
+ pickle_names = _module_aliases(tree, "pickle")
203
+ sys_path_names = {local for local, name in _from_imports(tree, "sys").items() if name == "path"}
204
+ addsitedir_names = {
205
+ local for local, name in _from_imports(tree, "site").items() if name == "addsitedir"
206
+ }
207
+ # ``from re import compile`` shadows the builtin.
208
+ imported_names = {
209
+ alias.asname or alias.name
210
+ for node in ast.walk(tree)
211
+ if isinstance(node, ast.ImportFrom)
212
+ for alias in node.names
213
+ }
214
+
215
+ def module_attribute(node: ast.expr, names: set[str], attr: str) -> bool:
216
+ return (
217
+ isinstance(node, ast.Attribute)
218
+ and node.attr == attr
219
+ and isinstance(node.value, ast.Name)
220
+ and node.value.id in names
221
+ )
222
+
223
+ def is_sys_path(node: ast.expr) -> bool:
224
+ return module_attribute(node, sys_names, "path") or (
225
+ isinstance(node, ast.Name) and node.id in sys_path_names
226
+ )
227
+
228
+ def writes_sys_path(target: ast.expr, *, rebinding: bool) -> bool:
229
+ """Whether writing to *target* changes ``sys.path``. Rebinding a bare
230
+ name imported from ``sys`` only changes the local name."""
231
+ if isinstance(target, ast.Subscript):
232
+ return is_sys_path(target.value)
233
+ if rebinding:
234
+ return module_attribute(target, sys_names, "path")
235
+ return is_sys_path(target)
236
+
237
+ violations: list[tuple[int, str]] = []
238
+ for node in ast.walk(tree):
239
+ if isinstance(node, ast.Import):
240
+ for alias in node.names:
241
+ root = alias.name.split(".")[0]
242
+ if root == "runpy" or root in _STARTUP_HOOK_MODULES:
243
+ violations.append((node.lineno, root))
244
+ elif isinstance(node, ast.ImportFrom):
245
+ from_root = node.module.split(".")[0] if node.module and node.level == 0 else None
246
+ if from_root == "runpy" or from_root in _STARTUP_HOOK_MODULES:
247
+ violations.append((node.lineno, from_root))
248
+ elif from_root == "types" and any(a.name == "FunctionType" for a in node.names):
249
+ violations.append((node.lineno, "FunctionType"))
250
+ elif from_root == "pickle":
251
+ violations.extend(
252
+ (node.lineno, f"pickle.{a.name}")
253
+ for a in node.names
254
+ if a.name in _PICKLE_LOADERS
255
+ )
256
+ elif isinstance(node, ast.Call):
257
+ func = node.func
258
+ if isinstance(func, ast.Attribute) and func.attr in _PATH_MUTATORS and is_sys_path(
259
+ func.value
260
+ ):
261
+ violations.append((node.lineno, "sys.path"))
262
+ elif module_attribute(func, site_names, "addsitedir") or (
263
+ isinstance(func, ast.Name) and func.id in addsitedir_names
264
+ ):
265
+ violations.append((node.lineno, "sys.path"))
266
+ elif (
267
+ isinstance(func, ast.Name)
268
+ and func.id == "compile"
269
+ and "compile" not in imported_names
270
+ ):
271
+ violations.append((node.lineno, "compile"))
272
+ elif isinstance(node, ast.Attribute):
273
+ if module_attribute(node, types_names, "FunctionType"):
274
+ violations.append((node.lineno, "FunctionType"))
275
+ elif node.attr in _PICKLE_LOADERS and module_attribute(node, pickle_names, node.attr):
276
+ violations.append((node.lineno, f"pickle.{node.attr}"))
277
+ elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete)):
278
+ rebinding = not isinstance(node, ast.AugAssign)
279
+ if any(
280
+ writes_sys_path(target, rebinding=rebinding)
281
+ for target in _assignment_targets(node)
282
+ ):
283
+ violations.append((node.lineno, "sys.path"))
284
+ return sorted(violations)
285
+
286
+
117
287
  def lint_source(source: str, filename: str) -> list[str]:
118
288
  """Lint a Python source code string for violations.
119
289
 
@@ -137,6 +307,14 @@ def lint_source(source: str, filename: str) -> list[str]:
137
307
  import a module whose edits never re-fingerprint it. Detection is
138
308
  delegated to ``continuo_python_runtime.closure.dynamic_import_violations``
139
309
  so the predicate is defined in exactly one place.
310
+ - L6: unhashed-code construct (``runpy``, a change to ``sys.path``, a
311
+ ``sitecustomize``/``usercustomize`` module, ``compile()``,
312
+ ``types.FunctionType``, ``pickle.load``/``loads``/``Unpickler``), and a
313
+ file named ``sitecustomize.py`` or ``usercustomize.py``. The import
314
+ closure follows imports only, so code these constructs run is not in
315
+ ``content_hash``. Detection is
316
+ ``unhashed_code_violations``. A file a script reads belongs in the
317
+ node's ``assets``, where it is hashed.
140
318
 
141
319
  Docstrings (the first statement of a Module/ClassDef/FunctionDef/
142
320
  AsyncFunctionDef body, when it is a bare string Expr) are exempt from the
@@ -148,11 +326,16 @@ def lint_source(source: str, filename: str) -> list[str]:
148
326
  """
149
327
  entries: list[tuple[int, str]] = []
150
328
 
329
+ # L6: a startup hook runs before the harness, whatever it contains.
330
+ if Path(filename).name in _STARTUP_HOOK_FILES:
331
+ entries.append((1, f"unhashed code construct '{Path(filename).stem}'"))
332
+
151
333
  # Try to parse the source code
152
334
  try:
153
335
  tree = ast.parse(source)
154
336
  except SyntaxError as e:
155
- return [f"{filename}:{e.lineno}: syntax error: {e.msg}"]
337
+ entries.append((e.lineno or 1, f"syntax error: {e.msg}"))
338
+ return [f"{filename}:{lineno}: {message}" for lineno, message in entries]
156
339
 
157
340
  # Track imported data-access functions and their local aliases
158
341
  imported_forbidden_calls: dict[str, int] = {} # {local_name: lineno}
@@ -262,10 +445,15 @@ def lint_source(source: str, filename: str) -> list[str]:
262
445
  for lineno, construct in dynamic_import_violations(tree):
263
446
  entries.append((lineno, f"dynamic import construct '{construct}'"))
264
447
 
265
- # Stable sort by line number: this is what puts L5 hits "alongside" the
266
- # L1-L4 hits found above instead of trailing as a separate block, while
448
+ # L6: constructs that run code the hash cannot see.
449
+ for lineno, construct in unhashed_code_violations(tree):
450
+ entries.append((lineno, f"unhashed code construct '{construct}'"))
451
+
452
+ # Stable sort by line number: this is what puts L5 and L6 hits "alongside"
453
+ # the L1-L4 hits found above instead of trailing as a separate block, while
267
454
  # violations on the same line keep the order the passes above found them
268
- # (L1 first pass, then L2-L4 second pass, then L5).
455
+ # (the file-name L6 entry, at line 1, first; then L1 from the first pass,
456
+ # then L2-L4 from the second pass, then L5, then the construct L6 hits).
269
457
  entries.sort(key=lambda entry: entry[0])
270
458
  return [f"{filename}:{lineno}: {message}" for lineno, message in entries]
271
459