cliV1 0.0.7__py3-none-any.whl
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.
- cliv1-0.0.7.dist-info/METADATA +212 -0
- cliv1-0.0.7.dist-info/RECORD +92 -0
- cliv1-0.0.7.dist-info/WHEEL +4 -0
- cliv1-0.0.7.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_1_0/__init__.py +15 -0
- swagger_petstore_open_api_3_1_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_1_0/apis/pet_api.py +922 -0
- swagger_petstore_open_api_3_1_0/apis/store.py +432 -0
- swagger_petstore_open_api_3_1_0/apis/user_api.py +829 -0
- swagger_petstore_open_api_3_1_0/async_client.py +87 -0
- swagger_petstore_open_api_3_1_0/auth.py +17 -0
- swagger_petstore_open_api_3_1_0/base_client.py +27 -0
- swagger_petstore_open_api_3_1_0/client.py +85 -0
- swagger_petstore_open_api_3_1_0/core/__init__.py +395 -0
- swagger_petstore_open_api_3_1_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_1_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_1_0/core/_internal/flattening.py +104 -0
- swagger_petstore_open_api_3_1_0/core/_internal/headers.py +243 -0
- swagger_petstore_open_api_3_1_0/core/_internal/redaction.py +208 -0
- swagger_petstore_open_api_3_1_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_1_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_1_0/core/_internal/urls.py +172 -0
- swagger_petstore_open_api_3_1_0/core/_internal/wire.py +201 -0
- swagger_petstore_open_api_3_1_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_1_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_1_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_1_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_1_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_authorization_code.py +395 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_client_credentials.py +130 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_password.py +145 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_1_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_1_0/core/bodies.py +590 -0
- swagger_petstore_open_api_3_1_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_1_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_1_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_1_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_1_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_1_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_1_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_1_0/core/exceptions.py +78 -0
- swagger_petstore_open_api_3_1_0/core/file_responses.py +337 -0
- swagger_petstore_open_api_3_1_0/core/files.py +174 -0
- swagger_petstore_open_api_3_1_0/core/httpx2_transport.py +748 -0
- swagger_petstore_open_api_3_1_0/core/logging.py +472 -0
- swagger_petstore_open_api_3_1_0/core/models.py +143 -0
- swagger_petstore_open_api_3_1_0/core/optionality.py +144 -0
- swagger_petstore_open_api_3_1_0/core/pagination.py +452 -0
- swagger_petstore_open_api_3_1_0/core/paginator.py +55 -0
- swagger_petstore_open_api_3_1_0/core/params.py +157 -0
- swagger_petstore_open_api_3_1_0/core/raw_client.py +480 -0
- swagger_petstore_open_api_3_1_0/core/request_options.py +172 -0
- swagger_petstore_open_api_3_1_0/core/results.py +178 -0
- swagger_petstore_open_api_3_1_0/core/retries.py +237 -0
- swagger_petstore_open_api_3_1_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_1_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_1_0/core/transport.py +218 -0
- swagger_petstore_open_api_3_1_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_1_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_1_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_1_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_1_0/models/category.py +15 -0
- swagger_petstore_open_api_3_1_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_1_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/order.py +27 -0
- swagger_petstore_open_api_3_1_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_1_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_1_0/models/user.py +29 -0
- swagger_petstore_open_api_3_1_0/py.typed +0 -0
- swagger_petstore_open_api_3_1_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_1_0/server/server.py +17 -0
- swagger_petstore_open_api_3_1_0/server/server_config.py +52 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The unset sentinel and the states it enables: omitted vs ``null`` vs a value.
|
|
2
|
+
|
|
3
|
+
:data:`UNSET` marks a field the caller never touched and :func:`strip_unset` is where "omitted" is
|
|
4
|
+
recovered at dump time, by dropping any key whose live value is still the sentinel. Two annotations
|
|
5
|
+
admit it, one per spelling a spec can declare: :data:`Optional` for a property that may be omitted,
|
|
6
|
+
:data:`OptionalNullable` for one that may be omitted *or* explicitly ``null``. See
|
|
7
|
+
``docs/designs/optional-nullable-fields.md``.
|
|
8
|
+
|
|
9
|
+
:data:`Optional` is deliberately **not** ``typing.Optional``. Here it means ``T | UnsetType`` --
|
|
10
|
+
present or absent, with no ``None`` arm -- where ``typing.Optional[T]`` means ``T | None``. They
|
|
11
|
+
differ by exactly the arm that matters: a property the spec does not declare nullable must not be
|
|
12
|
+
able to reach the wire as ``null``, so passing ``None`` to one of these fields is a type error
|
|
13
|
+
rather than a value that serializes. Nothing in a generated SDK imports ``typing.Optional`` -- unions
|
|
14
|
+
are spelled with PEP 604 ``|`` -- so the two never appear in the same module.
|
|
15
|
+
|
|
16
|
+
Both are plain generic aliases, not ``TypeAliasType``. pydantic substitutes a ``TypeAliasType``'s
|
|
17
|
+
parameter itself and folds ``Any | T`` into ``Any`` while doing so, which would take the sentinel arm
|
|
18
|
+
out of ``Optional[Any]`` -- the spelling of a property with no ``type``. ``typing`` substitutes a
|
|
19
|
+
plain alias and keeps the arm; mypy reads the two forms alike. See ADR-0071."""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, ClassVar, Final, Literal, TypeAlias, TypeVar, cast, final
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, GetCoreSchemaHandler
|
|
26
|
+
from pydantic_core import PydanticSerializationUnexpectedValue, core_schema
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@final
|
|
30
|
+
class UnsetType:
|
|
31
|
+
"""Marks a field the caller never touched, distinct from one set to ``None``.
|
|
32
|
+
|
|
33
|
+
Only ever seen as a field's default -- never constructed or passed by a caller. Dumps
|
|
34
|
+
as plain ``None`` through pydantic's normal fast path (see :func:`strip_unset` for
|
|
35
|
+
where the tri-state is actually recovered), and dumps nothing else: a value its union's
|
|
36
|
+
real arm failed to serialize is refused here, so it raises instead of going out as ``null``.
|
|
37
|
+
See ``docs/designs/optional-nullable-fields.md``.
|
|
38
|
+
|
|
39
|
+
A singleton, and identity-stable through every channel that could duplicate it: ``UnsetType()``
|
|
40
|
+
returns the existing instance, ``copy`` and ``deepcopy`` return it unchanged, and unpickling
|
|
41
|
+
reduces back through the constructor. Without that, ``model_copy(deep=True)`` would deep-copy
|
|
42
|
+
the sentinel into a *second* object, and ``field is UNSET`` -- the natural way to test for
|
|
43
|
+
it -- would start returning ``False`` on the copy. :func:`strip_unset` uses ``isinstance`` and
|
|
44
|
+
so is immune either way, but callers should not have to know that. ``final`` is the same
|
|
45
|
+
guarantee stated statically: a subclass would inherit ``_instance`` already set, so its
|
|
46
|
+
constructor would hand back a ``UnsetType`` rather than an instance of itself."""
|
|
47
|
+
|
|
48
|
+
_instance: ClassVar[UnsetType | None] = None
|
|
49
|
+
|
|
50
|
+
def __new__(cls) -> UnsetType:
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
cls._instance = super().__new__(cls)
|
|
53
|
+
return cls._instance
|
|
54
|
+
|
|
55
|
+
def __bool__(self) -> Literal[False]:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return "UNSET"
|
|
60
|
+
|
|
61
|
+
def __copy__(self) -> UnsetType:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> UnsetType:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __reduce__(self) -> tuple[type[UnsetType], tuple[()]]:
|
|
68
|
+
# Protocols >= 2 reduce via ``cls.__new__(cls)`` and reach the guard above by luck of
|
|
69
|
+
# protocol; 0 and 1 reconstruct through ``copyreg._reconstructor``, which calls
|
|
70
|
+
# ``object.__new__`` and bypasses ``__new__`` entirely -- measured, ``restored is UNSET``
|
|
71
|
+
# was ``False`` at both. Reducing to a plain call makes every protocol uniform.
|
|
72
|
+
return (UnsetType, ())
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
|
|
76
|
+
return core_schema.is_instance_schema(
|
|
77
|
+
cls, serialization=core_schema.plain_serializer_function_ser_schema(cls._dump)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _dump(value: object) -> None:
|
|
82
|
+
# pydantic offers a union's value to each arm until one serializes it. Answering None for
|
|
83
|
+
# anything made this arm the catch-all: a list its own arm failed on went out as null.
|
|
84
|
+
# PydanticSerializationUnexpectedValue is pydantic's "not this arm" signal.
|
|
85
|
+
if isinstance(value, UnsetType):
|
|
86
|
+
return None
|
|
87
|
+
raise PydanticSerializationUnexpectedValue(f"Expected UNSET, got {type(value).__name__}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
UNSET: Final = UnsetType()
|
|
91
|
+
|
|
92
|
+
T = TypeVar("T")
|
|
93
|
+
Optional: TypeAlias = T | UnsetType
|
|
94
|
+
OptionalNullable: TypeAlias = T | None | UnsetType
|
|
95
|
+
|
|
96
|
+
DumpedT = TypeVar("DumpedT")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def strip_unset(value: object, dumped: DumpedT) -> DumpedT:
|
|
100
|
+
"""Recursively drop any dumped key whose live field value is :class:`UnsetType`.
|
|
101
|
+
|
|
102
|
+
Walks ``value`` (the validated model, list or dict, pre-dump) alongside ``dumped`` (its
|
|
103
|
+
``mode="json"`` dump) so a field that dumped as ``None`` only because it defaulted to
|
|
104
|
+
``UNSET`` is omitted, while one explicitly set to ``None`` stays ``null``.
|
|
105
|
+
|
|
106
|
+
The mapping branch pairs the two by *key* rather than by position, because ``mode="json"``
|
|
107
|
+
renders a non-``str`` key as text (``{1: "x"}`` dumps as ``{"1": "x"}``): an entry the dump
|
|
108
|
+
renamed keeps its dumped form instead of misaligning the walk. ``list`` and ``dict`` are the
|
|
109
|
+
whole container set on purpose -- an array and a map are what a spec can express, so they are
|
|
110
|
+
the only shapes an emitted declared type nests, and they are the same two ``adapters.py``
|
|
111
|
+
descends. A ``tuple`` or ``set`` passes through like any other value.
|
|
112
|
+
|
|
113
|
+
Shape-preserving: a dict in, a dict out; a list in, a list out. The three ``cast`` calls
|
|
114
|
+
are that invariant, which mypy cannot verify through the rebuild -- they keep the
|
|
115
|
+
imprecision here rather than returning ``Any`` to every caller.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
value: The validated model, list or dict, before dumping -- the side that still
|
|
119
|
+
holds the sentinel.
|
|
120
|
+
dumped: That same value's ``mode="json"`` dump, walked alongside it.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
``dumped`` with every never-touched key removed, in the shape it arrived in."""
|
|
124
|
+
if isinstance(value, BaseModel):
|
|
125
|
+
result = dict(cast(Any, dumped))
|
|
126
|
+
for name, field in type(value).model_fields.items():
|
|
127
|
+
# ``dumped`` may be keyed by wire alias (the default) or by Python name
|
|
128
|
+
# (``to_dict(by_alias=False)``); prefer whichever spelling is actually there.
|
|
129
|
+
key = field.alias if field.alias is not None and field.alias in result else name
|
|
130
|
+
if key not in result:
|
|
131
|
+
continue
|
|
132
|
+
attr = getattr(value, name)
|
|
133
|
+
if isinstance(attr, UnsetType):
|
|
134
|
+
del result[key]
|
|
135
|
+
else:
|
|
136
|
+
result[key] = strip_unset(attr, result[key])
|
|
137
|
+
return cast(DumpedT, result)
|
|
138
|
+
if isinstance(value, list) and isinstance(dumped, list):
|
|
139
|
+
stripped = [strip_unset(item, d) for item, d in zip(value, dumped, strict=True)]
|
|
140
|
+
return cast(DumpedT, stripped)
|
|
141
|
+
if isinstance(value, dict) and isinstance(dumped, dict):
|
|
142
|
+
stripped_values = {key: strip_unset(value[key], item) if key in value else item for key, item in dumped.items()}
|
|
143
|
+
return cast(DumpedT, stripped_values)
|
|
144
|
+
return dumped
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Pagination: the five paging states, their factories, and the sync and async loops that walk them.
|
|
2
|
+
|
|
3
|
+
A state holds the value that moves from page to page (an offset, a page number, a cursor, a key, or the next
|
|
4
|
+
page's URL and query) and the selectors the endpoint uses to read the next move off a response. After each
|
|
5
|
+
successful page the loop calls ``advance``, which returns the next state, or ``None`` to stop.
|
|
6
|
+
|
|
7
|
+
States are frozen. ``advance`` returns a copy made with ``dataclasses.replace`` that changes only the moving
|
|
8
|
+
value, so the selectors stay with the walk. The stop rules, and the order they are checked in, match the C#
|
|
9
|
+
SDK. A state never advances to the value it just sent, so a server that keeps returning the same page ends
|
|
10
|
+
the walk instead of looping forever. A selector over an ``OptionalNullable`` field may return ``UNSET``;
|
|
11
|
+
every stop rule treats that as no answer.
|
|
12
|
+
|
|
13
|
+
An endpoint never names a state class. It calls a factory with the page model as a subscript, such as
|
|
14
|
+
``offset_state[Products](…)``. The subscript types the selectors, so a mistake inside one fails the build.
|
|
15
|
+
The factory returns the class itself, because Python 3.10 cannot construct a frozen, slotted dataclass
|
|
16
|
+
through a ``typing`` subscript. Calling a factory without its subscript fails the build.
|
|
17
|
+
|
|
18
|
+
``paginate`` and ``async_paginate`` run the loop: fetch the page for the current state, yield the result,
|
|
19
|
+
stop after a ``Failure``, then ask ``advance`` for the next state. The result is yielded before it is
|
|
20
|
+
checked, so a ``Failure`` is the last page the caller sees rather than one that is dropped. Each page is a
|
|
21
|
+
full ``execute``, so auth, retries, the idempotency key and ``request_options`` apply to every page.
|
|
22
|
+
Building a paginator sends nothing; the first request goes out when iteration starts, which is why neither
|
|
23
|
+
function is a coroutine.
|
|
24
|
+
|
|
25
|
+
Each function has one ``@overload`` per state class. The overload keeps the state's own class inside the
|
|
26
|
+
endpoint's ``fetch_page`` lambda, so ``state.offset`` resolves, and ties the state's page type to the
|
|
27
|
+
decoder's, so a mismatch fails the build. The implementation takes the union of all five states and mypy
|
|
28
|
+
checks it against every overload. A new strategy needs a new state, factory, pair of overloads and
|
|
29
|
+
``PagingState`` arm, and nothing else.
|
|
30
|
+
|
|
31
|
+
``results.unwrap`` turns a paginator of results into a paginator of payloads; that is the parsed mode."""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass, replace
|
|
37
|
+
from typing import Any, Final, Generic, TypeAlias, TypeVar, overload
|
|
38
|
+
from urllib.parse import parse_qsl, urljoin, urlsplit
|
|
39
|
+
|
|
40
|
+
from typing_extensions import TypeForm
|
|
41
|
+
|
|
42
|
+
from ._internal.subscripts import SubscriptOnly
|
|
43
|
+
from ._internal.urls import build_url
|
|
44
|
+
from .optionality import OptionalNullable, UnsetType
|
|
45
|
+
from .paginator import AsyncPaginator, Paginator
|
|
46
|
+
from .params import Param, SerializationFormat, UrlTemplate, param
|
|
47
|
+
from .results import ApiResult, Success, unwrap
|
|
48
|
+
|
|
49
|
+
PayloadT = TypeVar("PayloadT")
|
|
50
|
+
E = TypeVar("E")
|
|
51
|
+
ItemT = TypeVar("ItemT")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
55
|
+
class OffsetState(Generic[PayloadT]):
|
|
56
|
+
"""Moves by the number of items each page returned.
|
|
57
|
+
|
|
58
|
+
The next offset is the current offset (``None`` counts as ``0``) plus ``item_count``. The walk stops, in
|
|
59
|
+
this order: when the next offset reaches ``total_items``; when the page was empty; when ``has_more``
|
|
60
|
+
returns ``False``; or when there is no total and the page has fewer items than ``limit``. If ``has_more``
|
|
61
|
+
returns ``True``, the walk continues and the last rule is skipped."""
|
|
62
|
+
|
|
63
|
+
offset: int | None
|
|
64
|
+
limit: int | None
|
|
65
|
+
item_count: Callable[[PayloadT], int]
|
|
66
|
+
has_more: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[bool]] | None = None
|
|
67
|
+
total_items: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[int]] | None = None
|
|
68
|
+
|
|
69
|
+
def advance(self, *, payload: PayloadT, headers: Mapping[str, str]) -> OffsetState[PayloadT] | None:
|
|
70
|
+
"""Return the next state, or ``None`` when a stop rule applies."""
|
|
71
|
+
data_count = self.item_count(payload)
|
|
72
|
+
current_offset = self.offset if self.offset is not None else 0
|
|
73
|
+
next_offset = current_offset + data_count
|
|
74
|
+
total_items = self.total_items(payload, headers) if self.total_items is not None else None
|
|
75
|
+
if isinstance(total_items, int) and next_offset >= total_items:
|
|
76
|
+
return None
|
|
77
|
+
if next_offset == current_offset:
|
|
78
|
+
return None
|
|
79
|
+
has_more = self.has_more(payload, headers) if self.has_more is not None else None
|
|
80
|
+
if isinstance(has_more, bool):
|
|
81
|
+
return replace(self, offset=next_offset) if has_more else None
|
|
82
|
+
if not isinstance(total_items, int) and self.limit is not None and data_count < self.limit:
|
|
83
|
+
return None
|
|
84
|
+
return replace(self, offset=next_offset)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
88
|
+
class PageState(Generic[PayloadT]):
|
|
89
|
+
"""Moves by a 1-based page number.
|
|
90
|
+
|
|
91
|
+
The next page is the current page (``None`` counts as ``1``) plus one. The walk stops, in this order: when
|
|
92
|
+
the current page has reached ``total_pages``; when ``has_more`` returns ``False``; when the page was empty;
|
|
93
|
+
or when there is no total and the page has fewer items than ``limit``. If ``has_more`` returns ``True``,
|
|
94
|
+
the walk continues and the last two rules are skipped."""
|
|
95
|
+
|
|
96
|
+
page: int | None
|
|
97
|
+
limit: int | None
|
|
98
|
+
item_count: Callable[[PayloadT], int]
|
|
99
|
+
has_more: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[bool]] | None = None
|
|
100
|
+
total_pages: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[int]] | None = None
|
|
101
|
+
|
|
102
|
+
def advance(self, *, payload: PayloadT, headers: Mapping[str, str]) -> PageState[PayloadT] | None:
|
|
103
|
+
"""Return the next state, or ``None`` when a stop rule applies."""
|
|
104
|
+
total_pages = self.total_pages(payload, headers) if self.total_pages is not None else None
|
|
105
|
+
current_page = self.page if self.page is not None else 1
|
|
106
|
+
if isinstance(total_pages, int) and current_page >= total_pages:
|
|
107
|
+
return None
|
|
108
|
+
has_more = self.has_more(payload, headers) if self.has_more is not None else None
|
|
109
|
+
if isinstance(has_more, bool):
|
|
110
|
+
return replace(self, page=current_page + 1) if has_more else None
|
|
111
|
+
count = self.item_count(payload)
|
|
112
|
+
if count == 0:
|
|
113
|
+
return None
|
|
114
|
+
if not isinstance(total_pages, int) and self.limit is not None and count < self.limit:
|
|
115
|
+
return None
|
|
116
|
+
return replace(self, page=current_page + 1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
120
|
+
class CursorState(Generic[PayloadT]):
|
|
121
|
+
"""Moves by the cursor each page returns.
|
|
122
|
+
|
|
123
|
+
The walk stops when the next cursor is missing, empty or ``UNSET``, when it equals the cursor just sent,
|
|
124
|
+
or when ``has_more`` returns ``False``. There is no ``count < limit`` rule on purpose: a short page that
|
|
125
|
+
still has a cursor does not mean the collection has ended."""
|
|
126
|
+
|
|
127
|
+
cursor: str | None
|
|
128
|
+
next_cursor: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[str]]
|
|
129
|
+
has_more: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[bool]] | None = None
|
|
130
|
+
|
|
131
|
+
def advance(self, *, payload: PayloadT, headers: Mapping[str, str]) -> CursorState[PayloadT] | None:
|
|
132
|
+
"""Return the next state, or ``None`` when a stop rule applies."""
|
|
133
|
+
next_cursor = self.next_cursor(payload, headers)
|
|
134
|
+
if not next_cursor:
|
|
135
|
+
return None
|
|
136
|
+
if next_cursor == self.cursor:
|
|
137
|
+
return None
|
|
138
|
+
if self.has_more is not None and self.has_more(payload, headers) is False:
|
|
139
|
+
return None
|
|
140
|
+
return replace(self, cursor=next_cursor)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
144
|
+
class KeysetState(Generic[PayloadT]):
|
|
145
|
+
"""Moves by a key read from the page just received, usually the last item's id.
|
|
146
|
+
|
|
147
|
+
The walk stops, in this order: when the page was empty; when ``has_more`` returns ``False``; when
|
|
148
|
+
``has_more`` gives no answer and the page has fewer items than ``limit``; or when the next key is ``None``,
|
|
149
|
+
``UNSET`` or equal to the key just sent. ``""`` is a valid key, not a missing one."""
|
|
150
|
+
|
|
151
|
+
key: str | None
|
|
152
|
+
limit: int | None
|
|
153
|
+
item_count: Callable[[PayloadT], int]
|
|
154
|
+
next_key: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[str]]
|
|
155
|
+
has_more: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[bool]] | None = None
|
|
156
|
+
|
|
157
|
+
def advance(self, *, payload: PayloadT, headers: Mapping[str, str]) -> KeysetState[PayloadT] | None:
|
|
158
|
+
"""Return the next state, or ``None`` when a stop rule applies."""
|
|
159
|
+
count = self.item_count(payload)
|
|
160
|
+
if count == 0:
|
|
161
|
+
return None
|
|
162
|
+
has_more = self.has_more(payload, headers) if self.has_more is not None else None
|
|
163
|
+
if has_more is False:
|
|
164
|
+
return None
|
|
165
|
+
if not isinstance(has_more, bool) and self.limit is not None and count < self.limit:
|
|
166
|
+
return None
|
|
167
|
+
next_key = self.next_key(payload, headers)
|
|
168
|
+
if next_key is None or isinstance(next_key, UnsetType) or next_key == self.key:
|
|
169
|
+
return None
|
|
170
|
+
return replace(self, key=next_key)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
174
|
+
class LinkState(Generic[PayloadT]):
|
|
175
|
+
"""Moves by following the next-page link a response gives, in its body or its ``Link`` header.
|
|
176
|
+
|
|
177
|
+
``path_params`` fill the placeholders of the first ``url_template``. Following a link replaces
|
|
178
|
+
``url_template`` (with the link's path), ``path_params`` (with none, since that path is literal) and
|
|
179
|
+
``query_params`` (with its query). The link is resolved against the URL this state renders and must keep
|
|
180
|
+
its scheme, host and port; a link to another origin raises instead of being followed. The walk stops when
|
|
181
|
+
the next link is missing, empty or ``UNSET``, or when it renders to the same URL as this state."""
|
|
182
|
+
|
|
183
|
+
url_template: UrlTemplate
|
|
184
|
+
path_params: Sequence[Param[Any]] = ()
|
|
185
|
+
query_params: Sequence[Param[Any]]
|
|
186
|
+
next_link: Callable[[PayloadT, Mapping[str, str]], OptionalNullable[str]]
|
|
187
|
+
|
|
188
|
+
def advance(self, *, payload: PayloadT, headers: Mapping[str, str]) -> LinkState[PayloadT] | None:
|
|
189
|
+
"""Return the state for the page's next link, or ``None`` when the walk stops.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: If the link points to another origin, or its port is not a number."""
|
|
193
|
+
next_link = self.next_link(payload, headers)
|
|
194
|
+
if not next_link:
|
|
195
|
+
return None
|
|
196
|
+
current_url = build_url(self.url_template, path_params=self.path_params, query_params=self.query_params)
|
|
197
|
+
next_template, link_params = _resolve_next_link(current_url, next_link)
|
|
198
|
+
if build_url(next_template, query_params=link_params) == current_url:
|
|
199
|
+
return None
|
|
200
|
+
return replace(self, url_template=next_template, path_params=(), query_params=link_params)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_next_link(current_url: str, next_link: str) -> tuple[UrlTemplate, tuple[Param[Any], ...]]:
|
|
204
|
+
"""Resolve ``next_link`` against ``current_url`` and split it into a URL template and query parameters.
|
|
205
|
+
|
|
206
|
+
Scheme, host and port are compared as written; default ports are not filled in. Each query key becomes one
|
|
207
|
+
``Param[list[str]]`` rendered ``PLAIN``, so a repeated key keeps one pair per value, in the link's order.
|
|
208
|
+
Blank values are kept. A link with no path gets ``/``.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ValueError: If the resolved link's scheme, host or port differs from ``current_url``'s."""
|
|
212
|
+
current = urlsplit(current_url)
|
|
213
|
+
resolved = urlsplit(urljoin(current_url, next_link))
|
|
214
|
+
if resolved.scheme != current.scheme or resolved.hostname != current.hostname or resolved.port != current.port:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Cross-origin pagination link '{resolved.scheme}://{resolved.netloc}' does not match the origin of the "
|
|
217
|
+
f"URL just fetched, '{current.scheme}://{current.netloc}'."
|
|
218
|
+
)
|
|
219
|
+
grouped: dict[str, list[str]] = {}
|
|
220
|
+
for key, value in parse_qsl(resolved.query, keep_blank_values=True):
|
|
221
|
+
grouped.setdefault(key, []).append(value)
|
|
222
|
+
link_params = tuple(
|
|
223
|
+
param[list[str]](key, values, serialization_format=SerializationFormat.PLAIN) for key, values in grouped.items()
|
|
224
|
+
)
|
|
225
|
+
return UrlTemplate(base_url=f"{resolved.scheme}://{resolved.netloc}", path=resolved.path or "/"), link_params
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _OffsetStateFactory(SubscriptOnly):
|
|
229
|
+
"""``offset_state[PayloadT](…)`` builds a :class:`OffsetState` whose selectors read ``PayloadT``."""
|
|
230
|
+
|
|
231
|
+
__slots__ = ()
|
|
232
|
+
factory_name = "offset_state"
|
|
233
|
+
spelling = "offset_state[PayloadT](…), e.g. offset_state[Products](offset=offset, limit=limit, item_count=…)"
|
|
234
|
+
|
|
235
|
+
def __getitem__(self, declared: TypeForm[PayloadT]) -> type[OffsetState[PayloadT]]:
|
|
236
|
+
return OffsetState
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _PageStateFactory(SubscriptOnly):
|
|
240
|
+
"""``page_state[PayloadT](…)`` builds a :class:`PageState` whose selectors read ``PayloadT``."""
|
|
241
|
+
|
|
242
|
+
__slots__ = ()
|
|
243
|
+
factory_name = "page_state"
|
|
244
|
+
spelling = "page_state[PayloadT](…), e.g. page_state[Products](page=page, limit=limit, item_count=…)"
|
|
245
|
+
|
|
246
|
+
def __getitem__(self, declared: TypeForm[PayloadT]) -> type[PageState[PayloadT]]:
|
|
247
|
+
return PageState
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _CursorStateFactory(SubscriptOnly):
|
|
251
|
+
"""``cursor_state[PayloadT](…)`` builds a :class:`CursorState` whose selectors read ``PayloadT``."""
|
|
252
|
+
|
|
253
|
+
__slots__ = ()
|
|
254
|
+
factory_name = "cursor_state"
|
|
255
|
+
spelling = "cursor_state[PayloadT](…), e.g. cursor_state[Orders](cursor=cursor, next_cursor=…)"
|
|
256
|
+
|
|
257
|
+
def __getitem__(self, declared: TypeForm[PayloadT]) -> type[CursorState[PayloadT]]:
|
|
258
|
+
return CursorState
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class _KeysetStateFactory(SubscriptOnly):
|
|
262
|
+
"""``keyset_state[PayloadT](…)`` builds a :class:`KeysetState` whose selectors read ``PayloadT``."""
|
|
263
|
+
|
|
264
|
+
__slots__ = ()
|
|
265
|
+
factory_name = "keyset_state"
|
|
266
|
+
spelling = "keyset_state[PayloadT](…), e.g. keyset_state[CardTransactions](key=since, …)"
|
|
267
|
+
|
|
268
|
+
def __getitem__(self, declared: TypeForm[PayloadT]) -> type[KeysetState[PayloadT]]:
|
|
269
|
+
return KeysetState
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _LinkStateFactory(SubscriptOnly):
|
|
273
|
+
"""``link_state[PayloadT](…)`` builds a :class:`LinkState` whose selectors read ``PayloadT``."""
|
|
274
|
+
|
|
275
|
+
__slots__ = ()
|
|
276
|
+
factory_name = "link_state"
|
|
277
|
+
spelling = "link_state[PayloadT](…), e.g. link_state[Orders](url_template=…, query_params=…, next_link=…)"
|
|
278
|
+
|
|
279
|
+
def __getitem__(self, declared: TypeForm[PayloadT]) -> type[LinkState[PayloadT]]:
|
|
280
|
+
return LinkState
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
offset_state: Final = _OffsetStateFactory()
|
|
284
|
+
page_state: Final = _PageStateFactory()
|
|
285
|
+
cursor_state: Final = _CursorStateFactory()
|
|
286
|
+
keyset_state: Final = _KeysetStateFactory()
|
|
287
|
+
link_state: Final = _LinkStateFactory()
|
|
288
|
+
|
|
289
|
+
PagingState: TypeAlias = (
|
|
290
|
+
OffsetState[PayloadT] | PageState[PayloadT] | CursorState[PayloadT] | KeysetState[PayloadT] | LinkState[PayloadT]
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@overload
|
|
295
|
+
def paginate(
|
|
296
|
+
*,
|
|
297
|
+
initial_state: OffsetState[PayloadT],
|
|
298
|
+
fetch_page: Callable[[OffsetState[PayloadT]], ApiResult[PayloadT, E]],
|
|
299
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
300
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@overload
|
|
304
|
+
def paginate(
|
|
305
|
+
*,
|
|
306
|
+
initial_state: PageState[PayloadT],
|
|
307
|
+
fetch_page: Callable[[PageState[PayloadT]], ApiResult[PayloadT, E]],
|
|
308
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
309
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@overload
|
|
313
|
+
def paginate(
|
|
314
|
+
*,
|
|
315
|
+
initial_state: CursorState[PayloadT],
|
|
316
|
+
fetch_page: Callable[[CursorState[PayloadT]], ApiResult[PayloadT, E]],
|
|
317
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
318
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@overload
|
|
322
|
+
def paginate(
|
|
323
|
+
*,
|
|
324
|
+
initial_state: KeysetState[PayloadT],
|
|
325
|
+
fetch_page: Callable[[KeysetState[PayloadT]], ApiResult[PayloadT, E]],
|
|
326
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
327
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@overload
|
|
331
|
+
def paginate(
|
|
332
|
+
*,
|
|
333
|
+
initial_state: LinkState[PayloadT],
|
|
334
|
+
fetch_page: Callable[[LinkState[PayloadT]], ApiResult[PayloadT, E]],
|
|
335
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
336
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def paginate(
|
|
340
|
+
*,
|
|
341
|
+
initial_state: PagingState[PayloadT],
|
|
342
|
+
fetch_page: Callable[[Any], ApiResult[PayloadT, E]],
|
|
343
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
344
|
+
) -> Paginator[ApiResult[PayloadT, E], ItemT]:
|
|
345
|
+
"""Walk a paginated operation on the sync client, one ``execute`` per page.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
initial_state: The first page's state, built with a state factory such as
|
|
349
|
+
``offset_state[Products](…)``.
|
|
350
|
+
fetch_page: Sends the request for the state it is given and returns that page's ``ApiResult``.
|
|
351
|
+
items_selector: Returns one payload's items, in order. Iterating items unwraps each page first, so it
|
|
352
|
+
raises ``ApiError`` at a failed page.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
A lazy paginator that can be iterated more than once. Iterating it yields items. ``iter_pages()``
|
|
356
|
+
yields each ``ApiResult``, stops after a ``Failure`` and never raises."""
|
|
357
|
+
|
|
358
|
+
def fetch_pages() -> Iterator[ApiResult[PayloadT, E]]:
|
|
359
|
+
state = initial_state
|
|
360
|
+
while True:
|
|
361
|
+
result = fetch_page(state)
|
|
362
|
+
yield result
|
|
363
|
+
if not isinstance(result, Success):
|
|
364
|
+
return
|
|
365
|
+
next_state = state.advance(payload=result.payload, headers=result.headers)
|
|
366
|
+
if next_state is None:
|
|
367
|
+
return
|
|
368
|
+
state = next_state
|
|
369
|
+
|
|
370
|
+
return Paginator(fetch_pages, lambda result: items_selector(unwrap(result)))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@overload
|
|
374
|
+
def async_paginate(
|
|
375
|
+
*,
|
|
376
|
+
initial_state: OffsetState[PayloadT],
|
|
377
|
+
fetch_page: Callable[[OffsetState[PayloadT]], Awaitable[ApiResult[PayloadT, E]]],
|
|
378
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
379
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@overload
|
|
383
|
+
def async_paginate(
|
|
384
|
+
*,
|
|
385
|
+
initial_state: PageState[PayloadT],
|
|
386
|
+
fetch_page: Callable[[PageState[PayloadT]], Awaitable[ApiResult[PayloadT, E]]],
|
|
387
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
388
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@overload
|
|
392
|
+
def async_paginate(
|
|
393
|
+
*,
|
|
394
|
+
initial_state: CursorState[PayloadT],
|
|
395
|
+
fetch_page: Callable[[CursorState[PayloadT]], Awaitable[ApiResult[PayloadT, E]]],
|
|
396
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
397
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@overload
|
|
401
|
+
def async_paginate(
|
|
402
|
+
*,
|
|
403
|
+
initial_state: KeysetState[PayloadT],
|
|
404
|
+
fetch_page: Callable[[KeysetState[PayloadT]], Awaitable[ApiResult[PayloadT, E]]],
|
|
405
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
406
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@overload
|
|
410
|
+
def async_paginate(
|
|
411
|
+
*,
|
|
412
|
+
initial_state: LinkState[PayloadT],
|
|
413
|
+
fetch_page: Callable[[LinkState[PayloadT]], Awaitable[ApiResult[PayloadT, E]]],
|
|
414
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
415
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]: ...
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def async_paginate(
|
|
419
|
+
*,
|
|
420
|
+
initial_state: PagingState[PayloadT],
|
|
421
|
+
fetch_page: Callable[[Any], Awaitable[ApiResult[PayloadT, E]]],
|
|
422
|
+
items_selector: Callable[[PayloadT], Sequence[ItemT]],
|
|
423
|
+
) -> AsyncPaginator[ApiResult[PayloadT, E], ItemT]:
|
|
424
|
+
"""Walk a paginated operation on the async client, one awaited ``execute`` per page.
|
|
425
|
+
|
|
426
|
+
Works like :func:`paginate`, except that ``fetch_page`` returns an awaitable.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
initial_state: The first page's state, built with a state factory such as
|
|
430
|
+
``offset_state[Products](…)``.
|
|
431
|
+
fetch_page: Sends the request for the state it is given and returns an awaitable of that page's
|
|
432
|
+
``ApiResult``.
|
|
433
|
+
items_selector: Returns one payload's items, in order. Iterating items unwraps each page first, so it
|
|
434
|
+
raises ``ApiError`` at a failed page.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
A lazy async paginator that can be iterated more than once, with the same item and page views as the
|
|
438
|
+
sync one."""
|
|
439
|
+
|
|
440
|
+
async def fetch_pages() -> AsyncIterator[ApiResult[PayloadT, E]]:
|
|
441
|
+
state = initial_state
|
|
442
|
+
while True:
|
|
443
|
+
result = await fetch_page(state)
|
|
444
|
+
yield result
|
|
445
|
+
if not isinstance(result, Success):
|
|
446
|
+
return
|
|
447
|
+
next_state = state.advance(payload=result.payload, headers=result.headers)
|
|
448
|
+
if next_state is None:
|
|
449
|
+
return
|
|
450
|
+
state = next_state
|
|
451
|
+
|
|
452
|
+
return AsyncPaginator(fetch_pages, lambda result: items_selector(unwrap(result)))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The paginator a paginated endpoint returns: a lazy view over a walk of pages that can be iterated again.
|
|
2
|
+
|
|
3
|
+
A paginator holds a function that starts a new page iterator, never an iterator itself. So building one sends
|
|
4
|
+
nothing, the first request goes out when iteration starts, and each new iteration starts again from the
|
|
5
|
+
first page. Iterating yields items; ``iter_pages()`` yields pages.
|
|
6
|
+
|
|
7
|
+
``results`` imports this module, so this module must not import ``results``."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
PageT = TypeVar("PageT")
|
|
16
|
+
ItemT = TypeVar("ItemT")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Paginator(Generic[PageT, ItemT]):
|
|
21
|
+
"""A walk over a paginated operation. Iterate it for items, or call ``iter_pages()`` for pages.
|
|
22
|
+
|
|
23
|
+
Nothing is sent until iteration starts. Each iteration starts again from the first page, and a ``break``
|
|
24
|
+
stops it with no further request."""
|
|
25
|
+
|
|
26
|
+
_fetch_pages: Callable[[], Iterator[PageT]]
|
|
27
|
+
_items_selector: Callable[[PageT], Sequence[ItemT]]
|
|
28
|
+
|
|
29
|
+
def __iter__(self) -> Iterator[ItemT]:
|
|
30
|
+
for page in self._fetch_pages():
|
|
31
|
+
yield from self._items_selector(page)
|
|
32
|
+
|
|
33
|
+
def iter_pages(self) -> Iterator[PageT]:
|
|
34
|
+
"""Start a new walk and yield each page as it arrives."""
|
|
35
|
+
return self._fetch_pages()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class AsyncPaginator(Generic[PageT, ItemT]):
|
|
40
|
+
"""An async walk over a paginated operation. Use ``async for`` for items, or ``iter_pages()`` for pages.
|
|
41
|
+
|
|
42
|
+
Nothing is sent until iteration starts. Each iteration starts again from the first page, and a ``break``
|
|
43
|
+
stops it with no further request."""
|
|
44
|
+
|
|
45
|
+
_fetch_pages: Callable[[], AsyncIterator[PageT]]
|
|
46
|
+
_items_selector: Callable[[PageT], Sequence[ItemT]]
|
|
47
|
+
|
|
48
|
+
async def __aiter__(self) -> AsyncIterator[ItemT]:
|
|
49
|
+
async for page in self._fetch_pages():
|
|
50
|
+
for item in self._items_selector(page):
|
|
51
|
+
yield item
|
|
52
|
+
|
|
53
|
+
def iter_pages(self) -> AsyncIterator[PageT]:
|
|
54
|
+
"""Start a new walk and yield each page as it arrives."""
|
|
55
|
+
return self._fetch_pages()
|