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.
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.pyo
4
+ *.pyd
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ .eggs/
9
+ build/
10
+ dist/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ .venv/
17
+ venv/
18
+ .env
19
+ .DS_Store
20
+ .vscode/
@@ -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,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include src *.py
4
+ recursive-exclude tests *
@@ -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,4 @@
1
+ from .analyzer import ImpactReport, analyze_change
2
+
3
+ __all__ = ["ImpactReport", "analyze_change"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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,5 @@
1
+ from auth.login import login_user
2
+
3
+
4
+ def login_route(user: str, pwd: str) -> bool:
5
+ return login_user(user, pwd)
@@ -0,0 +1,2 @@
1
+ def login_user(username: str, password: str) -> bool:
2
+ return bool(username and password)
@@ -0,0 +1,5 @@
1
+ from auth.login import login_user
2
+
3
+
4
+ def test_login_user() -> None:
5
+ assert login_user("john", "secret")
@@ -0,0 +1,5 @@
1
+ from auth import login
2
+
3
+
4
+ def can_sign_in(user: str, pwd: str) -> bool:
5
+ return login.login_user(user, pwd)
@@ -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"}