github-backup 0.57.0__tar.gz → 0.58.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.57.0 → github_backup-0.58.0}/CHANGES.rst +30 -1
  2. {github_backup-0.57.0/github_backup.egg-info → github_backup-0.58.0}/PKG-INFO +1 -1
  3. github_backup-0.58.0/github_backup/__init__.py +1 -0
  4. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/github_backup.py +167 -202
  5. {github_backup-0.57.0 → github_backup-0.58.0/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/SOURCES.txt +1 -0
  7. github_backup-0.58.0/release-requirements.txt +15 -0
  8. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_http_451.py +6 -49
  9. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_pagination.py +7 -13
  10. github_backup-0.58.0/tests/test_retrieve_data.py +365 -0
  11. github_backup-0.57.0/github_backup/__init__.py +0 -1
  12. github_backup-0.57.0/release-requirements.txt +0 -40
  13. {github_backup-0.57.0 → github_backup-0.58.0}/LICENSE.txt +0 -0
  14. {github_backup-0.57.0 → github_backup-0.58.0}/MANIFEST.in +0 -0
  15. {github_backup-0.57.0 → github_backup-0.58.0}/README +0 -0
  16. {github_backup-0.57.0 → github_backup-0.58.0}/README.rst +0 -0
  17. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/__main__.py +0 -0
  18. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup/cli.py +0 -0
  19. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/dependency_links.txt +0 -0
  20. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/entry_points.txt +0 -0
  21. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/top_level.txt +0 -0
  22. {github_backup-0.57.0 → github_backup-0.58.0}/github_backup.egg-info/zip-safe +0 -0
  23. {github_backup-0.57.0 → github_backup-0.58.0}/requirements.txt +0 -0
  24. {github_backup-0.57.0 → github_backup-0.58.0}/setup.cfg +0 -0
  25. {github_backup-0.57.0 → github_backup-0.58.0}/setup.py +0 -0
  26. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_all_starred.py +0 -0
  27. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_attachments.py +0 -0
  28. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_case_sensitivity.py +0 -0
  29. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_json_dump_if_changed.py +0 -0
  30. {github_backup-0.57.0 → github_backup-0.58.0}/tests/test_skip_assets_on.py +0 -0
@@ -1,9 +1,38 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.57.0 (2025-12-12)
4
+ 0.58.0 (2025-12-16)
5
5
  -------------------
6
6
  ------------------------
7
+ - Fix retry logic for HTTP 5xx errors and network failures. [Rodos]
8
+
9
+ Refactors error handling to retry all 5xx errors (not just 502), network errors (URLError, socket.error, IncompleteRead), and JSON parse errors with exponential backoff and jitter. Respects retry-after and rate limit headers per GitHub API requirements. Consolidates retry logic into make_request_with_retry() wrapper and adds clear logging for retry attempts and failures. Removes dead code from 2016 (errors list, _request_http_error, _request_url_error) that was intentionally disabled in commit 1e5a9048 to fix #29.
10
+
11
+ Fixes #140, #110, #138
12
+ - Chore: remove transitive deps from release-requirements.txt. [Rodos]
13
+ - Chore(deps): bump urllib3 in the python-packages group.
14
+ [dependabot[bot]]
15
+
16
+ Bumps the python-packages group with 1 update: [urllib3](https://github.com/urllib3/urllib3).
17
+
18
+
19
+ Updates `urllib3` from 2.6.1 to 2.6.2
20
+ - [Release notes](https://github.com/urllib3/urllib3/releases)
21
+ - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
22
+ - [Commits](https://github.com/urllib3/urllib3/compare/2.6.1...2.6.2)
23
+
24
+ ---
25
+ updated-dependencies:
26
+ - dependency-name: urllib3
27
+ dependency-version: 2.6.2
28
+ dependency-type: direct:production
29
+ update-type: version-update:semver-patch
30
+ dependency-group: python-packages
31
+ ...
32
+
33
+
34
+ 0.57.0 (2025-12-12)
35
+ -------------------
7
36
  - Add GitHub Apps documentation and remove outdated header. [Rodos]
8
37
 
9
38
  - Add GitHub Apps authentication section with setup steps
@@ -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 @@
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
@@ -22,4 +22,5 @@ tests/test_case_sensitivity.py
22
22
  tests/test_http_451.py
23
23
  tests/test_json_dump_if_changed.py
24
24
  tests/test_pagination.py
25
+ tests/test_retrieve_data.py
25
26
  tests/test_skip_assets_on.py
@@ -0,0 +1,15 @@
1
+ # Linting & Formatting
2
+ autopep8==2.3.2
3
+ black==25.12.0
4
+ flake8==7.3.0
5
+
6
+ # Testing
7
+ pytest==9.0.2
8
+
9
+ # Release & Publishing
10
+ twine==6.2.0
11
+ gitchangelog==3.0.4
12
+ setuptools==80.9.0
13
+
14
+ # Documentation
15
+ restructuredtext-lint==2.0.2
@@ -13,7 +13,6 @@ class TestHTTP451Exception:
13
13
 
14
14
  def test_repository_unavailable_error_raised(self):
15
15
  """HTTP 451 should raise RepositoryUnavailableError with DMCA URL."""
16
- # Create mock args
17
16
  args = Mock()
18
17
  args.as_app = False
19
18
  args.token_fine = None
@@ -25,7 +24,6 @@ class TestHTTP451Exception:
25
24
  args.throttle_limit = None
26
25
  args.throttle_pause = 0
27
26
 
28
- # Mock HTTPError 451 response
29
27
  mock_response = Mock()
30
28
  mock_response.getcode.return_value = 451
31
29
 
@@ -41,14 +39,10 @@ class TestHTTP451Exception:
41
39
  mock_response.headers = {"x-ratelimit-remaining": "5000"}
42
40
  mock_response.reason = "Unavailable For Legal Reasons"
43
41
 
44
- def mock_get_response(request, auth, template):
45
- return mock_response, []
46
-
47
- with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
42
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
48
43
  with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
49
- list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
44
+ github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
50
45
 
51
- # Check exception has DMCA URL
52
46
  assert exc_info.value.dmca_url == "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
53
47
  assert "451" in str(exc_info.value)
54
48
 
@@ -71,14 +65,10 @@ class TestHTTP451Exception:
71
65
  mock_response.headers = {"x-ratelimit-remaining": "5000"}
72
66
  mock_response.reason = "Unavailable For Legal Reasons"
73
67
 
74
- def mock_get_response(request, auth, template):
75
- return mock_response, []
76
-
77
- with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
68
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
78
69
  with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
79
- list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
70
+ github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
80
71
 
81
- # Exception raised even without DMCA URL
82
72
  assert exc_info.value.dmca_url is None
83
73
  assert "451" in str(exc_info.value)
84
74
 
@@ -101,42 +91,9 @@ class TestHTTP451Exception:
101
91
  mock_response.headers = {"x-ratelimit-remaining": "5000"}
102
92
  mock_response.reason = "Unavailable For Legal Reasons"
103
93
 
104
- def mock_get_response(request, auth, template):
105
- return mock_response, []
106
-
107
- with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
94
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
108
95
  with pytest.raises(github_backup.RepositoryUnavailableError):
109
- list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
110
-
111
- def test_other_http_errors_unchanged(self):
112
- """Other HTTP errors should still raise generic Exception."""
113
- args = Mock()
114
- args.as_app = False
115
- args.token_fine = None
116
- args.token_classic = None
117
- args.username = None
118
- args.password = None
119
- args.osx_keychain_item_name = None
120
- args.osx_keychain_item_account = None
121
- args.throttle_limit = None
122
- args.throttle_pause = 0
123
-
124
- mock_response = Mock()
125
- mock_response.getcode.return_value = 404
126
- mock_response.read.return_value = b'{"message": "Not Found"}'
127
- mock_response.headers = {"x-ratelimit-remaining": "5000"}
128
- mock_response.reason = "Not Found"
129
-
130
- def mock_get_response(request, auth, template):
131
- return mock_response, []
132
-
133
- with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
134
- # Should raise generic Exception, not RepositoryUnavailableError
135
- with pytest.raises(Exception) as exc_info:
136
- list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/notfound/issues"))
137
-
138
- assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
139
- assert "404" in str(exc_info.value)
96
+ github_backup.retrieve_data(args, "https://api.github.com/repos/test/dmca/issues")
140
97
 
141
98
 
142
99
  if __name__ == "__main__":
@@ -40,7 +40,7 @@ class MockHTTPResponse:
40
40
 
41
41
  @pytest.fixture
42
42
  def mock_args():
43
- """Mock args for retrieve_data_gen."""
43
+ """Mock args for retrieve_data."""
44
44
  args = Mock()
45
45
  args.as_app = False
46
46
  args.token_fine = None
@@ -77,10 +77,8 @@ def test_cursor_based_pagination(mock_args):
77
77
  return responses[len(requests_made) - 1]
78
78
 
79
79
  with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
80
- results = list(
81
- github_backup.retrieve_data_gen(
82
- mock_args, "https://api.github.com/repos/owner/repo/issues"
83
- )
80
+ results = github_backup.retrieve_data(
81
+ mock_args, "https://api.github.com/repos/owner/repo/issues"
84
82
  )
85
83
 
86
84
  # Verify all items retrieved and cursor was used in second request
@@ -112,10 +110,8 @@ def test_page_based_pagination(mock_args):
112
110
  return responses[len(requests_made) - 1]
113
111
 
114
112
  with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
115
- results = list(
116
- github_backup.retrieve_data_gen(
117
- mock_args, "https://api.github.com/repos/owner/repo/pulls"
118
- )
113
+ results = github_backup.retrieve_data(
114
+ mock_args, "https://api.github.com/repos/owner/repo/pulls"
119
115
  )
120
116
 
121
117
  # Verify all items retrieved and page parameter was used (not cursor)
@@ -142,10 +138,8 @@ def test_no_link_header_stops_pagination(mock_args):
142
138
  return responses[len(requests_made) - 1]
143
139
 
144
140
  with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
145
- results = list(
146
- github_backup.retrieve_data_gen(
147
- mock_args, "https://api.github.com/repos/owner/repo/labels"
148
- )
141
+ results = github_backup.retrieve_data(
142
+ mock_args, "https://api.github.com/repos/owner/repo/labels"
149
143
  )
150
144
 
151
145
  # Verify pagination stopped after first request
@@ -0,0 +1,365 @@
1
+ """Tests for retrieve_data function."""
2
+
3
+ import json
4
+ import socket
5
+ from unittest.mock import Mock, patch
6
+ from urllib.error import HTTPError, URLError
7
+
8
+ import pytest
9
+
10
+ from github_backup import github_backup
11
+ from github_backup.github_backup import (
12
+ MAX_RETRIES,
13
+ calculate_retry_delay,
14
+ make_request_with_retry,
15
+ )
16
+
17
+
18
+ class TestCalculateRetryDelay:
19
+ def test_respects_retry_after_header(self):
20
+ headers = {'retry-after': '30'}
21
+ assert calculate_retry_delay(0, headers) == 30
22
+
23
+ def test_respects_rate_limit_reset(self):
24
+ import time
25
+ import calendar
26
+ # Set reset time 60 seconds in the future
27
+ future_reset = calendar.timegm(time.gmtime()) + 60
28
+ headers = {
29
+ 'x-ratelimit-remaining': '0',
30
+ 'x-ratelimit-reset': str(future_reset)
31
+ }
32
+ delay = calculate_retry_delay(0, headers)
33
+ # Should be approximately 60 seconds (with some tolerance for execution time)
34
+ assert 55 <= delay <= 65
35
+
36
+ def test_exponential_backoff(self):
37
+ delay_0 = calculate_retry_delay(0, {})
38
+ delay_1 = calculate_retry_delay(1, {})
39
+ delay_2 = calculate_retry_delay(2, {})
40
+ # Base delay is 1s, so delays should be roughly 1, 2, 4 (plus jitter)
41
+ assert 0.9 <= delay_0 <= 1.2 # ~1s + up to 10% jitter
42
+ assert 1.8 <= delay_1 <= 2.4 # ~2s + up to 10% jitter
43
+ assert 3.6 <= delay_2 <= 4.8 # ~4s + up to 10% jitter
44
+
45
+ def test_max_delay_cap(self):
46
+ # Very high attempt number should not exceed 120s + jitter
47
+ delay = calculate_retry_delay(100, {})
48
+ assert delay <= 120 * 1.1 # 120s max + 10% jitter
49
+
50
+ def test_minimum_rate_limit_delay(self):
51
+ import time
52
+ import calendar
53
+ # Set reset time in the past (already reset)
54
+ past_reset = calendar.timegm(time.gmtime()) - 100
55
+ headers = {
56
+ 'x-ratelimit-remaining': '0',
57
+ 'x-ratelimit-reset': str(past_reset)
58
+ }
59
+ delay = calculate_retry_delay(0, headers)
60
+ # Should be minimum 10 seconds even if reset time is in past
61
+ assert delay >= 10
62
+
63
+
64
+ class TestRetrieveDataRetry:
65
+ """Tests for retry behavior in retrieve_data."""
66
+
67
+ @pytest.fixture
68
+ def mock_args(self):
69
+ args = Mock()
70
+ args.as_app = False
71
+ args.token_fine = None
72
+ args.token_classic = "fake_token"
73
+ args.username = None
74
+ args.password = None
75
+ args.osx_keychain_item_name = None
76
+ args.osx_keychain_item_account = None
77
+ args.throttle_limit = None
78
+ args.throttle_pause = 0
79
+ return args
80
+
81
+ def test_json_parse_error_retries_and_fails(self, mock_args):
82
+ """HTTP 200 with invalid JSON should retry and eventually fail."""
83
+ mock_response = Mock()
84
+ mock_response.getcode.return_value = 200
85
+ mock_response.read.return_value = b"not valid json {"
86
+ mock_response.headers = {"x-ratelimit-remaining": "5000"}
87
+
88
+ call_count = 0
89
+
90
+ def mock_make_request(*args, **kwargs):
91
+ nonlocal call_count
92
+ call_count += 1
93
+ return mock_response
94
+
95
+ with patch("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
96
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0): # No delay in tests
97
+ with pytest.raises(Exception) as exc_info:
98
+ github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
99
+
100
+ assert "Failed to read response after" in str(exc_info.value)
101
+ assert call_count == MAX_RETRIES
102
+
103
+ def test_json_parse_error_recovers_on_retry(self, mock_args):
104
+ """HTTP 200 with invalid JSON should succeed if retry returns valid JSON."""
105
+ bad_response = Mock()
106
+ bad_response.getcode.return_value = 200
107
+ bad_response.read.return_value = b"not valid json {"
108
+ bad_response.headers = {"x-ratelimit-remaining": "5000"}
109
+
110
+ good_response = Mock()
111
+ good_response.getcode.return_value = 200
112
+ good_response.read.return_value = json.dumps([{"id": 1}]).encode("utf-8")
113
+ good_response.headers = {"x-ratelimit-remaining": "5000", "Link": ""}
114
+
115
+ responses = [bad_response, bad_response, good_response]
116
+ call_count = 0
117
+
118
+ def mock_make_request(*args, **kwargs):
119
+ nonlocal call_count
120
+ result = responses[call_count]
121
+ call_count += 1
122
+ return result
123
+
124
+ with patch("github_backup.github_backup.make_request_with_retry", side_effect=mock_make_request):
125
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
126
+ result = github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
127
+
128
+ assert result == [{"id": 1}]
129
+ assert call_count == 3 # Failed twice, succeeded on third
130
+
131
+ def test_http_error_raises_exception(self, mock_args):
132
+ """Non-success HTTP status codes should raise Exception."""
133
+ mock_response = Mock()
134
+ mock_response.getcode.return_value = 404
135
+ mock_response.read.return_value = b'{"message": "Not Found"}'
136
+ mock_response.headers = {"x-ratelimit-remaining": "5000"}
137
+ mock_response.reason = "Not Found"
138
+
139
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
140
+ with pytest.raises(Exception) as exc_info:
141
+ github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/notfound/issues")
142
+
143
+ assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
144
+ assert "404" in str(exc_info.value)
145
+
146
+
147
+ class TestMakeRequestWithRetry:
148
+ """Tests for HTTP error retry behavior in make_request_with_retry."""
149
+
150
+ def test_502_error_retries_and_succeeds(self):
151
+ """HTTP 502 should retry and succeed if subsequent request works."""
152
+ good_response = Mock()
153
+ good_response.read.return_value = b'{"ok": true}'
154
+
155
+ call_count = 0
156
+ fail_count = MAX_RETRIES - 1 # Fail all but last attempt
157
+
158
+ def mock_urlopen(*args, **kwargs):
159
+ nonlocal call_count
160
+ call_count += 1
161
+ if call_count <= fail_count:
162
+ raise HTTPError(
163
+ url="https://api.github.com/test",
164
+ code=502,
165
+ msg="Bad Gateway",
166
+ hdrs={"x-ratelimit-remaining": "5000"},
167
+ fp=None,
168
+ )
169
+ return good_response
170
+
171
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
172
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
173
+ result = make_request_with_retry(Mock(), None)
174
+
175
+ assert result == good_response
176
+ assert call_count == MAX_RETRIES
177
+
178
+ def test_503_error_retries_until_exhausted(self):
179
+ """HTTP 503 should retry MAX_RETRIES times then raise."""
180
+ call_count = 0
181
+
182
+ def mock_urlopen(*args, **kwargs):
183
+ nonlocal call_count
184
+ call_count += 1
185
+ raise HTTPError(
186
+ url="https://api.github.com/test",
187
+ code=503,
188
+ msg="Service Unavailable",
189
+ hdrs={"x-ratelimit-remaining": "5000"},
190
+ fp=None,
191
+ )
192
+
193
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
194
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
195
+ with pytest.raises(HTTPError) as exc_info:
196
+ make_request_with_retry(Mock(), None)
197
+
198
+ assert exc_info.value.code == 503
199
+ assert call_count == MAX_RETRIES
200
+
201
+ def test_404_error_not_retried(self):
202
+ """HTTP 404 should not be retried - raise immediately."""
203
+ call_count = 0
204
+
205
+ def mock_urlopen(*args, **kwargs):
206
+ nonlocal call_count
207
+ call_count += 1
208
+ raise HTTPError(
209
+ url="https://api.github.com/test",
210
+ code=404,
211
+ msg="Not Found",
212
+ hdrs={"x-ratelimit-remaining": "5000"},
213
+ fp=None,
214
+ )
215
+
216
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
217
+ with pytest.raises(HTTPError) as exc_info:
218
+ make_request_with_retry(Mock(), None)
219
+
220
+ assert exc_info.value.code == 404
221
+ assert call_count == 1 # No retries
222
+
223
+ def test_rate_limit_403_retried_when_remaining_zero(self):
224
+ """HTTP 403 with x-ratelimit-remaining=0 should retry."""
225
+ good_response = Mock()
226
+ call_count = 0
227
+
228
+ def mock_urlopen(*args, **kwargs):
229
+ nonlocal call_count
230
+ call_count += 1
231
+ if call_count == 1:
232
+ raise HTTPError(
233
+ url="https://api.github.com/test",
234
+ code=403,
235
+ msg="Forbidden",
236
+ hdrs={"x-ratelimit-remaining": "0"},
237
+ fp=None,
238
+ )
239
+ return good_response
240
+
241
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
242
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
243
+ result = make_request_with_retry(Mock(), None)
244
+
245
+ assert result == good_response
246
+ assert call_count == 2
247
+
248
+ def test_403_not_retried_when_remaining_nonzero(self):
249
+ """HTTP 403 with x-ratelimit-remaining>0 should not retry (permission error)."""
250
+ call_count = 0
251
+
252
+ def mock_urlopen(*args, **kwargs):
253
+ nonlocal call_count
254
+ call_count += 1
255
+ raise HTTPError(
256
+ url="https://api.github.com/test",
257
+ code=403,
258
+ msg="Forbidden",
259
+ hdrs={"x-ratelimit-remaining": "5000"},
260
+ fp=None,
261
+ )
262
+
263
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
264
+ with pytest.raises(HTTPError) as exc_info:
265
+ make_request_with_retry(Mock(), None)
266
+
267
+ assert exc_info.value.code == 403
268
+ assert call_count == 1 # No retries
269
+
270
+ def test_connection_error_retries_and_succeeds(self):
271
+ """URLError (connection error) should retry and succeed if subsequent request works."""
272
+ good_response = Mock()
273
+ call_count = 0
274
+ fail_count = MAX_RETRIES - 1 # Fail all but last attempt
275
+
276
+ def mock_urlopen(*args, **kwargs):
277
+ nonlocal call_count
278
+ call_count += 1
279
+ if call_count <= fail_count:
280
+ raise URLError("Connection refused")
281
+ return good_response
282
+
283
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
284
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
285
+ result = make_request_with_retry(Mock(), None)
286
+
287
+ assert result == good_response
288
+ assert call_count == MAX_RETRIES
289
+
290
+ def test_socket_error_retries_until_exhausted(self):
291
+ """socket.error should retry MAX_RETRIES times then raise."""
292
+ call_count = 0
293
+
294
+ def mock_urlopen(*args, **kwargs):
295
+ nonlocal call_count
296
+ call_count += 1
297
+ raise socket.error("Connection reset by peer")
298
+
299
+ with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
300
+ with patch("github_backup.github_backup.calculate_retry_delay", return_value=0):
301
+ with pytest.raises(socket.error):
302
+ make_request_with_retry(Mock(), None)
303
+
304
+ assert call_count == MAX_RETRIES
305
+
306
+
307
+ class TestRetrieveDataThrottling:
308
+ """Tests for throttling behavior in retrieve_data."""
309
+
310
+ @pytest.fixture
311
+ def mock_args(self):
312
+ args = Mock()
313
+ args.as_app = False
314
+ args.token_fine = None
315
+ args.token_classic = "fake_token"
316
+ args.username = None
317
+ args.password = None
318
+ args.osx_keychain_item_name = None
319
+ args.osx_keychain_item_account = None
320
+ args.throttle_limit = 10 # Throttle when remaining <= 10
321
+ args.throttle_pause = 5 # Pause 5 seconds
322
+ return args
323
+
324
+ def test_throttling_pauses_when_rate_limit_low(self, mock_args):
325
+ """Should pause when x-ratelimit-remaining is at or below throttle_limit."""
326
+ mock_response = Mock()
327
+ mock_response.getcode.return_value = 200
328
+ mock_response.read.return_value = json.dumps([{"id": 1}]).encode("utf-8")
329
+ mock_response.headers = {"x-ratelimit-remaining": "5", "Link": ""} # Below throttle_limit
330
+
331
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
332
+ with patch("github_backup.github_backup.time.sleep") as mock_sleep:
333
+ github_backup.retrieve_data(mock_args, "https://api.github.com/repos/test/repo/issues")
334
+
335
+ mock_sleep.assert_called_once_with(5) # throttle_pause value
336
+
337
+
338
+ class TestRetrieveDataSingleItem:
339
+ """Tests for single item (dict) responses in retrieve_data."""
340
+
341
+ @pytest.fixture
342
+ def mock_args(self):
343
+ args = Mock()
344
+ args.as_app = False
345
+ args.token_fine = None
346
+ args.token_classic = "fake_token"
347
+ args.username = None
348
+ args.password = None
349
+ args.osx_keychain_item_name = None
350
+ args.osx_keychain_item_account = None
351
+ args.throttle_limit = None
352
+ args.throttle_pause = 0
353
+ return args
354
+
355
+ def test_dict_response_returned_as_list(self, mock_args):
356
+ """Single dict response should be returned as a list with one item."""
357
+ mock_response = Mock()
358
+ mock_response.getcode.return_value = 200
359
+ mock_response.read.return_value = json.dumps({"login": "testuser", "id": 123}).encode("utf-8")
360
+ mock_response.headers = {"x-ratelimit-remaining": "5000", "Link": ""}
361
+
362
+ with patch("github_backup.github_backup.make_request_with_retry", return_value=mock_response):
363
+ result = github_backup.retrieve_data(mock_args, "https://api.github.com/user")
364
+
365
+ assert result == [{"login": "testuser", "id": 123}]
@@ -1 +0,0 @@
1
- __version__ = "0.57.0"
@@ -1,40 +0,0 @@
1
- autopep8==2.3.2
2
- black==25.12.0
3
- bleach==6.3.0
4
- certifi==2025.11.12
5
- charset-normalizer==3.4.4
6
- click==8.3.1
7
- colorama==0.4.6
8
- docutils==0.22.3
9
- flake8==7.3.0
10
- gitchangelog==3.0.4
11
- pytest==9.0.2
12
- idna==3.11
13
- importlib-metadata==8.7.0
14
- jaraco.classes==3.4.0
15
- keyring==25.7.0
16
- markdown-it-py==4.0.0
17
- mccabe==0.7.0
18
- mdurl==0.1.2
19
- more-itertools==10.8.0
20
- mypy-extensions==1.1.0
21
- packaging==25.0
22
- pathspec==0.12.1
23
- pkginfo==1.12.1.2
24
- platformdirs==4.5.1
25
- pycodestyle==2.14.0
26
- pyflakes==3.4.0
27
- Pygments==2.19.2
28
- readme-renderer==44.0
29
- requests==2.32.5
30
- requests-toolbelt==1.0.0
31
- restructuredtext-lint==2.0.2
32
- rfc3986==2.0.0
33
- rich==14.2.0
34
- setuptools==80.9.0
35
- six==1.17.0
36
- tqdm==4.67.1
37
- twine==6.2.0
38
- urllib3==2.6.1
39
- webencodings==0.5.1
40
- zipp==3.23.0
File without changes
File without changes
File without changes