py-drift-check 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.
- py_drift_check-0.1.0/.gitignore +7 -0
- py_drift_check-0.1.0/PKG-INFO +79 -0
- py_drift_check-0.1.0/README.md +70 -0
- py_drift_check-0.1.0/ast_demo.py +17 -0
- py_drift_check-0.1.0/drift_check/__init__.py +0 -0
- py_drift_check-0.1.0/drift_check/analyzer.py +105 -0
- py_drift_check-0.1.0/drift_check/cli.py +84 -0
- py_drift_check-0.1.0/drift_check/dependencies.py +41 -0
- py_drift_check-0.1.0/drift_check/env.py +40 -0
- py_drift_check-0.1.0/drift_check/models.py +36 -0
- py_drift_check-0.1.0/drift_check/reporter.py +58 -0
- py_drift_check-0.1.0/drift_check/rules.py +75 -0
- py_drift_check-0.1.0/drift_check/scanner.py +37 -0
- py_drift_check-0.1.0/drift_check/signatures.py +179 -0
- py_drift_check-0.1.0/pyproject.toml +20 -0
- py_drift_check-0.1.0/requirements.txt +3 -0
- py_drift_check-0.1.0/test_api.py +19 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-drift-check
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect API drift between codebase and installed dependencies.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: rich>=13.7.0
|
|
7
|
+
Requires-Dist: typer>=0.12.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
ο»Ώ```markdown
|
|
11
|
+
# drift-check π
|
|
12
|
+
|
|
13
|
+
**drift-check** is an experimental CLI tool that detects API signature mismatches between your Python codebase and your installed dependencies.
|
|
14
|
+
|
|
15
|
+
If you've ever upgraded a package (like OpenAI, FastAPI, or Pydantic) only to have your app crash at runtime because a parameter was renamed or removed, `drift-check` is built to catch that *before* you run your code.
|
|
16
|
+
|
|
17
|
+
## How It Works
|
|
18
|
+
Instead of executing your application, `drift-check` uses an AST (Abstract Syntax Tree) to read your function calls, spins up a secure subprocess inside your project's `.venv`, and dynamically validates your arguments against the actual signatures of your installed packages.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Install globally via `uv` (recommended):
|
|
23
|
+
```bash
|
|
24
|
+
uv tool install drift-check
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or via pip:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install drift-check
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
Navigate to any Python project and run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
drift-check run .
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Example Output
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
ββββββββββββββ³βββββββββββββββββββββ³βββββββββββ³ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
48
|
+
β Status β Location β Package β Message β
|
|
49
|
+
β‘βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ©
|
|
50
|
+
β β ERROR β main.py:19 β openai β Unknown parameter passed: 'model_name' β
|
|
51
|
+
β β WARN β database.py:42 β asyncpg β Unable to verify forwarded keyword argument β
|
|
52
|
+
β β OK β router.py:12 β httpx β All parameters valid. β
|
|
53
|
+
ββββββββββββββ΄βββββββββββββββββββββ΄βββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Features & Current Limitations (v0.1.0)
|
|
58
|
+
|
|
59
|
+
This tool is in active development. Please report edge cases!
|
|
60
|
+
|
|
61
|
+
**Supported:**
|
|
62
|
+
|
|
63
|
+
* β
Dynamic runtime signature inspection
|
|
64
|
+
* β
Automatic `.venv` detection
|
|
65
|
+
* β
Unpacking modern `TypedDict` type-hints (e.g., OpenAI SDK)
|
|
66
|
+
* β
Standard library filtering
|
|
67
|
+
|
|
68
|
+
**Known Limitations (v0.1.0):**
|
|
69
|
+
|
|
70
|
+
* β οΈ **Instantiated Variables:** Currently struggles to trace methods called on class instances (e.g., `app.post` in FastAPI).
|
|
71
|
+
* β οΈ **Dynamic Kwargs:** Cannot statically verify parameters passed dynamically via `**kwargs` at the call site.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
ο»Ώ```markdown
|
|
2
|
+
# drift-check π
|
|
3
|
+
|
|
4
|
+
**drift-check** is an experimental CLI tool that detects API signature mismatches between your Python codebase and your installed dependencies.
|
|
5
|
+
|
|
6
|
+
If you've ever upgraded a package (like OpenAI, FastAPI, or Pydantic) only to have your app crash at runtime because a parameter was renamed or removed, `drift-check` is built to catch that *before* you run your code.
|
|
7
|
+
|
|
8
|
+
## How It Works
|
|
9
|
+
Instead of executing your application, `drift-check` uses an AST (Abstract Syntax Tree) to read your function calls, spins up a secure subprocess inside your project's `.venv`, and dynamically validates your arguments against the actual signatures of your installed packages.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install globally via `uv` (recommended):
|
|
14
|
+
```bash
|
|
15
|
+
uv tool install drift-check
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or via pip:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install drift-check
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
Navigate to any Python project and run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
drift-check run .
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Example Output
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
ββββββββββββββ³βββββββββββββββββββββ³βββββββββββ³ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
39
|
+
β Status β Location β Package β Message β
|
|
40
|
+
β‘βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ©
|
|
41
|
+
β β ERROR β main.py:19 β openai β Unknown parameter passed: 'model_name' β
|
|
42
|
+
β β WARN β database.py:42 β asyncpg β Unable to verify forwarded keyword argument β
|
|
43
|
+
β β OK β router.py:12 β httpx β All parameters valid. β
|
|
44
|
+
ββββββββββββββ΄βββββββββββββββββββββ΄βββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Features & Current Limitations (v0.1.0)
|
|
49
|
+
|
|
50
|
+
This tool is in active development. Please report edge cases!
|
|
51
|
+
|
|
52
|
+
**Supported:**
|
|
53
|
+
|
|
54
|
+
* β
Dynamic runtime signature inspection
|
|
55
|
+
* β
Automatic `.venv` detection
|
|
56
|
+
* β
Unpacking modern `TypedDict` type-hints (e.g., OpenAI SDK)
|
|
57
|
+
* β
Standard library filtering
|
|
58
|
+
|
|
59
|
+
**Known Limitations (v0.1.0):**
|
|
60
|
+
|
|
61
|
+
* β οΈ **Instantiated Variables:** Currently struggles to trace methods called on class instances (e.g., `app.post` in FastAPI).
|
|
62
|
+
* β οΈ **Dynamic Kwargs:** Cannot statically verify parameters passed dynamically via `**kwargs` at the call site.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
# A tiny fake codebase
|
|
4
|
+
code = """
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
client = OpenAI()
|
|
7
|
+
client.chat.completions.create(
|
|
8
|
+
model="gpt-4",
|
|
9
|
+
temperature=0.2
|
|
10
|
+
)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Parse the code into an AST
|
|
14
|
+
tree = ast.parse(code)
|
|
15
|
+
|
|
16
|
+
# Print the tree nicely formatted
|
|
17
|
+
print(ast.dump(tree, indent=2))
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#analyzer.py
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
from drift_check.models import CallSite
|
|
6
|
+
|
|
7
|
+
class CallSiteVisitor(ast.NodeVisitor):
|
|
8
|
+
def __init__(self, file_path: str):
|
|
9
|
+
self.file_path = file_path
|
|
10
|
+
self.call_sites: List[CallSite] = []
|
|
11
|
+
|
|
12
|
+
# Tracks: {"OpenAI": "openai"}
|
|
13
|
+
self.imports: Dict[str, str] = {}
|
|
14
|
+
# Tracks: {"client": "OpenAI"}
|
|
15
|
+
self.assignments: Dict[str, str] = {}
|
|
16
|
+
|
|
17
|
+
def visit_Import(self, node: ast.Import):
|
|
18
|
+
for alias in node.names:
|
|
19
|
+
# Handles 'import requests' and 'import requests as req'
|
|
20
|
+
local_name = alias.asname or alias.name
|
|
21
|
+
self.imports[local_name] = alias.name.split('.')[0]
|
|
22
|
+
self.generic_visit(node)
|
|
23
|
+
|
|
24
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
25
|
+
if node.module:
|
|
26
|
+
for alias in node.names:
|
|
27
|
+
local_name = alias.asname or alias.name
|
|
28
|
+
self.imports[local_name] = node.module.split('.')[0]
|
|
29
|
+
self.generic_visit(node)
|
|
30
|
+
|
|
31
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
32
|
+
if node.module:
|
|
33
|
+
for alias in node.names:
|
|
34
|
+
# Maps the imported class/function to its base package
|
|
35
|
+
local_name = alias.asname or alias.name
|
|
36
|
+
self.imports[local_name] = node.module.split('.')[0]
|
|
37
|
+
self.generic_visit(node)
|
|
38
|
+
|
|
39
|
+
def visit_Assign(self, node: ast.Assign):
|
|
40
|
+
# Very basic tracking for: client = OpenAI()
|
|
41
|
+
if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name):
|
|
42
|
+
class_name = node.value.func.id
|
|
43
|
+
for target in node.targets:
|
|
44
|
+
if isinstance(target, ast.Name):
|
|
45
|
+
self.assignments[target.id] = class_name
|
|
46
|
+
self.generic_visit(node)
|
|
47
|
+
|
|
48
|
+
def visit_Call(self, node: ast.Call):
|
|
49
|
+
if isinstance(node.func, ast.Attribute):
|
|
50
|
+
parts = self._resolve_attribute_chain(node.func)
|
|
51
|
+
if parts:
|
|
52
|
+
base_var = parts[0]
|
|
53
|
+
method_chain = ".".join(parts[1:])
|
|
54
|
+
|
|
55
|
+
# Resolve base_var -> Class -> Package
|
|
56
|
+
class_name = self.assignments.get(base_var, base_var)
|
|
57
|
+
package_name = self.imports.get(class_name)
|
|
58
|
+
|
|
59
|
+
if package_name:
|
|
60
|
+
kwargs = []
|
|
61
|
+
has_dynamic = False
|
|
62
|
+
for kw in node.keywords:
|
|
63
|
+
if kw.arg is None:
|
|
64
|
+
has_dynamic = True # **kwargs was used
|
|
65
|
+
else:
|
|
66
|
+
kwargs.append(kw.arg)
|
|
67
|
+
|
|
68
|
+
self.call_sites.append(CallSite(
|
|
69
|
+
file_path=self.file_path,
|
|
70
|
+
line_number=node.lineno,
|
|
71
|
+
package_name=package_name,
|
|
72
|
+
method_name=method_chain,
|
|
73
|
+
kwargs_passed=kwargs,
|
|
74
|
+
has_dynamic_kwargs=has_dynamic
|
|
75
|
+
))
|
|
76
|
+
self.generic_visit(node)
|
|
77
|
+
|
|
78
|
+
def _resolve_attribute_chain(self, node: ast.expr) -> List[str]:
|
|
79
|
+
"""Unpacks Attribute(Attribute(Name('client'), 'chat'), 'create') -> ['client', 'chat', 'create']"""
|
|
80
|
+
parts = []
|
|
81
|
+
current = node
|
|
82
|
+
while isinstance(current, ast.Attribute):
|
|
83
|
+
parts.insert(0, current.attr)
|
|
84
|
+
current = current.value
|
|
85
|
+
if isinstance(current, ast.Name):
|
|
86
|
+
parts.insert(0, current.id)
|
|
87
|
+
return parts
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
class ASTAnalyzer:
|
|
91
|
+
def __init__(self, python_files: List[Path]):
|
|
92
|
+
self.python_files = python_files
|
|
93
|
+
|
|
94
|
+
def analyze(self) -> List[CallSite]:
|
|
95
|
+
all_calls = []
|
|
96
|
+
for file_path in self.python_files:
|
|
97
|
+
try:
|
|
98
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
99
|
+
tree = ast.parse(f.read(), filename=str(file_path))
|
|
100
|
+
visitor = CallSiteVisitor(str(file_path))
|
|
101
|
+
visitor.visit(tree)
|
|
102
|
+
all_calls.extend(visitor.call_sites)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
pass # Ignore syntax errors in V1
|
|
105
|
+
return all_calls
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import sys # NEW
|
|
2
|
+
import typer
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from drift_check.scanner import ProjectScanner
|
|
6
|
+
from drift_check.dependencies import DependencyResolver
|
|
7
|
+
from drift_check.analyzer import ASTAnalyzer
|
|
8
|
+
from drift_check.signatures import SignatureResolver
|
|
9
|
+
from drift_check.rules import RuleEngine
|
|
10
|
+
from drift_check.reporter import Reporter
|
|
11
|
+
from drift_check.env import EnvironmentDetector
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
@app.callback()
|
|
17
|
+
def main():
|
|
18
|
+
"""Drift Check: Detect API signature drift."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def run(
|
|
23
|
+
target_dir: str = typer.Argument(".", help="Directory to scan"),
|
|
24
|
+
package: Optional[str] = typer.Option(None, "--package", help="Filter analysis to a specific package"),
|
|
25
|
+
to_version: Optional[str] = typer.Option(None, "--to", help="Target version to check against"),
|
|
26
|
+
include_stdlib: bool = typer.Option(False, "--include-stdlib", help="Include standard library packages in analysis") # NEW
|
|
27
|
+
):
|
|
28
|
+
console.print("[bold blue]Starting Drift Check...[/bold blue]")
|
|
29
|
+
|
|
30
|
+
if to_version and not package:
|
|
31
|
+
console.print("[red]β Error: You must specify a --package when using --to[/red]")
|
|
32
|
+
raise typer.Exit(code=1)
|
|
33
|
+
|
|
34
|
+
# 1. Environment Discovery
|
|
35
|
+
env_detector = EnvironmentDetector(target_dir)
|
|
36
|
+
project_python = env_detector.find_python_executable()
|
|
37
|
+
console.print(f"[dim]Target Environment: {project_python}[/dim]")
|
|
38
|
+
|
|
39
|
+
scanner = ProjectScanner(target_dir)
|
|
40
|
+
py_files, req_files = scanner.scan()
|
|
41
|
+
|
|
42
|
+
analyzer = ASTAnalyzer(py_files)
|
|
43
|
+
call_sites = analyzer.analyze()
|
|
44
|
+
|
|
45
|
+
console.print("\n[bold]Resolving Signatures...[/bold]")
|
|
46
|
+
if to_version:
|
|
47
|
+
console.print(f"[dim]Building isolated sandbox for {package}=={to_version}...[/dim]")
|
|
48
|
+
|
|
49
|
+
# Inject the discovered project interpreter into the SignatureResolver
|
|
50
|
+
sig_resolver = SignatureResolver(
|
|
51
|
+
project_python_exec=project_python,
|
|
52
|
+
target_version=to_version,
|
|
53
|
+
package_to_sandbox=package
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
rule_engine = RuleEngine()
|
|
57
|
+
all_findings = []
|
|
58
|
+
|
|
59
|
+
# Determine the stdlib names (fallback for older Python versions just in case)
|
|
60
|
+
stdlib_names = sys.stdlib_module_names if hasattr(sys, 'stdlib_module_names') else set()
|
|
61
|
+
|
|
62
|
+
for call in call_sites:
|
|
63
|
+
# Skip self-references
|
|
64
|
+
if call.package_name == "drift_check":
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Filter by package if the user requested it
|
|
68
|
+
if package and call.package_name != package:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# NEW: Stdlib filtering
|
|
72
|
+
if not include_stdlib and call.package_name in stdlib_names:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
sig = sig_resolver.resolve(call.package_name, call.method_name)
|
|
76
|
+
findings = rule_engine.evaluate(call, sig)
|
|
77
|
+
all_findings.extend(findings)
|
|
78
|
+
|
|
79
|
+
reporter = Reporter()
|
|
80
|
+
reporter.generate_console_report(all_findings)
|
|
81
|
+
reporter.generate_json_report(all_findings)
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
app()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
from drift_check.models import Dependency
|
|
5
|
+
|
|
6
|
+
class DependencyResolver:
|
|
7
|
+
def __init__(self, dependency_files: List[Path]):
|
|
8
|
+
self.dependency_files = dependency_files
|
|
9
|
+
|
|
10
|
+
def resolve(self) -> List[Dependency]:
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
for file_path in self.dependency_files:
|
|
14
|
+
if file_path.name == "requirements.txt":
|
|
15
|
+
dependencies.extend(self._parse_requirements_txt(file_path))
|
|
16
|
+
|
|
17
|
+
return dependencies
|
|
18
|
+
|
|
19
|
+
def _parse_requirements_txt(self, file_path: Path) -> List[Dependency]:
|
|
20
|
+
deps = []
|
|
21
|
+
pattern = re.compile(r"^([a-zA-Z0-9_\-]+)(.*)$")
|
|
22
|
+
|
|
23
|
+
with open(file_path, "r", encoding="utf-8-sig") as f:
|
|
24
|
+
for line in f:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line or line.startswith("#"):
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
match = pattern.match(line)
|
|
30
|
+
if match:
|
|
31
|
+
name = match.group(1).strip()
|
|
32
|
+
specifier = match.group(2).strip()
|
|
33
|
+
is_pinned = "==" in specifier
|
|
34
|
+
|
|
35
|
+
deps.append(Dependency(
|
|
36
|
+
name=name,
|
|
37
|
+
installed_version=None,
|
|
38
|
+
is_pinned=is_pinned,
|
|
39
|
+
specifier=specifier
|
|
40
|
+
))
|
|
41
|
+
return deps
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
class EnvironmentDetector:
|
|
6
|
+
def __init__(self, target_dir: str):
|
|
7
|
+
self.target_dir = Path(target_dir).resolve()
|
|
8
|
+
|
|
9
|
+
def find_python_executable(self) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Finds the most appropriate Python interpreter for the target project.
|
|
12
|
+
Search order: VIRTUAL_ENV variable -> .venv/ -> venv/ -> env/ -> sys.executable
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# 1. Check for an actively sourced virtual environment
|
|
16
|
+
active_venv = os.environ.get("VIRTUAL_ENV")
|
|
17
|
+
if active_venv:
|
|
18
|
+
if os.name == 'nt':
|
|
19
|
+
exec_path = Path(active_venv) / "Scripts" / "python.exe"
|
|
20
|
+
else:
|
|
21
|
+
exec_path = Path(active_venv) / "bin" / "python"
|
|
22
|
+
|
|
23
|
+
if exec_path.exists():
|
|
24
|
+
return str(exec_path)
|
|
25
|
+
|
|
26
|
+
# 2. Check for standard local environment folders
|
|
27
|
+
venv_names = [".venv", "venv", "env"]
|
|
28
|
+
for venv_name in venv_names:
|
|
29
|
+
venv_path = self.target_dir / venv_name
|
|
30
|
+
if venv_path.is_dir():
|
|
31
|
+
if os.name == 'nt':
|
|
32
|
+
exec_path = venv_path / "Scripts" / "python.exe"
|
|
33
|
+
else:
|
|
34
|
+
exec_path = venv_path / "bin" / "python"
|
|
35
|
+
|
|
36
|
+
if exec_path.exists():
|
|
37
|
+
return str(exec_path)
|
|
38
|
+
|
|
39
|
+
# 3. Fallback to the active interpreter running the CLI
|
|
40
|
+
return sys.executable
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Dependency:
|
|
6
|
+
name: str
|
|
7
|
+
installed_version: Optional[str]
|
|
8
|
+
is_pinned: bool
|
|
9
|
+
specifier: str
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CallSite:
|
|
13
|
+
file_path: str
|
|
14
|
+
line_number: int
|
|
15
|
+
package_name: str
|
|
16
|
+
method_name: str
|
|
17
|
+
kwargs_passed: List[str]
|
|
18
|
+
has_dynamic_kwargs: bool
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SignatureDefinition:
|
|
22
|
+
package_name: str
|
|
23
|
+
method_name: str
|
|
24
|
+
valid_parameters: List[str]
|
|
25
|
+
is_unknown: bool = False
|
|
26
|
+
accepts_kwargs: bool = False
|
|
27
|
+
error_code: str = "SUCCESS"
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Finding:
|
|
31
|
+
severity: str
|
|
32
|
+
file_path: str
|
|
33
|
+
line_number: int
|
|
34
|
+
package_name: str
|
|
35
|
+
method_name: str
|
|
36
|
+
message: str
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import List
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from drift_check.models import Finding
|
|
6
|
+
|
|
7
|
+
class Reporter:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.console = Console()
|
|
10
|
+
|
|
11
|
+
def generate_console_report(self, findings: List[Finding]):
|
|
12
|
+
if not findings:
|
|
13
|
+
self.console.print("[green]No findings to report![/green]")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
self.console.print("\n[bold]Drift Check Results:[/bold]")
|
|
17
|
+
|
|
18
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
19
|
+
table.add_column("Status", width=10)
|
|
20
|
+
table.add_column("Location")
|
|
21
|
+
table.add_column("Package")
|
|
22
|
+
table.add_column("Message")
|
|
23
|
+
|
|
24
|
+
for finding in findings:
|
|
25
|
+
if finding.severity == "ERROR":
|
|
26
|
+
status = "[bold red]β ERROR[/bold red]"
|
|
27
|
+
elif finding.severity == "WARNING":
|
|
28
|
+
status = "[bold yellow]β WARN[/bold yellow]"
|
|
29
|
+
else:
|
|
30
|
+
status = "[bold green]β OK[/bold green]"
|
|
31
|
+
|
|
32
|
+
# We only want to show the file name, not the massive absolute path
|
|
33
|
+
short_path = finding.file_path.split("\\")[-1].split("/")[-1]
|
|
34
|
+
|
|
35
|
+
table.add_row(
|
|
36
|
+
status,
|
|
37
|
+
f"{short_path}:{finding.line_number}",
|
|
38
|
+
finding.package_name,
|
|
39
|
+
finding.message
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.console.print(table)
|
|
43
|
+
|
|
44
|
+
def generate_json_report(self, findings: List[Finding], output_path: str = "report.json"):
|
|
45
|
+
data = [
|
|
46
|
+
{
|
|
47
|
+
"severity": f.severity,
|
|
48
|
+
"file_path": f.file_path,
|
|
49
|
+
"line_number": f.line_number,
|
|
50
|
+
"package_name": f.package_name,
|
|
51
|
+
"method_name": f.method_name,
|
|
52
|
+
"message": f.message
|
|
53
|
+
}
|
|
54
|
+
for f in findings
|
|
55
|
+
]
|
|
56
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(data, f, indent=2)
|
|
58
|
+
self.console.print(f"\n[dim]Report successfully saved to {output_path}[/dim]")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from drift_check.models import CallSite, SignatureDefinition, Finding
|
|
2
|
+
|
|
3
|
+
class RuleEngine:
|
|
4
|
+
def evaluate(self, call: CallSite, sig: SignatureDefinition) -> list[Finding]:
|
|
5
|
+
findings = []
|
|
6
|
+
|
|
7
|
+
# Rule 1: Unknown Signatures (Diagnostic Messages)
|
|
8
|
+
if sig.is_unknown:
|
|
9
|
+
if sig.error_code == "PACKAGE_NOT_INSTALLED":
|
|
10
|
+
msg = f"Package '{sig.package_name}' not installed in target environment."
|
|
11
|
+
elif sig.error_code == "METHOD_NOT_FOUND":
|
|
12
|
+
msg = f"Method '{sig.method_name}' not found on '{sig.package_name}'."
|
|
13
|
+
elif sig.error_code == "SIGNATURE_UNAVAILABLE":
|
|
14
|
+
msg = f"Signature unavailable (likely C-extension). Cannot verify."
|
|
15
|
+
else:
|
|
16
|
+
msg = "Unsupported callable or unknown resolution error."
|
|
17
|
+
|
|
18
|
+
findings.append(Finding(
|
|
19
|
+
severity="WARNING",
|
|
20
|
+
file_path=call.file_path,
|
|
21
|
+
line_number=call.line_number,
|
|
22
|
+
package_name=call.package_name,
|
|
23
|
+
method_name=call.method_name,
|
|
24
|
+
message=msg
|
|
25
|
+
))
|
|
26
|
+
return findings
|
|
27
|
+
|
|
28
|
+
# Rule 2: Dynamic kwargs at the call site (e.g., func(**my_dict))
|
|
29
|
+
if call.has_dynamic_kwargs:
|
|
30
|
+
findings.append(Finding(
|
|
31
|
+
severity="WARNING",
|
|
32
|
+
file_path=call.file_path,
|
|
33
|
+
line_number=call.line_number,
|
|
34
|
+
package_name=call.package_name,
|
|
35
|
+
method_name=call.method_name,
|
|
36
|
+
message="Dynamic **kwargs detected. Cannot statically verify all parameters."
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
# Rule 3: Parameter Matching & Forwarding
|
|
40
|
+
passed_ok = True
|
|
41
|
+
for kwarg in call.kwargs_passed:
|
|
42
|
+
if kwarg not in sig.valid_parameters:
|
|
43
|
+
passed_ok = False
|
|
44
|
+
|
|
45
|
+
# Apply strict forwarding constraint
|
|
46
|
+
if sig.accepts_kwargs:
|
|
47
|
+
findings.append(Finding(
|
|
48
|
+
severity="WARNING",
|
|
49
|
+
file_path=call.file_path,
|
|
50
|
+
line_number=call.line_number,
|
|
51
|
+
package_name=call.package_name,
|
|
52
|
+
method_name=call.method_name,
|
|
53
|
+
message=f"Unable to verify forwarded keyword argument: '{kwarg}'"
|
|
54
|
+
))
|
|
55
|
+
else:
|
|
56
|
+
findings.append(Finding(
|
|
57
|
+
severity="ERROR",
|
|
58
|
+
file_path=call.file_path,
|
|
59
|
+
line_number=call.line_number,
|
|
60
|
+
package_name=call.package_name,
|
|
61
|
+
method_name=call.method_name,
|
|
62
|
+
message=f"Unknown parameter passed: '{kwarg}'"
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
if passed_ok and not call.has_dynamic_kwargs:
|
|
66
|
+
findings.append(Finding(
|
|
67
|
+
severity="OK",
|
|
68
|
+
file_path=call.file_path,
|
|
69
|
+
line_number=call.line_number,
|
|
70
|
+
package_name=call.package_name,
|
|
71
|
+
method_name=call.method_name,
|
|
72
|
+
message="All parameters valid."
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
return findings
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Tuple, List
|
|
4
|
+
|
|
5
|
+
class ProjectScanner:
|
|
6
|
+
def __init__(self, target_dir: str):
|
|
7
|
+
self.target_dir = Path(target_dir).resolve()
|
|
8
|
+
self.ignore_dirs = {
|
|
9
|
+
".git", ".venv", "venv", "__pycache__",
|
|
10
|
+
"build", "dist", "node_modules"
|
|
11
|
+
}
|
|
12
|
+
self.dependency_filenames = {"requirements.txt", "pyproject.toml", "uv.lock"}
|
|
13
|
+
|
|
14
|
+
def scan(self) -> Tuple[List[Path], List[Path]]:
|
|
15
|
+
"""
|
|
16
|
+
Walks the directory and returns a tuple of (python_files, dependency_files).
|
|
17
|
+
"""
|
|
18
|
+
python_files = []
|
|
19
|
+
dependency_files = []
|
|
20
|
+
|
|
21
|
+
if not self.target_dir.exists() or not self.target_dir.is_dir():
|
|
22
|
+
return python_files, dependency_files
|
|
23
|
+
|
|
24
|
+
for root, dirs, files in os.walk(self.target_dir):
|
|
25
|
+
# TODO: Parse .gitignore here to dynamically add to ignore_dirs
|
|
26
|
+
# Mutate the dirs list in-place to skip ignored directories
|
|
27
|
+
dirs[:] = [d for d in dirs if d not in self.ignore_dirs]
|
|
28
|
+
|
|
29
|
+
root_path = Path(root)
|
|
30
|
+
|
|
31
|
+
for file in files:
|
|
32
|
+
if file.endswith(".py"):
|
|
33
|
+
python_files.append(root_path / file)
|
|
34
|
+
elif file in self.dependency_filenames:
|
|
35
|
+
dependency_files.append(root_path / file)
|
|
36
|
+
|
|
37
|
+
return python_files, dependency_files
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import inspect
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import ast
|
|
9
|
+
import typing
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
from drift_check.models import SignatureDefinition
|
|
12
|
+
|
|
13
|
+
# The inline worker now handles both standard signatures and TypedDicts securely in the target environment
|
|
14
|
+
INLINE_WORKER = """
|
|
15
|
+
import sys, json, importlib, inspect, typing
|
|
16
|
+
sys.path.insert(0, os.getcwd())
|
|
17
|
+
|
|
18
|
+
SDK_REGISTRY = {
|
|
19
|
+
"openai.chat.completions.create": ("openai.resources.chat.completions", "Completions.create")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
package_name, method_name = sys.argv[1], sys.argv[2]
|
|
24
|
+
full_call = f"{package_name}.{method_name}"
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# 1. Resolve physical path via Registry or standard traversal
|
|
28
|
+
if full_call in SDK_REGISTRY:
|
|
29
|
+
mod_path, class_path = SDK_REGISTRY[full_call]
|
|
30
|
+
current_obj = importlib.import_module(mod_path)
|
|
31
|
+
for part in class_path.split('.'):
|
|
32
|
+
current_obj = getattr(current_obj, part)
|
|
33
|
+
else:
|
|
34
|
+
current_obj = importlib.import_module(package_name)
|
|
35
|
+
for part in method_name.split('.'):
|
|
36
|
+
current_obj = getattr(current_obj, part)
|
|
37
|
+
|
|
38
|
+
# 2. Standard Runtime Inspection
|
|
39
|
+
sig = inspect.signature(current_obj)
|
|
40
|
+
params = list(sig.parameters.keys())
|
|
41
|
+
has_varkw = False
|
|
42
|
+
|
|
43
|
+
# 3. Targeted TypedDict Extraction
|
|
44
|
+
for param in sig.parameters.values():
|
|
45
|
+
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
46
|
+
has_varkw = True
|
|
47
|
+
|
|
48
|
+
# Check if kwargs is annotated with Unpack[TypedDict]
|
|
49
|
+
ann = param.annotation
|
|
50
|
+
if ann != inspect.Parameter.empty:
|
|
51
|
+
type_args = getattr(ann, '__args__', None)
|
|
52
|
+
target_type = type_args[0] if type_args else ann
|
|
53
|
+
|
|
54
|
+
if hasattr(target_type, '__annotations__'):
|
|
55
|
+
# Found the TypedDict! Append its keys to our valid parameters
|
|
56
|
+
params.extend(target_type.__annotations__.keys())
|
|
57
|
+
# Since we successfully unpacked it, it is no longer an unknown wildcard
|
|
58
|
+
has_varkw = False
|
|
59
|
+
|
|
60
|
+
print(json.dumps({
|
|
61
|
+
"status": "SUCCESS",
|
|
62
|
+
"valid_parameters": list(set(params)),
|
|
63
|
+
"accepts_kwargs": has_varkw
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
except ModuleNotFoundError:
|
|
67
|
+
print(json.dumps({"status": "PACKAGE_NOT_INSTALLED"}))
|
|
68
|
+
except AttributeError:
|
|
69
|
+
print(json.dumps({"status": "METHOD_NOT_FOUND"}))
|
|
70
|
+
except ValueError:
|
|
71
|
+
print(json.dumps({"status": "SIGNATURE_UNAVAILABLE"}))
|
|
72
|
+
except Exception:
|
|
73
|
+
print(json.dumps({"status": "UNSUPPORTED_CALLABLE"}))
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__':
|
|
76
|
+
main()
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
class RuntimeProvider:
|
|
80
|
+
"""Executes signature inspection securely within the target environment."""
|
|
81
|
+
def resolve(self, python_exec: str, package_name: str, method_name: str) -> dict:
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
[python_exec, "-c", INLINE_WORKER, package_name, method_name],
|
|
84
|
+
capture_output=True, text=True
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(result.stdout.strip())
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return {"status": "UNSUPPORTED_CALLABLE"}
|
|
90
|
+
|
|
91
|
+
class StubProvider:
|
|
92
|
+
"""V2: Parses .pyi files (or typed .py files) using AST to extract signatures."""
|
|
93
|
+
def resolve(self, package_name: str, method_name: str) -> Optional[List[str]]:
|
|
94
|
+
try:
|
|
95
|
+
spec = importlib.util.find_spec(package_name)
|
|
96
|
+
if not spec or not spec.origin:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
base_path = spec.origin
|
|
100
|
+
|
|
101
|
+
if base_path.endswith('.py'):
|
|
102
|
+
pyi_path = base_path + 'i'
|
|
103
|
+
target_path = pyi_path if os.path.exists(pyi_path) else base_path
|
|
104
|
+
else:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if not os.path.exists(target_path):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
with open(target_path, 'r', encoding='utf-8') as f:
|
|
111
|
+
tree = ast.parse(f.read())
|
|
112
|
+
|
|
113
|
+
target_func = method_name.split('.')[-1]
|
|
114
|
+
|
|
115
|
+
for node in ast.walk(tree):
|
|
116
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
117
|
+
if node.name == target_func:
|
|
118
|
+
args = [arg.arg for arg in node.args.args if arg.arg != 'self']
|
|
119
|
+
args.extend([arg.arg for arg in node.args.kwonlyargs])
|
|
120
|
+
return args
|
|
121
|
+
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
class SignatureResolver:
|
|
127
|
+
def __init__(self, project_python_exec: str, target_version: Optional[str] = None, package_to_sandbox: Optional[str] = None):
|
|
128
|
+
self.project_python_exec = project_python_exec
|
|
129
|
+
self.target_version = target_version
|
|
130
|
+
self.package_to_sandbox = package_to_sandbox
|
|
131
|
+
self.sandbox_dir = None
|
|
132
|
+
self.sandbox_python_exec = None
|
|
133
|
+
|
|
134
|
+
self.runtime_provider = RuntimeProvider()
|
|
135
|
+
self.stub_provider = StubProvider()
|
|
136
|
+
|
|
137
|
+
if self.target_version and self.package_to_sandbox:
|
|
138
|
+
self._setup_sandbox()
|
|
139
|
+
|
|
140
|
+
def _setup_sandbox(self):
|
|
141
|
+
self.sandbox_dir = tempfile.TemporaryDirectory()
|
|
142
|
+
path = self.sandbox_dir.name
|
|
143
|
+
|
|
144
|
+
# 1. Create the virtual environment
|
|
145
|
+
subprocess.run(["uv", "venv"], cwd=path, check=True, capture_output=True)
|
|
146
|
+
|
|
147
|
+
self.sandbox_python_exec = os.path.join(path, ".venv", "Scripts", "python.exe") if os.name == 'nt' else os.path.join(path, ".venv", "bin", "python")
|
|
148
|
+
|
|
149
|
+
# 2. Install the package explicitly using the sandbox's python executable
|
|
150
|
+
# This strictly binds the installation to the sandbox, ignoring host environments
|
|
151
|
+
subprocess.run(
|
|
152
|
+
["uv", "pip", "install", "--python", self.sandbox_python_exec, f"{self.package_to_sandbox}=={self.target_version}"],
|
|
153
|
+
cwd=path, check=True, capture_output=True
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def resolve(self, package_name: str, method_name: str) -> SignatureDefinition:
|
|
157
|
+
target_exec = self.sandbox_python_exec if (self.sandbox_dir and package_name == self.package_to_sandbox) else self.project_python_exec
|
|
158
|
+
|
|
159
|
+
# 1. Run dynamic worker in the target environment
|
|
160
|
+
data = self.runtime_provider.resolve(target_exec, package_name, method_name)
|
|
161
|
+
status = data.get("status", "UNSUPPORTED_CALLABLE")
|
|
162
|
+
|
|
163
|
+
if status == "SUCCESS":
|
|
164
|
+
return SignatureDefinition(
|
|
165
|
+
package_name=package_name,
|
|
166
|
+
method_name=method_name,
|
|
167
|
+
valid_parameters=data.get("valid_parameters", []),
|
|
168
|
+
is_unknown=False,
|
|
169
|
+
accepts_kwargs=data.get("accepts_kwargs", False),
|
|
170
|
+
error_code="SUCCESS"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# 2. Fallback to static .pyi stubs
|
|
174
|
+
params = self.stub_provider.resolve(package_name, method_name)
|
|
175
|
+
if params is not None:
|
|
176
|
+
return SignatureDefinition(package_name, method_name, params, is_unknown=False, error_code="SUCCESS")
|
|
177
|
+
|
|
178
|
+
# 3. Unresolvable error
|
|
179
|
+
return SignatureDefinition(package_name, method_name, [], is_unknown=True, error_code=status)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-drift-check"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Detect API drift between codebase and installed dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.12.0",
|
|
9
|
+
"rich>=13.7.0"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
drift-check = "drift_check.cli:app"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["drift_check"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from openai import OpenAI
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
# V1 failed this because it's a C-extension. V2 will parse the .pyi stub!
|
|
6
|
+
now = datetime.datetime.now(tz=None)
|
|
7
|
+
|
|
8
|
+
requests.post(url="https://api.example.com", json={"key": "value"})
|
|
9
|
+
# The runtime provider handles this flawlessly
|
|
10
|
+
requests.post(
|
|
11
|
+
url="https://api.example.com/data",
|
|
12
|
+
json={"key": "value"},
|
|
13
|
+
timeout=10,
|
|
14
|
+
fake_parameter=True # This should trigger an error!
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# The runtime provider safely defers this to V2
|
|
18
|
+
client = OpenAI()
|
|
19
|
+
client.chat.completions.create(model="gpt-4")
|