Skip to content

Commit e12f029

Browse files
committed
Merge branch 'add-support-for-pagination'
2 parents e4d93c1 + 724837c commit e12f029

File tree

3 files changed

+124
-24
lines changed

3 files changed

+124
-24
lines changed

src/labels/github.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import typing
21
import logging
2+
from typing import Any, Dict, List, Optional, Tuple
33

44
import attr
55
import requests
@@ -15,7 +15,7 @@ class Repository:
1515
name: str
1616

1717

18-
def not_read_only(attr: attr.Attribute, value: typing.Any) -> bool:
18+
def not_read_only(attr: attr.Attribute, value: Any) -> bool:
1919
"""Filter for attr that checks for a leading underscore."""
2020
return not attr.name.startswith("_")
2121

@@ -35,12 +35,12 @@ class Label:
3535
_url: str = ""
3636

3737
@property
38-
def params_dict(self) -> typing.Dict[str, typing.Any]:
38+
def params_dict(self) -> Dict[str, Any]:
3939
"""Return label parameters as a dict."""
4040
return attr.asdict(self, recurse=True, filter=not_read_only)
4141

4242
@property
43-
def params_tuple(self) -> typing.Tuple[typing.Any, ...]:
43+
def params_tuple(self) -> Tuple[Any, ...]:
4444
"""Return label parameters as a tuple."""
4545
return attr.astuple(self, recurse=True, filter=not_read_only)
4646

@@ -56,7 +56,7 @@ def __init__(
5656
self.session = requests.Session()
5757
self.session.auth = auth
5858

59-
def list_labels(self, repo: Repository) -> typing.List[Label]:
59+
def list_labels(self, repo: Repository) -> List[Label]:
6060
"""Return the list of Labels from the repository.
6161
6262
GitHub API docs:
@@ -65,9 +65,10 @@ def list_labels(self, repo: Repository) -> typing.List[Label]:
6565
logger = logging.getLogger("labels")
6666
logger.debug(f"Requesting labels for {repo.owner}/{repo.name}")
6767

68+
headers = {"Accept": "application/vnd.github.symmetra-preview+json"}
69+
6870
response = self.session.get(
69-
f"{self.base_url}/repos/{repo.owner}/{repo.name}/labels",
70-
headers={"Accept": "application/vnd.github.symmetra-preview+json"},
71+
f"{self.base_url}/repos/{repo.owner}/{repo.name}/labels", headers=headers
7172
)
7273

7374
if response.status_code != 200:
@@ -77,7 +78,27 @@ def list_labels(self, repo: Repository) -> typing.List[Label]:
7778
f"{response.reason}"
7879
)
7980

80-
return [Label(**data) for data in response.json()]
81+
repo_labels: List[Dict] = response.json()
82+
83+
next_page: Optional[Dict] = response.links.get("next", None)
84+
85+
while next_page is not None:
86+
87+
logger.debug(f"Requesting next page of labels")
88+
response = self.session.get(next_page["url"], headers=headers)
89+
90+
if response.status_code != 200:
91+
raise GitHubException(
92+
f"Error retrieving next page of labels: "
93+
f"{response.status_code} - "
94+
f"{response.reason}"
95+
)
96+
97+
repo_labels.extend(response.json())
98+
99+
next_page = response.links.get("next", None)
100+
101+
return [Label(**label) for label in repo_labels]
81102

82103
def get_label(self, repo: Repository, *, name: str) -> Label:
83104
"""Return a single Label from the repository.

tests/conftest.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import typing
1+
from typing import Any, Dict, Generator, List
22

33
import attr
44
import pytest
55
import responses
66

77
from labels.github import Label
88

9-
ResponseLabel = typing.Dict[str, typing.Any]
10-
ResponseLabels = typing.List[ResponseLabel]
9+
ResponseLabel = Dict[str, Any]
10+
ResponseLabels = List[ResponseLabel]
1111

1212

1313
@pytest.fixture(name="username", scope="session")
@@ -34,6 +34,12 @@ def fixture_repo_name() -> str:
3434
return "turtle"
3535

3636

37+
@pytest.fixture(name="repo_id", scope="session")
38+
def fixture_repo_id() -> int:
39+
"""Return a repository ID."""
40+
return 102909380
41+
42+
3743
@attr.s(auto_attribs=True, frozen=True, kw_only=True)
3844
class FakeProc:
3945
"""Fake for a CompletedProcess instance."""
@@ -43,7 +49,7 @@ class FakeProc:
4349

4450

4551
@pytest.fixture(name="mock_repo_info")
46-
def fixture_mock_repo_info(mocker: typing.Any, remote_url: str) -> typing.Any:
52+
def fixture_mock_repo_info(mocker: Any, remote_url: str) -> Any:
4753
"""Patch the subprocess call to git remote get-url."""
4854

4955
return mocker.patch(
@@ -54,7 +60,7 @@ def fixture_mock_repo_info(mocker: typing.Any, remote_url: str) -> typing.Any:
5460

5561

5662
@pytest.fixture(name="mock_repo_info_error")
57-
def fixture_mock_repo_info_error(mocker: typing.Any) -> typing.Any:
63+
def fixture_mock_repo_info_error(mocker: Any) -> Any:
5864
"""Patch the subprocess call to git remote get-url with an error."""
5965

6066
return mocker.patch(
@@ -65,7 +71,7 @@ def fixture_mock_repo_info_error(mocker: typing.Any) -> typing.Any:
6571

6672

6773
@pytest.fixture(name="mock_repo_info_bad_url")
68-
def fixture_mock_repo_info_bad_url(mocker: typing.Any) -> typing.Any:
74+
def fixture_mock_repo_info_bad_url(mocker: Any) -> Any:
6975
"""Patch the subprocess call to git remote get-url with a bad URL."""
7076

7177
return mocker.patch(
@@ -148,7 +154,7 @@ def fixture_response_list_labels(
148154
@pytest.fixture(name="mock_list_labels")
149155
def fixture_mock_list_labels(
150156
base_url: str, repo_owner: str, repo_name: str, response_list_labels: ResponseLabels
151-
) -> None:
157+
) -> Generator:
152158
"""Mock requests for list labels."""
153159
with responses.RequestsMock() as rsps:
154160
rsps.add(
@@ -161,10 +167,55 @@ def fixture_mock_list_labels(
161167
yield
162168

163169

170+
@pytest.fixture(name="mock_list_labels_paginated")
171+
def fixture_mock_list_labels_paginated(
172+
base_url: str,
173+
repo_owner: str,
174+
repo_name: str,
175+
repo_id: int,
176+
response_get_infra: ResponseLabel,
177+
response_get_docs: ResponseLabel,
178+
response_get_bug: ResponseLabel,
179+
) -> Generator:
180+
"""Mock requests for list labels with pagination."""
181+
182+
with responses.RequestsMock() as rsps:
183+
184+
rsps.add(
185+
responses.GET,
186+
f"{base_url}/repos/{repo_owner}/{repo_name}/labels",
187+
json=[response_get_bug, response_get_docs],
188+
status=200,
189+
content_type="application/json",
190+
headers={
191+
"Link": (
192+
f'<{base_url}/repositories/{repo_id}/labels?page=2>; rel="next", '
193+
f'<{base_url}/repositories/{repo_id}/labels?page=2>; rel="last"'
194+
)
195+
},
196+
)
197+
198+
rsps.add(
199+
responses.GET,
200+
f"{base_url}/repositories/{repo_id}/labels?page=2",
201+
json=[response_get_infra],
202+
status=200,
203+
content_type="application/json",
204+
headers={
205+
"Link": (
206+
f'<{base_url}/repositories/{repo_id}/labels?page=1>; rel="prev", '
207+
f'<{base_url}/repositories/{repo_id}/labels?page=1>; rel="first"'
208+
)
209+
},
210+
)
211+
212+
yield
213+
214+
164215
@pytest.fixture(name="mock_get_label")
165216
def fixture_mock_get_label(
166217
base_url: str, repo_owner: str, repo_name: str, response_get_bug: ResponseLabel
167-
) -> None:
218+
) -> Generator:
168219
"""Mock requests for get label."""
169220
with responses.RequestsMock() as rsps:
170221
rsps.add(
@@ -180,7 +231,7 @@ def fixture_mock_get_label(
180231
@pytest.fixture(name="mock_edit_label")
181232
def fixture_mock_edit_label(
182233
base_url: str, repo_owner: str, repo_name: str, response_get_bug: ResponseLabel
183-
) -> None:
234+
) -> Generator:
184235
"""Mock requests for edit label."""
185236
with responses.RequestsMock() as rsps:
186237
rsps.add(
@@ -208,7 +259,7 @@ def fixture_mock_create_label(
208259
repo_name: str,
209260
label: Label,
210261
response_get_bug: ResponseLabel,
211-
) -> None:
262+
) -> Generator:
212263
"""Mock requests for create label."""
213264
with responses.RequestsMock() as rsps:
214265
rsps.add(
@@ -222,7 +273,9 @@ def fixture_mock_create_label(
222273

223274

224275
@pytest.fixture(name="mock_delete_label")
225-
def fixture_mock_delete_label(base_url: str, repo_owner: str, repo_name: str) -> None:
276+
def fixture_mock_delete_label(
277+
base_url: str, repo_owner: str, repo_name: str
278+
) -> Generator:
226279
"""Mock requests for delete label."""
227280
with responses.RequestsMock() as rsps:
228281
rsps.add(
@@ -236,7 +289,7 @@ def fixture_mock_delete_label(base_url: str, repo_owner: str, repo_name: str) ->
236289
@pytest.fixture(name="mock_sync")
237290
def fixture_mock_sync(
238291
base_url: str, repo_owner: str, repo_name: str, response_list_labels: ResponseLabels
239-
) -> None:
292+
) -> Generator:
240293
with responses.RequestsMock() as rsps:
241294
# Response mock for when sync requests the existing remote labels
242295
rsps.add(
@@ -292,7 +345,7 @@ def fixture_mock_sync(
292345

293346

294347
@pytest.fixture(name="labels")
295-
def fixture_labels() -> typing.List[Label]:
348+
def fixture_labels() -> List[Label]:
296349
"""Return a list of Label instances."""
297350
return [
298351
Label(
@@ -334,7 +387,7 @@ def fixture_labels() -> typing.List[Label]:
334387

335388

336389
@pytest.fixture(name="labels_file_dict")
337-
def fixture_labels_file_content() -> typing.Dict[str, typing.Any]:
390+
def fixture_labels_file_content() -> Dict[str, Any]:
338391
"""Return a mapping from label names to dicts representing Labels."""
339392
return {
340393
"bug": {
@@ -386,7 +439,7 @@ def fixture_labels_file_content() -> typing.Dict[str, typing.Any]:
386439

387440

388441
@pytest.fixture(name="labels_file_write")
389-
def fixture_labels_file_write(tmpdir: typing.Any) -> str:
442+
def fixture_labels_file_write(tmpdir: Any) -> str:
390443
"""Return a filepath to a temporary file."""
391444
labels_file = tmpdir.join("labels.toml")
392445
return str(labels_file)
@@ -399,6 +452,6 @@ def fixture_labels_file_load() -> str:
399452

400453

401454
@pytest.fixture(name="labels_file_sync")
402-
def fixture_labels_file_sync(tmpdir: typing.Any) -> str:
455+
def fixture_labels_file_sync(tmpdir: Any) -> str:
403456
"""Return a filepath to an existing labels file for the sync test."""
404457
return "tests/sync.toml"

tests/test_github.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,32 @@ def test_list_labels(client: Client, repo: Repository) -> None:
4545
assert [l.params_dict for l in labels] == expected_params
4646

4747

48+
@pytest.mark.usefixtures("mock_list_labels_paginated")
49+
def test_list_labels_pagination(client: Client, repo: Repository) -> None:
50+
"""Test that list_labels() supports pagination."""
51+
labels = client.list_labels(repo)
52+
53+
expected_params = [
54+
{
55+
"name": "bug",
56+
"description": "Bugs and problems with cookiecutter",
57+
"color": "ea707a",
58+
},
59+
{
60+
"name": "docs",
61+
"description": "Tasks to write and update documentation",
62+
"color": "2abf88",
63+
},
64+
{
65+
"name": "infra",
66+
"description": "Tasks related to Docker/CI etc.",
67+
"color": "f9d03b",
68+
},
69+
]
70+
71+
assert [l.params_dict for l in labels] == expected_params
72+
73+
4874
@pytest.mark.usefixtures("mock_get_label")
4975
def test_get_label(client: Client, repo: Repository) -> None:
5076
"""Test that get_label() requests the specified label for the repo and

0 commit comments

Comments
 (0)