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 +24 -0
- apdb-0.1.0/PKG-INFO +103 -0
- apdb-0.1.0/README.md +80 -0
- apdb-0.1.0/apdb/__init__.py +5 -0
- apdb-0.1.0/apdb/cli.py +57 -0
- apdb-0.1.0/apdb/debugger.py +194 -0
- apdb-0.1.0/apdb/protocol.py +45 -0
- apdb-0.1.0/apdb/server.py +24 -0
- apdb-0.1.0/apdb.egg-info/PKG-INFO +103 -0
- apdb-0.1.0/apdb.egg-info/SOURCES.txt +16 -0
- apdb-0.1.0/apdb.egg-info/dependency_links.txt +1 -0
- apdb-0.1.0/apdb.egg-info/entry_points.txt +2 -0
- apdb-0.1.0/apdb.egg-info/top_level.txt +1 -0
- apdb-0.1.0/pyproject.toml +36 -0
- apdb-0.1.0/setup.cfg +4 -0
- apdb-0.1.0/tests/test_cli.py +81 -0
- apdb-0.1.0/tests/test_integration.py +212 -0
- apdb-0.1.0/tests/test_protocol.py +62 -0
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.
|
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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|