autodebug 0.5.1__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.
autodebug/tools.py ADDED
@@ -0,0 +1,472 @@
1
+ """The agent-facing debugging tools (docs/design.md, "Agent tools").
2
+
3
+ `Debugger` keeps one connection to the chosen IDE window, the session's
4
+ timeline for the report, and applies redaction to everything it returns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import time
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+
14
+ from autodebug import report
15
+ from autodebug.connection import IDE_NAMES, DebugError, Window, choose, discover
16
+ from autodebug.redact import REDACTED, clean_text, clean_value, is_secret_name
17
+ from autodebug.session import Session, connect
18
+
19
+ SHORT_VALUE = 200
20
+ _NO_ANSWER = ("Nobody answered the Approve dialog in {ide} within 2 minutes, so nothing ran; it was probably "
21
+ "behind another window. This was not a Deny. Ask the developer whether to try again.")
22
+ STOPPED = ("paused", "ended", "denied", "build_failed")
23
+
24
+ _NEXT = {
25
+ "awaiting_approval": ("Tell the developer the Approve dialog is open in {ide} (switch to {ide} if it is hidden) "
26
+ "and that it closes unanswered after 2 minutes; then call wait."),
27
+ "denied": "The developer did not approve; nothing ran. Ask before trying again.",
28
+ "building": "The build task is running; call wait.",
29
+ "build_failed": "The build failed (output is in the IDE terminal). Fix it or ask the developer.",
30
+ "starting": "The debugger is starting. Large programs can take minutes to load symbols, so keep calling wait.",
31
+ "running": "The program is running; call wait for the next stop, or pause.",
32
+ "paused": "Paused. Use inspect, stack, step, continue, trace or breakpoints.",
33
+ "ended": "The debug session ended. Call write_report with your findings.",
34
+ }
35
+
36
+
37
+ _ERROR_LINE = re.compile(r"error|exception|fail|traceback|assert|panic|fatal", re.IGNORECASE)
38
+
39
+
40
+ def _telling_lines(output: list[str], count: int = 3) -> list[str]:
41
+ """The output lines that explain an exit: errors and failures first, else the last lines."""
42
+ lines = [line.strip() for line in output if line.strip() and set(line.strip()) - set("-=_*")]
43
+ errors = [line for line in lines if _ERROR_LINE.search(line)]
44
+ return (errors or lines)[-count:]
45
+
46
+
47
+ @dataclass
48
+ class Event:
49
+ kind: str
50
+ where: str
51
+ detail: str
52
+ at: float = field(default_factory=time.time)
53
+
54
+
55
+ class Debugger:
56
+ def __init__(self, find_windows: Callable[[], list[Window]] = discover, connect: Callable[[Window], Session] = connect) -> None:
57
+ self._find_windows = find_windows
58
+ self._connect = connect
59
+ self.connection: Session | None = None
60
+ self.folder: str | None = None
61
+ self.version = 0
62
+ self.timeline: list[Event] = []
63
+ self.plan: dict = {}
64
+ self.sessions = 0
65
+ self._last_status: str | None = None
66
+ self._last_build: dict | None = None
67
+ self._last_exception: str | None = None
68
+
69
+ # -- helpers ------------------------------------------------------------
70
+
71
+ def _log(self, kind: str, where: str = "", detail: str = "") -> None:
72
+ self.timeline.append(Event(kind, clean_text(where, 300), clean_text(detail, 1000)))
73
+
74
+ def _window(self, folder: str | None, ide: str | None = None) -> Window:
75
+ return choose(self._find_windows(), folder, ide)
76
+
77
+ def _session(self) -> Session:
78
+ if self.connection is None:
79
+ raise DebugError("NOT_FOUND", "no debug session; call start_session first")
80
+ return self.connection
81
+
82
+ def _call(self, op: str, args: dict | None = None, timeout: float = 30.0) -> dict:
83
+ return self._session().call(op, args, timeout=timeout)
84
+
85
+ def _state(self, raw: dict, quiet: bool = False) -> dict:
86
+ """The session state as the agent sees it, logging changes on the timeline.
87
+ `quiet`: the caller (step, trace) already logged this stop."""
88
+ self.version = max(self.version, int(raw.get("version") or 0))
89
+ status = raw.get("status", "none")
90
+ location = raw.get("location") or None
91
+ where = f"{location['file']}:{location['line']}" if location else ""
92
+ result = {"status": status}
93
+ if where:
94
+ result["location"] = clean_text(where, 300)
95
+ for key in ("reason", "exit_code", "message"):
96
+ if raw.get(key) not in (None, ""):
97
+ result[key] = clean_text(raw[key], 500) if isinstance(raw[key], str) else raw[key]
98
+ if "steps_used" in raw:
99
+ result["steps"] = f"{raw['steps_used']} of {raw['max_steps']} used"
100
+ if raw.get("breakpoints"):
101
+ result["breakpoints"] = [
102
+ {"at": clean_text(f"function {b['function']}" if b.get("function") else f"{b.get('file')}:{b.get('line')}", 300),
103
+ "source": b.get("source", "session"), "verified": b.get("verified", False),
104
+ **({"note": clean_text(b["message"], 200)} if b.get("message") else {})}
105
+ for b in raw["breakpoints"]
106
+ ]
107
+ output = [clean_text(line, 500) for line in raw.get("output") or []]
108
+ if output and status == "ended":
109
+ result["output"] = output
110
+ if raw.get("output_note"):
111
+ result["output_note"] = raw["output_note"]
112
+ if raw.get("thread"):
113
+ result["thread"] = raw["thread"]
114
+ if raw.get("exception"):
115
+ result["exception"] = {"id": clean_text(raw["exception"].get("id"), 200),
116
+ "description": clean_text(raw["exception"].get("description"), 500)}
117
+ if result["exception"]["id"] != self._last_exception:
118
+ self._last_exception = result["exception"]["id"]
119
+ self._log("exception", result["exception"]["id"], result["exception"]["description"])
120
+ key = (status, where, raw.get("reason"))
121
+ changed, previous = key != self._last_status, self._last_status
122
+ if changed and previous and previous[0] == "awaiting_approval" and status not in ("denied", "awaiting_approval"):
123
+ self._log("approved", "", "the developer clicked Approve")
124
+ build = raw.get("build")
125
+ if build and build.get("status") != "running" and build != self._last_build:
126
+ self._last_build = build
127
+ self._log("build", str(build.get("task")), f"{build.get('status')}, exit code {build.get('exit_code')}")
128
+ if changed:
129
+ self._last_status = key
130
+ if quiet and status == "paused":
131
+ pass
132
+ elif status == "paused":
133
+ self._log("stopped", where, str(raw.get("reason") or ""))
134
+ elif status == "ended":
135
+ code = raw.get("exit_code")
136
+ last = _telling_lines(output)
137
+ self._log("ended", "", f"{raw.get('reason') or ''}" + (f", exit code {code}" if code is not None else "")
138
+ + (f"; output: {' / '.join(last)}" if last else ""))
139
+ elif status in ("denied", "build_failed", "running"):
140
+ self._log(status, "", str(raw.get("reason") or raw.get("message") or ""))
141
+ ide = self.connection.window.ide if self.connection else "IDE"
142
+ next_step = _NEXT.get(status, "")
143
+ if status == "denied" and "no answer" in str(raw.get("reason") or ""):
144
+ next_step = _NO_ANSWER
145
+ result["next"] = next_step.format(ide=IDE_NAMES.get(ide, "IDE"))
146
+ if status == "paused":
147
+ result["variables"] = self._short_variables()
148
+ return result
149
+
150
+ def _wait(self, seconds: float, until: tuple[str, ...] = STOPPED) -> dict:
151
+ deadline = time.monotonic() + max(0.0, seconds)
152
+ while True:
153
+ remaining = deadline - time.monotonic()
154
+ wait_ms = int(max(0.0, min(remaining, 25.0)) * 1000)
155
+ raw = self._call("state", {"since": self.version, "wait_ms": wait_ms}, timeout=wait_ms / 1000 + 10)["state"]
156
+ self.version = max(self.version, int(raw.get("version") or 0))
157
+ if raw.get("status") in until or time.monotonic() >= deadline:
158
+ return raw
159
+
160
+ def _raw_variables(self, frame: int | None = None, names: list[str] | None = None, depth: int = 0) -> list[dict]:
161
+ args = {"names": names, "depth": max(0, min(int(depth), 3))}
162
+ if frame is not None:
163
+ args["frame"] = frame
164
+ return self._call("variables", args).get("variables", [])
165
+
166
+ def _short_variables(self, limit: int = 30) -> list[dict]:
167
+ try:
168
+ raw = self._raw_variables()
169
+ except DebugError:
170
+ return []
171
+ return [self._clean_variable(v, SHORT_VALUE) for v in raw[:limit]]
172
+
173
+ def _clean_variable(self, variable: dict, limit: int, parent_secret: bool = False) -> dict:
174
+ secret = parent_secret or is_secret_name(variable.get("name", ""))
175
+ cleaned = {"name": variable.get("name", ""), "type": variable.get("type", ""),
176
+ "value": REDACTED if secret else clean_value(variable.get("name", ""), variable.get("value"), limit)}
177
+ if variable.get("expandable"):
178
+ cleaned["expandable"] = True
179
+ if variable.get("more"):
180
+ cleaned["more"] = variable["more"]
181
+ if variable.get("children"):
182
+ cleaned["children"] = [self._clean_variable(child, limit, secret) for child in variable["children"]]
183
+ return cleaned
184
+
185
+ def _values(self) -> dict[str, str]:
186
+ return {v["name"]: clean_value(v["name"], v.get("value")) for v in self._raw_variables()}
187
+
188
+ # -- tools ---------------------------------------------------------------
189
+
190
+ def list_targets(self, workspace_folder: str | None = None, ide: str | None = None) -> dict:
191
+ found = self._find_windows()
192
+ windows = [w for w in found if w.current and (ide is None or w.ide == ide)]
193
+ if workspace_folder:
194
+ windows = [choose(found, workspace_folder, ide)]
195
+ if not windows:
196
+ choose(found, None, ide) # raises the setup or version hint
197
+ found = []
198
+ for window in windows:
199
+ connection = self._connect(window)
200
+ try:
201
+ folders = [workspace_folder] if workspace_folder else list(window.folders)
202
+ targets = [connection.call("targets", {"folder": f}) for f in folders]
203
+ finally:
204
+ connection.close()
205
+ found.append({"ide": window.ide, "folders": targets})
206
+ return {"windows": found,
207
+ "next": "Call start_session with a launch configuration name, the breakpoints and your goal."}
208
+
209
+ def start_session(self, launch_configuration: str, breakpoints: list[dict], goal: str,
210
+ workspace_folder: str | None = None, build_task: str | None = None,
211
+ max_steps: int = 100, max_minutes: int = 30, wait_seconds: float = 30,
212
+ build_minutes: int = 30, process_id: int | None = None,
213
+ source_folders: list[str] | None = None,
214
+ function_breakpoints: list[str] | None = None, ide: str | None = None) -> dict:
215
+ window = self._window(workspace_folder, ide)
216
+ if self.connection is not None:
217
+ self.connection.close()
218
+ self.connection = self._connect(window)
219
+ args = {"folder": workspace_folder, "launch": launch_configuration, "task": build_task,
220
+ "breakpoints": breakpoints, "goal": goal, "max_steps": max_steps, "max_minutes": max_minutes,
221
+ "build_minutes": build_minutes, "process_id": process_id, "source_folders": source_folders or [],
222
+ "function_breakpoints": function_breakpoints or []}
223
+ try:
224
+ raw = self._call("start", args)["state"]
225
+ except DebugError:
226
+ self.connection.close()
227
+ self.connection = None
228
+ raise
229
+ # The timeline runs until the next report, so it covers every session behind it.
230
+ self.version, self._last_status, self._last_build = 0, None, None
231
+ self.sessions += 1
232
+ self.folder = workspace_folder or (window.folders[0] if len(window.folders) == 1 else None)
233
+ self.plan = {"goal": goal, "launch": launch_configuration, "task": build_task,
234
+ "breakpoints": [f"{b.get('file')}:{b.get('line')}" for b in breakpoints], "ide": window.ide}
235
+ self._log(f"session {self.sessions}", "", f"asked the developer to approve running {launch_configuration!r}"
236
+ + (f" after building {build_task!r}" if build_task else ""))
237
+ self._state(raw)
238
+ return self._state(self._wait(min(max(wait_seconds, 0), 55)))
239
+
240
+ def wait(self, timeout_seconds: float = 30, ask_developer: str | None = None) -> dict:
241
+ if ask_developer is not None and len(ask_developer) > 300:
242
+ raise DebugError("BAD_REQUEST", "ask_developer is at most 300 characters")
243
+ if ask_developer:
244
+ self._log("asked the developer", "", clean_text(ask_developer, 300))
245
+ wait_ms = int(min(max(timeout_seconds, 0), 55) * 1000)
246
+ raw = self._call("state", {"since": self.version, "wait_ms": wait_ms, "ask_developer": ask_developer},
247
+ timeout=wait_ms / 1000 + 10)["state"]
248
+ return self._state(raw)
249
+ return self._state(self._wait(min(max(timeout_seconds, 0), 55)))
250
+
251
+ def step(self, kind: str = "over", count: int = 1, wait_ms: int = 15000) -> dict:
252
+ if kind not in ("over", "into", "out"):
253
+ raise DebugError("BAD_REQUEST", "kind must be over, into or out")
254
+ count = max(1, min(int(count), 20))
255
+ wait_ms = max(0, min(int(wait_ms), 60000))
256
+ before = self._values()
257
+ path = []
258
+ raw = {}
259
+ for _ in range(count):
260
+ raw = self._call("step", {"kind": kind, "wait_ms": wait_ms}, timeout=wait_ms / 1000 + 10)["state"]
261
+ location = raw.get("location")
262
+ if location:
263
+ path.append(f"{location['file']}:{location['line']}")
264
+ if raw.get("status") != "paused":
265
+ break
266
+ after = self._values() if raw.get("status") == "paused" else {}
267
+ changed = [{"name": n, "before": before.get(n, "(new)"), "after": v}
268
+ for n, v in after.items() if before.get(n) != v]
269
+ self._log(f"step {kind}" + (f" ×{len(path)}" if len(path) > 1 else ""), " → ".join(path[-5:]),
270
+ ", ".join(f"{c['name']}: {c['before']} → {c['after']}" for c in changed[:8]))
271
+ result = self._state(raw, quiet=True)
272
+ result["changed"] = changed
273
+ if len(path) > 1:
274
+ result["path"] = path
275
+ if raw.get("status") == "running":
276
+ result["next"] = "It's a slow function: call wait or pause."
277
+ return result
278
+
279
+ def continue_(self, timeout_seconds: float = 30, until: dict | None = None) -> dict:
280
+ """Run to the next stop, or with `until` run past other breakpoints to that line."""
281
+ timeout = min(max(timeout_seconds, 0), 55)
282
+ if until is None:
283
+ self._call("continue")
284
+ return self._state(self._wait(timeout))
285
+ target = f"{until['file']}:{until['line']}"
286
+ existing = {b["file"] + ":" + str(b["line"]) for b in self._call("breakpoints", {})["state"].get("breakpoints", [])}
287
+ if target not in existing:
288
+ self._call("breakpoints", {"add": [{"file": until["file"], "line": until["line"]}]})
289
+ skipped: list[str] = []
290
+ deadline = time.monotonic() + timeout
291
+ try:
292
+ while True:
293
+ self._call("continue")
294
+ raw = self._wait(max(deadline - time.monotonic(), 0), until=("paused", "ended"))
295
+ location = raw.get("location")
296
+ where = f"{location['file']}:{location['line']}" if location else ""
297
+ if raw.get("status") != "paused" or where == target or len(skipped) >= 50 or time.monotonic() >= deadline:
298
+ break
299
+ skipped.append(where)
300
+ finally:
301
+ if target not in existing and self.connection is not None:
302
+ try:
303
+ self._call("breakpoints", {"remove": [{"file": until["file"], "line": until["line"]}]})
304
+ except DebugError:
305
+ pass
306
+ if skipped:
307
+ self._log("continued past", ", ".join(skipped[:5]), f"to reach {target}")
308
+ result = self._state(raw)
309
+ if skipped:
310
+ result["skipped_stops"] = skipped
311
+ return result
312
+
313
+ def pause(self) -> dict:
314
+ self._call("pause")
315
+ return self._state(self._wait(5, until=("paused", "ended")))
316
+
317
+ def threads(self, select: int | None = None) -> dict:
318
+ args = {} if select is None else {"select": select}
319
+ raw = self._call("threads", args)
320
+ result = {"threads": raw.get("threads", []), "current": raw.get("current")}
321
+ if raw.get("state"):
322
+ seen = self._state(raw["state"])
323
+ if seen.get("status"):
324
+ result["status"] = seen["status"]
325
+ if seen.get("location"):
326
+ result["location"] = seen["location"]
327
+ return result
328
+
329
+ def exceptions(self, enable: list[str] | None = None) -> dict:
330
+ return self._call("exceptions", {} if enable is None else {"enable": enable})
331
+
332
+ def inspect(self, names: list[str] | None = None, frame_index: int = 0, depth: int = 0) -> dict:
333
+ frame = None
334
+ if frame_index:
335
+ frames = self._call("stack", {"levels": frame_index + 1}).get("frames", [])
336
+ if frame_index >= len(frames):
337
+ raise DebugError("BAD_REQUEST", f"the stack has {len(frames)} frames")
338
+ frame = frames[frame_index]["id"]
339
+ raw = self._raw_variables(frame, names, depth)
340
+ limit = 2000 if names else SHORT_VALUE
341
+ variables = [self._clean_variable(v, limit) for v in raw]
342
+ self._log("inspected", f"frame {frame_index}", ", ".join(f"{v['name']}={v['value']}" for v in variables[:12]))
343
+ result = {"frame_index": frame_index, "variables": variables}
344
+ if names:
345
+ result["not_found"] = [n for n in names if n not in {v["name"] for v in variables}]
346
+ return result
347
+
348
+ def stack(self, levels: int = 20, start_frame: int = 0) -> dict:
349
+ body = self._call("stack", {"levels": max(1, min(int(levels), 200)), "start_frame": max(0, int(start_frame))})
350
+ frames = body.get("frames", [])
351
+ return {"total": body.get("total", len(frames)),
352
+ "frames": [{"index": start_frame + i, "function": clean_text(f.get("name"), 200),
353
+ "location": "(no source)" if f.get("file") in (None, "(no source)")
354
+ else clean_text(f"{f.get('file')}:{f.get('line')}", 300)}
355
+ for i, f in enumerate(frames)]}
356
+
357
+ def breakpoints(self, add: list[dict] | None = None, remove: list[dict] | None = None,
358
+ add_functions: list[str] | None = None, remove_functions: list[str] | None = None) -> dict:
359
+ raw = self._call("breakpoints", {"add": add or [], "remove": remove or [],
360
+ "add_functions": add_functions or [], "remove_functions": remove_functions or []})["state"]
361
+ changes = [f"{verb} " + ", ".join(f"{b.get('file')}:{b.get('line')}" for b in items)
362
+ for verb, items in (("added", add), ("removed", remove)) if items]
363
+ if add_functions:
364
+ changes.append("added functions " + ", ".join(add_functions))
365
+ if remove_functions:
366
+ changes.append("removed functions " + ", ".join(remove_functions))
367
+ if changes:
368
+ self._log("breakpoints", "", "; ".join(changes))
369
+ return {"breakpoints": self._state(raw).get("breakpoints", [])}
370
+
371
+ def trace(self, file: str, line: int, variables: list[str], hits: int = 10, after: bool = True,
372
+ timeout_seconds: float = 20) -> dict:
373
+ """Run to `file:line` up to `hits` times. Each row has the caller and each variable as
374
+ `before → after` the line runs (one step over per hit when `after`). Stops early,
375
+ and returns the rows so far, when another breakpoint pauses the program."""
376
+ hits = max(1, min(int(hits), 50))
377
+ target = f"{file}:{line}"
378
+ existing = {f"{b['file']}:{b['line']}" for b in self._call("breakpoints", {})["state"].get("breakpoints", [])}
379
+ raw = self._call("breakpoints", {"add": [{"file": file, "line": line}]})["state"]
380
+ rows: list[dict] = []
381
+ notes: list[str] = []
382
+
383
+ def where(state: dict) -> str:
384
+ location = state.get("location")
385
+ return f"{location['file']}:{location['line']}" if location else ""
386
+
387
+ def values() -> dict[str, str]:
388
+ return {v["name"]: clean_value(v["name"], v.get("value"), SHORT_VALUE)
389
+ for v in self._raw_variables(names=list(variables))}
390
+
391
+ def record() -> dict:
392
+ frames = self._call("stack", {"levels": 2}).get("frames", [])
393
+ caller = clean_text(f"{frames[1]['file']}:{frames[1]['line']}", 300) if len(frames) > 1 else ""
394
+ before, after_values, state = values(), None, raw
395
+ if after:
396
+ try:
397
+ state = self._call("step", {"kind": "over"}, timeout=20)["state"]
398
+ after_values = values() if state.get("status") == "paused" else None
399
+ except DebugError as exc:
400
+ notes.append(f"no after-values: {exc.message}")
401
+ row = {"hit": len(rows) + 1, "caller": caller}
402
+ for name in variables:
403
+ old = before.get(name, "(not defined)")
404
+ new = after_values.get(name, "(not defined)") if after_values is not None else old
405
+ row[name] = old if new == old else f"{old} → {new}"
406
+ rows.append(row)
407
+ return state
408
+
409
+ consumed, first = False, True
410
+ try:
411
+ for _ in range(hits * 4 + 4):
412
+ if len(rows) >= hits:
413
+ break
414
+ status = raw.get("status")
415
+ if status == "paused" and where(raw) == target and not consumed:
416
+ raw = record()
417
+ consumed = not after
418
+ continue
419
+ if status == "paused" and not first and raw.get("reason") == "breakpoint" and where(raw) != target:
420
+ notes.append(f"stopped at {where(raw)} (another breakpoint); returning the rows so far")
421
+ break
422
+ if status == "paused":
423
+ self._call("continue")
424
+ elif status != "running":
425
+ break
426
+ first, consumed = False, False
427
+ raw = self._wait(timeout_seconds, until=("paused", "ended"))
428
+ if raw.get("status") == "running":
429
+ notes.append(f"{target} was not reached within {timeout_seconds:g} s")
430
+ break
431
+ finally:
432
+ if target not in existing and raw.get("status") != "ended":
433
+ try:
434
+ raw = self._call("breakpoints", {"remove": [{"file": file, "line": line}]})["state"]
435
+ except DebugError:
436
+ pass
437
+ self._log("trace", target, "; ".join(
438
+ ", ".join(f"{k}={v}" for k, v in row.items() if k not in ("hit", "caller")) for row in rows[:10]))
439
+ result = self._state(raw, quiet=True)
440
+ result.update({"trace": target, "rows": rows})
441
+ if len(rows) < hits and not notes:
442
+ notes.append(f"{len(rows)} of {hits} hits: the program ended first")
443
+ if notes:
444
+ result["note"] = "; ".join(notes)
445
+ return result
446
+
447
+ def stop_session(self) -> dict:
448
+ raw = self._call("stop")["state"]
449
+ return self._state(raw)
450
+
451
+ def write_report(self, title: str, problem: str, root_cause: str, solution: str, confidence: str,
452
+ how_confirmed: str | None = None, verification: str | None = None,
453
+ open_questions: list[str] | None = None, workspace_folder: str | None = None) -> dict:
454
+ folder = workspace_folder or self.folder
455
+ if not folder:
456
+ raise DebugError("BAD_REQUEST", "pass workspace_folder: there is no session to take it from")
457
+ if confidence not in ("confirmed", "likely", "unconfirmed"):
458
+ raise DebugError("BAD_REQUEST", "confidence is confirmed, likely or unconfirmed")
459
+ if confidence == "confirmed" and not how_confirmed:
460
+ raise DebugError("BAD_REQUEST", "confirmed needs how_confirmed (for example: the fix makes the failing test pass)")
461
+ path = report.write(folder, report.Report(
462
+ title=title, problem=problem, root_cause=root_cause, solution=solution, confidence=confidence,
463
+ how_confirmed=how_confirmed, verification=verification, open_questions=list(open_questions or []),
464
+ plan=self.plan, timeline=list(self.timeline),
465
+ ))
466
+ self.timeline, self.sessions = [], 0
467
+ return {"report_file": str(path), "next": "Tell the developer where the report is and summarize it."}
468
+
469
+ def close(self) -> None:
470
+ if self.connection is not None:
471
+ self.connection.close()
472
+ self.connection = None