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.
Files changed (45) hide show
  1. PyHardLinkBackup/__init__.py +7 -0
  2. PyHardLinkBackup/__main__.py +10 -0
  3. PyHardLinkBackup/backup.py +297 -0
  4. PyHardLinkBackup/cli_app/__init__.py +41 -0
  5. PyHardLinkBackup/cli_app/phlb.py +136 -0
  6. PyHardLinkBackup/cli_dev/__init__.py +70 -0
  7. PyHardLinkBackup/cli_dev/__main__.py +10 -0
  8. PyHardLinkBackup/cli_dev/benchmark.py +138 -0
  9. PyHardLinkBackup/cli_dev/code_style.py +12 -0
  10. PyHardLinkBackup/cli_dev/debugging.py +47 -0
  11. PyHardLinkBackup/cli_dev/packaging.py +62 -0
  12. PyHardLinkBackup/cli_dev/shell_completion.py +23 -0
  13. PyHardLinkBackup/cli_dev/testing.py +52 -0
  14. PyHardLinkBackup/cli_dev/update_readme_history.py +33 -0
  15. PyHardLinkBackup/compare_backup.py +259 -0
  16. PyHardLinkBackup/constants.py +18 -0
  17. PyHardLinkBackup/logging_setup.py +124 -0
  18. PyHardLinkBackup/rebuild_databases.py +217 -0
  19. PyHardLinkBackup/tests/__init__.py +36 -0
  20. PyHardLinkBackup/tests/test_backup.py +1167 -0
  21. PyHardLinkBackup/tests/test_compare_backup.py +167 -0
  22. PyHardLinkBackup/tests/test_doc_write.py +26 -0
  23. PyHardLinkBackup/tests/test_doctests.py +10 -0
  24. PyHardLinkBackup/tests/test_project_setup.py +46 -0
  25. PyHardLinkBackup/tests/test_readme.py +75 -0
  26. PyHardLinkBackup/tests/test_readme_history.py +9 -0
  27. PyHardLinkBackup/tests/test_rebuild_database.py +266 -0
  28. PyHardLinkBackup/utilities/__init__.py +0 -0
  29. PyHardLinkBackup/utilities/file_hash_database.py +62 -0
  30. PyHardLinkBackup/utilities/file_size_database.py +46 -0
  31. PyHardLinkBackup/utilities/filesystem.py +257 -0
  32. PyHardLinkBackup/utilities/humanize.py +39 -0
  33. PyHardLinkBackup/utilities/rich_utils.py +237 -0
  34. PyHardLinkBackup/utilities/sha256sums.py +61 -0
  35. PyHardLinkBackup/utilities/tee.py +40 -0
  36. PyHardLinkBackup/utilities/tests/__init__.py +0 -0
  37. PyHardLinkBackup/utilities/tests/test_file_hash_database.py +153 -0
  38. PyHardLinkBackup/utilities/tests/test_file_size_database.py +151 -0
  39. PyHardLinkBackup/utilities/tests/test_filesystem.py +167 -0
  40. PyHardLinkBackup/utilities/tests/unittest_utilities.py +78 -0
  41. PyHardLinkBackup/utilities/tyro_cli_shared_args.py +29 -0
  42. pyhardlinkbackup-1.8.1.dist-info/METADATA +700 -0
  43. pyhardlinkbackup-1.8.1.dist-info/RECORD +45 -0
  44. pyhardlinkbackup-1.8.1.dist-info/WHEEL +4 -0
  45. pyhardlinkbackup-1.8.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,7 @@
1
+ """DocWrite: README.md # PyHardLinkBackup
2
+ HardLink/Deduplication Backups with Python
3
+ """
4
+
5
+ # See https://packaging.python.org/en/latest/specifications/version-specifiers/
6
+ __version__ = '1.8.1'
7
+ __author__ = 'Jens Diemer <PyHardLinkBackup@jensdiemer.de>'
@@ -0,0 +1,10 @@
1
+ """
2
+ Allow PyHardLinkBackup to be executable
3
+ through `python -m PyHardLinkBackup`.
4
+ """
5
+
6
+ from PyHardLinkBackup.cli_app import main
7
+
8
+
9
+ if __name__ == '__main__':
10
+ main()
@@ -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
+ )
@@ -0,0 +1,10 @@
1
+ """
2
+ Allow PyHardLinkBackup to be executable
3
+ through `python -m PyHardLinkBackup`.
4
+ """
5
+
6
+ from PyHardLinkBackup.cli_dev import main
7
+
8
+
9
+ if __name__ == '__main__':
10
+ main()