pyholded 0.1.0__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.
pyholded/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """pyholded — a modular Python client and CLI for the complete Holded API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+
7
+ from ._registry import Endpoint, Resource
8
+ from .client import HoldedClient
9
+ from .config import Config, resolve_accounts, resolve_config, resolve_token
10
+ from .endpoints import REGISTRY
11
+ from .exceptions import (
12
+ APIError,
13
+ AuthenticationError,
14
+ ConfigError,
15
+ EndpointNotFoundError,
16
+ HoldedError,
17
+ NotFoundError,
18
+ RateLimitError,
19
+ )
20
+ from .multi import MultiClient
21
+ from .output import OutputFormat, render, to_json, to_toon
22
+
23
+
24
+ def _detect_version() -> str:
25
+ try:
26
+ return metadata.version("pyholded")
27
+ except metadata.PackageNotFoundError:
28
+ return "0.0.0"
29
+
30
+
31
+ __version__ = _detect_version()
32
+
33
+ __all__ = [
34
+ "REGISTRY",
35
+ "APIError",
36
+ "AuthenticationError",
37
+ "Config",
38
+ "ConfigError",
39
+ "Endpoint",
40
+ "EndpointNotFoundError",
41
+ "HoldedClient",
42
+ "HoldedError",
43
+ "MultiClient",
44
+ "NotFoundError",
45
+ "OutputFormat",
46
+ "RateLimitError",
47
+ "Resource",
48
+ "__version__",
49
+ "render",
50
+ "resolve_accounts",
51
+ "resolve_config",
52
+ "resolve_token",
53
+ "to_json",
54
+ "to_toon",
55
+ ]
pyholded/_proxies.py ADDED
@@ -0,0 +1,73 @@
1
+ """Attribute-based dispatch for the registry-driven clients.
2
+
3
+ These proxies turn ``caller.<resource>.<operation>(**kwargs)`` into a single
4
+ :meth:`call` on whatever caller they are bound to. They hold no API knowledge
5
+ of their own — the endpoint registry is the single source of truth — and work
6
+ identically for the single-account :class:`~pyholded.client.HoldedClient` and
7
+ the fan-out :class:`~pyholded.multi.MultiClient`, since both expose the same
8
+ :meth:`call` contract.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Protocol
14
+
15
+ from ._registry import Endpoint, Resource
16
+
17
+
18
+ class _Caller(Protocol):
19
+ """Anything the proxies can dispatch a registered operation to."""
20
+
21
+ def call(
22
+ self,
23
+ resource: str,
24
+ operation: str,
25
+ *,
26
+ path_params: dict[str, str] | None = ...,
27
+ params: dict[str, Any] | None = ...,
28
+ data: Any = ...,
29
+ paginate: bool = ...,
30
+ ) -> Any: ...
31
+
32
+
33
+ class ResourceProxy:
34
+ """Attribute access wrapper for a single resource's operations."""
35
+
36
+ def __init__(self, caller: _Caller, resource: Resource) -> None:
37
+ self._caller = caller
38
+ self._resource = resource
39
+
40
+ def __getattr__(self, name: str) -> OperationProxy:
41
+ endpoint = self._resource.get(name)
42
+ if endpoint is None:
43
+ raise AttributeError(name)
44
+ return OperationProxy(self._caller, self._resource.name, endpoint)
45
+
46
+ def __dir__(self) -> list[str]:
47
+ return [*super().__dir__(), *self._resource.operations]
48
+
49
+
50
+ class OperationProxy:
51
+ """A bound, callable API operation."""
52
+
53
+ def __init__(self, caller: _Caller, resource: str, endpoint: Endpoint) -> None:
54
+ self._caller = caller
55
+ self._resource = resource
56
+ self._endpoint = endpoint
57
+
58
+ def __call__(
59
+ self,
60
+ *,
61
+ params: dict[str, Any] | None = None,
62
+ data: Any = None,
63
+ paginate: bool = False,
64
+ **path_params: str,
65
+ ) -> Any:
66
+ return self._caller.call(
67
+ self._resource,
68
+ self._endpoint.name,
69
+ path_params=path_params,
70
+ params=params,
71
+ data=data,
72
+ paginate=paginate,
73
+ )
pyholded/_registry.py ADDED
@@ -0,0 +1,84 @@
1
+ """Declarative model for the Holded API surface.
2
+
3
+ Every endpoint is described once, as data. Both the high-level client
4
+ (:mod:`pyholded.client`) and the command-line interface (:mod:`pyholded.cli`)
5
+ are generated from this registry, so the API surface has a single source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from urllib.parse import quote
13
+
14
+ _PLACEHOLDER = re.compile(r"\{([^}]+)\}")
15
+
16
+ HTTPMethod = str # one of GET, POST, PUT, DELETE
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Endpoint:
21
+ """A single API operation within a resource."""
22
+
23
+ name: str
24
+ method: HTTPMethod
25
+ path: str
26
+ description: str = ""
27
+ query_params: tuple[str, ...] = ()
28
+ has_body: bool = False
29
+ binary: bool = False
30
+
31
+ @property
32
+ def path_params(self) -> tuple[str, ...]:
33
+ """Names of the ``{placeholder}`` segments in :attr:`path`."""
34
+ return tuple(_PLACEHOLDER.findall(self.path))
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Resource:
39
+ """A named group of related endpoints belonging to one API module."""
40
+
41
+ module: str
42
+ name: str
43
+ description: str
44
+ endpoints: tuple[Endpoint, ...]
45
+ operations: dict[str, Endpoint] = field(init=False, default_factory=dict, compare=False)
46
+
47
+ def __post_init__(self) -> None:
48
+ # frozen dataclass: populate the lookup table without reassigning the field.
49
+ self.operations.update({ep.name: ep for ep in self.endpoints})
50
+
51
+ def get(self, operation: str) -> Endpoint | None:
52
+ return self.operations.get(operation)
53
+
54
+
55
+ def build_index(resources: tuple[Resource, ...]) -> dict[str, Resource]:
56
+ """Index resources by name, rejecting duplicates."""
57
+ index: dict[str, Resource] = {}
58
+ for resource in resources:
59
+ if resource.name in index:
60
+ raise ValueError(f"Duplicate resource name: {resource.name}")
61
+ index[resource.name] = resource
62
+ return index
63
+
64
+
65
+ def render_path(template: str, path_params: dict[str, str]) -> str:
66
+ """Substitute ``{placeholders}`` in ``template`` with ``path_params`` values.
67
+
68
+ Each value is percent-encoded as a single path segment, so an id containing
69
+ ``/``, ``#`` or ``?`` cannot corrupt the request URL.
70
+ """
71
+ missing: list[str] = []
72
+
73
+ def _sub(match: re.Match[str]) -> str:
74
+ key = match.group(1)
75
+ value = path_params.get(key)
76
+ if value is None or value == "":
77
+ missing.append(key)
78
+ return ""
79
+ return quote(str(value), safe="")
80
+
81
+ rendered = _PLACEHOLDER.sub(_sub, template)
82
+ if missing:
83
+ raise KeyError(f"Missing path parameter(s): {', '.join(missing)}")
84
+ return rendered.lstrip("/")
pyholded/cli.py ADDED
@@ -0,0 +1,329 @@
1
+ """Command-line interface for the Holded API.
2
+
3
+ The CLI mirrors the endpoint registry: every resource is a command group and
4
+ every operation a subcommand whose path/query parameters are flags. Output is
5
+ selectable per invocation (``--output rich|json|toon``)::
6
+
7
+ holded invoices list --limit 50
8
+ holded contacts get --id 0123456789abcdef01234567 --output json
9
+ holded --account acme contacts list # one named account
10
+ holded --all-accounts contacts list # fan out to every account
11
+ holded accounts # list configured accounts
12
+ holded raw GET taxes --output toon
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import click
23
+
24
+ from ._registry import Endpoint, Resource
25
+ from .client import HoldedClient
26
+ from .config import resolve_accounts
27
+ from .endpoints import REGISTRY
28
+ from .exceptions import HoldedError
29
+ from .multi import MultiClient
30
+ from .output import OutputFormat, render
31
+
32
+
33
+ class _Context:
34
+ """Shared CLI state carried on the click context object."""
35
+
36
+ def __init__(
37
+ self,
38
+ token: str | None,
39
+ config: Path | None,
40
+ base_url: str | None,
41
+ output: str,
42
+ timeout: float,
43
+ account: str | None,
44
+ all_accounts: bool,
45
+ ) -> None:
46
+ self.token = token
47
+ self.config = config
48
+ self.base_url = base_url
49
+ self.output = OutputFormat(output)
50
+ self.timeout = timeout
51
+ self.account = account
52
+ self.all_accounts = all_accounts
53
+
54
+ def client(self) -> HoldedClient:
55
+ return HoldedClient(
56
+ self.token,
57
+ account=self.account,
58
+ base_url=self.base_url,
59
+ config_path=self.config,
60
+ timeout=self.timeout,
61
+ )
62
+
63
+ def multi(self) -> MultiClient:
64
+ return MultiClient.from_accounts(config_path=self.config, timeout=self.timeout)
65
+
66
+ def run_call(self, resource: str, operation: str, **kwargs: Any) -> Any:
67
+ """Run an operation on the selected account, or fan out to all accounts."""
68
+ if self.all_accounts:
69
+ with self.multi() as multi:
70
+ return multi.call(resource, operation, **kwargs)
71
+ with self.client() as client:
72
+ return client.call(resource, operation, **kwargs)
73
+
74
+ def run_request(self, method: str, path: str, **kwargs: Any) -> Any:
75
+ if self.all_accounts:
76
+ with self.multi() as multi:
77
+ return multi.request(method, path, **kwargs)
78
+ with self.client() as client:
79
+ return client.request(method, path, **kwargs)
80
+
81
+ def resolve_format(self, override: str | None) -> OutputFormat:
82
+ """Per-command --output wins over the group-level default."""
83
+ return OutputFormat(override) if override else self.output
84
+
85
+
86
+ _OUTPUT_CHOICES = [fmt.value for fmt in OutputFormat]
87
+
88
+
89
+ def _output_option() -> click.Option:
90
+ return click.Option(
91
+ ["-o", "--output"],
92
+ type=click.Choice(_OUTPUT_CHOICES),
93
+ default=None,
94
+ help="Output format (overrides the global default).",
95
+ )
96
+
97
+
98
+ def _to_flag(name: str) -> str:
99
+ out = [name[0].lower()]
100
+ for char in name[1:]:
101
+ if char.isupper():
102
+ out.append("-")
103
+ out.append(char.lower())
104
+ else:
105
+ out.append(char)
106
+ return "--" + "".join(out).replace("_", "-")
107
+
108
+
109
+ def _dest(name: str) -> str:
110
+ return _to_flag(name)[2:].replace("-", "_")
111
+
112
+
113
+ def _kebab(name: str) -> str:
114
+ return _to_flag(name)[2:]
115
+
116
+
117
+ def _parse_data(data: str | None, fields: tuple[str, ...]) -> Any:
118
+ body: Any = None
119
+ if data is not None:
120
+ if data.startswith("@"):
121
+ path = Path(data[1:])
122
+ try:
123
+ text = path.read_text(encoding="utf-8")
124
+ except OSError as exc:
125
+ raise click.BadParameter(f"cannot read {path}: {exc}", param_hint="--data") from exc
126
+ else:
127
+ text = data
128
+ try:
129
+ body = json.loads(text)
130
+ except json.JSONDecodeError as exc:
131
+ raise click.BadParameter(f"invalid JSON: {exc}", param_hint="--data") from exc
132
+ if fields:
133
+ merged: dict[str, str] = body if isinstance(body, dict) else {}
134
+ for item in fields:
135
+ key, _, value = item.partition("=")
136
+ merged[key] = value
137
+ body = merged
138
+ return body
139
+
140
+
141
+ def _operation_command(resource: Resource, endpoint: Endpoint) -> click.Command:
142
+ params: list[click.Parameter] = []
143
+ path_dests = {name: _dest(name) for name in endpoint.path_params}
144
+ query_dests = {name: _dest(name) for name in endpoint.query_params}
145
+
146
+ for name in endpoint.path_params:
147
+ params.append(click.Option([_to_flag(name)], required=True, help="path parameter"))
148
+ for name in endpoint.query_params:
149
+ params.append(click.Option([_to_flag(name)], required=False, help="query parameter"))
150
+ if endpoint.has_body:
151
+ params.append(
152
+ click.Option(
153
+ ["--data"],
154
+ required=False,
155
+ help="JSON request body, or @file.json to read from a file",
156
+ )
157
+ )
158
+ params.append(
159
+ click.Option(
160
+ ["--field", "fields"],
161
+ multiple=True,
162
+ help="body field as key=value (repeatable)",
163
+ )
164
+ )
165
+ paginates = endpoint.method == "GET" and not endpoint.binary
166
+ if paginates:
167
+ params.append(
168
+ click.Option(
169
+ ["--all", "fetch_all"],
170
+ is_flag=True,
171
+ help="follow the cursor and fetch every page",
172
+ )
173
+ )
174
+ params.append(_output_option())
175
+
176
+ def callback(**kwargs: Any) -> None:
177
+ ctx = click.get_current_context()
178
+ state: _Context = ctx.obj
179
+ path_params = {
180
+ name: kwargs[dest] for name, dest in path_dests.items() if kwargs.get(dest) is not None
181
+ }
182
+ query = {
183
+ name: kwargs[dest] for name, dest in query_dests.items() if kwargs.get(dest) is not None
184
+ }
185
+ data = (
186
+ _parse_data(kwargs.get("data"), kwargs.get("fields", ())) if endpoint.has_body else None
187
+ )
188
+ result = state.run_call(
189
+ resource.name,
190
+ endpoint.name,
191
+ path_params=path_params,
192
+ params=query or None,
193
+ data=data,
194
+ paginate=bool(kwargs.get("fetch_all")),
195
+ )
196
+ render(result, state.resolve_format(kwargs.get("output")))
197
+
198
+ return click.Command(
199
+ name=_kebab(endpoint.name),
200
+ params=params,
201
+ callback=callback,
202
+ help=endpoint.description or f"{endpoint.method} {endpoint.path}",
203
+ short_help=endpoint.description,
204
+ )
205
+
206
+
207
+ def _resource_group(resource: Resource) -> click.Group:
208
+ group = click.Group(
209
+ name=resource.name,
210
+ help=f"[{resource.module}] {resource.description}",
211
+ )
212
+ for endpoint in resource.endpoints:
213
+ group.add_command(_operation_command(resource, endpoint))
214
+ return group
215
+
216
+
217
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
218
+ @click.option("--token", envvar="HOLDED_TOKEN", help="Holded API token (PAT).")
219
+ @click.option("-a", "--account", help="Use a named account from env/config.")
220
+ @click.option("--all-accounts", is_flag=True, help="Run on every configured account.")
221
+ @click.option(
222
+ "--config",
223
+ type=click.Path(exists=False, dir_okay=False, path_type=Path),
224
+ help="Path to a TOML config file.",
225
+ )
226
+ @click.option("--base-url", help="Override the API base URL.")
227
+ @click.option(
228
+ "-o",
229
+ "--output",
230
+ type=click.Choice(_OUTPUT_CHOICES),
231
+ default=OutputFormat.RICH.value,
232
+ show_default=True,
233
+ help="Default output format.",
234
+ )
235
+ @click.option("--timeout", type=float, default=30.0, show_default=True, help="HTTP timeout (s).")
236
+ @click.pass_context
237
+ def cli(
238
+ ctx: click.Context,
239
+ token: str | None,
240
+ account: str | None,
241
+ all_accounts: bool,
242
+ config: Path | None,
243
+ base_url: str | None,
244
+ output: str,
245
+ timeout: float,
246
+ ) -> None:
247
+ """Modular command-line client for the complete Holded API."""
248
+ ctx.obj = _Context(token, config, base_url, output, timeout, account, all_accounts)
249
+
250
+
251
+ @cli.command(name="resources")
252
+ @click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
253
+ @click.pass_context
254
+ def list_resources(ctx: click.Context, output: str | None) -> None:
255
+ """List every resource and its operations."""
256
+ state: _Context = ctx.obj
257
+ overview = {
258
+ resource.name: {
259
+ "module": resource.module,
260
+ "operations": [ep.name for ep in resource.endpoints],
261
+ }
262
+ for resource in REGISTRY
263
+ }
264
+ render(overview, state.resolve_format(output))
265
+
266
+
267
+ @cli.command(name="raw")
268
+ @click.argument("method")
269
+ @click.argument("path")
270
+ @click.option("--param", "params", multiple=True, help="query parameter key=value (repeatable)")
271
+ @click.option("--data", help="JSON request body, or @file.json")
272
+ @click.option("--binary", is_flag=True, help="treat the response as raw bytes")
273
+ @click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
274
+ @click.pass_context
275
+ def raw(
276
+ ctx: click.Context,
277
+ method: str,
278
+ path: str,
279
+ params: tuple[str, ...],
280
+ data: str | None,
281
+ binary: bool,
282
+ output: str | None,
283
+ ) -> None:
284
+ """Call an arbitrary endpoint: METHOD and PATH (relative to the API base URL)."""
285
+ state: _Context = ctx.obj
286
+ query = dict(item.partition("=")[::2] for item in params) or None
287
+ body = _parse_data(data, ()) if data else None
288
+ result = state.run_request(method, path.lstrip("/"), params=query, json=body, binary=binary)
289
+ if binary and isinstance(result, bytes):
290
+ sys.stdout.buffer.write(result)
291
+ return
292
+ render(result, state.resolve_format(output))
293
+
294
+
295
+ @cli.command(name="accounts")
296
+ @click.option("-o", "--output", type=click.Choice(_OUTPUT_CHOICES), default=None)
297
+ @click.pass_context
298
+ def list_accounts(ctx: click.Context, output: str | None) -> None:
299
+ """List configured accounts (names and base URLs; tokens are never shown)."""
300
+ state: _Context = ctx.obj
301
+ accounts = resolve_accounts(state.config)
302
+ overview = [
303
+ {"account": name, "base_url": cfg.base_url} for name, cfg in sorted(accounts.items())
304
+ ]
305
+ render(overview, state.resolve_format(output))
306
+
307
+
308
+ def _register_resources() -> None:
309
+ for resource in REGISTRY:
310
+ cli.add_command(_resource_group(resource))
311
+
312
+
313
+ def main() -> None:
314
+ """Console-script entry point."""
315
+ _register_resources()
316
+ try:
317
+ cli.main(standalone_mode=False)
318
+ except HoldedError as exc:
319
+ click.echo(f"error: {exc}", err=True)
320
+ sys.exit(1)
321
+ except click.ClickException as exc:
322
+ exc.show()
323
+ sys.exit(exc.exit_code)
324
+ except click.exceptions.Abort:
325
+ sys.exit(130)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
pyholded/client.py ADDED
@@ -0,0 +1,159 @@
1
+ """High-level Holded client.
2
+
3
+ Resources and their operations are exposed as attributes generated from the
4
+ endpoint registry::
5
+
6
+ client = HoldedClient() # token from env or config file
7
+ invoices = client.invoices.list(params={"limit": 50})
8
+ contact = client.contacts.get(id="0123456789abcdef01234567")
9
+
10
+ Any endpoint can also be reached generically via :meth:`HoldedClient.request`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ._proxies import ResourceProxy
19
+ from ._registry import Endpoint, Resource, build_index, render_path
20
+ from .config import resolve_config
21
+ from .endpoints import REGISTRY
22
+ from .exceptions import EndpointNotFoundError
23
+ from .transport import DEFAULT_TIMEOUT, Transport
24
+
25
+
26
+ class HoldedClient:
27
+ """Entry point to the Holded API."""
28
+
29
+ def __init__(
30
+ self,
31
+ token: str | None = None,
32
+ *,
33
+ account: str | None = None,
34
+ base_url: str | None = None,
35
+ config_path: Path | None = None,
36
+ timeout: float = DEFAULT_TIMEOUT,
37
+ transport: Transport | None = None,
38
+ ) -> None:
39
+ config = resolve_config(token, base_url=base_url, config_path=config_path, account=account)
40
+ self._account = config.name
41
+ self._transport = transport or Transport(
42
+ config.token, base_url=config.base_url, timeout=timeout
43
+ )
44
+ self._resources = build_index(REGISTRY)
45
+
46
+ @property
47
+ def account(self) -> str:
48
+ """The name of the account this client is bound to."""
49
+ return self._account
50
+
51
+ @property
52
+ def resources(self) -> dict[str, Resource]:
53
+ """The full resource index, keyed by resource name."""
54
+ return self._resources
55
+
56
+ def request(
57
+ self,
58
+ method: str,
59
+ path: str,
60
+ *,
61
+ params: dict[str, Any] | None = None,
62
+ json: Any = None,
63
+ binary: bool = False,
64
+ ) -> Any:
65
+ """Call an arbitrary endpoint (escape hatch for anything not modelled)."""
66
+ return self._transport.request(method, path, params=params, json=json, binary=binary)
67
+
68
+ def call(
69
+ self,
70
+ resource: str,
71
+ operation: str,
72
+ *,
73
+ path_params: dict[str, str] | None = None,
74
+ params: dict[str, Any] | None = None,
75
+ data: Any = None,
76
+ paginate: bool = False,
77
+ ) -> Any:
78
+ """Invoke a registered operation by name (used by the CLI).
79
+
80
+ When ``paginate`` is true on a GET that returns a cursor-paginated
81
+ ``{items, cursor, has_more}`` body, every page is fetched and the
82
+ merged ``items`` list is returned.
83
+ """
84
+ endpoint = self._lookup(resource, operation)
85
+ path_params = path_params or {}
86
+ unknown = set(path_params) - set(endpoint.path_params)
87
+ if unknown:
88
+ raise TypeError(
89
+ f"unexpected argument(s) {sorted(unknown)} for {resource}.{operation}; "
90
+ f"pass query parameters via params=, the request body via data="
91
+ )
92
+ path = render_path(endpoint.path, path_params)
93
+ if paginate and endpoint.method == "GET" and not endpoint.binary:
94
+ return self._paginate(path, params)
95
+ return self._invoke(endpoint, path, params, data)
96
+
97
+ def close(self) -> None:
98
+ self._transport.close()
99
+
100
+ def __enter__(self) -> HoldedClient:
101
+ return self
102
+
103
+ def __exit__(self, *_exc: object) -> None:
104
+ self.close()
105
+
106
+ def __getattr__(self, name: str) -> ResourceProxy:
107
+ # Only reached for attributes not found normally.
108
+ resources = self.__dict__.get("_resources", {})
109
+ resource = resources.get(name)
110
+ if resource is None:
111
+ raise AttributeError(name)
112
+ return ResourceProxy(self, resource)
113
+
114
+ def __dir__(self) -> list[str]:
115
+ return [*super().__dir__(), *self._resources]
116
+
117
+ def _lookup(self, resource: str, operation: str) -> Endpoint:
118
+ res = self._resources.get(resource)
119
+ if res is None:
120
+ raise EndpointNotFoundError(f"Unknown resource: {resource}")
121
+ endpoint = res.get(operation)
122
+ if endpoint is None:
123
+ raise EndpointNotFoundError(f"Unknown operation '{operation}' on resource '{resource}'")
124
+ return endpoint
125
+
126
+ def _invoke(
127
+ self,
128
+ endpoint: Endpoint,
129
+ path: str,
130
+ params: dict[str, Any] | None,
131
+ data: Any,
132
+ ) -> Any:
133
+ body = data if endpoint.has_body else None
134
+ return self._transport.request(
135
+ endpoint.method,
136
+ path,
137
+ params=params,
138
+ json=body,
139
+ binary=endpoint.binary,
140
+ )
141
+
142
+ def _paginate(self, path: str, params: dict[str, Any] | None) -> list[Any]:
143
+ items: list[Any] = []
144
+ query = dict(params or {})
145
+ seen_cursors: set[str] = set()
146
+ while True:
147
+ page = self._transport.request("GET", path, params=query)
148
+ if not isinstance(page, dict) or "items" not in page:
149
+ # Not a paginated shape; hand the raw body back as a single item.
150
+ return [page]
151
+ items.extend(page["items"])
152
+ cursor = page.get("cursor")
153
+ if not page.get("has_more") or not cursor:
154
+ return items
155
+ # Defend against an API that keeps returning the same cursor.
156
+ if cursor in seen_cursors:
157
+ return items
158
+ seen_cursors.add(cursor)
159
+ query["cursor"] = cursor