Files
tarot/typer-test.py

909 lines
30 KiB
Python
Raw Normal View History

2025-12-05 03:41:16 -08:00
"""Typer-based CLI for quick Tarot exploration with completions.
Run `python typer-test.py --help` for commands. Install shell completions with
`python typer-test.py --install-completion`.
"""
from __future__ import annotations
import ast
import inspect
import shlex
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import typer
from rich.console import Console
from rich.table import Table
from letter import letter as letter_ns
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import Completer, Completion
except ImportError: # pragma: no cover - optional at runtime
PromptSession = None # type: ignore
Completer = object # type: ignore
Completion = object # type: ignore
WordCompleter = None # type: ignore
# Ensure the src directory is on sys.path when running from repo root
ROOT = Path(__file__).resolve().parent
SRC_DIR = ROOT / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from cli_renderers import Renderer # noqa: E402
from tarot import Card, Tarot, Tree, Cube # noqa: E402
app = typer.Typer(add_completion=True, help="CLI playground for the Tarot API.")
tree_app = typer.Typer(help="Tree of Life commands. Examples: tree sephera 1; tree path 11.")
cube_app = typer.Typer(help="Cube of Space commands. Examples: cube wall North; cube direction North East.")
letter_app = typer.Typer(help="Letter/I Ching/Periodic commands. Examples: letter alphabet; letter cipher; letter char Aleph.")
console = Console()
renderer = Renderer(console)
_render_value = renderer.render_value
_print_kv_table = renderer.print_kv_table
_print_rows_table = renderer.print_rows_table
_print_cards_table = renderer.print_cards_table
EXPR_NO_MATCH = object()
SAFE_ROOTS: Dict[str, Any] = {
"tarot": Tarot,
"tree": Tree,
"cube": Cube,
"letter": letter_ns,
}
_MISSING_DOCS: Set[str] = set()
ROOT_COMMANDS = [
"tarot",
"search",
"planet",
"hexagram",
"tree",
"cube",
"letter",
"help",
"quit",
"exit",
]
def _base_keywords() -> List[str]:
return list(ROOT_COMMANDS)
SUITS = ("Cups", "Pentacles", "Swords", "Wands")
ARCANA = ("Major", "Minor")
def _ensure_deck() -> None:
"""Make sure the shared deck is initialized before use."""
Tarot.deck.card._ensure_initialized() # type: ignore[attr-defined]
def _complete_suit(ctx: typer.Context, incomplete: str) -> List[str]:
return [s for s in SUITS if s.lower().startswith(incomplete.lower())]
def _complete_arcana(ctx: typer.Context, incomplete: str) -> List[str]:
return [a for a in ARCANA if a.lower().startswith(incomplete.lower())]
def _complete_planet_name(ctx: typer.Context, incomplete: str) -> List[str]:
try:
planets = Tarot.planet() # type: ignore[assignment]
except Exception:
return []
names = list(planets.keys()) if isinstance(planets, dict) else []
return [n for n in names if n.lower().startswith(incomplete.lower())]
def _complete_hexagram(ctx: typer.Context, incomplete: str) -> List[str]:
try:
hexagrams = Tarot.hexagram() # type: ignore[assignment]
except Exception:
return []
nums = [str(k) for k in hexagrams.keys()] if isinstance(hexagrams, dict) else []
return [n for n in nums if n.startswith(incomplete)]
def _planet_names() -> List[str]:
try:
planets = Tarot.planet() # type: ignore[assignment]
except Exception:
return []
return list(planets.keys()) if isinstance(planets, dict) else []
def _hexagram_numbers() -> List[str]:
try:
hexagrams = Tarot.hexagram() # type: ignore[assignment]
except Exception:
return []
return [str(k) for k in hexagrams.keys()] if isinstance(hexagrams, dict) else []
def _sephera_numbers() -> List[str]:
try:
from tarot import Tree
seph = Tree.sephera() # type: ignore[assignment]
except Exception:
return []
return [str(k) for k in seph.keys()] if isinstance(seph, dict) else []
def _path_numbers() -> List[str]:
try:
from tarot import Tree
paths = Tree.path() # type: ignore[assignment]
except Exception:
return []
return [str(k) for k in paths.keys()] if isinstance(paths, dict) else []
def _wall_names() -> List[str]:
return ["North", "South", "East", "West", "Above", "Below"]
def _alphabet_names() -> List[str]:
try:
alph = letter_ns.alphabet.all()
except Exception:
return []
return list(alph.keys()) if isinstance(alph, dict) else []
def _cipher_names() -> List[str]:
try:
ciphers = letter_ns.cipher.all()
except Exception:
return []
return list(ciphers.keys()) if isinstance(ciphers, dict) else []
def _letter_names() -> List[str]:
try:
letters = letter_ns.letter.all()
except Exception:
return []
return list(letters.keys()) if isinstance(letters, dict) else []
def _periodic_symbols() -> List[str]:
try:
periodic = letter_ns.periodic.all()
except Exception:
return []
return list(periodic.keys()) if isinstance(periodic, dict) else []
def _current_fragment(text: str) -> str:
stripped = text.rstrip()
if not stripped:
return ""
last_token = stripped.split()[-1]
if "." in last_token:
return last_token.rsplit(".", 1)[-1]
return last_token
def _completion_candidates(text: str) -> List[str]:
stripped = text.rstrip()
if not stripped:
return _base_keywords()
try:
tokens = shlex.split(stripped)
except ValueError:
tokens = stripped.split()
last_token = tokens[-1] if tokens else ""
# Dotted path completion
if "." in last_token:
head, partial = last_token.rsplit(".", 1)
target = _safe_eval_expr(head)
if target is EXPR_NO_MATCH:
return []
partial_lower = partial.lower()
if isinstance(target, dict):
return [str(k) for k in target.keys() if str(k).lower().startswith(partial_lower)]
return [name for name in dir(target) if not name.startswith("_") and name.lower().startswith(partial_lower)]
fragment = last_token.lower() if last_token else ""
# No tokens yet: suggest root commands
if len(tokens) == 0:
return [kw for kw in _base_keywords() if kw.lower().startswith(fragment)]
cmd = tokens[0].lower()
# First token: suggest matching commands
if len(tokens) == 1:
return [kw for kw in _base_keywords() if kw.lower().startswith(fragment)]
# Contextual suggestions per command
if cmd == "planet":
return [p for p in _planet_names() if p.lower().startswith(fragment)]
if cmd == "hexagram":
return [n for n in _hexagram_numbers() if n.startswith(fragment)]
if cmd == "tree":
if len(tokens) == 2:
return [sub for sub in ["sephera", "path"] if sub.startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "sephera":
return [n for n in _sephera_numbers() if n.startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "path":
return [n for n in _path_numbers() if n.startswith(fragment)]
if cmd == "cube":
if len(tokens) == 2:
return [sub for sub in ["wall", "direction"] if sub.startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "wall":
return [w for w in _wall_names() if w.lower().startswith(fragment)]
if len(tokens) >= 3 and tokens[1].lower() == "direction":
# suggest wall name at position 3
if len(tokens) == 3:
return [w for w in _wall_names() if w.lower().startswith(fragment)]
# suggest direction names after wall
return [d for d in _direction_names_for_wall(tokens[2]) if d.lower().startswith(fragment)]
if cmd == "letter":
if len(tokens) == 2:
return [sub for sub in ["alphabet", "cipher", "char", "periodic"] if sub.startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "alphabet":
return [a for a in _alphabet_names() if a.lower().startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "cipher":
return [c for c in _cipher_names() if c.lower().startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() in {"char", "letter"}:
return [l for l in _letter_names() if l.lower().startswith(fragment)]
if len(tokens) == 3 and tokens[1].lower() == "periodic":
return [s for s in _periodic_symbols() if s.lower().startswith(fragment)]
if cmd == "search":
# Simple heuristics: suggest option flags or option values based on prior flag
if last_token.startswith("-"):
return [opt for opt in ["--suit", "--arcana", "--pip", "-s", "-a", "-p"] if opt.startswith(last_token)]
if any(tok in {"--suit", "-s"} for tok in tokens[:-1]):
return [s for s in SUITS if s.lower().startswith(fragment)]
if any(tok in {"--arcana", "-a"} for tok in tokens[:-1]):
return [a for a in ARCANA if a.lower().startswith(fragment)]
return []
# Default: no suggestions
return []
class DotPathCompleter(Completer):
def get_completions(self, document, complete_event): # type: ignore[override]
text = document.text_before_cursor
fragment = _current_fragment(text)
for cand in _completion_candidates(text):
yield Completion(cand, start_position=-len(fragment))
def _safe_eval_expr(expr: str) -> Any:
expr = expr.strip()
if not expr:
return EXPR_NO_MATCH
try:
parsed = ast.parse(expr, mode="eval")
except SyntaxError:
return EXPR_NO_MATCH
def _eval_node(node: ast.AST) -> Any:
if isinstance(node, ast.Expression):
return _eval_node(node.body)
if isinstance(node, ast.Name):
if node.id in SAFE_ROOTS:
return SAFE_ROOTS[node.id]
raise ValueError("Name not allowed")
if isinstance(node, ast.Attribute):
value = _eval_node(node.value)
return getattr(value, node.attr)
if isinstance(node, ast.Call):
func = _eval_node(node.func)
args = [_eval_node(arg) for arg in node.args]
kwargs = {kw.arg: _eval_node(kw.value) for kw in node.keywords if kw.arg is not None}
return func(*args, **kwargs)
if isinstance(node, ast.Constant):
return node.value
raise ValueError("Expression not allowed")
try:
return _eval_node(parsed)
except Exception:
return EXPR_NO_MATCH
def _search_cards(suit: Optional[str], arcana: Optional[str], pip: Optional[int]) -> List[Card]:
_ensure_deck()
deck = Tarot.deck.card._deck # type: ignore[attr-defined]
if deck is None:
return []
cards: List[Card] = deck.cards
if suit is not None:
cards = [
card
for card in cards
if getattr(getattr(card, "suit", None), "name", "").lower() == suit.lower()
]
if arcana is not None:
cards = [card for card in cards if getattr(card, "arcana", "").lower() == arcana.lower()]
if pip is not None:
cards = [card for card in cards if getattr(card, "pip", None) == pip]
return cards
@app.command()
def search(
suit: Optional[str] = typer.Option(
None, "--suit", "-s", help="Filter by suit", autocompletion=_complete_suit
),
arcana: Optional[str] = typer.Option(
None, "--arcana", "-a", help="Filter by arcana", autocompletion=_complete_arcana
),
pip: Optional[int] = typer.Option(
None, "--pip", "-p", help="Filter by pip (1 for Ace, 2-10 for pips)"
),
) -> None:
"""Filter cards with autocompleting suit/arcana options."""
cards = _search_cards(suit, arcana, pip)
if not cards:
typer.echo("No cards matched that filter.")
raise typer.Exit(code=0)
def _doc(obj: Any, fallback: str) -> str:
return inspect.getdoc(obj) or fallback
def _sync_doc(cmd_fn: Any, source_obj: Any) -> None:
if cmd_fn is None or source_obj is None:
return
doc = _doc(source_obj, cmd_fn.__doc__ or "")
if doc:
cmd_fn.__doc__ = doc
else:
_MISSING_DOCS.add(cmd_fn.__name__)
def _report_missing_docs() -> None:
if not _MISSING_DOCS:
return
console.print(
f"[yellow]Docstring missing for: {', '.join(sorted(_MISSING_DOCS))}."
" Update source API docstrings to sync help.[/yellow]"
)
def _print_structure_table() -> None:
rows = [
("tarot.deck.card(n)", "Access cards by deck position", "tarot.deck.card(1)"),
(
"tarot.deck.card.filter(...)",
"Filter cards using attributes",
"tarot.deck.card.filter(arcana='Major')",
),
(
"tarot.deck.card.spread(name)",
"Draw a spread with random cards",
"tarot.deck.card.spread('Celtic Cross')",
),
("tarot.planet()", "Planet correspondences as dict", "tarot.planet()['Venus']"),
("tarot.hexagram()", "I Ching hexagrams as dict", "tarot.hexagram()[1]"),
("tree.sephera(n)", "Kabbalistic sephiroth", "tree.sephera(1)"),
("tree.path(n)", "Paths on the Tree of Life", "tree.path(11)"),
("cube.wall(name)", "Cube of Space walls", "cube.wall('North')"),
("cube.direction(wall, dir)", "Directions on a wall", "cube.direction('North', 'East')"),
("letter.alphabet.all()", "Alphabet metadata", "letter.alphabet.all()['english']"),
("letter.cipher.all()", "Cipher definitions", "letter.cipher.all()['english_simple']"),
("letter.letter.all()", "Letters and properties", "letter.letter.all()['Aleph']"),
("letter.periodic.all()", "Periodic correspondences", "letter.periodic.all()['H']"),
("letter.iching.all()", "I Ching via letter namespace", "letter.iching.all()[1]"),
]
table = Table(title="Tarot API Map", show_lines=True)
table.add_column("Expression", style="cyan")
table.add_column("Description")
table.add_column("Example")
for expr, desc, example in rows:
table.add_row(expr, desc, example)
console.print(table)
console.print(
"REPL: type expressions exactly as above; tab completion works per segment. "
"CLI shortcuts that remain: search, planet, hexagram, tree, cube, letter."
)
def _show_sephera(number: Optional[int]) -> None:
getattr(Tree, "_ensure_initialized", lambda: None)()
data = Tree.sephera(number)
if data is None:
typer.echo("Sephera not found.")
return
if isinstance(data, dict):
rows: List[List[Any]] = []
for num, obj in sorted(data.items()):
rows.append([
num,
getattr(obj, "name", ""),
getattr(obj, "hebrew_name", ""),
getattr(obj, "planet", ""),
getattr(obj, "element", ""),
])
_print_rows_table("Sephiroth", ["#", "Name", "Hebrew", "Planet", "Element"], rows)
else:
attrs = data.__dict__ if hasattr(data, "__dict__") else {"value": data}
_print_kv_table(f"Sephera: {getattr(data, 'name', number)}", attrs)
def _show_path(number: Optional[int]) -> None:
getattr(Tree, "_ensure_initialized", lambda: None)()
data = Tree.path(number)
if data is None:
typer.echo("Path not found.")
return
if isinstance(data, dict):
rows: List[List[Any]] = []
for num, obj in sorted(data.items()):
rows.append([
num,
getattr(obj, "name", ""),
getattr(obj, "hebrew_letter", getattr(obj, "hebrew", "")),
getattr(obj, "planet", getattr(obj, "element", "")),
])
_print_rows_table("Paths", ["#", "Name", "Hebrew", "Assoc"], rows)
else:
attrs = data.__dict__ if hasattr(data, "__dict__") else {"value": data}
_print_kv_table(f"Path: {number}", attrs)
def _show_walls(name: Optional[str]) -> None:
cube_walls = Cube.wall.all() if Cube.wall else [] # type: ignore[attr-defined]
if name is None:
rows = []
for wall in cube_walls:
rows.append([
getattr(wall, "name", ""),
getattr(wall, "element", ""),
getattr(wall, "planet", ""),
getattr(wall, "opposite", ""),
])
_print_rows_table("Cube Walls", ["Wall", "Element", "Planet", "Opposite"], rows)
return
wall_obj = None
for wall in cube_walls:
if getattr(wall, "name", "").lower() == name.lower():
wall_obj = wall
break
if wall_obj is None:
typer.echo(f"Wall '{name}' not found.")
return
attrs = wall_obj.__dict__ if hasattr(wall_obj, "__dict__") else {"value": wall_obj}
_print_kv_table(f"Wall: {getattr(wall_obj, 'name', name)}", attrs)
def _direction_names_for_wall(wall_name: str) -> List[str]:
cube_walls = Cube.wall.all() if Cube.wall else [] # type: ignore[attr-defined]
for wall in cube_walls:
if getattr(wall, "name", "").lower() == wall_name.lower():
return list(getattr(wall, "directions", {}).keys()) if hasattr(wall, "directions") else []
return []
def _show_direction(wall_name: str, direction: Optional[str]) -> None:
cube_walls = Cube.wall.all() if Cube.wall else [] # type: ignore[attr-defined]
wall_obj = None
for wall in cube_walls:
if getattr(wall, "name", "").lower() == wall_name.lower():
wall_obj = wall
break
if wall_obj is None:
typer.echo(f"Wall '{wall_name}' not found.")
return
if direction is None:
dirs = getattr(wall_obj, "directions", {}) if hasattr(wall_obj, "directions") else {}
rows = []
for name, dir_obj in dirs.items():
rows.append([
name,
getattr(dir_obj, "element", ""),
getattr(dir_obj, "planet", ""),
])
_print_rows_table(
f"Directions for {getattr(wall_obj, 'name', wall_name)}",
["Direction", "Element", "Planet"],
rows,
)
return
dirs = getattr(wall_obj, "directions", {}) if hasattr(wall_obj, "directions") else {}
dir_obj = dirs.get(direction.capitalize()) if isinstance(dirs, dict) else None
if dir_obj is None:
typer.echo(f"Direction '{direction}' not found in wall '{wall_name}'.")
return
attrs = dir_obj.__dict__ if hasattr(dir_obj, "__dict__") else {"value": dir_obj}
_print_kv_table(f"Direction: {direction} ({wall_name})", attrs)
def _show_alphabets(name: Optional[str]) -> None:
alph = letter_ns.alphabet.all()
if not isinstance(alph, dict):
typer.echo("Alphabet data unavailable.")
return
if name is None:
rows = [[k, v.get("name", "") if isinstance(v, dict) else ""] for k, v in alph.items()]
_print_rows_table("Alphabets", ["Key", "Name"], rows)
return
obj = alph.get(name)
if obj is None:
typer.echo(f"Alphabet '{name}' not found.")
return
attrs = obj if isinstance(obj, dict) else {"value": obj}
_print_kv_table(f"Alphabet: {name}", attrs)
def _show_ciphers(name: Optional[str]) -> None:
ciphers = letter_ns.cipher.all()
if not isinstance(ciphers, dict):
typer.echo("Cipher data unavailable.")
return
if name is None:
rows = [[k, ""] for k in ciphers.keys()]
_print_rows_table("Ciphers", ["Key", ""], rows)
return
obj = ciphers.get(name)
if obj is None:
typer.echo(f"Cipher '{name}' not found.")
return
attrs = obj if isinstance(obj, dict) else {"value": obj}
_print_kv_table(f"Cipher: {name}", attrs)
def _show_letter_char(name: Optional[str]) -> None:
letters = letter_ns.letter.all()
if not isinstance(letters, dict):
typer.echo("Letter data unavailable.")
return
if name is None:
rows = [[k, getattr(v, "hebrew_name", getattr(v, "name", ""))] for k, v in letters.items()]
_print_rows_table("Letters", ["Key", "Name"], rows)
return
obj = letters.get(name)
if obj is None:
typer.echo(f"Letter '{name}' not found.")
return
attrs = obj.__dict__ if hasattr(obj, "__dict__") else {"value": obj}
_print_kv_table(f"Letter: {name}", attrs)
def _show_periodic(symbol: Optional[str]) -> None:
periodic = letter_ns.periodic.all()
if not isinstance(periodic, dict):
typer.echo("Periodic table data unavailable.")
return
if symbol is None:
rows = [[k, getattr(v, "name", getattr(v, "element", ""))] for k, v in periodic.items()]
_print_rows_table("Periodic Table", ["Symbol", "Name"], rows)
return
obj = periodic.get(symbol)
if obj is None:
typer.echo(f"Symbol '{symbol}' not found.")
return
attrs = obj.__dict__ if hasattr(obj, "__dict__") else {"value": obj}
_print_kv_table(f"Element: {symbol}", attrs)
@app.command()
def planet(
name: Optional[str] = typer.Argument(
None, help="Planet name (omit to list all)", autocompletion=_complete_planet_name
)
) -> None:
"""Show a planet or list all planet names."""
Tarot._ensure_initialized() # type: ignore[attr-defined]
planets = Tarot.planet() # type: ignore[assignment]
if not isinstance(planets, dict):
typer.echo("Planet data unavailable.")
raise typer.Exit(code=1)
if name is None:
table = Table(title="Planets", show_header=True)
table.add_column("Name")
for planet_name in sorted(planets.keys()):
table.add_row(planet_name)
console.print(table)
return
planet_obj = planets.get(name)
if planet_obj is None:
typer.echo(f"Planet '{name}' not found.")
raise typer.Exit(code=1)
_render_value(planet_obj)
@app.command()
def hexagram(
number: Optional[int] = typer.Argument(
None, help="Hexagram number (1-64). Omit to list all.", autocompletion=_complete_hexagram
)
) -> None:
"""Show an I Ching hexagram or list all numbers."""
Tarot._ensure_initialized() # type: ignore[attr-defined]
hexagrams = Tarot.hexagram() # type: ignore[assignment]
if not isinstance(hexagrams, dict):
typer.echo("Hexagram data unavailable.")
raise typer.Exit(code=1)
if number is None:
table = Table(title="Hexagrams", show_header=True)
table.add_column("Number")
for num in sorted(hexagrams.keys()):
table.add_row(str(num))
console.print(table)
return
hex_obj = hexagrams.get(number)
if hex_obj is None:
typer.echo(f"Hexagram '{number}' not found.")
raise typer.Exit(code=1)
attrs = hex_obj.__dict__ if hasattr(hex_obj, "__dict__") else {"value": hex_obj}
_print_kv_table(f"Hexagram: {number}", attrs)
# Keep CLI command docs in sync with API docstrings
_sync_doc(planet, getattr(Tarot, "planet", None))
_sync_doc(hexagram, getattr(Tarot, "hexagram", None))
app.add_typer(tree_app, name="tree")
app.add_typer(cube_app, name="cube")
app.add_typer(letter_app, name="letter")
@tree_app.command("sephera")
def tree_sephera(number: Optional[int] = typer.Argument(None, autocompletion=_sephera_numbers)) -> None:
"""List all sephiroth or show a specific one."""
_show_sephera(number)
@tree_app.command("path")
def tree_path(number: Optional[int] = typer.Argument(None, autocompletion=_path_numbers)) -> None:
"""List all paths or show a specific one."""
_show_path(number)
@cube_app.command("wall")
def cube_wall(name: Optional[str] = typer.Argument(None, autocompletion=_wall_names)) -> None:
"""List walls or show details for a wall."""
_show_walls(name)
@cube_app.command("direction")
def cube_direction(
wall_name: str = typer.Argument(..., autocompletion=_wall_names),
direction: Optional[str] = typer.Argument(None),
) -> None:
"""List directions for a wall or show one."""
_show_direction(wall_name, direction)
@letter_app.command("alphabet")
def letter_alphabet(name: Optional[str] = typer.Argument(None, autocompletion=_alphabet_names)) -> None:
"""List alphabets or show one."""
_show_alphabets(name)
@letter_app.command("cipher")
def letter_cipher(name: Optional[str] = typer.Argument(None, autocompletion=_cipher_names)) -> None:
"""List ciphers or show one."""
_show_ciphers(name)
@letter_app.command("char")
def letter_char(name: Optional[str] = typer.Argument(None, autocompletion=_letter_names)) -> None:
"""List letters or show one."""
_show_letter_char(name)
@letter_app.command("periodic")
def letter_periodic(symbol: Optional[str] = typer.Argument(None, autocompletion=_periodic_symbols)) -> None:
"""List periodic elements or show one."""
_show_periodic(symbol)
# Sync all command docstrings from source APIs (single source of truth)
_sync_doc(planet, getattr(Tarot, "planet", None))
_sync_doc(hexagram, getattr(Tarot, "hexagram", None))
_sync_doc(tree_sephera, getattr(Tree, "sephera", None))
_sync_doc(tree_path, getattr(Tree, "path", None))
_sync_doc(cube_wall, getattr(Cube, "wall", None))
_sync_doc(cube_direction, getattr(Cube, "direction", None))
_sync_doc(letter_alphabet, getattr(letter_ns, "alphabet", None))
_sync_doc(letter_cipher, getattr(letter_ns, "cipher", None))
_sync_doc(letter_char, getattr(letter_ns, "letter", None))
_sync_doc(letter_periodic, getattr(letter_ns, "periodic", None))
_report_missing_docs()
def _handle_repl_command(parts: List[str], raw_line: str = "") -> bool:
if not parts:
return True
cmd = parts[0].lower()
if cmd in {"quit", "exit"}:
return False
if cmd == "help":
_print_structure_table()
return True
if cmd == "search":
suit = None
arcana = None
pip = None
tokens = parts[1:]
i = 0
while i < len(tokens):
if tokens[i] in {"--suit", "-s"} and i + 1 < len(tokens):
suit = tokens[i + 1]
i += 2
elif tokens[i] in {"--arcana", "-a"} and i + 1 < len(tokens):
arcana = tokens[i + 1]
i += 2
elif tokens[i] in {"--pip", "-p"} and i + 1 < len(tokens):
try:
pip = int(tokens[i + 1])
except ValueError:
console.print("Invalid pip value.")
return True
i += 2
else:
i += 1
cards = _search_cards(suit, arcana, pip)
if not cards:
console.print("No cards matched that filter.")
else:
_print_cards_table(cards)
return True
if cmd == "planet":
planet_name = parts[1] if len(parts) > 1 else None
planets = Tarot.planet() # type: ignore[assignment]
if not isinstance(planets, dict):
console.print("Planet data unavailable.")
return True
if planet_name is None:
console.print(", ".join(sorted(planets.keys())))
return True
planet_obj = planets.get(planet_name)
if planet_obj is None:
console.print(f"Planet '{planet_name}' not found.")
return True
_render_value(planet_obj)
return True
if cmd == "hexagram":
hex_num = None
if len(parts) > 1 and parts[1].isdigit():
hex_num = int(parts[1])
hexagrams = Tarot.hexagram() # type: ignore[assignment]
if not isinstance(hexagrams, dict):
console.print("Hexagram data unavailable.")
return True
if hex_num is None:
console.print(", ".join(str(n) for n in sorted(hexagrams.keys())))
return True
hex_obj = hexagrams.get(hex_num)
if hex_obj is None:
console.print(f"Hexagram '{hex_num}' not found.")
return True
attrs = hex_obj.__dict__ if hasattr(hex_obj, "__dict__") else {"value": hex_obj}
_print_kv_table(f"Hexagram: {hex_num}", attrs)
return True
if cmd == "tree" and len(parts) > 1:
sub = parts[1].lower()
arg = parts[2] if len(parts) > 2 else None
if sub == "sephera":
_show_sephera(int(arg)) if arg and arg.isdigit() else _show_sephera(None)
return True
if sub == "path":
_show_path(int(arg)) if arg and arg.isdigit() else _show_path(None)
return True
if cmd == "cube" and len(parts) > 1:
sub = parts[1].lower()
arg = parts[2] if len(parts) > 2 else None
if sub == "wall":
_show_walls(arg)
return True
if sub == "direction" and len(parts) >= 3:
direction = parts[3] if len(parts) > 3 else None
_show_direction(parts[2], direction)
return True
if cmd == "letter" and len(parts) > 1:
sub = parts[1].lower()
arg = parts[2] if len(parts) > 2 else None
if sub == "alphabet":
_show_alphabets(arg)
return True
if sub == "cipher":
_show_ciphers(arg)
return True
if sub in {"char", "letter"}:
_show_letter_char(arg)
return True
if sub == "periodic":
_show_periodic(arg)
return True
expr_result = _safe_eval_expr(raw_line or " ".join(parts))
if expr_result is not EXPR_NO_MATCH:
_render_value(expr_result)
return True
console.print("Unknown command. Type 'help' for the API map or use Python-style expressions.")
return True
@app.command()
def repl() -> None:
"""Interactive loop for quick Tarot exploration."""
console.print("Type 'help' for the API map, use Python-style expressions, 'quit' to exit.")
# Build prompt-toolkit completer if available (supports dotted paths)
session = None
completer = None
if PromptSession:
completer = DotPathCompleter()
session = PromptSession(completer=completer)
while True:
try:
if session:
line = session.prompt("tarot> ")
else:
line = input("tarot> ")
line = line.strip()
except (EOFError, KeyboardInterrupt):
console.print("Bye.")
break
if not line:
continue
try:
parts = shlex.split(line)
except ValueError as exc:
console.print(f"Parse error: {exc}")
continue
if not _handle_repl_command(parts, raw_line=line):
break
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"}:
_print_structure_table()
sys.exit(0)
# If invoked without a subcommand, drop into the REPL for convenience.
if len(sys.argv) == 1:
repl()
else:
app()