worklog-for-claude-code 0.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.
worklog/report.py ADDED
@@ -0,0 +1,229 @@
1
+ """Daily report: merge records from all devices/accounts, summarize, render Markdown."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from datetime import date, datetime
11
+
12
+ from .config import DEFAULT_CLAUDE_DIR, SUMMARIZER_CWD, Config
13
+ from .parser import zero_tokens
14
+ from .redact import redact
15
+ from .store import GitStore
16
+
17
+ log = logging.getLogger(__name__)
18
+ _MAX_AI_INPUT_CHARS = 24_000 # keep each summary call small and well below arg-size limits
19
+
20
+
21
+ def project_key(proj: dict) -> str:
22
+ """Same repo on two machines (different paths) merges via its remote slug."""
23
+ remote = proj.get("remote")
24
+ return f"{remote['host']}/{remote['slug']}" if remote else f"{proj.get('kind')}:{proj.get('name')}"
25
+
26
+
27
+ @dataclass
28
+ class ProjectDay:
29
+ key: str
30
+ name: str
31
+ kind: str
32
+ remote: dict | None
33
+ accounts: set[str] = field(default_factory=set)
34
+ devices: set[str] = field(default_factory=set)
35
+ sessions: int = 0
36
+ minutes: int = 0
37
+ tokens: dict[str, int] = field(default_factory=zero_tokens)
38
+ prompts: list[tuple[str, str]] = field(default_factory=list) # (start time, prompt)
39
+ files: set[str] = field(default_factory=set)
40
+ folders: set[str] = field(default_factory=set)
41
+ commits: dict[str, str] = field(default_factory=dict) # hash -> subject (deduped)
42
+ branches: set[str] = field(default_factory=set)
43
+
44
+
45
+ def _add(dst: dict[str, int], src: object) -> None:
46
+ for k in dst:
47
+ v = src.get(k, 0) if isinstance(src, dict) else 0
48
+ dst[k] += v if isinstance(v, int) else 0
49
+
50
+
51
+ def aggregate(records: list[dict]):
52
+ accounts: dict[str, dict] = {}
53
+ projects: dict[str, ProjectDay] = {}
54
+ for r in records:
55
+ acct = accounts.setdefault(r.get("account", "?"), {
56
+ "tokens": zero_tokens(), "sessions": 0, "devices": set(), "models": {}})
57
+ _add(acct["tokens"], r.get("tokens"))
58
+ acct["sessions"] += 1
59
+ acct["devices"].add(r.get("device", "?"))
60
+ for model, t in (r.get("tokens_by_model") or {}).items():
61
+ _add(acct["models"].setdefault(model, zero_tokens()), t)
62
+
63
+ proj = r.get("project") or {}
64
+ key = project_key(proj)
65
+ p = projects.setdefault(key, ProjectDay(key, str(proj.get("name", "?")), str(proj.get("kind")),
66
+ proj.get("remote")))
67
+ p.accounts.add(r.get("account", "?"))
68
+ p.devices.add(r.get("device", "?"))
69
+ p.sessions += 1
70
+ p.minutes += int(r.get("span_minutes") or 0)
71
+ _add(p.tokens, r.get("tokens"))
72
+ start = (r.get("start") or "")[11:16]
73
+ p.prompts.extend((start, q) for q in r.get("prompts") or [])
74
+ p.files.update(r.get("files_touched") or [])
75
+ p.branches.update(proj.get("branches") or [])
76
+ if proj.get("folder"):
77
+ p.folders.add(proj["folder"])
78
+ for c in r.get("commits") or []:
79
+ p.commits.setdefault(c.get("hash", ""), c.get("subject", ""))
80
+ for p in projects.values():
81
+ p.prompts.sort()
82
+ return accounts, projects
83
+
84
+
85
+ # ------------------------------------------------------------ summarization
86
+ def _claude_env(cfg: Config) -> dict[str, str]:
87
+ env = dict(os.environ)
88
+ acct = cfg.account(cfg.summarizer_account)
89
+ if acct and acct.config_dir != DEFAULT_CLAUDE_DIR:
90
+ env["CLAUDE_CONFIG_DIR"] = str(acct.config_dir)
91
+ else:
92
+ env.pop("CLAUDE_CONFIG_DIR", None)
93
+ return env
94
+
95
+
96
+ def ai_summary(p: ProjectDay, day: date, cfg: Config) -> list[str] | None:
97
+ exe = shutil.which(cfg.claude_bin)
98
+ if not exe:
99
+ log.warning("claude binary %r not found; using plain summary", cfg.claude_bin)
100
+ return None
101
+ data = {
102
+ "project": p.name, "kind": p.kind, "branches": sorted(p.branches), "folders": sorted(p.folders),
103
+ "prompts_in_order": [q for _, q in p.prompts],
104
+ "commits": list(p.commits.values()), "files_edited": sorted(p.files)[:80],
105
+ }
106
+ blob = json.dumps(data, ensure_ascii=False)[:_MAX_AI_INPUT_CHARS]
107
+ prompt = (
108
+ f"You are writing a developer's end-of-day work log for {day.isoformat()}. "
109
+ "The JSON below was extracted from their Claude Code sessions for ONE project. "
110
+ "Write 2-7 concise bullet points describing what was accomplished or investigated "
111
+ "(features implemented, bugs fixed, refactors, research). Past tense, no fluff. "
112
+ "Use ONLY the data given; if something is unclear, describe it generically rather than guess. "
113
+ "Output only the bullets, each line starting with '- '. Do not use any tools.\n\n"
114
+ f"<data>\n{blob}\n</data>"
115
+ )
116
+ SUMMARIZER_CWD.mkdir(parents=True, exist_ok=True) # excluded by the collector
117
+ try:
118
+ out = subprocess.run([exe, "-p", prompt, "--output-format", "text"], cwd=SUMMARIZER_CWD,
119
+ env=_claude_env(cfg), capture_output=True, text=True, timeout=cfg.ai_timeout)
120
+ except (OSError, subprocess.TimeoutExpired) as exc:
121
+ log.warning("claude -p failed for %s: %s", p.name, exc)
122
+ return None
123
+ if out.returncode != 0:
124
+ log.warning("claude -p exited %d for %s: %s", out.returncode, p.name, out.stderr.strip()[:300])
125
+ return None
126
+ bullets = [redact(line.strip())[:400] for line in out.stdout.splitlines()
127
+ if line.strip().startswith(("- ", "* "))]
128
+ return [("- " + b[2:]) for b in bullets] or None
129
+
130
+
131
+ def plain_summary(p: ProjectDay) -> list[str]:
132
+ lines = [f"- {q.splitlines()[0][:140]}" for _, q in p.prompts[:6]]
133
+ if len(p.prompts) > 6:
134
+ lines.append(f"- …and {len(p.prompts) - 6} more prompt(s)")
135
+ return lines or ["- (no prompts captured)"]
136
+
137
+
138
+ # ------------------------------------------------------------------ render
139
+ def _n(v: int) -> str:
140
+ return f"{v:,}"
141
+
142
+
143
+ def _dur(minutes: int) -> str:
144
+ h, m = divmod(minutes, 60)
145
+ return f"{h}h {m:02d}m" if h else f"{m}m"
146
+
147
+
148
+ def summarize_all(projects: dict[str, ProjectDay], day: date, cfg: Config, use_ai: bool) -> dict[str, dict]:
149
+ out = {}
150
+ for key, p in projects.items():
151
+ bullets = ai_summary(p, day, cfg) if use_ai else None
152
+ out[key] = {"bullets": bullets or plain_summary(p), "source": "ai" if bullets else "plain"}
153
+ return out
154
+
155
+
156
+ def render(day: date, cfg: Config, accounts: dict, projects: dict[str, ProjectDay], summaries: dict) -> str:
157
+ devices = sorted({d for a in accounts.values() for d in a["devices"]})
158
+ total_sessions = sum(a["sessions"] for a in accounts.values())
159
+ out = [f"# Claude Code work log — {day.isoformat()}", "",
160
+ f"_Generated {datetime.now().strftime('%Y-%m-%d %H:%M')} on `{cfg.device}` · "
161
+ f"{total_sessions} session(s) · devices: {', '.join(devices) or '—'}_", ""]
162
+ if not accounts:
163
+ out += ["No Claude Code activity recorded for this day.", ""]
164
+ return "\n".join(out)
165
+
166
+ head = "| Account | Sessions | Input | Output | Cache write | Cache read | Total |"
167
+ out += ["## Token usage by account", "", head, "|---|---:|---:|---:|---:|---:|---:|"]
168
+ grand = zero_tokens()
169
+ for name in sorted(accounts):
170
+ a = accounts[name]
171
+ t = a["tokens"]
172
+ _add(grand, t)
173
+ out.append(f"| {name} | {a['sessions']} | {_n(t['input'])} | {_n(t['output'])} | "
174
+ f"{_n(t['cache_creation'])} | {_n(t['cache_read'])} | {_n(sum(t.values()))} |")
175
+ out.append(f"| **All** | **{total_sessions}** | **{_n(grand['input'])}** | **{_n(grand['output'])}** | "
176
+ f"**{_n(grand['cache_creation'])}** | **{_n(grand['cache_read'])}** | **{_n(sum(grand.values()))}** |")
177
+ out += ["", "### By model", "", "| Account | Model | Input | Output | Cache write | Cache read |",
178
+ "|---|---|---:|---:|---:|---:|"]
179
+ for name in sorted(accounts):
180
+ for model, t in sorted(accounts[name]["models"].items()):
181
+ out.append(f"| {name} | {model} | {_n(t['input'])} | {_n(t['output'])} | "
182
+ f"{_n(t['cache_creation'])} | {_n(t['cache_read'])} |")
183
+ out += ["", "> Token counts are usage as logged by Claude Code, not a bill. "
184
+ "On Pro/Max plans they count against limits rather than cost money directly.", ""]
185
+
186
+ out += ["## What I worked on", ""]
187
+ ordered = sorted(projects.values(), key=lambda p: (p.kind == "misc", -p.sessions, p.name))
188
+ for p in ordered:
189
+ where = f"`{p.remote['host']}/{p.remote['slug']}`" if p.remote else (
190
+ "folders: " + ", ".join(sorted(p.folders)) if p.folders else "no git repo")
191
+ out += [f"### {p.name}", "",
192
+ f"{where} · accounts: {', '.join(sorted(p.accounts))} · devices: {', '.join(sorted(p.devices))} · "
193
+ f"{p.sessions} session(s) · {_dur(p.minutes)} span · {_n(sum(p.tokens.values()))} tokens · "
194
+ f"{len(p.files)} file(s) edited · {len(p.commits)} commit(s)", ""]
195
+ out += summaries.get(p.key, {}).get("bullets") or plain_summary(p)
196
+ out += [""]
197
+ if p.commits:
198
+ out += ["Commits:", ""] + [f"- `{h}` {s}" for h, s in p.commits.items()] + [""]
199
+ return "\n".join(out)
200
+
201
+
202
+ def build_report(cfg: Config, day: date, use_ai: bool, write: bool) -> tuple[str, str | None]:
203
+ store = GitStore(cfg.store_repo)
204
+ with store.lock(): # short lock: sync + read only
205
+ store.pull()
206
+ records = list(store.iter_json(f"records/{day.isoformat()}"))
207
+ accounts, projects = aggregate(records)
208
+ summaries = summarize_all(projects, day, cfg, use_ai) # AI calls run without the lock
209
+ text = render(day, cfg, accounts, projects, summaries)
210
+ if not write:
211
+ return text, None
212
+ rel = f"reports/{day.isoformat()}/{cfg.device}.md"
213
+ meta = {"date": day.isoformat(), "device": cfg.device, "summaries": summaries,
214
+ "generated_at": datetime.now().astimezone().isoformat(timespec="seconds")}
215
+ with store.lock():
216
+ store.pull()
217
+ store.write_text(rel, text)
218
+ store.write_json(f"reports/{day.isoformat()}/{cfg.device}.json", meta)
219
+ store.commit_and_push(f"report {day.isoformat()} ({cfg.device})")
220
+ return text, str(cfg.store_repo / rel)
221
+
222
+
223
+ def load_saved_summaries(store: GitStore, day: date) -> dict:
224
+ """Newest saved summaries for `day` from any device (used by the web UI)."""
225
+ best: dict = {}
226
+ for meta in store.iter_json(f"reports/{day.isoformat()}"):
227
+ if isinstance(meta, dict) and meta.get("generated_at", "") > best.get("generated_at", ""):
228
+ best = meta
229
+ return best
worklog/services.py ADDED
@@ -0,0 +1,139 @@
1
+ """systemd --user units: the web UI (always on) + collect/report timers (replace cron).
2
+
3
+ Timers use Persistent=true, so a run missed while the laptop was asleep/off happens on wake.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ UNIT_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
14
+ WEB, COLLECT, REPORT = "claude-worklog-web.service", "claude-worklog-collect.timer", "claude-worklog-report.timer"
15
+ # Set by `claude-worklog demo`: the demo must never start, stop or enable the user's real units.
16
+ DEMO_ENV = "CLAUDE_WORKLOG_DEMO"
17
+
18
+
19
+ def demo_mode() -> bool:
20
+ return os.environ.get(DEMO_ENV) == "1"
21
+
22
+
23
+ def _q(s: str) -> str:
24
+ """Quote for a systemd unit file (and escape % specifiers)."""
25
+ s = s.replace("%", "%%")
26
+ return f'"{s}"' if any(c in s for c in ' "\\') else s
27
+
28
+
29
+ def _units(port: int) -> dict[str, str]:
30
+ py = _q(sys.executable) # the pipx venv's python: always has the package
31
+ # Timers/services don't inherit your shell PATH; freeze it so `claude` and `git` are found.
32
+ env = f'Environment={_q("PATH=" + os.environ.get("PATH", "/usr/bin:/bin"))}\nEnvironment=PYTHONUNBUFFERED=1'
33
+ oneshot = "[Service]\nType=oneshot\nNice=10\n" + env + "\n"
34
+ return {
35
+ WEB: f"""[Unit]
36
+ Description=claude-worklog web UI (http://worklog.localhost:{port})
37
+
38
+ [Service]
39
+ Type=simple
40
+ ExecStart={py} -m worklog.cli serve --port {port}
41
+ Restart=on-failure
42
+ RestartSec=5
43
+ NoNewPrivileges=yes
44
+ {env}
45
+
46
+ [Install]
47
+ WantedBy=default.target
48
+ """,
49
+ "claude-worklog-collect.service": f"""[Unit]
50
+ Description=claude-worklog: collect Claude Code sessions (backstop for the hook)
51
+
52
+ {oneshot}ExecStart={py} -m worklog.cli --quiet collect --days 2
53
+ """,
54
+ COLLECT: """[Unit]
55
+ Description=claude-worklog hourly collect
56
+
57
+ [Timer]
58
+ OnBootSec=3min
59
+ OnCalendar=hourly
60
+ Persistent=true
61
+ RandomizedDelaySec=120
62
+
63
+ [Install]
64
+ WantedBy=timers.target
65
+ """,
66
+ "claude-worklog-report.service": f"""[Unit]
67
+ Description=claude-worklog: build daily report
68
+
69
+ {oneshot}ExecStart={py} -m worklog.cli --quiet report --auto
70
+ """,
71
+ REPORT: """[Unit]
72
+ Description=claude-worklog daily report (23:30 today, 00:20 re-run for yesterday)
73
+
74
+ [Timer]
75
+ OnCalendar=*-*-* 23:30:00
76
+ OnCalendar=*-*-* 00:20:00
77
+ Persistent=true
78
+
79
+ [Install]
80
+ WantedBy=timers.target
81
+ """,
82
+ }
83
+
84
+
85
+ def _systemctl(*args: str) -> subprocess.CompletedProcess:
86
+ if demo_mode():
87
+ raise RuntimeError("systemd is disabled in demo mode")
88
+ return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=30)
89
+
90
+
91
+ def available() -> bool:
92
+ return not demo_mode() and shutil.which("systemctl") is not None
93
+
94
+
95
+ def install(port: int, report: bool, start: bool = True) -> list[str]:
96
+ UNIT_DIR.mkdir(parents=True, exist_ok=True)
97
+ msgs = []
98
+ for name, text in _units(port).items():
99
+ (UNIT_DIR / name).write_text(text, encoding="utf-8")
100
+ msgs.append(f"wrote {UNIT_DIR / name}")
101
+ if not start:
102
+ return msgs
103
+ if not available():
104
+ raise RuntimeError("systemctl not found; this needs a systemd-based system such as Ubuntu")
105
+ for args in (("daemon-reload",), ("enable", "--now", WEB, COLLECT), ("restart", WEB)):
106
+ r = _systemctl(*args)
107
+ if r.returncode != 0:
108
+ raise RuntimeError(f"systemctl --user {' '.join(args)} failed: {r.stderr.strip()}")
109
+ msgs.append(set_report_timer(report))
110
+ return msgs
111
+
112
+
113
+ def set_report_timer(enabled: bool) -> str:
114
+ r = _systemctl("enable" if enabled else "disable", "--now", REPORT)
115
+ if r.returncode != 0:
116
+ raise RuntimeError(f"could not {'enable' if enabled else 'disable'} {REPORT}: {r.stderr.strip()}")
117
+ return f"daily report on this device: {'on' if enabled else 'off'}"
118
+
119
+
120
+ def status() -> dict[str, str]:
121
+ if not available():
122
+ reason = "disabled in demo mode" if demo_mode() else "systemd not available"
123
+ return {u: reason for u in (WEB, COLLECT, REPORT)}
124
+ out = {}
125
+ for unit in (WEB, COLLECT, REPORT):
126
+ try:
127
+ active = _systemctl("is-active", unit).stdout.strip() or "unknown"
128
+ enabled = _systemctl("is-enabled", unit).stdout.strip() or "unknown"
129
+ except (OSError, subprocess.TimeoutExpired):
130
+ active = enabled = "unknown"
131
+ out[unit] = f"{active}, {enabled}"
132
+ return out
133
+
134
+
135
+ def report_timer_enabled() -> bool:
136
+ try:
137
+ return available() and _systemctl("is-enabled", REPORT).stdout.strip() == "enabled"
138
+ except (OSError, subprocess.TimeoutExpired):
139
+ return False
worklog/store.py ADDED
@@ -0,0 +1,135 @@
1
+ """Git-backed central store (any host: GitHub, GitLab, Bitbucket, self-hosted).
2
+
3
+ Layout (every path is device-specific, so devices never conflict):
4
+ records/<YYYY-MM-DD>/<device>/<account>/<session>.json
5
+ reports/<YYYY-MM-DD>/<device>.md
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import errno
10
+ import fcntl
11
+ import json
12
+ import logging
13
+ import os
14
+ import subprocess
15
+ import time
16
+ from contextlib import contextmanager
17
+ from pathlib import Path
18
+
19
+ from .config import STATE_DIR, ConfigError
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ # Commits in the data repo use a neutral identity (no personal e-mail stored).
24
+ _IDENTITY = ["-c", "user.name=claude-worklog", "-c", "user.email=claude-worklog@localhost"]
25
+
26
+
27
+ class GitStore:
28
+ def __init__(self, path: Path):
29
+ self.path = path
30
+ if not (path / ".git").exists():
31
+ raise ConfigError(
32
+ f"{path} is not a git repo. Create a PRIVATE repo on your git host and clone it there."
33
+ )
34
+ env = dict(os.environ)
35
+ env["GIT_TERMINAL_PROMPT"] = "0" # never hang waiting for a password (hooks/cron)
36
+ env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=15")
37
+ self._env = env
38
+
39
+ # ---------------------------------------------------------------- locking
40
+ @contextmanager
41
+ def lock(self, timeout: float = 90.0):
42
+ """Serialize hook, cron and manual runs on this machine."""
43
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
44
+ with open(STATE_DIR / "store.lock", "w") as fh:
45
+ deadline = time.monotonic() + timeout
46
+ while True:
47
+ try:
48
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
49
+ break
50
+ except OSError as exc:
51
+ if exc.errno not in (errno.EAGAIN, errno.EACCES) or time.monotonic() > deadline:
52
+ raise TimeoutError("another claude-worklog run holds the lock") from exc
53
+ time.sleep(1)
54
+ try:
55
+ yield
56
+ finally:
57
+ fcntl.flock(fh, fcntl.LOCK_UN)
58
+
59
+ # ------------------------------------------------------------------- git
60
+ def _git(self, *args: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess:
61
+ return subprocess.run(
62
+ ["git", "-C", str(self.path), *args],
63
+ capture_output=True, text=True, timeout=timeout, check=check, env=self._env,
64
+ )
65
+
66
+ def _has_remote(self) -> bool:
67
+ return bool(self._git("remote", check=False).stdout.strip())
68
+
69
+ def pull(self) -> None:
70
+ if not self._has_remote():
71
+ return
72
+ try:
73
+ self._git("pull", "--rebase", "--autostash", timeout=90)
74
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
75
+ # Offline, empty repo, or first run: work locally, sync next time.
76
+ detail = getattr(exc, "stderr", "") or str(exc)
77
+ log.warning("git pull failed (continuing locally): %s", detail.strip()[:300])
78
+ if (self.path / ".git" / "rebase-merge").exists() or (self.path / ".git" / "rebase-apply").exists():
79
+ self._git("rebase", "--abort", check=False)
80
+
81
+ def commit_and_push(self, message: str) -> None:
82
+ dirs = [d for d in ("records", "reports") if (self.path / d).exists()]
83
+ if not dirs:
84
+ return
85
+ self._git("add", "-A", "--", *dirs)
86
+ if self._git("diff", "--cached", "--quiet", check=False).returncode == 0:
87
+ return # nothing staged
88
+ self._git(*_IDENTITY, "commit", "-q", "-m", message)
89
+ if not self._has_remote():
90
+ log.info("committed locally (no remote configured)")
91
+ return
92
+ try:
93
+ self._git("push", "-q", "-u", "origin", "HEAD", timeout=90)
94
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
95
+ detail = getattr(exc, "stderr", "") or str(exc)
96
+ log.warning("git push failed; commit kept locally and retried next run: %s", detail.strip()[:300])
97
+
98
+ # ----------------------------------------------------------------- files
99
+ def _target(self, rel: str) -> Path:
100
+ target = (self.path / rel).resolve()
101
+ if self.path.resolve() not in target.parents:
102
+ raise ValueError(f"refusing to write outside store: {rel}")
103
+ return target
104
+
105
+ def write_text(self, rel: str, text: str) -> bool:
106
+ """Atomically write; returns False if content is unchanged."""
107
+ target = self._target(rel)
108
+ if target.exists() and target.read_text(encoding="utf-8") == text:
109
+ return False
110
+ target.parent.mkdir(parents=True, exist_ok=True)
111
+ tmp = target.with_suffix(target.suffix + ".tmp")
112
+ tmp.write_text(text, encoding="utf-8")
113
+ os.replace(tmp, target)
114
+ return True
115
+
116
+ def move(self, src_rel: str, dst_rel: str) -> None:
117
+ """Rename a file inside the store (commit_and_push stages it as a rename)."""
118
+ src, dst = self._target(src_rel), self._target(dst_rel)
119
+ dst.parent.mkdir(parents=True, exist_ok=True)
120
+ os.replace(src, dst)
121
+ try:
122
+ src.parent.rmdir() # drop the now-empty account folder, if it is empty
123
+ except OSError:
124
+ pass
125
+
126
+ def write_json(self, rel: str, data: dict) -> bool:
127
+ return self.write_text(rel, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
128
+
129
+ def iter_json(self, rel_dir: str):
130
+ base = self.path / rel_dir
131
+ for p in sorted(base.rglob("*.json")) if base.exists() else []:
132
+ try:
133
+ yield json.loads(p.read_text(encoding="utf-8"))
134
+ except (OSError, json.JSONDecodeError) as exc:
135
+ log.warning("skipping unreadable record %s: %s", p, exc)