Many hyperlinks are disabled.
Use anonymous login
to enable hyperlinks.
Overview
| Comment: | Fix Scryfall API bulk data import failure, caused by API restructuring and switching to [JSONL](https://jsonltools.com/what-is-jsonl) format. Switched away from ijson, and use the standard library json module for per-line parsing, because ijson doesn't yet support JSONL (it chokes on the newline item delimiter). |
|---|---|
| Downloads: | Tarball | ZIP archive |
| Timelines: | family | ancestors | descendants | both | prepare_release |
| Files: | files | file ages | folders |
| SHA3-256: |
3147256b6217e3822d01eb0436879898 |
| User & Date: | thomas 2026-07-29 18:05:42.488 |
Context
|
2026-07-30
| ||
| 09:02 | PrintingPreferencePage: Replaced defunct unit tests with new tests verifying UI behavior check-in: d8a53477f6 user: thomas tags: prepare_release | |
|
2026-07-29
| ||
| 18:05 | Fix Scryfall API bulk data import failure, caused by API restructuring and switching to [JSONL](https://jsonltools.com/what-is-jsonl) format. Switched away from ijson, and use the standard library json module for per-line parsing, because ijson doesn't yet support JSONL (it chokes on the newline item delimiter). check-in: 3147256b62 user: thomas tags: prepare_release | |
|
2026-07-27
| ||
| 21:16 | Printing filters: Added 3 new filters, general promo cards, pre-release promo cards, and WPN promo pack cards. check-in: 63a345801d user: thomas tags: prepare_release | |
Changes
Changes to doc/changelog.md.
| ︙ | ︙ | |||
28 29 30 31 32 33 34 35 36 37 38 39 40 41 | - Changed search behavior in the built-in card search. - Search is now consistently case-insensitive, even for non-ASCII characters - The wildcard character to represent "any number of characters" is now `*` instead of `%` - Cards can be removed from the current page via a new context menu entry ## Fixed issues - The card lookup for "related cards" via the context menu now finds both sides of related double-faced cards or tokens. - Fixed application hang at exit, if a card data update was previously canceled. - Potentially fixed Scryfall card data import aborting on some systems with `IncompleteRead` errors # Version 0.35.2 (2026-03-08) <a name="v0_35_2"></a> ## Fixed issues | > | 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | - Changed search behavior in the built-in card search. - Search is now consistently case-insensitive, even for non-ASCII characters - The wildcard character to represent "any number of characters" is now `*` instead of `%` - Cards can be removed from the current page via a new context menu entry ## Fixed issues - Fixed broken card data update, that stopped workin on July 20th 2026 due to a change in the Scryfall API - The card lookup for "related cards" via the context menu now finds both sides of related double-faced cards or tokens. - Fixed application hang at exit, if a card data update was previously canceled. - Potentially fixed Scryfall card data import aborting on some systems with `IncompleteRead` errors # Version 0.35.2 (2026-03-08) <a name="v0_35_2"></a> ## Fixed issues |
| ︙ | ︙ |
Changes to mtg_proxy_printer/async_tasks/card_info_downloader.py.
| ︙ | ︙ | |||
15 16 17 18 19 20 21 | import abc import time from collections.abc import Generator, Sequence import functools import gzip import itertools | | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | import abc import time from collections.abc import Generator, Sequence import functools import gzip import itertools import json import shutil from gzip import GzipFile from pathlib import Path import queue import sqlite3 import socket import typing |
| ︙ | ︙ | |||
85 86 87 88 89 90 91 |
related_id: UUID
class CardInfoDownloadTaskBase(DownloaderBase):
"""Base class for tasks that fetch card data from the Scryfall bulk-data API."""
def get_scryfall_bulk_card_data_url(self) -> tuple[str, int]:
| | > | | > > | 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
related_id: UUID
class CardInfoDownloadTaskBase(DownloaderBase):
"""Base class for tasks that fetch card data from the Scryfall bulk-data API."""
def get_scryfall_bulk_card_data_url(self) -> tuple[str, int]:
"""Returns the bulk data URL and compressed size in bytes"""
logger.info("Obtaining the card data URL from the API bulk data end point")
data, _ = self.read_from_url(BULK_DATA_API_END_POINT)
with data:
item: BulkDataType = next(ijson.items(data, "", use_float=True))
try:
uri = item["jsonl_download_uri"]
size = item["compressed_size"]
except KeyError as e:
raise RuntimeError("Required data not found in API response. Format change?") from e
logger.debug(f"Bulk data with uncompressed size {size} bytes located at: {uri}")
return uri, size
class FileDownloadTask(CardInfoDownloadTaskBase):
"""Downloading the raw card data to a file stored in the file system."""
def __init__(self, download_path: Path):
|
| ︙ | ︙ | |||
122 123 124 125 126 127 128 |
logger.debug("Request bulk data URL from the Scryfall API.")
url, size = self.get_scryfall_bulk_card_data_url()
file_name = urllib.parse.urlparse(url).path.split("/")[-1]
logger.debug(f"Obtained url: '{url}'")
monitor = self._open_url(
url,
self.tr("Downloading card data:", "Progress bar label text"))
| < < < < < < < | 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
logger.debug("Request bulk data URL from the Scryfall API.")
url, size = self.get_scryfall_bulk_card_data_url()
file_name = urllib.parse.urlparse(url).path.split("/")[-1]
logger.debug(f"Obtained url: '{url}'")
monitor = self._open_url(
url,
self.tr("Downloading card data:", "Progress bar label text"))
if monitor.content_length <= 0:
monitor.content_length = size
download_file_path = self.download_path/file_name
logger.debug(f"Opened URL '{url}' and target file at '{download_file_path}', about to download contents.")
with download_file_path.open("wb") as download_file, monitor:
self.connection = monitor
try:
|
| ︙ | ︙ | |||
167 168 169 170 171 172 173 |
class StreamTask(CardInfoDownloadTaskBase):
"""Base class for tasks that stream data via a queue."""
_queue_depth = 5
_batch_size = 5000
| | < < < < < < < < < | 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
class StreamTask(CardInfoDownloadTaskBase):
"""Base class for tasks that stream data via a queue."""
_queue_depth = 5
_batch_size = 5000
def __init__(self, source: str | Path | None = None, json_path: str = ""):
super().__init__()
self.open_file: GzipFile | MeteredSeekableHTTPFile | None = None
self.source = source
self.json_path = json_path
self.queue: CardDataQueue = queue.Queue(self._queue_depth)
def _enqueue_stream(self, data: CardStream):
"""Put the CardStream into the queue for downstream consumption"""
try:
for batch in itertools.batched(data, self._batch_size): # type: tuple[CardDataType, ...]
self.queue.put(batch)
except AttributeError: # Cancelling closes and deletes the underlying file, causing an AttributeError in run()
logger.info(f"{self.__class__.__name__}: Read operation cancelled")
else:
logger.info(f"{self.__class__.__name__}: Card data exhausted.")
finally:
self.queue.put(None)
@property
def report_progress(self):
|
| ︙ | ︙ | |||
212 213 214 215 216 217 218 |
def can_cancel(self) -> bool:
return True
def cancel(self):
logger.debug(f"{self.__class__.__name__}: entering cancel()")
if self.open_file is not None:
self.open_file.close()
| < | | | | 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
def can_cancel(self) -> bool:
return True
def cancel(self):
logger.debug(f"{self.__class__.__name__}: entering cancel()")
if self.open_file is not None:
self.open_file.close()
while not self.queue.empty():
# Flush the queue to unblock a potentially blocked writer thread:
# The consumer thread stops immediately within it's currently processed batch,
# so may leave the producer in a deadlock waiting for a free queue slot that will never arrive.
try:
self.queue.get(block=False)
except queue.Empty:
time.sleep(0.1)
logger.debug(f"{self.__class__.__name__}: Cancel completed")
class FileStreamTask(StreamTask):
"""Reads card data from a local file and streams the content"""
def run(self):
data = self.read_json_card_data_from(self.source, self.json_path)
self._enqueue_stream(data)
def read_json_card_data_from(self, file_path: Path, json_path: str = "") -> CardStream:
file_size = file_path.stat().st_size
raw_file = file_path.open("rb")
with self._wrap_in_metered_file(raw_file, file_size) as file:
if file_path.suffix.casefold() == ".gz":
self.open_file = file = gzip.open(file, "rb")
while line := file.readline():
yield json.loads(line)
def _wrap_in_metered_file(self, raw_file, file_size: int):
monitor = mtg_proxy_printer.metered_file.MeteredFile(raw_file, file_size)
monitor.total_bytes_processed.connect(self.set_progress)
monitor.io_begin.connect(lambda size: self.task_begins.emit(
size,
self.tr("Importing card data from disk:", "Progress bar label text")))
|
| ︙ | ︙ | |||
269 270 271 272 273 274 275 |
When used as a Task, it streams the decoded card data from the API and batches the result.
This encapsulates requesting data via HTTPS, decryption, gzip stream decompression and parsing into dicts via ijson.
It enqueues a single None as the last value after finishing the last batch.
"""
def run(self):
logger.info(f"{self.__class__.__name__}: About to stream card data in batches of {self._batch_size}")
| | > | > > | | > | | > > | 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
When used as a Task, it streams the decoded card data from the API and batches the result.
This encapsulates requesting data via HTTPS, decryption, gzip stream decompression and parsing into dicts via ijson.
It enqueues a single None as the last value after finishing the last batch.
"""
def run(self):
logger.info(f"{self.__class__.__name__}: About to stream card data in batches of {self._batch_size}")
data = self.read_json_card_data_from(self.source)
try:
self._enqueue_stream(data)
except ValueError: # Cancelling raises ValueError
return
def read_json_card_data_from(self, url: str | None = None) -> CardStream:
"""
Parses the bulk card data JSON from https://scryfall.com/docs/api/bulk-data into individual objects.
This function takes a URL pointing to the card data JSON array in the Scryfall API.
The all cards JSON document is quite large (> 2.1GiB in 2024-10) and requires about 8GiB RAM to parse in one go.
So use an iterative parser to generate and yield individual card objects, without having to store the whole
document in memory.
"""
if url is None:
logger.debug("Request bulk data URL from the Scryfall API.")
url, _ = self.get_scryfall_bulk_card_data_url()
logger.debug(f"Obtained url: {url}")
else:
logger.debug(f"Reading from given URL {url}")
# Ignore the monitor, because progress reporting is done in the main import loop.
self.open_file, _ = self.read_from_url(url) # type: GzipFile | MeteredSeekableHTTPFile, MeteredSeekableHTTPFile
with self.open_file:
while line := self.open_file.readline():
yield json.loads(line)
@functools.cache
def get_available_card_count(self) -> int:
url_parameters = urllib.parse.urlencode({
"include_multilingual": "true",
"include_variations": "true",
"include_extras": "true",
"unique": "prints",
"q": "date>1970-01-01"
})
url = f"https://api.scryfall.com/cards/search?{url_parameters}"
logger.debug(f"Card data update query URL: {url}")
try:
total_cards_available: int = json.load(self.read_from_url(url)[0])["total_cards"]
except (urllib.error.URLError, socket.timeout, StopIteration) as e:
logger.warning(
"Requesting the number of available cards on Scryfall failed with a network error. "
"Report zero available cards.")
self.network_error_occurred.emit(
self.tr(
"Requesting the number of available cards on Scryfall failed: \n{error}",
"Error message shown in a message box").format(error=e))
logger.debug(f"Total cards currently available: {total_cards_available}")
return total_cards_available
@property
def item_count(self):
return self.get_available_card_count()
class AdditionalSetData(typing.NamedTuple):
svg_icon_uri: str
file_name: str
parent_set_code: str | None
class SetDataImportTask(DownloaderBase):
def __init__(self, db: sqlite3.Connection | None = None,
carddb_path: Path | Literal[":memory:"] = DEFAULT_DATABASE_LOCATION):
super().__init__()
self.carddb_path = carddb_path
|
| ︙ | ︙ |
Changes to mtg_proxy_printer/async_tasks/downloader_base.py.
| ︙ | ︙ | |||
37 38 39 40 41 42 43 |
Reads a given URL and returns a file-like object that can and should be used as a context manager.
GZip-Streams are implicitly decompressed.
:param url: URL to fetch
:param ui_hint: Display text shown in the UI next to the progress bar. If empty, no progress bar is shown at all
"""
monitor = self._open_url(url, ui_hint)
encoding = monitor.content_encoding()
| | > > > > > > | 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
Reads a given URL and returns a file-like object that can and should be used as a context manager.
GZip-Streams are implicitly decompressed.
:param url: URL to fetch
:param ui_hint: Display text shown in the UI next to the progress bar. If empty, no progress bar is shown at all
"""
monitor = self._open_url(url, ui_hint)
encoding = monitor.content_encoding()
if encoding == "gzip" or self._extract_file_name(url).endswith(".gz"):
data = gzip.open(monitor, "rb")
elif encoding in ("identity", None): # Implicit "identity" if the Content-Encoding header is missing.
data = monitor
else:
raise RuntimeError(f"Server returned unsupported encoding: {encoding}")
return data, monitor
def _open_url(self, url: str, ui_hint: str) -> mtg_proxy_printer.http_file.MeteredSeekableHTTPFile:
headers = {
"Accept": "*/*",
"Accept-Encoding": ", ".join(supported_encodings)
}
response = mtg_proxy_printer.http_file.MeteredSeekableHTTPFile(url, headers, ui_hint=ui_hint)
if (response_code := response.getcode()) >= 300:
raise RuntimeError(f"Error from server! Error code: {response_code}")
if ui_hint: # Without a display text for the UI, there is no meaningful progress report. So skip if not given
response.total_bytes_processed.connect(self.set_progress)
response.io_begin.connect(self.task_begins)
return response
@staticmethod
def _extract_file_name(url: str):
filename = url.split("/")[-1]
filename = filename.split("?")[0]
return filename
|
Changes to mtg_proxy_printer/carddb_migrations.py.
| ︙ | ︙ | |||
100 101 102 103 104 105 106 |
"include_multilingual": "true",
"include_variations": "true",
"include_extras": "true",
"unique": "prints",
"q": f"date>1970-01-01 date<={timestamp.date()}"
})
try:
| | < < | 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
"include_multilingual": "true",
"include_variations": "true",
"include_extras": "true",
"unique": "prints",
"q": f"date>1970-01-01 date<={timestamp.date()}"
})
try:
card_count = next(aw.get_available_card_count())
except (urllib.error.URLError, socket.error):
card_count = 0
data.append((id_, timestamp.isoformat(), card_count))
# Rate limit the requests to 10 per second, according to the Scryfall API usage recommendations
time.sleep(0.1)
progress_meter.advance_progress.emit()
|
| ︙ | ︙ |
Changes to mtg_proxy_printer/ui/settings_window_pages.py.
| ︙ | ︙ | |||
208 209 210 211 212 213 214 |
logger.debug("User about to import card tata from a previously downloaded file.")
location, _ = QFileDialog.getOpenFileName(
self, self.tr(
"Import previously downloaded card data obtained from Scryfall",
"File selection dialog caption. User should select a previously downloaded card data file.",
),
QStandardPaths.locate(StandardLocation.DownloadLocation, "", LocateOption.LocateDirectory),
| | | 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
logger.debug("User about to import card tata from a previously downloaded file.")
location, _ = QFileDialog.getOpenFileName(
self, self.tr(
"Import previously downloaded card data obtained from Scryfall",
"File selection dialog caption. User should select a previously downloaded card data file.",
),
QStandardPaths.locate(StandardLocation.DownloadLocation, "", LocateOption.LocateDirectory),
self.tr("Scryfall card data (*.jsonl *.jsonl.gz)", "File dialog file-type filter."))
logger.info(f"{location=}")
if not location:
logger.debug("User cancelled file selection. Not importing.")
return
if not (path := pathlib.Path(location)).is_file():
logger.warning("User selected something that is not a file. Aborting.")
QMessageBox.critical(
|
| ︙ | ︙ |
Changes to mtg_proxy_printer/units_and_sizes.py.
| ︙ | ︙ | |||
340 341 342 343 344 345 346 |
class BulkDataType(TypedDict):
"""
The data returned by the bulk data API end point.
See https://scryfall.com/docs/api/bulk-data
"""
id: ShouldBeUUID
uri: str
| | < > | < < | 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
class BulkDataType(TypedDict):
"""
The data returned by the bulk data API end point.
See https://scryfall.com/docs/api/bulk-data
"""
id: ShouldBeUUID
uri: str
type: Literal["oracle_cards", "unique_artwork", "default_cards", "all_cards", "rulings", "art_tags", "oracle_tags"]
name: str
description: str
updated_at: str
jsonl_download_uri: API_URI
compressed_size: int
class SetsAPIDataType(TypedDict):
object: Literal["set"]
id: UUID
code: str
mtgo_code: NotRequired[str]
|
| ︙ | ︙ |