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,166 @@
|
|
|
1
|
+
"""Walk a product directory and build a :class:`Project` shell.
|
|
2
|
+
|
|
3
|
+
Discovery is deliberately dumb: it finds source files, works out each Python
|
|
4
|
+
module's dotted name from its package roots, and sniffs the build system.
|
|
5
|
+
Parsing into functions and classes is the frontend's job.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from deploy_guard.ir.model import Module, Project
|
|
13
|
+
|
|
14
|
+
# Directories we never descend into.
|
|
15
|
+
SKIP_DIRS = {
|
|
16
|
+
".git", ".hg", ".svn",
|
|
17
|
+
".venv", "venv", "env", ".env",
|
|
18
|
+
"node_modules", "__pycache__",
|
|
19
|
+
".idea", ".vscode",
|
|
20
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".nox",
|
|
21
|
+
"build", "dist", ".eggs", "site-packages",
|
|
22
|
+
".deploy_guard",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
TEST_DIR_NAMES = {"tests", "test", "testing"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _looks_like_test(path: Path) -> bool:
|
|
29
|
+
name = path.name
|
|
30
|
+
if name == "conftest.py":
|
|
31
|
+
return True
|
|
32
|
+
if name.startswith("test_") or name.endswith("_test.py"):
|
|
33
|
+
return True
|
|
34
|
+
return any(part in TEST_DIR_NAMES for part in path.parts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _package_root(py_file: Path) -> Path:
|
|
38
|
+
"""The directory to put on ``sys.path`` so ``py_file`` imports cleanly.
|
|
39
|
+
|
|
40
|
+
Walk up while each directory is a package (has ``__init__.py``); the first
|
|
41
|
+
ancestor that is not a package is the root.
|
|
42
|
+
"""
|
|
43
|
+
parent = py_file.parent
|
|
44
|
+
while (parent.parent != parent) and (parent / "__init__.py").exists():
|
|
45
|
+
parent = parent.parent
|
|
46
|
+
return parent
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dotted_name(py_file: Path, root: Path) -> str:
|
|
50
|
+
rel = py_file.relative_to(root).with_suffix("")
|
|
51
|
+
parts = list(rel.parts)
|
|
52
|
+
if parts and parts[-1] == "__init__":
|
|
53
|
+
parts.pop()
|
|
54
|
+
return ".".join(parts) if parts else py_file.stem
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _detect_build_systems(root: Path) -> tuple[list[str], list[str]]:
|
|
58
|
+
"""Return (build_systems, notes)."""
|
|
59
|
+
systems: list[str] = []
|
|
60
|
+
notes: list[str] = []
|
|
61
|
+
checks = {
|
|
62
|
+
"pyproject.toml": "pyproject",
|
|
63
|
+
"setup.py": "setuptools",
|
|
64
|
+
"setup.cfg": "setuptools",
|
|
65
|
+
"Pipfile": "pipenv",
|
|
66
|
+
"poetry.lock": "poetry",
|
|
67
|
+
"pdm.lock": "pdm",
|
|
68
|
+
"pom.xml": "maven",
|
|
69
|
+
"build.gradle": "gradle",
|
|
70
|
+
"build.gradle.kts": "gradle",
|
|
71
|
+
}
|
|
72
|
+
for fname, system in checks.items():
|
|
73
|
+
if (root / fname).exists() and system not in systems:
|
|
74
|
+
systems.append(system)
|
|
75
|
+
for req in sorted(root.glob("requirements*.txt")):
|
|
76
|
+
notes.append(f"found {req.name}")
|
|
77
|
+
if "pip-requirements" not in systems:
|
|
78
|
+
systems.append("pip-requirements")
|
|
79
|
+
return systems, notes
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _inside_venv(directory: Path, root: Path, cache: dict[Path, bool]) -> bool:
|
|
83
|
+
"""True if ``directory`` is at or below a virtualenv (a dir with pyvenv.cfg)."""
|
|
84
|
+
if directory in cache:
|
|
85
|
+
return cache[directory]
|
|
86
|
+
if directory != root and root not in directory.parents:
|
|
87
|
+
cache[directory] = False
|
|
88
|
+
return False
|
|
89
|
+
result = (directory / "pyvenv.cfg").exists()
|
|
90
|
+
if not result and directory != root:
|
|
91
|
+
result = _inside_venv(directory.parent, root, cache)
|
|
92
|
+
cache[directory] = result
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover(
|
|
97
|
+
root: str | Path,
|
|
98
|
+
*,
|
|
99
|
+
include_tests: bool = False,
|
|
100
|
+
extra_exclude: list[str] | None = None,
|
|
101
|
+
) -> Project:
|
|
102
|
+
root = Path(root).resolve()
|
|
103
|
+
if not root.is_dir():
|
|
104
|
+
raise NotADirectoryError(f"not a directory: {root}")
|
|
105
|
+
_excluded = set(extra_exclude or ())
|
|
106
|
+
|
|
107
|
+
build_systems, notes = _detect_build_systems(root)
|
|
108
|
+
project = Project(root=root, build_systems=build_systems, notes=notes)
|
|
109
|
+
|
|
110
|
+
java_count = 0
|
|
111
|
+
package_roots: set[Path] = set()
|
|
112
|
+
venv_cache: dict[Path, bool] = {}
|
|
113
|
+
|
|
114
|
+
for path in sorted(root.rglob("*")):
|
|
115
|
+
if path.is_dir():
|
|
116
|
+
continue
|
|
117
|
+
rel_parts = path.relative_to(root).parts
|
|
118
|
+
if any(part in SKIP_DIRS for part in rel_parts):
|
|
119
|
+
continue
|
|
120
|
+
if _excluded and any(part in _excluded for part in rel_parts):
|
|
121
|
+
continue
|
|
122
|
+
# Skip any virtualenv regardless of its name (it carries a pyvenv.cfg).
|
|
123
|
+
if _inside_venv(path.parent, root, venv_cache):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if path.suffix == ".java":
|
|
127
|
+
java_count += 1
|
|
128
|
+
project.languages.add("java")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if path.suffix != ".py":
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
project.languages.add("python")
|
|
135
|
+
if not include_tests and _looks_like_test(path):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
pkg_root = _package_root(path)
|
|
139
|
+
package_roots.add(pkg_root)
|
|
140
|
+
try:
|
|
141
|
+
source = path.read_text(encoding="utf-8")
|
|
142
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
143
|
+
project.modules.append(
|
|
144
|
+
Module(
|
|
145
|
+
path=path,
|
|
146
|
+
dotted_name=path.stem,
|
|
147
|
+
parse_error=f"could not read file: {exc}",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
project.modules.append(
|
|
153
|
+
Module(
|
|
154
|
+
path=path,
|
|
155
|
+
dotted_name=_dotted_name(path, pkg_root),
|
|
156
|
+
is_package=(path.name == "__init__.py"),
|
|
157
|
+
source=source,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
project.package_roots = sorted(package_roots)
|
|
162
|
+
if java_count:
|
|
163
|
+
project.notes.append(
|
|
164
|
+
f"{java_count} Java file(s) found - Java frontend lands in M4, skipped for now"
|
|
165
|
+
)
|
|
166
|
+
return project
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Language-neutral intermediate representation.
|
|
2
|
+
|
|
3
|
+
Frontends (Python, Java) lower source into the dataclasses in
|
|
4
|
+
:mod:`deploy_guard.ir.model`; every analysis pass then runs on this shared IR
|
|
5
|
+
so the clever work is written once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from deploy_guard.ir.model import (
|
|
9
|
+
ClassDef,
|
|
10
|
+
FunctionDef,
|
|
11
|
+
Module,
|
|
12
|
+
Parameter,
|
|
13
|
+
Project,
|
|
14
|
+
SourceSpan,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Project",
|
|
19
|
+
"Module",
|
|
20
|
+
"ClassDef",
|
|
21
|
+
"FunctionDef",
|
|
22
|
+
"Parameter",
|
|
23
|
+
"SourceSpan",
|
|
24
|
+
]
|
deploy_guard/ir/cfg.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Control-flow graph dataclasses.
|
|
2
|
+
|
|
3
|
+
A :class:`CFG` is a set of :class:`BasicBlock` nodes connected by
|
|
4
|
+
:class:`Edge` objects. Blocks carry the source statements that run when the
|
|
5
|
+
block is entered; the block's :class:`Terminator` says how control leaves it
|
|
6
|
+
when it is not via a normal edge (a ``return``, ``raise``, ``break`` ...).
|
|
7
|
+
|
|
8
|
+
The representation is language-neutral: ``Statement.node`` and ``Edge.guard``
|
|
9
|
+
hold whatever native node the frontend produced.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EdgeKind(str, Enum):
|
|
22
|
+
SEQ = "seq" # unconditional fall-through
|
|
23
|
+
TRUE = "true" # branch taken when the guard holds
|
|
24
|
+
FALSE = "false" # branch taken when the guard fails
|
|
25
|
+
MATCH = "match" # a match-case branch (guard is the pattern)
|
|
26
|
+
LOOP_BACK = "loop_back" # back-edge to a loop header
|
|
27
|
+
EXCEPTION = "exception" # an exception may transfer control here
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Terminator(str, Enum):
|
|
31
|
+
NONE = "none" # leaves via its edges
|
|
32
|
+
RETURN = "return" # explicit ``return <value>``
|
|
33
|
+
IMPLICIT_RETURN = "implicit_return" # control fell off the end -> returns None
|
|
34
|
+
RAISE = "raise" # explicit ``raise``
|
|
35
|
+
BREAK = "break"
|
|
36
|
+
CONTINUE = "continue"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Statement:
|
|
41
|
+
node: Any # native AST node
|
|
42
|
+
lineno: int
|
|
43
|
+
kind: str # node class name, e.g. "Assign", "Expr", "If"
|
|
44
|
+
src: str = "" # unparsed source, possibly truncated
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Edge:
|
|
49
|
+
target: int
|
|
50
|
+
kind: EdgeKind = EdgeKind.SEQ
|
|
51
|
+
guard: Any = None # native expression node for TRUE/FALSE/MATCH
|
|
52
|
+
guard_src: str | None = None
|
|
53
|
+
negated: bool = False # guard_src is the condition; negated => FALSE arm
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class BasicBlock:
|
|
58
|
+
id: int
|
|
59
|
+
statements: list[Statement] = field(default_factory=list)
|
|
60
|
+
succ: list[Edge] = field(default_factory=list)
|
|
61
|
+
pred: list[int] = field(default_factory=list)
|
|
62
|
+
terminator: Terminator = Terminator.NONE
|
|
63
|
+
term_node: Any = None # the return / raise node, when applicable
|
|
64
|
+
label: str = ""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def first_line(self) -> int | None:
|
|
68
|
+
return self.statements[0].lineno if self.statements else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class CFG:
|
|
73
|
+
func_qualname: str
|
|
74
|
+
entry: int
|
|
75
|
+
exit: int
|
|
76
|
+
blocks: dict[int, BasicBlock] = field(default_factory=dict)
|
|
77
|
+
notes: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def block(self, bid: int) -> BasicBlock:
|
|
80
|
+
return self.blocks[bid]
|
|
81
|
+
|
|
82
|
+
def iter_blocks(self) -> Iterator[BasicBlock]:
|
|
83
|
+
return iter(self.blocks.values())
|
|
84
|
+
|
|
85
|
+
def finalize(self) -> CFG:
|
|
86
|
+
"""Recompute predecessor lists. Call once the graph is fully built."""
|
|
87
|
+
for b in self.blocks.values():
|
|
88
|
+
b.pred = []
|
|
89
|
+
for b in self.blocks.values():
|
|
90
|
+
for e in b.succ:
|
|
91
|
+
if e.target in self.blocks and b.id not in self.blocks[e.target].pred:
|
|
92
|
+
self.blocks[e.target].pred.append(b.id)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def reachable_ids(self) -> set[int]:
|
|
96
|
+
seen: set[int] = set()
|
|
97
|
+
q: deque[int] = deque([self.entry])
|
|
98
|
+
while q:
|
|
99
|
+
bid = q.popleft()
|
|
100
|
+
if bid in seen or bid not in self.blocks:
|
|
101
|
+
continue
|
|
102
|
+
seen.add(bid)
|
|
103
|
+
for e in self.blocks[bid].succ:
|
|
104
|
+
q.append(e.target)
|
|
105
|
+
return seen
|
|
106
|
+
|
|
107
|
+
def unreachable_blocks(self) -> list[BasicBlock]:
|
|
108
|
+
reach = self.reachable_ids()
|
|
109
|
+
out = [
|
|
110
|
+
b
|
|
111
|
+
for bid, b in sorted(self.blocks.items())
|
|
112
|
+
if bid not in reach and b.statements
|
|
113
|
+
]
|
|
114
|
+
return out
|
deploy_guard/ir/model.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The language-neutral IR dataclasses.
|
|
2
|
+
|
|
3
|
+
A :class:`Project` holds many :class:`Module` objects, each of which exposes
|
|
4
|
+
module-level :class:`FunctionDef` and :class:`ClassDef` nodes. Frontends fill
|
|
5
|
+
these in; analyses read them. The ``raw`` field on a function carries the
|
|
6
|
+
frontend's native AST node (a Python ``ast.FunctionDef`` today) and is only
|
|
7
|
+
touched by language-specific passes such as the CFG builder.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParamKind(str, Enum):
|
|
20
|
+
POSITIONAL_ONLY = "positional_only"
|
|
21
|
+
POSITIONAL_OR_KEYWORD = "positional_or_keyword"
|
|
22
|
+
VAR_POSITIONAL = "var_positional"
|
|
23
|
+
KEYWORD_ONLY = "keyword_only"
|
|
24
|
+
VAR_KEYWORD = "var_keyword"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SourceSpan:
|
|
29
|
+
"""A range in a source file. 1-indexed lines, 0-indexed columns."""
|
|
30
|
+
|
|
31
|
+
file: Path
|
|
32
|
+
lineno: int
|
|
33
|
+
end_lineno: int
|
|
34
|
+
col: int = 0
|
|
35
|
+
end_col: int = 0
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
38
|
+
return f"{self.file}:{self.lineno}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Parameter:
|
|
43
|
+
name: str
|
|
44
|
+
kind: ParamKind = ParamKind.POSITIONAL_OR_KEYWORD
|
|
45
|
+
annotation: str | None = None
|
|
46
|
+
default: str | None = None # source text of the default, if any
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def has_default(self) -> bool:
|
|
50
|
+
return self.default is not None or self.kind in (
|
|
51
|
+
ParamKind.VAR_POSITIONAL,
|
|
52
|
+
ParamKind.VAR_KEYWORD,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class FunctionDef:
|
|
58
|
+
name: str
|
|
59
|
+
qualname: str
|
|
60
|
+
span: SourceSpan
|
|
61
|
+
params: list[Parameter] = field(default_factory=list)
|
|
62
|
+
returns: str | None = None # return annotation source text
|
|
63
|
+
decorators: list[str] = field(default_factory=list)
|
|
64
|
+
docstring: str | None = None
|
|
65
|
+
is_async: bool = False
|
|
66
|
+
is_method: bool = False
|
|
67
|
+
# The frontend's native node, e.g. an ``ast.FunctionDef``. Only
|
|
68
|
+
# language-specific passes may read this.
|
|
69
|
+
raw: Any = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_public(self) -> bool:
|
|
73
|
+
return not self.name.startswith("_")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def positional_params(self) -> list[Parameter]:
|
|
77
|
+
skip = ParamKind.VAR_POSITIONAL, ParamKind.VAR_KEYWORD, ParamKind.KEYWORD_ONLY
|
|
78
|
+
out = [p for p in self.params if p.kind not in skip]
|
|
79
|
+
if self.is_method and out and out[0].name in ("self", "cls"):
|
|
80
|
+
out = out[1:]
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ClassDef:
|
|
86
|
+
name: str
|
|
87
|
+
qualname: str
|
|
88
|
+
span: SourceSpan
|
|
89
|
+
bases: list[str] = field(default_factory=list)
|
|
90
|
+
decorators: list[str] = field(default_factory=list)
|
|
91
|
+
docstring: str | None = None
|
|
92
|
+
methods: list[FunctionDef] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Module:
|
|
97
|
+
path: Path
|
|
98
|
+
dotted_name: str
|
|
99
|
+
is_package: bool = False
|
|
100
|
+
source: str = ""
|
|
101
|
+
imports: list[str] = field(default_factory=list)
|
|
102
|
+
functions: list[FunctionDef] = field(default_factory=list)
|
|
103
|
+
classes: list[ClassDef] = field(default_factory=list)
|
|
104
|
+
parse_error: str | None = None
|
|
105
|
+
# (lineno, message) for SyntaxWarnings raised while parsing this module.
|
|
106
|
+
syntax_warnings: list[tuple[int, str]] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
def iter_functions(self) -> Iterator[FunctionDef]:
|
|
109
|
+
"""Every function in the module: module-level then per class."""
|
|
110
|
+
yield from self.functions
|
|
111
|
+
for cls in self.classes:
|
|
112
|
+
yield from cls.methods
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Project:
|
|
117
|
+
root: Path
|
|
118
|
+
languages: set[str] = field(default_factory=set)
|
|
119
|
+
build_systems: list[str] = field(default_factory=list)
|
|
120
|
+
package_roots: list[Path] = field(default_factory=list)
|
|
121
|
+
modules: list[Module] = field(default_factory=list)
|
|
122
|
+
entrypoints: list[str] = field(default_factory=list)
|
|
123
|
+
notes: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
def iter_functions(self) -> Iterator[tuple[Module, FunctionDef]]:
|
|
126
|
+
for mod in self.modules:
|
|
127
|
+
for fn in mod.iter_functions():
|
|
128
|
+
yield mod, fn
|
deploy_guard/py.typed
ADDED
|
File without changes
|