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.
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.pyDownload AllPrintings.sqlite from MTGJSON
(see mtg_readme.md § Getting the Database).
Point tools at it via, in order of precedence:
card_resolver.ini:[database] path = /path/to/AllPrintings.sqlite
- the
MTG_DB_PATHenvironment variable - otherwise the default:
~/Documents/alpha_god/AllPrintings.sqlite
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.
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.)
python resolve.py input.csv output.csv [db_path] [--fanatics]db_pathdefaults to~/Documents/alpha_god/AllPrintings.sqliteif omitted.--fanaticsselects the Fanatics Collect listing parser; it's also auto-detected wheninput.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.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.
python card_resolver.pyLaunches 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.
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).
PrintingSpec/PrintingMatch(resolve.py) are the contract: aPrintingSpecis whatever partial/noisy info was extracted from a vendor description;Resolver.resolve()turns it into aPrintingMatchwith astatusladder —exact→mapped→found→ambiguous→unresolved— so callers can tell a confident match from a guess from a genuine dead end.Resolverwraps the sqlite connection: it builds lookup indexes on first connect (the shipped DB only indexesuuid), 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 DjinnvsJuzam Djinn,Abu Ja'farvsAbu Jafar) still match. - Set-hint resolution (
_resolve_set_hint, backed by the_SET_ALIASEStable) maps set codes, full set names, and vendor colloquialisms/promo-group aliases to the real MTGJSONsetCode(s). - Vendor adapters: each marketplace/source gets its own
<source>_row_to_spec(row) -> PrintingSpecfunction (e.g.hareruya_row_to_spec,fanaticscollect_row_to_spec) that parses that vendor's raw text/columns into aPrintingSpec, which then goes through the sameResolver.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.pyor 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 incard_resolver.py) only ever addresolved_*/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 bytest_resolve_csv_preserves_source_columnsandtest_fanatics_resolve_csv_preserves_source_columnsintests/test_regression.py— any change to the CSV output assembly (theout_cols/extra_colslogic) should keep these passing.
Adding a new vendor/data source — follow tests/regression/README.md § Adding a new source:
- Write a
<source>_row_to_spec(row)adapter inresolve.py, modeled on an existing one (hareruya_row_to_specis the simplest reference). - Add
tests/regression/<source>.csvwith real, verified input/expected rows. - Register the fixture in
SOURCE_FACTORIESintests/test_regression.py. crystalcommerce_generic.csvis 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.