Skip to content

Repository files navigation

MTG Printing Resolver

Resolves messy, vendor-specific card descriptions (marketplace listing titles, TSV exports, etc.) into a specific MTGJSON printing — setCode, number, finish, language — against a local MTGJSON AllPrintings.sqlite database.

For the domain background (what a "printing" is, MTGJSON schema quirks, multi-faced cards, finishes vs. languages, etc.), see mtg_readme.md. For the original product spec of the interactive grid tool, see requirements.md.

Setup

The core resolver (resolve.py) has no third-party dependencies — just the standard library. Optional pieces need extras:

pip install nicegui        # only for the interactive GUI (card_resolver.py)
pip install gspread google-auth-oauthlib   # only for upload_to_sheets.py

Getting the database

Download AllPrintings.sqlite from MTGJSON (see mtg_readme.md § Getting the Database).

Point tools at it via, in order of precedence:

  1. card_resolver.ini:
    [database]
    path = /path/to/AllPrintings.sqlite
  2. the MTG_DB_PATH environment variable
  3. otherwise the default: ~/Documents/alpha_god/AllPrintings.sqlite

Working data lives outside the repo

Vendor exports (e.g. Hareruya/Fanatics listing dumps) and resolver output CSVs are working data, not source — they may contain vendor pricing/consignment details and have no reason to be version-controlled. Keep them in ~/Documents/mtg-printing-resolver-data/ rather than the repo directory. .gitignore has a few patterns (*_resolved.csv, *_resolved.tsv, top-level *.tsv) as a safety net in case output ever lands in the repo root by habit, but the convention is to point the CLI's output.csv argument (and any input files you're resolving) at that external directory directly.

Usage

As a Python module

from resolve import Resolver, PrintingSpec

with Resolver('/path/to/AllPrintings.sqlite') as r:
    match = r.resolve(PrintingSpec(
        name='Lightning Bolt',
        set_hint='LEA',
        finish='nonfoil',
        language='en',
    ))
    # match.set_code, match.number, match.status ('exact'|'mapped'|'found'|'ambiguous'|'unresolved')

    # Batch:
    results = r.resolve_batch(list_of_specs)

To use resolve.py/condition.py from another project, install this repo as a package rather than fighting with sys.path:

pip install -e /path/to/mtg-printing-resolver

(tests/test_regression.py still uses a sys.path insert instead, since it needs to import private _fc_* helpers the package doesn't otherwise expose.)

From the command line

python resolve.py input.csv output.csv [db_path] [--fanatics]
  • db_path defaults to ~/Documents/alpha_god/AllPrintings.sqlite if omitted.
  • --fanatics selects the Fanatics Collect listing parser; it's also auto-detected when input.csv's filename contains "fanatic".
  • Otherwise the generic/Hareruya CSV path is used.
  • Prints a summary of row counts by resolution status when done.

This is a plain sys.argv parser (no --help, no flag validation) — functional but minimal.

Condition normalization

condition.py normalizes vendor/grading-service condition strings to a standard 5-tier scale (NM/LP/MP/HP/DM), including graded cards (PSA/BGS/CGC/ SGC/GC/MBA/BCCG):

from condition import normalize_condition, normalize_graded

normalize_condition('SP+', source_hint='hareruya')   # -> normalized='LP'
normalize_graded('CGC', '9.5')                        # -> normalized='NM'
normalize_graded('CGC', 'AUTH')                       # -> normalized=None, source='cgc_auth'

Prefer normalize_graded(company, grade) over normalize_condition() when the company and grade are already split out (as parse_fanatics_grade() produces) — it skips a second, potentially-inconsistent regex pass over the same text. Two grade forms aren't a plain number and are handled explicitly: "AUTH" (authentication-only — no numeric grade was assigned, so normalized is None, not a guess) and "QUALIFIED N" (a grade assigned despite a noted defect — resolved at a reduced confidence).

Ported from alpha_god/mtg_scraper/utils/condition_mapper.py, extended after cross-checking real Fanatics Collect listing data: the GC/MBA companies and the AUTH/ QUALIFIED grade forms above all came from that verification pass, not the original Hareruya-focused source. fanatics_resolve_csv()'s output already includes normalized_condition/condition_confidence/condition_source columns built this way; Hareruya listings already carry a pre-normalized normalized_condition column from the scraper itself (see alpha_god), so resolve_csv()'s generic path doesn't re-normalize one.

Interactive GUI

python card_resolver.py

Launches a NiceGUI spreadsheet-style app on localhost:8080 for manually reviewing/editing a CSV row by row (per requirements.md). Not scriptable — this is the human-in-the-loop tool for cases the automated resolver can't nail down.

Running tests

python tests/test_regression.py
# or, if pytest is installed:
pytest tests/

These are regression tests: each fixture CSV in tests/regression/ holds known-good (input → expected printing) pairs per vendor source, replayed against a live AllPrintings.sqlite (via card_resolver.ini). See tests/regression/README.md for the fixture schema, and the "How this evolves" section below for adding new ones.

tests/test_condition.py is separate: table-driven unit tests for condition.py with no database dependency, run the same way (python tests/test_condition.py).

General approach

  • PrintingSpec / PrintingMatch (resolve.py) are the contract: a PrintingSpec is whatever partial/noisy info was extracted from a vendor description; Resolver.resolve() turns it into a PrintingMatch with a status ladder — exactmappedfoundambiguousunresolved — so callers can tell a confident match from a guess from a genuine dead end.
  • Resolver wraps the sqlite connection: it builds lookup indexes on first connect (the shipped DB only indexes uuid), then lazily builds a per-set cache and a full name index to keep repeated lookups fast across a batch.
  • Tolerant name matching goes through _ascii_norm, a SQLite UDF that lowercases, strips diacritics, and drops punctuation (apostrophes, commas, colons, hyphens) so vendor name variants (Juzám Djinn vs Juzam Djinn, Abu Ja'far vs Abu Jafar) still match.
  • Set-hint resolution (_resolve_set_hint, backed by the _SET_ALIASES table) maps set codes, full set names, and vendor colloquialisms/promo-group aliases to the real MTGJSON setCode(s).
  • Vendor adapters: each marketplace/source gets its own <source>_row_to_spec(row) -> PrintingSpec function (e.g. hareruya_row_to_spec, fanaticscollect_row_to_spec) that parses that vendor's raw text/columns into a PrintingSpec, which then goes through the same Resolver.resolve() path. This is the seam for supporting a new vendor.
  • Condition normalization is a separate concern from printing resolution, on purpose: condition.py has no dependency on resolve.py or the MTGJSON database, and deliberately doesn't (and shouldn't) know anything about price — turning a normalized condition into a price adjustment is a downstream concern with its own, independently-evolving logic (e.g. older cards may hold value differently at a given condition), not something to fold into the text-matching tables here.
  • Source columns are sacred. resolve_csv()/fanatics_resolve_csv() (and the GUI's import/export in card_resolver.py) only ever add resolved_*/parsed_*/annotation columns on top of the original row — every other input column (quantity, price, condition, currency, url, ...) must survive untouched. This is the vendor's own transactional data; the resolver's job is to annotate a listing, not to transform or drop parts of it. Covered by test_resolve_csv_preserves_source_columns and test_fanatics_resolve_csv_preserves_source_columns in tests/test_regression.py — any change to the CSV output assembly (the out_cols/extra_cols logic) should keep these passing.

How this should evolve (notes for future contributors, human or AI)

Adding a new vendor/data source — follow tests/regression/README.md § Adding a new source:

  1. Write a <source>_row_to_spec(row) adapter in resolve.py, modeled on an existing one (hareruya_row_to_spec is the simplest reference).
  2. Add tests/regression/<source>.csv with real, verified input/expected rows.
  3. Register the fixture in SOURCE_FACTORIES in tests/test_regression.py.
  4. crystalcommerce_generic.csv is an already-scaffolded placeholder for the next CrystalCommerce-based storefront — check there before starting from scratch.

Regression fixtures are the safety net, not an afterthought. Every bug fixed or alias added should get a fixture row so it can't silently regress — add it in the same change that fixes the bug, not later. Known-limitation rows (ambiguous/unresolved on purpose, e.g. sets with 0 cards in the current DB) are first-class: don't delete them when they're inconvenient — if a later fix makes one resolve cleanly, that's a welcome test failure, so update the row's expected status instead.

When MTGJSON data changes (new sets, new promo types, a refreshed AllPrintings.sqlite): most lookups (available languages, set names, card names) are queried live from the DB, so they self-update. What doesn't self-update is anything in a hardcoded alias/lookup table — _SET_ALIASES and _FC_SET_ALIASES in resolve.py, _FC_LANGUAGE_MAP, basic_land_variants.csv — those need a manual entry when a new set introduces a new colloquial name, a new promo group, or a new land variant naming convention. Before writing new SQL against the database, read mtg_readme.md first — it documents non-obvious schema behavior (one cards row can represent several printings via finishes/languages, multi-faced cards producing multiple rows per physical card, sets.languages over-counting vs. cardForeignData, etc.) that's easy to get subtly wrong otherwise.

Extending condition.py for a new source or grading company: don't add a new company/table entry from documentation or guesswork — pull real listing text for that source first and check what it actually contains. The GC/MBA companies and the AUTH/ QUALIFIED N grade forms in the current module all came from cross-checking real Fanatics Collect exports, not the grading services' own published grade scales; a service's real-world listing text doesn't always match its marketing copy (e.g. "AUTH" carries no condition information at all despite looking like a grade).

Keep the CLI/module boundary thin. resolve.py's __main__ block and the Resolver/PrintingSpec API are intentionally the only two integration points; new automation (batch jobs, other scripts) should go through the module API rather than duplicating resolution logic.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages