Skip to content
Merged

2.0.3 #158

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
57 changes: 0 additions & 57 deletions .github/workflows/claude-code-review.yml

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ dmypy.json

# Python version
.python-version
docs/superpowers
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [2.0.3] - Unreleased

### Added
- **Per-endpoint rate limiting**: Scryfall enforces tiered rate limits. `Search`, `Named`, `Random`, and `Collection` endpoints now automatically enforce 2 requests/second; all other endpoints enforce 10 requests/second. See [Scryfall rate limit docs](https://scryfall.com/docs/api/rate-limits).
- **`SlowRateLimiter` subclass**: New rate limiter class for endpoints with stricter limits. Endpoint classes declare their tier via `_rate_limiter_class` class variable.
- **Per-class limiter registry**: Each `RateLimiter` subclass maintains its own global instance, allowing independent rate limits per endpoint category.

### Changed
- `rate_limit_per_second` kwarg now creates a per-instance limiter scoped to the handler's lifecycle. Separate instantiations do not coordinate with each other; pagination within a single handler (`iter_all()`) is properly throttled.
- `reset_global_limiter()` renamed to `reset_all_limiters()`. The old name still works but emits a `DeprecationWarning`.
- `get_global_limiter()` no longer accepts a `calls_per_second` argument. Passing one emits a `DeprecationWarning` and the value is ignored. Rate is determined by the class default.
- Passing `rate_limit_per_second` to `iter_all()` now emits a `UserWarning` and is ignored. The kwarg must be set at construction time.

---

## [2.0.0] - 2025-01-11 (Rewrite Branch)

Major refactoring and modernization of the Scrython library with significant improvements to code quality, type safety, and usability.
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ The library uses a **base request handler** (`ScrythonRequestHandler`) combined
scrython/
├── base.py # ScrythonRequestHandler, ScryfallError
├── base_mixins.py # ScryfallListMixin, ScryfallCatalogMixin
├── rate_limiter.py # RateLimiter, SlowRateLimiter (per-endpoint tiering)
├── cache.py # Request caching with TTL
├── utils.py # Utility functions (e.g., to_object_array)
├── cards/
│ ├── cards.py # Card endpoint classes + Cards factory
Expand Down Expand Up @@ -91,11 +93,11 @@ From Contributing.md:

## Important Notes

- **No rate limiting**: Library doesn't enforce Scryfall's rate limits - users must handle this themselves (e.g., `time.sleep(0.1)`)
- **Built-in rate limiting**: Automatic per-endpoint rate limiting (10/s default, 2/s for search/named/random/collection). See `scrython/rate_limiter.py`. Users can override with `rate_limit_per_second` kwarg or disable with `rate_limit=False`.
- **No backwards compatibility**: Breaking changes expected as Scryfall API evolves
- **Walrus operator usage**: Code uses `:=` (requires Python 3.8+)
- **Dependencies**: python >= 3.5.3, asyncio >= 3.4.3, aiohttp >= 3.4.4 (though current code uses urllib, not aiohttp)
- **Branches**: `master` is stable/PyPI, `develop` is staging (per Contributing.md, though current branch is `rewrite`)
- **Python 3.10+ required**: Uses `X | Y` union syntax and `type[X]` annotations throughout
- **Dependencies**: urllib (standard library), no external HTTP dependencies
- **Branches**: `main` is stable/PyPI, `develop` is staging

## Adding New Endpoints

Expand Down
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ pip install scrython

## ⚠️ Important: Rate Limiting

**Good news!** Scrython 2.0 now includes **built-in rate limiting** enabled by default, enforcing Scryfall's 10 requests/second guideline automatically. You no longer need to manually add delays between requests.
**Good news!** Scrython 2.0 includes **built-in rate limiting** enabled by default. You no longer need to manually add delays between requests.

**Scryfall requires 50-100 milliseconds delay between requests** (~10 requests/second maximum).
Scrython automatically enforces Scryfall's tiered rate limits:
- **10 requests/second** for most endpoints (cards by ID, sets, bulk data, autocomplete, etc.)
- **2 requests/second** for heavier endpoints: `Search`, `Named`, `Random`, and `Collection`

See [Scryfall's rate limit documentation](https://scryfall.com/docs/api/rate-limits) for details.

### Automatic Rate Limiting (Default):

Expand All @@ -27,17 +31,19 @@ import scrython
cards_to_fetch = ['Lightning Bolt', 'Counterspell', 'Black Lotus']

for card_name in cards_to_fetch:
card = scrython.cards.Named(fuzzy=card_name) # Automatically rate limited
card = scrython.cards.Named(fuzzy=card_name) # Automatically rate limited (2/s)
print(f"{card.name} - {card.set}")
```

### Custom Rate Limits:

```python
# Use a slower rate (5 requests/second)
# Override the rate for a specific call (5 requests/second)
# Note: the override is scoped to this handler instance only.
# Separate instantiations each get their own limiter.
card = scrython.cards.Named(fuzzy='Lightning Bolt', rate_limit_per_second=5)

# Disable rate limiting (use with caution!)
# Disable rate limiting entirely (use with caution!)
card = scrython.cards.Named(fuzzy='Lightning Bolt', rate_limit=False)
```

Expand All @@ -53,7 +59,7 @@ import time
for card_name in cards_to_fetch:
card = scrython.cards.Named(fuzzy=card_name, rate_limit=False)
print(f"{card.name} - {card.set}")
time.sleep(0.1) # 100ms delay
time.sleep(0.5) # 500ms delay for Named endpoint
```

### Better: Use Bulk Data for Large Datasets
Expand Down
1 change: 0 additions & 1 deletion gen_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ def format_functions(_class, function_list, f):

def main(subpackage):
for _class in subpackage.__all__:

intro = f"""
These docs will likely not be as detailed as the official Scryfall Documentation, and you should reference that for more information.

Expand Down
3 changes: 2 additions & 1 deletion scripts/update_version_for_testpypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def get_unique_identifier() -> str:

# Fallback for local testing: use timestamp
from datetime import datetime, timezone

return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")


Expand Down Expand Up @@ -51,7 +52,7 @@ def update_version_in_pyproject() -> str:
f'version = "{new_version}"',
content,
count=1,
flags=re.MULTILINE
flags=re.MULTILINE,
)

# Write back (temporary, only in workflow workspace)
Expand Down
52 changes: 43 additions & 9 deletions scrython/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def __init__(self, scryfall_data: dict[str, Any], *args: Any, **kwargs: Any) ->
self._status: int = scryfall_data["status"]
self._code: str = scryfall_data["code"]
self._details: str = scryfall_data["details"]
self._type: str | None = scryfall_data["type"]
self._warnings: list[str] | None = scryfall_data["warnings"]
self._type: str | None = scryfall_data.get("type")
self._warnings: list[str] | None = scryfall_data.get("warnings")

@property
def status(self) -> int:
Expand Down Expand Up @@ -58,6 +58,8 @@ class ScrythonRequestHandler:
_accept: str = "application/json"
_content_type: str = "application/json"
_endpoint: str = ""
_rate_limiter_class: type[RateLimiter] = RateLimiter
_override_limiter: RateLimiter | None = None

@classmethod
def set_user_agent(cls, user_agent: str) -> None:
Expand Down Expand Up @@ -117,6 +119,25 @@ def endpoint(self) -> str:
return self._endpoint

def __init__(self, **kwargs: Any) -> None:
"""
Initialize a Scryfall API request handler.

Args:
**kwargs: Endpoint-specific parameters, plus optional:
- rate_limit (bool): Enable rate limiting (default: True)
- rate_limit_per_second (float): Override the default rate limit
for this handler instance. Creates a per-instance limiter,
so separate instantiations do not coordinate with each other
or with the class default limiter. Pagination within a single
handler (e.g., iter_all()) is properly throttled.
- cache (bool): Enable caching (default: False)
- cache_ttl (int): Cache TTL in seconds (default: 3600)
"""
rate_limit_per_second = kwargs.get("rate_limit_per_second")
self._override_limiter: RateLimiter | None = None
if rate_limit_per_second is not None:
self._override_limiter = RateLimiter(rate_limit_per_second)

self._build_path(**kwargs)
self._build_params(**kwargs)
self._fetch(**kwargs)
Expand All @@ -139,7 +160,6 @@ def _fetch_raw(self, url: str, cache_key: str | None = None, **kwargs: Any) -> d
- cache (bool): Enable caching (default: False)
- cache_ttl (int): Cache TTL in seconds (default: 3600)
- rate_limit (bool): Enable rate limiting (default: True)
- rate_limit_per_second (float): Rate limit (default: 10.0)
- data (dict): POST data (optional)

Returns:
Expand All @@ -165,13 +185,13 @@ def _fetch_raw(self, url: str, cache_key: str | None = None, **kwargs: Any) -> d
rate_limit = kwargs.get("rate_limit", True)

if rate_limit:
# Get rate limit setting
rate_limit_per_second = kwargs.get("rate_limit_per_second", 10.0)
# Use the instance override limiter if set, otherwise fall back
# to the class-level global limiter for the endpoint's tier.
if self._override_limiter is not None:
limiter = self._override_limiter
else:
limiter = self._rate_limiter_class.get_global_limiter()

# Get or create global rate limiter
limiter = RateLimiter.get_global_limiter(rate_limit_per_second)

# Wait if necessary to respect rate limit
limiter.wait()

# Prepare POST data if provided
Expand Down Expand Up @@ -200,6 +220,20 @@ def _fetch_raw(self, url: str, cache_key: str | None = None, **kwargs: Any) -> d

return response_data
except urllib.error.HTTPError as exc:
# Scryfall returns JSON error bodies on 4xx/5xx responses.
# urllib raises HTTPError before we can read the body normally,
# but the HTTPError itself is a file-like object containing it.
try:
charset = exc.headers.get_param("charset")
if not isinstance(charset, str):
charset = "utf-8"
error_data = json.loads(exc.read().decode(charset))
except (json.JSONDecodeError, UnicodeDecodeError):
raise Exception(f"{exc}: {request.get_full_url()}") from exc

if error_data.get("object") == "error":
raise ScryfallError(error_data, error_data["details"]) from exc

raise Exception(f"{exc}: {request.get_full_url()}") from exc

def _fetch(self, **kwargs: Any) -> None:
Expand Down
15 changes: 14 additions & 1 deletion scrython/base_mixins.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from functools import cache
from typing import Any

Expand Down Expand Up @@ -105,7 +106,10 @@ def iter_all(self, **kwargs):
- cache (bool): Enable caching for pagination (default: False)
- cache_ttl (int): Cache TTL in seconds (default: 3600)
- rate_limit (bool): Enable rate limiting (default: True)
- rate_limit_per_second (float): Rate limit (default: 10.0)

Note:
rate_limit_per_second must be set at construction time, not here.
Pagination uses the handler's existing rate limiter.

Yields:
Individual items from all pages
Expand All @@ -121,6 +125,15 @@ def iter_all(self, **kwargs):
"""
import hashlib

if "rate_limit_per_second" in kwargs:
warnings.warn(
"rate_limit_per_second must be set at construction time, "
"not passed to iter_all(). This kwarg is ignored.",
UserWarning,
stacklevel=2,
)
kwargs.pop("rate_limit_per_second")

# Yield items from current page
yield from self.data

Expand Down
5 changes: 5 additions & 0 deletions scrython/cards/cards.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from ..base import ScrythonRequestHandler
from ..base_mixins import ScryfallCatalogMixin, ScryfallListMixin
from ..rate_limiter import SlowRateLimiter
from ..types import ScryfallCardData
from .cards_mixins import CardsObjectMixin

Expand Down Expand Up @@ -158,6 +159,7 @@ class Search(ScryfallListMixin, ScrythonRequestHandler):
"""

_endpoint = "/cards/search"
_rate_limiter_class = SlowRateLimiter
list_data_type = Object


Expand Down Expand Up @@ -189,6 +191,7 @@ class Named(CardsObjectMixin, ScrythonRequestHandler):
"""

_endpoint = "/cards/named"
_rate_limiter_class = SlowRateLimiter


class Autocomplete(ScryfallCatalogMixin, ScrythonRequestHandler):
Expand Down Expand Up @@ -244,6 +247,7 @@ class Random(CardsObjectMixin, ScrythonRequestHandler):
"""

_endpoint = "/cards/random"
_rate_limiter_class = SlowRateLimiter


class Collection(ScryfallListMixin, ScrythonRequestHandler):
Expand Down Expand Up @@ -276,6 +280,7 @@ class Collection(ScryfallListMixin, ScrythonRequestHandler):
"""

_endpoint = "/cards/collection"
_rate_limiter_class = SlowRateLimiter
list_data_type = Object


Expand Down
Loading
Loading