Skip to content

Commit 54ecd06

Browse files
fix(export): ZipSink deferred writes, mode guards, UTC/POSIX paths
1 parent 20cffe0 commit 54ecd06

1 file changed

Lines changed: 24 additions & 12 deletions

File tree

utils/export_engine.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from __future__ import annotations
44

55
import json
6-
import os
6+
import posixpath
77
import zipfile
88
from dataclasses import dataclass, field
9-
from datetime import date, datetime
9+
from datetime import date, datetime, timezone
1010
from typing import Any, Callable, Literal, Protocol
1111

1212
from models.project import ProjectDict, SessionListItemDict
@@ -42,6 +42,15 @@
4242
"tool_calls",
4343
)
4444

45+
_VALID_SINCE: frozenset[str] = frozenset({"all", "last", "incremental"})
46+
_VALID_FMT: frozenset[str] = frozenset({"md", "json", "both"})
47+
_VALID_LAYOUT: frozenset[str] = frozenset({"api", "cli"})
48+
49+
50+
def _validate_mode(name: str, value: str, allowed: frozenset[str]) -> None:
51+
if value not in allowed:
52+
raise ValueError(f"Invalid {name}: {value!r}")
53+
4554

4655
def serialize_manifest_jsonl(manifest: list[dict[str, Any]]) -> str:
4756
"""JSONL manifest body with a trailing newline (empty string if no rows)."""
@@ -106,18 +115,19 @@ class ZipSink:
106115

107116
def __init__(self, zf: zipfile.ZipFile) -> None:
108117
self._zf = zf
109-
self._manifest: list[dict[str, Any]] = []
118+
self._pending_files: list[tuple[str, str]] = []
110119

111120
def add_session(
112121
self,
113122
files: list[tuple[str, str]],
114123
manifest_entry: dict[str, Any],
115124
) -> None:
116-
for rel_path, content in files:
117-
self._zf.writestr(rel_path, content)
118-
self._manifest.append(manifest_entry)
125+
del manifest_entry
126+
self._pending_files.extend(files)
119127

120128
def finalize(self, manifest: list[dict[str, Any]]) -> None:
129+
for rel_path, content in self._pending_files:
130+
self._zf.writestr(rel_path, content)
121131
body = serialize_manifest_jsonl(manifest)
122132
if body:
123133
self._zf.writestr("manifest.jsonl", body)
@@ -129,9 +139,9 @@ def _resolve_first_timestamp(
129139
"""Return first_timestamp from metadata, or synthesise from mtime without mutating *meta*."""
130140
ts = (meta.get("first_timestamp") or "").strip()
131141
if not ts:
132-
ts = datetime.fromtimestamp(sess_info["modified"]).strftime(
133-
"%Y-%m-%dT%H:%M:%S"
134-
)
142+
ts = datetime.fromtimestamp(
143+
sess_info["modified"], tz=timezone.utc
144+
).strftime("%Y-%m-%dT%H:%M:%S")
135145
return ts
136146

137147

@@ -159,7 +169,7 @@ def build_export_rel_path(
159169
filename = f"{ts_file}__{title_slug}__{short_id}.{ext}"
160170
if layout == "api":
161171
return f"{proj_slug}/{filename}"
162-
return os.path.join(date_str, proj_slug, filename)
172+
return posixpath.join(date_str, proj_slug, filename)
163173

164174

165175
def build_manifest_entry(
@@ -247,11 +257,13 @@ def run_bulk_export(
247257
*since* must be one of ``all``, ``last``, or ``incremental``.
248258
Per-session failures are caught, counted, and skipped (batch continues).
249259
"""
250-
if since not in ("all", "last", "incremental"):
251-
raise ValueError(f"Invalid since mode: {since!r}")
260+
_validate_mode("since", since, _VALID_SINCE)
261+
_validate_mode("fmt", fmt, _VALID_FMT)
262+
_validate_mode("path_layout", path_layout, _VALID_LAYOUT)
252263

253264
if manifest_style is None:
254265
manifest_style = path_layout
266+
_validate_mode("manifest_style", manifest_style, _VALID_LAYOUT)
255267

256268
result = BulkExportResult()
257269
manifest: list[dict[str, Any]] = []

0 commit comments

Comments
 (0)