Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
- id: check-shebang-scripts-are-executable
- id: end-of-file-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.1
rev: v0.15.5
hooks:
- id: ruff-check
- id: ruff-format
Expand Down Expand Up @@ -42,7 +42,7 @@ repos:
args:
- "--tab-width=2"
- repo: https://github.com/shssoichiro/oxipng
rev: v10.0.0
rev: v10.1.0
hooks:
- id: oxipng
args:
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,22 @@ E.g.:
Or use the `convert.py` tool to convert a plain decklist to Arena format:

```bash
python convert.py examples/decklist_text.txt examples/decklist.txt
python convert.py tests/data/decklist_text.txt tests/data/decklist.txt
```

4. Create a PDF file.

```bash
python print.py examples/decklist.txt decklist.pdf
python print.py tests/data/decklist.txt decklist.pdf
```

Examples:

- Create separate outputs for front and back faces

```bash
python print.py examples/decklist.txt decklist_fronts.pdf --face front
python print.py examples/decklist.txt decklist_backs.pdf --face back
python print.py tests/data/decklist.txt decklist_fronts.pdf --face front
python print.py tests/data/decklist.txt decklist_backs.pdf --face back
```

## Updating
Expand Down Expand Up @@ -161,7 +161,7 @@ optional arguments:
Example:

```bash
python tokens.py examples/token_generators.txt
python tokens.py tests/data/token_generators.txt
```

### Deck Value Decomposition
Expand Down
27 changes: 0 additions & 27 deletions examples/wrong_names.txt

This file was deleted.

6 changes: 3 additions & 3 deletions mtg_proxies/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ def parse_decklist_spec(decklist_spec: str, warn_levels: Container[str] = ("ERRO
quit()

# Print warnings
for _, level, msg in warnings:
if level in warn_levels:
print(f"{level}: {msg}")
for warning in warnings:
if warning.level in warn_levels:
print(warning)

# Check for grave errors
if not ok:
Expand Down
3 changes: 2 additions & 1 deletion mtg_proxies/decklists/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from mtg_proxies.decklists.cleaning import merge_duplicates
from mtg_proxies.decklists.decklist import Card, Comment, Decklist, DecklistEntry, parse_decklist, parse_decklist_stream
from mtg_proxies.decklists.sanitizing import get_print_warnings, validate_card_name, validate_print
from mtg_proxies.decklists.sanitizing import ParseWarning, get_print_warnings, validate_card_name, validate_print

__all__ = [
"Card",
"Comment",
"Decklist",
"DecklistEntry",
"ParseWarning",
"get_print_warnings",
"merge_duplicates",
"parse_decklist",
Expand Down
8 changes: 4 additions & 4 deletions mtg_proxies/decklists/archidekt/archidekt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import requests

from mtg_proxies.decklists import Decklist, DecklistEntry
from mtg_proxies.decklists import Decklist, ParseWarning
from mtg_proxies.decklists.sanitizing import validate_card_name, validate_print


def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list[tuple[DecklistEntry, str, str]]]:
def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list[ParseWarning]]:
"""Parse a decklist from manastack.

Args:
Expand Down Expand Up @@ -38,15 +38,15 @@ def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list[tuple[Deckli
card_name, warnings_name = validate_card_name(raw_card_name)
if card_name is None:
decklist.append_comment(raw_card_name)
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name])
warnings.extend(warnings_name)
ok = False
continue

# Validate card print
card, warnings_print = validate_print(card_name, set_id, collector_number)

decklist.append_card(count, card)
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name + warnings_print])
warnings.extend(warnings_name + warnings_print)

decklist.name = data["name"]

Expand Down
22 changes: 11 additions & 11 deletions mtg_proxies/decklists/decklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from typing import Any, Literal, TextIO

import mtg_proxies.scryfall as scryfall
from mtg_proxies.decklists.sanitizing import validate_card_name, validate_print
from mtg_proxies.decklists.sanitizing import ParseWarning, validate_card_name, validate_print


@dataclass
@dataclass(slots=True)
class Card:
"""Card in a decklist.

Expand Down Expand Up @@ -43,7 +43,7 @@ def __format__(self, format_spec: str) -> str:
raise ValueError(f"Unkown format {format_spec}")


@dataclass
@dataclass(slots=True)
class Comment:
"""Comment in a decklist."""

Expand All @@ -56,7 +56,7 @@ def __format__(self, format_spec: str) -> str:
type DecklistEntry = Card | Comment


@dataclass
@dataclass(slots=True)
class Decklist:
"""Container class for a decklist.

Expand Down Expand Up @@ -116,7 +116,7 @@ def from_scryfall_ids(card_ids: list[str]) -> Decklist:
return decklist


def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]:
def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list[ParseWarning]]:
"""Parse card information from a decklist in text or MtG Arena (or mixed) format.

E.g.:
Expand All @@ -129,8 +129,8 @@ def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]:

Returns:
decklist: Decklist object
ok: whether all cards could be found.
warnings: List of (entry, warning) tuples
ok: whether all cards could be found
warnings: List of warnings and error encountered during parsing
"""
with open(filepath, encoding="utf-8") as f:
decklist, ok, warnings = parse_decklist_stream(f)
Expand All @@ -141,7 +141,7 @@ def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]:
return decklist, ok, warnings


def parse_decklist_stream(stream: TextIO) -> tuple[Decklist, bool, list]:
def parse_decklist_stream(stream: TextIO) -> tuple[Decklist, bool, list[ParseWarning]]:
"""Parse card information from a decklist in text or MtG Arena (or mixed) format from a stream.

See:
Expand All @@ -151,7 +151,7 @@ def parse_decklist_stream(stream: TextIO) -> tuple[Decklist, bool, list]:
warnings = []
ok = True
for line in stream:
m = re.search(r"([0-9]+)\s+(.+?)(?:\s+\((\S*)\)\s+(\S+))?\s*$", line)
m = re.search(r"([0-9]+)x?\s+(.+?)(?:\s+\((\S*)\)\s+(\S+))?\s*$", line)
if m:
# Extract relevant data
count = int(m.group(1))
Expand All @@ -163,15 +163,15 @@ def parse_decklist_stream(stream: TextIO) -> tuple[Decklist, bool, list]:
card_name, warnings_name = validate_card_name(card_name)
if card_name is None:
decklist.append_comment(line.rstrip())
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name])
warnings.extend(warnings_name)
ok = False
continue

# Validate card print
card, warnings_print = validate_print(card_name, set_id, collector_number)

decklist.append_card(count, card)
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name + warnings_print])
warnings.extend(warnings_name + warnings_print)
else:
decklist.append_comment(line.rstrip())
return decklist, ok, warnings
8 changes: 4 additions & 4 deletions mtg_proxies/decklists/manastack/manastack.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

import requests

from mtg_proxies.decklists import Decklist, DecklistEntry
from mtg_proxies.decklists import Decklist, ParseWarning
from mtg_proxies.decklists.sanitizing import validate_card_name, validate_print


def parse_decklist(
manastack_id: str, zones: Sequence[str] = ("commander", "mainboard")
) -> tuple[Decklist, bool, list[tuple[DecklistEntry, str, str]]]:
) -> tuple[Decklist, bool, list[ParseWarning]]:
"""Parse a decklist from manastack.

Args:
Expand Down Expand Up @@ -40,15 +40,15 @@ def parse_decklist(
card_name, warnings_name = validate_card_name(raw_card_name)
if card_name is None:
decklist.append_comment(raw_card_name)
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name])
warnings.extend(warnings_name)
ok = False
continue

# Validate card print
card, warnings_print = validate_print(card_name, set_id, collector_number)

decklist.append_card(count, card)
warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name + warnings_print])
warnings.extend(warnings_name + warnings_print)

if zone != zones[-1]:
decklist.append_comment("")
Expand Down
68 changes: 45 additions & 23 deletions mtg_proxies/decklists/sanitizing.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
from dataclasses import dataclass
from functools import cache
from typing import Literal

import mtg_proxies.scryfall as scryfall
from mtg_proxies.format import format_print, format_token, listing


@dataclass(slots=True)
class ParseWarning:
"""Warning during parsing."""

level: Literal["COSMETIC", "WARNING", "ERROR"]
message: str

def __str__(self) -> str:
return f"{self.level}: {self.message}"


@cache
def card_names() -> tuple[dict[str, str], dict[str, str]]:
"""Return sets of valid card names.
Expand All @@ -19,7 +32,7 @@ def card_names() -> tuple[dict[str, str], dict[str, str]]:
return cards_by_name, double_faced_by_front


def validate_card_name(card_name: str) -> tuple[str | None, list[tuple[str, str]]]:
def validate_card_name(card_name: str) -> tuple[str | None, list[ParseWarning]]:
"""Validate card name against the Scryfall database.

Returns:
Expand All @@ -32,12 +45,14 @@ def validate_card_name(card_name: str) -> tuple[str | None, list[tuple[str, str]

validated_name = None
sanizized_name = scryfall.canonic_card_name(card_name)
warnings = []
warnings: list[ParseWarning] = []
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}."))
warnings.append(
ParseWarning("WARNING", f"Misspelled card name {card_name!r}. Assuming you mean {validated_name!r}.")
)
else: # No exact match
# Try partial matching
candidates = [
Expand All @@ -46,12 +61,14 @@ def validate_card_name(card_name: str) -> tuple[str | None, list[tuple[str, str]

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}."))
warnings.append(
ParseWarning("WARNING", f"Misspelled card name {card_name!r}. Assuming you mean {validated_name!r}.")
)
elif len(candidates) == 0: # No matching card
warnings.append(("ERROR", f"Unable to find card '{card_name}'."))
warnings.append(ParseWarning("ERROR", f"Unable to find card {card_name!r}."))
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}?"))
alternatives = listing([repr(card) for card in candidates], ", ", " or ", 6)
warnings.append(ParseWarning("ERROR", f"Unable to find card {card_name!r}. Did you mean {alternatives}?"))

return validated_name, warnings

Expand All @@ -70,7 +87,7 @@ def get_print_warnings(card: dict) -> list[str]:
return warnings


def validate_print(card_name: str, set_id: str, collector_number: str) -> tuple[dict, list[tuple[str, str]]]:
def validate_print(card_name: str, set_id: str, collector_number: str) -> tuple[dict, list[ParseWarning]]:
"""Validate a print against the Scryfall database.

Assumes card name is valid.
Expand All @@ -79,26 +96,29 @@ def validate_print(card_name: str, set_id: str, collector_number: str) -> tuple[
card: valid Scryfall database object.
warnings: list of warnings.
"""
warnings = []
warnings: list[ParseWarning] = []

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)}'.",
))
warnings.append(
ParseWarning(
"WARNING", f"Tokens are not unique by name. Assuming {card_name!r} is a {format_token(card)!r}."
)
)
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.append(
ParseWarning(
"WARNING",
f"Unable to find scan of {format_print(card_name, set_id, collector_number)!r}."
+ f" Using {format_print(card)!r} instead.",
)
)

# Warnings for low-quality scans
quality_warnings = get_print_warnings(card)
Expand All @@ -109,9 +129,11 @@ def validate_print(card_name: str, set_id: str, collector_number: str) -> tuple[
# 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 ""),
))
warnings.append(
ParseWarning(
"COSMETIC",
f"{quality_warnings} for {format_print(card)!r}."
+ (f" Maybe you want {format_print(recommendation)!r}?" if recommendation != card else ""),
)
)
return card, warnings
Loading