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/__init__.py +193 -0
- sbackup/__main__.py +5 -0
- sbackup/auto_save.py +336 -0
- sbackup/compression.py +555 -0
- sbackup/config.py +134 -0
- sbackup/i18n.py +73 -0
- sbackup_cli-1.0.0.dist-info/METADATA +444 -0
- sbackup_cli-1.0.0.dist-info/RECORD +12 -0
- sbackup_cli-1.0.0.dist-info/WHEEL +5 -0
- sbackup_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sbackup_cli-1.0.0.dist-info/licenses/LICENSE +674 -0
- sbackup_cli-1.0.0.dist-info/top_level.txt +1 -0
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)
|