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.
- kedi_debugger/__init__.py +7 -0
- kedi_debugger/__main__.py +23 -0
- kedi_debugger/controller.py +493 -0
- kedi_debugger/environment.py +65 -0
- kedi_debugger/inspection.py +426 -0
- kedi_debugger/protocol.py +141 -0
- kedi_debugger/redaction.py +21 -0
- kedi_debugger/server.py +689 -0
- kedi_debugger/sources.py +139 -0
- kedi_debugger/worker.py +140 -0
- kedi_debugger-0.1.0.dist-info/METADATA +206 -0
- kedi_debugger-0.1.0.dist-info/RECORD +15 -0
- kedi_debugger-0.1.0.dist-info/WHEEL +4 -0
- kedi_debugger-0.1.0.dist-info/entry_points.txt +2 -0
- kedi_debugger-0.1.0.dist-info/licenses/LICENSE +203 -0
|
@@ -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
|