pfix 0.1.1__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.
- pfix/__init__.py +21 -0
- pfix/analyzer.py +223 -0
- pfix/cli.py +220 -0
- pfix/config.py +151 -0
- pfix/decorator.py +265 -0
- pfix/dependency.py +205 -0
- pfix/fixer.py +200 -0
- pfix/llm.py +135 -0
- pfix/mcp_client.py +81 -0
- pfix/mcp_server.py +215 -0
- pfix-0.1.1.dist-info/METADATA +232 -0
- pfix-0.1.1.dist-info/RECORD +15 -0
- pfix-0.1.1.dist-info/WHEEL +4 -0
- pfix-0.1.1.dist-info/entry_points.txt +2 -0
- pfix-0.1.1.dist-info/licenses/LICENSE +15 -0
pfix/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pfix — Self-healing Python for development.
|
|
3
|
+
|
|
4
|
+
Catches runtime errors and fixes source code + dependencies via LLM + MCP.
|
|
5
|
+
|
|
6
|
+
from pfix import pfix
|
|
7
|
+
|
|
8
|
+
@pfix
|
|
9
|
+
def my_function():
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
# Configure
|
|
13
|
+
from pfix import configure
|
|
14
|
+
configure(auto_apply=True, llm_model="openrouter/anthropic/claude-sonnet-4")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .config import PfixConfig, configure, get_config
|
|
18
|
+
from .decorator import apfix, pfix
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.1"
|
|
21
|
+
__all__ = ["pfix", "apfix", "configure", "get_config", "PfixConfig"]
|
pfix/analyzer.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pfix.analyzer — Extract structured error context for LLM analysis.
|
|
3
|
+
|
|
4
|
+
Gathers: traceback, source, local vars, imports, project deps,
|
|
5
|
+
pipreqs scan results, and decorator hints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import inspect
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ErrorContext:
|
|
21
|
+
"""Structured error report for LLM consumption."""
|
|
22
|
+
|
|
23
|
+
# Error
|
|
24
|
+
exception_type: str = ""
|
|
25
|
+
exception_message: str = ""
|
|
26
|
+
traceback_text: str = ""
|
|
27
|
+
|
|
28
|
+
# Source
|
|
29
|
+
source_file: str = ""
|
|
30
|
+
source_code: str = ""
|
|
31
|
+
function_name: str = ""
|
|
32
|
+
function_source: str = ""
|
|
33
|
+
line_number: int = 0
|
|
34
|
+
failing_line: str = ""
|
|
35
|
+
|
|
36
|
+
# Environment
|
|
37
|
+
local_vars: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
imports: list[str] = field(default_factory=list)
|
|
39
|
+
python_version: str = ""
|
|
40
|
+
|
|
41
|
+
# Project context
|
|
42
|
+
project_deps: list[str] = field(default_factory=list)
|
|
43
|
+
missing_deps: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
# Decorator metadata
|
|
46
|
+
hints: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def to_prompt(self) -> str:
|
|
49
|
+
parts = [
|
|
50
|
+
"## Error Report",
|
|
51
|
+
f"**Exception**: `{self.exception_type}: {self.exception_message}`",
|
|
52
|
+
f"**File**: `{self.source_file}` line {self.line_number}",
|
|
53
|
+
f"**Function**: `{self.function_name}`",
|
|
54
|
+
"",
|
|
55
|
+
"### Traceback",
|
|
56
|
+
f"```\n{self.traceback_text}\n```",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
if self.source_code:
|
|
60
|
+
parts += ["", "### Full Source File", f"```python\n{self.source_code}\n```"]
|
|
61
|
+
|
|
62
|
+
if self.function_source:
|
|
63
|
+
parts += ["", "### Function Source", f"```python\n{self.function_source}\n```"]
|
|
64
|
+
|
|
65
|
+
if self.local_vars:
|
|
66
|
+
parts.append("\n### Local Variables")
|
|
67
|
+
for k, v in list(self.local_vars.items())[:30]:
|
|
68
|
+
parts.append(f"- `{k}` = `{v}`")
|
|
69
|
+
|
|
70
|
+
if self.imports:
|
|
71
|
+
parts.append("\n### File Imports")
|
|
72
|
+
for imp in self.imports:
|
|
73
|
+
parts.append(f"- `{imp}`")
|
|
74
|
+
|
|
75
|
+
if self.missing_deps:
|
|
76
|
+
parts.append("\n### Missing Dependencies (pipreqs scan)")
|
|
77
|
+
for dep in self.missing_deps:
|
|
78
|
+
parts.append(f"- `{dep}`")
|
|
79
|
+
|
|
80
|
+
if self.hints:
|
|
81
|
+
parts.append("\n### Developer Hints (@pfix decorator)")
|
|
82
|
+
for k, v in self.hints.items():
|
|
83
|
+
parts.append(f"- **{k}**: {v}")
|
|
84
|
+
|
|
85
|
+
parts.append(f"\n### Python {self.python_version}")
|
|
86
|
+
return "\n".join(parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def analyze_exception(
|
|
90
|
+
exc: BaseException,
|
|
91
|
+
func: Optional[Any] = None,
|
|
92
|
+
local_vars: Optional[dict] = None,
|
|
93
|
+
hints: Optional[dict] = None,
|
|
94
|
+
) -> ErrorContext:
|
|
95
|
+
"""Build ErrorContext from a caught exception."""
|
|
96
|
+
ctx = ErrorContext()
|
|
97
|
+
ctx.exception_type = type(exc).__name__
|
|
98
|
+
ctx.exception_message = str(exc)
|
|
99
|
+
ctx.traceback_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
100
|
+
ctx.python_version = sys.version.split()[0]
|
|
101
|
+
|
|
102
|
+
# Walk traceback to innermost user-code frame
|
|
103
|
+
tb = exc.__traceback__
|
|
104
|
+
if tb is not None:
|
|
105
|
+
while tb.tb_next is not None:
|
|
106
|
+
tb = tb.tb_next
|
|
107
|
+
frame = tb.tb_frame
|
|
108
|
+
ctx.line_number = tb.tb_lineno
|
|
109
|
+
ctx.source_file = frame.f_code.co_filename
|
|
110
|
+
if local_vars is None:
|
|
111
|
+
local_vars = frame.f_locals
|
|
112
|
+
ctx.local_vars = {
|
|
113
|
+
k: _safe_repr(v) for k, v in (local_vars or {}).items()
|
|
114
|
+
if not k.startswith("__")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Function source
|
|
118
|
+
if func is not None:
|
|
119
|
+
try:
|
|
120
|
+
ctx.function_source = inspect.getsource(func)
|
|
121
|
+
ctx.function_name = func.__qualname__
|
|
122
|
+
ctx.source_file = inspect.getfile(func)
|
|
123
|
+
except (OSError, TypeError):
|
|
124
|
+
ctx.function_name = getattr(func, "__name__", "<unknown>")
|
|
125
|
+
|
|
126
|
+
# Full file source
|
|
127
|
+
if ctx.source_file and Path(ctx.source_file).is_file():
|
|
128
|
+
try:
|
|
129
|
+
ctx.source_code = Path(ctx.source_file).read_text(encoding="utf-8")
|
|
130
|
+
lines = ctx.source_code.splitlines()
|
|
131
|
+
if 0 < ctx.line_number <= len(lines):
|
|
132
|
+
ctx.failing_line = lines[ctx.line_number - 1]
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
# Extract imports
|
|
137
|
+
if ctx.source_code:
|
|
138
|
+
ctx.imports = _extract_imports(ctx.source_code)
|
|
139
|
+
|
|
140
|
+
# Scan for missing deps via pipreqs
|
|
141
|
+
if ctx.source_file:
|
|
142
|
+
ctx.missing_deps = scan_missing_deps(Path(ctx.source_file).parent)
|
|
143
|
+
|
|
144
|
+
ctx.hints = hints or {}
|
|
145
|
+
return ctx
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def classify_error(ctx: ErrorContext) -> str:
|
|
149
|
+
"""Classify error to guide fix strategy."""
|
|
150
|
+
exc = ctx.exception_type
|
|
151
|
+
msg = ctx.exception_message.lower()
|
|
152
|
+
|
|
153
|
+
if exc in ("ModuleNotFoundError", "ImportError"):
|
|
154
|
+
return "missing_dependency"
|
|
155
|
+
if exc == "NameError" and "is not defined" in msg:
|
|
156
|
+
return "missing_import"
|
|
157
|
+
if exc == "TypeError":
|
|
158
|
+
return "type_error"
|
|
159
|
+
if exc == "AttributeError":
|
|
160
|
+
return "attribute_error"
|
|
161
|
+
if exc == "SyntaxError":
|
|
162
|
+
return "syntax_error"
|
|
163
|
+
if exc == "IndexError":
|
|
164
|
+
return "index_error"
|
|
165
|
+
if exc == "KeyError":
|
|
166
|
+
return "key_error"
|
|
167
|
+
if exc == "ValueError":
|
|
168
|
+
return "value_error"
|
|
169
|
+
if exc == "FileNotFoundError":
|
|
170
|
+
return "file_not_found"
|
|
171
|
+
if exc == "PermissionError":
|
|
172
|
+
return "permission_error"
|
|
173
|
+
return "other"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def scan_missing_deps(project_dir: Path) -> list[str]:
|
|
177
|
+
"""Use pipreqs to detect imports that aren't installed."""
|
|
178
|
+
try:
|
|
179
|
+
from pipreqs import pipreqs
|
|
180
|
+
imports = pipreqs.get_all_imports(str(project_dir))
|
|
181
|
+
pkg_info = pipreqs.get_pkg_names(imports)
|
|
182
|
+
# Compare with installed
|
|
183
|
+
installed = {pkg.key for pkg in __import__("importlib.metadata").metadata.distributions()}
|
|
184
|
+
except Exception:
|
|
185
|
+
installed = set()
|
|
186
|
+
|
|
187
|
+
missing = []
|
|
188
|
+
try:
|
|
189
|
+
from pipreqs import pipreqs
|
|
190
|
+
all_imports = pipreqs.get_all_imports(str(project_dir))
|
|
191
|
+
for imp in all_imports:
|
|
192
|
+
top = imp.split(".")[0].lower()
|
|
193
|
+
if top not in installed and top not in sys.stdlib_module_names:
|
|
194
|
+
missing.append(imp)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
return missing
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_imports(source: str) -> list[str]:
|
|
202
|
+
imports = []
|
|
203
|
+
try:
|
|
204
|
+
tree = ast.parse(source)
|
|
205
|
+
for node in ast.walk(tree):
|
|
206
|
+
if isinstance(node, ast.Import):
|
|
207
|
+
for alias in node.names:
|
|
208
|
+
imports.append(f"import {alias.name}")
|
|
209
|
+
elif isinstance(node, ast.ImportFrom):
|
|
210
|
+
module = node.module or ""
|
|
211
|
+
names = ", ".join(a.name for a in node.names)
|
|
212
|
+
imports.append(f"from {module} import {names}")
|
|
213
|
+
except SyntaxError:
|
|
214
|
+
pass
|
|
215
|
+
return imports
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _safe_repr(obj: Any, max_len: int = 200) -> str:
|
|
219
|
+
try:
|
|
220
|
+
r = repr(obj)
|
|
221
|
+
return r[:max_len] + "..." if len(r) > max_len else r
|
|
222
|
+
except Exception:
|
|
223
|
+
return f"<{type(obj).__name__}: repr failed>"
|
pfix/cli.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pfix.cli — Command-line interface.
|
|
3
|
+
|
|
4
|
+
pfix run script.py # Run with global exception hook
|
|
5
|
+
pfix run script.py --auto # Auto-apply fixes
|
|
6
|
+
pfix check # Validate config
|
|
7
|
+
pfix deps scan # Scan for missing deps (pipreqs)
|
|
8
|
+
pfix deps install # Install missing deps
|
|
9
|
+
pfix deps generate # Generate requirements.txt
|
|
10
|
+
pfix server # Start MCP server (stdio)
|
|
11
|
+
pfix server --http 3001 # Start MCP server (HTTP)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import importlib.util
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> int:
|
|
28
|
+
parser = argparse.ArgumentParser(prog="pfix", description="Self-healing Python — fix code & deps via LLM + MCP")
|
|
29
|
+
sub = parser.add_subparsers(dest="command")
|
|
30
|
+
|
|
31
|
+
# run
|
|
32
|
+
run_p = sub.add_parser("run", help="Run a script with pfix active")
|
|
33
|
+
run_p.add_argument("script", help="Python script path")
|
|
34
|
+
run_p.add_argument("args", nargs="*")
|
|
35
|
+
run_p.add_argument("--auto", action="store_true", help="Auto-apply fixes")
|
|
36
|
+
run_p.add_argument("--dry-run", action="store_true")
|
|
37
|
+
run_p.add_argument("--restart", action="store_true", help="Restart process after fix")
|
|
38
|
+
|
|
39
|
+
# check
|
|
40
|
+
sub.add_parser("check", help="Validate config")
|
|
41
|
+
|
|
42
|
+
# deps
|
|
43
|
+
deps_p = sub.add_parser("deps", help="Dependency management")
|
|
44
|
+
deps_sub = deps_p.add_subparsers(dest="deps_command")
|
|
45
|
+
deps_sub.add_parser("scan", help="Scan for missing deps (pipreqs)")
|
|
46
|
+
deps_sub.add_parser("install", help="Install missing deps")
|
|
47
|
+
deps_sub.add_parser("generate", help="Generate requirements.txt")
|
|
48
|
+
|
|
49
|
+
# server
|
|
50
|
+
srv_p = sub.add_parser("server", help="Start MCP server")
|
|
51
|
+
srv_p.add_argument("--http", type=int, default=None, metavar="PORT", help="HTTP port (default: stdio)")
|
|
52
|
+
srv_p.add_argument("--host", default="127.0.0.1")
|
|
53
|
+
|
|
54
|
+
# version
|
|
55
|
+
sub.add_parser("version", help="Show version")
|
|
56
|
+
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if args.command == "run":
|
|
60
|
+
return cmd_run(args)
|
|
61
|
+
elif args.command == "check":
|
|
62
|
+
return cmd_check()
|
|
63
|
+
elif args.command == "deps":
|
|
64
|
+
return cmd_deps(args)
|
|
65
|
+
elif args.command == "server":
|
|
66
|
+
return cmd_server(args)
|
|
67
|
+
elif args.command == "version":
|
|
68
|
+
from pfix import __version__
|
|
69
|
+
console.print(f"pfix {__version__}")
|
|
70
|
+
return 0
|
|
71
|
+
else:
|
|
72
|
+
parser.print_help()
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_run(args) -> int:
|
|
77
|
+
from pfix import configure
|
|
78
|
+
|
|
79
|
+
script = Path(args.script).resolve()
|
|
80
|
+
if not script.is_file():
|
|
81
|
+
console.print(f"[red]✗ Not found: {script}[/]")
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
configure(
|
|
85
|
+
auto_apply=args.auto,
|
|
86
|
+
dry_run=args.dry_run,
|
|
87
|
+
auto_restart=args.restart,
|
|
88
|
+
project_root=script.parent,
|
|
89
|
+
)
|
|
90
|
+
_install_excepthook()
|
|
91
|
+
|
|
92
|
+
sys.argv = [str(script)] + (args.args or [])
|
|
93
|
+
spec = importlib.util.spec_from_file_location("__main__", str(script))
|
|
94
|
+
if spec is None or spec.loader is None:
|
|
95
|
+
console.print(f"[red]✗ Cannot load: {script}[/]")
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
module = importlib.util.module_from_spec(spec)
|
|
99
|
+
sys.modules["__main__"] = module
|
|
100
|
+
try:
|
|
101
|
+
spec.loader.exec_module(module)
|
|
102
|
+
except SystemExit as e:
|
|
103
|
+
return e.code if isinstance(e.code, int) else 0
|
|
104
|
+
except Exception as e:
|
|
105
|
+
console.print(f"[red]💥 Unhandled: {type(e).__name__}: {e}[/]")
|
|
106
|
+
return 1
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_check() -> int:
|
|
111
|
+
from pfix.config import get_config
|
|
112
|
+
|
|
113
|
+
config = get_config()
|
|
114
|
+
warnings = config.validate()
|
|
115
|
+
|
|
116
|
+
table = Table(title="pfix Configuration")
|
|
117
|
+
table.add_column("Setting", style="cyan")
|
|
118
|
+
table.add_column("Value")
|
|
119
|
+
|
|
120
|
+
rows = [
|
|
121
|
+
("Model", config.llm_model),
|
|
122
|
+
("API Key", "✓ set" if config.llm_api_key else "[red]✗ missing[/]"),
|
|
123
|
+
("API Base", config.llm_api_base),
|
|
124
|
+
("Pkg Manager", config.pkg_manager),
|
|
125
|
+
("Auto Apply", str(config.auto_apply)),
|
|
126
|
+
("Auto Install Deps", str(config.auto_install_deps)),
|
|
127
|
+
("Auto Restart", str(config.auto_restart)),
|
|
128
|
+
("Max Retries", str(config.max_retries)),
|
|
129
|
+
("Dry Run", str(config.dry_run)),
|
|
130
|
+
("Enabled", str(config.enabled)),
|
|
131
|
+
("MCP Enabled", str(config.mcp_enabled)),
|
|
132
|
+
("MCP Transport", config.mcp_transport),
|
|
133
|
+
("Git Auto-Commit", str(config.git_auto_commit)),
|
|
134
|
+
("Project Root", str(config.project_root)),
|
|
135
|
+
]
|
|
136
|
+
for k, v in rows:
|
|
137
|
+
table.add_row(k, v)
|
|
138
|
+
|
|
139
|
+
console.print(table)
|
|
140
|
+
|
|
141
|
+
if warnings:
|
|
142
|
+
console.print()
|
|
143
|
+
for w in warnings:
|
|
144
|
+
console.print(f"[yellow]⚠ {w}[/]")
|
|
145
|
+
return 1
|
|
146
|
+
|
|
147
|
+
console.print("\n[green]✓ Configuration valid[/]")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_deps(args) -> int:
|
|
152
|
+
if not hasattr(args, "deps_command") or args.deps_command is None:
|
|
153
|
+
console.print("Usage: pfix deps [scan|install|generate]")
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
from pfix.dependency import scan_project_deps, install_packages, generate_requirements
|
|
157
|
+
|
|
158
|
+
if args.deps_command == "generate":
|
|
159
|
+
generate_requirements()
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
result = scan_project_deps()
|
|
163
|
+
|
|
164
|
+
if not result["missing"]:
|
|
165
|
+
console.print(f"[green]✓ All dependencies satisfied ({len(result['installed'])} packages)[/]")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
table = Table(title=f"Missing Dependencies ({len(result['missing'])})")
|
|
169
|
+
table.add_column("Package", style="yellow")
|
|
170
|
+
for pkg in result["missing"]:
|
|
171
|
+
table.add_row(pkg)
|
|
172
|
+
console.print(table)
|
|
173
|
+
|
|
174
|
+
if args.deps_command == "install":
|
|
175
|
+
install_packages(result["missing"])
|
|
176
|
+
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_server(args) -> int:
|
|
181
|
+
try:
|
|
182
|
+
from pfix.mcp_server import start_server
|
|
183
|
+
|
|
184
|
+
if args.http:
|
|
185
|
+
start_server(transport="http", host=args.host, port=args.http)
|
|
186
|
+
else:
|
|
187
|
+
start_server(transport="stdio")
|
|
188
|
+
return 0
|
|
189
|
+
except ImportError as e:
|
|
190
|
+
console.print(f"[red]MCP server requires: pip install pfix[mcp][/]\n{e}")
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _install_excepthook():
|
|
195
|
+
from pfix.analyzer import analyze_exception
|
|
196
|
+
from pfix.llm import request_fix
|
|
197
|
+
from pfix.fixer import apply_fix
|
|
198
|
+
|
|
199
|
+
original = sys.excepthook
|
|
200
|
+
|
|
201
|
+
def hook(exc_type, exc_value, exc_tb):
|
|
202
|
+
if exc_type in (KeyboardInterrupt, SystemExit):
|
|
203
|
+
original(exc_type, exc_value, exc_tb)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
console.print(f"\n[red]💥 pfix hook: {exc_type.__name__}: {exc_value}[/]")
|
|
207
|
+
exc_value.__traceback__ = exc_tb
|
|
208
|
+
ctx = analyze_exception(exc_value)
|
|
209
|
+
proposal = request_fix(ctx)
|
|
210
|
+
|
|
211
|
+
if proposal.confidence > 0.1:
|
|
212
|
+
apply_fix(ctx, proposal, confirm=True)
|
|
213
|
+
|
|
214
|
+
original(exc_type, exc_value, exc_tb)
|
|
215
|
+
|
|
216
|
+
sys.excepthook = hook
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
sys.exit(main())
|
pfix/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pfix.config — Configuration management.
|
|
3
|
+
|
|
4
|
+
Loads from .env → environment → pyproject.toml [tool.pfix].
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PfixConfig:
|
|
20
|
+
"""Central configuration."""
|
|
21
|
+
|
|
22
|
+
# LLM
|
|
23
|
+
llm_model: str = "openrouter/anthropic/claude-sonnet-4"
|
|
24
|
+
llm_api_key: str = ""
|
|
25
|
+
llm_api_base: str = "https://openrouter.ai/api/v1"
|
|
26
|
+
llm_temperature: float = 0.2
|
|
27
|
+
llm_max_tokens: int = 4096
|
|
28
|
+
|
|
29
|
+
# Behavior
|
|
30
|
+
auto_apply: bool = False
|
|
31
|
+
auto_install_deps: bool = True
|
|
32
|
+
auto_restart: bool = False # os.execv restart after fix
|
|
33
|
+
max_retries: int = 3
|
|
34
|
+
enabled: bool = True
|
|
35
|
+
dry_run: bool = False
|
|
36
|
+
|
|
37
|
+
# Dependency manager: "pip" | "uv" (auto-detected)
|
|
38
|
+
pkg_manager: str = "pip"
|
|
39
|
+
|
|
40
|
+
# MCP
|
|
41
|
+
mcp_enabled: bool = False
|
|
42
|
+
mcp_server_url: str = "http://localhost:3001"
|
|
43
|
+
mcp_transport: str = "stdio" # "stdio" | "http"
|
|
44
|
+
|
|
45
|
+
# Git integration
|
|
46
|
+
git_auto_commit: bool = False
|
|
47
|
+
git_commit_prefix: str = "pfix: "
|
|
48
|
+
|
|
49
|
+
# Paths
|
|
50
|
+
project_root: Path = field(default_factory=lambda: Path.cwd())
|
|
51
|
+
log_file: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
# Extra context for LLM
|
|
54
|
+
extra_context: dict = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls, dotenv_path: Optional[str] = None) -> "PfixConfig":
|
|
58
|
+
"""Load config from .env + environment + pyproject.toml."""
|
|
59
|
+
# Find and load .env
|
|
60
|
+
if dotenv_path:
|
|
61
|
+
load_dotenv(dotenv_path)
|
|
62
|
+
else:
|
|
63
|
+
for parent in [Path.cwd(), *Path.cwd().parents]:
|
|
64
|
+
env_file = parent / ".env"
|
|
65
|
+
if env_file.exists():
|
|
66
|
+
load_dotenv(env_file)
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
# Detect uv
|
|
70
|
+
pkg_manager = "uv" if shutil.which("uv") else "pip"
|
|
71
|
+
|
|
72
|
+
cfg = cls(
|
|
73
|
+
llm_model=os.getenv("PFIX_MODEL", os.getenv("LIBFIX_MODEL", cls.llm_model)),
|
|
74
|
+
llm_api_key=os.getenv("OPENROUTER_API_KEY", os.getenv("PFIX_API_KEY", "")),
|
|
75
|
+
llm_api_base=os.getenv("PFIX_API_BASE", cls.llm_api_base),
|
|
76
|
+
llm_temperature=float(os.getenv("PFIX_TEMPERATURE", str(cls.llm_temperature))),
|
|
77
|
+
llm_max_tokens=int(os.getenv("PFIX_MAX_TOKENS", str(cls.llm_max_tokens))),
|
|
78
|
+
auto_apply=os.getenv("PFIX_AUTO_APPLY", "false").lower() in ("true", "1", "yes"),
|
|
79
|
+
auto_install_deps=os.getenv("PFIX_AUTO_INSTALL_DEPS", "true").lower() in ("true", "1", "yes"),
|
|
80
|
+
auto_restart=os.getenv("PFIX_AUTO_RESTART", "false").lower() in ("true", "1", "yes"),
|
|
81
|
+
max_retries=int(os.getenv("PFIX_MAX_RETRIES", "3")),
|
|
82
|
+
enabled=os.getenv("PFIX_ENABLED", "true").lower() in ("true", "1", "yes"),
|
|
83
|
+
dry_run=os.getenv("PFIX_DRY_RUN", "false").lower() in ("true", "1", "yes"),
|
|
84
|
+
pkg_manager=os.getenv("PFIX_PKG_MANAGER", pkg_manager),
|
|
85
|
+
mcp_enabled=os.getenv("PFIX_MCP_ENABLED", "false").lower() in ("true", "1", "yes"),
|
|
86
|
+
mcp_server_url=os.getenv("PFIX_MCP_SERVER_URL", cls.mcp_server_url),
|
|
87
|
+
mcp_transport=os.getenv("PFIX_MCP_TRANSPORT", cls.mcp_transport),
|
|
88
|
+
git_auto_commit=os.getenv("PFIX_GIT_COMMIT", "false").lower() in ("true", "1", "yes"),
|
|
89
|
+
git_commit_prefix=os.getenv("PFIX_GIT_PREFIX", cls.git_commit_prefix),
|
|
90
|
+
project_root=Path(os.getenv("PFIX_PROJECT_ROOT", str(Path.cwd()))),
|
|
91
|
+
log_file=os.getenv("PFIX_LOG_FILE"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Merge pyproject.toml [tool.pfix]
|
|
95
|
+
pyproject = cls._read_pyproject(cfg.project_root / "pyproject.toml")
|
|
96
|
+
for key, val in pyproject.items():
|
|
97
|
+
if hasattr(cfg, key) and not os.getenv(f"PFIX_{key.upper()}"):
|
|
98
|
+
setattr(cfg, key, val)
|
|
99
|
+
|
|
100
|
+
return cfg
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _read_pyproject(path: Path) -> dict:
|
|
104
|
+
if not path.exists():
|
|
105
|
+
return {}
|
|
106
|
+
try:
|
|
107
|
+
import tomllib
|
|
108
|
+
except ImportError:
|
|
109
|
+
try:
|
|
110
|
+
import tomli as tomllib # type: ignore
|
|
111
|
+
except ImportError:
|
|
112
|
+
return {}
|
|
113
|
+
with open(path, "rb") as f:
|
|
114
|
+
data = tomllib.load(f)
|
|
115
|
+
return data.get("tool", {}).get("pfix", {})
|
|
116
|
+
|
|
117
|
+
def validate(self) -> list[str]:
|
|
118
|
+
warnings = []
|
|
119
|
+
if not self.llm_api_key:
|
|
120
|
+
warnings.append("No API key. Set OPENROUTER_API_KEY in .env")
|
|
121
|
+
return warnings
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Global singleton ────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
_config: Optional[PfixConfig] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_config() -> PfixConfig:
|
|
130
|
+
global _config
|
|
131
|
+
if _config is None:
|
|
132
|
+
_config = PfixConfig.from_env()
|
|
133
|
+
return _config
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def configure(**kwargs) -> PfixConfig:
|
|
137
|
+
"""Override global config programmatically."""
|
|
138
|
+
global _config
|
|
139
|
+
_config = PfixConfig.from_env()
|
|
140
|
+
for k, v in kwargs.items():
|
|
141
|
+
if hasattr(_config, k):
|
|
142
|
+
setattr(_config, k, v)
|
|
143
|
+
else:
|
|
144
|
+
_config.extra_context[k] = v
|
|
145
|
+
return _config
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def reset_config():
|
|
149
|
+
"""Reset global config (useful in tests)."""
|
|
150
|
+
global _config
|
|
151
|
+
_config = None
|