Skip to content

Commit 9b7a9f0

Browse files
fix(export): address CodeRabbit review (engine hardening + manifest parity)
- Resolve first_timestamp from mtime without mutating SessionDict metadata - Apply mtime fallback for API and CLI export paths - Guard invalid since in run_bulk_export; use Literal types for since/fmt - Export MANIFEST_SHARED_KEYS; unify manifest JSONL via serialize_manifest_jsonl - CLI zip uses ZipSink.finalize; --no-zip uses same serializer - Public format_duration; ListSink no longer duplicates BulkExportResult.exports
1 parent 832f56e commit 9b7a9f0

4 files changed

Lines changed: 54 additions & 44 deletions

File tree

scripts/export.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import sys
1919
import zipfile
2020
from datetime import datetime
21+
from typing import cast
2122

2223
# Allow running from repo root or scripts/ directory
2324
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -26,12 +27,19 @@
2627

2728
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
2829
from utils.jsonl_parser import parse_session
29-
from utils.session_stats import compute_stats, _format_duration
30+
from utils.session_stats import compute_stats, format_duration
3031
from utils.md_exporter import session_to_markdown
3132
from utils.json_exporter import session_to_json
3233
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
3334
from utils.slugify import slugify
34-
from utils.export_engine import ListSink, run_bulk_export
35+
from utils.export_engine import (
36+
ExportFormat,
37+
ListSink,
38+
SinceMode,
39+
ZipSink,
40+
run_bulk_export,
41+
serialize_manifest_jsonl,
42+
)
3543
from utils.export_state_store import (
3644
atomic_write_export_state,
3745
export_state_lock,
@@ -247,7 +255,7 @@ def _session_stats(session_id: str, base_dir: str, fmt: str):
247255
print(f" Title: {session['title']}")
248256
if meta["first_timestamp"]:
249257
print(f" Created: {meta['first_timestamp'][:19]}")
250-
dur = _format_duration(meta.get("session_wall_time_seconds"))
258+
dur = format_duration(meta.get("session_wall_time_seconds"))
251259
if dur:
252260
print(f" Duration: {dur}")
253261
print(f" Models: {', '.join(meta['models_used']) or 'unknown'}")
@@ -432,11 +440,11 @@ def _on_export_error(sid: str, exc: Exception) -> None:
432440
collect_sink = ListSink()
433441
export_result = run_bulk_export(
434442
projects=projects,
435-
since=since,
443+
since=cast(SinceMode, since),
436444
rules=rules,
437445
last_export_sessions=last_export,
438446
sink=collect_sink,
439-
fmt=fmt,
447+
fmt=cast(ExportFormat, fmt),
440448
path_layout="cli",
441449
manifest_style="cli",
442450
on_export_error=_on_export_error,
@@ -502,8 +510,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
502510
f.write(content)
503511
manifest_path = os.path.join(out_dir, "manifest.jsonl")
504512
with open(manifest_path, "w", encoding="utf-8") as f:
505-
for entry in manifest:
506-
f.write(json.dumps(entry, default=str) + "\n")
513+
f.write(serialize_manifest_jsonl(manifest))
507514
print(f"Exported {exported} file(s) to {out_dir}")
508515
else:
509516
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -518,10 +525,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
518525
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
519526
for rel_path, content in all_exports:
520527
zf.writestr(rel_path, content)
521-
manifest_str = "\n".join(
522-
json.dumps(e, default=str) for e in manifest
523-
)
524-
zf.writestr("manifest.jsonl", manifest_str)
528+
ZipSink(zf).finalize(manifest)
525529
print(f"Exported {exported} file(s) to {zip_path}")
526530

527531
_save_state(last_export, count=len(manifest), out_dir=out_dir)

tests/test_export_engine_parity.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,13 @@
1818
from api.export_api import export_bp # noqa: E402
1919
from tests.test_cli_e2e import _run_cli, _seed_base_dir # noqa: E402
2020
from utils.export_engine import ( # noqa: E402
21+
MANIFEST_SHARED_KEYS,
2122
ListSink,
2223
manifest_shared_subset,
2324
run_bulk_export,
2425
)
2526
from utils.session_path import list_projects # noqa: E402
2627

27-
_MANIFEST_KEYS = (
28-
"session_id",
29-
"title",
30-
"project",
31-
"tokens",
32-
"tool_calls",
33-
)
34-
3528

3629
def _markdown_from_exports(exports: list[tuple[str, str]]) -> str:
3730
md_paths = [p for p, _ in exports if p.endswith(".md")]
@@ -75,7 +68,7 @@ def test_engine_api_vs_cli_layout_same_markdown_and_manifest(tmp_path: Path) ->
7568

7669
api_core = manifest_shared_subset(api_result.manifest[0])
7770
cli_core = manifest_shared_subset(cli_result.manifest[0])
78-
for key in _MANIFEST_KEYS:
71+
for key in MANIFEST_SHARED_KEYS:
7972
assert api_core[key] == cli_core[key]
8073

8174

@@ -126,6 +119,6 @@ def test_http_post_export_matches_cli_no_zip(tmp_path: Path, monkeypatch) -> Non
126119
]
127120

128121
assert http_md == cli_md
129-
http_core = {k: http_manifest[0][k] for k in _MANIFEST_KEYS}
130-
cli_core = {k: cli_manifest[0][k] for k in _MANIFEST_KEYS}
122+
http_core = {k: http_manifest[0][k] for k in MANIFEST_SHARED_KEYS}
123+
cli_core = {k: cli_manifest[0][k] for k in MANIFEST_SHARED_KEYS}
131124
assert http_core == cli_core

utils/export_engine.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131

3232
PathLayout = Literal["api", "cli"]
3333
ManifestStyle = Literal["api", "cli"]
34+
SinceMode = Literal["all", "last", "incremental"]
35+
ExportFormat = Literal["md", "json", "both"]
3436

35-
_MANIFEST_SHARED_KEYS = (
37+
MANIFEST_SHARED_KEYS: tuple[str, ...] = (
3638
"session_id",
3739
"title",
3840
"project",
@@ -41,10 +43,18 @@
4143
)
4244

4345

46+
def serialize_manifest_jsonl(manifest: list[dict[str, Any]]) -> str:
47+
"""JSONL manifest body with a trailing newline (empty string if no rows)."""
48+
if not manifest:
49+
return ""
50+
return "\n".join(json.dumps(e, default=str) for e in manifest) + "\n"
51+
52+
4453
@dataclass
4554
class BulkExportResult:
4655
"""Outcome of a bulk export run."""
4756

57+
# Canonical list of (rel_path, content); sinks do not duplicate this.
4858
exports: list[tuple[str, str]] = field(default_factory=list)
4959
manifest: list[dict[str, Any]] = field(default_factory=list)
5060
new_sessions_map: dict[str, float] = field(default_factory=dict)
@@ -64,24 +74,24 @@ def add_session(
6474
self,
6575
files: list[tuple[str, str]],
6676
manifest_entry: dict[str, Any],
67-
) -> None: ...
77+
) -> None:
78+
"""Write one session's export file(s) to the sink target."""
6879

69-
def finalize(self, manifest: list[dict[str, Any]]) -> None: ...
80+
def finalize(self, manifest: list[dict[str, Any]]) -> None:
81+
"""Flush manifest and any sink-specific completion (e.g. manifest.jsonl)."""
7082

7183

7284
@dataclass
7385
class ListSink:
74-
"""In-memory sink for tests and parity checks."""
86+
"""In-memory sink for tests; use :attr:`BulkExportResult.exports` for file pairs."""
7587

76-
exports: list[tuple[str, str]] = field(default_factory=list)
7788
manifest: list[dict[str, Any]] = field(default_factory=list)
7889

7990
def add_session(
8091
self,
8192
files: list[tuple[str, str]],
8293
manifest_entry: dict[str, Any],
8394
) -> None:
84-
self.exports.extend(files)
8595
self.manifest.append(manifest_entry)
8696

8797
def finalize(self, manifest: list[dict[str, Any]]) -> None:
@@ -105,21 +115,20 @@ def add_session(
105115
self._manifest.append(manifest_entry)
106116

107117
def finalize(self, manifest: list[dict[str, Any]]) -> None:
108-
if manifest:
109-
manifest_str = "\n".join(json.dumps(e, default=str) for e in manifest) + "\n"
110-
self._zf.writestr("manifest.jsonl", manifest_str)
118+
body = serialize_manifest_jsonl(manifest)
119+
if body:
120+
self._zf.writestr("manifest.jsonl", body)
111121

112122

113-
def _ensure_first_timestamp(
123+
def _resolve_first_timestamp(
114124
meta: SessionMetadataDict, sess_info: SessionListItemDict
115125
) -> str:
116-
ts_raw = meta.get("first_timestamp") or ""
117-
ts = ts_raw if ts_raw else ""
126+
"""Return first_timestamp from metadata, or synthesise from mtime without mutating *meta*."""
127+
ts = (meta.get("first_timestamp") or "").strip()
118128
if not ts:
119129
ts = datetime.fromtimestamp(sess_info["modified"]).strftime(
120130
"%Y-%m-%dT%H:%M:%S"
121131
)
122-
meta["first_timestamp"] = ts
123132
return ts
124133

125134

@@ -138,10 +147,8 @@ def build_export_rel_path(
138147
"""Build zip/disk-relative path for one exported session file."""
139148
sid = sess_info["id"]
140149
meta = session["metadata"]
141-
ts = meta.get("first_timestamp") or ""
142-
if layout == "cli" and not ts:
143-
ts = _ensure_first_timestamp(meta, sess_info)
144-
date_str = ts[:10] if ts else "0000-00-00"
150+
ts = _resolve_first_timestamp(meta, sess_info)
151+
date_str = ts[:10]
145152
ts_file = _ts_file_slug(ts)
146153
title_slug = slugify(session["title"], default="session")
147154
short_id = sid[:8]
@@ -185,15 +192,15 @@ def build_manifest_entry(
185192

186193
def manifest_shared_subset(entry: dict[str, Any]) -> dict[str, Any]:
187194
"""Core manifest fields compared in HTTP vs CLI parity tests."""
188-
return {k: entry[k] for k in _MANIFEST_SHARED_KEYS if k in entry}
195+
return {k: entry[k] for k in MANIFEST_SHARED_KEYS if k in entry}
189196

190197

191198
def _session_files(
192199
session: SessionDict,
193200
stats: SessionStatsDict,
194201
project: ProjectDict,
195202
sess_info: SessionListItemDict,
196-
fmt: str,
203+
fmt: ExportFormat,
197204
layout: PathLayout,
198205
) -> list[tuple[str, str]]:
199206
files: list[tuple[str, str]] = []
@@ -223,11 +230,11 @@ def _session_files(
223230
def run_bulk_export(
224231
*,
225232
projects: list[ProjectDict],
226-
since: str,
233+
since: SinceMode,
227234
rules: list[Any],
228235
last_export_sessions: dict[str, float],
229236
sink: ExportSink,
230-
fmt: str = "md",
237+
fmt: ExportFormat = "md",
231238
path_layout: PathLayout = "api",
232239
manifest_style: ManifestStyle | None = None,
233240
on_export_error: Callable[[str, Exception], None] | None = None,
@@ -237,6 +244,9 @@ def run_bulk_export(
237244
*since* must be one of ``all``, ``last``, or ``incremental``.
238245
Per-session failures are caught, counted, and skipped (batch continues).
239246
"""
247+
if since not in ("all", "last", "incremental"):
248+
raise ValueError(f"Invalid since mode: {since!r}")
249+
240250
if manifest_style is None:
241251
manifest_style = path_layout
242252

utils/session_stats.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def _summarize_tool_results(messages: list[MessageDict]) -> dict[str, int]:
197197
return summary
198198

199199

200-
def _format_duration(seconds: float | int | None) -> str | None:
200+
def format_duration(seconds: float | int | None) -> str | None:
201201
"""Turn seconds into something like '2h 15m' or '45s'."""
202202
if seconds is None:
203203
return None
@@ -211,3 +211,6 @@ def _format_duration(seconds: float | int | None) -> str | None:
211211
hours = minutes // 60
212212
mins = minutes % 60
213213
return f"{hours}h {mins}m"
214+
215+
216+
_format_duration = format_duration # backward compat for internal callers

0 commit comments

Comments
 (0)