fix: 修复了一些已知问题

This commit is contained in:
xiatianxuan committed 2026-05-03 20:45:07 +08:00
1 parent 0fb56b39fe
commit 72d6647fac
22 files changed
+1540 -232

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ wheels/
.vscode/ .vscode/
*.exe *.exe
sbackup.json sbackup.json
config.json
# Virtual environments # Virtual environments
.venv .venv
+52 -12
View File
@@ -9,7 +9,7 @@ from sbackup.i18n import set_locale, t
from sbackup.config import load_config, save_lang, save_format from sbackup.config import load_config, save_lang, save_format
from sbackup.compression import restore_backup from sbackup.compression import restore_backup
VERSION = "1.0.0" VERSION = "1.0.1"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,7 +32,8 @@ class LocalizedArgumentParser(argparse.ArgumentParser):
localized = localized.replace( localized = localized.replace(
"unrecognized arguments: ", t("err.argparse.unrecognized_args") "unrecognized arguments: ", t("err.argparse.unrecognized_args")
) )
localized = localized.replace("required", t("err.argparse.required")) # "required" 仅替换独立出现的关键词(argparse 格式: "the following arguments are required")
localized = localized.replace("are required", t("err.argparse.required"))
self.print_usage(sys.stderr) self.print_usage(sys.stderr)
sys.stderr.write(f"{self.prog}: {localized}\n") sys.stderr.write(f"{self.prog}: {localized}\n")
sys.exit(2) sys.exit(2)
@@ -87,6 +88,8 @@ def get_parser() -> argparse.ArgumentParser:
subparsers.add_parser("all", help=t("cli.help.all")) subparsers.add_parser("all", help=t("cli.help.all"))
subparsers.add_parser("list", aliases=["history"], help=t("cli.help.list"))
save_parser = subparsers.add_parser("save", help=t("cli.help.save")) save_parser = subparsers.add_parser("save", help=t("cli.help.save"))
save_parser.add_argument( save_parser.add_argument(
"--keep", type=int, default=0, help=t("cli.help.save.keep") "--keep", type=int, default=0, help=t("cli.help.save.keep")
@@ -119,6 +122,15 @@ def get_parser() -> argparse.ArgumentParser:
restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore")) restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file")) restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
restore_parser.add_argument("target_dir", help=t("cli.help.restore.dir")) restore_parser.add_argument("target_dir", help=t("cli.help.restore.dir"))
restore_parser.add_argument(
"--password", default="", help=t("cli.help.restore.password")
)
restore_parser.add_argument(
"-l", "--list", action="store_true", help=t("cli.help.restore.list")
)
verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify"))
verify_parser.add_argument("backup_file", help=t("cli.help.verify.file"))
sftp_parser = subparsers.add_parser("sftp", help=t("cli.help.sftp")) sftp_parser = subparsers.add_parser("sftp", help=t("cli.help.sftp"))
sftp_sub = sftp_parser.add_subparsers( sftp_sub = sftp_parser.add_subparsers(
@@ -218,7 +230,11 @@ def _handle_sftp(args, config) -> int:
) )
try: try:
port = int(port_str) port = int(port_str)
if not (1 <= port <= 65535):
print(t("err.sftp.ssh", error=f"port {port} out of range"))
port = 22
except ValueError: except ValueError:
print(t("err.sftp.ssh", error=f"invalid port: {port_str}"))
port = 22 port = 22
user = args.user or input(t("cli.prompt.sftp.user") + " ") user = args.user or input(t("cli.prompt.sftp.user") + " ")
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ") key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ")
@@ -247,12 +263,21 @@ def _handle_sftp(args, config) -> int:
) )
key_passphrase_input = "" key_passphrase_input = ""
else: else:
password = args.password or getpass.getpass( # 用户指定了私钥,先尝试无密码加载,需要时再提示
t("cli.prompt.sftp.password") + " " if not args.key_passphrase:
) key_passphrase_input = _try_load_key_passphrase(key_file_input)
key_passphrase_input = args.key_passphrase or getpass.getpass( if key_passphrase_input is None:
t("cli.prompt.sftp.key_passphrase") + " " # 用户放弃输入密码短语,回退到密码认证
) key_file_input = ""
password = args.password or getpass.getpass(
t("cli.prompt.sftp.password") + " "
)
key_passphrase_input = ""
else:
password = ""
else:
password = ""
key_passphrase_input = args.key_passphrase
remote_path = ( remote_path = (
args.remote_path or input(t("cli.prompt.sftp.remote_path") + " ") or "/" args.remote_path or input(t("cli.prompt.sftp.remote_path") + " ") or "/"
@@ -320,7 +345,8 @@ def _handle_sftp(args, config) -> int:
print(str(e)) print(str(e))
return 1 return 1
return 0 print(t("cli.help.sftp.action"))
return 1
def _handle_webdav(args, config) -> int: def _handle_webdav(args, config) -> int:
@@ -362,7 +388,8 @@ def _handle_webdav(args, config) -> int:
print(str(e)) print(str(e))
return 1 return 1
return 0 print(t("cli.help.webdav.action"))
return 1
def run() -> int: def run() -> int:
@@ -426,6 +453,9 @@ def run() -> int:
elif args.command == "all": elif args.command == "all":
print(manager.list_folder_table()) print(manager.list_folder_table())
return 0 return 0
elif args.command in ("list", "history"):
print(manager.format_history_table())
return 0
elif args.command == "save": elif args.command == "save":
manager.execute_backups( manager.execute_backups(
keep=args.keep, keep=args.keep,
@@ -437,7 +467,7 @@ def run() -> int:
elif args.command == "watch": elif args.command == "watch":
import time as _time import time as _time
interval_sec = args.interval * 60 interval_sec = max(args.interval, 0.1) * 60 # 最小 6 秒
print(t("cmd.watch.start", interval=args.interval)) print(t("cmd.watch.start", interval=args.interval))
try: try:
while True: while True:
@@ -451,7 +481,17 @@ def run() -> int:
except KeyboardInterrupt: except KeyboardInterrupt:
return 0 return 0
elif args.command == "restore": elif args.command == "restore":
result = restore_backup(args.backup_file, args.target_dir) if args.list:
from sbackup.compression import list_backup_contents
print(list_backup_contents(args.backup_file, args.password))
return 0
result = restore_backup(args.backup_file, args.target_dir, args.password)
return 0 if result["success"] else 1
elif args.command == "verify":
from sbackup.compression import verify_backup
result = verify_backup(args.backup_file, args.password)
return 0 if result["success"] else 1 return 0 if result["success"] else 1
elif args.command == "sftp": elif args.command == "sftp":
return _handle_sftp(args, config) return _handle_sftp(args, config)
+94 -48
View File
@@ -2,6 +2,7 @@ import os
import json import json
import shutil import shutil
import logging import logging
import unicodedata
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from sbackup.config import ( from sbackup.config import (
@@ -15,6 +16,8 @@ from sbackup.i18n import t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HISTORY_KEY = "_history"
@dataclass @dataclass
class BackupEntry: class BackupEntry:
@@ -32,6 +35,8 @@ class BackupEntry:
@staticmethod @staticmethod
def from_list(data: list) -> "BackupEntry": def from_list(data: list) -> "BackupEntry":
"""从 JSON 兼容的列表格式创建(向后兼容旧格式)""" """从 JSON 兼容的列表格式创建(向后兼容旧格式)"""
if not isinstance(data, list) or len(data) < 3:
return BackupEntry(mtime=0.0, target="", skip_patterns=[])
fmt = data[3] if len(data) > 3 else "" fmt = data[3] if len(data) > 3 else ""
return BackupEntry( return BackupEntry(
mtime=data[0], target=data[1], skip_patterns=data[2], compression_format=fmt mtime=data[0], target=data[1], skip_patterns=data[2], compression_format=fmt
@@ -131,6 +136,10 @@ class BackupManager:
return False return False
abs_path = os.path.abspath(folder_path) abs_path = os.path.abspath(folder_path)
abs_dest = os.path.abspath(target_folder)
if abs_path == abs_dest:
print(t("err.dest.invalid", path=target_folder))
return False
if abs_path in self.data: if abs_path in self.data:
print(t("info.already.added", path=abs_path)) print(t("info.already.added", path=abs_path))
return False return False
@@ -181,7 +190,7 @@ class BackupManager:
skip_count = 0 skip_count = 0
uploaded_files = [] uploaded_files = []
for key, raw in list(self.data.items()): for key, raw in list(self.data.items()):
if key == "_history": if key == _HISTORY_KEY:
continue continue
if not os.path.exists(key): if not os.path.exists(key):
print(t("warn.source.missing", path=key)) print(t("warn.source.missing", path=key))
@@ -233,6 +242,28 @@ class BackupManager:
# 向后兼容别名 # 向后兼容别名
save_folder = execute_backups save_folder = execute_backups
@staticmethod
def _resolve_key_passphrase(key_file: str, SFTPClient, SFTPError) -> str | None:
"""
检测私钥是否需要密码短语,需要时交互式提示输入
:return: 密码短语(空字符串表示不需要),None 表示用户放弃
"""
try:
SFTPClient._load_private_key(key_file, "")
return ""
except SFTPError:
import getpass
while True:
passphrase = getpass.getpass(t("cli.prompt.sftp.key_passphrase") + " ")
if not passphrase:
return None
try:
SFTPClient._load_private_key(key_file, passphrase)
return passphrase
except SFTPError:
print(t("err.sftp.wrong_passphrase"))
@staticmethod @staticmethod
def _upload_to_sftp(file_paths: list[str], config: Config) -> None: def _upload_to_sftp(file_paths: list[str], config: Config) -> None:
"""将备份文件上传到 SFTP 服务器""" """将备份文件上传到 SFTP 服务器"""
@@ -252,49 +283,24 @@ class BackupManager:
if default_key: if default_key:
print(t("cmd.sftp.using_default_key", path=default_key)) print(t("cmd.sftp.using_default_key", path=default_key))
key_file = default_key key_file = default_key
# 尝试加载私钥检测是否需要密码短语 key_passphrase = BackupManager._resolve_key_passphrase(
try: default_key, SFTPClient, SFTPError
SFTPClient._load_private_key(key_file, "") )
except SFTPError: if key_passphrase is None:
# 需要密码短语,提示用户输入 key_file = ""
import getpass password = config.sftp_password
key_passphrase = ""
while True:
passphrase = getpass.getpass(
t("cli.prompt.sftp.key_passphrase") + " "
)
if not passphrase:
print(t("cmd.sftp.no_default_key"))
return
try:
SFTPClient._load_private_key(key_file, passphrase)
key_passphrase = passphrase
break
except SFTPError:
print(t("err.sftp.wrong_passphrase"))
else: else:
print(t("cmd.sftp.no_default_key")) print(t("cmd.sftp.no_default_key"))
return return
elif key_file and not key_passphrase and not password: elif key_file and not key_passphrase and not password:
# 已配置私钥但未设置密码短语,检测是否需要 key_passphrase = BackupManager._resolve_key_passphrase(
import getpass key_file, SFTPClient, SFTPError
)
try: if key_passphrase is None:
SFTPClient._load_private_key(key_file, "") key_file = ""
except SFTPError: password = config.sftp_password
while True: key_passphrase = ""
passphrase = getpass.getpass(
t("cli.prompt.sftp.key_passphrase") + " "
)
if not passphrase:
print(t("cmd.sftp.no_default_key"))
return
try:
SFTPClient._load_private_key(key_file, passphrase)
key_passphrase = passphrase
break
except SFTPError:
print(t("err.sftp.wrong_passphrase"))
try: try:
with SFTPClient( with SFTPClient(
@@ -363,7 +369,7 @@ class BackupManager:
"""记录备份历史""" """记录备份历史"""
from datetime import datetime from datetime import datetime
history = self.data.setdefault("_history", []) history = self.data.setdefault(_HISTORY_KEY, [])
history.append( history.append(
{ {
"time": datetime.now().isoformat(timespec="seconds"), "time": datetime.now().isoformat(timespec="seconds"),
@@ -374,11 +380,47 @@ class BackupManager:
) )
# 保留最近 100 条记录 # 保留最近 100 条记录
if len(history) > 100: if len(history) > 100:
self.data["_history"] = history[-100:] self.data[_HISTORY_KEY] = history[-100:]
def get_history(self) -> list[dict]: def get_history(self) -> list[dict]:
"""获取备份历史记录""" """获取备份历史记录"""
return self.data.get("_history", []) return self.data.get(_HISTORY_KEY, [])
def format_history_table(self) -> str:
"""生成备份历史的对齐文本表格"""
history = self.get_history()
if not history:
return t("cmd.list.empty")
headers = [
t("table.header.time"),
t("table.header.source"),
t("table.header.size"),
t("table.header.files"),
]
rows = []
for entry in reversed(history):
rows.append(
[
entry.get("time", ""),
entry.get("source", ""),
str(entry.get("size_mb", 0)),
str(entry.get("files_count", 0)),
]
)
col_widths = [self._display_width(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], self._display_width(cell))
fmt = " | ".join(["{:<" + str(w) + "}" for w in col_widths])
sep = "-+-".join(["-" * w for w in col_widths])
lines = [fmt.format(*headers), sep]
for row in rows:
lines.append(fmt.format(*row))
return "\n".join(lines)
@staticmethod @staticmethod
def _cleanup_old_backups(target_dir: str, keep: int): def _cleanup_old_backups(target_dir: str, keep: int):
@@ -418,17 +460,20 @@ class BackupManager:
查看所有备份策略 查看所有备份策略
""" """
return { return {
key: BackupEntry.from_list(raw).target for key, raw in self.data.items() key: BackupEntry.from_list(raw).target
for key, raw in self.data.items()
if key != _HISTORY_KEY
} }
@staticmethod @staticmethod
def _display_width(s: str) -> int: def _display_width(s: str) -> int:
"""计算字符串的终端显示宽度(中文字符算2,英文字符算1)""" """计算字符串的终端显示宽度(东亚宽字符算2,其余算1)"""
if not isinstance(s, str): if not isinstance(s, str):
return len(str(s)) return len(str(s))
width = 0 width = 0
for ch in s: for ch in s:
if ord(ch) > 0x2E80: eaw = unicodedata.east_asian_width(ch)
if eaw in ("W", "F"):
width += 2 width += 2
else: else:
width += 1 width += 1
@@ -438,7 +483,8 @@ class BackupManager:
""" """
生成对齐的文本表格 生成对齐的文本表格
""" """
if not self.data: non_history_keys = [k for k in self.data if k != _HISTORY_KEY]
if not non_history_keys:
return t("cmd.all.empty") return t("cmd.all.empty")
headers = [ headers = [
@@ -449,7 +495,7 @@ class BackupManager:
] ]
rows = [] rows = []
for path, raw in self.data.items(): for path, raw in self.data.items():
if path == "_history": if path == _HISTORY_KEY:
continue continue
entry = BackupEntry.from_list(raw) entry = BackupEntry.from_list(raw)
fmt_display = ( fmt_display = (
+222 -50
View File
@@ -2,9 +2,10 @@
压缩模块:ZIP / TAR / Zstd / 7z 文件压缩逻辑 压缩模块:ZIP / TAR / Zstd / 7z 文件压缩逻辑
""" """
import logging
import os import os
import io
import tarfile import tarfile
import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from fnmatch import fnmatch from fnmatch import fnmatch
@@ -12,6 +13,8 @@ from tqdm import tqdm
from sbackup.i18n import t from sbackup.i18n import t
from sbackup.config import Config from sbackup.config import Config
logger = logging.getLogger(__name__)
# compresslevel 仅对 ZIP_DEFLATED 和 ZIP_BZIP2 有效 # compresslevel 仅对 ZIP_DEFLATED 和 ZIP_BZIP2 有效
_VALID_COMPRESSLEVEL_ALGORITHMS = {zipfile.ZIP_DEFLATED, zipfile.ZIP_BZIP2} _VALID_COMPRESSLEVEL_ALGORITHMS = {zipfile.ZIP_DEFLATED, zipfile.ZIP_BZIP2}
@@ -27,6 +30,8 @@ _TAR_FORMATS = {
class BaseCompressor: class BaseCompressor:
"""压缩器基类,提供公共的文件收集和忽略逻辑""" """压缩器基类,提供公共的文件收集和忽略逻辑"""
_IGNORE_FILENAME = ".sbackupignore"
def __init__(self, config: Config) -> None: def __init__(self, config: Config) -> None:
self.folder_path: Path = Path(config.folder_path) self.folder_path: Path = Path(config.folder_path)
self.zipfile_path: Path | None = ( self.zipfile_path: Path | None = (
@@ -35,18 +40,51 @@ class BaseCompressor:
self.skip_patterns: list[str] = config.skip_patterns self.skip_patterns: list[str] = config.skip_patterns
self.compression_level: int | None = None self.compression_level: int | None = None
def _should_ignore(self, rel_path: str) -> bool: def _load_ignore_file(self, folder_path: Path) -> list[str]:
"""检查相对路径是否匹配忽略模式(支持路径级匹配如 subdir/*.log)""" """从源目录的 .sbackupignore 文件加载忽略规则"""
for pattern in self.skip_patterns: ignore_file = folder_path / self._IGNORE_FILENAME
if not ignore_file.is_file():
return []
try:
lines = ignore_file.read_text(encoding="utf-8").splitlines()
patterns = []
for line in lines:
line = line.strip()
if line and not line.startswith("#"):
patterns.append(line)
if patterns:
logger.debug(t("log.ignore.loaded"), ignore_file)
return patterns
except OSError:
return []
def _should_ignore(
self, rel_path: str, extra_patterns: list[str] | None = None
) -> bool:
"""检查相对路径是否匹配忽略模式(支持 ** 递归匹配和 ! 取反)"""
all_patterns = self.skip_patterns + (extra_patterns or [])
negated = []
matched = False
for pattern in all_patterns:
if pattern.startswith("!"):
negated.append(pattern[1:])
continue
if fnmatch(rel_path, pattern) or fnmatch( if fnmatch(rel_path, pattern) or fnmatch(
os.path.basename(rel_path), pattern os.path.basename(rel_path), pattern
): ):
return True matched = True
return False # 取反模式可以恢复被忽略的文件
for pattern in negated:
if fnmatch(rel_path, pattern) or fnmatch(
os.path.basename(rel_path), pattern
):
return False
return matched
def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]: def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]:
"""遍历文件夹收集需要压缩的文件列表,处理权限错误""" """遍历文件夹收集需要压缩的文件列表,处理权限错误"""
files = [] files = []
extra_patterns = self._load_ignore_file(folder_path)
try: try:
for dirpath, dirnames, filenames in os.walk(folder_path): for dirpath, dirnames, filenames in os.walk(folder_path):
try: try:
@@ -59,7 +97,8 @@ class BaseCompressor:
if not self._should_ignore( if not self._should_ignore(
os.path.join(rel_dir, d).replace("\\", "/") os.path.join(rel_dir, d).replace("\\", "/")
if rel_dir if rel_dir
else d else d,
extra_patterns,
) )
] ]
for filename in filenames: for filename in filenames:
@@ -68,7 +107,7 @@ class BaseCompressor:
if rel_dir if rel_dir
else filename else filename
) )
if not self._should_ignore(file_rel): if not self._should_ignore(file_rel, extra_patterns):
files.append((dirpath, filename)) files.append((dirpath, filename))
except PermissionError: except PermissionError:
print(t("err.permission", path=dirpath)) print(t("err.permission", path=dirpath))
@@ -199,7 +238,7 @@ class TarfileCompression(BaseCompressor):
def __init__(self, config: Config) -> None: def __init__(self, config: Config) -> None:
super().__init__(config) super().__init__(config)
fmt = config.compression_format.upper() fmt = config.compression_format.upper().replace(".", "_")
if fmt not in _TAR_FORMATS: if fmt not in _TAR_FORMATS:
fmt = "TAR_GZ" fmt = "TAR_GZ"
self._extension, self._mode = _TAR_FORMATS[fmt] self._extension, self._mode = _TAR_FORMATS[fmt]
@@ -334,28 +373,26 @@ class ZstdCompression(BaseCompressor):
try: try:
cctx = zstd.ZstdCompressor(level=self.compression_level) cctx = zstd.ZstdCompressor(level=self.compression_level)
# 先创建 tar,再用 zstd 压缩 with open(output_path, "wb") as f_out:
tar_buffer = io.BytesIO() compressor = cctx.stream_writer(f_out)
with tarfile.open(fileobj=tar_buffer, mode="w") as tarf: with tarfile.open(fileobj=compressor, mode="w") as tarf:
with tqdm( with tqdm(
total=total_files, total=total_files,
desc=t("compress.progress"), desc=t("compress.progress"),
unit=t("compress.unit"), unit=t("compress.unit"),
) as pbar: ) as pbar:
for dirpath, filename in files_to_compress: for dirpath, filename in files_to_compress:
file_path = Path(dirpath) / filename file_path = Path(dirpath) / filename
arcname = str( arcname = str(
folder_path.name / file_path.relative_to(folder_path) folder_path.name / file_path.relative_to(folder_path)
).replace("\\", "/") ).replace("\\", "/")
try: try:
tarf.add(file_path, arcname=arcname, recursive=False) tarf.add(file_path, arcname=arcname, recursive=False)
pbar.update(1) pbar.update(1)
files_count += 1 files_count += 1
except (FileNotFoundError, PermissionError): except (FileNotFoundError, PermissionError):
continue continue
compressor.close()
compressed = cctx.compress(tar_buffer.getvalue())
output_path.write_bytes(compressed)
size_mb = output_path.stat().st_size / (1024 * 1024) size_mb = output_path.stat().st_size / (1024 * 1024)
print( print(
@@ -469,7 +506,7 @@ class SevenZipCompression(BaseCompressor):
def create_compressor(config: Config) -> BaseCompressor: def create_compressor(config: Config) -> BaseCompressor:
"""工厂函数:根据配置创建对应的压缩器""" """工厂函数:根据配置创建对应的压缩器"""
fmt = config.compression_format.upper() fmt = config.compression_format.upper().replace(".", "_")
if fmt in _TAR_FORMATS: if fmt in _TAR_FORMATS:
return TarfileCompression(config) return TarfileCompression(config)
if fmt == "TAR_ZST": if fmt == "TAR_ZST":
@@ -479,15 +516,16 @@ def create_compressor(config: Config) -> BaseCompressor:
return ZipfileCompression(config) return ZipfileCompression(config)
def restore_backup(backup_path: str, target_dir: str) -> dict: def restore_backup(backup_path: str, target_dir: str, password: str = "") -> dict:
""" """
从备份文件还原到目标目录 从备份文件还原到目标目录
自动检测格式(ZIP / tar.gz / tar.bz2 / tar.xz) 自动检测格式(ZIP / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)
:param password: 解密密码(仅 7z 加密备份需要)
:return: 包含统计信息的字典 :return: 包含统计信息的字典
""" """
backup = Path(backup_path) backup = Path(backup_path)
if not backup.exists(): if not backup.exists():
print(t("err.folder.invalid", path=backup_path)) print(t("err.file.not_found", path=backup_path))
return {"success": False, "files_count": 0} return {"success": False, "files_count": 0}
target = Path(target_dir) target = Path(target_dir)
@@ -511,7 +549,10 @@ def restore_backup(backup_path: str, target_dir: str) -> dict:
elif name_lower.endswith(".7z"): elif name_lower.endswith(".7z"):
import py7zr import py7zr
with py7zr.SevenZipFile(backup, "r") as szf: szf_kwargs = {"file": backup, "mode": "r"}
if password:
szf_kwargs["password"] = password
with py7zr.SevenZipFile(**szf_kwargs) as szf:
members = szf.getnames() members = szf.getnames()
with tqdm( with tqdm(
total=len(members), total=len(members),
@@ -526,20 +567,28 @@ def restore_backup(backup_path: str, target_dir: str) -> dict:
import zstandard as zstd import zstandard as zstd
dctx = zstd.ZstdDecompressor() dctx = zstd.ZstdDecompressor()
compressed = backup.read_bytes() # tarfile 需要可 seek 的 fileobj,先流式解压到临时文件
tar_data = dctx.decompress(compressed) with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp:
with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r") as tarf: with open(backup, "rb") as f_in:
members = tarf.getmembers() reader = dctx.stream_reader(f_in)
with tqdm( while True:
total=len(members), chunk = reader.read(65536)
desc=t("restore.progress"), if not chunk:
unit=t("compress.unit"), break
) as pbar: tmp.write(chunk)
for member in members: tmp.seek(0)
tarf.extract(member, target, filter="data") with tarfile.open(fileobj=tmp, mode="r") as tarf:
pbar.update(1) members = tarf.getmembers()
print(t("restore.success", path=target, count=len(members))) with tqdm(
return {"success": True, "files_count": len(members)} total=len(members),
desc=t("restore.progress"),
unit=t("compress.unit"),
) as pbar:
for member in members:
tarf.extract(member, target, filter="data")
pbar.update(1)
print(t("restore.success", path=target, count=len(members)))
return {"success": True, "files_count": len(members)}
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
mode = "r:gz" mode = "r:gz"
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
@@ -573,3 +622,126 @@ def restore_backup(backup_path: str, target_dir: str) -> dict:
except Exception as e: except Exception as e:
print(t("err.unknown", error=e)) print(t("err.unknown", error=e))
return {"success": False, "files_count": 0} return {"success": False, "files_count": 0}
def _get_archive_member_names(backup: Path, password: str = "") -> list[str]:
"""获取压缩包内所有成员名称,自动检测格式"""
name_lower = backup.name.lower()
if name_lower.endswith(".zip"):
with zipfile.ZipFile(backup, "r") as zf:
return zf.namelist()
elif name_lower.endswith(".7z"):
import py7zr
szf_kwargs = {"file": backup, "mode": "r"}
if password:
szf_kwargs["password"] = password
with py7zr.SevenZipFile(**szf_kwargs) as szf:
return szf.getnames()
elif name_lower.endswith(".tar.zst"):
import zstandard as zstd
dctx = zstd.ZstdDecompressor()
with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp:
with open(backup, "rb") as f_in:
reader = dctx.stream_reader(f_in)
while True:
chunk = reader.read(65536)
if not chunk:
break
tmp.write(chunk)
tmp.seek(0)
with tarfile.open(fileobj=tmp, mode="r") as tarf:
return [m.name for m in tarf.getmembers()]
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
mode = "r:gz"
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
mode = "r:bz2"
elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"):
mode = "r:xz"
elif name_lower.endswith(".tar"):
mode = "r"
else:
return []
with tarfile.open(backup, mode) as tarf:
return tarf.getnames()
def list_backup_contents(backup_path: str, password: str = "") -> str:
"""
列出备份文件内的所有文件,不解压
:return: 格式化的文件列表字符串
"""
backup = Path(backup_path)
if not backup.exists():
return t("err.file.not_found", path=backup_path)
try:
members = _get_archive_member_names(backup, password)
except Exception as e:
return t("err.unknown", error=e)
if not members:
return t("restore.list.empty", path=backup_path)
lines = [t("restore.list.title", path=backup_path)]
for name in members:
lines.append(f" {name}")
lines.append(f"\n({len(members)} files)")
return "\n".join(lines)
def verify_backup(backup_path: str, password: str = "") -> dict:
"""
校验备份文件完整性:解压到临时目录后比对文件数
:return: 包含校验结果的字典
"""
backup = Path(backup_path)
if not backup.exists():
print(t("err.file.not_found", path=backup_path))
return {"success": False, "files_count": 0}
print(t("cmd.verify.checking", path=backup_path))
try:
expected_names = _get_archive_member_names(backup, password)
except Exception as e:
print(t("err.unknown", error=e))
return {"success": False, "files_count": 0}
# 解压到临时目录
import tempfile as _tempfile
with _tempfile.TemporaryDirectory() as tmp_dir:
result = restore_backup(backup_path, tmp_dir, password)
if not result["success"]:
print(
t(
"cmd.verify.failed",
path=backup_path,
expected=len(expected_names),
actual=0,
)
)
return {"success": False, "files_count": 0}
actual_count = result["files_count"]
if actual_count == len(expected_names):
print(
t(
"cmd.verify.success",
path=backup_path,
count=actual_count,
)
)
return {"success": True, "files_count": actual_count}
else:
print(
t(
"cmd.verify.failed",
path=backup_path,
expected=len(expected_names),
actual=actual_count,
)
)
return {"success": False, "files_count": actual_count}
+41 -106
View File
@@ -14,6 +14,35 @@ logger = logging.getLogger(__name__)
DEFAULT_SKIP_PATTERNS = [".git", "__pycache__"] DEFAULT_SKIP_PATTERNS = [".git", "__pycache__"]
def _load_json_file(config_file: str) -> dict:
"""读取 JSON 配置文件,损坏时返回空字典"""
if not os.path.exists(config_file):
return {}
try:
with open(config_file, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
logger.warning(t("log.config.reset"), config_file)
return {}
def _save_json_file(data: dict, config_file: str) -> None:
"""将字典写入 JSON 配置文件,自动创建目录"""
data_dir = os.path.dirname(config_file)
if data_dir:
try:
os.makedirs(data_dir, exist_ok=True)
except OSError as e:
logger.error(t("log.config.mkdir.error"), data_dir, e)
return
try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except OSError as e:
logger.error(t("log.config.write.error"), config_file, e)
def get_default_data_file() -> str: def get_default_data_file() -> str:
"""返回跨平台的默认数据文件路径""" """返回跨平台的默认数据文件路径"""
if sys.platform == "win32": if sys.platform == "win32":
@@ -72,7 +101,7 @@ def load_config(config_file: str = "config.json") -> Config:
compression_config = config_data.get("compression", {}) compression_config = config_data.get("compression", {})
skip_patterns = config_data.get("skip_patterns", DEFAULT_SKIP_PATTERNS) skip_patterns = config_data.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
data_file = config_data.get("data_file", get_default_data_file()) data_file = config_data.get("data_file", get_default_data_file())
lang = config_data.get("lang", "en_US") lang = config_data.get("lang", "zh_CN")
compression_format = config_data.get("compression_format", "ZIP") compression_format = config_data.get("compression_format", "ZIP")
sftp_config = config_data.get("sftp", {}) sftp_config = config_data.get("sftp", {})
webdav_config = config_data.get("webdav", {}) webdav_config = config_data.get("webdav", {})
@@ -103,65 +132,17 @@ def load_config(config_file: str = "config.json") -> Config:
def save_lang(lang: str, config_file: str = "config.json") -> None: def save_lang(lang: str, config_file: str = "config.json") -> None:
""" """将语言偏好保存到配置文件"""
将语言偏好保存到配置文件 data = _load_json_file(config_file)
"""
if os.path.exists(config_file):
try:
with open(config_file, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
logger.warning(t("log.config.reset"), config_file)
data = {}
else:
data = {}
data["lang"] = lang data["lang"] = lang
_save_json_file(data, config_file)
data_dir = os.path.dirname(config_file)
if data_dir:
try:
os.makedirs(data_dir, exist_ok=True)
except OSError as e:
logger.error(t("log.config.mkdir.error"), data_dir, e)
return
try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except OSError as e:
logger.error(t("log.config.write.error"), config_file, e)
def save_format(fmt: str, config_file: str = "config.json") -> None: def save_format(fmt: str, config_file: str = "config.json") -> None:
""" """将打包格式偏好保存到配置文件"""
将打包格式偏好保存到配置文件 data = _load_json_file(config_file)
"""
if os.path.exists(config_file):
try:
with open(config_file, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
logger.warning(t("log.config.reset"), config_file)
data = {}
else:
data = {}
data["compression_format"] = fmt data["compression_format"] = fmt
_save_json_file(data, config_file)
data_dir = os.path.dirname(config_file)
if data_dir:
try:
os.makedirs(data_dir, exist_ok=True)
except OSError as e:
logger.error(t("log.config.mkdir.error"), data_dir, e)
return
try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except OSError as e:
logger.error(t("log.config.write.error"), config_file, e)
def save_sftp_config( def save_sftp_config(
@@ -175,19 +156,8 @@ def save_sftp_config(
key_passphrase: str = "", key_passphrase: str = "",
config_file: str = "config.json", config_file: str = "config.json",
) -> None: ) -> None:
""" """将 SFTP 配置保存到配置文件"""
将 SFTP 配置保存到配置文件 data = _load_json_file(config_file)
"""
if os.path.exists(config_file):
try:
with open(config_file, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
logger.warning(t("log.config.reset"), config_file)
data = {}
else:
data = {}
data["sftp"] = { data["sftp"] = {
"host": host, "host": host,
"port": port, "port": port,
@@ -198,20 +168,7 @@ def save_sftp_config(
"remote_path": remote_path, "remote_path": remote_path,
"enabled": enabled, "enabled": enabled,
} }
_save_json_file(data, config_file)
data_dir = os.path.dirname(config_file)
if data_dir:
try:
os.makedirs(data_dir, exist_ok=True)
except OSError as e:
logger.error(t("log.config.mkdir.error"), data_dir, e)
return
try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except OSError as e:
logger.error(t("log.config.write.error"), config_file, e)
def save_webdav_config( def save_webdav_config(
@@ -223,16 +180,7 @@ def save_webdav_config(
config_file: str = "config.json", config_file: str = "config.json",
) -> None: ) -> None:
"""将 WebDAV 配置保存到配置文件""" """将 WebDAV 配置保存到配置文件"""
if os.path.exists(config_file): data = _load_json_file(config_file)
try:
with open(config_file, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError:
logger.warning(t("log.config.reset"), config_file)
data = {}
else:
data = {}
data["webdav"] = { data["webdav"] = {
"url": url, "url": url,
"user": user, "user": user,
@@ -240,17 +188,4 @@ def save_webdav_config(
"remote_path": remote_path, "remote_path": remote_path,
"enabled": enabled, "enabled": enabled,
} }
_save_json_file(data, config_file)
data_dir = os.path.dirname(config_file)
if data_dir:
try:
os.makedirs(data_dir, exist_ok=True)
except OSError as e:
logger.error(t("log.config.mkdir.error"), data_dir, e)
return
try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except OSError as e:
logger.error(t("log.config.write.error"), config_file, e)
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Backup-Strategie erfolgreich hinzugefügt: {source} -> {dest}", "cmd.add.success": "Backup-Strategie erfolgreich hinzugefügt: {source} -> {dest}",
"cmd.rm.success": "Backup-Strategie erfolgreich entfernt: {path}", "cmd.rm.success": "Backup-Strategie erfolgreich entfernt: {path}",
"err.folder.invalid": "Fehler: {path} ist kein gültiger Ordner oder existiert nicht.", "err.folder.invalid": "Fehler: {path} ist kein gültiger Ordner oder existiert nicht.",
"err.file.not_found": "Error: Backup-Datei {path} existiert nicht.",
"err.dest.invalid": "Fehler: Zielordner {path} ist kein gültiger Ordner oder existiert nicht.", "err.dest.invalid": "Fehler: Zielordner {path} ist kein gültiger Ordner oder existiert nicht.",
"info.already.added": "Info: {path} wurde bereits hinzugefügt. Bitte nicht duplizieren.", "info.already.added": "Info: {path} wurde bereits hinzugefügt. Bitte nicht duplizieren.",
"warn.no.strategy.found": "Warnung: Backup-Strategie für {path} nicht gefunden.", "warn.no.strategy.found": "Warnung: Backup-Strategie für {path} nicht gefunden.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "Alle Backup-Strategien sind aktuell, kein Backup erforderlich.", "cmd.save.uptodate": "Alle Backup-Strategien sind aktuell, kein Backup erforderlich.",
"cmd.save.completed": "Backup abgeschlossen, {count} Strategie(n) bearbeitet.", "cmd.save.completed": "Backup abgeschlossen, {count} Strategie(n) bearbeitet.",
"cmd.all.empty": "Keine Backup-Strategien konfiguriert.", "cmd.all.empty": "Keine Backup-Strategien konfiguriert.",
"cmd.list.empty": "Noch keine Backup-Historie.",
"cmd.verify.checking": "Überprüfe Backup: {path}",
"cmd.verify.success": "Überprüfung bestanden: {path} ({count} Dateien)",
"cmd.verify.failed": "Überprüfung fehlgeschlagen: {path} — erwartet {expected} Dateien, erhalten {actual}",
"restore.list.title": "Archivinhalt: {path}",
"restore.list.empty": "Archiv ist leer: {path}",
"log.ignore.loaded": "Ignorierregeln-Datei geladen: %s",
"exit.message": "\nBeendet.", "exit.message": "\nBeendet.",
"table.header.source": "Quellordner", "table.header.source": "Quellordner",
"table.header.dest": "Zielordner", "table.header.dest": "Zielordner",
"table.header.ignore": " Ausschlussmuster", "table.header.ignore": " Ausschlussmuster",
"table.header.time": "Backup-Zeit",
"table.header.size": "Größe(MB)",
"table.header.files": "Dateien",
"table.cell.none": "Keine", "table.cell.none": "Keine",
"table.header.format": "Format", "table.header.format": "Format",
"table.cell.default": "Standard", "table.cell.default": "Standard",
@@ -62,6 +73,11 @@
"cli.help.restore": "Aus einer Backup-Datei wiederherstellen", "cli.help.restore": "Aus einer Backup-Datei wiederherstellen",
"cli.help.restore.file": "Backup-Dateipfad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "Backup-Dateipfad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "Zielverzeichnis für Wiederherstellung", "cli.help.restore.dir": "Zielverzeichnis für Wiederherstellung",
"cli.help.restore.password": "Entschlüsselungspasswort (zum Wiederherstellen verschüsselter 7z-Backups)",
"cli.help.restore.list": "Archivinhalt auflisten ohne zu extrahieren",
"cli.help.list": "Backup-Verlauf anzeigen",
"cli.help.verify": "Backup-Datei-Integrität überprüfen",
"cli.help.verify.file": "Zu überprüfende Backup-Datei",
"cli.help.version": "Versionsinformationen anzeigen", "cli.help.version": "Versionsinformationen anzeigen",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLizenz GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLizenz GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "Konfigurationsdatei %s ist fehlerhaft, Standardwerte werden verwendet", "log.config.malformed": "Konfigurationsdatei %s ist fehlerhaft, Standardwerte werden verwendet",
+13
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Backup strategy added successfully: {source} -> {dest}", "cmd.add.success": "Backup strategy added successfully: {source} -> {dest}",
"cmd.rm.success": "Backup strategy removed successfully: {path}", "cmd.rm.success": "Backup strategy removed successfully: {path}",
"err.folder.invalid": "Error: {path} is not a valid folder or does not exist.", "err.folder.invalid": "Error: {path} is not a valid folder or does not exist.",
"err.file.not_found": "Error: Backup file {path} does not exist.",
"err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.", "err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.",
"info.already.added": "Info: {path} has already been added. Please do not duplicate.", "info.already.added": "Info: {path} has already been added. Please do not duplicate.",
"warn.no.strategy.found": "Warning: Backup strategy for {path} not found.", "warn.no.strategy.found": "Warning: Backup strategy for {path} not found.",
@@ -27,6 +28,13 @@
"cmd.save.uptodate": "All backup strategies are up to date, no backup needed.", "cmd.save.uptodate": "All backup strategies are up to date, no backup needed.",
"cmd.save.completed": "Backup completed, processed {count} strategy(s).", "cmd.save.completed": "Backup completed, processed {count} strategy(s).",
"cmd.all.empty": "No backup strategies configured.", "cmd.all.empty": "No backup strategies configured.",
"cmd.list.empty": "No backup history yet.",
"cmd.verify.checking": "Verifying backup: {path}",
"cmd.verify.success": "Verification passed: {path} ({count} files)",
"cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}",
"restore.list.title": "Archive contents: {path}",
"restore.list.empty": "Archive is empty: {path}",
"log.ignore.loaded": "Loaded ignore rules file: %s",
"exit.message": "\nExiting.", "exit.message": "\nExiting.",
"table.header.source": "Source Folder", "table.header.source": "Source Folder",
"table.header.dest": "Target Folder", "table.header.dest": "Target Folder",
@@ -62,6 +70,11 @@
"cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", "cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
"cli.help.restore.file": "Backup file path", "cli.help.restore.file": "Backup file path",
"cli.help.restore.dir": "Target directory for restoration", "cli.help.restore.dir": "Target directory for restoration",
"cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)",
"cli.help.restore.list": "List archive contents without extracting",
"cli.help.list": "View backup history",
"cli.help.verify": "Verify backup file integrity",
"cli.help.verify.file": "Backup file to verify",
"cli.help.version": "Show version information", "cli.help.version": "Show version information",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "Config file %s is malformed, using defaults", "log.config.malformed": "Config file %s is malformed, using defaults",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Estrategia de copia de seguridad añadida con éxito: {source} -> {dest}", "cmd.add.success": "Estrategia de copia de seguridad añadida con éxito: {source} -> {dest}",
"cmd.rm.success": "Estrategia de copia de seguridad eliminada con éxito: {path}", "cmd.rm.success": "Estrategia de copia de seguridad eliminada con éxito: {path}",
"err.folder.invalid": "Error: {path} no es una carpeta válida o no existe.", "err.folder.invalid": "Error: {path} no es una carpeta válida o no existe.",
"err.file.not_found": "Error: El archivo de copia de seguridad {path} no existe.",
"err.dest.invalid": "Error: La carpeta destino {path} no es una carpeta válida o no existe.", "err.dest.invalid": "Error: La carpeta destino {path} no es una carpeta válida o no existe.",
"info.already.added": "Info: {path} ya ha sido añadido. No lo duplique.", "info.already.added": "Info: {path} ya ha sido añadido. No lo duplique.",
"warn.no.strategy.found": "Advertencia: Estrategia de copia de seguridad para {path} no encontrada.", "warn.no.strategy.found": "Advertencia: Estrategia de copia de seguridad para {path} no encontrada.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "Todas las estrategias de copia de seguridad están actualizadas, no se necesita copia.", "cmd.save.uptodate": "Todas las estrategias de copia de seguridad están actualizadas, no se necesita copia.",
"cmd.save.completed": "Copia de seguridad completada, {count} estrategia(s) procesada(s).", "cmd.save.completed": "Copia de seguridad completada, {count} estrategia(s) procesada(s).",
"cmd.all.empty": "No hay estrategias de copia de seguridad configuradas.", "cmd.all.empty": "No hay estrategias de copia de seguridad configuradas.",
"cmd.list.empty": "Aún no hay historial de copias de seguridad.",
"cmd.verify.checking": "Verificando copia de seguridad: {path}",
"cmd.verify.success": "Verificación exitosa: {path} ({count} archivos)",
"cmd.verify.failed": "Verificación fallida: {path} — se esperaban {expected} archivos, se obtuvieron {actual}",
"restore.list.title": "Contenido del archivo: {path}",
"restore.list.empty": "El archivo está vacío: {path}",
"log.ignore.loaded": "Archivo de reglas de ignorar cargado: %s",
"exit.message": "\nSaliendo.", "exit.message": "\nSaliendo.",
"table.header.source": "Carpeta fuente", "table.header.source": "Carpeta fuente",
"table.header.dest": "Carpeta destino", "table.header.dest": "Carpeta destino",
"table.header.ignore": "Patrones de exclusión", "table.header.ignore": "Patrones de exclusión",
"table.header.time": "Hora de copia",
"table.header.size": "Tamaño(MB)",
"table.header.files": "Archivos",
"table.cell.none": "Ninguno", "table.cell.none": "Ninguno",
"table.header.format": "Formato", "table.header.format": "Formato",
"table.cell.default": "Predet.", "table.cell.default": "Predet.",
@@ -62,6 +73,11 @@
"cli.help.restore": "Restaurar desde un archivo de copia de seguridad", "cli.help.restore": "Restaurar desde un archivo de copia de seguridad",
"cli.help.restore.file": "Ruta del archivo de copia de seguridad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "Ruta del archivo de copia de seguridad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "Directorio destino para la restauración", "cli.help.restore.dir": "Directorio destino para la restauración",
"cli.help.restore.password": "Contraseña de descifrado (para restaurar copias de seguridad 7z cifradas)",
"cli.help.restore.list": "Listar contenido del archivo sin extraer",
"cli.help.list": "Ver historial de copias de seguridad",
"cli.help.verify": "Verificar integridad del archivo de copia de seguridad",
"cli.help.verify.file": "Archivo de copia de seguridad a verificar",
"cli.help.version": "Mostrar la información de versión", "cli.help.version": "Mostrar la información de versión",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicencia GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicencia GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "El archivo de configuración %s está malformado, usando valores predeterminados", "log.config.malformed": "El archivo de configuración %s está malformado, usando valores predeterminados",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Stratégie de sauvegarde ajoutée avec succès : {source} -> {dest}", "cmd.add.success": "Stratégie de sauvegarde ajoutée avec succès : {source} -> {dest}",
"cmd.rm.success": "Stratégie de sauvegarde supprimée avec succès : {path}", "cmd.rm.success": "Stratégie de sauvegarde supprimée avec succès : {path}",
"err.folder.invalid": "Erreur : {path} n'est pas un dossier valide ou n'existe pas.", "err.folder.invalid": "Erreur : {path} n'est pas un dossier valide ou n'existe pas.",
"err.file.not_found": "Erreur : Le fichier de sauvegarde {path} n'existe pas.",
"err.dest.invalid": "Erreur : Le dossier cible {path} n'est pas un dossier valide ou n'existe pas.", "err.dest.invalid": "Erreur : Le dossier cible {path} n'est pas un dossier valide ou n'existe pas.",
"info.already.added": "Info : {path} a déjà été ajouté. Veuillez ne pas dupliquer.", "info.already.added": "Info : {path} a déjà été ajouté. Veuillez ne pas dupliquer.",
"warn.no.strategy.found": "Attention : Stratégie de sauvegarde pour {path} non trouvée.", "warn.no.strategy.found": "Attention : Stratégie de sauvegarde pour {path} non trouvée.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "Toutes les stratégies de sauvegarde sont à jour, aucune sauvegarde nécessaire.", "cmd.save.uptodate": "Toutes les stratégies de sauvegarde sont à jour, aucune sauvegarde nécessaire.",
"cmd.save.completed": "Sauvegarde terminée, {count} stratégie(s) traitée(s).", "cmd.save.completed": "Sauvegarde terminée, {count} stratégie(s) traitée(s).",
"cmd.all.empty": "Aucune stratégie de sauvegarde configurée.", "cmd.all.empty": "Aucune stratégie de sauvegarde configurée.",
"cmd.list.empty": "Aucun historique de sauvegarde pour le moment.",
"cmd.verify.checking": "Vérification de la sauvegarde: {path}",
"cmd.verify.success": "Vérification réussie: {path} ({count} fichiers)",
"cmd.verify.failed": "Vérification échouée: {path} — {expected} fichiers attendus, {actual} trouvés",
"restore.list.title": "Contenu de l'archive: {path}",
"restore.list.empty": "L'archive est vide: {path}",
"log.ignore.loaded": "Fichier de règles d'ignorance chargé: %s",
"exit.message": "\nSortie.", "exit.message": "\nSortie.",
"table.header.source": "Dossier source", "table.header.source": "Dossier source",
"table.header.dest": "Dossier cible", "table.header.dest": "Dossier cible",
"table.header.ignore": "Modèles d'exclusion", "table.header.ignore": "Modèles d'exclusion",
"table.header.time": "Heure de sauvegarde",
"table.header.size": "Taille(Mo)",
"table.header.files": "Fichiers",
"table.cell.none": "Aucun", "table.cell.none": "Aucun",
"table.header.format": "Format", "table.header.format": "Format",
"table.cell.default": "Défaut", "table.cell.default": "Défaut",
@@ -62,6 +73,11 @@
"cli.help.restore": "Restaurer depuis un fichier de sauvegarde", "cli.help.restore": "Restaurer depuis un fichier de sauvegarde",
"cli.help.restore.file": "Chemin du fichier de sauvegarde (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "Chemin du fichier de sauvegarde (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "Dossier cible pour la restauration", "cli.help.restore.dir": "Dossier cible pour la restauration",
"cli.help.restore.password": "Mot de passe de déchiffrement (pour restaurer les sauvegardes 7z chiffrées)",
"cli.help.restore.list": "Lister le contenu de l'archive sans extraire",
"cli.help.list": "Voir l'historique des sauvegardes",
"cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde",
"cli.help.verify.file": "Fichier de sauvegarde à vérifier",
"cli.help.version": "Afficher les informations de version", "cli.help.version": "Afficher les informations de version",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicence GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicence GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "Le fichier de configuration %s est malformé, utilisation des valeurs par défaut", "log.config.malformed": "Le fichier de configuration %s est malformé, utilisation des valeurs par défaut",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "バックアップ戦略を追加しました: {source} -> {dest}", "cmd.add.success": "バックアップ戦略を追加しました: {source} -> {dest}",
"cmd.rm.success": "バックアップ戦略を削除しました: {path}", "cmd.rm.success": "バックアップ戦略を削除しました: {path}",
"err.folder.invalid": "エラー: {path} は有効なフォルダではないか、存在しません。", "err.folder.invalid": "エラー: {path} は有効なフォルダではないか、存在しません。",
"err.file.not_found": "エラー:バックアップファイル {path} が存在しません。",
"err.dest.invalid": "エラー: 目標フォルダ {path} は有効なフォルダではないか、存在しません。", "err.dest.invalid": "エラー: 目標フォルダ {path} は有効なフォルダではないか、存在しません。",
"info.already.added": "情報: {path} は既に追加されています。重複して追加しないでください。", "info.already.added": "情報: {path} は既に追加されています。重複して追加しないでください。",
"warn.no.strategy.found": "警告: {path} のバックアップ戦略が見つかりません。", "warn.no.strategy.found": "警告: {path} のバックアップ戦略が見つかりません。",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "すべてのバックアップ戦略は最新です、バックアップは不要です。", "cmd.save.uptodate": "すべてのバックアップ戦略は最新です、バックアップは不要です。",
"cmd.save.completed": "バックアップ完了、{count} 戦略を処理しました。", "cmd.save.completed": "バックアップ完了、{count} 戦略を処理しました。",
"cmd.all.empty": "バックアップ戦略が設定されていません。", "cmd.all.empty": "バックアップ戦略が設定されていません。",
"cmd.list.empty": "バックアップ履歴はまだありません。",
"cmd.verify.checking": "バックアップを検証中: {path}",
"cmd.verify.success": "検証成功: {path} ({count} ファイル)",
"cmd.verify.failed": "検証失敗: {path} — 予想 {expected} ファイル、実際 {actual} ファイル",
"restore.list.title": "アーカイブ内容: {path}",
"restore.list.empty": "アーカイブが空です: {path}",
"log.ignore.loaded": "無視ルールファイルを読み込みました: %s",
"exit.message": "\n終了します。", "exit.message": "\n終了します。",
"table.header.source": "ソースフォルダ", "table.header.source": "ソースフォルダ",
"table.header.dest": "目標フォルダ", "table.header.dest": "目標フォルダ",
"table.header.ignore": "除外パターン", "table.header.ignore": "除外パターン",
"table.header.time": "バックアップ時刻",
"table.header.size": "サイズ(MB)",
"table.header.files": "ファイル数",
"table.cell.none": "なし", "table.cell.none": "なし",
"table.header.format": "形式", "table.header.format": "形式",
"table.cell.default": "デフォルト", "table.cell.default": "デフォルト",
@@ -62,6 +73,11 @@
"cli.help.restore": "バックアップファイルから復元", "cli.help.restore": "バックアップファイルから復元",
"cli.help.restore.file": "バックアップファイルパス (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "バックアップファイルパス (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "復元目標ディレクトリ", "cli.help.restore.dir": "復元目標ディレクトリ",
"cli.help.restore.password": "復号化パスワード(暗号化された7zバックアップの復元時に使用)",
"cli.help.restore.list": "展開せずにアーカイブ内容を一覧表示",
"cli.help.list": "バックアップ履歴を表示",
"cli.help.verify": "バックアップファイルの整合性を検証",
"cli.help.verify.file": "検証するバックアップファイル",
"cli.help.version": "バージョン情報を表示", "cli.help.version": "バージョン情報を表示",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 ライセンス — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 ライセンス — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "設定ファイル %s が破損しています、デフォルト値を使用", "log.config.malformed": "設定ファイル %s が破損しています、デフォルト値を使用",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "백업 전략 추가 성공: {source} -> {dest}", "cmd.add.success": "백업 전략 추가 성공: {source} -> {dest}",
"cmd.rm.success": "백업 전략 삭제 성공: {path}", "cmd.rm.success": "백업 전략 삭제 성공: {path}",
"err.folder.invalid": "오류: {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", "err.folder.invalid": "오류: {path}는 유효한 폴더가 아니거나 존재하지 않습니다.",
"err.file.not_found": "오류: 백업 파일 {path}이(가) 존재하지 않습니다.",
"err.dest.invalid": "오류: 대상 폴더 {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", "err.dest.invalid": "오류: 대상 폴더 {path}는 유효한 폴더가 아니거나 존재하지 않습니다.",
"info.already.added": "알림: {path}가 이미 추가되었습니다. 중복 추가하지 마세요.", "info.already.added": "알림: {path}가 이미 추가되었습니다. 중복 추가하지 마세요.",
"warn.no.strategy.found": "경고: {path}의 백업 전략을 찾을 수 없습니다.", "warn.no.strategy.found": "경고: {path}의 백업 전략을 찾을 수 없습니다.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "모든 백업 전략이 최신 상태입니다, 백업이 필요하지 않습니다.", "cmd.save.uptodate": "모든 백업 전략이 최신 상태입니다, 백업이 필요하지 않습니다.",
"cmd.save.completed": "백업 완료, {count}개 전략 처리됨.", "cmd.save.completed": "백업 완료, {count}개 전략 처리됨.",
"cmd.all.empty": "구성된 백업 전략이 없습니다.", "cmd.all.empty": "구성된 백업 전략이 없습니다.",
"cmd.list.empty": "아직 백업 기록이 없습니다.",
"cmd.verify.checking": "백업 파일 검증 중: {path}",
"cmd.verify.success": "검증 성공: {path} ({count}개 파일)",
"cmd.verify.failed": "검증 실패: {path} — 예상 {expected}개 파일, 실제 {actual}개",
"restore.list.title": "압축 파일 내용: {path}",
"restore.list.empty": "압축 파일이 비어 있습니다: {path}",
"log.ignore.loaded": "무시 규칙 파일 로드됨: %s",
"exit.message": "\n종료합니다.", "exit.message": "\n종료합니다.",
"table.header.source": "원본 폴더", "table.header.source": "원본 폴더",
"table.header.dest": "대상 폴더", "table.header.dest": "대상 폴더",
"table.header.ignore": "제외 패턴", "table.header.ignore": "제외 패턴",
"table.header.time": "백업 시간",
"table.header.size": "크기(MB)",
"table.header.files": "파일 수",
"table.cell.none": "없음", "table.cell.none": "없음",
"table.header.format": "형식", "table.header.format": "형식",
"table.cell.default": "기본값", "table.cell.default": "기본값",
@@ -62,6 +73,11 @@
"cli.help.restore": "백업 파일에서 복원", "cli.help.restore": "백업 파일에서 복원",
"cli.help.restore.file": "백업 파일 경로 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "백업 파일 경로 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "복원 대상 디렉토리", "cli.help.restore.dir": "복원 대상 디렉토리",
"cli.help.restore.password": "복호화 비밀번호 (암호화된 7z 백업 복원 시 사용)",
"cli.help.restore.list": "압축 해제 없이 내용 보기",
"cli.help.list": "백업 기록 보기",
"cli.help.verify": "백업 파일 무결성 검증",
"cli.help.verify.file": "검증할 백업 파일",
"cli.help.version": "버전 정보 표시", "cli.help.version": "버전 정보 표시",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 라이선스 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 라이선스 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "구성 파일 %s가 손상되었습니다, 기본값 사용", "log.config.malformed": "구성 파일 %s가 손상되었습니다, 기본값 사용",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Estratégia de backup adicionada com sucesso: {source} -> {dest}", "cmd.add.success": "Estratégia de backup adicionada com sucesso: {source} -> {dest}",
"cmd.rm.success": "Estratégia de backup removida com sucesso: {path}", "cmd.rm.success": "Estratégia de backup removida com sucesso: {path}",
"err.folder.invalid": "Erro: {path} não é uma pasta válida ou não existe.", "err.folder.invalid": "Erro: {path} não é uma pasta válida ou não existe.",
"err.file.not_found": "Erro: O arquivo de backup {path} não existe.",
"err.dest.invalid": "Erro: A pasta de destino {path} não é uma pasta válida ou não existe.", "err.dest.invalid": "Erro: A pasta de destino {path} não é uma pasta válida ou não existe.",
"info.already.added": "Info: {path} já foi adicionado. Não duplique.", "info.already.added": "Info: {path} já foi adicionado. Não duplique.",
"warn.no.strategy.found": "Aviso: Estratégia de backup para {path} não encontrada.", "warn.no.strategy.found": "Aviso: Estratégia de backup para {path} não encontrada.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "Todas as estratégias de backup estão atualizadas, backup não necessário.", "cmd.save.uptodate": "Todas as estratégias de backup estão atualizadas, backup não necessário.",
"cmd.save.completed": "Backup concluído, {count} estratégia(s) processada(s).", "cmd.save.completed": "Backup concluído, {count} estratégia(s) processada(s).",
"cmd.all.empty": "Nenhuma estratégia de backup configurada.", "cmd.all.empty": "Nenhuma estratégia de backup configurada.",
"cmd.list.empty": "Nenhum histórico de backup ainda.",
"cmd.verify.checking": "Verificando backup: {path}",
"cmd.verify.success": "Verificação bem-sucedida: {path} ({count} arquivos)",
"cmd.verify.failed": "Verificação falhou: {path} — esperado {expected} arquivos, obtido {actual}",
"restore.list.title": "Conteúdo do arquivo: {path}",
"restore.list.empty": "O arquivo está vazio: {path}",
"log.ignore.loaded": "Arquivo de regras de ignorar carregado: %s",
"exit.message": "\nSaindo.", "exit.message": "\nSaindo.",
"table.header.source": "Pasta de origem", "table.header.source": "Pasta de origem",
"table.header.dest": "Pasta de destino", "table.header.dest": "Pasta de destino",
"table.header.ignore": "Padrões de exclusão", "table.header.ignore": "Padrões de exclusão",
"table.header.time": "Hora do backup",
"table.header.size": "Tamanho(MB)",
"table.header.files": "Arquivos",
"table.cell.none": "Nenhum", "table.cell.none": "Nenhum",
"table.header.format": "Formato", "table.header.format": "Formato",
"table.cell.default": "Padrão", "table.cell.default": "Padrão",
@@ -62,6 +73,11 @@
"cli.help.restore": "Restaurar de um arquivo de backup", "cli.help.restore": "Restaurar de um arquivo de backup",
"cli.help.restore.file": "Caminho do arquivo de backup (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "Caminho do arquivo de backup (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "Diretório de destino para restauração", "cli.help.restore.dir": "Diretório de destino para restauração",
"cli.help.restore.password": "Senha de descriptografia (para restaurar backups 7z criptografados)",
"cli.help.restore.list": "Listar conteúdo do arquivo sem extrair",
"cli.help.list": "Ver histórico de backups",
"cli.help.verify": "Verificar integridade do arquivo de backup",
"cli.help.verify.file": "Arquivo de backup para verificar",
"cli.help.version": "Mostrar informações de versão", "cli.help.version": "Mostrar informações de versão",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicença GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicença GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "Arquivo de configuração %s malformado, usando valores padrão", "log.config.malformed": "Arquivo de configuração %s malformado, usando valores padrão",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "Стратегия резервного копирования добавлена: {source} -> {dest}", "cmd.add.success": "Стратегия резервного копирования добавлена: {source} -> {dest}",
"cmd.rm.success": "Стратегия резервного копирования удалена: {path}", "cmd.rm.success": "Стратегия резервного копирования удалена: {path}",
"err.folder.invalid": "Ошибка: {path} не является допустимой папкой или не существует.", "err.folder.invalid": "Ошибка: {path} не является допустимой папкой или не существует.",
"err.file.not_found": "Ошибка: Файл резервной копии {path} не существует.",
"err.dest.invalid": "Ошибка: Целевая папка {path} не является допустимой папкой или не существует.", "err.dest.invalid": "Ошибка: Целевая папка {path} не является допустимой папкой или не существует.",
"info.already.added": "Информация: {path} уже добавлен. Не добавляйте повторно.", "info.already.added": "Информация: {path} уже добавлен. Не добавляйте повторно.",
"warn.no.strategy.found": "Предупреждение: Стратегия резервного копирования для {path} не найдена.", "warn.no.strategy.found": "Предупреждение: Стратегия резервного копирования для {path} не найдена.",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "Все стратегии резервного копирования актуальны, копирование не требуется.", "cmd.save.uptodate": "Все стратегии резервного копирования актуальны, копирование не требуется.",
"cmd.save.completed": "Резервное копирование завершено, обработано {count} стратегий.", "cmd.save.completed": "Резервное копирование завершено, обработано {count} стратегий.",
"cmd.all.empty": "Нет настроенных стратегий резервного копирования.", "cmd.all.empty": "Нет настроенных стратегий резервного копирования.",
"cmd.list.empty": "История резервного копирования пока пуста.",
"cmd.verify.checking": "Проверка резервной копии: {path}",
"cmd.verify.success": "Проверка пройдена: {path} ({count} файлов)",
"cmd.verify.failed": "Проверка не пройдена: {path} — ожидается {expected} файлов, получено {actual}",
"restore.list.title": "Содержимое архива: {path}",
"restore.list.empty": "Архив пуст: {path}",
"log.ignore.loaded": "Загружен файл правил игнорирования: %s",
"exit.message": "\nВыход.", "exit.message": "\nВыход.",
"table.header.source": "Исходная папка", "table.header.source": "Исходная папка",
"table.header.dest": "Целевая папка", "table.header.dest": "Целевая папка",
"table.header.ignore": "Шаблоны исключения", "table.header.ignore": "Шаблоны исключения",
"table.header.time": "Время копии",
"table.header.size": "Размер(МБ)",
"table.header.files": "Файлы",
"table.cell.none": "Нет", "table.cell.none": "Нет",
"table.header.format": "Формат", "table.header.format": "Формат",
"table.cell.default": "По умолч.", "table.cell.default": "По умолч.",
@@ -62,6 +73,11 @@
"cli.help.restore": "Восстановить из файла резервной копии", "cli.help.restore": "Восстановить из файла резервной копии",
"cli.help.restore.file": "Путь к файлу резервной копии (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", "cli.help.restore.file": "Путь к файлу резервной копии (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)",
"cli.help.restore.dir": "Целевой каталог для восстановления", "cli.help.restore.dir": "Целевой каталог для восстановления",
"cli.help.restore.password": "Пароль расшифровки (для восстановления зашифрованных резервных копий 7z)",
"cli.help.restore.list": "Показать содержимое архива без распаковки",
"cli.help.list": "Просмотреть историю резервного копирования",
"cli.help.verify": "Проверить целостность файла резервной копии",
"cli.help.verify.file": "Файл резервной копии для проверки",
"cli.help.version": "Показать информацию о версии", "cli.help.version": "Показать информацию о версии",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nЛицензия GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nЛицензия GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "Файл конфигурации %s повреждён, используются значения по умолчанию", "log.config.malformed": "Файл конфигурации %s повреждён, используются значения по умолчанию",
+16
View File
@@ -2,6 +2,7 @@
"cmd.add.success": "备份策略添加成功: {source} -> {dest}", "cmd.add.success": "备份策略添加成功: {source} -> {dest}",
"cmd.rm.success": "备份策略删除成功: {path}", "cmd.rm.success": "备份策略删除成功: {path}",
"err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。", "err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。",
"err.file.not_found": "错误:备份文件 {path} 不存在。",
"err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。", "err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。",
"info.already.added": "提示:{path} 已经添加过了,请勿重复添加。", "info.already.added": "提示:{path} 已经添加过了,请勿重复添加。",
"warn.no.strategy.found": "警告:未找到 {path} 的备份策略。", "warn.no.strategy.found": "警告:未找到 {path} 的备份策略。",
@@ -27,10 +28,20 @@
"cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。",
"cmd.save.completed": "备份完成,共处理 {count} 个策略。", "cmd.save.completed": "备份完成,共处理 {count} 个策略。",
"cmd.all.empty": "没有配置任何备份策略。", "cmd.all.empty": "没有配置任何备份策略。",
"cmd.list.empty": "暂无备份历史记录。",
"cmd.verify.checking": "正在校验备份文件: {path}",
"cmd.verify.success": "校验通过: {path} ({count} 个文件)",
"cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个",
"restore.list.title": "备份内容: {path}",
"restore.list.empty": "备份文件为空: {path}",
"log.ignore.loaded": "已加载忽略规则文件: %s",
"exit.message": "\n已退出。", "exit.message": "\n已退出。",
"table.header.source": "源文件夹路径", "table.header.source": "源文件夹路径",
"table.header.dest": "目标文件夹路径", "table.header.dest": "目标文件夹路径",
"table.header.ignore": "忽略模式", "table.header.ignore": "忽略模式",
"table.header.time": "备份时间",
"table.header.size": "大小(MB)",
"table.header.files": "文件数",
"table.cell.none": "无", "table.cell.none": "无",
"table.header.format": "格式", "table.header.format": "格式",
"table.cell.default": "默认", "table.cell.default": "默认",
@@ -62,6 +73,11 @@
"cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", "cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
"cli.help.restore.file": "备份文件路径", "cli.help.restore.file": "备份文件路径",
"cli.help.restore.dir": "还原目标目录", "cli.help.restore.dir": "还原目标目录",
"cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)",
"cli.help.restore.list": "仅列出备份内容,不解压",
"cli.help.list": "查看备份历史记录",
"cli.help.verify": "校验备份文件完整性",
"cli.help.verify.file": "要校验的备份文件路径",
"cli.help.version": "查看版本信息", "cli.help.version": "查看版本信息",
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
"log.config.malformed": "配置文件 %s 格式错误,使用默认配置", "log.config.malformed": "配置文件 %s 格式错误,使用默认配置",
+17 -5
View File
@@ -71,10 +71,13 @@ class SFTPClient:
) )
self._sftp = paramiko.SFTPClient.from_transport(self._transport) self._sftp = paramiko.SFTPClient.from_transport(self._transport)
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
self.disconnect()
raise SFTPError(t("err.sftp.auth", host=self.host)) raise SFTPError(t("err.sftp.auth", host=self.host))
except paramiko.SSHException as e: except paramiko.SSHException as e:
self.disconnect()
raise SFTPError(t("err.sftp.ssh", error=str(e))) raise SFTPError(t("err.sftp.ssh", error=str(e)))
except OSError as e: except OSError as e:
self.disconnect()
raise SFTPError( raise SFTPError(
t("err.sftp.connect", host=self.host, port=self.port, error=str(e)) t("err.sftp.connect", host=self.host, port=self.port, error=str(e))
) )
@@ -130,12 +133,21 @@ class SFTPClient:
return None return None
def disconnect(self) -> None: def disconnect(self) -> None:
"""断开 SFTP 连接""" """断开 SFTP 连接(确保每个资源都被清理)"""
if self._sftp is not None: try:
self._sftp.close() if self._sftp is not None:
self._sftp.close()
except Exception:
pass
finally:
self._sftp = None self._sftp = None
if self._transport is not None:
self._transport.close() try:
if self._transport is not None:
self._transport.close()
except Exception:
pass
finally:
self._transport = None self._transport = None
def __enter__(self): def __enter__(self):
+17 -8
View File
@@ -42,14 +42,14 @@ class WebDAVClient:
return f"{self.url}/{path}" return f"{self.url}/{path}"
return self.url return self.url
def _request( def _build_request(
self, self,
method: str, method: str,
path: str = "", path: str = "",
data: bytes | None = None, data: bytes | None = None,
content_type: str = "application/octet-stream", content_type: str = "application/octet-stream",
) -> urllib.request.Request: ) -> urllib.request.Request:
"""构建并发送 HTTP 请求""" """构建 HTTP 请求对象"""
url = self._build_url(path) url = self._build_url(path)
req = urllib.request.Request(url, method=method) req = urllib.request.Request(url, method=method)
req.add_header("Authorization", self._auth_header) req.add_header("Authorization", self._auth_header)
@@ -68,7 +68,7 @@ class WebDAVClient:
for part in parts: for part in parts:
current = f"{current}/{part}" if current else part current = f"{current}/{part}" if current else part
try: try:
req = self._request("MKCOL", current) req = self._build_request("MKCOL", current)
urllib.request.urlopen(req, timeout=30) urllib.request.urlopen(req, timeout=30)
logger.debug("创建远程目录: %s", current) logger.debug("创建远程目录: %s", current)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
@@ -85,7 +85,7 @@ class WebDAVClient:
def connect(self) -> None: def connect(self) -> None:
"""测试 WebDAV 连接(PROPFIND)""" """测试 WebDAV 连接(PROPFIND)"""
try: try:
req = self._request( req = self._build_request(
"PROPFIND", "PROPFIND",
data=b'<?xml version="1.0"?>' data=b'<?xml version="1.0"?>'
b'<D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>', b'<D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>',
@@ -97,7 +97,7 @@ class WebDAVClient:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: if e.code == 401:
raise WebDAVError(t("err.webdav.auth", host=self.url)) raise WebDAVError(t("err.webdav.auth", host=self.url))
raise WebDAVError(t("err.webdav.ssh", error=str(e))) raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e)))
except OSError as e: except OSError as e:
raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e))) raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e)))
@@ -121,10 +121,13 @@ class WebDAVClient:
) )
try: try:
# 流式上传:不将整个文件读入内存
url = self._build_url(remote_path)
with open(local_path, "rb") as f: with open(local_path, "rb") as f:
data = f.read() req = urllib.request.Request(url, data=f, method="PUT")
req = self._request("PUT", remote_path, data=data) req.add_header("Authorization", self._auth_header)
urllib.request.urlopen(req, timeout=300) req.add_header("Content-Length", str(file_size))
urllib.request.urlopen(req, timeout=300)
logger.debug("上传成功: %s", remote_path) logger.debug("上传成功: %s", remote_path)
return file_size return file_size
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
@@ -141,3 +144,9 @@ class WebDAVClient:
return True return True
except WebDAVError: except WebDAVError:
return False return False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
return False
+241
View File
@@ -9,6 +9,7 @@ import tempfile
import shutil import shutil
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock
from sbackup.auto_save import BackupManager from sbackup.auto_save import BackupManager
from sbackup.i18n import t from sbackup.i18n import t
@@ -139,6 +140,11 @@ class TestAutoSave(unittest.TestCase):
result = self.manager.add_folder(self.source_folder, "/nonexistent/path") result = self.manager.add_folder(self.source_folder, "/nonexistent/path")
self.assertFalse(result) self.assertFalse(result)
def test_add_source_equals_dest(self):
"""测试源目录与目标目录相同时应失败"""
result = self.manager.add_folder(self.source_folder, self.source_folder)
self.assertFalse(result)
def test_save_missing_source(self): def test_save_missing_source(self):
"""测试源文件夹不存在时的 save 行为""" """测试源文件夹不存在时的 save 行为"""
# 手动构造一条源文件夹不存在的记录 # 手动构造一条源文件夹不存在的记录
@@ -455,6 +461,241 @@ class TestAutoSave(unittest.TestCase):
# 应包含"默认"文本(中文环境) # 应包含"默认"文本(中文环境)
self.assertIn(t("table.cell.default"), text) self.assertIn(t("table.cell.default"), text)
def test_display_width_ascii(self):
"""测试 ASCII 字符宽度计算"""
self.assertEqual(BackupManager._display_width("hello"), 5)
def test_display_width_cjk(self):
"""测试中文字符宽度计算(每个算2)"""
self.assertEqual(BackupManager._display_width("你好"), 4)
def test_display_width_fullwidth(self):
"""测试全角符号宽度计算(U+FF01-FF60 等)"""
# A 是全角大写 A,宽度应为2
self.assertEqual(BackupManager._display_width("A"), 2)
# ! 是全角感叹号
self.assertEqual(BackupManager._display_width("!"), 2)
def test_display_width_mixed(self):
"""测试中英混合字符串宽度"""
# "hi你好" = 1+1+2+2 = 6
self.assertEqual(BackupManager._display_width("hi你好"), 6)
def test_display_width_non_string(self):
"""测试非字符串输入"""
self.assertEqual(BackupManager._display_width(123), 3)
def test_display_width_empty(self):
"""测试空字符串"""
self.assertEqual(BackupManager._display_width(""), 0)
def test_list_folder_table_only_history(self):
"""测试仅含 _history 数据时返回空提示"""
self.manager.data["_history"] = [
{"time": "2026-01-01", "source": "/x", "size_mb": 1.0, "files_count": 1}
]
text = self.manager.list_folder_table()
self.assertEqual(text, t("cmd.all.empty"))
def test_format_history_table_empty(self):
"""测试无历史记录时返回提示"""
text = self.manager.format_history_table()
self.assertEqual(text, t("cmd.list.empty"))
def test_format_history_table_with_data(self):
"""测试有历史记录时生成表格"""
self.manager.add_folder(self.source_folder, self.target_folder, "")
import time
time.sleep(0.1)
(Path(self.source_folder) / "f.txt").write_text("x")
self.manager.execute_backups()
text = self.manager.format_history_table()
self.assertIn(self.source_folder, text)
self.assertIn(t("table.header.time"), text)
def test_entry_from_list_invalid_data(self):
"""测试 from_list 输入验证:非列表返回空条目"""
from sbackup.auto_save import BackupEntry
entry = BackupEntry.from_list("not a list")
self.assertEqual(entry.mtime, 0.0)
self.assertEqual(entry.target, "")
def test_entry_from_list_too_short(self):
"""测试 from_list 输入验证:少于3元素返回空条目"""
from sbackup.auto_save import BackupEntry
entry = BackupEntry.from_list([1.0, "/path"])
self.assertEqual(entry.mtime, 0.0)
self.assertEqual(entry.target, "")
def test_entry_from_list_empty(self):
"""测试 from_list 输入验证:空列表"""
from sbackup.auto_save import BackupEntry
entry = BackupEntry.from_list([])
self.assertEqual(entry.mtime, 0.0)
class TestUploadSftp(unittest.TestCase):
"""测试 _upload_to_sftp 静态方法"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.test_file = os.path.join(self.test_dir, "backup.zip")
with open(self.test_file, "w") as f:
f.write("fake backup")
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
@patch("sbackup.sftp.SFTPClient")
def test_upload_not_configured(self, mock_sftp_cls):
"""测试 SFTP 未配置时打印错误"""
from sbackup.config import Config
config = Config(sftp_enabled=False)
with patch("builtins.print") as mock_print:
BackupManager._upload_to_sftp([self.test_file], config)
mock_print.assert_called()
@patch("sbackup.sftp.SFTPClient")
def test_upload_no_credentials(self, mock_sftp_cls):
"""测试无凭据时打印提示"""
from sbackup.config import Config
mock_sftp_cls.try_default_key.return_value = None
config = Config(sftp_enabled=True, sftp_host="host", sftp_password="")
with patch("builtins.print") as mock_print:
BackupManager._upload_to_sftp([self.test_file], config)
printed = " ".join(str(c) for c in mock_print.call_args_list)
# 无默认私钥时应打印提示
self.assertIn(t("cmd.sftp.no_default_key"), printed)
@patch("sbackup.sftp.SFTPClient")
def test_upload_success(self, mock_sftp_cls):
"""测试 SFTP 上传成功"""
from sbackup.config import Config
mock_client = MagicMock()
mock_sftp_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_sftp_cls.return_value.__exit__ = MagicMock(return_value=False)
config = Config(
sftp_enabled=True,
sftp_host="host",
sftp_user="user",
sftp_password="pass",
)
with patch("builtins.print"):
BackupManager._upload_to_sftp([self.test_file], config)
mock_client.upload_file.assert_called_once()
@patch("sbackup.sftp.SFTPClient")
def test_upload_sftp_error(self, mock_sftp_cls):
"""测试 SFTP 上传失败"""
from sbackup.config import Config
from sbackup.sftp import SFTPError
mock_client = MagicMock()
mock_client.upload_file.side_effect = SFTPError("upload failed")
mock_sftp_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_sftp_cls.return_value.__exit__ = MagicMock(return_value=False)
config = Config(
sftp_enabled=True,
sftp_host="host",
sftp_user="user",
sftp_password="pass",
)
with patch("builtins.print") as mock_print:
BackupManager._upload_to_sftp([self.test_file], config)
printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("upload failed", printed)
class TestUploadWebdav(unittest.TestCase):
"""测试 _upload_to_webdav 静态方法"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.test_file = os.path.join(self.test_dir, "backup.zip")
with open(self.test_file, "w") as f:
f.write("fake backup")
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
@patch("sbackup.webdav.WebDAVClient")
def test_upload_not_configured(self, mock_wdav_cls):
"""测试 WebDAV 未配置时打印错误"""
from sbackup.config import Config
config = Config(webdav_enabled=False)
with patch("builtins.print") as mock_print:
BackupManager._upload_to_webdav([self.test_file], config)
mock_print.assert_called()
@patch("sbackup.webdav.WebDAVClient")
def test_upload_success(self, mock_wdav_cls):
"""测试 WebDAV 上传成功"""
from sbackup.config import Config
mock_client = MagicMock()
mock_wdav_cls.return_value = mock_client
config = Config(
webdav_enabled=True,
webdav_url="https://dav.example.com",
webdav_user="user",
webdav_password="pass",
)
with patch("builtins.print"):
BackupManager._upload_to_webdav([self.test_file], config)
mock_client.connect.assert_called_once()
mock_client.upload_file.assert_called_once()
@patch("sbackup.webdav.WebDAVClient")
def test_upload_webdav_error(self, mock_wdav_cls):
"""测试 WebDAV 上传失败"""
from sbackup.config import Config
from sbackup.webdav import WebDAVError
mock_client = MagicMock()
mock_client.upload_file.side_effect = WebDAVError("upload failed")
mock_wdav_cls.return_value = mock_client
config = Config(
webdav_enabled=True,
webdav_url="https://dav.example.com",
webdav_user="user",
webdav_password="pass",
)
with patch("builtins.print") as mock_print:
BackupManager._upload_to_webdav([self.test_file], config)
printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("upload failed", printed)
@patch("sbackup.webdav.WebDAVClient")
def test_upload_connect_error(self, mock_wdav_cls):
"""测试 WebDAV 连接失败"""
from sbackup.config import Config
from sbackup.webdav import WebDAVError
mock_wdav_cls.return_value.connect.side_effect = WebDAVError("connect failed")
config = Config(
webdav_enabled=True,
webdav_url="https://dav.example.com",
webdav_user="user",
webdav_password="pass",
)
with patch("builtins.print") as mock_print:
BackupManager._upload_to_webdav([self.test_file], config)
printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("connect failed", printed)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+309 -1
View File
@@ -6,9 +6,10 @@ import unittest
import os import os
import shutil import shutil
import tarfile import tarfile
import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch, MagicMock
from sbackup.config import Config from sbackup.config import Config
from sbackup.compression import ( from sbackup.compression import (
ZipfileCompression, ZipfileCompression,
@@ -17,6 +18,8 @@ from sbackup.compression import (
SevenZipCompression, SevenZipCompression,
create_compressor, create_compressor,
restore_backup, restore_backup,
list_backup_contents,
verify_backup,
) )
from sbackup.i18n import t from sbackup.i18n import t
@@ -465,6 +468,48 @@ class TestRestore(unittest.TestCase):
) )
os.remove("test.tar.zst") os.remove("test.tar.zst")
@patch("builtins.print")
def test_restore_permission_error(self, mock_print):
"""测试还原时权限不足"""
config = Config(folder_path=self.test_dir, zipfile_path="test.zip")
ZipfileCompression(config).compress()
with patch("zipfile.ZipFile") as mock_zf:
mock_zf.return_value.__enter__ = MagicMock(
side_effect=PermissionError("denied")
)
mock_zf.return_value.__exit__ = MagicMock(return_value=False)
result = restore_backup("test.zip", self.restore_dir)
self.assertFalse(result["success"])
@patch("builtins.print")
def test_restore_os_error(self, mock_print):
"""测试还原时 OS 错误"""
config = Config(folder_path=self.test_dir, zipfile_path="test.zip")
ZipfileCompression(config).compress()
with patch("zipfile.ZipFile") as mock_zf:
mock_zf.return_value.__enter__ = MagicMock(
side_effect=OSError("disk error")
)
mock_zf.return_value.__exit__ = MagicMock(return_value=False)
result = restore_backup("test.zip", self.restore_dir)
self.assertFalse(result["success"])
@patch("builtins.print")
def test_restore_7z_permission_error(self, mock_print):
"""测试 7z 还原时权限不足"""
config = Config(
folder_path=self.test_dir, zipfile_path="test.7z", compression_format="7Z"
)
SevenZipCompression(config).compress()
with patch("py7zr.SevenZipFile") as mock_7z:
mock_7z.return_value.__enter__ = MagicMock(
side_effect=PermissionError("denied")
)
mock_7z.return_value.__exit__ = MagicMock(return_value=False)
result = restore_backup("test.7z", self.restore_dir)
self.assertFalse(result["success"])
os.remove("test.7z")
class TestPathMatching(unittest.TestCase): class TestPathMatching(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -534,6 +579,44 @@ class TestTarNoCompression(unittest.TestCase):
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertTrue(os.path.exists("test.tar")) self.assertTrue(os.path.exists("test.tar"))
@patch("builtins.print")
def test_tar_permission_error(self, mock_print):
"""测试 tar 压缩时权限不足"""
config = Config(
folder_path=self.test_dir,
zipfile_path="/root/test.tar",
compression_format="TAR",
)
compressor = TarfileCompression(config)
result = compressor.compress()
self.assertFalse(result["success"])
@patch("builtins.print")
def test_tar_os_error(self, mock_print):
"""测试 tar 压缩时系统错误"""
config = Config(
folder_path=self.test_dir,
zipfile_path="test.tar",
compression_format="TAR",
)
compressor = TarfileCompression(config)
with patch("tarfile.open", side_effect=OSError("disk error")):
result = compressor.compress()
self.assertFalse(result["success"])
@patch("builtins.print")
def test_tar_unknown_exception(self, mock_print):
"""测试 tar 压缩时未知异常"""
config = Config(
folder_path=self.test_dir,
zipfile_path="test.tar",
compression_format="TAR",
)
compressor = TarfileCompression(config)
with patch("tarfile.open", side_effect=RuntimeError("unexpected")):
result = compressor.compress()
self.assertFalse(result["success"])
class TestZstdCompression(unittest.TestCase): class TestZstdCompression(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -613,6 +696,19 @@ class TestZstdCompression(unittest.TestCase):
result = compressor.compress() result = compressor.compress()
self.assertFalse(result["success"]) self.assertFalse(result["success"])
@patch("builtins.print")
def test_zstd_unknown_exception(self, mock_print):
"""测试 zstd 压缩未知异常"""
config = Config(
folder_path=self.test_dir,
zipfile_path="test.tar.zst",
compression_format="TAR_ZST",
)
compressor = ZstdCompression(config)
with patch("zstandard.ZstdCompressor", side_effect=RuntimeError("unexpected")):
result = compressor.compress()
self.assertFalse(result["success"])
class TestSevenZipCompression(unittest.TestCase): class TestSevenZipCompression(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -704,6 +800,218 @@ class TestSevenZipCompression(unittest.TestCase):
names = szf.getnames() names = szf.getnames()
self.assertGreater(len(names), 0) self.assertGreater(len(names), 0)
@patch("builtins.print")
def test_7z_unknown_exception(self, mock_print):
"""测试 7z 压缩未知异常"""
config = Config(
folder_path=self.test_dir, zipfile_path="test.7z", compression_format="7Z"
)
compressor = SevenZipCompression(config)
with patch("py7zr.SevenZipFile", side_effect=RuntimeError("unexpected")):
result = compressor.compress()
self.assertFalse(result["success"])
class TestCreateCompressor(unittest.TestCase):
"""测试工厂函数 create_compressor 的各种格式"""
def test_create_compressor_tar(self):
"""测试工厂函数创建 TAR 压缩器"""
config = Config(folder_path=".", compression_format="TAR")
compressor = create_compressor(config)
self.assertIsInstance(compressor, TarfileCompression)
def test_create_compressor_tar_gz_with_dot(self):
"""测试 tar.gz(带点号)格式正确匹配 TarfileCompression"""
config = Config(folder_path=".", compression_format="tar.gz")
compressor = create_compressor(config)
self.assertIsInstance(compressor, TarfileCompression)
def test_create_compressor_tar_bz2_with_dot(self):
"""测试 tar.bz2(带点号)格式正确匹配"""
config = Config(folder_path=".", compression_format="tar.bz2")
compressor = create_compressor(config)
self.assertIsInstance(compressor, TarfileCompression)
def test_create_compressor_tar_xz_with_dot(self):
"""测试 tar.xz(带点号)格式正确匹配"""
config = Config(folder_path=".", compression_format="tar.xz")
compressor = create_compressor(config)
self.assertIsInstance(compressor, TarfileCompression)
def test_create_compressor_tar_zst_with_dot(self):
"""测试 tar.zst(带点号)格式正确匹配"""
config = Config(folder_path=".", compression_format="tar.zst")
compressor = create_compressor(config)
self.assertIsInstance(compressor, ZstdCompression)
class TestShouldIgnore(unittest.TestCase):
"""测试 _should_ignore 模式匹配"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
os.makedirs(os.path.join(self.test_dir, "sub"), exist_ok=True)
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_should_ignore_directory_pattern(self):
"""测试忽略目录模式"""
config = Config(folder_path=self.test_dir, skip_patterns=["sub"])
compressor = ZipfileCompression(config)
self.assertTrue(compressor._should_ignore("sub"))
def test_should_ignore_no_match(self):
"""测试不匹配时不忽略"""
config = Config(folder_path=self.test_dir, skip_patterns=["*.log"])
compressor = ZipfileCompression(config)
self.assertFalse(compressor._should_ignore("readme.txt"))
def test_should_ignore_basename_match(self):
"""测试 basename 匹配"""
config = Config(folder_path=self.test_dir, skip_patterns=["*.log"])
compressor = ZipfileCompression(config)
self.assertTrue(compressor._should_ignore("subdir/error.log"))
class TestListBackupContents(unittest.TestCase):
"""测试 list_backup_contents 函数"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
(Path(self.test_dir) / "file1.txt").write_text("content1")
(Path(self.test_dir) / "sub").mkdir()
(Path(self.test_dir) / "sub" / "file2.txt").write_text("content2")
self.zip_path = os.path.join(self.test_dir, "test.zip")
config = Config(folder_path=self.test_dir, zipfile_path=self.zip_path)
ZipfileCompression(config).compress()
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_list_zip_contents(self):
"""测试列出 ZIP 文件内容"""
result = list_backup_contents(self.zip_path)
self.assertIn("file1.txt", result)
self.assertIn("file2.txt", result)
self.assertIn("2 files", result)
def test_list_nonexistent_file(self):
"""测试列出不存在的文件"""
result = list_backup_contents("/nonexistent/file.zip")
self.assertIn("/nonexistent/file.zip", result)
def test_list_unknown_format(self):
"""测试列出未知格式文件(返回空列表提示)"""
unknown = os.path.join(self.test_dir, "test.xyz")
Path(unknown).write_text("data")
result = list_backup_contents(unknown)
self.assertIn("test.xyz", result)
class TestVerifyBackup(unittest.TestCase):
"""测试 verify_backup 函数"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
(Path(self.test_dir) / "file1.txt").write_text("content1")
(Path(self.test_dir) / "sub").mkdir()
(Path(self.test_dir) / "sub" / "file2.txt").write_text("content2")
self.zip_path = os.path.join(self.test_dir, "test.zip")
config = Config(folder_path=self.test_dir, zipfile_path=self.zip_path)
ZipfileCompression(config).compress()
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_verify_valid_zip(self):
"""测试校验有效 ZIP 文件"""
result = verify_backup(self.zip_path)
self.assertTrue(result["success"])
self.assertEqual(result["files_count"], 2)
def test_verify_nonexistent(self):
"""测试校验不存在的文件"""
result = verify_backup("/nonexistent/file.zip")
self.assertFalse(result["success"])
class TestSbackupIgnore(unittest.TestCase):
"""测试 .sbackupignore 文件支持"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
(Path(self.test_dir) / "keep.txt").write_text("keep")
(Path(self.test_dir) / "skip.log").write_text("skip")
(Path(self.test_dir) / "sub").mkdir()
(Path(self.test_dir) / "sub" / "data.txt").write_text("data")
self.zip_path = os.path.join(self.test_dir, "test.zip")
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_ignore_file_loaded(self):
"""测试 .sbackupignore 文件中的规则生效"""
(Path(self.test_dir) / ".sbackupignore").write_text("*.log\n# comment\n")
config = Config(
folder_path=self.test_dir,
zipfile_path=self.zip_path,
skip_patterns=[],
)
compressor = ZipfileCompression(config)
result = compressor.compress()
self.assertTrue(result["success"])
with zipfile.ZipFile(self.zip_path, "r") as zf:
names = zf.namelist()
self.assertTrue(any(n.endswith("keep.txt") for n in names))
self.assertFalse(any(n.endswith("skip.log") for n in names))
def test_ignore_file_not_present(self):
"""测试没有 .sbackupignore 时不影响正常压缩"""
config = Config(
folder_path=self.test_dir,
zipfile_path=self.zip_path,
skip_patterns=[],
)
compressor = ZipfileCompression(config)
result = compressor.compress()
self.assertTrue(result["success"])
with zipfile.ZipFile(self.zip_path, "r") as zf:
names = zf.namelist()
self.assertTrue(any(n.endswith("keep.txt") for n in names))
self.assertTrue(any(n.endswith("skip.log") for n in names))
class TestPatternNegation(unittest.TestCase):
"""测试 ! 取反模式"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
(Path(self.test_dir) / "a.log").write_text("log")
(Path(self.test_dir) / "b.log").write_text("log")
(Path(self.test_dir) / "important.log").write_text("important")
self.zip_path = os.path.join(self.test_dir, "test.zip")
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_negation_restores_file(self):
"""测试 ! 模式恢复被忽略的文件"""
config = Config(
folder_path=self.test_dir,
zipfile_path=self.zip_path,
skip_patterns=["*.log", "!important.log"],
)
compressor = ZipfileCompression(config)
result = compressor.compress()
self.assertTrue(result["success"])
with zipfile.ZipFile(self.zip_path, "r") as zf:
names = zf.namelist()
self.assertFalse(any(n.endswith("a.log") for n in names))
self.assertFalse(any(n.endswith("b.log") for n in names))
self.assertTrue(any(n.endswith("important.log") for n in names))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+144
View File
@@ -395,6 +395,150 @@ class TestSaveSftpConfig(unittest.TestCase):
self.assertEqual(config.sftp_key_passphrase, "keypass") self.assertEqual(config.sftp_key_passphrase, "keypass")
self.assertTrue(config.sftp_enabled) self.assertTrue(config.sftp_enabled)
def test_load_config_default_lang_zh_cn(self):
"""测试 config.json 缺少 lang 字段时默认为 zh_CN"""
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump({"compression_format": "ZIP"}, f)
config = load_config(self.config_file)
self.assertEqual(config.lang, "zh_CN")
def test_load_config_with_webdav(self):
"""测试加载包含 WebDAV 配置"""
config_data = {
"webdav": {
"url": "https://dav.jianguoyun.com/dav/",
"user": "user@example.com",
"password": "secret",
"remote_path": "/backups",
"enabled": True,
}
}
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f)
config = load_config(self.config_file)
self.assertEqual(config.webdav_url, "https://dav.jianguoyun.com/dav/")
self.assertEqual(config.webdav_user, "user@example.com")
self.assertEqual(config.webdav_password, "secret")
self.assertEqual(config.webdav_remote_path, "/backups")
self.assertTrue(config.webdav_enabled)
def test_load_config_webdav_defaults(self):
"""测试 WebDAV 配置默认值"""
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump({}, f)
config = load_config(self.config_file)
self.assertEqual(config.webdav_url, "")
self.assertEqual(config.webdav_user, "")
self.assertEqual(config.webdav_password, "")
self.assertEqual(config.webdav_remote_path, "/")
self.assertFalse(config.webdav_enabled)
def test_save_webdav_config_creates_new_file(self):
"""测试 save_webdav_config 创建新配置文件"""
from sbackup.config import save_webdav_config
save_webdav_config(
"https://dav.example.com/dav/",
"user@test.com",
"pass123",
"/backups",
config_file=self.config_file,
)
self.assertTrue(os.path.exists(self.config_file))
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data["webdav"]["url"], "https://dav.example.com/dav/")
self.assertEqual(data["webdav"]["user"], "user@test.com")
self.assertEqual(data["webdav"]["password"], "pass123")
self.assertEqual(data["webdav"]["remote_path"], "/backups")
self.assertTrue(data["webdav"]["enabled"])
def test_save_webdav_config_updates_existing(self):
"""测试 save_webdav_config 更新已有配置"""
from sbackup.config import save_webdav_config
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump({"lang": "zh_CN"}, f)
save_webdav_config(
"https://dav.example.com/",
"user",
"pass",
"/",
config_file=self.config_file,
)
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data["webdav"]["url"], "https://dav.example.com/")
self.assertEqual(data["lang"], "zh_CN")
class TestJsonHelpers(unittest.TestCase):
"""测试 _load_json_file 和 _save_json_file 辅助函数"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.config_file = os.path.join(self.test_dir, "config.json")
def tearDown(self):
import shutil
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_load_json_file_nonexistent(self):
"""测试加载不存在的文件返回空字典"""
from sbackup.config import _load_json_file
result = _load_json_file("/nonexistent/file.json")
self.assertEqual(result, {})
def test_load_json_file_valid(self):
"""测试加载有效的 JSON 文件"""
from sbackup.config import _load_json_file
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump({"key": "value"}, f)
result = _load_json_file(self.config_file)
self.assertEqual(result, {"key": "value"})
def test_load_json_file_malformed(self):
"""测试加载损坏的 JSON 文件返回空字典"""
from sbackup.config import _load_json_file
with open(self.config_file, "w") as f:
f.write("{bad json")
result = _load_json_file(self.config_file)
self.assertEqual(result, {})
def test_save_json_file_creates_dir(self):
"""测试自动创建目录"""
from sbackup.config import _save_json_file
nested_file = os.path.join(self.test_dir, "sub", "config.json")
_save_json_file({"test": True}, nested_file)
self.assertTrue(os.path.exists(nested_file))
with open(nested_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data, {"test": True})
@patch("os.makedirs")
def test_save_json_file_makedirs_error(self, mock_makedirs):
"""测试目录创建失败时静默返回"""
from sbackup.config import _save_json_file
mock_makedirs.side_effect = OSError("denied")
nested_file = os.path.join(self.test_dir, "sub", "config.json")
_save_json_file({"test": True}, nested_file)
# 不应抛出异常
@patch("builtins.open")
def test_save_json_file_write_error(self, mock_open):
"""测试写入失败时静默处理"""
from sbackup.config import _save_json_file
mock_open.side_effect = OSError("disk full")
_save_json_file({"test": True}, self.config_file)
# 不应抛出异常
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+79 -2
View File
@@ -23,6 +23,9 @@ class TestMain(unittest.TestCase):
self.data_path = os.path.join(self.test_dir, "sbackup.json") self.data_path = os.path.join(self.test_dir, "sbackup.json")
with open(os.path.join(self.test_dir, "config.json"), "w") as f: with open(os.path.join(self.test_dir, "config.json"), "w") as f:
json.dump({"data_file": self.data_path}, f) json.dump({"data_file": self.data_path}, f)
# 创建独立的目标目录(不能与源目录相同)
self.dest_dir = os.path.join(self.test_dir, "backup_dest")
os.makedirs(self.dest_dir, exist_ok=True)
def tearDown(self): def tearDown(self):
sys.argv = self.original_argv sys.argv = self.original_argv
@@ -117,7 +120,7 @@ class TestMain(unittest.TestCase):
def test_rm_command_success(self, mock_print): def test_rm_command_success(self, mock_print):
"""测试 rm 命令成功删除策略""" """测试 rm 命令成功删除策略"""
os.chdir(self.test_dir) os.chdir(self.test_dir)
sys.argv = ["sbackup", "--lang", "en_US", "add", self.test_dir, self.test_dir] sys.argv = ["sbackup", "--lang", "en_US", "add", self.test_dir, self.dest_dir]
from sbackup import run from sbackup import run
result1 = run() result1 = run()
@@ -187,7 +190,7 @@ class TestMain(unittest.TestCase):
"en_US", "en_US",
"add", "add",
self.test_dir, self.test_dir,
self.test_dir, self.dest_dir,
"--format", "--format",
"tar.gz", "tar.gz",
] ]
@@ -217,6 +220,27 @@ class TestMain(unittest.TestCase):
args = parser.parse_args(["watch", "--interval", "30"]) args = parser.parse_args(["watch", "--interval", "30"])
self.assertEqual(args.interval, 30) self.assertEqual(args.interval, 30)
def test_restore_password_argument(self):
"""测试 restore --password 参数"""
from sbackup import get_parser
parser = get_parser()
args = parser.parse_args(
["restore", "backup.7z", "/tmp", "--password", "secret"]
)
self.assertEqual(args.command, "restore")
self.assertEqual(args.backup_file, "backup.7z")
self.assertEqual(args.target_dir, "/tmp")
self.assertEqual(args.password, "secret")
def test_restore_password_default_empty(self):
"""测试 restore --password 默认为空"""
from sbackup import get_parser
parser = get_parser()
args = parser.parse_args(["restore", "backup.zip", "/tmp"])
self.assertEqual(args.password, "")
def test_argparse_invalid_choice_localized(self): def test_argparse_invalid_choice_localized(self):
"""测试 argparse 无效选择错误被本地化""" """测试 argparse 无效选择错误被本地化"""
from sbackup import get_parser from sbackup import get_parser
@@ -293,6 +317,28 @@ class TestMain(unittest.TestCase):
printed = " ".join(str(call) for call in mock_print.call_args_list) printed = " ".join(str(call) for call in mock_print.call_args_list)
self.assertIn("not configured", printed) self.assertIn("not configured", printed)
@patch("builtins.print")
def test_sftp_no_action(self, mock_print):
"""测试 sftp 无子命令时提示并返回 1"""
os.chdir(self.test_dir)
sys.argv = ["sbackup", "--lang", "en_US", "sftp"]
from sbackup import run
result = run()
self.assertEqual(result, 1)
mock_print.assert_called()
@patch("builtins.print")
def test_webdav_no_action(self, mock_print):
"""测试 webdav 无子命令时提示并返回 1"""
os.chdir(self.test_dir)
sys.argv = ["sbackup", "--lang", "en_US", "webdav"]
from sbackup import run
result = run()
self.assertEqual(result, 1)
mock_print.assert_called()
@patch("builtins.print") @patch("builtins.print")
@patch("builtins.input") @patch("builtins.input")
@patch("getpass.getpass") @patch("getpass.getpass")
@@ -352,6 +398,37 @@ class TestMain(unittest.TestCase):
printed = " ".join(str(call) for call in mock_print.call_args_list) printed = " ".join(str(call) for call in mock_print.call_args_list)
self.assertIn("saved", printed) self.assertIn("saved", printed)
@patch("builtins.print")
@patch("sbackup.sftp.SFTPClient._load_private_key", return_value=MagicMock())
@patch("sbackup.sftp.SFTPClient.try_default_key", return_value=None)
def test_sftp_config_key_no_passphrase_needed(
self, mock_try_key, mock_load_key, mock_print
):
"""测试 sftp config --key-file 私钥不需要密码短语时不提示输入"""
os.chdir(self.test_dir)
sys.argv = [
"sbackup",
"--lang",
"en_US",
"sftp",
"config",
"--host",
"myhost",
"--port",
"22",
"--user",
"admin",
"--key-file",
"/path/to/id_rsa",
"--remote-path",
"/backups",
]
from sbackup import run
result = run()
self.assertEqual(result, 0)
mock_load_key.assert_called_once()
@patch("builtins.print") @patch("builtins.print")
def test_sftp_test_with_config(self, mock_print): def test_sftp_test_with_config(self, mock_print):
"""测试 sftp test 连接成功""" """测试 sftp test 连接成功"""
+99
View File
@@ -305,6 +305,65 @@ class TestSFTPClient(unittest.TestCase):
mock_sftp.stat.assert_called_once_with("/existing/dir") mock_sftp.stat.assert_called_once_with("/existing/dir")
mock_sftp.mkdir.assert_not_called() mock_sftp.mkdir.assert_not_called()
@patch("paramiko.Transport")
@patch("paramiko.SFTPClient")
def test_ensure_remote_dir_nested_create(self, mock_sftp_cls, mock_transport_cls):
"""测试远程嵌套目录逐级创建"""
mock_transport = MagicMock()
mock_transport_cls.return_value = mock_transport
mock_sftp = MagicMock()
# 所有目录都不存在
mock_sftp.stat.side_effect = FileNotFoundError()
mock_sftp_cls.from_transport.return_value = mock_sftp
client = SFTPClient(self.host, self.port, self.user, self.password)
client.connect()
client._ensure_remote_dir("/a/b/c")
# 应逐级创建 /a, /a/b, /a/b/c
expected_calls = ["/a/b/c", "/a/b", "/a"]
actual_calls = [c[0][0] for c in mock_sftp.stat.call_args_list]
self.assertEqual(actual_calls, expected_calls)
self.assertEqual(mock_sftp.mkdir.call_count, 3)
@patch("paramiko.Transport")
@patch("paramiko.SFTPClient")
def test_ensure_remote_dir_empty(self, mock_sftp_cls, mock_transport_cls):
"""测试空路径不操作"""
mock_transport = MagicMock()
mock_transport_cls.return_value = mock_transport
mock_sftp = MagicMock()
mock_sftp_cls.from_transport.return_value = mock_sftp
client = SFTPClient(self.host, self.port, self.user, self.password)
client.connect()
client._ensure_remote_dir("")
mock_sftp.stat.assert_not_called()
mock_sftp.mkdir.assert_not_called()
@patch("paramiko.Transport")
@patch("paramiko.SFTPClient")
def test_ensure_remote_dir_mkdir_error(self, mock_sftp_cls, mock_transport_cls):
"""测试创建远程目录失败"""
mock_transport = MagicMock()
mock_transport_cls.return_value = mock_transport
mock_sftp = MagicMock()
mock_sftp.stat.side_effect = FileNotFoundError()
mock_sftp.mkdir.side_effect = OSError("permission denied")
mock_sftp_cls.from_transport.return_value = mock_sftp
client = SFTPClient(self.host, self.port, self.user, self.password)
client.connect()
with self.assertRaises(SFTPError):
client._ensure_remote_dir("/forbidden")
@patch("paramiko.Transport")
@patch("paramiko.SFTPClient")
def test_ensure_remote_dir_not_connected(self, mock_sftp_cls, mock_transport_cls):
"""测试未连接时 _ensure_remote_dir 不报错"""
client = SFTPClient(self.host, self.port, self.user, self.password)
client._ensure_remote_dir("/some/path")
# 不应抛出异常
# ========== 私钥认证测试 ========== # ========== 私钥认证测试 ==========
def test_init_with_key_file(self): def test_init_with_key_file(self):
@@ -579,6 +638,46 @@ class TestSFTPClient(unittest.TestCase):
username=self.user, pkey=mock_pkey username=self.user, pkey=mock_pkey
) )
# ========== 资源清理测试 ==========
@patch("paramiko.Transport")
@patch("paramiko.SFTPClient")
def test_disconnect_cleans_up_even_if_sftp_close_raises(
self, mock_sftp_cls, mock_transport_cls
):
"""测试 sftp.close() 异常时 transport 仍被清理"""
mock_transport = MagicMock()
mock_transport_cls.return_value = mock_transport
mock_sftp = MagicMock()
mock_sftp_cls.from_transport.return_value = mock_sftp
mock_sftp.close.side_effect = OSError("socket closed")
client = SFTPClient(self.host, self.port, self.user, self.password)
client.connect()
client.disconnect()
mock_transport.close.assert_called_once()
self.assertIsNone(client._sftp)
self.assertIsNone(client._transport)
@patch("paramiko.Transport")
def test_connect_failure_cleans_up_transport(self, mock_transport_cls):
"""测试连接失败时 transport 被清理"""
import paramiko
mock_transport = MagicMock()
mock_transport_cls.return_value = mock_transport
mock_transport.connect.side_effect = paramiko.AuthenticationException(
"auth failed"
)
client = SFTPClient(self.host, self.port, self.user, self.password)
with self.assertRaises(SFTPError):
client.connect()
mock_transport.close.assert_called_once()
self.assertIsNone(client._transport)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+83
View File
@@ -132,6 +132,89 @@ class TestWebDAVClient(unittest.TestCase):
result = self.client.test_connection() result = self.client.test_connection()
self.assertFalse(result) self.assertFalse(result)
@patch("urllib.request.urlopen")
def test_ensure_remote_dir_401(self, mock_urlopen):
"""测试 MKCOL 认证失败"""
import urllib.error
mock_urlopen.side_effect = urllib.error.HTTPError(
self.url, 401, "Unauthorized", {}, None
)
with self.assertRaises(WebDAVError) as ctx:
self.client._ensure_remote_dir("secret")
self.assertIn("auth", str(ctx.exception).lower())
@patch("urllib.request.urlopen")
def test_ensure_remote_dir_other_error(self, mock_urlopen):
"""测试 MKCOL 其他 HTTP 错误"""
import urllib.error
mock_urlopen.side_effect = urllib.error.HTTPError(
self.url, 500, "Server Error", {}, None
)
with self.assertRaises(WebDAVError):
self.client._ensure_remote_dir("bad_dir")
@patch("urllib.request.urlopen")
def test_ensure_remote_dir_os_error(self, mock_urlopen):
"""测试 MKCOL 网络错误"""
mock_urlopen.side_effect = OSError("Connection refused")
with self.assertRaises(WebDAVError):
self.client._ensure_remote_dir("offline_dir")
@patch("urllib.request.urlopen")
def test_upload_file_401(self, mock_urlopen):
"""测试上传时认证失败"""
import urllib.error
mock_urlopen.side_effect = urllib.error.HTTPError(
self.url, 401, "Unauthorized", {}, None
)
with self.assertRaises(WebDAVError) as ctx:
self.client.upload_file(self.test_file, "backups/test.txt")
self.assertIn("auth", str(ctx.exception).lower())
@patch("urllib.request.urlopen")
def test_upload_file_os_error(self, mock_urlopen):
"""测试上传时网络错误"""
# 首次调用 (_ensure_remote_dir 的 MKCOL) 成功,第二次 (PUT) 失败
mock_urlopen.side_effect = [
MagicMock(), # MKCOL 成功
OSError("Network unreachable"), # PUT 失败
]
with self.assertRaises(WebDAVError) as ctx:
self.client.upload_file(self.test_file, "backups/test.txt")
self.assertIn("backups/test.txt", str(ctx.exception))
@patch("urllib.request.urlopen")
def test_connect_non_auth_http_error(self, mock_urlopen):
"""测试连接时非认证 HTTP 错误"""
import urllib.error
mock_urlopen.side_effect = urllib.error.HTTPError(
self.url, 502, "Bad Gateway", {}, None
)
with self.assertRaises(WebDAVError):
self.client.connect()
def test_context_manager(self):
"""测试上下文管理器"""
with WebDAVClient(self.url, self.user, self.password) as client:
self.assertIsInstance(client, WebDAVClient)
def test_build_request(self):
"""测试 _build_request 构建请求对象"""
req = self.client._build_request("GET", "test/path")
self.assertEqual(req.get_method(), "GET")
self.assertIn("Basic", req.get_header("Authorization"))
def test_build_request_with_data(self):
"""测试 _build_request 带数据时正确设置"""
data = b"test data"
req = self.client._build_request("PUT", "upload.txt", data=data)
self.assertEqual(req.get_method(), "PUT")
self.assertEqual(req.full_url, "https://dav.jianguoyun.com/dav/upload.txt")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()