Skip to content

Commit dbd804c

Browse files
CompactAIOfficialCTO Agentclaudearmand0e
authored
feat: show a tqdm progress bar during anonymization (#26)
anonymize_path accepts an optional per-file progress callback; the CLI wires it to a tqdm bar (dynamic_ncols, so it resizes with the terminal) for both teich extract and teich anonymize, showing files processed and running counts of scrubbed keys, emails, and usernames plus the current file name. tqdm was already a transitive dependency via huggingface_hub; it is now declared explicitly. Co-authored-by: CTO Agent <cto@paperclip.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Arman Rafiee <aran.rafiee@gmail.com>
1 parent 1d7ba7c commit dbd804c

6 files changed

Lines changed: 153 additions & 22 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"rich>=13.0",
2121
"datasets>=2.19.0",
2222
"huggingface_hub>=0.23.0",
23+
"tqdm>=4.66",
2324
"fastapi>=0.110",
2425
"uvicorn>=0.29",
2526
"websockets>=12",

src/teich/anonymize.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import string
1414
import sys
1515
from tempfile import NamedTemporaryFile
16-
from typing import Any
16+
from typing import Any, Callable
17+
18+
# Called after each file with (file_report, files_done, files_total). The total
19+
# is unknown while extraction is discovering and writing traces inline.
20+
AnonymizeProgress = Callable[["AnonymizeFileReport", int, int | None], None]
1721

1822

1923
TEXT_EXTENSIONS = {
@@ -70,7 +74,13 @@ def totals(self) -> dict[str, int]:
7074
return totals
7175

7276

73-
def anonymize_path(input_path: Path, output_path: Path, *, in_place: bool = False) -> AnonymizeReport:
77+
def anonymize_path(
78+
input_path: Path,
79+
output_path: Path,
80+
*,
81+
in_place: bool = False,
82+
progress: AnonymizeProgress | None = None,
83+
) -> AnonymizeReport:
7484
"""Anonymize trace files under input_path."""
7585
input_path = input_path.expanduser()
7686
output_path = output_path.expanduser()
@@ -86,6 +96,8 @@ def anonymize_path(input_path: Path, output_path: Path, *, in_place: bool = Fals
8696
destination = output_path / input_path.name if output_path.exists() and output_path.is_dir() else output_path
8797
file_report = anonymize_file(input_path, destination)
8898
report.files.append(file_report)
99+
if progress is not None:
100+
progress(file_report, 1, 1)
89101
return report
90102

91103
source_files = sorted(path for path in input_path.rglob("*") if path.is_file())
@@ -95,10 +107,16 @@ def anonymize_path(input_path: Path, output_path: Path, *, in_place: bool = Fals
95107
# Each file is anonymized independently (fresh TraceAnonymizer per
96108
# file), so files can be processed in parallel safely.
97109
with ProcessPoolExecutor(max_workers=workers) as executor:
98-
report.files.extend(executor.map(anonymize_file, source_files, destinations))
110+
for file_report in executor.map(anonymize_file, source_files, destinations):
111+
report.files.append(file_report)
112+
if progress is not None:
113+
progress(file_report, len(report.files), len(source_files))
99114
else:
100115
for source_file, destination in zip(source_files, destinations):
101-
report.files.append(anonymize_file(source_file, destination))
116+
file_report = anonymize_file(source_file, destination)
117+
report.files.append(file_report)
118+
if progress is not None:
119+
progress(file_report, len(report.files), len(source_files))
102120
return report
103121

104122

src/teich/cli.py

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

33
from __future__ import annotations
44

5+
from contextlib import contextmanager, nullcontext
56
from datetime import datetime, timezone
67
import json
78
from pathlib import Path
@@ -17,6 +18,8 @@
1718
from rich.table import Table
1819
from typer.core import TyperCommand, TyperGroup
1920

21+
from tqdm import tqdm
22+
2023
from .anonymize import anonymize_path
2124
from .config import CLAUDE_PROVIDER_ALIASES, Config
2225
from .converter import convert_traces_to_training_data
@@ -195,6 +198,35 @@ def _upload_ignore_patterns(cfg: Config) -> list[str]:
195198
return patterns
196199

197200

201+
@contextmanager
202+
def _anonymize_progress_bar():
203+
"""Yield a callback that updates a live file and replacement-count bar."""
204+
totals = {"api_key": 0, "email": 0, "username": 0}
205+
with tqdm(desc="Anonymizing", unit="file", dynamic_ncols=True, leave=False) as bar:
206+
207+
def on_file(file_report, done: int, total: int | None) -> None:
208+
if total is not None:
209+
bar.total = total
210+
for key, count in file_report.replacements.items():
211+
totals[key] = totals.get(key, 0) + count
212+
bar.set_postfix(
213+
keys=totals["api_key"],
214+
emails=totals["email"],
215+
users=totals["username"],
216+
file=file_report.path.name,
217+
refresh=False,
218+
)
219+
bar.update(max(0, done - bar.n))
220+
221+
yield on_file
222+
223+
224+
def _anonymize_with_progress(input_path: Path, output_path: Path, *, in_place: bool):
225+
"""Run anonymize_path with a live tqdm bar showing files and scrub counts."""
226+
with _anonymize_progress_bar() as progress:
227+
return anonymize_path(input_path, output_path, in_place=in_place, progress=progress)
228+
229+
198230
def _has_non_empty_trace_outputs(traces_dir: Path) -> bool:
199231
if not traces_dir.exists():
200232
return False
@@ -446,14 +478,17 @@ def _run_extract_command(
446478
console.print(Panel.fit("Teich Extract", style="bold blue"))
447479
if provider == "cursor":
448480
console.print(f"[yellow]{CURSOR_EXTRACTION_NOTICE}[/yellow]", soft_wrap=True)
449-
result = extract_local_sessions(
450-
provider,
451-
output_dir=output,
452-
sources=sessions_dir,
453-
model_filter=model_filter,
454-
clear_destination=True,
455-
anonymize=not skip_anonymize,
456-
)
481+
progress_context = nullcontext(None) if skip_anonymize else _anonymize_progress_bar()
482+
with progress_context as anonymize_progress:
483+
result = extract_local_sessions(
484+
provider,
485+
output_dir=output,
486+
sources=sessions_dir,
487+
model_filter=model_filter,
488+
clear_destination=True,
489+
anonymize=not skip_anonymize,
490+
anonymize_progress=anonymize_progress,
491+
)
457492
if not result.source_paths:
458493
console.print(f"[red]No local {provider} session folders found.[/red]")
459494
console.print("[yellow]Pass one or more explicit folders with --sessions-dir.[/yellow]")
@@ -558,7 +593,7 @@ def anonymize(
558593
) -> None:
559594
"""Replace emails, home-directory usernames, and API keys with deterministic dummy values."""
560595
try:
561-
report = anonymize_path(input_path, output, in_place=in_place)
596+
report = _anonymize_with_progress(input_path, output, in_place=in_place)
562597
except (FileNotFoundError, ValueError) as exc:
563598
console.print(f"[red]{exc}[/red]")
564599
raise typer.Exit(1)

src/teich/extract.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from tempfile import TemporaryDirectory
1616
from typing import Any, Literal
1717

18-
from .anonymize import TraceAnonymizer, anonymize_file
18+
from .anonymize import AnonymizeFileReport, AnonymizeProgress, TraceAnonymizer, anonymize_file
1919
from .tool_schema import CURSOR_BUILTIN_TOOLS
2020

2121
ExtractProvider = Literal["claude", "codex", "cursor", "hermes", "pi"]
@@ -50,22 +50,32 @@ def count(self) -> int:
5050
class _InlineAnonymizer:
5151
"""Anonymize traces as they are written, one TraceAnonymizer per file."""
5252

53-
def __init__(self) -> None:
53+
def __init__(self, progress: AnonymizeProgress | None = None) -> None:
5454
self.totals: dict[str, int] = {"email": 0, "username": 0, "api_key": 0}
55+
self.progress = progress
56+
self.files_done = 0
5557

5658
def _add(self, counts: dict[str, int]) -> None:
5759
for key, count in counts.items():
5860
self.totals[key] = self.totals.get(key, 0) + count
5961

60-
def copy_file(self, source: Path, destination: Path) -> None:
61-
report = anonymize_file(source, destination)
62+
def _record(self, report: AnonymizeFileReport) -> None:
6263
self._add(report.replacements)
64+
self.files_done += 1
65+
if self.progress is not None:
66+
self.progress(report, self.files_done, None)
67+
68+
def copy_file(self, source: Path, destination: Path) -> None:
69+
self._record(anonymize_file(source, destination))
6370

64-
def anonymize_events(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
71+
def anonymize_events(self, events: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, int]]:
6572
anonymizer = TraceAnonymizer()
6673
events = [anonymizer.anonymize_value(event) for event in events]
67-
self._add(anonymizer.counts)
68-
return events
74+
replacements = {key: value for key, value in anonymizer.counts.items() if value}
75+
return events, replacements
76+
77+
def record_events_file(self, path: Path, replacements: dict[str, int]) -> None:
78+
self._record(AnonymizeFileReport(path=path, output_path=path, replacements=replacements))
6979

7080
def anonymize_remaining_files(self, root: Path, excluded: Iterable[Path]) -> None:
7181
"""Scrub pre-existing output files without re-reading newly written traces."""
@@ -125,6 +135,7 @@ def extract_local_sessions(
125135
clear_destination: bool = False,
126136
progress: ProgressCallback | None = None,
127137
anonymize: bool = False,
138+
anonymize_progress: AnonymizeProgress | None = None,
128139
) -> ExtractResult:
129140
"""Extract local sessions for provider into output_dir."""
130141
source_candidates = list(sources) if sources is not None else default_session_sources(provider, home)
@@ -133,7 +144,7 @@ def extract_local_sessions(
133144
destination_dir.mkdir(parents=True, exist_ok=True)
134145
if clear_destination:
135146
_clear_extract_destination(destination_dir)
136-
anonymizer = _InlineAnonymizer() if anonymize else None
147+
anonymizer = _InlineAnonymizer(anonymize_progress) if anonymize else None
137148
if provider == "hermes":
138149
copied_files = _extract_hermes_state_dbs(
139150
resolved_sources, destination_dir, model_filter=model_filter, anonymizer=anonymizer
@@ -574,14 +585,17 @@ def _write_jsonl_dict_events(
574585
events: list[dict[str, Any]],
575586
anonymizer: _InlineAnonymizer | None = None,
576587
) -> None:
588+
replacements: dict[str, int] = {}
577589
if anonymizer is not None:
578-
events = anonymizer.anonymize_events(events)
590+
events, replacements = anonymizer.anonymize_events(events)
579591
path.parent.mkdir(parents=True, exist_ok=True)
580592
with path.open("w", encoding="utf-8") as handle:
581593
for event in events:
582594
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
583595
line = line.replace("\u0085", "\\u0085").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
584596
handle.write(line + "\n")
597+
if anonymizer is not None:
598+
anonymizer.record_events_file(path, replacements)
585599

586600

587601
def _write_individual_jsonl_rows(

tests/test_extract_anonymize_cli.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,16 +1242,32 @@ def test_extract_inline_anonymization_scrubs_traces_and_leftover_text(tmp_path:
12421242
stale_text.write_text(f"alice@example.com {original_key}\n", encoding="utf-8")
12431243
stale_binary = output_dir / "archive.bin"
12441244
stale_binary.write_bytes(b"alice@example.com sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ123456")
1245+
progress_updates: list[tuple[str, int, int | None, dict[str, int]]] = []
12451246

12461247
result = extract_local_sessions(
12471248
"codex",
12481249
output_dir=output_dir,
12491250
sources=[sessions_dir],
12501251
clear_destination=True,
12511252
anonymize=True,
1253+
anonymize_progress=lambda report, done, total: progress_updates.append(
1254+
(report.path.name, done, total, dict(report.replacements))
1255+
),
12521256
)
12531257

12541258
assert result.anonymize_totals == {"email": 3, "username": 2, "api_key": 3}
1259+
assert [update[0] for update in progress_updates] == [
1260+
"session-0.jsonl",
1261+
"session-1.jsonl",
1262+
"archive.bin",
1263+
"notes.txt",
1264+
]
1265+
assert [update[1] for update in progress_updates] == [1, 2, 3, 4]
1266+
assert all(update[2] is None for update in progress_updates)
1267+
assert {
1268+
key: sum(update[3].get(key, 0) for update in progress_updates)
1269+
for key in ("email", "username", "api_key")
1270+
} == result.anonymize_totals
12551271
for trace in result.copied_files:
12561272
text = trace.read_text(encoding="utf-8")
12571273
assert "/home/user1/project" in text
@@ -1712,6 +1728,51 @@ def test_anonymize_parallel_worker_count_caps_windows(monkeypatch):
17121728
assert anonymize_module._process_worker_count(100) == 61
17131729

17141730

1731+
def test_anonymize_path_progress_preserves_parallel_report_order(tmp_path: Path):
1732+
input_dir = tmp_path / "input"
1733+
input_dir.mkdir()
1734+
for index in reversed(range(9)):
1735+
(input_dir / f"trace-{index}.jsonl").write_text(
1736+
json.dumps({"message": f"user{index}@example.com"}) + "\n",
1737+
encoding="utf-8",
1738+
)
1739+
updates = []
1740+
1741+
report = anonymize_module.anonymize_path(
1742+
input_dir,
1743+
tmp_path / "output",
1744+
progress=lambda file_report, done, total: updates.append((file_report, done, total)),
1745+
)
1746+
1747+
assert [update[0].path.name for update in updates] == [f"trace-{index}.jsonl" for index in range(9)]
1748+
assert [update[1] for update in updates] == list(range(1, 10))
1749+
assert [update[2] for update in updates] == [9] * 9
1750+
assert sum(update[0].replacements.get("email", 0) for update in updates) == report.totals["email"] == 9
1751+
1752+
1753+
def test_anonymize_cli_updates_tqdm_progress(tmp_path: Path):
1754+
source = tmp_path / "trace.jsonl"
1755+
source.write_text(json.dumps({"message": "alice@example.com"}) + "\n", encoding="utf-8")
1756+
bar = MagicMock()
1757+
bar.n = 0
1758+
1759+
with patch("teich.cli.tqdm") as mock_tqdm:
1760+
mock_tqdm.return_value.__enter__.return_value = bar
1761+
result = runner.invoke(app, ["anonymize", str(source), "--output", str(tmp_path / "output.jsonl")])
1762+
1763+
assert result.exit_code == 0, result.output
1764+
mock_tqdm.assert_called_once_with(desc="Anonymizing", unit="file", dynamic_ncols=True, leave=False)
1765+
assert bar.total == 1
1766+
bar.update.assert_called_once_with(1)
1767+
bar.set_postfix.assert_called_once_with(
1768+
keys=0,
1769+
emails=1,
1770+
users=0,
1771+
file="trace.jsonl",
1772+
refresh=False,
1773+
)
1774+
1775+
17151776
def test_anonymize_jsonl_preserves_valid_json_after_escaped_path_replacements(tmp_path: Path):
17161777
input_dir = tmp_path / "input"
17171778
input_dir.mkdir()

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)