debugbrief 1.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.
- debugbrief/__init__.py +13 -0
- debugbrief/__main__.py +10 -0
- debugbrief/cli.py +843 -0
- debugbrief/command_runner.py +233 -0
- debugbrief/derive.py +353 -0
- debugbrief/doctor.py +324 -0
- debugbrief/filters.py +280 -0
- debugbrief/git_utils.py +268 -0
- debugbrief/models.py +320 -0
- debugbrief/paths.py +130 -0
- debugbrief/redaction.py +103 -0
- debugbrief/reporters/__init__.py +108 -0
- debugbrief/reporters/base.py +396 -0
- debugbrief/reporters/handoff.py +96 -0
- debugbrief/reporters/incident.py +99 -0
- debugbrief/reporters/pr.py +28 -0
- debugbrief/reports_index.py +50 -0
- debugbrief/session_manager.py +361 -0
- debugbrief/sessions_index.py +78 -0
- debugbrief/utils.py +151 -0
- debugbrief-1.1.0.dist-info/METADATA +143 -0
- debugbrief-1.1.0.dist-info/RECORD +26 -0
- debugbrief-1.1.0.dist-info/WHEEL +5 -0
- debugbrief-1.1.0.dist-info/entry_points.txt +2 -0
- debugbrief-1.1.0.dist-info/licenses/LICENSE +21 -0
- debugbrief-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Session lifecycle and persistence.
|
|
2
|
+
|
|
3
|
+
The canonical live record for an active session is its file under
|
|
4
|
+
``.debugbrief/sessions/<id>.json``; it is rewritten immediately after every
|
|
5
|
+
event so a crash never loses captured work. ``active_session.json`` is a small
|
|
6
|
+
pointer to the currently-active session and is removed on a clean ``end``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from . import git_utils
|
|
14
|
+
from .command_runner import RunResult
|
|
15
|
+
from .models import (
|
|
16
|
+
COMMAND_STATUS_ERROR,
|
|
17
|
+
COMMAND_STATUS_FAILED,
|
|
18
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
19
|
+
CommandData,
|
|
20
|
+
Event,
|
|
21
|
+
FileChange,
|
|
22
|
+
Session,
|
|
23
|
+
SessionStatus,
|
|
24
|
+
)
|
|
25
|
+
from .paths import ProjectPaths
|
|
26
|
+
from .redaction import redact_text
|
|
27
|
+
from .utils import atomic_write_json, now_iso8601, read_json
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionError(Exception):
|
|
31
|
+
"""Raised for expected, user-facing session errors."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionManager:
|
|
35
|
+
def __init__(self, paths: ProjectPaths) -> None:
|
|
36
|
+
self.paths = paths
|
|
37
|
+
|
|
38
|
+
# Active-pointer handling -------------------------------------------------
|
|
39
|
+
def _read_active_pointer(self) -> Optional[Dict[str, Any]]:
|
|
40
|
+
pointer_path = self.paths.active_session_file
|
|
41
|
+
if not pointer_path.exists():
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
data = read_json(pointer_path)
|
|
45
|
+
except (ValueError, OSError) as exc:
|
|
46
|
+
raise SessionError(
|
|
47
|
+
f"active_session.json exists but could not be read ({exc}). "
|
|
48
|
+
"Inspect or remove .debugbrief/active_session.json to recover."
|
|
49
|
+
) from exc
|
|
50
|
+
if not isinstance(data, dict) or "session_id" not in data:
|
|
51
|
+
raise SessionError(
|
|
52
|
+
"active_session.json is malformed. Remove "
|
|
53
|
+
".debugbrief/active_session.json to recover."
|
|
54
|
+
)
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
def _write_active_pointer(self, session: Session) -> None:
|
|
58
|
+
atomic_write_json(
|
|
59
|
+
self.paths.active_session_file,
|
|
60
|
+
{
|
|
61
|
+
"session_id": session.session_id,
|
|
62
|
+
"title": session.title,
|
|
63
|
+
"status": session.status,
|
|
64
|
+
"started_at": session.timestamps.start,
|
|
65
|
+
"session_file": str(
|
|
66
|
+
self.paths.session_file(session.session_id)
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _clear_active_pointer(self) -> None:
|
|
72
|
+
pointer_path = self.paths.active_session_file
|
|
73
|
+
try:
|
|
74
|
+
if pointer_path.exists():
|
|
75
|
+
pointer_path.unlink()
|
|
76
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
77
|
+
raise SessionError(
|
|
78
|
+
f"Could not clear active_session.json ({exc}). Remove it manually."
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
def has_active(self) -> bool:
|
|
82
|
+
return self.paths.active_session_file.exists()
|
|
83
|
+
|
|
84
|
+
# Session persistence -----------------------------------------------------
|
|
85
|
+
def save_session(self, session: Session) -> None:
|
|
86
|
+
self._recompute_counts(session)
|
|
87
|
+
atomic_write_json(
|
|
88
|
+
self.paths.session_file(session.session_id), session.to_dict()
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def load_session_file(self, session_id: str) -> Session:
|
|
92
|
+
path = self.paths.session_file(session_id)
|
|
93
|
+
if not path.exists():
|
|
94
|
+
raise SessionError(f"Session file not found for id {session_id}.")
|
|
95
|
+
try:
|
|
96
|
+
return Session.from_dict(read_json(path))
|
|
97
|
+
except (ValueError, OSError) as exc:
|
|
98
|
+
raise SessionError(f"Could not read session {session_id}: {exc}") from exc
|
|
99
|
+
|
|
100
|
+
def load_active(self) -> Optional[Session]:
|
|
101
|
+
"""Return the active Session, or None if no session is active.
|
|
102
|
+
|
|
103
|
+
Raises SessionError if the pointer exists but the underlying session
|
|
104
|
+
file is missing/unreadable (an interrupted/inconsistent state).
|
|
105
|
+
"""
|
|
106
|
+
pointer = self._read_active_pointer()
|
|
107
|
+
if pointer is None:
|
|
108
|
+
return None
|
|
109
|
+
session_id = pointer["session_id"]
|
|
110
|
+
path = self.paths.session_file(session_id)
|
|
111
|
+
if not path.exists():
|
|
112
|
+
raise SessionError(
|
|
113
|
+
"active_session.json points to a missing session file "
|
|
114
|
+
f"({session_id}). The session looks interrupted. Remove "
|
|
115
|
+
".debugbrief/active_session.json to recover."
|
|
116
|
+
)
|
|
117
|
+
return self.load_session_file(session_id)
|
|
118
|
+
|
|
119
|
+
def require_active(self, action: str) -> Session:
|
|
120
|
+
session = self.load_active()
|
|
121
|
+
if session is None:
|
|
122
|
+
raise SessionError(
|
|
123
|
+
f"No active DebugBrief session. Cannot {action}. "
|
|
124
|
+
'Start one with: debugbrief start "<title>"'
|
|
125
|
+
)
|
|
126
|
+
return session
|
|
127
|
+
|
|
128
|
+
# Lifecycle ---------------------------------------------------------------
|
|
129
|
+
def start(self, title: str) -> Session:
|
|
130
|
+
if self.has_active():
|
|
131
|
+
existing = self._read_active_pointer() or {}
|
|
132
|
+
raise SessionError(
|
|
133
|
+
"A DebugBrief session is already active"
|
|
134
|
+
+ (f" ({existing.get('title')!r})." if existing.get("title") else ".")
|
|
135
|
+
+ " End it with: debugbrief end --mode pr|handoff|incident, "
|
|
136
|
+
"or check it with: debugbrief status"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
clean_title = title.strip()
|
|
140
|
+
if not clean_title:
|
|
141
|
+
raise SessionError("Session title must not be empty.")
|
|
142
|
+
|
|
143
|
+
self.paths.ensure_directories()
|
|
144
|
+
git_state = git_utils.capture_state(self.paths.project_root, initial=True)
|
|
145
|
+
|
|
146
|
+
session = Session(
|
|
147
|
+
title=clean_title,
|
|
148
|
+
project_root=str(self.paths.project_root),
|
|
149
|
+
git=git_state,
|
|
150
|
+
)
|
|
151
|
+
session.timestamps.start = now_iso8601()
|
|
152
|
+
|
|
153
|
+
# Record an initial snapshot event for an honest timeline.
|
|
154
|
+
session.events.append(
|
|
155
|
+
Event.snapshot(
|
|
156
|
+
{
|
|
157
|
+
"phase": "start",
|
|
158
|
+
"git": git_state.to_dict(),
|
|
159
|
+
},
|
|
160
|
+
session.timestamps.start,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self.save_session(session)
|
|
165
|
+
self._write_active_pointer(session)
|
|
166
|
+
return session
|
|
167
|
+
|
|
168
|
+
def auto_start(self, seed_text: str) -> Session:
|
|
169
|
+
"""Start a session with a title derived from the time and ``seed_text``.
|
|
170
|
+
|
|
171
|
+
Used when ``run`` or ``note`` is invoked with no active session, so a
|
|
172
|
+
capture is never silently dropped.
|
|
173
|
+
"""
|
|
174
|
+
from .utils import utc_now
|
|
175
|
+
|
|
176
|
+
first_line = ""
|
|
177
|
+
for line in (seed_text or "").strip().splitlines():
|
|
178
|
+
if line.strip():
|
|
179
|
+
first_line = line.strip()
|
|
180
|
+
break
|
|
181
|
+
snippet = first_line[:60] if first_line else "debug session"
|
|
182
|
+
stamp = utc_now().strftime("%Y-%m-%d %H:%M")
|
|
183
|
+
return self.start(f"Auto session {stamp}: {snippet}")
|
|
184
|
+
|
|
185
|
+
def add_note(self, text: str) -> Session:
|
|
186
|
+
session = self.require_active("add a note")
|
|
187
|
+
clean = text.strip()
|
|
188
|
+
if not clean:
|
|
189
|
+
raise SessionError("Note text must not be empty.")
|
|
190
|
+
# Notes are persisted to the session JSON and surfaced in reports, so a
|
|
191
|
+
# secret pasted into a note (an env var, a log line) must be scrubbed
|
|
192
|
+
# before it ever reaches disk, the same as captured command output.
|
|
193
|
+
clean, n_redacted = redact_text(clean)
|
|
194
|
+
note_event = Event.note(clean, now_iso8601())
|
|
195
|
+
if n_redacted:
|
|
196
|
+
note_event.data["redacted"] = True
|
|
197
|
+
session.events.append(note_event)
|
|
198
|
+
self.save_session(session)
|
|
199
|
+
self._write_active_pointer(session)
|
|
200
|
+
return session
|
|
201
|
+
|
|
202
|
+
def record_command(self, result: RunResult) -> Session:
|
|
203
|
+
session = self.require_active("run a command")
|
|
204
|
+
# Best-effort, lightweight git snapshot at the moment of the command so
|
|
205
|
+
# later reports can correlate file changes with what happened. Safe and
|
|
206
|
+
# silent outside a repo.
|
|
207
|
+
if session.git.is_repo:
|
|
208
|
+
cwd = self.paths.project_root
|
|
209
|
+
result.command_data.git_head = git_utils.current_short_sha(cwd)
|
|
210
|
+
result.command_data.git_changed_files = git_utils.changed_files(cwd)
|
|
211
|
+
session.events.append(
|
|
212
|
+
Event.command(result.command_data, result.command_data.started_at)
|
|
213
|
+
)
|
|
214
|
+
if result.error_message and (result.errored or result.timed_out):
|
|
215
|
+
session.add_warning(result.error_message, now_iso8601())
|
|
216
|
+
self.save_session(session)
|
|
217
|
+
self._write_active_pointer(session)
|
|
218
|
+
return session
|
|
219
|
+
|
|
220
|
+
def end(self, mode: str, report_format: str = "md") -> Session:
|
|
221
|
+
# Local imports avoid an import cycle with the reporters package.
|
|
222
|
+
from .reporters import render_report, render_report_json
|
|
223
|
+
|
|
224
|
+
session = self.require_active("end the session")
|
|
225
|
+
|
|
226
|
+
# Capture final Git state, preserving the initial SHA.
|
|
227
|
+
final_state = git_utils.capture_state(self.paths.project_root, initial=False)
|
|
228
|
+
session.git.final_sha = final_state.final_sha
|
|
229
|
+
session.git.branch = final_state.branch
|
|
230
|
+
session.git.detached_head = final_state.detached_head
|
|
231
|
+
session.git.is_repo = final_state.is_repo
|
|
232
|
+
if final_state.repo_root:
|
|
233
|
+
session.git.repo_root = final_state.repo_root
|
|
234
|
+
|
|
235
|
+
session.timestamps.end = now_iso8601()
|
|
236
|
+
session.status = SessionStatus.COMPLETED.value
|
|
237
|
+
|
|
238
|
+
session.events.append(
|
|
239
|
+
Event.snapshot(
|
|
240
|
+
{"phase": "end", "git": session.git.to_dict()},
|
|
241
|
+
session.timestamps.end,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self._finalize_summary(session)
|
|
246
|
+
self.save_session(session)
|
|
247
|
+
|
|
248
|
+
from .utils import write_text
|
|
249
|
+
|
|
250
|
+
if report_format in ("md", "both"):
|
|
251
|
+
report_text = render_report(session, mode)
|
|
252
|
+
write_text(self.paths.report_file(session.session_id, mode), report_text)
|
|
253
|
+
if report_format in ("json", "both"):
|
|
254
|
+
import json
|
|
255
|
+
|
|
256
|
+
payload = render_report_json(session, mode)
|
|
257
|
+
write_text(
|
|
258
|
+
self.paths.report_json_file(session.session_id, mode),
|
|
259
|
+
json.dumps(payload, indent=2) + "\n",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Only clear the active pointer after the report is safely written.
|
|
263
|
+
self._clear_active_pointer()
|
|
264
|
+
return session
|
|
265
|
+
|
|
266
|
+
def cancel(self) -> Session:
|
|
267
|
+
"""Discard the active session without writing a report.
|
|
268
|
+
|
|
269
|
+
The session file is kept on disk with status ABANDONED, so nothing is
|
|
270
|
+
silently deleted; it simply never becomes a brief.
|
|
271
|
+
"""
|
|
272
|
+
session = self.require_active("cancel the session")
|
|
273
|
+
session.status = SessionStatus.ABANDONED.value
|
|
274
|
+
session.timestamps.end = now_iso8601()
|
|
275
|
+
self.save_session(session)
|
|
276
|
+
self._clear_active_pointer()
|
|
277
|
+
return session
|
|
278
|
+
|
|
279
|
+
# Status ------------------------------------------------------------------
|
|
280
|
+
def build_status(self) -> Dict[str, Any]:
|
|
281
|
+
"""Return a structured status payload for the CLI to render."""
|
|
282
|
+
pointer = self._read_active_pointer()
|
|
283
|
+
if pointer is None:
|
|
284
|
+
return {"active": False}
|
|
285
|
+
|
|
286
|
+
session_id = pointer.get("session_id", "")
|
|
287
|
+
path = self.paths.session_file(session_id)
|
|
288
|
+
if not path.exists():
|
|
289
|
+
return {
|
|
290
|
+
"active": True,
|
|
291
|
+
"interrupted": True,
|
|
292
|
+
"session_id": session_id,
|
|
293
|
+
"title": pointer.get("title"),
|
|
294
|
+
"reason": "Session file is missing.",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
session = self.load_session_file(session_id)
|
|
298
|
+
self._recompute_counts(session)
|
|
299
|
+
interrupted = session.status != SessionStatus.ACTIVE.value
|
|
300
|
+
return {
|
|
301
|
+
"active": True,
|
|
302
|
+
"interrupted": interrupted,
|
|
303
|
+
"session_id": session.session_id,
|
|
304
|
+
"title": session.title,
|
|
305
|
+
"status": session.status,
|
|
306
|
+
"project_root": session.project_root,
|
|
307
|
+
"start": session.timestamps.start,
|
|
308
|
+
"notes_count": session.summary.notes_count,
|
|
309
|
+
"commands_count": session.summary.commands_count,
|
|
310
|
+
"failed_commands_count": session.summary.failed_commands_count,
|
|
311
|
+
"branch": session.git.branch,
|
|
312
|
+
"detached_head": session.git.detached_head,
|
|
313
|
+
"is_repo": session.git.is_repo,
|
|
314
|
+
"warnings": list(session.warnings),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# Internal helpers --------------------------------------------------------
|
|
318
|
+
def _recompute_counts(self, session: Session) -> None:
|
|
319
|
+
commands = session.command_events()
|
|
320
|
+
notes = session.note_events()
|
|
321
|
+
failed = 0
|
|
322
|
+
for event in commands:
|
|
323
|
+
status = (event.data.get("classification") or {}).get("status")
|
|
324
|
+
if status in (
|
|
325
|
+
COMMAND_STATUS_FAILED,
|
|
326
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
327
|
+
COMMAND_STATUS_ERROR,
|
|
328
|
+
):
|
|
329
|
+
failed += 1
|
|
330
|
+
session.summary.notes_count = len(notes)
|
|
331
|
+
session.summary.commands_count = len(commands)
|
|
332
|
+
session.summary.failed_commands_count = failed
|
|
333
|
+
|
|
334
|
+
def _finalize_summary(self, session: Session) -> None:
|
|
335
|
+
self._recompute_counts(session)
|
|
336
|
+
|
|
337
|
+
tests_run: List[str] = []
|
|
338
|
+
for event in session.command_events():
|
|
339
|
+
data = CommandData.from_dict(event.data)
|
|
340
|
+
if data.classification.is_test:
|
|
341
|
+
tests_run.append(data.command)
|
|
342
|
+
session.summary.tests_run = tests_run
|
|
343
|
+
|
|
344
|
+
if session.git.is_repo:
|
|
345
|
+
pairs = git_utils.name_status(self.paths.project_root)
|
|
346
|
+
session.summary.file_changes = [
|
|
347
|
+
FileChange(status=label, path=path) for label, path in pairs
|
|
348
|
+
]
|
|
349
|
+
session.summary.modified_files = [path for _label, path in pairs]
|
|
350
|
+
added, deleted = git_utils.shortstat(self.paths.project_root)
|
|
351
|
+
session.summary.lines_added = added
|
|
352
|
+
session.summary.lines_deleted = deleted
|
|
353
|
+
else:
|
|
354
|
+
session.summary.file_changes = []
|
|
355
|
+
session.summary.modified_files = []
|
|
356
|
+
session.summary.lines_added = 0
|
|
357
|
+
session.summary.lines_deleted = 0
|
|
358
|
+
|
|
359
|
+
# The explicit-run capture model captures exactly what was run through
|
|
360
|
+
# DebugBrief; there is no silent gap to report.
|
|
361
|
+
session.summary.command_capture_status = "full"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read-only helpers for enumerating and resolving stored sessions.
|
|
2
|
+
|
|
3
|
+
Used by the ``list`` and ``show`` commands. None of these require an active
|
|
4
|
+
session, and they never mutate state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .models import Session
|
|
12
|
+
from .paths import ProjectPaths
|
|
13
|
+
from .reporters import VALID_MODES, build_context
|
|
14
|
+
from .utils import parse_iso8601, read_json
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _start_seconds(session: Session) -> float:
|
|
18
|
+
start = session.timestamps.start
|
|
19
|
+
if not start:
|
|
20
|
+
return 0.0
|
|
21
|
+
try:
|
|
22
|
+
return parse_iso8601(start).timestamp()
|
|
23
|
+
except (ValueError, TypeError):
|
|
24
|
+
return 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_all_sessions(paths: ProjectPaths) -> List[Session]:
|
|
28
|
+
"""Load every stored session, most recent first (by start time).
|
|
29
|
+
|
|
30
|
+
Unreadable session files are skipped silently so a single corrupt file
|
|
31
|
+
cannot break listing.
|
|
32
|
+
"""
|
|
33
|
+
sessions_dir = paths.sessions_dir
|
|
34
|
+
if not sessions_dir.is_dir():
|
|
35
|
+
return []
|
|
36
|
+
sessions: List[Session] = []
|
|
37
|
+
for path in sessions_dir.glob("*.json"):
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
sessions.append(Session.from_dict(read_json(path)))
|
|
42
|
+
except (ValueError, OSError, TypeError):
|
|
43
|
+
continue
|
|
44
|
+
sessions.sort(key=lambda s: (_start_seconds(s), s.session_id), reverse=True)
|
|
45
|
+
return sessions
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def report_modes_for(paths: ProjectPaths, session_id: str) -> List[str]:
|
|
49
|
+
"""Return the report modes that have been generated for ``session_id``."""
|
|
50
|
+
modes = []
|
|
51
|
+
for mode in VALID_MODES:
|
|
52
|
+
if paths.report_file(session_id, mode).exists():
|
|
53
|
+
modes.append(mode)
|
|
54
|
+
return modes
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_verified(session: Session) -> bool:
|
|
58
|
+
"""True if at least one verification command passed during the session."""
|
|
59
|
+
return len(build_context(session).verification_commands) > 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_session_id(
|
|
63
|
+
paths: ProjectPaths, prefix: str
|
|
64
|
+
) -> Tuple[Optional[str], List[str]]:
|
|
65
|
+
"""Resolve a (possibly short) session id prefix to a full id.
|
|
66
|
+
|
|
67
|
+
Returns (resolved_id, matches). ``resolved_id`` is set only when exactly one
|
|
68
|
+
session id matches; otherwise it is None and ``matches`` lists all candidate
|
|
69
|
+
ids (empty when there is no match, multiple when the prefix is ambiguous).
|
|
70
|
+
"""
|
|
71
|
+
clean = prefix.strip()
|
|
72
|
+
ids = [s.session_id for s in load_all_sessions(paths)]
|
|
73
|
+
if clean in ids:
|
|
74
|
+
return clean, [clean]
|
|
75
|
+
matches = [sid for sid in ids if sid.startswith(clean)]
|
|
76
|
+
if len(matches) == 1:
|
|
77
|
+
return matches[0], matches
|
|
78
|
+
return None, matches
|
debugbrief/utils.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Small shared helpers: timestamps, output truncation, and atomic JSON I/O.
|
|
2
|
+
|
|
3
|
+
These helpers are deliberately dependency-free and side-effect minimal so the
|
|
4
|
+
rest of the package can rely on consistent, testable behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Tuple
|
|
16
|
+
|
|
17
|
+
# Default preview limits for captured command output. We store previews, not
|
|
18
|
+
# unbounded logs, so a single noisy command can never balloon a session file.
|
|
19
|
+
DEFAULT_STDOUT_PREVIEW_LIMIT = 4000
|
|
20
|
+
DEFAULT_STDERR_PREVIEW_LIMIT = 4000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utc_now() -> datetime:
|
|
24
|
+
"""Return the current time as a timezone-aware UTC datetime."""
|
|
25
|
+
return datetime.now(timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_iso8601(moment: datetime) -> str:
|
|
29
|
+
"""Serialize a datetime to an ISO8601 UTC string ending in 'Z'.
|
|
30
|
+
|
|
31
|
+
Naive datetimes are assumed to already be UTC.
|
|
32
|
+
"""
|
|
33
|
+
if moment.tzinfo is None:
|
|
34
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
35
|
+
moment = moment.astimezone(timezone.utc)
|
|
36
|
+
# Use millisecond precision; drop the '+00:00' offset in favor of 'Z'.
|
|
37
|
+
return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def now_iso8601() -> str:
|
|
41
|
+
"""Convenience: current UTC time as an ISO8601 string."""
|
|
42
|
+
return to_iso8601(utc_now())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_iso8601(value: str) -> datetime:
|
|
46
|
+
"""Parse an ISO8601 string (possibly ending in 'Z') into a UTC datetime."""
|
|
47
|
+
normalized = value.strip()
|
|
48
|
+
if normalized.endswith("Z"):
|
|
49
|
+
normalized = normalized[:-1] + "+00:00"
|
|
50
|
+
parsed = datetime.fromisoformat(normalized)
|
|
51
|
+
if parsed.tzinfo is None:
|
|
52
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
53
|
+
return parsed.astimezone(timezone.utc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def human_duration(seconds: float) -> str:
|
|
57
|
+
"""Render a duration in seconds as a compact ``1h 2m 3s`` style string."""
|
|
58
|
+
total = int(round(seconds))
|
|
59
|
+
if total < 0:
|
|
60
|
+
total = 0
|
|
61
|
+
hours, remainder = divmod(total, 3600)
|
|
62
|
+
minutes, secs = divmod(remainder, 60)
|
|
63
|
+
if hours:
|
|
64
|
+
return f"{hours}h {minutes}m {secs}s"
|
|
65
|
+
if minutes:
|
|
66
|
+
return f"{minutes}m {secs}s"
|
|
67
|
+
return f"{secs}s"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def truncate_text(text: str, limit: int) -> Tuple[str, bool]:
|
|
71
|
+
"""Truncate ``text`` to ``limit`` characters, keeping the head and tail.
|
|
72
|
+
|
|
73
|
+
Returns a tuple of (possibly truncated text, was_truncated). A ``limit`` of
|
|
74
|
+
zero or negative is treated as "no limit".
|
|
75
|
+
|
|
76
|
+
When the text is longer than ``limit`` we keep a small head and a larger
|
|
77
|
+
tail with an elision marker in between. The decisive output of a debugging
|
|
78
|
+
run (tracebacks, assertions, the final build error) lands at the end, so the
|
|
79
|
+
tail gets the larger share: the head is the first ``limit // 3`` characters
|
|
80
|
+
and the tail is the remaining budget. The kept original content totals
|
|
81
|
+
``limit`` characters; the marker is added on top.
|
|
82
|
+
"""
|
|
83
|
+
if text is None:
|
|
84
|
+
return "", False
|
|
85
|
+
if limit is None or limit <= 0:
|
|
86
|
+
return text, False
|
|
87
|
+
if len(text) <= limit:
|
|
88
|
+
return text, False
|
|
89
|
+
head_len = limit // 3
|
|
90
|
+
tail_len = limit - head_len
|
|
91
|
+
omitted = len(text) - limit
|
|
92
|
+
marker = f"\n... [{omitted} characters omitted] ...\n"
|
|
93
|
+
head = text[:head_len]
|
|
94
|
+
tail = text[len(text) - tail_len:]
|
|
95
|
+
return head + marker + tail, True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def atomic_write_json(path: Path, data: Any) -> None:
|
|
99
|
+
"""Write ``data`` as JSON to ``path`` atomically.
|
|
100
|
+
|
|
101
|
+
The data is written to a temporary file in the same directory and then
|
|
102
|
+
renamed into place, so a crash mid-write cannot corrupt an existing file.
|
|
103
|
+
"""
|
|
104
|
+
path = Path(path)
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
107
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
111
|
+
json.dump(data, handle, indent=2, ensure_ascii=False, sort_keys=False)
|
|
112
|
+
handle.write("\n")
|
|
113
|
+
handle.flush()
|
|
114
|
+
os.fsync(handle.fileno())
|
|
115
|
+
os.replace(tmp_name, str(path))
|
|
116
|
+
except BaseException:
|
|
117
|
+
# Best-effort cleanup of the temp file on any failure.
|
|
118
|
+
try:
|
|
119
|
+
if os.path.exists(tmp_name):
|
|
120
|
+
os.unlink(tmp_name)
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_json(path: Path) -> Any:
|
|
127
|
+
"""Read and parse JSON from ``path``."""
|
|
128
|
+
with open(path, encoding="utf-8") as handle:
|
|
129
|
+
return json.load(handle)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_text(path: Path, text: str) -> None:
|
|
133
|
+
"""Write ``text`` to ``path``, creating parent directories as needed."""
|
|
134
|
+
path = Path(path)
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
137
|
+
handle.write(text)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_supported_platform() -> bool:
|
|
141
|
+
"""Return True on Unix-like platforms (Linux, macOS, BSD).
|
|
142
|
+
|
|
143
|
+
V1 explicitly does not support Windows / PowerShell.
|
|
144
|
+
"""
|
|
145
|
+
return os.name == "posix"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def eprint(*args: Any, **kwargs: Any) -> None:
|
|
149
|
+
"""Print to stderr."""
|
|
150
|
+
kwargs.setdefault("file", sys.stderr)
|
|
151
|
+
print(*args, **kwargs)
|