Skip to main content

Query client for MTGJSON card data

Project description

mtg-json-tools

PyPI Python License

A DuckDB-backed Python query client for MTGJSON card data. Auto-downloads Parquet data from the MTGJSON CDN and exposes the full Magic: The Gathering dataset through an ergonomic, fully-typed Python API.

Features

  • Zero-config setup -- data downloads automatically on first use
  • 10+ query modules -- cards, sets, prices, legalities, identifiers, tokens, decks, sealed products, SKUs, enums
  • Booster pack simulation -- weighted random draft/collector pack opening
  • 3 output modes -- Pydantic models, Python dicts, or Polars DataFrames
  • Async support -- AsyncMtgJsonTools for FastAPI, Django, and other async frameworks
  • DuckDB export -- export the full database to a standalone .duckdb file
  • Auto-refresh -- detect new MTGJSON releases in long-running services
  • Offline mode -- use cached files without network access
  • Fuzzy search -- typo-tolerant name matching via Jaro-Winkler similarity
  • Localized search -- find cards by foreign-language names
  • Progress callbacks -- integrate with tqdm or custom progress bars during downloads

Install

pip install mtg-json-tools

Optional extras:

pip install mtg-json-tools[polars]   # DataFrame support
pip install mtg-json-tools[all]      # polars + orjson

Quick Start

from mtg_json_tools import MtgJsonTools

with MtgJsonTools() as sdk:
    # Search for cards
    bolts = sdk.cards.search(name="Lightning Bolt")
    print(f"Found {len(bolts)} printings of Lightning Bolt")

    # Get a specific set
    mh3 = sdk.sets.get("MH3")
    print(f"{mh3.name} -- {mh3.totalSetSize} cards")

    # Check format legality
    is_legal = sdk.legalities.is_legal(bolts[0].uuid, "modern")
    print(f"Modern legal: {is_legal}")

    # Find the cheapest printing
    cheapest = sdk.prices.cheapest_printing("Lightning Bolt")
    if cheapest:
        print(f"Cheapest: ${cheapest['price']} ({cheapest['setCode']})")

    # Raw SQL for anything else
    rows = sdk.sql("SELECT name, manaValue FROM cards WHERE manaValue = 0 LIMIT 5")

Use Cases

Price Tracking

with MtgJsonTools() as sdk:
    # Find the cheapest printing of any card
    cheapest = sdk.prices.cheapest_printing("Ragavan, Nimble Pilferer")

    # Price trend over time
    trend = sdk.prices.price_trend(
        cheapest["uuid"], provider="tcgplayer", finish="normal"
    )
    print(f"Range: ${trend['min_price']} - ${trend['max_price']}")
    print(f"Average: ${trend['avg_price']} over {trend['data_points']} data points")

    # Full price history with date range
    history = sdk.prices.history(
        cheapest["uuid"],
        provider="tcgplayer",
        date_from="2024-01-01",
        date_to="2024-12-31",
    )

    # Most expensive printings across the entire dataset
    priciest = sdk.prices.most_expensive_printings(limit=10)

Deck Building Helper

with MtgJsonTools() as sdk:
    # Find modern-legal red creatures with CMC <= 2
    aggro_creatures = sdk.cards.search(
        colors=["R"],
        types="Creature",
        mana_value_lte=2.0,
        legal_in="modern",
        limit=50,
    )

    # Check what's banned
    banned = sdk.legalities.banned_in("modern")
    print(f"{len(banned)} cards banned in Modern")

    # Search by keyword ability
    flyers = sdk.cards.search(keyword="Flying", colors=["W", "U"], legal_in="standard")

    # Fuzzy search -- handles typos
    results = sdk.cards.search(fuzzy_name="Ligtning Bolt")  # still finds it!

    # Find cards by foreign-language name
    blitz = sdk.cards.search(localized_name="Blitzschlag")  # German for Lightning Bolt

Collection Management

with MtgJsonTools() as sdk:
    # Cross-reference by Scryfall ID
    cards = sdk.identifiers.find_by_scryfall_id("f7a21fe4-...")

    # Look up by TCGPlayer product ID
    cards = sdk.identifiers.find_by_tcgplayer_id("12345")

    # Get all identifiers for a card (Scryfall, TCGPlayer, MTGO, Arena, etc.)
    all_ids = sdk.identifiers.get_identifiers("card-uuid-here")

    # Export to a standalone DuckDB file for offline analysis
    sdk.export_db("my_collection.duckdb")
    # Now query with: duckdb my_collection.duckdb "SELECT * FROM cards LIMIT 5"

Discord Bot / Web API

from mtg_json_tools import AsyncMtgJsonTools

# FastAPI example
from fastapi import FastAPI

app = FastAPI()
sdk = AsyncMtgJsonTools()

@app.get("/card/{name}")
async def get_card(name: str):
    cards = await sdk.run(sdk.inner.cards.get_by_name, name)
    return [c.model_dump() for c in cards]

@app.on_event("startup")
async def refresh_data():
    """Check for new MTGJSON data on startup."""
    stale = sdk.inner.refresh()
    if stale:
        print("New MTGJSON data available -- cache refreshed")

@app.on_event("shutdown")
async def shutdown():
    await sdk.close()

Booster Pack Simulation

with MtgJsonTools() as sdk:
    # See what booster types are available
    types = sdk.booster.available_types("MH3")  # ["draft", "collector", ...]

    # Open a single draft pack
    pack = sdk.booster.open_pack("MH3", "draft")
    for card in pack:
        print(f"  {card.name} ({card.rarity})")

    # Open an entire box
    box = sdk.booster.open_box("MH3", "draft", packs=36)
    print(f"Opened {len(box)} packs, {sum(len(p) for p in box)} total cards")

API Reference

Cards

sdk.cards.get_by_uuid("uuid")              # -> CardSet | None
sdk.cards.get_by_uuids(["uuid1", "uuid2"]) # -> list[CardSet] (batch lookup)
sdk.cards.get_by_name("Lightning Bolt")     # -> list[CardSet]
sdk.cards.search(
    name="Lightning%",                      # name pattern (% = wildcard)
    fuzzy_name="Ligtning Bolt",             # typo-tolerant (Jaro-Winkler)
    localized_name="Blitzschlag",           # foreign-language name search
    colors=["R"],                           # cards containing these colors
    color_identity=["R", "U"],              # filter by color identity
    legal_in="modern",                      # format legality
    rarity="rare",                          # rarity filter
    mana_value=1.0,                         # exact mana value
    mana_value_lte=3.0,                     # mana value range
    mana_value_gte=1.0,
    text="damage",                          # rules text search
    text_regex=r"deals? \d+ damage",        # regex rules text search
    types="Creature",                       # type line search
    artist="Christopher Moeller",           # artist name search
    keyword="Flying",                       # keyword ability
    is_promo=False,                         # promo status
    availability="paper",                   # paper, mtgo
    language="English",                     # language filter
    layout="normal",                        # card layout
    set_code="MH3",                         # filter by set
    set_type="expansion",                   # set type (joins sets table)
    power="3", toughness="3",               # P/T filter
    limit=100, offset=0,                    # pagination
)
sdk.cards.get_printings("Lightning Bolt")   # all printings across sets
sdk.cards.get_atomic("Lightning Bolt")      # oracle data (no printing info)
sdk.cards.get_atomic("Fire")               # also works with face names (split/MDFC)
sdk.cards.find_by_scryfall_id("...")        # cross-reference
sdk.cards.random(5)                         # random cards
sdk.cards.count()                           # total count
sdk.cards.count(setCode="MH3", rarity="rare")  # filtered count

Tokens

sdk.tokens.get_by_uuid("uuid")             # -> CardToken | None
sdk.tokens.get_by_name("Soldier Token")     # -> list[CardToken]
sdk.tokens.search(name="%Token", set_code="MH3", colors=["W"])
sdk.tokens.for_set("MH3")                  # all tokens for a set
sdk.tokens.count()

Sets

sdk.sets.get("MH3")                        # -> SetList | None
sdk.sets.list(set_type="expansion")         # -> list[SetList]
sdk.sets.search(name="Horizons", release_year=2024)
sdk.sets.count()

Identifiers

sdk.identifiers.find_by_scryfall_id("...")
sdk.identifiers.find_by_tcgplayer_id("...")
sdk.identifiers.find_by_mtgo_id("...")
sdk.identifiers.find_by_mtgo_foil_id("...")
sdk.identifiers.find_by_mtg_arena_id("...")
sdk.identifiers.find_by_multiverse_id("...")
sdk.identifiers.find_by_mcm_id("...")
sdk.identifiers.find_by_card_kingdom_id("...")
sdk.identifiers.find_by_card_kingdom_foil_id("...")
sdk.identifiers.find_by_cardsphere_id("...")
sdk.identifiers.find_by_scryfall_oracle_id("...")
sdk.identifiers.find_by_scryfall_illustration_id("...")
sdk.identifiers.find_by("scryfallId", "...")  # generic lookup
sdk.identifiers.get_identifiers("uuid")       # all IDs for a card

Legalities

sdk.legalities.formats_for_card("uuid")    # -> {"modern": "Legal", ...}
sdk.legalities.legal_in("modern")          # all modern-legal cards
sdk.legalities.is_legal("uuid", "modern")  # -> bool
sdk.legalities.banned_in("modern")         # banned cards
sdk.legalities.restricted_in("vintage")    # restricted cards
sdk.legalities.suspended_in("historic")    # suspended cards
sdk.legalities.not_legal_in("standard")    # not-legal cards

Prices

sdk.prices.get("uuid")                     # full nested price data
sdk.prices.today("uuid", provider="tcgplayer", finish="foil")  # latest prices
sdk.prices.history("uuid", provider="tcgplayer", date_from="2024-01-01")
sdk.prices.price_trend("uuid", provider="tcgplayer", finish="normal")  # min/max/avg
sdk.prices.cheapest_printing("Lightning Bolt")   # cheapest printing by name
sdk.prices.cheapest_printings(limit=10)          # cheapest cards overall
sdk.prices.most_expensive_printings(limit=10)    # most expensive cards

Decks

sdk.decks.list(set_code="MH3")
sdk.decks.search(name="Eldrazi")

Sealed Products

sdk.sealed.list(set_code="MH3")
sdk.sealed.get("uuid")                    # efficient UNNEST lookup

SKUs

sdk.skus.get("uuid")                       # TCGPlayer SKUs for a card
sdk.skus.find_by_sku_id(123456)
sdk.skus.find_by_product_id(789)

Booster Simulation

sdk.booster.available_types("MH3")         # -> ["draft", "collector"]
sdk.booster.open_pack("MH3", "draft")      # -> list[CardSet]
sdk.booster.open_box("MH3", packs=36)      # -> list[list[CardSet]]
sdk.booster.sheet_contents("MH3", "draft", "common")  # card weights

Enums

sdk.enums.keywords()                       # -> Keywords
sdk.enums.card_types()                     # -> CardTypes
sdk.enums.enum_values()                    # all enum values

Metadata & Utilities

sdk.meta                                   # -> {"data": {"version": "...", "date": "..."}}
sdk.views                                  # -> ["cards", "sets", ...] registered views
sdk.refresh()                              # check for new data, reset if stale -> bool
sdk.export_db("output.duckdb")             # export to persistent DuckDB file
sdk.close()                                # release resources

Advanced Usage

Async Support

AsyncMtgJsonTools wraps the sync client in a thread pool executor, making it safe to use from async frameworks without blocking the event loop. DuckDB releases the GIL during query execution, so thread pool concurrency works well.

from mtg_json_tools import AsyncMtgJsonTools

async with AsyncMtgJsonTools(max_workers=4) as sdk:
    # Run any sync method asynchronously
    cards = await sdk.run(sdk.inner.cards.search, name="Lightning%")
    sets = await sdk.run(sdk.inner.sets.list, set_type="masters")

    # Raw SQL shortcut
    result = await sdk.sql("SELECT COUNT(*) FROM cards")

DataFrame Output

Every query method supports as_dataframe=True to return a Polars DataFrame (requires pip install mtg-json-tools[polars]):

import polars as pl

with MtgJsonTools() as sdk:
    # Get a DataFrame of all Modern-legal creatures
    df = sdk.cards.search(legal_in="modern", types="Creature", limit=5000, as_dataframe=True)

    # Analyze with Polars
    avg_by_color = (
        df.explode("colors")
        .group_by("colors")
        .agg(pl.col("manaValue").mean().alias("avg_cmc"))
        .sort("avg_cmc")
    )
    print(avg_by_color)

Database Export

Export all loaded data to a standalone DuckDB file that can be queried without the SDK:

with MtgJsonTools() as sdk:
    # Touch the query modules you want exported
    _ = sdk.cards.count()
    _ = sdk.sets.count()

    # Export to file
    sdk.export_db("mtgjson.duckdb")

# Now use it standalone:
# $ duckdb mtgjson.duckdb "SELECT name, setCode FROM cards LIMIT 10"

Auto-Refresh for Long-Running Services

The refresh() method checks the CDN for new MTGJSON releases. If a newer version is available, it clears internal state so the next query re-downloads fresh data:

sdk = MtgJsonTools()

# In a scheduled task or health check:
if sdk.refresh():
    print("New MTGJSON data detected -- cache refreshed")

Custom Cache Directory & Progress

from pathlib import Path

def on_progress(filename: str, downloaded: int, total: int):
    pct = (downloaded / total * 100) if total else 0
    print(f"\r{filename}: {pct:.1f}%", end="", flush=True)

sdk = MtgJsonTools(
    cache_dir=Path("/data/mtgjson-cache"),
    timeout=300.0,
    on_progress=on_progress,
)

Raw SQL

All user input goes through DuckDB parameter binding ($1, $2, ...) to prevent SQL injection:

with MtgJsonTools() as sdk:
    # Ensure views are registered before querying
    _ = sdk.cards.count()

    # Parameterized queries
    rows = sdk.sql(
        "SELECT name, setCode, rarity FROM cards WHERE manaValue <= $1 AND rarity = $2",
        [2, "mythic"],
    )

    # Complex analytics
    rows = sdk.sql("""
        SELECT setCode, COUNT(*) as card_count, AVG(manaValue) as avg_cmc
        FROM cards
        GROUP BY setCode
        ORDER BY card_count DESC
        LIMIT 10
    """)

Architecture

MTGJSON CDN (Parquet + JSON files)
        |
        | auto-download on first access
        v
Local Cache (platform-specific directory)
        |
        | lazy view registration
        v
DuckDB In-Memory Database
        |
        | parameterized SQL queries
        v
Typed Python API (Pydantic models / dicts / Polars DataFrames)

How it works:

  1. Auto-download: On first use, the SDK downloads ~15 Parquet files and ~7 JSON files from the MTGJSON CDN to a platform-specific cache directory (~/.cache/mtg-json-tools on Linux, ~/Library/Caches/mtg-json-tools on macOS, AppData/Local/mtg-json-tools on Windows).

  2. Lazy loading: DuckDB views are registered on-demand -- accessing sdk.cards triggers the cards view, sdk.prices triggers price data loading, etc. Only the data you use gets loaded into memory.

  3. Schema adaptation: The SDK auto-detects array columns in parquet files using a hybrid heuristic (static baseline + dynamic plural detection + blocklist), so it adapts to upstream MTGJSON schema changes without code updates.

  4. Legality UNPIVOT: Format legality columns are dynamically detected from the parquet schema and UNPIVOTed to (uuid, format, status) rows -- automatically scales to new formats.

  5. Price flattening: Deeply nested JSON price data is streamed to NDJSON and bulk-loaded into DuckDB, minimizing memory overhead.

Development

Prerequisites

  • Python 3.11+
  • uv (recommended) or pip

Setup

git clone https://github.com/the-muppet2/mtg-json-tools.git
cd mtg-json-tools
uv sync --group dev

Running Tests

uv run pytest

Code Style

uv run ruff check src/ tests/
uv run ruff format src/ tests/

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mtg_json_tools-0.1.0.tar.gz (97.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mtg_json_tools-0.1.0-py3-none-any.whl (55.4 kB view details)

Uploaded Python 3

File details

Details for the file mtg_json_tools-0.1.0.tar.gz.

File metadata

  • Download URL: mtg_json_tools-0.1.0.tar.gz
  • Upload date:
  • Size: 97.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for mtg_json_tools-0.1.0.tar.gz
Algorithm Hash digest
SHA256 79318793b9b83ce399b4440109f928f8f3119b3e1cae23f3804af88e8c4dbc99
MD5 c4cae485a2fe5fdc226b0922b67ec0cd
BLAKE2b-256 075a6508cca7643f92f131d28fce453dfec17b3272f4e9891277458523068351

See more details on using hashes here.

File details

Details for the file mtg_json_tools-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: mtg_json_tools-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 55.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for mtg_json_tools-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 00dae48a0df488a0a71a63da68694d47ec4c614a73c278cfb8cc3a4845931a9c
MD5 e6baec1debf0b7123aeb70ec48994f21
BLAKE2b-256 552b0beae01df636ba7b87b6a0386563881ea085d6d07e6f13d4a78496a24a2d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page