"""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()