sbackup-cli 1.0.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.
sbackup/compression.py ADDED
@@ -0,0 +1,555 @@
1
+ """
2
+ 压缩模块:ZIP / TAR / Zstd / 7z 文件压缩逻辑
3
+ """
4
+
5
+ import os
6
+ import io
7
+ import tarfile
8
+ import zipfile
9
+ from pathlib import Path
10
+ from fnmatch import fnmatch
11
+ from tqdm import tqdm
12
+ from sbackup.i18n import t
13
+ from sbackup.config import Config
14
+
15
+ # compresslevel 仅对 ZIP_DEFLATED 和 ZIP_BZIP2 有效
16
+ _VALID_COMPRESSLEVEL_ALGORITHMS = {zipfile.ZIP_DEFLATED, zipfile.ZIP_BZIP2}
17
+
18
+ # tar 格式 → (扩展名, 打开模式)
19
+ _TAR_FORMATS = {
20
+ "TAR": (".tar", "w"),
21
+ "TAR_GZ": (".tar.gz", "w:gz"),
22
+ "TAR_BZ2": (".tar.bz2", "w:bz2"),
23
+ "TAR_XZ": (".tar.xz", "w:xz"),
24
+ }
25
+
26
+
27
+ class BaseCompressor:
28
+ """压缩器基类,提供公共的文件收集和忽略逻辑"""
29
+
30
+ def __init__(self, config: Config) -> None:
31
+ self.folder_path: Path = Path(config.folder_path)
32
+ self.zipfile_path: Path | None = (
33
+ Path(config.zipfile_path) if config.zipfile_path else None
34
+ )
35
+ self.skip_patterns: list[str] = config.skip_patterns
36
+ self.compression_level: int | None = None
37
+
38
+ def _should_ignore(self, rel_path: str) -> bool:
39
+ """检查相对路径是否匹配忽略模式(支持路径级匹配如 subdir/*.log)"""
40
+ for pattern in self.skip_patterns:
41
+ if fnmatch(rel_path, pattern) or fnmatch(
42
+ os.path.basename(rel_path), pattern
43
+ ):
44
+ return True
45
+ return False
46
+
47
+ def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]:
48
+ """遍历文件夹收集需要压缩的文件列表,处理权限错误"""
49
+ files = []
50
+ try:
51
+ for dirpath, dirnames, filenames in os.walk(folder_path):
52
+ try:
53
+ rel_dir = os.path.relpath(dirpath, folder_path)
54
+ if rel_dir == ".":
55
+ rel_dir = ""
56
+ dirnames[:] = [
57
+ d
58
+ for d in dirnames
59
+ if not self._should_ignore(
60
+ os.path.join(rel_dir, d).replace("\\", "/")
61
+ if rel_dir
62
+ else d
63
+ )
64
+ ]
65
+ for filename in filenames:
66
+ file_rel = (
67
+ os.path.join(rel_dir, filename).replace("\\", "/")
68
+ if rel_dir
69
+ else filename
70
+ )
71
+ if not self._should_ignore(file_rel):
72
+ files.append((dirpath, filename))
73
+ except PermissionError:
74
+ print(t("err.permission", path=dirpath))
75
+ continue
76
+ except PermissionError:
77
+ print(t("err.permission", path=folder_path))
78
+ except OSError as e:
79
+ print(t("err.os", error=e))
80
+ return files
81
+
82
+ def compress(self) -> dict:
83
+ """子类实现具体压缩逻辑"""
84
+ raise NotImplementedError
85
+
86
+
87
+ class ZipfileCompression(BaseCompressor):
88
+ """ZIP 格式压缩器"""
89
+
90
+ def __init__(self, config: Config) -> None:
91
+ super().__init__(config)
92
+ self.compression_algorithm: int = self._choose_compression_algorithm(
93
+ config.compression_algorithm
94
+ )
95
+ self.compression_level = self._validate_compresslevel(
96
+ config.compression_level, self.compression_algorithm
97
+ )
98
+
99
+ @staticmethod
100
+ def _choose_compression_algorithm(compression_algorithm: str) -> int:
101
+ match compression_algorithm:
102
+ case "ZIP_DEFLATED":
103
+ return zipfile.ZIP_DEFLATED
104
+ case "ZIP_STORED":
105
+ return zipfile.ZIP_STORED
106
+ case "ZIP_BZIP2":
107
+ return zipfile.ZIP_BZIP2
108
+ case "ZIP_LZMA":
109
+ return zipfile.ZIP_LZMA
110
+ case _:
111
+ return zipfile.ZIP_DEFLATED
112
+
113
+ @staticmethod
114
+ def _validate_compresslevel(level: int, algorithm: int) -> int | None:
115
+ if algorithm not in _VALID_COMPRESSLEVEL_ALGORITHMS:
116
+ return None
117
+ if not (0 <= level <= 9):
118
+ print(t("warn.invalid.compresslevel", level=level))
119
+ return 6
120
+ return level
121
+
122
+ def _resolve_zipfile_path(self, folder_path: Path) -> Path:
123
+ if self.zipfile_path is None:
124
+ return folder_path.parent / f"{folder_path.name}.zip"
125
+ zipfile_path = self.zipfile_path.resolve()
126
+ if zipfile_path.is_dir():
127
+ return zipfile_path / f"{folder_path.name}.zip"
128
+ if zipfile_path.suffix.lower() != ".zip":
129
+ return zipfile_path.with_name(zipfile_path.name + ".zip")
130
+ return zipfile_path
131
+
132
+ def compress(self) -> dict:
133
+ folder_path = self.folder_path.resolve()
134
+ if not folder_path.is_dir():
135
+ print(t("err.folder.invalid", path=folder_path))
136
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
137
+
138
+ zipfile_path = self._resolve_zipfile_path(folder_path)
139
+ if zipfile_path.exists():
140
+ print(t("warn.zip.overwrite", path=zipfile_path))
141
+
142
+ files_to_compress = self._collect_files(folder_path)
143
+ total_files = len(files_to_compress)
144
+ files_count = 0
145
+
146
+ try:
147
+ zip_kwargs = {"mode": "w", "compression": self.compression_algorithm}
148
+ if self.compression_level is not None:
149
+ zip_kwargs["compresslevel"] = self.compression_level
150
+
151
+ with zipfile.ZipFile(zipfile_path, **zip_kwargs) as zipf:
152
+ with tqdm(
153
+ total=total_files,
154
+ desc=t("compress.progress"),
155
+ unit=t("compress.unit"),
156
+ ) as pbar:
157
+ for dirpath, filename in files_to_compress:
158
+ file_path = Path(dirpath) / filename
159
+ arcname = str(
160
+ folder_path.name / file_path.relative_to(folder_path)
161
+ ).replace("\\", "/")
162
+ try:
163
+ zipf.write(file_path, arcname)
164
+ pbar.update(1)
165
+ files_count += 1
166
+ except (FileNotFoundError, PermissionError):
167
+ continue
168
+
169
+ size_mb = zipfile_path.stat().st_size / (1024 * 1024)
170
+ print(
171
+ t(
172
+ "compress.success",
173
+ path=zipfile_path,
174
+ size=size_mb,
175
+ count=files_count,
176
+ )
177
+ )
178
+ return {"success": True, "files_count": files_count, "size_mb": size_mb}
179
+ except KeyboardInterrupt:
180
+ raise
181
+ except PermissionError:
182
+ print(t("err.permission", path=zipfile_path))
183
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
184
+ except OSError as e:
185
+ print(t("err.os", error=e))
186
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
187
+ except Exception as e:
188
+ print(t("err.unknown", error=e))
189
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
190
+
191
+
192
+ class TarfileCompression(BaseCompressor):
193
+ """TAR 格式压缩器(支持 tar.gz / tar.bz2 / tar.xz)"""
194
+
195
+ def __init__(self, config: Config) -> None:
196
+ super().__init__(config)
197
+ fmt = config.compression_format.upper()
198
+ if fmt not in _TAR_FORMATS:
199
+ fmt = "TAR_GZ"
200
+ self._extension, self._mode = _TAR_FORMATS[fmt]
201
+ self.compression_level = self._validate_compresslevel(config.compression_level)
202
+
203
+ @staticmethod
204
+ def _validate_compresslevel(level: int) -> int:
205
+ if not (0 <= level <= 9):
206
+ print(t("warn.invalid.compresslevel", level=level))
207
+ return 6
208
+ return level
209
+
210
+ def _resolve_tarfile_path(self, folder_path: Path) -> Path:
211
+ if self.zipfile_path is None:
212
+ return folder_path.parent / f"{folder_path.name}{self._extension}"
213
+ tarfile_path = self.zipfile_path.resolve()
214
+ if tarfile_path.is_dir():
215
+ return tarfile_path / f"{folder_path.name}{self._extension}"
216
+ # 如果已有后缀,直接使用;否则追加
217
+ name = tarfile_path.name
218
+ if not any(
219
+ name.endswith(ext) for ext in [".tar.gz", ".tar.bz2", ".tar.xz", ".tar"]
220
+ ):
221
+ return tarfile_path.with_name(name + self._extension)
222
+ return tarfile_path
223
+
224
+ def compress(self) -> dict:
225
+ folder_path = self.folder_path.resolve()
226
+ if not folder_path.is_dir():
227
+ print(t("err.folder.invalid", path=folder_path))
228
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
229
+
230
+ tarfile_path = self._resolve_tarfile_path(folder_path)
231
+ if tarfile_path.exists():
232
+ print(t("warn.zip.overwrite", path=tarfile_path))
233
+
234
+ files_to_compress = self._collect_files(folder_path)
235
+ total_files = len(files_to_compress)
236
+ files_count = 0
237
+
238
+ try:
239
+ # compresslevel 仅对 gz 和 bz2 模式有效
240
+ tar_kwargs = {"name": tarfile_path, "mode": self._mode}
241
+ if self._mode in ("w:gz", "w:bz2"):
242
+ tar_kwargs["compresslevel"] = self.compression_level
243
+
244
+ with tarfile.open(**tar_kwargs) as tarf:
245
+ with tqdm(
246
+ total=total_files,
247
+ desc=t("compress.progress"),
248
+ unit=t("compress.unit"),
249
+ ) as pbar:
250
+ for dirpath, filename in files_to_compress:
251
+ file_path = Path(dirpath) / filename
252
+ arcname = str(
253
+ folder_path.name / file_path.relative_to(folder_path)
254
+ ).replace("\\", "/")
255
+ try:
256
+ tarf.add(file_path, arcname=arcname, recursive=False)
257
+ pbar.update(1)
258
+ files_count += 1
259
+ except (FileNotFoundError, PermissionError):
260
+ continue
261
+
262
+ size_mb = tarfile_path.stat().st_size / (1024 * 1024)
263
+ print(
264
+ t(
265
+ "compress.success",
266
+ path=tarfile_path,
267
+ size=size_mb,
268
+ count=files_count,
269
+ )
270
+ )
271
+ return {"success": True, "files_count": files_count, "size_mb": size_mb}
272
+ except KeyboardInterrupt:
273
+ raise
274
+ except PermissionError:
275
+ print(t("err.permission", path=tarfile_path))
276
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
277
+ except OSError as e:
278
+ print(t("err.os", error=e))
279
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
280
+ except Exception as e:
281
+ print(t("err.unknown", error=e))
282
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
283
+
284
+
285
+ class ZstdCompression(BaseCompressor):
286
+ """tar.zst 格式压缩器(使用 zstandard 库)"""
287
+
288
+ def __init__(self, config: Config) -> None:
289
+ super().__init__(config)
290
+ self.compression_level = self._validate_compresslevel(config.compression_level)
291
+
292
+ @staticmethod
293
+ def _validate_compresslevel(level: int) -> int:
294
+ if not (0 <= level <= 22):
295
+ print(t("warn.invalid.compresslevel", level=level))
296
+ return 3
297
+ return level
298
+
299
+ def _resolve_path(self, folder_path: Path) -> Path:
300
+ if self.zipfile_path is None:
301
+ return folder_path.parent / f"{folder_path.name}.tar.zst"
302
+ path = self.zipfile_path.resolve()
303
+ if path.is_dir():
304
+ return path / f"{folder_path.name}.tar.zst"
305
+ if not path.name.endswith(".tar.zst"):
306
+ return path.with_name(path.name + ".tar.zst")
307
+ return path
308
+
309
+ def compress(self) -> dict:
310
+ import zstandard as zstd
311
+
312
+ folder_path = self.folder_path.resolve()
313
+ if not folder_path.is_dir():
314
+ print(t("err.folder.invalid", path=folder_path))
315
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
316
+
317
+ output_path = self._resolve_path(folder_path)
318
+ if output_path.exists():
319
+ print(t("warn.zip.overwrite", path=output_path))
320
+
321
+ files_to_compress = self._collect_files(folder_path)
322
+ total_files = len(files_to_compress)
323
+ files_count = 0
324
+
325
+ try:
326
+ cctx = zstd.ZstdCompressor(level=self.compression_level)
327
+ # 先创建 tar,再用 zstd 压缩
328
+ tar_buffer = io.BytesIO()
329
+ with tarfile.open(fileobj=tar_buffer, mode="w") as tarf:
330
+ with tqdm(
331
+ total=total_files,
332
+ desc=t("compress.progress"),
333
+ unit=t("compress.unit"),
334
+ ) as pbar:
335
+ for dirpath, filename in files_to_compress:
336
+ file_path = Path(dirpath) / filename
337
+ arcname = str(
338
+ folder_path.name / file_path.relative_to(folder_path)
339
+ ).replace("\\", "/")
340
+ try:
341
+ tarf.add(file_path, arcname=arcname, recursive=False)
342
+ pbar.update(1)
343
+ files_count += 1
344
+ except (FileNotFoundError, PermissionError):
345
+ continue
346
+
347
+ compressed = cctx.compress(tar_buffer.getvalue())
348
+ output_path.write_bytes(compressed)
349
+
350
+ size_mb = output_path.stat().st_size / (1024 * 1024)
351
+ print(
352
+ t("compress.success", path=output_path, size=size_mb, count=files_count)
353
+ )
354
+ return {"success": True, "files_count": files_count, "size_mb": size_mb}
355
+ except KeyboardInterrupt:
356
+ raise
357
+ except PermissionError:
358
+ print(t("err.permission", path=output_path))
359
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
360
+ except OSError as e:
361
+ print(t("err.os", error=e))
362
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
363
+ except Exception as e:
364
+ print(t("err.unknown", error=e))
365
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
366
+
367
+
368
+ class SevenZipCompression(BaseCompressor):
369
+ """7z 格式压缩器(使用 py7zr 库)"""
370
+
371
+ def __init__(self, config: Config) -> None:
372
+ super().__init__(config)
373
+ self.compression_level = self._validate_compresslevel(config.compression_level)
374
+ self.password = config.password if config.password else None
375
+
376
+ @staticmethod
377
+ def _validate_compresslevel(level: int) -> int:
378
+ if not (0 <= level <= 9):
379
+ print(t("warn.invalid.compresslevel", level=level))
380
+ return 6
381
+ return level
382
+
383
+ def _resolve_path(self, folder_path: Path) -> Path:
384
+ if self.zipfile_path is None:
385
+ return folder_path.parent / f"{folder_path.name}.7z"
386
+ path = self.zipfile_path.resolve()
387
+ if path.is_dir():
388
+ return path / f"{folder_path.name}.7z"
389
+ if path.suffix.lower() != ".7z":
390
+ return path.with_name(path.name + ".7z")
391
+ return path
392
+
393
+ def compress(self) -> dict:
394
+ import py7zr
395
+
396
+ folder_path = self.folder_path.resolve()
397
+ if not folder_path.is_dir():
398
+ print(t("err.folder.invalid", path=folder_path))
399
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
400
+
401
+ output_path = self._resolve_path(folder_path)
402
+ if output_path.exists():
403
+ print(t("warn.zip.overwrite", path=output_path))
404
+
405
+ files_to_compress = self._collect_files(folder_path)
406
+ total_files = len(files_to_compress)
407
+ files_count = 0
408
+
409
+ try:
410
+ filters = [{"id": py7zr.FILTER_LZMA2, "preset": self.compression_level}]
411
+ szf_kwargs = {"mode": "w", "filters": filters}
412
+ if self.password:
413
+ szf_kwargs["password"] = self.password
414
+ with py7zr.SevenZipFile(output_path, **szf_kwargs) as szf:
415
+ with tqdm(
416
+ total=total_files,
417
+ desc=t("compress.progress"),
418
+ unit=t("compress.unit"),
419
+ ) as pbar:
420
+ for dirpath, filename in files_to_compress:
421
+ file_path = Path(dirpath) / filename
422
+ arcname = str(
423
+ folder_path.name / file_path.relative_to(folder_path)
424
+ ).replace("\\", "/")
425
+ try:
426
+ szf.write(file_path, arcname)
427
+ pbar.update(1)
428
+ files_count += 1
429
+ except (FileNotFoundError, PermissionError):
430
+ continue
431
+
432
+ size_mb = output_path.stat().st_size / (1024 * 1024)
433
+ print(
434
+ t("compress.success", path=output_path, size=size_mb, count=files_count)
435
+ )
436
+ return {"success": True, "files_count": files_count, "size_mb": size_mb}
437
+ except KeyboardInterrupt:
438
+ raise
439
+ except PermissionError:
440
+ print(t("err.permission", path=output_path))
441
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
442
+ except OSError as e:
443
+ print(t("err.os", error=e))
444
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
445
+ except Exception as e:
446
+ print(t("err.unknown", error=e))
447
+ return {"success": False, "files_count": 0, "size_mb": 0.0}
448
+
449
+
450
+ def create_compressor(config: Config) -> BaseCompressor:
451
+ """工厂函数:根据配置创建对应的压缩器"""
452
+ fmt = config.compression_format.upper()
453
+ if fmt in _TAR_FORMATS:
454
+ return TarfileCompression(config)
455
+ if fmt == "TAR_ZST":
456
+ return ZstdCompression(config)
457
+ if fmt == "7Z":
458
+ return SevenZipCompression(config)
459
+ return ZipfileCompression(config)
460
+
461
+
462
+ def restore_backup(backup_path: str, target_dir: str) -> dict:
463
+ """
464
+ 从备份文件还原到目标目录
465
+ 自动检测格式(ZIP / tar.gz / tar.bz2 / tar.xz)
466
+ :return: 包含统计信息的字典
467
+ """
468
+ backup = Path(backup_path)
469
+ if not backup.exists():
470
+ print(t("err.folder.invalid", path=backup_path))
471
+ return {"success": False, "files_count": 0}
472
+
473
+ target = Path(target_dir)
474
+ target.mkdir(parents=True, exist_ok=True)
475
+
476
+ name_lower = backup.name.lower()
477
+ try:
478
+ if name_lower.endswith(".zip"):
479
+ with zipfile.ZipFile(backup, "r") as zf:
480
+ members = zf.namelist()
481
+ with tqdm(
482
+ total=len(members),
483
+ desc=t("restore.progress"),
484
+ unit=t("compress.unit"),
485
+ ) as pbar:
486
+ for member in members:
487
+ zf.extract(member, target)
488
+ pbar.update(1)
489
+ print(t("restore.success", path=target, count=len(members)))
490
+ return {"success": True, "files_count": len(members)}
491
+ elif name_lower.endswith(".7z"):
492
+ import py7zr
493
+
494
+ with py7zr.SevenZipFile(backup, "r") as szf:
495
+ members = szf.getnames()
496
+ with tqdm(
497
+ total=len(members),
498
+ desc=t("restore.progress"),
499
+ unit=t("compress.unit"),
500
+ ) as pbar:
501
+ szf.extractall(target)
502
+ pbar.update(len(members))
503
+ print(t("restore.success", path=target, count=len(members)))
504
+ return {"success": True, "files_count": len(members)}
505
+ elif name_lower.endswith(".tar.zst"):
506
+ import zstandard as zstd
507
+
508
+ dctx = zstd.ZstdDecompressor()
509
+ compressed = backup.read_bytes()
510
+ tar_data = dctx.decompress(compressed)
511
+ with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r") as tarf:
512
+ members = tarf.getmembers()
513
+ with tqdm(
514
+ total=len(members),
515
+ desc=t("restore.progress"),
516
+ unit=t("compress.unit"),
517
+ ) as pbar:
518
+ for member in members:
519
+ tarf.extract(member, target, filter="data")
520
+ pbar.update(1)
521
+ print(t("restore.success", path=target, count=len(members)))
522
+ return {"success": True, "files_count": len(members)}
523
+ elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
524
+ mode = "r:gz"
525
+ elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
526
+ mode = "r:bz2"
527
+ elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"):
528
+ mode = "r:xz"
529
+ elif name_lower.endswith(".tar"):
530
+ mode = "r"
531
+ else:
532
+ print(t("err.unknown.format", path=backup_path))
533
+ return {"success": False, "files_count": 0}
534
+
535
+ with tarfile.open(backup, mode) as tarf:
536
+ members = tarf.getmembers()
537
+ with tqdm(
538
+ total=len(members), desc=t("restore.progress"), unit=t("compress.unit")
539
+ ) as pbar:
540
+ for member in members:
541
+ tarf.extract(member, target, filter="data")
542
+ pbar.update(1)
543
+ print(t("restore.success", path=target, count=len(members)))
544
+ return {"success": True, "files_count": len(members)}
545
+ except KeyboardInterrupt:
546
+ raise
547
+ except PermissionError:
548
+ print(t("err.permission", path=target_dir))
549
+ return {"success": False, "files_count": 0}
550
+ except OSError as e:
551
+ print(t("err.os", error=e))
552
+ return {"success": False, "files_count": 0}
553
+ except Exception as e:
554
+ print(t("err.unknown", error=e))
555
+ return {"success": False, "files_count": 0}
sbackup/config.py ADDED
@@ -0,0 +1,134 @@
1
+ """
2
+ 配置管理模块:配置加载、语言持久化、数据路径
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import json
8
+ import logging
9
+ from dataclasses import dataclass, field
10
+ from sbackup.i18n import t
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_SKIP_PATTERNS = [".git", "__pycache__"]
15
+
16
+
17
+ def get_default_data_file() -> str:
18
+ """返回跨平台的默认数据文件路径"""
19
+ if sys.platform == "win32":
20
+ base = os.environ.get("APPDATA", os.path.expanduser("~"))
21
+ elif sys.platform == "darwin":
22
+ base = os.path.expanduser("~/Library/Application Support")
23
+ else:
24
+ base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
25
+ return os.path.join(base, "sbackup", "sbackup.json")
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ folder_path: str = "."
31
+ zipfile_path: str | None = None
32
+ skip_patterns: list[str] = field(
33
+ default_factory=lambda: list(DEFAULT_SKIP_PATTERNS)
34
+ )
35
+ compression_format: str = "ZIP"
36
+ compression_algorithm: str = "ZIP_DEFLATED"
37
+ compression_level: int = 6
38
+ lang: str = "zh_CN"
39
+ data_file: str = field(default_factory=get_default_data_file)
40
+ password: str = ""
41
+
42
+
43
+ def load_config(config_file: str = "config.json") -> Config:
44
+ """
45
+ 从配置文件中加载配置
46
+ """
47
+ if not os.path.exists(config_file):
48
+ return Config()
49
+
50
+ try:
51
+ with open(config_file, "r", encoding="utf-8") as f:
52
+ config_data = json.load(f)
53
+ except json.JSONDecodeError:
54
+ logger.warning(t("log.config.malformed"), config_file)
55
+ return Config()
56
+
57
+ compression_config = config_data.get("compression", {})
58
+ skip_patterns = config_data.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
59
+ data_file = config_data.get("data_file", get_default_data_file())
60
+ lang = config_data.get("lang", "en_US")
61
+ compression_format = config_data.get("compression_format", "ZIP")
62
+
63
+ return Config(
64
+ folder_path="",
65
+ zipfile_path=None,
66
+ skip_patterns=skip_patterns,
67
+ compression_format=compression_format,
68
+ compression_algorithm=compression_config.get("algorithm", "ZIP_DEFLATED"),
69
+ compression_level=compression_config.get("level", 6),
70
+ lang=lang,
71
+ data_file=data_file,
72
+ )
73
+
74
+
75
+ def save_lang(lang: str, config_file: str = "config.json") -> None:
76
+ """
77
+ 将语言偏好保存到配置文件
78
+ """
79
+ if os.path.exists(config_file):
80
+ try:
81
+ with open(config_file, "r", encoding="utf-8") as f:
82
+ data = json.load(f)
83
+ except json.JSONDecodeError:
84
+ logger.warning(t("log.config.reset"), config_file)
85
+ data = {}
86
+ else:
87
+ data = {}
88
+
89
+ data["lang"] = lang
90
+
91
+ data_dir = os.path.dirname(config_file)
92
+ if data_dir:
93
+ try:
94
+ os.makedirs(data_dir, exist_ok=True)
95
+ except OSError as e:
96
+ logger.error(t("log.config.mkdir.error"), data_dir, e)
97
+ return
98
+
99
+ try:
100
+ with open(config_file, "w", encoding="utf-8") as f:
101
+ json.dump(data, f, ensure_ascii=False, indent=4)
102
+ except OSError as e:
103
+ logger.error(t("log.config.write.error"), config_file, e)
104
+
105
+
106
+ def save_format(fmt: str, config_file: str = "config.json") -> None:
107
+ """
108
+ 将打包格式偏好保存到配置文件
109
+ """
110
+ if os.path.exists(config_file):
111
+ try:
112
+ with open(config_file, "r", encoding="utf-8") as f:
113
+ data = json.load(f)
114
+ except json.JSONDecodeError:
115
+ logger.warning(t("log.config.reset"), config_file)
116
+ data = {}
117
+ else:
118
+ data = {}
119
+
120
+ data["compression_format"] = fmt
121
+
122
+ data_dir = os.path.dirname(config_file)
123
+ if data_dir:
124
+ try:
125
+ os.makedirs(data_dir, exist_ok=True)
126
+ except OSError as e:
127
+ logger.error(t("log.config.mkdir.error"), data_dir, e)
128
+ return
129
+
130
+ try:
131
+ with open(config_file, "w", encoding="utf-8") as f:
132
+ json.dump(data, f, ensure_ascii=False, indent=4)
133
+ except OSError as e:
134
+ logger.error(t("log.config.write.error"), config_file, e)