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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ """Rendering of :class:`~deploy_guard.engine.ScanResult` for humans and machines."""
2
+
3
+ from deploy_guard.report.render import render_json, render_markdown, render_text
4
+
5
+ __all__ = ["render_text", "render_markdown", "render_json"]