Coverage for src/pipeline_analytics/pagination.py: 100%

39 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-16 19:27 +0000

1"""A real iterator over ``GET /api/repos``, the only endpoint in 

2pipeline-analytics' spec with pagination -- ``limit``/``offset`` query 

3params and a ``hasMore`` flag on the response. Walks every page 

4transparently rather than handing back one page and a raw offset for the 

5caller to loop on by hand (rules/sdk-generation.md's "Client shape"). 

6""" 

7 

8from __future__ import annotations 

9 

10from collections.abc import AsyncIterator, Iterator 

11 

12from ._generated.api.repos import list_repos as _list_repos 

13from ._generated.client import Client as _GeneratedClient 

14from ._generated.models.forge import Forge 

15from ._generated.models.repo import Repo 

16from ._generated.models.repo_list import RepoList 

17from ._generated.types import UNSET, Unset 

18from .errors import decode_error 

19 

20#: Used when the caller leaves ``limit`` unset -- ``GET /api/repos`` never 

21#: paginates on its own (``RepoList.has_more`` is always false with no 

22#: limit), so an iterator needs a default to actually walk pages. 

23DEFAULT_PAGE_SIZE = 50 

24 

25 

26def iter_repos( 

27 client: _GeneratedClient, 

28 *, 

29 forge: Forge | Unset = UNSET, 

30 limit: int = DEFAULT_PAGE_SIZE, 

31 offset: int = 0, 

32) -> Iterator[Repo]: 

33 """Iterate every tracked repo matching *forge*, walking ``GET 

34 /api/repos``'s offset pages transparently. 

35 

36 *client* is the generated client -- pass ``PipelineAnalyticsClient.raw`` 

37 or any ``pipeline_analytics._generated.client.Client``/ 

38 ``AuthenticatedClient``. Raises 

39 :class:`~pipeline_analytics.errors.APIError` if any page's response is 

40 an error, and whatever the underlying ``httpx`` transport raises on a 

41 transport failure that exhausts its retries. 

42 

43 Usage:: 

44 

45 for repo in iter_repos(client.raw): 

46 print(repo.identifier, repo.ingestion_status) 

47 """ 

48 while True: 

49 response = _list_repos.sync_detailed(client=client, forge=forge, limit=limit, offset=offset) 

50 if error := decode_error(response): 

51 raise error 

52 

53 page = response.parsed 

54 if page is None: 

55 return 

56 if not isinstance(page, RepoList): 

57 # decode_error already raised for status_code >= 400, so a 

58 # parsed body at this point should always be RepoList -- this 

59 # only guards against a future spec change adding another 

60 # documented 2xx shape to listRepos. 

61 raise TypeError(f"unexpected listRepos response body: {page!r}") 

62 

63 yield from page.repos 

64 

65 if not page.has_more: 

66 return 

67 offset += len(page.repos) 

68 

69 

70async def aiter_repos( 

71 client: _GeneratedClient, 

72 *, 

73 forge: Forge | Unset = UNSET, 

74 limit: int = DEFAULT_PAGE_SIZE, 

75 offset: int = 0, 

76) -> AsyncIterator[Repo]: 

77 """Async counterpart of :func:`iter_repos`, for 

78 ``PipelineAnalyticsClient`` used with ``async with``/``await``.""" 

79 while True: 

80 response = await _list_repos.asyncio_detailed(client=client, forge=forge, limit=limit, offset=offset) 

81 if error := decode_error(response): 

82 raise error 

83 

84 page = response.parsed 

85 if page is None: 

86 return 

87 if not isinstance(page, RepoList): 

88 raise TypeError(f"unexpected listRepos response body: {page!r}") 

89 

90 for repo in page.repos: 

91 yield repo 

92 

93 if not page.has_more: 

94 return 

95 offset += len(page.repos)