FileBackup 0.2.1__py3-none-any.whl → 0.3.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.
@@ -4,7 +4,7 @@
4
4
  # | Distributed under the MIT License.
5
5
  # |
6
6
  # ----------------------------------------------------------------------
7
- """This file serves as an example of how to create scripts that can be invoked from the command line once the package is installed."""
7
+ """Tools to backup and restore files and directories."""
8
8
 
9
9
  import sys
10
10
 
@@ -14,6 +14,7 @@ from typer.core import TyperGroup # type: ignore [import-untyped]
14
14
 
15
15
  from FileBackup import __version__
16
16
  from FileBackup.CommandLine import MirrorEntryPoint
17
+ from FileBackup.CommandLine import OffsiteEntryPoint
17
18
 
18
19
 
19
20
  # ----------------------------------------------------------------------
@@ -21,7 +22,7 @@ class NaturalOrderGrouper(TyperGroup):
21
22
  # pylint: disable=missing-class-docstring
22
23
  # ----------------------------------------------------------------------
23
24
  def list_commands(self, *args, **kwargs): # pylint: disable=unused-argument
24
- return self.commands.keys()
25
+ return self.commands.keys() # pragma: no cover
25
26
 
26
27
 
27
28
  # ----------------------------------------------------------------------
@@ -35,10 +36,13 @@ app = typer.Typer(
35
36
 
36
37
 
37
38
  app.add_typer(MirrorEntryPoint.app, name="mirror", help=MirrorEntryPoint.__doc__)
39
+ app.add_typer(OffsiteEntryPoint.app, name="offsite", help=OffsiteEntryPoint.__doc__)
38
40
 
39
41
 
40
42
  @app.command("version", no_args_is_help=False)
41
43
  def Version():
44
+ """Displays the current version and exits."""
45
+
42
46
  sys.stdout.write(f"FileBackup v{__version__}\n")
43
47
 
44
48
 
@@ -38,7 +38,7 @@ class NaturalOrderGrouper(TyperGroup):
38
38
  # pylint: disable=missing-class-docstring
39
39
  # ----------------------------------------------------------------------
40
40
  def list_commands(self, *args, **kwargs): # pylint: disable=unused-argument
41
- return self.commands.keys()
41
+ return self.commands.keys() # pragma: no cover
42
42
 
43
43
 
44
44
  # ----------------------------------------------------------------------
@@ -153,7 +153,7 @@ def Validate(
153
153
  bool, CommandLineArguments.debug_option
154
154
  ] = CommandLineArguments.debug_option_default,
155
155
  ) -> None:
156
- """Validates perviously mirrored content in the backup data store."""
156
+ """Validates previously mirrored content in the backup data store."""
157
157
 
158
158
  with DoneManager.CreateCommandLine(
159
159
  flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
@@ -0,0 +1,335 @@
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[
96
+ bool, CommandLineArguments.force_option
97
+ ] = CommandLineArguments.force_option_default,
98
+ verbose: Annotated[
99
+ bool, CommandLineArguments.verbose_option
100
+ ] = CommandLineArguments.verbose_option_default,
101
+ quiet: Annotated[
102
+ bool, CommandLineArguments.quiet_option
103
+ ] = CommandLineArguments.quiet_option_default,
104
+ debug: Annotated[
105
+ bool, CommandLineArguments.debug_option
106
+ ] = CommandLineArguments.debug_option_default,
107
+ working_dir: Annotated[
108
+ Optional[Path],
109
+ typer.Option(
110
+ "--working-dir",
111
+ file_okay=False,
112
+ resolve_path=True,
113
+ help="Local directory used to stage files prior to transferring them to the destination data store.",
114
+ ),
115
+ ] = None,
116
+ archive_volume_size: Annotated[
117
+ int,
118
+ typer.Option(
119
+ "--archive-volume-size",
120
+ min=1024,
121
+ 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.",
122
+ ),
123
+ ] = Offsite.DEFAULT_ARCHIVE_VOLUME_SIZE,
124
+ ignore_pending_snapshot: Annotated[
125
+ bool,
126
+ typer.Option(
127
+ "--ignore-pending-snapshot", help="Disable the pending warning snapshot and continue."
128
+ ),
129
+ ] = False,
130
+ file_include_params: Annotated[
131
+ list[str],
132
+ CommandLineArguments.file_include_option,
133
+ ] = CommandLineArguments.file_include_option_default,
134
+ file_exclude_params: Annotated[
135
+ list[str],
136
+ CommandLineArguments.file_exclude_option,
137
+ ] = CommandLineArguments.file_exclude_option_default,
138
+ ) -> None:
139
+ """Prepares local changes for offsite backup."""
140
+
141
+ file_includes = cast(list[Pattern], file_include_params)
142
+ file_excludes = cast(list[Pattern], file_exclude_params)
143
+
144
+ del file_include_params
145
+ del file_exclude_params
146
+
147
+ with DoneManager.CreateCommandLine(
148
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
149
+ ) as dm:
150
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
151
+
152
+ destination_value = None if destination.lower() == "none" else destination
153
+
154
+ with _ResolveWorkingDir(
155
+ dm,
156
+ working_dir,
157
+ always_preserve=destination_value is None,
158
+ ) as resolved_working_dir:
159
+ Offsite.Backup(
160
+ dm,
161
+ backup_name,
162
+ destination_value,
163
+ input_filename_or_dirs,
164
+ encryption_password,
165
+ resolved_working_dir,
166
+ compress=compress,
167
+ ssd=ssd,
168
+ force=force,
169
+ quiet=quiet,
170
+ file_includes=file_includes,
171
+ file_excludes=file_excludes,
172
+ archive_volume_size=archive_volume_size,
173
+ ignore_pending_snapshot=ignore_pending_snapshot,
174
+ )
175
+
176
+
177
+ # ----------------------------------------------------------------------
178
+ @app.command("commit", no_args_is_help=True)
179
+ def Commit(
180
+ backup_name: Annotated[str, _backup_name_argument],
181
+ verbose: Annotated[
182
+ bool, CommandLineArguments.verbose_option
183
+ ] = CommandLineArguments.verbose_option_default,
184
+ debug: Annotated[
185
+ bool, CommandLineArguments.debug_option
186
+ ] = CommandLineArguments.debug_option_default,
187
+ ) -> None:
188
+ """Commits a pending snapshot after the changes have been transferred to an offsite data store."""
189
+
190
+ with DoneManager.CreateCommandLine(
191
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
192
+ ) as dm:
193
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
194
+
195
+ Offsite.Commit(dm, backup_name)
196
+
197
+
198
+ # ----------------------------------------------------------------------
199
+ @app.command(
200
+ "restore",
201
+ epilog=Common.GetDestinationHelp(),
202
+ no_args_is_help=True,
203
+ )
204
+ def Restore( # pylint: disable=dangerous-default-value
205
+ backup_name: Annotated[str, _backup_name_argument],
206
+ backup_source: Annotated[
207
+ str, typer.Argument(help="Data store location containing content that has been backed up.")
208
+ ],
209
+ encryption_password: Annotated[
210
+ Optional[str],
211
+ typer.Option(
212
+ "--encryption-password",
213
+ help="Password used when creating the backups.",
214
+ ),
215
+ ] = None,
216
+ dir_substitution_key_value_args: Annotated[
217
+ list[str],
218
+ TyperEx.TyperDictOption(
219
+ {},
220
+ "--dir-substitution",
221
+ allow_any__=True,
222
+ 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.',
223
+ ),
224
+ ] = [],
225
+ dry_run: Annotated[
226
+ bool,
227
+ typer.Option(
228
+ "--dry-run",
229
+ help="Show the changes that would be made during the restoration process, but do not modify the local file system.",
230
+ ),
231
+ ] = False,
232
+ overwrite: Annotated[
233
+ bool,
234
+ typer.Option(
235
+ "--overwrite",
236
+ 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.",
237
+ ),
238
+ ] = False,
239
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
240
+ verbose: Annotated[
241
+ bool, CommandLineArguments.verbose_option
242
+ ] = CommandLineArguments.verbose_option_default,
243
+ quiet: Annotated[
244
+ bool, CommandLineArguments.quiet_option
245
+ ] = CommandLineArguments.quiet_option_default,
246
+ debug: Annotated[
247
+ bool, CommandLineArguments.debug_option
248
+ ] = CommandLineArguments.debug_option_default,
249
+ working_dir: Annotated[
250
+ Optional[Path],
251
+ typer.Option(
252
+ "--working-dir",
253
+ file_okay=False,
254
+ resolve_path=True,
255
+ 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.",
256
+ ),
257
+ ] = None,
258
+ ) -> None:
259
+ """Restores content from an offsite data store."""
260
+
261
+ with DoneManager.CreateCommandLine(
262
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
263
+ ) as dm:
264
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
265
+
266
+ dir_substitutions = TyperEx.PostprocessDictArgument(dir_substitution_key_value_args)
267
+
268
+ with _ResolveWorkingDir(dm, working_dir) as resolved_working_dir:
269
+ Offsite.Restore(
270
+ dm,
271
+ backup_name,
272
+ backup_source,
273
+ encryption_password,
274
+ resolved_working_dir,
275
+ dir_substitutions,
276
+ ssd=ssd,
277
+ quiet=quiet,
278
+ dry_run=dry_run,
279
+ overwrite=overwrite,
280
+ )
281
+
282
+
283
+ # ----------------------------------------------------------------------
284
+ # ----------------------------------------------------------------------
285
+ # ----------------------------------------------------------------------
286
+ @contextmanager
287
+ def _ResolveWorkingDir(
288
+ dm: DoneManager,
289
+ working_dir: Path | None,
290
+ *,
291
+ always_preserve: bool = False,
292
+ ) -> Iterator[Path]:
293
+ if working_dir is None:
294
+ delete_dir = not always_preserve
295
+ working_dir = PathEx.CreateTempDirectory()
296
+ else:
297
+ delete_dir = False
298
+
299
+ was_successful = True
300
+
301
+ try:
302
+ assert working_dir is not None
303
+ yield working_dir
304
+
305
+ except:
306
+ was_successful = False
307
+ raise
308
+
309
+ finally:
310
+ assert working_dir is not None
311
+
312
+ if delete_dir:
313
+ was_successful = was_successful and dm.result == 0
314
+
315
+ if was_successful:
316
+ shutil.rmtree(working_dir)
317
+ else:
318
+ if dm.result <= 0:
319
+ # dm.result can be 0 if an exception was raised
320
+ type_desc = "errors"
321
+ elif dm.result > 0:
322
+ type_desc = "warnings"
323
+ else:
324
+ assert False, dm.result # pragma: no cover
325
+
326
+ dm.WriteInfo(
327
+ f"The temporary directory '{working_dir}' was preserved due to {type_desc}.\n"
328
+ )
329
+
330
+
331
+ # ----------------------------------------------------------------------
332
+ # ----------------------------------------------------------------------
333
+ # ----------------------------------------------------------------------
334
+ if __name__ == "__main__":
335
+ app()
FileBackup/Impl/Common.py CHANGED
@@ -358,8 +358,7 @@ def YieldDataStore(
358
358
  ),
359
359
  )
360
360
 
361
- is_local_filesystem_override_value_for_testing = True
362
- destination = destination[len("[nonlocal]") :]
361
+ is_local_filesystem_override_value_for_testing = False
363
362
 
364
363
  yield FileSystemDataStore(
365
364
  Path(destination),