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 +15 -0
- bup-0.11.9/LICENSE.txt +15 -0
- bup-0.11.9/PKG-INFO +41 -0
- bup-0.11.9/README.md +7 -0
- bup-0.11.9/bup/LICENSE +15 -0
- bup-0.11.9/bup/__init__.py +11 -0
- bup-0.11.9/bup/__main__.py +18 -0
- bup-0.11.9/bup/__version__.py +9 -0
- bup-0.11.9/bup/arguments.py +53 -0
- bup-0.11.9/bup/backup_types.py +7 -0
- bup-0.11.9/bup/bup.ico +0 -0
- bup-0.11.9/bup/bup.png +0 -0
- bup-0.11.9/bup/bup_base.py +64 -0
- bup-0.11.9/bup/cli/__init__.py +1 -0
- bup-0.11.9/bup/cli/cli_main.py +61 -0
- bup-0.11.9/bup/dynamodb_backup.py +74 -0
- bup-0.11.9/bup/github_backup.py +146 -0
- bup-0.11.9/bup/gpl-3.0.md +675 -0
- bup-0.11.9/bup/gui/__init__.py +6 -0
- bup-0.11.9/bup/gui/about.py +54 -0
- bup-0.11.9/bup/gui/bup_dialog.py +106 -0
- bup-0.11.9/bup/gui/gui_icon.py +24 -0
- bup-0.11.9/bup/gui/gui_main.py +31 -0
- bup-0.11.9/bup/gui/preferences_widget.py +196 -0
- bup-0.11.9/bup/gui/run_backup_widget.py +191 -0
- bup-0.11.9/bup/preferences.py +68 -0
- bup-0.11.9/bup/print_log.py +10 -0
- bup-0.11.9/bup/robust_os_calls.py +77 -0
- bup-0.11.9/bup/s3_backup.py +172 -0
- bup-0.11.9/bup/ui_types.py +6 -0
- bup-0.11.9/bup.egg-info/PKG-INFO +41 -0
- bup-0.11.9/bup.egg-info/SOURCES.txt +36 -0
- bup-0.11.9/bup.egg-info/dependency_links.txt +1 -0
- bup-0.11.9/bup.egg-info/entry_points.txt +2 -0
- bup-0.11.9/bup.egg-info/requires.txt +19 -0
- bup-0.11.9/bup.egg-info/top_level.txt +1 -0
- bup-0.11.9/pyproject.toml +67 -0
- bup-0.11.9/setup.cfg +4 -0
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
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
|
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
|