oc_chat
Loading...
Searching...
No Matches
migrate_session_names.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/migrate_session_names.py
4
5"""
6Session name migration: timestamp format → random-word format.
7
8Purpose
9-------
10One-shot offline script that renames timestamp-format sessions
11(``session_YYYYMMDD_HHMMSS``) to readable random-word names
12(e.g., ``sunny-weaving-honey``).
13
14Responsibilities
15----------------
16- Detect sessions matching the old timestamp pattern
17- Generate a fresh random-word name for each
18- Rename the session directory on disk
19- Rename all files inside (which embed the old name as a prefix)
20- Update the ``session_name`` field in the ``_meta.json`` file
21- Print a mapping table: old_name → new_name
22- Support ``--dry-run`` (default) to preview without writing
23
24Diagnostics
25-----------
26Domain: OC_CHAT.MAIN
27Levels:
28 L2 — migration events (renamed, skipped, errors)
29 L3 — per-file rename details
30 L4 — per-path filesystem operation traces
31
32Contracts
33---------
34- Idempotent: sessions already in random-word format are skipped
35- ``--dry-run`` makes no filesystem changes
36- Exits with code 0 on success, 1 on any unrecoverable error
37- Never touches the archive/ or legacy/ subdirectories
38
39Dependencies
40------------
41- oc_diagnostics (structured debug logging via _dbg)
42- oc_chat.core.wordgen (lazy; random_name, which generates each collision-free new session name)
43- oc_chat.core.persistence (lazy; DEFAULT_SESSIONS_DIR, the default for --sessions-dir)
44"""
45from __future__ import annotations
46
47import argparse
48import json
49import re
50import shutil
51import sys
52import tempfile
53from pathlib import Path
54
55from oc_diagnostics import _dbg
56
57# Pattern for the old auto-generated timestamp names
58_TIMESTAMP_RE = re.compile(r"^session_\d{8}_\d{6}$")
59
60# Reserved subdirectory names inside the sessions root
61_SKIP_DIRS = {"archive", "legacy"}
62
63
64def _load_json(path: Path) -> dict:
65 """Read a JSON file, stripping UTF-8 BOM if present."""
66 raw = path.read_bytes()
67 if raw.startswith(b"\xef\xbb\xbf"):
68 raw = raw[3:]
69 return json.loads(raw.decode("utf-8"))
70
71
72def _write_json_atomic(path: Path, data: dict) -> None:
73 """Write JSON atomically via a temp file + rename."""
74 with tempfile.NamedTemporaryFile(
75 mode="w", encoding="utf-8", dir=path.parent,
76 suffix=".tmp", delete=False
77 ) as tmp:
78 json.dump(data, tmp, indent=2, ensure_ascii=False)
79 tmp_path = Path(tmp.name)
80 tmp_path.replace(path)
81
82
83def _find_meta_file(session_dir: Path) -> Path | None:
84 """Return the most recently modified *_meta.json in session_dir, or None."""
85 candidates = sorted(session_dir.glob("*_meta.json"), key=lambda p: p.stat().st_mtime)
86 return candidates[-1] if candidates else None
87
88
89def _collect_sessions(sessions_root: Path) -> list[Path]:
90 """Return session dirs that match the old timestamp naming pattern."""
91 results = []
92 for child in sorted(sessions_root.iterdir()):
93 if not child.is_dir():
94 continue
95 if child.name in _SKIP_DIRS:
96 continue
97 if _TIMESTAMP_RE.match(child.name):
98 results.append(child)
99 return results
100
101
103 sessions_dir: str,
104 dry_run: bool = True,
105 verbose: bool = True,
106) -> int:
107 """Run the migration.
108
109 Args:
110 sessions_dir: Path to the sessions root directory.
111 dry_run: If True, print what would happen but make no changes.
112 verbose: If True, print progress to stdout.
113
114 Returns:
115 Number of sessions migrated (or that would be migrated).
116 """
117 func = "migrate"
118 root = Path(sessions_dir)
119
120 if not root.is_dir():
121 print(f"Sessions directory not found: {root}", file=sys.stderr)
122 return 0
123
124 from oc_chat.core.wordgen import random_name
125
126 # Build the set of all existing session names (random-word + timestamp)
127 # so collision avoidance works correctly.
128 all_names: set[str] = set()
129 for child in root.iterdir():
130 if child.is_dir() and child.name not in _SKIP_DIRS:
131 all_names.add(child.name)
132
133 targets = _collect_sessions(root)
134 if not targets:
135 if verbose:
136 print("No timestamp-format sessions found. Nothing to migrate.")
137 return 0
138
139 if verbose and dry_run:
140 print("=== DRY RUN — no files will be changed ===\n")
141
142 migrated = 0
143 errors = 0
144 skipped = 0
145
146 col_w = max(len(t.name) for t in targets)
147
148 for session_dir in targets:
149 old_name = session_dir.name
150
151 meta_path = _find_meta_file(session_dir)
152 if meta_path is None:
153 print(f" SKIP {old_name:<{col_w}} (no meta file found)")
154 skipped += 1
155 continue
156
157 # Generate a unique new name
158 new_name = random_name(exists_fn=lambda n: n in all_names)
159 all_names.add(new_name)
160
161 _dbg("OC_CHAT.MAIN", func, f"{old_name!r} → {new_name!r}", 2)
162
163 if verbose:
164 print(f" {'(dry) ' if dry_run else ''}RENAME {old_name:<{col_w}} → {new_name}")
165
166 if dry_run:
167 migrated += 1
168 continue
169
170 try:
171 # 1. Rename all files inside the directory (old_name prefix → new_name)
172 for f in list(session_dir.iterdir()):
173 if not f.is_file():
174 continue
175 if f.name.startswith(old_name + "_"):
176 new_fname = new_name + f.name[len(old_name):]
177 f.rename(session_dir / new_fname)
178 _dbg("OC_CHAT.MAIN", func, f" file: {f.name} → {new_fname}", 3)
179
180 # 2. Update session_name in the (now-renamed) meta file
181 new_meta_path = session_dir / (new_name + meta_path.name[len(old_name):])
182 if new_meta_path.exists():
183 meta_data = _load_json(new_meta_path)
184 meta_data["session_name"] = new_name
185 _write_json_atomic(new_meta_path, meta_data)
186
187 # 3. Rename the session directory itself
188 new_dir = root / new_name
189 session_dir.rename(new_dir)
190
191 migrated += 1
192
193 except OSError as exc:
194 print(f" ERROR renaming {old_name}: {exc}", file=sys.stderr)
195 _dbg("OC_CHAT.MAIN", func, f"error renaming {old_name!r}: {exc}", 1, override=True)
196 errors += 1
197
198 if verbose:
199 print()
200 mode = "Would migrate" if dry_run else "Migrated"
201 print(f"{mode}: {migrated} | Skipped: {skipped} | Errors: {errors}")
202 if dry_run and migrated > 0:
203 print("\nRe-run without --dry-run to apply changes.")
204
205 return migrated
206
207
208def main() -> int:
209 """Entry point for ``python -m oc_chat.scripts.migrate_session_names``."""
210 for _stream in (sys.stdout, sys.stderr):
211 try:
212 _stream.reconfigure(errors="replace")
213 except (AttributeError, ValueError):
214 pass # non-reconfigurable stream (tests, pipes wrapped by harnesses)
215 from oc_chat.core.persistence import DEFAULT_SESSIONS_DIR
216
217 parser = argparse.ArgumentParser(
218 description="Rename timestamp-format sessions to random-word names.",
219 formatter_class=argparse.RawDescriptionHelpFormatter,
220 epilog=(
221 "Examples:\n"
222 " python -m oc_chat.scripts.migrate_session_names # dry run (safe)\n"
223 " python -m oc_chat.scripts.migrate_session_names --apply # apply changes\n"
224 ),
225 )
226 parser.add_argument(
227 "--apply",
228 action="store_true",
229 default=False,
230 help="Apply changes (default is dry-run/preview only)",
231 )
232 parser.add_argument(
233 "--sessions-dir",
234 default=DEFAULT_SESSIONS_DIR,
235 help=f"Sessions directory (default: {DEFAULT_SESSIONS_DIR})",
236 )
237 parser.add_argument(
238 "--quiet",
239 action="store_true",
240 default=False,
241 help="Suppress output",
242 )
243
244 args = parser.parse_args()
245 dry_run = not args.apply
246
247 migrate(
248 sessions_dir=args.sessions_dir,
249 dry_run=dry_run,
250 verbose=not args.quiet,
251 )
252 return 0
253
254
255if __name__ == "__main__":
256 sys.exit(main())
None _write_json_atomic(Path path, dict data)
Path|None _find_meta_file(Path session_dir)
int migrate(str sessions_dir, bool dry_run=True, bool verbose=True)
list[Path] _collect_sessions(Path sessions_root)