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.
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]"