icedcoffee-sync 0.1.0.dev5__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.
@@ -0,0 +1,539 @@
1
+ """Synchronous and asynchronous Frappe clients.
2
+
3
+ Neither wraps the other. Request construction and response parsing are pure
4
+ functions shared by both, so only the send differs. A synchronous facade over
5
+ the async client would need `asyncio.run()`, which fails inside a marimo
6
+ notebook -- and notebooks are a first-class consumer.
7
+ """
8
+
9
+ import dataclasses
10
+ from collections.abc import AsyncIterator, Iterator
11
+ from typing import Any, Self
12
+
13
+ import httpx2
14
+
15
+ from icedcoffee_sync.frappe import request as req
16
+ from icedcoffee_sync.frappe.lifecycle import SubmitNotPermittedError
17
+ from icedcoffee_sync.frappe.response import envelope, has_next_page, unwrap
18
+
19
+ # Both clients define a public `list` method, which shadows the builtin
20
+ # `list` type within their class bodies under Python 3.14's lazy annotation
21
+ # evaluation (PEP 649): any method annotated with a bare `list[...]` after
22
+ # that point resolves it to the method, not the type, and blows up the first
23
+ # time something evaluates the annotation. A module-level alias sidesteps it.
24
+ _Rows = list[dict[str, Any]]
25
+
26
+ # Whitelisted server methods that commit a document to the ledger (move it
27
+ # off docstatus 0). `call()` is a generic passthrough to any dotted server
28
+ # method, so without this it would be a way around `submit`/`cancel` entirely
29
+ # -- the guard has to sit on the method name, not just the friendly wrapper.
30
+ #
31
+ # This is a guardrail against an accidental submission from client code, not
32
+ # a sandbox: the list can't be exhaustive, since a Frappe app can whitelist
33
+ # its own committing methods this client has no way to know about. The real
34
+ # boundary is the API key's role permissions on the server.
35
+ _COMMITTING_METHODS = frozenset({
36
+ "frappe.client.submit",
37
+ "frappe.client.cancel",
38
+ # Applies a workflow action, which calls doc.submit() when the action
39
+ # moves a draft to a submitted state.
40
+ "frappe.model.workflow.apply_workflow",
41
+ # Dotted-path twins of insert/update, with the same docstatus mechanics
42
+ # guarded by `_require_no_docstatus` below.
43
+ "frappe.client.insert",
44
+ "frappe.client.save",
45
+ })
46
+
47
+
48
+ class FileDownloadError(RuntimeError):
49
+ """A site file could not be fetched, or was larger than the caller allows."""
50
+
51
+
52
+ _FILE_PREFIXES = ("/files/", "/private/files/")
53
+
54
+ # A scheme's port when a URL names none explicitly. httpx2.URL already
55
+ # collapses an *explicit* default (":443" on an https URL) down to `None`
56
+ # for `.port`, so comparing `.port` alone already treats an implicit and an
57
+ # explicit default the same -- this table is what makes that comparison
58
+ # explicit and independent of that normalization, rather than relying on it.
59
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
60
+
61
+
62
+ def _effective_port(url: httpx2.URL) -> int | None:
63
+ """`url`'s port, or its scheme's default when none was given -- so an implicit and an explicit default port compare equal."""
64
+ return url.port if url.port is not None else _DEFAULT_PORTS.get(url.scheme)
65
+
66
+
67
+ def _validate_file_url(file_url: str, site_url: str) -> None:
68
+ """Validate a file_url before downloading.
69
+
70
+ Rejects paths with query strings, fragments, backslashes, or dot-segments
71
+ (both literal and percent-encoded), then builds the full URL and checks
72
+ that its normalized scheme, host, port, and path are safe.
73
+ """
74
+ # The raw file_url must itself start with an allowed prefix -- before it
75
+ # is glued onto site_url and parsed. Without this, a file_url like
76
+ # ":8443/files/x" has no leading "/" at all: concatenated onto
77
+ # "https://site.example" it becomes "https://site.example:8443/files/x",
78
+ # a PORT change rather than a path. The *parsed* URL's host still matches
79
+ # and its path still starts with "/files/", so the checks below alone
80
+ # would wave it through onto a different origin at the same hostname.
81
+ if not file_url.startswith(_FILE_PREFIXES):
82
+ raise ValueError(f"not a site file path: {file_url!r}")
83
+
84
+ # Reject obviously bad characters before parsing
85
+ if "?" in file_url or "#" in file_url or "\\" in file_url:
86
+ raise ValueError(f"not a site file path: {file_url!r}")
87
+
88
+ # Reject literal dot-segments like /.. or /./ as path components
89
+ # (before URL parsing, to catch them before normalization)
90
+ parts = file_url.split("/")
91
+ if "." in parts or ".." in parts or any("%2e" in part for part in parts):
92
+ raise ValueError(f"not a site file path: {file_url!r}")
93
+
94
+ # Build the full URL and parse it
95
+ full_url = f"{site_url.rstrip('/')}{file_url}"
96
+ url = httpx2.URL(full_url)
97
+
98
+ # Extract the site's host from its URL
99
+ site_url_obj = httpx2.URL(site_url)
100
+
101
+ # Check scheme, host, and effective port all match the configured site.
102
+ # Host alone is not enough: an off-port file_url like ":8443/files/x"
103
+ # (caught above already, but the check stays here as the real boundary
104
+ # the request itself crosses) leaves the host unchanged while sending
105
+ # the credential to a different origin.
106
+ if (
107
+ url.scheme != site_url_obj.scheme
108
+ or url.host != site_url_obj.host
109
+ or _effective_port(url) != _effective_port(site_url_obj)
110
+ ):
111
+ raise ValueError(f"not a site file path: {file_url!r}")
112
+
113
+ # Check normalized path starts with allowed prefixes and has no dot-segments
114
+ path = url.path
115
+ if not path.startswith(_FILE_PREFIXES):
116
+ raise ValueError(f"not a site file path: {file_url!r}")
117
+
118
+ # Check for dot-segments in the normalized path (catches %2e%2e that gets decoded)
119
+ path_parts = path.split("/")
120
+ if "." in path_parts or ".." in path_parts:
121
+ raise ValueError(f"not a site file path: {file_url!r}")
122
+
123
+
124
+ @dataclasses.dataclass(frozen=True)
125
+ class SiteConfig:
126
+ """Where a Frappe site is and how to authenticate to it.
127
+
128
+ The URL is always supplied by the caller. Nothing here defaults to a
129
+ production host.
130
+
131
+ `may_submit` is off by default: a client must be deliberately constructed
132
+ with the capability before `submit`/`cancel`, a `call` naming a known
133
+ committing method, or an `insert`/`update` whose document body sets
134
+ `docstatus`, will run. This is a guardrail against an accidental
135
+ submission from client code, not a security boundary -- the real
136
+ boundary is the API key's role permissions on the server.
137
+ """
138
+
139
+ url: str
140
+ # Kept out of the generated repr: a dataclass prints every field, and this
141
+ # object turns up in log lines, tracebacks, and failing pytest assertions.
142
+ # A reusable Frappe credential should not ride along in any of them.
143
+ api_key: str = dataclasses.field(repr=False)
144
+ api_secret: str = dataclasses.field(repr=False)
145
+ timeout: float = 30.0
146
+ may_submit: bool = False
147
+
148
+
149
+ def _reject_reserved_pagination_kwargs(kwargs: dict[str, Any]) -> None:
150
+ """Refuse query options `iter_all` supplies itself.
151
+
152
+ `iter_all` owns `limit` and `start` -- it walks the pages. Forwarding a
153
+ caller's copy alongside its own produced a duplicate-keyword `TypeError`
154
+ from deep inside the pager, which tells the caller nothing about what they
155
+ did wrong. `page_size` is the knob they wanted.
156
+ """
157
+ reserved = sorted(k for k in ("limit", "start") if k in kwargs)
158
+ if reserved:
159
+ raise TypeError(f"iter_all controls {', '.join(reserved)}; use page_size to size each request")
160
+
161
+
162
+ def _require_positive_page_size(page_size: int) -> None:
163
+ """Refuse a page size that cannot page.
164
+
165
+ A zero or negative size asks the server for nothing, and the empty result
166
+ ends the loop on its first pass -- so `iter_all` returns silently having
167
+ fetched no documents, which reads exactly like an empty doctype.
168
+ """
169
+ if page_size < 1:
170
+ raise ValueError(f"page_size must be at least 1, got {page_size}")
171
+
172
+
173
+ def _merge_headers(config: SiteConfig, request: req.Request) -> dict[str, str]:
174
+ """Caller headers, then the credential, which nothing may displace.
175
+
176
+ Dropped case-insensitively: HTTP header names are case-insensitive, so a
177
+ request carrying `authorization` would otherwise sit alongside the real
178
+ `Authorization` rather than being overridden by it -- two credentials on
179
+ one request, with the server free to pick either.
180
+ """
181
+ headers = {"Accept": "application/json"}
182
+ headers.update({k: v for k, v in request.headers.items() if k.lower() != "authorization"})
183
+ headers.update(req.auth_header(config.api_key, config.api_secret))
184
+ return headers
185
+
186
+
187
+ def _send_args(config: SiteConfig, request: req.Request) -> dict[str, Any]:
188
+ return {
189
+ "method": request.method,
190
+ "url": f"{config.url.rstrip('/')}{request.path}",
191
+ "params": request.params or None,
192
+ "headers": _merge_headers(config, request),
193
+ "json": request.json_body,
194
+ "data": request.data,
195
+ "files": request.files,
196
+ }
197
+
198
+
199
+ class Client:
200
+ """Synchronous Frappe client."""
201
+
202
+ def __init__(self, config: SiteConfig, *, transport: httpx2.BaseTransport | None = None) -> None:
203
+ self._config = config
204
+ self._http = httpx2.Client(timeout=config.timeout, transport=transport)
205
+
206
+ def __enter__(self) -> Self:
207
+ return self
208
+
209
+ def __exit__(self, *exc: object) -> None:
210
+ self.close()
211
+
212
+ def close(self) -> None:
213
+ """Release the underlying connection pool."""
214
+ self._http.close()
215
+
216
+ def _run(self, request: req.Request) -> Any:
217
+ response = self._http.request(**_send_args(self._config, request))
218
+ return unwrap(response.status_code, response.text)
219
+
220
+ def list(self, doctype: str, **kwargs: Any) -> Any:
221
+ """List documents of a doctype."""
222
+ return self._run(req.list_documents(doctype, **kwargs))
223
+
224
+ def get(self, doctype: str, name: str) -> Any:
225
+ """Fetch one document by name."""
226
+ return self._run(req.get_document(doctype, name))
227
+
228
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any:
229
+ """Create a document.
230
+
231
+ Refuses locally, before anything is sent, if `doc` sets `docstatus`
232
+ and this client lacks submit capability: Frappe's own insert path
233
+ (`Document.check_docstatus_transition`) submits a document created
234
+ with `docstatus: 1` directly, with no separate `submit()` call.
235
+ """
236
+ self._require_no_docstatus(doc)
237
+ return self._run(req.insert_document(doctype, doc))
238
+
239
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any:
240
+ """Update a document.
241
+
242
+ Refuses locally, before anything is sent, if `doc` sets `docstatus`
243
+ and this client lacks submit capability -- same mechanism as
244
+ `insert`.
245
+ """
246
+ self._require_no_docstatus(doc)
247
+ return self._run(req.update_document(doctype, name, doc))
248
+
249
+ def delete(self, doctype: str, name: str) -> Any:
250
+ """Delete a document."""
251
+ return self._run(req.delete_document(doctype, name))
252
+
253
+ def upload_file(
254
+ self,
255
+ doctype: str,
256
+ docname: str,
257
+ filename: str,
258
+ content: bytes,
259
+ *,
260
+ is_private: bool = True,
261
+ folder: str | None = None,
262
+ ) -> Any:
263
+ """Attach a file to a document, returning the created `File` document.
264
+
265
+ Takes the content as bytes rather than a path -- the common caller
266
+ already has it in memory (an email attachment, a generated PDF), and
267
+ routing that through a temp file first would be pure ceremony.
268
+ Private by default: an attachment is financial evidence more often
269
+ than not, and a public URL is the wrong default for that.
270
+ """
271
+ return self._run(req.upload_file(doctype, docname, filename, content, is_private=is_private, folder=folder))
272
+
273
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any:
274
+ """Call a whitelisted server method.
275
+
276
+ Refuses locally, before anything is sent, when `dotted_path` names a
277
+ known committing method (e.g. `frappe.client.submit`) and this client
278
+ lacks submit capability -- `submit`/`cancel` are sugar over this, and
279
+ the guard has to hold on this route too.
280
+ """
281
+ self._require_submit(dotted_path)
282
+ return self._run(req.call_method(dotted_path, params))
283
+
284
+ def _require_submit(self, dotted_path: str | None = None) -> None:
285
+ if self._config.may_submit:
286
+ return
287
+ # Called with no path (from submit/cancel directly), this always
288
+ # refuses. Called from `call()`, it refuses only for a method known
289
+ # to commit -- everything else passes through unchecked.
290
+ if dotted_path is not None and dotted_path not in _COMMITTING_METHODS:
291
+ return
292
+ raise SubmitNotPermittedError(
293
+ "this client was constructed without submit capability; machines create drafts, humans submit"
294
+ )
295
+
296
+ def _require_no_docstatus(self, doc: dict[str, Any]) -> None:
297
+ # A drafts-only client has no legitimate reason to set docstatus at
298
+ # all -- a draft is 0, the default when the key is absent -- so this
299
+ # refuses on the key's presence, not its value. `docstatus: 0` is
300
+ # refused too: the rule is "don't touch this field", not "don't set
301
+ # it to something dangerous".
302
+ if not self._config.may_submit and "docstatus" in doc:
303
+ raise SubmitNotPermittedError("this client cannot set docstatus; machines create drafts, humans submit")
304
+
305
+ def submit(self, doc: dict[str, Any]) -> Any:
306
+ """Submit a draft document, moving it to docstatus 1.
307
+
308
+ Takes the whole document rather than a doctype and name, because
309
+ Frappe's `frappe.client.submit` does -- unlike `cancel`, which takes
310
+ the pair. The asymmetry is Frappe's, mirrored here rather than papered
311
+ over.
312
+ """
313
+ self._require_submit()
314
+ return self.call("frappe.client.submit", {"doc": doc})
315
+
316
+ def cancel(self, doctype: str, name: str) -> Any:
317
+ """Cancel a submitted document, moving it to docstatus 2."""
318
+ self._require_submit()
319
+ return self.call("frappe.client.cancel", {"doctype": doctype, "name": name})
320
+
321
+ def list_page(self, doctype: str, limit: int, start: int, **kwargs: Any) -> tuple[_Rows, bool | None]:
322
+ """One page of documents, plus whether the server says more remain.
323
+
324
+ The primitive under `iter_all`, public for callers that page by cursor
325
+ rather than offset: the flag is how they tell a page cut short by a
326
+ server cap from the end of the result.
327
+ """
328
+ request = req.list_documents(doctype, limit=limit, start=start, **kwargs)
329
+ response = self._http.request(**_send_args(self._config, request))
330
+ payload = envelope(response.status_code, response.text)
331
+ rows = payload["data"] if isinstance(payload, dict) and "data" in payload else payload
332
+ return rows, has_next_page(payload)
333
+
334
+ def iter_all(self, doctype: str, *, page_size: int = 100, **kwargs: Any) -> Iterator[dict[str, Any]]:
335
+ """Yield every matching document, paging until the server says stop.
336
+
337
+ Callers that page by hand eventually forget to, and a truncated read
338
+ looks exactly like a small result set. v2 states whether more pages
339
+ exist, so prefer that; fall back to the short-page heuristic only when
340
+ the flag is absent, as on v1 responses.
341
+ """
342
+ _require_positive_page_size(page_size)
343
+ _reject_reserved_pagination_kwargs(kwargs)
344
+ start = 0
345
+ while True:
346
+ rows, more = self.list_page(doctype, page_size, start, **kwargs)
347
+ if not rows:
348
+ return
349
+ yield from rows
350
+ if more is False:
351
+ return
352
+ if more is None and len(rows) < page_size:
353
+ return
354
+ # Advance by what the server actually sent, not what was asked
355
+ # for: a server that caps page size below `page_size` can return
356
+ # fewer rows while still reporting has_next_page: true, and
357
+ # advancing by page_size would skip the rows it never sent.
358
+ start += len(rows)
359
+
360
+ def download_file(self, file_url: str, *, max_bytes: int) -> bytes:
361
+ """The bytes a `File` document's `file_url` names, private files included.
362
+
363
+ Only site-relative `/files/` and `/private/files/` paths: a `file_url`
364
+ is data a person can edit, and following it off-site would hand the
365
+ credential to whoever it names. Validates against dot-segments and
366
+ off-site URLs before sending any request. Streamed and capped at
367
+ `max_bytes`.
368
+ """
369
+ _validate_file_url(file_url, self._config.url)
370
+ args = _send_args(self._config, req.Request("GET", file_url))
371
+ with self._http.stream("GET", args["url"], headers=args["headers"]) as response:
372
+ if response.status_code != 200:
373
+ raise FileDownloadError(f"{file_url}: HTTP {response.status_code}")
374
+ chunks: list[bytes] = []
375
+ size = 0
376
+ for chunk in response.iter_bytes():
377
+ size += len(chunk)
378
+ if size > max_bytes:
379
+ raise FileDownloadError(f"{file_url}: larger than {max_bytes} bytes")
380
+ chunks.append(chunk)
381
+ return b"".join(chunks)
382
+
383
+
384
+ class AsyncClient:
385
+ """Asynchronous Frappe client. Same surface as `Client`, awaitable."""
386
+
387
+ def __init__(self, config: SiteConfig, *, transport: httpx2.AsyncBaseTransport | None = None) -> None:
388
+ self._config = config
389
+ self._http = httpx2.AsyncClient(timeout=config.timeout, transport=transport)
390
+
391
+ async def __aenter__(self) -> Self:
392
+ return self
393
+
394
+ async def __aexit__(self, *exc: object) -> None:
395
+ await self.close()
396
+
397
+ async def close(self) -> None:
398
+ """Release the underlying connection pool."""
399
+ await self._http.aclose()
400
+
401
+ async def _run(self, request: req.Request) -> Any:
402
+ response = await self._http.request(**_send_args(self._config, request))
403
+ return unwrap(response.status_code, response.text)
404
+
405
+ async def list(self, doctype: str, **kwargs: Any) -> Any:
406
+ """List documents of a doctype."""
407
+ return await self._run(req.list_documents(doctype, **kwargs))
408
+
409
+ async def get(self, doctype: str, name: str) -> Any:
410
+ """Fetch one document by name."""
411
+ return await self._run(req.get_document(doctype, name))
412
+
413
+ async def insert(self, doctype: str, doc: dict[str, Any]) -> Any:
414
+ """Create a document.
415
+
416
+ Refuses locally, before anything is sent, if `doc` sets `docstatus`
417
+ and this client lacks submit capability: Frappe's own insert path
418
+ (`Document.check_docstatus_transition`) submits a document created
419
+ with `docstatus: 1` directly, with no separate `submit()` call.
420
+ """
421
+ self._require_no_docstatus(doc)
422
+ return await self._run(req.insert_document(doctype, doc))
423
+
424
+ async def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any:
425
+ """Update a document.
426
+
427
+ Refuses locally, before anything is sent, if `doc` sets `docstatus`
428
+ and this client lacks submit capability -- same mechanism as
429
+ `insert`.
430
+ """
431
+ self._require_no_docstatus(doc)
432
+ return await self._run(req.update_document(doctype, name, doc))
433
+
434
+ async def delete(self, doctype: str, name: str) -> Any:
435
+ """Delete a document."""
436
+ return await self._run(req.delete_document(doctype, name))
437
+
438
+ async def upload_file(
439
+ self,
440
+ doctype: str,
441
+ docname: str,
442
+ filename: str,
443
+ content: bytes,
444
+ *,
445
+ is_private: bool = True,
446
+ folder: str | None = None,
447
+ ) -> Any:
448
+ """Attach a file to a document, returning the created `File` document.
449
+
450
+ Takes the content as bytes rather than a path -- the common caller
451
+ already has it in memory (an email attachment, a generated PDF), and
452
+ routing that through a temp file first would be pure ceremony.
453
+ Private by default: an attachment is financial evidence more often
454
+ than not, and a public URL is the wrong default for that.
455
+ """
456
+ return await self._run(
457
+ req.upload_file(doctype, docname, filename, content, is_private=is_private, folder=folder)
458
+ )
459
+
460
+ async def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any:
461
+ """Call a whitelisted server method.
462
+
463
+ Refuses locally, before anything is sent, when `dotted_path` names a
464
+ known committing method (e.g. `frappe.client.submit`) and this client
465
+ lacks submit capability -- `submit`/`cancel` are sugar over this, and
466
+ the guard has to hold on this route too.
467
+ """
468
+ self._require_submit(dotted_path)
469
+ return await self._run(req.call_method(dotted_path, params))
470
+
471
+ def _require_submit(self, dotted_path: str | None = None) -> None:
472
+ if self._config.may_submit:
473
+ return
474
+ # Called with no path (from submit/cancel directly), this always
475
+ # refuses. Called from `call()`, it refuses only for a method known
476
+ # to commit -- everything else passes through unchecked.
477
+ if dotted_path is not None and dotted_path not in _COMMITTING_METHODS:
478
+ return
479
+ raise SubmitNotPermittedError(
480
+ "this client was constructed without submit capability; machines create drafts, humans submit"
481
+ )
482
+
483
+ def _require_no_docstatus(self, doc: dict[str, Any]) -> None:
484
+ # A drafts-only client has no legitimate reason to set docstatus at
485
+ # all -- a draft is 0, the default when the key is absent -- so this
486
+ # refuses on the key's presence, not its value. `docstatus: 0` is
487
+ # refused too: the rule is "don't touch this field", not "don't set
488
+ # it to something dangerous".
489
+ if not self._config.may_submit and "docstatus" in doc:
490
+ raise SubmitNotPermittedError("this client cannot set docstatus; machines create drafts, humans submit")
491
+
492
+ async def submit(self, doc: dict[str, Any]) -> Any:
493
+ """Submit a draft document, moving it to docstatus 1.
494
+
495
+ Takes the whole document rather than a doctype and name, because
496
+ Frappe's `frappe.client.submit` does -- unlike `cancel`, which takes
497
+ the pair. The asymmetry is Frappe's, mirrored here rather than papered
498
+ over.
499
+ """
500
+ self._require_submit()
501
+ return await self.call("frappe.client.submit", {"doc": doc})
502
+
503
+ async def cancel(self, doctype: str, name: str) -> Any:
504
+ """Cancel a submitted document, moving it to docstatus 2."""
505
+ self._require_submit()
506
+ return await self.call("frappe.client.cancel", {"doctype": doctype, "name": name})
507
+
508
+ async def list_page(self, doctype: str, limit: int, start: int, **kwargs: Any) -> tuple[_Rows, bool | None]:
509
+ """One page of documents, plus whether the server says more remain."""
510
+ request = req.list_documents(doctype, limit=limit, start=start, **kwargs)
511
+ response = await self._http.request(**_send_args(self._config, request))
512
+ payload = envelope(response.status_code, response.text)
513
+ rows = payload["data"] if isinstance(payload, dict) and "data" in payload else payload
514
+ return rows, has_next_page(payload)
515
+
516
+ async def iter_all(self, doctype: str, *, page_size: int = 100, **kwargs: Any) -> AsyncIterator[dict[str, Any]]:
517
+ """Yield every matching document, paging until the server says stop.
518
+
519
+ Mirrors the sync twin exactly: prefer the server's `has_next_page`, and
520
+ fall back to the short-page heuristic only when the flag is absent.
521
+ """
522
+ _require_positive_page_size(page_size)
523
+ _reject_reserved_pagination_kwargs(kwargs)
524
+ start = 0
525
+ while True:
526
+ rows, more = await self.list_page(doctype, page_size, start, **kwargs)
527
+ if not rows:
528
+ return
529
+ for row in rows:
530
+ yield row
531
+ if more is False:
532
+ return
533
+ if more is None and len(rows) < page_size:
534
+ return
535
+ # Advance by what the server actually sent, not what was asked
536
+ # for: a server that caps page size below `page_size` can return
537
+ # fewer rows while still reporting has_next_page: true, and
538
+ # advancing by page_size would skip the rows it never sent.
539
+ start += len(rows)