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 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]