Skip to content

Commit efece7d

Browse files
Scott KostolniScott Kostolni
authored andcommitted
feat(cli): add progress reporting and summaries
1 parent 9021360 commit efece7d

9 files changed

Lines changed: 295 additions & 13 deletions

File tree

docs/USAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ mypy limitless_tools
7777

7878
## CLI commands
7979

80-
All commands read `LIMITLESS_API_KEY` from the environment. Default data dir is `~/limitless_tools/data/lifelogs` (override with `--data-dir` or `LIMITLESS_DATA_DIR`). Default batch size is `50` (override with `--batch-size`). HTTP requests use a 30-second timeout by default; override with `LIMITLESS_HTTP_TIMEOUT` or the `http_timeout` config key. Use `-v/--verbose` to emit structured JSON debug logs to stderr for troubleshooting.
80+
All commands read `LIMITLESS_API_KEY` from the environment. Default data dir is `~/limitless_tools/data/lifelogs` (override with `--data-dir` or `LIMITLESS_DATA_DIR`). Default batch size is `50` (override with `--batch-size`). HTTP requests use a 30-second timeout by default; override with `LIMITLESS_HTTP_TIMEOUT` or the `http_timeout` config key. Use `-v/--verbose` to emit structured JSON debug logs to stderr for troubleshooting. Long-running `fetch`/`sync` runs print progress lines and a completion summary (new/updated/unchanged counts) to stderr so you always know the job status without polluting stdout/JSON output.
8181

8282
- Fetch latest N lifelogs (saves JSON files): defaults include markdown and headings. Use `--json` to print a JSON array of saved item summaries to stdout.
8383

limitless_tools/cli/main.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,70 @@
44
import logging
55
import os
66
import sys
7+
import time
8+
from collections.abc import Callable
79
from pathlib import Path
810
from zoneinfo import ZoneInfo
911

1012
from limitless_tools.config.config import default_config_path, get_profile, load_config
1113
from limitless_tools.config.env import load_env
1214
from limitless_tools.config.logging import setup_logging
1315
from limitless_tools.config.paths import default_data_dir, expand_path
14-
from limitless_tools.services.lifelog_service import LifelogService
16+
from limitless_tools.services.lifelog_service import LifelogService, SaveReport
17+
18+
19+
def _stderr_line(message: str) -> None:
20+
sys.stderr.write(f"{message}\n")
21+
sys.stderr.flush()
22+
23+
24+
class ProgressReporter:
25+
def __init__(self, action: str):
26+
self.action = action
27+
self._start_ts: float | None = None
28+
self._callback: Callable[[int, int], None] | None = None
29+
30+
def start(self) -> None:
31+
if self._start_ts is None:
32+
self._start_ts = time.perf_counter()
33+
_stderr_line(f"{self.action.title()} started...")
34+
35+
def make_callback(self) -> Callable[[int, int], None]:
36+
if self._callback is None:
37+
def _cb(page: int, total: int) -> None:
38+
_stderr_line(
39+
f"{self.action.title()} in progress: {total} lifelogs processed (page {page})"
40+
)
41+
42+
self._callback = _cb
43+
return self._callback
44+
45+
def finish(self, report: SaveReport | None) -> None:
46+
if self._start_ts is None:
47+
self.start()
48+
duration = (time.perf_counter() - self._start_ts) if self._start_ts is not None else None
49+
_stderr_line(_format_summary(self.action, report, duration))
50+
51+
52+
def _format_summary(action: str, report: SaveReport | None, duration: float | None) -> str:
53+
title = action.title()
54+
duration_text = ""
55+
if duration is not None:
56+
duration_text = f" in {duration:.1f}s"
57+
if report is None:
58+
return f"{title} complete{duration_text}."
59+
if report.created or report.updated:
60+
parts: list[str] = []
61+
if report.created:
62+
parts.append(f"{report.created} new")
63+
if report.updated:
64+
parts.append(f"{report.updated} updated")
65+
if report.unchanged:
66+
parts.append(f"{report.unchanged} unchanged")
67+
return f"{title} complete{duration_text}: {', '.join(parts)}."
68+
if report.unchanged:
69+
return f"{title} complete{duration_text}: no changes (data already up to date)."
70+
return f"{title} complete{duration_text}: no lifelogs returned."
1571

1672

1773
def _build_parser() -> argparse.ArgumentParser:
@@ -181,12 +237,15 @@ def _coerce_timeout_value(value: object) -> float | None:
181237
data_dir=args.data_dir,
182238
http_timeout=resolved_http_timeout,
183239
)
240+
reporter = ProgressReporter("fetch")
241+
reporter.start()
184242
saved = service.fetch(
185243
limit=args.limit,
186244
direction=args.direction,
187245
include_markdown=args.include_markdown,
188246
include_headings=args.include_headings,
189247
batch_size=max(1, int(args.batch_size)),
248+
progress_callback=reporter.make_callback(),
190249
)
191250
if args.json:
192251
import json as _json
@@ -206,6 +265,7 @@ def _coerce_timeout_value(value: object) -> float | None:
206265
log.debug("Skipping invalid saved lifelog %s: %s", p, exc)
207266
continue
208267
print(_json.dumps(docs, ensure_ascii=False))
268+
reporter.finish(getattr(service, "last_report", None))
209269
return 0
210270

211271
if args.command == "sync":
@@ -224,13 +284,16 @@ def _coerce_timeout_value(value: object) -> float | None:
224284
data_dir=args.data_dir,
225285
http_timeout=resolved_http_timeout,
226286
)
287+
reporter = ProgressReporter("sync")
288+
reporter.start()
227289
saved = service.sync(
228290
date=args.date,
229291
start=args.start,
230292
end=args.end,
231293
timezone=args.timezone,
232294
is_starred=True if args.starred_only else None,
233295
batch_size=max(1, int(args.batch_size)),
296+
progress_callback=reporter.make_callback(),
234297
)
235298
if args.json:
236299
import json as _json
@@ -268,6 +331,7 @@ def _coerce_timeout_value(value: object) -> float | None:
268331
"items": items,
269332
}
270333
print(_json.dumps(result, ensure_ascii=False))
334+
reporter.finish(getattr(service, "last_report", None))
271335
return 0
272336

273337
if args.command == "list":

limitless_tools/http/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
from collections.abc import Callable
56
from typing import Any
67

78
try:
@@ -97,6 +98,7 @@ def get_lifelogs(
9798
is_starred: bool | None = None,
9899
batch_size: int = 50,
99100
cursor: str | None = None,
101+
progress_callback: Callable[[int, int], None] | None = None,
100102
) -> list[dict[str, Any]]:
101103
"""
102104
Fetch lifelogs with automatic pagination. Returns a list of lifelog dicts.
@@ -112,7 +114,9 @@ def get_lifelogs(
112114
current_cursor: str | None = cursor
113115
self.last_next_cursor: str | None = None
114116

117+
page_number = 0
115118
while True:
119+
page_number += 1
116120
params: dict[str, Any] = {
117121
"limit": page_size,
118122
"direction": direction,
@@ -186,6 +190,11 @@ def get_lifelogs(
186190
body = resp.json()
187191
page_items: list[dict[str, Any]] = body.get("data", {}).get("lifelogs", []) or []
188192
collected.extend(page_items)
193+
if progress_callback is not None:
194+
try:
195+
progress_callback(page_number, len(collected))
196+
except Exception:
197+
log.debug("Progress callback failed", exc_info=True)
189198

190199
if limit is not None and len(collected) >= limit:
191200
return collected[:limit]

limitless_tools/services/lifelog_service.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import hashlib
44
import json
55
import logging
6+
from collections.abc import Callable
67
from dataclasses import dataclass
78
from pathlib import Path
89

@@ -21,6 +22,24 @@ def _load_json(path: Path) -> object | None:
2122
log.debug("Failed to read JSON from %s: %s", path, exc)
2223
return None
2324

25+
@dataclass
26+
class SaveReport:
27+
created: int = 0
28+
updated: int = 0
29+
unchanged: int = 0
30+
31+
def record(self, status: str) -> None:
32+
if status == "created":
33+
self.created += 1
34+
elif status == "updated":
35+
self.updated += 1
36+
else:
37+
self.unchanged += 1
38+
39+
@property
40+
def total(self) -> int:
41+
return self.created + self.updated + self.unchanged
42+
2443

2544
@dataclass
2645
class LifelogService:
@@ -30,6 +49,7 @@ class LifelogService:
3049
client: LimitlessClient | None = None
3150
repo: JsonFileRepository | None = None
3251
http_timeout: float | None = None
52+
last_report: SaveReport | None = None
3353

3454
def fetch(
3555
self,
@@ -44,6 +64,7 @@ def fetch(
4464
timezone: str | None = None,
4565
is_starred: bool | None = None,
4666
batch_size: int = 50,
67+
progress_callback: Callable[[int, int], None] | None = None,
4768
) -> list[str]:
4869
"""Fetch lifelogs from API and save them to JSON files. Returns saved file paths."""
4970

@@ -65,12 +86,17 @@ def fetch(
6586
timezone=timezone,
6687
is_starred=is_starred,
6788
batch_size=batch_size,
89+
progress_callback=progress_callback,
6890
)
6991

92+
report = SaveReport()
7093
saved_paths: list[str] = []
7194
for item in lifelogs:
72-
saved_paths.append(repo.save_lifelog(item))
95+
save_result = repo.save_lifelog(item)
96+
saved_paths.append(save_result.path)
97+
report.record(save_result.status)
7398

99+
self.last_report = report
74100
return saved_paths
75101

76102
def sync(
@@ -82,6 +108,7 @@ def sync(
82108
timezone: str | None = None,
83109
is_starred: bool | None = None,
84110
batch_size: int = 50,
111+
progress_callback: Callable[[int, int], None] | None = None,
85112
) -> list[str]:
86113
client = self.client or LimitlessClient(
87114
api_key=self.api_key or "",
@@ -121,13 +148,16 @@ def sync(
121148
is_starred=is_starred,
122149
batch_size=batch_size,
123150
cursor=(sig_state.get("lastCursor") or st.get("lastCursor")) if not any([date, start, end]) else None,
151+
progress_callback=progress_callback,
124152
)
125153

154+
report = SaveReport()
126155
saved_paths: list[str] = []
127156
index_rows: list[dict[str, str | bool | None]] = []
128157
for ll in lifelogs:
129-
p = repo.save_lifelog(ll)
130-
saved_paths.append(p)
158+
save_result = repo.save_lifelog(ll)
159+
saved_paths.append(save_result.path)
160+
report.record(save_result.status)
131161
index_rows.append(
132162
{
133163
"id": ll.get("id"),
@@ -136,7 +166,7 @@ def sync(
136166
"endTime": ll.get("endTime"),
137167
"isStarred": ll.get("isStarred"),
138168
"updatedAt": ll.get("updatedAt"),
139-
"path": p,
169+
"path": save_result.path,
140170
}
141171
)
142172

@@ -175,6 +205,7 @@ def sync(
175205
st["signatures"] = signatures
176206
state_repo.save(st)
177207

208+
self.last_report = report
178209
return saved_paths
179210

180211
def list_local(

limitless_tools/storage/json_repo.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
from __future__ import annotations
22

33
import json
4+
from dataclasses import dataclass
45
from pathlib import Path
5-
from typing import Any
6+
from typing import Any, Literal
7+
8+
9+
@dataclass
10+
class SaveResult:
11+
path: str
12+
status: Literal["created", "updated", "unchanged"]
613

714

815
class JsonFileRepository:
@@ -16,8 +23,22 @@ def path_for_lifelog(self, lifelog: dict[str, Any]) -> str:
1623
file_path = dir_path / f"lifelog_{lifelog.get('id')}.json"
1724
return str(file_path)
1825

19-
def save_lifelog(self, lifelog: dict[str, Any]) -> str:
26+
def save_lifelog(self, lifelog: dict[str, Any]) -> SaveResult:
2027
path = Path(self.path_for_lifelog(lifelog))
2128
path.parent.mkdir(parents=True, exist_ok=True)
22-
path.write_text(json.dumps(lifelog, ensure_ascii=False, indent=2))
23-
return str(path)
29+
serialized = json.dumps(lifelog, ensure_ascii=False, indent=2)
30+
status: Literal["created", "updated", "unchanged"]
31+
if path.exists():
32+
try:
33+
existing = json.loads(path.read_text())
34+
except (json.JSONDecodeError, OSError):
35+
existing = None
36+
if existing == lifelog:
37+
status = "unchanged"
38+
else:
39+
status = "updated"
40+
else:
41+
status = "created"
42+
if status != "unchanged":
43+
path.write_text(serialized)
44+
return SaveResult(str(path), status)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
CLI progress reporter tests ensure user-visible output occurs during long operations.
3+
Single assert per test using capsys.
4+
"""
5+
6+
from pathlib import Path
7+
8+
9+
def test_fetch_command_emits_progress_and_summary(monkeypatch, tmp_path: Path, capsys):
10+
from limitless_tools.cli import main as cli_main
11+
from limitless_tools.services.lifelog_service import SaveReport
12+
13+
class FakeService:
14+
def __init__(self, *_, **__):
15+
self.last_report = SaveReport(created=1, updated=1, unchanged=0)
16+
17+
def fetch(self, **kwargs):
18+
progress_callback = kwargs.get("progress_callback")
19+
if callable(progress_callback):
20+
progress_callback(1, 1)
21+
progress_callback(2, 2)
22+
return [str(tmp_path / "lifelog_a.json"), str(tmp_path / "lifelog_b.json")]
23+
24+
monkeypatch.setattr(cli_main, "LifelogService", FakeService)
25+
monkeypatch.setattr(cli_main, "load_env", lambda: None)
26+
monkeypatch.delenv("LIMITLESS_DATA_DIR", raising=False)
27+
28+
code = cli_main.main([
29+
"fetch",
30+
"--limit",
31+
"2",
32+
"--data-dir",
33+
str(tmp_path),
34+
])
35+
stderr = capsys.readouterr().err
36+
assert code == 0 and "Fetch started" in stderr and "Fetch complete" in stderr
37+
38+
39+
def test_sync_reports_no_changes(monkeypatch, tmp_path: Path, capsys):
40+
from limitless_tools.cli import main as cli_main
41+
from limitless_tools.services.lifelog_service import SaveReport
42+
43+
class FakeService:
44+
def __init__(self, *_, **__):
45+
self.last_report = SaveReport(created=0, updated=0, unchanged=3)
46+
47+
def sync(self, **kwargs):
48+
progress_callback = kwargs.get("progress_callback")
49+
if callable(progress_callback):
50+
progress_callback(1, 0)
51+
return []
52+
53+
monkeypatch.setattr(cli_main, "LifelogService", FakeService)
54+
monkeypatch.setattr(cli_main, "load_env", lambda: None)
55+
monkeypatch.delenv("LIMITLESS_DATA_DIR", raising=False)
56+
57+
code = cli_main.main([
58+
"sync",
59+
"--start",
60+
"2025-01-01",
61+
"--end",
62+
"2025-01-02",
63+
"--data-dir",
64+
str(tmp_path),
65+
])
66+
stderr = capsys.readouterr().err
67+
assert code == 0 and "no changes" in stderr

0 commit comments

Comments
 (0)