github-backup 0.51.1__tar.gz → 0.51.3__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 (22) hide show
  1. {github_backup-0.51.1 → github_backup-0.51.3}/CHANGES.rst +36 -1
  2. {github_backup-0.51.1/github_backup.egg-info → github_backup-0.51.3}/PKG-INFO +1 -1
  3. github_backup-0.51.3/github_backup/__init__.py +1 -0
  4. {github_backup-0.51.1 → github_backup-0.51.3}/github_backup/github_backup.py +66 -44
  5. {github_backup-0.51.1 → github_backup-0.51.3/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/SOURCES.txt +2 -1
  7. github_backup-0.51.3/requirements.txt +0 -0
  8. github_backup-0.51.3/tests/test_pagination.py +153 -0
  9. github_backup-0.51.1/github_backup/__init__.py +0 -1
  10. github_backup-0.51.1/requirements.txt +0 -1
  11. {github_backup-0.51.1 → github_backup-0.51.3}/LICENSE.txt +0 -0
  12. {github_backup-0.51.1 → github_backup-0.51.3}/MANIFEST.in +0 -0
  13. {github_backup-0.51.1 → github_backup-0.51.3}/README +0 -0
  14. {github_backup-0.51.1 → github_backup-0.51.3}/README.rst +0 -0
  15. {github_backup-0.51.1 → github_backup-0.51.3}/bin/github-backup +0 -0
  16. {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/dependency_links.txt +0 -0
  17. {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/top_level.txt +0 -0
  18. {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/zip-safe +0 -0
  19. {github_backup-0.51.1 → github_backup-0.51.3}/release-requirements.txt +0 -0
  20. {github_backup-0.51.1 → github_backup-0.51.3}/setup.cfg +0 -0
  21. {github_backup-0.51.1 → github_backup-0.51.3}/setup.py +0 -0
  22. {github_backup-0.51.1 → github_backup-0.51.3}/tests/test_attachments.py +0 -0
@@ -1,9 +1,44 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.51.1 (2025-11-16)
4
+ 0.51.3 (2025-11-18)
5
5
  -------------------
6
6
  ------------------------
7
+ - Test: Add pagination tests for cursor and page-based Link headers.
8
+ [Rodos]
9
+ - Use cursor based pagination. [Helio Machado]
10
+
11
+
12
+ 0.51.2 (2025-11-16)
13
+ -------------------
14
+
15
+ Fix
16
+ ~~~
17
+ - Improve CA certificate detection with fallback chain. [Rodos]
18
+
19
+ The previous implementation incorrectly assumed empty get_ca_certs()
20
+ meant broken SSL, causing false failures in GitHub Codespaces and other
21
+ directory-based cert systems where certificates exist but aren't pre-loaded.
22
+ It would then attempt to import certifi as a workaround, but certifi wasn't
23
+ listed in requirements.txt, causing the fallback to fail with ImportError
24
+ even though the system certificates would have worked fine.
25
+
26
+ This commit replaces the naive check with a layered fallback approach that
27
+ checks multiple certificate sources. First it checks for pre-loaded system
28
+ certs (file-based systems). Then it verifies system cert paths exist
29
+ (directory-based systems like Ubuntu/Debian/Codespaces). Finally it attempts
30
+ to use certifi as an optional fallback only if needed.
31
+
32
+ This approach eliminates hard dependencies (certifi is now optional), works
33
+ in GitHub Codespaces without any setup, and fails gracefully with clear hints
34
+ for resolution when SSL is actually broken rather than failing with
35
+ ModuleNotFoundError.
36
+
37
+ Fixes #444
38
+
39
+
40
+ 0.51.1 (2025-11-16)
41
+ -------------------
7
42
 
8
43
  Fix
9
44
  ~~~
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.1
3
+ Version: 0.51.3
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.51.3"
@@ -37,22 +37,33 @@ FNULL = open(os.devnull, "w")
37
37
  FILE_URI_PREFIX = "file://"
38
38
  logger = logging.getLogger(__name__)
39
39
 
40
+ # Setup SSL context with fallback chain
40
41
  https_ctx = ssl.create_default_context()
41
- if not https_ctx.get_ca_certs():
42
- import warnings
43
-
44
- warnings.warn(
45
- "\n\nYOUR DEFAULT CA CERTS ARE EMPTY.\n"
46
- + "PLEASE POPULATE ANY OF:"
47
- + "".join(
48
- ["\n - " + x for x in ssl.get_default_verify_paths() if type(x) is str]
49
- )
50
- + "\n",
51
- stacklevel=2,
52
- )
53
- import certifi
54
-
55
- https_ctx = ssl.create_default_context(cafile=certifi.where())
42
+ if https_ctx.get_ca_certs():
43
+ # Layer 1: Certificates pre-loaded from system (file-based)
44
+ pass
45
+ else:
46
+ paths = ssl.get_default_verify_paths()
47
+ if (paths.cafile and os.path.exists(paths.cafile)) or (
48
+ paths.capath and os.path.exists(paths.capath)
49
+ ):
50
+ # Layer 2: Cert paths exist, will be lazy-loaded on first use (directory-based)
51
+ pass
52
+ else:
53
+ # Layer 3: Try certifi package as optional fallback
54
+ try:
55
+ import certifi
56
+
57
+ https_ctx = ssl.create_default_context(cafile=certifi.where())
58
+ except ImportError:
59
+ # All layers failed - no certificates available anywhere
60
+ sys.exit(
61
+ "\nERROR: No CA certificates found. Cannot connect to GitHub over SSL.\n\n"
62
+ "Solutions you can explore:\n"
63
+ " 1. pip install certifi\n"
64
+ " 2. Alpine: apk add ca-certificates\n"
65
+ " 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
66
+ )
56
67
 
57
68
 
58
69
  def logging_subprocess(
@@ -581,27 +592,26 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
581
592
  auth = get_auth(args, encode=not args.as_app)
582
593
  query_args = get_query_args(query_args)
583
594
  per_page = 100
584
- page = 0
595
+ next_url = None
585
596
 
586
597
  while True:
587
598
  if single_request:
588
- request_page, request_per_page = None, None
599
+ request_per_page = None
589
600
  else:
590
- page = page + 1
591
- request_page, request_per_page = page, per_page
601
+ request_per_page = per_page
592
602
 
593
603
  request = _construct_request(
594
604
  request_per_page,
595
- request_page,
596
605
  query_args,
597
- template,
606
+ next_url or template,
598
607
  auth,
599
608
  as_app=args.as_app,
600
609
  fine=True if args.token_fine is not None else False,
601
610
  ) # noqa
602
- r, errors = _get_response(request, auth, template)
611
+ r, errors = _get_response(request, auth, next_url or template)
603
612
 
604
613
  status_code = int(r.getcode())
614
+
605
615
  # Check if we got correct data
606
616
  try:
607
617
  response = json.loads(r.read().decode("utf-8"))
@@ -633,15 +643,14 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
633
643
  retries += 1
634
644
  time.sleep(5)
635
645
  request = _construct_request(
636
- per_page,
637
- page,
646
+ request_per_page,
638
647
  query_args,
639
- template,
648
+ next_url or template,
640
649
  auth,
641
650
  as_app=args.as_app,
642
651
  fine=True if args.token_fine is not None else False,
643
652
  ) # noqa
644
- r, errors = _get_response(request, auth, template)
653
+ r, errors = _get_response(request, auth, next_url or template)
645
654
 
646
655
  status_code = int(r.getcode())
647
656
  try:
@@ -671,7 +680,16 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
671
680
  if type(response) is list:
672
681
  for resp in response:
673
682
  yield resp
674
- if len(response) < per_page:
683
+ # Parse Link header for next page URL (cursor-based pagination)
684
+ link_header = r.headers.get("Link", "")
685
+ next_url = None
686
+ if link_header:
687
+ # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
688
+ for link in link_header.split(","):
689
+ if 'rel="next"' in link:
690
+ next_url = link[link.find("<") + 1:link.find(">")]
691
+ break
692
+ if not next_url:
675
693
  break
676
694
  elif type(response) is dict and single_request:
677
695
  yield response
@@ -724,22 +742,27 @@ def _get_response(request, auth, template):
724
742
 
725
743
 
726
744
  def _construct_request(
727
- per_page, page, query_args, template, auth, as_app=None, fine=False
745
+ per_page, query_args, template, auth, as_app=None, fine=False
728
746
  ):
729
- all_query_args = {}
730
- if per_page:
731
- all_query_args["per_page"] = per_page
732
- if page:
733
- all_query_args["page"] = page
734
- if query_args:
735
- all_query_args.update(query_args)
736
-
737
- request_url = template
738
- if all_query_args:
739
- querystring = urlencode(all_query_args)
740
- request_url = template + "?" + querystring
747
+ # If template is already a full URL with query params (from Link header), use it directly
748
+ if "?" in template and template.startswith("http"):
749
+ request_url = template
750
+ # Extract query string for logging
751
+ querystring = template.split("?", 1)[1]
741
752
  else:
742
- querystring = ""
753
+ # Build URL with query parameters
754
+ all_query_args = {}
755
+ if per_page:
756
+ all_query_args["per_page"] = per_page
757
+ if query_args:
758
+ all_query_args.update(query_args)
759
+
760
+ request_url = template
761
+ if all_query_args:
762
+ querystring = urlencode(all_query_args)
763
+ request_url = template + "?" + querystring
764
+ else:
765
+ querystring = ""
743
766
 
744
767
  request = Request(request_url)
745
768
  if auth is not None:
@@ -755,7 +778,7 @@ def _construct_request(
755
778
  "Accept", "application/vnd.github.machine-man-preview+json"
756
779
  )
757
780
 
758
- log_url = template
781
+ log_url = template if "?" not in template else template.split("?")[0]
759
782
  if querystring:
760
783
  log_url += "?" + querystring
761
784
  logger.info("Requesting {}".format(log_url))
@@ -832,8 +855,7 @@ def download_file(url, path, auth, as_app=False, fine=False):
832
855
  return
833
856
 
834
857
  request = _construct_request(
835
- per_page=100,
836
- page=1,
858
+ per_page=None,
837
859
  query_args={},
838
860
  template=url,
839
861
  auth=auth,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.1
3
+ Version: 0.51.3
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
@@ -14,4 +14,5 @@ github_backup.egg-info/SOURCES.txt
14
14
  github_backup.egg-info/dependency_links.txt
15
15
  github_backup.egg-info/top_level.txt
16
16
  github_backup.egg-info/zip-safe
17
- tests/test_attachments.py
17
+ tests/test_attachments.py
18
+ tests/test_pagination.py
File without changes
@@ -0,0 +1,153 @@
1
+ """Tests for Link header pagination handling."""
2
+
3
+ import json
4
+ from unittest.mock import Mock, patch
5
+
6
+ import pytest
7
+
8
+ from github_backup import github_backup
9
+
10
+
11
+ class MockHTTPResponse:
12
+ """Mock HTTP response for paginated API calls."""
13
+
14
+ def __init__(self, data, link_header=None):
15
+ self._content = json.dumps(data).encode("utf-8")
16
+ self._link_header = link_header
17
+ self._read = False
18
+ self.reason = "OK"
19
+
20
+ def getcode(self):
21
+ return 200
22
+
23
+ def read(self):
24
+ if self._read:
25
+ return b""
26
+ self._read = True
27
+ return self._content
28
+
29
+ def get_header(self, name, default=None):
30
+ """Mock method for headers.get()."""
31
+ return self.headers.get(name, default)
32
+
33
+ @property
34
+ def headers(self):
35
+ headers = {"x-ratelimit-remaining": "5000"}
36
+ if self._link_header:
37
+ headers["Link"] = self._link_header
38
+ return headers
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_args():
43
+ """Mock args for retrieve_data_gen."""
44
+ args = Mock()
45
+ args.as_app = False
46
+ args.token_fine = None
47
+ args.token_classic = "fake_token"
48
+ args.username = None
49
+ args.password = None
50
+ args.osx_keychain_item_name = None
51
+ args.osx_keychain_item_account = None
52
+ args.throttle_limit = None
53
+ args.throttle_pause = 0
54
+ return args
55
+
56
+
57
+ def test_cursor_based_pagination(mock_args):
58
+ """Link header with 'after' cursor parameter works correctly."""
59
+
60
+ # Simulate issues endpoint behavior: returns cursor in Link header
61
+ responses = [
62
+ # Issues endpoint returns 'after' cursor parameter (not 'page')
63
+ MockHTTPResponse(
64
+ data=[{"issue": i} for i in range(1, 101)], # Page 1 contents
65
+ link_header='<https://api.github.com/repos/owner/repo/issues?per_page=100&after=ABC123&page=2>; rel="next"',
66
+ ),
67
+ MockHTTPResponse(
68
+ data=[{"issue": i} for i in range(101, 151)], # Page 2 contents
69
+ link_header=None, # No Link header - signals end of pagination
70
+ ),
71
+ ]
72
+ requests_made = []
73
+
74
+ def mock_urlopen(request, *args, **kwargs):
75
+ url = request.get_full_url()
76
+ requests_made.append(url)
77
+ return responses[len(requests_made) - 1]
78
+
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
+ )
84
+ )
85
+
86
+ # Verify all items retrieved and cursor was used in second request
87
+ assert len(results) == 150
88
+ assert len(requests_made) == 2
89
+ assert "after=ABC123" in requests_made[1]
90
+
91
+
92
+ def test_page_based_pagination(mock_args):
93
+ """Link header with 'page' parameter works correctly."""
94
+
95
+ # Simulate pulls/repos endpoint behavior: returns page numbers in Link header
96
+ responses = [
97
+ # Pulls endpoint uses traditional 'page' parameter (not cursor)
98
+ MockHTTPResponse(
99
+ data=[{"pull": i} for i in range(1, 101)], # Page 1 contents
100
+ link_header='<https://api.github.com/repos/owner/repo/pulls?per_page=100&page=2>; rel="next"',
101
+ ),
102
+ MockHTTPResponse(
103
+ data=[{"pull": i} for i in range(101, 181)], # Page 2 contents
104
+ link_header=None, # No Link header - signals end of pagination
105
+ ),
106
+ ]
107
+ requests_made = []
108
+
109
+ def mock_urlopen(request, *args, **kwargs):
110
+ url = request.get_full_url()
111
+ requests_made.append(url)
112
+ return responses[len(requests_made) - 1]
113
+
114
+ 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
+ )
119
+ )
120
+
121
+ # Verify all items retrieved and page parameter was used (not cursor)
122
+ assert len(results) == 180
123
+ assert len(requests_made) == 2
124
+ assert "page=2" in requests_made[1]
125
+ assert "after" not in requests_made[1]
126
+
127
+
128
+ def test_no_link_header_stops_pagination(mock_args):
129
+ """Pagination stops when Link header is absent."""
130
+
131
+ # Simulate endpoint with results that fit in a single page
132
+ responses = [
133
+ MockHTTPResponse(
134
+ data=[{"label": i} for i in range(1, 51)], # Page contents
135
+ link_header=None, # No Link header - signals end of pagination
136
+ )
137
+ ]
138
+ requests_made = []
139
+
140
+ def mock_urlopen(request, *args, **kwargs):
141
+ requests_made.append(request.get_full_url())
142
+ return responses[len(requests_made) - 1]
143
+
144
+ 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
+ )
149
+ )
150
+
151
+ # Verify pagination stopped after first request
152
+ assert len(results) == 50
153
+ assert len(requests_made) == 1
@@ -1 +0,0 @@
1
- __version__ = "0.51.1"
@@ -1 +0,0 @@
1
-
File without changes
File without changes
File without changes