PyHardLinkBackup 1.8.1__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.
- PyHardLinkBackup/__init__.py +7 -0
- PyHardLinkBackup/__main__.py +10 -0
- PyHardLinkBackup/backup.py +297 -0
- PyHardLinkBackup/cli_app/__init__.py +41 -0
- PyHardLinkBackup/cli_app/phlb.py +136 -0
- PyHardLinkBackup/cli_dev/__init__.py +70 -0
- PyHardLinkBackup/cli_dev/__main__.py +10 -0
- PyHardLinkBackup/cli_dev/benchmark.py +138 -0
- PyHardLinkBackup/cli_dev/code_style.py +12 -0
- PyHardLinkBackup/cli_dev/debugging.py +47 -0
- PyHardLinkBackup/cli_dev/packaging.py +62 -0
- PyHardLinkBackup/cli_dev/shell_completion.py +23 -0
- PyHardLinkBackup/cli_dev/testing.py +52 -0
- PyHardLinkBackup/cli_dev/update_readme_history.py +33 -0
- PyHardLinkBackup/compare_backup.py +259 -0
- PyHardLinkBackup/constants.py +18 -0
- PyHardLinkBackup/logging_setup.py +124 -0
- PyHardLinkBackup/rebuild_databases.py +217 -0
- PyHardLinkBackup/tests/__init__.py +36 -0
- PyHardLinkBackup/tests/test_backup.py +1167 -0
- PyHardLinkBackup/tests/test_compare_backup.py +167 -0
- PyHardLinkBackup/tests/test_doc_write.py +26 -0
- PyHardLinkBackup/tests/test_doctests.py +10 -0
- PyHardLinkBackup/tests/test_project_setup.py +46 -0
- PyHardLinkBackup/tests/test_readme.py +75 -0
- PyHardLinkBackup/tests/test_readme_history.py +9 -0
- PyHardLinkBackup/tests/test_rebuild_database.py +266 -0
- PyHardLinkBackup/utilities/__init__.py +0 -0
- PyHardLinkBackup/utilities/file_hash_database.py +62 -0
- PyHardLinkBackup/utilities/file_size_database.py +46 -0
- PyHardLinkBackup/utilities/filesystem.py +257 -0
- PyHardLinkBackup/utilities/humanize.py +39 -0
- PyHardLinkBackup/utilities/rich_utils.py +237 -0
- PyHardLinkBackup/utilities/sha256sums.py +61 -0
- PyHardLinkBackup/utilities/tee.py +40 -0
- PyHardLinkBackup/utilities/tests/__init__.py +0 -0
- PyHardLinkBackup/utilities/tests/test_file_hash_database.py +153 -0
- PyHardLinkBackup/utilities/tests/test_file_size_database.py +151 -0
- PyHardLinkBackup/utilities/tests/test_filesystem.py +167 -0
- PyHardLinkBackup/utilities/tests/unittest_utilities.py +78 -0
- PyHardLinkBackup/utilities/tyro_cli_shared_args.py +29 -0
- pyhardlinkbackup-1.8.1.dist-info/METADATA +700 -0
- pyhardlinkbackup-1.8.1.dist-info/RECORD +45 -0
- pyhardlinkbackup-1.8.1.dist-info/WHEEL +4 -0
- pyhardlinkbackup-1.8.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich import print # noqa
|
|
11
|
+
|
|
12
|
+
from PyHardLinkBackup.constants import CHUNK_SIZE
|
|
13
|
+
from PyHardLinkBackup.logging_setup import LoggingManager
|
|
14
|
+
from PyHardLinkBackup.utilities.file_hash_database import FileHashDatabase
|
|
15
|
+
from PyHardLinkBackup.utilities.file_size_database import FileSizeDatabase
|
|
16
|
+
from PyHardLinkBackup.utilities.filesystem import (
|
|
17
|
+
RemoveFileOnError,
|
|
18
|
+
copy_and_hash,
|
|
19
|
+
copy_with_progress,
|
|
20
|
+
hash_file,
|
|
21
|
+
humanized_fs_scan,
|
|
22
|
+
iter_scandir_files,
|
|
23
|
+
read_and_hash_file,
|
|
24
|
+
supports_hardlinks,
|
|
25
|
+
verbose_path_stat,
|
|
26
|
+
)
|
|
27
|
+
from PyHardLinkBackup.utilities.humanize import PrintTimingContextManager, human_filesize
|
|
28
|
+
from PyHardLinkBackup.utilities.rich_utils import DisplayFileTreeProgress
|
|
29
|
+
from PyHardLinkBackup.utilities.sha256sums import store_hash
|
|
30
|
+
from PyHardLinkBackup.utilities.tee import TeeStdoutContext
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass
|
|
37
|
+
class BackupResult:
|
|
38
|
+
backup_dir: Path
|
|
39
|
+
log_file: Path
|
|
40
|
+
#
|
|
41
|
+
backup_count: int = 0
|
|
42
|
+
backup_size: int = 0
|
|
43
|
+
#
|
|
44
|
+
symlink_files: int = 0
|
|
45
|
+
hardlinked_files: int = 0
|
|
46
|
+
hardlinked_size: int = 0
|
|
47
|
+
#
|
|
48
|
+
copied_files: int = 0
|
|
49
|
+
copied_size: int = 0
|
|
50
|
+
#
|
|
51
|
+
copied_small_files: int = 0
|
|
52
|
+
copied_small_size: int = 0
|
|
53
|
+
#
|
|
54
|
+
error_count: int = 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def copy_symlink(src_path: Path, dst_path: Path) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Copy file and directory symlinks.
|
|
60
|
+
"""
|
|
61
|
+
target_is_directory = src_path.is_dir()
|
|
62
|
+
logger.debug('Copy symlink: %s to %s (is directory: %r)', src_path, dst_path, target_is_directory)
|
|
63
|
+
target = os.readlink(src_path)
|
|
64
|
+
dst_path.symlink_to(target, target_is_directory=target_is_directory)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def backup_one_file(
|
|
68
|
+
*,
|
|
69
|
+
src_root: Path,
|
|
70
|
+
entry: os.DirEntry,
|
|
71
|
+
size_db: FileSizeDatabase,
|
|
72
|
+
hash_db: FileHashDatabase,
|
|
73
|
+
backup_dir: Path,
|
|
74
|
+
backup_result: BackupResult,
|
|
75
|
+
progress: DisplayFileTreeProgress,
|
|
76
|
+
) -> None:
|
|
77
|
+
backup_result.backup_count += 1
|
|
78
|
+
src_path = Path(entry.path)
|
|
79
|
+
|
|
80
|
+
dst_path = backup_dir / src_path.relative_to(src_root)
|
|
81
|
+
dst_dir_path = dst_path.parent
|
|
82
|
+
if not dst_dir_path.exists():
|
|
83
|
+
dst_dir_path.mkdir(parents=True, exist_ok=False)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
size = entry.stat().st_size
|
|
87
|
+
except FileNotFoundError as err:
|
|
88
|
+
logger.warning(f'Broken symlink {src_path}: {err.__class__.__name__}: {err}')
|
|
89
|
+
copy_symlink(src_path, dst_path)
|
|
90
|
+
backup_result.symlink_files += 1
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
backup_result.backup_size += size
|
|
94
|
+
|
|
95
|
+
if entry.name == 'SHA256SUMS':
|
|
96
|
+
# Skip existing SHA256SUMS files in source tree,
|
|
97
|
+
# because we create our own SHA256SUMS files.
|
|
98
|
+
logger.debug('Skip existing SHA256SUMS file: %s', src_path)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if entry.is_symlink():
|
|
102
|
+
copy_symlink(src_path, dst_path)
|
|
103
|
+
backup_result.symlink_files += 1
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Process regular files
|
|
107
|
+
assert entry.is_file(follow_symlinks=False), f'Unexpected non-file: {src_path}'
|
|
108
|
+
|
|
109
|
+
with RemoveFileOnError(dst_path):
|
|
110
|
+
# Deduplication logic
|
|
111
|
+
|
|
112
|
+
if size < size_db.MIN_SIZE:
|
|
113
|
+
# Small file -> always copy without deduplication
|
|
114
|
+
logger.info('Copy small file: %s to %s', src_path, dst_path)
|
|
115
|
+
file_hash = copy_and_hash(src_path, dst_path, progress=progress, total_size=size)
|
|
116
|
+
backup_result.copied_files += 1
|
|
117
|
+
backup_result.copied_size += size
|
|
118
|
+
backup_result.copied_small_files += 1
|
|
119
|
+
backup_result.copied_small_size += size
|
|
120
|
+
store_hash(dst_path, file_hash)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if size in size_db:
|
|
124
|
+
logger.debug('File with size %iBytes found before -> hash: %s', size, src_path)
|
|
125
|
+
|
|
126
|
+
if size <= CHUNK_SIZE:
|
|
127
|
+
# File can be read complete into memory
|
|
128
|
+
logger.debug('File size %iBytes <= CHUNK_SIZE (%iBytes) -> read complete into memory', size, CHUNK_SIZE)
|
|
129
|
+
file_content, file_hash = read_and_hash_file(src_path)
|
|
130
|
+
if existing_path := hash_db.get(file_hash):
|
|
131
|
+
logger.info('Hardlink duplicate file: %s to %s', dst_path, existing_path)
|
|
132
|
+
os.link(existing_path, dst_path)
|
|
133
|
+
backup_result.hardlinked_files += 1
|
|
134
|
+
backup_result.hardlinked_size += size
|
|
135
|
+
else:
|
|
136
|
+
logger.info('Store unique file: %s to %s', src_path, dst_path)
|
|
137
|
+
dst_path.write_bytes(file_content)
|
|
138
|
+
hash_db[file_hash] = dst_path
|
|
139
|
+
backup_result.copied_files += 1
|
|
140
|
+
backup_result.copied_size += size
|
|
141
|
+
|
|
142
|
+
else:
|
|
143
|
+
# Large file
|
|
144
|
+
file_hash = hash_file(src_path, progress=progress, total_size=size) # Calculate hash without copying
|
|
145
|
+
|
|
146
|
+
if existing_path := hash_db.get(file_hash):
|
|
147
|
+
logger.info('Hardlink duplicate file: %s to %s', dst_path, existing_path)
|
|
148
|
+
os.link(existing_path, dst_path)
|
|
149
|
+
backup_result.hardlinked_files += 1
|
|
150
|
+
backup_result.hardlinked_size += size
|
|
151
|
+
else:
|
|
152
|
+
logger.info('Copy unique file: %s to %s', src_path, dst_path)
|
|
153
|
+
copy_with_progress(src_path, dst_path, progress=progress, total_size=size)
|
|
154
|
+
hash_db[file_hash] = dst_path
|
|
155
|
+
backup_result.copied_files += 1
|
|
156
|
+
backup_result.copied_size += size
|
|
157
|
+
|
|
158
|
+
# Keep original file metadata (permission bits, time stamps, and flags)
|
|
159
|
+
shutil.copystat(src_path, dst_path)
|
|
160
|
+
else:
|
|
161
|
+
# A file with this size not backuped before -> Can't be duplicate -> copy and hash
|
|
162
|
+
file_hash = copy_and_hash(src_path, dst_path, progress=progress, total_size=size)
|
|
163
|
+
size_db.add(size)
|
|
164
|
+
hash_db[file_hash] = dst_path
|
|
165
|
+
backup_result.copied_files += 1
|
|
166
|
+
backup_result.copied_size += size
|
|
167
|
+
|
|
168
|
+
store_hash(dst_path, file_hash)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def backup_tree(
|
|
172
|
+
*,
|
|
173
|
+
src_root: Path,
|
|
174
|
+
backup_root: Path,
|
|
175
|
+
backup_name: str | None,
|
|
176
|
+
one_file_system: bool,
|
|
177
|
+
excludes: tuple[str, ...],
|
|
178
|
+
log_manager: LoggingManager,
|
|
179
|
+
) -> BackupResult:
|
|
180
|
+
src_root = src_root.resolve()
|
|
181
|
+
if not src_root.is_dir():
|
|
182
|
+
print('Error: Source directory does not exist!')
|
|
183
|
+
print(f'Please check source directory: "{src_root}"\n')
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
src_stat = verbose_path_stat(src_root)
|
|
187
|
+
src_device_id = src_stat.st_dev
|
|
188
|
+
|
|
189
|
+
backup_root = backup_root.resolve()
|
|
190
|
+
if not backup_root.is_dir():
|
|
191
|
+
print('Error: Backup directory does not exist!')
|
|
192
|
+
print(f'Please create "{backup_root}" directory first and start again!\n')
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
verbose_path_stat(backup_root)
|
|
196
|
+
|
|
197
|
+
if not os.access(backup_root, os.W_OK):
|
|
198
|
+
print('Error: No write access to backup directory!')
|
|
199
|
+
print(f'Please check permissions for backup directory: "{backup_root}"\n')
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
if not supports_hardlinks(backup_root):
|
|
203
|
+
print('Error: Filesystem for backup directory does not support hardlinks!')
|
|
204
|
+
print(f'Please check backup directory: "{backup_root}"\n')
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
# Step 1: Scan source directory:
|
|
208
|
+
excludes: set = set(excludes)
|
|
209
|
+
with PrintTimingContextManager('Filesystem scan completed in'):
|
|
210
|
+
src_file_count, src_total_size = humanized_fs_scan(
|
|
211
|
+
path=src_root,
|
|
212
|
+
one_file_system=one_file_system,
|
|
213
|
+
src_device_id=src_device_id,
|
|
214
|
+
excludes=excludes,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
phlb_conf_dir = backup_root / '.phlb'
|
|
218
|
+
phlb_conf_dir.mkdir(parents=False, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
timestamp = datetime.datetime.now().strftime('%Y-%m-%d-%H%M%S')
|
|
221
|
+
if not backup_name:
|
|
222
|
+
backup_name = src_root.name
|
|
223
|
+
backup_main_dir = backup_root / backup_name
|
|
224
|
+
backup_dir = backup_main_dir / timestamp
|
|
225
|
+
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
226
|
+
|
|
227
|
+
log_file = backup_main_dir / f'{timestamp}-backup.log'
|
|
228
|
+
log_manager.start_file_logging(log_file)
|
|
229
|
+
|
|
230
|
+
logger.info('Backup %s to %s', src_root, backup_dir)
|
|
231
|
+
|
|
232
|
+
print(f'\nBackup to {backup_dir}...\n')
|
|
233
|
+
|
|
234
|
+
with DisplayFileTreeProgress(
|
|
235
|
+
description=f'Backup {src_root}...',
|
|
236
|
+
total_file_count=src_file_count,
|
|
237
|
+
total_size=src_total_size,
|
|
238
|
+
) as progress:
|
|
239
|
+
# "Databases" for deduplication
|
|
240
|
+
size_db = FileSizeDatabase(phlb_conf_dir)
|
|
241
|
+
hash_db = FileHashDatabase(backup_root, phlb_conf_dir)
|
|
242
|
+
|
|
243
|
+
backup_result = BackupResult(backup_dir=backup_dir, log_file=log_file)
|
|
244
|
+
|
|
245
|
+
next_update = 0
|
|
246
|
+
for entry in iter_scandir_files(
|
|
247
|
+
path=src_root,
|
|
248
|
+
one_file_system=one_file_system,
|
|
249
|
+
src_device_id=src_device_id,
|
|
250
|
+
excludes=excludes,
|
|
251
|
+
):
|
|
252
|
+
try:
|
|
253
|
+
backup_one_file(
|
|
254
|
+
src_root=src_root,
|
|
255
|
+
entry=entry,
|
|
256
|
+
size_db=size_db,
|
|
257
|
+
hash_db=hash_db,
|
|
258
|
+
backup_dir=backup_dir,
|
|
259
|
+
backup_result=backup_result,
|
|
260
|
+
progress=progress,
|
|
261
|
+
)
|
|
262
|
+
except Exception as err:
|
|
263
|
+
logger.exception(f'Backup {entry.path} {err.__class__.__name__}: {err}')
|
|
264
|
+
backup_result.error_count += 1
|
|
265
|
+
else:
|
|
266
|
+
now = time.monotonic()
|
|
267
|
+
if now >= next_update:
|
|
268
|
+
progress.update(
|
|
269
|
+
completed_file_count=backup_result.backup_count, completed_size=backup_result.backup_size
|
|
270
|
+
)
|
|
271
|
+
next_update = now + 0.5
|
|
272
|
+
|
|
273
|
+
# Finalize progress indicator values:
|
|
274
|
+
progress.update(completed_file_count=backup_result.backup_count, completed_size=backup_result.backup_size)
|
|
275
|
+
|
|
276
|
+
summary_file = backup_main_dir / f'{timestamp}-summary.txt'
|
|
277
|
+
with TeeStdoutContext(summary_file):
|
|
278
|
+
print(f'\nBackup complete: {backup_dir} (total size {human_filesize(backup_result.backup_size)})\n')
|
|
279
|
+
print(f' Total files processed: {backup_result.backup_count}')
|
|
280
|
+
print(f' * Symlinked files: {backup_result.symlink_files}')
|
|
281
|
+
print(
|
|
282
|
+
f' * Hardlinked files: {backup_result.hardlinked_files}'
|
|
283
|
+
f' (saved {human_filesize(backup_result.hardlinked_size)})'
|
|
284
|
+
)
|
|
285
|
+
print(f' * Copied files: {backup_result.copied_files} (total {human_filesize(backup_result.copied_size)})')
|
|
286
|
+
print(
|
|
287
|
+
f' of which small (<{size_db.MIN_SIZE} Bytes)'
|
|
288
|
+
f' files: {backup_result.copied_small_files}'
|
|
289
|
+
f' (total {human_filesize(backup_result.copied_small_size)})'
|
|
290
|
+
)
|
|
291
|
+
if backup_result.error_count > 0:
|
|
292
|
+
print(f' Errors during backup: {backup_result.error_count} (see log for details)')
|
|
293
|
+
print()
|
|
294
|
+
|
|
295
|
+
logger.info('Backup completed. Summary created: %s', summary_file)
|
|
296
|
+
|
|
297
|
+
return backup_result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for usage
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from cli_base.autodiscover import import_all_files
|
|
10
|
+
from cli_base.cli_tools.version_info import print_version
|
|
11
|
+
from rich import print # noqa
|
|
12
|
+
from tyro.extras import SubcommandApp
|
|
13
|
+
|
|
14
|
+
import PyHardLinkBackup
|
|
15
|
+
from PyHardLinkBackup import constants
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
app = SubcommandApp()
|
|
21
|
+
|
|
22
|
+
# Register all CLI commands, just by import all files in this package:
|
|
23
|
+
import_all_files(package=__package__, init_file=__file__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command
|
|
27
|
+
def version():
|
|
28
|
+
"""Print version and exit"""
|
|
29
|
+
# Pseudo command, because the version always printed on every CLI call ;)
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(args: Sequence[str] | None = None):
|
|
34
|
+
print_version(PyHardLinkBackup)
|
|
35
|
+
app.cli(
|
|
36
|
+
prog='./cli.py',
|
|
37
|
+
description=constants.CLI_EPILOG,
|
|
38
|
+
use_underscores=False, # use hyphens instead of underscores
|
|
39
|
+
sort_subcommands=True,
|
|
40
|
+
args=args,
|
|
41
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import tyro
|
|
6
|
+
from rich import print # noqa
|
|
7
|
+
|
|
8
|
+
from PyHardLinkBackup import compare_backup, rebuild_databases
|
|
9
|
+
from PyHardLinkBackup.backup import backup_tree
|
|
10
|
+
from PyHardLinkBackup.cli_app import app
|
|
11
|
+
from PyHardLinkBackup.logging_setup import (
|
|
12
|
+
DEFAULT_CONSOLE_LOG_LEVEL,
|
|
13
|
+
DEFAULT_LOG_FILE_LEVEL,
|
|
14
|
+
LoggingManager,
|
|
15
|
+
TyroConsoleLogLevelArgType,
|
|
16
|
+
TyroLogFileLevelArgType,
|
|
17
|
+
)
|
|
18
|
+
from PyHardLinkBackup.utilities.tyro_cli_shared_args import (
|
|
19
|
+
DEFAULT_EXCLUDE_DIRECTORIES,
|
|
20
|
+
TyroBackupNameArgType,
|
|
21
|
+
TyroExcludeDirectoriesArgType,
|
|
22
|
+
TyroOneFileSystemArgType,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command
|
|
30
|
+
def backup(
|
|
31
|
+
src: Annotated[
|
|
32
|
+
Path,
|
|
33
|
+
tyro.conf.arg(
|
|
34
|
+
metavar='source',
|
|
35
|
+
help='Source directory to back up.',
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
dst: Annotated[
|
|
39
|
+
Path,
|
|
40
|
+
tyro.conf.arg(
|
|
41
|
+
metavar='destination',
|
|
42
|
+
help='Destination directory for the backup.',
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
/,
|
|
46
|
+
name: TyroBackupNameArgType = None,
|
|
47
|
+
one_file_system: TyroOneFileSystemArgType = True,
|
|
48
|
+
excludes: TyroExcludeDirectoriesArgType = DEFAULT_EXCLUDE_DIRECTORIES,
|
|
49
|
+
verbosity: TyroConsoleLogLevelArgType = DEFAULT_CONSOLE_LOG_LEVEL,
|
|
50
|
+
log_file_level: TyroLogFileLevelArgType = DEFAULT_LOG_FILE_LEVEL,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Backup the source directory to the destination directory using hard links for deduplication.
|
|
54
|
+
"""
|
|
55
|
+
log_manager = LoggingManager(
|
|
56
|
+
console_level=verbosity,
|
|
57
|
+
file_level=log_file_level,
|
|
58
|
+
)
|
|
59
|
+
backup_tree(
|
|
60
|
+
src_root=src,
|
|
61
|
+
backup_root=dst,
|
|
62
|
+
backup_name=name,
|
|
63
|
+
one_file_system=one_file_system,
|
|
64
|
+
excludes=excludes,
|
|
65
|
+
log_manager=log_manager,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command
|
|
70
|
+
def compare(
|
|
71
|
+
src: Annotated[
|
|
72
|
+
Path,
|
|
73
|
+
tyro.conf.arg(
|
|
74
|
+
metavar='source',
|
|
75
|
+
help='Source directory that should be compared with the last backup.',
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
dst: Annotated[
|
|
79
|
+
Path,
|
|
80
|
+
tyro.conf.arg(
|
|
81
|
+
metavar='destination',
|
|
82
|
+
help='Destination directory with the backups. Will pick the last backup for comparison.',
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
/,
|
|
86
|
+
one_file_system: TyroOneFileSystemArgType = True,
|
|
87
|
+
excludes: TyroExcludeDirectoriesArgType = DEFAULT_EXCLUDE_DIRECTORIES,
|
|
88
|
+
verbosity: TyroConsoleLogLevelArgType = DEFAULT_CONSOLE_LOG_LEVEL,
|
|
89
|
+
log_file_level: TyroLogFileLevelArgType = DEFAULT_LOG_FILE_LEVEL,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Compares a source tree with the last backup and validates all known file hashes.
|
|
93
|
+
"""
|
|
94
|
+
log_manager = LoggingManager(
|
|
95
|
+
console_level=verbosity,
|
|
96
|
+
file_level=log_file_level,
|
|
97
|
+
)
|
|
98
|
+
compare_backup.compare_tree(
|
|
99
|
+
src_root=src,
|
|
100
|
+
backup_root=dst,
|
|
101
|
+
one_file_system=one_file_system,
|
|
102
|
+
excludes=excludes,
|
|
103
|
+
log_manager=log_manager,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command
|
|
108
|
+
def rebuild(
|
|
109
|
+
backup_root: Annotated[
|
|
110
|
+
Path,
|
|
111
|
+
tyro.conf.arg(
|
|
112
|
+
metavar='backup-directory',
|
|
113
|
+
help='Root directory of the the backups.',
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
/,
|
|
117
|
+
skip_same_inode: Annotated[
|
|
118
|
+
bool,
|
|
119
|
+
tyro.conf.arg(help='Skip files that have the same inode number as already processed files.'),
|
|
120
|
+
] = True,
|
|
121
|
+
verbosity: TyroConsoleLogLevelArgType = DEFAULT_CONSOLE_LOG_LEVEL,
|
|
122
|
+
log_file_level: TyroLogFileLevelArgType = DEFAULT_LOG_FILE_LEVEL,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Rebuild the file hash and size database by scanning all backup files. And also verify SHA256SUMS
|
|
126
|
+
and/or store missing hashes in SHA256SUMS files.
|
|
127
|
+
"""
|
|
128
|
+
log_manager = LoggingManager(
|
|
129
|
+
console_level=verbosity,
|
|
130
|
+
file_level=log_file_level,
|
|
131
|
+
)
|
|
132
|
+
rebuild_databases.rebuild(
|
|
133
|
+
backup_root=backup_root,
|
|
134
|
+
skip_same_inode=skip_same_inode,
|
|
135
|
+
log_manager=log_manager,
|
|
136
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for development
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from bx_py_utils.path import assert_is_file
|
|
11
|
+
from cli_base.autodiscover import import_all_files
|
|
12
|
+
from cli_base.cli_tools.dev_tools import run_coverage, run_nox, run_unittest_cli
|
|
13
|
+
from cli_base.cli_tools.version_info import print_version
|
|
14
|
+
from typeguard import install_import_hook
|
|
15
|
+
from tyro.extras import SubcommandApp
|
|
16
|
+
|
|
17
|
+
import PyHardLinkBackup
|
|
18
|
+
from PyHardLinkBackup import constants
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Check type annotations via typeguard in all tests.
|
|
22
|
+
# Sadly we must activate this here and can't do this in ./tests/__init__.py
|
|
23
|
+
install_import_hook(packages=('PyHardLinkBackup',))
|
|
24
|
+
|
|
25
|
+
# reload the module, after the typeguard import hook is activated:
|
|
26
|
+
importlib.reload(PyHardLinkBackup)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PACKAGE_ROOT = constants.BASE_PATH.parent
|
|
33
|
+
assert_is_file(PACKAGE_ROOT / 'pyproject.toml') # Exists only in cloned git repo
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = SubcommandApp()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Register all CLI commands, just by import all files in this package:
|
|
40
|
+
import_all_files(package=__package__, init_file=__file__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command
|
|
44
|
+
def version():
|
|
45
|
+
"""Print version and exit"""
|
|
46
|
+
# Pseudo command, because the version always printed on every CLI call ;)
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(args: Sequence[str] | None = None):
|
|
51
|
+
print_version(PyHardLinkBackup)
|
|
52
|
+
|
|
53
|
+
if len(sys.argv) >= 2:
|
|
54
|
+
# Check if we can just pass a command call to origin CLI:
|
|
55
|
+
command = sys.argv[1]
|
|
56
|
+
command_map = {
|
|
57
|
+
'test': run_unittest_cli,
|
|
58
|
+
'nox': run_nox,
|
|
59
|
+
'coverage': run_coverage,
|
|
60
|
+
}
|
|
61
|
+
if real_func := command_map.get(command):
|
|
62
|
+
real_func(argv=sys.argv, exit_after_run=True)
|
|
63
|
+
|
|
64
|
+
app.cli(
|
|
65
|
+
prog='./dev-cli.py',
|
|
66
|
+
description=constants.CLI_EPILOG,
|
|
67
|
+
use_underscores=False, # use hyphens instead of underscores
|
|
68
|
+
sort_subcommands=True,
|
|
69
|
+
args=args,
|
|
70
|
+
)
|