Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,11 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # Allow unused imports in __init__ files
# Usage tests inject a payload fixture purely to arm the stub seam; the
# fixture parameter is intentionally unreferenced in the test body.
# fixture parameter is intentionally unreferenced in the test body. Same
# applies to conftest fixtures that depend on another fixture only to
# sequence its setup (e.g. arming one seam before another).
"tests/usage/test_*.py" = ["ARG001"]
"tests/usage/conftest.py" = ["ARG001"]

[tool.ruff.lint.isort]
known-first-party = ["scrython"]
Expand Down
49 changes: 49 additions & 0 deletions scripts/capture_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,41 @@
"path": "cards/named",
"query": {"exact": "Black Lotus"},
},
"cards_named__lightning_bolt": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Lightning Bolt"},
},
"cards_named__serra_angel": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Serra Angel"},
},
"cards_named__wrath_of_god": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Wrath of God"},
},
"cards_named__oblivion_ring": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Oblivion Ring"},
},
"cards_named__jace_beleren": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Jace Beleren"},
},
"cards_named__ornithopter": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Ornithopter"},
},
"cards_named__niv_mizzet_parun": {
"endpoint": "cards/named",
"path": "cards/named",
"query": {"exact": "Niv-Mizzet, Parun"},
},
"cards_by_id__normal": {
"endpoint": "cards/id",
"path": "cards/a59c24d9-804b-45d0-b60c-cfc7a6af7ef5",
Expand Down Expand Up @@ -149,6 +184,20 @@
"path": "migrations/f75b2d8b-c73b-4352-91f7-3b9239bd3c9f",
"query": {},
},
# Four cards across two sets (LEA and M10) for list-helper usage tests.
# Ordered by name so the result order is deterministic on refresh.
"cards_search__multiset": {
"endpoint": "cards/search",
"path": "cards/search",
"query": {
"q": (
"(name:\"Counterspell\" or name:\"Lightning Bolt\") s:lea"
" or (name:\"Fireball\" or name:\"Giant Growth\") s:m10"
),
"order": "name",
"unique": "prints",
},
},
}


Expand Down
169 changes: 163 additions & 6 deletions tests/usage/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Pytest configuration and shared fixtures for Scrython usage tests."""

import gzip
import json
from collections import deque
from io import BytesIO
from pathlib import Path
from unittest.mock import Mock, patch
from urllib.parse import urlsplit
Expand Down Expand Up @@ -65,16 +68,22 @@ def stub_response():
(e.g. "cards/named" and "cards/id/rulings") in one test is ambiguous and
raises; no current test does this.

Bulk download: register the CDN payload with the special endpoint key
"bulk-data/download" (a list of card dicts). The download() HTTP call is
intercepted by a separate patch on bulk_data_mixins.urlopen, and the list
is served back as gzip-compressed JSONL to match what the CDN sends.

Usage:
def test_something(stub_response, load_fixture):
stub_response("cards/named", load_fixture("cards_named_black_lotus"))
card = scrython.cards.Named(exact="Black Lotus")
assert card.name == "Black Lotus"
"""
registry: dict = {}
registry: dict[str, deque] = {}
download_registry: list = []

class _MockResponse:
def __init__(self, data: dict) -> None:
def __init__(self, data: dict | list) -> None:
self._data = json.dumps(data).encode("utf-8")
self._info = Mock()
self._info.get_param = Mock(return_value="utf-8")
Expand All @@ -91,6 +100,28 @@ def __enter__(self):
def __exit__(self, *args) -> None:
pass

class _MockDownloadResponse:
"""
Stands in for the CDN response to download(), which is not plain JSON.

Scryfall hosts jsonl_download_uri as a .jsonl.gz file, so download()
feeds the response straight to gzip.GzipFile. That reads in sized
chunks, which is why this cannot reuse _MockResponse.
"""

def __init__(self, cards: list) -> None:
jsonl = "\n".join(json.dumps(card) for card in cards).encode("utf-8")
self._stream = BytesIO(gzip.compress(jsonl))

def read(self, size: int = -1) -> bytes:
return self._stream.read(size)

def __enter__(self):
return self

def __exit__(self, *args) -> None:
pass

def _urlopen(request):
if not registry:
raise ValueError(
Expand All @@ -99,11 +130,17 @@ def _urlopen(request):

requested = _resource(urlsplit(request.full_url).path)
matches = [
payload for endpoint, payload in registry.items() if _resource(endpoint) == requested
(endpoint, queue)
for endpoint, queue in registry.items()
if _resource(endpoint) == requested
]

if len(matches) == 1:
return _MockResponse(matches[0])
_, queue = matches[0]
# Pop from the front when multiple payloads remain (successive pages);
# keep the last item in place so single-payload tests never exhaust.
payload = queue.popleft() if len(queue) > 1 else queue[0]
return _MockResponse(payload)
if not matches:
raise ValueError(
f"stub_response: no registered endpoint matches requested resource "
Expand All @@ -114,8 +151,21 @@ def _urlopen(request):
f"'{requested}'; cannot disambiguate (registered: {sorted(registry)})"
)

def _register(endpoint: str, payload: dict) -> None:
registry[endpoint] = payload
def _urlopen_download(_request):
if not download_registry:
raise ValueError(
"stub_response: register a download payload with "
"stub_response('bulk-data/download', [...]) before calling download()"
)
return _MockDownloadResponse(download_registry[0])

def _register(endpoint: str, *payloads: dict | list) -> None:
if not payloads:
raise ValueError("stub_response: register at least one payload")
if endpoint == "bulk-data/download":
download_registry.append(payloads[0])
else:
registry[endpoint] = deque(payloads)

# Patch the limiter's wait() itself so the bypass holds regardless of which
# _rate_limiter_class an endpoint uses; SlowRateLimiter inherits wait, so one
Expand All @@ -124,6 +174,7 @@ def _register(endpoint: str, payload: dict) -> None:
with (
patch.object(RateLimiter, "wait", lambda *_: None),
patch("scrython.base.urlopen", side_effect=_urlopen),
patch("scrython.bulk_data.bulk_data_mixins.urlopen", side_effect=_urlopen_download),
):
yield _register

Expand All @@ -134,6 +185,17 @@ def _register(endpoint: str, payload: dict) -> None:
# value is the endpoint the payload answers for.
_PAYLOAD_FIXTURES = {
"cards_named__black_lotus": "cards/named",
"cards_named__lightning_bolt": "cards/named",
"cards_named__serra_angel": "cards/named",
"cards_named__wrath_of_god": "cards/named",
"cards_named__oblivion_ring": "cards/named",
"cards_named__jace_beleren": "cards/named",
"cards_named__ornithopter": "cards/named",
"cards_named__niv_mizzet_parun": "cards/named",
"cards_named__prices_mixed": "cards/named",
"cards_named__prices_partial": "cards/named",
"cards_named__prices_all_null": "cards/named",
"cards_named__image_none": "cards/named",
"cards_by_id__normal": "cards/id",
"cards_by_id__transform": "cards/id",
"cards_by_id__modal_dfc": "cards/id",
Expand All @@ -151,6 +213,7 @@ def _register(endpoint: str, payload: dict) -> None:
"rulings_by_id__rules_lawyer": "cards/id/rulings",
"symbology_all": "symbology",
"migrations_by_id__merge": "migrations/id",
"cards_search__multiset": "cards/search",
}


Expand All @@ -165,3 +228,97 @@ def _payload(stub_response, load_fixture):
# Register one named pytest fixture per captured payload.
for _key, _endpoint in _PAYLOAD_FIXTURES.items():
globals()[_key] = _make_payload_fixture(_endpoint, _key)


# Minimal synthetic payload for bulk download tests — not a captured API response.
_SAMPLE_BULK_CARDS: list[dict] = [
{
"object": "card",
"id": "f4fa7d2c-3d02-4a5e-8b4d-2e4e3e7f8c9a",
"oracle_id": "93c2c107-d8f9-4d79-acfa-c6e1aa0e1f1b",
"name": "Black Lotus",
},
]


@pytest.fixture
def bulk_data_by_id__oracle_cards_download(bulk_data_by_id__oracle_cards, stub_response):
stub_response("bulk-data/download", _SAMPLE_BULK_CARDS)


# Multi-page rulings fixture: two synthetic pages for iter_all() pagination tests.
_RULES_LAWYER_ID = "6c02c575-5685-44f5-8b47-89d888529d1b"

_RULINGS_MULTIPAGE_PAGE_1: dict = {
"object": "list",
"has_more": True,
"next_page": f"https://api.scryfall.com/cards/{_RULES_LAWYER_ID}/rulings?page=2",
"data": [
{
"object": "ruling",
"oracle_id": "0a3d3d5e-fb77-4940-9ece-7ed62bd6413e",
"source": "wotc",
"published_at": "2025-01-24",
"comment": "Page one ruling.",
}
],
}

_RULINGS_MULTIPAGE_PAGE_2: dict = {
"object": "list",
"has_more": False,
"data": [
{
"object": "ruling",
"oracle_id": "0a3d3d5e-fb77-4940-9ece-7ed62bd6413e",
"source": "wotc",
"published_at": "2025-01-24",
"comment": "Page two ruling.",
}
],
}


@pytest.fixture
def rulings_multipage(stub_response):
stub_response("cards/id/rulings", _RULINGS_MULTIPAGE_PAGE_1, _RULINGS_MULTIPAGE_PAGE_2)


# Synthetic payload fixtures: unlike the captured fixtures above, these are not
# backed by a committed JSON file. They exist for scenarios a single captured
# payload can't cover — a second, mutated payload for the same resource within
# one test, or a fixed item count that a fixture refresh would otherwise drift.
# They still arm the seam here in conftest.py, not in test bodies (see
# tests/usage/CONVENTIONS.md #4).
@pytest.fixture
def cards_named__black_lotus_factory(stub_response, load_fixture):
"""Arm `cards/named` with the Black Lotus payload, optionally under a different id."""
payload = load_fixture("cards_named__black_lotus")

def _arm(id_override: str | None = None) -> None:
stub_response("cards/named", {**payload, "id": id_override} if id_override else payload)

return _arm


@pytest.fixture
def rulings_by_id__synthetic_five_items(stub_response):
"""Minimal rulings-list payload with a fixed item count, for the list str() format test."""
stub_response(
"cards/id/rulings",
{"object": "list", "has_more": False, "data": [], "total_cards": 5},
)


@pytest.fixture
def catalog_creature_types__synthetic_three_items(stub_response):
"""Minimal catalog payload with a fixed item count, for the catalog str() format test."""
stub_response(
"catalog/creature-types",
{
"object": "catalog",
"uri": "https://api.scryfall.com/catalog/creature-types",
"total_values": 3,
"data": ["Advisor", "Aetherborn", "Alien"],
},
)
14 changes: 14 additions & 0 deletions tests/usage/fixtures/cards_named__image_none.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"_provenance": {
"captured_at": "2026-06-01T00:00:00.000000+00:00",
"endpoint": "cards/named",
"source_url": "https://api.scryfall.com/cards/named?exact=No+Image+Card",
"note": "synthetic fixture — not a real Scryfall card; used to test get_image_url graceful degradation when no image exists"
},
"payload": {
"object": "card",
"id": "00000000-0000-0000-0000-000000000004",
"name": "No Image Card",
"layout": "normal"
}
}
Loading
Loading