diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9b23ba1..c8ea577 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Tests on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build: @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 2dc53ca..b7faf40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -46,7 +46,7 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover +*.py.cover .hypothesis/ .pytest_cache/ cover/ @@ -94,20 +94,35 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock +#poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control #pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -121,6 +136,7 @@ celerybeat.pid # Environments .env +.envrc .venv env/ venv/ @@ -157,4 +173,35 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a45c3c8..9869805 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,58 +2,50 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: end-of-file-fixer - - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.1 hooks: - - id: pyupgrade - args: - - "--py39-plus" - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 - hooks: - - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort + - id: ruff-check + - id: ruff-format - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.3.0 hooks: - id: flake8 additional_dependencies: - - flake8-absolute-import - - flake8-annotations - - flake8-builtins - - flake8-comprehensions - - flake8-docstrings - - flake8-future-annotations - - flake8-no-implicit-concat - - flake8-print + - flake8-pyproject - flake8-requirements - - flake8-simplify - - flake8-use-fstring - - flake8-use-pathlib - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + # - repo: https://github.com/nbQA-dev/nbQA + # rev: 1.9.1 + # hooks: + # - id: nbqa-flake8 + # additional_dependencies: + # - flake8-pyproject + # - flake8-requirements + # - repo: https://github.com/kynan/nbstripout + # rev: 0.8.2 + # hooks: + # - id: nbstripout + # args: + # - "--extra-keys=metadata.kernelspec metadata.language_info" + # - "--drop-empty-cells" + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.8.1 hooks: - id: prettier args: - - "--tab-width" - - "2" + - "--tab-width=2" - repo: https://github.com/shssoichiro/oxipng - rev: v9.1.3 + rev: v10.0.0 hooks: - id: oxipng args: - - "-o" - - "max" - - "--strip" - - "safe" + - "-o=max" + - "--strip=safe" - "--alpha" diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ed0d2d9..6ac5e1a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,10 +1,9 @@ { "recommendations": [ "esbenp.prettier-vscode", - "ms-python.black-formatter", - "ms-python.flake8", - "ms-python.isort", + "charliermarsh.ruff", "ms-python.python", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 18d136d..911e53a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,15 +4,19 @@ "python.testing.pytestEnabled": true, "git.rebaseWhenSync": true, "notebook.formatOnSave.enabled": true, + "notebook.codeActionsOnSave": { + "notebook.source.organizeImports.ruff": "explicit" + }, "files.eol": "\n", "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", + "editor.defaultFormatter": "charliermarsh.ruff", "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" + "source.organizeImports.ruff": "explicit" } }, - "window.title": "${dirty}${rootName}${separator}${activeEditorShort}" + "window.title": "${dirty}${rootName}${separator}${activeEditorShort}", + "evenBetterToml.formatter.arrayAutoCollapse": false } diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..0093d88 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1 @@ +mode: Mainline diff --git a/README.md b/README.md index 92c9293..0109b02 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ git clone https://github.com/DiddiZ/mtg-proxies.git cd mtg-proxies ``` -2. Install requirements. Requires at least [Python 3.9](https://www.python.org/downloads/). +2. Install requirements. Requires at least [Python 3.12](https://www.python.org/downloads/). ```bash # On Linux, use `python3` instead of `python` diff --git a/deck_value.py b/deck_value.py index a44a40f..29b7fb3 100644 --- a/deck_value.py +++ b/deck_value.py @@ -1,54 +1,55 @@ -import argparse - -import matplotlib.pyplot as plt - -import scryfall -from mtgproxies.cli import parse_decklist_spec - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Show deck value decomposition.") - parser.add_argument( - "decklist", - metavar="decklist_spec", - help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", - ) - parser.add_argument( - "--lump-threshold", - help="lump together cards with lesser proportional value", - type=float, - default=0.03, - metavar="FLOAT", - ) - - args = parser.parse_args() - - # Parse decklist - decklist = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) - - # Fetch prices - card_prices = [] - for card in decklist.cards: - price = scryfall.get_price(card["oracle_id"]) - if price is not None: - card_prices.append((card["name"], card.count * price)) - else: - print(f"WARNING: Unable to find price for {card['name']}") - - card_prices.sort(key=lambda x: x[1], reverse=True) - price_total = sum(p for _, p in card_prices) - - # Partition cards in named and bulk - cards_named = [(card, price) for card, price in card_prices if price >= args.lump_threshold * price_total] - cards_lump = [(card, price) for card, price in card_prices if price < args.lump_threshold * price_total] - - # Plot - plt.pie( - [price for _, price in cards_named] + [sum(p for _, p in cards_lump)], - labels=[name for name, _ in cards_named] + ["other"], - autopct=lambda pct: f"{pct / 100. * price_total:0.2f}€", - shadow=True, - startangle=90, - ) - plt.title(f"{decklist.name} ({price_total:0.2f}€)") - plt.tight_layout() - plt.show() +import argparse +from operator import itemgetter + +import matplotlib.pyplot as plt + +import scryfall +from mtgproxies.cli import parse_decklist_spec + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Show deck value decomposition.") + parser.add_argument( + "decklist", + metavar="decklist_spec", + help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", + ) + parser.add_argument( + "--lump-threshold", + help="lump together cards with lesser proportional value", + type=float, + default=0.03, + metavar="FLOAT", + ) + + args = parser.parse_args() + + # Parse decklist + decklist = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) + + # Fetch prices + card_prices = [] + for card in decklist.cards: + price = scryfall.get_price(card["oracle_id"]) + if price is not None: + card_prices.append((card["name"], card.count * price)) + else: + print(f"WARNING: Unable to find price for {card['name']}") + + card_prices.sort(key=itemgetter(1), reverse=True) + price_total = sum(p for _, p in card_prices) + + # Partition cards in named and bulk + cards_named = [(card, price) for card, price in card_prices if price >= args.lump_threshold * price_total] + cards_lump = [(card, price) for card, price in card_prices if price < args.lump_threshold * price_total] + + # Plot + plt.pie( + [price for _, price in cards_named] + [sum(p for _, p in cards_lump)], + labels=[name for name, _ in cards_named] + ["other"], + autopct=lambda pct: f"{pct / 100.0 * price_total:0.2f}€", + shadow=True, + startangle=90, + ) + plt.title(f"{decklist.name} ({price_total:0.2f}€)") + plt.tight_layout() + plt.show() diff --git a/examples/decklist.png b/examples/decklist.png index eb011d6..af9f4e5 100644 Binary files a/examples/decklist.png and b/examples/decklist.png differ diff --git a/mtgproxies/__init__.py b/mtgproxies/__init__.py index 1a6b38d..062e094 100644 --- a/mtgproxies/__init__.py +++ b/mtgproxies/__init__.py @@ -3,6 +3,6 @@ __all__ = [ "fetch_scans_scryfall", - "print_cards_matplotlib", "print_cards_fpdf", + "print_cards_matplotlib", ] diff --git a/mtgproxies/cli.py b/mtgproxies/cli.py index a57c074..8b47b3a 100644 --- a/mtgproxies/cli.py +++ b/mtgproxies/cli.py @@ -1,10 +1,11 @@ +from collections.abc import Container from pathlib import Path from mtgproxies.decklists import archidekt, manastack, parse_decklist from mtgproxies.decklists.decklist import Decklist -def parse_decklist_spec(decklist_spec: str, warn_levels=("ERROR", "WARNING", "COSMETIC")) -> Decklist: +def parse_decklist_spec(decklist_spec: str, warn_levels: Container[str] = ("ERROR", "WARNING", "COSMETIC")) -> Decklist: """Attempt to parse a decklist from different locations. Args: @@ -20,8 +21,8 @@ def parse_decklist_spec(decklist_spec: str, warn_levels=("ERROR", "WARNING", "CO decklist, ok, warnings = manastack.parse_decklist(manastack_id) elif decklist_spec.lower().startswith("archidekt:") and decklist_spec.split(":")[-1].isdigit(): # Decklist on Archidekt - manastack_id = decklist_spec.split(":")[-1] - decklist, ok, warnings = archidekt.parse_decklist(manastack_id) + archidekt_id = decklist_spec.split(":")[-1] + decklist, ok, warnings = archidekt.parse_decklist(archidekt_id) else: print(f"Cant find decklist '{decklist_spec}'") quit() diff --git a/mtgproxies/decklists/__init__.py b/mtgproxies/decklists/__init__.py index a11c80b..56aedde 100644 --- a/mtgproxies/decklists/__init__.py +++ b/mtgproxies/decklists/__init__.py @@ -1,15 +1,16 @@ from mtgproxies.decklists.cleaning import merge_duplicates -from mtgproxies.decklists.decklist import Card, Comment, Decklist, parse_decklist, parse_decklist_stream +from mtgproxies.decklists.decklist import Card, Comment, Decklist, DecklistEntry, parse_decklist, parse_decklist_stream from mtgproxies.decklists.sanitizing import get_print_warnings, validate_card_name, validate_print __all__ = [ "Card", "Comment", "Decklist", + "DecklistEntry", + "get_print_warnings", + "merge_duplicates", "parse_decklist", "parse_decklist_stream", - "merge_duplicates", "validate_card_name", "validate_print", - "get_print_warnings", ] diff --git a/mtgproxies/decklists/archidekt/archidekt.py b/mtgproxies/decklists/archidekt/archidekt.py index e745fd9..cbb2b27 100644 --- a/mtgproxies/decklists/archidekt/archidekt.py +++ b/mtgproxies/decklists/archidekt/archidekt.py @@ -2,11 +2,11 @@ import requests -from mtgproxies.decklists import Decklist +from mtgproxies.decklists import Decklist, DecklistEntry from mtgproxies.decklists.sanitizing import validate_card_name, validate_print -def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list]: +def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list[tuple[DecklistEntry, str, str]]]: """Parse a decklist from manastack. Args: @@ -28,16 +28,16 @@ def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list]: for item in data["cards"]: # Extract relevant data count = item["quantity"] - card_name = item["card"]["oracleCard"]["name"] + raw_card_name = item["card"]["oracleCard"]["name"] set_id = item["card"]["edition"]["editioncode"] collector_number = item["card"]["collectorNumber"] if item["categories"] is not None and len(item["categories"]) > 0 and item["categories"][0] not in in_deck: continue # Validate card name - card_name, warnings_name = validate_card_name(card_name) + card_name, warnings_name = validate_card_name(raw_card_name) if card_name is None: - decklist.append_comment(card_name) + decklist.append_comment(raw_card_name) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name]) ok = False continue diff --git a/mtgproxies/decklists/decklist.py b/mtgproxies/decklists/decklist.py index 65cccb0..6dd65ad 100644 --- a/mtgproxies/decklists/decklist.py +++ b/mtgproxies/decklists/decklist.py @@ -5,7 +5,7 @@ from collections import Counter from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Literal, TextIO import scryfall from mtgproxies.decklists.sanitizing import validate_card_name, validate_print @@ -19,16 +19,16 @@ class Card: """ count: int - card: dict[str | Any] + card: dict[str, Any] - def __getitem__(self, key: str) -> Any: + def __getitem__(self, key: str) -> Any: # noqa: ANN401 return self.card[key] def __contains__(self, key: str) -> bool: return key in self.card @property - def image_uris(self): + def image_uris(self) -> list[dict[str, str]]: """Image uris of all faces on this card. For single faced cards, this is just the front. @@ -53,6 +53,9 @@ def __format__(self, format_spec: str) -> str: return self.text +type DecklistEntry = Card | Comment + + @dataclass class Decklist: """Container class for a decklist. @@ -60,14 +63,14 @@ class Decklist: Contains cards and comment lines. """ - entries: list[Card | Comment] = field(default_factory=list) - name: str = None + entries: list[DecklistEntry] = field(default_factory=list) + name: str | None = None - def append_card(self, count: int, card) -> None: + def append_card(self, count: int, card: dict) -> None: """Append a card line to this decklist.""" self.entries.append(Card(count, card)) - def append_comment(self, text) -> None: + def append_comment(self, text: str) -> None: """Append a comment line to this decklist.""" self.entries.append(Comment(text)) @@ -75,12 +78,8 @@ def extend(self, other: Decklist) -> None: """Append another decklist to this.""" self.entries.extend(other.entries) - def save(self, file: str | Path, fmt: str = "arena", mode: str = "w") -> None: - """Write decklist to a file. - - Args: - fmt: Decklist format, either "arena" or "text". - """ + def save(self, file: str | Path, fmt: Literal["arena", "text"] = "arena", mode: str = "w") -> None: + """Write decklist to a file.""" with open(file, mode, encoding="utf-8", newline="") as f: f.write(format(self, fmt) + os.linesep) @@ -103,7 +102,7 @@ def total_count_unique(self) -> int: return len(self.cards) @staticmethod - def from_scryfall_ids(card_ids) -> Decklist: + def from_scryfall_ids(card_ids: list[str]) -> Decklist: """Construct a Decklist from scryfall ids. Multiple instances of the same id are counted. @@ -142,7 +141,7 @@ def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]: return decklist, ok, warnings -def parse_decklist_stream(stream) -> tuple[Decklist, bool, list]: +def parse_decklist_stream(stream: TextIO) -> tuple[Decklist, bool, list]: """Parse card information from a decklist in text or MtG Arena (or mixed) format from a stream. See: diff --git a/mtgproxies/decklists/manastack/manastack.py b/mtgproxies/decklists/manastack/manastack.py index 766dbed..e02e468 100644 --- a/mtgproxies/decklists/manastack/manastack.py +++ b/mtgproxies/decklists/manastack/manastack.py @@ -4,11 +4,13 @@ import requests -from mtgproxies.decklists import Decklist +from mtgproxies.decklists import Decklist, DecklistEntry from mtgproxies.decklists.sanitizing import validate_card_name, validate_print -def parse_decklist(manastack_id: str, zones: Sequence[str] = ("commander", "mainboard")): +def parse_decklist( + manastack_id: str, zones: Sequence[str] = ("commander", "mainboard") +) -> tuple[Decklist, bool, list[tuple[DecklistEntry, str, str]]]: """Parse a decklist from manastack. Args: @@ -30,14 +32,14 @@ def parse_decklist(manastack_id: str, zones: Sequence[str] = ("commander", "main for item in data["list"][zone]: # Extract relevant data count = item["count"] - card_name = item["card"]["name"] + raw_card_name = item["card"]["name"] set_id = item["card"]["set"]["slug"] collector_number = item["card"]["num"] # Validate card name - card_name, warnings_name = validate_card_name(card_name) + card_name, warnings_name = validate_card_name(raw_card_name) if card_name is None: - decklist.append_comment(card_name) + decklist.append_comment(raw_card_name) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name]) ok = False continue diff --git a/mtgproxies/decklists/sanitizing.py b/mtgproxies/decklists/sanitizing.py index 87d9d2b..693b34a 100644 --- a/mtgproxies/decklists/sanitizing.py +++ b/mtgproxies/decklists/sanitizing.py @@ -1,116 +1,117 @@ -from functools import lru_cache - -import scryfall -from mtgproxies.format import format_print, format_token, listing - - -@lru_cache(maxsize=None) -def card_names(): - """Sets of valid card names. - - Cached for performance. - """ - cards_by_name = { - card["name"].lower(): card["name"] for card in scryfall.get_cards() if card["layout"] not in ["art_series"] - } - double_faced_by_front = { - name.split("//")[0].strip().lower(): name for name in cards_by_name.values() if "//" in name - } - return cards_by_name, double_faced_by_front - - -def validate_card_name(card_name: str): - """Validate card name against the Scryfall database. - - Returns: - card_name: valid card name. - warnings: list of (level, message) warnings. - ok: whether the card could be found. - """ - # Unique names of all cards - cards_by_name, double_faced_by_front = card_names() - - validated_name = None - sanizized_name = scryfall.canonic_card_name(card_name) - warnings = [] - if sanizized_name in cards_by_name: # Exact match - validated_name = cards_by_name[sanizized_name] - elif sanizized_name in double_faced_by_front: # Exact match of front of double faced card - validated_name = double_faced_by_front[sanizized_name] - warnings.append(("WARNING", f"Misspelled card name '{card_name}'. Assuming you mean {validated_name}.")) - else: # No exact match - # Try partial matching - candidates = [ - cards_by_name[name] for name in cards_by_name if all(elem in name for elem in sanizized_name.split(" ")) - ] - - if len(candidates) == 1: # Found unique candidate - validated_name = candidates[0] - warnings.append(("WARNING", f"Misspelled card name '{card_name}'. Assuming you mean {validated_name}.")) - elif len(candidates) == 0: # No matching card - warnings.append(("ERROR", f"Unable to find card '{card_name}'.")) - else: # Multiple matching cards - alternatives = listing(["'" + card + "'" for card in candidates], ", ", " or ", 6) - warnings.append(("ERROR", f"Unable to find card '{card_name}'. Did you mean {alternatives}?")) - - return validated_name, warnings - - -def get_print_warnings(card) -> list[str]: - """Returns warnings for low-resolution scans.""" - warnings = [] - if not card["highres_image"] or card["digital"]: - warnings.append("low resolution scan") - if card["collector_number"][-1] in ["p", "s"]: - warnings.append("promo") - if card["lang"] != "en": - warnings.append("non-english print") - if card["border_color"] != "black": - warnings.append(card["border_color"] + " border") - return warnings - - -def validate_print(card_name: str, set_id: str, collector_number: str): - """Validate a print against the Scryfall database. - - Assumes card name is valid. - - Returns: - card: valid Scryfall database object. - warnings: list of warnings. - """ - warnings = [] - - if set_id is None: - card = scryfall.recommend_print(card_name=card_name) - # Warn for tokens, as they are not unique by name - if card["layout"] in ["token", "double_faced_token"]: - warnings.append( - ("WARNING", f"Tokens are not unique by name. Assuming '{card_name}' is a '{format_token(card)}'.") - ) - else: - card = scryfall.get_card(card_name, set_id, collector_number) - if card is None: # No exact match - # Find alternative print - card = scryfall.recommend_print(card_name=card_name) - warnings.append(( - "WARNING", - f"Unable to find scan of {format_print(card_name, set_id, collector_number)}." - + f" Using {format_print(card)} instead.", - )) - - # Warnings for low-quality scans - quality_warnings = get_print_warnings(card) - if len(quality_warnings) > 0: - # Get recommendation - recommendation = scryfall.recommend_print(card) - - # Format warnings string - quality_warnings = listing(quality_warnings, ", ", " and ").capitalize() - - warnings.append(( - "COSMETIC", - f"{quality_warnings} for {format_print(card)}." - + (f" Maybe you want {format_print(recommendation)}?" if recommendation != card else ""), - )) - return card, warnings +from functools import cache + +import scryfall +from mtgproxies.format import format_print, format_token, listing + + +@cache +def card_names() -> tuple[dict[str, str], dict[str, str]]: + """Return sets of valid card names. + + Cached for performance. + """ + cards_by_name = { + card["name"].lower(): card["name"] for card in scryfall.get_cards() if card["layout"] != "art_series" + } + double_faced_by_front = { + name.split("//")[0].strip().lower(): name for name in cards_by_name.values() if "//" in name + } + return cards_by_name, double_faced_by_front + + +def validate_card_name(card_name: str) -> tuple[str | None, list[tuple[str, str]]]: + """Validate card name against the Scryfall database. + + Returns: + card_name: valid card name. + warnings: list of (level, message) warnings. + ok: whether the card could be found. + """ + # Unique names of all cards + cards_by_name, double_faced_by_front = card_names() + + validated_name = None + sanizized_name = scryfall.canonic_card_name(card_name) + warnings = [] + if sanizized_name in cards_by_name: # Exact match + validated_name = cards_by_name[sanizized_name] + elif sanizized_name in double_faced_by_front: # Exact match of front of double faced card + validated_name = double_faced_by_front[sanizized_name] + warnings.append(("WARNING", f"Misspelled card name '{card_name}'. Assuming you mean {validated_name}.")) + else: # No exact match + # Try partial matching + candidates = [ + cards_by_name[name] for name in cards_by_name if all(elem in name for elem in sanizized_name.split(" ")) + ] + + if len(candidates) == 1: # Found unique candidate + validated_name = candidates[0] + warnings.append(("WARNING", f"Misspelled card name '{card_name}'. Assuming you mean {validated_name}.")) + elif len(candidates) == 0: # No matching card + warnings.append(("ERROR", f"Unable to find card '{card_name}'.")) + else: # Multiple matching cards + alternatives = listing(["'" + card + "'" for card in candidates], ", ", " or ", 6) + warnings.append(("ERROR", f"Unable to find card '{card_name}'. Did you mean {alternatives}?")) + + return validated_name, warnings + + +def get_print_warnings(card: dict) -> list[str]: + """Return warnings for low-resolution scans.""" + warnings = [] + if not card["highres_image"] or card["digital"]: + warnings.append("low resolution scan") + if card["collector_number"][-1] in ["p", "s"]: + warnings.append("promo") + if card["lang"] != "en": + warnings.append("non-english print") + if card["border_color"] != "black": + warnings.append(card["border_color"] + " border") + return warnings + + +def validate_print(card_name: str, set_id: str, collector_number: str) -> tuple[dict, list[tuple[str, str]]]: + """Validate a print against the Scryfall database. + + Assumes card name is valid. + + Returns: + card: valid Scryfall database object. + warnings: list of warnings. + """ + warnings = [] + + if set_id is None: + card = scryfall.recommend_print(card_name=card_name) + # Warn for tokens, as they are not unique by name + if card["layout"] in ["token", "double_faced_token"]: + warnings.append(( + "WARNING", + f"Tokens are not unique by name. Assuming '{card_name}' is a '{format_token(card)}'.", + )) + else: + card = scryfall.get_card(card_name, set_id, collector_number) + if card is None: # No exact match + # Find alternative print + card = scryfall.recommend_print(card_name=card_name) + warnings.append(( + "WARNING", + f"Unable to find scan of {format_print(card_name, set_id, collector_number)}." + + f" Using {format_print(card)} instead.", + )) + + # Warnings for low-quality scans + quality_warnings = get_print_warnings(card) + if len(quality_warnings) > 0: + # Get recommendation + recommendation = scryfall.recommend_print(card) + + # Format warnings string + quality_warnings = listing(quality_warnings, ", ", " and ").capitalize() + + warnings.append(( + "COSMETIC", + f"{quality_warnings} for {format_print(card)}." + + (f" Maybe you want {format_print(recommendation)}?" if recommendation != card else ""), + )) + return card, warnings diff --git a/mtgproxies/format.py b/mtgproxies/format.py index c7b5f16..9dec53e 100644 --- a/mtgproxies/format.py +++ b/mtgproxies/format.py @@ -3,7 +3,8 @@ from __future__ import annotations -def format_print(card_name: str | dict, set_id: str = None, collector_number: str = None) -> str: +def format_print(card_name: str | dict, set_id: str | None = None, collector_number: str | None = None) -> str: + """Format a card into a human-readable string.""" if "name" in card_name: card_name, set_id, collector_number = card_name["name"], card_name["set"], card_name["collector_number"] @@ -20,23 +21,25 @@ def format_print(card_name: str | dict, set_id: str = None, collector_number: st def format_colors(colors: list[str]) -> str: + """Format a list of color codes into a human-readable string.""" if len(colors) == 0: return "colorless" return listing([color_names[c] for c in colors], ", ", " and ") -def listing(items: list[str], sep: str, final_sep: str, max_items: int = None) -> str: +def listing(items: list[str], sep: str, final_sep: str, max_items: int | None = None) -> str: + """Format a list of items into a human-readable string.""" if len(items) == 0: return "" if len(items) == 1: return items[0] if max_items is None or len(items) <= max_items: return sep.join(items[:-1]) + final_sep + items[-1] - else: - return sep.join(items[:max_items] + ["..."]) + return sep.join([*items[:max_items], "..."]) def format_token(card: dict) -> str: + """Format a token card into a human-readable string.""" # Double faced cards if "colors" not in card: return format_token(card["card_faces"][0]) + " // " + format_token(card["card_faces"][1]) diff --git a/mtgproxies/plotting/splitpages.py b/mtgproxies/plotting/splitpages.py index cf04f6d..bbf7873 100644 --- a/mtgproxies/plotting/splitpages.py +++ b/mtgproxies/plotting/splitpages.py @@ -2,8 +2,10 @@ from pathlib import Path from types import TracebackType +from typing import Any import matplotlib.pyplot as plt +from matplotlib.figure import Figure class SplitPages: @@ -25,10 +27,10 @@ def __exit__( exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, - ): + ) -> None: pass - def savefig(self, figure=None, **kwargs): + def savefig(self, figure: Figure | None = None, **kwargs: Any) -> None: # noqa: ANN401 """Save figure to a new file. The file name is suffixed with the current page number. diff --git a/mtgproxies/print_cards.py b/mtgproxies/print_cards.py index 6033364..ee58c18 100644 --- a/mtgproxies/print_cards.py +++ b/mtgproxies/print_cards.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from pathlib import Path import matplotlib.pyplot as plt @@ -13,20 +14,20 @@ image_size = np.array([745, 1040]) -def _occupied_space(cardsize, pos, border_crop: int, closed: bool = False): +def _occupied_space(cardsize: np.ndarray, pos: np.ndarray, border_crop: int, closed: bool = False) -> np.ndarray: return cardsize * (pos * image_size - np.clip(2 * pos - 1 - closed, 0, None) * border_crop) / image_size def print_cards_matplotlib( - images: list[str | Path], + images: Sequence[str | Path], filepath: str | Path, - papersize=np.array([8.27, 11.69]), - cardsize=np.array([2.5, 3.5]), + papersize: np.ndarray = np.array([8.27, 11.69]), + cardsize: np.ndarray = np.array([2.5, 3.5]), border_crop: int = 14, interpolation: str | None = "lanczos", dpi: int = 600, - background_color=None, -): + background_color: str | None = None, +) -> None: """Print a list of cards to a pdf file. Args: @@ -35,6 +36,9 @@ def print_cards_matplotlib( papersize: Size of the paper in inches. Defaults to A4. cardsize: Size of a card in inches. border_crop: How many pixel to crop from the border of each card. + interpolation: Interpolation method for resizing images. + dpi: Dots per inch for the output PDF. + background_color: Background color of the PDF as name or hex code. """ # Cards per figure N = np.floor(papersize / cardsize).astype(int) @@ -47,23 +51,22 @@ def print_cards_matplotlib( filepath.parent.mkdir(parents=True, exist_ok=True) # Choose pdf of image saver - if filepath.suffix == ".pdf": - saver = PdfPages - else: - saver = SplitPages + saver = PdfPages if filepath.suffix == ".pdf" else SplitPages with saver(filepath) as saver, tqdm(total=len(images), desc="Plotting cards") as pbar: - while len(images) > 0: + idx = 0 + while idx < len(images): # Loop over pages fig = plt.figure(figsize=papersize) - ax = fig.add_axes([0, 0, 1, 1]) # ax covers the whole figure + ax = fig.add_axes((0, 0, 1, 1)) # ax covers the whole figure # Background if background_color is not None: plt.gca().add_patch(Rectangle((0, 0), 1, 1, color=background_color, zorder=-1000)) for y in range(N[1]): for x in range(N[0]): - if len(images) > 0: - img = plt.imread(images.pop(0)) + if idx < len(images): + img = plt.imread(images[idx]) + idx += 1 # Crop left and top if not on border of sheet left = border_crop if x > 0 else 0 @@ -77,7 +80,7 @@ def print_cards_matplotlib( + _occupied_space(cardsize, np.array([x, y]), border_crop) + cardsize * (image_size - [left, top]) / image_size ) / papersize - extent = [lower[0], upper[0], 1 - upper[1], 1 - lower[1]] # flip y-axis + extent = (lower[0], upper[0], 1 - upper[1], 1 - lower[1]) # flip y-axis plt.imshow( img, @@ -98,12 +101,12 @@ def print_cards_matplotlib( def print_cards_fpdf( - images: list[str | Path], + images: Sequence[str | Path], filepath: str | Path, - papersize=np.array([210, 297]), - cardsize=np.array([2.5 * 25.4, 3.5 * 25.4]), + papersize: np.ndarray = np.array([210, 297]), + cardsize: np.ndarray = np.array([2.5 * 25.4, 3.5 * 25.4]), border_crop: int = 14, - background_color: tuple[int, int, int] = None, + background_color: tuple[int, int, int] | None = None, cropmarks: bool = True, ) -> None: """Print a list of cards to a pdf file. @@ -114,6 +117,8 @@ def print_cards_fpdf( papersize: Size of the paper in inches. Defaults to A4. cardsize: Size of a card in inches. border_crop: How many pixel to crop from the border of each card. + background_color: Background color of the PDF as an RGB tuple. + cropmarks: Whether to add crop marks to the PDF. """ from fpdf import FPDF diff --git a/mtgproxies/py.typed b/mtgproxies/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/print.py b/print.py index 340a9c7..3ee48bd 100644 --- a/print.py +++ b/print.py @@ -1,97 +1,101 @@ -import argparse - -import numpy as np - -from mtgproxies import fetch_scans_scryfall, print_cards_fpdf, print_cards_matplotlib -from mtgproxies.cli import parse_decklist_spec - - -def papersize(string: str) -> np.ndarray: - spec = string.lower() - if spec == "a4": - return np.array([21, 29.7]) / 2.54 - if "x" in spec: - split = spec.split("x") - return np.array([float(split[0]), float(split[1])]) - raise argparse.ArgumentTypeError() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Prepare a decklist for printing.") - parser.add_argument( - "decklist", - metavar="decklist_spec", - help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", - ) - parser.add_argument("outfile", help="output file. Supports pdf, png and jpg.") - parser.add_argument("--dpi", help="dpi of output file (default: %(default)d)", type=int, default=300) - parser.add_argument( - "--paper", - help="paper size in inches or preconfigured format (default: %(default)s)", - type=papersize, - default="a4", - metavar="WIDTHxHEIGHT", - ) - parser.add_argument( - "--scale", - help="scaling factor for printed cards (default: %(default)s)", - type=float, - default=1.0, - metavar="FLOAT", - ) - parser.add_argument( - "--border_crop", - help="how much to crop inner borders of printed cards (default: %(default)s)", - type=int, - default=14, - metavar="PIXELS", - ) - parser.add_argument( - "--background", - help='background color, either by name or by hex code (e.g. black or "#ff0000", default: %(default)s)', - type=str, - default=None, - metavar="COLOR", - ) - parser.add_argument("--cropmarks", action=argparse.BooleanOptionalAction, default=True, help="add crop marks") - parser.add_argument( - "--faces", - help="which faces to print (default: %(default)s)", - choices=["all", "front", "back"], - default="all", - ) - args = parser.parse_args() - - # Parse decklist - decklist = parse_decklist_spec(args.decklist) - - # Fetch scans - images = fetch_scans_scryfall(decklist, faces=args.faces) - - # Plot cards - if args.outfile.endswith(".pdf"): - import matplotlib.colors as colors - - background_color = args.background - if background_color is not None: - background_color = (np.array(colors.to_rgb(background_color)) * 255).astype(int) - - print_cards_fpdf( - images, - args.outfile, - papersize=args.paper * 25.4, - cardsize=np.array([2.5, 3.5]) * 25.4 * args.scale, - border_crop=args.border_crop, - background_color=background_color, - cropmarks=args.cropmarks, - ) - else: - print_cards_matplotlib( - images, - args.outfile, - papersize=args.paper, - cardsize=np.array([2.5, 3.5]) * args.scale, - dpi=args.dpi, - border_crop=args.border_crop, - background_color=args.background, - ) +import argparse + +import numpy as np + +from mtgproxies import fetch_scans_scryfall, print_cards_fpdf, print_cards_matplotlib +from mtgproxies.cli import parse_decklist_spec + + +def papersize(string: str) -> np.ndarray: + """Parse paper size from string. + + Supports preconfigured formats (e.g. "a4") and custom formats (e.g. "8.5x11" in inches). + """ + spec = string.lower() + if spec == "a4": + return np.array([21, 29.7]) / 2.54 + if "x" in spec: + split = spec.split("x") + return np.array([float(split[0]), float(split[1])]) + raise argparse.ArgumentTypeError() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Prepare a decklist for printing.") + parser.add_argument( + "decklist", + metavar="decklist_spec", + help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", + ) + parser.add_argument("outfile", help="output file. Supports pdf, png and jpg.") + parser.add_argument("--dpi", help="dpi of output file (default: %(default)d)", type=int, default=300) + parser.add_argument( + "--paper", + help="paper size in inches or preconfigured format (default: %(default)s)", + type=papersize, + default="a4", + metavar="WIDTHxHEIGHT", + ) + parser.add_argument( + "--scale", + help="scaling factor for printed cards (default: %(default)s)", + type=float, + default=1.0, + metavar="FLOAT", + ) + parser.add_argument( + "--border_crop", + help="how much to crop inner borders of printed cards (default: %(default)s)", + type=int, + default=14, + metavar="PIXELS", + ) + parser.add_argument( + "--background", + help='background color, either by name or by hex code (e.g. black or "#ff0000", default: %(default)s)', + type=str, + default=None, + metavar="COLOR", + ) + parser.add_argument("--cropmarks", action=argparse.BooleanOptionalAction, default=True, help="add crop marks") + parser.add_argument( + "--faces", + help="which faces to print (default: %(default)s)", + choices=["all", "front", "back"], + default="all", + ) + args = parser.parse_args() + + # Parse decklist + decklist = parse_decklist_spec(args.decklist) + + # Fetch scans + images = fetch_scans_scryfall(decklist, faces=args.faces) + + # Plot cards + if args.outfile.endswith(".pdf"): + import matplotlib.colors as colors + + background_color = args.background + if background_color is not None: + background_color = (np.array(colors.to_rgb(background_color)) * 255).astype(int) + + print_cards_fpdf( + images, + args.outfile, + papersize=args.paper * 25.4, + cardsize=np.array([2.5, 3.5]) * 25.4 * args.scale, + border_crop=args.border_crop, + background_color=background_color, + cropmarks=args.cropmarks, + ) + else: + print_cards_matplotlib( + images, + args.outfile, + papersize=args.paper, + cardsize=np.array([2.5, 3.5]) * args.scale, + dpi=args.dpi, + border_crop=args.border_crop, + background_color=args.background, + ) diff --git a/pyproject.toml b/pyproject.toml index 73eb127..cdfc8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,115 @@ -[tool.black] +[project] +name = "mtg-proxies" +version = "0.1.0" +authors = [{ name = "Robin More", email = "more@diddiz.de" }] +description = "Print high-resolution MtG proxies." +requires-python = ">=3.12" +dependencies = [ + "numpy", + "matplotlib", + "requests", + "tqdm", + "fpdf2>=2.3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[build-system] +requires = ["uv_build >= 0.9.21, <0.10.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "mtgproxies" +module-root = "" + +[tool.ruff] line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = "py312" preview = true -enable-unstable-feature = ["hug_parens_with_braces_and_square_brackets"] +fix = true + +[tool.ruff.lint] +select = [ + "E4", # default + "E7", # default + "E9", # default + "F", # default + "E501", # line-length + "I", # isort + "UP", # pyupgrade + "W605", # pyupgrade + "ANN", # flake8-annotations + "A", # flake8-builtins + "C4", # flake8-comprehensions + "COM818", # Trailing comma on bare tuple prohibited + "D", # pydocstyle + "D401", # First line of docstring should be in imperative mood + "D404", # First word of the docstring should not be "This" + "DTZ", # flake8-datetimez + "FA", # flake8-future-annotations + "FURB", # refurb + "G", # flake8-logging-format + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + "NPY", # NumPy-specific rules + "PERF", # Perflint + "PIE", # flake8-pie + "PD", # pandas-vet + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # Ruff-specific rules + "SIM", # flake8-simplify + "T20", # flake8-print + "TD", # flake8-todos + "TID", # flake8-tidy-imports + "TRY", # tryceratops +] +ignore = [ + "A00", + "B019", # Use of functools.lru_cache or functools.cache on methods can lead to memory leaks + "B905", # zip-without-explicit-strict + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "G004", # Logging statement uses f-string TODO: Include this at some point + "ISC003", # Explicitly concatenated string should be implicitly concatenated + "N806", # Variable {name} in function should be lowercase + "PD008", # Use .loc instead of .at + "PD009", # Use .iloc instead of .iat + "PLR09", # Too many... + "PLR2004", # Magic value used in comparison + "PT003", # scope='function' is implied in @pytest.fixture() + "PTH123", # open() should be replaced by Path.open() + "TD002", # Missing author in TODO + "TD003", # Missing issue link for this TODO + "TRY003", # Avoid specifying long messages outside the exception class + "TRY004", # Prefer TypeError exception for invalid type +] +future-annotations = true + +[tool.ruff.lint.per-file-ignores] +"**.ipynb" = ["D", "T20"] +"tests/*" = ["D103"] +"!tests/*" = ["TID253"] +"cli.py" = ["T20"] +"convert.py" = ["T20"] +"deck_value.py" = ["T20"] +"tokens.py" = ["T20"] + +[tool.ruff.lint.flake8-tidy-imports] +banned-module-level-imports = ["mtgproxies"] + +[tool.ruff.lint.isort] +known-first-party = [] +known-local-folder = ["mtgproxies", "scryfall"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.flake8] +select = ["I900"] +known-modules = "mtg-proxies:[mtgproxies,scryfall],fpdf2:[fpdf]" diff --git a/scryfall/__init__.py b/scryfall/__init__.py index cc3848b..464be88 100644 --- a/scryfall/__init__.py +++ b/scryfall/__init__.py @@ -14,14 +14,14 @@ __all__ = [ "canonic_card_name", + "card_by_id", + "cards_by_oracle_id", "get_card", "get_cards", "get_faces", "get_image", - "search", - "recommend_print", - "card_by_id", - "cards_by_oracle_id", - "oracle_ids_by_name", "get_price", + "oracle_ids_by_name", + "recommend_print", + "search", ] diff --git a/scryfall/rate_limit.py b/scryfall/rate_limit.py index 4d38aae..0928b43 100644 --- a/scryfall/rate_limit.py +++ b/scryfall/rate_limit.py @@ -3,6 +3,7 @@ import threading import time from types import TracebackType +from typing import Self class RateLimiter: @@ -18,7 +19,7 @@ def __init__(self, delay: float) -> None: self.lock = threading.Lock() self.last_call = 0 - def __enter__(self) -> None: + def __enter__(self) -> Self: with self.lock: # Prevent asynchronous access # Check time since last call if time.time() < self.last_call + self.delay: # Wait if neccessary diff --git a/scryfall/scryfall.py b/scryfall/scryfall.py index 8849633..4fb9fc5 100644 --- a/scryfall/scryfall.py +++ b/scryfall/scryfall.py @@ -1,360 +1,379 @@ -"""Simple interface to the Scryfall API. - -See: - https://scryfall.com/docs/api -""" - -from __future__ import annotations - -import json -import pickle -import threading -from collections import defaultdict -from functools import lru_cache -from pathlib import Path -from tempfile import gettempdir - -import numpy as np -import requests -from tqdm import tqdm - -from scryfall.rate_limit import RateLimiter - -cache = Path(gettempdir()) / "scryfall_cache" -cache.mkdir(parents=True, exist_ok=True) # Create cache folder -scryfall_rate_limiter = RateLimiter(delay=0.1) -_download_lock = threading.Lock() - - -def get_image(image_uri: str, silent: bool = False) -> str: - """Download card artwork and return the path to a local copy. - - Uses cache and Scryfall API call rate limit. - - Returns: - string: Path to local file. - """ - split = image_uri.split("/") - file_name = split[-5] + "_" + split[-4] + "_" + split[-1].split("?")[0] - return get_file(file_name, image_uri, silent=silent) - - -def get_file(file_name: str, url: str, silent: bool = False) -> str: - """Download a file and return the path to a local copy. - - Uses cache and Scryfall API call rate limit. - - Returns: - string: Path to local file. - """ - file_path = cache / file_name - with _download_lock: - if not file_path.is_file(): - if "api.scryfall.com" in url: # Apply rate limit - with scryfall_rate_limiter: - download(url, file_path, silent=silent) - else: - download(url, file_path, silent=silent) - - return str(file_path) - - -def download(url: str, dst, chunk_size: int = 1024 * 4, silent: bool = False): - """Download a file with a tqdm progress bar.""" - with requests.get(url, stream=True) as req: - req.raise_for_status() - file_size = int(req.headers["Content-Length"]) if "Content-Length" in req.headers else None - with ( - open(dst, "xb") as f, - tqdm( - total=file_size, - unit="B", - unit_scale=True, - desc=url.split("/")[-1], - disable=silent, - ) as pbar, - ): - for chunk in req.iter_content(chunk_size=chunk_size): - if chunk: - f.write(chunk) - pbar.update(chunk_size) - - -def depaginate(url: str) -> list[dict]: - """Depaginates Scryfall search results. - - Uses cache and Scryfall API call rate limit. - - Returns: - list: Concatenation of all `data` entries. - """ - with scryfall_rate_limiter: - response = requests.get(url).json() - assert response["object"] - - if "data" not in response: - return [] - data = response["data"] - if response["has_more"]: - data = data + depaginate(response["next_page"]) - - return data - - -def search(q: str) -> list[dict]: - """Perform Scryfall search. - - Returns: - list: All matching cards. - - See: - https://scryfall.com/docs/api/cards/search - """ - return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json") - - -@lru_cache(maxsize=None) -def _get_database(database_name: str = "default_cards"): - databases = depaginate("https://api.scryfall.com/bulk-data") - bulk_data = [database for database in databases if database["type"] == database_name] - if len(bulk_data) != 1: - raise ValueError(f"Unknown database {database_name}") - - bulk_file = Path(get_file(bulk_data[0]["download_uri"].split("/")[-1], bulk_data[0]["download_uri"])) - pickle_file = bulk_file.with_suffix(".pickle") - if not pickle_file.is_file(): # Convert json to pickle - with open(bulk_file, encoding="utf-8") as json_file: - data = json.load(json_file) - with open(pickle_file, "wb") as pickle_file: - pickle.dump(data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) - return data - else: - with open(pickle_file, "rb") as f: - return pickle.load(f) - - -def canonic_card_name(card_name: str) -> str: - """Get canonic card name representation.""" - card_name = card_name.lower() - - # Replace special chars - card_name = card_name.replace("æ", "ae") # Sometimes used, e.g. in "Vedalken Aethermage" - - return card_name - - -def get_card(card_name: str, set_id: str = None, collector_number: str = None) -> dict | None: - """Find a card by it's name and possibly set and collector number. - - In case, the Scryfall database contains multiple cards, the first is returned. - - Args: - card_name: Exact English card name - set_id: Shorthand set name - collector_number: Collector number, may be a string for e.g. promo suffixes - - Returns: - card: Dictionary of card, or `None` if not found. - """ - cards = get_cards(name=card_name, set=set_id, collector_number=collector_number) - - return cards[0] if len(cards) > 0 else None - - -def get_cards(database: str = "default_cards", **kwargs): - """Get all cards matching certain attributes. - - Matching is case insensitive. - - Args: - kwargs: (key, value) pairs, e.g. `name="Tendershoot Dryad", set="RIX"`. - keys with a `None` value are ignored - - Returns: - List of all matching cards - """ - cards = _get_database(database) - - for key, value in kwargs.items(): - if value is not None: - value = value.lower() - if key == "name": # Normalize card name - value = canonic_card_name(value) - cards = [card for card in cards if key in card and card[key].lower() == value] - - return cards - - -def get_faces(card): - """All faces on this card. - - For single faced cards, this is just the card. - - Args: - card: Scryfall card object - """ - if "image_uris" in card: - return [card] - elif "card_faces" in card and "image_uris" in card["card_faces"][0]: - return card["card_faces"] - else: - raise ValueError(f"Unknown layout {card['layout']}") - - -def recommend_print(current=None, card_name: str | None = None, oracle_id: str | None = None, mode="best"): - if current is not None and oracle_id is None: # Use oracle id of current - if current.get("layout") == "reversible_card": - # Reversible cards have the same oracle id for both faces - oracle_id = current["card_faces"][0]["oracle_id"] - else: - oracle_id = current["oracle_id"] - - if oracle_id is not None: - alternatives = cards_by_oracle_id()[oracle_id] - else: - alternatives = get_cards(name=card_name) - - def score(card: dict): - points = 0 - if card["set"] != "mb1" and card["border_color"] != "gold": - points += 1 - if card["frame"] == "2015": - points += 2 - if not card["digital"]: - points += 4 - if card["border_color"] == "black" and ( - mode != "best" or "frame_effects" not in card or "extendedart" not in card["frame_effects"] - ): - points += 8 - if card["collector_number"][-1] not in ["p", "s"] and card["nonfoil"]: - points += 16 - if card["highres_image"]: - points += 32 - if card["lang"] == "en": - points += 64 - - return points - - scores = [score(card) for card in alternatives] - - if mode == "best": - if current is not None and scores[alternatives.index(current)] == np.max(scores): - return current # No better recommendation - - # Return print with highest score - recommendation = alternatives[np.argmax(scores)] - return recommendation - elif mode == "all": - recommendations = list(np.array(alternatives)[np.argsort(scores)][::-1]) - - # Bring current print to front - if current is not None: - if current in recommendations: - recommendations.remove(current) - recommendations = [current] + recommendations - - # Return all card in descending order - return recommendations - elif mode == "choices": - artworks = np.array([ - get_faces(card)[0]["illustration_id"] if "illustration_id" in get_faces(card)[0] else card["id"] - for card in alternatives - ]) # Not all cards have illustrations, use id instead - choices = [] - for artwork in set(artworks): - artwork_alternatives = np.array(alternatives)[artworks == artwork] - artwork_scores = np.array(scores)[artworks == artwork] - - recommendations = artwork_alternatives[artwork_scores == np.max(artwork_scores)] - # TODO Sort again - choices.extend(recommendations) - - # Bring current print to front - if current is not None: - choices = [current] + [c for c in choices if c["id"] != current["id"]] - - return choices - else: - raise ValueError(f"Unknown mode '{mode}'") - - -@lru_cache(maxsize=None) -def card_by_id(): - """Create dictionary to look up cards by their id. - - Faster than repeated lookup via get_cards(). - - Returns: - dict {id: card} - """ - return {c["id"]: c for c in get_cards()} - - -@lru_cache(maxsize=None) -def cards_by_oracle_id(): - """Create dictionary to look up cards by their oracle id. - - Faster than repeated lookup via get_cards(). - - Returns: - dict {id: [cards]} - """ - cards_by_oracle_id = defaultdict(list) - for c in get_cards(): - if "oracle_id" in c: # Not all cards have a oracle id, *sigh* - cards_by_oracle_id[c["oracle_id"]].append(c) - elif "card_faces" in c and "oracle_id" in c["card_faces"][0]: - cards_by_oracle_id[c["card_faces"][0]["oracle_id"]].append(c) - return cards_by_oracle_id - - -@lru_cache(maxsize=None) -def oracle_ids_by_name() -> dict[str, list[dict]]: - """Create dictionary to look up oracle ids by their name. - - Faster than repeated lookup via `get_cards(oracle_id=oracle_id)`. - Also matches the front side of double faced cards. - Names are lower case. - - Returns: - dict {name: [oracle_ids]} - """ - oracle_ids_by_name = defaultdict(set) - for oracle_id, cards in cards_by_oracle_id().items(): - card = cards[0] - if card["layout"] in ["art_series"]: # Skip art series, as they have double faced names - continue - name = card["name"].lower() - # Use name and also front face only for double faced cards - oracle_ids_by_name[name].add(oracle_id) - if "//" in name: - oracle_ids_by_name[name.split(" // ")[0]].add(oracle_id) - - # Converts sets to lists - oracle_ids_by_name = {k: list(v) for k, v in oracle_ids_by_name.items()} - return oracle_ids_by_name - - -def get_price(oracle_id: str, currency: str = "eur", foil: bool = None) -> float | None: - """Find lowest price for oracle id. - - Args: - oracle_id: oracle_id of card - currency: `usd`, `eur` or `tix` - foil: `False`, `True`, or `None` for any - """ - cards = cards_by_oracle_id()[oracle_id] - - slots = [] - if not foil: - slots += [currency] - if (foil or foil is None) and currency != "tix": # "TIX has no foil" - slots += [currency + "_foil"] - - prices = [float(c["prices"][slot]) for c in cards for slot in slots if c["prices"][slot] is not None] - - if len(prices) == 0 and currency == "eur": # Try dollar and apply conversion - usd = get_price(oracle_id, "usd") - return 0.83 * usd if usd is not None else None - - return min(prices) if len(prices) > 0 else None +"""Simple interface to the Scryfall API. + +See: + https://scryfall.com/docs/api +""" + +from __future__ import annotations + +import json +import pickle +import threading +from collections import defaultdict +from functools import cache +from pathlib import Path +from tempfile import gettempdir +from typing import Literal, overload + +import numpy as np +import requests +from tqdm import tqdm + +from scryfall.rate_limit import RateLimiter + +_cache_folder = Path(gettempdir()) / "scryfall_cache" +_cache_folder.mkdir(parents=True, exist_ok=True) # Create cache folder +scryfall_rate_limiter = RateLimiter(delay=0.1) +_download_lock = threading.Lock() + + +def get_image(image_uri: str, *, silent: bool = False) -> str: + """Download card artwork and return the path to a local copy. + + Uses cache and Scryfall API call rate limit. + + Returns: + string: Path to local file. + """ + split = image_uri.split("/") + file_name = split[-5] + "_" + split[-4] + "_" + split[-1].split("?")[0] + return get_file(file_name, image_uri, silent=silent) + + +def get_file(file_name: str, url: str, *, silent: bool = False) -> str: + """Download a file and return the path to a local copy. + + Uses cache and Scryfall API call rate limit. + + Returns: + string: Path to local file. + """ + file_path = _cache_folder / file_name + with _download_lock: + if not file_path.is_file(): + if "api.scryfall.com" in url: # Apply rate limit + with scryfall_rate_limiter: + download(url, file_path, silent=silent) + else: + download(url, file_path, silent=silent) + + return str(file_path) + + +def download(url: str, dst: Path | str, *, chunk_size: int = 1024 * 4, silent: bool = False) -> None: + """Download a file with a tqdm progress bar.""" + with requests.get(url, stream=True) as req: + req.raise_for_status() + file_size = int(req.headers["Content-Length"]) if "Content-Length" in req.headers else None + with ( + open(dst, "xb") as f, + tqdm( + total=file_size, + unit="B", + unit_scale=True, + desc=url.split("/")[-1], + disable=silent, + ) as pbar, + ): + for chunk in req.iter_content(chunk_size=chunk_size): + if chunk: + f.write(chunk) + pbar.update(chunk_size) + + +def depaginate(url: str) -> list[dict]: + """Depaginates Scryfall search results. + + Uses cache and Scryfall API call rate limit. + + Returns: + list: Concatenation of all `data` entries. + """ + with scryfall_rate_limiter: + response = requests.get(url).json() + assert response["object"] + + if "data" not in response: + return [] + data = response["data"] + if response["has_more"]: + data = data + depaginate(response["next_page"]) + + return data + + +def search(q: str) -> list[dict]: + """Perform Scryfall search. + + Returns: + list: All matching cards. + + See: + https://scryfall.com/docs/api/cards/search + """ + return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json") + + +@cache +def _get_database(database_name: str = "default_cards") -> list[dict]: + databases = depaginate("https://api.scryfall.com/bulk-data") + bulk_data = [database for database in databases if database["type"] == database_name] + if len(bulk_data) != 1: + raise ValueError(f"Unknown database {database_name}") + + bulk_file = Path(get_file(bulk_data[0]["download_uri"].split("/")[-1], bulk_data[0]["download_uri"])) + pickle_file = bulk_file.with_suffix(".pickle") + if not pickle_file.is_file(): # Convert json to pickle + with open(bulk_file, encoding="utf-8") as json_file: + data = json.load(json_file) + with open(pickle_file, "wb") as pickle_file: + pickle.dump(data, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) + return data + with open(pickle_file, "rb") as f: + return pickle.load(f) + + +def canonic_card_name(card_name: str) -> str: + """Get canonic card name representation.""" + card_name = card_name.lower() + + # Replace special chars + return card_name.replace("æ", "ae") # Sometimes used, e.g. in "Vedalken Aethermage" + + +def get_card(card_name: str, set_id: str | None = None, collector_number: str | None = None) -> dict | None: + """Find a card by it's name and possibly set and collector number. + + In case, the Scryfall database contains multiple cards, the first is returned. + + Args: + card_name: Exact English card name + set_id: Shorthand set name + collector_number: Collector number, may be a string for e.g. promo suffixes + + Returns: + card: Dictionary of card, or `None` if not found. + """ + cards = get_cards(name=card_name, set=set_id, collector_number=collector_number) + + return cards[0] if len(cards) > 0 else None + + +def get_cards(database: str = "default_cards", **kwargs: str | None) -> list[dict]: + """Get all cards matching certain attributes. + + Matching is case insensitive. + + Args: + database: Scryfall bulk data type, e.g. `default_cards` or `oracle_cards` + kwargs: (key, value) pairs, e.g. `name="Tendershoot Dryad", set="RIX"`. + Keys with a `None` value are ignored + + Returns: + List of all matching cards + """ + cards = _get_database(database) + + for key, value in kwargs.items(): + if value is not None: + value = value.lower() + if key == "name": # Normalize card name + value = canonic_card_name(value) + cards = [card for card in cards if key in card and card[key].lower() == value] + + return cards + + +def get_faces(card: dict) -> list[dict]: + """All faces on this card. + + For single faced cards, this is just the card. + + Args: + card: Scryfall card object + """ + if "image_uris" in card: + return [card] + if "card_faces" in card and "image_uris" in card["card_faces"][0]: + return card["card_faces"] + raise ValueError(f"Unknown layout {card['layout']}") + + +@overload +def recommend_print( + current: dict | None = None, + *, + card_name: str | None = None, + oracle_id: str | None = None, + mode: Literal["best"] = "best", +) -> dict: ... + + +@overload +def recommend_print( + current: dict | None = None, + *, + card_name: str | None = None, + oracle_id: str | None = None, + mode: Literal["all", "choices"], +) -> list[dict]: ... + + +def recommend_print( + current: dict | None = None, + *, + card_name: str | None = None, + oracle_id: str | None = None, + mode: Literal["best", "all", "choices"] = "best", +) -> dict | list[dict]: + """Recommend a (better) print of a card.""" + if current is not None and oracle_id is None: # Use oracle id of current + if current.get("layout") == "reversible_card": + # Reversible cards have the same oracle id for both faces + oracle_id = current["card_faces"][0]["oracle_id"] + else: + oracle_id = current["oracle_id"] + + alternatives = cards_by_oracle_id()[oracle_id] if oracle_id is not None else get_cards(name=card_name) + + def score(card: dict) -> int: + points = 0 + if card["set"] != "mb1" and card["border_color"] != "gold": + points += 1 + if card["frame"] == "2015": + points += 2 + if not card["digital"]: + points += 4 + if card["border_color"] == "black" and ( + mode != "best" or "frame_effects" not in card or "extendedart" not in card["frame_effects"] + ): + points += 8 + if card["collector_number"][-1] not in ["p", "s"] and card["nonfoil"]: + points += 16 + if card["highres_image"]: + points += 32 + if card["lang"] == "en": + points += 64 + + return points + + scores = [score(card) for card in alternatives] + + if mode == "best": + if current is not None and scores[alternatives.index(current)] == np.max(scores): + return current # No better recommendation + + # Return print with highest score + return alternatives[np.argmax(scores)] + if mode == "all": + recommendations = list(np.array(alternatives)[np.argsort(scores)][::-1]) + + # Bring current print to front + if current is not None: + if current in recommendations: + recommendations.remove(current) + recommendations = [current, *recommendations] + + # Return all card in descending order + return recommendations + if mode == "choices": + artworks = np.array([ + get_faces(card)[0]["illustration_id"] if "illustration_id" in get_faces(card)[0] else card["id"] + for card in alternatives + ]) # Not all cards have illustrations, use id instead + choices = [] + for artwork in set(artworks): + artwork_alternatives = np.array(alternatives)[artworks == artwork] + artwork_scores = np.array(scores)[artworks == artwork] + + recommendations = artwork_alternatives[artwork_scores == np.max(artwork_scores)] + # TODO: Sort again + choices.extend(recommendations) + + # Bring current print to front + if current is not None: + choices = [current, *(c for c in choices if c["id"] != current["id"])] + + return choices + raise ValueError(f"Unknown mode '{mode}'") + + +@cache +def card_by_id() -> dict[str, dict]: + """Create dictionary to look up cards by their id. + + Faster than repeated lookup via get_cards(). + + Returns: + dict {id: card} + """ + return {c["id"]: c for c in get_cards()} + + +@cache +def cards_by_oracle_id() -> dict[str, list[dict]]: + """Create dictionary to look up cards by their oracle id. + + Faster than repeated lookup via get_cards(). + + Returns: + dict {id: [cards]} + """ + cards_by_oracle_id = defaultdict(list) + for c in get_cards(): + if "oracle_id" in c: # Not all cards have a oracle id, *sigh* + cards_by_oracle_id[c["oracle_id"]].append(c) + elif "card_faces" in c and "oracle_id" in c["card_faces"][0]: + cards_by_oracle_id[c["card_faces"][0]["oracle_id"]].append(c) + return cards_by_oracle_id + + +@cache +def oracle_ids_by_name() -> dict[str, list[str]]: + """Create dictionary to look up oracle ids by their name. + + Faster than repeated lookup via `get_cards(oracle_id=oracle_id)`. + Also matches the front side of double faced cards. + Names are lower case. + + Returns: + dict {name: [oracle_ids]} + """ + oracle_ids_by_name = defaultdict(set) + for oracle_id, cards in cards_by_oracle_id().items(): + card = cards[0] + if card["layout"] == "art_series": # Skip art series, as they have double faced names + continue + name = card["name"].lower() + # Use name and also front face only for double faced cards + oracle_ids_by_name[name].add(oracle_id) + if "//" in name: + oracle_ids_by_name[name.split(" // ")[0]].add(oracle_id) + + # Converts sets to lists + return {k: list(v) for k, v in oracle_ids_by_name.items()} + + +def get_price(oracle_id: str, currency: str = "eur", foil: bool | None = None) -> float | None: + """Find lowest price for oracle id. + + Args: + oracle_id: oracle_id of card + currency: `usd`, `eur` or `tix` + foil: `False`, `True`, or `None` for any + """ + cards = cards_by_oracle_id()[oracle_id] + + slots = [] + if not foil: + slots += [currency] + if (foil or foil is None) and currency != "tix": # "TIX has no foil" + slots += [currency + "_foil"] + + prices = [float(c["prices"][slot]) for c in cards for slot in slots if c["prices"][slot] is not None] + + if len(prices) == 0 and currency == "eur": # Try dollar and apply conversion + usd = get_price(oracle_id, "usd") + return 0.83 * usd if usd is not None else None + + return min(prices) if len(prices) > 0 else None diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 04618ab..0000000 --- a/setup.cfg +++ /dev/null @@ -1,13 +0,0 @@ -[flake8] -ignore = E203,E741,W503,D100,D104,D105,PL123,ANN101,ANN102 -per-file-ignores = - tests/*:D103,ANN201,ABS101 -max-line-length = 120 -docstring-convention = google -known-modules = mtg-proxies:[mtgproxies,scryfall],fpdf2:[fpdf] - -[isort] -line_length = 120 -profile = black -known_first_party= -known_local_folder=mtgproxies,scryfall diff --git a/setup.py b/setup.py deleted file mode 100644 index 8339a6e..0000000 --- a/setup.py +++ /dev/null @@ -1,185 +0,0 @@ -"""A setuptools based setup module. - -See: -https://packaging.python.org/guides/distributing-packages-using-setuptools/ -https://github.com/pypa/sampleproject -Modified by Madoshakalaka@Github (dependency links added) -""" - -from pathlib import Path - -from setuptools import find_packages, setup - -here = Path(__file__).parent - - -# Get the long description from the README file -with open(here / "README.md", encoding="utf-8") as f: - long_description = f.read() - -# Arguments marked as "Required" below must be included for upload to PyPI. -# Fields marked as "Optional" may be commented out. - -setup( - # This is the name of your project. The first time you publish this - # package, this name will be registered for you. It will determine how - # users can install this project, e.g.: - # - # $ pip install sampleproject - # - # And where it will live on PyPI: https://pypi.org/project/sampleproject/ - # - # There are some restrictions on what makes a valid project name - # specification here: - # https://packaging.python.org/specifications/core-metadata/#name - name="mtg-proxies", # Required - # Versions should comply with PEP 440: - # https://www.python.org/dev/peps/pep-0440/ - # - # For a discussion on single-sourcing the version across setup.py and the - # project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version="0.1.0", # Required - # This is a one-line description or tagline of what your project does. This - # corresponds to the "Summary" metadata field: - # https://packaging.python.org/specifications/core-metadata/#summary - description="Print high-resolution MtG proxies.", # Optional - # This is an optional longer description of your project that represents - # the body of text which users will see when they visit PyPI. - # - # Often, this is the same as your README, so you can just read it in from - # that file directly (as we have already done above) - # - # This field corresponds to the "Description" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-optional - long_description=long_description, # Optional - # Denotes that our long_description is in Markdown; valid values are - # text/plain, text/x-rst, and text/markdown - # - # Optional if long_description is written in reStructuredText (rst) but - # required for plain-text or Markdown; if unspecified, "applications should - # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and - # fall back to text/plain if it is not valid rst" (see link below) - # - # This field corresponds to the "Description-Content-Type" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional - long_description_content_type="text/markdown", # Optional (see note above) - # This should be a valid link to your project's main homepage. - # - # This field corresponds to the "Home-Page" metadata field: - # https://packaging.python.org/specifications/core-metadata/#home-page-optional - url="https://github.com/DiddiZ/mtg-proxies", # Optional - # This should be your name or the name of the organization which owns the - # project. - author="Robin Kupper", # Optional - # This should be a valid email address corresponding to the author listed - # above. - author_email="robin.kupper@rwth-aachen.de", # Optional - # Classifiers help users find your project by categorizing it. - # - # For a list of valid classifiers, see https://pypi.org/classifiers/ - classifiers=[ # Optional - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 4 - Beta", - "Topic :: Games/Entertainment :: Card Games", - # Pick your license as you wish - "License :: OSI Approved :: MIT License", - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - # These classifiers are *not* checked by 'pip install'. See instead - # 'python_requires' below. - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - ], - # This field adds keywords for your project which will appear on the - # project page. What does your project relate to? - # - # Note that this is a string of words separated by whitespace, not a list. - keywords="pdf proxies mtg magic-the-gathering mtg-cards scryfall decklist", # Optional - # You can just specify package directories manually here if your project is - # simple. Or you can use find_packages(). - # - # Alternatively, if you just want to distribute a single Python file, use - # the `py_modules` argument instead as follows, which will expect a file - # called `my_module.py` to exist: - # - # py_modules=["my_module"], - # - packages=find_packages(exclude=["contrib", "docs", "tests"]), # Required - # Specify which Python versions you support. In contrast to the - # 'Programming Language' classifiers above, 'pip install' will check this - # and refuse to install the project if the version does not match. If you - # do not support Python 2, you can simplify this to '>=3.5' or similar, see - # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires - python_requires=">=3.9, <4", - # This field lists other packages that your project depends on to run. - # Any package you put here will be installed by pip when your project is - # installed, so they must be valid existing projects. - # - # For an analysis of "install_requires" vs pip's requirements files see: - # https://packaging.python.org/en/latest/requirements.html - install_requires=[ - "numpy", - "matplotlib", - "requests", - "tqdm", - "fpdf2>=2.3.0", - ], - # List additional groups of dependencies here (e.g. development - # dependencies). Users will be able to install these using the "extras" - # syntax, for example: - # - # $ pip install sampleproject[dev] - # - # Similar to `install_requires` above, these must be valid existing - # projects. - extras_require={ - "dev": [ - "pytest", - ] - }, - # If there are data files included in your packages that need to be - # installed, specify them here. - # - # Sometimes you’ll want to use packages that are properly arranged with - # setuptools, but are not published to PyPI. In those cases, you can specify - # a list of one or more dependency_links URLs where the package can - # be downloaded, along with some additional hints, and setuptools - # will find and install the package correctly. - # see https://python-packaging.readthedocs.io/en/latest/dependencies.html#packages-not-on-pypi - # - dependency_links=[], - # If using Python 2.6 or earlier, then these have to be included in - # MANIFEST.in as well. - # package_data={"sample": ["package_data.dat"]}, # Optional - # Although 'package_data' is the preferred approach, in some case you may - # need to place data files outside of your packages. See: - # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files - # - # In this case, 'data_file' will be installed into '/my_data' - # data_files=[("my_data", ["data/data_file"])], # Optional - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # `pip` to create the appropriate form of executable for the target - # platform. - # - # For example, the following would provide a command called `sample` which - # executes the function `main` from this package when invoked: - # entry_points={"console_scripts": ["sample=sample:main"]}, # Optional - # List additional URLs that are relevant to your project as a dict. - # - # This field corresponds to the "Project-URL" metadata fields: - # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use - # - # Examples listed include a pattern for specifying where the package tracks - # issues, where the source is hosted, where to say thanks to the package - # maintainers, and where to support the project financially. The key is - # what's used to render the link text on PyPI. - project_urls={ # Optional - "Bug Reports": "https://github.com/DiddiZ/mtg-proxies/issues", - "Source": "https://github.com/DiddiZ/mtg-proxies", - }, -) diff --git a/tests/decklist_test.py b/tests/decklist_test.py index 7ff42ee..7e60ef9 100644 --- a/tests/decklist_test.py +++ b/tests/decklist_test.py @@ -1,9 +1,10 @@ import os +from pathlib import Path import pytest -def test_parsing(): +def test_parsing() -> None: from mtgproxies.decklists import parse_decklist decklist, ok, warnings = parse_decklist("examples/decklist.txt") @@ -11,19 +12,19 @@ def test_parsing(): assert ok assert len(warnings) == 0 - with open("examples/decklist.txt", encoding="utf-8") as f: - # Ignore differences in linebreaks - assert (format(decklist, "arena") + os.linesep).replace("\r\n", "\n") == f.read().replace("\r\n", "\n") + # Ignore differences in linebreaks + expected = Path("examples/decklist.txt").read_text(encoding="utf-8") + assert (format(decklist, "arena") + os.linesep).replace("\r\n", "\n") == expected.replace("\r\n", "\n") @pytest.mark.parametrize( - "archidekt_id,expected_first_card", + ("archidekt_id", "expected_first_card"), [ ("1212142", "Emerald Medallion"), ("42", "Dromar's Cavern"), ], ) -def test_archidekt(archidekt_id: str, expected_first_card: str): +def test_archidekt(archidekt_id: str, expected_first_card: str) -> None: from mtgproxies.decklists.archidekt import parse_decklist decklist, ok, _ = parse_decklist(archidekt_id) @@ -32,7 +33,7 @@ def test_archidekt(archidekt_id: str, expected_first_card: str): assert decklist.cards[0]["name"] == expected_first_card -def test_reversible_cards(): +def test_reversible_cards() -> None: """Check that reversible cards are parsed correctly.""" from io import StringIO diff --git a/tests/print_test.py b/tests/print_test.py index f03bb3c..9c50ae6 100644 --- a/tests/print_test.py +++ b/tests/print_test.py @@ -9,16 +9,14 @@ def example_images() -> list[str]: from mtgproxies.decklists import parse_decklist decklist, _, _ = parse_decklist(Path(__file__).parent.parent / "examples/decklist.txt") - images = fetch_scans_scryfall(decklist) + return fetch_scans_scryfall(decklist) - return images - -def test_example_images(example_images: list[str]): +def test_example_images(example_images: list[str]) -> None: assert len(example_images) == 7 -def test_print_cards_fpdf(example_images: list[str], tmp_path: Path): +def test_print_cards_fpdf(example_images: list[str], tmp_path: Path) -> None: from mtgproxies import print_cards_fpdf out_file = tmp_path / "decklist.pdf" @@ -27,7 +25,7 @@ def test_print_cards_fpdf(example_images: list[str], tmp_path: Path): assert out_file.is_file() -def test_print_cards_matplotlib_pdf(example_images: list[str], tmp_path: Path): +def test_print_cards_matplotlib_pdf(example_images: list[str], tmp_path: Path) -> None: from mtgproxies import print_cards_matplotlib out_file = tmp_path / "decklist.pdf" @@ -37,7 +35,7 @@ def test_print_cards_matplotlib_pdf(example_images: list[str], tmp_path: Path): @pytest.mark.skip(reason="for some reason this fails on github actions, but works locally.") -def test_print_cards_matplotlib_png(example_images: list[str], tmp_path: Path): +def test_print_cards_matplotlib_png(example_images: list[str], tmp_path: Path) -> None: from mtgproxies import print_cards_matplotlib out_file = tmp_path / "decklist.png" diff --git a/tests/scans_test.py b/tests/scans_test.py index e35e0d6..38f339a 100644 --- a/tests/scans_test.py +++ b/tests/scans_test.py @@ -1,8 +1,12 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING import pytest -from mtgproxies.decklists import Decklist +if TYPE_CHECKING: + from mtgproxies.decklists import Decklist @pytest.fixture(scope="module") @@ -15,14 +19,14 @@ def example_decklist() -> Decklist: @pytest.mark.parametrize( - "faces,expected_images", + ("faces", "expected_images"), [ ("all", 7), ("front", 6), ("back", 1), ], ) -def test_fetch_scans_scryfall(example_decklist: Decklist, faces: str, expected_images: int): +def test_fetch_scans_scryfall(example_decklist: Decklist, faces: str, expected_images: int) -> None: from mtgproxies import fetch_scans_scryfall images = fetch_scans_scryfall(example_decklist, faces=faces) diff --git a/tests/scryfall_test.py b/tests/scryfall_test.py index f0f695b..8e7b335 100644 --- a/tests/scryfall_test.py +++ b/tests/scryfall_test.py @@ -2,7 +2,7 @@ @pytest.mark.parametrize( - "id,n_faces", + ("id", "n_faces"), [ ("76ac5b70-47db-4cdb-91e7-e5c18c42e516", 1), ("c470539a-9cc7-4175-8f7c-c982b6072b6d", 2), # Modal double-faced @@ -10,7 +10,7 @@ ("6ee6cd34-c117-4d7e-97d1-8f8464bfaac8", 1), # Flip ], ) -def test_get_faces(id: str, n_faces: int): +def test_get_faces(id: str, n_faces: int) -> None: import scryfall card = scryfall.card_by_id()[id] @@ -23,7 +23,7 @@ def test_get_faces(id: str, n_faces: int): @pytest.mark.parametrize( - "name,expected_id", + ("name", "expected_id"), [ ("Vedalken Aethermage", "496eb37d-5c8f-4dd7-a0a7-3ed1bd2210d6"), ("vedalken aethermage", "496eb37d-5c8f-4dd7-a0a7-3ed1bd2210d6"), @@ -31,7 +31,7 @@ def test_get_faces(id: str, n_faces: int): ("vedalken æthermage", "496eb37d-5c8f-4dd7-a0a7-3ed1bd2210d6"), ], ) -def test_canonic_card_name(name: str, expected_id: str): +def test_canonic_card_name(name: str, expected_id: str) -> None: import scryfall card = scryfall.get_card(name) diff --git a/tokens.py b/tokens.py index 47bace1..480c490 100644 --- a/tokens.py +++ b/tokens.py @@ -1,58 +1,59 @@ -import argparse -from pathlib import Path - -import scryfall -from mtgproxies.cli import parse_decklist_spec -from mtgproxies.decklists import Decklist - - -def get_tokens(decklist: Decklist): - tokens = {} - for card in decklist.cards: - if card["layout"] in ["token", "double_faced_token"]: - continue - - # Iterate over all prints, as not all have token information associated with them - for card_print in scryfall.get_cards(oracle_id=card["oracle_id"]): - if "all_parts" in card_print: - for related_card in card_print["all_parts"]: - if related_card["component"] == "token": - # Related cards are only provided by their id. - # We need the oracle id to weed out duplicates - related = scryfall.get_cards(id=related_card["id"])[0] - tokens[related["oracle_id"]] = related - - # Resolve oracle ids to actual cards. - return [scryfall.recommend_print(token) for token in tokens.values()] - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Append the tokens created by the cards in a decklist to it.") - parser.add_argument( - "decklist", - metavar="decklist_spec", - help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", - ) - parser.add_argument( - "--format", help="output format (default: %(default)s)", choices=["arena", "text"], default="arena" - ) - args = parser.parse_args() - - # Parse decklist - decklist = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) - - # Find tokens - tokens = get_tokens(decklist) - print(f"Found {len(tokens)} created tokens.") - - # Append tokens - decklist.append_comment("") - decklist.append_comment("Tokens") - for token in tokens: - decklist.append_card(1, token) - - # Write decklist - out_file = args.decklist if Path(args.decklist).is_file() else f"{args.decklist.split(':')[-1]}.txt" - decklist.save(out_file, fmt=args.format) - - print(f"Successfully appended to {Path(out_file).resolve()}.") +import argparse +from pathlib import Path + +import scryfall +from mtgproxies.cli import parse_decklist_spec +from mtgproxies.decklists import Decklist + + +def get_tokens(decklist: Decklist) -> list[dict]: + """Find all tokens related to the cards in a decklist.""" + tokens = {} + for card in decklist.cards: + if card["layout"] in ["token", "double_faced_token"]: + continue + + # Iterate over all prints, as not all have token information associated with them + for card_print in scryfall.get_cards(oracle_id=card["oracle_id"]): + if "all_parts" in card_print: + for related_card in card_print["all_parts"]: + if related_card["component"] == "token": + # Related cards are only provided by their id. + # We need the oracle id to weed out duplicates + related = scryfall.get_cards(id=related_card["id"])[0] + tokens[related["oracle_id"]] = related + + # Resolve oracle ids to actual cards. + return [scryfall.recommend_print(token) for token in tokens.values()] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Append the tokens created by the cards in a decklist to it.") + parser.add_argument( + "decklist", + metavar="decklist_spec", + help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", + ) + parser.add_argument( + "--format", help="output format (default: %(default)s)", choices=["arena", "text"], default="arena" + ) + args = parser.parse_args() + + # Parse decklist + decklist = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) + + # Find tokens + tokens = get_tokens(decklist) + print(f"Found {len(tokens)} created tokens.") + + # Append tokens + decklist.append_comment("") + decklist.append_comment("Tokens") + for token in tokens: + decklist.append_card(1, token) + + # Write decklist + out_file = args.decklist if Path(args.decklist).is_file() else f"{args.decklist.split(':')[-1]}.txt" + decklist.save(out_file, fmt=args.format) + + print(f"Successfully appended to {Path(out_file).resolve()}.")