Many hyperlinks are disabled.
Use anonymous login
to enable hyperlinks.
Changes In Branch import_from_scryfall_api_search Excluding Merge-Ins
This is equivalent to a diff from 8d9398b3cf to 0a24e6d490
2024-08-29
| ||
17:51 | Merge with trunk, Round 1. Merged in decimal document settings support. check-in: 5be445c009 user: thomas tags: WIP | |
2024-08-10
| ||
17:24 | Implement downloading Scryfall searches as CSV from the Scryfall API. Integrate into the deck list import wizard, adding a text field for Scryfall searches that can be directly imported as deck lists. Implements [d6085a9f5c42470a]. check-in: 3ac672ea94 user: thomas tags: trunk | |
17:23 | ScryfallCSVDownloader: Enable downloading "extras", like tokens, planes, etc. Closed-Leaf check-in: 0a24e6d490 user: thomas tags: import_from_scryfall_api_search | |
17:20 | Add changelog entry. Limit the Scryfall search query length to 900 characters. The API has a hard limit of 1000 characters, so stay below that. check-in: c53374c308 user: thomas tags: import_from_scryfall_api_search | |
15:00 | Add test case verifying that the sample data for the tappedout CSV parser is consistent with the actual live API results. check-in: 91311da813 user: thomas tags: import_from_scryfall_api_search | |
2024-08-08
| ||
08:06 | Merge with trunk. check-in: 17b2a898a0 user: thomas tags: l10n | |
2024-08-07
| ||
17:55 | units_and_sizes: Create the unit registry via a setup method. Register a conversion context for length ⇔ pixel conversions. The latter allows conversion without the need of an external function. check-in: 285cd70c39 user: thomas tags: embrace_pint | |
17:48 | Allow floating point values for numerical document settings. Also clamp these values to the [0, 10000] range. Implements [25c5698daf89fc54] and [34f7de80cb8b803e] check-in: 8d9398b3cf user: thomas tags: trunk | |
17:46 | DocumentSettings: Clamp all values to the [0, 10000] range, both when reading from the settings file and from loaded documents. This handles range violations more gracefully than resetting to defaults (settings file) or refusing to load (saved documents). Closed-Leaf check-in: bf1a830afd user: thomas tags: decimal_spacings | |
2024-07-23
| ||
07:34 | Ui string fixes. check-in: 2050ad6dff user: thomas tags: trunk | |
Changes to doc/changelog.md.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # Changelog # Next version (in development) ## New features - Add option to fully automatically remove basic lands from all imported deck lists. - When enabled in the settings, basic lands are automatically stripped from deck lists, otherwise the previous behavior is retained. - The option honors the settings regarding inclusion of Wastes or Snow-Covered basic lands. - Add new card filter to hide Art Series cards, which can be enabled in the application settings. - When updating from previous versions, the filter becomes functional after the next card data update. ## Changed features - Support decimal values in document settings, like margins, image spacings and the card bleed width. - As a safety measure against DoS-attacks via loading malicious documents, limit numerical document settings to 10000mm. Limiting the paper size to 10m (~394in) in each direction prevents the creation of indefinitely large drawing areas that could consume all system main memory until either the application or the system crashes. - Improved the related card search: The search now finds tokens created by Dungeons. Right-clicking a card with "Venture" or "Initiative" now also suggests the tokens created by the dungeon rooms. | > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # Changelog # Next version (in development) ## New features - The deck import wizard can now directly download Scryfall search queries as deck lists - Added a text field to enter a Scryfall card search query, a button to show the result on the Scryfall website, and a button that downloads the search result as a deck list. - Add option to fully automatically remove basic lands from all imported deck lists. - When enabled in the settings, basic lands are automatically stripped from deck lists, otherwise the previous behavior is retained. - The option honors the settings regarding inclusion of Wastes or Snow-Covered basic lands. - Add new card filter to hide Art Series cards, which can be enabled in the application settings. - When updating from previous versions, the filter becomes functional after the next card data update. ## Changed features - The deck list import wizard now supports downloading links to the Scryfall API card search at [https://api.scryfall.com/cards/search](https://scryfall.com/docs/api/cards/search) - Support decimal values in document settings, like margins, image spacings and the card bleed width. - As a safety measure against DoS-attacks via loading malicious documents, limit numerical document settings to 10000mm. Limiting the paper size to 10m (~394in) in each direction prevents the creation of indefinitely large drawing areas that could consume all system main memory until either the application or the system crashes. - Improved the related card search: The search now finds tokens created by Dungeons. Right-clicking a card with "Venture" or "Initiative" now also suggests the tokens created by the dungeon rooms. |
︙ | ︙ |
Changes to mtg_proxy_printer/decklist_downloader.py.
︙ | ︙ | |||
17 18 19 20 21 22 23 24 25 26 27 28 29 30 | This module is responsible for downloading deck lists from a known list of deckbuilder websites. """ import abc import collections import csv import html.parser import io from io import StringIO import platform import re import typing import ijson from PyQt5.QtGui import QValidator | > | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | This module is responsible for downloading deck lists from a known list of deckbuilder websites. """ import abc import collections import csv import html.parser import io import urllib.parse from io import StringIO import platform import re import typing import ijson from PyQt5.QtGui import QValidator |
︙ | ︙ | |||
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | class DecklistDownloader(DownloaderBase): DECKLIST_PATH_RE = re.compile(r"") PARSER_CLASS: typing.Type[ParserBase] = None APPLICABLE_WEBSITES: str = "" def download(self, decklist_url: str) -> str: logger.info(f"About to fetch deck list from {decklist_url}") download_url = self.map_to_download_url(decklist_url) logger.debug(f"Obtained download URL: {download_url}") data, monitor = self.read_from_url(download_url, "Downloading deck list:") with data, monitor: raw_data = data.read() deck_list = self.post_process(raw_data) line_count = deck_list.count('\n') logger.debug(f"Obtained deck list containing {line_count} lines.") return deck_list @staticmethod def post_process(data: bytes) -> str: """Takes the raw, downloaded data and post-processes them into a user-presentable string.""" deck_list = data.replace(b"\r\n", b"\n") deck_list = deck_list.decode("utf-8") return deck_list @abc.abstractmethod def map_to_download_url(self, decklist_url: str) -> str: | > > > > > | | | > | | > > > > > > > > > > | 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | class DecklistDownloader(DownloaderBase): DECKLIST_PATH_RE = re.compile(r"") PARSER_CLASS: typing.Type[ParserBase] = None APPLICABLE_WEBSITES: str = "" def download(self, decklist_url: str) -> str: """ Fetches the decklist from the given URL. The base class handles the download including transparent decompression, and performs post-processing steps: Replacing Windows-style line endings \r\n with plain \n newlines, and decoding bytes assuming utf-8 input """ logger.info(f"About to fetch deck list from {decklist_url}") download_url = self.map_to_download_url(decklist_url) logger.debug(f"Obtained download URL: {download_url}") data, monitor = self.read_from_url(download_url, "Downloading deck list:") with data, monitor: raw_data = data.read() deck_list = self.post_process(raw_data) line_count = deck_list.count('\n') logger.debug(f"Obtained deck list containing {line_count} lines.") return deck_list @staticmethod def post_process(data: bytes) -> str: """Takes the raw, downloaded data and post-processes them into a user-presentable string.""" deck_list = data.replace(b"\r\n", b"\n") deck_list = deck_list.decode("utf-8") return deck_list @abc.abstractmethod def map_to_download_url(self, decklist_url: str) -> str: """Takes a URL to a deck list and returns a download URL. By default, returns the identity""" return decklist_url class ScryfallDownloader(DecklistDownloader): DECKLIST_PATH_RE = re.compile( r"(https://scryfall\.com/@\w+/decks/(?P<uuid>[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12})/?)|" r"(https://api.scryfall.com/cards/search?.*(?P<search_param>q=.+).*)" ) PARSER_CLASS = ScryfallCSVParser APPLICABLE_WEBSITES = "Scryfall (scryfall.com)" def map_to_download_url(self, decklist_url: str) -> str: match = self.DECKLIST_PATH_RE.match(decklist_url) if uuid := match.group("uuid"): return f"https://api.scryfall.com/decks/{uuid}/export/csv" else: search_parameters = decklist_url.split("search?", 1)[1] parsed_parameters = dict(urllib.parse.parse_qsl(search_parameters)) parsed_parameters["format"] = "csv" # Enforce CSV format parsed_parameters["include_multilingual"] = "true" parsed_parameters["include_extras"] = "true" quoted_parameters = "&".join( f"{key}={urllib.parse.quote(value)}" for key, value in parsed_parameters.items()) return f"https://api.scryfall.com/cards/search?{quoted_parameters}" class MTGAZoneHTMLParser(html.parser.HTMLParser): def __init__(self, *, convert_charrefs: bool = True): super().__init__(convert_charrefs=convert_charrefs) self.deck: typing.List[str] = [] |
︙ | ︙ |
Changes to mtg_proxy_printer/decklist_parser/csv_parsers.py.
︙ | ︙ | |||
103 104 105 106 107 108 109 | doublequote = True skipinitialspace = False lineterminator = "\n" quoting = csv.QUOTE_MINIMAL DIALECT_NAME = "scryfall_com" USED_COLUMNS = { | | | | | > > > | | > > > | | | | | > > | 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | doublequote = True skipinitialspace = False lineterminator = "\n" quoting = csv.QUOTE_MINIMAL DIALECT_NAME = "scryfall_com" USED_COLUMNS = { "scryfall_id", "lang", } SUPPORTED_FILE_TYPES = { "Scryfall CSV export": ["csv"] } def parse_cards_from_line(self, line: typing.Dict[str, str], guess_printing: bool, language_override: str = None) \ -> LineParserResult: cards = collections.Counter() scryfall_id = line["scryfall_id"] count = int(line.get("count", 1)) language = line["lang"] target_language = language_override or language if card := self.card_db.get_card_with_scryfall_id(scryfall_id, True): if language_override: card = self.card_db.translate_card(card, target_language) self._add_card_to_deck(cards, card, count) elif card := self._handle_removed_printing(scryfall_id, language, guess_printing): if language_override: card = self.card_db.translate_card(card, target_language) self._add_card_to_deck(cards, card, count) elif guess_printing: logger.debug(f"Card not identified. Try to automatically select a printing") english_name = line.get("name") set_code = line.get("set_code") collector_number = line.get("collector_number") if english_name: card_name = english_name if target_language == "en" else self.card_db.translate_card_name( CardIdentificationData("en", english_name, scryfall_id=scryfall_id), target_language) else: card_name = english_name if card_name or (set_code and collector_number): card_data = CardIdentificationData( target_language, card_name, set_code, collector_number ) if (card := self.guess_printing(card_data)) is not None: self._add_card_to_deck(cards, card, count) else: logger.info("Not enough data available to select a printing for the given line. Skipping.") return cards def _handle_removed_printing(self, scryfall_id: str, language: str, guess_printing: bool) -> typing.Optional[Card]: if self.card_db.is_removed_printing(scryfall_id): choices = self.card_db.get_replacement_card_for_unknown_printing( CardIdentificationData(language, scryfall_id=scryfall_id, is_front=True), order_by_print_count=guess_printing) |
︙ | ︙ |
Changes to mtg_proxy_printer/http_file.py.
︙ | ︙ | |||
217 218 219 220 221 222 223 224 225 226 227 228 229 230 | if first_byte > 0: headers["range"] = f"bytes={first_byte}-{self.content_length-1}" request = urllib.request.Request(self.url, headers=headers) last_error = None for retry in range(outer_retries, self.retry_limit or 1): try: response: http.client.HTTPResponse = urllib.request.urlopen(request) except urllib.error.URLError as e: # URLError is most likely caused by being offline, # so wait a bit to not immediately burn all remaining retries if self.closed: # Do not sleep, if this instance was closed externally. Just break in that case. break time.sleep(5) | > > > > | 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | if first_byte > 0: headers["range"] = f"bytes={first_byte}-{self.content_length-1}" request = urllib.request.Request(self.url, headers=headers) last_error = None for retry in range(outer_retries, self.retry_limit or 1): try: response: http.client.HTTPResponse = urllib.request.urlopen(request) except urllib.error.HTTPError as e: if e.code in {400, 403, 404}: # Do not re-try bad requests, permission denied or not-found URLs raise e except urllib.error.URLError as e: # URLError is most likely caused by being offline, # so wait a bit to not immediately burn all remaining retries if self.closed: # Do not sleep, if this instance was closed externally. Just break in that case. break time.sleep(5) |
︙ | ︙ |
Changes to mtg_proxy_printer/resources/ui/deck_import_wizard/load_list_page.ui.
1 2 3 4 5 6 7 8 9 10 11 | <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> <class>LoadListPage</class> <widget class="QWizardPage" name="LoadListPage"> <property name="enabled"> <bool>true</bool> </property> <property name="geometry"> <rect> <x>0</x> <y>0</y> | | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | | > > > > > > > | | | > > > | > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> <class>LoadListPage</class> <widget class="QWizardPage" name="LoadListPage"> <property name="enabled"> <bool>true</bool> </property> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>712</width> <height>343</height> </rect> </property> <property name="title"> <string>Import a deck list for printing</string> </property> <property name="subTitle"> <string>Load a deck file from disk or paste deck list in the text field below</string> </property> <layout class="QGridLayout" name="gridLayout"> <item row="6" column="1" colspan="3"> <widget class="QComboBox" name="translate_deck_list_target_language"> <property name="enabled"> <bool>false</bool> </property> <property name="sizePolicy"> <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> <horstretch>1</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> </widget> </item> <item row="0" column="0" colspan="2"> <widget class="QLineEdit" name="deck_list_download_url_line_edit"> <property name="placeholderText"> <string>Paste a link to a deck list here. Hover to see supported sites.</string> </property> </widget> </item> <item row="1" column="0" colspan="2"> <widget class="QLineEdit" name="scryfall_search"> <property name="maxLength"> <number>900</number> </property> <property name="placeholderText"> <string>Scryfall search query</string> </property> </widget> </item> <item row="7" column="0" colspan="4"> <widget class="QCheckBox" name="print_guessing_enable"> <property name="toolTip"> <string>If checked, choose an arbitrary printing, if a unique printing is not identified. If unchecked, each ambiguous card is ignored and reported as unrecognized.</string> </property> <property name="text"> <string>Guess printings for ambiguous entries in the deck list</string> </property> </widget> </item> <item row="1" column="3"> <widget class="QPushButton" name="scryfall_search_download_button"> <property name="enabled"> <bool>false</bool> </property> <property name="sizePolicy"> <sizepolicy hsizetype="Maximum" vsizetype="Fixed"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="text"> <string extracomment="Download the entered Scryfall search query as a deck list">Download result</string> </property> <property name="icon"> <iconset theme="edit-download"> <normaloff>../../../../</normaloff>../../../../</iconset> </property> </widget> </item> <item row="4" column="0" colspan="4"> <widget class="QPlainTextEdit" name="deck_list"> <property name="placeholderText"> <string>Paste your deck list here or use one of the actions above</string> </property> </widget> </item> <item row="8" column="0" colspan="4"> <widget class="QCheckBox" name="print_guessing_prefer_already_downloaded"> <property name="enabled"> <bool>true</bool> </property> <property name="toolTip"> <string>When an exact printing is not determined or card translation is requested, choose a printing that is already downloaded, if possible. Enabling this can potentially save disk space and download volume, based on the images already downloaded.</string> </property> <property name="text"> <string>When guessing or translating cards, prefer printings with already downloaded images</string> </property> </widget> </item> <item row="6" column="0"> <widget class="QCheckBox" name="translate_deck_list_enable"> <property name="sizePolicy"> <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="text"> <string>Translate deck list to:</string> </property> </widget> </item> <item row="5" column="0" colspan="4"> <widget class="Line" name="line"> <property name="orientation"> <enum>Qt::Horizontal</enum> </property> </widget> </item> <item row="3" column="0" colspan="4"> <widget class="QPushButton" name="deck_list_browse_button"> <property name="toolTip"> <string>Opens a file picker and lets you load a deck file from disk.</string> </property> <property name="text"> <string extracomment="Lets the user select a file, and loads the content as a deck list">Select deck list file</string> </property> <property name="icon"> <iconset theme="document-open"/> </property> </widget> </item> <item row="1" column="2"> <widget class="QPushButton" name="scryfall_search_view_button"> <property name="enabled"> <bool>false</bool> </property> <property name="sizePolicy"> <sizepolicy hsizetype="Maximum" vsizetype="Fixed"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="text"> <string extracomment="View the entered Scryfall search query on the Scryfall website">View result</string> </property> <property name="icon"> <iconset theme="globe"/> </property> </widget> </item> <item row="0" column="2" colspan="2"> <widget class="QPushButton" name="deck_list_download_button"> <property name="enabled"> <bool>false</bool> </property> <property name="sizePolicy"> <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="text"> <string extracomment="On pressing the button, the deck list given by the entered URL is downloaded">Download deck list</string> </property> <property name="icon"> <iconset theme="edit-download"/> </property> </widget> </item> </layout> </widget> <tabstops> <tabstop>deck_list_download_url_line_edit</tabstop> <tabstop>deck_list_download_button</tabstop> <tabstop>deck_list_browse_button</tabstop> |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/deck_import_wizard.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 22 23 | import configparser import itertools import math import pathlib import re import typing import urllib.error from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, pyqtProperty as Property, QStringListModel, Qt, \ | > | | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | import configparser import itertools import math import pathlib import re import typing import urllib.error import urllib.parse from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, pyqtProperty as Property, QStringListModel, Qt, \ QItemSelection, QSize, QUrl from PyQt5.QtGui import QValidator, QIcon, QDesktopServices from PyQt5.QtWidgets import QWizard, QFileDialog, QMessageBox, QWizardPage, QWidget, QRadioButton import mtg_proxy_printer.settings from mtg_proxy_printer.decklist_parser import re_parsers, common, csv_parsers from mtg_proxy_printer.decklist_downloader import IsIdentifyingDeckUrlValidator, AVAILABLE_DOWNLOADERS, \ get_downloader_class, ParserBase from mtg_proxy_printer.model.carddb import CardDatabase |
︙ | ︙ | |||
96 97 98 99 100 101 102 | class LoadListPage(QWizardPage): LARGE_FILE_THRESHOLD_BYTES = 200*2**10 deck_list_downloader_changed = Signal(str) def __init__(self, language_model: QStringListModel, *args, **kwargs): super().__init__(*args, **kwargs) | | | > > > > > > | | | | | | | | | | | 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | class LoadListPage(QWizardPage): LARGE_FILE_THRESHOLD_BYTES = 200*2**10 deck_list_downloader_changed = Signal(str) def __init__(self, language_model: QStringListModel, *args, **kwargs): super().__init__(*args, **kwargs) self.ui = ui = Ui_LoadListPage() ui.setupUi(self) self.deck_list_url_validator = IsIdentifyingDeckUrlValidator(self) self._deck_list_downloader: typing.Optional[str] = None ui.scryfall_search.textChanged.connect( lambda text: ui.scryfall_search_view_button.setEnabled(bool(text)) ) ui.scryfall_search.textChanged.connect( lambda text: ui.scryfall_search_download_button.setEnabled(bool(text)) ) ui.deck_list_download_url_line_edit.textChanged.connect( lambda text: ui.deck_list_download_button.setEnabled( self.deck_list_url_validator.validate(text)[0] == State.Acceptable)) supported_sites = "\n".join((downloader.APPLICABLE_WEBSITES for downloader in AVAILABLE_DOWNLOADERS.values())) ui.deck_list_download_url_line_edit.setToolTip(f"Supported websites:\n{supported_sites}") ui.translate_deck_list_target_language.setModel(language_model) self.registerField("deck_list*", ui.deck_list, "plainText", ui.deck_list.textChanged) self.registerField("print-guessing-enable", ui.print_guessing_enable) self.registerField("print-guessing-prefer-already-downloaded", ui.print_guessing_prefer_already_downloaded) self.registerField("translate-deck-list-enable", ui.translate_deck_list_enable) self.registerField("deck-list-downloaded", self, "deck_list_downloader", self.deck_list_downloader_changed) self.registerField( "translate-deck-list-target-language", ui.translate_deck_list_target_language, "currentText", ui.translate_deck_list_target_language.currentTextChanged ) logger.info(f"Created {self.__class__.__name__} instance.") @Property(str, notify=deck_list_downloader_changed) def deck_list_downloader(self): return self._deck_list_downloader |
︙ | ︙ | |||
191 192 193 194 195 196 197 198 199 | f'{name} (*.{" *.".join(extensions)})' for name, extensions in individual_file_types) \ + f";;{everything}" return result @Slot() def on_deck_list_download_button_clicked(self): if not self.ui.deck_list.toPlainText() \ or QMessageBox.question( | > > > > > > > | | | < | < < < > > > > > > > > > > > > > > | 198 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 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | f'{name} (*.{" *.".join(extensions)})' for name, extensions in individual_file_types) \ + f";;{everything}" return result @Slot() def on_deck_list_download_button_clicked(self): url = self.ui.deck_list_download_url_line_edit.text() bad_request_msg="Verify that the URL is valid, reachable, and that the deck list is set to public.\n" \ "This program cannot download private deck lists. Please note, that setting deck lists to\n" \ "public may take a minute or two to apply." self._populate_deck_list_from_url(url, bad_request_msg) def _populate_deck_list_from_url(self, url: str, bad_request_msg: str): if not self.ui.deck_list.toPlainText() \ or QMessageBox.question( self, "Overwrite existing deck list?", "Downloading a deck list will overwrite the existing deck list. Continue?", StandardButton.Yes | StandardButton.No) == StandardButton.Yes: logger.info(f"User requests to download a deck list from the internet: {url}") downloader_class = get_downloader_class(url) if downloader_class is not None: self.setField("deck-list-downloaded", downloader_class.__name__) downloader = downloader_class(self) try: deck_list = downloader.download(url) except urllib.error.HTTPError as e: btn = StandardButton.Ok msg = f"Download failed with HTTP error {e.code}.\n\n{bad_request_msg}" QMessageBox.critical(self, "Deck list download failed", msg, btn, btn) except Exception: btn = StandardButton.Ok msg = f"Download failed.\n\n" \ f"Check your internet connection, verify that the URL is valid, reachable, " \ f"and that the deck list is set to public. " \ f"This program cannot download private deck lists. If this persists, " \ f"please report a bug in the issue tracker on the homepage." QMessageBox.critical(self, "Deck list download failed", msg, btn, btn) else: self.ui.deck_list.setPlainText(deck_list) @Slot() def on_scryfall_search_view_button_clicked(self): logger.debug("User views the currently entered Scryfall query on the Scryfall website") query = urllib.parse.quote(self.ui.scryfall_search.text()) QDesktopServices.openUrl(QUrl(f"https://scryfall.com/search?q={query}")) @Slot() def on_scryfall_search_download_button_clicked(self): logger.debug("User downloads the currently entered Scryfall query results") query = urllib.parse.quote(self.ui.scryfall_search.text()) self._populate_deck_list_from_url( f"https://api.scryfall.com/cards/search?q={query}", "Invalid Scryfall query entered, no result obtained") def _load_from_file(self, selected_file: typing.Optional[str]): if selected_file and (file_path := pathlib.Path(selected_file)).is_file() and \ self._ask_about_large_file(file_path): try: logger.debug("Selected path is valid file, trying to load the content") content = file_path.read_text() except UnicodeDecodeError: |
︙ | ︙ |
Changes to tests/decklist_parser/test_scryfall_csv_parser.py.
︙ | ︙ | |||
17 18 19 20 21 22 23 24 | import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData from mtg_proxy_printer.decklist_parser.csv_parsers import ScryfallCSVParser | > | | | > > > > > | > > > > > > > > > > > | > | > > > > > > > > > | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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 70 71 72 73 74 75 76 77 | import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData from mtg_proxy_printer.decklist_parser.csv_parsers import ScryfallCSVParser from mtg_proxy_printer.decklist_downloader import DecklistDownloader from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS StringList = typing.List[str] DECK_LIST_CSV_HEADER = "section,count,name,mana_cost,type,set,set_code,collector_number,lang,rarity," \ "artist,finish,usd_price,eur_price,tix_price,scryfall_uri,scryfall_id" SEARCH_CSV_HEADER = "multiverse_id,mtgo_id,set,collector_number,lang,rarity,name,mana_cost,cmc,type_line,artist," \ "usd_price,usd_foil_price,eur_price,tix_price,image_uri,scryfall_uri,scryfall_id" def append_to_header(header: str, plain_deck_list: str) -> str: return f"{header}\n{plain_deck_list}" @pytest.mark.skipif(SHOULD_SKIP_NETWORK_TESTS, reason="Skipping network-hitting tests") @pytest.mark.parametrize("url, header", [ ("https://api.scryfall.com/decks/e1a9af19-cfff-48c4-ae74-ed2dd78cb736/export/csv", DECK_LIST_CSV_HEADER), ("https://api.scryfall.com/cards/search?q=scryfallid%3Ad99a9a7d-d9ca-4c11-80ab-e39d5943a315&format=csv", SEARCH_CSV_HEADER) ]) def test_local_header_conforms_to_current_scryfall_return_data(url: str, header: str): """Verifies that the hard-coded CSV headers above match what the API returns""" downloader = DecklistDownloader() result = downloader.download(url) expected = result.splitlines()[0] assert_that( header, is_(equal_to(expected)), "CSV header format changed on Scryfall" ) def generate_test_cases_for_translation_and_replacement(): yield ( ["german_Back_to_Basics", "english_Back_to_Basics"], append_to_header( DECK_LIST_CSV_HEADER, "nonlands,1,Back to Basics,{2}{U},Enchantment,Urza's Saga,usg,62,de,rare,Andrew Robinson,,13.27,7.9," "2.92,https://scryfall.com/card/usg/62/de/grundlagenforschung,97b84e7d-258f-46dc-baef-4b1eb6f28d4d"), CardIdentificationData("en", "Back to Basics", is_front=True,) ) yield ( ["german_Back_to_Basics", "english_Back_to_Basics"], append_to_header( SEARCH_CSV_HEADER, ",,USG,62,de,R,Back to Basics,{2}{U},3.0,Enchantment,Andrew Robinson,,,,," "https://cards.scryfall.io/large/front/9/7/97b84e7d-258f-46dc-baef-4b1eb6f28d4d.jpg?1562927127," "https://scryfall.com/card/usg/62/de/grundlagenforschung,97b84e7d-258f-46dc-baef-4b1eb6f28d4d"), CardIdentificationData("en", "Back to Basics", is_front=True,) ) @pytest.mark.parametrize( "cards_to_import, deck_list, expected_card", generate_test_cases_for_translation_and_replacement()) def test_excluded_printing_is_replaced_with_an_available_printing( qtbot, card_db, image_db, cards_to_import: StringList, deck_list: str, expected_card: CardIdentificationData): fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {"hide-cards-without-images": "True"}) |
︙ | ︙ | |||
91 92 93 94 95 96 97 | return card_list[0] def generate_test_cases_for_test_card_identification_works_in_simple_cases(): yield ( ["english_basic_Forest", "english_basic_Forest_2"], append_to_header( | > | > > > > > > > > > | 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | return card_list[0] def generate_test_cases_for_test_card_identification_works_in_simple_cases(): yield ( ["english_basic_Forest", "english_basic_Forest_2"], append_to_header( DECK_LIST_CSV_HEADER, "columna,1,Forest,"",Basic Land — Forest,Arena Beginner Set,anb,112,en,common,Jonas De Ro,,0.06,0.01," "0.01,https://scryfall.com/card/anb/112/forest,7ef83f4c-d3ff-4905-a16d-f2bae673a5b2"), CardIdentificationData("en", "Forest", scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True,) ) yield ( ["english_basic_Forest", "english_basic_Forest_2"], append_to_header( SEARCH_CSV_HEADER, "548224,,ANB,112,en,C,Forest,"",0.0,Basic Land — Forest,Jonas De Ro,,,,," "https://cards.scryfall.io/large/front/7/e/7ef83f4c-d3ff-4905-a16d-f2bae673a5b2.jpg?1597375433," "https://scryfall.com/card/anb/112/forest,7ef83f4c-d3ff-4905-a16d-f2bae673a5b2"), CardIdentificationData("en", "Forest", scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True,) ) @pytest.mark.parametrize( "cards_to_import, deck_list, expected_card", generate_test_cases_for_test_card_identification_works_in_simple_cases()) def test_card_identification_works_in_simple_cases( qtbot, card_db, image_db, cards_to_import: StringList, deck_list: str, expected_card: CardIdentificationData): |
︙ | ︙ | |||
126 127 128 129 130 131 132 | @pytest.mark.parametrize( "cards_to_import, deck_list", [ ( ["english_basic_Forest"], append_to_header( | > | | | 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | @pytest.mark.parametrize( "cards_to_import, deck_list", [ ( ["english_basic_Forest"], append_to_header( DECK_LIST_CSV_HEADER, "columna,invalid_count,Forest,"",Basic Land — Forest,Arena Beginner Set,anb,112,en,common,Jonas De Ro,,0.06,0.01," "0.01,https://scryfall.com/card/anb/112/forest,7ef83f4c-d3ff-4905-a16d-f2bae673a5b2"), ), ] ) def test_line_with_invalid_count_is_added_to_invalid_lines( qtbot, card_db, image_db, cards_to_import: StringList, deck_list: str,): fill_card_database_with_json_cards(qtbot, card_db, cards_to_import) parser = ScryfallCSVParser(card_db, image_db) |
︙ | ︙ |
Changes to tests/decklist_parser/test_tappedout_csv_parser.py.
︙ | ︙ | |||
17 18 19 20 21 22 23 24 | import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData from mtg_proxy_printer.decklist_parser.csv_parsers import TappedOutCSVParser | > | | > > > > > > > > > > > > > > | | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData from mtg_proxy_printer.decklist_parser.csv_parsers import TappedOutCSVParser from mtg_proxy_printer.decklist_downloader import DecklistDownloader from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS StringList = typing.List[str] CSV_HEADER = "Board,Qty,Name,Printing,Foil,Alter,Signed,Condition,Language" def append_to_header(plain_deck_list: str) -> str: return f"{CSV_HEADER}\n{plain_deck_list}" @pytest.mark.skipif(SHOULD_SKIP_NETWORK_TESTS, reason="Skipping network-hitting tests") @pytest.mark.parametrize("url, header", [ ("https://tappedout.net/mtg-decks/mtgproxyprinter-test-deck/", CSV_HEADER), # TODO: Commander decks have an additional column for Commander designation ]) def test_local_header_conforms_to_current_scryfall_return_data(url: str, header: str): """Verifies that the hard-coded CSV headers above match what the API returns""" downloader = DecklistDownloader() result = downloader.download(url) expected = result.splitlines()[0] assert_that( header, is_(equal_to(expected)), "CSV header format changed on Tappedout" ) def generate_test_cases_for_translation_and_replacement(): yield ( ["german_Back_to_Basics", "english_Back_to_Basics"], append_to_header("main,1,Back to Basics,USG,,,,,de"), CardIdentificationData("en", "Back to Basics", is_front=True,) ) @pytest.mark.parametrize( "cards_to_import, deck_list, expected_card", generate_test_cases_for_translation_and_replacement()) def test_excluded_printing_is_replaced_with_an_available_printing( |
︙ | ︙ | |||
86 87 88 89 90 91 92 | ) return card_list[0] def generate_test_cases_for_test_card_identification_works_in_simple_cases(): yield ( ["english_basic_Forest", "english_basic_Forest_2"], | | | 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | ) return card_list[0] def generate_test_cases_for_test_card_identification_works_in_simple_cases(): yield ( ["english_basic_Forest", "english_basic_Forest_2"], append_to_header("main,1,Forest,ANB,,,,,en"), CardIdentificationData("en", "Forest", scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True,) ) @pytest.mark.parametrize( "cards_to_import, deck_list, expected_card", generate_test_cases_for_test_card_identification_works_in_simple_cases()) |
︙ | ︙ | |||
115 116 117 118 119 120 121 | ) @pytest.mark.parametrize( "cards_to_import, deck_list", [ ( ["english_basic_Forest"], | | | 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | ) @pytest.mark.parametrize( "cards_to_import, deck_list", [ ( ["english_basic_Forest"], append_to_header("main,invalid_count,Forest,ANB,,,,,en"), ), ] ) def test_rows_with_invalid_data_are_added_to_invalid_lines( qtbot, card_db, image_db, cards_to_import: StringList, deck_list: str): fill_card_database_with_json_cards(qtbot, card_db, cards_to_import) parser = TappedOutCSVParser(card_db, image_db) |
︙ | ︙ |
Changes to tests/test_decklist_downloader.py.
︙ | ︙ | |||
43 44 45 46 47 48 49 | yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/download/5077398?output=mtggoldfish&type=online" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/download/5077398?output=dek&type=online" # Deck archetype links yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#paper" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#arena" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#online" | | > > > | 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 70 71 72 73 74 | yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/download/5077398?output=mtggoldfish&type=online" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/download/5077398?output=dek&type=online" # Deck archetype links yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#paper" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#arena" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge#online" # Scryfall deck lists yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?with=eur" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?with=tix" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?with=arena" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?with=cah" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?as=visual" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/?as=visual&with=eur" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?with=eur" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?with=tix" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?with=arena" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?with=cah" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?as=visual" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e8-bfdea0951ce3?as=visual&with=eur" # Scryfall API searches yield ScryfallDownloader, "https://api.scryfall.com/cards/search?q=e%3Arex+cn%E2%89%A527+cn%E2%89%A445&format=csv" yield ScryfallDownloader, "https://api.scryfall.com/cards/search?format=csv&q=e%3Arex+cn%E2%89%A527+cn%E2%89%A445" # mtg.wtf yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/prismari-performance" yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/prismari-performance/" # MTG Arena Zone (mtgazone.com) yield MTGAZoneDownloader, "https://mtgazone.com/deck/orzhov-phyrexians-march-of-the-machine-theorycraft/" |
︙ | ︙ | |||
161 162 163 164 165 166 167 168 169 170 171 172 173 174 | # Scryfall yield ScryfallDownloader, "https://scryfall.com/@user/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/" # missing /deck # Invalid/missing UUIDS yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e-8-bfdea0951ce3/?with=eur" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-xyza-83e8-bfdea0951ce3/?with=tix" yield ScryfallDownloader, "https://scryfall.com/@user/decks/?with=arena" # mtg.wtf yield MTGWTFDownloader, "https://mtg.wtf/deck/c21" yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/" # MTG Arena Zone (mtgazone.com) yield MTGAZoneDownloader, "https://mtgazone.com/deck" | > > | 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | # Scryfall yield ScryfallDownloader, "https://scryfall.com/@user/8c02b4b2-50e2-4431-83e8-bfdea0951ce3/" # missing /deck # Invalid/missing UUIDS yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-4431-83e-8-bfdea0951ce3/?with=eur" yield ScryfallDownloader, "https://scryfall.com/@user/decks/8c02b4b2-50e2-xyza-83e8-bfdea0951ce3/?with=tix" yield ScryfallDownloader, "https://scryfall.com/@user/decks/?with=arena" # API search without query yield ScryfallDownloader, "https://api.scryfall.com/cards/search?format=csv" # mtg.wtf yield MTGWTFDownloader, "https://mtg.wtf/deck/c21" yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/" # MTG Arena Zone (mtgazone.com) yield MTGAZoneDownloader, "https://mtgazone.com/deck" |
︙ | ︙ | |||
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | -> typing.Generator[typing.Tuple[typing.Type[DecklistDownloader], str, str], None, None]: """ Yields tuples with Parser class, deck list url and a snippet of the deck list content. It does not include the full deck list, because reported printings or price information may change over time, causing test failures. The tests should pass as long as the website returns some plausible data. """ yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/prismari-performance/", "1 Jaya Ballard" yield ScryfallDownloader, "https://scryfall.com/@luziferius/decks/e1a9af19-cfff-48c4-ae74-ed2dd78cb736", "Island" yield MTGAZoneDownloader, "https://mtgazone.com/deck/orzhov-phyrexians-march-of-the-machine-theorycraft/", "3 Cut Down" yield MTGTop8Downloader, "http://mtgtop8.com/event?e=9011&d=251345&f=BL", "4 [KTK] Abzan Charm" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/5136573", "1 Ancestral Recall" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge", "4 Lion's Eye Diamond" yield TappedOutDownloader, "https://tappedout.net/mtg-decks/mtgproxyprinter-test-deck/", "Island" yield MoxfieldDownloader, "https://www.moxfield.com/decks/g1i2wHXC3kW0lanwY4Llkw", '"Zamriel, Seraph of Steel"' yield DeckstatsDownloader, "https://deckstats.net/decks/44867/576160-br-control-kld", "2 Blighted Fen" | > > > | 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 | -> typing.Generator[typing.Tuple[typing.Type[DecklistDownloader], str, str], None, None]: """ Yields tuples with Parser class, deck list url and a snippet of the deck list content. It does not include the full deck list, because reported printings or price information may change over time, causing test failures. The tests should pass as long as the website returns some plausible data. """ yield MTGWTFDownloader, "https://mtg.wtf/deck/c21/prismari-performance/", "1 Jaya Ballard" # Deck list yield ScryfallDownloader, "https://scryfall.com/@luziferius/decks/e1a9af19-cfff-48c4-ae74-ed2dd78cb736", "Island" # API search yield ScryfallDownloader, "https://api.scryfall.com/cards/search?format=csv&q=e%3Arex+cn%E2%89%A527+cn%E2%89%A445", "f197b176-8fa0-451b-a981-a7a942890296" yield MTGAZoneDownloader, "https://mtgazone.com/deck/orzhov-phyrexians-march-of-the-machine-theorycraft/", "3 Cut Down" yield MTGTop8Downloader, "http://mtgtop8.com/event?e=9011&d=251345&f=BL", "4 [KTK] Abzan Charm" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/deck/5136573", "1 Ancestral Recall" yield MTGGoldfishDownloader, "https://www.mtggoldfish.com/archetype/legacy-led-dredge", "4 Lion's Eye Diamond" yield TappedOutDownloader, "https://tappedout.net/mtg-decks/mtgproxyprinter-test-deck/", "Island" yield MoxfieldDownloader, "https://www.moxfield.com/decks/g1i2wHXC3kW0lanwY4Llkw", '"Zamriel, Seraph of Steel"' yield DeckstatsDownloader, "https://deckstats.net/decks/44867/576160-br-control-kld", "2 Blighted Fen" |
︙ | ︙ |