MTGProxyPrinter

Changes On Branch import_from_scryfall_api_search
Login

Changes On Branch import_from_scryfall_api_search

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

98
99
100
101
102
103
104
105










106
107
108
109
110
111
112

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:
        """Takes a URL to a deck list and returns a download URL"""
        pass


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})/?"

    )
    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)
        uuid = match.group("uuid")
        return f"https://api.scryfall.com/decks/{uuid}/export/csv"












class MTGAZoneHTMLParser(html.parser.HTMLParser):
    def __init__(self, *, convert_charrefs: bool = True):
        super().__init__(convert_charrefs=convert_charrefs)
        self.deck: typing.List[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
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
        doublequote = True
        skipinitialspace = False
        lineterminator = "\n"
        quoting = csv.QUOTE_MINIMAL

    DIALECT_NAME = "scryfall_com"
    USED_COLUMNS = {
        "scryfall_id", "count", "lang", "name", "set_code", "collector_number",
    }

    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["count"])
        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 guessing a printing")
            english_name = line["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)



            card_data = CardIdentificationData(
                target_language, card_name, line["set_code"], line["collector_number"]
            )
            if (card := self.guess_printing(card_data)) is not None:
                self._add_card_to_deck(cards, card, count)


        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)







|










|












|
|
>
>
>
|
|
>
>
>
|
|
|
|
|
>
>







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
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
<?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>581</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="5" 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="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="0" column="2">
    <widget class="QPushButton" name="deck_list_download_button">
     <property name="enabled">
      <bool>false</bool>
     </property>
     <property name="text">
      <string>Download</string>
     </property>
     <property name="icon">
      <iconset theme="edit-download"/>
     </property>
    </widget>
   </item>
   <item row="2" column="0" colspan="3">
    <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>Select deck list file</string>
     </property>
     <property name="icon">
      <iconset theme="document-open">
       <normaloff>.</normaloff>.</iconset>
     </property>
    </widget>
   </item>
   <item row="3" column="0" colspan="3">
    <widget class="QPlainTextEdit" name="deck_list">
     <property name="placeholderText">
      <string>Paste your deck list here or select a file using the button above.</string>
     </property>
    </widget>
   </item>
   <item row="5" column="1" colspan="2">
    <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="4" column="0" colspan="3">







    <widget class="Line" name="line">
     <property name="orientation">
      <enum>Qt::Horizontal</enum>



     </property>
    </widget>
   </item>
   <item row="6" column="0" colspan="3">
    <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="7" column="0" colspan="3">







    <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>
  </layout>
 </widget>
 <tabstops>
  <tabstop>deck_list_download_url_line_edit</tabstop>
  <tabstop>deck_list_download_button</tabstop>
  <tabstop>deck_list_browse_button</tabstop>











|










<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|












|
>
>
>
>
>
>
>
|
|
|
>
>
>



|










>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>












>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
24
25
26
27
28
29
30
31
32
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, \
    QItemSelection, QSize
from PyQt5.QtGui import QValidator, QIcon
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







>


|
|







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
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
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_LoadListPage()
        self.ui.setupUi(self)
        self.deck_list_url_validator = IsIdentifyingDeckUrlValidator(self)
        self._deck_list_downloader: typing.Optional[str] = None






        self.ui.deck_list_download_url_line_edit.textChanged.connect(
            lambda text: self.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()))
        self.ui.deck_list_download_url_line_edit.setToolTip(f"Supported websites:\n{supported_sites}")
        self.ui.translate_deck_list_target_language.setModel(language_model)
        self.registerField("deck_list*", self.ui.deck_list, "plainText", self.ui.deck_list.textChanged)
        self.registerField("print-guessing-enable", self.ui.print_guessing_enable)
        self.registerField("print-guessing-prefer-already-downloaded", self.ui.print_guessing_prefer_already_downloaded)
        self.registerField("translate-deck-list-enable", self.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", self.ui.translate_deck_list_target_language,
            "currentText", self.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








|
|


>
>
>
>
>
>
|
|


|
|
|
|
|
|


|
|







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
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
                     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(
                        self, "Overwrite existing deck list?",
                        "Downloading a deck list will overwrite the existing deck list. Continue?",
                        StandardButton.Yes | StandardButton.No) == StandardButton.Yes:
            url = self.ui.deck_list_download_url_line_edit.text()
            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" \
                          f"Verify that the URL is valid, reachable, and that the deck list is set to public.\n" \
                          f"This program cannot download private deck lists. Please note, that setting deck lists to\n"\
                          f"public may take a minute or two to apply."
                    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)















    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:







>
>
>
>
>
>
>


|
|
|
<









|
<
<
<












>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
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
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 tests.helpers import fill_card_database_with_json_cards

StringList = typing.List[str]
CSV_HEADER = "section,count,name,mana_cost,type,set,set_code,collector_number,lang,rarity," \
             "artist,foil,usd_price,eur_price,tix_price,scryfall_uri,scryfall_id"







def append_to_header(plain_deck_list: str) -> str:











    return f"{CSV_HEADER}\n{plain_deck_list}"


def generate_test_cases_for_translation_and_replacement():
    yield (
        ["german_Back_to_Basics", "english_Back_to_Basics"],
        append_to_header(

            "nonlands,1,Back to Basics,{2}{U},Enchantment,Urza's Saga,usg,62,de,rare,Andrew Robinson,false,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,)
    )











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







>

|


|
|
>
>

>
>

>
|
>
>
>
>
>
>
>
>
>
>
>
|






>
|



>
>
>
>
>
>
>
>
>







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

98
99
100
101









102
103
104
105
106
107
108
    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(

            "columna,1,Forest,"",Basic Land — Forest,Arena Beginner Set,anb,112,en,common,Jonas De Ro,false,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,)
    )











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







>
|



>
>
>
>
>
>
>
>
>







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

133
134
135
136
137
138
139
140
141


@pytest.mark.parametrize(
    "cards_to_import, deck_list", [
    (
        ["english_basic_Forest"],
        append_to_header(

        "columna,invalid_count,Forest,"",Basic Land — Forest,Arena Beginner Set,anb,112,en,common,Jonas De Ro,false,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)







>
|
|







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
25
26
27
28
29
30
31
32
33














34
35
36
37
38
39
40
41
42
43
44
45
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 tests.helpers import fill_card_database_with_json_cards

StringList = typing.List[str]
CSV_HEADER = "Board,Qty,Name,Printing,Foil,Alter,Signed,Condition,Language,Commander"


def append_to_header(plain_deck_list: str) -> str:
    return f"{CSV_HEADER}\n{plain_deck_list}"
















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(







>

|


|





>
>
>
>
>
>
>
>
>
>
>
>
>
>




|







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
93
94
95
96
97
98
99
100
    )
    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())







|







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
122
123
124
125
126
127
128
129
        )


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







|







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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64



65
66
67
68
69
70
71
    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
    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"




    # 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/"







|














>
>
>







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"