cctrack 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {cctrack-0.4.0 → cctrack-0.4.1}/PKG-INFO +5 -2
- {cctrack-0.4.0 → cctrack-0.4.1}/README.md +4 -1
- {cctrack-0.4.0 → cctrack-0.4.1}/pyproject.toml +1 -1
- {cctrack-0.4.0 → cctrack-0.4.1}/src/cctrack/__init__.py +157 -59
- {cctrack-0.4.0 → cctrack-0.4.1}/tests/test_hook_reporting.py +101 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/.github/workflows/ci.yml +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/.github/workflows/publish.yml +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/.gitignore +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/CLAUDE.md +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/LICENSE +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/src/cctrack/__main__.py +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/tests/test_cctrack.py +0 -0
- {cctrack-0.4.0 → cctrack-0.4.1}/tests/test_hook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cctrack
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
|
|
5
5
|
Project-URL: Homepage, https://github.com/rappdw/cctrack
|
|
6
6
|
Project-URL: Repository, https://github.com/rappdw/cctrack
|
|
@@ -86,11 +86,14 @@ Daily breakdown:
|
|
|
86
86
|
|
|
87
87
|
## Remote aggregation
|
|
88
88
|
|
|
89
|
-
With `--remote`, cctrack SSHs to each host
|
|
89
|
+
With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
# Aggregate this machine + DGX server + laptop
|
|
93
93
|
cctrack --remote dgx macbook-air
|
|
94
|
+
|
|
95
|
+
# For accurate aggregate costs, install the hook on each host too
|
|
96
|
+
cctrack --install-hook --remote dgx macbook-air
|
|
94
97
|
```
|
|
95
98
|
|
|
96
99
|
## Rate card
|
|
@@ -61,11 +61,14 @@ Daily breakdown:
|
|
|
61
61
|
|
|
62
62
|
## Remote aggregation
|
|
63
63
|
|
|
64
|
-
With `--remote`, cctrack SSHs to each host
|
|
64
|
+
With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
# Aggregate this machine + DGX server + laptop
|
|
68
68
|
cctrack --remote dgx macbook-air
|
|
69
|
+
|
|
70
|
+
# For accurate aggregate costs, install the hook on each host too
|
|
71
|
+
cctrack --install-hook --remote dgx macbook-air
|
|
69
72
|
```
|
|
70
73
|
|
|
71
74
|
## Rate card
|
|
@@ -126,6 +126,84 @@ def parse_remote_events(host: str) -> list[dict]:
|
|
|
126
126
|
return all_events
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
HOOK_FILE_SEP = "___CCTRACK_HOOK_FILE___"
|
|
130
|
+
MONTHLY_FILE_SEP = "___CCTRACK_MONTHLY_FILE___"
|
|
131
|
+
|
|
132
|
+
# Hook data locations on remote hosts: the host's own cctrack dir plus any
|
|
133
|
+
# Sandy sandbox equivalents (mirrors discover_hook_data_dirs).
|
|
134
|
+
REMOTE_HOOK_DIRS = "~/.claude/cctrack ~/.sandy/sandboxes"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fetch_remote_hook_data(host: str) -> str:
|
|
138
|
+
"""SSH to host and cat all statusline JSONL + monthly summary files.
|
|
139
|
+
Returns raw output with file-type separators."""
|
|
140
|
+
remote_cmd = (
|
|
141
|
+
f"find {REMOTE_HOOK_DIRS} -name 'statusline*.jsonl' "
|
|
142
|
+
f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
143
|
+
f"find {REMOTE_HOOK_DIRS} -name 'monthly-*.json' "
|
|
144
|
+
f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
145
|
+
f"true"
|
|
146
|
+
)
|
|
147
|
+
cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
|
|
148
|
+
try:
|
|
149
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
150
|
+
if result.returncode != 0 and not result.stdout:
|
|
151
|
+
print(f" Warning: SSH to {host} failed: {result.stderr.strip()}", file=sys.stderr)
|
|
152
|
+
return ""
|
|
153
|
+
return result.stdout
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
print(f" Warning: SSH to {host} timed out", file=sys.stderr)
|
|
156
|
+
return ""
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
print(f" Warning: ssh command not found", file=sys.stderr)
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_remote_hook_data(host: str) -> tuple[list[dict], dict]:
|
|
163
|
+
"""Fetch and parse hook data from a remote host.
|
|
164
|
+
Returns (hook_events, monthly_summaries) in the same shapes as
|
|
165
|
+
parse_hook_events and load_monthly_hook_summaries."""
|
|
166
|
+
raw = fetch_remote_hook_data(host)
|
|
167
|
+
if not raw:
|
|
168
|
+
return [], {}
|
|
169
|
+
|
|
170
|
+
# Statusline chunks come first, monthly chunks after (find order above).
|
|
171
|
+
head, _, monthly_raw = raw.partition(MONTHLY_FILE_SEP)
|
|
172
|
+
|
|
173
|
+
lines: list[str] = []
|
|
174
|
+
for chunk in head.split(HOOK_FILE_SEP):
|
|
175
|
+
lines.extend(chunk.splitlines())
|
|
176
|
+
events = _hook_events_from_lines(lines)
|
|
177
|
+
|
|
178
|
+
monthly: dict[str, dict] = {}
|
|
179
|
+
for chunk in monthly_raw.split(MONTHLY_FILE_SEP):
|
|
180
|
+
chunk = chunk.strip()
|
|
181
|
+
if not chunk:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(chunk)
|
|
185
|
+
except (json.JSONDecodeError, ValueError):
|
|
186
|
+
continue
|
|
187
|
+
for date_str, day_data in data.get("days", {}).items():
|
|
188
|
+
entry = {
|
|
189
|
+
"cost": float(day_data.get("cost", 0.0)),
|
|
190
|
+
"sessions": int(day_data.get("sessions", 0)),
|
|
191
|
+
}
|
|
192
|
+
merge_monthly_summaries(monthly, {date_str: entry})
|
|
193
|
+
return events, monthly
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def merge_monthly_summaries(target: dict, source: dict) -> None:
|
|
197
|
+
"""Merge per-date {cost, sessions} summaries from source into target,
|
|
198
|
+
summing where dates overlap (e.g. same day on multiple hosts)."""
|
|
199
|
+
for date_str, data in source.items():
|
|
200
|
+
if date_str in target:
|
|
201
|
+
target[date_str]["cost"] += data["cost"]
|
|
202
|
+
target[date_str]["sessions"] += data["sessions"]
|
|
203
|
+
else:
|
|
204
|
+
target[date_str] = dict(data)
|
|
205
|
+
|
|
206
|
+
|
|
129
207
|
# ── Hook infrastructure ───────────────────────────────────────────────
|
|
130
208
|
|
|
131
209
|
HOOK_SCRIPT = """\
|
|
@@ -485,59 +563,42 @@ def parse_events(files: list[str]) -> list[dict]:
|
|
|
485
563
|
|
|
486
564
|
# ── Hook data parsing ──────────────────────────────────────────────────
|
|
487
565
|
|
|
488
|
-
def
|
|
489
|
-
"""Parse statusline
|
|
566
|
+
def _hook_events_from_lines(lines) -> list[dict]:
|
|
567
|
+
"""Parse statusline JSONL lines. For each (session_id, date) pair, keeps the
|
|
490
568
|
entry with the latest _ts — the session's cumulative values as of end-of-day
|
|
491
|
-
for that date.
|
|
492
|
-
|
|
493
|
-
cache_read, cache_write, model}."""
|
|
494
|
-
if cctrack_dir is None:
|
|
495
|
-
cctrack_dir = CCTRACK_DIR
|
|
496
|
-
if not cctrack_dir.exists():
|
|
497
|
-
return []
|
|
498
|
-
|
|
569
|
+
for that date. Returns list of {date_str, session_id, cost_usd, input_tokens,
|
|
570
|
+
output_tokens, cache_read, cache_write, model}."""
|
|
499
571
|
by_session_day: dict[tuple[str, str], dict] = {}
|
|
500
572
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
for filepath in files:
|
|
573
|
+
for line in lines:
|
|
574
|
+
line = line.strip()
|
|
575
|
+
if not line:
|
|
576
|
+
continue
|
|
507
577
|
try:
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if not line:
|
|
512
|
-
continue
|
|
513
|
-
try:
|
|
514
|
-
entry = json.loads(line)
|
|
515
|
-
except (json.JSONDecodeError, ValueError):
|
|
516
|
-
continue
|
|
578
|
+
entry = json.loads(line)
|
|
579
|
+
except (json.JSONDecodeError, ValueError):
|
|
580
|
+
continue
|
|
517
581
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
582
|
+
session_id = entry.get("session_id", "")
|
|
583
|
+
if not session_id:
|
|
584
|
+
continue
|
|
521
585
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
586
|
+
ts = entry.get("_ts", "")
|
|
587
|
+
if not ts:
|
|
588
|
+
continue
|
|
525
589
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
590
|
+
try:
|
|
591
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
592
|
+
except (ValueError, AttributeError):
|
|
593
|
+
continue
|
|
594
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
531
595
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
596
|
+
key = (session_id, date_str)
|
|
597
|
+
if key in by_session_day:
|
|
598
|
+
if ts <= by_session_day[key].get("_ts", ""):
|
|
599
|
+
continue
|
|
536
600
|
|
|
537
|
-
|
|
538
|
-
by_session_day[key] = entry
|
|
539
|
-
except (OSError, IOError):
|
|
540
|
-
continue
|
|
601
|
+
by_session_day[key] = entry
|
|
541
602
|
|
|
542
603
|
results = []
|
|
543
604
|
for (session_id, date_str), entry in by_session_day.items():
|
|
@@ -555,6 +616,31 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
|
555
616
|
return results
|
|
556
617
|
|
|
557
618
|
|
|
619
|
+
def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
620
|
+
"""Parse statusline-*.jsonl files from a local hook data directory.
|
|
621
|
+
Also reads legacy statusline.jsonl for migration. See _hook_events_from_lines
|
|
622
|
+
for the per-(session, date) dedup semantics."""
|
|
623
|
+
if cctrack_dir is None:
|
|
624
|
+
cctrack_dir = CCTRACK_DIR
|
|
625
|
+
if not cctrack_dir.exists():
|
|
626
|
+
return []
|
|
627
|
+
|
|
628
|
+
files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
|
|
629
|
+
legacy = cctrack_dir / "statusline.jsonl"
|
|
630
|
+
if legacy.exists():
|
|
631
|
+
files.append(legacy)
|
|
632
|
+
|
|
633
|
+
lines: list[str] = []
|
|
634
|
+
for filepath in files:
|
|
635
|
+
try:
|
|
636
|
+
with open(filepath, "r", errors="replace") as f:
|
|
637
|
+
lines.extend(f.readlines())
|
|
638
|
+
except (OSError, IOError):
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
return _hook_events_from_lines(lines)
|
|
642
|
+
|
|
643
|
+
|
|
558
644
|
def compute_hook_deltas(events: list[dict]) -> list[dict]:
|
|
559
645
|
"""Convert per-(session, date) cumulative records into per-(session, date)
|
|
560
646
|
daily deltas. For each session, sorts its records by date and computes
|
|
@@ -1138,21 +1224,33 @@ def main():
|
|
|
1138
1224
|
except (ValueError, AttributeError):
|
|
1139
1225
|
pass
|
|
1140
1226
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1227
|
+
all_hook_events: list[dict] = []
|
|
1228
|
+
all_monthly: dict[str, dict] = {}
|
|
1229
|
+
|
|
1230
|
+
for hdir in discover_hook_data_dirs():
|
|
1231
|
+
rollup_old_hook_files(hdir)
|
|
1232
|
+
all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
|
|
1233
|
+
merge_monthly_summaries(all_monthly, load_monthly_hook_summaries(hdir))
|
|
1234
|
+
|
|
1235
|
+
# Remote hook data: each host's deltas are computed independently so a
|
|
1236
|
+
# session's cumulative history never mixes across hosts.
|
|
1237
|
+
if args.remote:
|
|
1238
|
+
for host in args.remote:
|
|
1239
|
+
remote_hook_events, remote_monthly = parse_remote_hook_data(host)
|
|
1240
|
+
all_hook_events.extend(compute_hook_deltas(remote_hook_events))
|
|
1241
|
+
merge_monthly_summaries(all_monthly, remote_monthly)
|
|
1242
|
+
|
|
1243
|
+
if all_hook_events or all_monthly:
|
|
1244
|
+
hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
|
|
1245
|
+
|
|
1246
|
+
# Hook coverage may start earlier than the local install date (e.g. a
|
|
1247
|
+
# remote host installed the hook first). Treat the earliest observed
|
|
1248
|
+
# hook date as the effective install date so those days count as
|
|
1249
|
+
# authoritative rather than being scaled by the accuracy factor.
|
|
1250
|
+
if hook_daily:
|
|
1251
|
+
earliest_hook_date = min(hook_daily.keys())
|
|
1252
|
+
if hook_install_date is None or earliest_hook_date < hook_install_date:
|
|
1253
|
+
hook_install_date = earliest_hook_date
|
|
1156
1254
|
|
|
1157
1255
|
# Suggest hook if not installed
|
|
1158
1256
|
if not is_hook_installed():
|
|
@@ -259,6 +259,107 @@ def test_compute_hook_deltas_multiple_sessions_independent():
|
|
|
259
259
|
assert by_key[("2026-04-15", "sB")] == 4.0
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
# ── Remote hook data tests ─────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def _remote_raw(hook_chunks: list[list[dict]], monthly_chunks: list[dict]) -> str:
|
|
265
|
+
"""Build a raw SSH output stream the way fetch_remote_hook_data returns it."""
|
|
266
|
+
parts = []
|
|
267
|
+
for entries in hook_chunks:
|
|
268
|
+
parts.append(cctrack.HOOK_FILE_SEP)
|
|
269
|
+
for e in entries:
|
|
270
|
+
parts.append(json.dumps(e))
|
|
271
|
+
for doc in monthly_chunks:
|
|
272
|
+
parts.append(cctrack.MONTHLY_FILE_SEP)
|
|
273
|
+
parts.append(json.dumps(doc))
|
|
274
|
+
return "\n".join(parts) + "\n"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_parse_remote_hook_data_basic():
|
|
278
|
+
"""Statusline entries and monthly summaries are both parsed from the stream."""
|
|
279
|
+
raw = _remote_raw(
|
|
280
|
+
hook_chunks=[
|
|
281
|
+
[make_hook_entry(session_id="r1", ts="2026-04-15T10:00:00+00:00", cost_usd=3.00)],
|
|
282
|
+
[make_hook_entry(session_id="r2", ts="2026-04-16T10:00:00+00:00", cost_usd=7.00)],
|
|
283
|
+
],
|
|
284
|
+
monthly_chunks=[
|
|
285
|
+
{"month": "2026-01", "days": {"2026-01-10": {"cost": 10.0, "sessions": 2}}},
|
|
286
|
+
],
|
|
287
|
+
)
|
|
288
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
289
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
290
|
+
assert len(events) == 2
|
|
291
|
+
by_session = {e["session_id"]: e for e in events}
|
|
292
|
+
assert by_session["r1"]["cost_usd"] == 3.00
|
|
293
|
+
assert by_session["r2"]["date_str"] == "2026-04-16"
|
|
294
|
+
assert monthly == {"2026-01-10": {"cost": 10.0, "sessions": 2}}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_parse_remote_hook_data_dedup_across_files():
|
|
298
|
+
"""Same (session, date) in multiple remote files: latest _ts wins."""
|
|
299
|
+
raw = _remote_raw(
|
|
300
|
+
hook_chunks=[
|
|
301
|
+
[make_hook_entry(session_id="rA", ts="2026-04-15T10:00:00+00:00", cost_usd=2.00)],
|
|
302
|
+
[make_hook_entry(session_id="rA", ts="2026-04-15T18:00:00+00:00", cost_usd=6.00)],
|
|
303
|
+
],
|
|
304
|
+
monthly_chunks=[],
|
|
305
|
+
)
|
|
306
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
307
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
308
|
+
assert len(events) == 1
|
|
309
|
+
assert events[0]["cost_usd"] == 6.00
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_parse_remote_hook_data_empty():
|
|
313
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=""):
|
|
314
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
315
|
+
assert events == []
|
|
316
|
+
assert monthly == {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_parse_remote_hook_data_malformed_monthly_skipped():
|
|
320
|
+
raw = (cctrack.HOOK_FILE_SEP + "\n"
|
|
321
|
+
+ json.dumps(make_hook_entry(session_id="ok", cost_usd=1.0)) + "\n"
|
|
322
|
+
+ cctrack.MONTHLY_FILE_SEP + "\n"
|
|
323
|
+
+ "not json\n")
|
|
324
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
325
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
326
|
+
assert len(events) == 1
|
|
327
|
+
assert monthly == {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_fetch_remote_hook_data_timeout():
|
|
331
|
+
with patch("subprocess.run", side_effect=cctrack.subprocess.TimeoutExpired("ssh", 60)):
|
|
332
|
+
result = cctrack.fetch_remote_hook_data("fakehost")
|
|
333
|
+
assert result == ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_merge_monthly_summaries_sums_overlapping_dates():
|
|
337
|
+
"""Same date on two hosts sums cost and sessions."""
|
|
338
|
+
target = {"2026-01-10": {"cost": 10.0, "sessions": 2}}
|
|
339
|
+
cctrack.merge_monthly_summaries(target, {
|
|
340
|
+
"2026-01-10": {"cost": 5.0, "sessions": 1},
|
|
341
|
+
"2026-01-11": {"cost": 3.0, "sessions": 1},
|
|
342
|
+
})
|
|
343
|
+
assert target["2026-01-10"] == {"cost": 15.0, "sessions": 3}
|
|
344
|
+
assert target["2026-01-11"] == {"cost": 3.0, "sessions": 1}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_remote_and_local_hook_costs_sum_per_date():
|
|
348
|
+
"""Regression: a date covered by hooks on two hosts must sum both hosts'
|
|
349
|
+
costs, not keep just one host's value."""
|
|
350
|
+
local_events = cctrack.compute_hook_deltas([
|
|
351
|
+
{"date_str": "2026-06-11", "session_id": "local_s", "cost_usd": 9.05,
|
|
352
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
353
|
+
])
|
|
354
|
+
remote_events = cctrack.compute_hook_deltas([
|
|
355
|
+
{"date_str": "2026-06-11", "session_id": "air_s", "cost_usd": 340.90,
|
|
356
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
357
|
+
])
|
|
358
|
+
hook_daily = cctrack.aggregate_hook_data(local_events + remote_events)
|
|
359
|
+
assert abs(hook_daily["2026-06-11"]["cost"] - 349.95) < 0.001
|
|
360
|
+
assert hook_daily["2026-06-11"]["sessions"] == 2
|
|
361
|
+
|
|
362
|
+
|
|
262
363
|
# ── aggregate_hook_data tests ──────────────────────────────────────────
|
|
263
364
|
|
|
264
365
|
def test_aggregate_hook_data_single_day():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|