conformdag 0.1.0b1__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.
conformdag/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Explainable conformance checks for Apache Airflow engineering policies."""
2
+
3
+ __version__ = "0.1.0b1"
conformdag/analysis.py ADDED
@@ -0,0 +1,327 @@
1
+ """Non-executing Python source discovery and structural analysis primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import cast
11
+
12
+ DEFAULT_EXCLUDES = ("**/.venv/**", "**/.git/**", "**/vendor/**", "**/generated/**")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SourceFile:
17
+ """A selected source file and its immutable input digest."""
18
+
19
+ path: Path
20
+ relative_path: str
21
+ content: str
22
+ content_hash: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ParseIssue:
27
+ path: str
28
+ message: str
29
+ line: int | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ImportRecord:
34
+ module: str
35
+ alias: str | None
36
+ line: int
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CallRecord:
41
+ qualified_name: str
42
+ line: int
43
+ column: int
44
+ module_scope: bool
45
+ uncertain: bool = False
46
+
47
+
48
+ def _empty_defaults() -> dict[str, object]:
49
+ return {}
50
+
51
+
52
+ @dataclass
53
+ class DagRecord:
54
+ line: int
55
+ owner: str | None
56
+ owner_source: str | None
57
+ tags: tuple[str, ...]
58
+ variable_name: str | None = None
59
+ defaults: dict[str, object] = field(default_factory=_empty_defaults)
60
+
61
+
62
+ @dataclass
63
+ class TaskRecord:
64
+ line: int
65
+ qualified_name: str
66
+ task_id: str | None
67
+ dag_name: str | None
68
+ values: dict[str, object]
69
+
70
+
71
+ def _empty_imports() -> list[ImportRecord]:
72
+ return []
73
+
74
+
75
+ def _empty_calls() -> list[CallRecord]:
76
+ return []
77
+
78
+
79
+ def _empty_dags() -> list[DagRecord]:
80
+ return []
81
+
82
+
83
+ def _empty_tasks() -> list[TaskRecord]:
84
+ return []
85
+
86
+
87
+ def _empty_assignments() -> dict[str, object]:
88
+ return {}
89
+
90
+
91
+ @dataclass
92
+ class SourceModel:
93
+ source: SourceFile
94
+ imports: list[ImportRecord] = field(default_factory=_empty_imports)
95
+ calls: list[CallRecord] = field(default_factory=_empty_calls)
96
+ dags: list[DagRecord] = field(default_factory=_empty_dags)
97
+ tasks: list[TaskRecord] = field(default_factory=_empty_tasks)
98
+ assignments: dict[str, object] = field(default_factory=_empty_assignments)
99
+
100
+
101
+ def _matches(relative_path: str, patterns: tuple[str, ...]) -> bool:
102
+ path = Path(relative_path)
103
+ return any(path.match(pattern) for pattern in patterns)
104
+
105
+
106
+ def discover_python_files(
107
+ repository_root: Path,
108
+ include: list[str],
109
+ exclude: list[str] | None = None,
110
+ follow_internal_symlinks: bool = False,
111
+ ) -> tuple[list[SourceFile], list[ParseIssue]]:
112
+ """Discover normalized Python inputs without following symlinks by default."""
113
+ root = repository_root.resolve()
114
+ excluded = tuple(DEFAULT_EXCLUDES) + tuple(exclude or [])
115
+ selected: dict[str, Path] = {}
116
+ issues: list[ParseIssue] = []
117
+ for pattern in include:
118
+ for candidate in root.glob(pattern):
119
+ if not candidate.is_file():
120
+ continue
121
+ if candidate.is_symlink() and not follow_internal_symlinks:
122
+ issues.append(
123
+ ParseIssue(candidate.relative_to(root).as_posix(), "symlink excluded")
124
+ )
125
+ continue
126
+ relative = candidate.relative_to(root).as_posix()
127
+ if _matches(relative, excluded):
128
+ continue
129
+ selected[relative] = candidate
130
+
131
+ files: list[SourceFile] = []
132
+ for relative, candidate in sorted(selected.items()):
133
+ try:
134
+ content = candidate.read_text(encoding="utf-8")
135
+ except OSError as exc:
136
+ issues.append(ParseIssue(relative, f"unreadable source: {exc}"))
137
+ continue
138
+ files.append(
139
+ SourceFile(
140
+ path=candidate,
141
+ relative_path=relative,
142
+ content=content,
143
+ content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
144
+ )
145
+ )
146
+ return files, issues
147
+
148
+
149
+ def _qualified_name(node: ast.AST) -> str | None:
150
+ if isinstance(node, ast.Name):
151
+ return node.id
152
+ if isinstance(node, ast.Attribute):
153
+ parent = _qualified_name(node.value)
154
+ return f"{parent}.{node.attr}" if parent else node.attr
155
+ return None
156
+
157
+
158
+ class _ModelVisitor(ast.NodeVisitor):
159
+ def __init__(self, source: SourceFile) -> None:
160
+ self.model = SourceModel(source)
161
+ self._function_depth = 0
162
+
163
+ def visit_Import(self, node: ast.Import) -> None:
164
+ for item in node.names:
165
+ self.model.imports.append(ImportRecord(item.name, item.asname, node.lineno))
166
+ self.generic_visit(node)
167
+
168
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
169
+ module = "." * node.level + (node.module or "")
170
+ for item in node.names:
171
+ self.model.imports.append(
172
+ ImportRecord(f"{module}.{item.name}", item.asname, node.lineno)
173
+ )
174
+ self.generic_visit(node)
175
+
176
+ def visit_Assign(self, node: ast.Assign) -> None:
177
+ self.generic_visit(node)
178
+ value = _literal_value(node.value)
179
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and value is not None:
180
+ self.model.assignments[node.targets[0].id] = value
181
+ if isinstance(node.value, ast.Call) and self._is_dag_call(node.value):
182
+ variable_name = node.targets[0].id if isinstance(node.targets[0], ast.Name) else None
183
+ for dag in reversed(self.model.dags):
184
+ if dag.line == node.value.lineno:
185
+ dag.variable_name = variable_name
186
+ break
187
+
188
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
189
+ self._function_depth += 1
190
+ self.generic_visit(node)
191
+ self._function_depth -= 1
192
+
193
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
194
+ self._function_depth += 1
195
+ self.generic_visit(node)
196
+ self._function_depth -= 1
197
+
198
+ def visit_Call(self, node: ast.Call) -> None:
199
+ qualified_name = _qualified_name(node.func)
200
+ self.model.calls.append(
201
+ CallRecord(
202
+ qualified_name or "<dynamic>",
203
+ node.lineno,
204
+ node.col_offset,
205
+ self._function_depth == 0,
206
+ uncertain=qualified_name is None,
207
+ )
208
+ )
209
+ if qualified_name:
210
+ if self._is_dag_call(node):
211
+ self.model.dags.append(self._dag_record(node))
212
+ elif qualified_name.rsplit(".", 1)[-1].endswith("Operator"):
213
+ self.model.tasks.append(self._task_record(node, qualified_name))
214
+ self.generic_visit(node)
215
+
216
+ @staticmethod
217
+ def _is_dag_call(node: ast.Call) -> bool:
218
+ qualified_name = _qualified_name(node.func)
219
+ return bool(qualified_name and qualified_name.rsplit(".", 1)[-1] == "DAG")
220
+
221
+ def _dag_record(self, node: ast.Call) -> DagRecord:
222
+ owner: str | None = None
223
+ owner_source: str | None = None
224
+ tags: tuple[str, ...] = ()
225
+ defaults: dict[str, object] = {}
226
+ for keyword in node.keywords:
227
+ if (
228
+ keyword.arg == "owner"
229
+ and isinstance(keyword.value, ast.Constant)
230
+ and isinstance(keyword.value.value, str)
231
+ ):
232
+ owner = keyword.value.value
233
+ owner_source = "DAG.owner"
234
+ if keyword.arg == "default_args":
235
+ defaults = self._resolve_mapping(keyword.value)
236
+ default_owner = defaults.get("owner")
237
+ if owner is None and isinstance(default_owner, str):
238
+ owner = default_owner
239
+ owner_source = "DAG.default_args.owner"
240
+ if keyword.arg == "tags":
241
+ resolved_tags = self._resolve_value(keyword.value)
242
+ if isinstance(resolved_tags, list):
243
+ tags = tuple(
244
+ item for item in cast(list[object], resolved_tags) if isinstance(item, str)
245
+ )
246
+ return DagRecord(node.lineno, owner, owner_source, tags, defaults=defaults)
247
+
248
+ def _task_record(self, node: ast.Call, qualified_name: str) -> TaskRecord:
249
+ values: dict[str, object] = {}
250
+ for keyword in node.keywords:
251
+ if keyword.arg:
252
+ values[keyword.arg] = self._resolve_value(keyword.value)
253
+ task_id = values.get("task_id")
254
+ dag_name = values.get("dag")
255
+ return TaskRecord(
256
+ line=node.lineno,
257
+ qualified_name=qualified_name,
258
+ task_id=task_id if isinstance(task_id, str) else None,
259
+ dag_name=dag_name if isinstance(dag_name, str) else None,
260
+ values=values,
261
+ )
262
+
263
+ def _resolve_value(self, node: ast.AST) -> object:
264
+ value = _literal_value(node)
265
+ if value is not None:
266
+ return value
267
+ if isinstance(node, ast.Name):
268
+ return self.model.assignments.get(node.id)
269
+ return None
270
+
271
+ def _resolve_mapping(self, node: ast.AST) -> dict[str, object]:
272
+ value = _literal_value(node)
273
+ if isinstance(value, dict):
274
+ return cast(dict[str, object], value)
275
+ if isinstance(node, ast.Name):
276
+ assigned = self.model.assignments.get(node.id)
277
+ if isinstance(assigned, dict):
278
+ return cast(dict[str, object], assigned)
279
+ return {}
280
+
281
+
282
+ def _literal_value(node: ast.AST) -> object:
283
+ if isinstance(node, ast.Constant) and isinstance(
284
+ node.value, (str, int, float, bool, type(None))
285
+ ):
286
+ return node.value
287
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
288
+ return [_literal_value(item) for item in node.elts]
289
+ if isinstance(node, ast.Dict):
290
+ result: dict[str, object] = {}
291
+ for key, value in zip(node.keys, node.values, strict=True):
292
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
293
+ result[key.value] = _literal_value(value)
294
+ return result
295
+ if isinstance(node, ast.Call) and _qualified_name(node.func) == "timedelta":
296
+ units = {
297
+ keyword.arg: _literal_value(keyword.value) for keyword in node.keywords if keyword.arg
298
+ }
299
+ total = 0.0
300
+ for unit, multiplier in (
301
+ ("weeks", 604800),
302
+ ("days", 86400),
303
+ ("hours", 3600),
304
+ ("minutes", 60),
305
+ ("seconds", 1),
306
+ ):
307
+ value = units.get(unit, 0)
308
+ if not isinstance(value, (int, float)):
309
+ return None
310
+ total += float(value) * multiplier
311
+ return total
312
+ return None
313
+
314
+
315
+ def analyze_source(source: SourceFile) -> tuple[SourceModel | None, ParseIssue | None]:
316
+ """Parse one file only; importing or executing repository code never occurs."""
317
+ try:
318
+ tree = ast.parse(source.content, filename=source.relative_path)
319
+ except SyntaxError as exc:
320
+ return None, ParseIssue(source.relative_path, exc.msg, exc.lineno)
321
+ visitor = _ModelVisitor(source)
322
+ visitor.visit(tree)
323
+ return visitor.model, None
324
+
325
+
326
+ def iter_module_scope_calls(model: SourceModel) -> Iterator[CallRecord]:
327
+ return (call for call in model.calls if call.module_scope)