xgparse 0.1.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.
xgparse/__init__.py ADDED
File without changes
@@ -0,0 +1,101 @@
1
+ #
2
+ # extractxgdata.py - Simple XG data extraction tool
3
+ # Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
4
+ # <mpetch@capp-sysware.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import pprint
24
+ from pathlib import Path
25
+
26
+ import xgimport
27
+ import xgstruct
28
+ import xgzarc
29
+
30
+
31
+ def _valid_directory(parser: argparse.ArgumentParser, path: str) -> Path:
32
+ p = Path(path)
33
+ if not p.is_dir():
34
+ parser.error(f"Directory '{path}' does not exist")
35
+ return p
36
+
37
+
38
+ def main() -> None:
39
+ parser = argparse.ArgumentParser(
40
+ description="XG data extraction utility",
41
+ formatter_class=argparse.RawTextHelpFormatter,
42
+ )
43
+ parser.add_argument(
44
+ "-d",
45
+ metavar="DIR",
46
+ dest="outdir",
47
+ help="Directory to write segments to (default: same directory as the import file)\n",
48
+ type=lambda p: _valid_directory(parser, p),
49
+ default=None,
50
+ )
51
+ parser.add_argument(
52
+ "files",
53
+ metavar="FILE",
54
+ nargs="+",
55
+ help="One or more .xg files to process",
56
+ )
57
+ args = parser.parse_args()
58
+
59
+ for xg_path_str in args.files:
60
+ xg_path = Path(xg_path_str)
61
+ out_dir = args.outdir if args.outdir is not None else xg_path.parent
62
+ stem = xg_path.stem
63
+
64
+ try:
65
+ importer = xgimport.Import(xg_path)
66
+ print(f"Processing file: {xg_path}")
67
+ file_version = -1
68
+
69
+ for segment in importer.get_file_segments():
70
+ if segment.extension is not None:
71
+ dest = (out_dir / stem).with_suffix("")
72
+ dest = out_dir / (stem + segment.extension)
73
+ segment.copy_to(dest)
74
+
75
+ if segment.seg_type is xgimport.SegmentType.XG_GAMEFILE:
76
+ segment.fd.seek(0)
77
+ while True:
78
+ rec = xgstruct.read_game_record(
79
+ segment.fd, version=file_version
80
+ )
81
+ if rec is None:
82
+ break
83
+ if isinstance(rec, xgstruct.HeaderMatchEntry):
84
+ file_version = rec.version
85
+ if not isinstance(rec, xgstruct.UnimplementedEntry):
86
+ pprint.pprint(rec, width=160)
87
+
88
+ elif segment.seg_type is xgimport.SegmentType.XG_ROLLOUTS:
89
+ segment.fd.seek(0)
90
+ while True:
91
+ rec = xgstruct.read_rollout_record(segment.fd)
92
+ if rec is None:
93
+ break
94
+ pprint.pprint(rec, width=160)
95
+
96
+ except (xgimport.Error, xgzarc.Error) as exc:
97
+ print(exc)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
xgparse/xgimport.py ADDED
@@ -0,0 +1,214 @@
1
+ #
2
+ # xgimport.py - XG import module
3
+ # Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
4
+ # <mpetch@capp-sysware.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import tempfile
25
+ from collections.abc import Generator
26
+ from dataclasses import dataclass, field
27
+ from enum import IntEnum
28
+ from pathlib import Path
29
+ from typing import BinaryIO
30
+
31
+ import xgstruct
32
+ import xgzarc
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Public exceptions
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class Error(Exception):
41
+ """Raised when a file cannot be parsed as a valid XG game data file."""
42
+
43
+ def __init__(self, message: str, filename: str | Path) -> None:
44
+ self.message = message
45
+ self.filename = Path(filename)
46
+ super().__init__(f"XG import error processing '{filename}': {message}")
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Segment types and metadata
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ class SegmentType(IntEnum):
55
+ GDF_HDR = 0
56
+ GDF_IMAGE = 1
57
+ XG_GAMEHDR = 2
58
+ XG_GAMEFILE = 3
59
+ XG_ROLLOUTS = 4
60
+ XG_COMMENT = 5
61
+ ZLIBARC_IDX = 6
62
+ XG_UNKNOWN = 7
63
+
64
+
65
+ _EXTENSIONS: dict[SegmentType, str | None] = {
66
+ SegmentType.GDF_HDR: "_gdh.bin",
67
+ SegmentType.GDF_IMAGE: ".jpg",
68
+ SegmentType.XG_GAMEHDR: "_gamehdr.bin",
69
+ SegmentType.XG_GAMEFILE: "_gamefile.bin",
70
+ SegmentType.XG_ROLLOUTS: "_rollouts.bin",
71
+ SegmentType.XG_COMMENT: "_comments.bin",
72
+ SegmentType.ZLIBARC_IDX: "_idx.bin",
73
+ SegmentType.XG_UNKNOWN: None,
74
+ }
75
+
76
+ _FILEMAP: dict[str, SegmentType] = {
77
+ "temp.xgi": SegmentType.XG_GAMEHDR,
78
+ "temp.xgr": SegmentType.XG_ROLLOUTS,
79
+ "temp.xgc": SegmentType.XG_COMMENT,
80
+ "temp.xg": SegmentType.XG_GAMEFILE,
81
+ }
82
+
83
+ # Byte offset of the magic string "DMLI" inside temp.xg.
84
+ _XG_GAMEHDR_LEN = 556
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Segment object
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ @dataclass
93
+ class Segment:
94
+ """One logical chunk extracted from an XG file.
95
+
96
+ Attributes:
97
+ seg_type: Which part of the XG file this segment represents.
98
+ filename: Path to the backing temp file on disk (may be ``None``
99
+ before :meth:`create_temp_file` is called).
100
+ fd: Open file object for the backing temp file.
101
+ extension: File extension used when saving this segment to disk.
102
+ """
103
+
104
+ seg_type: SegmentType
105
+ filename: Path | None = field(default=None, repr=False)
106
+ fd: BinaryIO | None = field(default=None, repr=False)
107
+ _autodelete: bool = field(default=True, repr=False)
108
+ _prefix: str = field(default="tmpXGI", repr=False)
109
+
110
+ @property
111
+ def extension(self) -> str | None:
112
+ return _EXTENSIONS.get(self.seg_type)
113
+
114
+ # Context-manager support for auto-creating/deleting temp files.
115
+ def __enter__(self) -> "Segment":
116
+ self._create_temp_file()
117
+ return self
118
+
119
+ def __exit__(self, *_: object) -> None:
120
+ self._close()
121
+
122
+ def _create_temp_file(self, mode: str = "w+b") -> None:
123
+ raw_fd, path_str = tempfile.mkstemp(prefix=self._prefix)
124
+ self.filename = Path(path_str)
125
+ self.fd = os.fdopen(raw_fd, mode)
126
+
127
+ def _close(self) -> None:
128
+ if self.fd is not None:
129
+ try:
130
+ self.fd.close()
131
+ finally:
132
+ self.fd = None
133
+ if self._autodelete and self.filename is not None:
134
+ self.filename.unlink(missing_ok=True)
135
+ self.filename = None
136
+
137
+ def copy_to(self, dest: str | Path) -> None:
138
+ """Copy the backing temp file to *dest*."""
139
+ if self.filename is None:
140
+ raise RuntimeError("Segment has no backing file to copy.")
141
+ shutil.copy(self.filename, dest)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Top-level importer
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class Import:
150
+ """Parse an eXtreme Gammon ``.xg`` file and iterate over its segments."""
151
+
152
+ def __init__(self, filename: str | Path) -> None:
153
+ self.filename = Path(filename)
154
+
155
+ def get_file_segments(self) -> Generator[Segment, None, None]:
156
+ """Yield each :class:`Segment` found in the XG file in order.
157
+
158
+ Segments are:
159
+
160
+ 1. The GDF header binary blob (always present).
161
+ 2. The thumbnail JPEG (present only when a thumbnail was saved).
162
+ 3. The game header (``temp.xgi``).
163
+ 4. The full game file (``temp.xg``).
164
+ 5. The rollout data (``temp.xgr``).
165
+ 6. The comments (``temp.xgc``).
166
+
167
+ Each segment's backing temp file is valid for the duration of a single
168
+ loop iteration. Do **not** hold on to segment references across
169
+ iterations — the file will be deleted.
170
+ """
171
+ with self.filename.open("rb") as xginfile:
172
+ # --- GDF / RichGame outer header ---
173
+ gdf_header = xgstruct.GameDataFormatHdrRecord.from_stream(xginfile)
174
+ if gdf_header is None:
175
+ raise Error("Not a game data format file", self.filename)
176
+
177
+ with Segment(SegmentType.GDF_HDR) as seg:
178
+ xginfile.seek(0)
179
+ seg.fd.write(xginfile.read(gdf_header.header_size)) # type: ignore[union-attr]
180
+ seg.fd.flush()
181
+ yield seg
182
+
183
+ # --- Embedded thumbnail JPEG (optional) ---
184
+ if gdf_header.thumbnail_size > 0:
185
+ with Segment(SegmentType.GDF_IMAGE) as seg:
186
+ xginfile.seek(gdf_header.thumbnail_offset, os.SEEK_CUR)
187
+ seg.fd.write(xginfile.read(gdf_header.thumbnail_size)) # type: ignore[union-attr]
188
+ seg.fd.flush()
189
+ yield seg
190
+
191
+ # --- Zlib archive containing the four inner XG files ---
192
+ archive = xgzarc.ZlibArchive(stream=xginfile)
193
+ for file_rec in archive.arcregistry:
194
+ seg_file, seg_path = archive.getarchivefile(file_rec)
195
+ seg_type = _FILEMAP[file_rec.name]
196
+ seg = Segment(
197
+ seg_type=seg_type,
198
+ filename=seg_path,
199
+ fd=seg_file,
200
+ _autodelete=False,
201
+ )
202
+
203
+ if seg_type is SegmentType.XG_GAMEFILE:
204
+ seg_file.seek(_XG_GAMEHDR_LEN)
205
+ magic = seg_file.read(4).decode("ascii", errors="replace")
206
+ if magic != "DMLI":
207
+ seg_file.close()
208
+ seg_path.unlink(missing_ok=True)
209
+ raise Error("Not a valid XG gamefile", self.filename)
210
+
211
+ yield seg
212
+
213
+ seg_file.close()
214
+ seg_path.unlink(missing_ok=True)