sgrud 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sgrud/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """sgrud: inspect a running CPython process from the outside.
2
+
3
+ Quick start::
4
+
5
+ from sgrud import Monitor
6
+
7
+ with Monitor.attach(pid) as m:
8
+ snap = m.snapshot()
9
+ print(snap.process.memory.rss, snap.process.cpu_percent)
10
+ for t in snap.threads:
11
+ print(t.tid, t.name, t.status.describe(), t.frames[:1])
12
+ """
13
+
14
+ from .errors import AttachError, NotSupported, ProcessExited, SgrudError
15
+ from .models import (
16
+ Awaiter,
17
+ Frame,
18
+ GCCollection,
19
+ GCGeneration,
20
+ Memory,
21
+ Process,
22
+ Snapshot,
23
+ Task,
24
+ Thread,
25
+ ThreadStatus,
26
+ )
27
+ from .monitor import Monitor
28
+
29
+ __all__ = [
30
+ "AttachError",
31
+ "Awaiter",
32
+ "Frame",
33
+ "GCCollection",
34
+ "GCGeneration",
35
+ "Memory",
36
+ "Monitor",
37
+ "NotSupported",
38
+ "Process",
39
+ "ProcessExited",
40
+ "SgrudError",
41
+ "Snapshot",
42
+ "Task",
43
+ "Thread",
44
+ "ThreadStatus",
45
+ ]
sgrud/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """Command line interface.
2
+
3
+ sgrud PID one text snapshot
4
+ sgrud PID -n 0.5 keep printing snapshots every 0.5 s
5
+ sgrud PID --json JSON lines instead of text
6
+ sgrud PID --profile 5 sample stacks for 5 s and print the hottest functions
7
+ sgrud run -- python app.py spawn the target as a child, then inspect it
8
+ sgrud tui PID interactive Textual interface
9
+ sgrud tui run -- python app.py
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import dataclasses
16
+ import json
17
+ import sys
18
+ import time
19
+ from collections.abc import Sequence
20
+
21
+ from . import __name__ as _pkg
22
+ from .errors import SgrudError
23
+ from .format import format_snapshot
24
+ from .monitor import Monitor
25
+
26
+
27
+ def _add_target(parser: argparse.ArgumentParser) -> None:
28
+ parser.add_argument(
29
+ "target",
30
+ help="a pid, or `run -- CMD [ARGS...]` to spawn the target as a child",
31
+ )
32
+
33
+
34
+ def _add_sections(parser: argparse.ArgumentParser) -> None:
35
+ parser.add_argument("--no-stacks", action="store_true", help="skip stack traces")
36
+ parser.add_argument("--no-tasks", action="store_true", help="skip asyncio tasks")
37
+ parser.add_argument("--no-gc", action="store_true", help="skip GC statistics")
38
+ parser.add_argument("--no-native", action="store_true", help="hide <native> marker frames")
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(prog=_pkg, description=__doc__.split("\n\n")[0])
43
+ sub = parser.add_subparsers(dest="command")
44
+
45
+ dump = sub.add_parser("dump", help="print snapshots (default command)")
46
+ _add_target(dump)
47
+ _add_sections(dump)
48
+ dump.add_argument("-n", "--interval", type=float, default=None,
49
+ help="repeat every N seconds until the target exits")
50
+ dump.add_argument("-c", "--count", type=int, default=None, help="stop after N snapshots")
51
+ dump.add_argument("--json", action="store_true", help="emit one JSON object per line")
52
+ dump.add_argument("--max-frames", type=int, default=None, help="frames per thread to show")
53
+ dump.add_argument("--profile", type=float, metavar="SECONDS", default=None,
54
+ help="sample stacks for SECONDS and print a hotspot table instead")
55
+ dump.add_argument("--rate", type=float, default=200.0, help="samples per second for --profile")
56
+ dump.add_argument("--sort", choices=("self", "total"), default="self",
57
+ help="hotspot ordering for --profile")
58
+ dump.add_argument("--mode", choices=("wall", "gil"), default="wall",
59
+ help="count every thread (wall) or only the GIL holder (gil)")
60
+
61
+ tui = sub.add_parser("tui", help="interactive terminal interface")
62
+ _add_target(tui)
63
+ _add_sections(tui)
64
+ tui.add_argument("-n", "--interval", type=float, default=1.0, help="refresh interval")
65
+ tui.add_argument("--rate", type=float, default=100.0,
66
+ help="background stack samples per second for the Hotspots tab, 0 to disable")
67
+ tui.add_argument("--mode", choices=("wall", "gil"), default="wall",
68
+ help="initial hotspot mode, toggle with `m` in the TUI")
69
+ return parser
70
+
71
+
72
+ def open_monitor(target: str, command: Sequence[str], **options) -> Monitor:
73
+ if target == "run":
74
+ if not command:
75
+ raise SgrudError("`run` needs a command after `--`")
76
+ return Monitor.spawn(list(command), **options)
77
+ if command:
78
+ raise SgrudError("a command after `--` only makes sense with `run`")
79
+ if not target.isdigit():
80
+ raise SgrudError(f"expected a pid or `run -- CMD`, got {target!r}")
81
+ return Monitor.attach(int(target), **options)
82
+
83
+
84
+ def _dump(args: argparse.Namespace) -> int:
85
+ sections = dict(stacks=not args.no_stacks, tasks=not args.no_tasks, gc=not args.no_gc)
86
+ try:
87
+ monitor = open_monitor(args.target, args.command_argv, native_frames=not args.no_native)
88
+ except SgrudError as e:
89
+ print(f"sgrud: {e}", file=sys.stderr)
90
+ return 1
91
+ produced = 0
92
+ if monitor.limited is not None:
93
+ print(f"sgrud: limited mode, only /proc data is available. {monitor.limited}",
94
+ file=sys.stderr)
95
+ try:
96
+ with monitor:
97
+ if args.profile is not None:
98
+ if monitor.limited is not None:
99
+ print("sgrud: --profile needs access to the target's memory", file=sys.stderr)
100
+ return 1
101
+ return _profile(monitor, args)
102
+ if args.interval is None:
103
+ # A second sample a moment later gives meaningful CPU percentages.
104
+ monitor.snapshot(stacks=False, tasks=False, gc=False)
105
+ time.sleep(0.1)
106
+ snaps = iter([monitor.snapshot(**sections)])
107
+ else:
108
+ snaps = monitor.stream(args.interval, **sections)
109
+ for snap in snaps:
110
+ if args.json:
111
+ print(json.dumps(snap.to_dict(), default=str), flush=True)
112
+ else:
113
+ if produced:
114
+ print()
115
+ print(
116
+ format_snapshot(
117
+ snap,
118
+ frames=sections["stacks"],
119
+ tasks=sections["tasks"],
120
+ gc=sections["gc"],
121
+ max_frames=args.max_frames,
122
+ ),
123
+ flush=True,
124
+ )
125
+ produced += 1
126
+ if args.count is not None and produced >= args.count:
127
+ break
128
+ except SgrudError as e:
129
+ print(f"sgrud: {e}", file=sys.stderr)
130
+ return 1 if not produced else 0
131
+ except KeyboardInterrupt:
132
+ pass
133
+ return 0
134
+
135
+
136
+ def _profile(monitor: Monitor, args: argparse.Namespace) -> int:
137
+ from .format import format_hotspots
138
+ from .sampler import Sampler
139
+
140
+ sampler = Sampler(monitor, rate=args.rate, mode=args.mode)
141
+ with sampler:
142
+ deadline = time.monotonic() + args.profile
143
+ while time.monotonic() < deadline and sampler.exited is None:
144
+ time.sleep(0.05)
145
+ hot = sampler.hotspots
146
+ if args.json:
147
+ rows = [dataclasses.asdict(r) for r in hot.rows(sort=args.sort)]
148
+ print(json.dumps({"samples": hot.samples, "rate": hot.rate(), "mode": hot.mode,
149
+ "errors": sampler.errors, "rows": rows}))
150
+ else:
151
+ print(format_hotspots(hot.rows(sort=args.sort), samples=hot.samples, rate=hot.rate(),
152
+ mode=hot.mode))
153
+ if sampler.errors:
154
+ print(f"({sampler.errors} samples failed, last: {sampler.last_error})")
155
+ if sampler.exited is not None:
156
+ print(f"sgrud: {sampler.exited}", file=sys.stderr)
157
+ return 0
158
+
159
+
160
+ def _tui(args: argparse.Namespace) -> int:
161
+ from .tui import run_tui
162
+
163
+ try:
164
+ monitor = open_monitor(args.target, args.command_argv, native_frames=not args.no_native)
165
+ except SgrudError as e:
166
+ print(f"sgrud: {e}", file=sys.stderr)
167
+ return 1
168
+ return run_tui(
169
+ monitor,
170
+ interval=args.interval,
171
+ stacks=not args.no_stacks,
172
+ tasks=not args.no_tasks,
173
+ gc=not args.no_gc,
174
+ sample_rate=args.rate,
175
+ sample_mode=args.mode,
176
+ )
177
+
178
+
179
+ def main(argv: Sequence[str] | None = None) -> int:
180
+ argv = list(sys.argv[1:] if argv is None else argv)
181
+ command_argv: list[str] = []
182
+ if "--" in argv:
183
+ cut = argv.index("--")
184
+ argv, command_argv = argv[:cut], argv[cut + 1 :]
185
+ if argv and argv[0] not in {"dump", "tui", "-h", "--help"}:
186
+ argv.insert(0, "dump")
187
+ args = build_parser().parse_args(argv)
188
+ args.command_argv = command_argv
189
+ if args.command == "tui":
190
+ return _tui(args)
191
+ if args.command == "dump":
192
+ return _dump(args)
193
+ build_parser().print_help()
194
+ return 2
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())
sgrud/errors.py ADDED
@@ -0,0 +1,42 @@
1
+ """Exceptions raised by sgrud."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SgrudError(Exception):
7
+ """Base class for all sgrud errors."""
8
+
9
+
10
+ class ProcessExited(SgrudError):
11
+ """The target process is gone."""
12
+
13
+ def __init__(self, pid: int, returncode: int | None = None):
14
+ self.pid = pid
15
+ self.returncode = returncode
16
+ msg = f"process {pid} has exited"
17
+ if returncode is not None:
18
+ msg += f" with code {returncode}"
19
+ super().__init__(msg)
20
+
21
+
22
+ class AttachError(SgrudError):
23
+ """Could not attach to the target process.
24
+
25
+ ``hint`` carries a human readable suggestion on how to fix it.
26
+ """
27
+
28
+ def __init__(
29
+ self, pid: int, message: str, hint: str | None = None, *, transient: bool = False
30
+ ):
31
+ self.pid = pid
32
+ self.hint = hint
33
+ #: True when the target may simply still be starting up.
34
+ self.transient = transient
35
+ text = f"cannot attach to process {pid}: {message}"
36
+ if hint:
37
+ text += f"\n{hint}"
38
+ super().__init__(text)
39
+
40
+
41
+ class NotSupported(SgrudError):
42
+ """The platform or interpreter lacks a required feature."""
sgrud/format.py ADDED
@@ -0,0 +1,168 @@
1
+ """Plain text rendering of snapshots, shared by the CLI and usable in tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from collections.abc import Iterable
8
+
9
+ from .models import Frame, Snapshot, Task, Thread
10
+
11
+
12
+ def human_bytes(n: int) -> str:
13
+ value = float(n)
14
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
15
+ if abs(value) < 1024 or unit == "TiB":
16
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
17
+ value /= 1024
18
+ return f"{value:.1f} TiB"
19
+
20
+
21
+ def human_duration(seconds: float) -> str:
22
+ if math.isnan(seconds):
23
+ return "?"
24
+ if seconds < 1e-3:
25
+ return f"{seconds * 1e6:.0f}µs"
26
+ if seconds < 1:
27
+ return f"{seconds * 1e3:.1f}ms"
28
+ if seconds < 60:
29
+ return f"{seconds:.1f}s"
30
+ minutes, sec = divmod(int(seconds), 60)
31
+ hours, minutes = divmod(minutes, 60)
32
+ if hours:
33
+ return f"{hours}h{minutes:02d}m"
34
+ return f"{minutes}m{sec:02d}s"
35
+
36
+
37
+ def short_path(path: str, keep: int = 2) -> str:
38
+ if not path or path.startswith("<"):
39
+ return path
40
+ parts = path.replace("\\", "/").split("/")
41
+ return "/".join(parts[-keep:]) if len(parts) > keep else path
42
+
43
+
44
+ def percent(value: float | None) -> str:
45
+ return " - " if value is None else f"{value:5.1f}"
46
+
47
+
48
+ def format_frames(frames: Iterable[Frame], indent: str = " ") -> list[str]:
49
+ lines = []
50
+ for f in frames:
51
+ if f.synthetic:
52
+ lines.append(f"{indent}{f.funcname}")
53
+ else:
54
+ lines.append(f"{indent}{f.funcname} {short_path(f.filename)}:{f.lineno}")
55
+ return lines
56
+
57
+
58
+ def format_thread(t: Thread, *, frames: bool = True, max_frames: int | None = None) -> list[str]:
59
+ head = (
60
+ f"[{t.tid}] {t.name:<16} {t.status.describe():<12} state={t.state} "
61
+ f"cpu={percent(t.cpu_percent)}% utime={t.user_time:.2f}s stime={t.system_time:.2f}s"
62
+ )
63
+ lines = [head]
64
+ if frames:
65
+ shown = t.frames if max_frames is None else t.frames[:max_frames]
66
+ lines.extend(format_frames(shown))
67
+ if max_frames is not None and len(t.frames) > max_frames:
68
+ lines.append(f" ... {len(t.frames) - max_frames} more")
69
+ return lines
70
+
71
+
72
+ def format_task_tree(snap: Snapshot) -> list[str]:
73
+ children = snap.task_children()
74
+ by_id = {t.id: t for t in snap.tasks}
75
+ lines: list[str] = []
76
+
77
+ def label(t: Task) -> str:
78
+ top = t.frames[0].funcname if t.frames else "?"
79
+ chain = " <- ".join(f.funcname for f in t.frames[:4])
80
+ return f"{t.name} (0x{t.id:x}) [tid {t.thread_id}] {chain or top}"
81
+
82
+ def walk(t: Task, prefix: str, last: bool, seen: set[int]) -> None:
83
+ branch = "└─ " if last else "├─ "
84
+ lines.append(f"{prefix}{branch}{label(t)}")
85
+ if t.id in seen:
86
+ lines.append(f"{prefix}{' ' if last else '│ '}(cycle)")
87
+ return
88
+ seen = seen | {t.id}
89
+ kids = children.get(t.id, [])
90
+ for i, k in enumerate(kids):
91
+ walk(k, prefix + (" " if last else "│ "), i == len(kids) - 1, seen)
92
+
93
+ roots = children.get(None, [])
94
+ for i, root in enumerate(roots):
95
+ walk(root, "", i == len(roots) - 1, set())
96
+ if not roots and by_id:
97
+ lines.append("(all tasks are in await cycles)")
98
+ return lines
99
+
100
+
101
+ def format_gc(snap: Snapshot) -> list[str]:
102
+ lines = []
103
+ for g in snap.gc:
104
+ lines.append(
105
+ f"gen{g.generation}: {g.collections} collections, {g.collected} collected, "
106
+ f"{g.uncollectable} uncollectable, total {human_duration(g.total_duration)}, "
107
+ f"heap {g.heap_size}"
108
+ )
109
+ for c in g.history[:3]:
110
+ lines.append(
111
+ f" last: {human_duration(c.duration)} collected={c.collected} "
112
+ f"candidates={c.candidates} heap={c.heap_size}"
113
+ )
114
+ return lines
115
+
116
+
117
+ def format_snapshot(
118
+ snap: Snapshot,
119
+ *,
120
+ threads: bool = True,
121
+ frames: bool = True,
122
+ tasks: bool = True,
123
+ gc: bool = True,
124
+ max_frames: int | None = None,
125
+ ) -> str:
126
+ p = snap.process
127
+ m = p.memory
128
+ lines = [
129
+ f"pid {p.pid} {os.path.basename(p.exe) or '?'} {' '.join(p.cmdline)[:80]}",
130
+ f"state={p.state} threads={p.num_threads} uptime={human_duration(p.uptime)} "
131
+ f"cpu={percent(p.cpu_percent)}% utime={p.user_time:.2f}s stime={p.system_time:.2f}s",
132
+ f"rss={human_bytes(m.rss)} vms={human_bytes(m.vms)} hwm={human_bytes(m.hwm)} "
133
+ f"swap={human_bytes(m.swap)} data={human_bytes(m.data)} shared={human_bytes(m.shared)}",
134
+ ]
135
+ if threads:
136
+ lines.append("")
137
+ lines.append(f"threads ({len(snap.threads)}):")
138
+ for t in snap.threads:
139
+ lines.extend(format_thread(t, frames=frames, max_frames=max_frames))
140
+ if tasks:
141
+ lines.append("")
142
+ lines.append(f"asyncio tasks ({len(snap.tasks)}):")
143
+ lines.extend(format_task_tree(snap))
144
+ if gc:
145
+ lines.append("")
146
+ lines.append("gc:")
147
+ lines.extend(format_gc(snap))
148
+ for section, err in snap.errors.items():
149
+ lines.append(f"! {section}: {err}")
150
+ return "\n".join(lines)
151
+
152
+
153
+ def format_hotspots(
154
+ rows, *, samples: int, rate: float | None = None, mode: str = "wall", limit: int = 25
155
+ ) -> str:
156
+ """Render hotspot rows (see :meth:`sgrud.profile.Hotspots.rows`) as a table."""
157
+ head = f"hotspots ({mode}): {samples} samples"
158
+ if rate:
159
+ head += f" at {rate:.0f}/s"
160
+ lines = [head, f"{'self%':>6} {'total%':>7} {'self':>7} {'total':>7} function file"]
161
+ for r in rows[:limit]:
162
+ lines.append(
163
+ f"{r.self_percent:6.1f} {r.total_percent:7.1f} {r.self_samples:7d} "
164
+ f"{r.total_samples:7d} {r.funcname} {short_path(r.filename)}"
165
+ )
166
+ if not rows:
167
+ lines.append("(no samples)")
168
+ return "\n".join(lines)
sgrud/models.py ADDED
@@ -0,0 +1,203 @@
1
+ """Plain data model for a snapshot of a remote Python process.
2
+
3
+ Everything here is a frozen dataclass so snapshots can be compared, cached,
4
+ serialized with :func:`dataclasses.asdict` and consumed by any front end
5
+ (CLI, TUI, tests) without touching ``_remote_debugging`` or ``/proc``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import enum
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+
16
+ class ThreadStatus(enum.IntFlag):
17
+ """Bit flags reported by ``_remote_debugging`` for each thread."""
18
+
19
+ NONE = 0
20
+ HAS_GIL = 1 << 0
21
+ ON_CPU = 1 << 1
22
+ UNKNOWN = 1 << 2
23
+ GIL_REQUESTED = 1 << 3
24
+ HAS_EXCEPTION = 1 << 4
25
+ MAIN_THREAD = 1 << 5
26
+
27
+ def describe(self) -> str:
28
+ """Short human readable label such as ``"gil,cpu"``."""
29
+ parts = []
30
+ if self & ThreadStatus.MAIN_THREAD:
31
+ parts.append("main")
32
+ if self & ThreadStatus.HAS_GIL:
33
+ parts.append("gil")
34
+ elif self & ThreadStatus.GIL_REQUESTED:
35
+ parts.append("wait-gil")
36
+ if self & ThreadStatus.ON_CPU:
37
+ parts.append("cpu")
38
+ if self & ThreadStatus.HAS_EXCEPTION:
39
+ parts.append("exc")
40
+ if self & ThreadStatus.UNKNOWN:
41
+ parts.append("?")
42
+ return ",".join(parts) or "idle"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Frame:
47
+ """One Python stack frame, or a synthetic ``<native>`` / ``<GC>`` marker."""
48
+
49
+ funcname: str
50
+ filename: str
51
+ lineno: int | None = None
52
+ end_lineno: int | None = None
53
+ col_offset: int | None = None
54
+ end_col_offset: int | None = None
55
+
56
+ @property
57
+ def synthetic(self) -> bool:
58
+ return self.lineno is None
59
+
60
+ def format(self) -> str:
61
+ if self.synthetic:
62
+ return self.funcname
63
+ return f"{self.funcname} ({self.filename}:{self.lineno})"
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class Thread:
68
+ """An OS thread that belongs to the target interpreter."""
69
+
70
+ tid: int
71
+ name: str
72
+ interpreter_id: int
73
+ status: ThreadStatus
74
+ #: Kernel scheduler state letter from ``/proc`` (R, S, D, ...), or "".
75
+ state: str
76
+ user_time: float
77
+ system_time: float
78
+ #: CPU usage since the previous snapshot in percent of one core.
79
+ #: ``None`` for the first snapshot of a monitor.
80
+ cpu_percent: float | None
81
+ #: Leaf frame first.
82
+ frames: tuple[Frame, ...] = ()
83
+
84
+ @property
85
+ def is_main(self) -> bool:
86
+ return bool(self.status & ThreadStatus.MAIN_THREAD)
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Awaiter:
91
+ """A task waiting on another task, with the frames of the awaiting coroutine."""
92
+
93
+ task_id: int
94
+ frames: tuple[Frame, ...] = ()
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class Task:
99
+ """An asyncio task discovered in the target process."""
100
+
101
+ id: int
102
+ name: str
103
+ thread_id: int
104
+ #: Coroutine call stack, leaf first.
105
+ frames: tuple[Frame, ...] = ()
106
+ awaited_by: tuple[Awaiter, ...] = ()
107
+
108
+ @property
109
+ def parent_ids(self) -> tuple[int, ...]:
110
+ return tuple(a.task_id for a in self.awaited_by)
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class GCCollection:
115
+ """One garbage collection recorded in the target's GC history ring."""
116
+
117
+ generation: int
118
+ interpreter_id: int
119
+ started_at: int
120
+ stopped_at: int
121
+ duration: float
122
+ collected: int
123
+ uncollectable: int
124
+ candidates: int
125
+ heap_size: int
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class GCGeneration:
130
+ generation: int
131
+ collections: int
132
+ collected: int
133
+ uncollectable: int
134
+ total_duration: float
135
+ heap_size: int
136
+ #: Most recent first.
137
+ history: tuple[GCCollection, ...] = ()
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class Memory:
142
+ """Process memory figures from ``/proc/<pid>/status``, in bytes."""
143
+
144
+ rss: int
145
+ vms: int
146
+ hwm: int
147
+ swap: int
148
+ data: int
149
+ shared: int
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class Process:
154
+ pid: int
155
+ exe: str
156
+ cmdline: tuple[str, ...]
157
+ state: str
158
+ num_threads: int
159
+ memory: Memory
160
+ user_time: float
161
+ system_time: float
162
+ #: Seconds since the process started.
163
+ uptime: float
164
+ #: CPU usage since the previous snapshot in percent of one core.
165
+ cpu_percent: float | None
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class Snapshot:
170
+ """A consistent-as-practical picture of the target at one instant."""
171
+
172
+ timestamp: float
173
+ process: Process
174
+ threads: tuple[Thread, ...] = ()
175
+ tasks: tuple[Task, ...] = ()
176
+ gc: tuple[GCGeneration, ...] = ()
177
+ #: Sections that could not be collected, mapped to the error text.
178
+ errors: dict[str, str] = field(default_factory=dict)
179
+
180
+ def thread(self, tid: int) -> Thread | None:
181
+ for t in self.threads:
182
+ if t.tid == tid:
183
+ return t
184
+ return None
185
+
186
+ def task(self, task_id: int) -> Task | None:
187
+ for t in self.tasks:
188
+ if t.id == task_id:
189
+ return t
190
+ return None
191
+
192
+ def task_children(self) -> dict[int | None, list[Task]]:
193
+ """Map parent task id (``None`` for roots) to its child tasks."""
194
+ known = {t.id for t in self.tasks}
195
+ children: dict[int | None, list[Task]] = {}
196
+ for t in self.tasks:
197
+ parents = [p for p in t.parent_ids if p in known] or [None]
198
+ for p in parents:
199
+ children.setdefault(p, []).append(t)
200
+ return children
201
+
202
+ def to_dict(self) -> dict[str, Any]:
203
+ return dataclasses.asdict(self)