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 +9 -0
- cs2df-3.0.0/PKG-INFO +100 -0
- cs2df-3.0.0/README.md +84 -0
- cs2df-3.0.0/pyproject.toml +32 -0
- cs2df-3.0.0/src/cs2df/__init__.py +11 -0
- cs2df-3.0.0/src/cs2df/cli.py +374 -0
- cs2df-3.0.0/src/cs2df/enums.py +269 -0
- cs2df-3.0.0/src/cs2df/events.py +881 -0
- cs2df-3.0.0/src/cs2df/package.py +203 -0
- cs2df-3.0.0/src/cs2df/parse.py +532 -0
- cs2df-3.0.0/src/cs2df/rounds.py +328 -0
- cs2df-3.0.0/src/cs2df/streams.py +472 -0
- cs2df-3.0.0/src/cs2df/validate.py +551 -0
- cs2df-3.0.0/tests/test_cli_batch.py +126 -0
- cs2df-3.0.0/uv.lock +662 -0
cs2df-3.0.0/.gitignore
ADDED
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())
|