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 ADDED
@@ -0,0 +1,7 @@
1
+ """ErrLens: local terminal error explanations, currently at foundation stage."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("errlens-cli")
6
+
7
+ __all__ = ["__version__"]
errlens/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support ``python -m errlens``."""
2
+
3
+ from errlens.cli import main_entry
4
+
5
+ if __name__ == "__main__":
6
+ main_entry()
errlens/_conpty.py ADDED
@@ -0,0 +1,236 @@
1
+ """Private Win32 bindings, imported only by the Windows terminal transport.
2
+
3
+ Session lives in a disposable helper process: ClosePseudoConsole has no timeout
4
+ on older Windows. Its job owns every client; no breakaway is enabled.
5
+ """
6
+
7
+ import sys
8
+ import ctypes as c
9
+ from ctypes import wintypes as w
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from typing import Any
14
+
15
+
16
+ class COORD(c.Structure):
17
+ _fields_ = [("X", c.c_short), ("Y", c.c_short)]
18
+
19
+
20
+ class INFO(c.Structure):
21
+ _fields_ = [("cb", w.DWORD), ("reserved", w.LPWSTR), ("desktop", w.LPWSTR),
22
+ ("title", w.LPWSTR), ("x", w.DWORD), ("y", w.DWORD),
23
+ ("width", w.DWORD), ("height", w.DWORD), ("chars_x", w.DWORD),
24
+ ("chars_y", w.DWORD), ("fill", w.DWORD), ("flags", w.DWORD),
25
+ ("show", w.WORD), ("reserved_size", w.WORD), ("reserved_data", w.LPVOID),
26
+ ("stdin", w.HANDLE), ("stdout", w.HANDLE), ("stderr", w.HANDLE)]
27
+
28
+
29
+ class INFOEX(c.Structure):
30
+ _fields_ = [("info", INFO), ("attributes", w.LPVOID)]
31
+
32
+
33
+ class PROCESS(c.Structure):
34
+ _fields_ = [("process", w.HANDLE), ("thread", w.HANDLE),
35
+ ("pid", w.DWORD), ("tid", w.DWORD)]
36
+
37
+
38
+ class LIMITS(c.Structure):
39
+ _fields_ = [("process_time", c.c_int64), ("job_time", c.c_int64),
40
+ ("flags", w.DWORD), ("min_working_set", c.c_size_t),
41
+ ("max_working_set", c.c_size_t), ("process_limit", w.DWORD),
42
+ ("affinity", c.c_size_t), ("priority", w.DWORD), ("scheduling", w.DWORD)]
43
+
44
+
45
+ class JOBINFO(c.Structure):
46
+ _fields_ = [("limits", LIMITS), ("io", c.c_uint64 * 6),
47
+ ("process_memory", c.c_size_t), ("job_memory", c.c_size_t),
48
+ ("peak_process", c.c_size_t), ("peak_job", c.c_size_t)]
49
+
50
+
51
+ class SCREEN(c.Structure):
52
+ _fields_ = [("size", COORD), ("cursor", COORD), ("attributes", w.WORD),
53
+ ("window", c.c_short * 4), ("maximum", COORD)]
54
+
55
+
56
+ _kernel: Any = None
57
+
58
+
59
+ def api(name: str, result: Any, args: list[Any]) -> Any:
60
+ # Loading bindings is lazy too: module discovery must remain portable.
61
+ function: Any = None
62
+ def invoke(*values: Any) -> Any:
63
+ nonlocal function
64
+ global _kernel
65
+ if sys.platform != "win32":
66
+ raise OSError("Windows-only ConPTY API")
67
+ if function is None:
68
+ if _kernel is None:
69
+ _kernel = c.WinDLL("kernel32", use_last_error=True)
70
+ function = getattr(_kernel, name)
71
+ function.restype, function.argtypes = result, args
72
+ return function(*values)
73
+ return invoke
74
+
75
+
76
+ close_handle = api("CloseHandle", w.BOOL, [w.HANDLE])
77
+ get_mode = api("GetConsoleMode", w.BOOL, [w.HANDLE, c.POINTER(w.DWORD)])
78
+ set_mode = api("SetConsoleMode", w.BOOL, [w.HANDLE, w.DWORD])
79
+ screen_info = api("GetConsoleScreenBufferInfo", w.BOOL, [w.HANDLE, c.POINTER(SCREEN)])
80
+ read_file = api("ReadFile", w.BOOL, [w.HANDLE, w.LPVOID, w.DWORD, c.POINTER(w.DWORD), w.LPVOID])
81
+ write_file = api("WriteFile", w.BOOL, [w.HANDLE, w.LPCVOID, w.DWORD, c.POINTER(w.DWORD), w.LPVOID])
82
+ peek = api("PeekNamedPipe", w.BOOL, [w.HANDLE, w.LPVOID, w.DWORD, w.LPVOID,
83
+ c.POINTER(w.DWORD), w.LPVOID])
84
+ wait = api("WaitForSingleObject", w.DWORD, [w.HANDLE, w.DWORD])
85
+ terminate_job = api("TerminateJobObject", w.BOOL, [w.HANDLE, w.UINT])
86
+
87
+
88
+ def check(value: Any) -> None:
89
+ if not value:
90
+ raise win_error()
91
+
92
+
93
+ def last_error() -> int:
94
+ if sys.platform == "win32":
95
+ return c.get_last_error()
96
+ raise OSError("Windows-only ConPTY API")
97
+
98
+
99
+ def win_error() -> OSError:
100
+ if sys.platform == "win32":
101
+ return c.WinError(c.get_last_error())
102
+ return OSError("Windows-only ConPTY API")
103
+
104
+
105
+ def cancel_thread(native_id: int) -> None:
106
+ handle = api("OpenThread", w.HANDLE, [w.DWORD, w.BOOL, w.DWORD])(1, False, native_id)
107
+ if handle:
108
+ try:
109
+ api("CancelSynchronousIo", w.BOOL, [w.HANDLE])(handle)
110
+ finally:
111
+ close_handle(handle)
112
+
113
+
114
+ class Session:
115
+ def __init__(self) -> None:
116
+ self.handles: list[int] = []
117
+ self.hpc: int | None = None
118
+ self.process = self.job = self.input = self.output = 0
119
+
120
+ def own(self, handle: int) -> int:
121
+ check(handle)
122
+ self.handles.append(handle)
123
+ return handle
124
+
125
+ def release(self, handle: int) -> None:
126
+ self.handles.remove(handle)
127
+ check(close_handle(handle))
128
+
129
+ def start(self, command: list[str], cwd: str, size: tuple[int, int]) -> None:
130
+ executable = shutil.which(command[0])
131
+ if executable is None:
132
+ raise FileNotFoundError(2, "Executable not found", command[0])
133
+ # CreateProcess must not implicitly dispatch batch files through cmd.exe.
134
+ if os.path.splitext(executable)[1].lower() not in {".exe", ".com"}:
135
+ raise OSError("ConPTY requires a native executable (.exe or .com)")
136
+ pipe = api("CreatePipe", w.BOOL, [c.POINTER(w.HANDLE), c.POINTER(w.HANDLE), w.LPVOID, w.DWORD])
137
+ def make_pipe() -> tuple[int, int]:
138
+ left, right = w.HANDLE(), w.HANDLE()
139
+ check(pipe(c.byref(left), c.byref(right), None, 0))
140
+ assert left.value is not None and right.value is not None
141
+ return self.own(left.value), self.own(right.value)
142
+ read_input, self.input = make_pipe()
143
+ self.output, write_output = make_pipe()
144
+ create = api("CreatePseudoConsole", c.c_long,
145
+ [COORD, w.HANDLE, w.HANDLE, w.DWORD, c.POINTER(w.HANDLE)])
146
+ hpc = w.HANDLE()
147
+ result = create(COORD(*size), read_input, write_output, 0, c.byref(hpc))
148
+ if result < 0:
149
+ raise OSError(f"CreatePseudoConsole failed: HRESULT 0x{result & 0xffffffff:08x}")
150
+ self.hpc = hpc.value
151
+ self.release(read_input)
152
+ self.release(write_output)
153
+ self.job = self.own(api("CreateJobObjectW", w.HANDLE, [w.LPVOID, w.LPCWSTR])(None, None))
154
+ limits = JOBINFO()
155
+ limits.limits.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
156
+ check(api("SetInformationJobObject", w.BOOL, [w.HANDLE, c.c_int, w.LPVOID, w.DWORD])(
157
+ self.job, 9, c.byref(limits), c.sizeof(limits)))
158
+ initialize = api("InitializeProcThreadAttributeList", w.BOOL,
159
+ [w.LPVOID, w.DWORD, w.DWORD, c.POINTER(c.c_size_t)])
160
+ length = c.c_size_t()
161
+ initialize(None, 2, 0, c.byref(length))
162
+ attributes = c.create_string_buffer(length.value)
163
+ check(initialize(attributes, 2, 0, c.byref(length)))
164
+ try:
165
+ check(api("UpdateProcThreadAttribute", w.BOOL,
166
+ [w.LPVOID, w.DWORD, c.c_size_t, w.LPVOID, c.c_size_t, w.LPVOID, w.LPVOID])(
167
+ attributes, 0, 0x20016, self.hpc, c.sizeof(w.HANDLE), None, None))
168
+ jobs = (w.HANDLE * 1)(self.job)
169
+ # Assign at creation, avoiding an orphan if the owner dies between
170
+ # CreateProcess and AssignProcessToJobObject/ResumeThread.
171
+ check(api("UpdateProcThreadAttribute", w.BOOL,
172
+ [w.LPVOID, w.DWORD, c.c_size_t, w.LPVOID, c.c_size_t, w.LPVOID, w.LPVOID])(
173
+ attributes, 0, 0x2000d, jobs, c.sizeof(jobs), None, None))
174
+ info, process = INFOEX(), PROCESS()
175
+ info.info.cb = c.sizeof(info)
176
+ # Null standard handles with this flag prevent redirected owner pipes
177
+ # from leaking into the client instead of its pseudoconsole handles.
178
+ info.info.flags = 0x100 # STARTF_USESTDHANDLES
179
+ info.attributes = c.cast(attributes, w.LPVOID)
180
+ argv = c.create_unicode_buffer(subprocess.list2cmdline([executable, *command[1:]]))
181
+ check(api("CreateProcessW", w.BOOL, [w.LPCWSTR, w.LPWSTR, w.LPVOID, w.LPVOID,
182
+ w.BOOL, w.DWORD, w.LPVOID, w.LPCWSTR, c.POINTER(INFOEX), c.POINTER(PROCESS)])(
183
+ executable, argv, None, None, False, 0x80000, None, cwd,
184
+ c.byref(info), c.byref(process)))
185
+ self.process = self.own(process.process)
186
+ thread = self.own(process.thread)
187
+ self.release(thread)
188
+ finally:
189
+ api("DeleteProcThreadAttributeList", None, [w.LPVOID])(attributes)
190
+
191
+ def resize(self, size: tuple[int, int]) -> None:
192
+ result = api("ResizePseudoConsole", c.c_long, [w.HANDLE, COORD])(self.hpc, COORD(*size))
193
+ if result < 0:
194
+ raise OSError(f"ResizePseudoConsole failed: {result}")
195
+
196
+ def status(self) -> int | None:
197
+ result = wait(self.process, 0)
198
+ if result == 258:
199
+ return None
200
+ if result != 0:
201
+ raise win_error()
202
+ code = w.DWORD()
203
+ check(api("GetExitCodeProcess", w.BOOL, [w.HANDLE, c.POINTER(w.DWORD)])(self.process, c.byref(code)))
204
+ return code.value
205
+
206
+ def read(self) -> bytes | None:
207
+ available = w.DWORD()
208
+ if not peek(self.output, None, 0, None, c.byref(available), None):
209
+ if last_error() in (109, 232):
210
+ return b""
211
+ raise win_error()
212
+ if not available.value:
213
+ return None
214
+ data, count = c.create_string_buffer(min(available.value, 65536)), w.DWORD()
215
+ check(read_file(self.output, data, len(data), c.byref(count), None))
216
+ return data.raw[:count.value]
217
+
218
+ def write(self, data: bytes) -> None:
219
+ while data:
220
+ count = w.DWORD()
221
+ check(write_file(self.input, data, len(data), c.byref(count), None))
222
+ if count.value == 0:
223
+ raise OSError("ConPTY input made no progress")
224
+ data = data[count.value:]
225
+
226
+ def close_pseudo_console(self) -> None:
227
+ if self.job:
228
+ terminate_job(self.job, 125)
229
+ hpc, self.hpc = self.hpc, None
230
+ if hpc:
231
+ api("ClosePseudoConsole", None, [w.HANDLE])(hpc)
232
+
233
+ def close_handles(self) -> None:
234
+ for handle in reversed(self.handles):
235
+ close_handle(handle)
236
+ self.handles.clear()
@@ -0,0 +1,158 @@
1
+ """Disposable ConPTY owner. stdin/stdout carry a private bounded binary protocol."""
2
+
3
+ import json
4
+ import queue
5
+ import struct
6
+ import sys
7
+ import threading
8
+ import time
9
+ from typing import BinaryIO
10
+
11
+ MAX_FRAME = 65536
12
+
13
+
14
+ def receive(stream: BinaryIO) -> tuple[bytes, bytes]:
15
+ def exact(size: int) -> bytes:
16
+ data = bytearray()
17
+ while len(data) < size:
18
+ chunk = stream.read(size - len(data))
19
+ if not chunk:
20
+ raise EOFError("ConPTY transport closed")
21
+ data.extend(chunk)
22
+ return bytes(data)
23
+ kind, size = struct.unpack("!cI", exact(5))
24
+ if size > MAX_FRAME:
25
+ raise ValueError("ConPTY frame exceeds limit")
26
+ return kind, exact(size)
27
+
28
+
29
+ def send(stream: BinaryIO, kind: bytes, data: bytes = b"") -> None:
30
+ if len(data) > MAX_FRAME:
31
+ raise ValueError("ConPTY frame exceeds limit")
32
+ view = memoryview(struct.pack("!cI", kind, len(data)) + data)
33
+ while view:
34
+ count = stream.write(view)
35
+ if count is None or count <= 0:
36
+ raise OSError("ConPTY transport made no progress")
37
+ view = view[count:]
38
+ stream.flush()
39
+
40
+
41
+ def main() -> None:
42
+ if sys.platform != "win32":
43
+ raise SystemExit("Windows only")
44
+ import os
45
+ from ctypes import wintypes as w
46
+ from errlens._conpty import api, check, close_handle, Session, wait
47
+ session = Session()
48
+ controls: queue.Queue[tuple[bytes, bytes]] = queue.Queue(16)
49
+ inputs: queue.Queue[bytes] = queue.Queue(16)
50
+ failure: queue.SimpleQueue[Exception] = queue.SimpleQueue()
51
+ stop = threading.Event()
52
+ # If the frontend is killed (e.g. outer test timeout), do not leave this
53
+ # owner or its clients behind, even while a native API is blocked.
54
+ parent = api("OpenProcess", w.HANDLE, [w.DWORD, w.BOOL, w.DWORD])(0x100000, False, os.getppid())
55
+ check(parent)
56
+
57
+ def watch_parent() -> None:
58
+ try:
59
+ while not stop.is_set():
60
+ if wait(parent, 100) != 258:
61
+ os._exit(125) # the kill-on-close job owns clients from birth
62
+ finally:
63
+ close_handle(parent)
64
+ watcher = threading.Thread(target=watch_parent, daemon=True)
65
+ watcher.start()
66
+
67
+ def read_controls() -> None:
68
+ try:
69
+ while not stop.is_set():
70
+ item = receive(sys.stdin.buffer)
71
+ while not stop.is_set():
72
+ try:
73
+ controls.put(item, timeout=0.05)
74
+ break
75
+ except queue.Full:
76
+ pass
77
+ except Exception as exc:
78
+ failure.put(exc)
79
+
80
+ def write_input() -> None:
81
+ try:
82
+ while not stop.is_set():
83
+ try:
84
+ session.write(inputs.get(timeout=0.05))
85
+ except queue.Empty:
86
+ pass
87
+ except Exception as exc:
88
+ failure.put(exc)
89
+
90
+ closer: threading.Thread | None = None
91
+ try:
92
+ kind, raw = receive(sys.stdin.buffer)
93
+ if kind != b"C":
94
+ raise ValueError("Expected ConPTY configuration")
95
+ config = json.loads(raw)
96
+ session.start(config["command"], config["cwd"], tuple(config["size"]))
97
+ threading.Thread(target=read_controls, daemon=True).start()
98
+ threading.Thread(target=write_input, daemon=True).start()
99
+ send(sys.stdout.buffer, b"R")
100
+ closing_at: float | None = None
101
+ eof_at: float | None = None
102
+ while True:
103
+ data = session.read()
104
+ if data:
105
+ send(sys.stdout.buffer, b"O", data)
106
+ if closer is None:
107
+ status = session.status()
108
+ if status is not None:
109
+ send(sys.stdout.buffer, b"S", struct.pack("!I", status))
110
+ stop.set()
111
+ closer = threading.Thread(target=session.close_pseudo_console, daemon=True)
112
+ closer.start()
113
+ closing_at = time.monotonic()
114
+ else:
115
+ if data == b"":
116
+ if eof_at is None:
117
+ eof_at = time.monotonic()
118
+ elif time.monotonic() - eof_at > 1:
119
+ raise OSError("ConPTY output closed while client remained alive")
120
+ if not failure.empty():
121
+ raise failure.get()
122
+ if not inputs.full():
123
+ try:
124
+ kind, payload = controls.get_nowait()
125
+ if kind == b"I":
126
+ inputs.put_nowait(payload)
127
+ elif kind == b"W":
128
+ session.resize(struct.unpack("!HH", payload))
129
+ else:
130
+ raise ValueError("Unknown ConPTY control")
131
+ except queue.Empty:
132
+ pass
133
+ elif not closer.is_alive() and data == b"":
134
+ break
135
+ elif closing_at is not None and time.monotonic() - closing_at > 2:
136
+ raise TimeoutError("ConPTY close/drain exceeded two seconds")
137
+ if not data:
138
+ time.sleep(0.005)
139
+ send(sys.stdout.buffer, b"D")
140
+ except Exception as exc:
141
+ code = 127 if isinstance(exc, FileNotFoundError) else 126 if isinstance(exc, PermissionError) else 125
142
+ send(sys.stdout.buffer, b"E", json.dumps({"detail": str(exc)[:8192], "code": code}).encode("utf-8"))
143
+ finally:
144
+ stop.set()
145
+ # The parent enforces the ultimate deadline even if this Windows API blocks.
146
+ if closer is None:
147
+ closer = threading.Thread(target=session.close_pseudo_console, daemon=True)
148
+ closer.start()
149
+ closer.join(2)
150
+ if not closer.is_alive():
151
+ session.close_handles()
152
+ # Avoid Python's buffered-stdin shutdown lock held by the daemon reader.
153
+ watcher.join(0.5)
154
+ os._exit(0)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
errlens/_pty_child.py ADDED
@@ -0,0 +1,25 @@
1
+ """Single-threaded POSIX session setup helper; no Python preexec_fn in parent."""
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ def main() -> None:
8
+ if sys.platform == "win32":
9
+ raise RuntimeError("POSIX PTY helper is not available on Windows")
10
+ import fcntl
11
+ import termios
12
+
13
+ error_fd = int(sys.argv[1])
14
+ os.set_inheritable(error_fd, False)
15
+ try:
16
+ fcntl.ioctl(0, termios.TIOCSCTTY, 0)
17
+ os.tcsetpgrp(0, os.getpgrp())
18
+ os.execvpe(sys.argv[2], sys.argv[2:], os.environ)
19
+ except OSError as exc:
20
+ os.write(error_fd, f"{exc.errno}: {exc}".encode("utf-8", errors="replace")[:4096])
21
+ os._exit(127 if isinstance(exc, FileNotFoundError) else 126)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1 @@
1
+ """Analyzer contracts; no automatic discovery or third-party code loading."""
@@ -0,0 +1,45 @@
1
+ """Pure analyzer contract and explicit, instance-scoped internal registry."""
2
+
3
+ from typing import Protocol
4
+
5
+ from errlens.capture import CapturedData
6
+ from errlens.models import Diagnostic, ExecutionContext, ExecutionResult
7
+
8
+
9
+ class Analyzer(Protocol):
10
+ """Inspect supplied data only: no output, translation, commands or writes."""
11
+
12
+ @property
13
+ def analyzer_id(self) -> str:
14
+ """Stable identifier unique within a registry."""
15
+ ...
16
+
17
+ def analyze(
18
+ self,
19
+ context: ExecutionContext,
20
+ result: ExecutionResult,
21
+ data: CapturedData,
22
+ ) -> list[Diagnostic]:
23
+ """Return observations; return an empty list when nothing is recognized."""
24
+ ...
25
+
26
+
27
+ class AnalyzerRegistry:
28
+ """Register trusted analyzer instances in deterministic insertion order."""
29
+
30
+ def __init__(self) -> None:
31
+ self._analyzers: dict[str, Analyzer] = {}
32
+
33
+ def register(self, analyzer: Analyzer) -> None:
34
+ identifier = analyzer.analyzer_id
35
+ if not identifier:
36
+ raise ValueError("analyzer_id must not be empty")
37
+ if identifier in self._analyzers:
38
+ raise ValueError(f"Analyzer already registered: {identifier}")
39
+ self._analyzers[identifier] = analyzer
40
+
41
+ def get(self, analyzer_id: str) -> Analyzer:
42
+ return self._analyzers[analyzer_id]
43
+
44
+ def all(self) -> tuple[Analyzer, ...]:
45
+ return tuple(self._analyzers.values())