pyfall-mtg 0.1.0
A Scryfall search syntax parser for Magic: The Gathering
Requires Python: >=3.9
Keywords: cards, magic-the-gathering, mtg, parser, scryfall, search

# Pyfall

A Python library for parsing [Scryfall](https://scryfall.com/) search syntax into an Abstract Syntax Tree (AST).

Pyfall lets you parse Scryfall's powerful card search syntax and compile it to SQL or other query languages for searching your own card database.

*Pyfall is not affiliated with, endorsed by, or sponsored by Scryfall.*

## Installation

```bash
pip install pyfall
```

## Quick Start

```python
from pyfall import parse
from pyfall.backends import SQLBackend

# Parse a Scryfall query
ast = parse("t:creature c:green cmc<=3")

# Compile to SQL
backend = SQLBackend()
sql, params = backend.compile(ast)

# Use with your database
cursor.execute(f"SELECT * FROM cards WHERE {sql}", params)
```

## CLI Usage

Pyfall includes a command-line interface for testing queries:

```bash
# Parse a query and show the AST
python -m pyfall parse "t:creature c:green"

# Compile a query to SQL
python -m pyfall sql "t:creature c:green"

# Validate a query
python -m pyfall validate "t:creature c:green"
```

## Supported Syntax

Pyfall supports ~99% of Scryfall's search syntax. The canonical reference is [Scryfall's syntax documentation](https://scryfall.com/docs/syntax).

### Text Filters

| Syntax | Description | Example |
|--------|-------------|---------|
| `name` (plain text) | Card name | `lightning bolt` |
| `t:` / `type:` | Type line | `t:creature`, `t:"legendary artifact"` |
| `o:` / `oracle:` | Oracle text | `o:draw`, `o:"enters the battlefield"` |
| `fo:` / `fulloracle:` | Full oracle text (includes reminder) | `fo:flying` |
| `kw:` / `keyword:` | Keyword abilities | `kw:flying`, `kw:trample` |
| `ft:` / `flavor:` | Flavor text | `ft:chandra` |
| `a:` / `artist:` | Artist name | `a:"Rebecca Guay"` |
| `wm:` / `watermark:` | Watermark | `wm:phyrexian` |

### Color Filters

| Syntax | Description | Example |
|--------|-------------|---------|
| `c:` / `color:` | Card colors | `c:w`, `c:wu`, `c:colorless` |
| `id:` / `identity:` | Color identity | `id:wubrg`, `id:c` |
| `produces:` | Mana production | `produces:g`, `produces:wu` |
| `devotion:` | Devotion (mana pips) | `devotion:ww`, `devotion:uuu` |

#### Color Values

- Single letters: `w` (white), `u` (blue), `b` (black), `r` (red), `g` (green)
- Colorless: `c` or `colorless`
- Multicolor: `m` or `multicolor`
- Guild nicknames: `azorius`, `dimir`, `rakdos`, `gruul`, `selesnya`, `orzhov`, `izzet`, `golgari`, `boros`, `simic`
- Shard/Wedge nicknames: `bant`, `esper`, `grixis`, `jund`, `naya`, `abzan`, `jeskai`, `sultai`, `mardu`, `temur`

#### Color Comparison Modes

```
c:wu      # Has at least white AND blue (may have more colors)
c=wu      # Has exactly white and blue (no other colors)
c<=wu     # Colors are subset of white/blue (could be W, U, WU, or colorless)
c>=wu     # Has at least white and blue (same as c:wu)
```

### Numeric Filters

| Syntax | Description | Example |
|--------|-------------|---------|
| `cmc:` / `mv:` / `manavalue:` | Mana value | `cmc:3`, `cmc<=2`, `mv>=5` |
| `pow:` / `power:` | Power | `pow:4`, `pow>=3`, `pow:*` |
| `tou:` / `toughness:` | Toughness | `tou:5`, `tou<3` |
| `pt:` / `powtou:` | Power + Toughness | `pt:6` |
| `loy:` / `loyalty:` | Loyalty | `loy:4` |
| `def:` / `defense:` | Defense (battles) | `def:5` |
| `usd:` / `eur:` / `tix:` | Price | `usd<1`, `eur>=10` |
| `year:` | Release year | `year:2023`, `year>=2020` |
| `date:` | Release date | `date>=2023-01-01`, `date:now` |
| `artists:` | Artist count | `artists:2` |
| `illustrations:` | Illustration count | `illustrations>=2` |

#### Special Numeric Values

- `even` / `odd`: Parity check for CMC (`cmc:even`)
- `*`: Variable power/toughness (`pow:*`)

### Collector Number

| Syntax | Description | Example |
|--------|-------------|---------|
| `cn:` / `number:` | Collector number | `cn:100`, `cn>=50` |
| Range syntax | Number range | `cn:100-200` |

### Set & Rarity

| Syntax | Description | Example |
|--------|-------------|---------|
| `set:` / `s:` / `e:` | Set code | `set:neo`, `s:mh2` |
| `b:` / `block:` | Block code | `b:zendikar` |
| `st:` | Set type | `st:core`, `st:expansion` |
| `r:` / `rarity:` | Rarity | `r:mythic`, `r:r`, `r>=uncommon` |
| `in:` | Ever printed in/at | `in:rare`, `in:paper`, `in:ja` |

### Format Legality

| Syntax | Description | Example |
|--------|-------------|---------|
| `f:` / `format:` / `legal:` | Legal in format | `f:commander`, `f:modern` |
| `banned:` | Banned in format | `banned:legacy` |
| `restricted:` | Restricted in format | `restricted:vintage` |

### Card Properties (`is:`)

Pyfall supports all Scryfall `is:` properties:

#### Type Properties
`is:legendary`, `is:creature`, `is:instant`, `is:sorcery`, `is:artifact`, `is:enchantment`, `is:planeswalker`, `is:land`, `is:battle`, `is:tribal`, `is:kindred`

#### Layout Properties
`is:split`, `is:flip`, `is:transform`, `is:meld`, `is:leveler`, `is:dfc`, `is:mdfc`, `is:tdfc`, `is:adventure`, `is:saga`, `is:class`, `is:prototype`, `is:case`

#### Card Characteristics
`is:spell`, `is:permanent`, `is:historic`, `is:vanilla`, `is:frenchvanilla`, `is:bear`, `is:modal`, `is:party`, `is:outlaw`, `is:manland`

#### Format Properties
`is:commander`, `is:brawler`, `is:companion`, `is:partner`, `is:reserved`

#### Mana Properties
`is:hybrid`, `is:phyrexian`

#### Print Properties
`is:foil`, `is:nonfoil`, `is:promo`, `is:fullart`, `is:textless`, `is:reprint`, `is:firstprint`, `is:digital`, `is:oversized`, `is:hires`, `is:spotlight`, `is:booster`

#### Land Cycle Shortcuts
`is:dual`, `is:fetchland`, `is:shockland`, `is:checkland`, `is:fastland`, `is:slowland`, `is:painland`, `is:filterland`, `is:triland`, `is:triome`, `is:canopyland`, `is:bikeland`, `is:bondland`, `is:bounceland`, `is:gainland`, `is:pathway`, `is:scryland`, `is:surveilland`, `is:shadowland`, `is:storageland`, `is:tangoland`, `is:creatureland`

#### Other Properties
`is:funny`, `is:etched`, `is:glossy`, `is:alchemy`, `is:rebalanced`, `is:universesbeyond`, `is:colorshifted`, `is:datestamped`

### Has Properties (`has:`)

| Syntax | Description |
|--------|-------------|
| `has:indicator` | Has color indicator |
| `has:watermark` | Has watermark |
| `has:foil` | Available in foil |
| `has:nonfoil` | Available in nonfoil |
| `has:promo` | Is a promo |
| `has:colors` | Has colors (not colorless) |
| `has:flavor` | Has flavor text |
| `has:oracle` | Has oracle text |
| `has:loyalty` | Has loyalty (planeswalker) |
| `has:pt` | Has power/toughness |
| `has:multifaced` | Has multiple faces |

### Mana Cost

| Syntax | Description | Example |
|--------|-------------|---------|
| `m:` / `mana:` | Mana cost pattern | `m:{2}{W}{W}`, `m:{G/U}` |

Supported mana symbols:
- Colored: `{W}`, `{U}`, `{B}`, `{R}`, `{G}`
- Generic: `{1}`, `{2}`, `{X}`, `{Y}`
- Colorless: `{C}`
- Snow: `{S}`
- Hybrid: `{W/U}`, `{2/W}`
- Phyrexian: `{W/P}`, `{U/P}`

### Frame & Border

| Syntax | Description | Example |
|--------|-------------|---------|
| `frame:` | Frame style | `frame:2015`, `frame:future` |
| `border:` | Border color | `border:black`, `border:borderless` |
| `stamp:` | Security stamp | `stamp:oval`, `stamp:acorn` |

### Game Availability

| Syntax | Description | Example |
|--------|-------------|---------|
| `game:` | Game availability | `game:paper`, `game:arena`, `game:mtgo` |

### Regex Support

| Syntax | Description | Example |
|--------|-------------|---------|
| `name:/pattern/` | Name regex | `name:/^Lightning/` |
| `type:/pattern/` | Type regex | `type:/Goblin.*Warrior/` |
| `oracle:/pattern/` | Oracle regex | `oracle:/\{T\}:.*damage/` |
| `flavor:/pattern/` | Flavor regex | `flavor:/fire/` |

**Note:** Regex patterns are converted to SQL LIKE patterns by default. For true regex support, use database-specific backends (see below).

### Logical Operators

| Syntax | Description | Example |
|--------|-------------|---------|
| (space) | AND (implicit) | `t:creature c:green` |
| `or` | OR | `t:creature or t:artifact` |
| `-` | NOT (negation) | `-t:creature`, `-c:red` |
| `()` | Grouping | `c:w (t:creature or t:enchantment)` |

### Display Directives

These operators parse successfully but affect result presentation rather than filtering:

| Syntax | Description | Example |
|--------|-------------|---------|
| `unique:` | Result grouping | `unique:cards`, `unique:prints`, `unique:art` |
| `order:` | Sort field | `order:cmc`, `order:name`, `order:usd` |
| `direction:` | Sort direction | `direction:asc`, `direction:desc` |
| `prefer:` | Print preference | `prefer:oldest`, `prefer:usd-low` |
| `include:` | Include extras | `include:extras` |
| `cheapest:` | Price preference | `cheapest:usd` |
| `display:` | Display mode | `display:grid`, `display:images` |

**Note:** Display directives return `1=1` from the SQL backend since they don't filter results. Use `extract_directives()` to handle them in your application layer.

### Stub Filters (Require External Data)

These filters parse correctly but require external data not in Scryfall bulk downloads:

| Syntax | Notes |
|--------|-------|
| `cube:` | Requires Scryfall cube API data |
| `art:` / `atag:` / `arttag:` | Requires Scryfall art tag data |
| `function:` / `otag:` / `oracletag:` | Requires Scryfall oracle tag data |
| `new:` | Requires printing history analysis |

Enable `strict_mode=True` to raise `UnsupportedFilterError` instead of silently matching all cards.

## Backend Configuration

### Column Mapping

Map Scryfall field names to your database columns:

```python
from pyfall.backends import SQLBackend

backend = SQLBackend(
    column_map={
        'name': 'card_name',
        'type': 'card_type_line',
        'oracle': 'rules_text',
        'colors': 'card_colors',
        'cmc': 'mana_value',
    },
    table_name='my_cards',
    case_insensitive=True,
)
```

### Strict Mode

Enable strict mode to raise errors on stub filters instead of silently matching all:

```python
backend = SQLBackend(strict_mode=True)

try:
    sql, params = backend.compile(parse("cube:vintage"))
except UnsupportedFilterError as e:
    print(f"Filter not supported: {e}")
```

### Database-Specific Features

#### PostgreSQL

```python
from pyfall.backends import create_postgres_backend

# Factory creates backend with PostgreSQL regex support
backend = create_postgres_backend()

ast = parse("name:/^Lightning/")
sql, params = backend.compile(ast)
# Uses ~* operator for case-insensitive regex
```

Or configure manually:

```python
from pyfall.backends import SQLBackend, postgres_regex
from pyfall import RegexFilter

backend = SQLBackend(
    node_compilers={
        RegexFilter: postgres_regex,  # Case-insensitive
        # Or: postgres_regex_case_sensitive for case-sensitive
    }
)
```

#### MySQL

```python
from pyfall.backends import SQLBackend, mysql_regex
from pyfall import RegexFilter

backend = SQLBackend(
    node_compilers={
        RegexFilter: mysql_regex,
    }
)
```

#### SQLite

```python
from pyfall.backends import SQLBackend, sqlite_regex
from pyfall import RegexFilter

# Note: Requires REGEXP function to be loaded in SQLite
backend = SQLBackend(
    node_compilers={
        RegexFilter: sqlite_regex,
    }
)
```

### Custom Node Compilers

Override compilation for any filter type:

```python
from pyfall.backends import SQLBackend
from pyfall import CubeFilter, IsFilter

def my_cube_filter(node, backend):
    """Look up cube membership in your own table."""
    return (
        "id IN (SELECT card_id FROM cube_cards WHERE cube = ?)",
        [node.value]
    )

def my_fetchland_check(node, backend):
    """Use a curated list of fetchland oracle IDs."""
    if node.property == 'fetchland':
        return (
            "oracle_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            [
                # Onslaught fetches
                "a1b2c3...", "d4e5f6...",
                # Zendikar fetches
                "g7h8i9...", "j0k1l2...",
                # etc.
            ]
        )
    # Fall back to default for other is: properties
    return backend._compile_is(node)

backend = SQLBackend(
    node_compilers={
        CubeFilter: my_cube_filter,
        IsFilter: my_fetchland_check,
    }
)
```

## Utility Functions

### Walking the AST

```python
from pyfall import parse, walk, find_all, TypeFilter, ColorFilter

ast = parse("t:creature c:green or t:artifact")

# Iterate all nodes
for node in walk(ast):
    print(type(node).__name__)

# Find specific node types
type_filters = find_all(ast, TypeFilter)
color_filters = find_all(ast, ColorFilter)
```

### Pretty Printing

```python
from pyfall import parse, pretty_print

ast = parse("t:creature c:green cmc<=3")
print(pretty_print(ast))
# Output:
# AndGroup(
#   TypeFilter(value='creature')
#   ColorFilter(colors=['G'], mode='at_least')
#   CMCFilter(op=LE, value=3.0)
# )
```

### Query Validation

```python
from pyfall import parse, validate

result = validate("t:creature c:green")
print(result.is_valid)        # True
print(result.filters_used)    # {TypeFilter, ColorFilter}
print(result.has_directives)  # False
print(result.stub_filters)    # set()

result = validate("cube:vintage")
print(result.stub_filters)    # {CubeFilter}
print(result.warnings)        # ['cube: requires external data']
```

### Extracting Display Directives

```python
from pyfall import parse, extract_directives

ast = parse("t:creature unique:art order:name")
filters, directives = extract_directives(ast)

# filters: AndGroup with just TypeFilter
# directives: {'unique': UniqueFilter(mode='art'), 'order': OrderFilter(field='name')}
```

## AST Node Types

### Logical Nodes
- `AndGroup` - Logical AND of children
- `OrGroup` - Logical OR of children
- `NotFilter` - Negation

### Text Filters
- `NameFilter` - Card name
- `TypeFilter` - Type line
- `OracleFilter` - Oracle text
- `FullOracleFilter` - Full oracle (with reminder)
- `FlavorFilter` - Flavor text
- `KeywordFilter` - Keywords

### Color Filters
- `ColorFilter` - Card colors
- `ColorIdentityFilter` - Color identity
- `ProducesFilter` - Mana production
- `DevotionFilter` - Devotion count

### Numeric Filters
- `CMCFilter` - Mana value
- `PowerFilter` - Power
- `ToughnessFilter` - Toughness
- `PowerToughnessFilter` - P+T combined
- `LoyaltyFilter` - Loyalty
- `DefenseFilter` - Defense
- `PriceFilter` - Price (usd/eur/tix)
- `CollectorNumberFilter` - Collector number
- `PrintsFilter` - Print count
- `DateFilter` - Release date
- `ArtistCountFilter` - Artist count
- `IllustrationsFilter` - Illustration count
- `YearFilter` - Release year

### Set/Rarity
- `SetFilter` - Set code
- `BlockFilter` - Block code
- `SetTypeFilter` - Set type
- `RarityFilter` - Rarity

### Format
- `FormatFilter` - Format legality

### Properties
- `IsFilter` - Card properties
- `HasFilter` - Has properties
- `ManaCostFilter` - Mana cost pattern
- `IncludeFilter` - Include extras

### Metadata
- `ArtistFilter` - Artist name
- `LanguageFilter` - Language
- `WatermarkFilter` - Watermark
- `FrameFilter` - Frame style
- `BorderFilter` - Border color
- `GameFilter` - Game availability
- `CubeFilter` - Cube membership
- `StampFilter` - Security stamp
- `InFilter` - Ever printed in
- `ArtTagFilter` - Art tags
- `OracleTagFilter` - Oracle tags
- `NewFilter` - New property

### Display/Sort (Directives)
- `UniqueFilter` - Result grouping
- `OrderFilter` - Sort order
- `PreferFilter` - Print preference
- `CheapestFilter` - Price preference
- `DisplayFilter` - Display mode

### Mana Symbols
- `ManaSymbol` - Base class
- `GenericMana` - Generic mana ({1}, {X})
- `ColoredMana` - Colored mana ({W}, {U})
- `HybridMana` - Hybrid mana ({W/U})
- `PhyrexianMana` - Phyrexian mana ({W/P})
- `ColorlessMana` - Colorless mana ({C})
- `SnowMana` - Snow mana ({S})

### Regex
- `RegexFilter` - Regex pattern match

## Known Semantic Differences from Scryfall

Some filters have different semantics than Scryfall:

| Filter | Scryfall | Pyfall Default |
|--------|----------|----------------|
| `in:rare` | Ever printed at rare (any printing) | Current printing is rare |
| `in:m` / `in:r` | Treats as set code (no match) | Treats as rarity abbreviation |
| `is:dual` | Exact list of 10 ABUR duals | Pattern match on type line |
| `is:fetchland` | Curated list | Pattern match on oracle text |
| `name:/pattern/` | True regex | Converted to LIKE (approximate) |
| `m:{Q}` | Error: "Unknown mana symbols" | Accepts as GenericMana |
| `c:xyz` | Error: "Unknown color" | Accepts with empty color list |
| `cmc:abc` | Error: "Value must be a number" | Falls back to 0.0 |

### `in:` Filter Disambiguation

Scryfall disambiguates `in:` values in this order:
1. **Full rarity names only**: `in:rare`, `in:mythic`, `in:uncommon` work; `in:r`, `in:m` do **not** expand to rarities
2. **Game platforms**: `in:paper`, `in:mtgo`, `in:arena`
3. **Language codes**: `in:ru`, `in:en`, `in:ja`, etc. (2-3 letter ISO codes)
4. **Set codes**: Any other value is treated as a set code

Pyfall currently treats single-letter rarity abbreviations (`m`, `r`, `c`, `u`) as rarities, which differs from Scryfall.

### Color Filter Defaults

| Filter | Default Mode |
|--------|--------------|
| `c:wu` | AT_LEAST (has at least W and U, may have more) |
| `id:wu` | AT_MOST (identity is subset of WU) |
| `c=wu` | EXACT (has exactly W and U) |
| `c<=wu` | AT_MOST (colors are subset of WU) |
| `c>=wu` | AT_LEAST (same as `c:wu`) |

### Silent Normalizations

Pyfall accepts some inputs that Scryfall rejects:
- **Invalid mana symbols**: `m:{Q}` becomes `GenericMana(value='Q')` instead of an error
- **Invalid colors**: `c:xyz` results in an empty color filter instead of an error
- **Non-numeric CMC**: `cmc:abc` falls back to 0.0 instead of an error

For stricter validation matching Scryfall's behavior, consider adding input validation before parsing.

### Recommended: Use strict_mode for Production

For production use, enable `strict_mode` to get errors on stub filters that require external data:

```python
from pyfall import parse, SQLBackend, UnsupportedFilterError

backend = SQLBackend(strict_mode=True)

try:
    sql, params = backend.compile(parse("cube:vintage"))
except UnsupportedFilterError as e:
    print(f"Filter not supported: {e}")
```

For exact Scryfall semantics on `in:` filters, you'll need a printings table with historical data. For `is:dual`/`is:fetchland`, use `node_compilers` to inject curated card lists.

## Format Legality Schema

The default `FormatFilter` compilation assumes a JSON column structure:

```json
{"commander": "legal", "modern": "banned", "vintage": "restricted"}
```

For different schemas (e.g., separate legalities table), override with `node_compilers`:

```python
def my_format_filter(node, backend):
    """Check format legality in normalized table."""
    return (
        "id IN (SELECT card_id FROM legalities WHERE format = ? AND status = ?)",
        [node.value, node.status]
    )

backend = SQLBackend(
    node_compilers={FormatFilter: my_format_filter}
)
```

## Development

```bash
# Clone the repo
git clone https://github.com/PhilMcClelland/pyfall.git
cd pyfall

# Install dev dependencies
pip install -e ".[dev]"

# Run tests
pytest

# Run with coverage
pytest --cov=pyfall

# Type checking
mypy src/pyfall

# Linting
ruff check src/pyfall
```

## License

MIT License - see [LICENSE](LICENSE) for details.

## Acknowledgments

- [Scryfall](https://scryfall.com/) for their excellent search syntax and API
- The Magic: The Gathering community