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.
- git4subeditor/__init__.py +41 -0
- git4subeditor/__main__.py +6 -0
- git4subeditor/cli.py +683 -0
- git4subeditor/gitformat.py +427 -0
- git4subeditor/l10n.py +107 -0
- git4subeditor/locales/cn.xml +135 -0
- git4subeditor/locales/en.xml +137 -0
- git4subeditor/locales/ru.xml +137 -0
- git4subeditor/repl.py +256 -0
- git4subeditor/subfile.py +189 -0
- git4subeditor/utils.py +122 -0
- git4subeditor/workspace.py +843 -0
- git4subeditor-0.1.20.dist-info/METADATA +227 -0
- git4subeditor-0.1.20.dist-info/RECORD +17 -0
- git4subeditor-0.1.20.dist-info/WHEEL +4 -0
- git4subeditor-0.1.20.dist-info/entry_points.txt +3 -0
- git4subeditor-0.1.20.dist-info/licenses/LICENSE +21 -0
git4subeditor/subfile.py
ADDED
|
@@ -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
|