filebackup 0.5.3__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.
@@ -0,0 +1,75 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | CommandLineArguments.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-06-12 13:20:05
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+
15
+ import re
16
+
17
+ from typing import Pattern
18
+
19
+ import typer
20
+
21
+
22
+ # ----------------------------------------------------------------------
23
+ def ToRegex(
24
+ values: list[str],
25
+ ) -> list[Pattern]:
26
+ expressions: list[Pattern] = []
27
+
28
+ for value in values:
29
+ try:
30
+ expressions.append(re.compile("^{}$".format(value)))
31
+ except re.error as ex:
32
+ raise typer.BadParameter("The regular expression '{}' is not valid ({}).".format(value, ex))
33
+
34
+ return expressions
35
+
36
+
37
+ # ----------------------------------------------------------------------
38
+ input_filename_or_dirs_argument = typer.Argument(
39
+ ..., exists=True, resolve_path=True, help="Input filename or directory."
40
+ )
41
+
42
+ ssd_option = typer.Option(
43
+ "--ssd",
44
+ help="Processes tasks in parallel to leverage the capabilities of solid-state-drives.",
45
+ )
46
+ ssd_option_default = False
47
+
48
+ quiet_option = typer.Option("--quiet", help="Reduce the amount of information displayed.")
49
+ quiet_option_default = False
50
+
51
+ force_option = typer.Option(
52
+ "--force",
53
+ help="Ignore previous backup information and overwrite all data in the destination data store.",
54
+ )
55
+ force_option_default = False
56
+
57
+ verbose_option = typer.Option("--verbose", help="Write verbose information to the terminal.")
58
+ verbose_option_default = False
59
+
60
+ debug_option = typer.Option("--debug", help="Write debug information to the terminal.")
61
+ debug_option_default = False
62
+
63
+ file_include_option = typer.Option(
64
+ "--file-include",
65
+ callback=ToRegex,
66
+ help="Regular expression (based on a posix path) used to include files and/or directories when preserving content.",
67
+ )
68
+ file_include_option_default: list[str] = []
69
+
70
+ file_exclude_option = typer.Option(
71
+ "--file-exclude",
72
+ callback=ToRegex,
73
+ help="Regular expression (based on a posix path) used to exclude files and/or directories when preserving content.",
74
+ )
75
+ file_exclude_option_default: list[str] = []
@@ -0,0 +1,53 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | Copyright (c) 2024 David Brownell
4
+ # | Distributed under the MIT License.
5
+ # |
6
+ # ----------------------------------------------------------------------
7
+ """Tools to backup and restore files and directories."""
8
+
9
+ import sys
10
+
11
+ import typer
12
+
13
+ from typer.core import TyperGroup # type: ignore [import-untyped]
14
+
15
+ from FileBackup import __version__
16
+ from FileBackup.CommandLine import MirrorEntryPoint
17
+ from FileBackup.CommandLine import OffsiteEntryPoint
18
+
19
+
20
+ # ----------------------------------------------------------------------
21
+ class NaturalOrderGrouper(TyperGroup):
22
+ # pylint: disable=missing-class-docstring
23
+ # ----------------------------------------------------------------------
24
+ def list_commands(self, *args, **kwargs): # pylint: disable=unused-argument
25
+ return self.commands.keys() # pragma: no cover
26
+
27
+
28
+ # ----------------------------------------------------------------------
29
+ app = typer.Typer(
30
+ cls=NaturalOrderGrouper,
31
+ help=__doc__,
32
+ no_args_is_help=True,
33
+ pretty_exceptions_show_locals=False,
34
+ pretty_exceptions_enable=False,
35
+ )
36
+
37
+
38
+ app.add_typer(MirrorEntryPoint.app, name="mirror", help=MirrorEntryPoint.__doc__)
39
+ app.add_typer(OffsiteEntryPoint.app, name="offsite", help=OffsiteEntryPoint.__doc__)
40
+
41
+
42
+ @app.command("version", no_args_is_help=False)
43
+ def Version():
44
+ """Displays the current version and exits."""
45
+
46
+ sys.stdout.write(f"FileBackup v{__version__}\n")
47
+
48
+
49
+ # ----------------------------------------------------------------------
50
+ # ----------------------------------------------------------------------
51
+ # ----------------------------------------------------------------------
52
+ if __name__ == "__main__":
53
+ app() # pragma: no cover
@@ -0,0 +1,182 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | MirrorEntryPoint.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-06-12 13:16:52
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+ """\
15
+ Mirrors backup content: files created locally will be added to the backup data store; files deleted
16
+ locally will be removed from the backup data store; files modified locally will be modified at the
17
+ backup data store.
18
+ """
19
+
20
+ import datetime
21
+ import textwrap
22
+
23
+ from pathlib import Path
24
+ from typing import Annotated, cast, Pattern
25
+
26
+ import typer
27
+
28
+ from dbrownell_Common.Streams.DoneManager import DoneManager, Flags as DoneManagerFlags # type: ignore [import-untyped]
29
+ from typer.core import TyperGroup # type: ignore [import-untyped]
30
+
31
+ from FileBackup.CommandLine import CommandLineArguments
32
+ from FileBackup.Impl import Common
33
+ from FileBackup import Mirror
34
+
35
+
36
+ # ----------------------------------------------------------------------
37
+ class NaturalOrderGrouper(TyperGroup):
38
+ # pylint: disable=missing-class-docstring
39
+ # ----------------------------------------------------------------------
40
+ def list_commands(self, *args, **kwargs): # pylint: disable=unused-argument
41
+ return self.commands.keys() # pragma: no cover
42
+
43
+
44
+ # ----------------------------------------------------------------------
45
+ app = typer.Typer(
46
+ cls=NaturalOrderGrouper,
47
+ help=__doc__,
48
+ no_args_is_help=True,
49
+ pretty_exceptions_show_locals=False,
50
+ pretty_exceptions_enable=False,
51
+ )
52
+
53
+
54
+ # ----------------------------------------------------------------------
55
+ _destination_argument = typer.Argument(
56
+ ...,
57
+ help="Destination data store used when mirroring local content; see the comments below for information on the different data store destination formats.",
58
+ )
59
+
60
+
61
+ # ----------------------------------------------------------------------
62
+ @app.command(
63
+ "execute",
64
+ epilog=Common.GetDestinationHelp(),
65
+ no_args_is_help=True,
66
+ )
67
+ def Execute(
68
+ destination: Annotated[str, _destination_argument],
69
+ input_filename_or_dirs: Annotated[
70
+ list[Path],
71
+ CommandLineArguments.input_filename_or_dirs_argument,
72
+ ],
73
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
74
+ force: Annotated[bool, CommandLineArguments.force_option] = CommandLineArguments.force_option_default,
75
+ verbose: Annotated[
76
+ bool, CommandLineArguments.verbose_option
77
+ ] = CommandLineArguments.verbose_option_default,
78
+ quiet: Annotated[bool, CommandLineArguments.quiet_option] = CommandLineArguments.quiet_option_default,
79
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
80
+ file_include_params: Annotated[
81
+ list[str],
82
+ CommandLineArguments.file_include_option,
83
+ ] = CommandLineArguments.file_include_option_default,
84
+ file_exclude_params: Annotated[
85
+ list[str],
86
+ CommandLineArguments.file_exclude_option,
87
+ ] = CommandLineArguments.file_exclude_option_default,
88
+ ) -> None:
89
+ """Mirrors content to a backup data store."""
90
+
91
+ file_includes = cast(list[Pattern], file_include_params)
92
+ file_excludes = cast(list[Pattern], file_exclude_params)
93
+
94
+ del file_include_params
95
+ del file_exclude_params
96
+
97
+ with DoneManager.CreateCommandLine(
98
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
99
+ ) as dm:
100
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
101
+
102
+ Mirror.Backup(
103
+ dm,
104
+ destination,
105
+ input_filename_or_dirs,
106
+ ssd=ssd,
107
+ force=force,
108
+ quiet=quiet,
109
+ file_includes=file_includes,
110
+ file_excludes=file_excludes,
111
+ )
112
+
113
+
114
+ # ----------------------------------------------------------------------
115
+ @app.command(
116
+ "validate",
117
+ no_args_is_help=True,
118
+ epilog=textwrap.dedent(
119
+ """\
120
+ {}
121
+ Validation Types
122
+ ================
123
+ standard: Validates that files and directories at the destination exist and file sizes match the expected values.
124
+ complete: Validates that files and directories at the destination exist and file hashes match the expected values.
125
+ """,
126
+ )
127
+ .replace("\n", "\n\n")
128
+ .format(Common.GetDestinationHelp()),
129
+ )
130
+ def Validate(
131
+ destination: Annotated[str, _destination_argument],
132
+ validate_type: Annotated[
133
+ Mirror.ValidateType,
134
+ typer.Argument(
135
+ case_sensitive=False,
136
+ help="Specifies the type of validation to use; the the comments below for information on the different validation types.",
137
+ ),
138
+ ] = Mirror.ValidateType.standard,
139
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
140
+ verbose: Annotated[
141
+ bool, CommandLineArguments.verbose_option
142
+ ] = CommandLineArguments.verbose_option_default,
143
+ quiet: Annotated[bool, CommandLineArguments.quiet_option] = CommandLineArguments.quiet_option_default,
144
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
145
+ ) -> None:
146
+ """Validates previously mirrored content in the backup data store."""
147
+
148
+ with DoneManager.CreateCommandLine(
149
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
150
+ ) as dm:
151
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
152
+
153
+ Mirror.Validate(
154
+ dm,
155
+ destination,
156
+ validate_type,
157
+ ssd=ssd,
158
+ quiet=quiet,
159
+ )
160
+
161
+
162
+ # ----------------------------------------------------------------------
163
+ @app.command(
164
+ "cleanup",
165
+ epilog=Common.GetDestinationHelp(),
166
+ no_args_is_help=True,
167
+ )
168
+ def Cleanup(
169
+ destination: Annotated[str, _destination_argument],
170
+ verbose: Annotated[
171
+ bool, CommandLineArguments.verbose_option
172
+ ] = CommandLineArguments.verbose_option_default,
173
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
174
+ ) -> None:
175
+ """Cleans a backup data store after a mirror execution that was interrupted or failed."""
176
+
177
+ with DoneManager.CreateCommandLine(
178
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
179
+ ) as dm:
180
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
181
+
182
+ Mirror.Cleanup(dm, destination)
@@ -0,0 +1,326 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | OffsiteEntryPoint.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-07-04 12:51:52
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+ """\
15
+ Copies content to an offsite location: a snapshot is saved after the initial backup and
16
+ deltas are applied to that snapshot for subsequent backups.
17
+ """
18
+
19
+ import datetime
20
+ import shutil
21
+
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from typing import Annotated, cast, Iterator, Optional, Pattern
25
+
26
+ import typer
27
+
28
+ from dbrownell_Common import PathEx # type: ignore[import-untyped]
29
+ from dbrownell_Common.Streams.DoneManager import DoneManager, Flags as DoneManagerFlags # type: ignore[import-untyped]
30
+ from dbrownell_Common import TyperEx
31
+ from typer.core import TyperGroup
32
+
33
+ from FileBackup.CommandLine import CommandLineArguments
34
+ from FileBackup.Impl import Common
35
+ from FileBackup import Offsite
36
+
37
+
38
+ # ----------------------------------------------------------------------
39
+ class NaturalOrderGrouper(TyperGroup):
40
+ # pylint: disable=missing-class-docstring
41
+ # ----------------------------------------------------------------------
42
+ def list_commands(self, *args, **kwargs): # pylint: disable=unused-argument
43
+ return self.commands.keys() # pragma: no cover
44
+
45
+
46
+ # ----------------------------------------------------------------------
47
+ app = typer.Typer(
48
+ cls=NaturalOrderGrouper,
49
+ help=__doc__,
50
+ no_args_is_help=True,
51
+ pretty_exceptions_show_locals=False,
52
+ pretty_exceptions_enable=False,
53
+ )
54
+
55
+
56
+ # ----------------------------------------------------------------------
57
+ _backup_name_argument = typer.Argument(
58
+ ...,
59
+ help="Unique name of the backup; this value allows for multiple distinct backups on the same machine.",
60
+ )
61
+ _destination_argument = typer.Argument(
62
+ ...,
63
+ help="Destination data store used to backup content; This value can be 'None' if the backup content should be created locally but manually distributed to the data store (this can be helpful when initially creating backups that are hundreds of GB in size). See the comments below for information on the different data store destination formats.",
64
+ )
65
+
66
+
67
+ # ----------------------------------------------------------------------
68
+ @app.command(
69
+ "execute",
70
+ epilog=Common.GetDestinationHelp(),
71
+ no_args_is_help=True,
72
+ )
73
+ def Execute(
74
+ backup_name: Annotated[str, _backup_name_argument],
75
+ destination: Annotated[str, _destination_argument],
76
+ input_filename_or_dirs: Annotated[
77
+ list[Path],
78
+ CommandLineArguments.input_filename_or_dirs_argument,
79
+ ],
80
+ encryption_password: Annotated[
81
+ Optional[str],
82
+ typer.Option(
83
+ "--encryption-password",
84
+ help="Encrypt the contents for backup prior to transferring them to the destination data store.",
85
+ ),
86
+ ] = None,
87
+ compress: Annotated[
88
+ bool,
89
+ typer.Option(
90
+ "--compress",
91
+ help="Compress the contents to backup prior to transferring them to the destination data store.",
92
+ ),
93
+ ] = False,
94
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
95
+ force: Annotated[bool, CommandLineArguments.force_option] = CommandLineArguments.force_option_default,
96
+ verbose: Annotated[
97
+ bool, CommandLineArguments.verbose_option
98
+ ] = CommandLineArguments.verbose_option_default,
99
+ quiet: Annotated[bool, CommandLineArguments.quiet_option] = CommandLineArguments.quiet_option_default,
100
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
101
+ working_dir: Annotated[
102
+ Optional[Path],
103
+ typer.Option(
104
+ "--working-dir",
105
+ file_okay=False,
106
+ resolve_path=True,
107
+ help="Local directory used to stage files prior to transferring them to the destination data store.",
108
+ ),
109
+ ] = None,
110
+ archive_volume_size: Annotated[
111
+ int,
112
+ typer.Option(
113
+ "--archive-volume-size",
114
+ min=1024,
115
+ help="Compressed/encrypted data will be converted to volumes of this size for easier transmission to the data store; value expressed in terms of bytes.",
116
+ ),
117
+ ] = Offsite.DEFAULT_ARCHIVE_VOLUME_SIZE,
118
+ ignore_pending_snapshot: Annotated[
119
+ bool,
120
+ typer.Option("--ignore-pending-snapshot", help="Disable the pending warning snapshot and continue."),
121
+ ] = False,
122
+ file_include_params: Annotated[
123
+ list[str],
124
+ CommandLineArguments.file_include_option,
125
+ ] = CommandLineArguments.file_include_option_default,
126
+ file_exclude_params: Annotated[
127
+ list[str],
128
+ CommandLineArguments.file_exclude_option,
129
+ ] = CommandLineArguments.file_exclude_option_default,
130
+ ) -> None:
131
+ """Prepares local changes for offsite backup."""
132
+
133
+ file_includes = cast(list[Pattern], file_include_params)
134
+ file_excludes = cast(list[Pattern], file_exclude_params)
135
+
136
+ del file_include_params
137
+ del file_exclude_params
138
+
139
+ with DoneManager.CreateCommandLine(
140
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
141
+ ) as dm:
142
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
143
+
144
+ destination_value = None if destination.lower() == "none" else destination
145
+
146
+ with _ResolveWorkingDir(
147
+ dm,
148
+ working_dir,
149
+ always_preserve=destination_value is None,
150
+ ) as resolved_working_dir:
151
+ Offsite.Backup(
152
+ dm,
153
+ backup_name,
154
+ destination_value,
155
+ input_filename_or_dirs,
156
+ encryption_password,
157
+ resolved_working_dir,
158
+ compress=compress,
159
+ ssd=ssd,
160
+ force=force,
161
+ quiet=quiet,
162
+ file_includes=file_includes,
163
+ file_excludes=file_excludes,
164
+ archive_volume_size=archive_volume_size,
165
+ ignore_pending_snapshot=ignore_pending_snapshot,
166
+ )
167
+
168
+
169
+ # ----------------------------------------------------------------------
170
+ @app.command("commit", no_args_is_help=True)
171
+ def Commit(
172
+ backup_name: Annotated[str, _backup_name_argument],
173
+ verbose: Annotated[
174
+ bool, CommandLineArguments.verbose_option
175
+ ] = CommandLineArguments.verbose_option_default,
176
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
177
+ ) -> None:
178
+ """Commits a pending snapshot after the changes have been transferred to an offsite data store."""
179
+
180
+ with DoneManager.CreateCommandLine(
181
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
182
+ ) as dm:
183
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
184
+
185
+ Offsite.Commit(dm, backup_name)
186
+
187
+
188
+ # ----------------------------------------------------------------------
189
+ @app.command(
190
+ "restore",
191
+ epilog=Common.GetDestinationHelp(),
192
+ no_args_is_help=True,
193
+ )
194
+ def Restore( # pylint: disable=dangerous-default-value
195
+ backup_name: Annotated[str, _backup_name_argument],
196
+ backup_source: Annotated[
197
+ str, typer.Argument(help="Data store location containing content that has been backed up.")
198
+ ],
199
+ encryption_password: Annotated[
200
+ Optional[str],
201
+ typer.Option(
202
+ "--encryption-password",
203
+ help="Password used when creating the backups.",
204
+ ),
205
+ ] = None,
206
+ dir_substitution_key_value_args: Annotated[
207
+ list[str],
208
+ TyperEx.TyperDictOption(
209
+ {},
210
+ "--dir-substitution",
211
+ allow_any__=True,
212
+ help='A key-value-pair consisting of a string to replace and its replacement value within a posix string; this can be used when restoring to a location that is different from the location used to create the backup. Example: \'--dir-substitution "C\\:/=C\\:/Restore/" will cause files backed-up as "C:/Foo/Bar.txt" to be restored as "C:/Restore/Foo/Bar.txt". This value can be provided multiple times on the command line when supporting multiple substitutions.',
213
+ ),
214
+ ] = [],
215
+ dry_run: Annotated[
216
+ bool,
217
+ typer.Option(
218
+ "--dry-run",
219
+ help="Show the changes that would be made during the restoration process, but do not modify the local file system.",
220
+ ),
221
+ ] = False,
222
+ overwrite: Annotated[
223
+ bool,
224
+ typer.Option(
225
+ "--overwrite",
226
+ help="By default, the restoration process will not overwrite existing files on the local file system; this flag indicates that files should be overwritten as they are restored.",
227
+ ),
228
+ ] = False,
229
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
230
+ verbose: Annotated[
231
+ bool, CommandLineArguments.verbose_option
232
+ ] = CommandLineArguments.verbose_option_default,
233
+ quiet: Annotated[bool, CommandLineArguments.quiet_option] = CommandLineArguments.quiet_option_default,
234
+ debug: Annotated[bool, CommandLineArguments.debug_option] = CommandLineArguments.debug_option_default,
235
+ working_dir: Annotated[
236
+ Optional[Path],
237
+ typer.Option(
238
+ "--working-dir",
239
+ file_okay=False,
240
+ resolve_path=True,
241
+ help="Working directory to use when decompressing archives; provide this value during a dry run and subsequent execution to only download and extract the backup content once.",
242
+ ),
243
+ ] = None,
244
+ continue_on_errors: Annotated[
245
+ bool,
246
+ typer.Option(
247
+ "--continue-on-errors", help="Continue restoring files even if some files cannot be restored."
248
+ ),
249
+ ] = False,
250
+ ) -> None:
251
+ """Restores content from an offsite data store."""
252
+
253
+ with DoneManager.CreateCommandLine(
254
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
255
+ ) as dm:
256
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
257
+
258
+ dir_substitutions = TyperEx.PostprocessDictArgument(dir_substitution_key_value_args)
259
+
260
+ with _ResolveWorkingDir(dm, working_dir) as resolved_working_dir:
261
+ Offsite.Restore(
262
+ dm,
263
+ backup_name,
264
+ backup_source,
265
+ encryption_password,
266
+ resolved_working_dir,
267
+ dir_substitutions,
268
+ ssd=ssd,
269
+ quiet=quiet,
270
+ dry_run=dry_run,
271
+ overwrite=overwrite,
272
+ continue_on_errors=continue_on_errors,
273
+ )
274
+
275
+
276
+ # ----------------------------------------------------------------------
277
+ # ----------------------------------------------------------------------
278
+ # ----------------------------------------------------------------------
279
+ @contextmanager
280
+ def _ResolveWorkingDir(
281
+ dm: DoneManager,
282
+ working_dir: Path | None,
283
+ *,
284
+ always_preserve: bool = False,
285
+ ) -> Iterator[Path]:
286
+ if working_dir is None:
287
+ delete_dir = not always_preserve
288
+ working_dir = PathEx.CreateTempDirectory()
289
+ else:
290
+ delete_dir = False
291
+
292
+ was_successful = True
293
+
294
+ try:
295
+ assert working_dir is not None
296
+ yield working_dir
297
+
298
+ except:
299
+ was_successful = False
300
+ raise
301
+
302
+ finally:
303
+ assert working_dir is not None
304
+
305
+ if delete_dir:
306
+ was_successful = was_successful and dm.result == 0
307
+
308
+ if was_successful:
309
+ shutil.rmtree(working_dir)
310
+ else:
311
+ if dm.result <= 0:
312
+ # dm.result can be 0 if an exception was raised
313
+ type_desc = "errors"
314
+ elif dm.result > 0:
315
+ type_desc = "warnings"
316
+ else:
317
+ assert False, dm.result # pragma: no cover
318
+
319
+ dm.WriteInfo(f"The temporary directory '{working_dir}' was preserved due to {type_desc}.\n")
320
+
321
+
322
+ # ----------------------------------------------------------------------
323
+ # ----------------------------------------------------------------------
324
+ # ----------------------------------------------------------------------
325
+ if __name__ == "__main__":
326
+ app()
File without changes