cs2df 3.0.0__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.
cs2df-3.0.0/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ *.js
4
+ *.d.ts
5
+ .DS_Store
6
+ .omc/
7
+ __pycache__/
8
+ *.py[cod]
9
+ .pytest_cache/
cs2df-3.0.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: cs2df
3
+ Version: 3.0.0
4
+ Summary: Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)
5
+ Project-URL: Homepage, https://github.com/Starfie1d1272/cs2-demo-format
6
+ Project-URL: Repository, https://github.com/Starfie1d1272/cs2-demo-format
7
+ Project-URL: Issues, https://github.com/Starfie1d1272/cs2-demo-format/issues
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: demoparser2>=0.41.2
11
+ Requires-Dist: jsonschema>=4.21
12
+ Requires-Dist: numpy>=1.26
13
+ Requires-Dist: orjson>=3.9
14
+ Requires-Dist: pandas>=2.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # cs2df
18
+
19
+ `cs2df` is the reference Python exporter and validator for the
20
+ [`cs2-demo-format`](https://github.com/Starfie1d1272/cs2-demo-format) v3 ZIP
21
+ contract.
22
+
23
+ It parses CS2 `.dem` files with
24
+ [`demoparser2`](https://github.com/LaihoE/demoparser), writes strict v3 ZIP
25
+ packages, and validates exported packages with schema plus package-level QA.
26
+
27
+ ## Setup
28
+
29
+ ```bash
30
+ uv sync
31
+ ```
32
+
33
+ The CLI entrypoint is `cs2df`.
34
+
35
+ ## Export
36
+
37
+ ```bash
38
+ # Standard profile: required files + shots.json + replay.json
39
+ uv run cs2df export match.dem
40
+
41
+ # Research profile: also emit full-tick duels.json windows
42
+ uv run cs2df export match.dem --research
43
+
44
+ # Choose an output path
45
+ uv run cs2df export match.dem -o match.zip
46
+ ```
47
+
48
+ The default ZIP compression level is `3`, chosen from local benchmark results as
49
+ a speed/size balance. Use a higher level when smaller ZIPs matter more:
50
+
51
+ ```bash
52
+ uv run cs2df export match.dem --compress-level 6
53
+ uv run cs2df export match.dem --compress-level 9
54
+ ```
55
+
56
+ ## Batch Export
57
+
58
+ ```bash
59
+ uv run cs2df export-batch ./demos --workers 8 --descriptive
60
+ ```
61
+
62
+ `export-batch` scans one directory non-recursively for `.dem` files and writes
63
+ one ZIP per demo. It also writes `report.json` next to the outputs with:
64
+
65
+ - per-demo success/failure status
66
+ - output ZIP size
67
+ - source demo size
68
+ - compression level
69
+ - total duration
70
+ - aggregate throughput
71
+ - parse/package/write stage timings
72
+
73
+ Bad demos are reported as failed rows; a single parser failure does not crash the
74
+ whole batch. Use `--fail-fast` when you want the batch to stop after the first
75
+ failed demo.
76
+
77
+ ## Validate
78
+
79
+ ```bash
80
+ uv run cs2df validate match.zip
81
+ uv run cs2df validate match.zip --strict
82
+ ```
83
+
84
+ Validation checks JSON Schema and package-level invariants such as cross-file
85
+ player indexes, round windows, column lengths, weapon dictionary indexes, and
86
+ formal-round consistency.
87
+
88
+ ## Role in the Repository
89
+
90
+ This is a reference implementation, not the contract itself. The authoritative
91
+ contract lives in [`../schemas/index.ts`](../schemas/index.ts) and
92
+ the generated JSON Schemas in the repository's `spec/` directory. Any producer
93
+ that emits a ZIP passing strict validation is conformant.
94
+
95
+ The exporter also serves as the performance baseline for the v3 format:
96
+ per-frame data stays in pandas DataFrames until the columnar stream builders
97
+ materialize compact integer arrays for JSON serialization.
98
+
99
+ Event-extraction logic was originally ported from `cs2-demo-analysis-kit`
100
+ (and before that `DrEAmSs59/CS2-insight-agent`, with the author's permission).
cs2df-3.0.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # cs2df
2
+
3
+ `cs2df` is the reference Python exporter and validator for the
4
+ [`cs2-demo-format`](https://github.com/Starfie1d1272/cs2-demo-format) v3 ZIP
5
+ contract.
6
+
7
+ It parses CS2 `.dem` files with
8
+ [`demoparser2`](https://github.com/LaihoE/demoparser), writes strict v3 ZIP
9
+ packages, and validates exported packages with schema plus package-level QA.
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ uv sync
15
+ ```
16
+
17
+ The CLI entrypoint is `cs2df`.
18
+
19
+ ## Export
20
+
21
+ ```bash
22
+ # Standard profile: required files + shots.json + replay.json
23
+ uv run cs2df export match.dem
24
+
25
+ # Research profile: also emit full-tick duels.json windows
26
+ uv run cs2df export match.dem --research
27
+
28
+ # Choose an output path
29
+ uv run cs2df export match.dem -o match.zip
30
+ ```
31
+
32
+ The default ZIP compression level is `3`, chosen from local benchmark results as
33
+ a speed/size balance. Use a higher level when smaller ZIPs matter more:
34
+
35
+ ```bash
36
+ uv run cs2df export match.dem --compress-level 6
37
+ uv run cs2df export match.dem --compress-level 9
38
+ ```
39
+
40
+ ## Batch Export
41
+
42
+ ```bash
43
+ uv run cs2df export-batch ./demos --workers 8 --descriptive
44
+ ```
45
+
46
+ `export-batch` scans one directory non-recursively for `.dem` files and writes
47
+ one ZIP per demo. It also writes `report.json` next to the outputs with:
48
+
49
+ - per-demo success/failure status
50
+ - output ZIP size
51
+ - source demo size
52
+ - compression level
53
+ - total duration
54
+ - aggregate throughput
55
+ - parse/package/write stage timings
56
+
57
+ Bad demos are reported as failed rows; a single parser failure does not crash the
58
+ whole batch. Use `--fail-fast` when you want the batch to stop after the first
59
+ failed demo.
60
+
61
+ ## Validate
62
+
63
+ ```bash
64
+ uv run cs2df validate match.zip
65
+ uv run cs2df validate match.zip --strict
66
+ ```
67
+
68
+ Validation checks JSON Schema and package-level invariants such as cross-file
69
+ player indexes, round windows, column lengths, weapon dictionary indexes, and
70
+ formal-round consistency.
71
+
72
+ ## Role in the Repository
73
+
74
+ This is a reference implementation, not the contract itself. The authoritative
75
+ contract lives in [`../schemas/index.ts`](../schemas/index.ts) and
76
+ the generated JSON Schemas in the repository's `spec/` directory. Any producer
77
+ that emits a ZIP passing strict validation is conformant.
78
+
79
+ The exporter also serves as the performance baseline for the v3 format:
80
+ per-frame data stays in pandas DataFrames until the columnar stream builders
81
+ materialize compact integer arrays for JSON serialization.
82
+
83
+ Event-extraction logic was originally ported from `cs2-demo-analysis-kit`
84
+ (and before that `DrEAmSs59/CS2-insight-agent`, with the author's permission).
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "cs2df"
3
+ version = "3.0.0"
4
+ description = "Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "demoparser2>=0.41.2",
10
+ "pandas>=2.0",
11
+ "numpy>=1.26",
12
+ "orjson>=3.9",
13
+ "jsonschema>=4.21",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/Starfie1d1272/cs2-demo-format"
18
+ Repository = "https://github.com/Starfie1d1272/cs2-demo-format"
19
+ Issues = "https://github.com/Starfie1d1272/cs2-demo-format/issues"
20
+
21
+ [project.scripts]
22
+ cs2df = "cs2df.cli:main"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/cs2df"]
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8"]
@@ -0,0 +1,11 @@
1
+ """cs2df — reference exporter & validator for cs2-demo-format v3.
2
+
3
+ Keep this module import-light: `cs2df validate` must work without the native
4
+ demoparser2 / pandas stack installed. Heavy imports are deferred into the
5
+ submodules that need them.
6
+ """
7
+
8
+ __version__ = "3.0.0"
9
+
10
+ SCHEMA_VERSION = "cs2-demo-format/3.0"
11
+ EXPORTER_NAME = "cs2df"
@@ -0,0 +1,374 @@
1
+ """cs2df CLI — reference exporter & validator for cs2-demo-format v3.
2
+
3
+ Commands:
4
+ cs2df export <demo.dem> [-o out.zip] [--research] [--sample-rate 8] [--compress-level 3]
5
+ cs2df export-batch <dir> [--research] [--sample-rate 8] [--workers N] [--fail-fast] [--descriptive] [--compress-level 3]
6
+ cs2df validate <export.zip> [--spec DIR] [--strict]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import builtins
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+ from concurrent.futures import ProcessPoolExecutor, as_completed
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ DEFAULT_COMPRESS_LEVEL = 3
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(prog="cs2df",
26
+ description="cs2-demo-format v3 reference exporter & validator")
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+
29
+ p_exp = sub.add_parser("export", help="export a CS2 .dem to a v3 ZIP package")
30
+ p_exp.add_argument("demo", help="path to the .dem file")
31
+ p_exp.add_argument("-o", "--output", default=None,
32
+ help="output zip path (default: <demo>.zip next to the input)")
33
+ p_exp.add_argument("--research", action="store_true",
34
+ help="also emit duels.json (full-tick combat windows)")
35
+ p_exp.add_argument("--sample-rate", type=int, default=8,
36
+ help="replay stream sample rate in Hz (default 8)")
37
+ p_exp.add_argument("--window-before", type=int, default=2000,
38
+ help="duel window extent before each anchor, ms (default 2000)")
39
+ p_exp.add_argument("--window-after", type=int, default=1000,
40
+ help="duel window extent after each anchor, ms (default 1000)")
41
+ p_exp.add_argument("--compress-level", type=int, default=DEFAULT_COMPRESS_LEVEL,
42
+ help="ZIP DEFLATE compression level 0-9 (default 3)")
43
+ p_exp.add_argument("-q", "--quiet", action="store_true", help="suppress progress output")
44
+
45
+ p_batch = sub.add_parser("export-batch", help="batch-export all .dem files in a directory")
46
+ p_batch.add_argument("directory", help="directory to scan for .dem files (non-recursive)")
47
+ p_batch.add_argument("--research", action="store_true",
48
+ help="also emit duels.json")
49
+ p_batch.add_argument("--sample-rate", type=int, default=8,
50
+ help="replay stream sample rate in Hz (default 8)")
51
+ p_batch.add_argument("--window-before", type=int, default=2000,
52
+ help="duel window extent before each anchor, ms (default 2000)")
53
+ p_batch.add_argument("--window-after", type=int, default=1000,
54
+ help="duel window extent after each anchor, ms (default 1000)")
55
+ p_batch.add_argument("--compress-level", type=int, default=DEFAULT_COMPRESS_LEVEL,
56
+ help="ZIP DEFLATE compression level 0-9 (default 3)")
57
+ p_batch.add_argument("--workers", type=int, default=None,
58
+ help="parallel worker count (default: logical CPU count)")
59
+ p_batch.add_argument("--fail-fast", action="store_true",
60
+ help="stop on first failure")
61
+ p_batch.add_argument("--descriptive", action="store_true",
62
+ help="use descriptive filenames (date_map_teams_score.zip)")
63
+
64
+ p_val = sub.add_parser("validate", help="validate a v3 ZIP package")
65
+ p_val.add_argument("zip", help="path to the .zip file to validate")
66
+ p_val.add_argument("--spec", default=None, help="path to the spec/ directory")
67
+ p_val.add_argument("--strict", action="store_true", help="treat warnings as errors")
68
+
69
+ args = parser.parse_args(argv)
70
+
71
+ if args.command == "export":
72
+ return _cmd_export(args)
73
+ if args.command == "export-batch":
74
+ return _cmd_export_batch(args)
75
+ if args.command == "validate":
76
+ return _cmd_validate(args)
77
+ return 2
78
+
79
+
80
+ def _cmd_export(args) -> int:
81
+ from .package import export_demo
82
+
83
+ dem = Path(args.demo)
84
+ if not dem.exists():
85
+ print(f"ERROR: demo not found: {dem}", file=sys.stderr)
86
+ return 1
87
+ if not _compress_level_ok(args.compress_level):
88
+ print("ERROR: --compress-level must be between 0 and 9", file=sys.stderr)
89
+ return 1
90
+ out = Path(args.output) if args.output else dem.with_suffix(".zip")
91
+
92
+ t0 = time.perf_counter()
93
+ progress = None
94
+ if not args.quiet:
95
+ def progress(stage: str, frac: float) -> None:
96
+ print(f" [{frac * 100:5.1f}%] {stage}")
97
+
98
+ data, _match_meta = export_demo(str(dem), research=args.research,
99
+ sample_rate=args.sample_rate,
100
+ window_before_ms=args.window_before,
101
+ window_after_ms=args.window_after,
102
+ compress_level=args.compress_level,
103
+ progress=progress)
104
+ out.write_bytes(data)
105
+ dt = time.perf_counter() - t0
106
+ print(f"wrote {out} ({len(data) / 1e6:.2f} MB) in {dt:.1f}s")
107
+ return 0
108
+
109
+
110
+ def _cmd_export_batch(args) -> int:
111
+ directory = Path(args.directory)
112
+ if not directory.is_dir():
113
+ print(f"ERROR: not a directory: {directory}", file=sys.stderr)
114
+ return 1
115
+
116
+ demos = sorted(directory.glob("*.dem"))
117
+ if not demos:
118
+ print(f"ERROR: no .dem files found in {directory}", file=sys.stderr)
119
+ return 1
120
+ if not _compress_level_ok(args.compress_level):
121
+ print("ERROR: --compress-level must be between 0 and 9", file=sys.stderr)
122
+ return 1
123
+
124
+ workers = args.workers if args.workers is not None else _default_workers()
125
+ if workers < 1:
126
+ print("ERROR: --workers must be >= 1", file=sys.stderr)
127
+ return 1
128
+
129
+ # Shared export args (picklable, no callbacks).
130
+ export_kwargs = {
131
+ "research": args.research,
132
+ "sample_rate": args.sample_rate,
133
+ "window_before_ms": args.window_before,
134
+ "window_after_ms": args.window_after,
135
+ "compress_level": args.compress_level,
136
+ }
137
+
138
+ report: list[dict] = []
139
+ t0 = time.perf_counter()
140
+ with ProcessPoolExecutor(max_workers=workers) as pool:
141
+ futures = {}
142
+ for dem in demos:
143
+ submitted = time.perf_counter()
144
+ future = pool.submit(_export_one_report, str(dem), str(directory),
145
+ export_kwargs, args.descriptive)
146
+ futures[future] = (dem, submitted)
147
+ for future in as_completed(futures):
148
+ dem, submitted = futures[future]
149
+ try:
150
+ row = future.result()
151
+ except BaseException as exc:
152
+ row = _failed_batch_row(dem, submitted, _format_exception(exc))
153
+ report.append(row)
154
+ if row["ok"]:
155
+ print(f" ok {dem.name} -> {Path(row['zip']).name} "
156
+ f"{row['zipBytes'] / 1e6:.1f}MB {row['durationSeconds']:.1f}s")
157
+ else:
158
+ print(f" FAIL {dem.name}: {row['error']} ({row['durationSeconds']:.1f}s)")
159
+ if args.fail_fast:
160
+ pool.shutdown(wait=False, cancel_futures=True)
161
+ dt = time.perf_counter() - t0
162
+ _write_batch_report(directory, report, dt)
163
+ return 1
164
+
165
+ dt = time.perf_counter() - t0
166
+ _write_batch_report(directory, report, dt)
167
+
168
+ ok = sum(1 for r in report if r["ok"])
169
+ fail = sum(1 for r in report if not r["ok"])
170
+ total_mb = sum(r["demoBytes"] for r in report) / 1e6
171
+ print(f"\n{ok} ok, {fail} failed, {dt:.1f}s total, {total_mb:.1f} MB demo data")
172
+ return 1 if fail else 0
173
+
174
+
175
+ def _cmd_validate(args) -> int:
176
+ from .validate import validate_zip
177
+
178
+ zip_path = Path(args.zip)
179
+ if not zip_path.exists():
180
+ print(f"ERROR: file not found: {zip_path}", file=sys.stderr)
181
+ return 1
182
+ spec_dir = _resolve_spec_dir(args.spec)
183
+ if spec_dir is None:
184
+ print("ERROR: spec directory not found; pass --spec", file=sys.stderr)
185
+ return 1
186
+ ok = validate_zip(zip_path, spec_dir, strict=args.strict)
187
+ return 0 if ok else 1
188
+
189
+
190
+ def _resolve_spec_dir(arg: str | None) -> Path | None:
191
+ if arg:
192
+ p = Path(arg)
193
+ return p if p.exists() else None
194
+ # repo layout: python/src/cs2df/cli.py → ../../../spec
195
+ repo_spec = Path(__file__).resolve().parents[3] / "spec"
196
+ if repo_spec.exists():
197
+ return repo_spec
198
+ return None
199
+
200
+
201
+ # ── batch helpers (module-level for picklability with ProcessPoolExecutor) ──────
202
+
203
+ def _export_one_report(dem_path: str, out_dir: str, export_kwargs: dict,
204
+ descriptive: bool) -> dict:
205
+ """Parse → build → package one demo; return a structured result row."""
206
+ from .package import export_demo
207
+
208
+ dem = Path(dem_path)
209
+ started = time.perf_counter()
210
+ try:
211
+ data, match_meta = export_demo(str(dem), progress=None, **export_kwargs)
212
+ timings = dict(match_meta.get("timingsSeconds") or {})
213
+ if descriptive:
214
+ date_str = _file_date_str(dem)
215
+ name = _build_descriptive_from_meta(match_meta, dem.stem, date_str)
216
+ write_started = time.perf_counter()
217
+ zip_path = _write_unique_zip(Path(out_dir), name, dem.stem, data)
218
+ else:
219
+ name = f"{dem.stem}.zip"
220
+ zip_path = Path(out_dir) / name
221
+ write_started = time.perf_counter()
222
+ zip_path.write_bytes(data)
223
+ timings["batch.writeFile"] = round(time.perf_counter() - write_started, 3)
224
+ duration = time.perf_counter() - started
225
+ return {
226
+ "demo": str(dem),
227
+ "zip": zip_path.name,
228
+ "ok": True,
229
+ "error": None,
230
+ "durationSeconds": round(duration, 3),
231
+ "demoBytes": dem.stat().st_size,
232
+ "zipBytes": len(data),
233
+ "compressLevel": match_meta.get("compressLevel"),
234
+ "timingsSeconds": timings,
235
+ }
236
+ except (KeyboardInterrupt, SystemExit):
237
+ raise
238
+ except BaseException as exc:
239
+ return _failed_batch_row(dem, started, _format_exception(exc))
240
+
241
+
242
+ def _file_date_str(dem: Path) -> str:
243
+ """Return file modification date as YYYY-MM-DD, or 'unknown' on error."""
244
+ try:
245
+ mtime = os.path.getmtime(str(dem))
246
+ return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")
247
+ except OSError:
248
+ return "unknown"
249
+
250
+
251
+ def _build_descriptive_from_meta(match_meta: dict, stem: str, date_str: str) -> str:
252
+ """Build a descriptive filename: {date}_{map}_{teamA}-vs-{teamB}_{scoreA}-{scoreB}.zip."""
253
+ map_name = _sanitize(match_meta.get("mapName", "unknown"))
254
+ team_a = _sanitize((match_meta.get("teamA") or {}).get("name") or "")
255
+ team_b = _sanitize((match_meta.get("teamB") or {}).get("name") or "")
256
+ score_a = (match_meta.get("teamA") or {}).get("score", 0)
257
+ score_b = (match_meta.get("teamB") or {}).get("score", 0)
258
+
259
+ if team_a and team_b:
260
+ file_stem = f"{date_str}_{map_name}_{team_a}-vs-{team_b}_{score_a}-{score_b}"
261
+ else:
262
+ file_stem = f"{date_str}_{map_name}_{score_a}-{score_b}_{stem}"
263
+ return f"{file_stem}.zip"
264
+
265
+
266
+ def _sanitize(s: str) -> str:
267
+ """Sanitize a string for safe filename use."""
268
+ for ch in r' <>:"/\|?*':
269
+ s = s.replace(ch, '_')
270
+ while '__' in s:
271
+ s = s.replace('__', '_')
272
+ return s.strip('_')
273
+
274
+
275
+ def _write_unique_zip(out_dir: Path, preferred_name: str, stem: str, data: bytes) -> Path:
276
+ """Write bytes without overwriting an existing batch output."""
277
+ preferred = Path(preferred_name)
278
+ suffix = preferred.suffix or ".zip"
279
+ base = preferred.stem or _sanitize(stem) or "export"
280
+ fallback = _sanitize(stem) or "export"
281
+ candidates = [f"{base}{suffix}", f"{base}_{fallback}{suffix}"]
282
+ for i in range(2, 1000):
283
+ candidates.append(f"{base}_{fallback}_{i}{suffix}")
284
+
285
+ for name in candidates:
286
+ path = out_dir / name
287
+ try:
288
+ with builtins.open(path, "xb") as f:
289
+ f.write(data)
290
+ return path
291
+ except FileExistsError:
292
+ continue
293
+ raise FileExistsError(f"could not allocate unique output name for {preferred_name}")
294
+
295
+
296
+ def _failed_batch_row(dem: Path, started: float, error: str) -> dict:
297
+ duration = time.perf_counter() - started
298
+ return {
299
+ "demo": str(dem),
300
+ "zip": None,
301
+ "ok": False,
302
+ "error": error,
303
+ "durationSeconds": round(duration, 3),
304
+ "demoBytes": dem.stat().st_size if dem.exists() else 0,
305
+ "zipBytes": 0,
306
+ "compressLevel": None,
307
+ "timingsSeconds": None,
308
+ }
309
+
310
+
311
+ def _format_exception(exc: BaseException) -> str:
312
+ text = str(exc)
313
+ if text:
314
+ return f"{type(exc).__name__}: {text}"
315
+ return type(exc).__name__
316
+
317
+
318
+ def _mb_per_s(total_bytes: int, duration_seconds: float) -> float:
319
+ if not duration_seconds:
320
+ return 0.0
321
+ return round((total_bytes / 1_000_000) / duration_seconds, 3)
322
+
323
+
324
+ def _compress_level_ok(value: int) -> bool:
325
+ return 0 <= value <= 9
326
+
327
+
328
+ def _aggregate_timings(report: list[dict]) -> dict:
329
+ rows = [r.get("timingsSeconds") for r in report if r.get("ok") and r.get("timingsSeconds")]
330
+ if not rows:
331
+ return {"count": 0, "total": {}, "average": {}}
332
+ keys = sorted({key for row in rows for key in row})
333
+ totals = {
334
+ key: round(sum(float(row.get(key) or 0.0) for row in rows), 3)
335
+ for key in keys
336
+ }
337
+ averages = {key: round(value / len(rows), 3) for key, value in totals.items()}
338
+ return {"count": len(rows), "total": totals, "average": averages}
339
+
340
+
341
+ def _write_batch_report(out_dir: Path, report: list[dict],
342
+ duration_seconds: float) -> None:
343
+ """Write report.json with aggregate stats next to the exported ZIPs."""
344
+ demo_bytes = sum(r["demoBytes"] for r in report)
345
+ zip_bytes = sum(r["zipBytes"] for r in report)
346
+ ok_count = sum(1 for r in report if r["ok"])
347
+ fail_count = sum(1 for r in report if not r["ok"])
348
+
349
+ payload = {
350
+ "createdAt": datetime.now().isoformat(),
351
+ "total": len(report),
352
+ "ok": ok_count,
353
+ "failed": fail_count,
354
+ "durationSeconds": round(duration_seconds, 3),
355
+ "demoBytes": demo_bytes,
356
+ "zipBytes": zip_bytes,
357
+ "demoMegabytesPerSecond": _mb_per_s(demo_bytes, duration_seconds),
358
+ "zipMegabytesPerSecond": _mb_per_s(zip_bytes, duration_seconds),
359
+ "compressionRatio": round(zip_bytes / demo_bytes, 4) if demo_bytes else None,
360
+ "timingsSeconds": _aggregate_timings(report),
361
+ "items": sorted(report, key=lambda r: r["demo"]),
362
+ }
363
+ report_path = out_dir / "report.json"
364
+ report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
365
+ print(f"wrote {report_path}")
366
+
367
+
368
+ def _default_workers() -> int:
369
+ count = getattr(os, "process_cpu_count", os.cpu_count)
370
+ return max(1, count() if callable(count) else (count or 1))
371
+
372
+
373
+ if __name__ == "__main__":
374
+ raise SystemExit(main())