bup 0.11.9__tar.gz

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.
bup-0.11.9/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ bup - AWS and github local backup
2
+ Copyright (C) 2020-2026 James Abel
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
bup-0.11.9/LICENSE.txt ADDED
@@ -0,0 +1,15 @@
1
+ bup - AWS and github local backup
2
+ Copyright (C) 2020-2026 James Abel
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
bup-0.11.9/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: bup
3
+ Version: 0.11.9
4
+ Summary: backup all your AWS S3 buckets and DynamoDB tables, and github repos to a backup directory/folder
5
+ Author-email: abel <j@abel.co>
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/jamesabel/bup
8
+ Project-URL: Download, https://github.com/jamesabel/bup
9
+ Keywords: aws,dynamodb,s3,github,backup
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ License-File: LICENSE.txt
14
+ Requires-Dist: ismain
15
+ Requires-Dist: balsa
16
+ Requires-Dist: boto3
17
+ Requires-Dist: typeguard
18
+ Requires-Dist: hashy
19
+ Requires-Dist: dictim
20
+ Requires-Dist: awsimple
21
+ Requires-Dist: pressenter2exit
22
+ Requires-Dist: awscli
23
+ Requires-Dist: GitPython
24
+ Requires-Dist: github3.py
25
+ Requires-Dist: sqlitedict
26
+ Requires-Dist: tobool
27
+ Requires-Dist: attrs
28
+ Requires-Dist: appdirs
29
+ Requires-Dist: pref
30
+ Requires-Dist: pyqt5
31
+ Requires-Dist: python-dotenv
32
+ Requires-Dist: sentry-sdk
33
+ Dynamic: license-file
34
+
35
+ # bup
36
+
37
+ backup for github repos and AWS S3 and DynamoDB
38
+
39
+ # Acknowledgements
40
+
41
+ <div>Icons made by <a href="https://www.freepik.com" title="Freepik">Freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a></div>
bup-0.11.9/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # bup
2
+
3
+ backup for github repos and AWS S3 and DynamoDB
4
+
5
+ # Acknowledgements
6
+
7
+ <div>Icons made by <a href="https://www.freepik.com" title="Freepik">Freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a></div>
bup-0.11.9/bup/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ bup - AWS and github local backup
2
+ Copyright (C) 2020-2026 James Abel
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,11 @@
1
+ from .__version__ import __application_name__, __version__, __description__, __author__, __url__, __download_url__, __author_url__
2
+ from .robust_os_calls import mkdirs, rmdir
3
+ from .ui_types import UITypes
4
+ from .print_log import print_log
5
+ from .arguments import arguments
6
+ from .preferences import BupPreferences, get_preferences, ExclusionPreferences
7
+ from .backup_types import BackupTypes
8
+ from .bup_base import BupBase
9
+ from .s3_backup import S3Backup
10
+ from .dynamodb_backup import DynamoDBBackup
11
+ from .github_backup import GithubBackup
@@ -0,0 +1,18 @@
1
+ from ismain import is_main
2
+
3
+ from bup import arguments
4
+ from bup.cli import cli_main
5
+ from bup.gui import gui_main
6
+
7
+
8
+ def main():
9
+ args = arguments()
10
+ if args is None:
11
+ gui_main()
12
+ else:
13
+ # if we've not been given anything to do on the command line, then it must be CLI
14
+ cli_main(args)
15
+
16
+
17
+ if is_main():
18
+ main()
@@ -0,0 +1,9 @@
1
+ __version__ = "0.11.9"
2
+ __application_name__ = "bup"
3
+ __title__ = __application_name__
4
+ __author__ = "abel"
5
+ __author_email__ = "j@abel.co"
6
+ __author_url__ = "https://www.abel.co"
7
+ __url__ = "https://github.com/jamesabel/bup"
8
+ __download_url__ = __url__
9
+ __description__ = "backup all your AWS S3 buckets and DynamoDB tables, and github repos to a backup directory/folder"
@@ -0,0 +1,53 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ import sys
4
+
5
+ from balsa import verbose_arg_string, log_dir_arg_string, delete_existing_arg_string
6
+
7
+ from bup import __application_name__, __version__, __description__
8
+
9
+
10
+ def arguments():
11
+
12
+ version_string = "version"
13
+
14
+ if len(sys.argv) > 1 and sys.argv[1].lower() == f"--{version_string}":
15
+ print(__version__)
16
+ sys.exit()
17
+
18
+ if len(sys.argv) > 1:
19
+
20
+ parser = argparse.ArgumentParser(
21
+ prog=__application_name__,
22
+ description=__description__,
23
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
24
+ epilog=f"{__application_name__} V{__version__}, see github.com/jamesabel/bup for LICENSE",
25
+ )
26
+ parser.add_argument("path", help="directory to back up to")
27
+ parser.add_argument("-s", "--s3", action="store_true", default=False, help="backup AWS S3")
28
+ parser.add_argument("-d", "--dynamodb", action="store_true", default=False, help="backup AWS DynamoDB")
29
+ parser.add_argument("-a", "--aws", action="store_true", default=False, help="backup both AWS S3 and DynamoDB")
30
+ parser.add_argument("-g", "--github", action="store_true", default=False, help="backup github")
31
+ parser.add_argument(
32
+ "-e",
33
+ "--exclude",
34
+ nargs="*",
35
+ help="exclude these AWS S3 buckets and/or tables (only do one backup type at a time when setting exclusions - these values are saved and used on subsequent runs)",
36
+ )
37
+ parser.add_argument("-p", "--profile", help="AWS profile (uses the default AWS profile if not given)")
38
+ parser.add_argument("-r", "--region", help="AWS region (uses the default AWS region if not given)")
39
+ parser.add_argument("-t", "--token", help="github token (saved and used on subsequent runs)")
40
+ parser.add_argument("--dry_run", action="store_true", default=False, help="displays operations that would be performed using the specified command without actually running them")
41
+ parser.add_argument(f"--{version_string}", action="store_true", default=False, help="display version and exit")
42
+ parser.add_argument("-v", f"--{verbose_arg_string}", dest=verbose_arg_string, action="store_true", default=False, help="set verbose")
43
+ parser.add_argument("-l", f"--{log_dir_arg_string}", dest=log_dir_arg_string, help="log dir")
44
+ parser.add_argument(f"--{delete_existing_arg_string}", dest=delete_existing_arg_string, help="delete existing logs")
45
+ args = parser.parse_args()
46
+
47
+ if args.logdir is None and args.path is not None:
48
+ # put the logs in the backup dir unless explicitly given
49
+ args.logdir = Path(args.path, "log")
50
+ else:
51
+ args = None
52
+
53
+ return args
@@ -0,0 +1,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class BackupTypes(Enum):
5
+ S3 = 0
6
+ DynamoDB = 1
7
+ github = 2
bup-0.11.9/bup/bup.ico ADDED
Binary file
bup-0.11.9/bup/bup.png ADDED
Binary file
@@ -0,0 +1,64 @@
1
+ from typing import Callable
2
+
3
+ from PyQt5.QtCore import QThread, pyqtSignal
4
+ from typeguard import typechecked
5
+
6
+ from balsa import get_logger
7
+
8
+ from bup import __application_name__, UITypes
9
+
10
+ log = get_logger(__application_name__)
11
+
12
+
13
+ class BupBase(QThread):
14
+
15
+ backup_type = None
16
+ info_out_signal = pyqtSignal(str)
17
+ warning_out_signal = pyqtSignal(str)
18
+ error_out_signal = pyqtSignal(str)
19
+
20
+ @typechecked()
21
+ def __init__(self, ui_type: UITypes, info_out: Callable, warning_out: Callable, error_out: Callable):
22
+ super().__init__()
23
+ self._stop_requested = False
24
+ self.ui_type = ui_type
25
+ self.caller_info_out = info_out
26
+ self.caller_warning_out = warning_out
27
+ self.caller_error_out = error_out
28
+ self.info_out_signal.connect(self._info_out)
29
+ self.warning_out_signal.connect(self._warning_out)
30
+ self.error_out_signal.connect(self._error_out)
31
+
32
+ def request_stop(self):
33
+ self._stop_requested = True
34
+
35
+ @property
36
+ def stop_requested(self) -> bool:
37
+ return self._stop_requested
38
+
39
+ def info_out(self, s: str):
40
+ # callees could call emit(), but that seems awkward so provide this method
41
+ self.info_out_signal.emit(s)
42
+
43
+ def _info_out(self, s: str):
44
+ # hooked up to the signal for threading
45
+ log.info(s)
46
+ self.caller_info_out(s)
47
+
48
+ def warning_out(self, s: str):
49
+ # callees could call emit(), but that seems awkward so provide this method
50
+ self.warning_out_signal.emit(s)
51
+
52
+ def _warning_out(self, s: str):
53
+ # hooked up to the signal for threading
54
+ log.warning(s)
55
+ self.caller_warning_out(s)
56
+
57
+ def error_out(self, s: str):
58
+ # callees could call emit(), but that seems awkward so provide this method
59
+ self.error_out_signal.emit(s)
60
+
61
+ def _error_out(self, s: str):
62
+ # hooked up to the signal for threading
63
+ log.error(s)
64
+ self.caller_error_out(s)
@@ -0,0 +1 @@
1
+ from .cli_main import cli_main
@@ -0,0 +1,61 @@
1
+ from balsa import Balsa, get_logger
2
+
3
+ from bup import __application_name__, __author__, __version__, S3Backup, DynamoDBBackup, GithubBackup, get_preferences, UITypes, ExclusionPreferences, BackupTypes
4
+
5
+ log = get_logger(__application_name__)
6
+
7
+
8
+ def cli_main(args):
9
+
10
+ ui_type = UITypes.cli
11
+
12
+ balsa = Balsa(__application_name__, __author__)
13
+ balsa.log_console_prefix = "\r"
14
+ balsa.init_logger_from_args(args)
15
+ log.info(f"__application_name__={__application_name__}")
16
+ log.info(f"__author__={__author__}")
17
+ log.info(f"__version__={__version__}")
18
+
19
+ try:
20
+ preferences = get_preferences(ui_type)
21
+ preferences.backup_directory = args.path # backup classes will read the preferences DB directly
22
+ preferences.github_token = args.token
23
+ preferences.aws_profile = args.profile
24
+ preferences.dry_run = args.dry_run
25
+
26
+ # If setting the exclusions, just do it for one backup type at a time. The values are stored for subsequent runs.
27
+ if args.exclude is not None and len(args.exclude) > 0:
28
+ if args.s3:
29
+ ExclusionPreferences(BackupTypes.S3.name).set(args.exclude)
30
+ elif args.dynamodb:
31
+ ExclusionPreferences(BackupTypes.DynamoDB.name).set(args.exclude)
32
+ elif args.github:
33
+ ExclusionPreferences(BackupTypes.github.name).set(args.exclude)
34
+
35
+ did_something = False
36
+ dynamodb_local_backup = None
37
+ s3_local_backup = None
38
+ github_local_backup = None
39
+ if args.s3 or args.aws:
40
+ s3_local_backup = S3Backup(ui_type, log.info, log.warning, log.error)
41
+ s3_local_backup.start()
42
+ did_something = True
43
+ if args.dynamodb or args.aws:
44
+ dynamodb_local_backup = DynamoDBBackup(ui_type, log.info, log.warning, log.error)
45
+ dynamodb_local_backup.start()
46
+ did_something = True
47
+ if args.github:
48
+ github_local_backup = GithubBackup(ui_type, log.info, log.warning, log.error)
49
+ github_local_backup.start()
50
+ did_something = True
51
+ if not did_something:
52
+ print("nothing to do - please specify a backup to do or -h/--help for help")
53
+
54
+ if dynamodb_local_backup is not None:
55
+ dynamodb_local_backup.join()
56
+ if s3_local_backup is not None:
57
+ s3_local_backup.join()
58
+ if github_local_backup is not None:
59
+ github_local_backup.join()
60
+ except Exception as e:
61
+ log.exception(e)
@@ -0,0 +1,74 @@
1
+ import logging
2
+ import pickle
3
+ from datetime import timedelta
4
+ from pathlib import Path
5
+
6
+ from botocore.exceptions import ClientError
7
+ from awsimple import DynamoDBAccess, dynamodb_to_json
8
+ from balsa import get_logger
9
+
10
+ # boto3/botocore log responses at DEBUG level; the repr() of large responses can contain
11
+ # characters that cp1252 (Windows console default) cannot encode, causing noisy logging errors.
12
+ logging.getLogger("boto3").setLevel(logging.WARNING)
13
+ logging.getLogger("botocore").setLevel(logging.WARNING)
14
+
15
+ from bup import BupBase, BackupTypes, get_preferences, ExclusionPreferences, __application_name__
16
+
17
+ log = get_logger(__application_name__)
18
+
19
+
20
+ class DynamoDBBackup(BupBase):
21
+
22
+ backup_type = BackupTypes.DynamoDB
23
+
24
+ def run(self):
25
+ preferences = get_preferences(self.ui_type)
26
+ backup_directory = preferences.backup_directory
27
+ dry_run = preferences.dry_run
28
+ exclusions = ExclusionPreferences(self.backup_type.name).get_no_comments()
29
+
30
+ dynamodb_access = DynamoDBAccess(
31
+ profile_name=preferences.aws_profile or None,
32
+ aws_access_key_id=preferences.aws_access_key_id or None,
33
+ aws_secret_access_key=preferences.aws_secret_access_key or None,
34
+ region_name=preferences.aws_region or None,
35
+ )
36
+ try:
37
+ tables = dynamodb_access.get_table_names()
38
+ except ClientError as e:
39
+ log.warning(e)
40
+ tables = []
41
+ self.info_out(f"found {len(tables)} DynamoDB tables")
42
+ count = 0
43
+ for table_name in tables:
44
+ if self.stop_requested:
45
+ break
46
+
47
+ # awsimple will update immediately if number of table rows changes, but backup from scratch every so often to be safe
48
+ cache_life = timedelta(days=1).total_seconds()
49
+
50
+ if table_name in exclusions:
51
+ self.info_out(f"excluding {table_name}")
52
+ elif dry_run:
53
+ self.info_out(f"dry run {table_name}")
54
+ else:
55
+ self.info_out(f"{table_name}")
56
+ table = DynamoDBAccess(
57
+ table_name,
58
+ profile_name=preferences.aws_profile or None,
59
+ aws_access_key_id=preferences.aws_access_key_id or None,
60
+ aws_secret_access_key=preferences.aws_secret_access_key or None,
61
+ region_name=preferences.aws_region or None,
62
+ cache_life=cache_life,
63
+ )
64
+ table_contents = table.scan_table_cached()
65
+
66
+ dir_path = Path(backup_directory, "dynamodb")
67
+ dir_path.mkdir(parents=True, exist_ok=True)
68
+ with Path(dir_path, f"{table_name}.pickle").open("wb") as f:
69
+ pickle.dump(table_contents, f)
70
+ with Path(dir_path, f"{table_name}.json").open("w", encoding="utf-8") as f:
71
+ f.write(dynamodb_to_json(table_contents, indent=4))
72
+ count += 1
73
+
74
+ self.info_out(f"{len(tables)} tables, {count} backed up, {len(exclusions)} excluded")
@@ -0,0 +1,146 @@
1
+ from pathlib import Path
2
+ import shutil
3
+ import time
4
+
5
+ import github3
6
+ from github3.exceptions import AuthenticationFailed
7
+ from balsa import get_logger
8
+ from typeguard import typechecked
9
+
10
+ from bup import __application_name__, BupBase, BackupTypes, get_preferences, ExclusionPreferences, rmdir
11
+
12
+ log = get_logger(__application_name__)
13
+
14
+
15
+ class GithubBackup(BupBase):
16
+
17
+ backup_type = BackupTypes.github
18
+
19
+ def run(self):
20
+ if shutil.which("git") is None:
21
+ self.error_out("git executable not found in PATH - GitHub backup cannot run")
22
+ return
23
+
24
+ from git import Repo
25
+ from git.exc import GitCommandError
26
+
27
+ preferences = get_preferences(self.ui_type)
28
+ dry_run = preferences.dry_run
29
+ exclusions = ExclusionPreferences(BackupTypes.github.name).get_no_comments()
30
+
31
+ backup_dir = Path(preferences.backup_directory, "github")
32
+
33
+ try:
34
+ gh = github3.login(token=preferences.github_token)
35
+ if gh is None:
36
+ log.warning("could not login to github")
37
+ repositories = []
38
+ else:
39
+ repositories = list(gh.repositories()) # this actually throws the authentication error
40
+ except AuthenticationFailed:
41
+ log.error("github authentication failed")
42
+ return
43
+
44
+ clone_count = 0
45
+ pull_count = 0
46
+
47
+ for github_repo in repositories:
48
+ if self.stop_requested:
49
+ break
50
+
51
+ repo_owner_and_name = str(github_repo)
52
+ repo_name = repo_owner_and_name.split("/")[-1]
53
+ if any([e == repo_name for e in exclusions]):
54
+ self.info_out(f"{repo_owner_and_name} excluded")
55
+ elif dry_run:
56
+ self.info_out(f"dry run {repo_owner_and_name}")
57
+ else:
58
+ repo_dir = Path(backup_dir, repo_owner_and_name).absolute()
59
+ branches = list(github_repo.branches())
60
+
61
+ # if we've cloned previously, just do a pull
62
+ pull_success = False
63
+ if repo_dir.exists():
64
+ try:
65
+ if pull_success := self.pull_branches(repo_owner_and_name, branches, repo_dir):
66
+ pull_count += 1
67
+ except GitCommandError as e:
68
+ self.warning_out(f'could not pull "{repo_dir}" - will try to start over and do a clone of "{repo_owner_and_name},{e}","{__file__}"')
69
+
70
+ # new to us - clone the repo
71
+ if not pull_success:
72
+ try:
73
+ if repo_dir.exists():
74
+ rmdir(repo_dir)
75
+
76
+ if repo_dir.exists():
77
+ self.error_out(f'could not remove "{repo_dir}" - may require manual removal')
78
+ else:
79
+ self.info_out(f'git clone "{repo_owner_and_name}"')
80
+ clone_url = github_repo.clone_url
81
+ if preferences.github_token:
82
+ clone_url = clone_url.replace("https://", f"https://{preferences.github_token}@")
83
+ Repo.clone_from(clone_url, repo_dir)
84
+ time.sleep(1.0)
85
+ self.pull_branches(repo_owner_and_name, branches, repo_dir)
86
+ clone_count += 1
87
+ except PermissionError as e:
88
+ self.warning_out(f'{repo_owner_and_name},"{repo_dir}",{e},"{__file__}"')
89
+ except GitCommandError as e:
90
+ self.warning_out(f'{repo_owner_and_name},"{repo_dir}",{e},"{__file__}"')
91
+
92
+ self.info_out(f"{len(repositories)} repos, {pull_count} pulls, {clone_count} clones, {len(exclusions)} excluded")
93
+
94
+ @typechecked()
95
+ def pull_branches(self, repo_name: str, branches: list, repo_dir: Path) -> bool:
96
+ from git import Repo
97
+ from git.exc import GitCommandError, InvalidGitRepositoryError
98
+
99
+ try:
100
+ git_repo = Repo(repo_dir)
101
+ except InvalidGitRepositoryError as e:
102
+ self.error_out(f'InvalidGitRepositoryError: {repo_name},"{repo_dir}",{e},"{__file__}"')
103
+ git_repo = None
104
+
105
+ success = False
106
+ main_branch = None
107
+ if git_repo is not None:
108
+ for branch in branches:
109
+ if self.stop_requested:
110
+ break
111
+ branch_name = branch.name
112
+
113
+ # prefer "main" over "master"
114
+ if branch_name.lower() == "main" or (main_branch is None and branch_name.lower() == "master"):
115
+ main_branch = branch
116
+
117
+ self.info_out(f'git pull "{repo_name}" branch:"{branch_name}"')
118
+ try:
119
+ git_repo.git.reset("--hard")
120
+ git_repo.git.clean("-fd")
121
+ git_repo.git.checkout(branch_name)
122
+ git_repo.git.pull()
123
+ success = True
124
+ except GitCommandError as e:
125
+ if "did not match any file".lower() in str(e).lower():
126
+ # new branch with no files yet - skip it and continue with other branches
127
+ self.info_out(f"git pull {repo_name} branch:{branch_name} - no files")
128
+ continue
129
+ elif "would be overwritten by checkout" in str(e).lower():
130
+ # typically caused by CRLF line-ending normalization on Windows, not actual user edits
131
+ self.warning_out(f"{repo_name} branch:{branch_name} : checkout blocked by apparent local changes (likely line-ending normalization) : {e}")
132
+ success = False
133
+ break
134
+ else:
135
+ self.error_out(f"{repo_name} : {e}")
136
+ success = False
137
+ break
138
+
139
+ # if more than one branch, switch to main (or master) branch upon exit
140
+ if len(branches) > 1 and main_branch is not None:
141
+ main_branch_name = main_branch.name
142
+ self.info_out(f'git switch "{repo_name}" branch:"{main_branch_name}"')
143
+ git_repo.git.reset("--hard") # clear any phantom changes (e.g. CRLF normalization) before switching
144
+ git_repo.git.switch(main_branch_name)
145
+
146
+ return success