cs2df 3.0.0__py3-none-any.whl

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/package.py ADDED
@@ -0,0 +1,203 @@
1
+ """Assemble a cs2-demo-format v3 ZIP package from a parsed demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import math
8
+ import time
9
+ import zipfile
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Callable
13
+
14
+ from . import EXPORTER_NAME, SCHEMA_VERSION, __version__
15
+ from .events import (
16
+ PlayerDirectory, build_blinds, build_bombs, build_clutches, build_damages,
17
+ build_economies, build_grenades, build_kills, build_match,
18
+ build_player_stats, build_players,
19
+ )
20
+ from .rounds import build_rounds
21
+ from .streams import build_duels, build_replay, build_shots
22
+
23
+ ProgressFn = Callable[[str, float], None]
24
+
25
+ _FILENAMES = {
26
+ "match": "match.json",
27
+ "players": "players.json",
28
+ "rounds": "rounds.json",
29
+ "playerStats": "player-stats.json",
30
+ "playerEconomies": "player-economies.json",
31
+ "kills": "kills.json",
32
+ "damages": "damages.json",
33
+ "blinds": "blinds.json",
34
+ "bombs": "bombs.json",
35
+ "grenades": "grenades.json",
36
+ "clutches": "clutches.json",
37
+ "shots": "shots.json",
38
+ "replay": "replay.json",
39
+ "duels": "duels.json",
40
+ }
41
+
42
+
43
+ def export_demo(dem_path: str, *, research: bool = False, sample_rate: int = 8,
44
+ window_before_ms: int = 2000, window_after_ms: int = 1000,
45
+ compress_level: int = 3,
46
+ progress: ProgressFn | None = None) -> tuple[bytes, dict]:
47
+ """Parse `dem_path` and return (v3 ZIP bytes, match_meta dict)."""
48
+ from .parse import parse_demo
49
+
50
+ try:
51
+ demo_hash: str | None = _sha256_hex(dem_path)
52
+ except Exception:
53
+ demo_hash = None
54
+
55
+ scaled = (lambda stage, frac: progress(stage, frac * 0.9)) if progress else None
56
+ raw = parse_demo(dem_path, sample_rate=sample_rate, research=research,
57
+ window_before_ms=window_before_ms,
58
+ window_after_ms=window_after_ms, progress=scaled)
59
+
60
+ if progress:
61
+ progress("build package", 0.92)
62
+ return build_package(raw, dem_path, demo_hash,
63
+ window_before_ms=window_before_ms,
64
+ window_after_ms=window_after_ms,
65
+ compress_level=compress_level)
66
+
67
+
68
+ def build_package(raw: dict[str, Any], dem_path: str, demo_hash: str | None, *,
69
+ window_before_ms: int = 2000, window_after_ms: int = 1000,
70
+ compress_level: int = 3) -> tuple[bytes, dict]:
71
+ """Pure assembly: raw parse output → (v3 ZIP bytes, match_meta)."""
72
+ timings = dict(raw.get("_timings") or {})
73
+ tickrate = int(raw.get("tickrate") or 64)
74
+ sample_rate = int(raw.get("sample_rate") or 8)
75
+
76
+ players = _timed(timings, "package.players", lambda: build_players(raw))
77
+ team_map = {p["steamId64"]: p["teamKey"] for p in players.rows}
78
+ rounds, round_model = _timed(timings, "package.rounds", lambda: build_rounds(raw, team_map))
79
+
80
+ def build_events():
81
+ kills_out = build_kills(raw, players, round_model)
82
+ grenades_out = build_grenades(raw, players, round_model)
83
+ flash_lookup = {
84
+ (g["roundNumber"], g["effectTick"]): g["grenadeId"]
85
+ for g in grenades_out if g["grenade"] == "flashbang" and g["grenadeId"]
86
+ }
87
+ blinds_out = build_blinds(raw, players, round_model, flash_lookup=flash_lookup)
88
+ damages_out = build_damages(raw, players, round_model)
89
+ clutches_out = build_clutches(kills_out, rounds, players)
90
+ economies_out = build_economies(raw, players, round_model, rounds) # mutates rounds economy
91
+ player_stats_out = build_player_stats(
92
+ raw, players, round_model, rounds,
93
+ kills_list=kills_out, blinds_list=blinds_out,
94
+ damages_list=damages_out, clutches_list=clutches_out,
95
+ )
96
+ bombs_out = build_bombs(raw, players, round_model)
97
+ match_out = build_match(raw, rounds)
98
+ return (kills_out, grenades_out, blinds_out, damages_out, clutches_out,
99
+ economies_out, player_stats_out, bombs_out, match_out)
100
+
101
+ (kills, grenades, blinds, damages, clutches,
102
+ economies, player_stats, bombs, match_json) = _timed(timings, "package.events", build_events)
103
+
104
+ shots = _timed(timings, "package.shots", lambda: build_shots(raw, players, round_model))
105
+ replay = _timed(timings, "package.replay", lambda: build_replay(raw, players, round_model,
106
+ tickrate, sample_rate))
107
+ duels = _timed(timings, "package.duels", lambda: build_duels(
108
+ raw, players, round_model, tickrate, kills, damages, window_before_ms, window_after_ms))
109
+
110
+ data_by_key: dict[str, Any] = {
111
+ "match": match_json,
112
+ "players": players.rows,
113
+ "rounds": rounds,
114
+ "playerStats": player_stats,
115
+ "playerEconomies": economies,
116
+ "kills": kills,
117
+ "damages": damages,
118
+ "blinds": blinds,
119
+ "bombs": bombs,
120
+ "grenades": grenades,
121
+ "clutches": clutches,
122
+ }
123
+ if shots:
124
+ data_by_key["shots"] = shots
125
+ if replay:
126
+ data_by_key["replay"] = replay
127
+ if duels:
128
+ data_by_key["duels"] = duels
129
+
130
+ manifest = {
131
+ "schemaVersion": SCHEMA_VERSION,
132
+ "exporter": {"name": EXPORTER_NAME, "version": __version__},
133
+ "parser": {"name": "demoparser2", "version": _demoparser2_version()},
134
+ "demo": {"hash": demo_hash, "sourceFileName": Path(dem_path).name},
135
+ "mapName": str(raw.get("header", {}).get("map_name") or "unknown"),
136
+ "tickrate": tickrate,
137
+ "exportedAt": datetime.now(timezone.utc).isoformat(),
138
+ "files": {key: _FILENAMES[key] for key in data_by_key},
139
+ }
140
+
141
+ match_meta = {
142
+ "mapName": match_json.get("mapName", "unknown"),
143
+ "teamA": match_json.get("teamA") or {},
144
+ "teamB": match_json.get("teamB") or {},
145
+ }
146
+ zip_bytes = _timed(timings, "package.writeZip",
147
+ lambda: _write_zip(manifest, data_by_key, compress_level=compress_level))
148
+ match_meta["timingsSeconds"] = timings
149
+ match_meta["compressLevel"] = compress_level
150
+ return zip_bytes, match_meta
151
+
152
+
153
+ def _write_zip(manifest: dict, data_by_key: dict[str, Any], *,
154
+ compress_level: int = 3) -> bytes:
155
+ buf = io.BytesIO()
156
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED,
157
+ compresslevel=compress_level) as zf:
158
+ zf.writestr("manifest.json", _dumps(manifest))
159
+ for key, data in data_by_key.items():
160
+ zf.writestr(_FILENAMES[key], _dumps(data))
161
+ return buf.getvalue()
162
+
163
+
164
+ def _timed(timings: dict[str, float], name: str, fn):
165
+ started = time.perf_counter()
166
+ result = fn()
167
+ timings[name] = round(time.perf_counter() - started, 3)
168
+ return result
169
+
170
+
171
+ def _dumps(data: Any) -> bytes:
172
+ """Minified JSON via orjson (fast, no NaN — raises on non-finite floats)."""
173
+ import orjson
174
+
175
+ return orjson.dumps(_json_safe(data))
176
+
177
+
178
+ def _json_safe(obj):
179
+ """Replace float NaN/inf with None (orjson would raise otherwise)."""
180
+ if isinstance(obj, float):
181
+ return None if (math.isnan(obj) or math.isinf(obj)) else obj
182
+ if isinstance(obj, list):
183
+ return [_json_safe(v) for v in obj]
184
+ if isinstance(obj, dict):
185
+ return {k: _json_safe(v) for k, v in obj.items()}
186
+ return obj
187
+
188
+
189
+ def _sha256_hex(path: str) -> str:
190
+ h = hashlib.sha256()
191
+ with open(path, "rb") as f:
192
+ for chunk in iter(lambda: f.read(1 << 20), b""):
193
+ h.update(chunk)
194
+ return h.hexdigest()
195
+
196
+
197
+ def _demoparser2_version() -> str:
198
+ try:
199
+ from importlib.metadata import version
200
+
201
+ return version("demoparser2")
202
+ except Exception:
203
+ return "unknown"