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.
Files changed (30) hide show
  1. {github_backup-0.59.0 → github_backup-0.61.0}/CHANGES.rst +17 -1
  2. {github_backup-0.59.0/github_backup.egg-info → github_backup-0.61.0}/PKG-INFO +10 -7
  3. {github_backup-0.59.0 → github_backup-0.61.0}/README +9 -6
  4. {github_backup-0.59.0 → github_backup-0.61.0}/README.rst +9 -6
  5. github_backup-0.61.0/github_backup/__init__.py +1 -0
  6. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/github_backup.py +92 -24
  7. {github_backup-0.59.0 → github_backup-0.61.0/github_backup.egg-info}/PKG-INFO +10 -7
  8. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_all_starred.py +1 -0
  9. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_http_451.py +31 -10
  10. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_pagination.py +1 -0
  11. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_retrieve_data.py +195 -40
  12. github_backup-0.59.0/github_backup/__init__.py +0 -1
  13. {github_backup-0.59.0 → github_backup-0.61.0}/LICENSE.txt +0 -0
  14. {github_backup-0.59.0 → github_backup-0.61.0}/MANIFEST.in +0 -0
  15. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/__main__.py +0 -0
  16. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup/cli.py +0 -0
  17. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/SOURCES.txt +0 -0
  18. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/dependency_links.txt +0 -0
  19. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/entry_points.txt +0 -0
  20. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/top_level.txt +0 -0
  21. {github_backup-0.59.0 → github_backup-0.61.0}/github_backup.egg-info/zip-safe +0 -0
  22. {github_backup-0.59.0 → github_backup-0.61.0}/release-requirements.txt +0 -0
  23. {github_backup-0.59.0 → github_backup-0.61.0}/requirements.txt +0 -0
  24. {github_backup-0.59.0 → github_backup-0.61.0}/setup.cfg +0 -0
  25. {github_backup-0.59.0 → github_backup-0.61.0}/setup.py +0 -0
  26. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_attachments.py +0 -0
  27. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_case_sensitivity.py +0 -0
  28. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_json_dump_if_changed.py +0 -0
  29. {github_backup-0.59.0 → github_backup-0.61.0}/tests/test_skip_assets_on.py +0 -0
  30. {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.59.0 (2025-12-21)
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.59.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] [--repositories]
77
- [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
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] [--repositories]
47
- [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
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] [--repositories]
47
- [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
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(MAX_RETRIES):
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 < MAX_RETRIES - 1:
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}/{MAX_RETRIES})"
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 {MAX_RETRIES} attempts for {next_url or template}"
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 {MAX_RETRIES} attempts for {next_url or template}"
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(MAX_RETRIES):
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 >= MAX_RETRIES - 1:
743
- logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
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}/{MAX_RETRIES})"
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 >= MAX_RETRIES - 1:
757
- logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
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}/{MAX_RETRIES})"
790
+ f"(attempt {attempt + 1}/{max_retries + 1}) for {request.full_url}"
763
791
  )
764
792
  time.sleep(delay)
765
793
 
766
- raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
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")), size_mb, args.starred_skip_size_over
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.59.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] [--repositories]
77
- [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
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("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
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(args, "https://api.github.com/repos/test/dmca/issues")
43
-
44
- assert exc_info.value.dmca_url == "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
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("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
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(args, "https://api.github.com/repos/test/dmca/issues")
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("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
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(args, "https://api.github.com/repos/test/dmca/issues")
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__":
@@ -49,6 +49,7 @@ def mock_args():
49
49
  args.osx_keychain_item_account = None
50
50
  args.throttle_limit = None
51
51
  args.throttle_pause = 0
52
+ args.max_retries = 5
52
53
  return args
53
54
 
54
55
 
@@ -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 = {'retry-after': '30'}
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("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
94
- with patch("github_backup.github_backup.calculate_retry_delay", return_value=0): # No delay in tests
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(mock_args, "https://api.github.com/repos/test/repo/issues")
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 call_count == MAX_RETRIES
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("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
123
- with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
124
- result = github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
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("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
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(mock_args, "https://api.github.com/repos/test/notfound/issues")
158
+ github_backup.retrieve_data(
159
+ mock_args, "https://api.github.com/repos/test/notfound/issues"
160
+ )
140
161
 
141
- assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
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 = MAX_RETRIES - 1 # Fail all but last attempt
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("github_backup.github_backup.calculate_retry_delay", return_value=0):
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 call_count == MAX_RETRIES
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 MAX_RETRIES times then raise."""
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("github_backup.github_backup.calculate_retry_delay", return_value=0):
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 call_count == MAX_RETRIES
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("github_backup.github_backup.calculate_retry_delay", return_value=0):
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 = MAX_RETRIES - 1 # Fail all but last attempt
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("github_backup.github_backup.calculate_retry_delay", return_value=0):
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 call_count == MAX_RETRIES
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 MAX_RETRIES times then raise."""
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("github_backup.github_backup.calculate_retry_delay", return_value=0):
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 call_count == MAX_RETRIES
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 = {"x-ratelimit-remaining": "5", "Link": ""} # Below throttle_limit
326
-
327
- with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
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(mock_args, "https://api.github.com/repos/test/repo/issues")
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({"login": "testuser", "id": 123}).encode("utf-8")
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("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
357
- result = github_backup.retrieve_data(mock_args, "https://api.github.com/user")
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