map-packer 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.
cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """map-packer 命令行入口
2
+
3
+ 集中注册各个子命令,提供 Minecraft 地图打包相关的命令行工具
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError
7
+ from importlib.metadata import version as package_version
8
+
9
+ import typer
10
+
11
+ from commands.init import init as init_command
12
+ from commands.pack import pack as pack_command
13
+ from commands.sync import sync as sync_command
14
+ from lib import ui
15
+
16
+
17
+ def _patch_typer_console() -> None:
18
+ """让 typer 自带的帮助与错误输出也保留颜色
19
+
20
+ typer 会自行创建 console,在 ConPTY 终端(Trae、VS Code)中它会重新把
21
+ 终端判定为「legacy Windows」,从而静默去掉 ``--help`` 的所有颜色
22
+ 复用我们强制 ANSI 的设置,让整个 CLI 的观感保持一致
23
+ """
24
+ try:
25
+ from typer import rich_utils
26
+ except ImportError: # pragma: no cover - typer 始终带有 rich_utils
27
+ return
28
+
29
+ original = rich_utils._get_rich_console
30
+
31
+ def _get_rich_console(stderr: bool = False):
32
+ console = original(stderr=stderr)
33
+ console.legacy_windows = False
34
+ return console
35
+
36
+ rich_utils._get_rich_console = _get_rich_console
37
+
38
+
39
+ _patch_typer_console()
40
+
41
+
42
+ def _version_callback(value: bool) -> None:
43
+ """处理 --version:打印版本号并退出"""
44
+ if not value:
45
+ return
46
+ try:
47
+ current = package_version("map-packer")
48
+ except PackageNotFoundError:
49
+ current = "0.1.0"
50
+ ui.console.print(f"[brand]mmp[/brand] [muted]v{current}[/muted]")
51
+ raise typer.Exit()
52
+
53
+
54
+ # 全局 CLI 应用,no_args_is_help 让用户直接运行时可看到帮助信息
55
+ app = typer.Typer(
56
+ name="mmp",
57
+ help="A Minecraft Map Packer CLI Tool",
58
+ epilog="Run [brand]mmp <command> --help[/brand] for details on a command.",
59
+ no_args_is_help=True,
60
+ add_completion=False,
61
+ rich_markup_mode="rich",
62
+ context_settings={"help_option_names": ["-h", "--help"]},
63
+ )
64
+
65
+ # 注册子命令:mmp init
66
+ app.command(name="init", help="Initialize the current directory as an mmp repository")(
67
+ init_command
68
+ )
69
+
70
+ # 注册子命令:mmp sync
71
+ app.command(name="sync", help="Sync a world save into the target directory")(sync_command)
72
+
73
+ # 注册子命令:mmp pack
74
+ app.command(name="pack", help="Pack the map directory into a compressed archive")(
75
+ pack_command
76
+ )
77
+
78
+
79
+ @app.callback()
80
+ def main(
81
+ version: bool = typer.Option(
82
+ False,
83
+ "--version",
84
+ "-V",
85
+ help="Show the version and exit",
86
+ callback=_version_callback,
87
+ is_eager=True,
88
+ ),
89
+ ) -> None:
90
+ """Minecraft 地图打包命令行工具"""
91
+
92
+
93
+ if __name__ == "__main__":
94
+ app()
commands/__init__.py ADDED
File without changes
commands/init.py ADDED
@@ -0,0 +1,122 @@
1
+ """初始化命令
2
+
3
+ 把当前目录初始化为一个 mmp 仓库:尝试执行 git init、写入默认的
4
+ mmp.json 配置,并创建地图输出目录
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ from rich.markup import escape
16
+
17
+ from lib import ui
18
+
19
+ # 默认配置,字段与 sync / pack 命令读取的 data 字段保持一致
20
+ DEFAULT_CONFIG = {
21
+ "version": "0.0.1",
22
+ "data": {
23
+ "sync": {
24
+ "optimize": True,
25
+ "prune": True,
26
+ "output_path": "./map",
27
+ "exclude": [
28
+ "session.lock",
29
+ "level.dat_old",
30
+ ],
31
+ },
32
+ "pack": {
33
+ "input_path": "./map",
34
+ "output_path": "./dist",
35
+ "name": "map",
36
+ "format": "zip",
37
+ "compression_level": 6,
38
+ "minify_datapack": True,
39
+ },
40
+ },
41
+ }
42
+
43
+ # 初始化时写入 .gitignore 的内容
44
+ GITIGNORE_CONTENT = ".env\ndist\n"
45
+
46
+
47
+ def _run_git_init(cwd: Path) -> bool:
48
+ """尝试在当前目录执行 git init,成功返回 True
49
+
50
+ git 不可用或执行失败时返回 False,由调用方决定如何提示
51
+ """
52
+ if shutil.which("git") is None:
53
+ return False
54
+ try:
55
+ result = subprocess.run(
56
+ ["git", "init"],
57
+ cwd=cwd,
58
+ capture_output=True,
59
+ text=True,
60
+ )
61
+ except OSError:
62
+ return False
63
+ return result.returncode == 0
64
+
65
+
66
+ def init() -> None:
67
+ """Initialize the current directory as an mmp repository.
68
+
69
+ Rules:
70
+ - run `git init` in the current directory when git is available;
71
+ - write the default mmp.json when it does not exist yet;
72
+ - create the output directory from data.sync.output_path.
73
+
74
+ Raises:
75
+ typer.Exit: When mmp.json already exists.
76
+ """
77
+ cwd = Path.cwd()
78
+ config_path = cwd / "mmp.json"
79
+
80
+ ui.header("init", "Initialize the current directory as an mmp repository")
81
+
82
+ if config_path.exists():
83
+ ui.error(
84
+ f"[path]{escape(str(config_path))}[/path] already exists",
85
+ hint="remove it first to re-initialize",
86
+ )
87
+ raise typer.Exit(code=1)
88
+
89
+ # 尝试执行 git init,git 不可用时仅提示,不阻断初始化
90
+ if _run_git_init(cwd):
91
+ ui.phase("Git", "[ok]initialized[/ok] [muted]repository[/muted]")
92
+ else:
93
+ ui.phase("Git", "[muted]skipped · git not available[/muted]")
94
+
95
+ # 写入 .gitignore(追加模式,若已有该文件则保留原有内容)
96
+ gitignore_path = cwd / ".gitignore"
97
+ needs_gitignore = False
98
+ if gitignore_path.exists():
99
+ content = gitignore_path.read_text(encoding="utf-8")
100
+ if ".env" not in content.splitlines():
101
+ gitignore_path.write_text(content + GITIGNORE_CONTENT, encoding="utf-8")
102
+ needs_gitignore = True
103
+ else:
104
+ gitignore_path.write_text(GITIGNORE_CONTENT, encoding="utf-8")
105
+ needs_gitignore = True
106
+ if needs_gitignore:
107
+ ui.phase("Gitignore", f"[path].gitignore[/path]")
108
+
109
+ # 写入默认配置
110
+ with open(config_path, "w", encoding="utf-8") as f:
111
+ json.dump(DEFAULT_CONFIG, f, indent=4)
112
+ f.write("\n")
113
+ ui.phase("Config", f"[path]{escape(str(config_path))}[/path]")
114
+
115
+ # 创建地图输出目录
116
+ output_path = Path(DEFAULT_CONFIG["data"]["sync"]["output_path"])
117
+ target = (cwd / output_path).resolve()
118
+ target.mkdir(parents=True, exist_ok=True)
119
+ ui.phase("Map", f"[path]{escape(str(target))}[/path]")
120
+
121
+ ui.blank()
122
+ ui.success("[bold]Initialized mmp repository[/bold]")
commands/pack.py ADDED
@@ -0,0 +1,281 @@
1
+ """地图打包命令
2
+
3
+ 将同步后的地图目录打包为压缩文件,打包格式、压缩等级与输出位置
4
+ 通过 mmp.json 的 data.pack 字段配置
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tarfile
10
+ import time
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ from rich.markup import escape
16
+ from rich.progress import (
17
+ BarColumn,
18
+ MofNCompleteColumn,
19
+ Progress,
20
+ ProgressColumn,
21
+ SpinnerColumn,
22
+ TextColumn,
23
+ TimeElapsedColumn,
24
+ )
25
+
26
+ from lib import ui
27
+ from lib.config import SUPPORTED_VERSION, get_config
28
+ from lib.datapack import minify_datapacks
29
+
30
+ # 各配置项的默认值,字段与 init 写入的 data.pack 保持一致
31
+ DEFAULT_INPUT = "./map"
32
+ DEFAULT_OUTPUT = "./dist"
33
+ DEFAULT_NAME = "map"
34
+ DEFAULT_FORMAT = "zip"
35
+ DEFAULT_COMPRESSION_LEVEL = 6
36
+ DEFAULT_MINIFY_DATAPACK = True
37
+
38
+ # 支持的打包格式及其输出扩展名
39
+ FORMATS: dict[str, str] = {
40
+ "zip": ".zip",
41
+ "tar": ".tar",
42
+ "tar.gz": ".tar.gz",
43
+ "tar.bz2": ".tar.bz2",
44
+ "tar.xz": ".tar.xz",
45
+ }
46
+
47
+ # 常见别名统一归一化为标准格式名
48
+ FORMAT_ALIASES: dict[str, str] = {
49
+ "tgz": "tar.gz",
50
+ "tbz": "tar.bz2",
51
+ "tbz2": "tar.bz2",
52
+ "txz": "tar.xz",
53
+ }
54
+
55
+
56
+ def _normalize_format(raw: str) -> str:
57
+ """把用户填写的格式归一化为标准格式名,未知格式原样返回"""
58
+ fmt = raw.strip().lower()
59
+ return FORMAT_ALIASES.get(fmt, fmt)
60
+
61
+
62
+ def _level_range(fmt: str) -> tuple[int, int] | None:
63
+ """返回指定格式支持的压缩等级范围,tar 不支持压缩时返回 None"""
64
+ if fmt == "tar":
65
+ return None
66
+ if fmt == "tar.bz2":
67
+ return (1, 9)
68
+ return (0, 9)
69
+
70
+
71
+ def _collect_files(root: Path) -> list[tuple[Path, str]]:
72
+ """收集目录下所有文件,返回 (绝对路径, 归档内相对路径) 列表"""
73
+ files: list[tuple[Path, str]] = []
74
+ for path in sorted(root.rglob("*")):
75
+ if path.is_file():
76
+ files.append((path, path.relative_to(root).as_posix()))
77
+ return files
78
+
79
+
80
+ def _write_zip(target: Path, files: list[tuple[Path, str]], level: int, advance) -> None:
81
+ """把文件写入 zip 归档,level 为 0 时改用仅存储模式"""
82
+ if level == 0:
83
+ with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_STORED) as zf:
84
+ for full, rel in files:
85
+ zf.write(full, arcname=rel)
86
+ advance()
87
+ return
88
+
89
+ with zipfile.ZipFile(
90
+ target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=level
91
+ ) as zf:
92
+ for full, rel in files:
93
+ zf.write(full, arcname=rel)
94
+ advance()
95
+
96
+
97
+ def _write_tar(target: Path, fmt: str, files: list[tuple[Path, str]], level: int, advance) -> None:
98
+ """把文件写入 tar 归档,按格式选择对应的压缩器与参数"""
99
+ mode = {
100
+ "tar": "w",
101
+ "tar.gz": "w:gz",
102
+ "tar.bz2": "w:bz2",
103
+ "tar.xz": "w:xz",
104
+ }[fmt]
105
+
106
+ kwargs: dict[str, int] = {}
107
+ if fmt in ("tar.gz", "tar.bz2"):
108
+ kwargs["compresslevel"] = level
109
+ elif fmt == "tar.xz":
110
+ kwargs["preset"] = level
111
+
112
+ with tarfile.open(target, mode, **kwargs) as tf:
113
+ for full, rel in files:
114
+ tf.add(full, arcname=rel)
115
+ advance()
116
+
117
+
118
+ def pack(
119
+ config: str = typer.Option(
120
+ "./mmp.json",
121
+ "--config",
122
+ "-c",
123
+ help="Path to the mmp.json configuration file",
124
+ ),
125
+ ) -> None:
126
+ """Pack the target directory into a compressed archive.
127
+
128
+ Rules:
129
+ - the source directory, output directory, archive name, format and
130
+ compression level come from the "data.pack" field in mmp.json;
131
+ - when "data.pack.minify_datapack" is enabled, datapack text files have their
132
+ comments (lines starting with #) and blank lines removed, and every JSON
133
+ file is rewritten without indentation or spaces before packing;
134
+ - the packed size change and shrink percentage are reported when packing;
135
+ - supported formats are zip, tar, tar.gz, tar.bz2 and tar.xz;
136
+ - mmp.json must declare version 0.0.1, the only supported format version.
137
+
138
+ Args:
139
+ config: Path to the mmp.json configuration file.
140
+
141
+ Raises:
142
+ typer.Exit: When the source directory is missing, the format is
143
+ unsupported, or the mmp.json version is unsupported.
144
+ """
145
+ # 读取配置:打包选项位于 mmp.json 的 data.pack 字段
146
+ config_data = get_config(config)
147
+ version = config_data.get("version")
148
+ if version != SUPPORTED_VERSION:
149
+ ui.error(
150
+ f"Unsupported mmp.json version: [path]{escape(str(version))}[/path]",
151
+ hint=f"only version {SUPPORTED_VERSION} is supported",
152
+ )
153
+ raise typer.Exit(code=1)
154
+
155
+ data = config_data.get("data") or {}
156
+ pack_config = data.get("pack") or {}
157
+
158
+ source = (
159
+ Path(pack_config.get("input_path", DEFAULT_INPUT)).expanduser().resolve()
160
+ )
161
+ output_dir = (
162
+ Path(pack_config.get("output_path", DEFAULT_OUTPUT)).expanduser().resolve()
163
+ )
164
+ name = str(pack_config.get("name", DEFAULT_NAME))
165
+ fmt = _normalize_format(str(pack_config.get("format", DEFAULT_FORMAT)))
166
+ try:
167
+ level = int(pack_config.get("compression_level", DEFAULT_COMPRESSION_LEVEL))
168
+ except (TypeError, ValueError):
169
+ ui.error(
170
+ "Invalid [path]data.pack.compression_level[/path] in mmp.json",
171
+ hint="expected an integer",
172
+ )
173
+ raise typer.Exit(code=1)
174
+ minify = bool(pack_config.get("minify_datapack", DEFAULT_MINIFY_DATAPACK))
175
+
176
+ # 校验源目录
177
+ if not source.is_dir():
178
+ ui.error(
179
+ f"Input directory not found: [path]{escape(str(source))}[/path]",
180
+ hint="run mmp sync first or point data.pack.input_path at a map directory",
181
+ )
182
+ raise typer.Exit(code=1)
183
+
184
+ # 校验打包格式
185
+ if fmt not in FORMATS:
186
+ supported = ", ".join(FORMATS)
187
+ ui.error(
188
+ f"Unsupported pack format: [path]{escape(fmt)}[/path]",
189
+ hint=f"supported formats: {supported}",
190
+ )
191
+ raise typer.Exit(code=1)
192
+
193
+ # 校验压缩等级
194
+ bounds = _level_range(fmt)
195
+ if bounds is not None and not bounds[0] <= level <= bounds[1]:
196
+ ui.error(
197
+ f"Invalid compression_level [num]{level}[/num] for format [path]{escape(fmt)}[/path]",
198
+ hint=f"expected an integer between {bounds[0]} and {bounds[1]}",
199
+ )
200
+ raise typer.Exit(code=1)
201
+
202
+ target = output_dir / f"{name}{FORMATS[fmt]}"
203
+
204
+ ui.header("pack", "Pack the map directory into a compressed archive")
205
+ ui.panel(
206
+ "Configuration",
207
+ [
208
+ ("Source", f"[path]{escape(str(source))}[/path]"),
209
+ ("Output", f"[path]{escape(str(target))}[/path]"),
210
+ ("Format", f"[value]{escape(fmt)}[/value]"),
211
+ ("Level", f"[num]{level}[/num]" if bounds else "[muted]n/a[/muted]"),
212
+ ("Minify", "[ok]enabled[/ok]" if minify else "[muted]disabled[/muted]"),
213
+ ],
214
+ )
215
+ ui.blank()
216
+
217
+ with ui.console.status("[brand]Scanning files…[/brand]", spinner="dots"):
218
+ files = _collect_files(source)
219
+ ui.phase("Scanning", f"[num]{len(files):,}[/num] [muted]files[/muted]")
220
+
221
+ if not files:
222
+ ui.error(
223
+ f"No files found in [path]{escape(str(source))}[/path]",
224
+ hint="run mmp sync first to populate the map directory",
225
+ )
226
+ raise typer.Exit(code=1)
227
+
228
+ # 记录精简前的源目录总体积,用于展示打包带来的体积变化
229
+ source_size = sum(path.stat().st_size for path, _rel in files)
230
+
231
+ # 精简数据包:去除注释与空行、把 JSON 压缩为无缩进无空格格式
232
+ if minify:
233
+ ui.blank()
234
+ with ui.console.status("[brand]Minifying datapacks…[/brand]", spinner="dots"):
235
+ datapack_files, datapack_saved = minify_datapacks(source)
236
+ if datapack_files:
237
+ ui.phase(
238
+ "Minify",
239
+ f"[num]{datapack_files:,}[/num] [muted]file(s) · saved[/muted] "
240
+ f"[num]{ui.format_size(datapack_saved)}[/num]",
241
+ )
242
+ else:
243
+ ui.phase("Minify", "[muted]nothing to minify[/muted]")
244
+
245
+ output_dir.mkdir(parents=True, exist_ok=True)
246
+ started = time.perf_counter()
247
+
248
+ columns: list[ProgressColumn] = [
249
+ SpinnerColumn(style="brand"),
250
+ TextColumn("{task.description}"),
251
+ BarColumn(
252
+ bar_width=None,
253
+ complete_style="brand",
254
+ finished_style="ok",
255
+ pulse_style="brand",
256
+ ),
257
+ MofNCompleteColumn(),
258
+ TimeElapsedColumn(),
259
+ ]
260
+
261
+ with Progress(*columns, console=ui.console) as progress:
262
+ task = progress.add_task(ui.task_label("Packing"), total=len(files))
263
+
264
+ def advance() -> None:
265
+ progress.advance(task)
266
+
267
+ if fmt == "zip":
268
+ _write_zip(target, files, level, advance)
269
+ else:
270
+ _write_tar(target, fmt, files, level, advance)
271
+
272
+ elapsed = time.perf_counter() - started
273
+ size = target.stat().st_size
274
+
275
+ ui.blank()
276
+ ui.success(
277
+ f"[bold]Pack complete[/bold] [muted]in {elapsed:.2f}s[/muted] "
278
+ f"[muted]·[/muted] [num]{len(files):,}[/num] [muted]files →[/muted] "
279
+ f"[path]{escape(str(target))}[/path] [muted]({ui.format_size(size)})[/muted]"
280
+ )
281
+ ui.phase("Size", ui.size_delta(source_size, size))