pycodegate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycodegate/profile.py ADDED
@@ -0,0 +1,107 @@
1
+ """Project profile detection and rule/scoring adjustments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from pycodegate._compat import tomllib
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Profile:
13
+ name: str # "cli", "web", "library", "script"
14
+ suppressed_rules: frozenset[str] = field(default_factory=frozenset)
15
+ max_deduction_overrides: dict[str, int] = field(default_factory=dict)
16
+
17
+
18
+ # Profile definitions
19
+ PROFILES = {
20
+ "web": Profile(name="web"),
21
+ "cli": Profile(
22
+ name="cli",
23
+ suppressed_rules=frozenset({"no-subprocess", "no-shell-exec"}),
24
+ max_deduction_overrides={"Security": 15},
25
+ ),
26
+ "library": Profile(name="library"),
27
+ "script": Profile(
28
+ name="script",
29
+ suppressed_rules=frozenset({"structure/no-tests", "structure/no-license"}),
30
+ max_deduction_overrides={"Structure": 5},
31
+ ),
32
+ }
33
+
34
+
35
+ def detect_profile(project_path: str) -> Profile:
36
+ """Auto-detect project profile from project structure and dependencies."""
37
+ path = Path(project_path)
38
+ pyproject = path / "pyproject.toml"
39
+
40
+ deps: set[str] = set()
41
+ has_build_system = False
42
+ has_scripts = False
43
+
44
+ if pyproject.exists():
45
+ try:
46
+ with open(pyproject, "rb") as f:
47
+ data = tomllib.load(f)
48
+ # Collect dependency names
49
+ for dep in data.get("project", {}).get("dependencies", []):
50
+ deps.add(
51
+ dep.split("[")[0]
52
+ .split(">")[0]
53
+ .split("<")[0]
54
+ .split("=")[0]
55
+ .split("!")[0]
56
+ .strip()
57
+ .lower()
58
+ )
59
+ has_build_system = "build-system" in data
60
+ has_scripts = bool(data.get("project", {}).get("scripts"))
61
+ except Exception:
62
+ pass
63
+
64
+ # Also check requirements.txt
65
+ for req_file in ["requirements.txt", "requirements-dev.txt"]:
66
+ req_path = path / req_file
67
+ if req_path.exists():
68
+ try:
69
+ for line in req_path.read_text().splitlines():
70
+ line = line.strip()
71
+ if line and not line.startswith("#") and not line.startswith("-"):
72
+ deps.add(
73
+ line.split("[")[0]
74
+ .split(">")[0]
75
+ .split("<")[0]
76
+ .split("=")[0]
77
+ .split("!")[0]
78
+ .strip()
79
+ .lower()
80
+ )
81
+ except Exception:
82
+ pass
83
+
84
+ # Web frameworks
85
+ web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
86
+ if deps & web_deps:
87
+ return PROFILES["web"]
88
+
89
+ # CLI tools
90
+ cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
91
+ if (deps & cli_deps) or has_scripts:
92
+ return PROFILES["cli"]
93
+
94
+ # Library
95
+ if has_build_system and not has_scripts:
96
+ return PROFILES["library"]
97
+
98
+ # Script: no __init__.py and few .py files
99
+ py_files = list(path.glob("*.py"))
100
+ has_init = (path / "__init__.py").exists() or any(
101
+ (path / d / "__init__.py").exists() for d in ["src", "lib"]
102
+ )
103
+ if not has_init and len(py_files) <= 5:
104
+ return PROFILES["script"]
105
+
106
+ # Default to library
107
+ return PROFILES["library"]
@@ -0,0 +1,38 @@
1
+ """Rule registry for python-doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pycodegate.rules.architecture import ArchitectureRules
6
+ from pycodegate.rules.base import BaseRules
7
+ from pycodegate.rules.complexity import ComplexityRules
8
+ from pycodegate.rules.correctness import CorrectnessRules
9
+ from pycodegate.rules.dependencies import DependencyRules as DependencyRules
10
+ from pycodegate.rules.django import DjangoRules
11
+ from pycodegate.rules.fastapi import FastAPIRules
12
+ from pycodegate.rules.flask import FlaskRules
13
+ from pycodegate.rules.imports import ImportsRules as ImportsRules
14
+ from pycodegate.rules.performance import PerformanceRules
15
+ from pycodegate.rules.security import SecurityRules
16
+ from pycodegate.rules.structure import StructureRules as StructureRules
17
+
18
+
19
+ def get_all_rule_sets() -> list[BaseRules]:
20
+ """Return all available rule sets."""
21
+ return [
22
+ SecurityRules(),
23
+ PerformanceRules(),
24
+ ArchitectureRules(),
25
+ CorrectnessRules(),
26
+ ComplexityRules(),
27
+ ]
28
+
29
+
30
+ def get_framework_rules(framework: str | None) -> list[BaseRules]:
31
+ """Return framework-specific rule sets."""
32
+ _framework_map: dict[str, type[BaseRules]] = {
33
+ "django": DjangoRules,
34
+ "fastapi": FastAPIRules,
35
+ "flask": FlaskRules,
36
+ }
37
+ cls = _framework_map.get(framework or "")
38
+ return [cls()] if cls else []
@@ -0,0 +1,121 @@
1
+ """Architecture rules: giant modules, deep nesting, god functions, too many args."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from pycodegate.rules.base import BaseRules
8
+ from pycodegate.types import Category, Diagnostic, Severity
9
+
10
+ MAX_MODULE_LINES = 500
11
+ MAX_FUNCTION_LINES = 50
12
+ MAX_NESTING_DEPTH = 5
13
+ MAX_ARGUMENTS = 7
14
+
15
+
16
+ class ArchitectureRules(BaseRules):
17
+ """Architecture-level checks."""
18
+
19
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
20
+ tree = self._parse(source)
21
+ if tree is None:
22
+ return []
23
+
24
+ diags: list[Diagnostic] = []
25
+ diags.extend(self._check_giant_module(source, filename))
26
+ diags.extend(self._check_deep_nesting(tree, filename))
27
+ diags.extend(self._check_god_functions(tree, filename))
28
+ diags.extend(self._check_too_many_args(tree, filename))
29
+ return diags
30
+
31
+ def _check_giant_module(self, source: str, filename: str) -> list[Diagnostic]:
32
+ lines = source.count("\n") + 1
33
+ if lines > MAX_MODULE_LINES:
34
+ return [
35
+ Diagnostic(
36
+ file_path=filename,
37
+ rule="no-giant-module",
38
+ severity=Severity.WARNING,
39
+ category=Category.ARCHITECTURE,
40
+ message=f"Module has {lines} lines (max {MAX_MODULE_LINES}) — consider splitting",
41
+ help="Extract related functions into separate modules",
42
+ line=1,
43
+ cost=1.0,
44
+ )
45
+ ]
46
+ return []
47
+
48
+ def _check_deep_nesting(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
49
+ diags: list[Diagnostic] = []
50
+ self._walk_nesting(tree, 0, filename, diags)
51
+ return diags
52
+
53
+ def _walk_nesting(
54
+ self, node: ast.AST, depth: int, filename: str, diags: list[Diagnostic]
55
+ ) -> None:
56
+ nesting_nodes = (ast.If, ast.For, ast.While, ast.With, ast.Try)
57
+ if isinstance(node, nesting_nodes):
58
+ depth += 1
59
+ if depth >= MAX_NESTING_DEPTH:
60
+ diags.append(
61
+ Diagnostic(
62
+ file_path=filename,
63
+ rule="no-deep-nesting",
64
+ severity=Severity.WARNING,
65
+ category=Category.ARCHITECTURE,
66
+ message=f"Nesting depth {depth} exceeds max {MAX_NESTING_DEPTH}",
67
+ help="Extract nested logic into helper functions or use early returns",
68
+ line=node.lineno,
69
+ column=node.col_offset,
70
+ cost=1.0,
71
+ )
72
+ )
73
+ for child in ast.iter_child_nodes(node):
74
+ self._walk_nesting(child, depth, filename, diags)
75
+
76
+ def _check_god_functions(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
77
+ diags: list[Diagnostic] = []
78
+ for node in ast.walk(tree):
79
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
80
+ end = getattr(node, "end_lineno", None)
81
+ if end is not None:
82
+ length = end - node.lineno + 1
83
+ if length > MAX_FUNCTION_LINES:
84
+ diags.append(
85
+ Diagnostic(
86
+ file_path=filename,
87
+ rule="no-god-function",
88
+ severity=Severity.WARNING,
89
+ category=Category.ARCHITECTURE,
90
+ message=f"Function '{node.name}' is {length} lines (max {MAX_FUNCTION_LINES})",
91
+ help="Break into smaller functions with single responsibilities",
92
+ line=node.lineno,
93
+ column=node.col_offset,
94
+ cost=1.0,
95
+ )
96
+ )
97
+ return diags
98
+
99
+ def _check_too_many_args(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
100
+ diags: list[Diagnostic] = []
101
+ for node in ast.walk(tree):
102
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
103
+ args = node.args
104
+ total = len(args.posonlyargs) + len(args.args) + len(args.kwonlyargs)
105
+ if total > 0 and args.args and args.args[0].arg in ("self", "cls"):
106
+ total -= 1
107
+ if total > MAX_ARGUMENTS:
108
+ diags.append(
109
+ Diagnostic(
110
+ file_path=filename,
111
+ rule="too-many-arguments",
112
+ severity=Severity.WARNING,
113
+ category=Category.ARCHITECTURE,
114
+ message=f"Function '{node.name}' has {total} arguments (max {MAX_ARGUMENTS})",
115
+ help="Group related arguments into a dataclass or TypedDict",
116
+ line=node.lineno,
117
+ column=node.col_offset,
118
+ cost=1.0,
119
+ )
120
+ )
121
+ return diags
@@ -0,0 +1,23 @@
1
+ """Base class for rule sets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from abc import ABC, abstractmethod
7
+
8
+ from pycodegate.types import Diagnostic
9
+
10
+
11
+ class BaseRules(ABC):
12
+ """Base class that all rule sets inherit from."""
13
+
14
+ @abstractmethod
15
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
16
+ """Analyze source code and return diagnostics."""
17
+
18
+ def _parse(self, source: str) -> ast.Module | None:
19
+ """Safely parse Python source into AST."""
20
+ try:
21
+ return ast.parse(source)
22
+ except SyntaxError:
23
+ return None
@@ -0,0 +1,87 @@
1
+ """Complexity rules: cyclomatic complexity analysis using pure AST."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from pycodegate.rules.base import BaseRules
8
+ from pycodegate.types import Category, Diagnostic, Severity
9
+
10
+ MAX_COMPLEXITY = 15
11
+ CRITICAL_COMPLEXITY = 25
12
+
13
+
14
+ def _cyclomatic_complexity(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
15
+ """Calculate cyclomatic complexity for a function node.
16
+
17
+ Starts at 1 and adds 1 for each decision point:
18
+ - if / elif statements
19
+ - for / while loops
20
+ - except handlers
21
+ - and / or boolean operators
22
+ - assert statements
23
+ - ternary expressions (IfExp)
24
+ """
25
+ complexity = 1
26
+ for node in ast.walk(func_node):
27
+ # Skip nested function/class definitions to avoid double-counting
28
+ if node is not func_node and isinstance(
29
+ node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
30
+ ):
31
+ continue
32
+ if isinstance(node, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.ExceptHandler)):
33
+ complexity += 1
34
+ elif isinstance(node, ast.BoolOp):
35
+ complexity += len(node.values) - 1
36
+ elif isinstance(node, (ast.Assert, ast.IfExp)):
37
+ complexity += 1
38
+ return complexity
39
+
40
+
41
+ class ComplexityRules(BaseRules):
42
+ """Cyclomatic complexity checks."""
43
+
44
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
45
+ tree = self._parse(source)
46
+ if tree is None:
47
+ return []
48
+
49
+ diags: list[Diagnostic] = []
50
+ for node in ast.walk(tree):
51
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
52
+ complexity = _cyclomatic_complexity(node)
53
+ if complexity > CRITICAL_COMPLEXITY:
54
+ diags.append(
55
+ Diagnostic(
56
+ file_path=filename,
57
+ rule="critical-complexity",
58
+ severity=Severity.ERROR,
59
+ category=Category.COMPLEXITY,
60
+ message=(
61
+ f"Function '{node.name}' has cyclomatic complexity"
62
+ f" {complexity} (max {MAX_COMPLEXITY})"
63
+ ),
64
+ help="Break into smaller functions or simplify conditional logic",
65
+ line=node.lineno,
66
+ column=node.col_offset,
67
+ cost=3.0,
68
+ )
69
+ )
70
+ elif complexity > MAX_COMPLEXITY:
71
+ diags.append(
72
+ Diagnostic(
73
+ file_path=filename,
74
+ rule="high-complexity",
75
+ severity=Severity.WARNING,
76
+ category=Category.COMPLEXITY,
77
+ message=(
78
+ f"Function '{node.name}' has cyclomatic complexity"
79
+ f" {complexity} (max {MAX_COMPLEXITY})"
80
+ ),
81
+ help="Break into smaller functions or simplify conditional logic",
82
+ line=node.lineno,
83
+ column=node.col_offset,
84
+ cost=1.5,
85
+ )
86
+ )
87
+ return diags
@@ -0,0 +1,131 @@
1
+ """Correctness rules: mutable defaults, bare except, broad exception, assert in prod, return in __init__."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from pycodegate.rules.base import BaseRules
8
+ from pycodegate.types import Category, Diagnostic, Severity
9
+
10
+
11
+ class CorrectnessRules(BaseRules):
12
+ """Correctness-related checks."""
13
+
14
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
15
+ tree = self._parse(source)
16
+ if tree is None:
17
+ return []
18
+
19
+ diags: list[Diagnostic] = []
20
+ diags.extend(self._check_mutable_defaults(tree, filename))
21
+ diags.extend(self._check_bare_except(tree, filename))
22
+ diags.extend(self._check_broad_exception(tree, filename))
23
+ diags.extend(self._check_assert_in_production(tree, filename))
24
+ diags.extend(self._check_return_in_init(tree, filename))
25
+ return diags
26
+
27
+ def _check_mutable_defaults(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
28
+ diags: list[Diagnostic] = []
29
+ for node in ast.walk(tree):
30
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
31
+ for default in node.args.defaults + node.args.kw_defaults:
32
+ if default is not None and isinstance(default, (ast.List, ast.Dict, ast.Set)):
33
+ diags.append(
34
+ Diagnostic(
35
+ file_path=filename,
36
+ rule="no-mutable-default",
37
+ severity=Severity.ERROR,
38
+ category=Category.CORRECTNESS,
39
+ message="Mutable default argument — shared across all calls",
40
+ help="Use None as default and create the mutable inside the function body",
41
+ line=node.lineno,
42
+ column=node.col_offset,
43
+ cost=2.0,
44
+ )
45
+ )
46
+ return diags
47
+
48
+ def _check_bare_except(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
49
+ diags: list[Diagnostic] = []
50
+ for node in ast.walk(tree):
51
+ if isinstance(node, ast.ExceptHandler) and node.type is None:
52
+ diags.append(
53
+ Diagnostic(
54
+ file_path=filename,
55
+ rule="no-bare-except",
56
+ severity=Severity.ERROR,
57
+ category=Category.CORRECTNESS,
58
+ message="Bare except catches all exceptions including SystemExit and KeyboardInterrupt",
59
+ help="Catch a specific exception type, or at minimum use 'except Exception'",
60
+ line=node.lineno,
61
+ column=node.col_offset,
62
+ cost=2.0,
63
+ )
64
+ )
65
+ return diags
66
+
67
+ def _check_broad_exception(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
68
+ diags: list[Diagnostic] = []
69
+ for node in ast.walk(tree):
70
+ if isinstance(node, ast.ExceptHandler) and node.type is not None:
71
+ if isinstance(node.type, ast.Name) and node.type.id in (
72
+ "Exception",
73
+ "BaseException",
74
+ ):
75
+ diags.append(
76
+ Diagnostic(
77
+ file_path=filename,
78
+ rule="no-broad-exception",
79
+ severity=Severity.WARNING,
80
+ category=Category.CORRECTNESS,
81
+ message=f"Catching '{node.type.id}' is too broad — masks real errors",
82
+ help="Catch specific exception types (ValueError, TypeError, etc.)",
83
+ line=node.lineno,
84
+ column=node.col_offset,
85
+ cost=1.0,
86
+ )
87
+ )
88
+ return diags
89
+
90
+ def _check_assert_in_production(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
91
+ if filename.startswith("test_") or "/test_" in filename or filename.endswith("_test.py"):
92
+ return []
93
+
94
+ diags: list[Diagnostic] = []
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, ast.Assert):
97
+ diags.append(
98
+ Diagnostic(
99
+ file_path=filename,
100
+ rule="no-assert-in-production",
101
+ severity=Severity.WARNING,
102
+ category=Category.CORRECTNESS,
103
+ message="assert statements are stripped with python -O flag",
104
+ help="Use explicit if/raise for production validation",
105
+ line=node.lineno,
106
+ column=node.col_offset,
107
+ cost=1.0,
108
+ )
109
+ )
110
+ return diags
111
+
112
+ def _check_return_in_init(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
113
+ diags: list[Diagnostic] = []
114
+ for node in ast.walk(tree):
115
+ if isinstance(node, ast.FunctionDef) and node.name == "__init__":
116
+ for child in ast.walk(node):
117
+ if isinstance(child, ast.Return) and child.value is not None:
118
+ diags.append(
119
+ Diagnostic(
120
+ file_path=filename,
121
+ rule="no-return-in-init",
122
+ severity=Severity.ERROR,
123
+ category=Category.CORRECTNESS,
124
+ message="__init__ should not return a value",
125
+ help="Remove the return value — __init__ must return None",
126
+ line=child.lineno,
127
+ column=child.col_offset,
128
+ cost=2.0,
129
+ )
130
+ )
131
+ return diags
@@ -0,0 +1,58 @@
1
+ """Dead code detection via vulture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import vulture
9
+ except ImportError:
10
+ vulture = None # type: ignore[assignment]
11
+
12
+ from pycodegate.types import Category, Diagnostic, Severity
13
+
14
+
15
+ class DeadCodeRules:
16
+ """Detect unused code using vulture."""
17
+
18
+ def check_project(self, project_path: str) -> list[Diagnostic]:
19
+ """Run vulture on the entire project and return diagnostics."""
20
+ if vulture is None:
21
+ return []
22
+
23
+ v = vulture.Vulture()
24
+
25
+ py_files = list(Path(project_path).rglob("*.py"))
26
+ ignore = {".venv", "venv", "node_modules", "__pycache__", ".git", "dist", "build"}
27
+ py_files = [
28
+ f
29
+ for f in py_files
30
+ if not any(part in ignore for part in f.relative_to(project_path).parts)
31
+ ]
32
+
33
+ if not py_files:
34
+ return []
35
+
36
+ # Include whitelist if present
37
+ whitelist = Path(project_path) / "whitelist.py"
38
+ scan_paths = [str(f) for f in py_files]
39
+ if whitelist.exists():
40
+ scan_paths.append(str(whitelist))
41
+
42
+ v.scavenge(scan_paths)
43
+
44
+ diags: list[Diagnostic] = []
45
+ for item in v.get_unused_code(min_confidence=60):
46
+ diags.append(
47
+ Diagnostic(
48
+ file_path=str(item.filename),
49
+ rule="dead-code",
50
+ severity=Severity.WARNING,
51
+ category=Category.DEAD_CODE,
52
+ message=f"Unused {item.typ}: '{item.name}' ({item.confidence}% confidence)",
53
+ help="Remove this dead code or add it to a vulture whitelist",
54
+ line=item.first_lineno,
55
+ cost=0.5,
56
+ )
57
+ )
58
+ return diags
@@ -0,0 +1,107 @@
1
+ """Dependency rules: vulnerability scanning via pip-audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from pycodegate.types import Category, Diagnostic, Severity
12
+
13
+
14
+ class DependencyRules:
15
+ """Dependency vulnerability checks using pip-audit (optional)."""
16
+
17
+ def check_project(self, project_path: str) -> list[Diagnostic]:
18
+ if not shutil.which("pip-audit"):
19
+ return []
20
+
21
+ path = Path(project_path)
22
+ req_file = self._find_or_export_requirements(path)
23
+ if not req_file:
24
+ return []
25
+
26
+ try:
27
+ result = subprocess.run(
28
+ [
29
+ "pip-audit",
30
+ "--requirement",
31
+ str(req_file),
32
+ "--format",
33
+ "json",
34
+ "--output",
35
+ "-",
36
+ ],
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=60,
40
+ )
41
+ # pip-audit exits non-zero when vulnerabilities found
42
+ if not result.stdout.strip():
43
+ return []
44
+ data = json.loads(result.stdout)
45
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
46
+ return []
47
+ finally:
48
+ # Clean up temp file if we created one
49
+ if req_file and str(req_file).startswith(tempfile.gettempdir()):
50
+ req_file.unlink(missing_ok=True)
51
+
52
+ diags: list[Diagnostic] = []
53
+ for vuln in data.get("dependencies", []):
54
+ pkg_name = vuln.get("name", "unknown")
55
+ version = vuln.get("version", "?")
56
+ for v in vuln.get("vulns", []):
57
+ vuln_id = v.get("id", "unknown")
58
+ fix_versions = v.get("fix_versions", [])
59
+ fix_str = f" (fix: {', '.join(fix_versions)})" if fix_versions else ""
60
+ diags.append(
61
+ Diagnostic(
62
+ file_path="requirements",
63
+ rule="deps/vulnerability",
64
+ severity=Severity.ERROR,
65
+ category=Category.DEPENDENCIES,
66
+ message=f"{pkg_name}=={version} has known vulnerability {vuln_id}{fix_str}",
67
+ help=f"Upgrade {pkg_name} to a patched version",
68
+ line=0,
69
+ cost=3.0,
70
+ )
71
+ )
72
+ return diags
73
+
74
+ def _find_or_export_requirements(self, path: Path) -> Path | None:
75
+ # Try existing requirements.txt
76
+ for name in ["requirements.txt", "requirements.lock"]:
77
+ req = path / name
78
+ if req.exists():
79
+ return req
80
+
81
+ # Try uv export
82
+ if shutil.which("uv") and (path / "pyproject.toml").exists():
83
+ try:
84
+ tmp = Path(tempfile.gettempdir()) / "py-gate-reqs.txt"
85
+ result = subprocess.run(
86
+ [
87
+ "uv",
88
+ "export",
89
+ "--no-dev",
90
+ "--no-editable",
91
+ "--no-emit-project",
92
+ "--format",
93
+ "requirements-txt",
94
+ "--quiet",
95
+ ],
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=30,
99
+ cwd=str(path),
100
+ )
101
+ if result.returncode == 0 and result.stdout.strip():
102
+ tmp.write_text(result.stdout)
103
+ return tmp
104
+ except (subprocess.TimeoutExpired, OSError):
105
+ pass
106
+
107
+ return None