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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ report.json
5
+ dist/
6
+ build/
7
+ *.egg-info/
@@ -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,3 @@
1
+ ο»Ώopenai==1.50.0
2
+ requests>=2.31
3
+ stripe
@@ -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")