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.
- {github_backup-0.51.1 → github_backup-0.51.3}/CHANGES.rst +36 -1
- {github_backup-0.51.1/github_backup.egg-info → github_backup-0.51.3}/PKG-INFO +1 -1
- github_backup-0.51.3/github_backup/__init__.py +1 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/github_backup/github_backup.py +66 -44
- {github_backup-0.51.1 → github_backup-0.51.3/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/SOURCES.txt +2 -1
- github_backup-0.51.3/requirements.txt +0 -0
- github_backup-0.51.3/tests/test_pagination.py +153 -0
- github_backup-0.51.1/github_backup/__init__.py +0 -1
- github_backup-0.51.1/requirements.txt +0 -1
- {github_backup-0.51.1 → github_backup-0.51.3}/LICENSE.txt +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/MANIFEST.in +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/README +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/README.rst +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/bin/github-backup +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/release-requirements.txt +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/setup.cfg +0 -0
- {github_backup-0.51.1 → github_backup-0.51.3}/setup.py +0 -0
- {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.
|
|
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
|
~~~
|
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
595
|
+
next_url = None
|
|
585
596
|
|
|
586
597
|
while True:
|
|
587
598
|
if single_request:
|
|
588
|
-
|
|
599
|
+
request_per_page = None
|
|
589
600
|
else:
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
745
|
+
per_page, query_args, template, auth, as_app=None, fine=False
|
|
728
746
|
):
|
|
729
|
-
|
|
730
|
-
if
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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=
|
|
836
|
-
page=1,
|
|
858
|
+
per_page=None,
|
|
837
859
|
query_args={},
|
|
838
860
|
template=url,
|
|
839
861
|
auth=auth,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|