FileBackup 0.1.1__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,77 @@
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(
33
+ "The regular expression '{}' is not valid ({}).".format(value, ex)
34
+ )
35
+
36
+ return expressions
37
+
38
+
39
+ # ----------------------------------------------------------------------
40
+ input_filename_or_dirs_argument = typer.Argument(
41
+ ..., exists=True, resolve_path=True, help="Input filename or directory."
42
+ )
43
+
44
+ ssd_option = typer.Option(
45
+ "--ssd",
46
+ help="Processes tasks in parallel to leverage the capabilities of solid-state-drives.",
47
+ )
48
+ ssd_option_default = False
49
+
50
+ quiet_option = typer.Option("--quiet", help="Reduce the amount of information displayed.")
51
+ quiet_option_default = False
52
+
53
+ force_option = typer.Option(
54
+ "--force",
55
+ help="Ignore previous backup information and overwrite all data in the destination data store.",
56
+ )
57
+ force_option_default = False
58
+
59
+ verbose_option = typer.Option("--verbose", help="Write verbose information to the terminal.")
60
+ verbose_option_default = False
61
+
62
+ debug_option = typer.Option("--debug", help="Write debug information to the terminal.")
63
+ debug_option_default = False
64
+
65
+ file_include_option = typer.Option(
66
+ "--file-include",
67
+ callback=ToRegex,
68
+ help="Regular expression (based on a posix path) used to include files and/or directories when preserving content.",
69
+ )
70
+ file_include_option_default: list[str] = []
71
+
72
+ file_exclude_option = typer.Option(
73
+ "--file-exclude",
74
+ callback=ToRegex,
75
+ help="Regular expression (based on a posix path) used to exclude files and/or directories when preserving content.",
76
+ )
77
+ file_exclude_option_default: list[str] = []
@@ -12,7 +12,8 @@ import typer
12
12
 
13
13
  from typer.core import TyperGroup # type: ignore [import-untyped]
14
14
 
15
- from FileBackup import Math, __version__
15
+ from FileBackup import __version__
16
+ from FileBackup.CommandLine import MirrorEntryPoint
16
17
 
17
18
 
18
19
  # ----------------------------------------------------------------------
@@ -33,56 +34,12 @@ app = typer.Typer(
33
34
  )
34
35
 
35
36
 
36
- # ----------------------------------------------------------------------
37
- @app.command("Add")
38
- def Add(
39
- x: int,
40
- y: int,
41
- ) -> None:
42
- """Adds 2 values."""
43
-
44
- sys.stdout.write(str(Math.Add(x, y)))
45
-
46
-
47
- # ----------------------------------------------------------------------
48
- @app.command("Sub")
49
- def Sub(
50
- x: int,
51
- y: int,
52
- ) -> None:
53
- """Subtracts 2 values."""
54
-
55
- sys.stdout.write(str(Math.Sub(x, y)))
56
-
57
-
58
- # ----------------------------------------------------------------------
59
- @app.command("Mult")
60
- def Mult(
61
- x: int,
62
- y: int,
63
- ) -> None:
64
- """Multiplies 2 values."""
65
-
66
- sys.stdout.write(str(Math.Mult(x, y)))
67
-
68
-
69
- # ----------------------------------------------------------------------
70
- @app.command("Div")
71
- def Div(
72
- x: int,
73
- y: int,
74
- ) -> None:
75
- """Divides 1 value by another."""
76
-
77
- sys.stdout.write(str(Math.Div(x, y)))
37
+ app.add_typer(MirrorEntryPoint.app, name="mirror", help=MirrorEntryPoint.__doc__)
78
38
 
79
39
 
80
- # ----------------------------------------------------------------------
81
- @app.command("Version")
82
- def Version() -> None:
83
- """Prints the version of the package."""
84
-
85
- sys.stdout.write(__version__)
40
+ @app.command("version", no_args_is_help=False)
41
+ def Version():
42
+ sys.stdout.write(f"FileBackup v{__version__}\n")
86
43
 
87
44
 
88
45
  # ----------------------------------------------------------------------
@@ -0,0 +1,194 @@
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()
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[
75
+ bool, CommandLineArguments.force_option
76
+ ] = CommandLineArguments.force_option_default,
77
+ verbose: Annotated[
78
+ bool, CommandLineArguments.verbose_option
79
+ ] = CommandLineArguments.verbose_option_default,
80
+ quiet: Annotated[
81
+ bool, CommandLineArguments.quiet_option
82
+ ] = CommandLineArguments.quiet_option_default,
83
+ debug: Annotated[
84
+ bool, CommandLineArguments.debug_option
85
+ ] = CommandLineArguments.debug_option_default,
86
+ file_include_params: Annotated[
87
+ list[str],
88
+ CommandLineArguments.file_include_option,
89
+ ] = CommandLineArguments.file_include_option_default,
90
+ file_exclude_params: Annotated[
91
+ list[str],
92
+ CommandLineArguments.file_exclude_option,
93
+ ] = CommandLineArguments.file_exclude_option_default,
94
+ ) -> None:
95
+ """Mirrors content to a backup data store."""
96
+
97
+ file_includes = cast(list[Pattern], file_include_params)
98
+ file_excludes = cast(list[Pattern], file_exclude_params)
99
+
100
+ del file_include_params
101
+ del file_exclude_params
102
+
103
+ with DoneManager.CreateCommandLine(
104
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
105
+ ) as dm:
106
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
107
+
108
+ Mirror.Backup(
109
+ dm,
110
+ destination,
111
+ input_filename_or_dirs,
112
+ ssd=ssd,
113
+ force=force,
114
+ quiet=quiet,
115
+ file_includes=file_includes,
116
+ file_excludes=file_excludes,
117
+ )
118
+
119
+
120
+ # ----------------------------------------------------------------------
121
+ @app.command(
122
+ "validate",
123
+ no_args_is_help=True,
124
+ epilog=textwrap.dedent(
125
+ """\
126
+ {}
127
+ Validation Types
128
+ ================
129
+ standard: Validates that files and directories at the destination exist and file sizes match the expected values.
130
+ complete: Validates that files and directories at the destination exist and file hashes match the expected values.
131
+ """,
132
+ )
133
+ .replace("\n", "\n\n")
134
+ .format(Common.GetDestinationHelp()),
135
+ )
136
+ def Validate(
137
+ destination: Annotated[str, _destination_argument],
138
+ validate_type: Annotated[
139
+ Mirror.ValidateType,
140
+ typer.Argument(
141
+ case_sensitive=False,
142
+ help="Specifies the type of validation to use; the the comments below for information on the different validation types.",
143
+ ),
144
+ ] = Mirror.ValidateType.standard,
145
+ ssd: Annotated[bool, CommandLineArguments.ssd_option] = CommandLineArguments.ssd_option_default,
146
+ verbose: Annotated[
147
+ bool, CommandLineArguments.verbose_option
148
+ ] = CommandLineArguments.verbose_option_default,
149
+ quiet: Annotated[
150
+ bool, CommandLineArguments.quiet_option
151
+ ] = CommandLineArguments.quiet_option_default,
152
+ debug: Annotated[
153
+ bool, CommandLineArguments.debug_option
154
+ ] = CommandLineArguments.debug_option_default,
155
+ ) -> None:
156
+ """Validates perviously mirrored content in the backup data store."""
157
+
158
+ with DoneManager.CreateCommandLine(
159
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
160
+ ) as dm:
161
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
162
+
163
+ Mirror.Validate(
164
+ dm,
165
+ destination,
166
+ validate_type,
167
+ ssd=ssd,
168
+ quiet=quiet,
169
+ )
170
+
171
+
172
+ # ----------------------------------------------------------------------
173
+ @app.command(
174
+ "cleanup",
175
+ epilog=Common.GetDestinationHelp(),
176
+ no_args_is_help=True,
177
+ )
178
+ def Cleanup(
179
+ destination: Annotated[str, _destination_argument],
180
+ verbose: Annotated[
181
+ bool, CommandLineArguments.verbose_option
182
+ ] = CommandLineArguments.verbose_option_default,
183
+ debug: Annotated[
184
+ bool, CommandLineArguments.debug_option
185
+ ] = CommandLineArguments.debug_option_default,
186
+ ) -> None:
187
+ """Cleans a backup data store after a mirror execution that was interrupted or failed."""
188
+
189
+ with DoneManager.CreateCommandLine(
190
+ flags=DoneManagerFlags.Create(verbose=verbose, debug=debug),
191
+ ) as dm:
192
+ dm.WriteVerbose(str(datetime.datetime.now()) + "\n\n")
193
+
194
+ Mirror.Cleanup(dm, destination)
File without changes
@@ -0,0 +1,79 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | FastGlacierDataStore.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-06-10 14:32:10
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+ """Contains the FastGlacierDataStore object"""
15
+
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from dbrownell_Common.Streams.DoneManager import DoneManager # type: ignore[import-untyped]
20
+ from dbrownell_Common import SubprocessEx # type: ignore[import-untyped]
21
+ from dbrownell_Common.Types import override # type: ignore[import-untyped]
22
+
23
+ from FileBackup.DataStore.Interfaces.BulkStorageDataStore import BulkStorageDataStore
24
+
25
+
26
+ # ----------------------------------------------------------------------
27
+ class FastGlacierDataStore(BulkStorageDataStore):
28
+ """Data store that uses the Fast Glacier application (https://fastglacier.com/)"""
29
+
30
+ # ----------------------------------------------------------------------
31
+ def __init__(
32
+ self,
33
+ account_name: str,
34
+ aws_region: str,
35
+ glacier_dir: Optional[Path],
36
+ ):
37
+ super(FastGlacierDataStore, self).__init__()
38
+
39
+ self.account_name = account_name
40
+ self.aws_region = aws_region
41
+
42
+ self._glacier_dir = glacier_dir or Path()
43
+
44
+ self._validated_command_line = False
45
+
46
+ # ----------------------------------------------------------------------
47
+ @override
48
+ def ExecuteInParallel(self) -> bool:
49
+ return False
50
+
51
+ # ----------------------------------------------------------------------
52
+ @override
53
+ def Upload(
54
+ self,
55
+ dm: DoneManager,
56
+ local_path: Path,
57
+ ) -> None:
58
+ if self._validated_command_line is False:
59
+ with dm.Nested(
60
+ "Validating Fast Glacier on the command line...",
61
+ suffix="\n",
62
+ ) as check_dm:
63
+ result = SubprocessEx.Run("glacier-con --version")
64
+
65
+ check_dm.WriteVerbose(result.output)
66
+
67
+ if result.returncode != 0 and "glacier-con.exe upload" not in result.output:
68
+ check_dm.WriteError(
69
+ "Fast Glacier is not available; please make sure it exists in the path and run the script again.\n"
70
+ )
71
+ return
72
+
73
+ with dm.Nested("Uploading to Fast Glacier...") as upload_dm:
74
+ command_line = f'glacier-con upload "{self.account_name}" "{local_path / "*"}" "{self.aws_region}" "{self._glacier_dir.as_posix()}"'
75
+
76
+ upload_dm.WriteVerbose(f"Command Line: {command_line}\n\n")
77
+
78
+ with upload_dm.YieldStream() as stream:
79
+ upload_dm.result = SubprocessEx.Stream(command_line, stream)
@@ -0,0 +1,218 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | FileSystemDataStore.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-06-10 13:42:04
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+ """Contains the FileSystemDataStore object"""
15
+
16
+ import itertools
17
+ import os
18
+ import shutil
19
+
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+ from typing import Generator, Optional
23
+
24
+ from dbrownell_Common import PathEx # type: ignore[import-untyped]
25
+ from dbrownell_Common.Types import override # type: ignore[import-untyped]
26
+
27
+ from FileBackup.DataStore.Interfaces.FileBasedDataStore import FileBasedDataStore, ItemType
28
+
29
+
30
+ # ----------------------------------------------------------------------
31
+ class FileSystemDataStore(FileBasedDataStore):
32
+ """DataStore associated with a standard, local file system"""
33
+
34
+ # ----------------------------------------------------------------------
35
+ def __init__(
36
+ self,
37
+ root: Path = Path.cwd(),
38
+ *,
39
+ ssd: bool = False,
40
+ is_local_filesystem_override_value_for_testing: Optional[bool] = None,
41
+ ) -> None:
42
+ assert not root.exists() or root.is_dir(), root
43
+
44
+ super(FileSystemDataStore, self).__init__(
45
+ is_local_filesystem=(
46
+ is_local_filesystem_override_value_for_testing
47
+ if is_local_filesystem_override_value_for_testing is not None
48
+ else root.root == Path.cwd().root
49
+ ),
50
+ )
51
+
52
+ self._working_dir: Path = root
53
+ self._ssd = ssd
54
+
55
+ # ----------------------------------------------------------------------
56
+ @override
57
+ def ExecuteInParallel(self) -> bool:
58
+ return self._ssd
59
+
60
+ # ----------------------------------------------------------------------
61
+ @override
62
+ def ValidateBackupInputs(
63
+ self,
64
+ input_filename_or_dirs: list[Path],
65
+ ) -> None:
66
+ for input_filename_or_dir in input_filename_or_dirs:
67
+ if input_filename_or_dir.is_file():
68
+ input_dir = input_filename_or_dir.parent
69
+ elif input_filename_or_dir.is_dir():
70
+ input_dir = input_filename_or_dir
71
+ else:
72
+ raise Exception(f"'{input_filename_or_dir}' is not a supported item type.")
73
+
74
+ if PathEx.IsDescendant(self._working_dir, input_dir):
75
+ raise Exception(
76
+ f"The directory '{input_filename_or_dir}' overlaps with the destination path '{self._working_dir}'."
77
+ )
78
+
79
+ # ----------------------------------------------------------------------
80
+ @override
81
+ def SnapshotFilenameToDestinationName(
82
+ self,
83
+ path: Path,
84
+ ) -> Path:
85
+ if path.parts[0] == "/":
86
+ path = Path(*path.parts[1:])
87
+ elif path.parts[0]:
88
+ # Probably on Windows
89
+ path = Path(path.parts[0].replace(":", "_").rstrip("\\")) / Path(*path.parts[1:])
90
+
91
+ return self.GetWorkingDir() / path
92
+
93
+ # ----------------------------------------------------------------------
94
+ @override
95
+ def GetBytesAvailable(self) -> Optional[int]:
96
+ # Find a directory that exists
97
+ for potential_dir in itertools.chain(
98
+ [self._working_dir],
99
+ self._working_dir.parents,
100
+ [Path.cwd()],
101
+ ):
102
+ if potential_dir.is_dir():
103
+ return shutil.disk_usage(potential_dir).free
104
+
105
+ assert False, self._working_dir
106
+
107
+ # ----------------------------------------------------------------------
108
+ @override
109
+ def GetWorkingDir(self) -> Path:
110
+ return self._working_dir
111
+
112
+ # ----------------------------------------------------------------------
113
+ @override
114
+ def SetWorkingDir(
115
+ self,
116
+ path: Path,
117
+ ) -> None:
118
+ self._working_dir /= path
119
+
120
+ # ----------------------------------------------------------------------
121
+ @override
122
+ def GetItemType(
123
+ self,
124
+ path: Path,
125
+ ) -> Optional[ItemType]:
126
+ path = self._working_dir / path
127
+
128
+ if not path.exists():
129
+ return None
130
+
131
+ if path.is_symlink():
132
+ return ItemType.SymLink
133
+
134
+ if path.is_file():
135
+ return ItemType.File
136
+
137
+ if path.is_dir():
138
+ return ItemType.Dir
139
+
140
+ raise Exception(f"'{path}' is not a known type.")
141
+
142
+ # ----------------------------------------------------------------------
143
+ @override
144
+ def GetFileSize(
145
+ self,
146
+ path: Path,
147
+ ) -> int:
148
+ return (self._working_dir / path).stat().st_size
149
+
150
+ # ----------------------------------------------------------------------
151
+ @override
152
+ def RemoveDir(
153
+ self,
154
+ path: Path,
155
+ ) -> None:
156
+ shutil.rmtree(self._working_dir / path)
157
+
158
+ # ----------------------------------------------------------------------
159
+ @override
160
+ def RemoveFile(
161
+ self,
162
+ path: Path,
163
+ ) -> None:
164
+ (self._working_dir / path).unlink()
165
+
166
+ # ----------------------------------------------------------------------
167
+ @override
168
+ def MakeDirs(
169
+ self,
170
+ path: Path,
171
+ ) -> None:
172
+ (self._working_dir / path).mkdir(parents=True, exist_ok=True)
173
+
174
+ # ----------------------------------------------------------------------
175
+ @override
176
+ @contextmanager
177
+ def Open(
178
+ self,
179
+ filename: Path,
180
+ *args,
181
+ **kwargs,
182
+ ):
183
+ with (self._working_dir / filename).open(*args, **kwargs) as f:
184
+ yield f
185
+
186
+ # ----------------------------------------------------------------------
187
+ @override
188
+ def Rename(
189
+ self,
190
+ old_path: Path,
191
+ new_path: Path,
192
+ ) -> None:
193
+ old_path = self._working_dir / old_path
194
+ new_path = self._working_dir / new_path
195
+
196
+ if new_path.is_file():
197
+ new_path.unlink()
198
+ elif new_path.is_dir():
199
+ shutil.rmtree(new_path)
200
+
201
+ shutil.move(old_path, new_path)
202
+
203
+ # ----------------------------------------------------------------------
204
+ @override
205
+ def Walk(
206
+ self,
207
+ path: Path = Path(),
208
+ ) -> Generator[
209
+ tuple[
210
+ Path, # root
211
+ list[str], # directories
212
+ list[str], # files
213
+ ],
214
+ None,
215
+ None,
216
+ ]:
217
+ for root, directories, filenames in os.walk(self._working_dir / path):
218
+ yield Path(root), directories, filenames
@@ -0,0 +1,36 @@
1
+ # ----------------------------------------------------------------------
2
+ # |
3
+ # | BulkStorageDataStore.py
4
+ # |
5
+ # | David Brownell <db@DavidBrownell.com>
6
+ # | 2024-06-10 13:33:45
7
+ # |
8
+ # ----------------------------------------------------------------------
9
+ # |
10
+ # | Copyright David Brownell 2024
11
+ # | Distributed under the MIT License.
12
+ # |
13
+ # ----------------------------------------------------------------------
14
+ """Contains the BulkStorageDataStore object"""
15
+
16
+ from abc import abstractmethod
17
+ from pathlib import Path
18
+
19
+ from dbrownell_Common.Streams.DoneManager import DoneManager # type: ignore[import-untyped]
20
+
21
+ from FileBackup.DataStore.Interfaces.DataStore import DataStore # type: ignore[import-untyped]
22
+
23
+
24
+ # ----------------------------------------------------------------------
25
+ class BulkStorageDataStore(DataStore):
26
+ """Abstraction for data stores that can upload content in bulk but not easily retrieve it (such as cloud storage)"""
27
+
28
+ # ----------------------------------------------------------------------
29
+ @abstractmethod
30
+ def Upload(
31
+ self,
32
+ dm: DoneManager,
33
+ local_path: Path,
34
+ ) -> None:
35
+ """Uploads all content in the provided path and its children"""
36
+ raise Exception("Abstract method") # pragma: no cover