github-backup 0.59.0__tar.gz → 0.61.0__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.
- {github_backup-0.59.0 → github_backup-0.61.0}/CHANGES.rst +17 -1
- {github_backup-0.59.0/github_backup.egg-info → github_backup-0.61.0}/PKG-INFO +10 -7
- {github_backup-0.59.0 → github_backup-0.61.0}/README +9 -6
- {github_backup-0.59.0 → github_backup-0.61.0}/README.rst +9 -6
- github_backup-0.61.0/github_backup/__init__.py +1 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/github_backup.py +92 -24
- {github_backup-0.59.0 → github_backup-0.61.0/github_backup.egg-info}/PKG-INFO +10 -7
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_all_starred.py +1 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_http_451.py +31 -10
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_pagination.py +1 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_retrieve_data.py +195 -40
- github_backup-0.59.0/github_backup/__init__.py +0 -1
- {github_backup-0.59.0 → github_backup-0.61.0}/LICENSE.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/MANIFEST.in +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/__main__.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/cli.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/SOURCES.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/entry_points.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/release-requirements.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/requirements.txt +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/setup.cfg +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/setup.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_attachments.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_case_sensitivity.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_json_dump_if_changed.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_skip_assets_on.py +0 -0
- {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_starred_skip_size_over.py +0 -0
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
0.
|
|
4
|
+
0.61.0 (2026-01-12)
|
|
5
5
|
-------------------
|
|
6
6
|
------------------------
|
|
7
|
+
- Docs: Add missing `--retries` argument to README. [Lukas Bestle]
|
|
8
|
+
- Test: Adapt tests to new argument. [Lukas Bestle]
|
|
9
|
+
- Feat: Backup of repository security advisories. [Lukas Bestle]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
0.60.0 (2025-12-24)
|
|
13
|
+
-------------------
|
|
14
|
+
- Rm max_retries.py. [michaelmartinez]
|
|
15
|
+
- Readme. [michaelmartinez]
|
|
16
|
+
- Don't use a global variable, pass the args instead. [michaelmartinez]
|
|
17
|
+
- Readme, simplify the logic a bit. [michaelmartinez]
|
|
18
|
+
- Max_retries 5. [michaelmartinez]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
0.59.0 (2025-12-21)
|
|
22
|
+
-------------------
|
|
7
23
|
- Add --starred-skip-size-over flag to limit starred repo size (#108)
|
|
8
24
|
[Rodos]
|
|
9
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.61.0
|
|
4
4
|
Summary: backup a github user or organization
|
|
5
5
|
Home-page: http://github.com/josegonzalez/python-github-backup
|
|
6
6
|
Author: Jose Diaz-Gonzalez
|
|
@@ -73,9 +73,9 @@ CLI Help output::
|
|
|
73
73
|
[--watched] [--followers] [--following] [--all]
|
|
74
74
|
[--issues] [--issue-comments] [--issue-events] [--pulls]
|
|
75
75
|
[--pull-comments] [--pull-commits] [--pull-details]
|
|
76
|
-
[--labels] [--hooks] [--milestones] [--
|
|
77
|
-
[--bare] [--no-prune] [--lfs] [--wikis]
|
|
78
|
-
[--starred-gists] [--skip-archived] [--skip-existing]
|
|
76
|
+
[--labels] [--hooks] [--milestones] [--security-advisories]
|
|
77
|
+
[--repositories] [--bare] [--no-prune] [--lfs] [--wikis]
|
|
78
|
+
[--gists] [--starred-gists] [--skip-archived] [--skip-existing]
|
|
79
79
|
[-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
|
|
80
80
|
[-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
|
|
81
81
|
[--keychain-name OSX_KEYCHAIN_ITEM_NAME]
|
|
@@ -85,7 +85,7 @@ CLI Help output::
|
|
|
85
85
|
[--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
|
|
86
86
|
[--throttle-limit THROTTLE_LIMIT]
|
|
87
87
|
[--throttle-pause THROTTLE_PAUSE]
|
|
88
|
-
[--exclude [EXCLUDE ...]]
|
|
88
|
+
[--exclude [EXCLUDE ...]] [--retries MAX_RETRIES]
|
|
89
89
|
USER
|
|
90
90
|
|
|
91
91
|
Backup a github account
|
|
@@ -131,6 +131,8 @@ CLI Help output::
|
|
|
131
131
|
--hooks include hooks in backup (works only when
|
|
132
132
|
authenticated)
|
|
133
133
|
--milestones include milestones in backup
|
|
134
|
+
--security-advisories
|
|
135
|
+
include security advisories in backup
|
|
134
136
|
--repositories include repository clone in backup
|
|
135
137
|
--bare clone bare repositories
|
|
136
138
|
--no-prune disable prune option for git fetch
|
|
@@ -182,7 +184,8 @@ CLI Help output::
|
|
|
182
184
|
--throttle-limit to be set)
|
|
183
185
|
--exclude [EXCLUDE ...]
|
|
184
186
|
names of repositories to exclude
|
|
185
|
-
|
|
187
|
+
--retries MAX_RETRIES
|
|
188
|
+
maximum number of retries for API calls (default: 5)
|
|
186
189
|
|
|
187
190
|
Usage Details
|
|
188
191
|
=============
|
|
@@ -430,7 +433,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
|
|
|
430
433
|
export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
|
|
431
434
|
GH_USER=YOUR-GITHUB-USER
|
|
432
435
|
|
|
433
|
-
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
436
|
+
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --security-advisories --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
434
437
|
|
|
435
438
|
Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
|
|
436
439
|
|
|
@@ -43,9 +43,9 @@ CLI Help output::
|
|
|
43
43
|
[--watched] [--followers] [--following] [--all]
|
|
44
44
|
[--issues] [--issue-comments] [--issue-events] [--pulls]
|
|
45
45
|
[--pull-comments] [--pull-commits] [--pull-details]
|
|
46
|
-
[--labels] [--hooks] [--milestones] [--
|
|
47
|
-
[--bare] [--no-prune] [--lfs] [--wikis]
|
|
48
|
-
[--starred-gists] [--skip-archived] [--skip-existing]
|
|
46
|
+
[--labels] [--hooks] [--milestones] [--security-advisories]
|
|
47
|
+
[--repositories] [--bare] [--no-prune] [--lfs] [--wikis]
|
|
48
|
+
[--gists] [--starred-gists] [--skip-archived] [--skip-existing]
|
|
49
49
|
[-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
|
|
50
50
|
[-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
|
|
51
51
|
[--keychain-name OSX_KEYCHAIN_ITEM_NAME]
|
|
@@ -55,7 +55,7 @@ CLI Help output::
|
|
|
55
55
|
[--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
|
|
56
56
|
[--throttle-limit THROTTLE_LIMIT]
|
|
57
57
|
[--throttle-pause THROTTLE_PAUSE]
|
|
58
|
-
[--exclude [EXCLUDE ...]]
|
|
58
|
+
[--exclude [EXCLUDE ...]] [--retries MAX_RETRIES]
|
|
59
59
|
USER
|
|
60
60
|
|
|
61
61
|
Backup a github account
|
|
@@ -101,6 +101,8 @@ CLI Help output::
|
|
|
101
101
|
--hooks include hooks in backup (works only when
|
|
102
102
|
authenticated)
|
|
103
103
|
--milestones include milestones in backup
|
|
104
|
+
--security-advisories
|
|
105
|
+
include security advisories in backup
|
|
104
106
|
--repositories include repository clone in backup
|
|
105
107
|
--bare clone bare repositories
|
|
106
108
|
--no-prune disable prune option for git fetch
|
|
@@ -152,7 +154,8 @@ CLI Help output::
|
|
|
152
154
|
--throttle-limit to be set)
|
|
153
155
|
--exclude [EXCLUDE ...]
|
|
154
156
|
names of repositories to exclude
|
|
155
|
-
|
|
157
|
+
--retries MAX_RETRIES
|
|
158
|
+
maximum number of retries for API calls (default: 5)
|
|
156
159
|
|
|
157
160
|
Usage Details
|
|
158
161
|
=============
|
|
@@ -400,7 +403,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
|
|
|
400
403
|
export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
|
|
401
404
|
GH_USER=YOUR-GITHUB-USER
|
|
402
405
|
|
|
403
|
-
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
406
|
+
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --security-advisories --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
404
407
|
|
|
405
408
|
Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
|
|
406
409
|
|
|
@@ -43,9 +43,9 @@ CLI Help output::
|
|
|
43
43
|
[--watched] [--followers] [--following] [--all]
|
|
44
44
|
[--issues] [--issue-comments] [--issue-events] [--pulls]
|
|
45
45
|
[--pull-comments] [--pull-commits] [--pull-details]
|
|
46
|
-
[--labels] [--hooks] [--milestones] [--
|
|
47
|
-
[--bare] [--no-prune] [--lfs] [--wikis]
|
|
48
|
-
[--starred-gists] [--skip-archived] [--skip-existing]
|
|
46
|
+
[--labels] [--hooks] [--milestones] [--security-advisories]
|
|
47
|
+
[--repositories] [--bare] [--no-prune] [--lfs] [--wikis]
|
|
48
|
+
[--gists] [--starred-gists] [--skip-archived] [--skip-existing]
|
|
49
49
|
[-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
|
|
50
50
|
[-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
|
|
51
51
|
[--keychain-name OSX_KEYCHAIN_ITEM_NAME]
|
|
@@ -55,7 +55,7 @@ CLI Help output::
|
|
|
55
55
|
[--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
|
|
56
56
|
[--throttle-limit THROTTLE_LIMIT]
|
|
57
57
|
[--throttle-pause THROTTLE_PAUSE]
|
|
58
|
-
[--exclude [EXCLUDE ...]]
|
|
58
|
+
[--exclude [EXCLUDE ...]] [--retries MAX_RETRIES]
|
|
59
59
|
USER
|
|
60
60
|
|
|
61
61
|
Backup a github account
|
|
@@ -101,6 +101,8 @@ CLI Help output::
|
|
|
101
101
|
--hooks include hooks in backup (works only when
|
|
102
102
|
authenticated)
|
|
103
103
|
--milestones include milestones in backup
|
|
104
|
+
--security-advisories
|
|
105
|
+
include security advisories in backup
|
|
104
106
|
--repositories include repository clone in backup
|
|
105
107
|
--bare clone bare repositories
|
|
106
108
|
--no-prune disable prune option for git fetch
|
|
@@ -152,7 +154,8 @@ CLI Help output::
|
|
|
152
154
|
--throttle-limit to be set)
|
|
153
155
|
--exclude [EXCLUDE ...]
|
|
154
156
|
names of repositories to exclude
|
|
155
|
-
|
|
157
|
+
--retries MAX_RETRIES
|
|
158
|
+
maximum number of retries for API calls (default: 5)
|
|
156
159
|
|
|
157
160
|
Usage Details
|
|
158
161
|
=============
|
|
@@ -400,7 +403,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
|
|
|
400
403
|
export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
|
|
401
404
|
GH_USER=YOUR-GITHUB-USER
|
|
402
405
|
|
|
403
|
-
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
406
|
+
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --security-advisories --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
404
407
|
|
|
405
408
|
Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
|
|
406
409
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.61.0"
|
|
@@ -74,9 +74,6 @@ else:
|
|
|
74
74
|
" 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
|
|
75
75
|
)
|
|
76
76
|
|
|
77
|
-
# Retry configuration
|
|
78
|
-
MAX_RETRIES = 5
|
|
79
|
-
|
|
80
77
|
|
|
81
78
|
def logging_subprocess(
|
|
82
79
|
popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
|
|
@@ -144,6 +141,17 @@ def mask_password(url, secret="*****"):
|
|
|
144
141
|
return url.replace(parsed.password, secret)
|
|
145
142
|
|
|
146
143
|
|
|
144
|
+
def non_negative_int(value):
|
|
145
|
+
"""Argparse type validator for non-negative integers."""
|
|
146
|
+
try:
|
|
147
|
+
ivalue = int(value)
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise argparse.ArgumentTypeError(f"'{value}' is not a valid integer")
|
|
150
|
+
if ivalue < 0:
|
|
151
|
+
raise argparse.ArgumentTypeError(f"{value} must be 0 or greater")
|
|
152
|
+
return ivalue
|
|
153
|
+
|
|
154
|
+
|
|
147
155
|
def parse_args(args=None):
|
|
148
156
|
parser = argparse.ArgumentParser(description="Backup a github account")
|
|
149
157
|
parser.add_argument("user", metavar="USER", type=str, help="github username")
|
|
@@ -302,6 +310,12 @@ def parse_args(args=None):
|
|
|
302
310
|
dest="include_milestones",
|
|
303
311
|
help="include milestones in backup",
|
|
304
312
|
)
|
|
313
|
+
parser.add_argument(
|
|
314
|
+
"--security-advisories",
|
|
315
|
+
action="store_true",
|
|
316
|
+
dest="include_security_advisories",
|
|
317
|
+
help="include security advisories in backup",
|
|
318
|
+
)
|
|
305
319
|
parser.add_argument(
|
|
306
320
|
"--repositories",
|
|
307
321
|
action="store_true",
|
|
@@ -468,6 +482,13 @@ def parse_args(args=None):
|
|
|
468
482
|
parser.add_argument(
|
|
469
483
|
"--exclude", dest="exclude", help="names of repositories to exclude", nargs="*"
|
|
470
484
|
)
|
|
485
|
+
parser.add_argument(
|
|
486
|
+
"--retries",
|
|
487
|
+
dest="max_retries",
|
|
488
|
+
type=non_negative_int,
|
|
489
|
+
default=5,
|
|
490
|
+
help="maximum number of retries for API calls (default: 5)",
|
|
491
|
+
)
|
|
471
492
|
return parser.parse_args(args)
|
|
472
493
|
|
|
473
494
|
|
|
@@ -622,7 +643,7 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
622
643
|
def _extract_next_page_url(link_header):
|
|
623
644
|
for link in link_header.split(","):
|
|
624
645
|
if 'rel="next"' in link:
|
|
625
|
-
return link[link.find("<") + 1:link.find(">")]
|
|
646
|
+
return link[link.find("<") + 1 : link.find(">")]
|
|
626
647
|
return None
|
|
627
648
|
|
|
628
649
|
def fetch_all() -> Generator[dict, None, None]:
|
|
@@ -631,7 +652,7 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
631
652
|
while True:
|
|
632
653
|
# FIRST: Fetch response
|
|
633
654
|
|
|
634
|
-
for attempt in range(
|
|
655
|
+
for attempt in range(args.max_retries + 1):
|
|
635
656
|
request = _construct_request(
|
|
636
657
|
per_page=per_page if paginated else None,
|
|
637
658
|
query_args=query_args,
|
|
@@ -640,7 +661,7 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
640
661
|
as_app=args.as_app,
|
|
641
662
|
fine=args.token_fine is not None,
|
|
642
663
|
)
|
|
643
|
-
http_response = make_request_with_retry(request, auth)
|
|
664
|
+
http_response = make_request_with_retry(request, auth, args.max_retries)
|
|
644
665
|
|
|
645
666
|
match http_response.getcode():
|
|
646
667
|
case 200:
|
|
@@ -654,10 +675,10 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
654
675
|
TimeoutError,
|
|
655
676
|
) as e:
|
|
656
677
|
logger.warning(f"{type(e).__name__} reading response")
|
|
657
|
-
if attempt <
|
|
678
|
+
if attempt < args.max_retries:
|
|
658
679
|
delay = calculate_retry_delay(attempt, {})
|
|
659
680
|
logger.warning(
|
|
660
|
-
f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{
|
|
681
|
+
f"Retrying read in {delay:.1f}s (attempt {attempt + 1}/{args.max_retries + 1})"
|
|
661
682
|
)
|
|
662
683
|
time.sleep(delay)
|
|
663
684
|
continue # Next retry attempt
|
|
@@ -683,10 +704,10 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
683
704
|
)
|
|
684
705
|
else:
|
|
685
706
|
logger.error(
|
|
686
|
-
f"Failed to read response after {
|
|
707
|
+
f"Failed to read response after {args.max_retries + 1} attempts for {next_url or template}"
|
|
687
708
|
)
|
|
688
709
|
raise Exception(
|
|
689
|
-
f"Failed to read response after {
|
|
710
|
+
f"Failed to read response after {args.max_retries + 1} attempts for {next_url or template}"
|
|
690
711
|
)
|
|
691
712
|
|
|
692
713
|
# SECOND: Process and paginate
|
|
@@ -718,7 +739,7 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
718
739
|
return list(fetch_all())
|
|
719
740
|
|
|
720
741
|
|
|
721
|
-
def make_request_with_retry(request, auth):
|
|
742
|
+
def make_request_with_retry(request, auth, max_retries=5):
|
|
722
743
|
"""Make HTTP request with automatic retry for transient errors."""
|
|
723
744
|
|
|
724
745
|
def is_retryable_status(status_code, headers):
|
|
@@ -730,40 +751,49 @@ def make_request_with_retry(request, auth):
|
|
|
730
751
|
return int(headers.get("x-ratelimit-remaining", 1)) < 1
|
|
731
752
|
return False
|
|
732
753
|
|
|
733
|
-
for attempt in range(
|
|
754
|
+
for attempt in range(max_retries + 1):
|
|
734
755
|
try:
|
|
735
756
|
return urlopen(request, context=https_ctx)
|
|
736
757
|
|
|
737
758
|
except HTTPError as exc:
|
|
738
759
|
# HTTPError can be used as a response-like object
|
|
739
760
|
if not is_retryable_status(exc.code, exc.headers):
|
|
761
|
+
logger.error(
|
|
762
|
+
f"API Error: {exc.code} {exc.reason} for {request.full_url}"
|
|
763
|
+
)
|
|
740
764
|
raise # Non-retryable error
|
|
741
765
|
|
|
742
|
-
if attempt >=
|
|
743
|
-
logger.error(
|
|
766
|
+
if attempt >= max_retries:
|
|
767
|
+
logger.error(
|
|
768
|
+
f"HTTP {exc.code} failed after {max_retries + 1} attempts for {request.full_url}"
|
|
769
|
+
)
|
|
744
770
|
raise
|
|
745
771
|
|
|
746
772
|
delay = calculate_retry_delay(attempt, exc.headers)
|
|
747
773
|
logger.warning(
|
|
748
|
-
f"HTTP {exc.code}, retrying in {delay:.1f}s "
|
|
749
|
-
f"(attempt {attempt + 1}/{
|
|
774
|
+
f"HTTP {exc.code} ({exc.reason}), retrying in {delay:.1f}s "
|
|
775
|
+
f"(attempt {attempt + 1}/{max_retries + 1}) for {request.full_url}"
|
|
750
776
|
)
|
|
751
777
|
if auth is None and exc.code in (403, 429):
|
|
752
778
|
logger.info("Hint: Authenticate to raise your GitHub rate limit")
|
|
753
779
|
time.sleep(delay)
|
|
754
780
|
|
|
755
781
|
except (URLError, socket.error) as e:
|
|
756
|
-
if attempt >=
|
|
757
|
-
logger.error(
|
|
782
|
+
if attempt >= max_retries:
|
|
783
|
+
logger.error(
|
|
784
|
+
f"Connection error failed after {max_retries + 1} attempts: {e} for {request.full_url}"
|
|
785
|
+
)
|
|
758
786
|
raise
|
|
759
787
|
delay = calculate_retry_delay(attempt, {})
|
|
760
788
|
logger.warning(
|
|
761
789
|
f"Connection error: {e}, retrying in {delay:.1f}s "
|
|
762
|
-
f"(attempt {attempt + 1}/{
|
|
790
|
+
f"(attempt {attempt + 1}/{max_retries + 1}) for {request.full_url}"
|
|
763
791
|
)
|
|
764
792
|
time.sleep(delay)
|
|
765
793
|
|
|
766
|
-
raise Exception(
|
|
794
|
+
raise Exception(
|
|
795
|
+
f"Request failed after {max_retries + 1} attempts"
|
|
796
|
+
) # pragma: no cover
|
|
767
797
|
|
|
768
798
|
|
|
769
799
|
def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
|
|
@@ -1579,9 +1609,7 @@ def filter_repositories(args, unfiltered_repositories):
|
|
|
1579
1609
|
repositories = [r for r in repositories if not r.get("archived")]
|
|
1580
1610
|
if args.starred_skip_size_over is not None:
|
|
1581
1611
|
if args.starred_skip_size_over <= 0:
|
|
1582
|
-
logger.warning(
|
|
1583
|
-
"--starred-skip-size-over must be greater than 0, ignoring"
|
|
1584
|
-
)
|
|
1612
|
+
logger.warning("--starred-skip-size-over must be greater than 0, ignoring")
|
|
1585
1613
|
else:
|
|
1586
1614
|
size_limit_kb = args.starred_skip_size_over * 1024
|
|
1587
1615
|
filtered = []
|
|
@@ -1590,7 +1618,9 @@ def filter_repositories(args, unfiltered_repositories):
|
|
|
1590
1618
|
size_mb = r.get("size", 0) / 1024
|
|
1591
1619
|
logger.info(
|
|
1592
1620
|
"Skipping starred repo {0} ({1:.0f} MB) due to --starred-skip-size-over {2}".format(
|
|
1593
|
-
r.get("full_name", r.get("name")),
|
|
1621
|
+
r.get("full_name", r.get("name")),
|
|
1622
|
+
size_mb,
|
|
1623
|
+
args.starred_skip_size_over,
|
|
1594
1624
|
)
|
|
1595
1625
|
)
|
|
1596
1626
|
else:
|
|
@@ -1694,6 +1724,9 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1694
1724
|
if args.include_milestones or args.include_everything:
|
|
1695
1725
|
backup_milestones(args, repo_cwd, repository, repos_template)
|
|
1696
1726
|
|
|
1727
|
+
if args.include_security_advisories or args.include_everything:
|
|
1728
|
+
backup_security_advisories(args, repo_cwd, repository, repos_template)
|
|
1729
|
+
|
|
1697
1730
|
if args.include_labels or args.include_everything:
|
|
1698
1731
|
backup_labels(args, repo_cwd, repository, repos_template)
|
|
1699
1732
|
|
|
@@ -1910,6 +1943,41 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
|
|
|
1910
1943
|
)
|
|
1911
1944
|
|
|
1912
1945
|
|
|
1946
|
+
def backup_security_advisories(args, repo_cwd, repository, repos_template):
|
|
1947
|
+
advisory_cwd = os.path.join(repo_cwd, "security-advisories")
|
|
1948
|
+
if args.skip_existing and os.path.isdir(advisory_cwd):
|
|
1949
|
+
return
|
|
1950
|
+
|
|
1951
|
+
logger.info("Retrieving {0} security advisories".format(repository["full_name"]))
|
|
1952
|
+
mkdir_p(repo_cwd, advisory_cwd)
|
|
1953
|
+
|
|
1954
|
+
template = "{0}/{1}/security-advisories".format(repos_template, repository["full_name"])
|
|
1955
|
+
|
|
1956
|
+
_advisories = retrieve_data(args, template)
|
|
1957
|
+
|
|
1958
|
+
advisories = {}
|
|
1959
|
+
for advisory in _advisories:
|
|
1960
|
+
advisories[advisory["ghsa_id"]] = advisory
|
|
1961
|
+
|
|
1962
|
+
written_count = 0
|
|
1963
|
+
for ghsa_id, advisory in list(advisories.items()):
|
|
1964
|
+
advisory_file = "{0}/{1}.json".format(advisory_cwd, ghsa_id)
|
|
1965
|
+
if json_dump_if_changed(advisory, advisory_file):
|
|
1966
|
+
written_count += 1
|
|
1967
|
+
|
|
1968
|
+
total = len(advisories)
|
|
1969
|
+
if written_count == total:
|
|
1970
|
+
logger.info("Saved {0} security advisories to disk".format(total))
|
|
1971
|
+
elif written_count == 0:
|
|
1972
|
+
logger.info("{0} security advisories unchanged, skipped write".format(total))
|
|
1973
|
+
else:
|
|
1974
|
+
logger.info(
|
|
1975
|
+
"Saved {0} of {1} security advisories to disk ({2} unchanged)".format(
|
|
1976
|
+
written_count, total, total - written_count
|
|
1977
|
+
)
|
|
1978
|
+
)
|
|
1979
|
+
|
|
1980
|
+
|
|
1913
1981
|
def backup_labels(args, repo_cwd, repository, repos_template):
|
|
1914
1982
|
label_cwd = os.path.join(repo_cwd, "labels")
|
|
1915
1983
|
output_file = "{0}/labels.json".format(label_cwd)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.61.0
|
|
4
4
|
Summary: backup a github user or organization
|
|
5
5
|
Home-page: http://github.com/josegonzalez/python-github-backup
|
|
6
6
|
Author: Jose Diaz-Gonzalez
|
|
@@ -73,9 +73,9 @@ CLI Help output::
|
|
|
73
73
|
[--watched] [--followers] [--following] [--all]
|
|
74
74
|
[--issues] [--issue-comments] [--issue-events] [--pulls]
|
|
75
75
|
[--pull-comments] [--pull-commits] [--pull-details]
|
|
76
|
-
[--labels] [--hooks] [--milestones] [--
|
|
77
|
-
[--bare] [--no-prune] [--lfs] [--wikis]
|
|
78
|
-
[--starred-gists] [--skip-archived] [--skip-existing]
|
|
76
|
+
[--labels] [--hooks] [--milestones] [--security-advisories]
|
|
77
|
+
[--repositories] [--bare] [--no-prune] [--lfs] [--wikis]
|
|
78
|
+
[--gists] [--starred-gists] [--skip-archived] [--skip-existing]
|
|
79
79
|
[-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
|
|
80
80
|
[-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
|
|
81
81
|
[--keychain-name OSX_KEYCHAIN_ITEM_NAME]
|
|
@@ -85,7 +85,7 @@ CLI Help output::
|
|
|
85
85
|
[--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
|
|
86
86
|
[--throttle-limit THROTTLE_LIMIT]
|
|
87
87
|
[--throttle-pause THROTTLE_PAUSE]
|
|
88
|
-
[--exclude [EXCLUDE ...]]
|
|
88
|
+
[--exclude [EXCLUDE ...]] [--retries MAX_RETRIES]
|
|
89
89
|
USER
|
|
90
90
|
|
|
91
91
|
Backup a github account
|
|
@@ -131,6 +131,8 @@ CLI Help output::
|
|
|
131
131
|
--hooks include hooks in backup (works only when
|
|
132
132
|
authenticated)
|
|
133
133
|
--milestones include milestones in backup
|
|
134
|
+
--security-advisories
|
|
135
|
+
include security advisories in backup
|
|
134
136
|
--repositories include repository clone in backup
|
|
135
137
|
--bare clone bare repositories
|
|
136
138
|
--no-prune disable prune option for git fetch
|
|
@@ -182,7 +184,8 @@ CLI Help output::
|
|
|
182
184
|
--throttle-limit to be set)
|
|
183
185
|
--exclude [EXCLUDE ...]
|
|
184
186
|
names of repositories to exclude
|
|
185
|
-
|
|
187
|
+
--retries MAX_RETRIES
|
|
188
|
+
maximum number of retries for API calls (default: 5)
|
|
186
189
|
|
|
187
190
|
Usage Details
|
|
188
191
|
=============
|
|
@@ -430,7 +433,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
|
|
|
430
433
|
export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
|
|
431
434
|
GH_USER=YOUR-GITHUB-USER
|
|
432
435
|
|
|
433
|
-
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
436
|
+
github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --security-advisories --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
|
|
434
437
|
|
|
435
438
|
Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
|
|
436
439
|
|
|
@@ -37,6 +37,7 @@ class TestAllStarredCloning:
|
|
|
37
37
|
args.include_labels = False
|
|
38
38
|
args.include_hooks = False
|
|
39
39
|
args.include_milestones = False
|
|
40
|
+
args.include_security_advisories = False
|
|
40
41
|
args.include_releases = False
|
|
41
42
|
args.include_assets = False
|
|
42
43
|
args.include_attachments = False
|
|
@@ -21,6 +21,7 @@ class TestHTTP451Exception:
|
|
|
21
21
|
args.osx_keychain_item_account = None
|
|
22
22
|
args.throttle_limit = None
|
|
23
23
|
args.throttle_pause = 0
|
|
24
|
+
args.max_retries = 5
|
|
24
25
|
|
|
25
26
|
mock_response = Mock()
|
|
26
27
|
mock_response.getcode.return_value = 451
|
|
@@ -30,18 +31,26 @@ class TestHTTP451Exception:
|
|
|
30
31
|
"block": {
|
|
31
32
|
"reason": "dmca",
|
|
32
33
|
"created_at": "2024-11-12T14:38:04Z",
|
|
33
|
-
"html_url": "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
34
|
-
}
|
|
34
|
+
"html_url": "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md",
|
|
35
|
+
},
|
|
35
36
|
}
|
|
36
37
|
mock_response.read.return_value = json.dumps(dmca_data).encode("utf-8")
|
|
37
38
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
38
39
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
39
40
|
|
|
40
|
-
with patch(
|
|
41
|
+
with patch(
|
|
42
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
43
|
+
return_value=mock_response,
|
|
44
|
+
):
|
|
41
45
|
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
42
|
-
github_backup.retrieve_data(
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
github_backup.retrieve_data(
|
|
47
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert (
|
|
51
|
+
exc_info.value.dmca_url
|
|
52
|
+
== "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
53
|
+
)
|
|
45
54
|
assert "451" in str(exc_info.value)
|
|
46
55
|
|
|
47
56
|
def test_repository_unavailable_error_without_dmca_url(self):
|
|
@@ -54,6 +63,7 @@ class TestHTTP451Exception:
|
|
|
54
63
|
args.osx_keychain_item_account = None
|
|
55
64
|
args.throttle_limit = None
|
|
56
65
|
args.throttle_pause = 0
|
|
66
|
+
args.max_retries = 5
|
|
57
67
|
|
|
58
68
|
mock_response = Mock()
|
|
59
69
|
mock_response.getcode.return_value = 451
|
|
@@ -61,9 +71,14 @@ class TestHTTP451Exception:
|
|
|
61
71
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
62
72
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
63
73
|
|
|
64
|
-
with patch(
|
|
74
|
+
with patch(
|
|
75
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
76
|
+
return_value=mock_response,
|
|
77
|
+
):
|
|
65
78
|
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
66
|
-
github_backup.retrieve_data(
|
|
79
|
+
github_backup.retrieve_data(
|
|
80
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
81
|
+
)
|
|
67
82
|
|
|
68
83
|
assert exc_info.value.dmca_url is None
|
|
69
84
|
assert "451" in str(exc_info.value)
|
|
@@ -78,6 +93,7 @@ class TestHTTP451Exception:
|
|
|
78
93
|
args.osx_keychain_item_account = None
|
|
79
94
|
args.throttle_limit = None
|
|
80
95
|
args.throttle_pause = 0
|
|
96
|
+
args.max_retries = 5
|
|
81
97
|
|
|
82
98
|
mock_response = Mock()
|
|
83
99
|
mock_response.getcode.return_value = 451
|
|
@@ -85,9 +101,14 @@ class TestHTTP451Exception:
|
|
|
85
101
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
86
102
|
mock_response.reason = "Unavailable For Legal Reasons"
|
|
87
103
|
|
|
88
|
-
with patch(
|
|
104
|
+
with patch(
|
|
105
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
106
|
+
return_value=mock_response,
|
|
107
|
+
):
|
|
89
108
|
with pytest.raises(github_backup.RepositoryUnavailableError):
|
|
90
|
-
github_backup.retrieve_data(
|
|
109
|
+
github_backup.retrieve_data(
|
|
110
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
111
|
+
)
|
|
91
112
|
|
|
92
113
|
|
|
93
114
|
if __name__ == "__main__":
|
|
@@ -9,26 +9,27 @@ import pytest
|
|
|
9
9
|
|
|
10
10
|
from github_backup import github_backup
|
|
11
11
|
from github_backup.github_backup import (
|
|
12
|
-
MAX_RETRIES,
|
|
13
12
|
calculate_retry_delay,
|
|
14
13
|
make_request_with_retry,
|
|
15
14
|
)
|
|
16
15
|
|
|
16
|
+
# Default retry count used in tests (matches argparse default)
|
|
17
|
+
# With max_retries=5, total attempts = 6 (1 initial + 5 retries)
|
|
18
|
+
DEFAULT_MAX_RETRIES = 5
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
class TestCalculateRetryDelay:
|
|
19
22
|
def test_respects_retry_after_header(self):
|
|
20
|
-
headers = {
|
|
23
|
+
headers = {"retry-after": "30"}
|
|
21
24
|
assert calculate_retry_delay(0, headers) == 30
|
|
22
25
|
|
|
23
26
|
def test_respects_rate_limit_reset(self):
|
|
24
27
|
import time
|
|
25
28
|
import calendar
|
|
29
|
+
|
|
26
30
|
# Set reset time 60 seconds in the future
|
|
27
31
|
future_reset = calendar.timegm(time.gmtime()) + 60
|
|
28
|
-
headers = {
|
|
29
|
-
'x-ratelimit-remaining': '0',
|
|
30
|
-
'x-ratelimit-reset': str(future_reset)
|
|
31
|
-
}
|
|
32
|
+
headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": str(future_reset)}
|
|
32
33
|
delay = calculate_retry_delay(0, headers)
|
|
33
34
|
# Should be approximately 60 seconds (with some tolerance for execution time)
|
|
34
35
|
assert 55 <= delay <= 65
|
|
@@ -50,12 +51,10 @@ class TestCalculateRetryDelay:
|
|
|
50
51
|
def test_minimum_rate_limit_delay(self):
|
|
51
52
|
import time
|
|
52
53
|
import calendar
|
|
54
|
+
|
|
53
55
|
# Set reset time in the past (already reset)
|
|
54
56
|
past_reset = calendar.timegm(time.gmtime()) - 100
|
|
55
|
-
headers = {
|
|
56
|
-
'x-ratelimit-remaining': '0',
|
|
57
|
-
'x-ratelimit-reset': str(past_reset)
|
|
58
|
-
}
|
|
57
|
+
headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": str(past_reset)}
|
|
59
58
|
delay = calculate_retry_delay(0, headers)
|
|
60
59
|
# Should be minimum 10 seconds even if reset time is in past
|
|
61
60
|
assert delay >= 10
|
|
@@ -74,6 +73,7 @@ class TestRetrieveDataRetry:
|
|
|
74
73
|
args.osx_keychain_item_account = None
|
|
75
74
|
args.throttle_limit = None
|
|
76
75
|
args.throttle_pause = 0
|
|
76
|
+
args.max_retries = DEFAULT_MAX_RETRIES
|
|
77
77
|
return args
|
|
78
78
|
|
|
79
79
|
def test_json_parse_error_retries_and_fails(self, mock_args):
|
|
@@ -90,13 +90,22 @@ class TestRetrieveDataRetry:
|
|
|
90
90
|
call_count += 1
|
|
91
91
|
return mock_response
|
|
92
92
|
|
|
93
|
-
with patch(
|
|
94
|
-
|
|
93
|
+
with patch(
|
|
94
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
95
|
+
side_effect=mock_make_request,
|
|
96
|
+
):
|
|
97
|
+
with patch(
|
|
98
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
99
|
+
): # No delay in tests
|
|
95
100
|
with pytest.raises(Exception) as exc_info:
|
|
96
|
-
github_backup.retrieve_data(
|
|
101
|
+
github_backup.retrieve_data(
|
|
102
|
+
mock_args, "https://api.github.com/repos/test/repo/issues"
|
|
103
|
+
)
|
|
97
104
|
|
|
98
105
|
assert "Failed to read response after" in str(exc_info.value)
|
|
99
|
-
assert
|
|
106
|
+
assert (
|
|
107
|
+
call_count == DEFAULT_MAX_RETRIES + 1
|
|
108
|
+
) # 1 initial + 5 retries = 6 attempts
|
|
100
109
|
|
|
101
110
|
def test_json_parse_error_recovers_on_retry(self, mock_args):
|
|
102
111
|
"""HTTP 200 with invalid JSON should succeed if retry returns valid JSON."""
|
|
@@ -119,9 +128,16 @@ class TestRetrieveDataRetry:
|
|
|
119
128
|
call_count += 1
|
|
120
129
|
return result
|
|
121
130
|
|
|
122
|
-
with patch(
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
with patch(
|
|
132
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
133
|
+
side_effect=mock_make_request,
|
|
134
|
+
):
|
|
135
|
+
with patch(
|
|
136
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
137
|
+
):
|
|
138
|
+
result = github_backup.retrieve_data(
|
|
139
|
+
mock_args, "https://api.github.com/repos/test/repo/issues"
|
|
140
|
+
)
|
|
125
141
|
|
|
126
142
|
assert result == [{"id": 1}]
|
|
127
143
|
assert call_count == 3 # Failed twice, succeeded on third
|
|
@@ -134,11 +150,18 @@ class TestRetrieveDataRetry:
|
|
|
134
150
|
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
135
151
|
mock_response.reason = "Not Found"
|
|
136
152
|
|
|
137
|
-
with patch(
|
|
153
|
+
with patch(
|
|
154
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
155
|
+
return_value=mock_response,
|
|
156
|
+
):
|
|
138
157
|
with pytest.raises(Exception) as exc_info:
|
|
139
|
-
github_backup.retrieve_data(
|
|
158
|
+
github_backup.retrieve_data(
|
|
159
|
+
mock_args, "https://api.github.com/repos/test/notfound/issues"
|
|
160
|
+
)
|
|
140
161
|
|
|
141
|
-
assert not isinstance(
|
|
162
|
+
assert not isinstance(
|
|
163
|
+
exc_info.value, github_backup.RepositoryUnavailableError
|
|
164
|
+
)
|
|
142
165
|
assert "404" in str(exc_info.value)
|
|
143
166
|
|
|
144
167
|
|
|
@@ -151,7 +174,7 @@ class TestMakeRequestWithRetry:
|
|
|
151
174
|
good_response.read.return_value = b'{"ok": true}'
|
|
152
175
|
|
|
153
176
|
call_count = 0
|
|
154
|
-
fail_count =
|
|
177
|
+
fail_count = DEFAULT_MAX_RETRIES # Fail all retries, succeed on last attempt
|
|
155
178
|
|
|
156
179
|
def mock_urlopen(*args, **kwargs):
|
|
157
180
|
nonlocal call_count
|
|
@@ -167,14 +190,18 @@ class TestMakeRequestWithRetry:
|
|
|
167
190
|
return good_response
|
|
168
191
|
|
|
169
192
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
170
|
-
with patch(
|
|
193
|
+
with patch(
|
|
194
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
195
|
+
):
|
|
171
196
|
result = make_request_with_retry(Mock(), None)
|
|
172
197
|
|
|
173
198
|
assert result == good_response
|
|
174
|
-
assert
|
|
199
|
+
assert (
|
|
200
|
+
call_count == DEFAULT_MAX_RETRIES + 1
|
|
201
|
+
) # 1 initial + 5 retries = 6 attempts
|
|
175
202
|
|
|
176
203
|
def test_503_error_retries_until_exhausted(self):
|
|
177
|
-
"""HTTP 503 should retry
|
|
204
|
+
"""HTTP 503 should make 1 initial + DEFAULT_MAX_RETRIES retry attempts then raise."""
|
|
178
205
|
call_count = 0
|
|
179
206
|
|
|
180
207
|
def mock_urlopen(*args, **kwargs):
|
|
@@ -189,12 +216,16 @@ class TestMakeRequestWithRetry:
|
|
|
189
216
|
)
|
|
190
217
|
|
|
191
218
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
192
|
-
with patch(
|
|
219
|
+
with patch(
|
|
220
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
221
|
+
):
|
|
193
222
|
with pytest.raises(HTTPError) as exc_info:
|
|
194
223
|
make_request_with_retry(Mock(), None)
|
|
195
224
|
|
|
196
225
|
assert exc_info.value.code == 503
|
|
197
|
-
assert
|
|
226
|
+
assert (
|
|
227
|
+
call_count == DEFAULT_MAX_RETRIES + 1
|
|
228
|
+
) # 1 initial + 5 retries = 6 attempts
|
|
198
229
|
|
|
199
230
|
def test_404_error_not_retried(self):
|
|
200
231
|
"""HTTP 404 should not be retried - raise immediately."""
|
|
@@ -237,7 +268,9 @@ class TestMakeRequestWithRetry:
|
|
|
237
268
|
return good_response
|
|
238
269
|
|
|
239
270
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
240
|
-
with patch(
|
|
271
|
+
with patch(
|
|
272
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
273
|
+
):
|
|
241
274
|
result = make_request_with_retry(Mock(), None)
|
|
242
275
|
|
|
243
276
|
assert result == good_response
|
|
@@ -269,7 +302,7 @@ class TestMakeRequestWithRetry:
|
|
|
269
302
|
"""URLError (connection error) should retry and succeed if subsequent request works."""
|
|
270
303
|
good_response = Mock()
|
|
271
304
|
call_count = 0
|
|
272
|
-
fail_count =
|
|
305
|
+
fail_count = DEFAULT_MAX_RETRIES # Fail all retries, succeed on last attempt
|
|
273
306
|
|
|
274
307
|
def mock_urlopen(*args, **kwargs):
|
|
275
308
|
nonlocal call_count
|
|
@@ -279,14 +312,18 @@ class TestMakeRequestWithRetry:
|
|
|
279
312
|
return good_response
|
|
280
313
|
|
|
281
314
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
282
|
-
with patch(
|
|
315
|
+
with patch(
|
|
316
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
317
|
+
):
|
|
283
318
|
result = make_request_with_retry(Mock(), None)
|
|
284
319
|
|
|
285
320
|
assert result == good_response
|
|
286
|
-
assert
|
|
321
|
+
assert (
|
|
322
|
+
call_count == DEFAULT_MAX_RETRIES + 1
|
|
323
|
+
) # 1 initial + 5 retries = 6 attempts
|
|
287
324
|
|
|
288
325
|
def test_socket_error_retries_until_exhausted(self):
|
|
289
|
-
"""socket.error should retry
|
|
326
|
+
"""socket.error should make 1 initial + DEFAULT_MAX_RETRIES retry attempts then raise."""
|
|
290
327
|
call_count = 0
|
|
291
328
|
|
|
292
329
|
def mock_urlopen(*args, **kwargs):
|
|
@@ -295,11 +332,15 @@ class TestMakeRequestWithRetry:
|
|
|
295
332
|
raise socket.error("Connection reset by peer")
|
|
296
333
|
|
|
297
334
|
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
298
|
-
with patch(
|
|
335
|
+
with patch(
|
|
336
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
337
|
+
):
|
|
299
338
|
with pytest.raises(socket.error):
|
|
300
339
|
make_request_with_retry(Mock(), None)
|
|
301
340
|
|
|
302
|
-
assert
|
|
341
|
+
assert (
|
|
342
|
+
call_count == DEFAULT_MAX_RETRIES + 1
|
|
343
|
+
) # 1 initial + 5 retries = 6 attempts
|
|
303
344
|
|
|
304
345
|
|
|
305
346
|
class TestRetrieveDataThrottling:
|
|
@@ -315,6 +356,7 @@ class TestRetrieveDataThrottling:
|
|
|
315
356
|
args.osx_keychain_item_account = None
|
|
316
357
|
args.throttle_limit = 10 # Throttle when remaining <= 10
|
|
317
358
|
args.throttle_pause = 5 # Pause 5 seconds
|
|
359
|
+
args.max_retries = DEFAULT_MAX_RETRIES
|
|
318
360
|
return args
|
|
319
361
|
|
|
320
362
|
def test_throttling_pauses_when_rate_limit_low(self, mock_args):
|
|
@@ -322,11 +364,19 @@ class TestRetrieveDataThrottling:
|
|
|
322
364
|
mock_response = Mock()
|
|
323
365
|
mock_response.getcode.return_value = 200
|
|
324
366
|
mock_response.read.return_value = json.dumps([{"id": 1}]).encode("utf-8")
|
|
325
|
-
mock_response.headers = {
|
|
326
|
-
|
|
327
|
-
|
|
367
|
+
mock_response.headers = {
|
|
368
|
+
"x-ratelimit-remaining": "5",
|
|
369
|
+
"Link": "",
|
|
370
|
+
} # Below throttle_limit
|
|
371
|
+
|
|
372
|
+
with patch(
|
|
373
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
374
|
+
return_value=mock_response,
|
|
375
|
+
):
|
|
328
376
|
with patch("github_backup.github_backup.time.sleep") as mock_sleep:
|
|
329
|
-
github_backup.retrieve_data(
|
|
377
|
+
github_backup.retrieve_data(
|
|
378
|
+
mock_args, "https://api.github.com/repos/test/repo/issues"
|
|
379
|
+
)
|
|
330
380
|
|
|
331
381
|
mock_sleep.assert_called_once_with(5) # throttle_pause value
|
|
332
382
|
|
|
@@ -344,16 +394,121 @@ class TestRetrieveDataSingleItem:
|
|
|
344
394
|
args.osx_keychain_item_account = None
|
|
345
395
|
args.throttle_limit = None
|
|
346
396
|
args.throttle_pause = 0
|
|
397
|
+
args.max_retries = DEFAULT_MAX_RETRIES
|
|
347
398
|
return args
|
|
348
399
|
|
|
349
400
|
def test_dict_response_returned_as_list(self, mock_args):
|
|
350
401
|
"""Single dict response should be returned as a list with one item."""
|
|
351
402
|
mock_response = Mock()
|
|
352
403
|
mock_response.getcode.return_value = 200
|
|
353
|
-
mock_response.read.return_value = json.dumps(
|
|
404
|
+
mock_response.read.return_value = json.dumps(
|
|
405
|
+
{"login": "testuser", "id": 123}
|
|
406
|
+
).encode("utf-8")
|
|
354
407
|
mock_response.headers = {"x-ratelimit-remaining": "5000", "Link": ""}
|
|
355
408
|
|
|
356
|
-
with patch(
|
|
357
|
-
|
|
409
|
+
with patch(
|
|
410
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
411
|
+
return_value=mock_response,
|
|
412
|
+
):
|
|
413
|
+
result = github_backup.retrieve_data(
|
|
414
|
+
mock_args, "https://api.github.com/user"
|
|
415
|
+
)
|
|
358
416
|
|
|
359
417
|
assert result == [{"login": "testuser", "id": 123}]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class TestRetriesCliArgument:
|
|
421
|
+
"""Tests for --retries CLI argument validation and behavior."""
|
|
422
|
+
|
|
423
|
+
def test_retries_argument_accepted(self):
|
|
424
|
+
"""--retries flag should be accepted and parsed correctly."""
|
|
425
|
+
args = github_backup.parse_args(["--retries", "3", "testuser"])
|
|
426
|
+
assert args.max_retries == 3
|
|
427
|
+
|
|
428
|
+
def test_retries_default_value(self):
|
|
429
|
+
"""--retries should default to 5 if not specified."""
|
|
430
|
+
args = github_backup.parse_args(["testuser"])
|
|
431
|
+
assert args.max_retries == 5
|
|
432
|
+
|
|
433
|
+
def test_retries_zero_is_valid(self):
|
|
434
|
+
"""--retries 0 should be valid and mean 1 attempt (no retries)."""
|
|
435
|
+
args = github_backup.parse_args(["--retries", "0", "testuser"])
|
|
436
|
+
assert args.max_retries == 0
|
|
437
|
+
|
|
438
|
+
def test_retries_negative_rejected(self):
|
|
439
|
+
"""--retries with negative value should be rejected by argparse."""
|
|
440
|
+
with pytest.raises(SystemExit):
|
|
441
|
+
github_backup.parse_args(["--retries", "-1", "testuser"])
|
|
442
|
+
|
|
443
|
+
def test_retries_non_integer_rejected(self):
|
|
444
|
+
"""--retries with non-integer value should be rejected by argparse."""
|
|
445
|
+
with pytest.raises(SystemExit):
|
|
446
|
+
github_backup.parse_args(["--retries", "abc", "testuser"])
|
|
447
|
+
|
|
448
|
+
def test_retries_one_with_transient_error_succeeds(self):
|
|
449
|
+
"""--retries 1 should allow one retry after initial failure."""
|
|
450
|
+
good_response = Mock()
|
|
451
|
+
good_response.read.return_value = b'{"ok": true}'
|
|
452
|
+
|
|
453
|
+
call_count = 0
|
|
454
|
+
|
|
455
|
+
def mock_urlopen(*args, **kwargs):
|
|
456
|
+
nonlocal call_count
|
|
457
|
+
call_count += 1
|
|
458
|
+
if call_count == 1:
|
|
459
|
+
raise HTTPError(
|
|
460
|
+
url="https://api.github.com/test",
|
|
461
|
+
code=502,
|
|
462
|
+
msg="Bad Gateway",
|
|
463
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
464
|
+
fp=None,
|
|
465
|
+
)
|
|
466
|
+
return good_response
|
|
467
|
+
|
|
468
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
469
|
+
with patch(
|
|
470
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
471
|
+
):
|
|
472
|
+
result = make_request_with_retry(Mock(), None, max_retries=1)
|
|
473
|
+
|
|
474
|
+
assert result == good_response
|
|
475
|
+
assert call_count == 2 # 1 initial + 1 retry = 2 attempts
|
|
476
|
+
|
|
477
|
+
def test_custom_retry_count_limits_attempts(self):
|
|
478
|
+
"""Custom --retries value should limit actual retry attempts."""
|
|
479
|
+
args = Mock()
|
|
480
|
+
args.as_app = False
|
|
481
|
+
args.token_fine = None
|
|
482
|
+
args.token_classic = "fake_token"
|
|
483
|
+
args.osx_keychain_item_name = None
|
|
484
|
+
args.osx_keychain_item_account = None
|
|
485
|
+
args.throttle_limit = None
|
|
486
|
+
args.throttle_pause = 0
|
|
487
|
+
args.max_retries = 2 # 2 retries = 3 total attempts (1 initial + 2 retries)
|
|
488
|
+
|
|
489
|
+
mock_response = Mock()
|
|
490
|
+
mock_response.getcode.return_value = 200
|
|
491
|
+
mock_response.read.return_value = b"not valid json {"
|
|
492
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
493
|
+
|
|
494
|
+
call_count = 0
|
|
495
|
+
|
|
496
|
+
def mock_make_request(*args, **kwargs):
|
|
497
|
+
nonlocal call_count
|
|
498
|
+
call_count += 1
|
|
499
|
+
return mock_response
|
|
500
|
+
|
|
501
|
+
with patch(
|
|
502
|
+
"github_backup.github_backup.make_request_with_retry",
|
|
503
|
+
side_effect=mock_make_request,
|
|
504
|
+
):
|
|
505
|
+
with patch(
|
|
506
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
507
|
+
):
|
|
508
|
+
with pytest.raises(Exception) as exc_info:
|
|
509
|
+
github_backup.retrieve_data(
|
|
510
|
+
args, "https://api.github.com/repos/test/repo/issues"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
assert "Failed to read response after 3 attempts" in str(exc_info.value)
|
|
514
|
+
assert call_count == 3 # 1 initial + 2 retries = 3 attempts
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.59.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|