apdb 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.
apdb/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .debugger import APDBError, AgentPdbSession, set_trace
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["APDBError", "AgentPdbSession", "__version__", "set_trace"]
apdb/cli.py ADDED
@@ -0,0 +1,57 @@
1
+ import argparse
2
+ import json
3
+ import socket
4
+ import sys
5
+
6
+ from .protocol import dumps_response, loads_json_line
7
+
8
+
9
+ DEFAULT_HOST = "127.0.0.1"
10
+ DEFAULT_TIMEOUT = 2.0
11
+
12
+
13
+ def build_parser():
14
+ shared = argparse.ArgumentParser(add_help=False)
15
+ shared.add_argument("--host", default=DEFAULT_HOST)
16
+ shared.add_argument("--port", type=int, required=True)
17
+ shared.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
18
+
19
+ parser = argparse.ArgumentParser(prog="apdb_cli")
20
+ subparsers = parser.add_subparsers(dest="cmd", required=True)
21
+ for name in ("ping", "state", "where", "locals", "next", "step", "continue", "quit"):
22
+ subparsers.add_parser(name, parents=[shared])
23
+
24
+ eval_parser = subparsers.add_parser("eval", parents=[shared])
25
+ eval_parser.add_argument("expr")
26
+ return parser
27
+
28
+
29
+ def make_request(args):
30
+ request = {"id": 1, "cmd": args.cmd}
31
+ if args.cmd == "eval":
32
+ request["expr"] = args.expr
33
+ return request
34
+
35
+
36
+ def send_command(host, port, request, timeout=DEFAULT_TIMEOUT):
37
+ with socket.create_connection((host, port), timeout=timeout) as sock:
38
+ sock.sendall(dumps_response(request))
39
+ with sock.makefile("rb") as reader:
40
+ return loads_json_line(reader.readline())
41
+
42
+
43
+ def main(argv=None):
44
+ parser = build_parser()
45
+ args = parser.parse_args(argv)
46
+ try:
47
+ response = send_command(args.host, args.port, make_request(args), args.timeout)
48
+ except OSError as exc:
49
+ print(f"connection failed: {exc}", file=sys.stderr)
50
+ return 2
51
+
52
+ print(json.dumps(response, sort_keys=True))
53
+ return 0 if response.get("ok") else 1
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
apdb/debugger.py ADDED
@@ -0,0 +1,194 @@
1
+ import bdb
2
+ import linecache
3
+ import sys
4
+ import threading
5
+
6
+ from .protocol import error_response, ok_response
7
+ from .server import APDBTCPServer
8
+
9
+
10
+ RELEASE_COMMANDS = {"continue", "next", "step", "quit"}
11
+
12
+
13
+ class APDBError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class AgentPdbSession:
18
+ def __init__(self, host, port, header=None):
19
+ self.host = host
20
+ self.port = port
21
+ self.header = header
22
+ self._condition = threading.Condition()
23
+ self._release_event = threading.Event()
24
+ self._server = None
25
+ self._server_thread = None
26
+ self._current_frame = None
27
+ self._current_action = None
28
+ self._done = False
29
+ self._paused = False
30
+ self._trace_mode = None
31
+ self._next_frame = None
32
+
33
+ def start(self):
34
+ try:
35
+ self._server = APDBTCPServer((self.host, self.port), self)
36
+ except OSError as exc:
37
+ raise APDBError(f"could not start apdb server on {self.host}:{self.port}: {exc}") from exc
38
+
39
+ self._server_thread = threading.Thread(
40
+ target=self._server.serve_forever,
41
+ name=f"apdb:{self.host}:{self.port}",
42
+ daemon=True,
43
+ )
44
+ self._server_thread.start()
45
+
46
+ def close(self):
47
+ self._done = True
48
+ if self._server is not None:
49
+ self._server.shutdown()
50
+ self._server.server_close()
51
+ self._server = None
52
+
53
+ def pause(self, frame):
54
+ with self._condition:
55
+ self._current_frame = frame
56
+ self._current_action = None
57
+ self._paused = True
58
+ self._release_event.clear()
59
+ self._condition.notify_all()
60
+
61
+ self._release_event.wait()
62
+
63
+ with self._condition:
64
+ self._paused = False
65
+ return self._current_action
66
+
67
+ def handle_request(self, request):
68
+ cmd = request["cmd"]
69
+ try:
70
+ if cmd == "ping":
71
+ return ok_response(request, {"status": "online"})
72
+ if cmd == "state":
73
+ return ok_response(request, self._state())
74
+ if cmd == "where":
75
+ return ok_response(request, {"frames": self._stack()})
76
+ if cmd == "locals":
77
+ return ok_response(request, self._locals())
78
+ if cmd == "eval":
79
+ return ok_response(request, self._eval(request.get("expr", "")))
80
+ if cmd in RELEASE_COMMANDS:
81
+ return self._release(request, cmd)
82
+ return error_response(request, "unknown_command", f"unknown command: {cmd}")
83
+ except Exception as exc:
84
+ return error_response(request, "command_error", f"{type(exc).__name__}: {exc}")
85
+
86
+ def trace_dispatch(self, frame, event, arg):
87
+ if self._done:
88
+ return None
89
+ if self._should_pause_for_trace(frame, event):
90
+ action = self.pause(frame)
91
+ return self._after_action(action, frame)
92
+ return self.trace_dispatch
93
+
94
+ def _release(self, request, action):
95
+ with self._condition:
96
+ self._current_action = action
97
+ self._release_event.set()
98
+ status = {
99
+ "continue": "continuing",
100
+ "next": "next",
101
+ "step": "step",
102
+ "quit": "quitting",
103
+ }[action]
104
+ return ok_response(request, {"status": status})
105
+
106
+ def _state(self):
107
+ frame = self._current_frame
108
+ result = {"status": "paused" if self._paused else "running"}
109
+ if frame is not None:
110
+ result.update(self._frame_info(frame, index=0))
111
+ return result
112
+
113
+ def _stack(self):
114
+ frames = []
115
+ frame = self._current_frame
116
+ index = 0
117
+ while frame is not None:
118
+ frames.append(self._frame_info(frame, index=index))
119
+ frame = frame.f_back
120
+ index += 1
121
+ return frames
122
+
123
+ def _locals(self):
124
+ frame = self._require_frame()
125
+ return {name: safe_repr(value) for name, value in sorted(frame.f_locals.items())}
126
+
127
+ def _eval(self, expr):
128
+ if not expr:
129
+ raise ValueError("eval command requires expr")
130
+ frame = self._require_frame()
131
+ value = eval(expr, frame.f_globals, frame.f_locals)
132
+ return {"repr": safe_repr(value), "type": type(value).__name__}
133
+
134
+ def _require_frame(self):
135
+ if self._current_frame is None:
136
+ raise RuntimeError("debugger is not paused at a frame")
137
+ return self._current_frame
138
+
139
+ def _frame_info(self, frame, index):
140
+ filename = frame.f_code.co_filename
141
+ line = linecache.getline(filename, frame.f_lineno).strip()
142
+ return {
143
+ "index": index,
144
+ "file": filename,
145
+ "line": frame.f_lineno,
146
+ "function": frame.f_code.co_name,
147
+ "code": line,
148
+ }
149
+
150
+ def _should_pause_for_trace(self, frame, event):
151
+ if event != "line":
152
+ return False
153
+ if self._trace_mode == "step":
154
+ return True
155
+ if self._trace_mode == "next":
156
+ return frame is self._next_frame
157
+ return False
158
+
159
+ def _after_action(self, action, frame):
160
+ if action == "continue":
161
+ self.close()
162
+ sys.settrace(None)
163
+ return None
164
+ if action == "quit":
165
+ self.close()
166
+ sys.settrace(None)
167
+ raise bdb.BdbQuit()
168
+ if action in {"next", "step"}:
169
+ self._trace_mode = action
170
+ self._next_frame = frame
171
+ frame.f_trace = self.trace_dispatch
172
+ sys.settrace(self.trace_dispatch)
173
+ return self.trace_dispatch
174
+ self.close()
175
+ sys.settrace(None)
176
+ return None
177
+
178
+
179
+ def safe_repr(value):
180
+ try:
181
+ return repr(value)
182
+ except Exception:
183
+ return f"<unrepresentable {type(value).__name__}>"
184
+
185
+
186
+ def set_trace(host="127.0.0.1", port=None, header=None):
187
+ if port is None:
188
+ raise TypeError("apdb.set_trace() missing required keyword argument: 'port'")
189
+
190
+ frame = sys._getframe().f_back
191
+ session = AgentPdbSession(host=host, port=port, header=header)
192
+ session.start()
193
+ action = session.pause(frame)
194
+ return session._after_action(action, frame)
apdb/protocol.py ADDED
@@ -0,0 +1,45 @@
1
+ import json
2
+
3
+
4
+ class ProtocolError(ValueError):
5
+ def __init__(self, code, message):
6
+ super().__init__(message)
7
+ self.code = code
8
+ self.message = message
9
+
10
+
11
+ def loads_request(line):
12
+ request = loads_json_line(line)
13
+ if not isinstance(request, dict):
14
+ raise ProtocolError("invalid_request", "request must be a JSON object")
15
+ if not request.get("cmd"):
16
+ raise ProtocolError("missing_command", "request must include cmd")
17
+ if not isinstance(request["cmd"], str):
18
+ raise ProtocolError("invalid_command", "cmd must be a string")
19
+ return request
20
+
21
+
22
+ def loads_json_line(line):
23
+ try:
24
+ payload = json.loads(line.decode("utf-8"))
25
+ except UnicodeDecodeError as exc:
26
+ raise ProtocolError("invalid_encoding", str(exc)) from exc
27
+ except json.JSONDecodeError as exc:
28
+ raise ProtocolError("invalid_json", str(exc)) from exc
29
+ return payload
30
+
31
+
32
+ def dumps_response(response):
33
+ return (json.dumps(response, sort_keys=True) + "\n").encode("utf-8")
34
+
35
+
36
+ def ok_response(request, result):
37
+ return {"id": request.get("id"), "ok": True, "result": result}
38
+
39
+
40
+ def error_response(request, code, message):
41
+ return {
42
+ "id": request.get("id"),
43
+ "ok": False,
44
+ "error": {"code": code, "message": message},
45
+ }
apdb/server.py ADDED
@@ -0,0 +1,24 @@
1
+ import socketserver
2
+
3
+ from .protocol import ProtocolError, dumps_response, error_response, loads_request
4
+
5
+
6
+ class APDBRequestHandler(socketserver.StreamRequestHandler):
7
+ def handle(self):
8
+ line = self.rfile.readline()
9
+ try:
10
+ request = loads_request(line)
11
+ except ProtocolError as exc:
12
+ response = error_response({"id": None}, exc.code, exc.message)
13
+ else:
14
+ response = self.server.session.handle_request(request)
15
+ self.wfile.write(dumps_response(response))
16
+
17
+
18
+ class APDBTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
19
+ allow_reuse_address = True
20
+ daemon_threads = True
21
+
22
+ def __init__(self, server_address, session):
23
+ self.session = session
24
+ super().__init__(server_address, APDBRequestHandler)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: apdb
3
+ Version: 0.1.0
4
+ Summary: Agent-friendly remote Python debugger over a zero-dependency TCP API
5
+ Author: apdb contributors
6
+ License-Expression: BSD-2-Clause
7
+ Keywords: debugger,pdb,agent,tcp,remote-debugger
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Debuggers
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # apdb
25
+
26
+ `apdb` is an agent-friendly Python debugger inspired by `remote-pdb`.
27
+ It keeps the Python call site close to `pdb`, but exposes debugger control over
28
+ a newline-delimited JSON TCP API instead of a human interactive shell.
29
+
30
+ The runtime and CLI use only the Python standard library.
31
+
32
+ ## Python API
33
+
34
+ ```python
35
+ import apdb
36
+
37
+ answer = 41
38
+ apdb.set_trace(port=4444)
39
+ print(answer + 1)
40
+ ```
41
+
42
+ `set_trace()` blocks the target process whether or not a client is connected.
43
+ The process resumes only after the TCP API receives a release command such as
44
+ `continue`, `next`, `step`, or `quit`.
45
+
46
+ By default, `apdb` binds to `127.0.0.1` and uses no authentication:
47
+
48
+ ```python
49
+ apdb.set_trace(port=4444, host="127.0.0.1")
50
+ ```
51
+
52
+ ## CLI
53
+
54
+ The package installs `apdb_cli`:
55
+
56
+ ```bash
57
+ apdb_cli ping --port 4444
58
+ apdb_cli state --port 4444
59
+ apdb_cli where --port 4444
60
+ apdb_cli locals --port 4444
61
+ apdb_cli eval 'answer + 1' --port 4444
62
+ apdb_cli next --port 4444
63
+ apdb_cli step --port 4444
64
+ apdb_cli continue --port 4444
65
+ ```
66
+
67
+ The CLI prints one JSON response to stdout and exits nonzero for connection
68
+ failures or API errors.
69
+
70
+ ## TCP API
71
+
72
+ The TCP API speaks newline-delimited JSON. Each request and response is one JSON
73
+ object followed by `\n`.
74
+
75
+ Request:
76
+
77
+ ```json
78
+ {"id": 1, "cmd": "state"}
79
+ ```
80
+
81
+ Response:
82
+
83
+ ```json
84
+ {"id": 1, "ok": true, "result": {"status": "paused"}}
85
+ ```
86
+
87
+ Supported v0 commands:
88
+
89
+ - `ping`
90
+ - `state`
91
+ - `where`
92
+ - `locals`
93
+ - `eval`
94
+ - `next`
95
+ - `step`
96
+ - `continue`
97
+ - `quit`
98
+
99
+ ## Security
100
+
101
+ `apdb` has no authentication in v0. Debugger access can inspect and evaluate
102
+ code inside the target process. Bind to `127.0.0.1` unless you explicitly want
103
+ to expose that control surface.
@@ -0,0 +1,11 @@
1
+ apdb/__init__.py,sha256=xLXWQawoqb5qFQnFuULx9kd2v1FoQfOLg905iMN6Eew,155
2
+ apdb/cli.py,sha256=maNWSdMO4FMKOtlGrvkp5y-P5DO8l2DyG1ah7fIciOk,1679
3
+ apdb/debugger.py,sha256=LFbPCKWOuU5Z_cuZ_QiGN6398nT8ifohxBmsyDslxSA,6153
4
+ apdb/protocol.py,sha256=etPnmmVPaIn64C1hF4HxTTUNMn3yOgAW-k4s522xSzo,1293
5
+ apdb/server.py,sha256=-NmcEBHChmfEUs1EbI9GHax94vSmZ4pdfDxB-YHjKcQ,800
6
+ apdb-0.1.0.dist-info/licenses/LICENSE,sha256=lvGLyG5yWbZsUmKNvQoD1wcjE1lpKc2rNTP1L25V0MI,1306
7
+ apdb-0.1.0.dist-info/METADATA,sha256=wkiX5VyhdbPNGw6e56e_Hhiu7yBClQC1f4xRHJ04kz4,2535
8
+ apdb-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ apdb-0.1.0.dist-info/entry_points.txt,sha256=DZTiyDDhf5HZdZcEWKzw-nY2SNm_NUH6bY3BumeHARc,43
10
+ apdb-0.1.0.dist-info/top_level.txt,sha256=FcE0oU1p-OxPclBZ9-Ux3fedckbLNW1-FCK7UB-JRkU,5
11
+ apdb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apdb_cli = apdb.cli:main
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, apdb contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ apdb