map-packer 0.1.0__tar.gz
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.
- map_packer-0.1.0/LICENSE +7 -0
- map_packer-0.1.0/PKG-INFO +15 -0
- map_packer-0.1.0/README.md +1 -0
- map_packer-0.1.0/pyproject.toml +29 -0
- map_packer-0.1.0/setup.cfg +4 -0
- map_packer-0.1.0/src/cli.py +94 -0
- map_packer-0.1.0/src/commands/__init__.py +0 -0
- map_packer-0.1.0/src/commands/init.py +122 -0
- map_packer-0.1.0/src/commands/pack.py +281 -0
- map_packer-0.1.0/src/commands/sync.py +402 -0
- map_packer-0.1.0/src/lib/__init__.py +0 -0
- map_packer-0.1.0/src/lib/config.py +24 -0
- map_packer-0.1.0/src/lib/datapack.py +88 -0
- map_packer-0.1.0/src/lib/env.py +21 -0
- map_packer-0.1.0/src/lib/ui.py +154 -0
- map_packer-0.1.0/src/lib/world.py +390 -0
- map_packer-0.1.0/src/map_packer.egg-info/PKG-INFO +15 -0
- map_packer-0.1.0/src/map_packer.egg-info/SOURCES.txt +20 -0
- map_packer-0.1.0/src/map_packer.egg-info/dependency_links.txt +1 -0
- map_packer-0.1.0/src/map_packer.egg-info/entry_points.txt +2 -0
- map_packer-0.1.0/src/map_packer.egg-info/requires.txt +4 -0
- map_packer-0.1.0/src/map_packer.egg-info/top_level.txt +3 -0
map_packer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 yed
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: map-packer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Minecraft Map Packer CLI Tool
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: nbtlib>=2.0.4
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
11
|
+
Requires-Dist: rich>=15.0.0
|
|
12
|
+
Requires-Dist: typer>=0.27.2
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# A Minecraft Map Packer CLI Tool
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# A Minecraft Map Packer CLI Tool
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "map-packer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Minecraft Map Packer CLI Tool"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"nbtlib>=2.0.4",
|
|
16
|
+
"python-dotenv>=1.2.4",
|
|
17
|
+
"rich>=15.0.0",
|
|
18
|
+
"typer>=0.27.2",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
mmp = "cli:app"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
package-dir = { "" = "src" }
|
|
26
|
+
py-modules = ["cli"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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]")
|
|
@@ -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))
|