pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/profiling.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Opt-in, local-only profiling. Never capture arguments, locals, or source text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import tracemalloc
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
PROFILE_MODES = ("off", "resources", "cpu", "memory")
|
|
20
|
+
"""The `profile` preference: no capture, sampling only, or sampling plus one tracer."""
|
|
21
|
+
|
|
22
|
+
CAPTURES_KEPT = 20
|
|
23
|
+
"""Automatically named captures retained. An always-on capture writes one directory
|
|
24
|
+
per session, so unbounded growth would be the cost of leaving the setting on."""
|
|
25
|
+
|
|
26
|
+
_AUTOMATIC_NAME = re.compile(r"\d{8}-\d{6}-\d+\Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def captures_root() -> Path:
|
|
30
|
+
"""`PCODE_PROFILE_DIR` overrides where automatically named captures are written."""
|
|
31
|
+
override = os.environ.get("PCODE_PROFILE_DIR", "").strip()
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
35
|
+
return state / "pcode" / "profiles"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_capture(root: Path | None = None) -> Path:
|
|
39
|
+
"""A fresh per-session directory, since a capture refuses to reuse an existing one."""
|
|
40
|
+
root = captures_root() if root is None else root
|
|
41
|
+
return root / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def prune_captures(root: Path | None = None, keep: int | None = None) -> list[Path]:
|
|
45
|
+
"""Delete all but the newest `keep` automatic captures, by name.
|
|
46
|
+
|
|
47
|
+
Only `new_capture` names are considered, so a capture written to a directory
|
|
48
|
+
the user named is never removed. Retention is best effort: a capture that
|
|
49
|
+
cannot be deleted is left in place rather than failing the session.
|
|
50
|
+
"""
|
|
51
|
+
root = captures_root() if root is None else root
|
|
52
|
+
keep = CAPTURES_KEPT if keep is None else keep
|
|
53
|
+
try:
|
|
54
|
+
candidates = sorted(
|
|
55
|
+
path for path in root.iterdir() if path.is_dir() and _AUTOMATIC_NAME.match(path.name)
|
|
56
|
+
)
|
|
57
|
+
except OSError:
|
|
58
|
+
return []
|
|
59
|
+
removed = []
|
|
60
|
+
for path in candidates[: max(0, len(candidates) - keep)]:
|
|
61
|
+
try:
|
|
62
|
+
shutil.rmtree(path)
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
removed.append(path)
|
|
66
|
+
return removed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ActivityTotals:
|
|
70
|
+
"""Wall and process CPU time spent inside named spans; the rest of a run is idle.
|
|
71
|
+
|
|
72
|
+
Spans are reference counted per label rather than stacked: concurrent work
|
|
73
|
+
opens one span, and the earliest still-open label names the current activity.
|
|
74
|
+
Overlapping labels each see the whole process's CPU for their own span, so
|
|
75
|
+
their CPU seconds can sum to more than the process spent.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._open: dict[str, tuple[int, float, float]] = {}
|
|
81
|
+
self._totals: dict[str, dict[str, float]] = {}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def label(self) -> str | None:
|
|
85
|
+
"""The longest-running open span's label, or None while nothing is running."""
|
|
86
|
+
return next(iter(self._open), None)
|
|
87
|
+
|
|
88
|
+
def enter(self, label: str) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
depth, wall, cpu = self._open.get(label, (0, 0.0, 0.0))
|
|
91
|
+
if depth == 0:
|
|
92
|
+
wall, cpu = time.monotonic(), time.process_time()
|
|
93
|
+
self._open[label] = (depth + 1, wall, cpu)
|
|
94
|
+
self._totals.setdefault(label, {"wall_seconds": 0.0, "cpu_seconds": 0.0, "spans": 0})
|
|
95
|
+
self._totals[label]["spans"] += 1
|
|
96
|
+
|
|
97
|
+
def exit(self, label: str) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
current = self._open.get(label)
|
|
100
|
+
if current is None:
|
|
101
|
+
return
|
|
102
|
+
depth, wall, cpu = current
|
|
103
|
+
if depth > 1:
|
|
104
|
+
# Assigning an existing key keeps insertion order, so a nested
|
|
105
|
+
# span does not make its label look like the newest one.
|
|
106
|
+
self._open[label] = (depth - 1, wall, cpu)
|
|
107
|
+
return
|
|
108
|
+
del self._open[label]
|
|
109
|
+
entry = self._totals[label]
|
|
110
|
+
entry["wall_seconds"] += time.monotonic() - wall
|
|
111
|
+
entry["cpu_seconds"] += time.process_time() - cpu
|
|
112
|
+
|
|
113
|
+
def snapshot(self) -> dict[str, dict[str, float]]:
|
|
114
|
+
"""Totals including the elapsed part of spans that are still open."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
now, cpu_now = time.monotonic(), time.process_time()
|
|
117
|
+
totals = {label: dict(entry) for label, entry in self._totals.items()}
|
|
118
|
+
for label, (_, wall, cpu) in self._open.items():
|
|
119
|
+
totals[label]["wall_seconds"] += now - wall
|
|
120
|
+
totals[label]["cpu_seconds"] += cpu_now - cpu
|
|
121
|
+
return totals
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_ACTIVITY: ActivityTotals | None = None
|
|
125
|
+
"""Set only while a capture runs, so `activity()` costs an attribute read otherwise."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def activity(label: str) -> Iterator[None]:
|
|
130
|
+
"""Attribute the enclosed work to `label` when a capture is running.
|
|
131
|
+
|
|
132
|
+
Safe to call from anywhere: without a capture it does nothing, and a capture
|
|
133
|
+
that stops mid-span still receives that span's time.
|
|
134
|
+
"""
|
|
135
|
+
totals = _ACTIVITY
|
|
136
|
+
if totals is None:
|
|
137
|
+
yield
|
|
138
|
+
return
|
|
139
|
+
totals.enter(label)
|
|
140
|
+
try:
|
|
141
|
+
yield
|
|
142
|
+
finally:
|
|
143
|
+
totals.exit(label)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ResourceProfile:
|
|
147
|
+
"""Optional function timings plus sampled process-tree resource usage.
|
|
148
|
+
|
|
149
|
+
Samples are streamed to disk, not retained in memory. Descendants can disappear
|
|
150
|
+
between samples; their totals are observations, not complete job accounting.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
directory: Path,
|
|
156
|
+
*,
|
|
157
|
+
cpu: bool = False,
|
|
158
|
+
memory: bool = False,
|
|
159
|
+
interval: float = 1.0,
|
|
160
|
+
discovery_interval: float = 5.0,
|
|
161
|
+
):
|
|
162
|
+
self.directory = directory
|
|
163
|
+
self.memory = memory
|
|
164
|
+
self.cpu = cpu
|
|
165
|
+
self.interval = interval
|
|
166
|
+
# Finding descendants costs far more than measuring known ones, so it
|
|
167
|
+
# runs on its own slower clock; never faster than sampling itself.
|
|
168
|
+
self.discovery_interval = max(interval, discovery_interval)
|
|
169
|
+
self._stop = threading.Event()
|
|
170
|
+
self._tracked: dict[int, object] = {}
|
|
171
|
+
self._discovered = float("-inf")
|
|
172
|
+
self._previous: dict[tuple[int, float], tuple[float, float]] = {}
|
|
173
|
+
self._peak_rss = 0
|
|
174
|
+
self._peak_children_rss = 0
|
|
175
|
+
self._samples = 0
|
|
176
|
+
self._sampling_errors = 0
|
|
177
|
+
self.activity = ActivityTotals()
|
|
178
|
+
self._cpu = None
|
|
179
|
+
self._cpu_clock = None
|
|
180
|
+
|
|
181
|
+
def _open(self, name: str):
|
|
182
|
+
fd = os.open(self.directory / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
183
|
+
return os.fdopen(fd, "w", encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
def _replace(self, name: str, payload: dict) -> None:
|
|
186
|
+
"""Rewrite a file atomically, so an interrupted capture leaves the last one whole."""
|
|
187
|
+
temporary = self.directory / f".{name}.partial"
|
|
188
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
189
|
+
try:
|
|
190
|
+
with os.fdopen(fd, "w", encoding="utf-8") as output:
|
|
191
|
+
json.dump(payload, output, indent=2)
|
|
192
|
+
output.write("\n")
|
|
193
|
+
os.replace(temporary, self.directory / name)
|
|
194
|
+
except BaseException:
|
|
195
|
+
temporary.unlink(missing_ok=True)
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def start(self) -> None:
|
|
199
|
+
global _ACTIVITY
|
|
200
|
+
import psutil
|
|
201
|
+
|
|
202
|
+
if self.memory and tracemalloc.is_tracing():
|
|
203
|
+
raise ValueError("allocation tracing is already active")
|
|
204
|
+
if self.cpu:
|
|
205
|
+
# Not cProfile: on Python 3.14 it can observe worker threads, and a
|
|
206
|
+
# `time.thread_time` timer then produces negative timings. Yappi
|
|
207
|
+
# accounts CPU per thread.
|
|
208
|
+
import yappi
|
|
209
|
+
|
|
210
|
+
if yappi.is_running() or yappi.get_func_stats():
|
|
211
|
+
raise ValueError("function profiler is already in use")
|
|
212
|
+
self._cpu = yappi
|
|
213
|
+
self._cpu_clock = yappi.get_clock_type()
|
|
214
|
+
# Refuse reuse rather than clobbering an earlier capture or following a symlink.
|
|
215
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
216
|
+
self._process = psutil.Process()
|
|
217
|
+
self._stream = self._open("resources.jsonl")
|
|
218
|
+
self._started = time.monotonic()
|
|
219
|
+
self._cpu_started = time.process_time()
|
|
220
|
+
self.activity = ActivityTotals()
|
|
221
|
+
_ACTIVITY = self.activity
|
|
222
|
+
self._thread = threading.Thread(target=self._monitor, name="resource-profile", daemon=True)
|
|
223
|
+
try:
|
|
224
|
+
if self.memory:
|
|
225
|
+
tracemalloc.start(10)
|
|
226
|
+
self._sample()
|
|
227
|
+
self._thread.start()
|
|
228
|
+
if self._cpu is not None:
|
|
229
|
+
self._cpu.set_clock_type("cpu")
|
|
230
|
+
self._cpu.start(builtins=True, profile_threads=True)
|
|
231
|
+
except BaseException:
|
|
232
|
+
_ACTIVITY = None
|
|
233
|
+
self._stop.set()
|
|
234
|
+
if self._thread.is_alive():
|
|
235
|
+
self._thread.join()
|
|
236
|
+
try:
|
|
237
|
+
self._stream.close()
|
|
238
|
+
finally:
|
|
239
|
+
if self.memory:
|
|
240
|
+
tracemalloc.stop()
|
|
241
|
+
self._clear_cpu()
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
def _descendants(self, psutil, now: float) -> list:
|
|
245
|
+
"""Known descendants, rediscovering the process tree only occasionally.
|
|
246
|
+
|
|
247
|
+
`children(recursive=True)` has to read every process on the machine to
|
|
248
|
+
build a parent map, which dominates a sample: about 11 ms of 12 ms on a
|
|
249
|
+
machine with a thousand processes, or a percent of a core at one sample
|
|
250
|
+
per second. Re-measuring processes already known is thousands of times
|
|
251
|
+
cheaper, so discovery runs every `discovery_interval` instead. A
|
|
252
|
+
descendant is therefore picked up within that window, and one that both
|
|
253
|
+
starts and exits inside a window is missed entirely -- as anything
|
|
254
|
+
shorter than `interval` always was.
|
|
255
|
+
"""
|
|
256
|
+
if now - self._discovered < self.discovery_interval:
|
|
257
|
+
return list(self._tracked.values())
|
|
258
|
+
self._discovered = now
|
|
259
|
+
try:
|
|
260
|
+
found = self._process.children(recursive=True)
|
|
261
|
+
except psutil.Error:
|
|
262
|
+
self._sampling_errors += 1
|
|
263
|
+
return list(self._tracked.values())
|
|
264
|
+
# Keep the object already held for a surviving PID: psutil caches its
|
|
265
|
+
# creation time there, which is what detects a recycled PID later.
|
|
266
|
+
self._tracked = {process.pid: self._tracked.get(process.pid, process) for process in found}
|
|
267
|
+
return list(self._tracked.values())
|
|
268
|
+
|
|
269
|
+
def _sample(self) -> None:
|
|
270
|
+
import psutil
|
|
271
|
+
|
|
272
|
+
now = time.monotonic()
|
|
273
|
+
processes = []
|
|
274
|
+
previous = {}
|
|
275
|
+
for process in [self._process, *self._descendants(psutil, now)]:
|
|
276
|
+
try:
|
|
277
|
+
with process.oneshot():
|
|
278
|
+
identity = (process.pid, process.create_time())
|
|
279
|
+
cpu = process.cpu_times()
|
|
280
|
+
cpu_seconds = cpu.user + cpu.system
|
|
281
|
+
rss = process.memory_info().rss
|
|
282
|
+
threads = process.num_threads()
|
|
283
|
+
ppid = process.ppid()
|
|
284
|
+
before = self._previous.get(identity)
|
|
285
|
+
percent = None
|
|
286
|
+
if before is not None and now > before[0]:
|
|
287
|
+
percent = max(0.0, 100 * (cpu_seconds - before[1]) / (now - before[0]))
|
|
288
|
+
previous[identity] = (now, cpu_seconds)
|
|
289
|
+
processes.append(
|
|
290
|
+
dict(
|
|
291
|
+
pid=process.pid,
|
|
292
|
+
ppid=ppid,
|
|
293
|
+
role="pcode" if process.pid == self._process.pid else "child",
|
|
294
|
+
cpu_seconds=cpu_seconds,
|
|
295
|
+
cpu_percent=percent,
|
|
296
|
+
rss_bytes=rss,
|
|
297
|
+
threads=threads,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
except psutil.Error:
|
|
301
|
+
# Gone, or a PID psutil no longer recognizes as the same process.
|
|
302
|
+
# Either way it is not a descendant to measure next time.
|
|
303
|
+
self._tracked.pop(process.pid, None)
|
|
304
|
+
self._sampling_errors += 1
|
|
305
|
+
self._previous = previous
|
|
306
|
+
own_rss = next((p["rss_bytes"] for p in processes if p["role"] == "pcode"), 0)
|
|
307
|
+
children_rss = sum(p["rss_bytes"] for p in processes if p["role"] == "child")
|
|
308
|
+
self._peak_rss = max(self._peak_rss, own_rss)
|
|
309
|
+
self._peak_children_rss = max(self._peak_children_rss, children_rss)
|
|
310
|
+
sample = dict(
|
|
311
|
+
elapsed_seconds=now - self._started,
|
|
312
|
+
activity=self.activity.label,
|
|
313
|
+
processes=processes,
|
|
314
|
+
)
|
|
315
|
+
if self.memory:
|
|
316
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
317
|
+
sample.update(python_current_bytes=current, python_peak_bytes=peak)
|
|
318
|
+
self._stream.write(json.dumps(sample) + "\n")
|
|
319
|
+
self._stream.flush()
|
|
320
|
+
self._samples += 1
|
|
321
|
+
# A live session is usually killed with its terminal rather than exited
|
|
322
|
+
# cleanly, so the aggregate is republished with every sample instead of
|
|
323
|
+
# only at shutdown.
|
|
324
|
+
self._replace("summary.json", self._summary(complete=False))
|
|
325
|
+
|
|
326
|
+
def _monitor(self) -> None:
|
|
327
|
+
while not self._stop.wait(self.interval):
|
|
328
|
+
try:
|
|
329
|
+
self._sample()
|
|
330
|
+
except OSError:
|
|
331
|
+
self._sampling_errors += 1
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
def _clear_cpu(self) -> None:
|
|
335
|
+
if self._cpu is not None:
|
|
336
|
+
self._cpu.stop()
|
|
337
|
+
self._cpu.clear_stats()
|
|
338
|
+
self._cpu.set_clock_type(self._cpu_clock)
|
|
339
|
+
|
|
340
|
+
def _write_cpu(self) -> None:
|
|
341
|
+
if self._cpu is None:
|
|
342
|
+
return
|
|
343
|
+
with self._open("cpu.pstats"):
|
|
344
|
+
pass
|
|
345
|
+
stats = self._cpu.convert2pstats(self._cpu.get_func_stats())
|
|
346
|
+
stats.dump_stats(str(self.directory / "cpu.pstats"))
|
|
347
|
+
report = io.StringIO()
|
|
348
|
+
stats.stream = report
|
|
349
|
+
stats.strip_dirs()
|
|
350
|
+
report.write(
|
|
351
|
+
"Function CPU seconds across Python threads; sleeping/network waits excluded.\n"
|
|
352
|
+
)
|
|
353
|
+
report.write(
|
|
354
|
+
"Includes profiling overhead. Native-only threads/children are not attributed.\n"
|
|
355
|
+
)
|
|
356
|
+
report.write("\nTop cumulative CPU:\n")
|
|
357
|
+
stats.sort_stats("cumulative").print_stats(50)
|
|
358
|
+
report.write("\nTop self CPU:\n")
|
|
359
|
+
stats.sort_stats("tottime").print_stats(50)
|
|
360
|
+
with self._open("cpu.txt") as output:
|
|
361
|
+
output.write(report.getvalue())
|
|
362
|
+
|
|
363
|
+
def _summary(
|
|
364
|
+
self, *, complete: bool, elapsed: float | None = None, cpu_seconds: float | None = None
|
|
365
|
+
) -> dict:
|
|
366
|
+
if elapsed is None:
|
|
367
|
+
elapsed = time.monotonic() - self._started
|
|
368
|
+
if cpu_seconds is None:
|
|
369
|
+
cpu_seconds = time.process_time() - self._cpu_started
|
|
370
|
+
attributed = self.activity.snapshot()
|
|
371
|
+
busy = sum(entry["wall_seconds"] for entry in attributed.values())
|
|
372
|
+
busy_cpu = sum(entry["cpu_seconds"] for entry in attributed.values())
|
|
373
|
+
return dict(
|
|
374
|
+
schema_version=3,
|
|
375
|
+
# A capture that was killed still has every field except this one.
|
|
376
|
+
complete=complete,
|
|
377
|
+
python=platform.python_version(),
|
|
378
|
+
platform=sys.platform,
|
|
379
|
+
elapsed_seconds=elapsed,
|
|
380
|
+
process_cpu_seconds=cpu_seconds,
|
|
381
|
+
average_cpu_percent=100 * cpu_seconds / elapsed if elapsed else 0,
|
|
382
|
+
sampled_peak_rss_bytes=self._peak_rss,
|
|
383
|
+
sampled_peak_children_rss_bytes=self._peak_children_rss,
|
|
384
|
+
samples=self._samples,
|
|
385
|
+
sampling_errors=self._sampling_errors,
|
|
386
|
+
interval_seconds=self.interval,
|
|
387
|
+
descendant_scan_seconds=self.discovery_interval,
|
|
388
|
+
memory_tracing=self.memory,
|
|
389
|
+
function_clock="cpu" if self._cpu is not None else None,
|
|
390
|
+
function_scope="python_threads" if self._cpu is not None else None,
|
|
391
|
+
activity=attributed,
|
|
392
|
+
# Time outside every span: what the session cost while doing nothing
|
|
393
|
+
# the app named. Overlapping spans can push these below zero.
|
|
394
|
+
idle_seconds=max(0.0, elapsed - busy),
|
|
395
|
+
idle_cpu_seconds=max(0.0, cpu_seconds - busy_cpu),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def stop(self) -> None:
|
|
399
|
+
global _ACTIVITY
|
|
400
|
+
|
|
401
|
+
if self._cpu is not None:
|
|
402
|
+
self._cpu.stop()
|
|
403
|
+
elapsed = time.monotonic() - self._started
|
|
404
|
+
cpu_seconds = time.process_time() - self._cpu_started
|
|
405
|
+
_ACTIVITY = None
|
|
406
|
+
self._stop.set()
|
|
407
|
+
self._thread.join()
|
|
408
|
+
try:
|
|
409
|
+
# One last full walk: the closing sample should describe the tree as
|
|
410
|
+
# it is now, not as the previous discovery left it.
|
|
411
|
+
self._discovered = float("-inf")
|
|
412
|
+
self._sample()
|
|
413
|
+
self._stream.close()
|
|
414
|
+
memory_usage = None
|
|
415
|
+
snapshot = None
|
|
416
|
+
if self.memory:
|
|
417
|
+
memory_usage = tracemalloc.get_traced_memory()
|
|
418
|
+
snapshot = tracemalloc.take_snapshot()
|
|
419
|
+
tracemalloc.stop()
|
|
420
|
+
self._write_cpu()
|
|
421
|
+
summary = self._summary(complete=True, elapsed=elapsed, cpu_seconds=cpu_seconds)
|
|
422
|
+
if snapshot is not None and memory_usage is not None:
|
|
423
|
+
current, peak = memory_usage
|
|
424
|
+
summary.update(python_current_bytes=current, python_peak_bytes=peak)
|
|
425
|
+
# No source lines or heap contents, only allocation locations/sizes.
|
|
426
|
+
allocations = [
|
|
427
|
+
dict(
|
|
428
|
+
filename=stat.traceback[0].filename,
|
|
429
|
+
line=stat.traceback[0].lineno,
|
|
430
|
+
size_bytes=stat.size,
|
|
431
|
+
count=stat.count,
|
|
432
|
+
)
|
|
433
|
+
for stat in snapshot.statistics("lineno")[:50]
|
|
434
|
+
]
|
|
435
|
+
with self._open("allocations.json") as output:
|
|
436
|
+
json.dump(allocations, output, indent=2)
|
|
437
|
+
output.write("\n")
|
|
438
|
+
self._replace("summary.json", summary)
|
|
439
|
+
finally:
|
|
440
|
+
try:
|
|
441
|
+
self._stream.close()
|
|
442
|
+
finally:
|
|
443
|
+
if self.memory:
|
|
444
|
+
tracemalloc.stop()
|
|
445
|
+
self._clear_cpu()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@contextmanager
|
|
449
|
+
def profile_session(directory: Path, *, cpu: bool = False, memory: bool = False) -> Iterator[None]:
|
|
450
|
+
profile = ResourceProfile(directory, cpu=cpu, memory=memory)
|
|
451
|
+
profile.start()
|
|
452
|
+
try:
|
|
453
|
+
yield
|
|
454
|
+
finally:
|
|
455
|
+
try:
|
|
456
|
+
profile.stop()
|
|
457
|
+
except Exception as error:
|
|
458
|
+
# Profiling must not hide the application's error or change its exit code.
|
|
459
|
+
print(f"Profile could not be finalized ({type(error).__name__}).", file=sys.stderr)
|
|
460
|
+
else:
|
|
461
|
+
print(f"Profile saved to {directory}", file=sys.stderr)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _megabytes(value: float) -> str:
|
|
465
|
+
return f"{value / 1_000_000:.1f} MB"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def report(directory: Path) -> str:
|
|
469
|
+
"""A readable digest of one capture, so the artifacts need no extra tooling.
|
|
470
|
+
|
|
471
|
+
Reads only what the capture wrote. A killed session has no `cpu`/`allocations`
|
|
472
|
+
artifacts and an incomplete summary; both are reported rather than treated as
|
|
473
|
+
an error.
|
|
474
|
+
"""
|
|
475
|
+
summary = json.loads((directory / "summary.json").read_text())
|
|
476
|
+
elapsed = summary.get("elapsed_seconds") or 0.0
|
|
477
|
+
lines = [
|
|
478
|
+
f"Capture: {directory}",
|
|
479
|
+
"Ended: clean shutdown" if summary.get("complete") else "Ended: killed (last sample)",
|
|
480
|
+
f"Duration: {elapsed:.1f} s over {summary.get('samples', 0)} samples",
|
|
481
|
+
f"Process CPU: {summary.get('process_cpu_seconds', 0):.1f} s"
|
|
482
|
+
f" ({summary.get('average_cpu_percent', 0):.1f}% of one core)",
|
|
483
|
+
f"Peak RSS: {_megabytes(summary.get('sampled_peak_rss_bytes', 0))}"
|
|
484
|
+
f" (children {_megabytes(summary.get('sampled_peak_children_rss_bytes', 0))})",
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
def share(cpu: float) -> str:
|
|
488
|
+
return f" ({100 * cpu / elapsed:.1f}% of one core)" if elapsed else ""
|
|
489
|
+
|
|
490
|
+
idle_cpu = summary.get("idle_cpu_seconds", 0.0)
|
|
491
|
+
lines.append(
|
|
492
|
+
f"Unattributed CPU: {idle_cpu:.1f} s{share(idle_cpu)}"
|
|
493
|
+
f" over {summary.get('idle_seconds', 0):.1f} s"
|
|
494
|
+
)
|
|
495
|
+
for label, entry in sorted(
|
|
496
|
+
summary.get("activity", {}).items(), key=lambda item: -item[1]["cpu_seconds"]
|
|
497
|
+
):
|
|
498
|
+
lines.append(
|
|
499
|
+
f"Activity {label}: {entry['cpu_seconds']:.1f} s CPU{share(entry['cpu_seconds'])}"
|
|
500
|
+
f" over {entry['wall_seconds']:.1f} s in {int(entry['spans'])} spans"
|
|
501
|
+
)
|
|
502
|
+
children: dict[int, tuple[float, int]] = {}
|
|
503
|
+
own_peak_threads = 0
|
|
504
|
+
try:
|
|
505
|
+
with (directory / "resources.jsonl").open(encoding="utf-8") as stream:
|
|
506
|
+
for line in stream:
|
|
507
|
+
sample = json.loads(line)
|
|
508
|
+
for process in sample["processes"]:
|
|
509
|
+
if process["role"] == "pcode":
|
|
510
|
+
own_peak_threads = max(own_peak_threads, process["threads"])
|
|
511
|
+
continue
|
|
512
|
+
# Per-PID CPU is a lifetime counter, so the last observation
|
|
513
|
+
# is that child's total, not a per-sample cost.
|
|
514
|
+
seen = children.get(process["pid"], (0.0, 0))
|
|
515
|
+
children[process["pid"]] = (
|
|
516
|
+
max(seen[0], process["cpu_seconds"]),
|
|
517
|
+
max(seen[1], process["rss_bytes"]),
|
|
518
|
+
)
|
|
519
|
+
except (OSError, ValueError, KeyError):
|
|
520
|
+
lines.append("Samples: unreadable or truncated")
|
|
521
|
+
else:
|
|
522
|
+
lines.append(f"Peak threads in pcode: {own_peak_threads}")
|
|
523
|
+
ranked = sorted(children.items(), key=lambda item: -item[1][0])[:5]
|
|
524
|
+
for pid, (cpu, rss) in ranked:
|
|
525
|
+
lines.append(f"Child {pid}: {cpu:.1f} s CPU seen, peak RSS {_megabytes(rss)}")
|
|
526
|
+
if not ranked:
|
|
527
|
+
lines.append("Children: none observed")
|
|
528
|
+
if summary.get("sampling_errors"):
|
|
529
|
+
lines.append(f"Incomplete coverage: {summary['sampling_errors']} sampling errors")
|
|
530
|
+
for artifact, description in (
|
|
531
|
+
("cpu.txt", "function CPU"),
|
|
532
|
+
("allocations.json", "live allocations"),
|
|
533
|
+
):
|
|
534
|
+
if (directory / artifact).exists():
|
|
535
|
+
lines.append(f"See {directory / artifact} for {description}")
|
|
536
|
+
return "\n".join(lines)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main(argv: list[str] | None = None) -> int:
|
|
540
|
+
import argparse
|
|
541
|
+
|
|
542
|
+
parser = argparse.ArgumentParser(
|
|
543
|
+
prog="python -m pcode.profiling",
|
|
544
|
+
description="Summarize a pcode profiling capture, or list recent captures",
|
|
545
|
+
)
|
|
546
|
+
parser.add_argument("directory", type=Path, nargs="?", help="Capture directory; omit to list")
|
|
547
|
+
arguments = parser.parse_args(argv)
|
|
548
|
+
if arguments.directory is None:
|
|
549
|
+
root = captures_root()
|
|
550
|
+
captures = sorted(path for path in root.glob("*") if (path / "summary.json").exists())
|
|
551
|
+
if not captures:
|
|
552
|
+
print(f"No captures under {root}", file=sys.stderr)
|
|
553
|
+
return 1
|
|
554
|
+
for path in captures:
|
|
555
|
+
print(path)
|
|
556
|
+
return 0
|
|
557
|
+
try:
|
|
558
|
+
print(report(arguments.directory))
|
|
559
|
+
except (OSError, ValueError) as error:
|
|
560
|
+
print(f"Cannot read capture ({type(error).__name__})", file=sys.stderr)
|
|
561
|
+
return 1
|
|
562
|
+
return 0
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
if __name__ == "__main__":
|
|
566
|
+
raise SystemExit(main())
|
pcode/project_trust.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Which repositories may run the code they ship: `.pcode/extensions` and `.pcode/worktree-setup`.
|
|
2
|
+
|
|
3
|
+
Both execute at launch with the user's permissions, so a freshly cloned
|
|
4
|
+
repository must not get to run them just because pcode was started inside it.
|
|
5
|
+
Trust is granted per repository, once, by answering the launch prompt; the
|
|
6
|
+
answer lands in the user-only `trusted_projects` setting, keyed by the primary
|
|
7
|
+
checkout so every worktree of a trusted repository is covered.
|
|
8
|
+
`project_extensions on` remains the blanket opt-in.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pcode.preferences import SETTINGS, load_preferences, update_preferences
|
|
15
|
+
|
|
16
|
+
EXTENSIONS_DIR = Path(".pcode") / "extensions"
|
|
17
|
+
SETUP_SCRIPT = Path(".pcode") / "worktree-setup"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def trust_key(workspace: Path) -> Path:
|
|
21
|
+
"""The path trust is recorded under: the primary checkout of a git repo."""
|
|
22
|
+
from pcode.worktree import repo_scope
|
|
23
|
+
|
|
24
|
+
return repo_scope(workspace)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def project_code(workspace: Path) -> list[Path]:
|
|
28
|
+
"""Launch-time code the repository ships, relative to its root, or empty."""
|
|
29
|
+
found = []
|
|
30
|
+
extensions = workspace / EXTENSIONS_DIR
|
|
31
|
+
if extensions.is_dir() and any(not p.name.startswith(("_", ".")) for p in extensions.iterdir()):
|
|
32
|
+
found.append(EXTENSIONS_DIR)
|
|
33
|
+
if (workspace / SETUP_SCRIPT).is_file():
|
|
34
|
+
found.append(SETUP_SCRIPT)
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def trusted_projects() -> set[Path]:
|
|
39
|
+
raw = load_preferences().get("trusted_projects", SETTINGS["trusted_projects"].default) or ""
|
|
40
|
+
return {Path(entry).expanduser() for entry in raw.split(os.pathsep) if entry.strip()}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_trusted(workspace: Path) -> bool:
|
|
44
|
+
if load_preferences().get("project_extensions", "off") == "on":
|
|
45
|
+
return True
|
|
46
|
+
return trust_key(workspace) in trusted_projects()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def trust(workspace: Path) -> Path:
|
|
50
|
+
"""Record trust for the repository containing `workspace`; returns the recorded key."""
|
|
51
|
+
key = trust_key(workspace)
|
|
52
|
+
entries = trusted_projects() | {key}
|
|
53
|
+
update_preferences({"trusted_projects": os.pathsep.join(str(p) for p in sorted(entries))})
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def prompt_trust(workspace: Path, ask=input, stream=None) -> bool:
|
|
58
|
+
"""Ask once, at launch, whether a repository shipping code may run it.
|
|
59
|
+
|
|
60
|
+
Returns whether it is trusted afterwards. No prompt when nothing is
|
|
61
|
+
shipped, when already trusted, or when `ask` is None (non-interactive):
|
|
62
|
+
then the code is skipped and a note says so.
|
|
63
|
+
"""
|
|
64
|
+
import sys
|
|
65
|
+
|
|
66
|
+
stream = stream or sys.stderr
|
|
67
|
+
code = project_code(workspace)
|
|
68
|
+
if not code:
|
|
69
|
+
return False
|
|
70
|
+
if is_trusted(workspace):
|
|
71
|
+
return True
|
|
72
|
+
key = trust_key(workspace)
|
|
73
|
+
listed = ", ".join(str(p) for p in code)
|
|
74
|
+
if ask is None:
|
|
75
|
+
print(f"pcode: skipping untrusted project code in {key}: {listed}", file=stream)
|
|
76
|
+
return False
|
|
77
|
+
print(
|
|
78
|
+
f"pcode: {key} ships code that runs at launch with your permissions: {listed}",
|
|
79
|
+
file=stream,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
answer = ask("Trust this repository? [y/N] ")
|
|
83
|
+
except (EOFError, OSError, KeyboardInterrupt):
|
|
84
|
+
answer = "" # closed or captured stdin: same as declining
|
|
85
|
+
if answer.strip().lower() in ("y", "yes"):
|
|
86
|
+
trust(workspace)
|
|
87
|
+
print(
|
|
88
|
+
"pcode: trusted; revoke with `pcode config unset trusted_projects` "
|
|
89
|
+
"or edit that setting.",
|
|
90
|
+
file=stream,
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
print("pcode: not trusted; that code is skipped this launch.", file=stream)
|
|
94
|
+
return False
|