code-change-impact-analyzer 0.1.0__tar.gz
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.
- code_change_impact_analyzer-0.1.0/.gitignore +20 -0
- code_change_impact_analyzer-0.1.0/LICENSE +21 -0
- code_change_impact_analyzer-0.1.0/MANIFEST.in +4 -0
- code_change_impact_analyzer-0.1.0/PKG-INFO +97 -0
- code_change_impact_analyzer-0.1.0/README.md +69 -0
- code_change_impact_analyzer-0.1.0/pyproject.toml +65 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/__init__.py +4 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/__main__.py +4 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/analyzer.py +100 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/cli.py +56 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/models.py +34 -0
- code_change_impact_analyzer-0.1.0/src/code_impact_analyzer/parser.py +88 -0
- code_change_impact_analyzer-0.1.0/tests/fixtures/sample_project/api/routes.py +5 -0
- code_change_impact_analyzer-0.1.0/tests/fixtures/sample_project/auth/login.py +2 -0
- code_change_impact_analyzer-0.1.0/tests/fixtures/sample_project/tests/test_login.py +5 -0
- code_change_impact_analyzer-0.1.0/tests/fixtures/sample_project/user/service.py +5 -0
- code_change_impact_analyzer-0.1.0/tests/test_analyzer.py +19 -0
- code_change_impact_analyzer-0.1.0/tests/test_cli.py +15 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vidhi Bhutia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: code-change-impact-analyzer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Analyze and predict impacted Python modules from code changes using static dependency analysis.
|
|
5
|
+
Project-URL: PyPI, https://pypi.org/project/code-change-impact-analyzer/
|
|
6
|
+
Author-email: Vidhi Bhutia <vidhibhutia2407@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,dependency-graph,impact-analysis,python,static-analysis
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.11.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.12.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Code Change Impact Analyzer
|
|
30
|
+
|
|
31
|
+
A Python package and CLI that predicts which Python modules are impacted when a file changes.
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
Given a changed file (for example `auth/login.py`), the analyzer:
|
|
36
|
+
|
|
37
|
+
1. Parses project files.
|
|
38
|
+
2. Detects function definitions.
|
|
39
|
+
3. Builds import and call dependency graphs.
|
|
40
|
+
4. Maps function usage across modules.
|
|
41
|
+
5. Shows impacted modules.
|
|
42
|
+
|
|
43
|
+
## Why engineers use it
|
|
44
|
+
|
|
45
|
+
- Understand blast radius before merging changes.
|
|
46
|
+
- Speed up code reviews in large codebases.
|
|
47
|
+
- Decide where to add or run tests.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install code-change-impact-analyzer
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For local development:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e .[dev]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## CLI Usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
impact-analyzer --root . --changed auth/login.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
|
|
69
|
+
- `--root`: project root directory to analyze (default: current directory)
|
|
70
|
+
- `--changed`: changed file path relative to root (required)
|
|
71
|
+
- `--json`: print JSON output
|
|
72
|
+
|
|
73
|
+
## Example Output
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
Editing: auth/login.py
|
|
77
|
+
Impacted modules:
|
|
78
|
+
- api/routes.py
|
|
79
|
+
- user/service.py
|
|
80
|
+
- tests/test_login.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
The tool performs static analysis over Python source files:
|
|
86
|
+
|
|
87
|
+
- Collects module imports (`import x`, `from x import y`)
|
|
88
|
+
- Collects function definitions per module
|
|
89
|
+
- Collects function calls per module
|
|
90
|
+
- Builds reverse dependency graph to find modules depending on the changed module
|
|
91
|
+
- Expands impact by function-level usage signals
|
|
92
|
+
|
|
93
|
+
## Limitations
|
|
94
|
+
|
|
95
|
+
- Dynamic imports and metaprogramming are not fully resolved.
|
|
96
|
+
- Runtime dispatch and monkey patching are not modeled.
|
|
97
|
+
- Best for conventional Python codebases.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Code Change Impact Analyzer
|
|
2
|
+
|
|
3
|
+
A Python package and CLI that predicts which Python modules are impacted when a file changes.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Given a changed file (for example `auth/login.py`), the analyzer:
|
|
8
|
+
|
|
9
|
+
1. Parses project files.
|
|
10
|
+
2. Detects function definitions.
|
|
11
|
+
3. Builds import and call dependency graphs.
|
|
12
|
+
4. Maps function usage across modules.
|
|
13
|
+
5. Shows impacted modules.
|
|
14
|
+
|
|
15
|
+
## Why engineers use it
|
|
16
|
+
|
|
17
|
+
- Understand blast radius before merging changes.
|
|
18
|
+
- Speed up code reviews in large codebases.
|
|
19
|
+
- Decide where to add or run tests.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install code-change-impact-analyzer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For local development:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e .[dev]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
impact-analyzer --root . --changed auth/login.py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
|
|
41
|
+
- `--root`: project root directory to analyze (default: current directory)
|
|
42
|
+
- `--changed`: changed file path relative to root (required)
|
|
43
|
+
- `--json`: print JSON output
|
|
44
|
+
|
|
45
|
+
## Example Output
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Editing: auth/login.py
|
|
49
|
+
Impacted modules:
|
|
50
|
+
- api/routes.py
|
|
51
|
+
- user/service.py
|
|
52
|
+
- tests/test_login.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
The tool performs static analysis over Python source files:
|
|
58
|
+
|
|
59
|
+
- Collects module imports (`import x`, `from x import y`)
|
|
60
|
+
- Collects function definitions per module
|
|
61
|
+
- Collects function calls per module
|
|
62
|
+
- Builds reverse dependency graph to find modules depending on the changed module
|
|
63
|
+
- Expands impact by function-level usage signals
|
|
64
|
+
|
|
65
|
+
## Limitations
|
|
66
|
+
|
|
67
|
+
- Dynamic imports and metaprogramming are not fully resolved.
|
|
68
|
+
- Runtime dispatch and monkey patching are not modeled.
|
|
69
|
+
- Best for conventional Python codebases.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "code-change-impact-analyzer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Analyze and predict impacted Python modules from code changes using static dependency analysis."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vidhi Bhutia", email = "vidhibhutia2407@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["static-analysis", "dependency-graph", "impact-analysis", "python", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Topic :: Software Development :: Quality Assurance"
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.3.0",
|
|
34
|
+
"pytest-cov>=5.0.0",
|
|
35
|
+
"ruff>=0.12.0",
|
|
36
|
+
"mypy>=1.11.0"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
PyPI = "https://pypi.org/project/code-change-impact-analyzer/"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
impact-analyzer = "code_impact_analyzer.cli:main"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/code_impact_analyzer"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py39"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
pythonpath = ["src"]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
addopts = "-q"
|
|
59
|
+
norecursedirs = ["tests/fixtures"]
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.9"
|
|
63
|
+
strict = true
|
|
64
|
+
warn_unused_configs = true
|
|
65
|
+
mypy_path = "src"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import ImpactReport, ImpactedModule, ModuleAnalysis
|
|
7
|
+
from .parser import discover_python_files, module_name_from_path, module_path_from_name, parse_module_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_imported_modules(import_name: str, known_modules: set[str]) -> set[str]:
|
|
11
|
+
resolved: set[str] = set()
|
|
12
|
+
|
|
13
|
+
if import_name in known_modules:
|
|
14
|
+
resolved.add(import_name)
|
|
15
|
+
|
|
16
|
+
prefix = import_name
|
|
17
|
+
while "." in prefix:
|
|
18
|
+
prefix = prefix.rsplit(".", 1)[0]
|
|
19
|
+
if prefix in known_modules:
|
|
20
|
+
resolved.add(prefix)
|
|
21
|
+
|
|
22
|
+
for module in known_modules:
|
|
23
|
+
if module.startswith(import_name + "."):
|
|
24
|
+
resolved.add(module)
|
|
25
|
+
|
|
26
|
+
return resolved
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_reverse_dependency_graph(module_map: dict[str, ModuleAnalysis]) -> dict[str, set[str]]:
|
|
30
|
+
known_modules = set(module_map)
|
|
31
|
+
reverse_graph: dict[str, set[str]] = {module: set() for module in module_map}
|
|
32
|
+
|
|
33
|
+
for module, analysis in module_map.items():
|
|
34
|
+
for imported in analysis.imports:
|
|
35
|
+
for resolved in _resolve_imported_modules(imported, known_modules):
|
|
36
|
+
reverse_graph.setdefault(resolved, set()).add(module)
|
|
37
|
+
|
|
38
|
+
return reverse_graph
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _transitive_dependents(changed_module: str, reverse_graph: dict[str, set[str]]) -> set[str]:
|
|
42
|
+
impacted: set[str] = set()
|
|
43
|
+
queue: deque[str] = deque([changed_module])
|
|
44
|
+
visited: set[str] = {changed_module}
|
|
45
|
+
|
|
46
|
+
while queue:
|
|
47
|
+
current = queue.popleft()
|
|
48
|
+
for dependent in reverse_graph.get(current, set()):
|
|
49
|
+
if dependent in visited:
|
|
50
|
+
continue
|
|
51
|
+
visited.add(dependent)
|
|
52
|
+
impacted.add(dependent)
|
|
53
|
+
queue.append(dependent)
|
|
54
|
+
|
|
55
|
+
return impacted
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def analyze_change(root: str | Path, changed_file: str | Path) -> ImpactReport:
|
|
59
|
+
root_path = Path(root).resolve()
|
|
60
|
+
changed_path = (root_path / changed_file).resolve() if not Path(changed_file).is_absolute() else Path(changed_file).resolve()
|
|
61
|
+
|
|
62
|
+
if not changed_path.exists() or changed_path.suffix != ".py":
|
|
63
|
+
raise FileNotFoundError(f"Changed file does not exist or is not a .py file: {changed_file}")
|
|
64
|
+
|
|
65
|
+
python_files = discover_python_files(root_path)
|
|
66
|
+
module_to_path: dict[str, Path] = {}
|
|
67
|
+
module_map: dict[str, ModuleAnalysis] = {}
|
|
68
|
+
|
|
69
|
+
for file_path in python_files:
|
|
70
|
+
module_name = module_name_from_path(root_path, file_path)
|
|
71
|
+
module_to_path[module_name] = file_path
|
|
72
|
+
module_map[module_name] = parse_module_file(file_path)
|
|
73
|
+
|
|
74
|
+
changed_module = module_name_from_path(root_path, changed_path)
|
|
75
|
+
if changed_module not in module_map:
|
|
76
|
+
raise ValueError(f"Changed module was not discovered during analysis: {changed_module}")
|
|
77
|
+
|
|
78
|
+
reverse_graph = _build_reverse_dependency_graph(module_map)
|
|
79
|
+
impacted_by_import = _transitive_dependents(changed_module, reverse_graph)
|
|
80
|
+
|
|
81
|
+
changed_functions = module_map[changed_module].function_defs
|
|
82
|
+
impacted_entries: list[ImpactedModule] = []
|
|
83
|
+
|
|
84
|
+
for module in sorted(impacted_by_import):
|
|
85
|
+
reasons: list[str] = ["imports-or-transitively-depends-on-changed-module"]
|
|
86
|
+
if changed_functions and module_map[module].function_calls.intersection(changed_functions):
|
|
87
|
+
reasons.append("calls-function-defined-in-changed-module")
|
|
88
|
+
|
|
89
|
+
resolved_path = module_to_path.get(module) or module_path_from_name(root_path, module)
|
|
90
|
+
relative_path = str(resolved_path.relative_to(root_path)).replace("\\", "/")
|
|
91
|
+
impacted_entries.append(
|
|
92
|
+
ImpactedModule(module=module, path=relative_path, reasons=tuple(reasons))
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
changed_relative = str(changed_path.relative_to(root_path)).replace("\\", "/")
|
|
96
|
+
return ImpactReport(
|
|
97
|
+
changed_file=changed_relative,
|
|
98
|
+
changed_module=changed_module,
|
|
99
|
+
impacted_modules=tuple(impacted_entries),
|
|
100
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .analyzer import analyze_change
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="impact-analyzer",
|
|
14
|
+
description="Predict impacted Python modules for a changed file.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--root", default=".", help="Project root to analyze (default: current directory)")
|
|
17
|
+
parser.add_argument("--changed", required=True, help="Changed Python file path, relative to root")
|
|
18
|
+
parser.add_argument("--json", action="store_true", dest="as_json", help="Print output in JSON format")
|
|
19
|
+
return parser
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_human(report: dict[str, object]) -> None:
|
|
23
|
+
changed_file = report["changed_file"]
|
|
24
|
+
impacted = report["impacted_modules"]
|
|
25
|
+
|
|
26
|
+
print(f"Editing: {changed_file}")
|
|
27
|
+
print("Impacted modules:")
|
|
28
|
+
if not impacted:
|
|
29
|
+
print("(none)")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
for item in impacted:
|
|
33
|
+
path = item["path"]
|
|
34
|
+
print(f"- {path}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
report = analyze_change(Path(args.root), Path(args.changed)).as_dict()
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
if args.as_json:
|
|
48
|
+
print(json.dumps(report, indent=2))
|
|
49
|
+
else:
|
|
50
|
+
_print_human(report)
|
|
51
|
+
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class ModuleAnalysis:
|
|
8
|
+
imports: set[str]
|
|
9
|
+
function_defs: set[str]
|
|
10
|
+
function_calls: set[str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ImpactedModule:
|
|
15
|
+
module: str
|
|
16
|
+
path: str
|
|
17
|
+
reasons: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ImpactReport:
|
|
22
|
+
changed_file: str
|
|
23
|
+
changed_module: str
|
|
24
|
+
impacted_modules: tuple[ImpactedModule, ...] = field(default_factory=tuple)
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"changed_file": self.changed_file,
|
|
29
|
+
"changed_module": self.changed_module,
|
|
30
|
+
"impacted_modules": [
|
|
31
|
+
{"module": item.module, "path": item.path, "reasons": list(item.reasons)}
|
|
32
|
+
for item in self.impacted_modules
|
|
33
|
+
],
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import ModuleAnalysis
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _ModuleVisitor(ast.NodeVisitor):
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.imports: set[str] = set()
|
|
12
|
+
self.function_defs: set[str] = set()
|
|
13
|
+
self.function_calls: set[str] = set()
|
|
14
|
+
|
|
15
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
16
|
+
for alias in node.names:
|
|
17
|
+
self.imports.add(alias.name)
|
|
18
|
+
self.generic_visit(node)
|
|
19
|
+
|
|
20
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
21
|
+
if node.module:
|
|
22
|
+
self.imports.add(node.module)
|
|
23
|
+
self.generic_visit(node)
|
|
24
|
+
|
|
25
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
26
|
+
self.function_defs.add(node.name)
|
|
27
|
+
self.generic_visit(node)
|
|
28
|
+
|
|
29
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
30
|
+
self.function_defs.add(node.name)
|
|
31
|
+
self.generic_visit(node)
|
|
32
|
+
|
|
33
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
34
|
+
function_name = _extract_called_name(node.func)
|
|
35
|
+
if function_name:
|
|
36
|
+
self.function_calls.add(function_name)
|
|
37
|
+
self.generic_visit(node)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_called_name(node: ast.AST) -> str | None:
|
|
41
|
+
if isinstance(node, ast.Name):
|
|
42
|
+
return node.id
|
|
43
|
+
if isinstance(node, ast.Attribute):
|
|
44
|
+
return node.attr
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def module_name_from_path(root: Path, file_path: Path) -> str:
|
|
49
|
+
rel_path = file_path.relative_to(root)
|
|
50
|
+
if rel_path.suffix != ".py":
|
|
51
|
+
raise ValueError(f"Expected a Python file, got: {file_path}")
|
|
52
|
+
|
|
53
|
+
parts = list(rel_path.with_suffix("").parts)
|
|
54
|
+
if parts and parts[-1] == "__init__":
|
|
55
|
+
parts = parts[:-1]
|
|
56
|
+
|
|
57
|
+
return ".".join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def module_path_from_name(root: Path, module_name: str) -> Path:
|
|
61
|
+
parts = module_name.split(".") if module_name else []
|
|
62
|
+
return root.joinpath(*parts).with_suffix(".py")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def discover_python_files(root: Path) -> list[Path]:
|
|
66
|
+
ignore_dirs = {".git", ".venv", "venv", "__pycache__", "build", "dist", ".mypy_cache", ".pytest_cache"}
|
|
67
|
+
python_files: list[Path] = []
|
|
68
|
+
|
|
69
|
+
for path in root.rglob("*.py"):
|
|
70
|
+
if any(part in ignore_dirs for part in path.parts):
|
|
71
|
+
continue
|
|
72
|
+
python_files.append(path)
|
|
73
|
+
|
|
74
|
+
return sorted(python_files)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_module_file(file_path: Path) -> ModuleAnalysis:
|
|
78
|
+
source = file_path.read_text(encoding="utf-8")
|
|
79
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
80
|
+
|
|
81
|
+
visitor = _ModuleVisitor()
|
|
82
|
+
visitor.visit(tree)
|
|
83
|
+
|
|
84
|
+
return ModuleAnalysis(
|
|
85
|
+
imports=visitor.imports,
|
|
86
|
+
function_defs=visitor.function_defs,
|
|
87
|
+
function_calls=visitor.function_calls,
|
|
88
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from code_impact_analyzer import analyze_change
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_analyze_change_returns_expected_impacted_modules() -> None:
|
|
7
|
+
root = Path(__file__).parent / "fixtures" / "sample_project"
|
|
8
|
+
report = analyze_change(root, "auth/login.py")
|
|
9
|
+
|
|
10
|
+
impacted_paths = {item.path for item in report.impacted_modules}
|
|
11
|
+
assert impacted_paths == {"api/routes.py", "tests/test_login.py", "user/service.py"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_changed_file_not_listed_as_impacted() -> None:
|
|
15
|
+
root = Path(__file__).parent / "fixtures" / "sample_project"
|
|
16
|
+
report = analyze_change(root, "auth/login.py")
|
|
17
|
+
|
|
18
|
+
impacted_paths = {item.path for item in report.impacted_modules}
|
|
19
|
+
assert "auth/login.py" not in impacted_paths
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from code_impact_analyzer.cli import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_cli_json_output(capsys) -> None:
|
|
8
|
+
root = Path(__file__).parent / "fixtures" / "sample_project"
|
|
9
|
+
exit_code = main(["--root", str(root), "--changed", "auth/login.py", "--json"])
|
|
10
|
+
assert exit_code == 0
|
|
11
|
+
|
|
12
|
+
output = capsys.readouterr().out
|
|
13
|
+
data = json.loads(output)
|
|
14
|
+
impacted_paths = {item["path"] for item in data["impacted_modules"]}
|
|
15
|
+
assert impacted_paths == {"api/routes.py", "tests/test_login.py", "user/service.py"}
|