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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Conservative parser for CPython's text traceback format, without I/O.
|
|
2
|
+
|
|
3
|
+
This recognizes diagnostic *text*, not proof of an unhandled exception. A caller
|
|
4
|
+
can distinguish a failed execution using Diagnostic.execution_failed. No source
|
|
5
|
+
files, imports, commands, translations or explanation rules are consulted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
from errlens.capture import CapturedData
|
|
14
|
+
from errlens.models import Diagnostic, ExecutionContext, ExecutionResult, Parameter, StackFrame
|
|
15
|
+
|
|
16
|
+
_HEADER = "Traceback (most recent call last):"
|
|
17
|
+
_GROUP_HEADER = "Exception Group Traceback (most recent call last):"
|
|
18
|
+
_CHAINS = {
|
|
19
|
+
"During handling of the above exception, another exception occurred:": "context",
|
|
20
|
+
"The above exception was the direct cause of the following exception:": "cause",
|
|
21
|
+
}
|
|
22
|
+
_SYNTAX = {"SyntaxError", "IndentationError", "TabError"}
|
|
23
|
+
_FRAME = re.compile(r'^ File "(?P<path>.+)", line (?P<line>[0-9]+)(?:, in (?P<function>.+))?$')
|
|
24
|
+
_NO_LINE_FRAME = re.compile(r'^ File "(?P<path>.+)"(?:, in (?P<function>.+))?$')
|
|
25
|
+
_TYPE = r"[^\W\d]\w*(?:\.[^\W\d]\w*)*"
|
|
26
|
+
_EXCEPTION = re.compile(rf"^(?P<type>{_TYPE})(?::(?: (?P<message>.*))?)?$")
|
|
27
|
+
_PYTHON_COMMAND = re.compile(r"(?:py|python(?:w)?(?:[0-9]+(?:\.[0-9]+)*)?|pypy(?:[0-9]+)?)", re.I)
|
|
28
|
+
_ANSI = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_]")
|
|
29
|
+
_HORIZONTAL = re.compile(r"\x1b\[([0-9]*)([CDGXK])")
|
|
30
|
+
_COPY_LIMIT = 1024 * 1024
|
|
31
|
+
_COLUMN_LIMIT = 4096
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _diagnostic_text(text: str) -> tuple[str, bool]:
|
|
35
|
+
"""Bounded line-local VT handling, never used by output forwarding.
|
|
36
|
+
|
|
37
|
+
Track columns for CUF/CUB/CHA, ECH/EL, CR, BS and tabs. Other ANSI is
|
|
38
|
+
stripped as before; this is not a screen emulator or a wrapping engine.
|
|
39
|
+
"""
|
|
40
|
+
limited = len(text) > _COPY_LIMIT
|
|
41
|
+
text = text[:_COPY_LIMIT]
|
|
42
|
+
if not _HORIZONTAL.search(text):
|
|
43
|
+
return _ANSI.sub("", text), limited
|
|
44
|
+
parts: list[str] = []
|
|
45
|
+
line: list[str] = []
|
|
46
|
+
column = total = 0
|
|
47
|
+
|
|
48
|
+
def emit(ending: str) -> None:
|
|
49
|
+
nonlocal line, total, limited
|
|
50
|
+
value = "".join(line) + ending
|
|
51
|
+
room = _COPY_LIMIT - total
|
|
52
|
+
parts.append(value[:room])
|
|
53
|
+
limited |= len(value) > room
|
|
54
|
+
total += min(len(value), room)
|
|
55
|
+
line = []
|
|
56
|
+
|
|
57
|
+
def position(target: int) -> int:
|
|
58
|
+
nonlocal limited
|
|
59
|
+
limited |= target > _COLUMN_LIMIT
|
|
60
|
+
return min(target, _COLUMN_LIMIT)
|
|
61
|
+
|
|
62
|
+
def literal(value: str) -> None:
|
|
63
|
+
nonlocal column, limited
|
|
64
|
+
for char in value:
|
|
65
|
+
if char == "\n":
|
|
66
|
+
emit("\n")
|
|
67
|
+
column = 0
|
|
68
|
+
elif char == "\r":
|
|
69
|
+
column = 0
|
|
70
|
+
elif char == "\b":
|
|
71
|
+
column = max(0, column - 1)
|
|
72
|
+
elif char == "\t":
|
|
73
|
+
column = position((column // 8 + 1) * 8)
|
|
74
|
+
else:
|
|
75
|
+
width = 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
|
|
76
|
+
if not width:
|
|
77
|
+
if column and column <= len(line) and len(line[column - 1]) < 32:
|
|
78
|
+
line[column - 1] += char
|
|
79
|
+
else:
|
|
80
|
+
# No base cell, or too many marks: do not insert at the
|
|
81
|
+
# beginning repeatedly (quadratic work on hostile input).
|
|
82
|
+
limited = True
|
|
83
|
+
elif column + width <= _COLUMN_LIMIT:
|
|
84
|
+
if len(line) < column + width:
|
|
85
|
+
line.extend([" "] * (column + width - len(line)))
|
|
86
|
+
line[column:column + width] = [char] + [""] * (width - 1)
|
|
87
|
+
column += width
|
|
88
|
+
else:
|
|
89
|
+
limited = True
|
|
90
|
+
|
|
91
|
+
offset = 0
|
|
92
|
+
for match in _ANSI.finditer(text):
|
|
93
|
+
literal(text[offset:match.start()])
|
|
94
|
+
horizontal = _HORIZONTAL.fullmatch(match[0])
|
|
95
|
+
if horizontal:
|
|
96
|
+
raw, command = horizontal.groups()
|
|
97
|
+
# Never convert arbitrary-length untrusted decimal parameters.
|
|
98
|
+
digits = raw.lstrip("0")
|
|
99
|
+
number = int(digits) if len(digits) <= 4 and digits else 0
|
|
100
|
+
if len(digits) > 4:
|
|
101
|
+
number = _COLUMN_LIMIT + 1
|
|
102
|
+
limited = True
|
|
103
|
+
if command in "CDGX":
|
|
104
|
+
number = number or 1
|
|
105
|
+
if command == "C":
|
|
106
|
+
column = position(column + number)
|
|
107
|
+
elif command == "D":
|
|
108
|
+
column = max(0, column - number)
|
|
109
|
+
elif command == "G":
|
|
110
|
+
column = position(number - 1)
|
|
111
|
+
elif command == "X":
|
|
112
|
+
end = position(column + number)
|
|
113
|
+
# Erasing does not advance the cursor or create trailing text.
|
|
114
|
+
line[column:min(end, len(line))] = [" "] * max(0, min(end, len(line)) - column)
|
|
115
|
+
elif number == 0: # EL from cursor to end
|
|
116
|
+
del line[column:]
|
|
117
|
+
elif number == 1:
|
|
118
|
+
end = min(column + 1, len(line))
|
|
119
|
+
line[:end] = [" "] * end
|
|
120
|
+
elif number == 2:
|
|
121
|
+
line = []
|
|
122
|
+
offset = match.end()
|
|
123
|
+
if total >= _COPY_LIMIT:
|
|
124
|
+
limited = True
|
|
125
|
+
break
|
|
126
|
+
else:
|
|
127
|
+
literal(text[offset:])
|
|
128
|
+
emit("")
|
|
129
|
+
return "".join(parts), limited
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _python_context(context: ExecutionContext) -> bool:
|
|
133
|
+
name = context.command[0].replace("\\", "/").rsplit("/", 1)[-1]
|
|
134
|
+
if name.lower().endswith(".exe"):
|
|
135
|
+
name = name[:-4]
|
|
136
|
+
return bool(_PYTHON_COMMAND.fullmatch(name)) or context.environment.get("language") == "python"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _frame(line: str) -> StackFrame | None:
|
|
140
|
+
match = _FRAME.fullmatch(line)
|
|
141
|
+
if match:
|
|
142
|
+
# Refuse oversized or invalid line numbers rather than raising on input.
|
|
143
|
+
number = match["line"]
|
|
144
|
+
if len(number) > 9 or int(number) < 1:
|
|
145
|
+
return None
|
|
146
|
+
return StackFrame(match["path"], int(number), match["function"])
|
|
147
|
+
match = _NO_LINE_FRAME.fullmatch(line)
|
|
148
|
+
if match:
|
|
149
|
+
return StackFrame(match["path"], function=match["function"])
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parameters(error_type: str, message: str) -> dict[str, Parameter]:
|
|
154
|
+
"""Extract only explicit scalar values; never infer a package or a fix."""
|
|
155
|
+
parameters: dict[str, Parameter] = {}
|
|
156
|
+
patterns = {
|
|
157
|
+
"ModuleNotFoundError": (r"^No module named '([^']+)'(?:; .*)?$", ("module_name",)),
|
|
158
|
+
"ImportError": (r"^cannot import name '([^']+)'(?: from .*)?$", ("import_name",)),
|
|
159
|
+
"NameError": (r"^name '([^']+)' is not defined(?:\..*)?$", ("name",)),
|
|
160
|
+
"AttributeError": (r"^'([^']+)' object has no attribute '([^']+)'(?:\..*)?$",
|
|
161
|
+
("object_type", "attribute_name")),
|
|
162
|
+
}
|
|
163
|
+
base_type = error_type.rsplit(".", 1)[-1]
|
|
164
|
+
if base_type in patterns:
|
|
165
|
+
pattern, names = patterns[base_type]
|
|
166
|
+
match = re.fullmatch(pattern, message.split("\n", 1)[0])
|
|
167
|
+
if match:
|
|
168
|
+
parameters.update(zip(names, match.groups()))
|
|
169
|
+
if base_type == "AttributeError" and not parameters:
|
|
170
|
+
match = re.fullmatch(r"module '([^']+)' has no attribute '([^']+)'(?:\..*)?", message)
|
|
171
|
+
if match:
|
|
172
|
+
parameters.update(module_name=match[1], attribute_name=match[2])
|
|
173
|
+
else:
|
|
174
|
+
match = re.fullmatch(r"type object '([^']+)' has no attribute '([^']+)'(?:\..*)?", message)
|
|
175
|
+
if match:
|
|
176
|
+
parameters.update(object_type=match[1], attribute_name=match[2])
|
|
177
|
+
if base_type == "KeyError":
|
|
178
|
+
parameters["key_repr"] = message
|
|
179
|
+
# Literal evaluation is limited and scalar-only, not arbitrary execution.
|
|
180
|
+
if len(message) <= 4096:
|
|
181
|
+
try:
|
|
182
|
+
value = ast.literal_eval(message)
|
|
183
|
+
except (ValueError, SyntaxError, RecursionError, MemoryError):
|
|
184
|
+
pass
|
|
185
|
+
else:
|
|
186
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
187
|
+
parameters["key"] = value
|
|
188
|
+
if base_type in {"FileNotFoundError", "PermissionError"}:
|
|
189
|
+
match = re.fullmatch(r"\[Errno ([0-9]+)\] [^\n]*?: (.+)", message)
|
|
190
|
+
if match:
|
|
191
|
+
parameters["errno"] = int(match[1]) if len(match[1]) < 10 else None
|
|
192
|
+
# A rename error can contain two repr paths. Do not pick one as fact.
|
|
193
|
+
path_repr = match[2]
|
|
194
|
+
if len(path_repr) <= 4096:
|
|
195
|
+
try:
|
|
196
|
+
path = ast.literal_eval(path_repr)
|
|
197
|
+
except (ValueError, SyntaxError, RecursionError, MemoryError):
|
|
198
|
+
pass
|
|
199
|
+
else:
|
|
200
|
+
if isinstance(path, str):
|
|
201
|
+
parameters["file_path"] = path
|
|
202
|
+
return parameters
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class _Block:
|
|
207
|
+
has_header: bool
|
|
208
|
+
complex_structure: bool = False
|
|
209
|
+
frames: list[StackFrame] = field(default_factory=list)
|
|
210
|
+
error_type: str | None = None
|
|
211
|
+
messages: list[str] = field(default_factory=list)
|
|
212
|
+
caret_column: int | None = None
|
|
213
|
+
caret_end_column: int | None = None
|
|
214
|
+
damaged: bool = False
|
|
215
|
+
relation: str | None = None
|
|
216
|
+
related_id: str | None = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class PythonAnalyzer:
|
|
220
|
+
"""Recognize strong stderr structure; stdout also requires failed Python context.
|
|
221
|
+
|
|
222
|
+
Bare 'SomeError: text' lines are deliberately insufficient. Headerless tail
|
|
223
|
+
recovery requires known truncation and Python context, except compiler-style
|
|
224
|
+
syntax diagnostics which naturally have no traceback header.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
analyzer_id = "python"
|
|
228
|
+
|
|
229
|
+
def analyze(
|
|
230
|
+
self, context: ExecutionContext, result: ExecutionResult, data: CapturedData,
|
|
231
|
+
) -> list[Diagnostic]:
|
|
232
|
+
python = _python_context(context)
|
|
233
|
+
failed = result.exit_code != 0 if result.exit_code is not None else None
|
|
234
|
+
diagnostics: list[Diagnostic] = []
|
|
235
|
+
# Preserve stream-local order. There is no reliable order across streams.
|
|
236
|
+
sources = [("stderr", data.stderr, data.stderr_truncated),
|
|
237
|
+
("stdout", data.stdout, data.stdout_truncated)]
|
|
238
|
+
if data.combined is not None:
|
|
239
|
+
sources.append(("combined", data.combined, data.combined_truncated))
|
|
240
|
+
for source, text, truncated in sources:
|
|
241
|
+
if source != "stderr" and not (python and failed and not result.interrupted):
|
|
242
|
+
continue
|
|
243
|
+
diagnostics.extend(self._parse(text, source, python, failed,
|
|
244
|
+
truncated or not result.output_complete))
|
|
245
|
+
return diagnostics
|
|
246
|
+
|
|
247
|
+
def _parse(
|
|
248
|
+
self, text: str, source: str, python: bool, failed: bool | None, truncated: bool,
|
|
249
|
+
) -> list[Diagnostic]:
|
|
250
|
+
diagnostics: list[Diagnostic] = []
|
|
251
|
+
block: _Block | None = None
|
|
252
|
+
relation: str | None = None
|
|
253
|
+
related_id: str | None = None
|
|
254
|
+
in_group_tree = False
|
|
255
|
+
group_diagnostic: Diagnostic | None = None
|
|
256
|
+
# A terminal may append VT mode/cursor resets after the final newline.
|
|
257
|
+
# Determine completeness from diagnostic text, not transport controls.
|
|
258
|
+
text, limited = _diagnostic_text(text)
|
|
259
|
+
truncated |= limited
|
|
260
|
+
unterminated = bool(text) and not text.endswith(("\n", "\r"))
|
|
261
|
+
|
|
262
|
+
def finish() -> None:
|
|
263
|
+
nonlocal block
|
|
264
|
+
if block is None:
|
|
265
|
+
return
|
|
266
|
+
base = (block.error_type or "").rsplit(".", 1)[-1]
|
|
267
|
+
syntax = base in _SYNTAX
|
|
268
|
+
supported_structure = block.has_header or (python and (syntax or truncated))
|
|
269
|
+
if block.frames and supported_structure:
|
|
270
|
+
message = "\n".join(block.messages).rstrip("\n")
|
|
271
|
+
error_type = block.error_type or "UnknownPythonException"
|
|
272
|
+
parameters = _parameters(error_type, message)
|
|
273
|
+
if syntax and block.caret_column is not None:
|
|
274
|
+
parameters["caret_column"] = block.caret_column
|
|
275
|
+
parameters["caret_end_column"] = block.caret_end_column
|
|
276
|
+
location = block.frames[-1]
|
|
277
|
+
diagnostics.append(Diagnostic(
|
|
278
|
+
diagnostic_id=f"python.{source}.{len(diagnostics) + 1}",
|
|
279
|
+
error_type=error_type, original_message=message,
|
|
280
|
+
file_path=location.file_path, line_number=location.line_number,
|
|
281
|
+
parameters=parameters,
|
|
282
|
+
incomplete=(truncated or unterminated or block.damaged or block.error_type is None
|
|
283
|
+
or (not block.has_header and not syntax)),
|
|
284
|
+
frames=block.frames, source=source, chain_relation=block.relation,
|
|
285
|
+
related_diagnostic_id=block.related_id,
|
|
286
|
+
complex_structure=block.complex_structure, execution_failed=failed,
|
|
287
|
+
))
|
|
288
|
+
block = None
|
|
289
|
+
|
|
290
|
+
# splitlines/ANSI work only on the bounded diagnostic copy from Capture.
|
|
291
|
+
for line in text.splitlines():
|
|
292
|
+
group_header = line.strip().lstrip("+ ") == _GROUP_HEADER
|
|
293
|
+
if line == _HEADER or group_header:
|
|
294
|
+
finish()
|
|
295
|
+
block = _Block(True, group_header, relation=relation, related_id=related_id)
|
|
296
|
+
relation = related_id = None
|
|
297
|
+
in_group_tree = False
|
|
298
|
+
continue
|
|
299
|
+
if line in _CHAINS:
|
|
300
|
+
finish()
|
|
301
|
+
relation = _CHAINS[line] if diagnostics else None
|
|
302
|
+
related_id = diagnostics[-1].diagnostic_id if diagnostics else None
|
|
303
|
+
in_group_tree = False
|
|
304
|
+
continue
|
|
305
|
+
if in_group_tree:
|
|
306
|
+
# Never misreport nested frames as independent top-level failures.
|
|
307
|
+
if re.fullmatch(r" \+-{3,}", line) and group_diagnostic is not None:
|
|
308
|
+
group_diagnostic.incomplete = truncated or unterminated
|
|
309
|
+
group_diagnostic = None
|
|
310
|
+
if line.startswith(" ") or not line:
|
|
311
|
+
continue
|
|
312
|
+
in_group_tree = False
|
|
313
|
+
if block is not None and block.complex_structure and line.startswith(" | "):
|
|
314
|
+
line = line[4:]
|
|
315
|
+
frame = _frame(line)
|
|
316
|
+
if frame is not None:
|
|
317
|
+
if block is not None and block.error_type is not None:
|
|
318
|
+
finish()
|
|
319
|
+
if block is None:
|
|
320
|
+
block = _Block(False, relation=relation, related_id=related_id)
|
|
321
|
+
relation = related_id = None
|
|
322
|
+
if "\ufffd" in line or "\x1b" in line or "\x00" in line:
|
|
323
|
+
block.damaged = True
|
|
324
|
+
block.frames.append(frame)
|
|
325
|
+
block.caret_column = block.caret_end_column = None
|
|
326
|
+
continue
|
|
327
|
+
if block is None:
|
|
328
|
+
if line.strip():
|
|
329
|
+
relation = related_id = None
|
|
330
|
+
continue
|
|
331
|
+
if "\ufffd" in line or "\x1b" in line or "\x00" in line:
|
|
332
|
+
block.damaged = True
|
|
333
|
+
if block.error_type is not None:
|
|
334
|
+
block.messages.append(line)
|
|
335
|
+
continue
|
|
336
|
+
match = _EXCEPTION.fullmatch(line)
|
|
337
|
+
if match and block.frames:
|
|
338
|
+
block.error_type = match["type"]
|
|
339
|
+
block.messages.append(match["message"] or "")
|
|
340
|
+
if block.complex_structure:
|
|
341
|
+
damaged = block.damaged
|
|
342
|
+
count = len(diagnostics)
|
|
343
|
+
finish()
|
|
344
|
+
if len(diagnostics) > count:
|
|
345
|
+
group_diagnostic = diagnostics[-1]
|
|
346
|
+
# A missing outer closing border indicates an unfinished tree.
|
|
347
|
+
group_diagnostic.incomplete = True
|
|
348
|
+
if damaged:
|
|
349
|
+
group_diagnostic = None # closing border cannot repair damaged text
|
|
350
|
+
in_group_tree = True
|
|
351
|
+
continue
|
|
352
|
+
if line.startswith(" ") and "^" in line and set(line.strip()) <= set("^~ \t"):
|
|
353
|
+
# One-based column in the *displayed* source, not SyntaxError.offset.
|
|
354
|
+
block.caret_column = line.index("^") - 4 + 1
|
|
355
|
+
block.caret_end_column = line.rindex("^") - 4 + 2
|
|
356
|
+
elif line.startswith(" File "):
|
|
357
|
+
block.damaged = True
|
|
358
|
+
elif line and not line.startswith(" "):
|
|
359
|
+
# Unstructured text cannot complete an unfinished traceback.
|
|
360
|
+
block.damaged = True
|
|
361
|
+
finish()
|
|
362
|
+
return diagnostics
|
errlens/capture.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Bounded byte tails, decoded only when diagnostic text is requested."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CapturedData:
|
|
9
|
+
"""Decoded diagnostic text, separate from future raw output forwarding.
|
|
10
|
+
|
|
11
|
+
A terminal adapter may only provide combined output; it must not invent
|
|
12
|
+
stdout/stderr separation. Completeness lives in ExecutionResult.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
stdout: str = ""
|
|
16
|
+
stderr: str = ""
|
|
17
|
+
combined: str | None = None
|
|
18
|
+
stdout_truncated: bool = False
|
|
19
|
+
stderr_truncated: bool = False
|
|
20
|
+
combined_truncated: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TailBuffer:
|
|
24
|
+
"""Retain at most limit bytes, independently of line length or chunk count.
|
|
25
|
+
|
|
26
|
+
Feed from one reader only; inspect after that reader has finished.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, limit: int = 1024 * 1024) -> None:
|
|
30
|
+
if limit < 0:
|
|
31
|
+
raise ValueError("Buffer limit must not be negative")
|
|
32
|
+
self.limit = limit
|
|
33
|
+
self._data = bytearray()
|
|
34
|
+
self.truncated = False
|
|
35
|
+
|
|
36
|
+
def append(self, data: bytes) -> None:
|
|
37
|
+
if len(self._data) + len(data) > self.limit:
|
|
38
|
+
self.truncated = True
|
|
39
|
+
if self.limit == 0:
|
|
40
|
+
return
|
|
41
|
+
if len(data) >= self.limit:
|
|
42
|
+
self._data[:] = data[-self.limit:]
|
|
43
|
+
else:
|
|
44
|
+
excess = max(0, len(self._data) + len(data) - self.limit)
|
|
45
|
+
del self._data[:excess]
|
|
46
|
+
self._data.extend(data)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def data(self) -> bytes:
|
|
50
|
+
return bytes(self._data)
|
|
51
|
+
|
|
52
|
+
def text(self, encoding: str = "utf-8") -> str:
|
|
53
|
+
"""Chunk boundaries disappear before decoding; cut/invalid bytes replace.
|
|
54
|
+
|
|
55
|
+
The caller may supply another encoding. No universal detection is assumed.
|
|
56
|
+
"""
|
|
57
|
+
return self._data.decode(encoding, errors="replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Capture:
|
|
61
|
+
"""Independent 1 MiB tails by default; never stores full logs on disk."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, max_bytes: int = 1024 * 1024) -> None:
|
|
64
|
+
self.stdout = TailBuffer(max_bytes)
|
|
65
|
+
self.stderr = TailBuffer(max_bytes)
|
|
66
|
+
|
|
67
|
+
def append(self, stream: Literal["stdout", "stderr"], data: bytes) -> None:
|
|
68
|
+
getattr(self, stream).append(data)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def truncated(self) -> bool:
|
|
72
|
+
return self.stdout.truncated or self.stderr.truncated
|
|
73
|
+
|
|
74
|
+
def decoded(self, encoding: str = "utf-8") -> CapturedData:
|
|
75
|
+
return CapturedData(
|
|
76
|
+
self.stdout.text(encoding), self.stderr.text(encoding),
|
|
77
|
+
stdout_truncated=self.stdout.truncated, stderr_truncated=self.stderr.truncated,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TerminalCapture:
|
|
82
|
+
"""One bounded terminal stream, never represented as separate stderr."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, max_bytes: int = 1024 * 1024) -> None:
|
|
85
|
+
self.combined = TailBuffer(max_bytes)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def truncated(self) -> bool:
|
|
89
|
+
return self.combined.truncated
|
|
90
|
+
|
|
91
|
+
def decoded(self, encoding: str = "utf-8") -> CapturedData:
|
|
92
|
+
return CapturedData(combined=self.combined.text(encoding), combined_truncated=self.truncated)
|
errlens/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""CLI coordination; child output bypasses Rich and translation entirely."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from errlens import __version__
|
|
13
|
+
from errlens.i18n import Translator
|
|
14
|
+
from errlens.i18n import SUPPORTED_LOCALES
|
|
15
|
+
from errlens.capture import Capture, TerminalCapture
|
|
16
|
+
from errlens.models import ExecutionContext
|
|
17
|
+
from errlens.runner import RunnerError, run as run_command
|
|
18
|
+
from errlens.render import render_report, safe_text
|
|
19
|
+
from errlens.service import explain_capture
|
|
20
|
+
|
|
21
|
+
_translator = Translator()
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
help=_translator.translate("cli.description"),
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
shell_app = typer.Typer(help="Manage opt-in shell integration.")
|
|
28
|
+
app.add_typer(shell_app, name="shell")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main_entry() -> None:
|
|
32
|
+
"""Use UTF-8 for our Windows text output before Typer renders help.
|
|
33
|
+
|
|
34
|
+
Native child streams are forwarded through the underlying binary buffers,
|
|
35
|
+
so changing the text wrappers does not re-encode the child's output.
|
|
36
|
+
"""
|
|
37
|
+
if os.name == "nt":
|
|
38
|
+
for stream in (sys.stdout, sys.stderr):
|
|
39
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
40
|
+
if reconfigure is not None:
|
|
41
|
+
try:
|
|
42
|
+
reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
# Some embedded hosts expose streams that cannot be reconfigured.
|
|
45
|
+
pass
|
|
46
|
+
app()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shell_action(action: str, shell: str, profile: Path | None) -> None:
|
|
50
|
+
from errlens.powershell import default_profile, manage
|
|
51
|
+
if shell not in {"powershell", "bash", "zsh"}:
|
|
52
|
+
raise typer.BadParameter("Choose powershell, bash or zsh.")
|
|
53
|
+
try:
|
|
54
|
+
if shell == "powershell":
|
|
55
|
+
typer.echo(manage(action, profile if profile is not None else default_profile()))
|
|
56
|
+
else:
|
|
57
|
+
from errlens import posix_shell
|
|
58
|
+
typer.echo(posix_shell.manage(action, shell, profile if profile is not None else posix_shell.default_profile(shell)))
|
|
59
|
+
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
60
|
+
typer.echo(f"ErrLens: {exc}", err=True)
|
|
61
|
+
raise typer.Exit(1) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@shell_app.command("install")
|
|
65
|
+
def shell_install(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
|
|
66
|
+
_shell_action("install", shell, profile)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@shell_app.command("uninstall")
|
|
70
|
+
def shell_uninstall(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
|
|
71
|
+
_shell_action("uninstall", shell, profile)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@shell_app.command("status")
|
|
75
|
+
def shell_status(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
|
|
76
|
+
_shell_action("status", shell, profile)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _show_version(value: bool) -> None:
|
|
80
|
+
if value:
|
|
81
|
+
typer.echo(_translator.translate("cli.version", version=__version__))
|
|
82
|
+
raise typer.Exit()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _language(value: str) -> str:
|
|
86
|
+
if value not in SUPPORTED_LOCALES:
|
|
87
|
+
raise typer.BadParameter("Choose ru or en.")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _silence_failed_streams() -> None:
|
|
92
|
+
"""Prevent shutdown flush from replacing a child's status after pipe failure."""
|
|
93
|
+
for stream in (sys.stdout, sys.stderr):
|
|
94
|
+
try:
|
|
95
|
+
stream.flush()
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
try:
|
|
98
|
+
with open(os.devnull, "wb") as sink:
|
|
99
|
+
os.dup2(sink.fileno(), stream.fileno())
|
|
100
|
+
except (OSError, ValueError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.callback()
|
|
105
|
+
def main(
|
|
106
|
+
version: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option("--version", callback=_show_version, is_eager=True, help="Show version."),
|
|
109
|
+
] = False,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Configure ErrLens."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command(
|
|
115
|
+
help=_translator.translate("cli.run_help"),
|
|
116
|
+
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
|
|
117
|
+
)
|
|
118
|
+
def run(
|
|
119
|
+
command: Annotated[
|
|
120
|
+
list[str],
|
|
121
|
+
typer.Argument(help="Executable followed by its arguments.", metavar="COMMAND [ARGS]..."),
|
|
122
|
+
],
|
|
123
|
+
lang: Annotated[str, typer.Option("--lang", callback=_language, help="Explanation language: ru or en.")] = "ru",
|
|
124
|
+
no_explain: Annotated[bool, typer.Option("--no-explain", help="Forward output without explanations.")] = False,
|
|
125
|
+
terminal_mode: Annotated[str, typer.Option("--terminal-mode", help="pipe (default) or pty (POSIX PTY / Windows ConPTY).")] = "pipe",
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Forward child bytes literally and propagate its termination status."""
|
|
128
|
+
translator = Translator(lang)
|
|
129
|
+
context = ExecutionContext(command, str(Path.cwd()))
|
|
130
|
+
if terminal_mode not in {"pipe", "pty"}:
|
|
131
|
+
raise typer.BadParameter("Choose pipe or pty.")
|
|
132
|
+
capture: Capture | TerminalCapture
|
|
133
|
+
capture = TerminalCapture() if terminal_mode == "pty" else Capture()
|
|
134
|
+
try:
|
|
135
|
+
if isinstance(capture, TerminalCapture):
|
|
136
|
+
from errlens.pty_runner import run as run_pty
|
|
137
|
+
result = run_pty(context, capture, sys.stdout.buffer)
|
|
138
|
+
else:
|
|
139
|
+
result = run_command(context, capture, sys.stdout.buffer, sys.stderr.buffer)
|
|
140
|
+
except RunnerError as exc:
|
|
141
|
+
if exc.kind == "broken_pipe":
|
|
142
|
+
_silence_failed_streams()
|
|
143
|
+
try:
|
|
144
|
+
Console(stderr=True).print(
|
|
145
|
+
translator.translate("cli.runner_error", kind=exc.kind, detail=safe_text(exc)),
|
|
146
|
+
markup=False, highlight=False,
|
|
147
|
+
)
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
pass # stderr itself may be closed
|
|
150
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
151
|
+
if not no_explain and result.exit_code not in (0, None) and not result.interrupted:
|
|
152
|
+
try:
|
|
153
|
+
report = explain_capture(context, result, capture)
|
|
154
|
+
if report is not None:
|
|
155
|
+
terminal = sys.stderr.isatty()
|
|
156
|
+
try:
|
|
157
|
+
width = os.get_terminal_size(sys.stderr.fileno()).columns if terminal else 80
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
width = 80
|
|
160
|
+
block = render_report(report, translator, terminal=terminal, width=width,
|
|
161
|
+
color="NO_COLOR" not in os.environ)
|
|
162
|
+
sys.stderr.write(block)
|
|
163
|
+
sys.stderr.flush()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
# Analysis/rendering is optional. Preserve the already-forwarded bytes
|
|
166
|
+
# and the child's result; never turn this failure into a guessed fix.
|
|
167
|
+
try:
|
|
168
|
+
sys.stderr.write("\n" + translator.translate("cli.postprocess_error", kind=type(exc).__name__) + "\n")
|
|
169
|
+
sys.stderr.flush()
|
|
170
|
+
except (OSError, ValueError):
|
|
171
|
+
_silence_failed_streams()
|
|
172
|
+
code = result.exit_code if result.exit_code is not None else 125
|
|
173
|
+
if result.interrupted:
|
|
174
|
+
code = 130
|
|
175
|
+
elif code < 0 and os.name == "posix":
|
|
176
|
+
code = 128 - code
|
|
177
|
+
raise typer.Exit(code=code)
|