github-backup 0.51.2__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.2 → github_backup-0.51.3}/CHANGES.rst +8 -1
- {github_backup-0.51.2/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.2 → github_backup-0.51.3}/github_backup/github_backup.py +40 -29
- {github_backup-0.51.2 → github_backup-0.51.3/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.51.2 → github_backup-0.51.3}/github_backup.egg-info/SOURCES.txt +2 -1
- github_backup-0.51.3/tests/test_pagination.py +153 -0
- github_backup-0.51.2/github_backup/__init__.py +0 -1
- {github_backup-0.51.2 → github_backup-0.51.3}/LICENSE.txt +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/MANIFEST.in +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/README +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/README.rst +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/bin/github-backup +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/release-requirements.txt +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/requirements.txt +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/setup.cfg +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/setup.py +0 -0
- {github_backup-0.51.2 → github_backup-0.51.3}/tests/test_attachments.py +0 -0
|
@@ -1,9 +1,16 @@
|
|
|
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
|
+
-------------------
|
|
7
14
|
|
|
8
15
|
Fix
|
|
9
16
|
~~~
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.51.3"
|
|
@@ -592,27 +592,26 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
|
|
|
592
592
|
auth = get_auth(args, encode=not args.as_app)
|
|
593
593
|
query_args = get_query_args(query_args)
|
|
594
594
|
per_page = 100
|
|
595
|
-
|
|
595
|
+
next_url = None
|
|
596
596
|
|
|
597
597
|
while True:
|
|
598
598
|
if single_request:
|
|
599
|
-
|
|
599
|
+
request_per_page = None
|
|
600
600
|
else:
|
|
601
|
-
|
|
602
|
-
request_page, request_per_page = page, per_page
|
|
601
|
+
request_per_page = per_page
|
|
603
602
|
|
|
604
603
|
request = _construct_request(
|
|
605
604
|
request_per_page,
|
|
606
|
-
request_page,
|
|
607
605
|
query_args,
|
|
608
|
-
template,
|
|
606
|
+
next_url or template,
|
|
609
607
|
auth,
|
|
610
608
|
as_app=args.as_app,
|
|
611
609
|
fine=True if args.token_fine is not None else False,
|
|
612
610
|
) # noqa
|
|
613
|
-
r, errors = _get_response(request, auth, template)
|
|
611
|
+
r, errors = _get_response(request, auth, next_url or template)
|
|
614
612
|
|
|
615
613
|
status_code = int(r.getcode())
|
|
614
|
+
|
|
616
615
|
# Check if we got correct data
|
|
617
616
|
try:
|
|
618
617
|
response = json.loads(r.read().decode("utf-8"))
|
|
@@ -644,15 +643,14 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
|
|
|
644
643
|
retries += 1
|
|
645
644
|
time.sleep(5)
|
|
646
645
|
request = _construct_request(
|
|
647
|
-
|
|
648
|
-
page,
|
|
646
|
+
request_per_page,
|
|
649
647
|
query_args,
|
|
650
|
-
template,
|
|
648
|
+
next_url or template,
|
|
651
649
|
auth,
|
|
652
650
|
as_app=args.as_app,
|
|
653
651
|
fine=True if args.token_fine is not None else False,
|
|
654
652
|
) # noqa
|
|
655
|
-
r, errors = _get_response(request, auth, template)
|
|
653
|
+
r, errors = _get_response(request, auth, next_url or template)
|
|
656
654
|
|
|
657
655
|
status_code = int(r.getcode())
|
|
658
656
|
try:
|
|
@@ -682,7 +680,16 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
|
|
|
682
680
|
if type(response) is list:
|
|
683
681
|
for resp in response:
|
|
684
682
|
yield resp
|
|
685
|
-
|
|
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:
|
|
686
693
|
break
|
|
687
694
|
elif type(response) is dict and single_request:
|
|
688
695
|
yield response
|
|
@@ -735,22 +742,27 @@ def _get_response(request, auth, template):
|
|
|
735
742
|
|
|
736
743
|
|
|
737
744
|
def _construct_request(
|
|
738
|
-
per_page,
|
|
745
|
+
per_page, query_args, template, auth, as_app=None, fine=False
|
|
739
746
|
):
|
|
740
|
-
|
|
741
|
-
if
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if query_args:
|
|
746
|
-
all_query_args.update(query_args)
|
|
747
|
-
|
|
748
|
-
request_url = template
|
|
749
|
-
if all_query_args:
|
|
750
|
-
querystring = urlencode(all_query_args)
|
|
751
|
-
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]
|
|
752
752
|
else:
|
|
753
|
-
|
|
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 = ""
|
|
754
766
|
|
|
755
767
|
request = Request(request_url)
|
|
756
768
|
if auth is not None:
|
|
@@ -766,7 +778,7 @@ def _construct_request(
|
|
|
766
778
|
"Accept", "application/vnd.github.machine-man-preview+json"
|
|
767
779
|
)
|
|
768
780
|
|
|
769
|
-
log_url = template
|
|
781
|
+
log_url = template if "?" not in template else template.split("?")[0]
|
|
770
782
|
if querystring:
|
|
771
783
|
log_url += "?" + querystring
|
|
772
784
|
logger.info("Requesting {}".format(log_url))
|
|
@@ -843,8 +855,7 @@ def download_file(url, path, auth, as_app=False, fine=False):
|
|
|
843
855
|
return
|
|
844
856
|
|
|
845
857
|
request = _construct_request(
|
|
846
|
-
per_page=
|
|
847
|
-
page=1,
|
|
858
|
+
per_page=None,
|
|
848
859
|
query_args={},
|
|
849
860
|
template=url,
|
|
850
861
|
auth=auth,
|
|
@@ -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.2"
|
|
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
|
|
File without changes
|