kedi-debugger 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.
@@ -0,0 +1,7 @@
1
+ """Optional Kedi debugger. Importing the transport does not load the runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from .server import DebugAdapter
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(description="Kedi Debug Adapter Protocol backend")
12
+ parser.add_argument("--stdio", action="store_true", required=True)
13
+ parser.parse_args(argv)
14
+ # Unbuffered duplicates avoid interpreter shutdown waiting on a blocked IO thread.
15
+ with (
16
+ os.fdopen(os.dup(sys.stdin.fileno()), "rb", buffering=0) as reader,
17
+ os.fdopen(os.dup(sys.stdout.fileno()), "wb", buffering=0) as writer,
18
+ ):
19
+ return DebugAdapter(reader, writer).run()
20
+
21
+
22
+ if __name__ == "__main__":
23
+ raise SystemExit(main())
@@ -0,0 +1,493 @@
1
+ """Cooperative lane control and detached, read-only DAP inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ import threading
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from kedi.debugging import DebugEvent
14
+
15
+ from .inspection import Snapshot, SnapshotLimitError
16
+ from .sources import Sources, source_path
17
+
18
+
19
+ def _integer(value: Any, name: str, *, minimum: int = 0) -> int:
20
+ if type(value) is not int or value < minimum:
21
+ raise ValueError(f"{name} must be an integer greater than or equal to {minimum}")
22
+ return value
23
+
24
+
25
+ @dataclass
26
+ class Lane:
27
+ id: int
28
+ name: str
29
+ paused: bool = False
30
+ pause_requested: bool = False
31
+ step: str | None = None
32
+ depth: int = 0
33
+ frames: list[dict[str, Any]] = field(default_factory=list)
34
+ handles: set[int] = field(default_factory=set)
35
+ cursors: dict[int, Any] = field(default_factory=dict)
36
+ observations: dict[str, dict[str, Any]] = field(default_factory=dict)
37
+ previous: dict[str, Any] | None = None
38
+ exception: dict[str, Any] | None = None
39
+ waiting: str | None = None
40
+ progress_id: str | None = None
41
+ last_span: Any = None
42
+
43
+
44
+ class Controller:
45
+ def __init__(self, config: dict[str, Any], send: Callable[[dict[str, Any]], None]):
46
+ self.config = config
47
+ self.send = send
48
+ client = config.get("__initialize", {})
49
+ self.line_offset = 0 if client.get("linesStartAt1", True) else 1
50
+ self.column_offset = 0 if client.get("columnsStartAt1", True) else 1
51
+ self.uri_paths = client.get("pathFormat") == "uri"
52
+ self.progress_reporting = bool(client.get("supportsProgressReporting", False))
53
+ self._next_progress = 1
54
+ self.sources = Sources()
55
+ self.sources.load(config["program"])
56
+ self.condition = threading.Condition(threading.RLock())
57
+ self.lanes: dict[int, Lane] = {}
58
+ self.breakpoints: dict[str, set[int]] = {}
59
+ self.filters: set[str] = {"exceptions"}
60
+ self.entry = bool(config.get("stopOnEntry", True))
61
+ self.finished = False
62
+ self.terminated = False
63
+ self._next_handle = 1
64
+ self._handles: dict[int, list[dict[str, Any]]] = {}
65
+ self._frames: dict[int, tuple[Lane, list[dict[str, Any]]]] = {}
66
+
67
+ def emit(self, event: str, **body: Any) -> None:
68
+ self.send({"type": "event", "event": event, "body": body})
69
+
70
+ def _lane(self) -> Lane:
71
+ identity = threading.get_ident()
72
+ if identity not in self.lanes:
73
+ if len(self.lanes) >= 128:
74
+ raise RuntimeError("Debugger lane limit reached (128); restart the session")
75
+ lane = Lane(len(self.lanes) + 1, threading.current_thread().name)
76
+ self.lanes[identity] = lane
77
+ self.emit("thread", reason="started", threadId=lane.id)
78
+ return self.lanes[identity]
79
+
80
+ def _thread(self, identifier: Any) -> Lane:
81
+ identifier = _integer(identifier, "threadId", minimum=1)
82
+ for lane in self.lanes.values():
83
+ if lane.id == identifier:
84
+ return lane
85
+ raise ValueError("Unknown execution lane")
86
+
87
+ def _source(self, span: Any) -> dict[str, Any] | None:
88
+ if span is None or span.source_path is None:
89
+ return None
90
+ path = source_path(span.source_path)
91
+ return {"name": Path(path).name, "path": Path(path).as_uri() if self.uri_paths else path}
92
+
93
+ def __call__(self, event: DebugEvent) -> None:
94
+ with self.condition:
95
+ if self.terminated:
96
+ raise SystemExit("Debugger terminated")
97
+ if event.kind == "source_loaded":
98
+ source_map = event.runtime._source_map
99
+ if source_map is not None:
100
+ for document in source_map.documents:
101
+ if document.path is not None:
102
+ self.sources.register(str(document.path), document.text)
103
+ return
104
+ lane = self._lane()
105
+ if event.kind == "waiting":
106
+ self._end_wait(lane)
107
+ lane.waiting = "Waiting for a capture"
108
+ if self.progress_reporting:
109
+ lane.progress_id = f"capture-{self._next_progress}"
110
+ self._next_progress += 1
111
+ self.emit("progressStart", progressId=lane.progress_id, title=lane.waiting)
112
+ return
113
+ if event.kind == "wait_complete":
114
+ self._end_wait(lane)
115
+ return
116
+ if event.kind == "frame_exit":
117
+ if event.frames:
118
+ lane.cursors.pop(id(event.frames[-1]), None)
119
+ return
120
+ if event.kind == "execution_complete":
121
+ self._end_wait(lane)
122
+ self._snapshot(lane, event)
123
+ return
124
+ if event.span is not None:
125
+ lane.last_span = event.span
126
+ if event.frames and event.span is not None:
127
+ lane.cursors[id(event.frames[-1])] = event.span
128
+ if event.kind in ("request", "model_input", "model_result", "usage"):
129
+ lane.observations[event.kind] = dict(
130
+ Snapshot(max_bytes=16384).capture(event.kind, event.payload)
131
+ )
132
+ if not event.safe:
133
+ return
134
+ depth = len(event.frames)
135
+ reason = None
136
+ if event.kind == "statement":
137
+ source = self._source(event.span)
138
+ path = source_path(source["path"]) if source else ""
139
+ if event.span is not None and event.span.start_line in self.breakpoints.get(
140
+ path, set()
141
+ ):
142
+ reason = "breakpoint"
143
+ elif self.entry:
144
+ reason = "entry"
145
+ self.entry = False
146
+ if lane.pause_requested:
147
+ reason = reason or "pause"
148
+ if lane.step is not None and event.kind == "statement":
149
+ if (
150
+ lane.step == "stepIn"
151
+ or (lane.step == "next" and depth <= lane.depth)
152
+ or (lane.step == "stepOut" and depth < lane.depth)
153
+ ):
154
+ reason = reason or "step"
155
+ if event.kind == "exception" and "exceptions" in self.filters:
156
+ reason = "exception"
157
+ if event.kind in self.filters:
158
+ reason = reason or "breakpoint"
159
+ if reason is None:
160
+ return
161
+ self.entry = False
162
+ lane.pause_requested = False
163
+ lane.step = None
164
+ lane.depth = depth
165
+ self._snapshot(lane, event)
166
+ lane.paused = True
167
+ self.emit(
168
+ "stopped",
169
+ reason=reason,
170
+ description=event.kind,
171
+ threadId=lane.id,
172
+ allThreadsStopped=False,
173
+ )
174
+ while lane.paused and not self.terminated:
175
+ self.condition.wait()
176
+ if self.terminated:
177
+ raise SystemExit("Debugger terminated")
178
+
179
+ def _invalidate(self, lane: Lane) -> None:
180
+ for handle in lane.handles:
181
+ self._handles.pop(handle, None)
182
+ self._frames.pop(handle, None)
183
+ lane.handles.clear()
184
+ lane.frames.clear()
185
+
186
+ def _handle(self, lane: Lane, children: list[dict[str, Any]]) -> int:
187
+ handle = self._next_handle
188
+ self._next_handle += 1
189
+ self._handles[handle] = children
190
+ lane.handles.add(handle)
191
+ return handle
192
+
193
+ def _variable(self, lane: Lane, node: dict[str, Any]) -> dict[str, Any]:
194
+ children = node.get("children", [])
195
+ result = {key: node[key] for key in ("name", "value", "type")}
196
+ result["variablesReference"] = (
197
+ self._handle(lane, [self._variable(lane, c) for c in children]) if children else 0
198
+ )
199
+ return result
200
+
201
+ def _snapshot(self, lane: Lane, event: DebugEvent) -> None:
202
+ self._invalidate(lane)
203
+ remaining_bytes = 65536
204
+
205
+ def capture(name: str, value: Any) -> dict[str, Any]:
206
+ nonlocal remaining_bytes
207
+ try:
208
+ if remaining_bytes < 128:
209
+ raise SnapshotLimitError("Snapshot byte budget exhausted")
210
+ node = dict(Snapshot(max_bytes=min(8192, remaining_bytes)).capture(name, value))
211
+ remaining_bytes -= len(json.dumps(node))
212
+ return node
213
+ except SnapshotLimitError:
214
+ return {"name": name, "value": "<snapshot budget exhausted>", "type": "truncated"}
215
+
216
+ frames = list(event.frames)
217
+ if not frames:
218
+ from kedi.debugging import DebugFrame
219
+
220
+ frames = [DebugFrame("<program>", event.runtime, event.env, event.span)]
221
+ for index, frame in enumerate(reversed(frames[-32:])):
222
+ span = (
223
+ (event.span or lane.last_span)
224
+ if index == 0
225
+ else lane.cursors.get(id(frame), frame.span)
226
+ )
227
+ env = event.env if index == 0 else frame.env
228
+ local = capture("Locals", env)
229
+ nodes = [local]
230
+ from kedi.lang.compiler.environment import ScopeFrame
231
+
232
+ scope = env
233
+ seen = {id(scope)}
234
+ for level in range(8):
235
+ if type(scope) is not ScopeFrame:
236
+ break
237
+ storage = object.__getattribute__(scope, "__dict__")
238
+ parent = None
239
+ for offset, (key, value) in enumerate(dict.items(storage)):
240
+ if offset >= 32:
241
+ break
242
+ if type(key) is str and key == "parent":
243
+ parent = value
244
+ break
245
+ if parent is None or id(parent) in seen or parent is frame.runtime._globals_env:
246
+ break
247
+ seen.add(id(parent))
248
+ nodes.append(capture(f"Closure {level + 1}", parent))
249
+ scope = parent
250
+ nodes.append(capture("Globals", frame.runtime._globals_env))
251
+ if index == 0:
252
+ nodes.append(capture("Python", frame.runtime._prelude_env))
253
+ if index == 0:
254
+ nodes.extend(lane.observations.values())
255
+ if lane.previous is not None:
256
+ before = {n["name"]: n for n in lane.previous.get("children", [])}
257
+ changes = [n for n in local.get("children", []) if before.get(n["name"]) != n]
258
+ nodes.append(
259
+ {
260
+ "name": "Changes",
261
+ "value": "Captured values only",
262
+ "type": "snapshot",
263
+ "children": changes,
264
+ }
265
+ )
266
+ lane.previous = local
267
+ scopes = []
268
+ for node in nodes:
269
+ children = node.get("children")
270
+ if children is None:
271
+ children = [{**node, "name": "value"}]
272
+ elif "<truncated:" in node["value"]:
273
+ # DAP scopes hide the root value; retain its limit notice.
274
+ children = [
275
+ *children,
276
+ {"name": "<snapshot>", "value": node["value"], "type": "truncated"},
277
+ ]
278
+ scopes.append(
279
+ {
280
+ "name": node["name"],
281
+ "variablesReference": self._handle(
282
+ lane, [self._variable(lane, child) for child in children]
283
+ ),
284
+ "expensive": False,
285
+ }
286
+ )
287
+ frame_id = self._handle(lane, [])
288
+ self._frames[frame_id] = (lane, scopes)
289
+ dap_frame: dict[str, Any] = {
290
+ "id": frame_id,
291
+ "name": frame.name,
292
+ "line": max(1, span.start_line if span else 1) - self.line_offset,
293
+ "column": max(1, span.start_col if span else 1) - self.column_offset,
294
+ }
295
+ source = self._source(span)
296
+ if source is not None:
297
+ dap_frame["source"] = source
298
+ lane.frames.append(dap_frame)
299
+ if event.kind != "execution_complete":
300
+ lane.exception = None
301
+ if event.kind == "exception":
302
+ node = capture("arguments", BaseException.__dict__["args"].__get__(event.payload))
303
+ lane.exception = {
304
+ "exceptionId": Snapshot().capture("exception", event.payload)["type"],
305
+ "breakMode": "always",
306
+ "description": node["value"],
307
+ }
308
+
309
+ def request(
310
+ self,
311
+ command: str,
312
+ args: dict[str, Any],
313
+ *,
314
+ acknowledge: Callable[[dict[str, Any]], None] | None = None,
315
+ ) -> dict[str, Any]:
316
+ with self.condition:
317
+ if command == "setBreakpoints":
318
+ path = self.sources.load(args["source"]["path"])
319
+ selected: set[int] = set()
320
+ result = []
321
+ for bp in args.get("breakpoints", []):
322
+ line = (
323
+ _integer(bp["line"], "line", minimum=1 - self.line_offset)
324
+ + self.line_offset
325
+ )
326
+ valid = line in self.sources.lines[path] and not any(
327
+ bp.get(k) for k in ("condition", "hitCondition", "logMessage")
328
+ )
329
+ if valid:
330
+ selected.add(line)
331
+ result.append(
332
+ {
333
+ "verified": valid,
334
+ "line": line - self.line_offset,
335
+ "message": ""
336
+ if valid
337
+ else "No executable Kedi statement here, or unsupported breakpoint condition",
338
+ }
339
+ )
340
+ self.breakpoints[path] = selected
341
+ return {"breakpoints": result}
342
+ if command == "setExceptionBreakpoints":
343
+ filters = set(args.get("filters", []))
344
+ if filters - {"exceptions", "model_input", "model_result"}:
345
+ raise ValueError("Unknown Kedi exception/event filter")
346
+ self.filters = filters
347
+ return {}
348
+ if command == "threads":
349
+ return {
350
+ "threads": [
351
+ {"id": lane.id, "name": lane.name}
352
+ for lane in self.lanes.values()
353
+ if not self.finished
354
+ ]
355
+ }
356
+ if command == "stackTrace":
357
+ lane = self._thread(args["threadId"])
358
+ if not lane.paused and not self.finished:
359
+ raise ValueError("Lane is not stopped")
360
+ start = _integer(args.get("startFrame", 0), "startFrame")
361
+ levels = _integer(args.get("levels", 0), "levels") or len(lane.frames)
362
+ return {
363
+ "stackFrames": lane.frames[start : start + levels],
364
+ "totalFrames": len(lane.frames),
365
+ }
366
+ if command == "scopes":
367
+ item = self._frames.get(_integer(args["frameId"], "frameId", minimum=1))
368
+ if item is None:
369
+ raise ValueError("Expired or unknown frame")
370
+ return {"scopes": item[1]}
371
+ if command == "variables":
372
+ values = self._handles.get(
373
+ _integer(args["variablesReference"], "variablesReference", minimum=1)
374
+ )
375
+ if values is None:
376
+ raise ValueError("Expired or unknown variable reference")
377
+ start = _integer(args.get("start", 0), "start")
378
+ count = _integer(args.get("count", 0), "count") or len(values)
379
+ return {"variables": values[start : start + count]}
380
+ if command == "source":
381
+ return {
382
+ "content": self.sources.content(args["source"]["path"]),
383
+ "mimeType": "text/x-kedi",
384
+ }
385
+ if command == "evaluate":
386
+ return self._evaluate(args)
387
+ if command == "exceptionInfo":
388
+ lane = self._thread(args["threadId"])
389
+ if lane.exception is None:
390
+ raise ValueError("No exception at this stop")
391
+ return lane.exception
392
+ if command in ("continue", "next", "stepIn", "stepOut", "pause"):
393
+ if type(args.get("singleThread", False)) is not bool:
394
+ raise ValueError("singleThread must be a boolean")
395
+ if self.finished:
396
+ raise ValueError("Execution has finished")
397
+ lane = self._thread(args["threadId"])
398
+ if command == "pause":
399
+ if acknowledge is not None:
400
+ acknowledge({})
401
+ lane.pause_requested = not lane.paused
402
+ return {}
403
+ if not lane.paused:
404
+ raise ValueError("Lane is not stopped")
405
+ try:
406
+ self.sources.verify()
407
+ except ValueError as exc:
408
+ self.emit("output", category="console", output=f"{exc}\n")
409
+ raise
410
+ targets = [lane]
411
+ if not args.get("singleThread", False):
412
+ targets = [other for other in self.lanes.values() if other.paused]
413
+ all_continued = not args.get("singleThread", False)
414
+ body = {"allThreadsContinued": all_continued} if command == "continue" else {}
415
+ # The wire response must precede events from newly runnable lanes.
416
+ if acknowledge is not None:
417
+ acknowledge(body)
418
+ for target in targets:
419
+ target.step = command if target is lane and command != "continue" else None
420
+ target.paused = False
421
+ self._invalidate(target)
422
+ self.emit("continued", threadId=lane.id, allThreadsContinued=all_continued)
423
+ self.condition.notify_all()
424
+ return body
425
+ raise ValueError(f"Unsupported debugger request: {command}")
426
+
427
+ def _evaluate(self, args: dict[str, Any]) -> dict[str, Any]:
428
+ expression = args.get("expression", "")
429
+ if not isinstance(expression, str) or len(expression) > 256:
430
+ raise ValueError("Only short read-only names and paths are supported")
431
+ frame_id = args.get("frameId")
432
+ item = self._frames.get(frame_id) if type(frame_id) is int else None
433
+ if item is None:
434
+ raise ValueError("Select a current stopped frame")
435
+ path: list[str] = []
436
+ node = ast.parse(expression, mode="eval").body
437
+ while isinstance(node, (ast.Attribute, ast.Subscript)):
438
+ if isinstance(node, ast.Attribute):
439
+ path.append(node.attr)
440
+ elif isinstance(node.slice, ast.Constant) and type(node.slice.value) in (str, int):
441
+ path.append(str(node.slice.value))
442
+ else:
443
+ raise ValueError("Only literal indices are supported")
444
+ node = node.value
445
+ if not isinstance(node, ast.Name):
446
+ raise ValueError("Evaluation cannot execute code")
447
+ path.append(node.id)
448
+ scopes = item[1]
449
+ candidates = []
450
+ for scope in scopes:
451
+ if scope["name"] in ("Locals", "Globals", "Python") or scope["name"].startswith(
452
+ "Closure "
453
+ ):
454
+ candidates.extend(self._handles.get(scope["variablesReference"], []))
455
+ selected = None
456
+ for index, name in enumerate(reversed(path)):
457
+ matches = [value for value in candidates if value["name"] == name]
458
+ if index and len(matches) > 1:
459
+ raise ValueError("Ambiguous key in the snapshot; inspect variables directly")
460
+ selected = matches[0] if matches else None
461
+ if selected is None:
462
+ raise ValueError("Value is not present in the bounded snapshot")
463
+ candidates = self._handles.get(selected["variablesReference"], [])
464
+ assert selected is not None
465
+ return {
466
+ "result": selected["value"],
467
+ "type": selected["type"],
468
+ "variablesReference": selected["variablesReference"],
469
+ }
470
+
471
+ def _end_wait(self, lane: Lane) -> None:
472
+ lane.waiting = None
473
+ if lane.progress_id is not None:
474
+ self.emit("progressEnd", progressId=lane.progress_id)
475
+ lane.progress_id = None
476
+
477
+ def finish(self) -> None:
478
+ with self.condition:
479
+ if self.finished:
480
+ return
481
+ self.finished = True
482
+ for lane in self.lanes.values():
483
+ self._end_wait(lane)
484
+ lane.paused = False
485
+ lane.pause_requested = False
486
+ lane.step = None
487
+ self.emit("thread", reason="exited", threadId=lane.id)
488
+ self.condition.notify_all()
489
+
490
+ def close(self) -> None:
491
+ with self.condition:
492
+ self.terminated = True
493
+ self.condition.notify_all()
@@ -0,0 +1,65 @@
1
+ """Resolve a debuggee's dotenv snapshot before starting or importing its runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+
9
+
10
+ def launch_environment(cwd: Path, overrides: Mapping[str, str | None]) -> dict[str, str]:
11
+ environment = os.environ.copy()
12
+ removed = {name for name, value in overrides.items() if value is None}
13
+ for name, value in overrides.items():
14
+ if value is None:
15
+ environment.pop(name, None)
16
+ else:
17
+ environment[name] = value
18
+
19
+ if environment.get("PYTHON_DOTENV_DISABLED", "").casefold() not in {
20
+ "1",
21
+ "true",
22
+ "t",
23
+ "yes",
24
+ "y",
25
+ }:
26
+ # Search from the debuggee, never from this installed package's location.
27
+ for directory in (cwd, *cwd.parents):
28
+ path = directory / ".env"
29
+ if not path.exists():
30
+ continue
31
+ if not path.is_file():
32
+ raise ValueError("Debug environment .env must be a regular file")
33
+ try:
34
+ from dotenv.parser import parse_stream
35
+ from dotenv.variables import parse_variables
36
+ except ImportError as exc:
37
+ raise ValueError(
38
+ "Debug environment loading requires python-dotenv in the selected Python"
39
+ ) from exc
40
+ inherited = set(environment)
41
+ try:
42
+ with path.open(encoding="utf-8-sig") as stream:
43
+ for binding in parse_stream(stream):
44
+ if binding.error:
45
+ raise ValueError(
46
+ f"Invalid debug environment .env syntax at line {binding.original.line}"
47
+ )
48
+ name, value = binding.key, binding.value
49
+ if name is None or value is None or name in removed:
50
+ continue
51
+ value = "".join(
52
+ atom.resolve(environment) for atom in parse_variables(value)
53
+ )
54
+ if not name or "=" in name or "\0" in name or "\0" in value:
55
+ raise ValueError("Invalid debug environment .env entry")
56
+ if name not in inherited:
57
+ environment[name] = value
58
+ except (OSError, UnicodeError) as exc:
59
+ raise ValueError("Unable to read debug environment .env as UTF-8") from exc
60
+ break
61
+
62
+ # Kedi imports also call load_dotenv(). Do not rediscover a different file,
63
+ # restore explicitly removed keys, or introduce secrets unknown to redaction.
64
+ environment["PYTHON_DOTENV_DISABLED"] = "1"
65
+ return environment