deploy-guard-engine 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.
- deploy_guard/__init__.py +10 -0
- deploy_guard/__main__.py +4 -0
- deploy_guard/analysis/__init__.py +39 -0
- deploy_guard/analysis/callgraph.py +149 -0
- deploy_guard/analysis/context.py +31 -0
- deploy_guard/analysis/findings.py +333 -0
- deploy_guard/analysis/nullability.py +461 -0
- deploy_guard/analysis/paths.py +219 -0
- deploy_guard/cli.py +190 -0
- deploy_guard/config.py +72 -0
- deploy_guard/engine.py +270 -0
- deploy_guard/explain/__init__.py +16 -0
- deploy_guard/explain/explainer.py +504 -0
- deploy_guard/explain/render.py +60 -0
- deploy_guard/explain/traceback_parse.py +89 -0
- deploy_guard/frontend/__init__.py +10 -0
- deploy_guard/frontend/python_cfg.py +332 -0
- deploy_guard/frontend/python_frontend.py +182 -0
- deploy_guard/generators/__init__.py +11 -0
- deploy_guard/generators/base.py +12 -0
- deploy_guard/generators/import_smoke.py +94 -0
- deploy_guard/ingest/__init__.py +5 -0
- deploy_guard/ingest/discover.py +166 -0
- deploy_guard/ir/__init__.py +24 -0
- deploy_guard/ir/cfg.py +114 -0
- deploy_guard/ir/model.py +128 -0
- deploy_guard/py.typed +0 -0
- deploy_guard/report/__init__.py +5 -0
- deploy_guard/report/render.py +262 -0
- deploy_guard/store.py +50 -0
- deploy_guard_engine-0.1.0.dist-info/METADATA +163 -0
- deploy_guard_engine-0.1.0.dist-info/RECORD +35 -0
- deploy_guard_engine-0.1.0.dist-info/WHEEL +4 -0
- deploy_guard_engine-0.1.0.dist-info/entry_points.txt +3 -0
- deploy_guard_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Build a control-flow graph from a Python function AST.
|
|
2
|
+
|
|
3
|
+
Supported structurally: sequential statements, ``if`` / ``elif`` / ``else``,
|
|
4
|
+
``while`` / ``for`` (with ``else``), ``with``, ``try`` / ``except`` / ``else``
|
|
5
|
+
/ ``finally``, ``match`` / ``case``, ``return``, ``raise``, ``break``,
|
|
6
|
+
``continue``. Exceptions are modelled approximately: a ``try`` body is
|
|
7
|
+
assumed able to raise into any of its handlers at any point.
|
|
8
|
+
|
|
9
|
+
Anything not recognised is kept as an opaque statement in the current block,
|
|
10
|
+
so flow stays sound even when a construct is not modelled precisely.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
|
|
17
|
+
from deploy_guard.ir.cfg import CFG, BasicBlock, Edge, EdgeKind, Statement, Terminator
|
|
18
|
+
from deploy_guard.ir.model import FunctionDef
|
|
19
|
+
|
|
20
|
+
_MAX_SRC = 120
|
|
21
|
+
|
|
22
|
+
_SIMPLE = (
|
|
23
|
+
ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Expr, ast.Pass,
|
|
24
|
+
ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Delete,
|
|
25
|
+
ast.Assert, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_cfg(fn: FunctionDef) -> CFG:
|
|
30
|
+
node = fn.raw
|
|
31
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
32
|
+
raise TypeError(f"{fn.qualname}: no Python AST attached (raw={type(node)!r})")
|
|
33
|
+
return _Builder(fn.qualname).build(node.body)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _src(node: ast.AST) -> str:
|
|
37
|
+
try:
|
|
38
|
+
text = ast.unparse(node)
|
|
39
|
+
except Exception: # pragma: no cover
|
|
40
|
+
return f"<{type(node).__name__}>"
|
|
41
|
+
text = " ".join(text.split())
|
|
42
|
+
return text if len(text) <= _MAX_SRC else text[: _MAX_SRC - 1] + "…"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Builder:
|
|
46
|
+
def __init__(self, qualname: str) -> None:
|
|
47
|
+
self.qualname = qualname
|
|
48
|
+
self.blocks: dict[int, BasicBlock] = {}
|
|
49
|
+
self._next = 0
|
|
50
|
+
self.entry_id = self._new("entry").id
|
|
51
|
+
self.exit_id = self._new("exit").id
|
|
52
|
+
self.loop_stack: list[tuple[int, int]] = [] # (header_id, exit_id)
|
|
53
|
+
self.exc_stack: list[list[int]] = [] # handler entry ids
|
|
54
|
+
self.notes: list[str] = []
|
|
55
|
+
|
|
56
|
+
# -- graph construction helpers ------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _new(self, label: str = "") -> BasicBlock:
|
|
59
|
+
b = BasicBlock(id=self._next, label=label)
|
|
60
|
+
self.blocks[b.id] = b
|
|
61
|
+
self._next += 1
|
|
62
|
+
return b
|
|
63
|
+
|
|
64
|
+
def _stmt(self, block: BasicBlock, node: ast.AST, *, kind: str = "", src: str = "") -> None:
|
|
65
|
+
block.statements.append(
|
|
66
|
+
Statement(
|
|
67
|
+
node=node,
|
|
68
|
+
lineno=getattr(node, "lineno", 0) or 0,
|
|
69
|
+
kind=kind or type(node).__name__,
|
|
70
|
+
src=src or _src(node),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _edge(
|
|
75
|
+
self,
|
|
76
|
+
src: BasicBlock,
|
|
77
|
+
target: int,
|
|
78
|
+
kind: EdgeKind = EdgeKind.SEQ,
|
|
79
|
+
*,
|
|
80
|
+
guard: ast.AST | None = None,
|
|
81
|
+
negated: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
for e in src.succ:
|
|
84
|
+
if e.target == target and e.kind == kind and e.negated == negated:
|
|
85
|
+
return
|
|
86
|
+
src.succ.append(
|
|
87
|
+
Edge(
|
|
88
|
+
target=target,
|
|
89
|
+
kind=kind,
|
|
90
|
+
guard=guard,
|
|
91
|
+
guard_src=_src(guard) if guard is not None else None,
|
|
92
|
+
negated=negated,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# -- public entrypoint -------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def build(self, body: list[ast.stmt]) -> CFG:
|
|
99
|
+
entry = self.blocks[self.entry_id]
|
|
100
|
+
tail = self._suite(body, entry)
|
|
101
|
+
if tail is not None:
|
|
102
|
+
tail.terminator = Terminator.IMPLICIT_RETURN
|
|
103
|
+
self._edge(tail, self.exit_id)
|
|
104
|
+
cfg = CFG(
|
|
105
|
+
func_qualname=self.qualname,
|
|
106
|
+
entry=self.entry_id,
|
|
107
|
+
exit=self.exit_id,
|
|
108
|
+
blocks=self.blocks,
|
|
109
|
+
notes=self.notes,
|
|
110
|
+
)
|
|
111
|
+
return cfg.finalize()
|
|
112
|
+
|
|
113
|
+
# -- suite / statement dispatch -------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _suite(self, stmts: list[ast.stmt], block: BasicBlock | None) -> BasicBlock | None:
|
|
116
|
+
for stmt in stmts:
|
|
117
|
+
if block is None:
|
|
118
|
+
# Everything from here on is unreachable; keep it in a detached
|
|
119
|
+
# block so the dead-code detector can point at it.
|
|
120
|
+
block = self._new("unreachable")
|
|
121
|
+
block = self._stmt_dispatch(stmt, block)
|
|
122
|
+
return block
|
|
123
|
+
|
|
124
|
+
def _stmt_dispatch(self, stmt: ast.stmt, block: BasicBlock) -> BasicBlock | None:
|
|
125
|
+
method = getattr(self, f"_do_{type(stmt).__name__}", None)
|
|
126
|
+
if method is not None:
|
|
127
|
+
return method(stmt, block)
|
|
128
|
+
if isinstance(stmt, _SIMPLE):
|
|
129
|
+
self._stmt(block, stmt)
|
|
130
|
+
return block
|
|
131
|
+
# Unknown / unmodelled construct: keep it, stay sound.
|
|
132
|
+
self._stmt(block, stmt)
|
|
133
|
+
self.notes.append(f"unmodelled statement {type(stmt).__name__} at line {stmt.lineno}")
|
|
134
|
+
return block
|
|
135
|
+
|
|
136
|
+
# -- terminators -------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _do_Return(self, stmt: ast.Return, block: BasicBlock) -> None:
|
|
139
|
+
self._stmt(block, stmt, kind="Return")
|
|
140
|
+
block.terminator = Terminator.RETURN
|
|
141
|
+
block.term_node = stmt
|
|
142
|
+
self._edge(block, self.exit_id)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
def _do_Raise(self, stmt: ast.Raise, block: BasicBlock) -> None:
|
|
146
|
+
self._stmt(block, stmt, kind="Raise")
|
|
147
|
+
block.terminator = Terminator.RAISE
|
|
148
|
+
block.term_node = stmt
|
|
149
|
+
for handler_id in self._current_handlers():
|
|
150
|
+
self._edge(block, handler_id, EdgeKind.EXCEPTION)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
def _do_Break(self, stmt: ast.Break, block: BasicBlock) -> None:
|
|
154
|
+
self._stmt(block, stmt, kind="Break")
|
|
155
|
+
block.terminator = Terminator.BREAK
|
|
156
|
+
if self.loop_stack:
|
|
157
|
+
self._edge(block, self.loop_stack[-1][1])
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
def _do_Continue(self, stmt: ast.Continue, block: BasicBlock) -> None:
|
|
161
|
+
self._stmt(block, stmt, kind="Continue")
|
|
162
|
+
block.terminator = Terminator.CONTINUE
|
|
163
|
+
if self.loop_stack:
|
|
164
|
+
self._edge(block, self.loop_stack[-1][0], EdgeKind.LOOP_BACK)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
def _current_handlers(self) -> list[int]:
|
|
168
|
+
return self.exc_stack[-1] if self.exc_stack else []
|
|
169
|
+
|
|
170
|
+
# -- branches --------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _do_If(self, stmt: ast.If, block: BasicBlock) -> BasicBlock | None:
|
|
173
|
+
self._stmt(block, stmt.test, kind="branch", src=f"if {_src(stmt.test)}")
|
|
174
|
+
join = self._new("if.join")
|
|
175
|
+
join_used = False
|
|
176
|
+
|
|
177
|
+
then_block = self._new("if.then")
|
|
178
|
+
self._edge(block, then_block.id, EdgeKind.TRUE, guard=stmt.test)
|
|
179
|
+
then_tail = self._suite(stmt.body, then_block)
|
|
180
|
+
if then_tail is not None:
|
|
181
|
+
self._edge(then_tail, join.id)
|
|
182
|
+
join_used = True
|
|
183
|
+
|
|
184
|
+
if stmt.orelse:
|
|
185
|
+
else_block = self._new("if.else")
|
|
186
|
+
self._edge(block, else_block.id, EdgeKind.FALSE, guard=stmt.test, negated=True)
|
|
187
|
+
else_tail = self._suite(stmt.orelse, else_block)
|
|
188
|
+
if else_tail is not None:
|
|
189
|
+
self._edge(else_tail, join.id)
|
|
190
|
+
join_used = True
|
|
191
|
+
else:
|
|
192
|
+
self._edge(block, join.id, EdgeKind.FALSE, guard=stmt.test, negated=True)
|
|
193
|
+
join_used = True
|
|
194
|
+
|
|
195
|
+
return join if join_used else None
|
|
196
|
+
|
|
197
|
+
def _do_Match(self, stmt: ast.Match, block: BasicBlock) -> BasicBlock | None:
|
|
198
|
+
self._stmt(block, stmt.subject, kind="match", src=f"match {_src(stmt.subject)}")
|
|
199
|
+
join = self._new("match.join")
|
|
200
|
+
join_used = False
|
|
201
|
+
has_catch_all = False
|
|
202
|
+
|
|
203
|
+
for case in stmt.cases:
|
|
204
|
+
case_block = self._new("match.case")
|
|
205
|
+
label = f"case {_src(case.pattern)}"
|
|
206
|
+
if case.guard is not None:
|
|
207
|
+
label += f" if {_src(case.guard)}"
|
|
208
|
+
self._edge(block, case_block.id, EdgeKind.MATCH, guard=case.pattern)
|
|
209
|
+
self._stmt(case_block, case.pattern, kind="case", src=label)
|
|
210
|
+
case_tail = self._suite(case.body, case_block)
|
|
211
|
+
if case_tail is not None:
|
|
212
|
+
self._edge(case_tail, join.id)
|
|
213
|
+
join_used = True
|
|
214
|
+
if (
|
|
215
|
+
isinstance(case.pattern, ast.MatchAs)
|
|
216
|
+
and case.pattern.pattern is None
|
|
217
|
+
and case.guard is None
|
|
218
|
+
):
|
|
219
|
+
has_catch_all = True
|
|
220
|
+
|
|
221
|
+
if not has_catch_all:
|
|
222
|
+
self._edge(block, join.id)
|
|
223
|
+
join_used = True
|
|
224
|
+
return join if join_used else None
|
|
225
|
+
|
|
226
|
+
# -- loops ---------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def _do_While(self, stmt: ast.While, block: BasicBlock) -> BasicBlock | None:
|
|
229
|
+
return self._loop(stmt, block, header_src=f"while {_src(stmt.test)}", guard=stmt.test)
|
|
230
|
+
|
|
231
|
+
def _do_For(self, stmt: ast.For, block: BasicBlock) -> BasicBlock | None:
|
|
232
|
+
src = f"for {_src(stmt.target)} in {_src(stmt.iter)}"
|
|
233
|
+
return self._loop(stmt, block, header_src=src, guard=stmt.iter)
|
|
234
|
+
|
|
235
|
+
_do_AsyncFor = _do_For
|
|
236
|
+
|
|
237
|
+
def _loop(
|
|
238
|
+
self,
|
|
239
|
+
stmt: ast.While | ast.For | ast.AsyncFor,
|
|
240
|
+
block: BasicBlock,
|
|
241
|
+
*,
|
|
242
|
+
header_src: str,
|
|
243
|
+
guard: ast.AST,
|
|
244
|
+
) -> BasicBlock | None:
|
|
245
|
+
header = self._new("loop.header")
|
|
246
|
+
self._stmt(header, guard, kind="loop", src=header_src)
|
|
247
|
+
self._edge(block, header.id)
|
|
248
|
+
|
|
249
|
+
exit_block = self._new("loop.exit")
|
|
250
|
+
body_block = self._new("loop.body")
|
|
251
|
+
# The loop guard is kept on the header statement for display, but it is
|
|
252
|
+
# not a per-path branch predicate the way an ``if`` test is, so the
|
|
253
|
+
# enter/exit edges carry no guard - they must not leak into path
|
|
254
|
+
# conditions as ``cond and not (cond)``.
|
|
255
|
+
self._edge(header, body_block.id, EdgeKind.TRUE)
|
|
256
|
+
|
|
257
|
+
false_target = exit_block.id
|
|
258
|
+
else_tail: BasicBlock | None = None
|
|
259
|
+
if stmt.orelse:
|
|
260
|
+
else_block = self._new("loop.else")
|
|
261
|
+
false_target = else_block.id
|
|
262
|
+
else_tail = self._suite(stmt.orelse, else_block)
|
|
263
|
+
self._edge(header, false_target, EdgeKind.FALSE)
|
|
264
|
+
if stmt.orelse and else_tail is not None:
|
|
265
|
+
self._edge(else_tail, exit_block.id)
|
|
266
|
+
|
|
267
|
+
self.loop_stack.append((header.id, exit_block.id))
|
|
268
|
+
body_tail = self._suite(stmt.body, body_block)
|
|
269
|
+
self.loop_stack.pop()
|
|
270
|
+
if body_tail is not None:
|
|
271
|
+
self._edge(body_tail, header.id, EdgeKind.LOOP_BACK)
|
|
272
|
+
|
|
273
|
+
return exit_block
|
|
274
|
+
|
|
275
|
+
# -- with / try --------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def _do_With(self, stmt: ast.With, block: BasicBlock) -> BasicBlock | None:
|
|
278
|
+
items = ", ".join(_src(i.context_expr) for i in stmt.items)
|
|
279
|
+
self._stmt(block, stmt, kind="with", src=f"with {items}")
|
|
280
|
+
return self._suite(stmt.body, block)
|
|
281
|
+
|
|
282
|
+
_do_AsyncWith = _do_With
|
|
283
|
+
|
|
284
|
+
def _do_Try(self, stmt: ast.Try, block: BasicBlock) -> BasicBlock | None:
|
|
285
|
+
self._stmt(block, stmt, kind="try", src="try")
|
|
286
|
+
try_block = self._new("try.body")
|
|
287
|
+
self._edge(block, try_block.id)
|
|
288
|
+
|
|
289
|
+
handler_blocks = [self._new("try.handler") for _ in stmt.handlers]
|
|
290
|
+
for hb in handler_blocks:
|
|
291
|
+
self._edge(try_block, hb.id, EdgeKind.EXCEPTION)
|
|
292
|
+
|
|
293
|
+
self.exc_stack.append([hb.id for hb in handler_blocks])
|
|
294
|
+
try_tail = self._suite(stmt.body, try_block)
|
|
295
|
+
self.exc_stack.pop()
|
|
296
|
+
|
|
297
|
+
# ``else`` runs only when the try body completed without raising.
|
|
298
|
+
else_tail = try_tail
|
|
299
|
+
if stmt.orelse and try_tail is not None:
|
|
300
|
+
else_block = self._new("try.else")
|
|
301
|
+
self._edge(try_tail, else_block.id)
|
|
302
|
+
else_tail = self._suite(stmt.orelse, else_block)
|
|
303
|
+
|
|
304
|
+
handler_tails: list[BasicBlock | None] = []
|
|
305
|
+
for handler, hb in zip(stmt.handlers, handler_blocks):
|
|
306
|
+
type_src = _src(handler.type) if handler.type is not None else ""
|
|
307
|
+
name = f" as {handler.name}" if handler.name else ""
|
|
308
|
+
self._stmt(hb, handler, kind="except", src=f"except {type_src}{name}".rstrip())
|
|
309
|
+
handler_tails.append(self._suite(handler.body, hb))
|
|
310
|
+
|
|
311
|
+
sources = [t for t in ([else_tail, *handler_tails]) if t is not None]
|
|
312
|
+
|
|
313
|
+
if stmt.finalbody:
|
|
314
|
+
fin_block = self._new("try.finally")
|
|
315
|
+
self._stmt(fin_block, stmt, kind="finally", src="finally")
|
|
316
|
+
for s in sources:
|
|
317
|
+
self._edge(s, fin_block.id)
|
|
318
|
+
if not sources:
|
|
319
|
+
# ``finally`` still runs on the exception-propagation path.
|
|
320
|
+
self._edge(try_block, fin_block.id, EdgeKind.EXCEPTION)
|
|
321
|
+
return self._suite(stmt.finalbody, fin_block)
|
|
322
|
+
|
|
323
|
+
if not sources:
|
|
324
|
+
return None
|
|
325
|
+
if len(sources) == 1:
|
|
326
|
+
return sources[0]
|
|
327
|
+
join = self._new("try.join")
|
|
328
|
+
for s in sources:
|
|
329
|
+
self._edge(s, join.id)
|
|
330
|
+
return join
|
|
331
|
+
|
|
332
|
+
_do_TryStar = _do_Try
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Lower Python source into the language-neutral IR.
|
|
2
|
+
|
|
3
|
+
Captures module-level functions and classes with their methods. Functions
|
|
4
|
+
nested inside other functions are not lowered in M1 (noted per module); the
|
|
5
|
+
CFG builder still handles them structurally when they appear inside a body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from deploy_guard.ir.model import (
|
|
15
|
+
ClassDef,
|
|
16
|
+
FunctionDef,
|
|
17
|
+
Module,
|
|
18
|
+
Parameter,
|
|
19
|
+
ParamKind,
|
|
20
|
+
Project,
|
|
21
|
+
SourceSpan,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lower_python(project: Project) -> Project:
|
|
28
|
+
"""Fill in functions/classes/imports for every Python module in place."""
|
|
29
|
+
for module in project.modules:
|
|
30
|
+
if module.parse_error or module.path.suffix != ".py":
|
|
31
|
+
continue
|
|
32
|
+
_lower_module(module)
|
|
33
|
+
return project
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lower_module(module: Module) -> None:
|
|
37
|
+
# Parsing quietly captures SyntaxWarnings (invalid escape sequences,
|
|
38
|
+
# ``is`` with a literal, ...) so they become findings instead of noise on
|
|
39
|
+
# stderr.
|
|
40
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
41
|
+
warnings.simplefilter("always")
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(module.source, filename=str(module.path))
|
|
44
|
+
except SyntaxError as exc:
|
|
45
|
+
module.parse_error = f"SyntaxError: {exc.msg} (line {exc.lineno})"
|
|
46
|
+
return
|
|
47
|
+
module.syntax_warnings = [
|
|
48
|
+
(getattr(w.message, "lineno", None) or w.lineno or 0, str(w.message))
|
|
49
|
+
for w in caught
|
|
50
|
+
if issubclass(w.category, SyntaxWarning)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
for node in tree.body:
|
|
54
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
55
|
+
module.imports.extend(_import_names(node))
|
|
56
|
+
elif isinstance(node, _FUNC_NODES):
|
|
57
|
+
module.functions.append(
|
|
58
|
+
_lower_function(node, module.path, prefix=module.dotted_name)
|
|
59
|
+
)
|
|
60
|
+
elif isinstance(node, ast.ClassDef):
|
|
61
|
+
module.classes.append(_lower_class(node, module.path, module.dotted_name))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _import_names(node: ast.Import | ast.ImportFrom) -> list[str]:
|
|
65
|
+
if isinstance(node, ast.Import):
|
|
66
|
+
return [alias.name for alias in node.names]
|
|
67
|
+
if node.module and node.level == 0:
|
|
68
|
+
return [node.module]
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _lower_class(node: ast.ClassDef, file: Path, prefix: str) -> ClassDef:
|
|
73
|
+
qualname = f"{prefix}.{node.name}" if prefix else node.name
|
|
74
|
+
cls = ClassDef(
|
|
75
|
+
name=node.name,
|
|
76
|
+
qualname=qualname,
|
|
77
|
+
span=_span(node, file),
|
|
78
|
+
bases=[_unparse(b) for b in node.bases],
|
|
79
|
+
decorators=[_unparse(d) for d in node.decorator_list],
|
|
80
|
+
docstring=ast.get_docstring(node),
|
|
81
|
+
)
|
|
82
|
+
for child in node.body:
|
|
83
|
+
if isinstance(child, _FUNC_NODES):
|
|
84
|
+
cls.methods.append(
|
|
85
|
+
_lower_function(child, file, prefix=qualname, is_method=True)
|
|
86
|
+
)
|
|
87
|
+
return cls
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _lower_function(
|
|
91
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
92
|
+
file: Path,
|
|
93
|
+
*,
|
|
94
|
+
prefix: str,
|
|
95
|
+
is_method: bool = False,
|
|
96
|
+
) -> FunctionDef:
|
|
97
|
+
qualname = f"{prefix}.{node.name}" if prefix else node.name
|
|
98
|
+
return FunctionDef(
|
|
99
|
+
name=node.name,
|
|
100
|
+
qualname=qualname,
|
|
101
|
+
span=_span(node, file),
|
|
102
|
+
params=_params(node.args),
|
|
103
|
+
returns=_unparse(node.returns) if node.returns is not None else None,
|
|
104
|
+
decorators=[_unparse(d) for d in node.decorator_list],
|
|
105
|
+
docstring=ast.get_docstring(node),
|
|
106
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
107
|
+
is_method=is_method,
|
|
108
|
+
raw=node,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _params(args: ast.arguments) -> list[Parameter]:
|
|
113
|
+
out: list[Parameter] = []
|
|
114
|
+
|
|
115
|
+
positional = list(args.posonlyargs) + list(args.args)
|
|
116
|
+
# ``defaults`` covers the *last* len(defaults) positional params.
|
|
117
|
+
n_defaults = len(args.defaults)
|
|
118
|
+
first_with_default = len(positional) - n_defaults
|
|
119
|
+
posonly_count = len(args.posonlyargs)
|
|
120
|
+
|
|
121
|
+
for i, arg in enumerate(positional):
|
|
122
|
+
kind = (
|
|
123
|
+
ParamKind.POSITIONAL_ONLY
|
|
124
|
+
if i < posonly_count
|
|
125
|
+
else ParamKind.POSITIONAL_OR_KEYWORD
|
|
126
|
+
)
|
|
127
|
+
default = None
|
|
128
|
+
if i >= first_with_default:
|
|
129
|
+
default = _unparse(args.defaults[i - first_with_default])
|
|
130
|
+
out.append(Parameter(name=arg.arg, kind=kind, annotation=_ann(arg), default=default))
|
|
131
|
+
|
|
132
|
+
if args.vararg:
|
|
133
|
+
out.append(
|
|
134
|
+
Parameter(
|
|
135
|
+
name=args.vararg.arg,
|
|
136
|
+
kind=ParamKind.VAR_POSITIONAL,
|
|
137
|
+
annotation=_ann(args.vararg),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
for arg, default_node in zip(args.kwonlyargs, args.kw_defaults):
|
|
142
|
+
out.append(
|
|
143
|
+
Parameter(
|
|
144
|
+
name=arg.arg,
|
|
145
|
+
kind=ParamKind.KEYWORD_ONLY,
|
|
146
|
+
annotation=_ann(arg),
|
|
147
|
+
default=_unparse(default_node) if default_node is not None else None,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if args.kwarg:
|
|
152
|
+
out.append(
|
|
153
|
+
Parameter(
|
|
154
|
+
name=args.kwarg.arg,
|
|
155
|
+
kind=ParamKind.VAR_KEYWORD,
|
|
156
|
+
annotation=_ann(args.kwarg),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ann(arg: ast.arg) -> str | None:
|
|
163
|
+
return _unparse(arg.annotation) if arg.annotation is not None else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _span(node: ast.AST, file: Path) -> SourceSpan:
|
|
167
|
+
return SourceSpan(
|
|
168
|
+
file=file,
|
|
169
|
+
lineno=getattr(node, "lineno", 0) or 0,
|
|
170
|
+
end_lineno=getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0,
|
|
171
|
+
col=getattr(node, "col_offset", 0) or 0,
|
|
172
|
+
end_col=getattr(node, "end_col_offset", 0) or 0,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _unparse(node: ast.AST | None) -> str:
|
|
177
|
+
if node is None:
|
|
178
|
+
return ""
|
|
179
|
+
try:
|
|
180
|
+
return ast.unparse(node)
|
|
181
|
+
except Exception: # pragma: no cover - unparse is robust on parsed trees
|
|
182
|
+
return "<expr>"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Test generators: turn the analysis into runnable tests.
|
|
2
|
+
|
|
3
|
+
M1 ships the import-smoke generator - the cheapest, highest-value check for
|
|
4
|
+
"the deployment will not even start". Characterization, property, endpoint,
|
|
5
|
+
config and contract generators follow in later milestones.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from deploy_guard.generators.base import GeneratedFile
|
|
9
|
+
from deploy_guard.generators.import_smoke import generate_import_smoke
|
|
10
|
+
|
|
11
|
+
__all__ = ["GeneratedFile", "generate_import_smoke"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Import-smoke generator.
|
|
2
|
+
|
|
3
|
+
Emits a single pytest module that imports every discovered product module by
|
|
4
|
+
its dotted name. A broken import, a missing dependency, a module-level
|
|
5
|
+
exception or a circular import all surface as a failing parametrised case -
|
|
6
|
+
which is exactly the class of bug that takes a deployment down before it
|
|
7
|
+
serves a request.
|
|
8
|
+
|
|
9
|
+
The generated file is written to ``.deploy-guard/generated/`` and finds the
|
|
10
|
+
project root by walking up to the ``.deploy-guard`` directory, so it stays
|
|
11
|
+
correct no matter where pytest is invoked from or how deep the tree is.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from deploy_guard.generators.base import GeneratedFile
|
|
19
|
+
from deploy_guard.ir.model import Project
|
|
20
|
+
|
|
21
|
+
_TEMPLATE = '''\
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Generated by Deployment Guard Engine - import smoke test.
|
|
24
|
+
# Do not edit by hand; re-run `deploy-guard scan --write-tests` to refresh.
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
import importlib
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
_HERE = Path(__file__).resolve()
|
|
33
|
+
# .../<root>/.deploy-guard/generated/test_import_smoke.py -> <root>
|
|
34
|
+
_PROJECT_ROOT = next(
|
|
35
|
+
(p.parent for p in _HERE.parents if p.name == ".deploy-guard"),
|
|
36
|
+
_HERE.parents[2],
|
|
37
|
+
)
|
|
38
|
+
_PACKAGE_ROOTS = {roots!r}
|
|
39
|
+
for _rel in _PACKAGE_ROOTS:
|
|
40
|
+
_abs = str((_PROJECT_ROOT / _rel).resolve())
|
|
41
|
+
if _abs not in sys.path:
|
|
42
|
+
sys.path.insert(0, _abs)
|
|
43
|
+
|
|
44
|
+
IMPORTABLE_MODULES = {modules!r}
|
|
45
|
+
{broken_block}
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize(
|
|
48
|
+
"module_name",
|
|
49
|
+
IMPORTABLE_MODULES
|
|
50
|
+
or [pytest.param(None, marks=pytest.mark.skip(reason="no modules discovered"))],
|
|
51
|
+
)
|
|
52
|
+
def test_module_imports_cleanly(module_name):
|
|
53
|
+
"""Importing the module must not raise: no import errors, no crash at import time."""
|
|
54
|
+
importlib.import_module(module_name)
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
_BROKEN_BLOCK = '''\
|
|
58
|
+
UNPARSEABLE_MODULES = {broken!r}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("module_name, error", UNPARSEABLE_MODULES)
|
|
62
|
+
def test_module_parses(module_name, error):
|
|
63
|
+
"""A module the engine cannot parse cannot be reasoned about or shipped safely."""
|
|
64
|
+
pytest.fail(f"{{module_name}}: {{error}}")
|
|
65
|
+
'''
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def generate_import_smoke(project: Project) -> GeneratedFile:
|
|
69
|
+
roots = sorted({_rel_root(project, str(p)) for p in project.package_roots}) or ["."]
|
|
70
|
+
|
|
71
|
+
modules = sorted(
|
|
72
|
+
{m.dotted_name for m in project.modules if not m.parse_error and m.dotted_name}
|
|
73
|
+
)
|
|
74
|
+
broken = sorted(
|
|
75
|
+
(m.dotted_name or str(m.path), m.parse_error or "unknown parse error")
|
|
76
|
+
for m in project.modules
|
|
77
|
+
if m.parse_error
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
broken_block = _BROKEN_BLOCK.format(broken=broken) if broken else ""
|
|
81
|
+
content = _TEMPLATE.format(roots=roots, modules=modules, broken_block=broken_block)
|
|
82
|
+
return GeneratedFile(
|
|
83
|
+
relpath="test_import_smoke.py", content=content, kind="import-smoke"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _rel_root(project: Project, path: str) -> str:
|
|
88
|
+
try:
|
|
89
|
+
return os.path.relpath(path, project.root)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["generate_import_smoke"]
|