dagprobe 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.
- dagprobe/__init__.py +1 -0
- dagprobe/analyze.py +243 -0
- dagprobe/audit.py +79 -0
- dagprobe/ci.py +161 -0
- dagprobe/cli.py +292 -0
- dagprobe/errors.py +41 -0
- dagprobe/fixtures.py +167 -0
- dagprobe/lint.py +138 -0
- dagprobe/shim/__init__.py +32 -0
- dagprobe/shim/_core.py +167 -0
- dagprobe/shim/_fileintercept.py +172 -0
- dagprobe/shim/_intercept.py +73 -0
- dagprobe/shim/_taskflow.py +161 -0
- dagprobe/shim/_templates.py +224 -0
- dagprobe/shim/airflow/__init__.py +27 -0
- dagprobe/shim/airflow/example_dags/__init__.py +1 -0
- dagprobe/shim/airflow/example_dags/plugins/__init__.py +1 -0
- dagprobe/shim/airflow/example_dags/plugins/workday.py +114 -0
- dagprobe/shim/airflow/plugins_manager.py +20 -0
- dagprobe/shim/airflow/providers/standard/operators/bash.py +11 -0
- dagprobe/shim/airflow/providers/standard/operators/empty.py +9 -0
- dagprobe/shim/airflow/providers/standard/operators/python.py +48 -0
- dagprobe/shim/airflow/sdk/__init__.py +36 -0
- dagprobe/shim/airflow/sdk/bases/__init__.py +0 -0
- dagprobe/shim/airflow/sdk/bases/hook.py +98 -0
- dagprobe/shim/airflow/timetables/__init__.py +1 -0
- dagprobe/shim/airflow/timetables/base.py +45 -0
- dagprobe/shim/finder.py +70 -0
- dagprobe/shim/xcom.py +70 -0
- dagprobe/task_test.py +275 -0
- dagprobe-0.1.0.dist-info/METADATA +110 -0
- dagprobe-0.1.0.dist-info/RECORD +35 -0
- dagprobe-0.1.0.dist-info/WHEEL +4 -0
- dagprobe-0.1.0.dist-info/entry_points.txt +2 -0
- dagprobe-0.1.0.dist-info/licenses/LICENSE +202 -0
dagprobe/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DagProbe — debug and test Apache Airflow DAGs without installing or running Airflow."""
|
dagprobe/analyze.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Срез 1 (файл 18, Часть F): анализ DAG-файла через shim с изоляцией в subprocess.
|
|
2
|
+
|
|
3
|
+
``analyze_file`` — обёртка над наработками спайка (B2–B5): активирует shim,
|
|
4
|
+
импортирует DAG-файл и снимает граф. Сам файл исполняется в subprocess —
|
|
5
|
+
ошибка/зависание пользовательского кода не роняет процесс-родитель (§6.1).
|
|
6
|
+
Рендер template_fields — превью-фича §6.4 (Срез 2+), в AnalysisResult не входит.
|
|
7
|
+
|
|
8
|
+
Профиль пока один (v3.3) — выбор профиля появится вместе со вторым (файл 18, F4).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import cast
|
|
21
|
+
|
|
22
|
+
from dagprobe import errors
|
|
23
|
+
|
|
24
|
+
# ponytail: таймаут-константа без настройки (файл 18, F2); env-ручка — когда появятся висящие DAG
|
|
25
|
+
TIMEOUT_S = 30.0
|
|
26
|
+
LOGICAL_DATE = "2026-09-29T00:00:00+00:00" # фиксированная logical_date превью/прогона
|
|
27
|
+
|
|
28
|
+
# скрипт, исполняемый в subprocess: снимок всех DAG файла в JSON на stdout.
|
|
29
|
+
# Внимание: сабпроцесс не наследует src-layout пакет неявно — запускаем
|
|
30
|
+
# [sys.executable, "-c", _CHILD, file]; dagprobe уже импортируем в родителе,
|
|
31
|
+
# значит при установленном пакете (uv sync / pip) он виден и ребёнку.
|
|
32
|
+
_CHILD = r"""
|
|
33
|
+
import importlib.util
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import traceback
|
|
38
|
+
|
|
39
|
+
from dagprobe.shim import _core, activate, deactivate
|
|
40
|
+
|
|
41
|
+
path, out_path = sys.argv[1], sys.argv[3]
|
|
42
|
+
mode = sys.argv[2] if len(sys.argv) > 2 else "list" # list | render (§6.4)
|
|
43
|
+
activate()
|
|
44
|
+
|
|
45
|
+
def emit(obj):
|
|
46
|
+
# результат в файл, не в stdout: пользовательский код печатает при импорте
|
|
47
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
48
|
+
json.dump(obj, fh, default=str)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
spec = importlib.util.spec_from_file_location("dagprobe_target", path)
|
|
52
|
+
module = importlib.util.module_from_spec(spec)
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
result = {"dags": {}, "errors": []}
|
|
55
|
+
if mode == "render":
|
|
56
|
+
from datetime import datetime
|
|
57
|
+
|
|
58
|
+
from dagprobe.shim import _templates
|
|
59
|
+
for dag_id, dag in sorted(_core.all_dags().items()):
|
|
60
|
+
entry = {
|
|
61
|
+
"tasks": {
|
|
62
|
+
t.task_id: {
|
|
63
|
+
"class": type(t).__name__,
|
|
64
|
+
"upstream": sorted(t.upstream_task_ids),
|
|
65
|
+
"downstream": sorted(t.downstream_task_ids),
|
|
66
|
+
}
|
|
67
|
+
for t in dag.task_dict.values()
|
|
68
|
+
},
|
|
69
|
+
"edges": dag.edges(),
|
|
70
|
+
}
|
|
71
|
+
if mode == "render":
|
|
72
|
+
logical_date = datetime.fromisoformat("__LOGICAL_DATE__")
|
|
73
|
+
rendered = {}
|
|
74
|
+
for t in dag.task_dict.values():
|
|
75
|
+
ctx = _templates.build_context(t, logical_date)
|
|
76
|
+
# исходники шаблонов до рендера — для превью «до → после»
|
|
77
|
+
before = {f: getattr(t, f, None) for f in getattr(t, "template_fields", ())}
|
|
78
|
+
_templates.render_template_fields(t, ctx)
|
|
79
|
+
rendered[t.task_id] = {
|
|
80
|
+
f: {"before": repr(v), "after": repr(getattr(t, f, None))}
|
|
81
|
+
for f, v in before.items()
|
|
82
|
+
}
|
|
83
|
+
entry["rendered"] = rendered
|
|
84
|
+
result["dags"][dag_id] = entry
|
|
85
|
+
emit(result)
|
|
86
|
+
except Exception:
|
|
87
|
+
tb = traceback.format_exc()
|
|
88
|
+
# локация в пользовательском коде: последняя рамка с его файлом (не <string>)
|
|
89
|
+
frames = re.findall(rf'File "{re.escape(path)}", line (\d+)', tb)
|
|
90
|
+
emit(
|
|
91
|
+
{
|
|
92
|
+
"dags": {},
|
|
93
|
+
"errors": [
|
|
94
|
+
{
|
|
95
|
+
"message": tb.strip().splitlines()[-1],
|
|
96
|
+
"traceback": tb,
|
|
97
|
+
"line": int(frames[-1]) if frames else None,
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
finally:
|
|
103
|
+
deactivate()
|
|
104
|
+
"""
|
|
105
|
+
_CHILD = _CHILD.replace("__LOGICAL_DATE__", LOGICAL_DATE)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclasses.dataclass(frozen=True)
|
|
109
|
+
class DAGInfo:
|
|
110
|
+
"""Один DAG из файла: задачи, рёбра."""
|
|
111
|
+
|
|
112
|
+
dag_id: str
|
|
113
|
+
folder: str
|
|
114
|
+
file: str
|
|
115
|
+
tasks: dict[str, dict[str, object]] # task_id -> {class, upstream, downstream}
|
|
116
|
+
edges: list[list[str]]
|
|
117
|
+
rendered: dict[str, dict[str, dict[str, str]]] = dataclasses.field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _error_from(raw: object, file: str) -> errors.DAGProbeError:
|
|
121
|
+
"""Ошибка из child-JSON (dict с message/line) или строка (exit/таймаут)."""
|
|
122
|
+
if isinstance(raw, dict):
|
|
123
|
+
return errors.DAGProbeError(
|
|
124
|
+
code=errors.IMPORT_FAILURE,
|
|
125
|
+
severity=errors.ERROR,
|
|
126
|
+
message=str(raw.get("message", "")),
|
|
127
|
+
file=file,
|
|
128
|
+
line=cast("int | None", raw.get("line")),
|
|
129
|
+
)
|
|
130
|
+
return errors.DAGProbeError(
|
|
131
|
+
code=errors.IMPORT_FAILURE, severity=errors.ERROR, message=str(raw), file=file
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclasses.dataclass(frozen=True)
|
|
136
|
+
class AnalysisResult:
|
|
137
|
+
"""Модель вывода анализа (файл 02): dag_ids + типизированные ошибки."""
|
|
138
|
+
|
|
139
|
+
dag_ids: list[str]
|
|
140
|
+
errors: list[errors.DAGProbeError]
|
|
141
|
+
import_duration_ms: float
|
|
142
|
+
dags: list[DAGInfo]
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_json(cls, data: dict[str, object], file: str) -> AnalysisResult:
|
|
146
|
+
dags_data = cast(dict[str, dict[str, object]], data.get("dags") or {})
|
|
147
|
+
dags = [
|
|
148
|
+
DAGInfo(
|
|
149
|
+
dag_id=str(dag_id),
|
|
150
|
+
folder=str(dag_data["folder"]),
|
|
151
|
+
file=str(dag_data["file"]),
|
|
152
|
+
tasks=cast(dict[str, dict[str, object]], dag_data["tasks"]),
|
|
153
|
+
edges=cast("list[list[str]]", dag_data["edges"]),
|
|
154
|
+
rendered=cast(dict[str, dict[str, dict[str, str]]], dag_data.get("rendered") or {}),
|
|
155
|
+
)
|
|
156
|
+
for dag_id, dag_data in dags_data.items()
|
|
157
|
+
]
|
|
158
|
+
return cls(
|
|
159
|
+
dag_ids=sorted(dags_data),
|
|
160
|
+
errors=[_error_from(e, file) for e in cast("list[object]", data.get("errors") or [])],
|
|
161
|
+
import_duration_ms=float(cast("int | float", data.get("import_duration_ms", 0.0))),
|
|
162
|
+
dags=dags,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclasses.dataclass(frozen=True)
|
|
167
|
+
class ChildRun:
|
|
168
|
+
"""Результат запуска child-скрипта: payload из файла либо диагностика."""
|
|
169
|
+
|
|
170
|
+
payload: dict[str, object] | None
|
|
171
|
+
timed_out: bool
|
|
172
|
+
diagnostics: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run_child(child_src: str, args: list[str], *, timeout_s: float = TIMEOUT_S) -> ChildRun:
|
|
176
|
+
"""Запустить child и забрать его JSON-вывод из файла.
|
|
177
|
+
|
|
178
|
+
Пользовательский DAG может печатать в stdout при импорте — парсить stdout
|
|
179
|
+
нельзя (ложные ``IMPORT_FAILURE`` и падение родителя). Child пишет результат
|
|
180
|
+
в отдельный файл, последним аргументом.
|
|
181
|
+
"""
|
|
182
|
+
with tempfile.TemporaryDirectory() as td:
|
|
183
|
+
out = Path(td) / "child.json"
|
|
184
|
+
cmd = [sys.executable, "-c", child_src, *args, str(out)]
|
|
185
|
+
try:
|
|
186
|
+
proc = subprocess.run(
|
|
187
|
+
cmd, capture_output=True, text=True, timeout=timeout_s, check=False
|
|
188
|
+
)
|
|
189
|
+
except subprocess.TimeoutExpired:
|
|
190
|
+
return ChildRun(payload=None, timed_out=True, diagnostics=f"таймаут {timeout_s:.0f} с")
|
|
191
|
+
if out.exists() and out.stat().st_size:
|
|
192
|
+
return ChildRun(
|
|
193
|
+
payload=cast("dict[str, object]", json.loads(out.read_text(encoding="utf-8"))),
|
|
194
|
+
timed_out=False,
|
|
195
|
+
diagnostics="",
|
|
196
|
+
)
|
|
197
|
+
noise = (proc.stderr.strip() or proc.stdout.strip())[:2000]
|
|
198
|
+
diag = f"exit={proc.returncode}: {noise}"
|
|
199
|
+
return ChildRun(payload=None, timed_out=False, diagnostics=diag)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _child_snapshot(data: dict[str, object], file: Path) -> dict[str, object]:
|
|
203
|
+
"""Дополнить снимок subprocess полями ``folder``/``file``, которые знает только родитель."""
|
|
204
|
+
for dag_data in cast(dict[str, dict[str, object]], data.get("dags") or {}).values():
|
|
205
|
+
dag_data["folder"] = str(file.parent)
|
|
206
|
+
dag_data["file"] = file.name
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def analyze_file(
|
|
211
|
+
path: Path | str, *, timeout_s: float = TIMEOUT_S, render: bool = False
|
|
212
|
+
) -> AnalysisResult:
|
|
213
|
+
"""Проанализировать DAG-файл в изолированном subprocess с таймаутом.
|
|
214
|
+
|
|
215
|
+
Ошибки импорта/парсинга — в ``errors`` (traceback строкой), не исключением:
|
|
216
|
+
это нормальный результат анализа. Каждая задача — отдельный subprocess,
|
|
217
|
+
изоляция как в §6.1. ``render=True`` добавляет рендер template_fields (§6.4).
|
|
218
|
+
"""
|
|
219
|
+
file = Path(path).resolve()
|
|
220
|
+
start = time.monotonic()
|
|
221
|
+
run = run_child(_CHILD, [str(file), "render" if render else "list"], timeout_s=timeout_s)
|
|
222
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
223
|
+
|
|
224
|
+
if run.payload is None:
|
|
225
|
+
code = errors.SANDBOX_TIMEOUT if run.timed_out else errors.IMPORT_FAILURE
|
|
226
|
+
message = (
|
|
227
|
+
f"таймаут {timeout_s:.0f} с при импорте {file.name}"
|
|
228
|
+
if run.timed_out
|
|
229
|
+
else run.diagnostics
|
|
230
|
+
)
|
|
231
|
+
return AnalysisResult(
|
|
232
|
+
dag_ids=[],
|
|
233
|
+
errors=[
|
|
234
|
+
errors.DAGProbeError(
|
|
235
|
+
code=code, severity=errors.ERROR, message=message, file=file.name
|
|
236
|
+
)
|
|
237
|
+
],
|
|
238
|
+
import_duration_ms=duration_ms,
|
|
239
|
+
dags=[],
|
|
240
|
+
)
|
|
241
|
+
data = _child_snapshot(run.payload, file)
|
|
242
|
+
data["import_duration_ms"] = duration_ms
|
|
243
|
+
return AnalysisResult.from_json(data, file.name)
|
dagprobe/audit.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Срез 8 (клин G4 из файла 18): поведенческий аудит миграции Airflow 2.10 → 3.x.
|
|
2
|
+
|
|
3
|
+
DAG прогоняется дважды на одних фикстурах — под профилем ``2.10`` (легаси-ключи
|
|
4
|
+
контекста рендера) и ``3.3`` — и результаты сравниваются. Каждое расхождение —
|
|
5
|
+
находка: поведение DAG меняется при миграции, статические правила (Ruff AIR)
|
|
6
|
+
такое не ловят, потому что это семантика вызова/рендера, а не синтаксис.
|
|
7
|
+
|
|
8
|
+
Находки (поля ``code``):
|
|
9
|
+
- ``status`` — статус задачи различается (типично: ok в 2.10, failed в 3.3);
|
|
10
|
+
- ``value`` — статусы одинаковые (ok), но XCom ``return_value`` различается
|
|
11
|
+
(типично: легаси-ключ отрендерился в дату против пустой строки).
|
|
12
|
+
|
|
13
|
+
Покрытие — контекст рендера Jinja (G2 #4). XCom-семантика ``xcom_pull`` (G2
|
|
14
|
+
#1–#2) в прогоне ещё не эмулируется — находок этого класса не будет (Срез 9+).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from dagprobe.analyze import TIMEOUT_S, analyze_file
|
|
23
|
+
from dagprobe.task_test import TaskRunResult, run_dag
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _diff_dag(dag_id: str, v2: TaskRunResult, v3: TaskRunResult) -> list[dict[str, Any]]:
|
|
27
|
+
"""Расхождения двух прогонов одного DAG: статусы, затем значения."""
|
|
28
|
+
findings: list[dict[str, Any]] = []
|
|
29
|
+
for tid in sorted(set(v2.statuses) | set(v3.statuses)):
|
|
30
|
+
s2, s3 = v2.statuses.get(tid), v3.statuses.get(tid)
|
|
31
|
+
if s2 != s3:
|
|
32
|
+
failed = v3 if s3 == "failed" else v2
|
|
33
|
+
tail = (failed.failures.get(tid) or "").strip().splitlines()[-1:]
|
|
34
|
+
findings.append(
|
|
35
|
+
{
|
|
36
|
+
"dag_id": dag_id,
|
|
37
|
+
"task_id": tid,
|
|
38
|
+
"code": "status",
|
|
39
|
+
"in_v2": s2,
|
|
40
|
+
"in_v3": s3,
|
|
41
|
+
"detail": tail[0] if tail else "",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
elif s2 == "ok" and v2.values.get(tid) != v3.values.get(tid):
|
|
45
|
+
findings.append(
|
|
46
|
+
{
|
|
47
|
+
"dag_id": dag_id,
|
|
48
|
+
"task_id": tid,
|
|
49
|
+
"code": "value",
|
|
50
|
+
"in_v2": v2.values.get(tid),
|
|
51
|
+
"in_v3": v3.values.get(tid),
|
|
52
|
+
"detail": "",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return findings
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def audit_2to3(
|
|
59
|
+
dags_folder: Path | str,
|
|
60
|
+
*,
|
|
61
|
+
dag_id: str | None = None,
|
|
62
|
+
fixtures: Path | str | None = None,
|
|
63
|
+
timeout_s: float = TIMEOUT_S,
|
|
64
|
+
) -> list[dict[str, Any]]:
|
|
65
|
+
"""Аудит всех DAG каталога (или одного ``dag_id``): прогон 2.10 vs 3.3."""
|
|
66
|
+
findings: list[dict[str, Any]] = []
|
|
67
|
+
for f in sorted(Path(dags_folder).glob("*.py")):
|
|
68
|
+
result = analyze_file(f, timeout_s=timeout_s)
|
|
69
|
+
for did in result.dag_ids:
|
|
70
|
+
if dag_id and did != dag_id:
|
|
71
|
+
continue
|
|
72
|
+
common: dict[str, Any] = {
|
|
73
|
+
"fixtures_path": fixtures,
|
|
74
|
+
"timeout_s": timeout_s,
|
|
75
|
+
}
|
|
76
|
+
v2 = run_dag(f, did, profile="2.10", **common)
|
|
77
|
+
v3 = run_dag(f, did, profile="3.3", **common)
|
|
78
|
+
findings.extend(_diff_dag(did, v2, v3))
|
|
79
|
+
return findings
|
dagprobe/ci.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""CI-режим (§6.21): exit-коды, gate-уровни, JUnit XML, «только изменённые файлы».
|
|
2
|
+
|
|
3
|
+
Единственное место, где находки превращаются в код возврата:
|
|
4
|
+
|
|
5
|
+
0 — находок уровня gate нет
|
|
6
|
+
1 — есть находки уровня gate (по умолчанию error; ``--fail-on warning`` расширяет)
|
|
7
|
+
2 — прогон не состоялся (DAG не найден, git недоступен, ошибка аргументов)
|
|
8
|
+
|
|
9
|
+
JUnit XML собирается stdlib-``xml.etree`` — экранирование сообщений не ручное.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from dagprobe.errors import ERROR, TASK_FAILURE, UPSTREAM_FAILED, WARNING, DAGProbeError
|
|
21
|
+
|
|
22
|
+
OK = 0
|
|
23
|
+
FINDINGS = 1
|
|
24
|
+
USAGE = 2
|
|
25
|
+
|
|
26
|
+
_GATE: dict[str, tuple[str, ...]] = {
|
|
27
|
+
"error": (ERROR,),
|
|
28
|
+
"warning": (ERROR, WARNING),
|
|
29
|
+
"never": (),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CiError(RuntimeError):
|
|
34
|
+
"""Прогон не состоялся (нет git / нет базовой ветки) — это exit 2, а не находка."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exit_code(errors: Iterable[DAGProbeError], *, fail_on: str = "error") -> int:
|
|
38
|
+
"""Код возврата по gate-уровню (§6.21): warning по умолчанию CI не роняет."""
|
|
39
|
+
levels = _GATE[fail_on]
|
|
40
|
+
return FINDINGS if any(e.severity in levels for e in errors) else OK
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_findings(failures: dict[str, str]) -> list[DAGProbeError]:
|
|
44
|
+
"""Провалы прогона — находки той же модели (§11.9), чтобы формат вывода был один."""
|
|
45
|
+
return [
|
|
46
|
+
DAGProbeError(code=TASK_FAILURE, severity=ERROR, message=msg, file=task_id)
|
|
47
|
+
for task_id, msg in failures.items()
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# (имя теста для CI, находки, исход: ok/failed/skipped)
|
|
52
|
+
Case = tuple[str, list[DAGProbeError], str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def lint_cases(errors: Iterable[DAGProbeError], files: Iterable[Path]) -> list[Case]:
|
|
56
|
+
"""По одному тесту на файл DAG: находки файла → failed, чистый файл → ok."""
|
|
57
|
+
by_file: dict[str, list[DAGProbeError]] = {f.name: [] for f in files}
|
|
58
|
+
for e in errors:
|
|
59
|
+
by_file.setdefault(e.file or "<unknown>", []).append(e)
|
|
60
|
+
return [(name, errs, "failed" if errs else "ok") for name, errs in sorted(by_file.items())]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_cases(dag_id: str, statuses: dict[str, str], failures: dict[str, str]) -> list[Case]:
|
|
64
|
+
"""По одному тесту на задачу прогона: failed с трейсбеком, upstream_failed, skipped, ok."""
|
|
65
|
+
cases: list[Case] = []
|
|
66
|
+
for task_id, status in statuses.items():
|
|
67
|
+
if task_id in failures:
|
|
68
|
+
errs = run_findings({task_id: failures[task_id]})
|
|
69
|
+
elif status == "upstream_failed":
|
|
70
|
+
errs = [
|
|
71
|
+
DAGProbeError(
|
|
72
|
+
code=UPSTREAM_FAILED,
|
|
73
|
+
severity=ERROR,
|
|
74
|
+
message="триггер-правило не выполнено: upstream пропущен или упал",
|
|
75
|
+
file=task_id,
|
|
76
|
+
)
|
|
77
|
+
]
|
|
78
|
+
else:
|
|
79
|
+
errs = []
|
|
80
|
+
cases.append((f"{dag_id}::{task_id}", errs, "failed" if errs else status))
|
|
81
|
+
return cases
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def findings_of(cases: Iterable[Case]) -> list[DAGProbeError]:
|
|
85
|
+
return [e for _name, errs, _outcome in cases for e in errs]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _location(name: str, e: DAGProbeError) -> str:
|
|
89
|
+
"""`файл:строка` (строка — если известна); для прогона вместо файла — имя задачи."""
|
|
90
|
+
where = e.file or name
|
|
91
|
+
return f"{where}:{e.line}" if e.line else where
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def to_text(cases: Iterable[Case]) -> str:
|
|
95
|
+
"""Человекочитаемо: строка на находку, `name: ok` — на чистый тест."""
|
|
96
|
+
lines = [f"{name}: {outcome}" for name, _errs, outcome in cases]
|
|
97
|
+
lines += [
|
|
98
|
+
f" {_location(name, e)}: {e.severity} {e.code}: {e.message}"
|
|
99
|
+
for name, errs, _outcome in cases
|
|
100
|
+
for e in errs
|
|
101
|
+
]
|
|
102
|
+
return "\n".join(lines) + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def to_junit(cases: Iterable[Case], *, time_s: float = 0.0) -> str:
|
|
106
|
+
"""JUnit XML: тест на файл/задачу, находки — в `<failure>` (§6.21, DoD п.5)."""
|
|
107
|
+
items = list(cases)
|
|
108
|
+
failed = sum(1 for _n, _e, outcome in items if outcome == "failed")
|
|
109
|
+
root = ET.Element("testsuites")
|
|
110
|
+
suite = ET.SubElement(
|
|
111
|
+
root,
|
|
112
|
+
"testsuite",
|
|
113
|
+
name="dagprobe",
|
|
114
|
+
tests=str(len(items)),
|
|
115
|
+
failures=str(failed),
|
|
116
|
+
skipped=str(sum(1 for _n, _e, outcome in items if outcome == "skipped")),
|
|
117
|
+
time=f"{time_s:.3f}",
|
|
118
|
+
)
|
|
119
|
+
for name, errs, outcome in items:
|
|
120
|
+
case = ET.SubElement(suite, "testcase", classname="dagprobe", name=name)
|
|
121
|
+
if outcome == "skipped":
|
|
122
|
+
ET.SubElement(case, "skipped")
|
|
123
|
+
elif errs:
|
|
124
|
+
head = errs[0]
|
|
125
|
+
failure = ET.SubElement(
|
|
126
|
+
case, "failure", type=head.code, message=f"{head.code}: {head.message}"
|
|
127
|
+
)
|
|
128
|
+
failure.text = "\n".join(f"{_location(name, e)}: {e.message}" for e in errs)
|
|
129
|
+
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def changed_python_files(dags_folder: Path, base_ref: str) -> list[Path]:
|
|
133
|
+
"""`*.py` из diff к базовой ветке (§6.21) — режим «только изменённые файлы»."""
|
|
134
|
+
if not dags_folder.is_dir():
|
|
135
|
+
raise CiError(f"каталог DAG не найден: {dags_folder}")
|
|
136
|
+
root = Path(_git("rev-parse", "--show-toplevel", cwd=dags_folder).strip())
|
|
137
|
+
folder = dags_folder.resolve()
|
|
138
|
+
out: list[Path] = []
|
|
139
|
+
diff = _git("diff", "--name-only", "--diff-filter=ACMR", f"{base_ref}...HEAD", cwd=root)
|
|
140
|
+
for line in diff.splitlines():
|
|
141
|
+
path = (root / line.strip()).resolve()
|
|
142
|
+
if path.suffix == ".py" and folder in path.parents and path.exists():
|
|
143
|
+
out.append(path)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _git(*args: str, cwd: Path | None = None) -> str:
|
|
148
|
+
"""Тонкая обёртка над git: нет репозитория/ветки — громкий CiError (exit 2)."""
|
|
149
|
+
try:
|
|
150
|
+
proc = subprocess.run(("git", *args), cwd=cwd, capture_output=True, text=True, check=False)
|
|
151
|
+
except FileNotFoundError as exc: # git не установлен
|
|
152
|
+
raise CiError("git не найден — режим --changed-only недоступен") from exc
|
|
153
|
+
if proc.returncode != 0:
|
|
154
|
+
detail = proc.stderr.strip() or proc.stdout.strip()
|
|
155
|
+
raise CiError(f"git {' '.join(args)}: {detail}")
|
|
156
|
+
return proc.stdout
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def with_location(errors: Iterable[DAGProbeError], file: str) -> list[DAGProbeError]:
|
|
160
|
+
"""Дописать имя файла находкам, пришедшим без него (анализ отдаёт имя сам)."""
|
|
161
|
+
return [e if e.file else replace(e, file=file) for e in errors]
|