apdb 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
apdb-0.1.0/PKG-INFO ADDED
@@ -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.
apdb-0.1.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # apdb
2
+
3
+ `apdb` is an agent-friendly Python debugger inspired by `remote-pdb`.
4
+ It keeps the Python call site close to `pdb`, but exposes debugger control over
5
+ a newline-delimited JSON TCP API instead of a human interactive shell.
6
+
7
+ The runtime and CLI use only the Python standard library.
8
+
9
+ ## Python API
10
+
11
+ ```python
12
+ import apdb
13
+
14
+ answer = 41
15
+ apdb.set_trace(port=4444)
16
+ print(answer + 1)
17
+ ```
18
+
19
+ `set_trace()` blocks the target process whether or not a client is connected.
20
+ The process resumes only after the TCP API receives a release command such as
21
+ `continue`, `next`, `step`, or `quit`.
22
+
23
+ By default, `apdb` binds to `127.0.0.1` and uses no authentication:
24
+
25
+ ```python
26
+ apdb.set_trace(port=4444, host="127.0.0.1")
27
+ ```
28
+
29
+ ## CLI
30
+
31
+ The package installs `apdb_cli`:
32
+
33
+ ```bash
34
+ apdb_cli ping --port 4444
35
+ apdb_cli state --port 4444
36
+ apdb_cli where --port 4444
37
+ apdb_cli locals --port 4444
38
+ apdb_cli eval 'answer + 1' --port 4444
39
+ apdb_cli next --port 4444
40
+ apdb_cli step --port 4444
41
+ apdb_cli continue --port 4444
42
+ ```
43
+
44
+ The CLI prints one JSON response to stdout and exits nonzero for connection
45
+ failures or API errors.
46
+
47
+ ## TCP API
48
+
49
+ The TCP API speaks newline-delimited JSON. Each request and response is one JSON
50
+ object followed by `\n`.
51
+
52
+ Request:
53
+
54
+ ```json
55
+ {"id": 1, "cmd": "state"}
56
+ ```
57
+
58
+ Response:
59
+
60
+ ```json
61
+ {"id": 1, "ok": true, "result": {"status": "paused"}}
62
+ ```
63
+
64
+ Supported v0 commands:
65
+
66
+ - `ping`
67
+ - `state`
68
+ - `where`
69
+ - `locals`
70
+ - `eval`
71
+ - `next`
72
+ - `step`
73
+ - `continue`
74
+ - `quit`
75
+
76
+ ## Security
77
+
78
+ `apdb` has no authentication in v0. Debugger access can inspect and evaluate
79
+ code inside the target process. Bind to `127.0.0.1` unless you explicitly want
80
+ to expose that control surface.
@@ -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-0.1.0/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())
@@ -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)
@@ -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
+ }
@@ -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,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ apdb/__init__.py
5
+ apdb/cli.py
6
+ apdb/debugger.py
7
+ apdb/protocol.py
8
+ apdb/server.py
9
+ apdb.egg-info/PKG-INFO
10
+ apdb.egg-info/SOURCES.txt
11
+ apdb.egg-info/dependency_links.txt
12
+ apdb.egg-info/entry_points.txt
13
+ apdb.egg-info/top_level.txt
14
+ tests/test_cli.py
15
+ tests/test_integration.py
16
+ tests/test_protocol.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apdb_cli = apdb.cli:main
@@ -0,0 +1 @@
1
+ apdb
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "apdb"
7
+ version = "0.1.0"
8
+ description = "Agent-friendly remote Python debugger over a zero-dependency TCP API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "BSD-2-Clause"
12
+ authors = [
13
+ {name = "apdb contributors"}
14
+ ]
15
+ keywords = ["debugger", "pdb", "agent", "tcp", "remote-debugger"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Debuggers",
28
+ ]
29
+ dependencies = []
30
+ license-files = ["LICENSE"]
31
+
32
+ [project.scripts]
33
+ apdb_cli = "apdb.cli:main"
34
+
35
+ [tool.setuptools]
36
+ packages = ["apdb"]
apdb-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ import contextlib
2
+ import io
3
+ import json
4
+ import socket
5
+ import socketserver
6
+ import threading
7
+ import unittest
8
+
9
+ from apdb import cli
10
+
11
+
12
+ class OneShotHandler(socketserver.StreamRequestHandler):
13
+ response = {"id": 1, "ok": True, "result": {"status": "online"}}
14
+ seen_request = None
15
+
16
+ def handle(self):
17
+ line = self.rfile.readline()
18
+ type(self).seen_request = json.loads(line.decode("utf-8"))
19
+ self.wfile.write((json.dumps(type(self).response) + "\n").encode("utf-8"))
20
+
21
+
22
+ class CLITests(unittest.TestCase):
23
+ def run_server(self):
24
+ server = socketserver.TCPServer(("127.0.0.1", 0), OneShotHandler)
25
+ thread = threading.Thread(target=server.handle_request, daemon=True)
26
+ thread.start()
27
+ self.addCleanup(server.server_close)
28
+ return server, thread
29
+
30
+ def test_send_command_exchanges_one_ndjson_request(self):
31
+ server, thread = self.run_server()
32
+ host, port = server.server_address
33
+
34
+ response = cli.send_command(host, port, {"id": 1, "cmd": "ping"}, timeout=1.0)
35
+ thread.join(timeout=1.0)
36
+
37
+ self.assertEqual(response, OneShotHandler.response)
38
+ self.assertEqual(OneShotHandler.seen_request, {"id": 1, "cmd": "ping"})
39
+
40
+ def test_main_prints_json_response(self):
41
+ server, thread = self.run_server()
42
+ host, port = server.server_address
43
+ stdout = io.StringIO()
44
+
45
+ with contextlib.redirect_stdout(stdout):
46
+ exit_code = cli.main(["ping", "--host", host, "--port", str(port)])
47
+ thread.join(timeout=1.0)
48
+
49
+ self.assertEqual(exit_code, 0)
50
+ self.assertEqual(json.loads(stdout.getvalue()), OneShotHandler.response)
51
+
52
+ def test_main_returns_nonzero_when_connection_fails(self):
53
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
54
+ sock.bind(("127.0.0.1", 0))
55
+ host, port = sock.getsockname()
56
+ stderr = io.StringIO()
57
+
58
+ with contextlib.redirect_stderr(stderr):
59
+ exit_code = cli.main(["ping", "--host", host, "--port", str(port)])
60
+
61
+ self.assertEqual(exit_code, 2)
62
+ self.assertIn("connection failed", stderr.getvalue())
63
+
64
+ def test_eval_command_sends_expression(self):
65
+ server, thread = self.run_server()
66
+ host, port = server.server_address
67
+ stdout = io.StringIO()
68
+
69
+ with contextlib.redirect_stdout(stdout):
70
+ exit_code = cli.main(["eval", "x + 1", "--host", host, "--port", str(port)])
71
+ thread.join(timeout=1.0)
72
+
73
+ self.assertEqual(exit_code, 0)
74
+ self.assertEqual(
75
+ OneShotHandler.seen_request,
76
+ {"id": 1, "cmd": "eval", "expr": "x + 1"},
77
+ )
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main()
@@ -0,0 +1,212 @@
1
+ import os
2
+ import socket
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import textwrap
7
+ import time
8
+ import unittest
9
+
10
+ from apdb.cli import send_command
11
+
12
+
13
+ def unused_port():
14
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
15
+ sock.bind(("127.0.0.1", 0))
16
+ return sock.getsockname()[1]
17
+
18
+
19
+ def wait_for_ping(port, timeout=5.0):
20
+ deadline = time.monotonic() + timeout
21
+ last_error = None
22
+ while time.monotonic() < deadline:
23
+ try:
24
+ return send_command("127.0.0.1", port, {"id": 1, "cmd": "ping"}, timeout=0.2)
25
+ except OSError as exc:
26
+ last_error = exc
27
+ time.sleep(0.05)
28
+ raise AssertionError(f"apdb service did not become ready: {last_error}")
29
+
30
+
31
+ class IntegrationTests(unittest.TestCase):
32
+ def start_debuggee(self, source):
33
+ tempdir = tempfile.TemporaryDirectory()
34
+ self.addCleanup(tempdir.cleanup)
35
+ script = os.path.join(tempdir.name, "debuggee.py")
36
+ with open(script, "w", encoding="utf-8") as handle:
37
+ handle.write(source)
38
+
39
+ env = os.environ.copy()
40
+ repo_root = os.path.dirname(os.path.dirname(__file__))
41
+ env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
42
+ process = subprocess.Popen(
43
+ [sys.executable, script],
44
+ cwd=tempdir.name,
45
+ env=env,
46
+ stdout=subprocess.PIPE,
47
+ stderr=subprocess.PIPE,
48
+ text=True,
49
+ )
50
+ self.addCleanup(self.cleanup_process, process)
51
+ return process
52
+
53
+ def cleanup_process(self, process):
54
+ if process.poll() is None:
55
+ process.terminate()
56
+ try:
57
+ process.wait(timeout=2)
58
+ except subprocess.TimeoutExpired:
59
+ process.kill()
60
+ process.wait(timeout=2)
61
+ if process.stdout is not None:
62
+ process.stdout.close()
63
+ if process.stderr is not None:
64
+ process.stderr.close()
65
+
66
+ def test_set_trace_serves_state_locals_eval_and_continue(self):
67
+ port = unused_port()
68
+ source = textwrap.dedent(
69
+ f"""
70
+ import apdb
71
+
72
+ def main():
73
+ answer = 41
74
+ print("before", flush=True)
75
+ apdb.set_trace(port={port})
76
+ print("after", answer + 1, flush=True)
77
+
78
+ main()
79
+ """
80
+ )
81
+ process = self.start_debuggee(source)
82
+
83
+ self.assertEqual(process.stdout.readline().strip(), "before")
84
+ self.assertEqual(wait_for_ping(port)["result"], {"status": "online"})
85
+
86
+ state = send_command("127.0.0.1", port, {"id": 2, "cmd": "state"}, timeout=1.0)
87
+ self.assertTrue(state["ok"])
88
+ self.assertEqual(state["result"]["status"], "paused")
89
+ self.assertEqual(state["result"]["function"], "main")
90
+ self.assertEqual(os.path.basename(state["result"]["file"]), "debuggee.py")
91
+
92
+ locals_response = send_command(
93
+ "127.0.0.1", port, {"id": 3, "cmd": "locals"}, timeout=1.0
94
+ )
95
+ self.assertEqual(locals_response["result"]["answer"], "41")
96
+
97
+ eval_response = send_command(
98
+ "127.0.0.1", port, {"id": 4, "cmd": "eval", "expr": "answer + 1"}, timeout=1.0
99
+ )
100
+ self.assertEqual(eval_response["result"]["repr"], "42")
101
+
102
+ continue_response = send_command(
103
+ "127.0.0.1", port, {"id": 5, "cmd": "continue"}, timeout=1.0
104
+ )
105
+ self.assertTrue(continue_response["ok"])
106
+ self.assertEqual(continue_response["result"]["status"], "continuing")
107
+
108
+ self.assertEqual(process.stdout.readline().strip(), "after 42")
109
+ self.assertEqual(process.wait(timeout=2), 0)
110
+
111
+ def test_unknown_command_returns_structured_error(self):
112
+ port = unused_port()
113
+ source = textwrap.dedent(
114
+ f"""
115
+ import apdb
116
+ print("before", flush=True)
117
+ apdb.set_trace(port={port})
118
+ print("after", flush=True)
119
+ """
120
+ )
121
+ process = self.start_debuggee(source)
122
+
123
+ self.assertEqual(process.stdout.readline().strip(), "before")
124
+ wait_for_ping(port)
125
+ response = send_command("127.0.0.1", port, {"id": 9, "cmd": "bad"}, timeout=1.0)
126
+
127
+ self.assertFalse(response["ok"])
128
+ self.assertEqual(response["error"]["code"], "unknown_command")
129
+ send_command("127.0.0.1", port, {"id": 10, "cmd": "continue"}, timeout=1.0)
130
+ self.assertEqual(process.wait(timeout=2), 0)
131
+
132
+ def test_next_releases_to_next_line_and_pauses_again(self):
133
+ port = unused_port()
134
+ source = textwrap.dedent(
135
+ f"""
136
+ import apdb
137
+
138
+ def main():
139
+ value = 1
140
+ print("before", flush=True)
141
+ apdb.set_trace(port={port})
142
+ value = value + 1
143
+ print("middle", value, flush=True)
144
+ print("after", flush=True)
145
+
146
+ main()
147
+ """
148
+ )
149
+ process = self.start_debuggee(source)
150
+
151
+ self.assertEqual(process.stdout.readline().strip(), "before")
152
+ wait_for_ping(port)
153
+ next_response = send_command(
154
+ "127.0.0.1", port, {"id": 11, "cmd": "next"}, timeout=1.0
155
+ )
156
+ self.assertTrue(next_response["ok"])
157
+
158
+ state = send_command("127.0.0.1", port, {"id": 12, "cmd": "state"}, timeout=2.0)
159
+ self.assertEqual(state["result"]["status"], "paused")
160
+ self.assertIn("value = value + 1", state["result"]["code"])
161
+
162
+ send_command("127.0.0.1", port, {"id": 13, "cmd": "continue"}, timeout=1.0)
163
+ self.assertEqual(process.stdout.readline().strip(), "middle 2")
164
+ self.assertEqual(process.stdout.readline().strip(), "after")
165
+ self.assertEqual(process.wait(timeout=2), 0)
166
+
167
+ def test_step_enters_called_function_and_pauses_again(self):
168
+ port = unused_port()
169
+ source = textwrap.dedent(
170
+ f"""
171
+ import apdb
172
+
173
+ def helper():
174
+ marker = "inside"
175
+ return marker
176
+
177
+ def main():
178
+ print("before", flush=True)
179
+ apdb.set_trace(port={port})
180
+ result = helper()
181
+ print("after", result, flush=True)
182
+
183
+ main()
184
+ """
185
+ )
186
+ process = self.start_debuggee(source)
187
+
188
+ self.assertEqual(process.stdout.readline().strip(), "before")
189
+ wait_for_ping(port)
190
+ step_response = send_command(
191
+ "127.0.0.1", port, {"id": 14, "cmd": "step"}, timeout=1.0
192
+ )
193
+ self.assertTrue(step_response["ok"])
194
+
195
+ state = send_command("127.0.0.1", port, {"id": 15, "cmd": "state"}, timeout=2.0)
196
+ self.assertEqual(state["result"]["status"], "paused")
197
+ self.assertIn("result = helper()", state["result"]["code"])
198
+
199
+ step_into_response = send_command(
200
+ "127.0.0.1", port, {"id": 16, "cmd": "step"}, timeout=1.0
201
+ )
202
+ self.assertTrue(step_into_response["ok"])
203
+ state = send_command("127.0.0.1", port, {"id": 17, "cmd": "state"}, timeout=2.0)
204
+ self.assertEqual(state["result"]["function"], "helper")
205
+
206
+ send_command("127.0.0.1", port, {"id": 18, "cmd": "continue"}, timeout=1.0)
207
+ self.assertEqual(process.stdout.readline().strip(), "after inside")
208
+ self.assertEqual(process.wait(timeout=2), 0)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ unittest.main()
@@ -0,0 +1,62 @@
1
+ import json
2
+ import unittest
3
+
4
+ from apdb.protocol import (
5
+ ProtocolError,
6
+ dumps_response,
7
+ error_response,
8
+ loads_request,
9
+ ok_response,
10
+ )
11
+
12
+
13
+ class ProtocolTests(unittest.TestCase):
14
+ def test_loads_request_accepts_json_object_with_command(self):
15
+ request = loads_request(b'{"id": 7, "cmd": "ping"}\n')
16
+
17
+ self.assertEqual(request, {"id": 7, "cmd": "ping"})
18
+
19
+ def test_loads_request_rejects_invalid_json(self):
20
+ with self.assertRaises(ProtocolError) as context:
21
+ loads_request(b"{not-json}\n")
22
+
23
+ self.assertEqual(context.exception.code, "invalid_json")
24
+
25
+ def test_loads_request_rejects_missing_command(self):
26
+ with self.assertRaises(ProtocolError) as context:
27
+ loads_request(b'{"id": 1}\n')
28
+
29
+ self.assertEqual(context.exception.code, "missing_command")
30
+
31
+ def test_dumps_response_writes_newline_delimited_json(self):
32
+ payload = dumps_response({"id": 1, "ok": True, "result": {"status": "online"}})
33
+
34
+ self.assertTrue(payload.endswith(b"\n"))
35
+ self.assertEqual(
36
+ json.loads(payload.decode("utf-8")),
37
+ {"id": 1, "ok": True, "result": {"status": "online"}},
38
+ )
39
+
40
+ def test_ok_response_preserves_request_id(self):
41
+ response = ok_response({"id": "abc", "cmd": "ping"}, {"status": "online"})
42
+
43
+ self.assertEqual(
44
+ response,
45
+ {"id": "abc", "ok": True, "result": {"status": "online"}},
46
+ )
47
+
48
+ def test_error_response_preserves_request_id(self):
49
+ response = error_response({"id": 3}, "unknown_command", "unknown command: nope")
50
+
51
+ self.assertEqual(
52
+ response,
53
+ {
54
+ "id": 3,
55
+ "ok": False,
56
+ "error": {"code": "unknown_command", "message": "unknown command: nope"},
57
+ },
58
+ )
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()