Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions birdbuddy/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ async def _iter_pages(

Yields:
Each page's connection object, in natural iterating order.

Raises:
UnexpectedResponseError: If a response lacks the expected
connection object.
"""
after: str | None = None
seen: set[str] = set()
Expand All @@ -325,7 +329,12 @@ async def _iter_pages(
# the first page and only send a real cursor.
page_vars["after"] = after
data = await self._make_request(query=query, variables=page_vars)
page = connection(data)
try:
page = connection(data)
except (KeyError, TypeError) as err:
raise UnexpectedResponseError(data) from err
if not isinstance(page, dict):
raise UnexpectedResponseError(data)
yield page

page_info = page.get("pageInfo") or {}
Expand Down Expand Up @@ -1280,6 +1289,7 @@ async def collection(

Raises:
ValueError: If ``page_size`` is not between 1 and 100.
UnexpectedResponseError: If a page lacks the expected media edges.
"""
_require_page_size(page_size)
result: dict[str, Media] = {}
Expand All @@ -1290,9 +1300,12 @@ async def collection(
connection=lambda data: data["collection"]["media"],
)
async for media in pages:
for edge in media["edges"]:
node = edge["node"]["media"]
result[node["id"]] = Media(node)
try:
for edge in media["edges"]:
node = edge["node"]["media"]
result[node["id"]] = Media(node)
except (KeyError, TypeError) as err:
raise UnexpectedResponseError(media) from err
return result

@deprecated("latest_collections is deprecated; use refresh_collections()")
Expand Down
45 changes: 32 additions & 13 deletions scripts/dump_payloads.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""Dump real Bird Buddy payloads for building test fixtures (needs creds).

Reads ``BB_EMAIL`` and ``BB_PASSWORD`` (a local ``.env`` is loaded via
python-dotenv), logs in, and captures the feeders, new postcards, and the
postcard collect flow. Each risky call is captured so a server error
(e.g. the postcard ``INTERNAL_SERVER_ERROR``) lands in the dump rather than
aborting the run. It writes two git-ignored files:
python-dotenv), logs in, and captures the profile, collections, feeders, new
postcards, and the postcard collect flow. Each risky call is captured so a
server error (e.g. the postcard ``INTERNAL_SERVER_ERROR``) lands in the dump
rather than aborting the run. It writes two git-ignored files:

* ``birdbuddy_payload.dump.json`` -- the raw capture (real account data).
* ``birdbuddy_payload.sanitized.json`` -- the same data with identifying
values scrubbed, suitable for copying into ``tests/fixtures`` after review.

By default the run is read-only: it reads the profile, collections and feed,
and reanalyzes a postcard (running AI inference, exactly what the app's
identify button does). It only collects a postcard, an irreversible change to
your account, when ``BB_COLLECT_POSTCARD_ID`` names the feed-item to collect.

Usage:
Copy ``.env.example`` to ``.env`` and fill in your credentials (or export
BB_EMAIL / BB_PASSWORD), then run:
Expand Down Expand Up @@ -172,29 +177,36 @@ async def _capture(label: str, coro: Any, out: dict[str, Any]) -> Any:


async def _collect() -> dict[str, Any]:
"""Log in and capture the postcard collect flow.
"""Log in and capture the read-only profile plus the postcard collect flow.

Sends the library's own ``POSTCARD_REANALYZE`` and ``POSTCARD_COLLECT``
queries, so the fixture cannot drift from what the client actually issues.
Collecting is a real mutation; run against a throwaway/test account.
Reads the ME profile and collections, then sends the library's own
``POSTCARD_REANALYZE`` query (and ``POSTCARD_COLLECT`` only when opted in
via ``BB_COLLECT_POSTCARD_ID``), so the fixtures cannot drift from what the
client actually issues.

Returns:
A dict mapping each captured step (feeders, new_postcards, reanalyze,
postcard_collect) to its payload, or an ``{"error": ...}`` entry when
that step failed.
A dict mapping each captured step (feeders, me, collections,
new_postcards, reanalyze, and postcard_collect when opted in) to its
payload, or an ``{"error": ...}`` entry when that step failed.
"""
bb = BirdBuddy(os.environ["BB_EMAIL"], os.environ["BB_PASSWORD"])
out: dict[str, Any] = {}
await bb.refresh()
out["feeders"] = {k: v.data for k, v in bb.feeders.items()}

# Read-only profile and collections, straight from the library's queries.
profile = await bb._make_request(query=queries.me.ME)
out["me"] = profile["me"]
collections = await bb._make_request(query=queries.me.COLLECTIONS)
out["collections"] = collections["me"]["collections"]
Comment thread
parrot-tailor marked this conversation as resolved.
Outdated

postcards = await bb.new_postcards()
out["new_postcards"] = [p.data for p in postcards]
if not postcards:
return out

feed_item_id = postcards[0].node_id
Comment thread
parrot-tailor marked this conversation as resolved.
Outdated
# Reanalyze first (idempotent), then collect; capture either path's error.
# Reanalyze is idempotent (the app's identify button); safe to always run.
await _capture(
"reanalyze",
bb._make_request(
Expand All @@ -205,12 +217,19 @@ async def _collect() -> dict[str, Any]:
)
# Key the reanalyze capture by feed-item id, matching the fixture shape.
out["reanalyze"] = {feed_item_id: out["reanalyze"]}

# Collecting is an irreversible mutation, so it is opt-in: set
# BB_COLLECT_POSTCARD_ID to the feed-item id to collect. Unset (the
# default) keeps the run read-only.
collect_id = os.environ.get("BB_COLLECT_POSTCARD_ID")
if not collect_id:
return out
await _capture(
"postcard_collect",
bb._make_request(
query=queries.birds.POSTCARD_COLLECT,
variables={
"feedItemId": feed_item_id,
"feedItemId": collect_id,
"postcardCollectInput": {"share": False},
},
),
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ def collect_flow_fixture() -> dict:
return load_json_fixture("collect_flow.json")


@pytest.fixture(name="api_payloads")
def api_payloads_fixture() -> dict:
"""Load the sanitized real API payload fixture."""
return load_json_fixture("api_payloads.json")


@pytest.fixture(name="bbclient")
def logged_in_client() -> BirdBuddy:
"""Return a BirdBuddy client pre-seeded with fake tokens."""
Expand Down
Loading