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 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