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/__init__.py +7 -0
- errlens/__main__.py +6 -0
- errlens/_conpty.py +236 -0
- errlens/_conpty_worker.py +158 -0
- errlens/_pty_child.py +25 -0
- errlens/analyzers/__init__.py +1 -0
- errlens/analyzers/base.py +45 -0
- errlens/analyzers/python.py +362 -0
- errlens/capture.py +92 -0
- errlens/cli.py +177 -0
- errlens/conpty_runner.py +269 -0
- errlens/i18n.py +61 -0
- errlens/locales/en.json +96 -0
- errlens/locales/ru.json +96 -0
- errlens/models.py +120 -0
- errlens/posix_shell.py +71 -0
- errlens/powershell.py +77 -0
- errlens/pty_runner.py +172 -0
- errlens/py.typed +0 -0
- errlens/render.py +96 -0
- errlens/rules/__init__.py +1 -0
- errlens/rules/python.py +84 -0
- errlens/runner.py +241 -0
- errlens/service.py +58 -0
- errlens_cli-0.1.0.dist-info/METADATA +233 -0
- errlens_cli-0.1.0.dist-info/RECORD +30 -0
- errlens_cli-0.1.0.dist-info/WHEEL +5 -0
- errlens_cli-0.1.0.dist-info/entry_points.txt +2 -0
- errlens_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- errlens_cli-0.1.0.dist-info/top_level.txt +1 -0
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."""
|
errlens/rules/python.py
ADDED
|
@@ -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
|