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 +94 -0
- commands/__init__.py +0 -0
- commands/init.py +122 -0
- commands/pack.py +281 -0
- commands/sync.py +402 -0
- lib/__init__.py +0 -0
- lib/config.py +24 -0
- lib/datapack.py +88 -0
- lib/env.py +21 -0
- lib/ui.py +154 -0
- lib/world.py +390 -0
- map_packer-0.1.0.dist-info/METADATA +15 -0
- map_packer-0.1.0.dist-info/RECORD +17 -0
- map_packer-0.1.0.dist-info/WHEEL +5 -0
- map_packer-0.1.0.dist-info/entry_points.txt +2 -0
- map_packer-0.1.0.dist-info/licenses/LICENSE +7 -0
- map_packer-0.1.0.dist-info/top_level.txt +3 -0
commands/sync.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""地图同步命令
|
|
2
|
+
|
|
3
|
+
将 Minecraft 存档目录(由 .env 中的 SAVE_PATH 指定)同步到目标目录,
|
|
4
|
+
忽略规则通过 mmp.json 的 data.exclude 字段配置
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from dotenv import find_dotenv, load_dotenv
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.progress import (
|
|
19
|
+
BarColumn,
|
|
20
|
+
MofNCompleteColumn,
|
|
21
|
+
Progress,
|
|
22
|
+
ProgressColumn,
|
|
23
|
+
SpinnerColumn,
|
|
24
|
+
TextColumn,
|
|
25
|
+
TimeElapsedColumn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from lib import ui
|
|
29
|
+
from lib.config import SUPPORTED_VERSION, get_config
|
|
30
|
+
from lib.world import optimize_world, prune_world, region_matches
|
|
31
|
+
|
|
32
|
+
DEFAULT_EXCLUDES = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_ignore_patterns(patterns: list[str]) -> list[str]:
|
|
36
|
+
"""过滤空行与 # 注释行,返回有效的忽略规则
|
|
37
|
+
|
|
38
|
+
会去掉规则开头的 "./",因为匹配时使用的是不带该前缀的相对路径
|
|
39
|
+
"""
|
|
40
|
+
cleaned: list[str] = []
|
|
41
|
+
for raw in patterns:
|
|
42
|
+
p = raw.strip()
|
|
43
|
+
if not p or p.startswith("#"):
|
|
44
|
+
continue
|
|
45
|
+
negate = p.startswith("!")
|
|
46
|
+
body = p[1:] if negate else p
|
|
47
|
+
while body.startswith("./"):
|
|
48
|
+
body = body[2:]
|
|
49
|
+
cleaned.append(("!" if negate else "") + body)
|
|
50
|
+
return cleaned
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _match_ignore(rel_path: str, is_dir: bool, patterns: list[str]) -> bool:
|
|
54
|
+
"""判断路径是否命中忽略规则(支持 ! 取反与目录规则)"""
|
|
55
|
+
rel = rel_path.replace(os.sep, "/")
|
|
56
|
+
rel_dir = rel + "/" if is_dir and not rel.endswith("/") else rel
|
|
57
|
+
ignored = False
|
|
58
|
+
|
|
59
|
+
for raw in patterns:
|
|
60
|
+
negate = raw.startswith("!")
|
|
61
|
+
pat = raw[1:] if negate else raw
|
|
62
|
+
dir_only = pat.endswith("/")
|
|
63
|
+
if dir_only:
|
|
64
|
+
pat = pat.rstrip("/")
|
|
65
|
+
|
|
66
|
+
candidates = [rel, rel_dir]
|
|
67
|
+
name = rel.rsplit("/", 1)[-1]
|
|
68
|
+
|
|
69
|
+
matched = False
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
if fnmatch.fnmatch(candidate, pat):
|
|
72
|
+
matched = True
|
|
73
|
+
break
|
|
74
|
+
if "/" not in pat and fnmatch.fnmatch(name, pat):
|
|
75
|
+
matched = True
|
|
76
|
+
break
|
|
77
|
+
if pat.startswith("**/") and fnmatch.fnmatch(candidate, pat[3:]):
|
|
78
|
+
matched = True
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if dir_only and matched and not is_dir:
|
|
82
|
+
matched = any(c.startswith(pat + "/") for c in candidates)
|
|
83
|
+
|
|
84
|
+
if matched:
|
|
85
|
+
ignored = not negate
|
|
86
|
+
|
|
87
|
+
return ignored
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _iter_tree(root: Path, patterns: list[str]):
|
|
91
|
+
"""递归遍历目录,产出 (绝对路径, 相对路径) 并跳过被忽略的条目"""
|
|
92
|
+
for dirpath, dir_names, filenames in os.walk(root):
|
|
93
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
94
|
+
if rel_dir == ".":
|
|
95
|
+
rel_dir = ""
|
|
96
|
+
|
|
97
|
+
dir_names[:] = [
|
|
98
|
+
d
|
|
99
|
+
for d in dir_names
|
|
100
|
+
if not _match_ignore(
|
|
101
|
+
os.path.join(rel_dir, d) if rel_dir else d,
|
|
102
|
+
True,
|
|
103
|
+
patterns,
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
for filename in filenames:
|
|
108
|
+
rel = os.path.join(rel_dir, filename) if rel_dir else filename
|
|
109
|
+
if _match_ignore(rel, False, patterns):
|
|
110
|
+
continue
|
|
111
|
+
yield Path(dirpath) / filename, rel
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _collect_source(save_path: Path, patterns: list[str]) -> dict[str, Path]:
|
|
115
|
+
"""扫描源目录,返回 {相对路径: 绝对路径} 映射"""
|
|
116
|
+
files: dict[str, Path] = {}
|
|
117
|
+
for full, rel in _iter_tree(save_path, patterns):
|
|
118
|
+
files[rel.replace(os.sep, "/")] = full
|
|
119
|
+
return files
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _same_file(a: Path, b: Path) -> bool:
|
|
123
|
+
"""比较两个文件是否一致(先比大小再比字节)"""
|
|
124
|
+
try:
|
|
125
|
+
if a.stat().st_size != b.stat().st_size:
|
|
126
|
+
return False
|
|
127
|
+
return a.read_bytes() == b.read_bytes()
|
|
128
|
+
except OSError:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _files_match(src: Path, dst: Path, optimize: bool, prune: bool) -> bzool:
|
|
133
|
+
"""判断源文件与目标文件是否一致
|
|
134
|
+
|
|
135
|
+
启用 prune / optimize 时,已被剔除区块或擦除缓存的 region 文件与源文件
|
|
136
|
+
内容语义相同,视为一致,避免处理后的目标文件每次都因内容差异被误判为改动
|
|
137
|
+
"""
|
|
138
|
+
if _same_file(src, dst):
|
|
139
|
+
return True
|
|
140
|
+
if src.suffix == ".mca" and (optimize or prune):
|
|
141
|
+
return region_matches(src, dst, prune=prune, optimize=optimize)
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _plan(
|
|
146
|
+
source: dict[str, Path], target: Path, optimize: bool, prune: bool
|
|
147
|
+
):
|
|
148
|
+
"""计算同步计划,返回 (待复制列表, 待删除列表)"""
|
|
149
|
+
to_copy: list[tuple[Path, Path]] = []
|
|
150
|
+
to_delete: list[Path] = []
|
|
151
|
+
|
|
152
|
+
for rel, src in source.items():
|
|
153
|
+
dst = target / rel
|
|
154
|
+
if dst.exists() and _files_match(src, dst, optimize, prune):
|
|
155
|
+
continue
|
|
156
|
+
to_copy.append((src, dst))
|
|
157
|
+
|
|
158
|
+
if target.exists():
|
|
159
|
+
for full, rel in _iter_tree(target, []):
|
|
160
|
+
key = rel.replace(os.sep, "/")
|
|
161
|
+
if key not in source:
|
|
162
|
+
to_delete.append(full)
|
|
163
|
+
|
|
164
|
+
return to_copy, to_delete
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ensure_dir(path: Path) -> None:
|
|
168
|
+
"""确保目录存在,不存在则递归创建"""
|
|
169
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _copy_file(src: Path, dst: Path) -> None:
|
|
173
|
+
"""复制文件并保留元数据"""
|
|
174
|
+
_ensure_dir(dst.parent)
|
|
175
|
+
shutil.copy2(src, dst)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _remove_empty_dirs(root: Path) -> None:
|
|
179
|
+
"""自底向上删除目标目录中遗留的空目录"""
|
|
180
|
+
if not root.exists():
|
|
181
|
+
return
|
|
182
|
+
for dirpath, _dir_names, _filenames in os.walk(root, topdown=False):
|
|
183
|
+
current = Path(dirpath)
|
|
184
|
+
if current == root:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
if not any(current.iterdir()):
|
|
188
|
+
current.rmdir()
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _dir_size(root: Path) -> int:
|
|
194
|
+
"""统计目录下所有文件的总字节数"""
|
|
195
|
+
total = 0
|
|
196
|
+
for path in root.rglob("*"):
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
total += path.stat().st_size
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
return total
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def sync(
|
|
207
|
+
config: str = typer.Option(
|
|
208
|
+
"./mmp.json",
|
|
209
|
+
"--config",
|
|
210
|
+
"-c",
|
|
211
|
+
help="Path to the mmp.json configuration file",
|
|
212
|
+
),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Sync the SAVE_PATH world save into the target directory.
|
|
215
|
+
|
|
216
|
+
Rules:
|
|
217
|
+
- files that are new or changed in the source are copied to the target;
|
|
218
|
+
- files present only in the target are deleted;
|
|
219
|
+
- the target directory, ignore rules and sync options come from the
|
|
220
|
+
"data.sync" field in mmp.json;
|
|
221
|
+
- mmp.json must declare version 0.0.1, the only supported format version.
|
|
222
|
+
- when prune is enabled, chunks that were generated from world noise and never
|
|
223
|
+
modified (no entities, block entities, ticks or block changes) are removed
|
|
224
|
+
after syncing; the game regenerates them from the seed on first load.
|
|
225
|
+
- when optimize is enabled, cached light and heightmap data is erased from
|
|
226
|
+
the synced chunks so the game recomputes them on first load.
|
|
227
|
+
- the size change and shrink percentage against the source save are reported
|
|
228
|
+
after syncing.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
config: Path to the mmp.json configuration file.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
typer.Exit: When SAVE_PATH is unset or is not a valid directory, or the
|
|
235
|
+
mmp.json version is unsupported.
|
|
236
|
+
"""
|
|
237
|
+
env_file = find_dotenv(usecwd=True)
|
|
238
|
+
if env_file:
|
|
239
|
+
load_dotenv(env_file)
|
|
240
|
+
|
|
241
|
+
# 读取并校验存档目录
|
|
242
|
+
save_path_raw = os.getenv("SAVE_PATH")
|
|
243
|
+
if not save_path_raw:
|
|
244
|
+
ui.error(
|
|
245
|
+
"SAVE_PATH is not set",
|
|
246
|
+
hint="add SAVE_PATH=<world save directory> to your .env",
|
|
247
|
+
)
|
|
248
|
+
raise typer.Exit(code=1)
|
|
249
|
+
|
|
250
|
+
save_path = Path(save_path_raw).expanduser().resolve()
|
|
251
|
+
if not save_path.is_dir():
|
|
252
|
+
ui.error(
|
|
253
|
+
f"SAVE_PATH is not a directory: [path]{escape(str(save_path))}[/path]",
|
|
254
|
+
hint="point SAVE_PATH in .env at an existing world save",
|
|
255
|
+
)
|
|
256
|
+
raise typer.Exit(code=1)
|
|
257
|
+
|
|
258
|
+
# 读取配置:忽略规则与同步选项位于 mmp.json 的 data.sync 字段
|
|
259
|
+
config_data = get_config(config)
|
|
260
|
+
version = config_data.get("version")
|
|
261
|
+
if version != SUPPORTED_VERSION:
|
|
262
|
+
ui.error(
|
|
263
|
+
f"Unsupported mmp.json version: [path]{escape(str(version))}[/path]",
|
|
264
|
+
hint=f"only version {SUPPORTED_VERSION} is supported",
|
|
265
|
+
)
|
|
266
|
+
raise typer.Exit(code=1)
|
|
267
|
+
|
|
268
|
+
data = config_data.get("data") or {}
|
|
269
|
+
sync_config = data.get("sync") or {}
|
|
270
|
+
|
|
271
|
+
optimize = bool(sync_config.get("optimize", True))
|
|
272
|
+
prune = bool(sync_config.get("prune", True))
|
|
273
|
+
target_raw = sync_config.get("output_path", "./map")
|
|
274
|
+
|
|
275
|
+
excludes = sync_config.get("exclude") or list(DEFAULT_EXCLUDES)
|
|
276
|
+
patterns = _load_ignore_patterns(list(excludes))
|
|
277
|
+
|
|
278
|
+
target = Path(target_raw).expanduser().resolve()
|
|
279
|
+
_ensure_dir(target)
|
|
280
|
+
|
|
281
|
+
# 打印同步概要
|
|
282
|
+
ui.header("sync", "Pack a Minecraft world save into a clean directory")
|
|
283
|
+
ui.panel(
|
|
284
|
+
"Configuration",
|
|
285
|
+
[
|
|
286
|
+
("Source", f"[path]{escape(str(save_path))}[/path]"),
|
|
287
|
+
("Target", f"[path]{escape(str(target))}[/path]"),
|
|
288
|
+
("Ignore", f"[num]{len(patterns)}[/num] [muted]pattern(s)[/muted]"),
|
|
289
|
+
("Prune", "[ok]enabled[/ok]" if prune else "[muted]disabled[/muted]"),
|
|
290
|
+
("Optimize", "[ok]enabled[/ok]" if optimize else "[muted]disabled[/muted]"),
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
ui.blank()
|
|
294
|
+
|
|
295
|
+
started = time.perf_counter()
|
|
296
|
+
|
|
297
|
+
# 扫描源目录并计算与目标目录的差异
|
|
298
|
+
with ui.console.status("[brand]Scanning source…[/brand]", spinner="dots"):
|
|
299
|
+
source = _collect_source(save_path, patterns)
|
|
300
|
+
ui.phase("Scanning", f"[num]{len(source):,}[/num] [muted]files[/muted]")
|
|
301
|
+
|
|
302
|
+
# 记录源存档总体积,用于展示同步与优化带来的体积变化
|
|
303
|
+
source_size = 0
|
|
304
|
+
for path in source.values():
|
|
305
|
+
try:
|
|
306
|
+
source_size += path.stat().st_size
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
with ui.console.status("[brand]Computing diff…[/brand]", spinner="dots"):
|
|
311
|
+
to_copy, to_delete = _plan(source, target, optimize, prune)
|
|
312
|
+
ui.phase(
|
|
313
|
+
"Planning",
|
|
314
|
+
f"[ok]+{len(to_copy):,}[/ok] [muted]add[/muted] "
|
|
315
|
+
f"[err]-{len(to_delete):,}[/err] [muted]remove[/muted]",
|
|
316
|
+
)
|
|
317
|
+
ui.blank()
|
|
318
|
+
|
|
319
|
+
if not to_copy and not to_delete:
|
|
320
|
+
ui.success("[bold]Already in sync[/bold] [muted]· nothing to do[/muted]")
|
|
321
|
+
else:
|
|
322
|
+
columns: list[ProgressColumn] = [
|
|
323
|
+
SpinnerColumn(style="brand"),
|
|
324
|
+
TextColumn("{task.description}"),
|
|
325
|
+
BarColumn(
|
|
326
|
+
bar_width=None,
|
|
327
|
+
complete_style="brand",
|
|
328
|
+
finished_style="ok",
|
|
329
|
+
pulse_style="brand",
|
|
330
|
+
),
|
|
331
|
+
MofNCompleteColumn(),
|
|
332
|
+
TimeElapsedColumn(),
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
with Progress(*columns, console=ui.console) as progress:
|
|
336
|
+
if to_copy:
|
|
337
|
+
copy_task = progress.add_task(
|
|
338
|
+
ui.task_label("Copying"),
|
|
339
|
+
total=len(to_copy),
|
|
340
|
+
)
|
|
341
|
+
for src, dst in to_copy:
|
|
342
|
+
_copy_file(src, dst)
|
|
343
|
+
progress.advance(copy_task)
|
|
344
|
+
|
|
345
|
+
if to_delete:
|
|
346
|
+
delete_task = progress.add_task(
|
|
347
|
+
ui.task_label("Deleting"),
|
|
348
|
+
total=len(to_delete),
|
|
349
|
+
)
|
|
350
|
+
for file in to_delete:
|
|
351
|
+
try:
|
|
352
|
+
file.unlink()
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
progress.advance(delete_task)
|
|
356
|
+
|
|
357
|
+
_remove_empty_dirs(target)
|
|
358
|
+
|
|
359
|
+
# 汇总耗时与变更数量
|
|
360
|
+
elapsed = time.perf_counter() - started
|
|
361
|
+
parts: list[str] = []
|
|
362
|
+
if to_copy:
|
|
363
|
+
parts.append(f"[ok]+{len(to_copy):,}[/ok] [muted]added[/muted]")
|
|
364
|
+
if to_delete:
|
|
365
|
+
parts.append(f"[err]-{len(to_delete):,}[/err] [muted]removed[/muted]")
|
|
366
|
+
detail = f" [muted]·[/muted] {' '.join(parts)}" if parts else ""
|
|
367
|
+
ui.blank()
|
|
368
|
+
ui.success(
|
|
369
|
+
f"[bold]Sync complete[/bold] [muted]in {elapsed:.2f}s[/muted]{detail}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# 剔除未改动区块:删除噪声生成后从未被改动的原始区块
|
|
373
|
+
if prune:
|
|
374
|
+
ui.blank()
|
|
375
|
+
with ui.console.status("[brand]Pruning chunks…[/brand]", spinner="dots"):
|
|
376
|
+
region_files, chunks = prune_world(target)
|
|
377
|
+
if chunks:
|
|
378
|
+
ui.phase(
|
|
379
|
+
"Prune",
|
|
380
|
+
f"[num]{chunks:,}[/num] [muted]chunk(s) in[/muted] "
|
|
381
|
+
f"[num]{region_files:,}[/num] [muted]region file(s) removed[/muted]",
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
ui.phase("Prune", "[muted]nothing to prune[/muted]")
|
|
385
|
+
|
|
386
|
+
# 优化世界:擦除区块缓存(eraseCache)
|
|
387
|
+
if optimize:
|
|
388
|
+
ui.blank()
|
|
389
|
+
with ui.console.status("[brand]Optimizing world…[/brand]", spinner="dots"):
|
|
390
|
+
region_files, chunks = optimize_world(target)
|
|
391
|
+
if chunks:
|
|
392
|
+
ui.phase(
|
|
393
|
+
"Optimize",
|
|
394
|
+
f"[num]{chunks:,}[/num] [muted]chunk(s) in[/muted] "
|
|
395
|
+
f"[num]{region_files:,}[/num] [muted]region file(s) cleaned[/muted]",
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
ui.phase("Optimize", "[muted]already clean[/muted]")
|
|
399
|
+
|
|
400
|
+
# 汇总体积变化:源存档总大小 → 同步并优化后的目录大小
|
|
401
|
+
ui.blank()
|
|
402
|
+
ui.phase("Size", ui.size_delta(source_size, _dir_size(target)))
|
lib/__init__.py
ADDED
|
File without changes
|
lib/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""配置读取工具"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
# 当前唯一支持的配置文件格式版本
|
|
6
|
+
SUPPORTED_VERSION = "0.0.1"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_config(path: str = "./mmp.json") -> dict:
|
|
10
|
+
"""读取 JSON 格式的配置文件
|
|
11
|
+
|
|
12
|
+
配置文件不存在时返回空字典,由调用方决定默认值
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: 配置文件路径,默认为当前目录下的 mmp.json
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
解析后的配置字典;文件缺失时返回空字典
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
return {}
|
lib/datapack.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""数据包精简工具
|
|
2
|
+
|
|
3
|
+
删除数据包目录下文本文件的注释行(以 # 开头)与空行,并把所有 JSON
|
|
4
|
+
文件重新编码为无缩进、无空格的紧凑格式,以减小数据包体积
|
|
5
|
+
|
|
6
|
+
仅处理已知的文本类型,避免误伤纹理解析(.png)与结构文件(.nbt)等二进制内容
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# 需要处理的文本文件后缀
|
|
15
|
+
_TEXT_SUFFIXES = {".json", ".mcmeta", ".mcfunction", ".txt"}
|
|
16
|
+
# 需要重新编码为紧凑格式的 JSON 文件后缀
|
|
17
|
+
_JSON_SUFFIXES = {".json", ".mcmeta"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_comment_or_blank(line: str) -> bool:
|
|
21
|
+
"""判断一行是否为注释行(以 # 开头)或空行"""
|
|
22
|
+
stripped = line.strip()
|
|
23
|
+
return not stripped or stripped.startswith("#")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strip_text(raw: str) -> str:
|
|
27
|
+
"""删除文本中的注释行与空行"""
|
|
28
|
+
lines = [line for line in raw.splitlines() if not _is_comment_or_blank(line)]
|
|
29
|
+
return "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compact_json(raw: str) -> str | None:
|
|
33
|
+
"""把 JSON 文本重新编码为无缩进无空格的紧凑格式;解析失败返回 None"""
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(raw)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return None
|
|
38
|
+
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _minify_file(path: Path) -> int | None:
|
|
42
|
+
"""精简单个数据包文件,返回节省的字节数;未改动返回 None"""
|
|
43
|
+
suffix = path.suffix.lower()
|
|
44
|
+
if suffix not in _TEXT_SUFFIXES:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
raw = path.read_text(encoding="utf-8")
|
|
49
|
+
except (OSError, UnicodeDecodeError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# JSON 优先尝试紧凑编码,格式非法时退化为仅去除注释与空行
|
|
53
|
+
compacted = _compact_json(raw) if suffix in _JSON_SUFFIXES else None
|
|
54
|
+
new = compacted if compacted is not None else _strip_text(raw)
|
|
55
|
+
if new == raw:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
path.write_text(new, encoding="utf-8")
|
|
60
|
+
except OSError:
|
|
61
|
+
return None
|
|
62
|
+
return len(raw.encode("utf-8")) - len(new.encode("utf-8"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def minify_datapacks(root: Path) -> tuple[int, int]:
|
|
66
|
+
"""精简 root/datapacks 目录下的所有数据包文件
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
root: 地图目录(数据包位于其下的 datapacks 子目录)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
(改动的文件数, 节省的字节数)
|
|
73
|
+
"""
|
|
74
|
+
datapacks = root / "datapacks"
|
|
75
|
+
if not datapacks.is_dir():
|
|
76
|
+
return 0, 0
|
|
77
|
+
|
|
78
|
+
files = 0
|
|
79
|
+
saved = 0
|
|
80
|
+
for path in sorted(datapacks.rglob("*")):
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
continue
|
|
83
|
+
delta = _minify_file(path)
|
|
84
|
+
if delta is None:
|
|
85
|
+
continue
|
|
86
|
+
files += 1
|
|
87
|
+
saved += max(delta, 0)
|
|
88
|
+
return files, saved
|
lib/env.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""环境变量读取工具"""
|
|
2
|
+
|
|
3
|
+
from os import PathLike, getenv
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Env:
|
|
9
|
+
"""封装 .env 文件的加载与变量读取"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, dotenv_path: str | PathLike[str] | None = None) -> None:
|
|
12
|
+
"""加载 .env 文件
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
dotenv_path: 自定义 .env 路径,为空时按默认规则查找
|
|
16
|
+
"""
|
|
17
|
+
load_dotenv(dotenv_path=dotenv_path)
|
|
18
|
+
|
|
19
|
+
def __getitem__(self, name: str) -> str | None:
|
|
20
|
+
"""按名称读取环境变量,未设置时返回 None"""
|
|
21
|
+
return getenv(name)
|
lib/ui.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""终端 UI 主题与排版辅助
|
|
2
|
+
|
|
3
|
+
集中定义 CLI 的配色、图标与常用排版块,让各命令拥有一致、清晰的视觉风格
|
|
4
|
+
设计参考 uv 与 docker:用品牌色突出关键信息,用语义色区分状态,
|
|
5
|
+
只有次要信息才使用弱化色
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.theme import Theme
|
|
17
|
+
|
|
18
|
+
# 语义化主题,所有颜色都通过名称引用,保证调色板集中在一处
|
|
19
|
+
THEME = Theme(
|
|
20
|
+
{
|
|
21
|
+
"brand": "bold bright_cyan",
|
|
22
|
+
"ok": "bold bright_green",
|
|
23
|
+
"err": "bold bright_red",
|
|
24
|
+
"key": "bold bright_blue",
|
|
25
|
+
"value": "bold white",
|
|
26
|
+
"path": "bright_cyan",
|
|
27
|
+
"num": "bold bright_yellow",
|
|
28
|
+
"muted": "dim",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# IDE 终端(Trae、VS Code)与 ConPTY 会话经常错误地设置 TERM=dumb,导致
|
|
33
|
+
# rich 以及 typer 自带的帮助输出去掉所有颜色该值仅在 stdout 为 TTY 时才
|
|
34
|
+
# 有意义,因此覆盖它不会把转义码泄漏到管道输出中
|
|
35
|
+
if os.environ.get("TERM", "").lower() in ("dumb", "unknown"):
|
|
36
|
+
os.environ["TERM"] = "xterm-256color"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_color_system() -> str | None:
|
|
40
|
+
"""选择颜色系统,同时尊重 NO_COLOR 与非 TTY 输出"""
|
|
41
|
+
if os.environ.get("NO_COLOR"):
|
|
42
|
+
return None
|
|
43
|
+
if os.environ.get("FORCE_COLOR"):
|
|
44
|
+
return "truecolor"
|
|
45
|
+
if not sys.stdout.isatty():
|
|
46
|
+
return None
|
|
47
|
+
if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit"):
|
|
48
|
+
return "truecolor"
|
|
49
|
+
if "256color" in os.environ.get("TERM", "").lower():
|
|
50
|
+
return "256"
|
|
51
|
+
return "truecolor" if sys.platform == "win32" else "standard"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# highlight=False 关闭 rich 的自动高亮,保证输出颜色完全可控
|
|
55
|
+
# legacy_windows=False 强制输出 ANSI:rich 的 legacy 检测在现代 ConPTY 终端
|
|
56
|
+
# 中会失效,从而静默地丢掉全部颜色
|
|
57
|
+
console = Console(
|
|
58
|
+
theme=THEME,
|
|
59
|
+
highlight=False,
|
|
60
|
+
color_system=_detect_color_system(),
|
|
61
|
+
legacy_windows=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 统一图标,保证各命令视觉一致
|
|
65
|
+
MARK = "◆"
|
|
66
|
+
OK = "✓"
|
|
67
|
+
|
|
68
|
+
# 阶段名右对齐到该宽度,使不同阶段的信息落在同一列
|
|
69
|
+
LABEL_WIDTH = 10
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def header(command: str, tagline: str) -> None:
|
|
73
|
+
"""打印命令标题:品牌名 + 子命令 + 一行说明"""
|
|
74
|
+
console.print()
|
|
75
|
+
console.print(
|
|
76
|
+
f"[brand]{MARK}[/brand] [bold]mmp[/bold] "
|
|
77
|
+
f"[muted]·[/muted] [brand]{command}[/brand]"
|
|
78
|
+
)
|
|
79
|
+
console.print(f" [muted]{tagline}[/muted]")
|
|
80
|
+
console.print()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def panel(title: str, pairs: list[tuple[str, str]]) -> None:
|
|
84
|
+
"""打印带边框的「键 / 值」面板"""
|
|
85
|
+
grid = Table.grid(padding=(0, 2))
|
|
86
|
+
grid.add_column(justify="right", style="key", no_wrap=True)
|
|
87
|
+
grid.add_column(style="value")
|
|
88
|
+
for label, value in pairs:
|
|
89
|
+
grid.add_row(label, value)
|
|
90
|
+
console.print(
|
|
91
|
+
Panel(
|
|
92
|
+
grid,
|
|
93
|
+
title=f"[brand]{title}[/brand]",
|
|
94
|
+
title_align="left",
|
|
95
|
+
border_style="brand",
|
|
96
|
+
padding=(1, 2),
|
|
97
|
+
expand=False,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def phase(label: str, detail: str) -> None:
|
|
103
|
+
"""打印已完成的阶段行:右对齐的阶段名 + 结果说明"""
|
|
104
|
+
console.print(f" [brand]{label:>{LABEL_WIDTH}}[/brand] {detail}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def task_label(label: str) -> str:
|
|
108
|
+
"""把阶段名格式化为进度条描述,保持与 phase() 相同的列宽"""
|
|
109
|
+
return f"[brand]{label:>{LABEL_WIDTH}}[/brand]"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def success(message: str) -> None:
|
|
113
|
+
"""打印成功提示"""
|
|
114
|
+
console.print(f"[ok]{OK}[/ok] {message}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def error(message: str, hint: str | None = None) -> None:
|
|
118
|
+
"""打印错误提示,可选附带一行修复建议"""
|
|
119
|
+
console.print(f"[err]error:[/err] {message}")
|
|
120
|
+
if hint:
|
|
121
|
+
console.print(f" [muted]→ {hint}[/muted]")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def blank() -> None:
|
|
125
|
+
"""打印一个空行"""
|
|
126
|
+
console.print()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_size(size: int) -> str:
|
|
130
|
+
"""把字节数格式化为易读的大小字符串"""
|
|
131
|
+
value = float(size)
|
|
132
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
133
|
+
if value < 1024 or unit == "TB":
|
|
134
|
+
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}"
|
|
135
|
+
value /= 1024
|
|
136
|
+
return f"{value:.1f} TB"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def size_delta(before: int, after: int) -> str:
|
|
140
|
+
"""格式化体积变化:原始大小 → 产物大小 · 减少/增加量与百分比"""
|
|
141
|
+
if before <= 0:
|
|
142
|
+
return "[muted]n/a[/muted]"
|
|
143
|
+
|
|
144
|
+
delta = before - after
|
|
145
|
+
percent = abs(delta) / before * 100
|
|
146
|
+
base = (
|
|
147
|
+
f"[muted]{format_size(before)}[/muted] [muted]→[/muted] "
|
|
148
|
+
f"[value]{format_size(after)}[/value] [muted]·[/muted] "
|
|
149
|
+
)
|
|
150
|
+
if delta > 0:
|
|
151
|
+
return f"{base}[ok]↓ {format_size(delta)}[/ok] [muted]({percent:.1f}% smaller)[/muted]"
|
|
152
|
+
if delta < 0:
|
|
153
|
+
return f"{base}[err]↑ {format_size(-delta)}[/err] [muted]({percent:.1f}% larger)[/muted]"
|
|
154
|
+
return f"{base}[muted]unchanged[/muted]"
|