"""Scryfall API helpers for the MTG reference connector.""" import base64 import json import re from urllib import error, parse, request from odoo import _, models from odoo.exceptions import UserError from .constants import ( DEFAULT_SCRYFALL_API_BASE_URL, DEFAULT_SCRYFALL_IMPORT_LANGUAGES, DEFAULT_SCRYFALL_TIMEOUT_SECONDS, DEFAULT_SCRYFALL_USER_AGENT, ) SCRYFALL_ODOO_LANGUAGE_ALIASES = { "de": ("de_DE",), "en": ("en_US",), "es": ("es_ES", "es_419"), "fr": ("fr_FR",), "it": ("it_IT",), "ja": ("ja_JP",), "ko": ("ko_KR",), "pt": ("pt_PT", "pt_BR"), "ru": ("ru_RU",), "zhs": ("zh_CN",), "zht": ("zh_TW",), } SCRYFALL_LANGUAGE_CODES = { "de", "en", "es", "fr", "it", "ja", "ko", "ph", "pt", "ru", "zhs", "zht", } SCRYFALL_LOOKUP_MODES = {"exact", "fuzzy", "url"} _SCRYFALL_CARD_UUID_PATTERN = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) def normalize_language_codes(raw_value, default=None): """Normalize Scryfall language codes into a stable tuple. Args: raw_value: Iterable or comma-separated language codes. default: Fallback language codes when the input is empty. Returns: tuple[str, ...]: Deduplicated lower-case Scryfall language codes. """ if isinstance(raw_value, str): chunks = re.split(r"[\s,;]+", raw_value) else: chunks = list(raw_value or ()) values = [] for chunk in chunks: code = str(chunk or "").strip().lower() if code and code not in values: values.append(code) fallback = list(default or DEFAULT_SCRYFALL_IMPORT_LANGUAGES) return tuple(values or fallback) class MvdTcgMtgScryfallApi(models.AbstractModel): """Provide Scryfall lookups for the MTG connector.""" _name = "mvd.tcg.mtg.scryfall.api" _description = "MTG Scryfall API" _SCRYFALL_ALLOWED_API_HOSTS = frozenset({"api.scryfall.com"}) _SCRYFALL_ALLOWED_IMAGE_HOSTS = frozenset( {"api.scryfall.com", "cards.scryfall.io"} ) _SCRYFALL_ALLOWED_IMAGE_HOST_SUFFIXES = (".scryfall.io",) def _mtg_scryfall_check_manager_access(self): """Require manager-level access for Scryfall connector actions.""" if self.env.is_superuser() or any( self.env.user.has_group(xmlid) for xmlid in ( "mvd_tcg_base.mvd_tcg_base_group_manager", "base.group_system", ) ): return raise UserError(_("Only TCG managers can run Scryfall connector actions.")) @classmethod def _validate_allowed_url( cls, url, *, label, allowed_hosts, allowed_host_suffixes=(), ): """Validate one configured or upstream URL against a strict allowlist. Args: url: Absolute URL that should be validated. label: Human label used in error messages. allowed_hosts: Exact hostnames allowed for this URL class. allowed_host_suffixes: Optional allowed hostname suffixes. Returns: str: Normalized absolute URL. Raises: UserError: If the URL uses a disallowed scheme or host. """ normalized_url = (url or "").strip() parsed_url = parse.urlparse(normalized_url) hostname = (parsed_url.hostname or "").lower() if parsed_url.scheme != "https" or not hostname: raise UserError( _("%(label)s must use HTTPS and a trusted host.") % {"label": label} ) if hostname in allowed_hosts: return normalized_url if any(hostname.endswith(suffix) for suffix in allowed_host_suffixes): return normalized_url raise UserError( _("%(label)s must use a trusted Scryfall host.") % {"label": label} ) @classmethod def _validate_api_url(cls, url): """Validate one Scryfall API URL. Args: url: Absolute API URL. Returns: str: Validated absolute API URL. """ return cls._validate_allowed_url( url, label=_("Scryfall API URL"), allowed_hosts=cls._SCRYFALL_ALLOWED_API_HOSTS, ) @classmethod def _validate_image_url(cls, url): """Validate one Scryfall-hosted image URL. Args: url: Absolute image URL. Returns: str: Validated absolute image URL. """ return cls._validate_allowed_url( url, label=_("Scryfall image URL"), allowed_hosts=cls._SCRYFALL_ALLOWED_IMAGE_HOSTS, allowed_host_suffixes=cls._SCRYFALL_ALLOWED_IMAGE_HOST_SUFFIXES, ) def _get_config_parameter(self, name, default=False): """Return one connector configuration parameter. Args: name: Technical config parameter key. default: Fallback value when the parameter is unset. Returns: str | bool: Stored config value or the provided fallback. """ return self.env["ir.config_parameter"].sudo().get_param(name, default) def get_api_base_url(self): """Return the configured Scryfall API base URL. Returns: str: Absolute base URL for API requests. """ return self._validate_api_url( ( self._get_config_parameter( "mvd_tcg_mtg_scryfall.api_base_url", DEFAULT_SCRYFALL_API_BASE_URL, ) or DEFAULT_SCRYFALL_API_BASE_URL ).strip() ) def get_timeout_seconds(self): """Return the configured Scryfall request timeout. Returns: int: Timeout in seconds. """ raw_value = self._get_config_parameter( "mvd_tcg_mtg_scryfall.timeout_seconds", DEFAULT_SCRYFALL_TIMEOUT_SECONDS, ) try: return max(1, int(raw_value)) except (TypeError, ValueError): return DEFAULT_SCRYFALL_TIMEOUT_SECONDS def get_user_agent(self): """Return the configured Scryfall HTTP user agent. Returns: str: User agent string for API and image requests. """ return ( self._get_config_parameter( "mvd_tcg_mtg_scryfall.user_agent", DEFAULT_SCRYFALL_USER_AGENT, ) or DEFAULT_SCRYFALL_USER_AGENT ).strip() def get_default_max_cards_per_set(self): """Return the default per-set card limit for controlled imports. Returns: int: Configured limit or ``0`` for no limit. """ raw_value = self._get_config_parameter( "mvd_tcg_mtg_scryfall.import_max_cards_per_set", 0, ) try: return max(0, int(raw_value)) except (TypeError, ValueError): return 0 def get_default_include_tokens(self): """Return whether token cards should be included by default. Returns: bool: ``True`` when controlled set imports should include tokens. """ raw_value = self._get_config_parameter( "mvd_tcg_mtg_scryfall.import_include_tokens", False, ) return str(raw_value).strip().lower() in {"1", "true", "yes", "on"} def _build_api_url(self, path, params=None): """Build one absolute Scryfall API URL. Args: path: Relative Scryfall API path. params: Optional query parameters. Returns: str: Absolute API URL. """ url = f"{self.get_api_base_url().rstrip('/')}/{path.lstrip('/')}" if params: url = f"{url}?{parse.urlencode(params, doseq=True)}" return url def _request_json(self, url): """Perform one JSON request against the Scryfall API. Args: url: Absolute API URL. Returns: dict: Parsed JSON payload. Raises: UserError: If Scryfall rejects the request or returns invalid JSON. """ validated_url = self._validate_api_url(url) http_request = request.Request( validated_url, headers={ "User-Agent": self.get_user_agent(), "Accept": "application/json;q=0.9,*/*;q=0.8", }, ) try: with request.urlopen( http_request, timeout=self.get_timeout_seconds(), ) as response: return json.loads(response.read().decode("utf-8")) except error.HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") try: payload = json.loads(details) except json.JSONDecodeError: payload = {} message = payload.get("details") or payload.get("error") or details or str(exc) raise UserError(_("Scryfall lookup failed: %s") % message) from exc except (error.URLError, OSError, json.JSONDecodeError) as exc: raise UserError(_("Scryfall lookup failed: %s") % exc) from exc def _request_json_path(self, path, params=None): """Perform one JSON request against a relative Scryfall API path. Args: path: Relative Scryfall API path. params: Optional query parameters. Returns: dict: Parsed JSON payload. """ return self._request_json(self._build_api_url(path, params=params)) def _normalize_lookup_mode(self, lookup_mode): """Return a validated lookup mode for card imports. Args: lookup_mode: Raw lookup mode from a wizard or batch entry. Returns: str: One of ``exact``, ``fuzzy`` or ``url``. Raises: UserError: If the lookup mode is unknown. """ normalized_mode = (lookup_mode or "").strip().lower() if normalized_mode not in SCRYFALL_LOOKUP_MODES: raise UserError(_("Unsupported Scryfall lookup mode: %s") % (lookup_mode or "")) return normalized_mode def map_translation_language_codes(self, scryfall_language_code): """Map one Scryfall language code to active Odoo languages. Args: scryfall_language_code: Raw Scryfall language code such as ``de``. Returns: list[str]: Active Odoo language codes for the given Scryfall code. """ normalized_scryfall_code = (scryfall_language_code or "").strip().lower() if not normalized_scryfall_code: return [] installed_codes = { code.lower().replace("-", "_"): code for code in self.env["res.lang"].search([("active", "=", True)]).mapped("code") } resolved_codes = [] for alias in SCRYFALL_ODOO_LANGUAGE_ALIASES.get(normalized_scryfall_code, ()): normalized_alias = alias.lower().replace("-", "_") installed_code = installed_codes.get(normalized_alias) if installed_code and installed_code not in resolved_codes: resolved_codes.append(installed_code) for normalized_code, installed_code in installed_codes.items(): if normalized_code.split("_", 1)[0] == normalized_scryfall_code: if installed_code not in resolved_codes: resolved_codes.append(installed_code) return resolved_codes def map_scryfall_language_code(self, odoo_language_code): """Map one Odoo language code to a Scryfall language code. Args: odoo_language_code: Odoo language code such as ``de_DE``. Returns: str | bool: Matching Scryfall language code or ``False``. """ normalized_odoo_code = ( (odoo_language_code or "").strip().lower().replace("-", "_") ) if not normalized_odoo_code: return False for scryfall_code, aliases in SCRYFALL_ODOO_LANGUAGE_ALIASES.items(): normalized_aliases = { alias.lower().replace("-", "_") for alias in aliases } if normalized_odoo_code in normalized_aliases: return scryfall_code base_language = normalized_odoo_code.split("_", 1)[0] return base_language if base_language in SCRYFALL_LANGUAGE_CODES else False def get_import_language_codes(self): """Return active Scryfall language codes that should be imported. Returns: tuple[str, ...]: Normalized Scryfall language codes. """ configured_languages = self._get_config_parameter( "mvd_tcg_mtg_scryfall.import_language_codes", False, ) return normalize_language_codes( configured_languages or DEFAULT_SCRYFALL_IMPORT_LANGUAGES ) def _parse_card_url_or_id(self, identifier): """Parse one Scryfall URL or UUID into a reusable lookup descriptor. Args: identifier: Human card URL, API URL or raw Scryfall UUID. Returns: dict[str, object]: Parsed descriptor containing request path. Raises: UserError: If the identifier is not a supported Scryfall reference. """ normalized_identifier = (identifier or "").strip() if not normalized_identifier: raise UserError(_("Enter a Scryfall card URL or card id.")) if _SCRYFALL_CARD_UUID_PATTERN.match(normalized_identifier): return { "lookup_mode": "url", "query": normalized_identifier, "path": f"/cards/{normalized_identifier}", "params": None, } parsed_url = parse.urlparse(normalized_identifier) hostname = (parsed_url.hostname or "").lower() path_segments = [segment for segment in parsed_url.path.split("/") if segment] if hostname not in {"scryfall.com", "www.scryfall.com", "api.scryfall.com"}: raise UserError(_("Enter a Scryfall card URL or card id.")) if not path_segments: raise UserError(_("Enter a Scryfall card URL or card id.")) if path_segments[0] == "cards" and len(path_segments) >= 2: return { "lookup_mode": "url", "query": normalized_identifier, "path": f"/cards/{parse.quote(path_segments[1])}", "params": None, } if path_segments[0] == "card" and len(path_segments) >= 3: set_code = parse.quote(path_segments[1]) collector_number = parse.quote(path_segments[2], safe="") path = f"/cards/{set_code}/{collector_number}" language_code = (path_segments[3] or "").lower() if len(path_segments) >= 4 else "" if language_code in SCRYFALL_LANGUAGE_CODES: path = f"{path}/{parse.quote(path_segments[3])}" return { "lookup_mode": "url", "query": normalized_identifier, "path": path, "params": None, } raise UserError(_("Enter a Scryfall card URL or card id.")) def parse_lookup_descriptor(self, query, lookup_mode): """Parse one card lookup into a reusable request descriptor. Args: query: Raw card lookup query from the user. lookup_mode: Lookup mode such as ``exact``, ``fuzzy`` or ``url``. Returns: dict[str, object]: Parsed descriptor with request path and parameters. Raises: UserError: If the query is empty or malformed for the chosen mode. """ normalized_query = (query or "").strip() if not normalized_query: raise UserError(_("Enter a card name or Scryfall card URL first.")) normalized_mode = self._normalize_lookup_mode(lookup_mode) if normalized_mode == "url": return self._parse_card_url_or_id(normalized_query) return { "lookup_mode": normalized_mode, "query": normalized_query, "path": "/cards/named", "params": {normalized_mode: normalized_query}, } def load_image_base64(self, image_url): """Download one card image and return it in Odoo's base64 format. Args: image_url: Absolute image URL from Scryfall. Returns: str | bool: Base64-encoded image data or ``False`` on download issues. """ if not image_url: return False validated_image_url = self._validate_image_url(image_url) http_request = request.Request( validated_image_url, headers={ "User-Agent": self.get_user_agent(), "Accept": "image/avif,image/webp,image/*,*/*;q=0.8", }, ) try: with request.urlopen( http_request, timeout=self.get_timeout_seconds(), ) as response: return base64.b64encode(response.read()).decode("ascii") except (error.URLError, OSError): return False def lookup_card_payload(self, query, lookup_mode): """Resolve one MTG card payload from a parsed lookup descriptor. Args: query: Raw lookup query. lookup_mode: Lookup mode such as ``exact``, ``fuzzy`` or ``url``. Returns: dict: Raw Scryfall card payload. """ self._mtg_scryfall_check_manager_access() descriptor = self.parse_lookup_descriptor(query, lookup_mode) return self._request_json_path( descriptor["path"], params=descriptor.get("params"), ) def lookup_card_payloads(self, query, lookup_mode, languages=None): """Resolve one card lookup and expand it to localized print payloads. Args: query: Raw lookup query. lookup_mode: Lookup mode such as ``exact``, ``fuzzy`` or ``url``. languages: Optional import language scope. Returns: list[dict]: Deduplicated localized payloads for one print group. """ payload = self.lookup_card_payload(query, lookup_mode) return self.get_localized_print_payloads(payload, languages=languages) def lookup_exact(self, card_name): """Look up one card by exact name on Scryfall.""" return self.lookup_card_payload(card_name, "exact") def lookup_fuzzy(self, card_name): """Look up one card by fuzzy name on Scryfall.""" return self.lookup_card_payload(card_name, "fuzzy") def lookup_by_url_or_id(self, identifier): """Look up one card from a Scryfall URL or card UUID.""" return self.lookup_card_payload(identifier, "url") def iter_search_card_payloads(self, query, *, unique="cards", limit=None, include_multilingual=False, order=None, direction=None): """Yield card payloads from the paginated Scryfall search API. Args: query: Raw Scryfall search query. unique: Requested Scryfall uniqueness mode. limit: Optional maximum number of returned payloads. include_multilingual: Whether Scryfall should include multilingual prints. order: Optional Scryfall ordering key. direction: Optional Scryfall sort direction. Yields: dict: Card payloads from the result stream. """ self._mtg_scryfall_check_manager_access() query_params = { "q": (query or "").strip(), "unique": unique, } if include_multilingual: query_params["include_multilingual"] = "true" if order: query_params["order"] = order if direction: query_params["dir"] = direction next_url = self._build_api_url("/cards/search", query_params) remaining_limit = int(limit) if limit and int(limit) > 0 else None while next_url: page_payload = self._request_json(next_url) page_card_payloads = [ card_payload for card_payload in page_payload.get("data", []) if card_payload.get("object") == "card" and card_payload.get("id") ] if remaining_limit is not None: page_card_payloads = page_card_payloads[:remaining_limit] for card_payload in page_card_payloads: yield card_payload if remaining_limit is not None: remaining_limit -= len(page_card_payloads) if remaining_limit <= 0: break if not page_payload.get("has_more"): break next_url = page_payload.get("next_page") or "" def search_cards(self, query, *, unique="cards", limit=None, include_multilingual=False): """Search card payloads on Scryfall with optional pagination. Args: query: Raw Scryfall search query. unique: Requested Scryfall uniqueness mode. limit: Optional maximum number of returned payloads. include_multilingual: Whether Scryfall should include multilingual prints. Returns: list[dict]: Card payloads returned by the Scryfall search API. """ return list( self.iter_search_card_payloads( query, unique=unique, limit=limit, include_multilingual=include_multilingual, ) ) def fetch_search_results_by_url(self, search_url, *, limit=None): """Fetch paginated Scryfall search results from an absolute URL. Args: search_url: Absolute search URL such as ``prints_search_uri``. limit: Optional maximum number of returned payloads. Returns: list[dict]: Card payloads from the paginated result stream. """ self._mtg_scryfall_check_manager_access() if search_url: parsed_url = parse.urlsplit(search_url) query_params = parse.parse_qsl(parsed_url.query, keep_blank_values=True) query_params = [ (key, value) for key, value in query_params if key != "include_multilingual" ] query_params.append(("include_multilingual", "true")) search_url = parse.urlunsplit( ( parsed_url.scheme, parsed_url.netloc, parsed_url.path, parse.urlencode(query_params), parsed_url.fragment, ) ) next_url = (search_url or "").strip() remaining_limit = int(limit) if limit and int(limit) > 0 else None payloads = [] while next_url: page_payload = self._request_json(next_url) page_card_payloads = [ card_payload for card_payload in page_payload.get("data", []) if card_payload.get("object") == "card" and card_payload.get("id") ] if remaining_limit is not None: page_card_payloads = page_card_payloads[:remaining_limit] payloads.extend(page_card_payloads) if remaining_limit is not None: remaining_limit -= len(page_card_payloads) if remaining_limit <= 0: break if not page_payload.get("has_more"): break next_url = page_payload.get("next_page") or "" return payloads def get_localized_print_payloads(self, payload, *, languages=None): """Return localized payloads for one MTG print group. Args: payload: Seed Scryfall card payload. languages: Optional iterable of Scryfall language codes. Returns: list[dict]: Deduplicated payloads for the same set and collector number. """ if payload.get("object") != "card" or not payload.get("id"): return [] import_language_codes = normalize_language_codes( languages, default=self.get_import_language_codes(), ) wanted_languages = set(import_language_codes) same_print_payloads = [payload] search_url = payload.get("prints_search_uri") if search_url: same_print_payloads.extend( candidate_payload for candidate_payload in self.fetch_search_results_by_url(search_url) if ( candidate_payload.get("set") == payload.get("set") and candidate_payload.get("collector_number") == payload.get("collector_number") ) ) deduplicated_payloads = {} for candidate_payload in same_print_payloads: candidate_id = candidate_payload.get("id") candidate_language = (candidate_payload.get("lang") or "").strip().lower() if not candidate_id: continue if wanted_languages and candidate_language and candidate_language not in wanted_languages: continue deduplicated_payloads[candidate_id] = candidate_payload language_sort_order = { code: index for index, code in enumerate(import_language_codes) } return sorted( deduplicated_payloads.values(), key=lambda candidate_payload: ( language_sort_order.get( (candidate_payload.get("lang") or "").strip().lower(), 999, ), candidate_payload.get("lang") or "", candidate_payload.get("id") or "", ), ) def expand_payloads_with_localized_prints(self, payloads, *, languages=None): """Expand seed payloads to localized print payloads. Args: payloads: Seed Scryfall card payloads. languages: Optional iterable of Scryfall language codes. Returns: list[dict]: Deduplicated localized payloads. """ expanded_payloads = {} for payload in payloads: for localized_payload in self.get_localized_print_payloads( payload, languages=languages, ): expanded_payloads[localized_payload["id"]] = localized_payload return list(expanded_payloads.values()) def resolve_lookup_payload(self, query, lookup_mode): """Resolve one Scryfall lookup input to a seed payload.""" return self.lookup_card_payload(query, lookup_mode) def resolve_lookup_payloads(self, query, lookup_mode, languages=None): """Resolve one lookup query and expand it to localized payloads.""" return self.lookup_card_payloads(query, lookup_mode, languages=languages) def parse_batch_queries(self, raw_batch_input, default_lookup_mode="url"): """Parse a multiline batch input into normalized lookup entries. Each non-empty line is treated as one lookup item. Lines may optionally start with ``url:``, ``exact:`` or ``fuzzy:`` to override the default lookup mode for that line. Args: raw_batch_input: Multiline raw text from the import wizard. default_lookup_mode: Fallback lookup mode for plain lines. Returns: list[dict[str, str]]: Cleaned batch entries with ``query`` and ``lookup_mode`` keys. """ normalized_default_mode = self._normalize_lookup_mode(default_lookup_mode) parsed_queries = [] for raw_line in (raw_batch_input or "").splitlines(): normalized_line = raw_line.strip() if not normalized_line or normalized_line.startswith("#"): continue lookup_mode = normalized_default_mode query = normalized_line if ":" in normalized_line: prefix, suffix = normalized_line.split(":", 1) normalized_prefix = prefix.strip().lower() if normalized_prefix in SCRYFALL_LOOKUP_MODES and suffix.strip(): lookup_mode = normalized_prefix query = suffix.strip() parsed_queries.append( { "lookup_mode": lookup_mode, "query": query, } ) return parsed_queries def parse_set_codes(self, raw_text): """Parse one or many MTG set codes from free-text input. Args: raw_text: Multiline or comma-separated set-code input. Returns: list[str]: Deduplicated lower-case set codes. """ set_codes = [] for chunk in re.split(r"[\s,;]+", raw_text or ""): set_code = (chunk or "").strip().lower() if set_code and set_code not in set_codes: set_codes.append(set_code) return set_codes def fetch_set_seed_payloads(self, set_code, *, card_limit=0, include_tokens=False): """Fetch seed payloads for one MTG set code. Args: set_code: Scryfall set code such as ``tdm``. card_limit: Optional maximum number of returned cards. include_tokens: Whether token cards should be included. Returns: list[dict]: Seed payloads for the requested set import. """ normalized_set_code = (set_code or "").strip().lower() if not normalized_set_code: raise UserError(_("Enter a set code first.")) query_terms = [f"e:{normalized_set_code}", "game:paper"] if not include_tokens: query_terms.append("-is:token") seed_payloads = list( self.iter_search_card_payloads( " ".join(query_terms), unique="prints", limit=card_limit or None, order="set", direction="asc", ) ) if not seed_payloads: raise UserError( _("Scryfall returned no cards for the set code %s.") % normalized_set_code.upper() ) return seed_payloads def get_set_print_group_payloads( self, set_code, *, languages=None, limit=None, include_tokens=False, ): """Return localized payload groups for one MTG set import. Args: set_code: Raw MTG set code. languages: Optional iterable of wanted Scryfall language codes. limit: Optional maximum number of imported print groups. include_tokens: Whether token cards should be included. Returns: list[list[dict]]: Localized payload groups keyed by print reference. """ import_language_codes = normalize_language_codes( languages, default=self.get_import_language_codes(), ) wanted_languages = set(import_language_codes) grouped_payloads = {} card_model = self.env["mvd.tcg.card"] normalized_set_code = (set_code or "").strip().lower() if not normalized_set_code: raise UserError(_("Enter a set code first.")) query_terms = [f"e:{normalized_set_code}", "game:paper"] if not include_tokens: query_terms.append("-is:token") for payload in self.iter_search_card_payloads( " ".join(query_terms), unique="prints", include_multilingual=True, order="set", direction="asc", ): external_ref = card_model._mtg_scryfall_build_external_ref(payload) if not external_ref or external_ref == ":": continue payload_language = (payload.get("lang") or "").strip().lower() if wanted_languages and payload_language and payload_language not in wanted_languages: continue grouped_payloads.setdefault(external_ref, {})[payload.get("id")] = payload sorted_group_payloads = [] language_sort_order = { code: index for index, code in enumerate(import_language_codes) } for external_ref in sorted(grouped_payloads): localized_payloads = sorted( grouped_payloads[external_ref].values(), key=lambda candidate_payload: ( language_sort_order.get( (candidate_payload.get("lang") or "").strip().lower(), 999, ), candidate_payload.get("lang") or "", candidate_payload.get("id") or "", ), ) if localized_payloads: sorted_group_payloads.append(localized_payloads) if limit and len(sorted_group_payloads) >= int(limit): break if not sorted_group_payloads: raise UserError( _("Scryfall returned no cards for the set code %s.") % normalized_set_code.upper() ) return sorted_group_payloads