github-backup 0.57.0__py3-none-any.whl → 0.58.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github_backup/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.57.0"
1
+ __version__ = "0.58.0"
@@ -12,6 +12,7 @@ import json
12
12
  import logging
13
13
  import os
14
14
  import platform
15
+ import random
15
16
  import re
16
17
  import select
17
18
  import socket
@@ -19,6 +20,7 @@ import ssl
19
20
  import subprocess
20
21
  import sys
21
22
  import time
23
+ from collections.abc import Generator
22
24
  from datetime import datetime
23
25
  from http.client import IncompleteRead
24
26
  from urllib.error import HTTPError, URLError
@@ -74,6 +76,9 @@ else:
74
76
  " 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
75
77
  )
76
78
 
79
+ # Retry configuration
80
+ MAX_RETRIES = 5
81
+
77
82
 
78
83
  def logging_subprocess(
79
84
  popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
@@ -603,170 +608,178 @@ def get_github_repo_url(args, repository):
603
608
  return repo_url
604
609
 
605
610
 
606
- def retrieve_data_gen(args, template, query_args=None, single_request=False):
607
- auth = get_auth(args, encode=not args.as_app)
608
- query_args = get_query_args(query_args)
609
- per_page = 100
610
- next_url = None
611
+ def calculate_retry_delay(attempt, headers):
612
+ """Calculate delay before next retry with exponential backoff."""
613
+ # Respect retry-after header if present
614
+ if retry_after := headers.get("retry-after"):
615
+ return int(retry_after)
611
616
 
612
- while True:
613
- if single_request:
614
- request_per_page = None
615
- else:
616
- request_per_page = per_page
617
+ # Respect rate limit reset time
618
+ if int(headers.get("x-ratelimit-remaining", 1)) < 1:
619
+ reset_time = int(headers.get("x-ratelimit-reset", 0))
620
+ return max(10, reset_time - calendar.timegm(time.gmtime()))
617
621
 
618
- request = _construct_request(
619
- request_per_page,
620
- query_args,
621
- next_url or template,
622
- auth,
623
- as_app=args.as_app,
624
- fine=True if args.token_fine is not None else False,
625
- ) # noqa
626
- r, errors = _get_response(request, auth, next_url or template)
622
+ # Exponential backoff with jitter for server errors (1s base, 120s max)
623
+ delay = min(1.0 * (2**attempt), 120.0)
624
+ return delay + random.uniform(0, delay * 0.1)
627
625
 
628
- status_code = int(r.getcode())
629
626
 
630
- # Handle DMCA takedown (HTTP 451) - raise exception to skip entire repository
631
- if status_code == 451:
632
- dmca_url = None
633
- try:
634
- response_data = json.loads(r.read().decode("utf-8"))
635
- dmca_url = response_data.get("block", {}).get("html_url")
636
- except Exception:
637
- pass
638
- raise RepositoryUnavailableError(
639
- "Repository unavailable due to legal reasons (HTTP 451)",
640
- dmca_url=dmca_url,
641
- )
627
+ def retrieve_data(args, template, query_args=None, paginated=True):
628
+ """
629
+ Fetch the data from GitHub API.
642
630
 
643
- # Check if we got correct data
644
- try:
645
- response = json.loads(r.read().decode("utf-8"))
646
- except IncompleteRead:
647
- logger.warning("Incomplete read error detected")
648
- read_error = True
649
- except json.decoder.JSONDecodeError:
650
- logger.warning("JSON decode error detected")
651
- read_error = True
652
- except TimeoutError:
653
- logger.warning("Tiemout error detected")
654
- read_error = True
655
- else:
656
- read_error = False
631
+ Handle both single requests and pagination with yield of individual dicts.
632
+ Handles throttling, retries, read errors, and DMCA takedowns.
633
+ """
634
+ query_args = query_args or {}
635
+ auth = get_auth(args, encode=not args.as_app)
636
+ per_page = 100
657
637
 
658
- # be gentle with API request limit and throttle requests if remaining requests getting low
659
- limit_remaining = int(r.headers.get("x-ratelimit-remaining", 0))
660
- if args.throttle_limit and limit_remaining <= args.throttle_limit:
661
- logger.info(
662
- "API request limit hit: {} requests left, pausing further requests for {}s".format(
663
- limit_remaining, args.throttle_pause
638
+ def _extract_next_page_url(link_header):
639
+ for link in link_header.split(","):
640
+ if 'rel="next"' in link:
641
+ return link[link.find("<") + 1:link.find(">")]
642
+ return None
643
+
644
+ def fetch_all() -> Generator[dict, None, None]:
645
+ next_url = None
646
+
647
+ while True:
648
+ # FIRST: Fetch response
649
+
650
+ for attempt in range(MAX_RETRIES):
651
+ request = _construct_request(
652
+ per_page=per_page if paginated else None,
653
+ query_args=query_args,
654
+ template=next_url or template,
655
+ auth=auth,
656
+ as_app=args.as_app,
657
+ fine=args.token_fine is not None,
664
658
  )
665
- )
666
- time.sleep(args.throttle_pause)
667
-
668
- retries = 0
669
- while retries < 3 and (status_code == 502 or read_error):
670
- logger.warning("API request failed. Retrying in 5 seconds")
671
- retries += 1
672
- time.sleep(5)
673
- request = _construct_request(
674
- request_per_page,
675
- query_args,
676
- next_url or template,
677
- auth,
678
- as_app=args.as_app,
679
- fine=True if args.token_fine is not None else False,
680
- ) # noqa
681
- r, errors = _get_response(request, auth, next_url or template)
682
-
683
- status_code = int(r.getcode())
684
- try:
685
- response = json.loads(r.read().decode("utf-8"))
686
- read_error = False
687
- except IncompleteRead:
688
- logger.warning("Incomplete read error detected")
689
- read_error = True
690
- except json.decoder.JSONDecodeError:
691
- logger.warning("JSON decode error detected")
692
- read_error = True
693
- except TimeoutError:
694
- logger.warning("Tiemout error detected")
695
- read_error = True
696
-
697
- if status_code != 200:
698
- template = "API request returned HTTP {0}: {1}"
699
- errors.append(template.format(status_code, r.reason))
700
- raise Exception(", ".join(errors))
701
-
702
- if read_error:
703
- template = "API request problem reading response for {0}"
704
- errors.append(template.format(request))
705
- raise Exception(", ".join(errors))
706
-
707
- if len(errors) == 0:
708
- if type(response) is list:
709
- for resp in response:
710
- yield resp
711
- # Parse Link header for next page URL (cursor-based pagination)
712
- link_header = r.headers.get("Link", "")
713
- next_url = None
714
- if link_header:
715
- # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
716
- for link in link_header.split(","):
717
- if 'rel="next"' in link:
718
- next_url = link[link.find("<") + 1 : link.find(">")]
719
- break
720
- if not next_url:
721
- break
722
- elif type(response) is dict and single_request:
723
- yield response
659
+ http_response = make_request_with_retry(request, auth)
660
+
661
+ match http_response.getcode():
662
+ case 200:
663
+ # Success - Parse JSON response
664
+ try:
665
+ response = json.loads(http_response.read().decode("utf-8"))
666
+ break # Exit retry loop and handle the data returned
667
+ except (
668
+ IncompleteRead,
669
+ json.decoder.JSONDecodeError,
670
+ TimeoutError,
671
+ ) as e:
672
+ logger.warning(f"{type(e).__name__} reading response")
673
+ if attempt < MAX_RETRIES - 1:
674
+ delay = calculate_retry_delay(attempt, {})
675
+ logger.warning(
676
+ f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
677
+ )
678
+ time.sleep(delay)
679
+ continue # Next retry attempt
680
+
681
+ case 451:
682
+ # DMCA takedown - extract URL if available, then raise
683
+ dmca_url = None
684
+ try:
685
+ response_data = json.loads(
686
+ http_response.read().decode("utf-8")
687
+ )
688
+ dmca_url = response_data.get("block", {}).get("html_url")
689
+ except Exception:
690
+ pass
691
+ raise RepositoryUnavailableError(
692
+ "Repository unavailable due to legal reasons (HTTP 451)",
693
+ dmca_url=dmca_url,
694
+ )
695
+
696
+ case _:
697
+ raise Exception(
698
+ f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
699
+ )
700
+ else:
701
+ logger.error(
702
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
703
+ )
704
+ raise Exception(
705
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
706
+ )
707
+
708
+ # SECOND: Process and paginate
724
709
 
725
- if len(errors) > 0:
726
- raise Exception(", ".join(errors))
710
+ # Pause before next request if rate limit is low
711
+ if (
712
+ remaining := int(http_response.headers.get("x-ratelimit-remaining", 0))
713
+ ) <= (args.throttle_limit or 0):
714
+ if args.throttle_limit:
715
+ logger.info(
716
+ f"Throttling: {remaining} requests left, pausing {args.throttle_pause}s"
717
+ )
718
+ time.sleep(args.throttle_pause)
727
719
 
728
- if single_request:
729
- break
720
+ # Yield results
721
+ if isinstance(response, list):
722
+ yield from response
723
+ elif isinstance(response, dict):
724
+ yield response
730
725
 
726
+ # Check for more pages
727
+ if not paginated or not (
728
+ next_url := _extract_next_page_url(
729
+ http_response.headers.get("Link", "")
730
+ )
731
+ ):
732
+ break # No more data
731
733
 
732
- def retrieve_data(args, template, query_args=None, single_request=False):
733
- return list(retrieve_data_gen(args, template, query_args, single_request))
734
+ return list(fetch_all())
734
735
 
735
736
 
736
- def get_query_args(query_args=None):
737
- if not query_args:
738
- query_args = {}
739
- return query_args
737
+ def make_request_with_retry(request, auth):
738
+ """Make HTTP request with automatic retry for transient errors."""
740
739
 
740
+ def is_retryable_status(status_code, headers):
741
+ # Server errors are always retryable
742
+ if status_code in (500, 502, 503, 504):
743
+ return True
744
+ # Rate limit (403/429) is retryable if limit exhausted
745
+ if status_code in (403, 429):
746
+ return int(headers.get("x-ratelimit-remaining", 1)) < 1
747
+ return False
741
748
 
742
- def _get_response(request, auth, template):
743
- retry_timeout = 3
744
- errors = []
745
- # We'll make requests in a loop so we can
746
- # delay and retry in the case of rate-limiting
747
- while True:
748
- should_continue = False
749
+ for attempt in range(MAX_RETRIES):
749
750
  try:
750
- r = urlopen(request, context=https_ctx)
751
+ return urlopen(request, context=https_ctx)
752
+
751
753
  except HTTPError as exc:
752
- errors, should_continue = _request_http_error(exc, auth, errors) # noqa
753
- r = exc
754
- except URLError as e:
755
- logger.warning(e.reason)
756
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
757
- if not should_continue:
758
- raise
759
- except socket.error as e:
760
- logger.warning(e.strerror)
761
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
762
- if not should_continue:
754
+ # HTTPError can be used as a response-like object
755
+ if not is_retryable_status(exc.code, exc.headers):
756
+ raise # Non-retryable error
757
+
758
+ if attempt >= MAX_RETRIES - 1:
759
+ logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
763
760
  raise
764
761
 
765
- if should_continue:
766
- continue
762
+ delay = calculate_retry_delay(attempt, exc.headers)
763
+ logger.warning(
764
+ f"HTTP {exc.code}, retrying in {delay:.1f}s "
765
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
766
+ )
767
+ if auth is None and exc.code in (403, 429):
768
+ logger.info("Hint: Authenticate to raise your GitHub rate limit")
769
+ time.sleep(delay)
767
770
 
768
- break
769
- return r, errors
771
+ except (URLError, socket.error) as e:
772
+ if attempt >= MAX_RETRIES - 1:
773
+ logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
774
+ raise
775
+ delay = calculate_retry_delay(attempt, {})
776
+ logger.warning(
777
+ f"Connection error: {e}, retrying in {delay:.1f}s "
778
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
779
+ )
780
+ time.sleep(delay)
781
+
782
+ raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
770
783
 
771
784
 
772
785
  def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
@@ -808,52 +821,6 @@ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=F
808
821
  return request
809
822
 
810
823
 
811
- def _request_http_error(exc, auth, errors):
812
- # HTTPError behaves like a Response so we can
813
- # check the status code and headers to see exactly
814
- # what failed.
815
-
816
- should_continue = False
817
- headers = exc.headers
818
- limit_remaining = int(headers.get("x-ratelimit-remaining", 0))
819
-
820
- if exc.code == 403 and limit_remaining < 1:
821
- # The X-RateLimit-Reset header includes a
822
- # timestamp telling us when the limit will reset
823
- # so we can calculate how long to wait rather
824
- # than inefficiently polling:
825
- gm_now = calendar.timegm(time.gmtime())
826
- reset = int(headers.get("x-ratelimit-reset", 0)) or gm_now
827
- # We'll never sleep for less than 10 seconds:
828
- delta = max(10, reset - gm_now)
829
-
830
- limit = headers.get("x-ratelimit-limit")
831
- logger.warning(
832
- "Exceeded rate limit of {} requests; waiting {} seconds to reset".format(
833
- limit, delta
834
- )
835
- ) # noqa
836
-
837
- if auth is None:
838
- logger.info("Hint: Authenticate to raise your GitHub rate limit")
839
-
840
- time.sleep(delta)
841
- should_continue = True
842
- return errors, should_continue
843
-
844
-
845
- def _request_url_error(template, retry_timeout):
846
- # In case of a connection timing out, we can retry a few time
847
- # But we won't crash and not back-up the rest now
848
- logger.info("'{}' timed out".format(template))
849
- retry_timeout -= 1
850
-
851
- if retry_timeout >= 0:
852
- return True, retry_timeout
853
-
854
- raise Exception("'{}' timed out to much, skipping!".format(template))
855
-
856
-
857
824
  class S3HTTPRedirectHandler(HTTPRedirectHandler):
858
825
  """
859
826
  A subclassed redirect handler for downloading Github assets from S3.
@@ -1503,7 +1470,7 @@ def download_attachments(
1503
1470
 
1504
1471
  def get_authenticated_user(args):
1505
1472
  template = "https://{0}/user".format(get_github_api_host(args))
1506
- data = retrieve_data(args, template, single_request=True)
1473
+ data = retrieve_data(args, template, paginated=False)
1507
1474
  return data[0]
1508
1475
 
1509
1476
 
@@ -1517,7 +1484,7 @@ def check_git_lfs_install():
1517
1484
 
1518
1485
  def retrieve_repositories(args, authenticated_user):
1519
1486
  logger.info("Retrieving repositories")
1520
- single_request = False
1487
+ paginated = True
1521
1488
  if args.user == authenticated_user["login"]:
1522
1489
  # we must use the /user/repos API to be able to access private repos
1523
1490
  template = "https://{0}/user/repos".format(get_github_api_host(args))
@@ -1540,16 +1507,16 @@ def retrieve_repositories(args, authenticated_user):
1540
1507
  repo_path = args.repository
1541
1508
  else:
1542
1509
  repo_path = "{0}/{1}".format(args.user, args.repository)
1543
- single_request = True
1510
+ paginated = False
1544
1511
  template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
1545
1512
 
1546
- repos = retrieve_data(args, template, single_request=single_request)
1513
+ repos = retrieve_data(args, template, paginated=paginated)
1547
1514
 
1548
1515
  if args.all_starred:
1549
1516
  starred_template = "https://{0}/users/{1}/starred".format(
1550
1517
  get_github_api_host(args), args.user
1551
1518
  )
1552
- starred_repos = retrieve_data(args, starred_template, single_request=False)
1519
+ starred_repos = retrieve_data(args, starred_template)
1553
1520
  # flag each repo as starred for downstream processing
1554
1521
  for item in starred_repos:
1555
1522
  item.update({"is_starred": True})
@@ -1559,7 +1526,7 @@ def retrieve_repositories(args, authenticated_user):
1559
1526
  gists_template = "https://{0}/users/{1}/gists".format(
1560
1527
  get_github_api_host(args), args.user
1561
1528
  )
1562
- gists = retrieve_data(args, gists_template, single_request=False)
1529
+ gists = retrieve_data(args, gists_template)
1563
1530
  # flag each repo as a gist for downstream processing
1564
1531
  for item in gists:
1565
1532
  item.update({"is_gist": True})
@@ -1578,9 +1545,7 @@ def retrieve_repositories(args, authenticated_user):
1578
1545
  starred_gists_template = "https://{0}/gists/starred".format(
1579
1546
  get_github_api_host(args)
1580
1547
  )
1581
- starred_gists = retrieve_data(
1582
- args, starred_gists_template, single_request=False
1583
- )
1548
+ starred_gists = retrieve_data(args, starred_gists_template)
1584
1549
  # flag each repo as a starred gist for downstream processing
1585
1550
  for item in starred_gists:
1586
1551
  item.update({"is_gist": True, "is_starred": True})
@@ -1849,14 +1814,14 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1849
1814
  pull_states = ["open", "closed"]
1850
1815
  for pull_state in pull_states:
1851
1816
  query_args["state"] = pull_state
1852
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1817
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1853
1818
  for pull in _pulls:
1854
1819
  if args.since and pull["updated_at"] < args.since:
1855
1820
  break
1856
1821
  if not args.since or pull["updated_at"] >= args.since:
1857
1822
  pulls[pull["number"]] = pull
1858
1823
  else:
1859
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1824
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1860
1825
  for pull in _pulls:
1861
1826
  if args.since and pull["updated_at"] < args.since:
1862
1827
  break
@@ -1864,7 +1829,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1864
1829
  pulls[pull["number"]] = retrieve_data(
1865
1830
  args,
1866
1831
  _pulls_template + "/{}".format(pull["number"]),
1867
- single_request=True,
1832
+ paginated=False,
1868
1833
  )[0]
1869
1834
 
1870
1835
  logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.57.0
3
+ Version: 0.58.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
@@ -0,0 +1,11 @@
1
+ github_backup/__init__.py,sha256=oIkJTCdzxBkcFHWQdIvMrmJih4tpnF1BnfoNPd1mB48,23
2
+ github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
+ github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
4
+ github_backup/github_backup.py,sha256=Q3EcCwO7X8v5MPdWilx0zsJa8FLUINzSI8bjHw08O50,81287
5
+ github_backup-0.58.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
+ github_backup-0.58.0.dist-info/METADATA,sha256=wYRqf6V7hjtir_td4uYuK296O5KHeqpCPGhsr-ndnK8,25921
7
+ github_backup-0.58.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ github_backup-0.58.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
+ github_backup-0.58.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
+ github_backup-0.58.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
+ github_backup-0.58.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- github_backup/__init__.py,sha256=jmnoPljZ40w5Zc7DEZpxkJSfC9yHbRjV50qzvnfEbdI,23
2
- github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
- github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
4
- github_backup/github_backup.py,sha256=_P0WVPpZpyzs3PPkwtmIWDu2mkmeGXd2EePymeEtOV0,81890
5
- github_backup-0.57.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
- github_backup-0.57.0.dist-info/METADATA,sha256=4Jni1TDR6oVFdIKCzjy6hacTh1TQrigaAeXVyidL-As,25921
7
- github_backup-0.57.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- github_backup-0.57.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
- github_backup-0.57.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
- github_backup-0.57.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- github_backup-0.57.0.dist-info/RECORD,,