stacktrace-cli 0.3.1__py3-none-any.whl → 0.4.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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +202 -20
- stacktrace_cli/build.py +130 -0
- stacktrace_cli/cli.py +137 -55
- stacktrace_cli/correlate/acquire.py +23 -2
- stacktrace_cli/correlate/orchestrate.py +73 -9
- stacktrace_cli/correlate/render.py +7 -5
- stacktrace_cli/daemon/__init__.py +1 -0
- stacktrace_cli/daemon/cli.py +106 -0
- stacktrace_cli/daemon/client.py +134 -0
- stacktrace_cli/daemon/composition.py +133 -0
- stacktrace_cli/daemon/observer.py +173 -0
- stacktrace_cli/daemon/paths.py +35 -0
- stacktrace_cli/daemon/protocol.py +29 -0
- stacktrace_cli/daemon/runtime.py +222 -0
- stacktrace_cli/daemon/server.py +164 -0
- stacktrace_cli/daemon/service.py +154 -0
- stacktrace_cli/daemon/store.py +368 -0
- stacktrace_cli/detector/blocked.py +450 -0
- stacktrace_cli/detector/cache.py +245 -9
- stacktrace_cli/detector/deterministic.py +359 -511
- stacktrace_cli/detector/finding.py +163 -15
- stacktrace_cli/detector/history.py +210 -0
- stacktrace_cli/detector/jev.py +213 -0
- stacktrace_cli/detector/priors.py +53 -285
- stacktrace_cli/detector/prompts/__init__.py +20 -0
- stacktrace_cli/detector/prompts/jev-v1/stacktrace-deceptive-completion.json +16 -0
- stacktrace_cli/detector/prompts/jev-v2/stacktrace-deceptive-completion.json +16 -0
- stacktrace_cli/detector/reasoning.py +858 -308
- stacktrace_cli/detector/render.py +88 -7
- stacktrace_cli/detector/rules.py +69 -56
- stacktrace_cli/detector/run.py +423 -77
- stacktrace_cli/detector/secrets.py +8 -6
- stacktrace_cli/detector/windows.py +293 -0
- stacktrace_cli/monitor/reasoning.py +153 -21
- stacktrace_cli/monitor/render.py +421 -10
- stacktrace_cli/monitor/server.py +242 -21
- stacktrace_cli/monitor/site/app.js +1154 -178
- stacktrace_cli/monitor/site/index.html +16 -1
- stacktrace_cli/monitor/site/styles.css +89 -1
- stacktrace_cli/monitor/state.py +27 -1
- stacktrace_cli/monitor/watch.py +227 -47
- stacktrace_cli/options.py +107 -0
- stacktrace_cli/private_state.py +38 -0
- stacktrace_cli/remote/cli.py +20 -10
- stacktrace_cli/remote/detect_payload.py +37 -5
- stacktrace_cli/remote/redact.py +3 -3
- stacktrace_cli/remote/spool.py +3 -2
- stacktrace_cli/remote/sync_detect.py +15 -1
- stacktrace_cli/remote/upload_contract.py +7 -0
- stacktrace_cli/sessions/access.py +24 -0
- stacktrace_cli/sessions/index.py +30 -0
- stacktrace_cli/sessions/protocols.py +13 -0
- stacktrace_cli/sessions/render.py +15 -9
- stacktrace_cli/telemetry/__init__.py +11 -0
- stacktrace_cli/telemetry/cli.py +67 -0
- stacktrace_cli/telemetry/events.py +266 -0
- stacktrace_cli/telemetry/posthog.py +126 -0
- stacktrace_cli/telemetry/state.py +190 -0
- {stacktrace_cli-0.3.1.dist-info → stacktrace_cli-0.4.0.dist-info}/METADATA +56 -24
- stacktrace_cli-0.4.0.dist-info/RECORD +90 -0
- {stacktrace_cli-0.3.1.dist-info → stacktrace_cli-0.4.0.dist-info}/WHEEL +1 -1
- stacktrace_cli/detector/markers.py +0 -158
- stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +0 -14
- stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +0 -11
- stacktrace_cli-0.3.1.dist-info/RECORD +0 -67
- {stacktrace_cli-0.3.1.dist-info → stacktrace_cli-0.4.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/analysis.py
CHANGED
|
@@ -29,9 +29,11 @@ from stacktrace_cli.correlate.orchestrate import Acquired, acquire_correlated_vi
|
|
|
29
29
|
from stacktrace_cli.correlate.project_map import parse_mapping
|
|
30
30
|
from stacktrace_cli.correlate.record import CorrelatedSession, CorrelatedView, Coverage
|
|
31
31
|
from stacktrace_cli.detector.cache import VerdictCache
|
|
32
|
+
from stacktrace_cli.detector.history import DriftHistory
|
|
32
33
|
from stacktrace_cli.detector.run import (
|
|
33
34
|
DEFAULT_BUDGET,
|
|
34
35
|
DEFAULT_SAMPLE_BUDGET,
|
|
36
|
+
NO_ANALYZER,
|
|
35
37
|
DetectorRun,
|
|
36
38
|
run_detector,
|
|
37
39
|
)
|
|
@@ -82,20 +84,40 @@ def parse_since(value: str) -> datetime:
|
|
|
82
84
|
raise ValueError(f"--since must be a positive day count, not {value!r}") from None
|
|
83
85
|
|
|
84
86
|
|
|
85
|
-
def _only(
|
|
86
|
-
|
|
87
|
+
def _only(
|
|
88
|
+
view: SessionView,
|
|
89
|
+
session_ids: tuple[str, ...],
|
|
90
|
+
agent_kinds: tuple[str, ...],
|
|
91
|
+
) -> SessionView:
|
|
92
|
+
"""The window narrowed to named session identities, or the window itself.
|
|
87
93
|
|
|
88
94
|
Narrowed *before* correlation rather than after judgement, because the point
|
|
89
95
|
is that nothing else can be dispatched: a caller that pays for one named
|
|
90
96
|
session must not be able to spend that payment on another one the detector
|
|
91
|
-
happened to rank higher.
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
happened to rank higher. The kind and id filters are both applied when the
|
|
98
|
+
caller supplies both halves of the identity. `unavailable` is carried
|
|
99
|
+
through unchanged — a reader that failed is still a reader that failed,
|
|
100
|
+
whichever session was asked for.
|
|
101
|
+
|
|
102
|
+
A kind-anonymous session (`agent_kind is None`) is never dropped by the
|
|
103
|
+
kind filter: `sessions/access.py`'s coverage contract is that a session
|
|
104
|
+
OpenAIDR could not map to a kind is a gap to surface, not a kind the user
|
|
105
|
+
filtered out, and this seam must not reintroduce that drop after
|
|
106
|
+
collection already kept it.
|
|
94
107
|
"""
|
|
95
|
-
if not session_ids:
|
|
108
|
+
if not session_ids and not agent_kinds:
|
|
96
109
|
return view
|
|
97
110
|
wanted = set(session_ids)
|
|
98
|
-
|
|
111
|
+
kinds = set(agent_kinds)
|
|
112
|
+
return replace(
|
|
113
|
+
view,
|
|
114
|
+
sessions=tuple(
|
|
115
|
+
session
|
|
116
|
+
for session in view.sessions
|
|
117
|
+
if (not wanted or session.session_id in wanted)
|
|
118
|
+
and (not kinds or session.agent_kind is None or session.agent_kind in kinds)
|
|
119
|
+
),
|
|
120
|
+
)
|
|
99
121
|
|
|
100
122
|
|
|
101
123
|
def analyse(
|
|
@@ -111,6 +133,10 @@ def analyse(
|
|
|
111
133
|
sample_budget: int = DEFAULT_SAMPLE_BUDGET,
|
|
112
134
|
cache: VerdictCache | None = None,
|
|
113
135
|
attach_advisories: Any = None,
|
|
136
|
+
build_all: Any = None,
|
|
137
|
+
analyzer: str = NO_ANALYZER,
|
|
138
|
+
jev_key: str | None = None,
|
|
139
|
+
history: DriftHistory | None = None,
|
|
114
140
|
) -> Analysis:
|
|
115
141
|
"""Read this machine's sessions, correlate them, and judge them.
|
|
116
142
|
|
|
@@ -122,23 +148,111 @@ def analyse(
|
|
|
122
148
|
cache through it so osv.dev is asked once per component rather than once per
|
|
123
149
|
pass; a one-shot command passes nothing and gets the default.
|
|
124
150
|
|
|
151
|
+
`build_all` is the same kind of seam for composition builds, which shell out
|
|
152
|
+
and cost seconds each. A long-lived process passes a cache through it so an
|
|
153
|
+
unchanged machine or project is not rebuilt on every pass; a one-shot
|
|
154
|
+
command passes nothing and gets the default.
|
|
155
|
+
|
|
125
156
|
`session_ids` narrows the window to named sessions. Empty means the whole
|
|
126
157
|
window, which is what every command passes; monitor's reasoning button is
|
|
127
158
|
what needs the narrowing, and needs it to be structural.
|
|
128
159
|
|
|
129
160
|
`since` is the window's spelling — `parse_since` reads it — or the cutoff
|
|
130
161
|
itself, for a caller whose own `--since` spelling is not this one's.
|
|
162
|
+
|
|
163
|
+
`reasoning` switches the third stage on and is off by default; `jev_key`
|
|
164
|
+
hands the analyzer a key a caller already holds, in place of reading
|
|
165
|
+
`TYPESAFE_API_KEY`. Both travel to `run_detector` unchanged, so a
|
|
166
|
+
programmatic caller controls exactly what `detect` controls.
|
|
131
167
|
"""
|
|
132
168
|
window_end = datetime.now(UTC)
|
|
133
169
|
window_start = since if isinstance(since, datetime) else parse_since(since)
|
|
134
|
-
|
|
170
|
+
sessions = _collected(
|
|
135
171
|
agent_kinds=agent_kinds,
|
|
136
172
|
window_start=window_start,
|
|
137
|
-
bom_paths=bom_paths,
|
|
138
|
-
project_map=project_map,
|
|
139
173
|
root=root,
|
|
140
174
|
session_ids=session_ids,
|
|
175
|
+
)
|
|
176
|
+
return _analyse_sessions(
|
|
177
|
+
sessions,
|
|
178
|
+
window_start=window_start,
|
|
179
|
+
window_end=window_end,
|
|
180
|
+
bom_paths=bom_paths,
|
|
181
|
+
project_map=project_map,
|
|
182
|
+
reasoning=reasoning,
|
|
183
|
+
budget=budget,
|
|
184
|
+
sample_budget=sample_budget,
|
|
185
|
+
cache=cache,
|
|
186
|
+
attach_advisories=attach_advisories,
|
|
187
|
+
build_all=build_all,
|
|
188
|
+
analyzer=analyzer,
|
|
189
|
+
jev_key=jev_key,
|
|
190
|
+
history=history,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def analyse_sessions(
|
|
195
|
+
sessions: SessionView,
|
|
196
|
+
*,
|
|
197
|
+
agent_kinds: tuple[str, ...] = (),
|
|
198
|
+
since: str | datetime = "7d",
|
|
199
|
+
bom_paths: tuple[Path, ...] = (),
|
|
200
|
+
project_map: tuple[str, ...] = (),
|
|
201
|
+
session_ids: tuple[str, ...] = (),
|
|
202
|
+
reasoning: bool = False,
|
|
203
|
+
budget: int = DEFAULT_BUDGET,
|
|
204
|
+
sample_budget: int = DEFAULT_SAMPLE_BUDGET,
|
|
205
|
+
cache: VerdictCache | None = None,
|
|
206
|
+
attach_advisories: Any = None,
|
|
207
|
+
build_all: Any = None,
|
|
208
|
+
analyzer: str = NO_ANALYZER,
|
|
209
|
+
jev_key: str | None = None,
|
|
210
|
+
history: DriftHistory | None = None,
|
|
211
|
+
) -> Analysis:
|
|
212
|
+
"""Correlate and judge a session view already collected by a long-lived reader."""
|
|
213
|
+
window_end = datetime.now(UTC)
|
|
214
|
+
window_start = since if isinstance(since, datetime) else parse_since(since)
|
|
215
|
+
return _analyse_sessions(
|
|
216
|
+
_only(sessions, session_ids, agent_kinds),
|
|
217
|
+
window_start=window_start,
|
|
218
|
+
window_end=window_end,
|
|
219
|
+
bom_paths=bom_paths,
|
|
220
|
+
project_map=project_map,
|
|
221
|
+
reasoning=reasoning,
|
|
222
|
+
budget=budget,
|
|
223
|
+
sample_budget=sample_budget,
|
|
224
|
+
cache=cache,
|
|
141
225
|
attach_advisories=attach_advisories,
|
|
226
|
+
build_all=build_all,
|
|
227
|
+
analyzer=analyzer,
|
|
228
|
+
jev_key=jev_key,
|
|
229
|
+
history=history,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _analyse_sessions(
|
|
234
|
+
sessions: SessionView,
|
|
235
|
+
*,
|
|
236
|
+
window_start: datetime,
|
|
237
|
+
window_end: datetime,
|
|
238
|
+
bom_paths: tuple[Path, ...],
|
|
239
|
+
project_map: tuple[str, ...],
|
|
240
|
+
reasoning: bool,
|
|
241
|
+
budget: int,
|
|
242
|
+
sample_budget: int,
|
|
243
|
+
cache: VerdictCache | None,
|
|
244
|
+
attach_advisories: Any,
|
|
245
|
+
build_all: Any,
|
|
246
|
+
analyzer: str,
|
|
247
|
+
jev_key: str | None,
|
|
248
|
+
history: DriftHistory | None,
|
|
249
|
+
) -> Analysis:
|
|
250
|
+
acquired = _acquire(
|
|
251
|
+
sessions,
|
|
252
|
+
bom_paths=bom_paths,
|
|
253
|
+
project_map=project_map,
|
|
254
|
+
attach_advisories=attach_advisories,
|
|
255
|
+
build_all=build_all,
|
|
142
256
|
)
|
|
143
257
|
run = run_detector(
|
|
144
258
|
acquired.view,
|
|
@@ -146,6 +260,9 @@ def analyse(
|
|
|
146
260
|
budget=budget,
|
|
147
261
|
sample_budget=sample_budget,
|
|
148
262
|
cache=cache,
|
|
263
|
+
analyzer=analyzer,
|
|
264
|
+
jev_key=jev_key,
|
|
265
|
+
history=history,
|
|
149
266
|
)
|
|
150
267
|
return Analysis(
|
|
151
268
|
run=run,
|
|
@@ -170,7 +287,11 @@ def _collected(
|
|
|
170
287
|
session_ids: tuple[str, ...],
|
|
171
288
|
) -> SessionView:
|
|
172
289
|
"""Read the window. Its own function so a caller can read twice."""
|
|
173
|
-
return _only(
|
|
290
|
+
return _only(
|
|
291
|
+
collect_sessions(list(agent_kinds) or None, window_start, root=root),
|
|
292
|
+
session_ids,
|
|
293
|
+
agent_kinds,
|
|
294
|
+
)
|
|
174
295
|
|
|
175
296
|
|
|
176
297
|
def _acquire(
|
|
@@ -179,9 +300,14 @@ def _acquire(
|
|
|
179
300
|
bom_paths: tuple[Path, ...],
|
|
180
301
|
project_map: tuple[str, ...],
|
|
181
302
|
attach_advisories: Any,
|
|
303
|
+
build_all: Any = None,
|
|
182
304
|
) -> Acquired:
|
|
183
305
|
"""Correlate what was read."""
|
|
184
|
-
hook
|
|
306
|
+
hook: dict[str, Any] = {}
|
|
307
|
+
if attach_advisories is not None:
|
|
308
|
+
hook["attach_advisories"] = attach_advisories
|
|
309
|
+
if build_all is not None:
|
|
310
|
+
hook["build_all"] = build_all
|
|
185
311
|
return acquire_correlated_view(
|
|
186
312
|
sessions,
|
|
187
313
|
bom_paths=bom_paths,
|
|
@@ -199,6 +325,7 @@ def _correlated(
|
|
|
199
325
|
root: Path | None,
|
|
200
326
|
session_ids: tuple[str, ...],
|
|
201
327
|
attach_advisories: Any,
|
|
328
|
+
build_all: Any = None,
|
|
202
329
|
) -> Acquired:
|
|
203
330
|
"""Everything up to the judging, shared by both entry points."""
|
|
204
331
|
return _acquire(
|
|
@@ -211,6 +338,7 @@ def _correlated(
|
|
|
211
338
|
bom_paths=bom_paths,
|
|
212
339
|
project_map=project_map,
|
|
213
340
|
attach_advisories=attach_advisories,
|
|
341
|
+
build_all=build_all,
|
|
214
342
|
)
|
|
215
343
|
|
|
216
344
|
|
|
@@ -258,9 +386,18 @@ def analyse_progressively(
|
|
|
258
386
|
preview: timedelta | None = PREVIEW_WINDOW,
|
|
259
387
|
cache: VerdictCache | None = None,
|
|
260
388
|
attach_advisories: Any = None,
|
|
389
|
+
analyzer: str = NO_ANALYZER,
|
|
390
|
+
jev_key: str | None = None,
|
|
391
|
+
history: DriftHistory | None = None,
|
|
392
|
+
reasoning: bool = False,
|
|
393
|
+
budget: int = DEFAULT_BUDGET,
|
|
261
394
|
) -> Iterator[Analysis]:
|
|
262
395
|
"""The same pipeline, delivered in instalments.
|
|
263
396
|
|
|
397
|
+
`history` is accepted and unused: this path never requests reasoning, so
|
|
398
|
+
it never adds a point. It is here so the watcher can forward one options
|
|
399
|
+
dict to both entry points (the test in `tests/monitor/test_watch.py`).
|
|
400
|
+
|
|
264
401
|
`analyse` answers once, which is right for a command that prints and exits
|
|
265
402
|
and wrong for a page meant to look alive. Measured over 339 real sessions
|
|
266
403
|
the work splits about 6.6s to collect and correlate, then about 12ms to
|
|
@@ -289,11 +426,13 @@ def analyse_progressively(
|
|
|
289
426
|
`cache` is read and never written. A session graded by an earlier
|
|
290
427
|
`detect --reasoning` shows that grade here; this path never commissions one.
|
|
291
428
|
|
|
292
|
-
**
|
|
293
|
-
|
|
294
|
-
of what was authorised
|
|
295
|
-
|
|
296
|
-
|
|
429
|
+
**Reasoning runs once, after the batches, never inside them.** Stage 3's
|
|
430
|
+
budget is per *run*, so asking each batch would give each its own budget and
|
|
431
|
+
spend a multiple of what was authorised — which is why this path refused the
|
|
432
|
+
option entirely until now. Running it once over the whole view keeps the
|
|
433
|
+
budget meaning what the flag said, and keeps the ordering that matters: the
|
|
434
|
+
cheap stages paint first and stage 3 arrives as one more instalment, rather
|
|
435
|
+
than holding the first paint behind several hundred sequential requests.
|
|
297
436
|
"""
|
|
298
437
|
window_end = datetime.now(UTC)
|
|
299
438
|
window_start = since if isinstance(since, datetime) else parse_since(since)
|
|
@@ -348,20 +487,43 @@ def analyse_progressively(
|
|
|
348
487
|
if preview is None:
|
|
349
488
|
yield _stage(DetectorRun(collection_failures=failures), ())
|
|
350
489
|
|
|
490
|
+
# Newest *activity* first, not newest start. This decides which sessions are
|
|
491
|
+
# judged first and therefore what a progressive reader sees first, and
|
|
492
|
+
# `started_at` answers a different question: a session running for a day
|
|
493
|
+
# began long ago and is the most recent thing on the machine. Ordering by
|
|
494
|
+
# its start put the session doing the work behind sessions idle for hours --
|
|
495
|
+
# the same defect the monitor's own session list had, one layer up, so the
|
|
496
|
+
# batch order and the page's order now read the same clock.
|
|
351
497
|
ordered = sorted(
|
|
352
498
|
view.sessions,
|
|
353
|
-
key=lambda correlated:
|
|
499
|
+
key=lambda correlated: (
|
|
500
|
+
correlated.session.last_activity_at
|
|
501
|
+
or correlated.session.started_at
|
|
502
|
+
or datetime.min.replace(tzinfo=UTC)
|
|
503
|
+
),
|
|
354
504
|
reverse=True,
|
|
355
505
|
)
|
|
356
506
|
step = max(batch, 1)
|
|
357
|
-
accumulated = DetectorRun(collection_failures=failures)
|
|
507
|
+
accumulated = DetectorRun(collection_failures=failures, analyzer=analyzer)
|
|
358
508
|
for index in range(0, len(ordered), step):
|
|
359
509
|
judged = run_detector(
|
|
360
510
|
CorrelatedView(sessions=tuple(ordered[index : index + step])),
|
|
361
511
|
# Read, never written, and never requesting: a session an earlier
|
|
362
512
|
# `detect --reasoning` graded renders with that grade here, and this
|
|
363
|
-
# path commissions nothing (ADR-0026 clause 2).
|
|
513
|
+
# path commissions nothing (ADR-0026 clause 2). The analyzer name
|
|
514
|
+
# is what lets a stored Jev map be served: the cache key carries
|
|
515
|
+
# the analyzer's identity, so a page started with --analyzer jev
|
|
516
|
+
# reads the entries that flag paid for and no others.
|
|
517
|
+
#
|
|
518
|
+
# `jev_key` rides along although this path asks nothing. Today the
|
|
519
|
+
# cache key reads `identity()`, which does not depend on the key,
|
|
520
|
+
# so omitting it would work — but it would leave one call building
|
|
521
|
+
# two differently-configured analyzers under one name, and the next
|
|
522
|
+
# thing to depend on the key would break here and not in the
|
|
523
|
+
# instalment below.
|
|
364
524
|
cache=cache,
|
|
525
|
+
analyzer=analyzer,
|
|
526
|
+
jev_key=jev_key,
|
|
365
527
|
)
|
|
366
528
|
accumulated = DetectorRun(
|
|
367
529
|
detections=accumulated.detections + judged.detections,
|
|
@@ -370,6 +532,26 @@ def analyse_progressively(
|
|
|
370
532
|
requested=accumulated.requested + judged.requested,
|
|
371
533
|
analysed=accumulated.analysed + judged.analysed,
|
|
372
534
|
cache_hits=accumulated.cache_hits + judged.cache_hits,
|
|
535
|
+
maps=accumulated.maps + judged.maps,
|
|
373
536
|
collection_failures=failures,
|
|
537
|
+
analyzer=analyzer,
|
|
374
538
|
)
|
|
375
539
|
yield _stage(accumulated, tuple(ordered))
|
|
540
|
+
|
|
541
|
+
if not reasoning or not ordered:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
# One more instalment, one run, one budget. Everything above has already
|
|
545
|
+
# been published, so the page is complete and readable while this is in
|
|
546
|
+
# flight — on a large window it is several hundred sequential requests and
|
|
547
|
+
# holding the first paint behind it is what made the page look hung.
|
|
548
|
+
judged = run_detector(
|
|
549
|
+
view,
|
|
550
|
+
reasoning=True,
|
|
551
|
+
budget=budget,
|
|
552
|
+
cache=cache,
|
|
553
|
+
analyzer=analyzer,
|
|
554
|
+
jev_key=jev_key,
|
|
555
|
+
history=history,
|
|
556
|
+
)
|
|
557
|
+
yield _stage(judged, tuple(ordered))
|
stacktrace_cli/build.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Which commit this install was built from, when it was not a PyPI release.
|
|
2
|
+
|
|
3
|
+
Read from the install's own PEP 610 record (`direct_url.json` in the
|
|
4
|
+
dist-info), which pip and uv write for every install that did not come from an
|
|
5
|
+
index:
|
|
6
|
+
|
|
7
|
+
- ``pip install git+https://…@branch`` / ``uv tool install git+…`` record the
|
|
8
|
+
resolved commit in ``vcs_info.commit_id``.
|
|
9
|
+
- ``pip install -e .`` / ``uv sync`` record an editable ``file://`` URL; the
|
|
10
|
+
code runs from that checkout, so its current ``HEAD`` is the answer, plus
|
|
11
|
+
``.dirty`` when the worktree has uncommitted changes.
|
|
12
|
+
|
|
13
|
+
A wheel from PyPI has no record, and a non-editable install from a local
|
|
14
|
+
directory records only the path, whose ``HEAD`` may have moved since the build
|
|
15
|
+
— both answer `None` rather than a commit that may not be the one running.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import functools
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import threading
|
|
26
|
+
from importlib.metadata import distribution
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from urllib.parse import unquote, urlparse
|
|
29
|
+
|
|
30
|
+
DISTRIBUTION = "stacktrace-cli"
|
|
31
|
+
|
|
32
|
+
_SHA = re.compile(r"^[0-9a-f]{7,64}$")
|
|
33
|
+
_SHORT = 7
|
|
34
|
+
|
|
35
|
+
#: Serializes `_compute_version_label`'s cache misses. `functools.cache` alone
|
|
36
|
+
#: lets concurrent misses run the wrapped function more than once (verified:
|
|
37
|
+
#: two threads racing an uncached call both entered `build_commit`), so the
|
|
38
|
+
#: daemon's startup warm-up (`daemon/runtime.py`) and a request thread could
|
|
39
|
+
#: each launch their own `git` probe. Held only around the call: the thread
|
|
40
|
+
#: that loses the race blocks briefly here rather than repeating the winner's
|
|
41
|
+
#: work, and finds the answer already cached once it gets in.
|
|
42
|
+
_version_label_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def version_label() -> str:
|
|
46
|
+
"""The version as `--version` prints it: `0.4.0`, or `0.4.0+86eef86` for a
|
|
47
|
+
build from a branch or a pull request.
|
|
48
|
+
|
|
49
|
+
One expression for both places that report it, the CLI and telemetry, so a
|
|
50
|
+
PostHog event and `--version` cannot disagree about which build ran.
|
|
51
|
+
"""
|
|
52
|
+
with _version_label_lock:
|
|
53
|
+
return _compute_version_label()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@functools.cache
|
|
57
|
+
def _compute_version_label() -> str:
|
|
58
|
+
from . import __version__
|
|
59
|
+
|
|
60
|
+
commit = build_commit()
|
|
61
|
+
return f"{__version__}+{commit}" if commit else __version__
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_commit() -> str | None:
|
|
65
|
+
"""The short commit this install runs, or `None` for a release install."""
|
|
66
|
+
try:
|
|
67
|
+
return _read_build_commit()
|
|
68
|
+
except Exception: # noqa: BLE001 - direct_url.json is external, untrusted input
|
|
69
|
+
# Every step below reads or parses `direct_url.json`, an external
|
|
70
|
+
# document written by pip or uv. Three narrower guards in a row each
|
|
71
|
+
# missed a new failure mode in turn (`UnicodeDecodeError` from
|
|
72
|
+
# `read_text`'s UTF-8 decode, `urlparse`'s `ValueError` on a malformed
|
|
73
|
+
# URL, `json.loads`'s `RecursionError` on deep nesting) -- the fix is
|
|
74
|
+
# the boundary, not the exception list. Anything this parse can raise
|
|
75
|
+
# reads the same as no record.
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_build_commit() -> str | None:
|
|
80
|
+
raw = distribution(DISTRIBUTION).read_text("direct_url.json")
|
|
81
|
+
if not raw:
|
|
82
|
+
return None
|
|
83
|
+
record = json.loads(raw)
|
|
84
|
+
if not isinstance(record, dict):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
vcs = record.get("vcs_info")
|
|
88
|
+
if isinstance(vcs, dict):
|
|
89
|
+
commit = vcs.get("commit_id")
|
|
90
|
+
if vcs.get("vcs") == "git" and isinstance(commit, str) and _SHA.match(commit):
|
|
91
|
+
return commit[:_SHORT]
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
directory = record.get("dir_info")
|
|
95
|
+
url = record.get("url")
|
|
96
|
+
if isinstance(directory, dict) and directory.get("editable") is True and isinstance(url, str):
|
|
97
|
+
parsed = urlparse(url)
|
|
98
|
+
if parsed.scheme == "file":
|
|
99
|
+
return _checkout_commit(Path(unquote(parsed.path)))
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _checkout_commit(root: Path) -> str | None:
|
|
104
|
+
# GIT_DIR/GIT_WORK_TREE in the caller's environment override -C, so a
|
|
105
|
+
# caller running us from inside another repo's hook can make git report
|
|
106
|
+
# that repo's HEAD instead of `root`'s.
|
|
107
|
+
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE")}
|
|
108
|
+
|
|
109
|
+
def git(*args: str) -> str | None:
|
|
110
|
+
try:
|
|
111
|
+
done = subprocess.run(
|
|
112
|
+
["git", "-C", str(root), *args],
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
timeout=5,
|
|
116
|
+
check=False,
|
|
117
|
+
env=env,
|
|
118
|
+
)
|
|
119
|
+
except (OSError, subprocess.SubprocessError):
|
|
120
|
+
return None
|
|
121
|
+
return done.stdout if done.returncode == 0 else None
|
|
122
|
+
|
|
123
|
+
head = git("rev-parse", "HEAD")
|
|
124
|
+
if head is None or not _SHA.match(head.strip()):
|
|
125
|
+
return None
|
|
126
|
+
commit = head.strip()[:_SHORT]
|
|
127
|
+
status = git("status", "--porcelain")
|
|
128
|
+
if status is None:
|
|
129
|
+
return None
|
|
130
|
+
return f"{commit}.dirty" if status else commit
|