confmirror 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
confmirror/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ ConfMirror - 系统配置文件备份与还原工具
3
+
4
+ 功能特性:
5
+ - 模块化备份与还原
6
+ - 元数据保留(权限、属主等)
7
+ - 增量备份/还原
8
+ - 差异对比功能
9
+ - 多脚本语言支持
10
+ """
11
+
12
+ try:
13
+ from importlib.metadata import version
14
+ __version__ = version("confmirror")
15
+ except ImportError:
16
+ __version__ = "1.0.0"
17
+
18
+ _LAZY_IMPORTS = {
19
+ 'load_config': ('.config', 'load_config'),
20
+ 'Config': ('.config', 'Config'),
21
+ 'Settings': ('.config', 'Settings'),
22
+ 'ModuleConfig': ('.config', 'ModuleConfig'),
23
+ 'execute_backup': ('.backup', 'execute_backup'),
24
+ 'execute_restore': ('.restore', 'execute_restore'),
25
+ 'setup_logger': ('.logger', 'setup_logger'),
26
+ }
27
+
28
+ __all__ = list(_LAZY_IMPORTS.keys())
29
+
30
+
31
+ def __getattr__(name):
32
+ """延迟导入,减少 import confmirror 时的启动开销"""
33
+ if name in _LAZY_IMPORTS:
34
+ import importlib
35
+ module_path, attr_name = _LAZY_IMPORTS[name]
36
+ module = importlib.import_module(module_path, package=__package__)
37
+ return getattr(module, attr_name)
38
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
39
+
40
+
41
+ def __dir__():
42
+ """确保 dir(confmirror) 和 IDE 补全能看到延迟导入的符号"""
43
+ return list(__all__)
confmirror/backup.py ADDED
@@ -0,0 +1,334 @@
1
+ import logging
2
+ import glob
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import List, Optional, Dict, Any
6
+
7
+ import pathspec
8
+
9
+ from confmirror.config import Config, ModuleConfig, Settings
10
+ from confmirror.diff import compare_meta, same_file
11
+ from confmirror.meta import write_meta
12
+ from confmirror.utils import (
13
+ find_matching_module_with_path,
14
+ run_script,
15
+ should_exclude_path,
16
+ )
17
+ from confmirror.logger import ModuleLog
18
+
19
+ logger = logging.getLogger(__name__)
20
+ _log = ModuleLog("backup", logger)
21
+
22
+
23
+ def execute_backup(config: Config, target_module_name: Optional[str] = None,
24
+ target_path: Optional[str] = None, force: bool = False,
25
+ dry_run: bool = False) -> None:
26
+ """
27
+ 执行备份操作
28
+
29
+ Args:
30
+ config: 配置对象
31
+ target_module_name: 指定要备份的模块名称
32
+ target_path: 指定要备份的路径
33
+ force: 是否强制覆盖备份(默认为False,即差异备份)
34
+ dry_run: 是否为预览模式(不实际执行)
35
+ """
36
+ settings = config.settings
37
+ backup_root = settings.backup_root
38
+
39
+ if dry_run:
40
+ _log.info("[DRY-RUN] 预览模式,不实际执行备份操作")
41
+ else:
42
+ # 确保备份根目录存在
43
+ backup_root.mkdir(parents=True, exist_ok=True)
44
+
45
+ if target_module_name:
46
+ # 分模块备份
47
+ modules = config.modules
48
+ found_module = next((mod for mod in modules if mod.name == target_module_name), None)
49
+ if not found_module:
50
+ _log.error(f"找不到模块: '{target_module_name}'")
51
+ return
52
+ backup_module(found_module, backup_root, settings, force, dry_run=dry_run)
53
+
54
+ elif target_path:
55
+ module = find_matching_module_with_path(config.modules, Path(target_path))
56
+ if not module:
57
+ _log.error(f"路径 '{target_path}' 不属于任何模块,无法备份")
58
+ return
59
+ # 获取排除路径模式和父路径
60
+ all_exclude_patterns = module.exclude_paths or []
61
+ parent_path = module.base_path or ""
62
+ if should_exclude_path(Path(target_path), exclude_patterns=all_exclude_patterns, parent_path=parent_path):
63
+ _log.skip(f"路径 '{target_path}' 被排除")
64
+ return
65
+ # 展开可能的通配符路径,并应用排除规则
66
+ expanded_paths = expand_path_patterns(target_path, "", all_exclude_patterns)
67
+
68
+ if not expanded_paths:
69
+ _log.warn(f"路径模式未匹配到任何文件: {target_path}")
70
+ return
71
+
72
+ # 预编译排除规则,避免循环内重复构建
73
+ spec = pathspec.GitIgnoreSpec.from_lines(all_exclude_patterns) if all_exclude_patterns else None
74
+ # 对每个匹配的路径进行备份
75
+ for path in expanded_paths:
76
+ # 检查路径是否在当前模块的排除列表中
77
+ if should_exclude_path(path, spec=spec, parent_path=parent_path):
78
+ _log.skip(f"路径 '{path}' 被排除")
79
+ continue
80
+ backup_single_path(path, backup_root, settings, force, spec=spec, parent_path=parent_path)
81
+ else:
82
+ # 全量备份
83
+ for module in config.modules:
84
+ backup_module(module, backup_root, settings, force, dry_run=dry_run)
85
+
86
+
87
+ def _backup_directory(src_dir: Path, dest_dir: Path, settings: Settings, force: bool = False,
88
+ spec: Optional[pathspec.GitIgnoreSpec] = None, parent_path: str = "",
89
+ dry_run: bool = False):
90
+ """
91
+ 备份目录及其内容(递归)
92
+
93
+ Args:
94
+ src_dir: 源目录
95
+ dest_dir: 目标目录
96
+ settings: 配置设置对象
97
+ force: 是否强制覆盖
98
+ spec: 预编译的 pathspec 对象,用于排除规则
99
+ parent_path: 模块的父路径,用于排除规则匹配
100
+ dry_run: 是否为预览模式
101
+ """
102
+ if dry_run:
103
+ _log.info(f"[DRY-RUN] 将备份目录: {src_dir} -> {dest_dir}")
104
+ else:
105
+ # 创建目标目录
106
+ dest_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ # 获取源目录的统计信息并写入目录的元数据
109
+ src_stat = src_dir.stat()
110
+ dir_mode = oct(src_stat.st_mode)[-3:]
111
+ if not write_meta(dest_dir, dir_mode, src_stat.st_uid, src_stat.st_gid, "dir"):
112
+ _log.warn(f"目录元数据写入失败,继续备份子内容: {src_dir}")
113
+
114
+ # 确保备份目录可读写
115
+ backup_dir_mode = int(settings.mirror_dir_mode, 8)
116
+ dest_dir.chmod(backup_dir_mode)
117
+
118
+ _log.ok(f"→ {src_dir} (权限:{dir_mode} 用户:{src_stat.st_uid}:{src_stat.st_gid})")
119
+
120
+ # 递归处理子文件和子目录
121
+ for child in src_dir.iterdir():
122
+ # 应用排除规则
123
+ if spec and should_exclude_path(child, spec=spec, parent_path=parent_path):
124
+ _log.skip(f"路径 '{child}' 被排除")
125
+ continue
126
+
127
+ child_dest = dest_dir / child.name
128
+ if child.is_file():
129
+ _backup_file(child, child_dest, settings, force, dry_run=dry_run)
130
+ elif child.is_dir():
131
+ _backup_directory(child, child_dest, settings, force, spec=spec, parent_path=parent_path, dry_run=dry_run)
132
+ else:
133
+ _log.skip(f"不支持的文件类型: {child}")
134
+
135
+
136
+ def _backup_file(src: Path, dest: Path, settings: Settings, force: bool = False,
137
+ use_hash: bool = False, dry_run: bool = False):
138
+ """
139
+ 备份单个文件
140
+
141
+ Args:
142
+ src: 源文件路径
143
+ dest: 目标文件路径
144
+ settings: 配置设置对象
145
+ force: 是否强制覆盖
146
+ use_hash: 是否使用哈希比对(增量备份时)
147
+ dry_run: 是否为预览模式
148
+ """
149
+ # 检查是否跳过(差异备份)
150
+ if not force and same_file(src, dest):
151
+ _log.skip(f"文件信息无变化: {src}")
152
+ return
153
+
154
+ if dry_run:
155
+ _log.info(f"[DRY-RUN] 将备份文件: {src} -> {dest}")
156
+ return
157
+
158
+ try:
159
+ # 确保目标父目录存在
160
+ dest.parent.mkdir(parents=True, exist_ok=True)
161
+
162
+ # 复制文件内容
163
+ shutil.copy2(src, dest)
164
+
165
+ # 获取文件统计信息
166
+ stat = src.stat()
167
+ mode = oct(stat.st_mode)[-3:] # 获取最后3位权限数字
168
+
169
+ # 写入元数据(重要:在修改权限之前保存原始权限信息)
170
+ if not write_meta(dest, mode, stat.st_uid, stat.st_gid, "file"):
171
+ _log.warn(f"文件元数据写入失败: {src}")
172
+
173
+ # 设置备份文件权限,确保当前用户和 Git 可读写
174
+ backup_file_mode = int(settings.mirror_file_mode, 8)
175
+ dest.chmod(backup_file_mode)
176
+
177
+ _log.ok(f"→ {src} (权限:{mode} 用户:{stat.st_uid}:{stat.st_gid})")
178
+
179
+ except PermissionError:
180
+ _log.error(f"无法备份文件 {src},可能需要更高权限")
181
+ except Exception as e:
182
+ _log.error(f"{src}: {str(e)}")
183
+
184
+
185
+ def backup_single_path(src: Path, mirror_root: Path, settings: Settings, force: bool = False,
186
+ spec: Optional[pathspec.GitIgnoreSpec] = None, parent_path: str = "",
187
+ dry_run: bool = False):
188
+ """
189
+ 备份单个路径(文件或目录)到镜像目录
190
+
191
+ Args:
192
+ src: 源路径
193
+ mirror_root: 镜像根目录
194
+ settings: 配置设置对象
195
+ force: 是否强制覆盖
196
+ spec: 预编译的 pathspec 对象,用于排除规则
197
+ parent_path: 模块的父路径,用于排除规则匹配
198
+ dry_run: 是否为预览模式
199
+ """
200
+ if not src.exists():
201
+ _log.warn(f"路径不存在: {src}")
202
+ return
203
+
204
+ # 检查是否为支持的文件类型
205
+ if not (src.is_file() or src.is_dir()):
206
+ _log.warn(f"不支持的文件类型: {src}")
207
+ return
208
+
209
+ # 检查源路径是否在备份根目录内,避免递归备份
210
+ if src.resolve().is_relative_to(mirror_root.resolve()):
211
+ _log.error(f"源路径 '{src}' 是备份目录或其子目录,不能备份备份目录自身")
212
+ return
213
+
214
+ # 直接使用源路径的绝对路径作为备份路径
215
+ dest = mirror_root / str(src).lstrip('/')
216
+
217
+ if src.is_file():
218
+ _backup_file(src, dest, settings, force, dry_run=dry_run)
219
+ elif src.is_dir():
220
+ _backup_directory(src, dest, settings, force, spec=spec, parent_path=parent_path, dry_run=dry_run)
221
+
222
+
223
+ def expand_path_patterns(
224
+ path_pattern: str,
225
+ parent_path: str = "",
226
+ exclude_patterns: Optional[list] = None,
227
+ spec: Optional[pathspec.GitIgnoreSpec] = None,
228
+ ) -> List[Path]:
229
+ """
230
+ 展开通配符路径模式为实际路径列表
231
+
232
+ Args:
233
+ path_pattern: 路径模式,可能包含通配符
234
+ parent_path: 父路径
235
+ exclude_patterns: 排除模式列表(仅在 spec 为 None 时使用)
236
+ spec: 预编译的 pathspec 对象(推荐在循环外预编译后传入)
237
+
238
+ Returns:
239
+ 匹配的路径列表
240
+ """
241
+ if exclude_patterns is None:
242
+ exclude_patterns = []
243
+
244
+ # 如果提供了父路径,则将模式附加到父路径
245
+ if parent_path:
246
+ full_pattern = str(Path(parent_path) / path_pattern)
247
+ else:
248
+ full_pattern = path_pattern
249
+
250
+ # 使用 glob 模块匹配所有路径
251
+ # recursive=True 使 glob.glob 支持 ** 模式
252
+ matched_strs = glob.glob(full_pattern, recursive=True)
253
+
254
+ # 将匹配的字符串路径转为 Path 对象
255
+ matched_paths = [Path(p) for p in matched_strs]
256
+
257
+ # 应用排除模式过滤结果
258
+ filtered_paths = [
259
+ path for path in matched_paths
260
+ if not should_exclude_path(path, spec=spec, parent_path=parent_path)
261
+ ]
262
+
263
+ return filtered_paths
264
+
265
+
266
+ def backup_module(module: ModuleConfig, backup_root: Path, settings: Settings,
267
+ force: bool = False, dry_run: bool = False):
268
+ """
269
+ 备份模块配置中指定的路径或脚本
270
+
271
+ Args:
272
+ module: 模块配置对象
273
+ backup_root: 镜像根目录
274
+ settings: 配置设置对象
275
+ force: 是否强制覆盖备份(默认为False,即差异备份)
276
+ dry_run: 是否为预览模式
277
+ """
278
+ module_name = module.name
279
+ _log.info(f"正在备份模块: {module_name}")
280
+
281
+ if dry_run:
282
+ _log.info(f"[DRY-RUN] 预览模块 '{module_name}' 的备份内容")
283
+
284
+ if module.hook is not None:
285
+ if dry_run:
286
+ _log.info(f"[DRY-RUN] 将执行脚本: {module.hook}")
287
+ else:
288
+ # 使用脚本备份
289
+ script_rel = module.hook
290
+ hook_lang = module.hook_lang
291
+
292
+ # 如果未指定语言,尝试自动检测
293
+ if hook_lang == "auto":
294
+ from confmirror.utils import get_script_shebang
295
+ script_path = settings.script_hooks_dir / script_rel
296
+ detected = get_script_shebang(script_path)
297
+ hook_lang = detected if detected else "bash"
298
+ _log.info(f"自动检测到脚本语言: {hook_lang}")
299
+
300
+ run_script(script_rel, settings, "backup", hook_lang)
301
+
302
+ elif module.paths is not None:
303
+ # 使用路径备份
304
+ parent_path = module.base_path or ""
305
+
306
+ # 获取排除路径模式
307
+ exclude_patterns = module.exclude_paths or []
308
+
309
+ # 预编译排除规则,避免循环内重复构建
310
+ spec = pathspec.GitIgnoreSpec.from_lines(exclude_patterns) if exclude_patterns else None
311
+ for path_str in module.paths:
312
+ # 展开可能的通配符路径,同时应用排除规则
313
+ expanded_paths = expand_path_patterns(path_str, parent_path, exclude_patterns, spec=spec)
314
+
315
+ if not expanded_paths:
316
+ _log.warn(f"该路径未到任何文件: {path_str}")
317
+ continue
318
+
319
+ for path in expanded_paths:
320
+ # 检查路径是否在备份根目录内,避免递归备份
321
+ if path.resolve().is_relative_to(backup_root.resolve()):
322
+ _log.error(f"源路径 '{path}' 是备份目录或其子目录,不能备份备份目录自身")
323
+ continue
324
+
325
+ # 直接处理glob结果,根据文件类型执行相应备份
326
+ if path.is_file():
327
+ _backup_file(path, backup_root / str(path).lstrip('/'), settings, force, dry_run=dry_run)
328
+ elif path.is_dir():
329
+ _backup_directory(path, backup_root / str(path).lstrip('/'), settings, force,
330
+ spec=spec, parent_path=parent_path, dry_run=dry_run)
331
+ else:
332
+ _log.skip(f"不支持的文件类型: {path}")
333
+ else:
334
+ _log.warn(f"模块 {module_name} 既没有配置路径也没有配置脚本")