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 ADDED
@@ -0,0 +1,193 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import logging
5
+ from typing import NoReturn
6
+ from sbackup.auto_save import BackupManager
7
+ from sbackup.i18n import set_locale, t
8
+ from sbackup.config import load_config, save_lang, save_format
9
+ from sbackup.compression import restore_backup
10
+
11
+ VERSION = "1.0.0"
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class LocalizedArgumentParser(argparse.ArgumentParser):
16
+ """本地化错误输出的 ArgumentParser 子类"""
17
+
18
+ def error(self, message: str) -> NoReturn:
19
+ # 将 argparse 生成的英文错误关键词替换为本地化文本
20
+ localized = message
21
+ localized = localized.replace(
22
+ "invalid choice: ", t("err.argparse.invalid_choice")
23
+ )
24
+ localized = localized.replace("choose from", t("err.argparse.choose_from"))
25
+ localized = localized.replace(
26
+ "invalid float value: ", t("err.argparse.invalid_float")
27
+ )
28
+ localized = localized.replace(
29
+ "invalid int value: ", t("err.argparse.invalid_int")
30
+ )
31
+ localized = localized.replace(
32
+ "unrecognized arguments: ", t("err.argparse.unrecognized_args")
33
+ )
34
+ localized = localized.replace("required", t("err.argparse.required"))
35
+ self.print_usage(sys.stderr)
36
+ sys.stderr.write(f"{self.prog}: {localized}\n")
37
+ sys.exit(2)
38
+
39
+
40
+ def _detect_lang_from_argv() -> str | None:
41
+ """从 sys.argv 中提取 --lang 参数值"""
42
+ for i, arg in enumerate(sys.argv):
43
+ if arg == "--lang" and i + 1 < len(sys.argv):
44
+ return sys.argv[i + 1]
45
+ if arg.startswith("--lang="):
46
+ return arg.split("=", 1)[1]
47
+ return None
48
+
49
+
50
+ def get_parser() -> argparse.ArgumentParser:
51
+ parser = LocalizedArgumentParser(
52
+ prog="sbackup",
53
+ description=t("cli.description", version=VERSION),
54
+ epilog=t("cli.epilog"),
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ add_help=False,
57
+ )
58
+
59
+ parser.add_argument("--debug", action="store_true", help=t("cli.help.debug"))
60
+ parser.add_argument("-h", "--help", action="help", help=t("cli.help.help"))
61
+ parser.add_argument("--lang", default=None, help=t("cli.help.lang"))
62
+ parser.add_argument(
63
+ "--format",
64
+ default=None,
65
+ choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"],
66
+ help=t("cli.help.format"),
67
+ )
68
+
69
+ subparsers = parser.add_subparsers(dest="command", help=t("cli.help.subcommands"))
70
+
71
+ add_parser = subparsers.add_parser("add", help=t("cli.help.add"))
72
+ add_parser.add_argument("source", help=t("cli.help.add.source"))
73
+ add_parser.add_argument("dest", help=t("cli.help.add.dest"))
74
+ add_parser.add_argument(
75
+ "-i", "--ignore", default=".git,__pycache__", help=t("cli.help.add.ignore")
76
+ )
77
+ add_parser.add_argument(
78
+ "--format",
79
+ default=None,
80
+ choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"],
81
+ help=t("cli.help.add.format"),
82
+ )
83
+
84
+ rm_parser = subparsers.add_parser("rm", aliases=["remove"], help=t("cli.help.rm"))
85
+ rm_parser.add_argument("path", help=t("cli.help.rm.path"))
86
+
87
+ subparsers.add_parser("all", help=t("cli.help.all"))
88
+
89
+ save_parser = subparsers.add_parser("save", help=t("cli.help.save"))
90
+ save_parser.add_argument(
91
+ "--keep", type=int, default=0, help=t("cli.help.save.keep")
92
+ )
93
+ save_parser.add_argument("--password", default="", help=t("cli.help.save.password"))
94
+
95
+ watch_parser = subparsers.add_parser("watch", help=t("cli.help.watch"))
96
+ watch_parser.add_argument(
97
+ "--interval", type=float, default=60, help=t("cli.help.watch.interval")
98
+ )
99
+ watch_parser.add_argument(
100
+ "--keep", type=int, default=0, help=t("cli.help.watch.keep")
101
+ )
102
+ watch_parser.add_argument(
103
+ "--password", default="", help=t("cli.help.watch.password")
104
+ )
105
+
106
+ restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
107
+ restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
108
+ restore_parser.add_argument("target_dir", help=t("cli.help.restore.dir"))
109
+
110
+ subparsers.add_parser("version", help=t("cli.help.version"))
111
+
112
+ return parser
113
+
114
+
115
+ def parse_path(path_str: str) -> str:
116
+ return os.path.expanduser(path_str.strip())
117
+
118
+
119
+ def run() -> int:
120
+ # 先检测 --lang 参数,初始化语言环境,再创建本地化 parser
121
+ lang_from_argv = _detect_lang_from_argv()
122
+ config = load_config()
123
+ current_lang = lang_from_argv if lang_from_argv is not None else config.lang
124
+ set_locale(current_lang)
125
+
126
+ parser = get_parser()
127
+ args = parser.parse_args()
128
+
129
+ # 持久化语言设置
130
+ if lang_from_argv is not None:
131
+ save_lang(lang_from_argv)
132
+
133
+ # 持久化格式设置
134
+ if args.format is not None:
135
+ save_format(args.format)
136
+ config.compression_format = args.format.upper().replace(".", "_")
137
+
138
+ if args.debug:
139
+ logging.basicConfig(
140
+ level=logging.DEBUG,
141
+ format="%(asctime)s [%(levelname)s] %(message)s",
142
+ datefmt="%H:%M:%S",
143
+ )
144
+
145
+ if args.command is None:
146
+ parser.print_help()
147
+ return 0
148
+
149
+ if args.command == "version":
150
+ print(t("cli.version", version=VERSION))
151
+ return 0
152
+
153
+ manager = BackupManager(data_file=config.data_file)
154
+
155
+ if args.command == "add":
156
+ source = parse_path(args.source)
157
+ dest = parse_path(args.dest)
158
+ # 条目级格式:用户在 add 时指定的 --format 仅作用于该条目
159
+ entry_fmt = args.format.upper().replace(".", "_") if args.format else ""
160
+ success = manager.add_folder(source, dest, args.ignore, entry_fmt)
161
+ if success:
162
+ print(t("cmd.add.success", source=source, dest=dest))
163
+ return 0
164
+ return 1
165
+ elif args.command in ("rm", "remove"):
166
+ path = parse_path(args.path)
167
+ success = manager.rm_folder(path)
168
+ if success:
169
+ print(t("cmd.rm.success", path=path))
170
+ return 0
171
+ return 1
172
+ elif args.command == "all":
173
+ print(manager.list_folder_table())
174
+ return 0
175
+ elif args.command == "save":
176
+ manager.execute_backups(keep=args.keep, password=args.password)
177
+ return 0
178
+ elif args.command == "watch":
179
+ import time as _time
180
+
181
+ interval_sec = args.interval * 60
182
+ print(t("cmd.watch.start", interval=args.interval))
183
+ try:
184
+ while True:
185
+ manager.execute_backups(keep=args.keep, password=args.password)
186
+ _time.sleep(interval_sec)
187
+ except KeyboardInterrupt:
188
+ return 0
189
+ elif args.command == "restore":
190
+ result = restore_backup(args.backup_file, args.target_dir)
191
+ return 0 if result["success"] else 1
192
+
193
+ return 0
sbackup/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from sbackup import run
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(run())
sbackup/auto_save.py ADDED
@@ -0,0 +1,336 @@
1
+ import os
2
+ import json
3
+ import shutil
4
+ import logging
5
+ from pathlib import Path
6
+ from dataclasses import dataclass
7
+ from sbackup.config import (
8
+ Config,
9
+ load_config,
10
+ get_default_data_file,
11
+ DEFAULT_SKIP_PATTERNS,
12
+ )
13
+ from sbackup.compression import create_compressor
14
+ from sbackup.i18n import t
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class BackupEntry:
21
+ """备份策略条目"""
22
+
23
+ mtime: float
24
+ target: str
25
+ skip_patterns: list[str]
26
+ compression_format: str = "" # 空字符串表示使用全局默认格式
27
+
28
+ def to_list(self) -> list:
29
+ """转为 JSON 兼容的列表格式"""
30
+ return [self.mtime, self.target, self.skip_patterns, self.compression_format]
31
+
32
+ @staticmethod
33
+ def from_list(data: list) -> "BackupEntry":
34
+ """从 JSON 兼容的列表格式创建(向后兼容旧格式)"""
35
+ fmt = data[3] if len(data) > 3 else ""
36
+ return BackupEntry(
37
+ mtime=data[0], target=data[1], skip_patterns=data[2], compression_format=fmt
38
+ )
39
+
40
+
41
+ class BackupManager:
42
+ """
43
+ 管理备份策略的类,封装状态和读写操作
44
+ """
45
+
46
+ def __init__(self, data_file: str = ""):
47
+ self.data_file: str = data_file or get_default_data_file()
48
+ self.data: dict[str, list] = {}
49
+ self.load()
50
+
51
+ def load(self):
52
+ """
53
+ 从 JSON 文件加载数据到内存
54
+ """
55
+ logger.debug(t("log.data.read"), self.data_file)
56
+ if not os.path.exists(self.data_file):
57
+ logger.debug(t("log.data.create"), self.data_file)
58
+ self.save(initial=True)
59
+ else:
60
+ logger.debug(t("log.data.load"), self.data_file)
61
+ try:
62
+ with open(self.data_file, "r", encoding="utf-8") as f:
63
+ self.data = json.load(f)
64
+ except json.JSONDecodeError:
65
+ print(t("warn.json.decode.error", path=self.data_file))
66
+ # 备份损坏文件,避免数据丢失
67
+ backup_path = self.data_file + ".bak"
68
+ try:
69
+ shutil.copy2(self.data_file, backup_path)
70
+ print(t("warn.json.backup", path=backup_path))
71
+ except OSError:
72
+ # 备份失败时重命名损坏文件,避免下次再次触发
73
+ try:
74
+ os.rename(self.data_file, self.data_file + ".corrupted")
75
+ print(
76
+ t("warn.json.renamed", path=self.data_file + ".corrupted")
77
+ )
78
+ except OSError:
79
+ pass
80
+ self.data = {}
81
+
82
+ def save(self, initial: bool = False):
83
+ """
84
+ 将内存数据写入 JSON 文件
85
+ """
86
+ if not initial:
87
+ logger.debug(t("log.data.write"), self.data_file)
88
+
89
+ data_dir = os.path.dirname(self.data_file)
90
+ if data_dir:
91
+ os.makedirs(data_dir, exist_ok=True)
92
+
93
+ with open(self.data_file, "w", encoding="utf-8") as f:
94
+ json.dump(self.data, f, ensure_ascii=False, indent=4)
95
+
96
+ def _get_entry(self, key: str) -> BackupEntry | None:
97
+ """获取指定路径的备份策略条目"""
98
+ raw = self.data.get(key)
99
+ if raw is None:
100
+ return None
101
+ return BackupEntry.from_list(raw)
102
+
103
+ def _set_entry(self, key: str, entry: BackupEntry):
104
+ """设置指定路径的备份策略条目"""
105
+ self.data[key] = entry.to_list()
106
+
107
+ def add_folder(
108
+ self,
109
+ folder_path: str,
110
+ target_folder: str,
111
+ skip_patterns: str | None = None,
112
+ compression_format: str = "",
113
+ ):
114
+ """
115
+ 添加备份策略
116
+ :param compression_format: 条目级打包格式,空字符串使用全局默认
117
+ """
118
+ if skip_patterns is None:
119
+ skip_patterns = ",".join(DEFAULT_SKIP_PATTERNS)
120
+ skip_list = (
121
+ [s.strip() for s in skip_patterns.split(",") if s.strip()]
122
+ if skip_patterns
123
+ else []
124
+ )
125
+
126
+ if not os.path.isdir(folder_path):
127
+ print(t("err.folder.invalid", path=folder_path))
128
+ return False
129
+ if not os.path.isdir(target_folder):
130
+ print(t("err.dest.invalid", path=target_folder))
131
+ return False
132
+
133
+ abs_path = os.path.abspath(folder_path)
134
+ if abs_path in self.data:
135
+ print(t("info.already.added", path=abs_path))
136
+ return False
137
+
138
+ try:
139
+ entry = BackupEntry(
140
+ mtime=os.stat(abs_path).st_mtime,
141
+ target=os.path.abspath(target_folder),
142
+ skip_patterns=skip_list,
143
+ compression_format=compression_format,
144
+ )
145
+ except OSError as e:
146
+ print(t("err.os", error=e))
147
+ return False
148
+ self._set_entry(abs_path, entry)
149
+ self.save()
150
+ return True
151
+
152
+ def rm_folder(self, folder_path: str) -> bool:
153
+ """
154
+ 删除备份策略
155
+ """
156
+ abs_path = os.path.abspath(folder_path)
157
+ if abs_path in self.data:
158
+ del self.data[abs_path]
159
+ self.save()
160
+ return True
161
+ else:
162
+ print(t("warn.no.strategy.found", path=abs_path))
163
+ return False
164
+
165
+ def execute_backups(self, keep: int = 0, password: str = ""):
166
+ """
167
+ 执行所有备份策略
168
+ :param keep: 保留最近 N 个备份文件,0 表示不清理
169
+ :param password: 加密密码(仅 7z 格式支持)
170
+ """
171
+ config = load_config()
172
+ backup_count = 0
173
+ skip_count = 0
174
+ for key, raw in list(self.data.items()):
175
+ if key == "_history":
176
+ continue
177
+ if not os.path.exists(key):
178
+ print(t("warn.source.missing", path=key))
179
+ continue
180
+ try:
181
+ current_mtime = os.stat(key).st_mtime
182
+ except OSError as e:
183
+ print(t("err.os", error=e))
184
+ continue
185
+ entry = BackupEntry.from_list(raw)
186
+ if entry.mtime != current_mtime:
187
+ # 条目级格式优先,否则使用全局配置
188
+ fmt = entry.compression_format or config.compression_format
189
+ config_instance = Config(
190
+ folder_path=key,
191
+ zipfile_path=entry.target,
192
+ skip_patterns=entry.skip_patterns,
193
+ compression_format=fmt,
194
+ compression_algorithm=config.compression_algorithm,
195
+ compression_level=config.compression_level,
196
+ password=password,
197
+ )
198
+ result = create_compressor(config_instance).compress()
199
+ if result["success"]:
200
+ entry.mtime = current_mtime
201
+ self._set_entry(key, entry)
202
+ self._add_history(key, result["size_mb"], result["files_count"])
203
+ if keep > 0:
204
+ self._cleanup_old_backups(entry.target, keep)
205
+ backup_count += 1
206
+ else:
207
+ skip_count += 1
208
+ if backup_count > 0:
209
+ self.save()
210
+ print(t("cmd.save.completed", count=backup_count))
211
+ elif skip_count > 0:
212
+ print(t("cmd.save.uptodate"))
213
+
214
+ # 向后兼容别名
215
+ save_folder = execute_backups
216
+
217
+ def _add_history(self, source: str, size_mb: float, files_count: int):
218
+ """记录备份历史"""
219
+ from datetime import datetime
220
+
221
+ history = self.data.setdefault("_history", [])
222
+ history.append(
223
+ {
224
+ "time": datetime.now().isoformat(timespec="seconds"),
225
+ "source": source,
226
+ "size_mb": round(size_mb, 2),
227
+ "files_count": files_count,
228
+ }
229
+ )
230
+ # 保留最近 100 条记录
231
+ if len(history) > 100:
232
+ self.data["_history"] = history[-100:]
233
+
234
+ def get_history(self) -> list[dict]:
235
+ """获取备份历史记录"""
236
+ return self.data.get("_history", [])
237
+
238
+ @staticmethod
239
+ def _cleanup_old_backups(target_dir: str, keep: int):
240
+ """清理旧备份文件,仅保留最近 keep 个(keep=0 时不清理)"""
241
+ if keep <= 0:
242
+ return
243
+
244
+ target = Path(target_dir)
245
+ if not target.is_dir():
246
+ return
247
+ # 收集所有备份文件(.zip / .tar.* / .7z)
248
+ patterns = [
249
+ "*.zip",
250
+ "*.tar",
251
+ "*.tar.gz",
252
+ "*.tar.bz2",
253
+ "*.tar.xz",
254
+ "*.tar.zst",
255
+ "*.7z",
256
+ ]
257
+ files = []
258
+ for pat in patterns:
259
+ files.extend(target.glob(pat))
260
+ if len(files) <= keep:
261
+ return
262
+ # 按修改时间排序,删除旧的
263
+ files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
264
+ for old_file in files[keep:]:
265
+ try:
266
+ old_file.unlink()
267
+ logger.debug(t("log.cleanup.delete"), old_file)
268
+ except OSError:
269
+ pass
270
+
271
+ def all_folder(self) -> dict[str, str]:
272
+ """
273
+ 查看所有备份策略
274
+ """
275
+ return {
276
+ key: BackupEntry.from_list(raw).target for key, raw in self.data.items()
277
+ }
278
+
279
+ @staticmethod
280
+ def _display_width(s: str) -> int:
281
+ """计算字符串的终端显示宽度(中文字符算2,英文字符算1)"""
282
+ if not isinstance(s, str):
283
+ return len(str(s))
284
+ width = 0
285
+ for ch in s:
286
+ if ord(ch) > 0x2E80:
287
+ width += 2
288
+ else:
289
+ width += 1
290
+ return width
291
+
292
+ def list_folder_table(self) -> str:
293
+ """
294
+ 生成对齐的文本表格
295
+ """
296
+ if not self.data:
297
+ return t("cmd.all.empty")
298
+
299
+ headers = [
300
+ t("table.header.source"),
301
+ t("table.header.dest"),
302
+ t("table.header.format"),
303
+ t("table.header.ignore"),
304
+ ]
305
+ rows = []
306
+ for path, raw in self.data.items():
307
+ if path == "_history":
308
+ continue
309
+ entry = BackupEntry.from_list(raw)
310
+ fmt_display = (
311
+ entry.compression_format
312
+ if entry.compression_format
313
+ else t("table.cell.default")
314
+ )
315
+ skip = (
316
+ ", ".join(entry.skip_patterns)
317
+ if entry.skip_patterns
318
+ else t("table.cell.none")
319
+ )
320
+ rows.append([path, entry.target, fmt_display, skip])
321
+
322
+ col_widths = [self._display_width(h) for h in headers]
323
+ for row in rows:
324
+ for i, cell in enumerate(row):
325
+ col_widths[i] = max(col_widths[i], self._display_width(cell))
326
+
327
+ fmt = " | ".join(["{:<" + str(w) + "}" for w in col_widths])
328
+ sep = "-+-".join(["-" * w for w in col_widths])
329
+
330
+ lines = []
331
+ lines.append(fmt.format(*headers))
332
+ lines.append(sep)
333
+ for row in rows:
334
+ lines.append(fmt.format(*row))
335
+
336
+ return "\n".join(lines)