This commit is contained in:
2026-02-12 17:19:24 -08:00
parent ccadea0576
commit a92a7fdc7d
8 changed files with 1132 additions and 39 deletions

29
cli.py
View File

@@ -375,6 +375,35 @@ def search(
typer.echo("No cards matched that filter.") typer.echo("No cards matched that filter.")
raise typer.Exit(code=0) raise typer.Exit(code=0)
@app.command("import-db")
def import_db(
file: Path = typer.Argument(..., help="CSV or XLSX file to import"),
table: str = typer.Option(..., "--table", "-t", help="Target table to import into"),
sheet: Optional[str] = typer.Option(None, "--sheet", help="Excel sheet name (optional)"),
delimiter: str = typer.Option(",", "--delimiter", "-d", help="CSV delimiter (default ',')"),
db_path: Optional[Path] = typer.Option(None, "--db", help="Path to target SQLite DB"),
) -> None:
"""Import CSV or Excel data into the project's DB tables.
Examples:
tarot import-db angels.xlsx --table card_angels
tarot import-db angels.csv --table card_angels --delimiter ','
"""
try:
from tarot.db_import import import_file
except Exception as exc: # pragma: no cover - import errors bubble up in tests
typer.echo(f"Error loading import helper: {exc}")
raise typer.Exit(code=1)
try:
res = import_file(file, table, db_path=db_path, sheet=sheet, delimiter=delimiter)
except Exception as exc: # pragma: no cover - errors will be surfaced for visibility
typer.echo(f"Import failed: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Inserted: {res.inserted}; Skipped: {res.skipped}; Updated: {res.updated}")
def _doc(obj: Any, fallback: str) -> str: def _doc(obj: Any, fallback: str) -> str:
return inspect.getdoc(obj) or fallback return inspect.getdoc(obj) or fallback

View File

@@ -6,7 +6,7 @@ specialized handling for planets plus generic list/dict/object renderers.
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence, Tuple
from rich import box from rich import box
from rich.console import Console, Group from rich.console import Console, Group
@@ -15,6 +15,8 @@ from rich.table import Table
from tarot import Card from tarot import Card
from utils.attributes import Color, Planet from utils.attributes import Color, Planet
from utils.dates import format_month_day
from utils.object_formatting import format_value, get_object_attributes, is_nested_object
class Renderer: class Renderer:
@@ -69,27 +71,10 @@ class Renderer:
self.console.print(table) self.console.print(table)
def print_cards_table(self, cards: List[Card]) -> None: def print_cards_table(self, cards: List[Card]) -> None:
table = Table(title="Cards", show_lines=True) for idx, card in enumerate(cards):
table.add_column("#", justify="right") self._render_card(card)
table.add_column("Name") if idx < len(cards) - 1:
table.add_column("Arcana") self.console.print()
table.add_column("Suit")
table.add_column("Pip/Court")
for card in cards:
suit = getattr(getattr(card, "suit", None), "name", "")
pip = str(getattr(card, "pip", "")) if getattr(card, "pip", None) else ""
court = getattr(card, "court_rank", "")
pip_display = court or pip
table.add_row(
str(getattr(card, "number", "")),
getattr(card, "name", ""),
getattr(card, "arcana", ""),
suit,
pip_display,
)
self.console.print(table)
# Internal renderers ------------------------------------------------- # Internal renderers -------------------------------------------------
def _render_card(self, card: Card) -> None: def _render_card(self, card: Card) -> None:
@@ -99,7 +84,6 @@ class Renderer:
arcana = getattr(card, "arcana", "") arcana = getattr(card, "arcana", "")
suit_obj = getattr(card, "suit", None) suit_obj = getattr(card, "suit", None)
suit = getattr(suit_obj, "name", "") if suit_obj is not None else "" suit = getattr(suit_obj, "name", "") if suit_obj is not None else ""
pip = getattr(card, "pip", None)
number = getattr(card, "number", "") number = getattr(card, "number", "")
keywords = getattr(card, "keywords", None) or [] keywords = getattr(card, "keywords", None) or []
meaning = getattr(card, "meaning", None) meaning = getattr(card, "meaning", None)
@@ -112,18 +96,7 @@ class Renderer:
top_right = f"#{number}" if number not in (None, "") else "" top_right = f"#{number}" if number not in (None, "") else ""
top.add_row(top_left, top_center, top_right) top.add_row(top_left, top_center, top_right)
body_lines = [f"Arcana: {arcana or '-'}"] body_lines = self._card_field_lines(card)
if suit:
body_lines.append(f"Suit: {suit}")
if pip:
body_lines.append(f"Pip: {pip}")
if getattr(card, "court_rank", ""):
body_lines.append(f"Court Rank: {card.court_rank}")
if keywords:
body_lines.append(f"Keywords: {', '.join(keywords)}")
guidance = getattr(card, "guidance", None)
if guidance:
body_lines.append(f"Guidance: {guidance}")
body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1)) body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1))
bottom = Table( bottom = Table(
@@ -292,6 +265,68 @@ class Renderer:
safe_val = value if value not in (None, "") else "" safe_val = value if value not in (None, "") else ""
return f"{label}:\n{safe_val}" return f"{label}:\n{safe_val}"
def _card_field_lines(self, card: Card) -> List[str]:
exclude = {"name", "number", "meaning", "keywords"}
lines: List[str] = []
for attr_name, attr_value in get_object_attributes(card):
if attr_name in exclude:
continue
if attr_value in (None, "", [], {}, ()): # Skip empty values
continue
label = self._humanize_key(attr_name).title()
if is_nested_object(attr_value) and not hasattr(attr_value, "name"):
lines.append(f"{label}:")
nested = format_value(attr_value, indent=2)
lines.extend(nested.splitlines())
continue
lines.append(f"{label}: {self._format_card_value(attr_value)}")
if not lines:
lines.append("(no fields)")
return lines
def _format_card_value(self, value: Any) -> str:
if value is None:
return ""
if isinstance(value, (str, int, float, bool)):
return str(value)
if isinstance(value, tuple):
date_range = self._format_date_range(value)
if date_range is not None:
return date_range
return ", ".join(self._format_card_value(v) for v in value)
if isinstance(value, list):
return ", ".join(self._format_card_value(v) for v in value)
if isinstance(value, dict):
return ", ".join(
f"{self._humanize_key(str(k)).title()}: {self._format_card_value(v)}"
for k, v in value.items()
)
if hasattr(value, "name"):
return str(getattr(value, "name", value))
if is_nested_object(value):
return format_value(value, indent=2)
return str(value)
@staticmethod
def _format_date_range(value: Tuple[Any, ...]) -> Optional[str]:
if len(value) != 2:
return None
start, end = value
if not (
isinstance(start, tuple)
and isinstance(end, tuple)
and len(start) == 2
and len(end) == 2
):
return None
try:
return f"{format_month_day(start)} - {format_month_day(end)}"
except Exception:
return None
class PlanetRenderer: class PlanetRenderer:
"""Type-specific table rendering for planets.""" """Type-specific table rendering for planets."""

View File

@@ -21,7 +21,11 @@ Usage:
registry.load_into_card(card) registry.load_into_card(card)
""" """
from typing import TYPE_CHECKING, Any, Dict, Optional import json
import os
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from tarot.constants import build_position_map from tarot.constants import build_position_map
@@ -43,11 +47,457 @@ class CardDetailsRegistry:
- 65-78: Wands (same structure) - 65-78: Wands (same structure)
""" """
def __init__(self) -> None: _DETAIL_KEYS = {
"""Initialize the card details registry with interpretive data.""" "explanation",
self._details: Dict[str, Dict[str, Any]] = self._build_registry() "interpretation",
"keywords",
"reversed_keywords",
"guidance",
"date_range",
"degrees",
"day_angel",
"night_angel",
"day_demon",
"night_demon",
}
def __init__(self, *, use_db: bool = True, db_path: Optional[Path] = None) -> None:
"""Initialize the card details registry with interpretive data.
Args:
use_db: If True, load details from a local SQLite database (creating it
on first run). If False, use the in-code registry only.
db_path: Optional custom path for the SQLite file.
"""
# Map card positions (1-78) to registry keys # Map card positions (1-78) to registry keys
self._position_map = self._build_position_map() self._position_map = self._build_position_map()
self._use_db = use_db
self._db_path = self._resolve_db_path(db_path)
if self._use_db:
self._details = self._load_registry_from_db()
else:
self._details = self._build_registry()
@staticmethod
def _resolve_db_path(db_path: Optional[Path]) -> Path:
"""Resolve the database path with priority order.
Priority:
1. Explicit db_path argument
2. PY_TAROT_DB_PATH or TAROT_DB_PATH environment variable
3. Project root (where pyproject.toml is located)
4. User home directory ~/.py-tarot/
"""
if db_path is not None:
return Path(db_path)
env_path = os.getenv("PY_TAROT_DB_PATH") or os.getenv("TAROT_DB_PATH")
if env_path:
return Path(env_path)
# Try to find project root by looking for pyproject.toml
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists() or (parent / ".git").exists():
return parent / "tarot.db"
# Fallback to user home directory
return Path.home() / ".py-tarot" / "tarot.db"
def _load_registry_from_db(self) -> Dict[str, Dict[str, Any]]:
try:
self._ensure_db()
except (OSError, sqlite3.Error):
return self._build_registry()
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
# Check for new schema columns
conn.execute("SELECT explanation_summary FROM card_details LIMIT 1")
rows = conn.execute(
"SELECT registry_key, position, explanation_summary, explanation_waite, "
"interpretation, guidance, date_start_month, date_start_day, "
"date_end_month, date_end_day, degrees, extra_json FROM card_details"
).fetchall()
except sqlite3.OperationalError:
conn.close()
if self._rebuild_db():
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT registry_key, position, explanation_summary, explanation_waite, "
"interpretation, guidance, date_start_month, date_start_day, "
"date_end_month, date_end_day, degrees, extra_json FROM card_details"
).fetchall()
finally:
pass
else:
return self._build_registry()
# Load keywords
keywords_map: Dict[str, Dict[str, List[str]]] = {}
try:
kw_rows = conn.execute(
"SELECT card_key, keyword, is_reversed FROM card_keywords"
).fetchall()
for k_row in kw_rows:
ckey = k_row["card_key"]
kw = k_row["keyword"]
is_rev = bool(k_row["is_reversed"])
if ckey not in keywords_map:
keywords_map[ckey] = {"keywords": [], "reversed_keywords": []}
if is_rev:
keywords_map[ckey]["reversed_keywords"].append(kw)
else:
keywords_map[ckey]["keywords"].append(kw)
except sqlite3.OperationalError:
pass
# Load angels and demons
angels_map: Dict[str, Dict[str, str]] = {}
demons_map: Dict[str, Dict[str, str]] = {}
try:
angel_rows = conn.execute(
"SELECT card_key, name, is_night FROM card_angels"
).fetchall()
for a_row in angel_rows:
ckey = a_row["card_key"]
name = a_row["name"]
is_night = bool(a_row["is_night"])
if ckey not in angels_map:
angels_map[ckey] = {}
field = "night_angel" if is_night else "day_angel"
angels_map[ckey][field] = name
demon_rows = conn.execute(
"SELECT card_key, name, is_night FROM card_demons"
).fetchall()
for d_row in demon_rows:
ckey = d_row["card_key"]
name = d_row["name"]
is_night = bool(d_row["is_night"])
if ckey not in demons_map:
demons_map[ckey] = {}
field = "night_demon" if is_night else "day_demon"
demons_map[ckey][field] = name
except sqlite3.OperationalError:
pass
details: Dict[str, Dict[str, Any]] = {}
for row in rows:
entry: Dict[str, Any] = {}
ex = {}
if row["explanation_summary"]:
ex["summary"] = row["explanation_summary"]
if row["explanation_waite"]:
ex["waite"] = row["explanation_waite"]
if row["extra_json"]:
try:
extra = json.loads(row["extra_json"])
if "explanation" in extra and isinstance(extra["explanation"], dict):
ex.update(extra.pop("explanation"))
entry.update(self._restore_details(extra))
except (TypeError, ValueError):
pass
if ex:
entry["explanation"] = ex
if row["interpretation"]:
entry["interpretation"] = row["interpretation"]
if row["guidance"]:
entry["guidance"] = row["guidance"]
kws = keywords_map.get(row["registry_key"], {})
if kws.get("keywords"):
entry["keywords"] = kws["keywords"]
if kws.get("reversed_keywords"):
entry["reversed_keywords"] = kws["reversed_keywords"]
# Merge angels from table
angels = angels_map.get(row["registry_key"], {})
for field in ["day_angel", "night_angel"]:
if field in angels:
entry[field] = angels[field]
# Merge demons from table
demons = demons_map.get(row["registry_key"], {})
for field in ["day_demon", "night_demon"]:
if field in demons:
entry[field] = demons[field]
if (
row["date_start_month"]
and row["date_start_day"]
and row["date_end_month"]
and row["date_end_day"]
):
entry["date_range"] = (
(int(row["date_start_month"]), int(row["date_start_day"])),
(int(row["date_end_month"]), int(row["date_end_day"])),
)
if row["degrees"]:
entry["degrees"] = row["degrees"]
details[row["registry_key"]] = entry
conn.close()
return details
def _ensure_db(self) -> None:
self._db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self._db_path)
try:
# Main card details table
conn.execute(
"CREATE TABLE IF NOT EXISTS card_details ("
"registry_key TEXT PRIMARY KEY, "
"position INTEGER, "
"explanation_summary TEXT, "
"explanation_waite TEXT, "
"interpretation TEXT, "
"guidance TEXT, "
"date_start_month INTEGER, "
"date_start_day INTEGER, "
"date_end_month INTEGER, "
"date_end_day INTEGER, "
"degrees TEXT, "
"extra_json TEXT"
")"
)
# Keywords table (normalized)
conn.execute(
"CREATE TABLE IF NOT EXISTS card_keywords ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"card_key TEXT NOT NULL, "
"keyword TEXT NOT NULL, "
"is_reversed BOOLEAN DEFAULT 0, "
"FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE"
")"
)
# Angels table (normalized)
conn.execute(
"CREATE TABLE IF NOT EXISTS card_angels ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"card_key TEXT NOT NULL, "
"name TEXT NOT NULL, "
"is_night BOOLEAN DEFAULT 0, "
"FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE"
")"
)
# Demons table (normalized)
conn.execute(
"CREATE TABLE IF NOT EXISTS card_demons ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"card_key TEXT NOT NULL, "
"name TEXT NOT NULL, "
"is_night BOOLEAN DEFAULT 0, "
"FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE"
")"
)
# Indexes
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_card_keywords_card_key ON card_keywords(card_key)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_card_angels_card_key ON card_angels(card_key)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_card_demons_card_key ON card_demons(card_key)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_card_details_position ON card_details(position)"
)
row = conn.execute("SELECT COUNT(1) FROM card_details").fetchone()
count = row[0] if row else 0
if count == 0:
self._seed_db(conn)
finally:
conn.close()
def _rebuild_db(self) -> bool:
try:
if self._db_path.exists():
self._db_path.unlink()
self._ensure_db()
except (OSError, sqlite3.Error):
return False
return True
def _seed_db(self, conn: sqlite3.Connection) -> None:
registry = self._build_registry()
key_to_position = {key: pos for pos, key in self._position_map.items()}
details_rows = []
keyword_rows = []
angel_rows = []
demon_rows = []
for key, details in registry.items():
row_data, kws, angels, demons = self._prepare_rows(key, details)
row_data["position"] = key_to_position.get(key)
details_rows.append(
(
key,
row_data["position"],
row_data["explanation_summary"],
row_data["explanation_waite"],
row_data["interpretation"],
row_data["guidance"],
row_data["date_start_month"],
row_data["date_start_day"],
row_data["date_end_month"],
row_data["date_end_day"],
row_data["degrees"],
row_data["extra_json"],
)
)
for kw in kws:
keyword_rows.append((key, kw["keyword"], kw["is_reversed"]))
for angel in angels:
angel_rows.append((key, angel["name"], angel["is_night"]))
for demon in demons:
demon_rows.append((key, demon["name"], demon["is_night"]))
conn.executemany(
"INSERT OR REPLACE INTO card_details ("
"registry_key, position, explanation_summary, explanation_waite, "
"interpretation, guidance, date_start_month, date_start_day, "
"date_end_month, date_end_day, degrees, extra_json"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
details_rows,
)
conn.executemany(
"INSERT INTO card_keywords (card_key, keyword, is_reversed) VALUES (?, ?, ?)",
keyword_rows,
)
conn.executemany(
"INSERT INTO card_angels (card_key, name, is_night) VALUES (?, ?, ?)",
angel_rows,
)
conn.executemany(
"INSERT INTO card_demons (card_key, name, is_night) VALUES (?, ?, ?)",
demon_rows,
)
conn.commit()
def _prepare_rows(
self, key: str, details: Dict[str, Any]
) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
explanation = details.get("explanation") or {}
summary = explanation.get("summary") if isinstance(explanation, dict) else None
waite = explanation.get("waite") if isinstance(explanation, dict) else None
extra_explanation = {}
if isinstance(explanation, dict):
extra_explanation = {
k: v for k, v in explanation.items() if k not in ("summary", "waite")
}
keywords = details.get("keywords") or []
reversed_keywords = details.get("reversed_keywords") or []
date_start_month = None
date_start_day = None
date_end_month = None
date_end_day = None
date_range = details.get("date_range")
if isinstance(date_range, tuple) and len(date_range) == 2:
start, end = date_range
if (
isinstance(start, tuple)
and isinstance(end, tuple)
and len(start) == 2
and len(end) == 2
):
date_start_month, date_start_day = start
date_end_month, date_end_day = end
extras = {k: v for k, v in details.items() if k not in self._DETAIL_KEYS}
if extra_explanation:
extras["explanation"] = extra_explanation
row = {
"explanation_summary": summary,
"explanation_waite": waite,
"interpretation": details.get("interpretation") or None,
"guidance": details.get("guidance") or None,
"date_start_month": date_start_month,
"date_start_day": date_start_day,
"date_end_month": date_end_month,
"date_end_day": date_end_day,
"degrees": details.get("degrees") or None,
"extra_json": json.dumps(self._to_json_compatible(extras))
if extras
else None,
}
kws_list = []
for k in keywords:
kws_list.append({"keyword": k, "is_reversed": False})
for k in reversed_keywords:
kws_list.append({"keyword": k, "is_reversed": True})
angels_list = []
if details.get("day_angel"):
angels_list.append({"name": details["day_angel"], "is_night": False})
if details.get("night_angel"):
angels_list.append({"name": details["night_angel"], "is_night": True})
demons_list = []
if details.get("day_demon"):
demons_list.append({"name": details["day_demon"], "is_night": False})
if details.get("night_demon"):
demons_list.append({"name": details["night_demon"], "is_night": True})
return row, kws_list, angels_list, demons_list
def _details_to_row(self, details: Dict[str, Any]) -> Dict[str, Any]:
"""Deprecated internal method, kept incase of direct calls but _prepare_rows is used now."""
row, _ = self._prepare_rows("", details)
return row
def _restore_details(self, details: Dict[str, Any]) -> Dict[str, Any]:
date_range = details.get("date_range")
if isinstance(date_range, list) and len(date_range) == 2:
start, end = date_range
if (
isinstance(start, list)
and isinstance(end, list)
and len(start) == 2
and len(end) == 2
):
details["date_range"] = (tuple(start), tuple(end))
return details
@classmethod
def _to_json_compatible(cls, value: Any) -> Any:
if isinstance(value, dict):
return {k: cls._to_json_compatible(v) for k, v in value.items()}
if isinstance(value, tuple):
return [cls._to_json_compatible(v) for v in value]
if isinstance(value, list):
return [cls._to_json_compatible(v) for v in value]
return value
@staticmethod @staticmethod
def key_to_roman(key: int) -> str: def key_to_roman(key: int) -> str:
@@ -668,6 +1118,14 @@ class CardDetailsRegistry:
"keywords": [], "keywords": [],
"reversed_keywords": [], "reversed_keywords": [],
"guidance": "", "guidance": "",
# Date range: month/day (no year) — (start_month, start_day) to (end_month, end_day)
"date_range": ((6, 21), (7, 1)),
# Optional fields (can be populated later):
"degrees": 0-10,
"day_angel": "EIAEL",
"night_angel": "HABUIAH",
"day_demon": "Buer",
"night_demon": "Bifrons",
}, },
"Three of Cups": { "Three of Cups": {
"explanation": { "explanation": {
@@ -1152,8 +1610,74 @@ class CardDetailsRegistry:
card.reversed_keywords = details.get("reversed_keywords", []) card.reversed_keywords = details.get("reversed_keywords", [])
card.guidance = details.get("guidance", "") card.guidance = details.get("guidance", "")
# Optional temporal/astrological correspondences for minor cards
card.date_range = details.get("date_range")
card.degrees = details.get("degrees")
card.day_angel = details.get("day_angel")
card.night_angel = details.get("night_angel")
card.day_demon = details.get("day_demon")
card.night_demon = details.get("night_demon")
return True return True
def __getitem__(self, card_name: str) -> Optional[Dict[str, Any]]: def __getitem__(self, card_name: str) -> Optional[Dict[str, Any]]:
"""Allow dict-like access: registry['Princess of Swords']""" """Allow dict-like access: registry['Princess of Swords']"""
return self.get(card_name) return self.get(card_name)
def find_positions_by_month_day(self, month: int, day: int) -> list:
"""Return list of registry positions whose date_range includes the month/day.
Args:
month: Month (1-12)
day: Day (1-31)
Returns:
List of integer positions (1-78) matching the date
"""
from utils.dates import is_month_day_in_range
matches: list[int] = []
for pos, key in self._position_map.items():
details = self._details.get(key)
if not details:
continue
dr = details.get("date_range")
if not dr:
continue
start, end = dr
try:
if is_month_day_in_range(month, day, start, end):
matches.append(pos)
except ValueError:
# Invalid date in registry or input; ignore
continue
return matches
def find_cards_by_month_day(self, month: int, day: int, load_details: bool = True) -> list:
"""Return a list of Card objects whose date_range includes month/day.
If load_details is True, registry details will be loaded into returned Card objects.
"""
from tarot.deck import Deck
from .loader import load_card_details
deck = Deck()
positions = self.find_positions_by_month_day(month, day)
cards = []
for pos in positions:
# Positions are 1-indexed; deck.cards is list
try:
card = deck.cards[pos - 1]
except Exception:
continue
if load_details:
load_card_details(card, self)
cards.append(card)
return cards
def find_cards_by_date_str(self, date_str: str, load_details: bool = True) -> list:
"""Parse a date string and return matching Card objects."""
from utils.dates import parse_month_day
month, day = parse_month_day(date_str)
return self.find_cards_by_month_day(month, day, load_details)

297
src/tarot/db_import.py Normal file
View File

@@ -0,0 +1,297 @@
"""Helpers to import CSV/Excel data into the project's SQLite DB.
This module provides a small, dependency-light importer that supports CSV out of the
box and Excel (.xlsx) when ``openpyxl`` is installed. It intentionally keeps the
mapping simple (header-driven) and provides safe deduplication for normalized
keyword/angel/demon tables.
"""
from __future__ import annotations
import csv
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
try:
from openpyxl import load_workbook # type: ignore
except Exception: # pragma: no cover - optional dependency
load_workbook = None # type: ignore
from tarot.card.details import CardDetailsRegistry
# Simple helpers -----------------------------------------------------------
def _read_csv(path: Path, delimiter: str = ",") -> Iterable[Dict[str, str]]:
with path.open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh, delimiter=delimiter)
for row in reader:
yield {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
def _read_excel(path: Path, sheet: Optional[str] = None) -> Iterable[Dict[str, str]]:
if load_workbook is None:
raise RuntimeError("openpyxl is required to import Excel files - pip install openpyxl")
wb = load_workbook(path, read_only=True, data_only=True)
ws = wb[sheet] if sheet and sheet in wb.sheetnames else wb[wb.sheetnames[0]]
it = ws.iter_rows(values_only=True)
try:
headers = next(it)
except StopIteration:
return
headers = [h.strip() if isinstance(h, str) else str(h) for h in headers]
for row in it:
yield {str(h).strip(): (str(v).strip() if v is not None else "") for h, v in zip(headers, row)}
def _get_conn(db_path: Optional[Path]) -> sqlite3.Connection:
if db_path is None:
reg = CardDetailsRegistry(use_db=True)
db_path = reg._db_path # use the same resolution logic
else:
# Ensure DB is seeded if missing
CardDetailsRegistry(use_db=True, db_path=db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def _coerce_bool(val: Optional[str]) -> int:
if val is None:
return 0
if isinstance(val, bool):
return 1 if val else 0
s = str(val).strip().lower()
return 1 if s in {"1", "true", "yes", "y", "t"} else 0
def _parse_date_to_parts(val: Optional[str]) -> Optional[Tuple[int, int]]:
if not val:
return None
s = str(val).strip()
if not s:
return None
# Accept formats: M/D, MM/DD, YYYY-MM-DD
if "/" in s:
parts = s.split("/")
if len(parts) >= 2:
try:
m = int(parts[0])
d = int(parts[1])
return (m, d)
except Exception:
return None
if "-" in s:
parts = s.split("-")
try:
m = int(parts[1])
d = int(parts[2])
return (m, d)
except Exception:
return None
return None
# Table-specific importers -----------------------------------------------
@dataclass
class ImportResult:
inserted: int = 0
skipped: int = 0
updated: int = 0
def import_keywords(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult:
conn = _get_conn(db_path)
cur = conn.cursor()
res = ImportResult()
try:
for row in rows:
card_key = row.get("card_key") or row.get("registry_key") or row.get("card")
keyword = row.get("keyword") or row.get("kw")
is_rev = _coerce_bool(row.get("is_reversed") or row.get("reversed"))
if not card_key or not keyword:
res.skipped += 1
continue
# deduplicate
exists = cur.execute(
"SELECT 1 FROM card_keywords WHERE card_key=? AND keyword=? AND is_reversed=?",
(card_key, keyword, is_rev),
).fetchone()
if exists:
res.skipped += 1
continue
cur.execute(
"INSERT INTO card_keywords (card_key, keyword, is_reversed) VALUES (?, ?, ?)",
(card_key, keyword, is_rev),
)
res.inserted += 1
conn.commit()
finally:
conn.close()
return res
def import_angels(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult:
conn = _get_conn(db_path)
cur = conn.cursor()
res = ImportResult()
try:
for row in rows:
card_key = row.get("card_key") or row.get("registry_key") or row.get("card")
name = row.get("name") or row.get("angel")
is_night = _coerce_bool(row.get("is_night") or row.get("night") or row.get("isNight"))
if not card_key or not name:
res.skipped += 1
continue
exists = cur.execute(
"SELECT 1 FROM card_angels WHERE card_key=? AND name=? AND is_night=?",
(card_key, name, is_night),
).fetchone()
if exists:
res.skipped += 1
continue
cur.execute(
"INSERT INTO card_angels (card_key, name, is_night) VALUES (?, ?, ?)",
(card_key, name, is_night),
)
res.inserted += 1
conn.commit()
finally:
conn.close()
return res
def import_demons(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult:
conn = _get_conn(db_path)
cur = conn.cursor()
res = ImportResult()
try:
for row in rows:
card_key = row.get("card_key") or row.get("registry_key") or row.get("card")
name = row.get("name") or row.get("demon")
is_night = _coerce_bool(row.get("is_night") or row.get("night") or row.get("isNight"))
if not card_key or not name:
res.skipped += 1
continue
exists = cur.execute(
"SELECT 1 FROM card_demons WHERE card_key=? AND name=? AND is_night=?",
(card_key, name, is_night),
).fetchone()
if exists:
res.skipped += 1
continue
cur.execute(
"INSERT INTO card_demons (card_key, name, is_night) VALUES (?, ?, ?)",
(card_key, name, is_night),
)
res.inserted += 1
conn.commit()
finally:
conn.close()
return res
def import_card_details(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult:
conn = _get_conn(db_path)
cur = conn.cursor()
res = ImportResult()
allowed = {
"registry_key",
"explanation_summary",
"explanation_waite",
"interpretation",
"guidance",
"date_start",
"date_end",
"date_start_month",
"date_start_day",
"date_end_month",
"date_end_day",
"degrees",
"extra_json",
}
try:
for row in rows:
if not row:
res.skipped += 1
continue
reg_key = row.get("registry_key") or row.get("card_key") or row.get("card")
if not reg_key:
res.skipped += 1
continue
# Process date short forms
ds = _parse_date_to_parts(row.get("date_start"))
de = _parse_date_to_parts(row.get("date_end"))
if ds and de:
row["date_start_month"], row["date_start_day"] = ds
row["date_end_month"], row["date_end_day"] = de
# Build columns/values to upsert
cols = ["registry_key"]
vals = [reg_key]
up_cols = []
up_vals = []
for k, v in row.items():
if k == "registry_key":
continue
if k not in allowed:
continue
if v is None or v == "":
continue
cols.append(k)
vals.append(v)
up_cols.append(f"{k}=excluded.{k}")
placeholders = ",".join("?" for _ in vals)
sql = (
f"INSERT INTO card_details ({','.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(registry_key) DO UPDATE SET {', '.join(up_cols)}"
)
cur.execute(sql, tuple(vals))
# rough heuristic: treat as inserted if no row existed
if cur.rowcount > 0:
res.inserted += 1
else:
res.updated += 1
conn.commit()
finally:
conn.close()
return res
# Public helper -----------------------------------------------------------
def import_file(
path: Path,
table: str,
*,
db_path: Optional[Path] = None,
sheet: Optional[str] = None,
delimiter: str = ",",
) -> ImportResult:
"""Import a CSV or Excel file into one of the supported tables.
Supported tables: ``card_keywords``, ``card_angels``, ``card_demons``, ``card_details``.
The importer is header-driven and will look for reasonable alternate column
names (e.g., ``card_key`` / ``card`` / ``registry_key``).
"""
if not path.exists():
raise FileNotFoundError(path)
suffix = path.suffix.lower()
if suffix in {".xls", ".xlsx"}:
rows = _read_excel(path, sheet)
else:
rows = _read_csv(path, delimiter)
table = table.lower()
if table == "card_keywords":
return import_keywords(rows, db_path)
if table == "card_angels":
return import_angels(rows, db_path)
if table == "card_demons":
return import_demons(rows, db_path)
if table == "card_details":
return import_card_details(rows, db_path)
raise ValueError(f"Unsupported table: {table}")

View File

@@ -73,6 +73,17 @@ class Card:
# Image path for custom deck images # Image path for custom deck images
image_path: Optional[str] = None image_path: Optional[str] = None
# Optional astrological/temporal correspondences (used primarily for Minor Arcana)
# - date_range: ((start_month, start_day), (end_month, end_day))
# - degrees: free-form degree information (string)
# - day/night angels and demons
date_range: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None
degrees: Optional[str] = None
day_angel: Optional[str] = None
night_angel: Optional[str] = None
day_demon: Optional[str] = None
night_demon: Optional[str] = None
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.number}. {self.name}" return f"{self.number}. {self.name}"

139
src/utils/dates.py Normal file
View File

@@ -0,0 +1,139 @@
"""
Date utilities for month/day ranges (no year).
Provides parsing and range-checking functions for month/day pairs
(e.g., (6,21) meaning June 21).
Functions:
- parse_month_day: parse '6/21', '06-21', '2026-06-21', 'June 21', 'Jun 21'
- is_month_day_in_range: test whether a month/day lies within a start/end pair
- month_day_to_day_of_year: convert a month/day to day-of-year for comparisons
- format_month_day: display nicely
Note: Uses a fixed non-leap reference year for day-of-year calculations.
"""
import re
import calendar
from datetime import date
from typing import Tuple
# Reference non-leap year used for day-of-year calculations
_REFERENCE_YEAR = 2001
_MONTH_NAME_MAP = {
'jan': 1,
'january': 1,
'feb': 2,
'february': 2,
'mar': 3,
'march': 3,
'apr': 4,
'april': 4,
'may': 5,
'jun': 6,
'june': 6,
'jul': 7,
'july': 7,
'aug': 8,
'august': 8,
'sep': 9,
'sept': 9,
'september': 9,
'oct': 10,
'october': 10,
'nov': 11,
'november': 11,
'dec': 12,
'december': 12,
}
def month_day_to_day_of_year(month: int, day: int) -> int:
"""Convert (month, day) to day-of-year using a non-leap reference year."""
try:
dt = date(_REFERENCE_YEAR, month, day)
except ValueError as e:
raise ValueError(f"Invalid month/day: {month}/{day}: {e}")
return dt.timetuple().tm_yday
def is_month_day_in_range(
month: int, day: int, start: Tuple[int, int], end: Tuple[int, int]
) -> bool:
"""Return True if (month, day) is within the inclusive range start->end.
Handles ranges that cross year boundary (e.g., Dec 20 -> Jan 5).
"""
day_num = month_day_to_day_of_year(month, day)
start_num = month_day_to_day_of_year(*start)
end_num = month_day_to_day_of_year(*end)
if start_num <= end_num:
return start_num <= day_num <= end_num
# Wrap-around
return day_num >= start_num or day_num <= end_num
def parse_month_day(s: str) -> Tuple[int, int]:
"""Parse a date string and return (month, day).
Accepted formats:
- MM/DD or M/D
- MM-DD
- YYYY-MM-DD or YYYY/MM/DD (year ignored)
- MonthName D or Mon D (e.g., 'June 21' or 'Jun 21')
Raises ValueError if cannot parse or the date is invalid.
"""
if not s or not isinstance(s, str):
raise ValueError("Empty date string")
s = s.strip()
# Numeric forms: MM/DD or MM-DD
m = re.match(r"^(?P<m>\d{1,2})\s*[/-]\s*(?P<d>\d{1,2})$", s)
if m:
month = int(m.group("m"))
day = int(m.group("d"))
# validate
month_day_to_day_of_year(month, day)
return month, day
# ISO with year: YYYY-MM-DD or YYYY/MM/DD
m = re.match(r"^(?P<y>\d{4})[/-](?P<m>\d{1,2})[/-](?P<d>\d{1,2})$", s)
if m:
month = int(m.group("m"))
day = int(m.group("d"))
month_day_to_day_of_year(month, day)
return month, day
# Month name forms: 'June 21' or '21 June'
parts = re.split(r"[ ,]+", s)
if len(parts) == 2:
a, b = parts
# If a is month name
a_low = a.lower()
if a_low in _MONTH_NAME_MAP:
month = _MONTH_NAME_MAP[a_low]
day = int(b)
month_day_to_day_of_year(month, day)
return month, day
# If b is month name (e.g., '21 June')
b_low = b.lower()
if b_low in _MONTH_NAME_MAP:
month = _MONTH_NAME_MAP[b_low]
day = int(a)
month_day_to_day_of_year(month, day)
return month, day
raise ValueError(f"Unrecognized date format: '{s}'")
def format_month_day(month: int, day: int) -> str:
"""Return a short human-friendly representation like 'Jun 21'."""
if not (1 <= month <= 12):
raise ValueError("month must be 1..12")
# Use abbreviated month name
month_name = calendar.month_abbr[month]
return f"{month_name} {day}"

View File

@@ -85,6 +85,64 @@ def _matches_filter(obj: Any, key: str, value: Any) -> bool:
""" """
attr_value = getattr(obj, key, None) attr_value = getattr(obj, key, None)
# Special handling for date filters: accept strings like '6/21', 'Jun 21',
# ISO dates (YYYY-MM-DD), or tuples like (6, 21). The object's `date_range`
# (if present) will be checked; if not, fall back to registry lookup by
# numeric position when available (e.g., Card.number).
if key == "date":
from utils.dates import parse_month_day, is_month_day_in_range
# Normalize the provided value(s) into a list of checks
values_to_check = []
if isinstance(value, str) and "," in value:
values_to_check = [v.strip() for v in value.split(",")]
elif isinstance(value, (list, tuple)):
# Could be a single tuple (6,21) or list of such tuples/strings
if len(value) and isinstance(value[0], (list, tuple)):
values_to_check = list(value)
else:
values_to_check = [value]
else:
values_to_check = [value]
# Obtain the object's date_range, fallback to registry if absent
dr = attr_value
if dr is None:
obj_num = getattr(obj, "number", None)
if obj_num is not None:
try:
from tarot.card.details import CardDetailsRegistry
registry = CardDetailsRegistry()
details = registry.get_by_position(obj_num)
if details is not None:
dr = details.get("date_range")
except Exception:
dr = None
if dr is None:
return False
start, end = dr
for check_value in values_to_check:
try:
if isinstance(check_value, (list, tuple)):
month, day = int(check_value[0]), int(check_value[1])
elif isinstance(check_value, str):
month, day = parse_month_day(check_value)
else:
# Fallback: try to interpret as sequence
month, day = int(check_value[0]), int(check_value[1])
if is_month_day_in_range(month, day, start, end):
return True
except Exception:
# Ignore parse/validation errors per individual check
continue
return False
if attr_value is None: if attr_value is None:
return False return False

BIN
tarot.db Normal file

Binary file not shown.