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 +3 -0
- conformdag/analysis.py +327 -0
- conformdag/benchmark.py +501 -0
- conformdag/benchmark_semantic.py +111 -0
- conformdag/cli.py +540 -0
- conformdag/config.py +29 -0
- conformdag/evaluator.py +433 -0
- conformdag/models.py +425 -0
- conformdag/policy.py +149 -0
- conformdag/reference.py +101 -0
- conformdag/reporting.py +233 -0
- conformdag/runtime.py +220 -0
- conformdag/scan.py +256 -0
- conformdag/semantic.py +382 -0
- conformdag/semantic_evaluator.py +196 -0
- conformdag-0.1.0b1.dist-info/METADATA +312 -0
- conformdag-0.1.0b1.dist-info/RECORD +20 -0
- conformdag-0.1.0b1.dist-info/WHEEL +4 -0
- conformdag-0.1.0b1.dist-info/entry_points.txt +2 -0
- conformdag-0.1.0b1.dist-info/licenses/LICENSE +201 -0
conformdag/__init__.py
ADDED
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)
|