errlens-cli 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.
errlens/models.py ADDED
@@ -0,0 +1,120 @@
1
+ """JSON-friendly contracts; messages remain identifiers until rendering.
2
+
3
+ Paths are strings so dataclasses.asdict results can be JSON-encoded directly.
4
+ Environment metadata is explicitly supplied context, not an environment dump.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import TypeAlias
9
+
10
+ Parameter: TypeAlias = str | int | float | bool | None
11
+
12
+
13
+ @dataclass
14
+ class ExecutionContext:
15
+ """Executable plus arguments, working directory and capture provenance."""
16
+
17
+ command: list[str]
18
+ cwd: str
19
+ capture_source: str = "runner"
20
+ environment: dict[str, Parameter] = field(default_factory=dict)
21
+
22
+ def __post_init__(self) -> None:
23
+ if not self.command or not self.command[0]:
24
+ raise ValueError("command must contain a nonempty executable")
25
+ if not self.cwd or not self.capture_source:
26
+ raise ValueError("cwd and capture_source must not be empty")
27
+
28
+
29
+ @dataclass
30
+ class ExecutionResult:
31
+ """None means no exit status is available; negative POSIX signals are allowed.
32
+
33
+ output_complete describes the diagnostic copy (false when capture truncates),
34
+ not whether all bytes were forwarded. Forwarding failures raise RunnerError.
35
+ """
36
+
37
+ exit_code: int | None
38
+ interrupted: bool = False
39
+ output_complete: bool = True
40
+
41
+
42
+ @dataclass
43
+ class StackFrame:
44
+ """Location reported by Python; no claim that it belongs to user code."""
45
+
46
+ file_path: str
47
+ line_number: int | None = None
48
+ function: str | None = None
49
+
50
+ def __post_init__(self) -> None:
51
+ if self.line_number is not None and self.line_number < 1:
52
+ raise ValueError("line_number must be positive or None")
53
+
54
+
55
+ @dataclass
56
+ class Diagnostic:
57
+ """Observed error facts, never a localized explanation or guessed cause."""
58
+
59
+ diagnostic_id: str
60
+ error_type: str
61
+ original_message: str
62
+ file_path: str | None = None
63
+ line_number: int | None = None
64
+ parameters: dict[str, Parameter] = field(default_factory=dict)
65
+ incomplete: bool = False
66
+ frames: list[StackFrame] = field(default_factory=list)
67
+ source: str | None = None
68
+ chain_relation: str | None = None
69
+ related_diagnostic_id: str | None = None
70
+ complex_structure: bool = False
71
+ execution_failed: bool | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ if not self.diagnostic_id or not self.error_type:
75
+ raise ValueError("diagnostic_id and error_type must not be empty")
76
+ if self.line_number is not None and self.line_number < 1:
77
+ raise ValueError("line_number must be positive or None")
78
+
79
+
80
+ @dataclass
81
+ class SuggestedCommand:
82
+ """Display-only argv from trusted rules, guarded by a localized condition."""
83
+
84
+ argv: list[str]
85
+ condition_id: str
86
+
87
+
88
+ @dataclass
89
+ class Explanation:
90
+ """Rule output. Actions, possible causes and conditions are message IDs.
91
+
92
+ message_ids maps semantic roles (such as 'problem') to translation keys.
93
+ actions lists suggested-action message IDs; conditions lists applicability
94
+ message IDs for the recommendations as a whole. commands holds separate
95
+ display-only argv with explicit applicability. No execution is implied.
96
+ """
97
+
98
+ rule_id: str
99
+ message_ids: dict[str, str]
100
+ parameters: dict[str, Parameter] = field(default_factory=dict)
101
+ actions: list[str] = field(default_factory=list)
102
+ conditions: list[str] = field(default_factory=list)
103
+ causes: list[str] = field(default_factory=list)
104
+ commands: list[SuggestedCommand] = field(default_factory=list)
105
+
106
+ def __post_init__(self) -> None:
107
+ if not self.rule_id or not self.message_ids:
108
+ raise ValueError("rule_id and message_ids must not be empty")
109
+ if any(not role or not key for role, key in self.message_ids.items()):
110
+ raise ValueError("message roles and IDs must not be empty")
111
+
112
+
113
+ @dataclass
114
+ class ExplanationReport:
115
+ """One primary diagnostic with its actual linked predecessors."""
116
+
117
+ diagnostic: Diagnostic
118
+ explanation: Explanation
119
+ related: list[Diagnostic] = field(default_factory=list)
120
+ other_count: int = 0
errlens/posix_shell.py ADDED
@@ -0,0 +1,71 @@
1
+ """Opt-in Bash and Zsh configuration; never runs a shell while installing."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import shlex
6
+ import uuid
7
+
8
+ OWNER = "# ErrLens managed integration v1"
9
+
10
+
11
+ def default_profile(shell: str) -> Path:
12
+ if os.name == "nt":
13
+ raise ValueError("Default Bash/Zsh profiles are supported on Linux and macOS only; use --profile for an isolated test.")
14
+ if shell == "zsh":
15
+ return Path(os.environ.get("ZDOTDIR") or Path.home()) / ".zshrc"
16
+ return Path.home() / ".bashrc"
17
+
18
+
19
+ def script(shell: str) -> str:
20
+ resolver = "type -P" if shell == "bash" else "whence -p"
21
+ existing = "declare -F python >/dev/null" if shell == "bash" else "typeset -f python >/dev/null"
22
+ return OWNER + "\n" + f'''if {existing} || alias python >/dev/null 2>&1; then
23
+ printf '%s\\n' 'ErrLens: existing python function or alias preserved' >&2
24
+ else
25
+ python() {{
26
+ local target launcher
27
+ target=$({resolver} python) || {{ printf '%s\\n' 'ErrLens: python not found' >&2; return 127; }}
28
+ if [ "${{ERRLENS_DISABLE:-}}" = 1 ]; then
29
+ command "$target" "$@"
30
+ elif launcher=$({resolver} errlens); then
31
+ command "$launcher" run -- "$target" "$@"
32
+ else
33
+ command "$target" "$@"
34
+ fi
35
+ }}
36
+ fi
37
+ '''
38
+
39
+
40
+ def manage(action: str, shell: str, profile: Path) -> str:
41
+ profile = profile.expanduser().absolute()
42
+ integration = profile.parent / ".errlens" / (profile.name + "." + shell + ".sh")
43
+ marker = "# ErrLens " + shell + " v1"
44
+ line = "[ ! -f " + shlex.quote(str(integration)) + " ] || . " + shlex.quote(str(integration)) + " " + marker
45
+ addition = ("\n" + line + "\n").encode("utf-8")
46
+ original = profile.read_bytes() if profile.exists() else b""
47
+ original.decode("utf-8-sig")
48
+ installed = addition in original
49
+ if marker.encode() in original and not installed:
50
+ raise ValueError("ErrLens connection was edited; resolve it manually before changing integration.")
51
+ if action == "status":
52
+ return f"{'installed' if installed and integration.exists() else 'not installed'}: {profile}"
53
+ if integration.exists() and not integration.read_text(encoding="utf-8").startswith(OWNER + "\n"):
54
+ raise ValueError(f"Refusing to modify unowned integration file: {integration}")
55
+ if action == "install":
56
+ integration.parent.mkdir(parents=True, exist_ok=True)
57
+ if not integration.exists():
58
+ integration.write_text(script(shell), encoding="utf-8", newline="\n")
59
+ updated = original if installed else original + addition
60
+ elif action == "uninstall":
61
+ updated = original.replace(addition, b"", 1)
62
+ else:
63
+ raise ValueError("Unsupported action")
64
+ if updated != original:
65
+ profile.parent.mkdir(parents=True, exist_ok=True)
66
+ if profile.exists():
67
+ profile.with_name(profile.name + ".errlens-backup-" + uuid.uuid4().hex).write_bytes(original)
68
+ profile.write_bytes(updated)
69
+ if action == "uninstall" and integration.exists():
70
+ integration.unlink()
71
+ return f"{action}: {profile}; restart or source the profile."
errlens/powershell.py ADDED
@@ -0,0 +1,77 @@
1
+ """Explicit profile edits for the opt-in PowerShell python wrapper."""
2
+
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import uuid
8
+
9
+ OWNER = "# ErrLens managed integration v1"
10
+
11
+
12
+ def quote(value: str) -> str:
13
+ return "'" + value.replace("'", "''") + "'"
14
+
15
+
16
+ def default_profile() -> Path:
17
+ executable = shutil.which("pwsh")
18
+ if executable is None:
19
+ raise ValueError("PowerShell 7 (pwsh) was not found; specify --profile explicitly.")
20
+ result = subprocess.run([executable, "-NoProfile", "-NonInteractive", "-Command",
21
+ "if ($PSVersionTable.PSVersion.Major -lt 7) { exit 1 }; $PROFILE.CurrentUserAllHosts"],
22
+ capture_output=True, text=True, encoding="utf-8", timeout=15, check=True)
23
+ return Path(result.stdout.strip())
24
+
25
+
26
+ def script() -> str:
27
+ return OWNER + "\n" + r'''
28
+ if ($PSVersionTable.PSVersion.Major -lt 7) { Write-Warning 'ErrLens requires PowerShell 7'; return }
29
+ $existing = Get-Command python -CommandType Alias,Function -ErrorAction SilentlyContinue
30
+ if ($existing) { Write-Warning 'ErrLens: existing python alias/function preserved'; return }
31
+ function global:python {
32
+ # ErrLens wrapper: resolve applications only, on every invocation (venv-aware).
33
+ $target = Get-Command python -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
34
+ if (-not $target) { $global:LASTEXITCODE = 127; Write-Error 'ErrLens: python executable not found'; return }
35
+ $PSNativeCommandUseErrorActionPreference = $false
36
+ if ($PSVersionTable.PSVersion -ge [version]'7.3') { $PSNativeCommandArgumentPassing = 'Standard' }
37
+ if ($env:ERRLENS_DISABLE -eq '1') {
38
+ & $target.Source @args
39
+ } else {
40
+ & __RUNTIME__ -m errlens run -- $target.Source @args
41
+ }
42
+ $global:LASTEXITCODE = $LASTEXITCODE
43
+ }
44
+ '''.replace("__RUNTIME__", quote(sys.executable))
45
+
46
+
47
+ def manage(action: str, profile: Path) -> str:
48
+ profile = profile.expanduser().absolute()
49
+ integration = profile.parent / "ErrLens" / (profile.name + ".python.ps1")
50
+ line = ("if (Test-Path -LiteralPath " + quote(str(integration)) + ") { . "
51
+ + quote(str(integration)) + " } # ErrLens v1")
52
+ addition = ("\n" + line + "\n").encode("utf-8")
53
+ original = profile.read_bytes() if profile.exists() else b""
54
+ original.decode("utf-8-sig") # Refuse unsupported encodings rather than corrupting them.
55
+ installed = addition in original
56
+ if b"# ErrLens v1" in original and not installed:
57
+ raise ValueError("ErrLens profile line was edited; restore it from backup before changing integration.")
58
+ if action == "status":
59
+ return f"{'installed' if installed and integration.exists() else 'not installed'}: {profile}"
60
+ if integration.exists() and not integration.read_text(encoding="utf-8").startswith(OWNER + "\n"):
61
+ raise ValueError(f"Refusing to modify an unowned integration file: {integration}")
62
+ if action == "install":
63
+ integration.parent.mkdir(parents=True, exist_ok=True)
64
+ if not integration.exists():
65
+ integration.write_text(script(), encoding="utf-8", newline="\n")
66
+ updated = original if installed else original + addition
67
+ else:
68
+ updated = original.replace(addition, b"", 1)
69
+ if updated != original:
70
+ profile.parent.mkdir(parents=True, exist_ok=True)
71
+ if profile.exists():
72
+ backup = profile.with_name(profile.name + ".errlens-backup-" + uuid.uuid4().hex)
73
+ backup.write_bytes(original)
74
+ profile.write_bytes(updated)
75
+ if action == "uninstall" and integration.exists():
76
+ integration.unlink()
77
+ return f"{action}: {profile}. Restart PowerShell to apply."
errlens/pty_runner.py ADDED
@@ -0,0 +1,172 @@
1
+ """PTY dispatch and POSIX transport; platform-specific imports remain lazy."""
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import threading
8
+ import time
9
+ from typing import BinaryIO
10
+
11
+ from errlens.capture import TerminalCapture
12
+ from errlens.models import ExecutionContext, ExecutionResult
13
+ from errlens.runner import RunnerError
14
+
15
+
16
+ def run(context: ExecutionContext, capture: TerminalCapture, stdout: BinaryIO,
17
+ *, input_fd: int = 0) -> ExecutionResult:
18
+ if sys.platform == "win32":
19
+ from errlens.conpty_runner import run as run_conpty
20
+ return run_conpty(context, capture, stdout, input_fd=input_fd)
21
+ import errno
22
+ import fcntl
23
+ import pty
24
+ import select
25
+ import termios
26
+ import tty
27
+
28
+ if threading.current_thread() is not threading.main_thread():
29
+ raise RunnerError("pty", "PTY mode must run on the main thread")
30
+ if not os.isatty(input_fd) or not os.isatty(stdout.fileno()):
31
+ raise RunnerError("pty", "PTY mode requires terminal stdin and stdout; use pipe for redirection")
32
+ saved = termios.tcgetattr(input_fd)
33
+ previous = {number: signal.getsignal(number) for number in (signal.SIGINT, signal.SIGWINCH)}
34
+ master = slave = error_read = error_write = -1
35
+ process: subprocess.Popen[bytes] | None = None
36
+ cancel = False
37
+ resized = True
38
+ completed = False
39
+
40
+ def on_interrupt(signum: int, frame: object) -> None:
41
+ nonlocal cancel
42
+ cancel = True
43
+
44
+ def on_resize(signum: int, frame: object) -> None:
45
+ nonlocal resized
46
+ resized = True
47
+
48
+ def send(number: int, *, initial: bool = False) -> None:
49
+ if process is None:
50
+ return
51
+ groups = {process.pid} if initial else set()
52
+ try:
53
+ groups.add(os.tcgetpgrp(master))
54
+ except OSError:
55
+ groups.add(process.pid)
56
+ for group in groups:
57
+ if group > 0 and group != os.getpgrp():
58
+ try:
59
+ os.killpg(group, number)
60
+ except ProcessLookupError:
61
+ pass
62
+
63
+ try:
64
+ master, slave = pty.openpty()
65
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, fcntl.ioctl(input_fd, termios.TIOCGWINSZ, b"\0" * 8))
66
+ error_read, error_write = os.pipe()
67
+ process = subprocess.Popen(
68
+ [sys.executable, "-m", "errlens._pty_child", str(error_write), *context.command],
69
+ cwd=context.cwd, stdin=slave, stdout=slave, stderr=slave,
70
+ pass_fds=(error_write,), start_new_session=True, shell=False,
71
+ )
72
+ os.close(slave)
73
+ slave = -1
74
+ os.close(error_write)
75
+ error_write = -1
76
+ os.set_blocking(master, False)
77
+ signal.signal(signal.SIGINT, on_interrupt)
78
+ signal.signal(signal.SIGWINCH, on_resize)
79
+ tty.setraw(input_fd, termios.TCSANOW)
80
+ pending = bytearray()
81
+ eof = False
82
+ checking_launch = True
83
+ cancel_deadline: float | None = None
84
+ drain_deadline: float | None = None
85
+ eof_deadline: float | None = None
86
+ while True:
87
+ now = time.monotonic()
88
+ if resized:
89
+ fcntl.ioctl(master, termios.TIOCSWINSZ,
90
+ fcntl.ioctl(input_fd, termios.TIOCGWINSZ, b"\0" * 8))
91
+ resized = False
92
+ if cancel and cancel_deadline is None:
93
+ send(signal.SIGINT)
94
+ cancel_deadline = now + 1
95
+ if cancel_deadline is not None and now >= cancel_deadline:
96
+ send(signal.SIGKILL, initial=True)
97
+ status = process.poll()
98
+ if eof_deadline is not None and status is None and now >= eof_deadline:
99
+ raise RunnerError("pty_eof", "Terminal closed while child remained alive")
100
+ if status is not None:
101
+ if eof:
102
+ break
103
+ if drain_deadline is None:
104
+ drain_deadline = now + 1
105
+ if now >= drain_deadline:
106
+ raise RunnerError("drain", "PTY did not close after process exit")
107
+ reads = ([] if eof else [master]) + ([input_fd] if len(pending) < 65536 and status is None else [])
108
+ if checking_launch:
109
+ reads.append(error_read)
110
+ ready, writable, _ = select.select(reads, [master] if pending and not eof else [], [], 0.05)
111
+ if error_read in ready:
112
+ message = os.read(error_read, 4096)
113
+ checking_launch = False
114
+ if message:
115
+ raise RunnerError("pty_launch", message.decode("utf-8", errors="replace"),
116
+ 127 if message.startswith(b"2:") else 126)
117
+ if master in ready:
118
+ try:
119
+ data = os.read(master, 65536)
120
+ except BlockingIOError:
121
+ data = None
122
+ except OSError as exc:
123
+ if exc.errno != errno.EIO:
124
+ raise
125
+ data = b"" # Linux slave hangup; macOS commonly returns EOF.
126
+ if data == b"":
127
+ eof = True
128
+ eof_deadline = now + 1
129
+ elif data:
130
+ capture.combined.append(data)
131
+ offset = 0
132
+ while offset < len(data):
133
+ written = stdout.write(data[offset:])
134
+ if written is None or written <= 0:
135
+ raise OSError("Output stream made no progress")
136
+ offset += written
137
+ stdout.flush()
138
+ if input_fd in ready:
139
+ data = os.read(input_fd, 4096)
140
+ if not data:
141
+ raise RunnerError("pty_input", "Host terminal input closed")
142
+ pending.extend(data)
143
+ if master in writable and not eof:
144
+ try:
145
+ written = os.write(master, pending)
146
+ del pending[:written]
147
+ except BlockingIOError:
148
+ pass
149
+ completed = True
150
+ return ExecutionResult(status, cancel or status == -signal.SIGINT, not capture.truncated)
151
+ except BrokenPipeError as exc:
152
+ raise RunnerError("broken_pipe", str(exc), 141) from exc
153
+ except OSError as exc:
154
+ raise RunnerError("pty", str(exc)) from exc
155
+ finally:
156
+ try:
157
+ if process is not None:
158
+ if process.poll() is None or not completed:
159
+ send(signal.SIGKILL, initial=True)
160
+ try:
161
+ process.wait(timeout=2)
162
+ except subprocess.TimeoutExpired as exc:
163
+ raise RunnerError("cleanup", "PTY child did not exit after kill") from exc
164
+ finally:
165
+ try:
166
+ termios.tcsetattr(input_fd, termios.TCSANOW, saved)
167
+ finally:
168
+ for number, handler in previous.items():
169
+ signal.signal(number, handler)
170
+ for fd in (master, slave, error_read, error_write):
171
+ if fd >= 0:
172
+ os.close(fd)
errlens/py.typed ADDED
File without changes
errlens/render.py ADDED
@@ -0,0 +1,96 @@
1
+ """Render ErrLens-owned text in memory; child bytes never pass through Rich."""
2
+
3
+ import io
4
+ import os
5
+ import shlex
6
+ import unicodedata
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from errlens.i18n import Translator
13
+ from errlens.models import ExplanationReport, Parameter
14
+
15
+
16
+ def safe_text(value: object, limit: int = 2048) -> str:
17
+ """Escape control/bidi characters, retaining literal Rich markup and Unicode."""
18
+ raw = str(value)
19
+ clipped = raw[:limit]
20
+ text = "".join(
21
+ (f"\\u{ord(character):04x}" if ord(character) > 255 else f"\\x{ord(character):02x}")
22
+ if unicodedata.category(character).startswith("C") else character
23
+ for character in clipped
24
+ )
25
+ return text + ("…" if len(raw) > limit else "")
26
+
27
+
28
+ def format_command(argv: list[str], *, windows: bool) -> str:
29
+ """Quote display-only argv for POSIX shell or PowerShell (never cmd.exe)."""
30
+ if windows:
31
+ return "& " + " ".join("'" + part.replace("'", "''") + "'" for part in argv)
32
+ return shlex.join(argv)
33
+
34
+
35
+ def render_report(
36
+ report: ExplanationReport, translator: Translator, *, terminal: bool,
37
+ width: int = 80, color: bool = True, windows: bool | None = None,
38
+ ) -> str:
39
+ """Stage the entire block before writing; no partial card on build failure."""
40
+ explanation, diagnostic = report.explanation, report.diagnostic
41
+ parameters: dict[str, Parameter] = {
42
+ key: safe_text(value) for key, value in explanation.parameters.items()
43
+ }
44
+
45
+ def translated(key: str) -> str:
46
+ return translator.translate(key, **parameters)
47
+
48
+ body = Text(overflow="fold")
49
+ body.append(translated(explanation.message_ids["title"]) + "\n", style="bold")
50
+ body.append(translated(explanation.message_ids["description"]) + "\n")
51
+ if "detail" in explanation.message_ids:
52
+ body.append(translated(explanation.message_ids["detail"]) + "\n")
53
+ if diagnostic.original_message:
54
+ body.append(translator.translate("render.original", message=safe_text(diagnostic.original_message)) + "\n")
55
+ if diagnostic.file_path is not None:
56
+ body.append(translator.translate("render.file", path=safe_text(diagnostic.file_path)) + "\n")
57
+ if diagnostic.line_number is not None:
58
+ body.append(translator.translate("render.line", line=diagnostic.line_number) + "\n")
59
+ if diagnostic.incomplete:
60
+ body.append(translator.translate("render.incomplete") + "\n", style="bold")
61
+ for cause in explanation.causes:
62
+ body.append(translator.translate("render.possible") + " " + translated(cause) + "\n")
63
+ body.append("\n" + translator.translate("render.actions") + "\n", style="bold")
64
+ for action in explanation.actions:
65
+ body.append("• " + translated(action) + "\n")
66
+ for condition in explanation.conditions:
67
+ body.append(translator.translate("render.condition") + " " + translated(condition) + "\n")
68
+ for command in explanation.commands:
69
+ shell = "PowerShell" if (os.name == "nt" if windows is None else windows) else "POSIX shell"
70
+ body.append("\n" + translator.translate("render.command", shell=shell) + "\n", style="bold")
71
+ body.append(safe_text(format_command(command.argv, windows=shell == "PowerShell"), limit=8192) + "\n")
72
+ body.append(translated(command.condition_id) + "\n")
73
+ if report.related:
74
+ body.append("\n" + translator.translate("render.related") + "\n", style="bold")
75
+ for predecessor in report.related[:5]:
76
+ body.append("• " + safe_text(predecessor.error_type) + ": "
77
+ + safe_text(predecessor.original_message, limit=256) + "\n")
78
+ if len(report.related) > 5:
79
+ body.append(translator.translate("render.more", count=len(report.related) - 5) + "\n")
80
+ if diagnostic.chain_relation in {"context", "cause"}:
81
+ body.append(translator.translate("render.chain." + diagnostic.chain_relation) + "\n")
82
+ if report.other_count:
83
+ body.append(translator.translate("render.other", count=report.other_count) + "\n")
84
+ output = io.StringIO()
85
+ console = Console(file=output, force_terminal=terminal, force_jupyter=False,
86
+ color_system="auto" if terminal and color else None,
87
+ width=max(12, width), height=25, legacy_windows=False,
88
+ highlight=False, markup=False)
89
+ title = Text(translator.translate("render.header"), style="bold")
90
+ console.print()
91
+ if terminal and width >= 30:
92
+ console.print(Panel(body, title=title, title_align="left", border_style="blue", padding=(0, 1)))
93
+ else:
94
+ console.print(title)
95
+ console.print(body)
96
+ return output.getvalue()
@@ -0,0 +1 @@
1
+ """Rules will map diagnostic facts to locale-independent explanations."""
@@ -0,0 +1,84 @@
1
+ """Locale-independent explanation rules. Commands are display-only fixed argv."""
2
+
3
+ import os
4
+ import re
5
+ from types import MappingProxyType
6
+ from typing import Mapping
7
+ import unicodedata
8
+
9
+ from errlens.models import Diagnostic, ExecutionContext, Explanation, SuggestedCommand
10
+
11
+ RULE_IDS: Mapping[str, str] = MappingProxyType({
12
+ "ModuleNotFoundError": "python.module_not_found",
13
+ "ImportError": "python.import_error",
14
+ "SyntaxError": "python.syntax_error",
15
+ "IndentationError": "python.indentation_error",
16
+ "TabError": "python.tab_error",
17
+ "NameError": "python.name_error",
18
+ "TypeError": "python.type_error",
19
+ "AttributeError": "python.attribute_error",
20
+ "KeyError": "python.key_error",
21
+ "FileNotFoundError": "python.file_not_found",
22
+ "PermissionError": "python.permission_error",
23
+ })
24
+
25
+
26
+ def rule_id_for(error_type: str) -> str | None:
27
+ """Unknown/custom types have no rule; do not guess their inheritance."""
28
+ return RULE_IDS.get(error_type)
29
+
30
+
31
+ # Exact imports only. Do not infer distribution names or strip module suffixes.
32
+ _PACKAGES = MappingProxyType({"requests": "requests"})
33
+ _PYTHON_EXECUTABLE = re.compile(r"(?:pythonw?|python[0-9]+(?:\.[0-9]+)*|pypy[0-9]*)", re.I)
34
+
35
+
36
+ def _known_interpreter(context: ExecutionContext) -> str | None:
37
+ executable = context.command[0]
38
+ name = os.path.basename(executable)
39
+ if name.lower().endswith(".exe"):
40
+ name = name[:-4]
41
+ if len(executable) > 1024 or not os.path.isabs(executable) or not _PYTHON_EXECUTABLE.fullmatch(name):
42
+ return None
43
+ if any(unicodedata.category(character).startswith("C") for character in executable):
44
+ return None
45
+ # Avoid promising the same import environment with isolation/site flags.
46
+ if len(context.command) > 1 and context.command[1].startswith("-"):
47
+ if context.command[1] not in {"-c", "-m"}:
48
+ return None
49
+ return executable
50
+
51
+
52
+ def explain(diagnostic: Diagnostic, context: ExecutionContext) -> Explanation:
53
+ """Explain known facts; unknown/incomplete types receive neutral notices."""
54
+ if diagnostic.incomplete:
55
+ rule = "python.incomplete"
56
+ elif diagnostic.complex_structure or diagnostic.error_type in {"ExceptionGroup", "BaseExceptionGroup"}:
57
+ rule = "python.exception_group"
58
+ else:
59
+ rule = rule_id_for(diagnostic.error_type) or "python.unknown"
60
+ explanation = Explanation(
61
+ rule, {"title": f"{rule}.title", "description": f"{rule}.description"},
62
+ parameters={**diagnostic.parameters, "error_type": diagnostic.error_type},
63
+ actions=[f"{rule}.action"], conditions=[f"{rule}.condition"],
64
+ )
65
+ # The possible-cause sentence is explicitly hypothetical, never a diagnosis.
66
+ if rule in RULE_IDS.values():
67
+ explanation.causes.append(f"{rule}.cause")
68
+ detail_fields = {
69
+ "ModuleNotFoundError": "module_name", "ImportError": "import_name",
70
+ "NameError": "name", "AttributeError": "attribute_name", "KeyError": "key_repr",
71
+ "FileNotFoundError": "file_path", "PermissionError": "file_path",
72
+ }
73
+ detail = detail_fields.get(diagnostic.error_type)
74
+ if detail and detail in diagnostic.parameters and not diagnostic.incomplete:
75
+ explanation.message_ids["detail"] = f"detail.{detail}"
76
+ module = diagnostic.parameters.get("module_name")
77
+ interpreter = _known_interpreter(context)
78
+ if (rule == "python.module_not_found" and isinstance(module, str)
79
+ and module in _PACKAGES and interpreter is not None):
80
+ explanation.commands.append(SuggestedCommand(
81
+ [interpreter, "-m", "pip", "install", _PACKAGES[module]],
82
+ "command.install_condition",
83
+ ))
84
+ return explanation