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
|
2025-12-07 20:52:47 -08:00
|
|
|
import tarot.ui as ui_mod # noqa: E402
|
|
|
|
|
from tarot.ui import SpreadDisplay, display_cards as ui_display_cards # noqa: E402
|
2025-12-05 03:41:16 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2025-12-07 20:52:47 -08:00
|
|
|
"ui": ui_mod,
|
2025-12-05 03:41:16 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_MISSING_DOCS: Set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ROOT_COMMANDS = [
|
|
|
|
|
"tarot",
|
|
|
|
|
"search",
|
|
|
|
|
"planet",
|
|
|
|
|
"hexagram",
|
|
|
|
|
"tree",
|
|
|
|
|
"cube",
|
|
|
|
|
"letter",
|
2025-12-07 20:52:47 -08:00
|
|
|
"display-cards",
|
|
|
|
|
"display-spread",
|
2025-12-05 03:41:16 -08:00
|
|
|
"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 []
|
|
|
|
|
|
|
|
|
|
|
2025-12-07 20:52:47 -08:00
|
|
|
def _spread_names() -> List[str]:
|
|
|
|
|
return list(SpreadDisplay.LAYOUTS.keys())
|
|
|
|
|
|
|
|
|
|
|
2025-12-05 03:41:16 -08:00
|
|
|
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)]
|
|
|
|
|
|
2025-12-07 20:52:47 -08:00
|
|
|
if cmd == "display-spread":
|
|
|
|
|
return [name for name in _spread_names() if name.lower().startswith(fragment)]
|
|
|
|
|
|
2025-12-05 03:41:16 -08:00
|
|
|
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')",
|
|
|
|
|
),
|
2025-12-07 20:52:47 -08:00
|
|
|
("ui.display_cards([card1, card2])", "Open GUI to view cards", "ui.display_cards([tarot.deck.card(1)])"),
|
|
|
|
|
("display-spread 'Celtic Cross'", "CLI: draw + show spread", "python typer-test.py display-spread 'Celtic Cross'"),
|
|
|
|
|
("display-cards 1 2 3", "CLI: show specific cards", "python typer-test.py display-cards 1 2 3"),
|
2025-12-05 03:41:16 -08:00
|
|
|
("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)
|
2025-12-07 20:52:47 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.command("display-cards")
|
|
|
|
|
def display_cards_cmd(
|
|
|
|
|
numbers: List[int] = typer.Argument(..., help="Card numbers to display"),
|
|
|
|
|
deck_name: str = typer.Option("default", help="Deck name for UI display"),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Open a GUI window showing the given card numbers."""
|
|
|
|
|
|
|
|
|
|
_ensure_deck()
|
|
|
|
|
cards: List[Card] = []
|
|
|
|
|
for num in numbers:
|
|
|
|
|
card = Tarot.deck.card(num) # type: ignore[attr-defined]
|
|
|
|
|
if card is None:
|
|
|
|
|
typer.echo(f"Card {num} not found.")
|
|
|
|
|
raise typer.Exit(code=1)
|
|
|
|
|
cards.append(card)
|
|
|
|
|
ui_display_cards(cards, deck_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.command("display-spread")
|
|
|
|
|
def display_spread_cmd(
|
|
|
|
|
spread_name: str = typer.Argument("Celtic Cross", help="Spread name (e.g., 'Celtic Cross')"),
|
|
|
|
|
deck_name: str = typer.Option("default", help="Deck name for UI display"),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Draw a spread and open the GUI display for it."""
|
|
|
|
|
|
|
|
|
|
_ensure_deck()
|
|
|
|
|
reading = Tarot.deck.card.spread(spread_name) # type: ignore[attr-defined]
|
|
|
|
|
if reading is None:
|
|
|
|
|
typer.echo(f"Spread '{spread_name}' not found.")
|
|
|
|
|
raise typer.Exit(code=1)
|
|
|
|
|
ui_mod.display_spread(reading, deck_name)
|
2025-12-05 03:41:16 -08:00
|
|
|
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
|
2025-12-07 20:52:47 -08:00
|
|
|
if cmd == "display-cards":
|
|
|
|
|
if len(parts) < 2:
|
|
|
|
|
console.print("Usage: display-cards <card numbers...>")
|
|
|
|
|
console.print("Examples: display-cards 1 2 3")
|
|
|
|
|
console.print(" display-cards 2,11,20")
|
|
|
|
|
return True
|
|
|
|
|
_ensure_deck()
|
|
|
|
|
nums: List[int] = []
|
|
|
|
|
|
|
|
|
|
# Handle both space-separated and comma-separated numbers
|
|
|
|
|
tokens = []
|
|
|
|
|
for tok in parts[1:]:
|
|
|
|
|
# Split by comma if present
|
|
|
|
|
if "," in tok:
|
|
|
|
|
tokens.extend(tok.split(","))
|
|
|
|
|
else:
|
|
|
|
|
tokens.append(tok)
|
|
|
|
|
|
|
|
|
|
for tok in tokens:
|
|
|
|
|
tok = tok.strip()
|
|
|
|
|
if not tok:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
nums.append(int(tok))
|
|
|
|
|
except ValueError:
|
|
|
|
|
console.print(f"Invalid card number: {tok}")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
cards: List[Card] = []
|
|
|
|
|
for num in nums:
|
|
|
|
|
card = Tarot.deck.card(num) # type: ignore[attr-defined]
|
|
|
|
|
if card is None:
|
|
|
|
|
console.print(f"Card {num} not found.")
|
|
|
|
|
return True
|
|
|
|
|
cards.append(card)
|
|
|
|
|
ui_display_cards(cards)
|
|
|
|
|
return True
|
|
|
|
|
if cmd == "display-spread":
|
|
|
|
|
spread_name = " ".join(parts[1:]) if len(parts) > 1 else "Celtic Cross"
|
|
|
|
|
_ensure_deck()
|
|
|
|
|
reading = Tarot.deck.card.spread(spread_name) # type: ignore[attr-defined]
|
|
|
|
|
if reading is None:
|
|
|
|
|
console.print(f"Spread '{spread_name}' not found.")
|
|
|
|
|
return True
|
|
|
|
|
ui_mod.display_spread(reading)
|
|
|
|
|
return True
|
2025-12-05 03:41:16 -08:00
|
|
|
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()
|