Spaces:
Running
Running
Download server/decklist.py from nsaroiu/mtg-deck-completion: direct link, hf CLI and curl.
- Browser
- Download file 3.15 kB
-
https://huggingface.co/spaces/nsaroiu/mtg-deck-completion/resolve/main/server/decklist.py
- Command line
-
hf download hf://spaces/nsaroiu/mtg-deck-completion/server/decklist.py
-
curl -L -o decklist.py https://huggingface.co/spaces/nsaroiu/mtg-deck-completion/resolve/main/server/decklist.py
3.15 kB
| """ | |
| decklist.py | |
| Parses a pasted decklist into (name, quantity) entries. Accepts the plain | |
| text formats players actually paste, rather than one strict format: | |
| Sol Ring | |
| 1 Sol Ring | |
| 1x Sol Ring | |
| 1 Sol Ring (CMM) 410 <- Moxfield / MTG Arena export | |
| 1 Sol Ring (CMM) 410 *F* <- foil / etched markers | |
| 1 Sol Ring [CMM] <- bracketed set code | |
| 1x Sol Ring (cmm) 410 [Ramp] <- Archidekt export with a category tag | |
| Section headers ("Commander", "Deck:", "// Creatures", ...) are skipped. | |
| Anything after a sideboard/maybeboard/considering header is ignored, since | |
| those cards aren't part of the deck being completed. | |
| Pure text handling only: resolving names against the card vocabulary | |
| happens in server/deckbuilder.py. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass | |
| _QTY_RE = re.compile(r"^(\d+)\s*x?\s+(.+)$", re.IGNORECASE) | |
| # Trailing "(SET) 123", "(SET)", "[SET]", "*F*"/"*E*" markers and Archidekt | |
| # "[Category]" / "^Tag^" annotations, stripped repeatedly from the right. | |
| _TRAILING_NOISE_RE = re.compile( | |
| r"\s+(?:\([A-Za-z0-9]{2,6}\)(?:\s+[A-Za-z0-9\-★]+)?|\[[^\]]*\]|\*[A-Za-z]\*|\^[^^]*\^)\s*$" | |
| ) | |
| _IGNORED_SECTION_RE = re.compile( | |
| r"^(sideboard|maybeboard|maybe|considering|tokens?)\s*(\(\d+\))?\s*:?$", re.IGNORECASE | |
| ) | |
| _HEADER_RE = re.compile( | |
| r"^(commanders?|companion|deck|main\s*deck|mainboard|" | |
| r"creatures?|instants?|sorcery|sorceries|artifacts?|enchantments?|planeswalkers?|" | |
| r"battles?|lands?|other|spells?)\s*(\(\d+\))?\s*:?$", | |
| re.IGNORECASE, | |
| ) | |
| class DecklistEntry: | |
| name: str | |
| quantity: int | |
| line_number: int # 1-based, for pointing the user at a bad line | |
| def _strip_trailing_noise(text: str) -> str: | |
| while True: | |
| stripped = _TRAILING_NOISE_RE.sub("", text) | |
| if stripped == text: | |
| return text.strip() | |
| text = stripped | |
| def parse_decklist(text: str) -> list[DecklistEntry]: | |
| """Returns one entry per card line, in input order. Duplicate names are | |
| NOT merged here -- the caller merges after name resolution, since two | |
| differently-spelled lines can resolve to the same card.""" | |
| entries: list[DecklistEntry] = [] | |
| for line_number, raw in enumerate(text.splitlines(), start=1): | |
| line = raw.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| # A "// Heading" or "SIDEBOARD:" style line: stop at ignored sections, | |
| # skip everything else that looks like a header. | |
| heading = line.lstrip("/").strip() | |
| if line.startswith("//") or line.endswith(":") or _HEADER_RE.match(heading): | |
| if _IGNORED_SECTION_RE.match(heading): | |
| break | |
| continue | |
| if _IGNORED_SECTION_RE.match(heading): | |
| break | |
| quantity = 1 | |
| m = _QTY_RE.match(line) | |
| if m: | |
| quantity = int(m.group(1)) | |
| line = m.group(2) | |
| name = _strip_trailing_noise(line) | |
| if name and quantity > 0: | |
| entries.append(DecklistEntry(name=name, quantity=quantity, line_number=line_number)) | |
| return entries | |