Skip to content

Commit 4765229

Browse files
parrot-tailortombeaulahjhansche
authored
Paginate the feed and collections, align timestamp types, and deprecate dead methods (#51)
* Fix missing created_at on feed items Some AnyFeedItem union members were not enumerated individually in the FEED query, so they returned without createdAt and newest_edge/filter skipped them. Add an inline fragment on the FeedItem interface so every member selects id and createdAt. Fixes #24 * Paginate collection() to return all media collection() returned only the first page, silently truncating larger collections. Follow the connection cursor until hasNextPage is false so the full set of media is returned. Add a page_size argument (default 50) that caps the per-request batch. The API rejects a page size above 100 -- it responds HTTP 200 with a GraphQL INTERNAL_SERVER_ERROR rather than a client error -- so guard the input to 1-100 and raise ValueError instead of round-tripping to fail. page_size only tunes how many media are fetched per request; the full collection is returned regardless. Paginate through a shared _iter_pages helper that advances by endCursor and stops defensively when the server claims another page but returns no advancing cursor (missing, null, or already seen), so a stuck cursor cannot spin the client in an infinite request loop. supersedes #38 Co-authored-by: tombeaulah <tom.beaulah@gmail.com> * Paginate and guard the feed The feed methods fetched only the first page. refresh_feed therefore under-reported when more than one page of items was new since the last refresh -- fewer results than the app showed -- and new_postcards and feed_nodes saw only the newest page. feed() also passed first straight through, so grabbing a large window with first above 100 errored server side (HTTP 200 with a GraphQL INTERNAL_SERVER_ERROR). Guard feed(first=) to 1-100 and raise ValueError otherwise. Page refresh_feed backward through the feed until it reaches items no newer than since, so every new item is returned rather than truncated; with no cutoff (no prior refresh) it still returns only the newest page to avoid replaying the whole history on startup. Page feed_nodes and new_postcards across every page. All of it reuses the defensive _iter_pages cursor walk, so a server that reports another page without an advancing cursor cannot spin the client in an infinite request loop. Refs #29 * Deprecate latest_collections in favor of refresh_collections latest_collections referenced queries.me.LATEST_MEDIA, which is not defined, so every call raised AttributeError. Rather than remove the public method, mark it deprecated (PEP 702) and delegate to refresh_collections, so existing callers keep working and now receive a real result. Adds typing_extensions>=4.5 as a runtime dependency for the decorator. PEP 702 landed in the standard library as warnings.deprecated only in Python 3.13; the package supports Python 3.10+, so the backport is required to reach older runtimes. * Add the ULTRA_FRENZY power profile The API reports ULTRA_FRENZY_MODE as a power profile; without a matching member it fell back to PowerProfile.UNKNOWN. Add the member so it maps directly. * Guard update_firmware_start when no update is available Starting a firmware update on a feeder already running the latest version returns an internal server error rather than a client error -- the same class of misbehavior as the collection and feed page-size limits. The feeder reports firmwareVersion and availableFirmwareVersion, so compare them after the progress check and raise NoFirmwareUpdateAvailableError instead of round-tripping into the server error. * Type BirdBuddy timestamps to match the schema createdAt (Media, FeedItem) and visitLastTime (collections) are DateTime! in the schema, and every query returning those objects selects them, so the values are never null. Type Media.created_at and Collection.last_visit as datetime, raising UnexpectedResponseError if a response ever omits the field rather than returning None for a value the schema guarantees. FeedNode.created_at stays optional: feed items are AnyFeedItem union members, and a member whose createdAt the query does not select carries no timestamp. parse_datetime is left tolerant of None, so its public signature is unchanged. Feed.newest_edge filters undated edges with a walrus guard so the type checker can follow that the compared values are non-null. That max() comparison is the code cited in #24; the interface fragment added earlier requests createdAt for every member, and this hardens the comparison against a member that still omits it. Refs #24. * Document the deprecations in the README Add a Deprecations section noting that deprecated methods keep working and emit a DeprecationWarning, with latest_collections (deprecated in 0.0.22) and its refresh_collections replacement. * (fixup) clarify yield docstring in _iter_pages() _iter_pages() is generic and follows the natural order of the cursor, i.e., doesn't impose age ordering. Clarify the confusing language as it contradicts the docstrings of the callers that consume it. Co-authored-by: Joe <madcoder@gmail.com> --------- Co-authored-by: tombeaulah <tom.beaulah@gmail.com> Co-authored-by: Joe <madcoder@gmail.com>
1 parent d0e15ec commit 4765229

12 files changed

Lines changed: 568 additions & 56 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ async def main():
9292
print(birds)
9393
```
9494

95+
## Deprecations
96+
97+
Deprecated methods keep working and emit a `DeprecationWarning`, so upgrading
98+
does not break existing callers. Prefer the replacement:
99+
100+
- `latest_collections()` — deprecated in 0.0.22; use `refresh_collections()`.
101+
The old method referenced an undefined query and raised `AttributeError` on
102+
every call, so it never returned data. It now delegates to
103+
`refresh_collections()`, which returns the account's bird collections.
104+
95105
## Development
96106

97107
Install [pyenv] and the pinned interpreter, then use the Makefile — every

birdbuddy/client.py

Lines changed: 188 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from collections.abc import AsyncIterator, Callable
67
from datetime import datetime
78
from typing import Any
89

910
import langcodes
1011
from python_graphql_client import GraphqlClient
12+
from typing_extensions import deprecated
1113

1214
from birdbuddy import LOGGER, VERBOSE, queries
1315
from birdbuddy.const import BB_URL
1416
from birdbuddy.exceptions import (
1517
AuthenticationFailedError,
1618
AuthTokenExpiredError,
1719
GraphqlError,
20+
NoFirmwareUpdateAvailableError,
1821
NoResponseError,
1922
UnexpectedResponseError,
2023
)
@@ -35,12 +38,30 @@
3538
_NO_VALUE = object()
3639
"""Sentinel value to allow None to override a default value."""
3740

41+
_MAX_PAGE_SIZE = 100
42+
"""The largest page size the API accepts; a larger ``first`` errors server
43+
side (HTTP 200 with a GraphQL ``INTERNAL_SERVER_ERROR``)."""
44+
3845

3946
def _redact(data: object, redacted: bool = True) -> object:
4047
"""Return a redacted string if necessary."""
4148
return "**REDACTED**" if redacted else data
4249

4350

51+
def _require_page_size(page_size: int) -> None:
52+
"""Validate a pagination page size.
53+
54+
Args:
55+
page_size: The requested per-page item count.
56+
57+
Raises:
58+
ValueError: If ``page_size`` is not between 1 and the API's limit.
59+
"""
60+
if not 1 <= page_size <= _MAX_PAGE_SIZE:
61+
msg = f"page size must be between 1 and {_MAX_PAGE_SIZE}"
62+
raise ValueError(msg)
63+
64+
4465
class BirdBuddy:
4566
"""Bird Buddy api client."""
4667

@@ -269,6 +290,59 @@ async def _make_request(
269290
return result[subscript]
270291
return result
271292

293+
async def _iter_pages(
294+
self,
295+
query: str,
296+
variables: dict[str, Any],
297+
connection: Callable[[dict], dict],
298+
) -> AsyncIterator[dict]:
299+
"""Yield successive pages of a Relay connection, following the cursor.
300+
301+
Requests ``query`` repeatedly, advancing ``after`` by the previous
302+
page's ``endCursor`` until ``hasNextPage`` is false. Terminates
303+
defensively if the server reports another page but returns no usable
304+
cursor (missing, null, or one already seen), rather than looping
305+
forever on a stuck cursor.
306+
307+
Args:
308+
query: The GraphQL query text; it must accept an ``after`` cursor
309+
and select ``pageInfo { hasNextPage endCursor }``.
310+
variables: Base variables sent with every page (e.g. ``first``);
311+
``after`` is injected per page.
312+
connection: Extracts the connection object (the one carrying
313+
``edges`` and ``pageInfo``) from a response ``data`` dict.
314+
315+
Yields:
316+
Each page's connection object, in natural iterating order.
317+
"""
318+
after: str | None = None
319+
seen: set[str] = set()
320+
while True:
321+
page_vars = dict(variables)
322+
if after is not None:
323+
# The API errors on an explicit ``after: null``; omit it for
324+
# the first page and only send a real cursor.
325+
page_vars["after"] = after
326+
data = await self._make_request(query=query, variables=page_vars)
327+
page = connection(data)
328+
yield page
329+
330+
page_info = page.get("pageInfo") or {}
331+
if not page_info.get("hasNextPage"):
332+
return
333+
cursor = page_info.get("endCursor")
334+
if not cursor or cursor in seen:
335+
# Defensive: the server claims another page but gave no new
336+
# cursor to advance with. Stop instead of re-requesting.
337+
LOGGER.debug(
338+
"Pagination stopped: hasNextPage but cursor did not "
339+
"advance (endCursor=%r)",
340+
cursor,
341+
)
342+
return
343+
seen.add(cursor)
344+
after = cursor
345+
272346
@property
273347
def user(self) -> None | BirdBuddyUser:
274348
"""The logged in user data."""
@@ -403,7 +477,8 @@ async def feed(
403477
and an ``"edges"`` key with FeedEdge nodes, newest first.
404478
405479
Args:
406-
first: Return the first N items older than ``after``.
480+
first: Return the first N items older than ``after``. Must be
481+
1-100; the API returns an internal error for larger values.
407482
after: Cursor of the oldest item previously seen (pagination).
408483
last: Return the last N items newer than ``before``. Currently
409484
ignored; the backward-pagination request path is disabled.
@@ -412,7 +487,11 @@ async def feed(
412487
413488
Returns:
414489
The Feed.
490+
491+
Raises:
492+
ValueError: If ``first`` is not between 1 and 100.
415493
"""
494+
_require_page_size(first)
416495
variables: dict[str, Any] = {
417496
# $first: Int,
418497
# $after: String,
@@ -434,49 +513,94 @@ async def feed(
434513
data = await self._make_request(query=queries.me.FEED, variables=variables)
435514
return Feed(data["me"]["feed"])
436515

516+
def _feed_pages(self) -> AsyncIterator[dict]:
517+
"""Iterate feed connection pages, newest first, one per request."""
518+
return self._iter_pages(
519+
query=queries.me.FEED,
520+
variables={"first": _MAX_PAGE_SIZE},
521+
connection=lambda data: data["me"]["feed"],
522+
)
523+
524+
def _note_newest_feed_date(self, feed: Feed) -> None:
525+
"""Advance the saved last-seen timestamp to a page's newest item.
526+
527+
Args:
528+
feed: A feed page; its newest edge sets the last-seen timestamp.
529+
"""
530+
if not (newest_edge := feed.newest_edge):
531+
return
532+
newest_date = newest_edge.node.created_at
533+
if newest_date is not None and newest_date != self._last_feed_date:
534+
LOGGER.debug(
535+
"Updating latest seen Feed timestamp: %s -> %s",
536+
self._last_feed_date,
537+
newest_date,
538+
)
539+
self._last_feed_date = newest_date
540+
437541
async def refresh_feed(
438542
self,
439543
since: datetime | str = _NO_VALUE, # type: ignore[assignment]
440544
) -> list[FeedNode]:
441545
"""Return only feed items new since the last refresh.
442546
443-
The most recent edge node timestamp is saved as the last-seen feed
444-
item, which becomes the new default value for ``since``. Useful to,
445-
for example, restore a last-seen timestamp in a new instance.
547+
Pages backward through the feed (newest first) until it reaches
548+
items no newer than ``since``, so more than one page of new items is
549+
returned rather than truncated at the first page. The newest item's
550+
timestamp is saved as the last-seen feed item, the new default for
551+
``since``.
552+
553+
With no ``since`` and no prior refresh there is no lower bound to
554+
page toward, so only the most recent page is returned; this avoids
555+
replaying the entire history on a first refresh.
446556
447557
Args:
448558
since: The time after which to restrict new feed items; defaults
449559
to the last-seen timestamp.
450560
451561
Returns:
452-
The new feed nodes.
562+
The new feed nodes, newest first.
453563
"""
454564
resolved = self._last_feed_date if since is _NO_VALUE else since
455565
if isinstance(resolved, str):
456566
resolved = FeedNode.parse_datetime(resolved)
457-
feed = await self.feed()
458-
if (newest_edge := feed.newest_edge) and (
459-
newest_date := newest_edge.node.created_at
460-
) != self._last_feed_date:
461-
LOGGER.debug(
462-
"Updating latest seen Feed timestamp: %s -> %s",
463-
self._last_feed_date,
464-
newest_date,
567+
568+
if resolved is None:
569+
feed = await self.feed()
570+
self._note_newest_feed_date(feed)
571+
return feed.filter(newer_than=None)
572+
573+
new_nodes: list[FeedNode] = []
574+
noted = False
575+
async for page in self._feed_pages():
576+
feed = Feed(page)
577+
if not noted:
578+
self._note_newest_feed_date(feed)
579+
noted = True
580+
new_nodes.extend(feed.filter(newer_than=resolved))
581+
oldest = min(
582+
(n.created_at for n in feed.nodes if n.created_at),
583+
default=None,
465584
)
466-
self._last_feed_date = newest_date
467-
return feed.filter(newer_than=resolved)
585+
if oldest is not None and oldest <= resolved:
586+
# Reached items no newer than the cutoff; older pages hold
587+
# nothing new.
588+
break
589+
return new_nodes
468590

469591
async def feed_nodes(self, node_type: FeedNodeType) -> list[FeedNode]:
470-
"""Return all feed items of the given type.
592+
"""Return all feed items of the given type across every page.
471593
472594
Args:
473595
node_type: The feed node type to filter by.
474596
475597
Returns:
476-
The matching feed nodes.
598+
The matching feed nodes, newest first.
477599
"""
478-
feed = await self.feed()
479-
return feed.filter(of_type=node_type)
600+
nodes: list[FeedNode] = []
601+
async for page in self._feed_pages():
602+
nodes.extend(Feed(page).filter(of_type=node_type))
603+
return nodes
480604

481605
async def new_postcards(self) -> list[FeedNode]:
482606
"""Return all new 'Postcard' feed items.
@@ -947,13 +1071,24 @@ async def update_firmware_start(self, feeder: Feeder | str) -> FeederUpdateStatu
9471071
9481072
Returns:
9491073
The firmware update status.
1074+
1075+
Raises:
1076+
NoFirmwareUpdateAvailableError: If the feeder already runs the
1077+
latest firmware (the API errors internally otherwise).
9501078
"""
9511079
current_status = await self.update_firmware_check(feeder)
9521080

9531081
if current_status.is_in_progress:
9541082
# There's already an update in progress
9551083
return current_status
9561084

1085+
# The API returns an internal error when asked to start an update that
1086+
# is not available, so guard on the versions the check reported.
1087+
reported = Feeder(current_status.get("feeder") or {})
1088+
if reported.version and reported.version == reported.version_update_available:
1089+
msg = f"feeder already on the latest firmware ({reported.version})"
1090+
raise NoFirmwareUpdateAvailableError(msg)
1091+
9571092
feeder_id: str
9581093
if isinstance(feeder, Feeder):
9591094
if not feeder.is_owner:
@@ -1025,34 +1160,49 @@ def collections(self) -> dict[str, Collection]:
10251160
del self._collections[collection.collection_id]
10261161
return self._collections
10271162

1028-
async def collection(self, collection_id: str) -> dict[str, Media]:
1029-
"""Return the media in the specified collection.
1163+
async def collection(
1164+
self, collection_id: str, page_size: int = 50
1165+
) -> dict[str, Media]:
1166+
"""Return all media in the specified collection.
1167+
1168+
Follows pagination so collections larger than one page are fully
1169+
retrieved (not truncated to the first page).
10301170
10311171
Args:
10321172
collection_id: The collection ``UUID``.
1173+
page_size: How many media to request per page. Must be 1-100; the
1174+
API returns an internal error for larger page sizes.
10331175
10341176
Returns:
10351177
A mapping of ``media_id`` to its ``Media``.
1178+
1179+
Raises:
1180+
ValueError: If ``page_size`` is not between 1 and 100.
10361181
"""
1037-
variables = {
1038-
"collectionId": collection_id,
1039-
# other inputs: first, orderBy, last, after, before
1040-
}
1041-
data = await self._make_request(
1042-
query=queries.me.COLLECTIONS_MEDIA, variables=variables
1182+
_require_page_size(page_size)
1183+
result: dict[str, Media] = {}
1184+
variables = {"collectionId": collection_id, "first": page_size}
1185+
pages = self._iter_pages(
1186+
query=queries.me.COLLECTIONS_MEDIA,
1187+
variables=variables,
1188+
connection=lambda data: data["collection"]["media"],
10431189
)
1044-
# TODO: check [collection][media][pageInfo][hasNextPage]?
1045-
return {
1046-
(node := edge["node"]["media"])["id"]: Media(node)
1047-
for edge in data["collection"]["media"]["edges"]
1048-
}
1190+
async for media in pages:
1191+
for edge in media["edges"]:
1192+
node = edge["node"]["media"]
1193+
result[node["id"]] = Media(node)
1194+
return result
10491195

1050-
async def latest_collections(
1051-
self,
1052-
) -> dict[str, Collection]:
1053-
"""Return the latest collections."""
1054-
query = queries.me.LATEST_MEDIA # type: ignore[attr-defined]
1055-
return await self._make_request(query=query)
1196+
@deprecated("latest_collections is deprecated; use refresh_collections()")
1197+
async def latest_collections(self) -> dict[str, Collection]:
1198+
"""Return the account's bird collections.
1199+
1200+
Deprecated since 0.0.22; use :func:`refresh_collections` instead. The
1201+
previous implementation referenced an undefined query and raised
1202+
``AttributeError`` on every call; this now delegates to
1203+
``refresh_collections``, which keeps only bird collections.
1204+
"""
1205+
return await self.refresh_collections()
10561206

10571207
@property
10581208
def feeders(self) -> dict[str, Feeder]:

birdbuddy/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ class AuthenticationFailedError(Exception):
7070
"""The login attempt failed."""
7171

7272

73+
class NoFirmwareUpdateAvailableError(Exception):
74+
"""A firmware update was requested but the feeder is already current."""
75+
76+
7377
class UnexpectedResponseError(Exception):
7478
"""The response did not contain the expected fields."""
7579

0 commit comments

Comments
 (0)