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.
- continuo_python_runtime/cli.py +6 -0
- continuo_python_runtime/content_check.py +73 -0
- continuo_python_runtime/contract/kinds.py +10 -2
- continuo_python_runtime/contract/loader.py +52 -2
- continuo_python_runtime/contract/merge.py +26 -45
- continuo_python_runtime/contract/model.py +1 -0
- continuo_python_runtime/contract/paths.py +33 -0
- continuo_python_runtime/errors.py +13 -1
- continuo_python_runtime/harness.py +15 -1
- continuo_python_runtime/hash_inputs.py +63 -0
- continuo_python_runtime/hashing.py +69 -70
- continuo_python_runtime/lint.py +194 -6
- continuo_python_runtime/validation/preflight.py +410 -0
- continuo_python_runtime/validation/runner.py +20 -3
- {continuo_python_runtime-0.10.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/METADATA +136 -16
- continuo_python_runtime-0.12.0.dist-info/RECORD +35 -0
- {continuo_python_runtime-0.10.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/WHEEL +1 -1
- continuo_python_runtime-0.10.0.dist-info/RECORD +0 -32
- {continuo_python_runtime-0.10.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/entry_points.txt +0 -0
- {continuo_python_runtime-0.10.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {continuo_python_runtime-0.10.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -1,94 +1,93 @@
|
|
|
1
1
|
"""Content hashing: three independent parts folded into one content_hash.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
contracts
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
the
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:"
|
|
79
|
-
return
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
source = source_hash(primary)
|
|
86
|
+
shared = shared_code_hash(units)
|
|
87
|
+
config = config_hash(node)
|
|
89
88
|
return {
|
|
90
|
-
"source_hash":
|
|
91
|
-
"shared_code_hash":
|
|
92
|
-
"config_hash":
|
|
93
|
-
"content_hash": content_hash_fold(
|
|
89
|
+
"source_hash": source,
|
|
90
|
+
"shared_code_hash": shared,
|
|
91
|
+
"config_hash": config,
|
|
92
|
+
"content_hash": content_hash_fold(source, shared, config),
|
|
94
93
|
}
|
continuo_python_runtime/lint.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""Script linting for forbidden imports, SQL literals, data-access calls,
|
|
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
|
-
|
|
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
|
-
#
|
|
266
|
-
|
|
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
|
-
# (
|
|
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
|
|