git4subeditor 0.1.20__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.
@@ -0,0 +1,189 @@
1
+ """Read and extract Barotrauma submarine files.
2
+
3
+ A .sub file is gzip-compressed XML (magic bytes 1F 8B). Some files produced by
4
+ older tools may be plain XML. Both forms are supported.
5
+ """
6
+
7
+ import gzip
8
+ import sys
9
+ import xml.etree.ElementTree as ET
10
+ from pathlib import Path
11
+
12
+ from git4subeditor.l10n import t
13
+ from git4subeditor.utils import format_message
14
+
15
+ GZIP_MAGIC: bytes = b"\x1f\x8b"
16
+
17
+
18
+ class SubFileError(Exception):
19
+ """Raised when a submarine file cannot be read, extracted, or validated."""
20
+
21
+
22
+ def read_sub_bytes(path: Path) -> bytes:
23
+ """Read the raw bytes of a .sub file.
24
+
25
+ Args:
26
+ path: Path to the .sub file.
27
+
28
+ Returns:
29
+ The raw file contents.
30
+
31
+ Raises:
32
+ SubFileError: If the file cannot be read.
33
+ """
34
+ try:
35
+ return path.read_bytes()
36
+ except OSError as exc:
37
+ raise SubFileError(t("err.read_file", path=path, error=exc)) from exc
38
+
39
+
40
+ def decompress_sub(data: bytes) -> bytes:
41
+ """Decompress .sub data.
42
+
43
+ Gzip content is detected by its magic bytes and decompressed. Any other
44
+ content, such as plain XML, is returned unchanged.
45
+
46
+ Args:
47
+ data: Raw bytes read from a .sub file.
48
+
49
+ Returns:
50
+ The decompressed XML bytes.
51
+
52
+ Raises:
53
+ SubFileError: If gzip decompression fails.
54
+ """
55
+ if data.startswith(GZIP_MAGIC):
56
+ try:
57
+ return gzip.decompress(data)
58
+ except (OSError, EOFError) as exc:
59
+ raise SubFileError(t("err.gzip_decompress", error=exc)) from exc
60
+ return data
61
+
62
+
63
+ def decode_xml(data: bytes, encoding: str | None = None) -> str:
64
+ """Decode XML bytes into text.
65
+
66
+ UTF-8 (with or without BOM) is used by default. UTF-16 is only attempted
67
+ when a UTF-16 BOM is present, so corrupted UTF-8 data is not silently
68
+ decoded into plausible-looking garbage.
69
+
70
+ Args:
71
+ data: XML bytes.
72
+ encoding: Optional encoding name that overrides auto-detection.
73
+
74
+ Returns:
75
+ The decoded XML text.
76
+
77
+ Raises:
78
+ SubFileError: If the requested encoding cannot decode the data.
79
+ """
80
+ if encoding is not None:
81
+ try:
82
+ return data.decode(encoding)
83
+ except (LookupError, UnicodeDecodeError) as exc:
84
+ raise SubFileError(t("err.decode", encoding=encoding, error=exc)) from exc
85
+
86
+ try:
87
+ return data.decode("utf-8-sig")
88
+ except UnicodeDecodeError:
89
+ pass
90
+
91
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
92
+ try:
93
+ return data.decode("utf-16")
94
+ except UnicodeDecodeError as exc:
95
+ raise SubFileError(t("err.decode_utf16", error=exc)) from exc
96
+
97
+ print(
98
+ format_message(t("warning.decode_replace"), label="warning"),
99
+ file=sys.stderr,
100
+ )
101
+ return data.decode("utf-8", errors="replace")
102
+
103
+
104
+ def validate_xml(text: str) -> None:
105
+ """Validate that the text is well-formed XML.
106
+
107
+ Args:
108
+ text: XML text to validate.
109
+
110
+ Raises:
111
+ SubFileError: If the text is not well-formed XML.
112
+ """
113
+ try:
114
+ ET.fromstring(text)
115
+ except ET.ParseError as exc:
116
+ raise SubFileError(t("err.invalid_decompressed_xml", error=exc)) from exc
117
+
118
+
119
+ def unpack_sub(
120
+ src: Path,
121
+ out_dir: Path | None = None,
122
+ *,
123
+ encoding: str | None = None,
124
+ force: bool = False,
125
+ to_stdout: bool = False,
126
+ validate: bool = True,
127
+ ) -> Path | None:
128
+ """Extract a single .sub file.
129
+
130
+ Args:
131
+ src: Path to the input .sub file.
132
+ out_dir: Output directory. Defaults to the input file's directory.
133
+ encoding: Optional encoding name that overrides auto-detection.
134
+ force: Whether to overwrite an existing output file.
135
+ to_stdout: Whether to print the XML instead of writing a file.
136
+ validate: Whether to check that the result is well-formed XML.
137
+
138
+ Returns:
139
+ The output path, or None when writing to stdout.
140
+
141
+ Raises:
142
+ SubFileError: If reading, decompressing, validating, or writing fails,
143
+ or if the output file already exists and force is False.
144
+ """
145
+ raw: bytes = read_sub_bytes(src)
146
+ is_gzip: bool = raw.startswith(GZIP_MAGIC)
147
+ xml_bytes: bytes = decompress_sub(raw)
148
+ text: str = decode_xml(xml_bytes, encoding)
149
+
150
+ if validate:
151
+ validate_xml(text)
152
+
153
+ if to_stdout:
154
+ sys.stdout.write(text)
155
+ if not text.endswith("\n"):
156
+ sys.stdout.write("\n")
157
+ return None
158
+
159
+ target_dir: Path = out_dir if out_dir is not None else src.parent
160
+ try:
161
+ target_dir.mkdir(parents=True, exist_ok=True)
162
+ except OSError as exc:
163
+ raise SubFileError(t("err.create_dir", path=target_dir, error=exc)) from exc
164
+
165
+ dst: Path = target_dir / f"{src.stem}.xml"
166
+ try:
167
+ with dst.open("x", encoding="utf-8", newline="\n") as stream:
168
+ stream.write(text)
169
+ except FileExistsError as exc:
170
+ if not force:
171
+ raise SubFileError(t("err.output_exists", path=dst)) from exc
172
+ try:
173
+ dst.write_text(text, encoding="utf-8", newline="\n")
174
+ except OSError as write_exc:
175
+ raise SubFileError(t("err.write", path=dst, error=write_exc)) from write_exc
176
+ except OSError as exc:
177
+ raise SubFileError(t("err.write", path=dst, error=exc)) from exc
178
+
179
+ kind: str = t("kind.gzip") if is_gzip else t("kind.plain_xml")
180
+ print(
181
+ t(
182
+ "status.unpack",
183
+ src=src.name,
184
+ dst=dst,
185
+ kind=kind,
186
+ bytes=f"{len(xml_bytes):,}",
187
+ )
188
+ )
189
+ return dst
git4subeditor/utils.py ADDED
@@ -0,0 +1,122 @@
1
+ """Miscellaneous console and project utilities."""
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from typing import TextIO
7
+
8
+ _COLORS: dict[str, str] = {
9
+ "yellow": "\033[33m",
10
+ "green": "\033[32m",
11
+ "red": "\033[31m",
12
+ "blue": "\033[34m",
13
+ "light_blue": "\033[94m",
14
+ }
15
+ _RESET = "\033[0m"
16
+
17
+ _LABEL_COLORS: dict[str, str] = {
18
+ "error": "red",
19
+ "warning": "yellow",
20
+ }
21
+
22
+ _BANNER = """\
23
+ ██████╗ ██╗████████╗ ██╗ ██╗
24
+ ██╔════╝ ██║╚══██╔══╝ ██║ ██║
25
+ ██║ ███╗██║ ██║ ███████║
26
+ ██║ ██║██║ ██║ ╚════██║
27
+ ╚██████╔╝██║ ██║ ██║
28
+ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
29
+
30
+ ███████╗██╗ ██╗██████╗ ███████╗██████╗ ██╗████████╗ ██████╗ ██████╗
31
+ ██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██║╚══██╔══╝██╔═══██╗██╔══██╗
32
+ ███████╗██║ ██║██████╔╝█████╗ ██║ ██║██║ ██║ ██║ ██║██████╔╝
33
+ ╚════██║██║ ██║██╔══██╗██╔══╝ ██║ ██║██║ ██║ ██║ ██║██╔══██╗
34
+ ███████║╚██████╔╝██████╔╝███████╗██████╔╝██║ ██║ ╚██████╔╝██║ ██║
35
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
36
+ """
37
+
38
+
39
+ def format_message(
40
+ message: str,
41
+ *,
42
+ label: str | None = None,
43
+ color: str | None = None,
44
+ ) -> str:
45
+ """Format text with an optional colored label.
46
+
47
+ When ``label`` is given, the result is ``label: message`` and the label is
48
+ colored according to ``color`` or the label defaults. Otherwise the whole
49
+ message is colored with ``color``.
50
+
51
+ Args:
52
+ message: Message text.
53
+ label: Optional label placed before the message.
54
+ color: Optional color name such as ``red``, ``green``, or ``blue``.
55
+
56
+ Returns:
57
+ The formatted text.
58
+ """
59
+ if label is None:
60
+ color_code: str | None = _COLORS.get(color) if color else None
61
+ if color_code is None:
62
+ return message
63
+ return _colorize(message, color_code, sys.stdout)
64
+
65
+ color_name: str | None = color or _LABEL_COLORS.get(label)
66
+ color_code = _COLORS.get(color_name) if color_name else None
67
+ label_text: str = label
68
+ if color_code is not None:
69
+ label_text = _colorize(label, color_code, sys.stderr)
70
+ return f"{label_text}: {message}"
71
+
72
+
73
+ def _colorize(text: str, color: str, stream: TextIO | None) -> str:
74
+ """Wrap text in an ANSI color when the stream supports it."""
75
+ if _supports_color(stream):
76
+ return f"{color}{text}{_RESET}"
77
+ return text
78
+
79
+
80
+ def build_banner(version: str) -> tuple[str, ...]:
81
+ """Build the banner with author and version beside the first lines.
82
+
83
+ Args:
84
+ version: Version text shown on the second line.
85
+
86
+ Returns:
87
+ Banner lines with trailing spaces removed.
88
+ """
89
+ lines: list[str] = [line.rstrip() for line in _BANNER.splitlines()]
90
+ text_column: int = max(len(lines[0]), len(lines[1])) + 4
91
+ lines[0] = f"{lines[0]:<{text_column}}by Yinjie"
92
+ lines[1] = f"{lines[1]:<{text_column}}{version}"
93
+ return tuple(lines)
94
+
95
+
96
+ def print_banner(version: str) -> None:
97
+ """Print the banner, or a one-line fallback when the terminal is narrow.
98
+
99
+ Args:
100
+ version: Version text shown on the banner.
101
+ """
102
+ if sys.stdout is None:
103
+ return
104
+ lines: tuple[str, ...] = build_banner(version)
105
+ columns: int = shutil.get_terminal_size(fallback=(80, 24)).columns
106
+ try:
107
+ if columns < max(len(line) for line in lines):
108
+ print(f"Git4SubEditor by Yinjie {version}", end="\n")
109
+ return
110
+ print("\n".join(lines))
111
+ except UnicodeEncodeError:
112
+ pass
113
+
114
+
115
+ def _supports_color(stream: TextIO | None) -> bool:
116
+ """Return whether a text stream supports ANSI color output."""
117
+ if stream is None or "NO_COLOR" in os.environ:
118
+ return False
119
+ try:
120
+ return stream.isatty()
121
+ except AttributeError:
122
+ return False