chklib 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.
chklib/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """chklib - a read/write library for StarCraft map data."""
2
+
3
+ from .chk import SECTION_HEADER_SIZE, Chk, Diagnostic, Section
4
+ from .records import (
5
+ Action,
6
+ Condition,
7
+ IsomRect,
8
+ Location,
9
+ Sprite,
10
+ Trigger,
11
+ Unit,
12
+ )
13
+ from .views import (
14
+ Forces,
15
+ Dimensions,
16
+ PlayerRaces,
17
+ PlayerSlots,
18
+ RecordArrayView,
19
+ ScenarioProperties,
20
+ FogGrid,
21
+ IsomGrid,
22
+ StringTable,
23
+ StringTableView,
24
+ TileGrid,
25
+ TilesetRef,
26
+ TriggerListView,
27
+ Version,
28
+ string_table_for,
29
+ isom_for,
30
+ terrain_for,
31
+ view_for,
32
+ TYPED_SECTIONS,
33
+ )
34
+
35
+ __all__ = [
36
+ # container
37
+ "Chk", "Section", "Diagnostic", "SECTION_HEADER_SIZE",
38
+ # records
39
+ "Unit", "Sprite", "Location", "Condition", "Action", "Trigger", "IsomRect",
40
+ # views
41
+ "Dimensions", "Version", "TilesetRef", "PlayerSlots", "PlayerRaces",
42
+ "ScenarioProperties", "Forces", "RecordArrayView", "TriggerListView",
43
+ "StringTableView", "StringTable", "TileGrid", "FogGrid",
44
+ "IsomGrid", "terrain_for", "isom_for", "string_table_for", "view_for",
45
+ "TYPED_SECTIONS",
46
+ ]
47
+ __version__ = "0.1.0"
chklib/chk.py ADDED
@@ -0,0 +1,291 @@
1
+ """The CHK section container.
2
+
3
+ A ``scenario.chk`` is a flat sequence of sections, each an 8-byte header -- a
4
+ 4-byte name and a signed little-endian 32-bit length -- followed by that many
5
+ payload bytes.
6
+
7
+ The container below preserves three things that existing CHK readers throw
8
+ away, and that anything claiming to round-trip a map has to keep:
9
+
10
+ **Order.** StarCraft applies sections in file order, with later sections
11
+ overriding earlier ones of the same name. A reader that returns a mapping has
12
+ already destroyed the information needed to know which one wins.
13
+
14
+ **Duplicates.** Deliberately duplicated sections are a standard map-protection
15
+ technique. A ``dict[name] -> bytes`` silently keeps one and discards the rest.
16
+
17
+ **Raw bytes.** Sections this library does not understand -- and sections whose
18
+ declared length disagrees with reality -- survive a read/write cycle untouched.
19
+
20
+ Malformed input is reported as :class:`Diagnostic` values rather than raised.
21
+ A large share of interesting maps in the wild are malformed on purpose, and a
22
+ parser that refuses them is useless for exactly the maps people care about.
23
+
24
+ ``Chk.from_bytes(raw).to_bytes() == raw`` holds for every input, including
25
+ truncated and protected files. That is guaranteed by construction: each section
26
+ re-emits its own verbatim header fields and payload, and anything the parser
27
+ could not interpret is preserved in :attr:`Chk.trailing`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import struct
33
+ from dataclasses import dataclass, field
34
+ from typing import Iterator, Sequence
35
+
36
+ __all__ = ["Chk", "Section", "Diagnostic", "SECTION_HEADER_SIZE"]
37
+
38
+ SECTION_HEADER_SIZE = 8
39
+
40
+ _NAME_STRUCT = struct.Struct("<4si")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Diagnostic:
45
+ """Something notable about the input, reported rather than raised."""
46
+
47
+ code: str
48
+ message: str
49
+ offset: int
50
+ severity: str = "warning"
51
+
52
+ def __str__(self) -> str:
53
+ return f"{self.severity}: {self.code} at 0x{self.offset:X}: {self.message}"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Section:
58
+ """One CHK section, exactly as it appeared in the file."""
59
+
60
+ name: bytes
61
+ """The 4 name bytes verbatim. Not necessarily printable ASCII."""
62
+
63
+ declared_size: int
64
+ """The header's signed int32 length, verbatim -- may disagree with ``data``."""
65
+
66
+ data: bytes
67
+ """The payload bytes actually present in the file."""
68
+
69
+ offset: int
70
+ """Byte offset of this section's header within the source."""
71
+
72
+ @property
73
+ def label(self) -> str:
74
+ """A printable name for display. Non-printable bytes become ``.``."""
75
+ return "".join(
76
+ chr(b) if 0x20 <= b < 0x7F else "." for b in self.name
77
+ )
78
+
79
+ @property
80
+ def key(self) -> bytes:
81
+ """The name with trailing spaces stripped, e.g. ``b'VER'`` for ``b'VER '``."""
82
+ return self.name.rstrip(b" ")
83
+
84
+ @property
85
+ def is_truncated(self) -> bool:
86
+ """True when the file ended before ``declared_size`` bytes were available."""
87
+ return len(self.data) != self.declared_size
88
+
89
+ @property
90
+ def header_bytes(self) -> bytes:
91
+ return _NAME_STRUCT.pack(self.name, self.declared_size)
92
+
93
+ def to_bytes(self) -> bytes:
94
+ return self.header_bytes + self.data
95
+
96
+ def __len__(self) -> int:
97
+ return len(self.data)
98
+
99
+ def __repr__(self) -> str:
100
+ trunc = (
101
+ f", truncated(declared={self.declared_size})" if self.is_truncated else ""
102
+ )
103
+ return (
104
+ f"Section({self.label!r}, {len(self.data)} bytes"
105
+ f"{trunc}, offset=0x{self.offset:X})"
106
+ )
107
+
108
+
109
+ def _normalize(name: str | bytes) -> bytes:
110
+ """Accept ``'VER'``, ``'VER '`` or ``b'VER '`` and return the 4-byte form."""
111
+ raw = name.encode("ascii") if isinstance(name, str) else bytes(name)
112
+ if len(raw) > 4:
113
+ raise ValueError(f"section name must be at most 4 bytes, got {raw!r}")
114
+ return raw.ljust(4, b" ")
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class Chk:
119
+ """An ordered, duplicate-preserving sequence of CHK sections."""
120
+
121
+ sections: list[Section] = field(default_factory=list)
122
+ """Every section, in file order, duplicates included."""
123
+
124
+ trailing: bytes = b""
125
+ """Bytes after the last section the parser could interpret."""
126
+
127
+ diagnostics: list[Diagnostic] = field(default_factory=list)
128
+ """Problems found while parsing. Never raised."""
129
+
130
+ # -- reading ----------------------------------------------------------
131
+
132
+ @classmethod
133
+ def from_bytes(cls, raw: bytes) -> "Chk":
134
+ """Parse ``raw``. Never raises on malformed input."""
135
+ sections: list[Section] = []
136
+ diagnostics: list[Diagnostic] = []
137
+ trailing = b""
138
+ total = len(raw)
139
+ offset = 0
140
+
141
+ while offset + SECTION_HEADER_SIZE <= total:
142
+ name, declared = _NAME_STRUCT.unpack_from(raw, offset)
143
+ body = offset + SECTION_HEADER_SIZE
144
+
145
+ if declared < 0:
146
+ # A negative length is not a size; it is a backwards jump used
147
+ # by "jump section" protectors. Following it would mean
148
+ # emulating StarCraft's parser, including its overlaps. We stop
149
+ # and keep the remainder verbatim so the file still round-trips.
150
+ diagnostics.append(
151
+ Diagnostic(
152
+ "negative-section-length",
153
+ f"section {name!r} declares length {declared}; stopping and "
154
+ f"preserving the remaining {total - offset} bytes verbatim",
155
+ offset,
156
+ "error",
157
+ )
158
+ )
159
+ trailing = raw[offset:]
160
+ offset = total
161
+ break
162
+
163
+ end = body + declared
164
+ if end > total:
165
+ available = total - body
166
+ diagnostics.append(
167
+ Diagnostic(
168
+ "truncated-section",
169
+ f"section {name!r} declares {declared} bytes but only "
170
+ f"{available} remain",
171
+ offset,
172
+ "error",
173
+ )
174
+ )
175
+ sections.append(Section(name, declared, raw[body:], offset))
176
+ offset = total
177
+ break
178
+
179
+ sections.append(Section(name, declared, raw[body:end], offset))
180
+ offset = end
181
+
182
+ if offset < total and not trailing:
183
+ trailing = raw[offset:]
184
+ diagnostics.append(
185
+ Diagnostic(
186
+ "trailing-bytes",
187
+ f"{len(trailing)} bytes after the last section are too few to "
188
+ f"form a {SECTION_HEADER_SIZE}-byte header",
189
+ offset,
190
+ )
191
+ )
192
+
193
+ chk = cls(sections=sections, trailing=trailing, diagnostics=diagnostics)
194
+ chk._diagnose_names()
195
+ return chk
196
+
197
+ def _diagnose_names(self) -> None:
198
+ for section in self.sections:
199
+ if any(b < 0x20 or b >= 0x7F for b in section.name):
200
+ self.diagnostics.append(
201
+ Diagnostic(
202
+ "non-printable-section-name",
203
+ f"section name {section.name!r} is not printable ASCII",
204
+ section.offset,
205
+ )
206
+ )
207
+
208
+ # -- writing ----------------------------------------------------------
209
+
210
+ def to_bytes(self) -> bytes:
211
+ """Serialize. Round-trips byte-exactly if nothing has been modified."""
212
+ return b"".join(s.to_bytes() for s in self.sections) + self.trailing
213
+
214
+ def replace_section(self, name: str | bytes, data: bytes) -> Section:
215
+ """Replace the payload of the *effective* section ``name``.
216
+
217
+ The effective section is the last one with that name, matching the
218
+ override order StarCraft applies. Earlier duplicates are left alone --
219
+ they are still what the file contained, and collapsing them would be a
220
+ silent edit.
221
+
222
+ Returns the new :class:`Section`. Raises :class:`KeyError` if absent.
223
+ """
224
+ wanted = _normalize(name)
225
+ for index in range(len(self.sections) - 1, -1, -1):
226
+ section = self.sections[index]
227
+ if section.name == wanted:
228
+ replacement = Section(wanted, len(data), bytes(data), section.offset)
229
+ self.sections[index] = replacement
230
+ return replacement
231
+ raise KeyError(f"no {wanted!r} section to replace")
232
+
233
+ def add_section(self, name: str | bytes, data: bytes) -> Section:
234
+ """Append a new section. Its offset is recorded as the current end."""
235
+ wanted = _normalize(name)
236
+ section = Section(wanted, len(data), bytes(data), len(self.to_bytes()))
237
+ self.sections.append(section)
238
+ return section
239
+
240
+ # -- lookup -----------------------------------------------------------
241
+
242
+ def find(self, name: str | bytes) -> list[Section]:
243
+ """Every section with this name, in file order."""
244
+ wanted = _normalize(name)
245
+ return [s for s in self.sections if s.name == wanted]
246
+
247
+ def last(self, name: str | bytes) -> Section | None:
248
+ """The section StarCraft would use: the last one with this name.
249
+
250
+ Later sections override earlier ones, so this is the effective value.
251
+ Returns ``None`` when absent.
252
+ """
253
+ wanted = _normalize(name)
254
+ for section in reversed(self.sections):
255
+ if section.name == wanted:
256
+ return section
257
+ return None
258
+
259
+ def __contains__(self, name: str | bytes) -> bool:
260
+ return self.last(name) is not None
261
+
262
+ def __iter__(self) -> Iterator[Section]:
263
+ return iter(self.sections)
264
+
265
+ def __len__(self) -> int:
266
+ return len(self.sections)
267
+
268
+ # -- summary ----------------------------------------------------------
269
+
270
+ @property
271
+ def duplicated_names(self) -> list[bytes]:
272
+ """Names appearing more than once, in first-appearance order."""
273
+ seen: dict[bytes, int] = {}
274
+ for section in self.sections:
275
+ seen[section.name] = seen.get(section.name, 0) + 1
276
+ return [name for name, count in seen.items() if count > 1]
277
+
278
+ @property
279
+ def has_errors(self) -> bool:
280
+ return any(d.severity == "error" for d in self.diagnostics)
281
+
282
+ def describe(self) -> str:
283
+ """A short human-readable summary. Not the ``inspect`` output format."""
284
+ lines = [f"{len(self.sections)} sections, {len(self.to_bytes())} bytes"]
285
+ for section in self.sections:
286
+ lines.append(f" {section.label} {len(section.data):>8} bytes")
287
+ if self.trailing:
288
+ lines.append(f" <trailing> {len(self.trailing):>8} bytes")
289
+ for diagnostic in self.diagnostics:
290
+ lines.append(f" ! {diagnostic}")
291
+ return "\n".join(lines)
chklib/cli.py ADDED
@@ -0,0 +1,312 @@
1
+ """``chkdiff`` -- command line entry point.
2
+
3
+ ``inspect`` and ``diff`` are the tools; ``pack`` and ``unpack`` move a scenario
4
+ in and out of a map archive; ``textconv`` and ``install-textconv`` wire the whole
5
+ thing into ``git diff``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import pathlib
13
+ import sys
14
+
15
+ from . import __version__
16
+ from .chk import Chk
17
+ from .diff import diff
18
+ from .inspect import FORMAT_VERSION, render
19
+ from .mpq import (
20
+ MpqArchive,
21
+ MpqError,
22
+ SCENARIO_PATH,
23
+ looks_like_mpq,
24
+ write_scenario,
25
+ )
26
+
27
+ __all__ = ["main"]
28
+
29
+
30
+ def _load(path: pathlib.Path) -> Chk:
31
+ """Read a bare ``scenario.chk``, or pull one out of a ``.scm``/``.scx``.
32
+
33
+ Map files in a repository are MPQ archives, so accepting them directly is
34
+ what makes the git integration possible at all.
35
+ """
36
+ raw = path.read_bytes()
37
+ if looks_like_mpq(raw):
38
+ try:
39
+ raw = MpqArchive(raw).read_file(SCENARIO_PATH)
40
+ except MpqError as exc:
41
+ raise SystemExit(f"{path}: {exc}") from exc
42
+ return Chk.from_bytes(raw)
43
+
44
+
45
+ def _cmd_inspect(args: argparse.Namespace) -> int:
46
+ path = pathlib.Path(args.path)
47
+ if not path.is_file():
48
+ raise SystemExit(f"not a file: {path}")
49
+ chk = _load(path)
50
+ # --stable omits the source line: git's textconv passes a temporary filename
51
+ # that changes on every invocation and would appear as a spurious diff.
52
+ source = None if args.stable else str(path)
53
+ sys.stdout.write(render(chk, source=source))
54
+ if args.strict and chk.has_errors:
55
+ for diagnostic in chk.diagnostics:
56
+ if diagnostic.severity == "error":
57
+ print(f"error: {diagnostic}", file=sys.stderr)
58
+ return 1
59
+ return 0
60
+
61
+
62
+ def _cmd_diff(args: argparse.Namespace) -> int:
63
+ left, right = pathlib.Path(args.a), pathlib.Path(args.b)
64
+ for path in (left, right):
65
+ if not path.is_file():
66
+ raise SystemExit(f"not a file: {path}")
67
+ report = diff(_load(left), _load(right))
68
+ sys.stdout.write(report.to_json() if args.json else report.to_text())
69
+ # Exit codes follow diff(1): 0 identical, 1 differences found.
70
+ return 0 if report.is_empty else 1
71
+
72
+
73
+ def _cmd_unpack(args: argparse.Namespace) -> int:
74
+ """Extract ``staredit\\scenario.chk`` from a map archive."""
75
+ source = pathlib.Path(args.map)
76
+ if not source.is_file():
77
+ raise SystemExit(f"not a file: {source}")
78
+ raw = source.read_bytes()
79
+ if not looks_like_mpq(raw):
80
+ raise SystemExit(f"{source} is not an MPQ archive")
81
+ try:
82
+ chk = MpqArchive(raw).read_file(SCENARIO_PATH)
83
+ except MpqError as exc:
84
+ raise SystemExit(f"{source}: {exc}") from exc
85
+ destination = pathlib.Path(args.out)
86
+ destination.write_bytes(chk)
87
+ print(f"wrote {destination} ({len(chk)} bytes)")
88
+ return 0
89
+
90
+
91
+ def _cmd_pack(args: argparse.Namespace) -> int:
92
+ """Wrap a ``scenario.chk`` into a playable map archive."""
93
+ source = pathlib.Path(args.chk)
94
+ if not source.is_file():
95
+ raise SystemExit(f"not a file: {source}")
96
+ raw = source.read_bytes()
97
+ if looks_like_mpq(raw):
98
+ raise SystemExit(f"{source} is already an archive; did you mean unpack?")
99
+
100
+ # Refuse to emit something the reader cannot make sense of. A map that fails
101
+ # to parse here will fail in StarCraft too, and silently writing it wastes
102
+ # the user's time later rather than now.
103
+ chk = Chk.from_bytes(raw)
104
+ if chk.has_errors and not args.force:
105
+ for diagnostic in chk.diagnostics:
106
+ if diagnostic.severity == "error":
107
+ print(f"error: {diagnostic}", file=sys.stderr)
108
+ raise SystemExit(
109
+ f"{source} has parse errors; pass --force to pack it anyway"
110
+ )
111
+
112
+ destination = pathlib.Path(args.out)
113
+ archive = write_scenario(raw, compress=args.compress)
114
+ destination.write_bytes(archive)
115
+ ratio = f", {len(archive) / len(raw):.0%} of the scenario" if raw else ""
116
+ print(f"wrote {destination} ({len(archive)} bytes{ratio})")
117
+ return 0
118
+
119
+
120
+ def _cmd_textconv(args: argparse.Namespace) -> int:
121
+ """The git textconv driver. Must never fail.
122
+
123
+ Git runs this over every blob on both sides of a diff, including historical
124
+ ones that may be truncated, protected, or not maps at all. A driver that
125
+ exits non-zero or raises makes ``git diff`` fail outright, so anything
126
+ unreadable degrades to a short deterministic note instead.
127
+
128
+ The note is deliberately content-derived rather than error-derived: two
129
+ unreadable blobs with the same bytes must produce the same text, or the diff
130
+ churns.
131
+ """
132
+ path = pathlib.Path(args.path)
133
+ try:
134
+ chk = _load(path)
135
+ except SystemExit as exc:
136
+ # _load raises SystemExit with a human message; keep the reason but not
137
+ # the path, which git randomises per invocation.
138
+ reason = str(exc).replace(str(path), "").lstrip(": ").strip()
139
+ sys.stdout.write(f"# chklib: unreadable ({reason})\n")
140
+ return 0
141
+ except Exception as exc: # noqa: BLE001 - a driver must not propagate
142
+ sys.stdout.write(f"# chklib: unreadable ({type(exc).__name__})\n")
143
+ return 0
144
+ sys.stdout.write(render(chk, source=None))
145
+ return 0
146
+
147
+
148
+ _GIT_SETUP = """\
149
+ # 1. Tell git how to render a map as text (once per machine, or --local):
150
+ git config --global diff.starcraft.textconv "chkdiff textconv"
151
+ git config --global diff.starcraft.binary false
152
+
153
+ # 2. Tell git which files that applies to. In .gitattributes:
154
+ *.scm diff=starcraft
155
+ *.scx diff=starcraft
156
+ *.chk diff=starcraft
157
+ """
158
+
159
+
160
+ def _cmd_install_textconv(args: argparse.Namespace) -> int:
161
+ scope = "--local" if args.local else "--global"
162
+ if not args.write:
163
+ sys.stdout.write(_GIT_SETUP)
164
+ sys.stdout.write(
165
+ "\nRe-run with --write to apply the git config, and --local to scope"
166
+ " it to this repository.\n"
167
+ )
168
+ return 0
169
+
170
+ import subprocess
171
+
172
+ for key, value in (
173
+ ("diff.starcraft.textconv", "chkdiff textconv"),
174
+ ("diff.starcraft.binary", "false"),
175
+ ):
176
+ result = subprocess.run(
177
+ ["git", "config", scope, key, value],
178
+ capture_output=True, text=True,
179
+ )
180
+ if result.returncode != 0:
181
+ raise SystemExit(f"git config failed: {result.stderr.strip()}")
182
+ print(f"set {key} = {value!r} ({scope})")
183
+ print("\nNow add these lines to .gitattributes:")
184
+ print(" *.scm diff=starcraft")
185
+ print(" *.scx diff=starcraft")
186
+ print(" *.chk diff=starcraft")
187
+ return 0
188
+
189
+
190
+ def build_parser() -> argparse.ArgumentParser:
191
+ parser = argparse.ArgumentParser(
192
+ prog="chkdiff",
193
+ description="Inspect and compare StarCraft scenario data.",
194
+ )
195
+ parser.add_argument(
196
+ "--version", action="version",
197
+ version=f"chkdiff {__version__} (inspect format v{FORMAT_VERSION})",
198
+ )
199
+ sub = parser.add_subparsers(dest="command", required=True)
200
+
201
+ inspect = sub.add_parser(
202
+ "inspect",
203
+ help="print a stable, deterministic rendering of a scenario",
204
+ description=(
205
+ "Print a deterministic textual rendering of a scenario.chk. "
206
+ "Suitable as a git textconv driver, in which case pass --stable."
207
+ ),
208
+ )
209
+ inspect.add_argument("path", help="path to a scenario.chk")
210
+ inspect.add_argument(
211
+ "--stable", action="store_true",
212
+ help="omit the source filename, for reproducible output (use for git textconv)",
213
+ )
214
+ inspect.add_argument(
215
+ "--strict", action="store_true",
216
+ help="exit non-zero if the file has parse errors",
217
+ )
218
+ inspect.set_defaults(func=_cmd_inspect)
219
+
220
+ compare = sub.add_parser(
221
+ "diff",
222
+ help="compare two scenarios semantically",
223
+ description=(
224
+ "Compare two scenario.chk files by meaning rather than by bytes. "
225
+ "Exit status follows diff(1): 0 when identical, 1 when they differ."
226
+ ),
227
+ )
228
+ compare.add_argument("a", help="path to the first scenario.chk")
229
+ compare.add_argument("b", help="path to the second scenario.chk")
230
+ compare.add_argument(
231
+ "--json", action="store_true",
232
+ help="emit machine-readable JSON instead of text",
233
+ )
234
+ compare.set_defaults(func=_cmd_diff)
235
+
236
+ unpack = sub.add_parser(
237
+ "unpack", help="extract scenario.chk from a .scm/.scx map archive"
238
+ )
239
+ unpack.add_argument("map", help="path to a .scm or .scx")
240
+ unpack.add_argument("out", help="path to write scenario.chk to")
241
+ unpack.set_defaults(func=_cmd_unpack)
242
+
243
+ pack = sub.add_parser(
244
+ "pack",
245
+ help="wrap a scenario.chk into a .scm/.scx map archive",
246
+ description=(
247
+ "Wrap a scenario.chk into a map archive. Stores the scenario "
248
+ "uncompressed by default, which every reader accepts; --compress "
249
+ "uses zlib, which is what euddraft writes for production maps."
250
+ ),
251
+ )
252
+ pack.add_argument("chk", help="path to a scenario.chk")
253
+ pack.add_argument("out", help="path to write the map archive to")
254
+ pack.add_argument(
255
+ "--compress", action="store_true", help="zlib-compress the scenario"
256
+ )
257
+ pack.add_argument(
258
+ "--force", action="store_true", help="pack even if the scenario has parse errors"
259
+ )
260
+ pack.set_defaults(func=_cmd_pack)
261
+
262
+ textconv = sub.add_parser(
263
+ "textconv",
264
+ help="render a map for git's diff.<driver>.textconv",
265
+ description=(
266
+ "Render a map as deterministic text for git. Always exits 0 and "
267
+ "always prints something, because a textconv driver that fails "
268
+ "makes git diff fail."
269
+ ),
270
+ )
271
+ textconv.add_argument("path", help="path to a map or scenario.chk")
272
+ textconv.set_defaults(func=_cmd_textconv)
273
+
274
+ install = sub.add_parser(
275
+ "install-textconv",
276
+ help="print (or apply) the git configuration for map diffs",
277
+ )
278
+ install.add_argument(
279
+ "--write", action="store_true", help="run the git config commands"
280
+ )
281
+ install.add_argument(
282
+ "--local", action="store_true",
283
+ help="scope the config to this repository instead of the whole machine",
284
+ )
285
+ install.set_defaults(func=_cmd_install_textconv)
286
+ return parser
287
+
288
+
289
+ def main(argv: list[str] | None = None) -> int:
290
+ args = build_parser().parse_args(argv)
291
+ try:
292
+ return args.func(args)
293
+ except BrokenPipeError:
294
+ # A downstream consumer closed the pipe -- `chkdiff diff x y | head` is
295
+ # completely normal usage. Python would otherwise flush stdout again at
296
+ # shutdown and print a second traceback to stderr, so point the fd at
297
+ # devnull first. 128 + SIGPIPE(13) is the conventional status.
298
+ devnull = None
299
+ try:
300
+ devnull = os.open(os.devnull, os.O_WRONLY)
301
+ os.dup2(devnull, sys.stdout.fileno())
302
+ except (OSError, AttributeError, ValueError):
303
+ # stdout may not be a real file (a test double, or already closed).
304
+ # There is nothing useful left to do, and raising here would defeat
305
+ # the purpose of catching BrokenPipeError in the first place.
306
+ if devnull is not None:
307
+ os.close(devnull)
308
+ return 141
309
+
310
+
311
+ if __name__ == "__main__": # pragma: no cover
312
+ raise SystemExit(main())