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.
@@ -0,0 +1,396 @@
1
+ """Shared building blocks for markdown report generation.
2
+
3
+ A :class:`ReportContext` is computed once from a finalized session and contains
4
+ only deterministic, evidence-backed data. Reporters consume it to render their
5
+ mode-specific markdown. No reporter invents root causes, intent, or results.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Optional, Tuple
12
+
13
+ from ..derive import Derivation, derive
14
+ from ..filters import ReportCommand, build_report_commands
15
+ from ..models import (
16
+ COMMAND_STATUS_ERROR,
17
+ COMMAND_STATUS_PASSED,
18
+ COMMAND_STATUS_TIMED_OUT,
19
+ CommandData,
20
+ EventType,
21
+ Session,
22
+ )
23
+ from ..utils import human_duration, parse_iso8601
24
+
25
+
26
+ @dataclass
27
+ class TimelineEntry:
28
+ timestamp: str
29
+ kind: str
30
+ text: str
31
+
32
+
33
+ @dataclass
34
+ class ReportContext:
35
+ session: Session
36
+ report_commands: List[ReportCommand] = field(default_factory=list)
37
+ failed_commands: List[ReportCommand] = field(default_factory=list)
38
+ verification_commands: List[ReportCommand] = field(default_factory=list)
39
+ test_commands: List[ReportCommand] = field(default_factory=list)
40
+ notes: List[Tuple[str, str]] = field(default_factory=list)
41
+ timeline: List[TimelineEntry] = field(default_factory=list)
42
+ derivation: Derivation = field(default_factory=Derivation)
43
+
44
+
45
+ def _short_time(iso_timestamp: Optional[str]) -> str:
46
+ if not iso_timestamp:
47
+ return "unknown time"
48
+ try:
49
+ moment = parse_iso8601(iso_timestamp)
50
+ except (ValueError, TypeError):
51
+ return iso_timestamp
52
+ return moment.strftime("%Y-%m-%d %H:%M:%S UTC")
53
+
54
+
55
+ def _clock(iso_timestamp: Optional[str]) -> str:
56
+ if not iso_timestamp:
57
+ return "--:--:--"
58
+ try:
59
+ moment = parse_iso8601(iso_timestamp)
60
+ except (ValueError, TypeError):
61
+ return iso_timestamp
62
+ return moment.strftime("%H:%M:%S")
63
+
64
+
65
+ def status_label(status: str) -> str:
66
+ return {
67
+ COMMAND_STATUS_PASSED: "passed",
68
+ "failed": "failed",
69
+ COMMAND_STATUS_TIMED_OUT: "timed out",
70
+ COMMAND_STATUS_ERROR: "did not run",
71
+ }.get(status, status)
72
+
73
+
74
+ def build_context(session: Session) -> ReportContext:
75
+ command_events = session.command_events()
76
+ report_commands = build_report_commands(command_events)
77
+
78
+ failed = [rc for rc in report_commands if rc.failed]
79
+ verification = [rc for rc in report_commands if rc.is_verification]
80
+ tests = [rc for rc in report_commands if rc.is_test]
81
+
82
+ notes: List[Tuple[str, str]] = []
83
+ for event in session.note_events():
84
+ text = (event.data or {}).get("text", "").strip()
85
+ if text:
86
+ notes.append((event.timestamp, text))
87
+
88
+ timeline = _build_timeline(session)
89
+
90
+ return ReportContext(
91
+ session=session,
92
+ report_commands=report_commands,
93
+ failed_commands=failed,
94
+ verification_commands=verification,
95
+ test_commands=tests,
96
+ notes=notes,
97
+ timeline=timeline,
98
+ derivation=derive(session),
99
+ )
100
+
101
+
102
+ def _build_timeline(session: Session) -> List[TimelineEntry]:
103
+ entries: List[TimelineEntry] = []
104
+ for event in session.events:
105
+ if event.type == EventType.NOTE.value:
106
+ text = (event.data or {}).get("text", "").strip()
107
+ if text:
108
+ entries.append(TimelineEntry(event.timestamp, "note", text))
109
+ elif event.type == EventType.COMMAND.value:
110
+ data = CommandData.from_dict(event.data)
111
+ label = status_label(data.classification.status)
112
+ exit_repr = "n/a" if data.exit_code is None else str(data.exit_code)
113
+ duration = f" [{_fmt_duration(data.duration_seconds)}]"
114
+ entries.append(
115
+ TimelineEntry(
116
+ event.timestamp,
117
+ "command",
118
+ f"`{data.command}` -> {label} (exit {exit_repr}){duration}",
119
+ )
120
+ )
121
+ elif event.type == EventType.WARNING.value:
122
+ message = (event.data or {}).get("message", "").strip()
123
+ if message:
124
+ entries.append(TimelineEntry(event.timestamp, "warning", message))
125
+ elif event.type == EventType.SNAPSHOT.value:
126
+ phase = (event.data or {}).get("phase")
127
+ if phase == "start":
128
+ entries.append(
129
+ TimelineEntry(event.timestamp, "snapshot", "Session started.")
130
+ )
131
+ elif phase == "end":
132
+ entries.append(
133
+ TimelineEntry(event.timestamp, "snapshot", "Session ended.")
134
+ )
135
+ entries.sort(key=lambda e: _safe_seconds(e.timestamp))
136
+ return entries
137
+
138
+
139
+ def _safe_seconds(iso_timestamp: str) -> float:
140
+ try:
141
+ return parse_iso8601(iso_timestamp).timestamp()
142
+ except (ValueError, TypeError):
143
+ return 0.0
144
+
145
+
146
+ def _fmt_duration(seconds: float) -> str:
147
+ """Compact per-command duration, e.g. ``0.25s`` or ``3s``."""
148
+ if seconds < 10:
149
+ return f"{seconds:g}s"
150
+ return human_duration(seconds)
151
+
152
+
153
+ class BaseReporter:
154
+ """Base class providing shared, reusable markdown section builders."""
155
+
156
+ mode = "base"
157
+
158
+ def __init__(self, context: ReportContext) -> None:
159
+ self.ctx = context
160
+ self.session = context.session
161
+
162
+ # Subclasses must implement render().
163
+ def render(self) -> str: # pragma: no cover - abstract
164
+ raise NotImplementedError
165
+
166
+ # Reusable sections -------------------------------------------------------
167
+ def title_line(self) -> str:
168
+ return f"# {self.session.title}"
169
+
170
+ def metadata_lines(self) -> List[str]:
171
+ s = self.session
172
+ lines = ["## Session metadata", ""]
173
+ lines.append(f"- **Session ID:** `{s.session_id}`")
174
+ lines.append(f"- **Status:** {s.status}")
175
+ lines.append(f"- **Project root:** `{s.project_root}`")
176
+ lines.append(f"- **Started:** {_short_time(s.timestamps.start)}")
177
+ lines.append(f"- **Ended:** {_short_time(s.timestamps.end)}")
178
+ if s.git.is_repo:
179
+ branch = s.git.branch or (
180
+ "(detached HEAD)" if s.git.detached_head else "(unknown)"
181
+ )
182
+ lines.append(f"- **Git branch:** {branch}")
183
+ lines.append(
184
+ f"- **Initial commit:** `{_sha(s.git.initial_sha)}` "
185
+ f"**Final commit:** `{_sha(s.git.final_sha)}`"
186
+ )
187
+ else:
188
+ lines.append("- **Git:** not a Git repository")
189
+ lines.append(
190
+ f"- **Notes:** {s.summary.notes_count} "
191
+ f"**Commands:** {s.summary.commands_count} "
192
+ f"**Failed commands:** {s.summary.failed_commands_count}"
193
+ )
194
+ return lines
195
+
196
+ def warnings_section(self) -> List[str]:
197
+ warnings = self.session.warnings
198
+ capture = self.session.summary.command_capture_status
199
+ redacted = self.ctx.derivation.redaction_applied
200
+ if not warnings and capture == "full" and not redacted:
201
+ return []
202
+ lines = ["## Warnings and limitations", ""]
203
+ if capture != "full":
204
+ lines.append(
205
+ f"- Command capture status: **{capture}** "
206
+ "(some commands may not have been recorded)."
207
+ )
208
+ if redacted:
209
+ lines.append(
210
+ "- Secret-like values in captured output, commands, or notes "
211
+ "were replaced with `[redacted]`. Redaction is best effort and "
212
+ "conservative; it does not catch everything."
213
+ )
214
+ for warning in warnings:
215
+ lines.append(f"- {warning}")
216
+ return lines
217
+
218
+ _STATUS_WORDS = {
219
+ "M": "modified",
220
+ "A": "added",
221
+ "D": "deleted",
222
+ "R": "renamed",
223
+ "C": "copied",
224
+ }
225
+
226
+ def changed_files_section(self) -> List[str]:
227
+ s = self.session
228
+ # Only render inside a repo and only when files actually changed. With
229
+ # no real content the section is omitted rather than padded.
230
+ if not s.git.is_repo:
231
+ return []
232
+ file_changes = s.summary.file_changes
233
+ files = s.summary.modified_files
234
+ if not file_changes and not files:
235
+ return []
236
+ lines = ["## Modified files", ""]
237
+ count = len(file_changes) if file_changes else len(files)
238
+ lines.append(
239
+ f"_{count} file(s) changed, "
240
+ f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines._"
241
+ )
242
+ lines.append("")
243
+ if file_changes:
244
+ for fc in file_changes:
245
+ word = self._STATUS_WORDS.get(fc.status, fc.status)
246
+ lines.append(f"- `{fc.status}` {word}: `{fc.path}`")
247
+ else:
248
+ for path in files:
249
+ lines.append(f"- `{path}`")
250
+ return lines
251
+
252
+ def verification_section(self) -> List[str]:
253
+ lines = ["## Verification and tests", ""]
254
+ verification = self.ctx.verification_commands
255
+ if not verification:
256
+ if self.ctx.test_commands:
257
+ lines.append(
258
+ "_Test/verification commands were run but none passed. "
259
+ "This work is **not** verified._"
260
+ )
261
+ else:
262
+ lines.append(
263
+ "_No verification commands (test/build/lint/typecheck) "
264
+ "were run during this session._"
265
+ )
266
+ return lines
267
+ for rc in verification:
268
+ kind = "test" if rc.is_test else "check"
269
+ tool = f" ({rc.tool})" if rc.tool else ""
270
+ repeat = f" x{rc.count}" if rc.count > 1 else ""
271
+ lines.append(f"- [passed] {kind}{tool}: `{rc.command}`{repeat}")
272
+ return lines
273
+
274
+ def relevant_commands_section(
275
+ self, heading: str = "## Relevant commands"
276
+ ) -> List[str]:
277
+ lines = [heading, ""]
278
+ commands = self.ctx.report_commands
279
+ if not commands:
280
+ lines.append("_No notable commands were recorded._")
281
+ return lines
282
+ for rc in commands:
283
+ repeat = f" x{rc.count}" if rc.count > 1 else ""
284
+ exit_repr = "n/a" if rc.exit_code is None else str(rc.exit_code)
285
+ lines.append(
286
+ f"- `{rc.command}`{repeat} -> {status_label(rc.status)} "
287
+ f"(exit {exit_repr})"
288
+ )
289
+ return lines
290
+
291
+ # Derived sections --------------------------------------------------------
292
+ def one_liner_section(self) -> List[str]:
293
+ one_liner = self.ctx.derivation.one_liner
294
+ if not one_liner:
295
+ return []
296
+ return ["## Summary", "", one_liner]
297
+
298
+ def reproduce_verify_section(self) -> List[str]:
299
+ d = self.ctx.derivation
300
+ if not d.reproduce_command and not d.verify_command:
301
+ return []
302
+ lines = ["## Reproduce and verify", ""]
303
+ if d.reproduce_command:
304
+ lines.append(f"- Reproduce (failed): `{d.reproduce_command}`")
305
+ if d.verify_command:
306
+ lines.append(f"- Verify (passed): `{d.verify_command}`")
307
+ return lines
308
+
309
+ def red_to_green_section(self) -> List[str]:
310
+ rtg = self.ctx.derivation.red_to_green
311
+ if rtg is None:
312
+ return []
313
+ lines = ["## Red to green", ""]
314
+ window = human_duration(rtg.window_seconds)
315
+ lines.append(
316
+ f"A check failed at `{_clock(rtg.failed_at)}` and `{rtg.command}` "
317
+ f"passed at `{_clock(rtg.passed_at)}` (window {window})."
318
+ )
319
+ if rtg.changed_files:
320
+ lines.append("")
321
+ lines.append(
322
+ "Between the failing and passing checks, these files changed "
323
+ "(correlation, not proven cause):"
324
+ )
325
+ for path in rtg.changed_files:
326
+ lines.append(f"- `{path}`")
327
+ else:
328
+ lines.append("")
329
+ lines.append(
330
+ "No tracked file changes were recorded across this window."
331
+ )
332
+ return lines
333
+
334
+ def timeline_section(
335
+ self, heading: str = "## Timeline", condensed: bool = False
336
+ ) -> List[str]:
337
+ entries = self.ctx.timeline
338
+ if condensed:
339
+ entries = [e for e in entries if e.kind in ("note", "command", "warning")]
340
+ if not entries:
341
+ return []
342
+ lines = [heading, ""]
343
+ for entry in entries:
344
+ lines.append(f"- `{_clock(entry.timestamp)}` ({entry.kind}) {entry.text}")
345
+ return lines
346
+
347
+ def observed_error_section(self) -> List[str]:
348
+ error = self.ctx.derivation.observed_error
349
+ if not error:
350
+ return []
351
+ return [
352
+ "## Observed error",
353
+ "",
354
+ "Quoted verbatim from real command output:",
355
+ "",
356
+ "```",
357
+ error,
358
+ "```",
359
+ ]
360
+
361
+ def ruled_out_section(self) -> List[str]:
362
+ ruled = self.ctx.derivation.ruled_out
363
+ if not ruled:
364
+ return []
365
+ lines = ["## What was ruled out", ""]
366
+ for rec in ruled:
367
+ exit_repr = "n/a" if rec.exit_code is None else str(rec.exit_code)
368
+ lines.append(
369
+ f"- `{rec.command}` -> {status_label(rec.status)} (exit {exit_repr})"
370
+ )
371
+ return lines
372
+
373
+ def footer(self) -> List[str]:
374
+ return [
375
+ "---",
376
+ "",
377
+ "_Generated by DebugBrief. This report is built from explicitly "
378
+ "recorded notes, executed commands, and Git state. DebugBrief does "
379
+ "not use AI and does not infer root causes, intent, or results._",
380
+ ]
381
+
382
+
383
+ def _sha(value: Optional[str]) -> str:
384
+ if not value:
385
+ return "n/a"
386
+ return value[:12]
387
+
388
+
389
+ def join_sections(*blocks: List[str]) -> str:
390
+ """Join section line-lists into a single markdown document."""
391
+ parts: List[str] = []
392
+ for block in blocks:
393
+ if not block:
394
+ continue
395
+ parts.append("\n".join(block))
396
+ return "\n\n".join(parts).rstrip() + "\n"
@@ -0,0 +1,96 @@
1
+ """Handoff-mode report: hand a partially solved or tricky issue to someone else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from ..derive import next_step_notes
8
+ from .base import BaseReporter, join_sections
9
+
10
+
11
+ class HandoffReporter(BaseReporter):
12
+ mode = "handoff"
13
+
14
+ def render(self) -> str:
15
+ return join_sections(
16
+ [self.title_line()],
17
+ self.one_liner_section(),
18
+ self.metadata_lines(),
19
+ self.warnings_section(),
20
+ self._current_status_section(),
21
+ self._hypotheses_section(),
22
+ self.timeline_section("## Timeline", condensed=False),
23
+ self.relevant_commands_section("## Commands attempted"),
24
+ self.ruled_out_section(),
25
+ self._repo_state_section(),
26
+ self._next_steps_section(),
27
+ self.footer(),
28
+ )
29
+
30
+ def _current_status_section(self) -> List[str]:
31
+ lines = ["## Current status", ""]
32
+ verified = len(self.ctx.verification_commands) > 0
33
+ failed = len(self.ctx.failed_commands)
34
+
35
+ if verified and failed == 0:
36
+ summary = (
37
+ "At least one verification command passed and no recorded "
38
+ "commands are currently failing."
39
+ )
40
+ elif failed:
41
+ summary = (
42
+ f"{failed} command(s) were failing when the session ended; "
43
+ "work appears incomplete."
44
+ )
45
+ else:
46
+ summary = (
47
+ "No verification command passed; the state of the fix is "
48
+ "unconfirmed."
49
+ )
50
+ lines.append(summary)
51
+ return lines
52
+
53
+ def _hypotheses_section(self) -> List[str]:
54
+ lines = ["## Working hypotheses / findings", ""]
55
+ if not self.ctx.notes:
56
+ lines.append(
57
+ "_No hypotheses or findings were recorded as notes during this "
58
+ "session._"
59
+ )
60
+ return lines
61
+ for _timestamp, text in self.ctx.notes:
62
+ lines.append(f"- {text}")
63
+ return lines
64
+
65
+ def _repo_state_section(self) -> List[str]:
66
+ s = self.session
67
+ if not s.git.is_repo:
68
+ return []
69
+ lines = ["## Current repo state", ""]
70
+ branch = s.git.branch or (
71
+ "(detached HEAD)" if s.git.detached_head else "(unknown)"
72
+ )
73
+ lines.append(f"- Branch: {branch}")
74
+ lines.append(f"- HEAD at session end: `{_sha(s.git.final_sha)}`")
75
+ lines.append(
76
+ f"- Uncommitted changes: {len(s.summary.modified_files)} file(s), "
77
+ f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines."
78
+ )
79
+ return lines
80
+
81
+ def _next_steps_section(self) -> List[str]:
82
+ # Next steps are drawn only from recorded notes, never inferred.
83
+ steps = next_step_notes(self.session)
84
+ if not steps:
85
+ return []
86
+ lines = ["## Suggested next steps", ""]
87
+ lines.append("_Based only on the notes you recorded:_")
88
+ for note in steps:
89
+ lines.append(f"- {note}")
90
+ return lines
91
+
92
+
93
+ def _sha(value):
94
+ if not value:
95
+ return "n/a"
96
+ return value[:12]
@@ -0,0 +1,99 @@
1
+ """Incident-mode report: a chronological, timeline-oriented engineering note."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from ..utils import parse_iso8601
8
+ from .base import BaseReporter, join_sections
9
+
10
+
11
+ class IncidentReporter(BaseReporter):
12
+ mode = "incident"
13
+
14
+ def render(self) -> str:
15
+ return join_sections(
16
+ [self.title_line()],
17
+ self.one_liner_section(),
18
+ self.metadata_lines(),
19
+ self.warnings_section(),
20
+ self._time_window_section(),
21
+ self.timeline_section("## Chronological event timeline", condensed=False),
22
+ self.observed_error_section(),
23
+ self._resolution_section(),
24
+ self.verification_section(),
25
+ self._followup_section(),
26
+ self.footer(),
27
+ )
28
+
29
+ def _time_window_section(self) -> List[str]:
30
+ s = self.session
31
+ lines = ["## Time window", ""]
32
+ start = s.timestamps.start
33
+ end = s.timestamps.end
34
+ lines.append(f"- **Start:** {start or 'unknown'}")
35
+ lines.append(f"- **End:** {end or 'unknown'}")
36
+ duration = _duration(start, end)
37
+ if duration is not None:
38
+ lines.append(f"- **Duration:** {duration}")
39
+ return lines
40
+
41
+ def _resolution_section(self) -> List[str]:
42
+ s = self.session
43
+ lines = ["## Resolution / current state", ""]
44
+ verified = len(self.ctx.verification_commands) > 0
45
+ failed = len(self.ctx.failed_commands)
46
+ if verified and failed == 0:
47
+ lines.append(
48
+ "A verification command passed and no commands are failing. "
49
+ "The recorded evidence is consistent with a resolved state."
50
+ )
51
+ elif failed:
52
+ lines.append(
53
+ f"{failed} command(s) were failing at the end of the session; "
54
+ "the incident does not appear fully resolved."
55
+ )
56
+ else:
57
+ lines.append(
58
+ "No verification command passed; the resolution state is "
59
+ "unconfirmed by the recorded evidence."
60
+ )
61
+ if s.git.is_repo and s.summary.modified_files:
62
+ lines.append("")
63
+ lines.append(
64
+ f"Working tree: {len(s.summary.modified_files)} file(s) changed, "
65
+ f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines."
66
+ )
67
+ return lines
68
+
69
+ def _followup_section(self) -> List[str]:
70
+ lines = ["## Follow-up items", ""]
71
+ items: List[str] = []
72
+ for rc in self.ctx.failed_commands:
73
+ items.append(f"Resolve the failing command `{rc.command}`.")
74
+ if not self.ctx.verification_commands:
75
+ items.append("Add a passing verification command to confirm resolution.")
76
+ if not items:
77
+ return []
78
+ for item in items:
79
+ lines.append(f"- {item}")
80
+ return lines
81
+
82
+
83
+ def _duration(start, end):
84
+ if not start or not end:
85
+ return None
86
+ try:
87
+ delta = parse_iso8601(end) - parse_iso8601(start)
88
+ except (ValueError, TypeError):
89
+ return None
90
+ total = int(delta.total_seconds())
91
+ if total < 0:
92
+ return None
93
+ hours, remainder = divmod(total, 3600)
94
+ minutes, seconds = divmod(remainder, 60)
95
+ if hours:
96
+ return f"{hours}h {minutes}m {seconds}s"
97
+ if minutes:
98
+ return f"{minutes}m {seconds}s"
99
+ return f"{seconds}s"
@@ -0,0 +1,28 @@
1
+ """PR-mode report: a pull-request-ready summary of a debugging session.
2
+
3
+ Every section is derived from recorded evidence and is omitted when it has no
4
+ real content, so the report never pads itself with templated filler.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import BaseReporter, join_sections
10
+
11
+
12
+ class PRReporter(BaseReporter):
13
+ mode = "pr"
14
+
15
+ def render(self) -> str:
16
+ return join_sections(
17
+ [self.title_line()],
18
+ self.one_liner_section(),
19
+ self.metadata_lines(),
20
+ self.warnings_section(),
21
+ self.reproduce_verify_section(),
22
+ self.red_to_green_section(),
23
+ self.changed_files_section(),
24
+ self.timeline_section("## Timeline", condensed=True),
25
+ self.verification_section(),
26
+ self.ruled_out_section(),
27
+ self.footer(),
28
+ )
@@ -0,0 +1,50 @@
1
+ """Helpers for locating and describing generated reports.
2
+
3
+ These are read-only utilities used by the ``last`` and ``open`` commands. They
4
+ never require an active session and never open files themselves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from .reporters import VALID_MODES
13
+
14
+
15
+ def list_reports(reports_dir: Path) -> List[Path]:
16
+ """Return report markdown files, most-recently-modified first."""
17
+ if not reports_dir.is_dir():
18
+ return []
19
+ reports = [p for p in reports_dir.glob("*.md") if p.is_file()]
20
+ reports.sort(key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
21
+ return reports
22
+
23
+
24
+ def latest_report(reports_dir: Path) -> Optional[Path]:
25
+ reports = list_reports(reports_dir)
26
+ return reports[0] if reports else None
27
+
28
+
29
+ def infer_mode(report_path: Path) -> Optional[str]:
30
+ """Infer the report mode from a filename like ``<id>-pr.md``."""
31
+ stem = report_path.stem # filename without .md
32
+ for mode in VALID_MODES:
33
+ if stem.endswith(f"-{mode}"):
34
+ return mode
35
+ return None
36
+
37
+
38
+ def first_title(report_path: Path) -> Optional[str]:
39
+ """Return the first markdown H1 title line ('# ...') from the report."""
40
+ try:
41
+ with open(report_path, encoding="utf-8") as handle:
42
+ for line in handle:
43
+ stripped = line.strip()
44
+ if stripped.startswith("# "):
45
+ return stripped[2:].strip()
46
+ if stripped.startswith("#"):
47
+ return stripped.lstrip("#").strip()
48
+ except OSError:
49
+ return None
50
+ return None