MTGProxyPrinter

Check-in [3147256b62]
Login

Check-in [3147256b62]

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: 3147256b6217e3822d01eb043687989842060ea9c80263f62832b7afea6e3ad1
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
Unified Diff Ignore Whitespace Patch
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
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 math
import shutil
from gzip import GzipFile
from pathlib import Path
import queue
import sqlite3
import socket
import typing







|







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
92
93
94
95
96

97
98


99
100
101
102
103
104
105
    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 item count"""
        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))

        uri = item["download_uri"]
        size = item["size"]


        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):







|




>
|
|
>
>







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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
        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"))
        # Hack: As of writing this, the CDN does not offer the size of the gzip-compressed data.
        # The API also only offers the uncompressed size. So divide the API-provided size by an empirically
        # determined compression factor to estimate the download size. Only do so, if the CDN does not offer the size.
        if monitor.content_encoding() == "gzip":
            file_name += ".gz"
            size = math.floor(size / GZIP_COMPRESSION_FACTOR)
            logger.info(f"Content length estimated as {size} bytes")
        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:







<
<
<
<
<
<
<







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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203


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 = "item"):
        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)
        self._stream = None

    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")
        except Exception as e:
            # Cancelling also exhausts the queue to prevent deadlocks.
            # That exhaustion deliberately causes a "read from closed file".
            # So suppress any error, if the stream no longer exists, as it only disappears through cancel()
            if self._stream is not None:
                signal = self.error_occurred if isinstance(self.source, Path) else self.network_error_occurred
                logger.warning(f"{self.__class__.__name__}: Unexpected end of stream")
                signal.emit(str(e))
        else:
            logger.info(f"{self.__class__.__name__}: Card data exhausted.")
        finally:
            self.queue.put(None)

    @property
    def report_progress(self):







|





<








<
<
<
<
<
<
<
<







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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
    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()
        self.open_file = self._stream = None
        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 = "item") -> 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")
            self._stream = ijson.items(file, json_path, use_float=True)
            yield from self._stream

    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")))







<



















|





|
|







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
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
332

333
334
335
336
337
338
339

    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, self.json_path)

        self._enqueue_stream(data)



    def read_json_card_data_from(self, url: str | None = None, json_path: str = "item") -> 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:
            self._stream = ijson.items(self.open_file, json_path, use_float=True)

            yield from self._stream

    @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 = next(self.read_json_card_data_from(url, "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







|
>
|
>
>

|

















|
>
|













|















>





>







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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63






        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":
            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













|



















>
>
>
>
>
>
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
107
108
109
110
111
112
113
114
115
116
                "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.read_json_card_data_from(
                    f'https://api.scryfall.com/cards/search?{url_parameters}', 'total_cards'
                ))
            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()








|
<
<







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
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 (*.json *.json.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(







|







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
347
348
349
350
351

352
353
354
355
356
357
358
359
360
361
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: str
    name: str
    description: str
    download_uri: str
    updated_at: str

    size: int
    content_type: str
    content_encoding: str


class SetsAPIDataType(TypedDict):
    object: Literal["set"]
    id: UUID
    code: str
    mtgo_code: NotRequired[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]