Skip to content

Commit 406d90b

Browse files
pdepetrometa-codesync[bot]
authored andcommitted
Remove unused Tintype DAP path argument and update docs
Summary: Remove the redundant `pytb_file` positional argument and its fail-fast exit code from `tintype-dap-server`; DAP clients already provide the authoritative snapshot path through `launch.pytbPath`. Update the CLI tests and internal launcher documentation accordingly. Also correct the binary-format parsing example and expand the platform, VS Code, CLI, demo, and serialized collection documentation. Reviewed By: aperez Differential Revision: D116650471 fbshipit-source-id: 0698e6ff05597efabbc8fcd0729717fb58b15de5
1 parent 21dbbd1 commit 406d90b

9 files changed

Lines changed: 123 additions & 137 deletions

File tree

PYTHON_API.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,10 @@ Represents a deserialized complex Python object that doesn't map to a builtin ty
478478

479479
### `SerializedListObject(list)`
480480

481-
A `list` subclass representing a deserialized list (or list subclass) with a custom repr and additional attributes accessible via `__dict__`.
481+
A `list` subclass representing a deserialized list, set, or tuple subclass with
482+
a custom repr and additional attributes accessible via `__dict__`. Collection
483+
contents are exposed through the list interface even when the captured object
484+
was a set or tuple subclass.
482485

483486
### `SerializedDictObject(dict)`
484487

@@ -538,17 +541,21 @@ with sampling(interval, path=path):
538541

539542
| Command | Description |
540543
|---------|-------------|
544+
| `tintype-dap-server [--listen [HOST:]PORT]` | Serve snapshots through the Debug Adapter Protocol. The default transport is stdio; `--listen` enables a loopback TCP listener. Snapshot paths are supplied by the DAP client's `launch.pytbPath` request field. |
541545
| `python -m tintype.utils.tintype_dump /path/to/file.pytb` | Dump a snapshot file as structured text or JSON. |
542546
| `python -m tintype.utils.tintype_viewer /path/to/file.pytb` | Interactive curses-based snapshot file viewer. |
543547

544548
## Demo Scripts
545549

550+
After installing Tintype from the repository checkout, run these commands from
551+
the checkout root. The demo modules are not included in installed wheels.
552+
546553
| Script | Description |
547554
|--------|-------------|
548-
| `python -m tintype.demo.tintype_demo` | Captures snapshots with complex object graphs. |
549-
| `python -m tintype.demo.demo_all_threads` | Captures all threads (CPU workers, native sleepers, lock contention). |
550-
| `python -m tintype.demo.demo_sampling` | Periodic sampling with ALL_THREADS and SINGLE_THREAD modes. |
551-
| `python -m tintype.demo.stress_all_threads` | Stress test: concurrent snapshots, ephemeral threads, sampling interactions. |
555+
| `python demo/tintype_demo.py` | Captures snapshots with complex object graphs. |
556+
| `python demo/demo_all_threads.py` | Captures all threads (CPU workers, native sleepers, lock contention). |
557+
| `python demo/demo_sampling.py` | Periodic sampling with ALL_THREADS and SINGLE_THREAD modes. |
558+
| `python demo/stress_all_threads.py` | Stress test: concurrent snapshots, ephemeral threads, sampling interactions. |
552559

553560
## Related Documentation
554561

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,20 @@ Built as a C++/Python hybrid using pybind11, tintype is designed for minimal ove
2121
pip install tintype
2222
```
2323

24-
**Requirements:** Python ≥ 3.12, a C++20 compiler, and zstd. On macOS, 10.15 or
25-
newer is required
24+
**Requirements:** Python ≥ 3.12, a C++20 compiler, and the zstd development
25+
headers. Tintype currently supports Linux and macOS; Windows is not supported.
26+
Source builds require macOS 10.15 or newer, while published macOS wheels target
27+
macOS 11 or newer.
28+
29+
Install the zstd development package before building from source:
30+
31+
```bash
32+
# Ubuntu/Debian
33+
sudo apt-get install libzstd-dev
34+
35+
# macOS
36+
brew install zstd
37+
```
2638

2739
## Quick Start
2840

dap/cli.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
import argparse
2727
import enum
28-
import os
2928
import sys
3029

3130
from tintype.dap.server import run_session_on_stdio, serve
@@ -46,9 +45,6 @@ class LauncherExitCode(enum.IntEnum):
4645
# Generic failure: import error, no snapshots available,
4746
# unexpected downstream error, etc.
4847
ERROR = 1
49-
# Input validation failure: the ``.pytb`` file is missing, not a
50-
# regular file, or SnapshotReader rejected it as malformed.
51-
INVALID_PYTB = 2
5248
# SIGINT / Ctrl-C received before the session cleanly
5349
# disconnected — 128 + SIGINT(2) per POSIX convention.
5450
KEYBOARD_INTERRUPT = 130
@@ -87,17 +83,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
8783
"Protocol. Defaults to stdio; pass --listen to opt into TCP."
8884
),
8985
)
90-
parser.add_argument(
91-
"pytb_file",
92-
nargs="?",
93-
default=None,
94-
help=(
95-
"Optional path to the .pytb snapshot file. When supplied, the CLI "
96-
"fast-fails with INVALID_PYTB if the path is missing or not a "
97-
"regular file. When omitted, all path validation is deferred to "
98-
"the DAP ``launch`` request's ``pytbPath`` argument."
99-
),
100-
)
10186
parser.add_argument(
10287
"--listen",
10388
type=_parse_listen,
@@ -119,33 +104,19 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
119104
def main(argv: list[str] | None = None) -> LauncherExitCode:
120105
"""Run the DAP server over stdio (default) or TCP (``--listen``).
121106
122-
When ``pytb_file`` is supplied as a positional argument, fast-fails
123-
on a missing path with :attr:`LauncherExitCode.INVALID_PYTB` before
124-
the transport starts. When omitted, all path validation is
125-
deferred to the DAP ``launch`` handler — which surfaces failures
126-
as DAP errors the client can render cleanly via
127-
``arguments.pytbPath`` — so the path can live entirely in the
128-
launch request body.
107+
Snapshot path validation is handled by the DAP ``launch`` handler, which
108+
receives the path through the request's ``pytbPath`` argument and can
109+
surface failures as DAP errors the client can render cleanly.
129110
130111
Returns:
131112
:attr:`LauncherExitCode.OK` on clean shutdown.
132113
:attr:`LauncherExitCode.ERROR` when the session loop returns a
133114
non-zero status.
134-
:attr:`LauncherExitCode.INVALID_PYTB` when a ``pytb_file`` was
135-
supplied but does not exist or is not a regular file.
136115
:attr:`LauncherExitCode.KEYBOARD_INTERRUPT` on SIGINT during the
137116
serve loop.
138117
"""
139118
args = _parse_args(argv)
140119

141-
if args.pytb_file is not None and not os.path.isfile(args.pytb_file):
142-
print(
143-
f"tintype_dap_server: pytb path does not exist or is not a "
144-
f"file: {args.pytb_file}",
145-
file=sys.stderr,
146-
)
147-
return LauncherExitCode.INVALID_PYTB
148-
149120
try:
150121
if args.listen is not None:
151122
host, port = args.listen

dap/tests/test_cli.py

Lines changed: 12 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@
66

77
"""Tests for ``tintype.dap.cli`` (the DAP-server CLI).
88
9-
Covers argparse wiring (stdio default + ``--listen`` opt-in), file
10-
validation, and exit-code mapping. The end-to-end wire-format tests
11-
live in ``test_integration.py``.
9+
Covers argparse wiring (stdio default + ``--listen`` opt-in) and exit-code
10+
mapping. The end-to-end wire-format tests live in ``test_integration.py``.
1211
"""
1312

1413
from __future__ import annotations
1514

16-
import os
17-
import tempfile
1815
import unittest
1916
import unittest.mock
2017

@@ -26,78 +23,47 @@ class ListenArgParsingTest(unittest.TestCase):
2623
"""``--listen`` defaults to off (stdio); a value opts into TCP."""
2724

2825
def test_stdio_is_default(self) -> None:
29-
args = module._parse_args(["/tmp/snap.pytb"])
30-
self.assertEqual(args.pytb_file, "/tmp/snap.pytb")
26+
args = module._parse_args([])
3127
self.assertIsNone(args.listen)
3228

3329
def test_parses_port_only_as_loopback(self) -> None:
34-
args = module._parse_args(["/tmp/snap.pytb", "--listen", "1234"])
30+
args = module._parse_args(["--listen", "1234"])
3531
self.assertEqual(args.listen, ("127.0.0.1", 1234))
3632

3733
def test_parses_host_and_port(self) -> None:
38-
args = module._parse_args(["/tmp/snap.pytb", "--listen", "127.0.0.1:45678"])
34+
args = module._parse_args(["--listen", "127.0.0.1:45678"])
3935
self.assertEqual(args.listen, ("127.0.0.1", 45678))
4036

4137
def test_parses_ephemeral_port(self) -> None:
42-
args = module._parse_args(["/tmp/snap.pytb", "--listen", "0"])
38+
args = module._parse_args(["--listen", "0"])
4339
self.assertEqual(args.listen, ("127.0.0.1", 0))
4440

4541
def test_rejects_malformed_listen(self) -> None:
4642
with self.assertRaises(SystemExit):
47-
module._parse_args(["/tmp/snap.pytb", "--listen", "not-a-port"])
48-
49-
def test_pytb_file_optional(self) -> None:
50-
"""``pytb_file`` is optional — when omitted, validation defers to
51-
the DAP ``launch`` handler. Argparse must not raise."""
52-
args = module._parse_args([])
53-
self.assertIsNone(args.pytb_file)
54-
self.assertIsNone(args.listen)
55-
56-
57-
class MainFileValidationTest(unittest.TestCase):
58-
"""The CLI fast-fails on obvious path mistakes before touching transports."""
59-
60-
def test_returns_invalid_pytb_for_missing_pytb_file(self) -> None:
61-
self.assertEqual(
62-
module.main(["/no/such/file.pytb"]),
63-
LauncherExitCode.INVALID_PYTB,
64-
)
65-
66-
def test_returns_invalid_pytb_for_directory_as_pytb(self) -> None:
67-
with tempfile.TemporaryDirectory() as tmpdir:
68-
self.assertEqual(module.main([tmpdir]), LauncherExitCode.INVALID_PYTB)
43+
module._parse_args(["--listen", "not-a-port"])
6944

7045

7146
class MainDispatchTest(unittest.TestCase):
7247
"""The CLI dispatches to stdio by default and TCP under ``--listen``."""
7348

74-
def _make_pytb(self) -> str:
75-
f = tempfile.NamedTemporaryFile(suffix=".pytb", delete=False)
76-
f.write(b"not a real tintype file, but exists")
77-
f.close()
78-
self.addCleanup(lambda: os.unlink(f.name))
79-
return f.name
80-
8149
def test_stdio_default_calls_run_session_on_stdio(self) -> None:
82-
path = self._make_pytb()
8350
with (
8451
unittest.mock.patch(
8552
"tintype.dap.cli.run_session_on_stdio", return_value=0
8653
) as stdio_mock,
8754
unittest.mock.patch("tintype.dap.cli.serve") as serve_mock,
8855
):
89-
rc = module.main([path])
56+
rc = module.main([])
9057
self.assertEqual(rc, LauncherExitCode.OK)
9158
stdio_mock.assert_called_once_with()
9259
serve_mock.assert_not_called()
9360

9461
def test_listen_routes_to_serve(self) -> None:
95-
path = self._make_pytb()
9662
with (
9763
unittest.mock.patch("tintype.dap.cli.serve", return_value=0) as serve_mock,
9864
unittest.mock.patch("tintype.dap.cli.run_session_on_stdio") as stdio_mock,
9965
):
100-
rc = module.main([path, "--listen", "127.0.0.1:4242"])
66+
rc = module.main(["--listen", "127.0.0.1:4242"])
10167
self.assertEqual(rc, LauncherExitCode.OK)
10268
serve_mock.assert_called_once_with(host="127.0.0.1", port=4242)
10369
stdio_mock.assert_not_called()
@@ -106,47 +72,18 @@ def test_nonzero_return_maps_to_error(self) -> None:
10672
"""A non-zero session-loop return maps to
10773
:attr:`LauncherExitCode.ERROR` so unexpected shutdown rcs
10874
surface as a generic error rather than leaking raw values."""
109-
path = self._make_pytb()
11075
with unittest.mock.patch(
11176
"tintype.dap.cli.run_session_on_stdio", return_value=7
11277
):
113-
self.assertEqual(module.main([path]), LauncherExitCode.ERROR)
78+
self.assertEqual(module.main([]), LauncherExitCode.ERROR)
11479

11580
def test_keyboard_interrupt_maps_to_exit_code(self) -> None:
11681
"""SIGINT during the serve loop surfaces as the conventional
11782
128 + SIGINT(2) exit code via the enum."""
118-
path = self._make_pytb()
11983
with unittest.mock.patch(
12084
"tintype.dap.cli.run_session_on_stdio", side_effect=KeyboardInterrupt
12185
):
122-
self.assertEqual(module.main([path]), LauncherExitCode.KEYBOARD_INTERRUPT)
123-
124-
def test_no_pytb_dispatches_to_stdio(self) -> None:
125-
"""``pytb_file`` may be omitted entirely; the CLI's existence
126-
check is skipped and the transport launches as usual. Path
127-
validation is left to ``handle_launch``."""
128-
with (
129-
unittest.mock.patch(
130-
"tintype.dap.cli.run_session_on_stdio", return_value=0
131-
) as stdio_mock,
132-
unittest.mock.patch("tintype.dap.cli.serve") as serve_mock,
133-
):
134-
rc = module.main([])
135-
self.assertEqual(rc, LauncherExitCode.OK)
136-
stdio_mock.assert_called_once_with()
137-
serve_mock.assert_not_called()
138-
139-
def test_no_pytb_with_listen_dispatches_to_serve(self) -> None:
140-
"""Same as ``test_no_pytb_dispatches_to_stdio`` but for the
141-
TCP transport branch — ``--listen`` works without a positional."""
142-
with (
143-
unittest.mock.patch("tintype.dap.cli.serve", return_value=0) as serve_mock,
144-
unittest.mock.patch("tintype.dap.cli.run_session_on_stdio") as stdio_mock,
145-
):
146-
rc = module.main(["--listen", "0"])
147-
self.assertEqual(rc, LauncherExitCode.OK)
148-
serve_mock.assert_called_once_with(host="127.0.0.1", port=0)
149-
stdio_mock.assert_not_called()
86+
self.assertEqual(module.main([]), LauncherExitCode.KEYBOARD_INTERRUPT)
15087

15188

15289
class LauncherExitCodeTest(unittest.TestCase):
@@ -157,14 +94,13 @@ class LauncherExitCodeTest(unittest.TestCase):
15794
def test_values_match_posix_conventions(self) -> None:
15895
self.assertEqual(LauncherExitCode.OK.value, 0)
15996
self.assertEqual(LauncherExitCode.ERROR.value, 1)
160-
self.assertEqual(LauncherExitCode.INVALID_PYTB.value, 2)
16197
self.assertEqual(LauncherExitCode.KEYBOARD_INTERRUPT.value, 130)
16298

16399
def test_is_int_enum_so_values_pass_through_sys_exit(self) -> None:
164100
# IntEnum members compare equal to their underlying int value,
165101
# which ``sys.exit()`` needs for the entrypoint's
166102
# ``sys.exit(main())`` call to produce the expected OS exit code.
167-
self.assertEqual(LauncherExitCode.INVALID_PYTB, 2)
103+
self.assertEqual(LauncherExitCode.ERROR, 1)
168104
self.assertEqual(int(LauncherExitCode.OK), 0)
169105

170106

demo/tintype_chain_demo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,7 @@ def main() -> None:
4949
},
5050
)
5151
process_request()
52+
53+
54+
if __name__ == "__main__":
55+
main()

demo/tintype_demo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,7 @@ def main() -> None:
201201
},
202202
)
203203
_print_stats()
204+
205+
206+
if __name__ == "__main__":
207+
main()

demo/tintype_truncated_demo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,7 @@ def main() -> None:
165165
f"View with: python -m tintype.utils.tintype_viewer {OUTPUT_PATH}",
166166
file=sys.stderr,
167167
)
168+
169+
170+
if __name__ == "__main__":
171+
main()

snapshot_lib/FILE_FORMAT.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ The Tintype file stores snapshots of Python traceback information including:
1010
- Source file contents for referenced files
1111
- User-provided metadata
1212

13-
The file is compressed using zstd. All positions (Pos) are absolute byte offsets from the start of the **uncompressed** data. All offsets (Offset) are relative to a section start (e.g., object heap offsets are relative to the object heap start).
13+
Output files are zstd-compressed by default, but callers can request an
14+
uncompressed file. All positions (Pos) are absolute byte offsets from the start
15+
of the **uncompressed** data. All offsets (Offset) are relative to a section
16+
start (e.g., object heap offsets are relative to the object heap start).
1417

1518
All multi-byte integers are stored in little-endian byte order. All on-disk structs use `#pragma pack(push, 1)` to eliminate compiler-inserted alignment padding. This makes the binary format portable across platforms (e.g., files written on Linux can be read on macOS).
1619

@@ -383,7 +386,9 @@ To read the statistics:
383386
## Reading the File
384387

385388
### Decompression
386-
The entire file is zstd compressed. Decompress the full file before parsing.
389+
Read the first four bytes as a little-endian `uint32`. If they equal the magic
390+
number, the file is already uncompressed and can be parsed directly. Otherwise,
391+
treat the file as a zstd frame and decompress it fully before parsing.
387392

388393
### Output Compaction
389394
During writing, the working file has a zero-filled gap between the snapshot records section and the object heap (used to allow the snapshot records section to grow without relocating the heap). When the output file is produced, this gap is stripped: the snapshot records are immediately followed by the object heap data, and the `FileHeader` position fields (`objectHeapPos`, `fileTablePos`, `envPos`, `manifestPos`, `metadataPos`, `statsPos`) are adjusted to reflect the compacted layout. The `lastSnapshotPos` and all positions within snapshot records (`prevSnapshotPos`, `objectMapPos`) are not adjusted because they fall before the gap. Readers see a contiguous file with no gap.
@@ -415,10 +420,13 @@ import zstandard
415420

416421
def read_snapshot_file(path):
417422
with open(path, 'rb') as f:
418-
compressed = f.read()
423+
file_data = f.read()
419424

420-
dctx = zstandard.ZstdDecompressor()
421-
data = dctx.decompress(compressed)
425+
if file_data[:4] == struct.pack('<I', 0x50595442):
426+
data = file_data
427+
else:
428+
dctx = zstandard.ZstdDecompressor()
429+
data = dctx.decompress(file_data)
422430

423431
# Read FileHeader (100 bytes, packed)
424432
magic, version = struct.unpack_from('<II', data, 0)
@@ -431,19 +439,19 @@ def read_snapshot_file(path):
431439
manifest_pos, manifest_size,
432440
metadata_pos, metadata_size,
433441
stats_pos, stats_count) = struct.unpack_from(
434-
'<QIQIQQQQQQQI', data, 8)
442+
'<QIQQIQQQQQQQI', data, 8)
435443

436444
# Read metadata (raw JSON string, size from header)
437445
metadata_json = data[metadata_pos:metadata_pos + metadata_size]
438446

439-
# Read most recent snapshot (32-byte header)
447+
# Read most recent snapshot (33-byte header)
440448
pos = last_snapshot_pos
441-
timestamp, prev_pos, stacktrace_count, obj_map_pos, obj_map_count, flags = \
449+
timestamp, prev_pos, stacktrace_count, obj_map_pos, obj_map_count, flags = \
442450
struct.unpack_from('<QQIQIB', data, pos)
443451
pos += 33 # sizeof(SnapshotRecordHeader)
444452
is_truncated = (flags & 0x01) != 0
445453

446-
# Read stacktraces (37-byte header + thread name + frames)
454+
# Read stacktraces (41-byte header + thread name + frames)
447455
for s in range(stacktrace_count):
448456
id, frame_count, exception_python_id, cause_id, context_id, st_flags, \
449457
thread_name_length = struct.unpack_from('<QIQQQ BI', data, pos)

0 commit comments

Comments
 (0)