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.
- continuo_python_runtime/__init__.py +1 -0
- continuo_python_runtime/cli.py +142 -0
- continuo_python_runtime/conform.py +220 -0
- continuo_python_runtime/context.py +62 -0
- continuo_python_runtime/contract/__init__.py +1 -0
- continuo_python_runtime/contract/loader.py +363 -0
- continuo_python_runtime/contract/merge.py +80 -0
- continuo_python_runtime/contract/model.py +39 -0
- continuo_python_runtime/contract/paths.py +38 -0
- continuo_python_runtime/errors.py +36 -0
- continuo_python_runtime/harness.py +208 -0
- continuo_python_runtime/hashing.py +34 -0
- continuo_python_runtime/lint.py +295 -0
- continuo_python_runtime/types.py +208 -0
- continuo_python_runtime-0.1.0.dist-info/METADATA +193 -0
- continuo_python_runtime-0.1.0.dist-info/RECORD +18 -0
- continuo_python_runtime-0.1.0.dist-info/WHEEL +4 -0
- continuo_python_runtime-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|