This commit is contained in:
2026-02-11 00:02:35 -08:00
parent be37eb9d5b
commit ccadea0576
59 changed files with 4569 additions and 2510 deletions

View File

@@ -1,16 +0,0 @@
from tarot import Tarot, letter, number, kaballah
from temporal import ThalemaClock
from datetime import datetime
from utils import Personality, MBTIType
from tarot.ui import display_cards,display_cube
from tarot.deck import Deck
# Get some cards
deck = Deck()
cards = Tarot.deck.card.filter(suit="cups",type="ace")
print(cards)
# Display using default deck
display_cards(cards)
)

View File

@@ -11,19 +11,19 @@ Provides four root namespaces for different domains:
Quick Start: Quick Start:
from tarot import number, letter, kaballah, Tarot from tarot import number, letter, kaballah, Tarot
# Number # Number
num = number.number(5) num = number.number(5)
root = number.digital_root(256) root = number.digital_root(256)
# Letter # Letter
letter_obj = letter.letter('A') letter_obj = letter.letter('A')
result = letter.word('MAGICK').cipher('english_simple') result = letter.word('MAGICK').cipher('english_simple')
# Kaballah # Kaballah
sephera = kaballah.Tree.sephera(1) sephera = kaballah.Tree.sephera(1)
wall = kaballah.Cube.wall('North') wall = kaballah.Cube.wall('North')
# Tarot # Tarot
card = Tarot.deck.card(3) card = Tarot.deck.card(3)
major5 = Tarot.deck.card.major(5) major5 = Tarot.deck.card.major(5)

View File

@@ -8,15 +8,15 @@ Provides fluent query interface for:
Usage: Usage:
from tarot import kaballah from tarot import kaballah
sephera = kaballah.Tree.sephera(1) sephera = kaballah.Tree.sephera(1)
path = kaballah.Tree.path(11) path = kaballah.Tree.path(11)
wall = kaballah.Cube.wall("North") wall = kaballah.Cube.wall("North")
direction = kaballah.Cube.direction("North", "East") direction = kaballah.Cube.direction("North", "East")
""" """
from .tree import Tree
from .cube import Cube from .cube import Cube
from .tree import Tree
# Export classes for fluent access # Export classes for fluent access
__all__ = ["Tree", "Cube"] __all__ = ["Tree", "Cube"]

View File

@@ -6,22 +6,22 @@ including Sephira, Paths, and Tree of Life structures.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple
from utils.attributes import ( from utils.attributes import (
Element,
ElementType,
Planet,
Color, Color,
Colorscale, Colorscale,
Perfume, ElementType,
God, God,
Perfume,
Planet,
) )
@dataclass @dataclass
class Sephera: class Sephera:
"""Represents a Sephira on the Tree of Life.""" """Represents a Sephira on the Tree of Life."""
number: int number: int
name: str name: str
hebrew_name: str hebrew_name: str
@@ -29,21 +29,22 @@ class Sephera:
archangel: str archangel: str
order_of_angels: str order_of_angels: str
mundane_chakra: str mundane_chakra: str
element: Optional['ElementType'] = None element: Optional["ElementType"] = None
planetary_ruler: Optional[str] = None planetary_ruler: Optional[str] = None
tarot_trump: Optional[str] = None tarot_trump: Optional[str] = None
colorscale: Optional['Colorscale'] = None colorscale: Optional["Colorscale"] = None
@dataclass @dataclass
class PeriodicTable: class PeriodicTable:
"""Represents a Sephirothic position in Kabbalah with cross-correspondences.""" """Represents a Sephirothic position in Kabbalah with cross-correspondences."""
number: int number: int
name: str name: str
sephera: Optional[Sephera] sephera: Optional[Sephera]
element: Optional['ElementType'] = None element: Optional["ElementType"] = None
planet: Optional['Planet'] = None planet: Optional["Planet"] = None
color: Optional['Color'] = None color: Optional["Color"] = None
tarot_trump: Optional[str] = None tarot_trump: Optional[str] = None
hebrew_letter: Optional[str] = None hebrew_letter: Optional[str] = None
divine_name: Optional[str] = None divine_name: Optional[str] = None
@@ -55,6 +56,7 @@ class PeriodicTable:
@dataclass @dataclass
class TreeOfLife: class TreeOfLife:
"""Represents the Tree of Life structure.""" """Represents the Tree of Life structure."""
sephiroth: Dict[int, str] sephiroth: Dict[int, str]
paths: Dict[Tuple[int, int], str] paths: Dict[Tuple[int, int], str]
@@ -62,6 +64,7 @@ class TreeOfLife:
@dataclass @dataclass
class Correspondences: class Correspondences:
"""Represents Kabbalistic correspondences.""" """Represents Kabbalistic correspondences."""
number: int number: int
sephira: str sephira: str
element: Optional[str] element: Optional[str]
@@ -76,55 +79,56 @@ class Correspondences:
@dataclass @dataclass
class Path: class Path:
"""Represents one of the 22 Paths on the Tree of Life with full correspondences.""" """Represents one of the 22 Paths on the Tree of Life with full correspondences."""
number: int # 11-32 number: int # 11-32
hebrew_letter: str # Hebrew letter name (Aleph through Tau) hebrew_letter: str # Hebrew letter name (Aleph through Tau)
transliteration: str # English transliteration transliteration: str # English transliteration
tarot_trump: str # Major Arcana card (0-XXI) tarot_trump: str # Major Arcana card (0-XXI)
sephera_from: Optional['Sephera'] = None # Lower Sephira sephera_from: Optional["Sephera"] = None # Lower Sephira
sephera_to: Optional['Sephera'] = None # Upper Sephira sephera_to: Optional["Sephera"] = None # Upper Sephira
element: Optional['ElementType'] = None # Element (Air, Fire, Water, Earth) element: Optional["ElementType"] = None # Element (Air, Fire, Water, Earth)
planet: Optional['Planet'] = None # Planetary ruler planet: Optional["Planet"] = None # Planetary ruler
zodiac_sign: Optional[str] = None # Zodiac sign (12 paths only) zodiac_sign: Optional[str] = None # Zodiac sign (12 paths only)
colorscale: Optional['Colorscale'] = None # Golden Dawn color scales colorscale: Optional["Colorscale"] = None # Golden Dawn color scales
perfumes: List['Perfume'] = field(default_factory=list) perfumes: List["Perfume"] = field(default_factory=list)
gods: Dict[str, List['God']] = field(default_factory=dict) gods: Dict[str, List["God"]] = field(default_factory=dict)
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 11 <= self.number <= 32: if not 11 <= self.number <= 32:
raise ValueError(f"Path number must be between 11 and 32, got {self.number}") raise ValueError(f"Path number must be between 11 and 32, got {self.number}")
def is_elemental_path(self) -> bool: def is_elemental_path(self) -> bool:
"""Check if this is one of the 4 elemental paths.""" """Check if this is one of the 4 elemental paths."""
elemental_numbers = {11, 23, 31, 32} # Aleph, Mem, Shin, 32-bis elemental_numbers = {11, 23, 31, 32} # Aleph, Mem, Shin, 32-bis
return self.number in elemental_numbers return self.number in elemental_numbers
def is_planetary_path(self) -> bool: def is_planetary_path(self) -> bool:
"""Check if this path has planetary correspondence.""" """Check if this path has planetary correspondence."""
return self.planet is not None return self.planet is not None
def is_zodiacal_path(self) -> bool: def is_zodiacal_path(self) -> bool:
"""Check if this path has zodiac correspondence.""" """Check if this path has zodiac correspondence."""
return self.zodiac_sign is not None return self.zodiac_sign is not None
def add_god(self, god: 'God') -> None: def add_god(self, god: "God") -> None:
"""Attach a god to this path grouped by culture.""" """Attach a god to this path grouped by culture."""
culture_key = god.culture_key() culture_key = god.culture_key()
culture_bucket = self.gods.setdefault(culture_key, []) culture_bucket = self.gods.setdefault(culture_key, [])
if god not in culture_bucket: if god not in culture_bucket:
culture_bucket.append(god) culture_bucket.append(god)
def add_perfume(self, perfume: 'Perfume') -> None: def add_perfume(self, perfume: "Perfume") -> None:
"""Attach a perfume correspondence if it is not already present.""" """Attach a perfume correspondence if it is not already present."""
if perfume not in self.perfumes: if perfume not in self.perfumes:
self.perfumes.append(perfume) self.perfumes.append(perfume)
def get_gods(self, culture: Optional[str] = None) -> List['God']: def get_gods(self, culture: Optional[str] = None) -> List["God"]:
"""Return all gods for this path, optionally filtered by culture.""" """Return all gods for this path, optionally filtered by culture."""
if culture: if culture:
return list(self.gods.get(culture.lower(), [])) return list(self.gods.get(culture.lower(), []))
merged: List['God'] = [] merged: List["God"] = []
for values in self.gods.values(): for values in self.gods.values():
merged.extend(values) merged.extend(values)
return merged return merged
@@ -132,36 +136,36 @@ class Path:
def __str__(self) -> str: def __str__(self) -> str:
"""Return nicely formatted string representation of the Path.""" """Return nicely formatted string representation of the Path."""
lines = [] lines = []
# Header with path number and letter # Header with path number and letter
lines.append(f"--- Path {self.number}: {self.hebrew_letter} ({self.transliteration}) ---") lines.append(f"--- Path {self.number}: {self.hebrew_letter} ({self.transliteration}) ---")
lines.append("") lines.append("")
# Basic correspondences # Basic correspondences
lines.append(f"tarot_trump: {self.tarot_trump}") lines.append(f"tarot_trump: {self.tarot_trump}")
# Connections # Connections
if self.sephera_from or self.sephera_to: if self.sephera_from or self.sephera_to:
seph_from = self.sephera_from.name if self.sephera_from else "Unknown" seph_from = self.sephera_from.name if self.sephera_from else "Unknown"
seph_to = self.sephera_to.name if self.sephera_to else "Unknown" seph_to = self.sephera_to.name if self.sephera_to else "Unknown"
lines.append(f"connects: {seph_from}{seph_to}") lines.append(f"connects: {seph_from}{seph_to}")
# Element # Element
if self.element: if self.element:
element_name = self.element.name if hasattr(self.element, 'name') else str(self.element) element_name = self.element.name if hasattr(self.element, "name") else str(self.element)
lines.append(f"element: {element_name}") lines.append(f"element: {element_name}")
# Planet # Planet
if self.planet: if self.planet:
lines.append("") lines.append("")
lines.append("--- Planet ---") lines.append("--- Planet ---")
for line in str(self.planet).split("\n"): for line in str(self.planet).split("\n"):
lines.append(f" {line}") lines.append(f" {line}")
# Zodiac # Zodiac
if self.zodiac_sign: if self.zodiac_sign:
lines.append(f"zodiac_sign: {self.zodiac_sign}") lines.append(f"zodiac_sign: {self.zodiac_sign}")
# Colorscale # Colorscale
if self.colorscale: if self.colorscale:
lines.append("") lines.append("")
@@ -178,7 +182,7 @@ class Path:
lines.append(f" keywords: {', '.join(self.colorscale.keywords)}") lines.append(f" keywords: {', '.join(self.colorscale.keywords)}")
if self.colorscale.description: if self.colorscale.description:
lines.append(f" description: {self.colorscale.description}") lines.append(f" description: {self.colorscale.description}")
# Perfumes # Perfumes
if self.perfumes: if self.perfumes:
lines.append("") lines.append("")
@@ -187,7 +191,7 @@ class Path:
for line in str(perfume).split("\n"): for line in str(perfume).split("\n"):
lines.append(f" {line}") lines.append(f" {line}")
lines.append("") lines.append("")
# Gods # Gods
if self.gods: if self.gods:
lines.append("") lines.append("")
@@ -198,18 +202,18 @@ class Path:
for line in str(god).split("\n"): for line in str(god).split("\n"):
lines.append(f" {line}") lines.append(f" {line}")
lines.append("") lines.append("")
# Keywords # Keywords
if self.keywords: if self.keywords:
lines.append("") lines.append("")
lines.append("--- Keywords ---") lines.append("--- Keywords ---")
lines.append(f" {', '.join(self.keywords)}") lines.append(f" {', '.join(self.keywords)}")
# Description # Description
if self.description: if self.description:
lines.append("") lines.append("")
lines.append("--- Description ---") lines.append("--- Description ---")
lines.append(f" {self.description}") lines.append(f" {self.description}")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -1,6 +1,6 @@
"""Cube namespace - access Cube of Space walls and areas.""" """Cube namespace - access Cube of Space walls and areas."""
from .cube import Cube
from .attributes import CubeOfSpace, Wall, WallDirection from .attributes import CubeOfSpace, Wall, WallDirection
from .cube import Cube
__all__ = ["Cube", "CubeOfSpace", "Wall", "WallDirection"] __all__ = ["Cube", "CubeOfSpace", "Wall", "WallDirection"]

View File

@@ -13,10 +13,11 @@ from typing import Dict, List, Optional
class WallDirection: class WallDirection:
""" """
Represents a single direction within a Wall of the Cube of Space. Represents a single direction within a Wall of the Cube of Space.
Each wall has 5 directions: North, South, East, West, Center. Each wall has 5 directions: North, South, East, West, Center.
Each direction has a Hebrew letter and zodiac correspondence. Each direction has a Hebrew letter and zodiac correspondence.
""" """
name: str # "North", "South", "East", "West", "Center" name: str # "North", "South", "East", "West", "Center"
letter: str # Hebrew letter (e.g., "Aleph", "Bet", etc.) letter: str # Hebrew letter (e.g., "Aleph", "Bet", etc.)
zodiac: Optional[str] = None # Zodiac sign if applicable zodiac: Optional[str] = None # Zodiac sign if applicable
@@ -24,9 +25,9 @@ class WallDirection:
planet: Optional[str] = None # Associated planet if any planet: Optional[str] = None # Associated planet if any
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
VALID_DIRECTION_NAMES = {"North", "South", "East", "West", "Center"} VALID_DIRECTION_NAMES = {"North", "South", "East", "West", "Center"}
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.name not in self.VALID_DIRECTION_NAMES: if self.name not in self.VALID_DIRECTION_NAMES:
raise ValueError( raise ValueError(
@@ -35,7 +36,7 @@ class WallDirection:
) )
if not self.letter or not isinstance(self.letter, str): if not self.letter or not isinstance(self.letter, str):
raise ValueError(f"Direction must have a letter, got {self.letter}") raise ValueError(f"Direction must have a letter, got {self.letter}")
def __repr__(self) -> str: def __repr__(self) -> str:
"""Custom repr showing key attributes.""" """Custom repr showing key attributes."""
return f"WallDirection({self.name}, {self.letter})" return f"WallDirection({self.name}, {self.letter})"
@@ -45,12 +46,13 @@ class WallDirection:
class Wall: class Wall:
""" """
Represents one of the 6 walls of the Cube of Space. Represents one of the 6 walls of the Cube of Space.
Each wall has 5 directions: North, South, East, West, Center. Each wall has 5 directions: North, South, East, West, Center.
The 6 walls are: North, South, East, West, Above, Below. The 6 walls are: North, South, East, West, Above, Below.
Opposite walls: North↔South, East↔West, Above↔Below. Opposite walls: North↔South, East↔West, Above↔Below.
Each direction has a Hebrew letter and zodiac correspondence. Each direction has a Hebrew letter and zodiac correspondence.
""" """
name: str # "North", "South", "East", "West", "Above", "Below" name: str # "North", "South", "East", "West", "Above", "Below"
side: str # Alias for name, used for filtering (e.g., "north", "south") side: str # Alias for name, used for filtering (e.g., "north", "south")
opposite: str # Opposite wall name (e.g., "South" for North wall) opposite: str # Opposite wall name (e.g., "South" for North wall)
@@ -60,9 +62,9 @@ class Wall:
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
directions: Dict[str, "WallDirection"] = field(default_factory=dict) directions: Dict[str, "WallDirection"] = field(default_factory=dict)
VALID_WALL_NAMES = {"North", "South", "East", "West", "Above", "Below"} VALID_WALL_NAMES = {"North", "South", "East", "West", "Above", "Below"}
# Opposite wall mappings # Opposite wall mappings
OPPOSITE_WALLS = { OPPOSITE_WALLS = {
"North": "South", "North": "South",
@@ -72,45 +74,43 @@ class Wall:
"Above": "Below", "Above": "Below",
"Below": "Above", "Below": "Above",
} }
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.name not in self.VALID_WALL_NAMES: if self.name not in self.VALID_WALL_NAMES:
raise ValueError( raise ValueError(
f"Invalid wall name '{self.name}'. " f"Invalid wall name '{self.name}'. "
f"Valid walls: {', '.join(sorted(self.VALID_WALL_NAMES))}" f"Valid walls: {', '.join(sorted(self.VALID_WALL_NAMES))}"
) )
# Validate side matches name (case-insensitive) # Validate side matches name (case-insensitive)
if self.side.capitalize() != self.name: if self.side.capitalize() != self.name:
raise ValueError( raise ValueError(f"Wall side '{self.side}' must match name '{self.name}'")
f"Wall side '{self.side}' must match name '{self.name}'"
)
# Validate opposite wall # Validate opposite wall
expected_opposite = self.OPPOSITE_WALLS.get(self.name) expected_opposite = self.OPPOSITE_WALLS.get(self.name)
if self.opposite != expected_opposite: if self.opposite != expected_opposite:
raise ValueError( raise ValueError(
f"Wall '{self.name}' must have opposite '{expected_opposite}', got '{self.opposite}'" f"Wall '{self.name}' must have opposite '{expected_opposite}', got '{self.opposite}'"
) )
# Ensure all 5 directions exist # Ensure all 5 directions exist
if len(self.directions) != 5: if len(self.directions) != 5:
raise ValueError( raise ValueError(
f"Wall '{self.name}' must have exactly 5 directions (North, South, East, West, Center), " f"Wall '{self.name}' must have exactly 5 directions (North, South, East, West, Center), "
f"got {len(self.directions)}" f"got {len(self.directions)}"
) )
required_directions = {"North", "South", "East", "West", "Center"} required_directions = {"North", "South", "East", "West", "Center"}
if set(self.directions.keys()) != required_directions: if set(self.directions.keys()) != required_directions:
raise ValueError( raise ValueError(
f"Wall '{self.name}' must have directions: {required_directions}, " f"Wall '{self.name}' must have directions: {required_directions}, "
f"got {set(self.directions.keys())}" f"got {set(self.directions.keys())}"
) )
def __repr__(self) -> str: def __repr__(self) -> str:
"""Custom repr showing wall name and element.""" """Custom repr showing wall name and element."""
return f"Wall({self.name}, {self.element})" return f"Wall({self.name}, {self.element})"
def __str__(self) -> str: def __str__(self) -> str:
"""Custom string representation for printing wall details with recursive direction details.""" """Custom string representation for printing wall details with recursive direction details."""
keywords_str = ", ".join(self.keywords) if self.keywords else "None" keywords_str = ", ".join(self.keywords) if self.keywords else "None"
@@ -123,7 +123,7 @@ class Wall:
f" Archangel: {self.archangel}", f" Archangel: {self.archangel}",
f" Keywords: {keywords_str}", f" Keywords: {keywords_str}",
] ]
# Add directions with their details recursively # Add directions with their details recursively
if self.directions: if self.directions:
lines.append(" Directions:") lines.append(" Directions:")
@@ -145,22 +145,22 @@ class Wall:
lines.append(f" Keywords: {keywords}") lines.append(f" Keywords: {keywords}")
if direction.description: if direction.description:
lines.append(f" Description: {direction.description}") lines.append(f" Description: {direction.description}")
return "\n".join(lines) return "\n".join(lines)
def direction(self, direction_name: str) -> Optional["WallDirection"]: def direction(self, direction_name: str) -> Optional["WallDirection"]:
"""Get a specific direction by name. Usage: wall.direction("North")""" """Get a specific direction by name. Usage: wall.direction("North")"""
return self.directions.get(direction_name.capitalize()) return self.directions.get(direction_name.capitalize())
def all_directions(self) -> list: def all_directions(self) -> list:
"""Return all 5 directions as a list.""" """Return all 5 directions as a list."""
return list(self.directions.values()) return list(self.directions.values())
# Aliases for backward compatibility # Aliases for backward compatibility
def get_direction(self, direction_name: str) -> Optional["WallDirection"]: def get_direction(self, direction_name: str) -> Optional["WallDirection"]:
"""Deprecated: use direction() instead.""" """Deprecated: use direction() instead."""
return self.direction(direction_name) return self.direction(direction_name)
def get_opposite_wall_name(self) -> str: def get_opposite_wall_name(self) -> str:
"""Deprecated: use the opposite property instead.""" """Deprecated: use the opposite property instead."""
return self.opposite return self.opposite
@@ -170,16 +170,17 @@ class Wall:
class CubeOfSpace: class CubeOfSpace:
""" """
Represents the Cube of Space with 6 walls. Represents the Cube of Space with 6 walls.
The Cube of Space is a 3D sacred geometry model consisting of: The Cube of Space is a 3D sacred geometry model consisting of:
- 6 walls (North, South, East, West, Above, Below) - 6 walls (North, South, East, West, Above, Below)
- Each wall contains 5 areas (center, above, below, east, west) - Each wall contains 5 areas (center, above, below, east, west)
- Opposite walls: North↔South, East↔West, Above↔Below - Opposite walls: North↔South, East↔West, Above↔Below
- Total: 30 positions plus central core - Total: 30 positions plus central core
""" """
walls: Dict[str, Wall] = field(default_factory=dict) walls: Dict[str, Wall] = field(default_factory=dict)
center: Optional[WallDirection] = None # Central core position center: Optional[WallDirection] = None # Central core position
# Built-in wall definitions with all correspondences # Built-in wall definitions with all correspondences
_WALL_DEFINITIONS = { _WALL_DEFINITIONS = {
"North": { "North": {
@@ -387,28 +388,26 @@ class CubeOfSpace:
}, },
}, },
} }
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Validate that all 6 walls are present.""" """Validate that all 6 walls are present."""
required_walls = {"North", "South", "East", "West", "Above", "Below"} required_walls = {"North", "South", "East", "West", "Above", "Below"}
if set(self.walls.keys()) != required_walls: if set(self.walls.keys()) != required_walls:
raise ValueError( raise ValueError(f"CubeOfSpace must have all 6 walls, got: {set(self.walls.keys())}")
f"CubeOfSpace must have all 6 walls, got: {set(self.walls.keys())}"
)
@classmethod @classmethod
def create_default(cls) -> "CubeOfSpace": def create_default(cls) -> "CubeOfSpace":
""" """
Create a CubeOfSpace with all 6 walls fully populated with built-in definitions. Create a CubeOfSpace with all 6 walls fully populated with built-in definitions.
Each wall has 5 directions (North, South, East, West, Center) positioned on that wall. Each wall has 5 directions (North, South, East, West, Center) positioned on that wall.
Each direction has a Hebrew letter and optional zodiac correspondence. Each direction has a Hebrew letter and optional zodiac correspondence.
Returns: Returns:
CubeOfSpace: Fully initialized cube with all walls and directions CubeOfSpace: Fully initialized cube with all walls and directions
""" """
walls = {} walls = {}
# Direction name mapping - same 5 directions on every wall # Direction name mapping - same 5 directions on every wall
# Maps old area names to consistent direction names # Maps old area names to consistent direction names
direction_map = { direction_map = {
@@ -416,9 +415,9 @@ class CubeOfSpace:
"above": {"name": "North", "letter": "Bet", "zodiac": None}, "above": {"name": "North", "letter": "Bet", "zodiac": None},
"below": {"name": "South", "letter": "Gimel", "zodiac": None}, "below": {"name": "South", "letter": "Gimel", "zodiac": None},
"east": {"name": "East", "letter": "Daleth", "zodiac": "Aries"}, "east": {"name": "East", "letter": "Daleth", "zodiac": "Aries"},
"west": {"name": "West", "letter": "He", "zodiac": "Pisces"} "west": {"name": "West", "letter": "He", "zodiac": "Pisces"},
} }
for wall_name, wall_data in cls._WALL_DEFINITIONS.items(): for wall_name, wall_data in cls._WALL_DEFINITIONS.items():
# Create directions for this wall # Create directions for this wall
# Each wall has the same 5 directions: North, South, East, West, Center # Each wall has the same 5 directions: North, South, East, West, Center
@@ -436,7 +435,7 @@ class CubeOfSpace:
) )
# Use the direction name as key so every wall has North, South, East, West, Center # Use the direction name as key so every wall has North, South, East, West, Center
directions[direction_config["name"]] = direction directions[direction_config["name"]] = direction
# Create the wall # Create the wall
wall = Wall( wall = Wall(
name=wall_name, name=wall_name,
@@ -450,7 +449,7 @@ class CubeOfSpace:
directions=directions, directions=directions,
) )
walls[wall_name] = wall walls[wall_name] = wall
# Create central core # Create central core
central_core = WallDirection( central_core = WallDirection(
name="Center", name="Center",
@@ -459,55 +458,55 @@ class CubeOfSpace:
keywords=["Unity", "Source", "All"], keywords=["Unity", "Source", "All"],
description="Central core of the Cube of Space - synthesis of all forces", description="Central core of the Cube of Space - synthesis of all forces",
) )
return cls(walls=walls, center=central_core) return cls(walls=walls, center=central_core)
def wall(self, wall_name: str) -> Optional[Wall]: def wall(self, wall_name: str) -> Optional[Wall]:
"""Get a wall by name. Usage: cube.wall("north")""" """Get a wall by name. Usage: cube.wall("north")"""
return self.walls.get(wall_name) return self.walls.get(wall_name)
def opposite(self, wall_name: str) -> Optional[Wall]: def opposite(self, wall_name: str) -> Optional[Wall]:
"""Get the opposite wall. Usage: cube.opposite("north")""" """Get the opposite wall. Usage: cube.opposite("north")"""
opposite_name = Wall.OPPOSITE_WALLS.get(wall_name) opposite_name = Wall.OPPOSITE_WALLS.get(wall_name)
if not opposite_name: if not opposite_name:
return None return None
return self.walls.get(opposite_name) return self.walls.get(opposite_name)
def direction(self, wall_name: str, direction_name: str) -> Optional[WallDirection]: def direction(self, wall_name: str, direction_name: str) -> Optional[WallDirection]:
"""Get a specific direction from a specific wall. Usage: cube.direction("north", "center")""" """Get a specific direction from a specific wall. Usage: cube.direction("north", "center")"""
wall = self.wall(wall_name) wall = self.wall(wall_name)
if not wall: if not wall:
return None return None
return wall.direction(direction_name) return wall.direction(direction_name)
def walls_all(self) -> List[Wall]: def walls_all(self) -> List[Wall]:
"""Return all 6 walls as a list.""" """Return all 6 walls as a list."""
return list(self.walls.values()) return list(self.walls.values())
def directions(self, wall_name: str) -> list: def directions(self, wall_name: str) -> list:
"""Return all 5 directions for a specific wall. Usage: cube.directions("north")""" """Return all 5 directions for a specific wall. Usage: cube.directions("north")"""
wall = self.wall(wall_name) wall = self.wall(wall_name)
if not wall: if not wall:
return [] return []
return wall.all_directions() return wall.all_directions()
# Aliases for backward compatibility # Aliases for backward compatibility
def get_wall(self, wall_name: str) -> Optional[Wall]: def get_wall(self, wall_name: str) -> Optional[Wall]:
"""Deprecated: use wall() instead.""" """Deprecated: use wall() instead."""
return self.wall(wall_name) return self.wall(wall_name)
def get_direction(self, wall_name: str, direction_name: str) -> Optional[WallDirection]: def get_direction(self, wall_name: str, direction_name: str) -> Optional[WallDirection]:
"""Deprecated: use direction() instead.""" """Deprecated: use direction() instead."""
return self.direction(wall_name, direction_name) return self.direction(wall_name, direction_name)
def get_opposite_wall(self, wall_name: str) -> Optional[Wall]: def get_opposite_wall(self, wall_name: str) -> Optional[Wall]:
"""Deprecated: use opposite() instead.""" """Deprecated: use opposite() instead."""
return self.opposite(wall_name) return self.opposite(wall_name)
def all_walls(self) -> List[Wall]: def all_walls(self) -> List[Wall]:
"""Deprecated: use walls_all() instead.""" """Deprecated: use walls_all() instead."""
return self.walls_all() return self.walls_all()
def all_directions_for_wall(self, wall_name: str) -> list: def all_directions_for_wall(self, wall_name: str) -> list:
"""Deprecated: use directions() instead.""" """Deprecated: use directions() instead."""
return self.directions(wall_name) return self.directions(wall_name)

View File

@@ -5,11 +5,11 @@ Provides hierarchical access to Cube > Wall > Direction structure.
Usage: Usage:
from tarot.cube import Cube from tarot.cube import Cube
# Access walls # Access walls
Tarot.cube.wall("North") # Get specific wall Tarot.cube.wall("North") # Get specific wall
Tarot.cube.wall().filter(element="Air") # Filter all walls Tarot.cube.wall().filter(element="Air") # Filter all walls
# Access directions (NEW - replaces old "area" concept) # Access directions (NEW - replaces old "area" concept)
wall = Tarot.cube.wall("North") wall = Tarot.cube.wall("North")
wall.filter("East") # Filter by direction wall.filter("East") # Filter by direction
@@ -17,19 +17,22 @@ Usage:
wall.direction("East") # Get specific direction wall.direction("East") # Get specific direction
""" """
from typing import Optional, Any from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from kaballah.cube.attributes import CubeOfSpace
class CubeMeta(type): class CubeMeta(type):
"""Metaclass to add __str__ to Cube class itself.""" """Metaclass to add __str__ to Cube class itself."""
def __str__(cls) -> str: def __str__(cls) -> str:
"""Return readable representation when Cube is converted to string.""" """Return readable representation when Cube is converted to string."""
cls._ensure_initialized() cls._ensure_initialized()
if cls._cube is None: if cls._cube is None:
return "Cube of Space (not initialized)" return "Cube of Space (not initialized)"
walls = cls._cube.walls if hasattr(cls._cube, 'walls') else {} walls = cls._cube.walls if hasattr(cls._cube, "walls") else {}
lines = [ lines = [
"Cube of Space", "Cube of Space",
"=" * 60, "=" * 60,
@@ -37,23 +40,23 @@ class CubeMeta(type):
"", "",
"Structure:", "Structure:",
] ]
# Show walls with their elements and areas # Show walls with their elements and areas
for wall_name in ["North", "South", "East", "West", "Above", "Below"]: for wall_name in ["North", "South", "East", "West", "Above", "Below"]:
wall = walls.get(wall_name) wall = walls.get(wall_name)
if wall: if wall:
element = f" [{wall.element}]" if hasattr(wall, 'element') else "" element = f" [{wall.element}]" if hasattr(wall, "element") else ""
areas = len(wall.directions) if hasattr(wall, 'directions') else 0 areas = len(wall.directions) if hasattr(wall, "directions") else 0
lines.append(f" {wall_name}{element}: {areas} areas") lines.append(f" {wall_name}{element}: {areas} areas")
return "\n".join(lines) return "\n".join(lines)
def __repr__(cls) -> str: def __repr__(cls) -> str:
"""Return object representation.""" """Return object representation."""
cls._ensure_initialized() cls._ensure_initialized()
if cls._cube is None: if cls._cube is None:
return "Cube(not initialized)" return "Cube(not initialized)"
walls = cls._cube.walls if hasattr(cls._cube, 'walls') else {} walls = cls._cube.walls if hasattr(cls._cube, "walls") else {}
return f"Cube(walls={len(walls)})" return f"Cube(walls={len(walls)})"
@@ -68,7 +71,7 @@ class DirectionAccessor:
def all(self) -> list: def all(self) -> list:
"""Get all directions in this wall.""" """Get all directions in this wall."""
if self._wall is None or not hasattr(self._wall, 'directions'): if self._wall is None or not hasattr(self._wall, "directions"):
return [] return []
return list(self._wall.directions.values()) return list(self._wall.directions.values())
@@ -89,10 +92,7 @@ class DirectionAccessor:
# Filter by direction name if provided # Filter by direction name if provided
if direction_name: if direction_name:
all_dirs = [ all_dirs = [d for d in all_dirs if d.name.lower() == direction_name.lower()]
d for d in all_dirs
if d.name.lower() == direction_name.lower()
]
# Apply other filters # Apply other filters
if kwargs: if kwargs:
@@ -126,7 +126,7 @@ class DirectionAccessor:
"""Get specific direction by name.""" """Get specific direction by name."""
if direction_name is None: if direction_name is None:
return self.all() return self.all()
if self._wall is None or not hasattr(self._wall, 'directions'): if self._wall is None or not hasattr(self._wall, "directions"):
return None return None
return self._wall.directions.get(direction_name.capitalize()) return self._wall.directions.get(direction_name.capitalize())
@@ -152,7 +152,7 @@ class WallWrapper:
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
"""Delegate attribute access to the wrapped wall.""" """Delegate attribute access to the wrapped wall."""
if name in ('_wall', '_direction_accessor'): if name in ("_wall", "_direction_accessor"):
return object.__getattribute__(self, name) return object.__getattribute__(self, name)
return getattr(self._wall, name) return getattr(self._wall, name)
@@ -274,7 +274,7 @@ class WallAccessor:
def __call__(self, wall_name: Optional[str] = None) -> Optional[Any]: def __call__(self, wall_name: Optional[str] = None) -> Optional[Any]:
"""Get a specific wall by name or return all walls. """Get a specific wall by name or return all walls.
Deprecated: Use filter(side="north") instead. Deprecated: Use filter(side="north") instead.
""" """
self._ensure_initialized() self._ensure_initialized()
@@ -307,10 +307,10 @@ class Cube(metaclass=CubeMeta):
# Filter walls by side # Filter walls by side
north = Cube.wall.filter(side="north")[0] # Get north wall north = Cube.wall.filter(side="north")[0] # Get north wall
air_walls = Cube.wall.filter(element="Air") # Filter by element air_walls = Cube.wall.filter(element="Air") # Filter by element
# Access all walls # Access all walls
all_walls = Cube.wall.all() # Get all 6 walls all_walls = Cube.wall.all() # Get all 6 walls
# Work with directions within a wall # Work with directions within a wall
wall = Cube.wall.filter(side="north")[0] wall = Cube.wall.filter(side="north")[0]
east_dir = wall.direction("East") # Get direction east_dir = wall.direction("East") # Get direction
@@ -339,15 +339,16 @@ class Cube(metaclass=CubeMeta):
if cls._wall_accessor is None: if cls._wall_accessor is None:
cls._wall_accessor = WallAccessor() cls._wall_accessor = WallAccessor()
return cls._wall_accessor return cls._wall_accessor
# Use a descriptor to make wall work like a property on the class # Use a descriptor to make wall work like a property on the class
class WallProperty: class WallProperty:
"""Descriptor that returns wall accessor when accessed.""" """Descriptor that returns wall accessor when accessed."""
def __get__(self, obj: Any, objtype: Optional[type] = None) -> "WallAccessor": def __get__(self, obj: Any, objtype: Optional[type] = None) -> "WallAccessor":
if objtype is None: if objtype is None:
objtype = type(obj) objtype = type(obj)
return objtype._get_wall_accessor() return objtype._get_wall_accessor()
wall = WallProperty() wall = WallProperty()
@classmethod @classmethod

View File

@@ -5,31 +5,31 @@ Provides access to Sephiroth, Paths, and Tree of Life correspondences.
Usage: Usage:
from tarot.tree import Tree from tarot.tree import Tree
sephera = Tree.sephera(1) # Get Sephira 1 (Kether) sephera = Tree.sephera(1) # Get Sephira 1 (Kether)
path = Tree.path(11) # Get Path 11 path = Tree.path(11) # Get Path 11
all_sepheras = Tree.sephera() # Get all Sephiroth all_sepheras = Tree.sephera() # Get all Sephiroth
print(Tree()) # Display Tree structure print(Tree()) # Display Tree structure
""" """
from typing import TYPE_CHECKING, Dict, List, Optional, Union, overload from typing import TYPE_CHECKING, Dict, Optional, Union, overload
if TYPE_CHECKING: if TYPE_CHECKING:
from tarot.attributes import Sephera, Path from tarot.attributes import Path, Sephera
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
from utils.query import QueryResult, Query from utils.query import Query
class TreeMeta(type): class TreeMeta(type):
"""Metaclass to add __str__ to Tree class itself.""" """Metaclass to add __str__ to Tree class itself."""
def __str__(cls) -> str: def __str__(cls) -> str:
"""Return readable representation when Tree is converted to string.""" """Return readable representation when Tree is converted to string."""
# Access Tree class attributes through type.__getattribute__ # Access Tree class attributes through type.__getattribute__
Tree._ensure_initialized() Tree._ensure_initialized()
sepheras = type.__getattribute__(cls, '_sepheras') sepheras = type.__getattribute__(cls, "_sepheras")
paths = type.__getattribute__(cls, '_paths') paths = type.__getattribute__(cls, "_paths")
lines = [ lines = [
"Tree of Life", "Tree of Life",
"=" * 60, "=" * 60,
@@ -38,99 +38,99 @@ class TreeMeta(type):
"", "",
"Structure:", "Structure:",
] ]
# Show Sephira hierarchy # Show Sephira hierarchy
for num in sorted(sepheras.keys()): for num in sorted(sepheras.keys()):
seph = sepheras[num] seph = sepheras[num]
lines.append(f" {num}. {seph.name} ({seph.hebrew_name})") lines.append(f" {num}. {seph.name} ({seph.hebrew_name})")
return "\n".join(lines) return "\n".join(lines)
def __repr__(cls) -> str: def __repr__(cls) -> str:
"""Return object representation.""" """Return object representation."""
Tree._ensure_initialized() Tree._ensure_initialized()
sepheras = type.__getattribute__(cls, '_sepheras') sepheras = type.__getattribute__(cls, "_sepheras")
paths = type.__getattribute__(cls, '_paths') paths = type.__getattribute__(cls, "_paths")
return f"Tree(sepheras={len(sepheras)}, paths={len(paths)})" return f"Tree(sepheras={len(sepheras)}, paths={len(paths)})"
class Tree(metaclass=TreeMeta): class Tree(metaclass=TreeMeta):
""" """
Unified accessor for Tree of Life correspondences. Unified accessor for Tree of Life correspondences.
All methods are class methods, so Tree is accessed as a static namespace: All methods are class methods, so Tree is accessed as a static namespace:
sephera = Tree.sephera(1) sephera = Tree.sephera(1)
path = Tree.path(11) path = Tree.path(11)
print(Tree()) # Displays tree structure print(Tree()) # Displays tree structure
""" """
_sepheras: Dict[int, 'Sephera'] = {} # type: ignore _sepheras: Dict[int, "Sephera"] = {} # type: ignore
_paths: Dict[int, 'Path'] = {} # type: ignore _paths: Dict[int, "Path"] = {} # type: ignore
_initialized: bool = False _initialized: bool = False
_loader: Optional['CardDataLoader'] = None # type: ignore _loader: Optional["CardDataLoader"] = None # type: ignore
@classmethod @classmethod
def _ensure_initialized(cls) -> None: def _ensure_initialized(cls) -> None:
"""Lazy-load data from CardDataLoader on first access.""" """Lazy-load data from CardDataLoader on first access."""
if cls._initialized: if cls._initialized:
return return
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
cls._loader = CardDataLoader() cls._loader = CardDataLoader()
cls._sepheras = cls._loader._sephera cls._sepheras = cls._loader._sephera
cls._paths = cls._loader._paths cls._paths = cls._loader._paths
cls._initialized = True cls._initialized = True
@classmethod @classmethod
@overload @overload
def sephera(cls, number: int) -> Optional['Sephera']: def sephera(cls, number: int) -> Optional["Sephera"]: ...
...
@classmethod @classmethod
@overload @overload
def sephera(cls, number: None = ...) -> Dict[int, 'Sephera']: def sephera(cls, number: None = ...) -> Dict[int, "Sephera"]: ...
...
@classmethod @classmethod
def sephera(cls, number: Optional[int] = None) -> Union[Optional['Sephera'], Dict[int, 'Sephera']]: def sephera(
cls, number: Optional[int] = None
) -> Union[Optional["Sephera"], Dict[int, "Sephera"]]:
"""Return a Sephira or all Sephiroth.""" """Return a Sephira or all Sephiroth."""
cls._ensure_initialized() cls._ensure_initialized()
if number is None: if number is None:
return cls._sepheras.copy() return cls._sepheras.copy()
return cls._sepheras.get(number) return cls._sepheras.get(number)
@classmethod @classmethod
@overload @overload
def path(cls, number: int) -> Optional['Path']: def path(cls, number: int) -> Optional["Path"]: ...
...
@classmethod @classmethod
@overload @overload
def path(cls, number: None = ...) -> Dict[int, 'Path']: def path(cls, number: None = ...) -> Dict[int, "Path"]: ...
...
@classmethod @classmethod
def path(cls, number: Optional[int] = None) -> Union[Optional['Path'], Dict[int, 'Path']]: def path(cls, number: Optional[int] = None) -> Union[Optional["Path"], Dict[int, "Path"]]:
"""Return a Path or all Paths.""" """Return a Path or all Paths."""
cls._ensure_initialized() cls._ensure_initialized()
if number is None: if number is None:
return cls._paths.copy() return cls._paths.copy()
return cls._paths.get(number) return cls._paths.get(number)
@classmethod @classmethod
def filter(cls, expression: str) -> 'Query': def filter(cls, expression: str) -> "Query":
""" """
Filter Sephiroth by attribute:value expression. Filter Sephiroth by attribute:value expression.
Examples: Examples:
Tree.filter('name:Kether').first() Tree.filter('name:Kether').first()
Tree.filter('number:1').first() Tree.filter('number:1').first()
Tree.filter('sphere:1').all() Tree.filter('sphere:1').all()
Returns a Query object for chaining. Returns a Query object for chaining.
""" """
from tarot.query import Query from tarot.query import Query
cls._ensure_initialized() cls._ensure_initialized()
# Create a query from all Sephiroth # Create a query from all Sephiroth
return Query(cls._sepheras).filter(expression) return Query(cls._sepheras).filter(expression)

View File

@@ -11,17 +11,16 @@ Provides fluent query interface for:
Usage: Usage:
from tarot import letter from tarot import letter
letter.alphabet('english') letter.alphabet('english')
letter.words.word('MAGICK').cipher('english_simple') letter.words.word('MAGICK').cipher('english_simple')
letter.iching.hexagram(1) letter.iching.hexagram(1)
letter.paths('aleph') # Get Hebrew letter with Tarot correspondences letter.paths('aleph') # Get Hebrew letter with Tarot correspondences
""" """
from .iChing import hexagram, trigram
from .letter import letter from .letter import letter
from .iChing import trigram, hexagram
from .words import word
from .paths import letters from .paths import letters
from .words import word
__all__ = ["letter", "trigram", "hexagram", "word", "letters"] __all__ = ["letter", "trigram", "hexagram", "word", "letters"]

View File

@@ -6,19 +6,19 @@ including Alphabets, Enochian letters, and Double Letter Trumps.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Any from typing import Any, Dict, List, Optional, Tuple
from utils.attributes import ( from utils.attributes import (
Element,
ElementType, ElementType,
Planet,
Meaning, Meaning,
Planet,
) )
@dataclass @dataclass
class Letter: class Letter:
"""Represents a letter with its attributes.""" """Represents a letter with its attributes."""
character: str character: str
position: int position: int
name: str name: str
@@ -27,10 +27,11 @@ class Letter:
@dataclass @dataclass
class EnglishAlphabet: class EnglishAlphabet:
"""English alphabet with Tarot/Kabbalistic correspondence.""" """English alphabet with Tarot/Kabbalistic correspondence."""
letter: str letter: str
position: int position: int
sound: str sound: str
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not (1 <= self.position <= 26): if not (1 <= self.position <= 26):
raise ValueError(f"Position must be between 1 and 26, got {self.position}") raise ValueError(f"Position must be between 1 and 26, got {self.position}")
@@ -41,10 +42,11 @@ class EnglishAlphabet:
@dataclass @dataclass
class GreekAlphabet: class GreekAlphabet:
"""Greek alphabet with Tarot/Kabbalistic correspondence.""" """Greek alphabet with Tarot/Kabbalistic correspondence."""
letter: str letter: str
position: int position: int
transliteration: str transliteration: str
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not (1 <= self.position <= 24): if not (1 <= self.position <= 24):
raise ValueError(f"Position must be between 1 and 24, got {self.position}") raise ValueError(f"Position must be between 1 and 24, got {self.position}")
@@ -53,11 +55,12 @@ class GreekAlphabet:
@dataclass @dataclass
class HebrewAlphabet: class HebrewAlphabet:
"""Hebrew alphabet with Tarot/Kabbalistic correspondence.""" """Hebrew alphabet with Tarot/Kabbalistic correspondence."""
letter: str letter: str
position: int position: int
transliteration: str transliteration: str
meaning: str meaning: str
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not (1 <= self.position <= 22): if not (1 <= self.position <= 22):
raise ValueError(f"Position must be between 1 and 22, got {self.position}") raise ValueError(f"Position must be between 1 and 22, got {self.position}")
@@ -66,19 +69,20 @@ class HebrewAlphabet:
@dataclass @dataclass
class DoublLetterTrump: class DoublLetterTrump:
"""Represents a Double Letter Trump (Yodh through Tau, 3-21 of Major Arcana).""" """Represents a Double Letter Trump (Yodh through Tau, 3-21 of Major Arcana)."""
number: int # 3-21 (19 double letter trumps) number: int # 3-21 (19 double letter trumps)
name: str # Full name (e.g., "The Empress") name: str # Full name (e.g., "The Empress")
hebrew_letter_1: str # First Hebrew letter (e.g., "Gimel") hebrew_letter_1: str # First Hebrew letter (e.g., "Gimel")
hebrew_letter_2: Optional[str] = None # Second Hebrew letter if applicable hebrew_letter_2: Optional[str] = None # Second Hebrew letter if applicable
planet: Optional['Planet'] = None # Associated planet planet: Optional["Planet"] = None # Associated planet
tarot_trump: Optional[str] = None # e.g., "III - The Empress" tarot_trump: Optional[str] = None # e.g., "III - The Empress"
astrological_sign: Optional[str] = None # Zodiac sign if any astrological_sign: Optional[str] = None # Zodiac sign if any
element: Optional['ElementType'] = None # Associated element element: Optional["ElementType"] = None # Associated element
number_value: Optional[int] = None # Numerological value number_value: Optional[int] = None # Numerological value
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
meaning: Optional['Meaning'] = None # Upright and reversed meanings meaning: Optional["Meaning"] = None # Upright and reversed meanings
description: str = "" description: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 3 <= self.number <= 21: if not 3 <= self.number <= 21:
raise ValueError(f"Double Letter Trump number must be 3-21, got {self.number}") raise ValueError(f"Double Letter Trump number must be 3-21, got {self.number}")
@@ -87,6 +91,7 @@ class DoublLetterTrump:
@dataclass(frozen=True) @dataclass(frozen=True)
class EnochianLetter: class EnochianLetter:
"""Represents an Enochian letter with its properties.""" """Represents an Enochian letter with its properties."""
name: str # Enochian letter name name: str # Enochian letter name
letter: str # The letter itself letter: str # The letter itself
hebrew_equivalent: Optional[str] = None hebrew_equivalent: Optional[str] = None
@@ -100,6 +105,7 @@ class EnochianLetter:
@dataclass(frozen=True) @dataclass(frozen=True)
class EnochianSpirit: class EnochianSpirit:
"""Represents an Enochian spirit or intelligence.""" """Represents an Enochian spirit or intelligence."""
name: str # Spirit name name: str # Spirit name
rank: str # e.g., "King", "Prince", "Duke", "Intelligence" rank: str # e.g., "King", "Prince", "Duke", "Intelligence"
element: Optional[str] = None element: Optional[str] = None
@@ -113,30 +119,33 @@ class EnochianSpirit:
class EnochianArchetype: class EnochianArchetype:
""" """
Archetypal form of an Enochian Tablet. Archetypal form of an Enochian Tablet.
Provides a 4x4 grid with positions that can be filled with different Provides a 4x4 grid with positions that can be filled with different
visual representations (colors, images, symbols, etc.). visual representations (colors, images, symbols, etc.).
""" """
name: str # e.g., "Tablet of Air Archetype" name: str # e.g., "Tablet of Air Archetype"
tablet_name: str # Reference to parent tablet tablet_name: str # Reference to parent tablet
grid: Dict[Tuple[int, int], 'EnochianGridPosition'] = field(default_factory=dict) # 4x4 grid grid: Dict[Tuple[int, int], "EnochianGridPosition"] = field(default_factory=dict) # 4x4 grid
row_correspondences: List[Dict[str, Any]] = field(default_factory=list) # Row meanings (4 rows) row_correspondences: List[Dict[str, Any]] = field(default_factory=list) # Row meanings (4 rows)
col_correspondences: List[Dict[str, Any]] = field(default_factory=list) # Column meanings (4 cols) col_correspondences: List[Dict[str, Any]] = field(
default_factory=list
) # Column meanings (4 cols)
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
def get_position(self, row: int, col: int) -> Optional['EnochianGridPosition']: def get_position(self, row: int, col: int) -> Optional["EnochianGridPosition"]:
"""Get the grid position at (row, col).""" """Get the grid position at (row, col)."""
if not 0 <= row < 4 or not 0 <= col < 4: if not 0 <= row < 4 or not 0 <= col < 4:
return None return None
return self.grid.get((row, col)) return self.grid.get((row, col))
def get_row_correspondence(self, row: int) -> Optional[Dict[str, Any]]: def get_row_correspondence(self, row: int) -> Optional[Dict[str, Any]]:
"""Get the meaning/correspondence for a row.""" """Get the meaning/correspondence for a row."""
if 0 <= row < len(self.row_correspondences): if 0 <= row < len(self.row_correspondences):
return self.row_correspondences[row] return self.row_correspondences[row]
return None return None
def get_col_correspondence(self, col: int) -> Optional[Dict[str, Any]]: def get_col_correspondence(self, col: int) -> Optional[Dict[str, Any]]:
"""Get the meaning/correspondence for a column.""" """Get the meaning/correspondence for a column."""
if 0 <= col < len(self.col_correspondences): if 0 <= col < len(self.col_correspondences):
@@ -148,12 +157,13 @@ class EnochianArchetype:
class EnochianGridPosition: class EnochianGridPosition:
""" """
Represents a single position in an Enochian Tablet grid. Represents a single position in an Enochian Tablet grid.
A 4x4 grid cell with: A 4x4 grid cell with:
- Center letter - Center letter
- Directional letters (N, S, E, W) - Directional letters (N, S, E, W)
- Archetypal correspondences (Tarot, element, etc.) - Archetypal correspondences (Tarot, element, etc.)
""" """
row: int # Grid row (0-3) row: int # Grid row (0-3)
col: int # Grid column (0-3) col: int # Grid column (0-3)
center_letter: str # The main letter at this position center_letter: str # The main letter at this position
@@ -169,7 +179,7 @@ class EnochianGridPosition:
planetary_hour: Optional[str] = None # Associated hour planetary_hour: Optional[str] = None # Associated hour
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
meanings: List[str] = field(default_factory=list) meanings: List[str] = field(default_factory=list)
def get_all_letters(self) -> Dict[str, str]: def get_all_letters(self) -> Dict[str, str]:
"""Get all letters in this position: center and directional.""" """Get all letters in this position: center and directional."""
letters = {"center": self.center_letter} letters = {"center": self.center_letter}
@@ -188,24 +198,27 @@ class EnochianGridPosition:
class EnochianTablet: class EnochianTablet:
""" """
Represents an Enochian Tablet. Represents an Enochian Tablet.
The Enochian system contains: The Enochian system contains:
- 4 elemental tablets (Earth, Water, Air, Fire) - 4 elemental tablets (Earth, Water, Air, Fire)
- 1 tablet of union (Aethyr) - 1 tablet of union (Aethyr)
- Each tablet is 12x12 (144 squares) containing Enochian letters - Each tablet is 12x12 (144 squares) containing Enochian letters
- Archetypal form with 4x4 grid for user customization - Archetypal form with 4x4 grid for user customization
""" """
name: str # e.g., "Tablet of Earth", "Tablet of Air", etc. name: str # e.g., "Tablet of Earth", "Tablet of Air", etc.
number: int # Tablet identifier (1-5) number: int # Tablet identifier (1-5)
element: Optional[str] = None # Earth, Water, Air, Fire, or Aethyr/Union element: Optional[str] = None # Earth, Water, Air, Fire, or Aethyr/Union
rulers: List[str] = field(default_factory=list) # Names of spirits ruling this tablet rulers: List[str] = field(default_factory=list) # Names of spirits ruling this tablet
archangels: List[str] = field(default_factory=list) # Associated archangels archangels: List[str] = field(default_factory=list) # Associated archangels
letters: Dict[Tuple[int, int], str] = field(default_factory=dict) # Grid of letters (row, col) -> letter letters: Dict[Tuple[int, int], str] = field(
default_factory=dict
) # Grid of letters (row, col) -> letter
planetary_hours: List[str] = field(default_factory=list) # Associated hours planetary_hours: List[str] = field(default_factory=list) # Associated hours
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
archetype: Optional[EnochianArchetype] = None # Archetypal form for visualization archetype: Optional[EnochianArchetype] = None # Archetypal form for visualization
# Valid tablets # Valid tablets
VALID_TABLETS = { VALID_TABLETS = {
"Tablet of Union", # Aethyr "Tablet of Union", # Aethyr
@@ -214,12 +227,11 @@ class EnochianTablet:
"Tablet of Air", "Tablet of Air",
"Tablet of Fire", "Tablet of Fire",
} }
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.name not in self.VALID_TABLETS: if self.name not in self.VALID_TABLETS:
raise ValueError( raise ValueError(
f"Invalid tablet '{self.name}'. " f"Invalid tablet '{self.name}'. " f"Valid tablets: {', '.join(self.VALID_TABLETS)}"
f"Valid tablets: {', '.join(self.VALID_TABLETS)}"
) )
# Tablet of Union uses 0, elemental tablets use 1-5 # Tablet of Union uses 0, elemental tablets use 1-5
valid_range = (0, 0) if "Union" in self.name else (1, 5) valid_range = (0, 0) if "Union" in self.name else (1, 5)
@@ -227,23 +239,23 @@ class EnochianTablet:
raise ValueError( raise ValueError(
f"Tablet number must be {valid_range[0]}-{valid_range[1]}, got {self.number}" f"Tablet number must be {valid_range[0]}-{valid_range[1]}, got {self.number}"
) )
def is_elemental(self) -> bool: def is_elemental(self) -> bool:
"""Check if this is an elemental tablet (not union).""" """Check if this is an elemental tablet (not union)."""
return self.element in {"Earth", "Water", "Air", "Fire"} return self.element in {"Earth", "Water", "Air", "Fire"}
def is_union(self) -> bool: def is_union(self) -> bool:
"""Check if this is the Tablet of Union (Aethyr).""" """Check if this is the Tablet of Union (Aethyr)."""
return self.element == "Aethyr" or "Union" in self.name return self.element == "Aethyr" or "Union" in self.name
def get_letter(self, row: int, col: int) -> Optional[str]: def get_letter(self, row: int, col: int) -> Optional[str]:
"""Get letter at specific grid position.""" """Get letter at specific grid position."""
return self.letters.get((row, col)) return self.letters.get((row, col))
def get_row(self, row: int) -> List[Optional[str]]: def get_row(self, row: int) -> List[Optional[str]]:
"""Get all letters in a row.""" """Get all letters in a row."""
return [self.letters.get((row, col)) for col in range(12)] return [self.letters.get((row, col)) for col in range(12)]
def get_column(self, col: int) -> List[Optional[str]]: def get_column(self, col: int) -> List[Optional[str]]:
"""Get all letters in a column.""" """Get all letters in a column."""
return [self.letters.get((row, col)) for row in range(12)] return [self.letters.get((row, col)) for row in range(12)]

View File

@@ -5,18 +5,17 @@ including Tarot correspondences and binary representations.
Usage: Usage:
from letter.iChing import trigram, hexagram from letter.iChing import trigram, hexagram
qian = trigram.trigram('Qian') qian = trigram.trigram('Qian')
creative = hexagram.hexagram(1) creative = hexagram.hexagram(1)
""" """
from typing import TYPE_CHECKING, Dict, Optional from typing import TYPE_CHECKING, Dict
from utils.query import CollectionAccessor from utils.query import CollectionAccessor
if TYPE_CHECKING: if TYPE_CHECKING:
from tarot.card.data import CardDataLoader from tarot.attributes import Hexagram, Trigram
from tarot.attributes import Trigram, Hexagram
def _line_diagram_from_binary(binary: str) -> str: def _line_diagram_from_binary(binary: str) -> str:
@@ -36,14 +35,14 @@ class _Trigram:
def __init__(self) -> None: def __init__(self) -> None:
self._initialized: bool = False self._initialized: bool = False
self._trigrams: Dict[str, 'Trigram'] = {} self._trigrams: Dict[str, "Trigram"] = {}
self.trigram = CollectionAccessor(self._get_trigrams) self.trigram = CollectionAccessor(self._get_trigrams)
def _ensure_initialized(self) -> None: def _ensure_initialized(self) -> None:
"""Load trigrams on first access.""" """Load trigrams on first access."""
if self._initialized: if self._initialized:
return return
self._load_trigrams() self._load_trigrams()
self._initialized = True self._initialized = True
@@ -54,16 +53,80 @@ class _Trigram:
def _load_trigrams(self) -> None: def _load_trigrams(self) -> None:
"""Load the eight I Ching trigrams.""" """Load the eight I Ching trigrams."""
from tarot.attributes import Trigram from tarot.attributes import Trigram
trigram_specs = [ trigram_specs = [
{"name": "Qian", "chinese": "", "pinyin": "Qián", "element": "Heaven", "attribute": "Creative", "binary": "111", "description": "Pure yang drive that initiates action."}, {
{"name": "Dui", "chinese": "", "pinyin": "Duì", "element": "Lake", "attribute": "Joyous", "binary": "011", "description": "Open delight that invites community."}, "name": "Qian",
{"name": "Li", "chinese": "", "pinyin": "", "element": "Fire", "attribute": "Clinging", "binary": "101", "description": "Radiant clarity that adheres to insight."}, "chinese": "",
{"name": "Zhen", "chinese": "", "pinyin": "Zhèn", "element": "Thunder", "attribute": "Arousing", "binary": "001", "description": "Sudden awakening that shakes stagnation."}, "pinyin": "Qián",
{"name": "Xun", "chinese": "", "pinyin": "Xùn", "element": "Wind", "attribute": "Gentle", "binary": "110", "description": "Penetrating influence that persuades subtly."}, "element": "Heaven",
{"name": "Kan", "chinese": "", "pinyin": "Kǎn", "element": "Water", "attribute": "Abysmal", "binary": "010", "description": "Depth, risk, and sincere feeling."}, "attribute": "Creative",
{"name": "Gen", "chinese": "", "pinyin": "Gèn", "element": "Mountain", "attribute": "Stillness", "binary": "100", "description": "Grounded rest that establishes boundaries."}, "binary": "111",
{"name": "Kun", "chinese": "", "pinyin": "Kūn", "element": "Earth", "attribute": "Receptive", "binary": "000", "description": "Vast receptivity that nurtures form."}, "description": "Pure yang drive that initiates action.",
},
{
"name": "Dui",
"chinese": "",
"pinyin": "Duì",
"element": "Lake",
"attribute": "Joyous",
"binary": "011",
"description": "Open delight that invites community.",
},
{
"name": "Li",
"chinese": "",
"pinyin": "",
"element": "Fire",
"attribute": "Clinging",
"binary": "101",
"description": "Radiant clarity that adheres to insight.",
},
{
"name": "Zhen",
"chinese": "",
"pinyin": "Zhèn",
"element": "Thunder",
"attribute": "Arousing",
"binary": "001",
"description": "Sudden awakening that shakes stagnation.",
},
{
"name": "Xun",
"chinese": "",
"pinyin": "Xùn",
"element": "Wind",
"attribute": "Gentle",
"binary": "110",
"description": "Penetrating influence that persuades subtly.",
},
{
"name": "Kan",
"chinese": "",
"pinyin": "Kǎn",
"element": "Water",
"attribute": "Abysmal",
"binary": "010",
"description": "Depth, risk, and sincere feeling.",
},
{
"name": "Gen",
"chinese": "",
"pinyin": "Gèn",
"element": "Mountain",
"attribute": "Stillness",
"binary": "100",
"description": "Grounded rest that establishes boundaries.",
},
{
"name": "Kun",
"chinese": "",
"pinyin": "Kūn",
"element": "Earth",
"attribute": "Receptive",
"binary": "000",
"description": "Vast receptivity that nurtures form.",
},
] ]
self._trigrams = {} self._trigrams = {}
for spec in trigram_specs: for spec in trigram_specs:
@@ -88,14 +151,14 @@ class _Hexagram:
def __init__(self) -> None: def __init__(self) -> None:
self._initialized: bool = False self._initialized: bool = False
self._hexagrams: Dict[int, 'Hexagram'] = {} self._hexagrams: Dict[int, "Hexagram"] = {}
self.hexagram = CollectionAccessor(self._get_hexagrams) self.hexagram = CollectionAccessor(self._get_hexagrams)
def _ensure_initialized(self) -> None: def _ensure_initialized(self) -> None:
"""Load hexagrams on first access.""" """Load hexagrams on first access."""
if self._initialized: if self._initialized:
return return
self._load_hexagrams() self._load_hexagrams()
self._initialized = True self._initialized = True
@@ -107,78 +170,718 @@ class _Hexagram:
"""Load all 64 I Ching hexagrams.""" """Load all 64 I Ching hexagrams."""
from tarot.attributes import Hexagram from tarot.attributes import Hexagram
from tarot.card.data import CardDataLoader, calculate_digital_root from tarot.card.data import CardDataLoader, calculate_digital_root
# Ensure trigrams are loaded first # Ensure trigrams are loaded first
trigram._ensure_initialized() trigram._ensure_initialized()
# Load planets for hexagram correspondences # Load planets for hexagram correspondences
loader = CardDataLoader() loader = CardDataLoader()
hex_specs = [ hex_specs = [
{"number": 1, "name": "Creative Force", "chinese": "", "pinyin": "Qián", "judgement": "Initiative succeeds when anchored in integrity.", "image": "Heaven above and below mirrors unstoppable drive.", "upper": "Qian", "lower": "Qian", "keywords": "Leadership|Momentum|Clarity"}, {
{"number": 2, "name": "Receptive Field", "chinese": "", "pinyin": "Kūn", "judgement": "Grounded support flourishes through patience.", "image": "Earth layered upon earth offers fertile space.", "upper": "Kun", "lower": "Kun", "keywords": "Nurture|Support|Yielding"}, "number": 1,
{"number": 3, "name": "Sprouting", "chinese": "", "pinyin": "Zhūn", "judgement": "Challenges at the start need perseverance.", "image": "Water over thunder shows storms that germinate seeds.", "upper": "Kan", "lower": "Zhen", "keywords": "Beginnings|Struggle|Resolve"}, "name": "Creative Force",
{"number": 4, "name": "Youthful Insight", "chinese": "", "pinyin": "Méng", "judgement": "Ignorance yields to steady guidance.", "image": "Mountain above water signals learning via restraint.", "upper": "Gen", "lower": "Kan", "keywords": "Study|Mentorship|Humility"}, "chinese": "",
{"number": 5, "name": "Waiting", "chinese": "", "pinyin": "", "judgement": "Hold position until nourishment arrives.", "image": "Water above heaven depicts clouds gathering provision.", "upper": "Kan", "lower": "Qian", "keywords": "Patience|Faith|Preparation"}, "pinyin": "Qián",
{"number": 6, "name": "Conflict", "chinese": "", "pinyin": "Sòng", "judgement": "Clarity and fairness prevent escalation.", "image": "Heaven above water shows tension seeking balance.", "upper": "Qian", "lower": "Kan", "keywords": "Debate|Justice|Boundaries"}, "judgement": "Initiative succeeds when anchored in integrity.",
{"number": 7, "name": "Collective Force", "chinese": "", "pinyin": "Shī", "judgement": "Coordinated effort requires disciplined leadership.", "image": "Earth over water mirrors troops marshaling supplies.", "upper": "Kun", "lower": "Kan", "keywords": "Discipline|Leadership|Community"}, "image": "Heaven above and below mirrors unstoppable drive.",
{"number": 8, "name": "Union", "chinese": "", "pinyin": "", "judgement": "Shared values attract loyal allies.", "image": "Water over earth highlights bonds formed through empathy.", "upper": "Kan", "lower": "Kun", "keywords": "Alliance|Affinity|Trust"}, "upper": "Qian",
{"number": 9, "name": "Small Accumulating", "chinese": "小畜", "pinyin": "Xiǎo Chù", "judgement": "Gentle restraint nurtures gradual gains.", "image": "Wind over heaven indicates tender guidance on great power.", "upper": "Xun", "lower": "Qian", "keywords": "Restraint|Cultivation|Care"}, "lower": "Qian",
{"number": 10, "name": "Treading", "chinese": "", "pinyin": "", "judgement": "Walk with awareness when near power.", "image": "Heaven over lake shows respect between ranks.", "upper": "Qian", "lower": "Dui", "keywords": "Conduct|Respect|Sensitivity"}, "keywords": "Leadership|Momentum|Clarity",
{"number": 11, "name": "Peace", "chinese": "", "pinyin": "Tài", "judgement": "Harmony thrives when resources circulate freely.", "image": "Earth over heaven signals prosperity descending.", "upper": "Kun", "lower": "Qian", "keywords": "Harmony|Prosperity|Flourish"}, },
{"number": 12, "name": "Standstill", "chinese": "", "pinyin": "", "judgement": "When channels close, conserve strength.", "image": "Heaven over earth reveals blocked exchange.", "upper": "Qian", "lower": "Kun", "keywords": "Stagnation|Reflection|Pause"}, {
{"number": 13, "name": "Fellowship", "chinese": "同人", "pinyin": "Tóng Rén", "judgement": "Shared purpose unites distant hearts.", "image": "Heaven over fire shows clarity within community.", "upper": "Qian", "lower": "Li", "keywords": "Community|Shared Vision|Openness"}, "number": 2,
{"number": 14, "name": "Great Possession", "chinese": "大有", "pinyin": "Dà Yǒu", "judgement": "Generosity cements lasting influence.", "image": "Fire over heaven reflects radiance sustained by ethics.", "upper": "Li", "lower": "Qian", "keywords": "Wealth|Stewardship|Confidence"}, "name": "Receptive Field",
{"number": 15, "name": "Modesty", "chinese": "", "pinyin": "Qiān", "judgement": "Balance is found by lowering the proud.", "image": "Earth over mountain reveals humility safeguarding strength.", "upper": "Kun", "lower": "Gen", "keywords": "Humility|Balance|Service"}, "chinese": "",
{"number": 16, "name": "Enthusiasm", "chinese": "", "pinyin": "", "judgement": "Inspired music rallies the people.", "image": "Thunder over earth depicts drums stirring hearts.", "upper": "Zhen", "lower": "Kun", "keywords": "Inspiration|Celebration|Momentum"}, "pinyin": "Kūn",
{"number": 17, "name": "Following", "chinese": "", "pinyin": "Suí", "judgement": "Adapt willingly to timely leadership.", "image": "Lake over thunder points to joyful allegiance.", "upper": "Dui", "lower": "Zhen", "keywords": "Adaptation|Loyalty|Flow"}, "judgement": "Grounded support flourishes through patience.",
{"number": 18, "name": "Repairing", "chinese": "", "pinyin": "", "judgement": "Address decay with responsibility and care.", "image": "Mountain over wind shows correction of lineages.", "upper": "Gen", "lower": "Xun", "keywords": "Restoration|Accountability|Healing"}, "image": "Earth layered upon earth offers fertile space.",
{"number": 19, "name": "Approach", "chinese": "", "pinyin": "Lín", "judgement": "Leaders draw near to listen sincerely.", "image": "Earth over lake signifies compassion visiting the people.", "upper": "Kun", "lower": "Dui", "keywords": "Empathy|Guidance|Presence"}, "upper": "Kun",
{"number": 20, "name": "Contemplation", "chinese": "", "pinyin": "Guān", "judgement": "Observation inspires ethical alignment.", "image": "Wind over earth is the elevated view of the sage.", "upper": "Xun", "lower": "Kun", "keywords": "Perspective|Ritual|Vision"}, "lower": "Kun",
{"number": 21, "name": "Biting Through", "chinese": "噬嗑", "pinyin": "Shì Kè", "judgement": "Decisive action cuts through obstruction.", "image": "Fire over thunder shows justice enforced with clarity.", "upper": "Li", "lower": "Zhen", "keywords": "Decision|Justice|Resolve"}, "keywords": "Nurture|Support|Yielding",
{"number": 22, "name": "Grace", "chinese": "", "pinyin": "", "judgement": "Beauty adorns substance when humility remains.", "image": "Mountain over fire highlights poise and restraint.", "upper": "Gen", "lower": "Li", "keywords": "Aesthetics|Poise|Form"}, },
{"number": 23, "name": "Splitting Apart", "chinese": "", "pinyin": "", "judgement": "When decay spreads, strip away excess.", "image": "Mountain over earth signals outer shells falling.", "upper": "Gen", "lower": "Kun", "keywords": "Decline|Release|Truth"}, {
{"number": 24, "name": "Return", "chinese": "", "pinyin": "", "judgement": "Cycles renew when rest follows completion.", "image": "Earth over thunder marks the turning of the year.", "upper": "Kun", "lower": "Zhen", "keywords": "Renewal|Rhythm|Faith"}, "number": 3,
{"number": 25, "name": "Innocence", "chinese": "無妄", "pinyin": "Wú Wàng", "judgement": "Sincerity triumphs over scheming.", "image": "Heaven over thunder shows spontaneous virtue.", "upper": "Qian", "lower": "Zhen", "keywords": "Authenticity|Spontaneity|Trust"}, "name": "Sprouting",
{"number": 26, "name": "Great Taming", "chinese": "大畜", "pinyin": "Dà Chù", "judgement": "Conserve strength until action serves wisdom.", "image": "Mountain over heaven portrays restraint harnessing power.", "upper": "Gen", "lower": "Qian", "keywords": "Discipline|Reserve|Mastery"}, "chinese": "",
{"number": 27, "name": "Nourishment", "chinese": "", "pinyin": "", "judgement": "Words and food alike must be chosen with care.", "image": "Mountain over thunder emphasizes mindful sustenance.", "upper": "Gen", "lower": "Zhen", "keywords": "Nutrition|Speech|Mindfulness"}, "pinyin": "Zhūn",
{"number": 28, "name": "Great Exceeding", "chinese": "大過", "pinyin": "Dà Guò", "judgement": "Bearing heavy loads demands flexibility.", "image": "Lake over wind shows a beam bending before it breaks.", "upper": "Dui", "lower": "Xun", "keywords": "Weight|Adaptability|Responsibility"}, "judgement": "Challenges at the start need perseverance.",
{"number": 29, "name": "The Abyss", "chinese": "", "pinyin": "Kǎn", "judgement": "Repeated trials teach sincere caution.", "image": "Water over water is the perilous gorge.", "upper": "Kan", "lower": "Kan", "keywords": "Trial|Honesty|Depth"}, "image": "Water over thunder shows storms that germinate seeds.",
{"number": 30, "name": "Radiance", "chinese": "", "pinyin": "", "judgement": "Clarity is maintained by tending the flame.", "image": "Fire over fire represents brilliance sustained through care.", "upper": "Li", "lower": "Li", "keywords": "Illumination|Culture|Attention"}, "upper": "Kan",
{"number": 31, "name": "Influence", "chinese": "", "pinyin": "Xián", "judgement": "Sincere attraction arises from mutual respect.", "image": "Lake over mountain highlights responsive hearts.", "upper": "Dui", "lower": "Gen", "keywords": "Attraction|Mutuality|Sensitivity"}, "lower": "Zhen",
{"number": 32, "name": "Duration", "chinese": "", "pinyin": "Héng", "judgement": "Commitment endures when balanced.", "image": "Thunder over wind speaks of constancy amid change.", "upper": "Zhen", "lower": "Xun", "keywords": "Commitment|Consistency|Rhythm"}, "keywords": "Beginnings|Struggle|Resolve",
{"number": 33, "name": "Retreat", "chinese": "", "pinyin": "Dùn", "judgement": "Strategic withdrawal preserves integrity.", "image": "Heaven over mountain shows noble retreat.", "upper": "Qian", "lower": "Gen", "keywords": "Withdrawal|Strategy|Self-care"}, },
{"number": 34, "name": "Great Power", "chinese": "大壯", "pinyin": "Dà Zhuàng", "judgement": "Strength must remain aligned with virtue.", "image": "Thunder over heaven affirms action matched with purpose.", "upper": "Zhen", "lower": "Qian", "keywords": "Power|Ethics|Momentum"}, {
{"number": 35, "name": "Progress", "chinese": "", "pinyin": "Jìn", "judgement": "Advancement arrives through clarity and loyalty.", "image": "Fire over earth depicts dawn spreading across the plain.", "upper": "Li", "lower": "Kun", "keywords": "Advancement|Visibility|Service"}, "number": 4,
{"number": 36, "name": "Darkening Light", "chinese": "明夷", "pinyin": "Míng Yí", "judgement": "Protect the inner light when circumstances grow harsh.", "image": "Earth over fire shows brilliance concealed for safety.", "upper": "Kun", "lower": "Li", "keywords": "Protection|Subtlety|Endurance"}, "name": "Youthful Insight",
{"number": 37, "name": "Family", "chinese": "家人", "pinyin": "Jiā Rén", "judgement": "Clear roles nourish household harmony.", "image": "Wind over fire indicates rituals ordering the home.", "upper": "Xun", "lower": "Li", "keywords": "Home|Roles|Care"}, "chinese": "",
{"number": 38, "name": "Opposition", "chinese": "", "pinyin": "Kuí", "judgement": "Recognize difference without hostility.", "image": "Fire over lake reflects contrast seeking balance.", "upper": "Li", "lower": "Dui", "keywords": "Contrast|Perspective|Tolerance"}, "pinyin": "Méng",
{"number": 39, "name": "Obstruction", "chinese": "", "pinyin": "Jiǎn", "judgement": "Turn hindrance into training.", "image": "Water over mountain shows difficult ascent.", "upper": "Kan", "lower": "Gen", "keywords": "Obstacle|Effort|Learning"}, "judgement": "Ignorance yields to steady guidance.",
{"number": 40, "name": "Deliverance", "chinese": "", "pinyin": "Xiè", "judgement": "Relief comes when knots are untied.", "image": "Thunder over water portrays release after storm.", "upper": "Zhen", "lower": "Kan", "keywords": "Release|Solution|Breath"}, "image": "Mountain above water signals learning via restraint.",
{"number": 41, "name": "Decrease", "chinese": "", "pinyin": "Sǔn", "judgement": "Voluntary simplicity restores balance.", "image": "Mountain over lake shows graceful sharing of resources.", "upper": "Gen", "lower": "Dui", "keywords": "Simplicity|Offering|Balance"}, "upper": "Gen",
{"number": 42, "name": "Increase", "chinese": "", "pinyin": "", "judgement": "Blessings multiply when shared.", "image": "Wind over thunder reveals generous expansion.", "upper": "Xun", "lower": "Zhen", "keywords": "Growth|Generosity|Opportunity"}, "lower": "Kan",
{"number": 43, "name": "Breakthrough", "chinese": "", "pinyin": "Guài", "judgement": "Speak truth boldly to clear corruption.", "image": "Lake over heaven highlights decisive proclamation.", "upper": "Dui", "lower": "Qian", "keywords": "Resolution|Declaration|Courage"}, "keywords": "Study|Mentorship|Humility",
{"number": 44, "name": "Encounter", "chinese": "", "pinyin": "Gòu", "judgement": "Unexpected influence requires discernment.", "image": "Heaven over wind shows potent visitors arriving.", "upper": "Qian", "lower": "Xun", "keywords": "Encounter|Discernment|Temptation"}, },
{"number": 45, "name": "Gathering", "chinese": "", "pinyin": "Cuì", "judgement": "Unity grows when motive is sincere.", "image": "Lake over earth signifies assembly around shared cause.", "upper": "Dui", "lower": "Kun", "keywords": "Assembly|Devotion|Focus"}, {
{"number": 46, "name": "Ascending", "chinese": "", "pinyin": "Shēng", "judgement": "Slow steady progress pierces obstacles.", "image": "Earth over wind shows roots pushing upward.", "upper": "Kun", "lower": "Xun", "keywords": "Growth|Perseverance|Aspiration"}, "number": 5,
{"number": 47, "name": "Oppression", "chinese": "", "pinyin": "Kùn", "judgement": "Constraints refine inner resolve.", "image": "Lake over water indicates fatigue relieved only by integrity.", "upper": "Dui", "lower": "Kan", "keywords": "Constraint|Endurance|Faith"}, "name": "Waiting",
{"number": 48, "name": "The Well", "chinese": "", "pinyin": "Jǐng", "judgement": "Communal resources must be maintained.", "image": "Water over wind depicts a well drawing fresh insight.", "upper": "Kan", "lower": "Xun", "keywords": "Resource|Maintenance|Depth"}, "chinese": "",
{"number": 49, "name": "Revolution", "chinese": "", "pinyin": "", "judgement": "Change succeeds when timing and virtue align.", "image": "Lake over fire indicates shedding the old skin.", "upper": "Dui", "lower": "Li", "keywords": "Change|Timing|Renewal"}, "pinyin": "",
{"number": 50, "name": "The Vessel", "chinese": "", "pinyin": "Dǐng", "judgement": "Elevated service transforms the culture.", "image": "Fire over wind depicts the cauldron that refines offerings.", "upper": "Li", "lower": "Xun", "keywords": "Service|Transformation|Heritage"}, "judgement": "Hold position until nourishment arrives.",
{"number": 51, "name": "Arousing Thunder", "chinese": "", "pinyin": "Zhèn", "judgement": "Shock awakens the heart to reverence.", "image": "Thunder over thunder doubles the drumbeat of alertness.", "upper": "Zhen", "lower": "Zhen", "keywords": "Shock|Awakening|Movement"}, "image": "Water above heaven depicts clouds gathering provision.",
{"number": 52, "name": "Still Mountain", "chinese": "", "pinyin": "Gèn", "judgement": "Cultivate stillness to master desire.", "image": "Mountain over mountain shows unmoving focus.", "upper": "Gen", "lower": "Gen", "keywords": "Stillness|Meditation|Boundaries"}, "upper": "Kan",
{"number": 53, "name": "Gradual Development", "chinese": "", "pinyin": "Jiàn", "judgement": "Lasting progress resembles a tree growing rings.", "image": "Wind over mountain displays slow maturation.", "upper": "Xun", "lower": "Gen", "keywords": "Patience|Evolution|Commitment"}, "lower": "Qian",
{"number": 54, "name": "Marrying Maiden", "chinese": "歸妹", "pinyin": "Guī Mèi", "judgement": "Adjust expectations when circumstances limit rank.", "image": "Thunder over lake spotlights unequal partnerships.", "upper": "Zhen", "lower": "Dui", "keywords": "Transition|Adaptation|Protocol"}, "keywords": "Patience|Faith|Preparation",
{"number": 55, "name": "Abundance", "chinese": "", "pinyin": "Fēng", "judgement": "Radiant success must be handled with balance.", "image": "Thunder over fire illuminates the hall at noon.", "upper": "Zhen", "lower": "Li", "keywords": "Splendor|Responsibility|Timing"}, },
{"number": 56, "name": "The Wanderer", "chinese": "", "pinyin": "", "judgement": "Travel lightly and guard reputation.", "image": "Fire over mountain marks a traveler tending the campfire.", "upper": "Li", "lower": "Gen", "keywords": "Travel|Restraint|Awareness"}, {
{"number": 57, "name": "Gentle Wind", "chinese": "", "pinyin": "Xùn", "judgement": "Persistent influence accomplishes what force cannot.", "image": "Wind over wind indicates subtle penetration.", "upper": "Xun", "lower": "Xun", "keywords": "Penetration|Diplomacy|Subtlety"}, "number": 6,
{"number": 58, "name": "Joyous Lake", "chinese": "", "pinyin": "Duì", "judgement": "Openhearted dialogue dissolves resentment.", "image": "Lake over lake celebrates shared delight.", "upper": "Dui", "lower": "Dui", "keywords": "Joy|Conversation|Trust"}, "name": "Conflict",
{"number": 59, "name": "Dispersion", "chinese": "", "pinyin": "Huàn", "judgement": "Loosen rigid structures so spirit can move.", "image": "Wind over water shows breath dispersing fear.", "upper": "Xun", "lower": "Kan", "keywords": "Dissolve|Freedom|Relief"}, "chinese": "",
{"number": 60, "name": "Limitation", "chinese": "", "pinyin": "Jié", "judgement": "Clear boundaries enable real freedom.", "image": "Water over lake portrays calibrated vessels.", "upper": "Kan", "lower": "Dui", "keywords": "Boundaries|Measure|Discipline"}, "pinyin": "Sòng",
{"number": 61, "name": "Inner Truth", "chinese": "中孚", "pinyin": "Zhōng Fú", "judgement": "Trustworthiness unites disparate groups.", "image": "Wind over lake depicts resonance within the heart.", "upper": "Xun", "lower": "Dui", "keywords": "Sincerity|Empathy|Alignment"}, "judgement": "Clarity and fairness prevent escalation.",
{"number": 62, "name": "Small Exceeding", "chinese": "小過", "pinyin": "Xiǎo Guò", "judgement": "Attend to details when stakes are delicate.", "image": "Thunder over mountain reveals careful movement.", "upper": "Zhen", "lower": "Gen", "keywords": "Detail|Caution|Adjustment"}, "image": "Heaven above water shows tension seeking balance.",
{"number": 63, "name": "After Completion", "chinese": "既濟", "pinyin": "Jì Jì", "judgement": "Success endures only if vigilance continues.", "image": "Water over fire displays balance maintained through work.", "upper": "Kan", "lower": "Li", "keywords": "Completion|Maintenance|Balance"}, "upper": "Qian",
{"number": 64, "name": "Before Completion", "chinese": "未濟", "pinyin": "Wèi Jì", "judgement": "Stay attentive as outcomes crystallize.", "image": "Fire over water illustrates the final push before harmony.", "upper": "Li", "lower": "Kan", "keywords": "Transition|Focus|Preparation"}, "lower": "Kan",
"keywords": "Debate|Justice|Boundaries",
},
{
"number": 7,
"name": "Collective Force",
"chinese": "",
"pinyin": "Shī",
"judgement": "Coordinated effort requires disciplined leadership.",
"image": "Earth over water mirrors troops marshaling supplies.",
"upper": "Kun",
"lower": "Kan",
"keywords": "Discipline|Leadership|Community",
},
{
"number": 8,
"name": "Union",
"chinese": "",
"pinyin": "",
"judgement": "Shared values attract loyal allies.",
"image": "Water over earth highlights bonds formed through empathy.",
"upper": "Kan",
"lower": "Kun",
"keywords": "Alliance|Affinity|Trust",
},
{
"number": 9,
"name": "Small Accumulating",
"chinese": "小畜",
"pinyin": "Xiǎo Chù",
"judgement": "Gentle restraint nurtures gradual gains.",
"image": "Wind over heaven indicates tender guidance on great power.",
"upper": "Xun",
"lower": "Qian",
"keywords": "Restraint|Cultivation|Care",
},
{
"number": 10,
"name": "Treading",
"chinese": "",
"pinyin": "",
"judgement": "Walk with awareness when near power.",
"image": "Heaven over lake shows respect between ranks.",
"upper": "Qian",
"lower": "Dui",
"keywords": "Conduct|Respect|Sensitivity",
},
{
"number": 11,
"name": "Peace",
"chinese": "",
"pinyin": "Tài",
"judgement": "Harmony thrives when resources circulate freely.",
"image": "Earth over heaven signals prosperity descending.",
"upper": "Kun",
"lower": "Qian",
"keywords": "Harmony|Prosperity|Flourish",
},
{
"number": 12,
"name": "Standstill",
"chinese": "",
"pinyin": "",
"judgement": "When channels close, conserve strength.",
"image": "Heaven over earth reveals blocked exchange.",
"upper": "Qian",
"lower": "Kun",
"keywords": "Stagnation|Reflection|Pause",
},
{
"number": 13,
"name": "Fellowship",
"chinese": "同人",
"pinyin": "Tóng Rén",
"judgement": "Shared purpose unites distant hearts.",
"image": "Heaven over fire shows clarity within community.",
"upper": "Qian",
"lower": "Li",
"keywords": "Community|Shared Vision|Openness",
},
{
"number": 14,
"name": "Great Possession",
"chinese": "大有",
"pinyin": "Dà Yǒu",
"judgement": "Generosity cements lasting influence.",
"image": "Fire over heaven reflects radiance sustained by ethics.",
"upper": "Li",
"lower": "Qian",
"keywords": "Wealth|Stewardship|Confidence",
},
{
"number": 15,
"name": "Modesty",
"chinese": "",
"pinyin": "Qiān",
"judgement": "Balance is found by lowering the proud.",
"image": "Earth over mountain reveals humility safeguarding strength.",
"upper": "Kun",
"lower": "Gen",
"keywords": "Humility|Balance|Service",
},
{
"number": 16,
"name": "Enthusiasm",
"chinese": "",
"pinyin": "",
"judgement": "Inspired music rallies the people.",
"image": "Thunder over earth depicts drums stirring hearts.",
"upper": "Zhen",
"lower": "Kun",
"keywords": "Inspiration|Celebration|Momentum",
},
{
"number": 17,
"name": "Following",
"chinese": "",
"pinyin": "Suí",
"judgement": "Adapt willingly to timely leadership.",
"image": "Lake over thunder points to joyful allegiance.",
"upper": "Dui",
"lower": "Zhen",
"keywords": "Adaptation|Loyalty|Flow",
},
{
"number": 18,
"name": "Repairing",
"chinese": "",
"pinyin": "",
"judgement": "Address decay with responsibility and care.",
"image": "Mountain over wind shows correction of lineages.",
"upper": "Gen",
"lower": "Xun",
"keywords": "Restoration|Accountability|Healing",
},
{
"number": 19,
"name": "Approach",
"chinese": "",
"pinyin": "Lín",
"judgement": "Leaders draw near to listen sincerely.",
"image": "Earth over lake signifies compassion visiting the people.",
"upper": "Kun",
"lower": "Dui",
"keywords": "Empathy|Guidance|Presence",
},
{
"number": 20,
"name": "Contemplation",
"chinese": "",
"pinyin": "Guān",
"judgement": "Observation inspires ethical alignment.",
"image": "Wind over earth is the elevated view of the sage.",
"upper": "Xun",
"lower": "Kun",
"keywords": "Perspective|Ritual|Vision",
},
{
"number": 21,
"name": "Biting Through",
"chinese": "噬嗑",
"pinyin": "Shì Kè",
"judgement": "Decisive action cuts through obstruction.",
"image": "Fire over thunder shows justice enforced with clarity.",
"upper": "Li",
"lower": "Zhen",
"keywords": "Decision|Justice|Resolve",
},
{
"number": 22,
"name": "Grace",
"chinese": "",
"pinyin": "",
"judgement": "Beauty adorns substance when humility remains.",
"image": "Mountain over fire highlights poise and restraint.",
"upper": "Gen",
"lower": "Li",
"keywords": "Aesthetics|Poise|Form",
},
{
"number": 23,
"name": "Splitting Apart",
"chinese": "",
"pinyin": "",
"judgement": "When decay spreads, strip away excess.",
"image": "Mountain over earth signals outer shells falling.",
"upper": "Gen",
"lower": "Kun",
"keywords": "Decline|Release|Truth",
},
{
"number": 24,
"name": "Return",
"chinese": "",
"pinyin": "",
"judgement": "Cycles renew when rest follows completion.",
"image": "Earth over thunder marks the turning of the year.",
"upper": "Kun",
"lower": "Zhen",
"keywords": "Renewal|Rhythm|Faith",
},
{
"number": 25,
"name": "Innocence",
"chinese": "無妄",
"pinyin": "Wú Wàng",
"judgement": "Sincerity triumphs over scheming.",
"image": "Heaven over thunder shows spontaneous virtue.",
"upper": "Qian",
"lower": "Zhen",
"keywords": "Authenticity|Spontaneity|Trust",
},
{
"number": 26,
"name": "Great Taming",
"chinese": "大畜",
"pinyin": "Dà Chù",
"judgement": "Conserve strength until action serves wisdom.",
"image": "Mountain over heaven portrays restraint harnessing power.",
"upper": "Gen",
"lower": "Qian",
"keywords": "Discipline|Reserve|Mastery",
},
{
"number": 27,
"name": "Nourishment",
"chinese": "",
"pinyin": "",
"judgement": "Words and food alike must be chosen with care.",
"image": "Mountain over thunder emphasizes mindful sustenance.",
"upper": "Gen",
"lower": "Zhen",
"keywords": "Nutrition|Speech|Mindfulness",
},
{
"number": 28,
"name": "Great Exceeding",
"chinese": "大過",
"pinyin": "Dà Guò",
"judgement": "Bearing heavy loads demands flexibility.",
"image": "Lake over wind shows a beam bending before it breaks.",
"upper": "Dui",
"lower": "Xun",
"keywords": "Weight|Adaptability|Responsibility",
},
{
"number": 29,
"name": "The Abyss",
"chinese": "",
"pinyin": "Kǎn",
"judgement": "Repeated trials teach sincere caution.",
"image": "Water over water is the perilous gorge.",
"upper": "Kan",
"lower": "Kan",
"keywords": "Trial|Honesty|Depth",
},
{
"number": 30,
"name": "Radiance",
"chinese": "",
"pinyin": "",
"judgement": "Clarity is maintained by tending the flame.",
"image": "Fire over fire represents brilliance sustained through care.",
"upper": "Li",
"lower": "Li",
"keywords": "Illumination|Culture|Attention",
},
{
"number": 31,
"name": "Influence",
"chinese": "",
"pinyin": "Xián",
"judgement": "Sincere attraction arises from mutual respect.",
"image": "Lake over mountain highlights responsive hearts.",
"upper": "Dui",
"lower": "Gen",
"keywords": "Attraction|Mutuality|Sensitivity",
},
{
"number": 32,
"name": "Duration",
"chinese": "",
"pinyin": "Héng",
"judgement": "Commitment endures when balanced.",
"image": "Thunder over wind speaks of constancy amid change.",
"upper": "Zhen",
"lower": "Xun",
"keywords": "Commitment|Consistency|Rhythm",
},
{
"number": 33,
"name": "Retreat",
"chinese": "",
"pinyin": "Dùn",
"judgement": "Strategic withdrawal preserves integrity.",
"image": "Heaven over mountain shows noble retreat.",
"upper": "Qian",
"lower": "Gen",
"keywords": "Withdrawal|Strategy|Self-care",
},
{
"number": 34,
"name": "Great Power",
"chinese": "大壯",
"pinyin": "Dà Zhuàng",
"judgement": "Strength must remain aligned with virtue.",
"image": "Thunder over heaven affirms action matched with purpose.",
"upper": "Zhen",
"lower": "Qian",
"keywords": "Power|Ethics|Momentum",
},
{
"number": 35,
"name": "Progress",
"chinese": "",
"pinyin": "Jìn",
"judgement": "Advancement arrives through clarity and loyalty.",
"image": "Fire over earth depicts dawn spreading across the plain.",
"upper": "Li",
"lower": "Kun",
"keywords": "Advancement|Visibility|Service",
},
{
"number": 36,
"name": "Darkening Light",
"chinese": "明夷",
"pinyin": "Míng Yí",
"judgement": "Protect the inner light when circumstances grow harsh.",
"image": "Earth over fire shows brilliance concealed for safety.",
"upper": "Kun",
"lower": "Li",
"keywords": "Protection|Subtlety|Endurance",
},
{
"number": 37,
"name": "Family",
"chinese": "家人",
"pinyin": "Jiā Rén",
"judgement": "Clear roles nourish household harmony.",
"image": "Wind over fire indicates rituals ordering the home.",
"upper": "Xun",
"lower": "Li",
"keywords": "Home|Roles|Care",
},
{
"number": 38,
"name": "Opposition",
"chinese": "",
"pinyin": "Kuí",
"judgement": "Recognize difference without hostility.",
"image": "Fire over lake reflects contrast seeking balance.",
"upper": "Li",
"lower": "Dui",
"keywords": "Contrast|Perspective|Tolerance",
},
{
"number": 39,
"name": "Obstruction",
"chinese": "",
"pinyin": "Jiǎn",
"judgement": "Turn hindrance into training.",
"image": "Water over mountain shows difficult ascent.",
"upper": "Kan",
"lower": "Gen",
"keywords": "Obstacle|Effort|Learning",
},
{
"number": 40,
"name": "Deliverance",
"chinese": "",
"pinyin": "Xiè",
"judgement": "Relief comes when knots are untied.",
"image": "Thunder over water portrays release after storm.",
"upper": "Zhen",
"lower": "Kan",
"keywords": "Release|Solution|Breath",
},
{
"number": 41,
"name": "Decrease",
"chinese": "",
"pinyin": "Sǔn",
"judgement": "Voluntary simplicity restores balance.",
"image": "Mountain over lake shows graceful sharing of resources.",
"upper": "Gen",
"lower": "Dui",
"keywords": "Simplicity|Offering|Balance",
},
{
"number": 42,
"name": "Increase",
"chinese": "",
"pinyin": "",
"judgement": "Blessings multiply when shared.",
"image": "Wind over thunder reveals generous expansion.",
"upper": "Xun",
"lower": "Zhen",
"keywords": "Growth|Generosity|Opportunity",
},
{
"number": 43,
"name": "Breakthrough",
"chinese": "",
"pinyin": "Guài",
"judgement": "Speak truth boldly to clear corruption.",
"image": "Lake over heaven highlights decisive proclamation.",
"upper": "Dui",
"lower": "Qian",
"keywords": "Resolution|Declaration|Courage",
},
{
"number": 44,
"name": "Encounter",
"chinese": "",
"pinyin": "Gòu",
"judgement": "Unexpected influence requires discernment.",
"image": "Heaven over wind shows potent visitors arriving.",
"upper": "Qian",
"lower": "Xun",
"keywords": "Encounter|Discernment|Temptation",
},
{
"number": 45,
"name": "Gathering",
"chinese": "",
"pinyin": "Cuì",
"judgement": "Unity grows when motive is sincere.",
"image": "Lake over earth signifies assembly around shared cause.",
"upper": "Dui",
"lower": "Kun",
"keywords": "Assembly|Devotion|Focus",
},
{
"number": 46,
"name": "Ascending",
"chinese": "",
"pinyin": "Shēng",
"judgement": "Slow steady progress pierces obstacles.",
"image": "Earth over wind shows roots pushing upward.",
"upper": "Kun",
"lower": "Xun",
"keywords": "Growth|Perseverance|Aspiration",
},
{
"number": 47,
"name": "Oppression",
"chinese": "",
"pinyin": "Kùn",
"judgement": "Constraints refine inner resolve.",
"image": "Lake over water indicates fatigue relieved only by integrity.",
"upper": "Dui",
"lower": "Kan",
"keywords": "Constraint|Endurance|Faith",
},
{
"number": 48,
"name": "The Well",
"chinese": "",
"pinyin": "Jǐng",
"judgement": "Communal resources must be maintained.",
"image": "Water over wind depicts a well drawing fresh insight.",
"upper": "Kan",
"lower": "Xun",
"keywords": "Resource|Maintenance|Depth",
},
{
"number": 49,
"name": "Revolution",
"chinese": "",
"pinyin": "",
"judgement": "Change succeeds when timing and virtue align.",
"image": "Lake over fire indicates shedding the old skin.",
"upper": "Dui",
"lower": "Li",
"keywords": "Change|Timing|Renewal",
},
{
"number": 50,
"name": "The Vessel",
"chinese": "",
"pinyin": "Dǐng",
"judgement": "Elevated service transforms the culture.",
"image": "Fire over wind depicts the cauldron that refines offerings.",
"upper": "Li",
"lower": "Xun",
"keywords": "Service|Transformation|Heritage",
},
{
"number": 51,
"name": "Arousing Thunder",
"chinese": "",
"pinyin": "Zhèn",
"judgement": "Shock awakens the heart to reverence.",
"image": "Thunder over thunder doubles the drumbeat of alertness.",
"upper": "Zhen",
"lower": "Zhen",
"keywords": "Shock|Awakening|Movement",
},
{
"number": 52,
"name": "Still Mountain",
"chinese": "",
"pinyin": "Gèn",
"judgement": "Cultivate stillness to master desire.",
"image": "Mountain over mountain shows unmoving focus.",
"upper": "Gen",
"lower": "Gen",
"keywords": "Stillness|Meditation|Boundaries",
},
{
"number": 53,
"name": "Gradual Development",
"chinese": "",
"pinyin": "Jiàn",
"judgement": "Lasting progress resembles a tree growing rings.",
"image": "Wind over mountain displays slow maturation.",
"upper": "Xun",
"lower": "Gen",
"keywords": "Patience|Evolution|Commitment",
},
{
"number": 54,
"name": "Marrying Maiden",
"chinese": "歸妹",
"pinyin": "Guī Mèi",
"judgement": "Adjust expectations when circumstances limit rank.",
"image": "Thunder over lake spotlights unequal partnerships.",
"upper": "Zhen",
"lower": "Dui",
"keywords": "Transition|Adaptation|Protocol",
},
{
"number": 55,
"name": "Abundance",
"chinese": "",
"pinyin": "Fēng",
"judgement": "Radiant success must be handled with balance.",
"image": "Thunder over fire illuminates the hall at noon.",
"upper": "Zhen",
"lower": "Li",
"keywords": "Splendor|Responsibility|Timing",
},
{
"number": 56,
"name": "The Wanderer",
"chinese": "",
"pinyin": "",
"judgement": "Travel lightly and guard reputation.",
"image": "Fire over mountain marks a traveler tending the campfire.",
"upper": "Li",
"lower": "Gen",
"keywords": "Travel|Restraint|Awareness",
},
{
"number": 57,
"name": "Gentle Wind",
"chinese": "",
"pinyin": "Xùn",
"judgement": "Persistent influence accomplishes what force cannot.",
"image": "Wind over wind indicates subtle penetration.",
"upper": "Xun",
"lower": "Xun",
"keywords": "Penetration|Diplomacy|Subtlety",
},
{
"number": 58,
"name": "Joyous Lake",
"chinese": "",
"pinyin": "Duì",
"judgement": "Openhearted dialogue dissolves resentment.",
"image": "Lake over lake celebrates shared delight.",
"upper": "Dui",
"lower": "Dui",
"keywords": "Joy|Conversation|Trust",
},
{
"number": 59,
"name": "Dispersion",
"chinese": "",
"pinyin": "Huàn",
"judgement": "Loosen rigid structures so spirit can move.",
"image": "Wind over water shows breath dispersing fear.",
"upper": "Xun",
"lower": "Kan",
"keywords": "Dissolve|Freedom|Relief",
},
{
"number": 60,
"name": "Limitation",
"chinese": "",
"pinyin": "Jié",
"judgement": "Clear boundaries enable real freedom.",
"image": "Water over lake portrays calibrated vessels.",
"upper": "Kan",
"lower": "Dui",
"keywords": "Boundaries|Measure|Discipline",
},
{
"number": 61,
"name": "Inner Truth",
"chinese": "中孚",
"pinyin": "Zhōng Fú",
"judgement": "Trustworthiness unites disparate groups.",
"image": "Wind over lake depicts resonance within the heart.",
"upper": "Xun",
"lower": "Dui",
"keywords": "Sincerity|Empathy|Alignment",
},
{
"number": 62,
"name": "Small Exceeding",
"chinese": "小過",
"pinyin": "Xiǎo Guò",
"judgement": "Attend to details when stakes are delicate.",
"image": "Thunder over mountain reveals careful movement.",
"upper": "Zhen",
"lower": "Gen",
"keywords": "Detail|Caution|Adjustment",
},
{
"number": 63,
"name": "After Completion",
"chinese": "既濟",
"pinyin": "Jì Jì",
"judgement": "Success endures only if vigilance continues.",
"image": "Water over fire displays balance maintained through work.",
"upper": "Kan",
"lower": "Li",
"keywords": "Completion|Maintenance|Balance",
},
{
"number": 64,
"name": "Before Completion",
"chinese": "未濟",
"pinyin": "Wèi Jì",
"judgement": "Stay attentive as outcomes crystallize.",
"image": "Fire over water illustrates the final push before harmony.",
"upper": "Li",
"lower": "Kan",
"keywords": "Transition|Focus|Preparation",
},
] ]
planet_cycle = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Earth"] planet_cycle = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Earth"]
self._hexagrams = {} self._hexagrams = {}

View File

@@ -14,6 +14,7 @@ from utils.attributes import Number, Planet
@dataclass @dataclass
class Trigram: class Trigram:
"""Represents one of the eight I Ching trigrams.""" """Represents one of the eight I Ching trigrams."""
name: str name: str
chinese_name: str chinese_name: str
pinyin: str pinyin: str
@@ -27,6 +28,7 @@ class Trigram:
@dataclass @dataclass
class Hexagram: class Hexagram:
"""Represents an I Ching hexagram with Tarot correspondence.""" """Represents an I Ching hexagram with Tarot correspondence."""
number: int number: int
name: str name: str
chinese_name: str chinese_name: str

View File

@@ -13,7 +13,7 @@ class Letter:
def __init__(self) -> None: def __init__(self) -> None:
self._initialized: bool = False self._initialized: bool = False
self._loader: 'CardDataLoader | None' = None self._loader: "CardDataLoader | None" = None
self.alphabet = CollectionAccessor(self._get_alphabets) self.alphabet = CollectionAccessor(self._get_alphabets)
self.cipher = CollectionAccessor(self._get_ciphers) self.cipher = CollectionAccessor(self._get_ciphers)
self.letter = CollectionAccessor(self._get_letters) self.letter = CollectionAccessor(self._get_letters)
@@ -26,10 +26,11 @@ class Letter:
return return
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
self._loader = CardDataLoader() self._loader = CardDataLoader()
self._initialized = True self._initialized = True
def _require_loader(self) -> 'CardDataLoader': def _require_loader(self) -> "CardDataLoader":
self._ensure_initialized() self._ensure_initialized()
assert self._loader is not None, "Loader not initialized" assert self._loader is not None, "Loader not initialized"
return self._loader return self._loader
@@ -54,10 +55,10 @@ class Letter:
loader = self._require_loader() loader = self._require_loader()
return loader._periodic_table.copy() return loader._periodic_table.copy()
def word(self, text: str, *, alphabet: str = 'english'): def word(self, text: str, *, alphabet: str = "english"):
""" """
Start a fluent cipher request for the given text. Start a fluent cipher request for the given text.
Usage: Usage:
letter.word('MAGICK').cipher('english_simple') letter.word('MAGICK').cipher('english_simple')
letter.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard') letter.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard')

View File

@@ -17,66 +17,68 @@ Each letter has attributes like:
- Musical Note - Musical Note
""" """
from typing import List, Optional, Dict, Union, TYPE_CHECKING from dataclasses import dataclass
from dataclasses import dataclass, field from typing import TYPE_CHECKING, Dict, List, Optional, Union
from utils.filter import universal_filter, get_filterable_fields, format_results
from utils.filter import format_results, get_filterable_fields, universal_filter
if TYPE_CHECKING: if TYPE_CHECKING:
from utils.query import CollectionAccessor
from tarot.attributes import Path from tarot.attributes import Path
from utils.query import CollectionAccessor
@dataclass @dataclass
class TarotLetter: class TarotLetter:
""" """
Represents a Hebrew letter with full Tarot correspondences. Represents a Hebrew letter with full Tarot correspondences.
Wraps Path objects from CardDataLoader to provide a letter-focused interface Wraps Path objects from CardDataLoader to provide a letter-focused interface
while maintaining a single source of truth. while maintaining a single source of truth.
""" """
path: 'Path' # Reference to the actual Path object from CardDataLoader
path: "Path" # Reference to the actual Path object from CardDataLoader
letter_type: str # "Mother", "Double", or "Simple" (derived from path) letter_type: str # "Mother", "Double", or "Simple" (derived from path)
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Validate that path is set.""" """Validate that path is set."""
if not self.path: if not self.path:
raise ValueError("TarotLetter requires a valid Path object") raise ValueError("TarotLetter requires a valid Path object")
@property @property
def hebrew_letter(self) -> str: def hebrew_letter(self) -> str:
"""Get Hebrew letter character.""" """Get Hebrew letter character."""
return self.path.hebrew_letter or "" return self.path.hebrew_letter or ""
@property @property
def transliteration(self) -> str: def transliteration(self) -> str:
"""Get transliterated name.""" """Get transliterated name."""
return self.path.transliteration or "" return self.path.transliteration or ""
@property @property
def position(self) -> int: def position(self) -> int:
"""Get position (1-22 for paths).""" """Get position (1-22 for paths)."""
return self.path.number return self.path.number
@property @property
def trump(self) -> Optional[str]: def trump(self) -> Optional[str]:
"""Get Tarot trump designation.""" """Get Tarot trump designation."""
return self.path.tarot_trump return self.path.tarot_trump
@property @property
def element(self) -> Optional[str]: def element(self) -> Optional[str]:
"""Get element name if applicable.""" """Get element name if applicable."""
return self.path.element.name if self.path.element else None return self.path.element.name if self.path.element else None
@property @property
def planet(self) -> Optional[str]: def planet(self) -> Optional[str]:
"""Get planet name if applicable.""" """Get planet name if applicable."""
return self.path.planet.name if self.path.planet else None return self.path.planet.name if self.path.planet else None
@property @property
def zodiac(self) -> Optional[str]: def zodiac(self) -> Optional[str]:
"""Get zodiac sign if applicable.""" """Get zodiac sign if applicable."""
return self.path.zodiac_sign return self.path.zodiac_sign
@property @property
def intelligence(self) -> Optional[str]: def intelligence(self) -> Optional[str]:
"""Get archangel/intelligence name from associated gods.""" """Get archangel/intelligence name from associated gods."""
@@ -85,17 +87,17 @@ class TarotLetter:
if all_gods: if all_gods:
return all_gods[0].name return all_gods[0].name
return None return None
@property @property
def meaning(self) -> Optional[str]: def meaning(self) -> Optional[str]:
"""Get path meaning/description.""" """Get path meaning/description."""
return self.path.description return self.path.description
@property @property
def keywords(self) -> List[str]: def keywords(self) -> List[str]:
"""Get keywords associated with path.""" """Get keywords associated with path."""
return self.path.keywords or [] return self.path.keywords or []
def display(self) -> str: def display(self) -> str:
"""Format letter for display.""" """Format letter for display."""
lines = [ lines = [
@@ -104,7 +106,7 @@ class TarotLetter:
f"Type: {self.letter_type}", f"Type: {self.letter_type}",
f"Position: {self.position}", f"Position: {self.position}",
] ]
if self.trump: if self.trump:
lines.append(f"Trump: {self.trump}") lines.append(f"Trump: {self.trump}")
if self.zodiac: if self.zodiac:
@@ -119,20 +121,20 @@ class TarotLetter:
lines.append(f"Meaning: {self.meaning}") lines.append(f"Meaning: {self.meaning}")
if self.keywords: if self.keywords:
lines.append(f"Keywords: {', '.join(self.keywords)}") lines.append(f"Keywords: {', '.join(self.keywords)}")
return "\n".join(lines) return "\n".join(lines)
class LetterAccessor: class LetterAccessor:
"""Fluent accessor for Tarot letters.""" """Fluent accessor for Tarot letters."""
def __init__(self, letters_dict: Dict[str, TarotLetter]) -> None: def __init__(self, letters_dict: Dict[str, TarotLetter]) -> None:
self._letters = letters_dict self._letters = letters_dict
def __call__(self, transliteration: str) -> Optional[TarotLetter]: def __call__(self, transliteration: str) -> Optional[TarotLetter]:
"""Get a letter by transliteration (e.g., 'aleph', 'beth', 'gimel').""" """Get a letter by transliteration (e.g., 'aleph', 'beth', 'gimel')."""
return self._letters.get(transliteration.lower()) return self._letters.get(transliteration.lower())
def __getitem__(self, key: Union[str, int]) -> Optional[TarotLetter]: def __getitem__(self, key: Union[str, int]) -> Optional[TarotLetter]:
"""Get letter by name or position.""" """Get letter by name or position."""
if isinstance(key, int): if isinstance(key, int):
@@ -142,55 +144,59 @@ class LetterAccessor:
return letter return letter
return None return None
return self(key) return self(key)
def all(self) -> List[TarotLetter]: def all(self) -> List[TarotLetter]:
"""Get all letters.""" """Get all letters."""
return sorted(self._letters.values(), key=lambda x: x.position) return sorted(self._letters.values(), key=lambda x: x.position)
def by_type(self, letter_type: str) -> List[TarotLetter]: def by_type(self, letter_type: str) -> List[TarotLetter]:
"""Filter by type: 'Mother', 'Double', or 'Simple'.""" """Filter by type: 'Mother', 'Double', or 'Simple'."""
return [l for l in self._letters.values() if l.letter_type == letter_type] return [letter for letter in self._letters.values() if letter.letter_type == letter_type]
def by_zodiac(self, zodiac: str) -> Optional[TarotLetter]: def by_zodiac(self, zodiac: str) -> Optional[TarotLetter]:
"""Get letter by zodiac sign.""" """Get letter by zodiac sign."""
for letter in self._letters.values(): for letter in self._letters.values():
if letter.zodiac and zodiac.lower() in letter.zodiac.lower(): if letter.zodiac and zodiac.lower() in letter.zodiac.lower():
return letter return letter
return None return None
def by_planet(self, planet: str) -> List[TarotLetter]: def by_planet(self, planet: str) -> List[TarotLetter]:
"""Get letters by planet.""" """Get letters by planet."""
return [l for l in self._letters.values() if l.planet and planet.lower() in l.planet.lower()] return [
letter
for letter in self._letters.values()
if letter.planet and planet.lower() in letter.planet.lower()
]
def by_trump(self, trump: str) -> Optional[TarotLetter]: def by_trump(self, trump: str) -> Optional[TarotLetter]:
"""Get letter by tarot trump.""" """Get letter by tarot trump."""
return next((l for l in self._letters.values() if l.trump == trump), None) return next((letter for letter in self._letters.values() if letter.trump == trump), None)
def get_filterable_fields(self) -> List[str]: def get_filterable_fields(self) -> List[str]:
""" """
Dynamically get all filterable fields from TarotLetter. Dynamically get all filterable fields from TarotLetter.
Returns the same fields as the universal filter utility. Returns the same fields as the universal filter utility.
Useful for introspection and validation. Useful for introspection and validation.
""" """
return get_filterable_fields(TarotLetter) return get_filterable_fields(TarotLetter)
def filter(self, **kwargs) -> List[TarotLetter]: def filter(self, **kwargs) -> List[TarotLetter]:
""" """
Filter letters by any TarotLetter attribute. Filter letters by any TarotLetter attribute.
Uses the universal filter from utils.filter for consistency Uses the universal filter from utils.filter for consistency
across the entire project. across the entire project.
The filter automatically handles all fields from the TarotLetter dataclass: The filter automatically handles all fields from the TarotLetter dataclass:
- letter_type, element, trump, zodiac, planet - letter_type, element, trump, zodiac, planet
- king, queen, prince, princess - king, queen, prince, princess
- cube, intelligence, note, meaning, hebrew_letter, transliteration, position - cube, intelligence, note, meaning, hebrew_letter, transliteration, position
- keywords (list matching) - keywords (list matching)
Args: Args:
**kwargs: Any TarotLetter attribute with its value **kwargs: Any TarotLetter attribute with its value
Usage: Usage:
Tarot.letters.filter(letter_type="Simple") Tarot.letters.filter(letter_type="Simple")
Tarot.letters.filter(element="Fire") Tarot.letters.filter(element="Fire")
@@ -198,30 +204,30 @@ class LetterAccessor:
Tarot.letters.filter(element="Air", letter_type="Mother") Tarot.letters.filter(element="Air", letter_type="Mother")
Tarot.letters.filter(intelligence="Metatron") Tarot.letters.filter(intelligence="Metatron")
Tarot.letters.filter(position=1) Tarot.letters.filter(position=1)
Returns: Returns:
List of TarotLetter objects matching all filters List of TarotLetter objects matching all filters
""" """
return universal_filter(self.all(), **kwargs) return universal_filter(self.all(), **kwargs)
def display_filter(self, **kwargs) -> str: def display_filter(self, **kwargs) -> str:
""" """
Filter letters and display results nicely formatted. Filter letters and display results nicely formatted.
Combines filtering and formatting in one call. Combines filtering and formatting in one call.
Args: Args:
**kwargs: Any TarotLetter attribute with its value **kwargs: Any TarotLetter attribute with its value
Returns: Returns:
Formatted string with filtered letters Formatted string with filtered letters
Example: Example:
print(Tarot.letters.display_filter(element="Fire")) print(Tarot.letters.display_filter(element="Fire"))
""" """
results = self.filter(**kwargs) results = self.filter(**kwargs)
return format_results(results) return format_results(results)
def display_all(self) -> str: def display_all(self) -> str:
"""Display all letters formatted.""" """Display all letters formatted."""
lines = [] lines = []
@@ -229,20 +235,20 @@ class LetterAccessor:
lines.append(letter.display()) lines.append(letter.display())
lines.append("-" * 50) lines.append("-" * 50)
return "\n".join(lines) return "\n".join(lines)
def display_by_type(self, letter_type: str) -> str: def display_by_type(self, letter_type: str) -> str:
"""Display all letters of a specific type.""" """Display all letters of a specific type."""
letters = self.by_type(letter_type) letters = self.by_type(letter_type)
if not letters: if not letters:
return f"No letters found with type: {letter_type}" return f"No letters found with type: {letter_type}"
lines = [f"\n{letter_type.upper()} LETTERS ({len(letters)} total)"] lines = [f"\n{letter_type.upper()} LETTERS ({len(letters)} total)"]
lines.append("=" * 50) lines.append("=" * 50)
for letter in letters: for letter in letters:
lines.append(letter.display()) lines.append(letter.display())
lines.append("-" * 50) lines.append("-" * 50)
return "\n".join(lines) return "\n".join(lines)
@property @property
def iChing(self): def iChing(self):
"""Access I Ching trigrams and hexagrams.""" """Access I Ching trigrams and hexagrams."""
@@ -251,33 +257,34 @@ class LetterAccessor:
class IChing: class IChing:
"""Namespace for I Ching trigrams and hexagrams access. """Namespace for I Ching trigrams and hexagrams access.
Provides fluent query interface for accessing I Ching trigrams and hexagrams Provides fluent query interface for accessing I Ching trigrams and hexagrams
with Tarot correspondences. with Tarot correspondences.
Usage: Usage:
trigrams = Tarot.letters.iChing.trigram trigrams = Tarot.letters.iChing.trigram
qian = trigrams.name('Qian') qian = trigrams.name('Qian')
all_trigrams = trigrams.all() all_trigrams = trigrams.all()
hexagrams = Tarot.letters.iChing.hexagram hexagrams = Tarot.letters.iChing.hexagram
hex1 = hexagrams.all()[1] hex1 = hexagrams.all()[1]
all_hex = hexagrams.list() all_hex = hexagrams.list()
""" """
trigram: 'CollectionAccessor' trigram: "CollectionAccessor"
hexagram: 'CollectionAccessor' hexagram: "CollectionAccessor"
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize iChing accessor with trigram and hexagram collections.""" """Initialize iChing accessor with trigram and hexagram collections."""
from tarot.letter import iChing as iching_module from tarot.letter import iChing as iching_module
self.trigram = iching_module.trigram.trigram self.trigram = iching_module.trigram.trigram
self.hexagram = iching_module.hexagram.hexagram self.hexagram = iching_module.hexagram.hexagram
def __repr__(self) -> str: def __repr__(self) -> str:
"""Clean representation of iChing namespace.""" """Clean representation of iChing namespace."""
return "IChing(trigram, hexagram)" return "IChing(trigram, hexagram)"
def __str__(self) -> str: def __str__(self) -> str:
"""String representation of iChing namespace.""" """String representation of iChing namespace."""
return "I Ching (trigrams and hexagrams)" return "I Ching (trigrams and hexagrams)"
@@ -285,50 +292,50 @@ class IChing:
class LettersRegistry: class LettersRegistry:
"""Registry and accessor for all Hebrew letters with Tarot correspondences.""" """Registry and accessor for all Hebrew letters with Tarot correspondences."""
_instance: Optional['LettersRegistry'] = None _instance: Optional["LettersRegistry"] = None
_letters: Dict[str, TarotLetter] = {} _letters: Dict[str, TarotLetter] = {}
_initialized: bool = False _initialized: bool = False
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self) -> None: def __init__(self) -> None:
if self._initialized: if self._initialized:
return return
self._initialize_letters() self._initialize_letters()
self._initialized = True self._initialized = True
def _initialize_letters(self) -> None: def _initialize_letters(self) -> None:
"""Initialize all 22 Hebrew letters by wrapping Path objects from CardDataLoader.""" """Initialize all 22 Hebrew letters by wrapping Path objects from CardDataLoader."""
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
loader = CardDataLoader() loader = CardDataLoader()
paths = loader.path() # Get all 22 paths paths = loader.path() # Get all 22 paths
self._letters = {} self._letters = {}
# Map each path (11-32) to a TarotLetter with appropriate type # Map each path (11-32) to a TarotLetter with appropriate type
for path_number, path in paths.items(): for path_number, path in paths.items():
# Determine letter type based on path number # Determine letter type based on path number
# Mother letters: 11 (Aleph), 23 (Mem), 31 (Shin) # Mother letters: 11 (Aleph), 23 (Mem), 31 (Shin)
# Double letters: 12, 13, 14, 15, 18, 21, 22 # Double letters: 12, 13, 14, 15, 18, 21, 22
# Simple (Zodiacal/Planetary): 16, 17, 19, 20, 24, 25, 26, 27, 28, 29, 30, 32 # Simple (Zodiacal/Planetary): 16, 17, 19, 20, 24, 25, 26, 27, 28, 29, 30, 32
if path_number in {11, 23, 31}: if path_number in {11, 23, 31}:
letter_type = "Mother" letter_type = "Mother"
elif path_number in {12, 13, 14, 15, 18, 21, 22}: elif path_number in {12, 13, 14, 15, 18, 21, 22}:
letter_type = "Double" letter_type = "Double"
else: else:
letter_type = "Simple" letter_type = "Simple"
# Create TarotLetter wrapping the path # Create TarotLetter wrapping the path
letter_key = path.transliteration.lower() letter_key = path.transliteration.lower()
self._letters[letter_key] = TarotLetter(path=path, letter_type=letter_type) self._letters[letter_key] = TarotLetter(path=path, letter_type=letter_type)
def accessor(self) -> LetterAccessor: def accessor(self) -> LetterAccessor:
"""Get the letter accessor.""" """Get the letter accessor."""
return LetterAccessor(self._letters) return LetterAccessor(self._letters)

View File

@@ -8,25 +8,26 @@ if TYPE_CHECKING:
class _Word: class _Word:
"""Fluent accessor for word analysis and cipher operations.""" """Fluent accessor for word analysis and cipher operations."""
_loader: 'CardDataLoader | None' = None _loader: "CardDataLoader | None" = None
_initialized: bool = False _initialized: bool = False
@classmethod @classmethod
def _ensure_initialized(cls) -> None: def _ensure_initialized(cls) -> None:
"""Lazy-load CardDataLoader on first access.""" """Lazy-load CardDataLoader on first access."""
if cls._initialized: if cls._initialized:
return return
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
cls._loader = CardDataLoader() cls._loader = CardDataLoader()
cls._initialized = True cls._initialized = True
@classmethod @classmethod
def word(cls, text: str, *, alphabet: str = 'english'): def word(cls, text: str, *, alphabet: str = "english"):
""" """
Start a fluent cipher request for the given text. Start a fluent cipher request for the given text.
Usage: Usage:
word.word('MAGICK').cipher('english_simple') word.word('MAGICK').cipher('english_simple')
word.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard') word.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard')

View File

@@ -8,12 +8,12 @@ Provides fluent query interface for:
Usage: Usage:
from tarot import number from tarot import number
num = number.number(5) num = number.number(5)
root = number.digital_root(256) root = number.digital_root(256)
colors = number.color() colors = number.color()
""" """
from .number import number, calculate_digital_root from .number import calculate_digital_root, number
__all__ = ["number", "calculate_digital_root"] __all__ = ["number", "calculate_digital_root"]

View File

@@ -1,22 +1,26 @@
"""Numbers loader - access to numerology and number correspondences.""" """Numbers loader - access to numerology and number correspondences."""
from typing import Dict, Optional, Union, overload from typing import TYPE_CHECKING, Dict, Optional, Union, overload
from utils.filter import universal_filter from utils.filter import universal_filter
if TYPE_CHECKING:
from utils.attributes import Color, Number
def calculate_digital_root(value: int) -> int: def calculate_digital_root(value: int) -> int:
""" """
Calculate the digital root of a number by repeatedly summing its digits. Calculate the digital root of a number by repeatedly summing its digits.
Digital root reduces any number to a single digit (1-9) by repeatedly Digital root reduces any number to a single digit (1-9) by repeatedly
summing its digits until a single digit remains. summing its digits until a single digit remains.
Args: Args:
value: The number to reduce to digital root value: The number to reduce to digital root
Returns: Returns:
The digital root (1-9) The digital root (1-9)
Examples: Examples:
>>> calculate_digital_root(14) # 1+4 = 5 >>> calculate_digital_root(14) # 1+4 = 5
5 5
@@ -27,172 +31,173 @@ def calculate_digital_root(value: int) -> int:
""" """
if value < 1: if value < 1:
raise ValueError(f"Value must be positive, got {value}") raise ValueError(f"Value must be positive, got {value}")
while value >= 10: while value >= 10:
value = sum(int(digit) for digit in str(value)) value = sum(int(digit) for digit in str(value))
return value return value
class Numbers: class Numbers:
""" """
Unified accessor for numerology, numbers, and color correspondences. Unified accessor for numerology, numbers, and color correspondences.
All methods are class methods, so Numbers is accessed as a static namespace: All methods are class methods, so Numbers is accessed as a static namespace:
num = Numbers.number(5) num = Numbers.number(5)
root = Numbers.digital_root(256) root = Numbers.digital_root(256)
color = Numbers.color_by_number(root) color = Numbers.color_by_number(root)
""" """
# These are populated on first access from CardDataLoader # These are populated on first access from CardDataLoader
_numbers: Dict[int, 'Number'] = {} # type: ignore _numbers: Dict[int, "Number"] = {} # type: ignore
_colors: Dict[int, 'Color'] = {} # type: ignore _colors: Dict[int, "Color"] = {} # type: ignore
_initialized: bool = False _initialized: bool = False
@classmethod @classmethod
def _ensure_initialized(cls) -> None: def _ensure_initialized(cls) -> None:
"""Lazy-load data from CardDataLoader on first access.""" """Lazy-load data from CardDataLoader on first access."""
if cls._initialized: if cls._initialized:
return return
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
loader = CardDataLoader() loader = CardDataLoader()
cls._numbers = loader.number() cls._numbers = loader.number()
cls._colors = loader.color() cls._colors = loader.color()
cls._initialized = True cls._initialized = True
@classmethod @classmethod
@overload @overload
def number(cls, value: int) -> Optional['Number']: def number(cls, value: int) -> Optional["Number"]: ...
...
@classmethod @classmethod
@overload @overload
def number(cls, value: None = ...) -> Dict[int, 'Number']: def number(cls, value: None = ...) -> Dict[int, "Number"]: ...
...
@classmethod @classmethod
def number(cls, value: Optional[int] = None) -> Union[Optional['Number'], Dict[int, 'Number']]: def number(cls, value: Optional[int] = None) -> Union[Optional["Number"], Dict[int, "Number"]]:
"""Return an individual Number or the full numerology table.""" """Return an individual Number or the full numerology table."""
cls._ensure_initialized() cls._ensure_initialized()
if value is None: if value is None:
return cls._numbers.copy() return cls._numbers.copy()
return cls._numbers.get(value) return cls._numbers.get(value)
@classmethod @classmethod
@overload @overload
def color(cls, sephera_number: int) -> Optional['Color']: def color(cls, sephera_number: int) -> Optional["Color"]: ...
...
@classmethod @classmethod
@overload @overload
def color(cls, sephera_number: None = ...) -> Dict[int, 'Color']: def color(cls, sephera_number: None = ...) -> Dict[int, "Color"]: ...
...
@classmethod @classmethod
def color(cls, sephera_number: Optional[int] = None) -> Union[Optional['Color'], Dict[int, 'Color']]: def color(
cls, sephera_number: Optional[int] = None
) -> Union[Optional["Color"], Dict[int, "Color"]]:
"""Return a single color correspondence or the entire map.""" """Return a single color correspondence or the entire map."""
cls._ensure_initialized() cls._ensure_initialized()
if sephera_number is None: if sephera_number is None:
return cls._colors.copy() return cls._colors.copy()
return cls._colors.get(sephera_number) return cls._colors.get(sephera_number)
@classmethod @classmethod
def color_by_number(cls, number: int) -> Optional['Color']: def color_by_number(cls, number: int) -> Optional["Color"]:
"""Get a Color by mapping a number through digital root.""" """Get a Color by mapping a number through digital root."""
root = calculate_digital_root(number) root = calculate_digital_root(number)
return cls.color(root) return cls.color(root)
@classmethod @classmethod
def number_by_digital_root(cls, value: int) -> Optional['Number']: def number_by_digital_root(cls, value: int) -> Optional["Number"]:
"""Get a Number object using digital root calculation.""" """Get a Number object using digital root calculation."""
root = calculate_digital_root(value) root = calculate_digital_root(value)
return cls.number(root) return cls.number(root)
@classmethod @classmethod
def digital_root(cls, value: int) -> int: def digital_root(cls, value: int) -> int:
"""Get the digital root of a value.""" """Get the digital root of a value."""
return calculate_digital_root(value) return calculate_digital_root(value)
@classmethod @classmethod
def filter_numbers(cls, **kwargs) -> list: def filter_numbers(cls, **kwargs) -> list:
""" """
Filter numbers by any Number attribute. Filter numbers by any Number attribute.
Uses the universal filter from utils.filter for consistency Uses the universal filter from utils.filter for consistency
across the entire project. across the entire project.
Args: Args:
**kwargs: Any Number attribute with its value **kwargs: Any Number attribute with its value
Usage: Usage:
Numbers.filter_numbers(element="Fire") Numbers.filter_numbers(element="Fire")
Numbers.filter_numbers(sephera_number=5) Numbers.filter_numbers(sephera_number=5)
Returns: Returns:
List of Number objects matching all filters List of Number objects matching all filters
""" """
cls._ensure_initialized() cls._ensure_initialized()
return universal_filter(list(cls._numbers.values()), **kwargs) return universal_filter(list(cls._numbers.values()), **kwargs)
@classmethod @classmethod
def display_filter_numbers(cls, **kwargs) -> str: def display_filter_numbers(cls, **kwargs) -> str:
""" """
Filter numbers and display results nicely formatted. Filter numbers and display results nicely formatted.
Combines filtering and formatting in one call. Combines filtering and formatting in one call.
Args: Args:
**kwargs: Any Number attribute with its value **kwargs: Any Number attribute with its value
Returns: Returns:
Formatted string with filtered numbers Formatted string with filtered numbers
Example: Example:
print(Numbers.display_filter_numbers(element="Fire")) print(Numbers.display_filter_numbers(element="Fire"))
""" """
from utils.filter import format_results from utils.filter import format_results
results = cls.filter_numbers(**kwargs) results = cls.filter_numbers(**kwargs)
return format_results(results) return format_results(results)
@classmethod @classmethod
def filter_colors(cls, **kwargs) -> list: def filter_colors(cls, **kwargs) -> list:
""" """
Filter colors by any Color attribute. Filter colors by any Color attribute.
Uses the universal filter from utils.filter for consistency Uses the universal filter from utils.filter for consistency
across the entire project. across the entire project.
Args: Args:
**kwargs: Any Color attribute with its value **kwargs: Any Color attribute with its value
Usage: Usage:
Numbers.filter_colors(element="Water") Numbers.filter_colors(element="Water")
Numbers.filter_colors(sephera_number=3) Numbers.filter_colors(sephera_number=3)
Returns: Returns:
List of Color objects matching all filters List of Color objects matching all filters
""" """
cls._ensure_initialized() cls._ensure_initialized()
return universal_filter(list(cls._colors.values()), **kwargs) return universal_filter(list(cls._colors.values()), **kwargs)
@classmethod @classmethod
def display_filter_colors(cls, **kwargs) -> str: def display_filter_colors(cls, **kwargs) -> str:
""" """
Filter colors and display results nicely formatted. Filter colors and display results nicely formatted.
Combines filtering and formatting in one call. Combines filtering and formatting in one call.
Args: Args:
**kwargs: Any Color attribute with its value **kwargs: Any Color attribute with its value
Returns: Returns:
Formatted string with filtered colors Formatted string with filtered colors
Example: Example:
print(Numbers.display_filter_colors(element="Water")) print(Numbers.display_filter_colors(element="Water"))
""" """
from utils.filter import format_results from utils.filter import format_results
results = cls.filter_colors(**kwargs) results = cls.filter_colors(**kwargs)
return format_results(results) return format_results(results)

View File

@@ -11,16 +11,16 @@ if TYPE_CHECKING:
def calculate_digital_root(value: int) -> int: def calculate_digital_root(value: int) -> int:
""" """
Calculate the digital root of a number by repeatedly summing its digits. Calculate the digital root of a number by repeatedly summing its digits.
Digital root reduces any number to a single digit (1-9) by repeatedly Digital root reduces any number to a single digit (1-9) by repeatedly
summing its digits until a single digit remains. summing its digits until a single digit remains.
""" """
if value < 1: if value < 1:
raise ValueError(f"Value must be positive, got {value}") raise ValueError(f"Value must be positive, got {value}")
while value >= 10: while value >= 10:
value = sum(int(digit) for digit in str(value)) value = sum(int(digit) for digit in str(value))
return value return value
@@ -29,7 +29,7 @@ class _Number:
def __init__(self) -> None: def __init__(self) -> None:
self._initialized: bool = False self._initialized: bool = False
self._loader: 'CardDataLoader | None' = None self._loader: "CardDataLoader | None" = None
self.number = CollectionAccessor(self._get_numbers) self.number = CollectionAccessor(self._get_numbers)
self.color = CollectionAccessor(self._get_colors) self.color = CollectionAccessor(self._get_colors)
self.cipher = CollectionAccessor(self._get_ciphers) self.cipher = CollectionAccessor(self._get_ciphers)
@@ -40,10 +40,11 @@ class _Number:
return return
from tarot.card.data import CardDataLoader from tarot.card.data import CardDataLoader
self._loader = CardDataLoader() self._loader = CardDataLoader()
self._initialized = True self._initialized = True
def _require_loader(self) -> 'CardDataLoader': def _require_loader(self) -> "CardDataLoader":
self._ensure_initialized() self._ensure_initialized()
assert self._loader is not None, "Loader not initialized" assert self._loader is not None, "Loader not initialized"
return self._loader return self._loader

View File

@@ -17,66 +17,99 @@ Unified Namespaces (singular names):
Usage: Usage:
from tarot import number, letter, words, Tarot from tarot import number, letter, words, Tarot
num = number.number(5) num = number.number(5)
result = letter.words.word('MAGICK').cipher('english_simple') result = letter.words.word('MAGICK').cipher('english_simple')
card = Tarot.deck.card(3) card = Tarot.deck.card(3)
""" """
from .deck import Deck, Card, MajorCard, MinorCard, DLT import kaballah
from .attributes import ( from kaballah import Cube, Tree
Month, Day, Weekday, Hour, ClockHour, Zodiac, Suit, Meaning, Letter, from kaballah.cube.attributes import CubeOfSpace, Wall, WallDirection
Sephera, PeriodicTable, Degree, AstrologicalInfluence,
TreeOfLife, Correspondences, CardImage, DoublLetterTrump, # Import from namespace folders
EnglishAlphabet, GreekAlphabet, HebrewAlphabet, from letter import hexagram, letter, trigram
Trigram, Hexagram, from number import calculate_digital_root, number
EnochianTablet, EnochianGridPosition, EnochianArchetype, Path, from temporal import PlanetPosition, ThalemaClock
) from temporal import Zodiac as AstrologyZodiac
# Import shared attributes from utils # Import shared attributes from utils
from utils.attributes import ( from utils.attributes import (
Note, Element, ElementType, Number, Color, Colorscale, Cipher,
Planet, God, Cipher, CipherResult, Perfume, CipherResult,
Color,
Colorscale,
Element,
ElementType,
God,
Note,
Number,
Perfume,
Planet,
)
from .attributes import (
AstrologicalInfluence,
CardImage,
ClockHour,
Correspondences,
Day,
Degree,
DoublLetterTrump,
EnglishAlphabet,
EnochianArchetype,
EnochianGridPosition,
EnochianTablet,
GreekAlphabet,
HebrewAlphabet,
Hexagram,
Hour,
Letter,
Meaning,
Month,
Path,
PeriodicTable,
Sephera,
Suit,
TreeOfLife,
Trigram,
Weekday,
Zodiac,
) )
from kaballah.cube.attributes import CubeOfSpace, WallDirection, Wall
from .card.data import CardDataLoader, calculate_digital_root
from .tarot_api import Tarot
# Import from card module (includes details, loader, and image_loader) # Import from card module (includes details, loader, and image_loader)
from .card import ( from .card import (
CardAccessor, CardAccessor,
CardDetailsRegistry, CardDetailsRegistry,
ImageDeckLoader,
filter_cards_by_keywords,
get_card_info,
get_cards_by_suit,
load_card_details, load_card_details,
load_deck_details, load_deck_details,
get_cards_by_suit,
filter_cards_by_keywords,
print_card_details,
get_card_info,
ImageDeckLoader,
load_deck_images, load_deck_images,
print_card_details,
) )
from .card.data import CardDataLoader
# Import from namespace folders from .deck import DLT, Card, Deck, MajorCard, MinorCard
from letter import letter, trigram, hexagram from .tarot_api import Tarot
from number import number, calculate_digital_root
import kaballah
from kaballah import Tree, Cube
from temporal import ThalemaClock, Zodiac as AstrologyZodiac, PlanetPosition
def display(obj): def display(obj):
""" """
Pretty print any tarot object by showing all its attributes. Pretty print any tarot object by showing all its attributes.
Automatically detects dataclass objects and displays their fields Automatically detects dataclass objects and displays their fields
with values in a readable format. with values in a readable format.
Usage: Usage:
from tarot import display, number from tarot import display, number
num = number.number(5) num = number.number(5)
display(num) # Shows all attributes nicely formatted display(num) # Shows all attributes nicely formatted
""" """
from dataclasses import fields from dataclasses import fields
if hasattr(obj, '__dataclass_fields__'):
if hasattr(obj, "__dataclass_fields__"):
# It's a dataclass - show all fields # It's a dataclass - show all fields
print(f"{obj.__class__.__name__}:") print(f"{obj.__class__.__name__}:")
for field in fields(obj): for field in fields(obj):
@@ -96,12 +129,10 @@ __all__ = [
"Tarot", "Tarot",
"trigram", "trigram",
"hexagram", "hexagram",
# Temporal and astrological # Temporal and astrological
"ThalemaClock", "ThalemaClock",
"AstrologyZodiac", "AstrologyZodiac",
"PlanetPosition", "PlanetPosition",
# Card details and loading # Card details and loading
"CardDetailsRegistry", "CardDetailsRegistry",
"load_card_details", "load_card_details",
@@ -110,24 +141,20 @@ __all__ = [
"filter_cards_by_keywords", "filter_cards_by_keywords",
"print_card_details", "print_card_details",
"get_card_info", "get_card_info",
# Image loading # Image loading
"ImageDeckLoader", "ImageDeckLoader",
"load_deck_images", "load_deck_images",
# Utilities # Utilities
"display", "display",
"CardAccessor", "CardAccessor",
"Tree", "Tree",
"Cube", "Cube",
# Deck classes # Deck classes
"Deck", "Deck",
"Card", "Card",
"MajorCard", "MajorCard",
"MinorCard", "MinorCard",
"DLT", "DLT",
# Calendar/attribute classes # Calendar/attribute classes
"Month", "Month",
"Day", "Day",
@@ -142,7 +169,6 @@ __all__ = [
"CubeOfSpace", "CubeOfSpace",
"WallDirection", "WallDirection",
"Wall", "Wall",
# Sepheric classes # Sepheric classes
"Sephera", "Sephera",
"PeriodicTable", "PeriodicTable",
@@ -157,12 +183,10 @@ __all__ = [
"EnochianTablet", "EnochianTablet",
"EnochianGridPosition", "EnochianGridPosition",
"EnochianArchetype", "EnochianArchetype",
# Alphabet classes # Alphabet classes
"EnglishAlphabet", "EnglishAlphabet",
"GreekAlphabet", "GreekAlphabet",
"HebrewAlphabet", "HebrewAlphabet",
# Number and color classes # Number and color classes
"Number", "Number",
"Color", "Color",
@@ -172,7 +196,6 @@ __all__ = [
"Hexagram", "Hexagram",
"Cipher", "Cipher",
"CipherResult", "CipherResult",
# Data loader and functions # Data loader and functions
"CardDataLoader", "CardDataLoader",
"calculate_digital_root", "calculate_digital_root",

View File

@@ -8,54 +8,54 @@ attribute classes for cards.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
# Re-export shared attributes from utils
from utils.attributes import (
Element,
ElementType,
Number,
Color,
Colorscale,
Planet,
God,
Cipher,
CipherResult,
Perfume,
Note,
Meaning,
)
# Re-export attributes from other modules for convenience/backward compatibility # Re-export attributes from other modules for convenience/backward compatibility
from kaballah.attributes import ( from kaballah.attributes import (
Sephera,
PeriodicTable,
TreeOfLife,
Correspondences, Correspondences,
Path, Path,
PeriodicTable,
Sephera,
TreeOfLife,
) )
from letter.attributes import ( from letter.attributes import (
Letter,
EnglishAlphabet,
GreekAlphabet,
HebrewAlphabet,
DoublLetterTrump, DoublLetterTrump,
EnglishAlphabet,
EnochianArchetype,
EnochianGridPosition,
EnochianLetter, EnochianLetter,
EnochianSpirit, EnochianSpirit,
EnochianTablet, EnochianTablet,
EnochianGridPosition, GreekAlphabet,
EnochianArchetype, HebrewAlphabet,
Letter,
) )
from letter.iChing_attributes import ( from letter.iChing_attributes import (
Trigram,
Hexagram, Hexagram,
Trigram,
) )
from temporal.attributes import ( from temporal.attributes import (
AstrologicalInfluence,
ClockHour,
Degree,
Hour,
Month, Month,
Weekday, Weekday,
Hour,
ClockHour,
Zodiac, Zodiac,
Degree, )
AstrologicalInfluence,
# Re-export shared attributes from utils
from utils.attributes import (
Cipher,
CipherResult,
Color,
Colorscale,
Element,
ElementType,
God,
Meaning,
Note,
Number,
Perfume,
Planet,
) )
# Alias Day to Weekday for backward compatibility (Day in this context was Day of Week) # Alias Day to Weekday for backward compatibility (Day in this context was Day of Week)
@@ -113,8 +113,9 @@ __all__ = [
@dataclass @dataclass
class Suit: class Suit:
"""Represents a tarot suit.""" """Represents a tarot suit."""
name: str name: str
element: 'ElementType' element: "ElementType"
tarot_correspondence: str tarot_correspondence: str
number: int number: int
@@ -122,8 +123,8 @@ class Suit:
@dataclass @dataclass
class CardImage: class CardImage:
"""Represents an image associated with a card.""" """Represents an image associated with a card."""
filename: str filename: str
artist: str artist: str
deck_name: str deck_name: str
url: Optional[str] = None url: Optional[str] = None

View File

@@ -2,15 +2,15 @@
from .card import CardAccessor from .card import CardAccessor
from .details import CardDetailsRegistry from .details import CardDetailsRegistry
from .image_loader import ImageDeckLoader, load_deck_images
from .loader import ( from .loader import (
filter_cards_by_keywords,
get_card_info,
get_cards_by_suit,
load_card_details, load_card_details,
load_deck_details, load_deck_details,
get_cards_by_suit,
filter_cards_by_keywords,
print_card_details, print_card_details,
get_card_info,
) )
from .image_loader import ImageDeckLoader, load_deck_images
__all__ = [ __all__ = [
"CardAccessor", "CardAccessor",

View File

@@ -5,7 +5,7 @@ Provides fluent access to Tarot cards through Tarot.deck namespace.
Usage: Usage:
from tarot.card import Deck, Card from tarot.card import Deck, Card
card = Deck.card(3) # Get card 3 card = Deck.card(3) # Get card 3
cards = Deck.card.filter(arcana="Major") # Get all Major Arcana cards = Deck.card.filter(arcana="Major") # Get all Major Arcana
cards = Deck.card.filter(arcana="Minor") # Get all Minor Arcana cards = Deck.card.filter(arcana="Minor") # Get all Minor Arcana
@@ -13,43 +13,45 @@ Usage:
cards = Deck.card.filter(arcana="Minor", suit="Wands", pip=5) # 5 of Wands cards = Deck.card.filter(arcana="Minor", suit="Wands", pip=5) # 5 of Wands
""" """
from typing import List, Optional from typing import TYPE_CHECKING, List, Optional
from utils.filter import universal_filter, format_results
from utils.object_formatting import is_nested_object, get_object_attributes, format_value from utils.filter import format_results, universal_filter
from utils.object_formatting import format_value, get_object_attributes, is_nested_object
if TYPE_CHECKING:
from tarot.deck import Card, Deck
class CardList(list): class CardList(list):
"""Custom list class for cards that formats nicely when printed.""" """Custom list class for cards that formats nicely when printed."""
def __str__(self) -> str: def __str__(self) -> str:
"""Format card list for display.""" """Format card list for display."""
if not self: if not self:
return "(no cards)" return "(no cards)"
return _format_cards(self) return _format_cards(self)
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return string representation.""" """Return string representation."""
return self.__str__() return self.__str__()
def _format_cards(cards: List['Card']) -> str: def _format_cards(cards: List["Card"]) -> str:
""" """
Format a list of cards for user-friendly display. Format a list of cards for user-friendly display.
Args: Args:
cards: List of Card objects to format cards: List of Card objects to format
Returns: Returns:
Formatted string with each card separated by blank lines Formatted string with each card separated by blank lines
""" """
from utils.object_formatting import is_nested_object, get_object_attributes, format_value
lines = [] lines = []
for card in cards: for card in cards:
card_num = getattr(card, 'number', '?') card_num = getattr(card, "number", "?")
card_name = getattr(card, 'name', 'Unknown') card_name = getattr(card, "name", "Unknown")
lines.append(f"--- {card_num}: {card_name} ---") lines.append(f"--- {card_num}: {card_name} ---")
# Format all attributes with proper nesting # Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(card): for attr_name, attr_value in get_object_attributes(card):
if is_nested_object(attr_value): if is_nested_object(attr_value):
@@ -59,16 +61,16 @@ def _format_cards(cards: List['Card']) -> str:
lines.append(nested) lines.append(nested)
else: else:
lines.append(f" {attr_name}: {attr_value}") lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items lines.append("") # Blank line between items
return "\n".join(lines) return "\n".join(lines)
class CardAccessor: class CardAccessor:
""" """
Fluent accessor for Tarot cards in the deck. Fluent accessor for Tarot cards in the deck.
Usage: Usage:
Tarot.deck.card(3) # Get card 3 Tarot.deck.card(3) # Get card 3
Tarot.deck.card.filter(arcana="Major") # Get all Major Arcana Tarot.deck.card.filter(arcana="Major") # Get all Major Arcana
@@ -77,18 +79,19 @@ class CardAccessor:
Tarot.deck.card.filter(arcana="Minor", suit="Wands") # Get all Wand cards Tarot.deck.card.filter(arcana="Minor", suit="Wands") # Get all Wand cards
Tarot.deck.card.display_filter(arcana="Major") # Display Major Arcana Tarot.deck.card.display_filter(arcana="Major") # Display Major Arcana
""" """
_deck: Optional['Deck'] = None _deck: Optional["Deck"] = None
_initialized: bool = False _initialized: bool = False
def _ensure_initialized(self) -> None: def _ensure_initialized(self) -> None:
"""Lazy-load the Deck on first access.""" """Lazy-load the Deck on first access."""
if not self._initialized: if not self._initialized:
from tarot.deck import Deck as DeckClass from tarot.deck import Deck as DeckClass
CardAccessor._deck = DeckClass() CardAccessor._deck = DeckClass()
CardAccessor._initialized = True CardAccessor._initialized = True
def __call__(self, number: int) -> Optional['Card']: def __call__(self, number: int) -> Optional["Card"]:
"""Get a card by number.""" """Get a card by number."""
self._ensure_initialized() self._ensure_initialized()
if self._deck is None: if self._deck is None:
@@ -97,11 +100,11 @@ class CardAccessor:
if card.number == number: if card.number == number:
return card return card
return None return None
def filter(self, **kwargs) -> CardList: def filter(self, **kwargs) -> CardList:
""" """
Filter cards by any Card attribute. Filter cards by any Card attribute.
Uses the universal filter from utils.filter for consistency Uses the universal filter from utils.filter for consistency
across the entire project. across the entire project.
@@ -122,11 +125,11 @@ class CardAccessor:
if self._deck is None: if self._deck is None:
return CardList() return CardList()
return CardList(universal_filter(self._deck.cards, **kwargs)) return CardList(universal_filter(self._deck.cards, **kwargs))
def display_filter(self, **kwargs) -> str: def display_filter(self, **kwargs) -> str:
""" """
Filter cards and display results nicely formatted. Filter cards and display results nicely formatted.
Combines filtering and formatting in one call. Combines filtering and formatting in one call.
Args: Args:
@@ -140,11 +143,11 @@ class CardAccessor:
""" """
results = self.filter(**kwargs) results = self.filter(**kwargs)
return format_results(results) return format_results(results)
def display(self) -> str: def display(self) -> str:
""" """
Format all cards in the deck for user-friendly display. Format all cards in the deck for user-friendly display.
Returns a formatted string with each card separated by blank lines. Returns a formatted string with each card separated by blank lines.
Nested objects are indented and separated with their own sections. Nested objects are indented and separated with their own sections.
""" """
@@ -152,13 +155,13 @@ class CardAccessor:
if self._deck is None: if self._deck is None:
return "(deck not initialized)" return "(deck not initialized)"
return _format_cards(self._deck.cards) return _format_cards(self._deck.cards)
def __str__(self) -> str: def __str__(self) -> str:
"""Return the complete Tarot deck structure built from actual cards.""" """Return the complete Tarot deck structure built from actual cards."""
self._ensure_initialized() self._ensure_initialized()
if self._deck is None: if self._deck is None:
return "CardAccessor (deck not initialized)" return "CardAccessor (deck not initialized)"
lines = [ lines = [
"Tarot Deck Structure", "Tarot Deck Structure",
"=" * 60, "=" * 60,
@@ -166,166 +169,168 @@ class CardAccessor:
"The 78-card Tarot deck organized by structure and correspondence:", "The 78-card Tarot deck organized by structure and correspondence:",
"", "",
] ]
# Build structure from actual cards # Build structure from actual cards
major_arcana = [c for c in self._deck.cards if c.arcana == "Major"] major_arcana = [c for c in self._deck.cards if c.arcana == "Major"]
minor_arcana = [c for c in self._deck.cards if c.arcana == "Minor"] minor_arcana = [c for c in self._deck.cards if c.arcana == "Minor"]
# Major Arcana # Major Arcana
if major_arcana: if major_arcana:
lines.append(f"MAJOR ARCANA ({len(major_arcana)} cards):") lines.append(f"MAJOR ARCANA ({len(major_arcana)} cards):")
fool = next((c for c in major_arcana if c.number == 0), None) fool = next((c for c in major_arcana if c.number == 0), None)
world = next((c for c in major_arcana if c.number == 21), None) world = next((c for c in major_arcana if c.number == 21), None)
if fool and world: if fool and world:
lines.append(f" Special Pair: {fool.name} ({fool.number}) - {world.name} ({world.number})") lines.append(
f" Special Pair: {fool.name} ({fool.number}) - {world.name} ({world.number})"
)
double_letter_trumps = [c for c in major_arcana if 3 <= c.number <= 21] double_letter_trumps = [c for c in major_arcana if 3 <= c.number <= 21]
lines.append(f" Double Letter Trumps ({len(double_letter_trumps)} cards): Cards 3-21") lines.append(f" Double Letter Trumps ({len(double_letter_trumps)} cards): Cards 3-21")
lines.append("") lines.append("")
# Minor Arcana # Minor Arcana
if minor_arcana: if minor_arcana:
lines.append(f"MINOR ARCANA ({len(minor_arcana)} cards - 4 suits × 14 ranks):") lines.append(f"MINOR ARCANA ({len(minor_arcana)} cards - 4 suits × 14 ranks):")
lines.append("") lines.append("")
# Aces # Aces
aces = [c for c in minor_arcana if hasattr(c, 'pip') and c.pip == 1] aces = [c for c in minor_arcana if hasattr(c, "pip") and c.pip == 1]
if aces: if aces:
lines.append(f" ACES ({len(aces)} cards - The Root Powers):") lines.append(f" ACES ({len(aces)} cards - The Root Powers):")
for ace in aces: for ace in aces:
suit_name = ace.suit.name if hasattr(ace.suit, 'name') else str(ace.suit) suit_name = ace.suit.name if hasattr(ace.suit, "name") else str(ace.suit)
lines.append(f" Ace of {suit_name}") lines.append(f" Ace of {suit_name}")
lines.append("") lines.append("")
# Pips (2-10) # Pips (2-10)
pips = [c for c in minor_arcana if hasattr(c, 'pip') and 2 <= c.pip <= 10] pips = [c for c in minor_arcana if hasattr(c, "pip") and 2 <= c.pip <= 10]
if pips: if pips:
lines.append(f" PIPS ({len(pips)} cards - 2-10 of each suit):") lines.append(f" PIPS ({len(pips)} cards - 2-10 of each suit):")
# Group by suit # Group by suit
suits_dict = {} suits_dict = {}
for pip in pips: for pip in pips:
suit_name = pip.suit.name if hasattr(pip.suit, 'name') else str(pip.suit) suit_name = pip.suit.name if hasattr(pip.suit, "name") else str(pip.suit)
if suit_name not in suits_dict: if suit_name not in suits_dict:
suits_dict[suit_name] = [] suits_dict[suit_name] = []
suits_dict[suit_name].append(pip) suits_dict[suit_name].append(pip)
for suit_name in ['Cups', 'Pentacles', 'Swords', 'Wands']: for suit_name in ["Cups", "Pentacles", "Swords", "Wands"]:
if suit_name in suits_dict: if suit_name in suits_dict:
pip_nums = sorted([p.pip for p in suits_dict[suit_name]]) pip_nums = sorted([p.pip for p in suits_dict[suit_name]])
lines.append(f" {suit_name}: {', '.join(str(n) for n in pip_nums)}") lines.append(f" {suit_name}: {', '.join(str(n) for n in pip_nums)}")
lines.append("") lines.append("")
# Court Cards # Court Cards
courts = [c for c in minor_arcana if hasattr(c, 'court_rank') and c.court_rank] courts = [c for c in minor_arcana if hasattr(c, "court_rank") and c.court_rank]
if courts: if courts:
lines.append(f" COURT CARDS ({len(courts)} cards - 4 ranks × 4 suits):") lines.append(f" COURT CARDS ({len(courts)} cards - 4 ranks × 4 suits):")
# Get unique ranks and their order # Get unique ranks and their order
rank_order = {"Knight": 0, "Prince": 1, "Princess": 2, "Queen": 3} rank_order = {"Knight": 0, "Prince": 1, "Princess": 2, "Queen": 3}
lines.append(" Rank order per suit: Knight, Prince, Princess, Queen") lines.append(" Rank order per suit: Knight, Prince, Princess, Queen")
lines.append("") lines.append("")
# Group by suit # Group by suit
suits_dict = {} suits_dict = {}
for court in courts: for court in courts:
suit_name = court.suit.name if hasattr(court.suit, 'name') else str(court.suit) suit_name = court.suit.name if hasattr(court.suit, "name") else str(court.suit)
if suit_name not in suits_dict: if suit_name not in suits_dict:
suits_dict[suit_name] = [] suits_dict[suit_name] = []
suits_dict[suit_name].append(court) suits_dict[suit_name].append(court)
for suit_name in ['Cups', 'Pentacles', 'Swords', 'Wands']: for suit_name in ["Cups", "Pentacles", "Swords", "Wands"]:
if suit_name in suits_dict: if suit_name in suits_dict:
suit_courts = sorted(suits_dict[suit_name], suit_courts = sorted(
key=lambda c: rank_order.get(c.court_rank, 99)) suits_dict[suit_name], key=lambda c: rank_order.get(c.court_rank, 99)
)
court_names = [c.court_rank for c in suit_courts] court_names = [c.court_rank for c in suit_courts]
lines.append(f" {suit_name}: {', '.join(court_names)}") lines.append(f" {suit_name}: {', '.join(court_names)}")
lines.append("") lines.append("")
# Element correspondences # Element correspondences
lines.append("SUIT CORRESPONDENCES:") lines.append("SUIT CORRESPONDENCES:")
suits_info = {} suits_info = {}
for card in minor_arcana: for card in minor_arcana:
if hasattr(card, 'suit') and card.suit: if hasattr(card, "suit") and card.suit:
suit_name = card.suit.name if hasattr(card.suit, 'name') else str(card.suit) suit_name = card.suit.name if hasattr(card.suit, "name") else str(card.suit)
if suit_name not in suits_info: if suit_name not in suits_info:
# Extract element info # Extract element info
element_name = "Unknown" element_name = "Unknown"
if hasattr(card.suit, 'element') and card.suit.element: if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, 'name'): if hasattr(card.suit.element, "name"):
element_name = card.suit.element.name element_name = card.suit.element.name
else: else:
element_name = str(card.suit.element) element_name = str(card.suit.element)
# Extract zodiac signs # Extract zodiac signs
zodiac_signs = [] zodiac_signs = []
if hasattr(card.suit, 'element') and card.suit.element: if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, 'zodiac_signs'): if hasattr(card.suit.element, "zodiac_signs"):
zodiac_signs = card.suit.element.zodiac_signs zodiac_signs = card.suit.element.zodiac_signs
# Extract keywords # Extract keywords
keywords = [] keywords = []
if hasattr(card.suit, 'element') and card.suit.element: if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, 'keywords'): if hasattr(card.suit.element, "keywords"):
keywords = card.suit.element.keywords keywords = card.suit.element.keywords
suits_info[suit_name] = { suits_info[suit_name] = {
'element': element_name, "element": element_name,
'zodiac': zodiac_signs, "zodiac": zodiac_signs,
'keywords': keywords "keywords": keywords,
} }
for suit_name in ['Cups', 'Pentacles', 'Swords', 'Wands']: for suit_name in ["Cups", "Pentacles", "Swords", "Wands"]:
if suit_name in suits_info: if suit_name in suits_info:
info = suits_info[suit_name] info = suits_info[suit_name]
lines.append(f" {suit_name} ({info['element']}):") lines.append(f" {suit_name} ({info['element']}):")
if info['zodiac']: if info["zodiac"]:
lines.append(f" Zodiac: {', '.join(info['zodiac'])}") lines.append(f" Zodiac: {', '.join(info['zodiac'])}")
if info['keywords']: if info["keywords"]:
lines.append(f" Keywords: {', '.join(info['keywords'])}") lines.append(f" Keywords: {', '.join(info['keywords'])}")
lines.append("") lines.append("")
lines.append(f"Total: {len(self._deck.cards)} cards") lines.append(f"Total: {len(self._deck.cards)} cards")
return "\n".join(lines) return "\n".join(lines)
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return a nice representation of the deck accessor.""" """Return a nice representation of the deck accessor."""
return self.__str__() return self.__str__()
def spread(self, spread_name: str): def spread(self, spread_name: str):
""" """
Draw a Tarot card reading for a spread. Draw a Tarot card reading for a spread.
Automatically draws random cards for each position in the spread, Automatically draws random cards for each position in the spread,
with random reversals. Returns formatted reading with card details. with random reversals. Returns formatted reading with card details.
Args: Args:
spread_name: Name of the spread (case-insensitive, underscores or spaces) spread_name: Name of the spread (case-insensitive, underscores or spaces)
Examples: 'Celtic Cross', 'golden dawn', 'three_card', 'tree of life' Examples: 'Celtic Cross', 'golden dawn', 'three_card', 'tree of life'
Returns: Returns:
SpreadReading object containing the spread and drawn cards SpreadReading object containing the spread and drawn cards
Raises: Raises:
ValueError: If spread name not found ValueError: If spread name not found
Examples: Examples:
print(Tarot.deck.card.spread('Celtic Cross')) print(Tarot.deck.card.spread('Celtic Cross'))
print(Tarot.deck.card.spread('golden dawn')) print(Tarot.deck.card.spread('golden dawn'))
print(Tarot.deck.card.spread('three card')) print(Tarot.deck.card.spread('three card'))
print(Tarot.deck.card.spread('tree of life')) print(Tarot.deck.card.spread('tree of life'))
""" """
from tarot.card.spread import Spread, draw_spread, SpreadReading from tarot.card.spread import Spread, SpreadReading, draw_spread
# Initialize deck if needed # Initialize deck if needed
self._ensure_initialized() self._ensure_initialized()
# Create spread object # Create spread object
spread = Spread(spread_name) spread = Spread(spread_name)
# Draw cards for the spread # Draw cards for the spread
drawn_cards = draw_spread(spread, self._deck.cards if self._deck else None) drawn_cards = draw_spread(spread, self._deck.cards if self._deck else None)
# Create and return reading # Create and return reading
reading = SpreadReading(spread, drawn_cards) reading = SpreadReading(spread, drawn_cards)
return reading return reading

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,15 @@ This module provides intelligent image matching and loading, supporting:
Usage: Usage:
from tarot.card.image_loader import load_deck_images from tarot.card.image_loader import load_deck_images
deck = Deck() deck = Deck()
count = load_deck_images(deck, "/path/to/deck/folder") count = load_deck_images(deck, "/path/to/deck/folder")
print(f"Loaded {count} card images") print(f"Loaded {count} card images")
""" """
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from tarot.deck import Card, Deck from tarot.deck import Card, Deck
@@ -25,58 +24,62 @@ if TYPE_CHECKING:
class ImageDeckLoader: class ImageDeckLoader:
"""Loader for matching Tarot card images to deck cards.""" """Loader for matching Tarot card images to deck cards."""
# Supported image extensions # Supported image extensions
SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
# Regex patterns for file matching # Regex patterns for file matching
NUMBERED_PATTERN = re.compile(r'^(\d+)(?:_(.+))?\.(?:jpg|jpeg|png|gif|bmp|webp)$', re.IGNORECASE) NUMBERED_PATTERN = re.compile(
r"^(\d+)(?:_(.+))?\.(?:jpg|jpeg|png|gif|bmp|webp)$", re.IGNORECASE
)
def __init__(self, deck_folder: str) -> None: def __init__(self, deck_folder: str) -> None:
""" """
Initialize the image deck loader. Initialize the image deck loader.
Args: Args:
deck_folder: Path to the folder containing card images deck_folder: Path to the folder containing card images
Raises: Raises:
ValueError: If folder doesn't exist or is not a directory ValueError: If folder doesn't exist or is not a directory
""" """
self.deck_folder = Path(deck_folder) self.deck_folder = Path(deck_folder)
if not self.deck_folder.exists(): if not self.deck_folder.exists():
raise ValueError(f"Deck folder does not exist: {deck_folder}") raise ValueError(f"Deck folder does not exist: {deck_folder}")
if not self.deck_folder.is_dir(): if not self.deck_folder.is_dir():
raise ValueError(f"Deck path is not a directory: {deck_folder}") raise ValueError(f"Deck path is not a directory: {deck_folder}")
self.image_files = self._scan_folder() self.image_files = self._scan_folder()
self.card_mapping: Dict[int, Tuple[str, bool]] = {} # card_number -> (path, has_custom_name) self.card_mapping: Dict[int, Tuple[str, bool]] = (
{}
) # card_number -> (path, has_custom_name)
self._build_mapping() self._build_mapping()
def _scan_folder(self) -> List[Path]: def _scan_folder(self) -> List[Path]:
"""Scan folder for image files.""" """Scan folder for image files."""
images = [] images = []
for ext in self.SUPPORTED_EXTENSIONS: for ext in self.SUPPORTED_EXTENSIONS:
images.extend(self.deck_folder.glob(f'*{ext}')) images.extend(self.deck_folder.glob(f"*{ext}"))
images.extend(self.deck_folder.glob(f'*{ext.upper()}')) images.extend(self.deck_folder.glob(f"*{ext.upper()}"))
# Sort by filename for consistent ordering # Sort by filename for consistent ordering
return sorted(images) return sorted(images)
def _parse_filename(self, filename: str) -> Tuple[Optional[int], Optional[str], bool]: def _parse_filename(self, filename: str) -> Tuple[Optional[int], Optional[str], bool]:
""" """
Parse image filename to extract card number and optional custom name. Parse image filename to extract card number and optional custom name.
Args: Args:
filename: The filename (without path) filename: The filename (without path)
Returns: Returns:
Tuple of (card_number, custom_name, has_custom_name) Tuple of (card_number, custom_name, has_custom_name)
- card_number: Parsed number if found, else None - card_number: Parsed number if found, else None
- custom_name: Custom name if present (e.g., "foolish" from "00_foolish.jpg") - custom_name: Custom name if present (e.g., "foolish" from "00_foolish.jpg")
- has_custom_name: True if custom name was found - has_custom_name: True if custom name was found
Examples: Examples:
"0.jpg" -> (0, None, False) "0.jpg" -> (0, None, False)
"00_foolish.jpg" -> (0, "foolish", True) "00_foolish.jpg" -> (0, "foolish", True)
@@ -84,37 +87,37 @@ class ImageDeckLoader:
"invalid.jpg" -> (None, None, False) "invalid.jpg" -> (None, None, False)
""" """
match = self.NUMBERED_PATTERN.match(filename) match = self.NUMBERED_PATTERN.match(filename)
if not match: if not match:
return None, None, False return None, None, False
card_number = int(match.group(1)) card_number = int(match.group(1))
custom_name = match.group(2) custom_name = match.group(2)
has_custom_name = custom_name is not None has_custom_name = custom_name is not None
return card_number, custom_name, has_custom_name return card_number, custom_name, has_custom_name
def _build_mapping(self) -> None: def _build_mapping(self) -> None:
"""Build mapping from card numbers to image file paths.""" """Build mapping from card numbers to image file paths."""
for image_path in self.image_files: for image_path in self.image_files:
card_num, custom_name, has_custom_name = self._parse_filename(image_path.name) card_num, custom_name, has_custom_name = self._parse_filename(image_path.name)
if card_num is not None: if card_num is not None:
# Store path and whether it has a custom name # Store path and whether it has a custom name
self.card_mapping[card_num] = (str(image_path), has_custom_name) self.card_mapping[card_num] = (str(image_path), has_custom_name)
def _normalize_card_name(self, name: str) -> str: def _normalize_card_name(self, name: str) -> str:
""" """
Normalize card name for matching. Normalize card name for matching.
Converts to lowercase, removes special characters, collapses whitespace. Converts to lowercase, removes special characters, collapses whitespace.
Args: Args:
name: Original card name name: Original card name
Returns: Returns:
Normalized name Normalized name
Examples: Examples:
"The Fool" -> "the fool" "The Fool" -> "the fool"
"Princess of Swords" -> "princess of swords" "Princess of Swords" -> "princess of swords"
@@ -122,69 +125,69 @@ class ImageDeckLoader:
""" """
# Convert to lowercase # Convert to lowercase
normalized = name.lower() normalized = name.lower()
# Replace special characters with spaces # Replace special characters with spaces
normalized = re.sub(r'[^\w\s]', ' ', normalized) normalized = re.sub(r"[^\w\s]", " ", normalized)
# Collapse multiple spaces # Collapse multiple spaces
normalized = re.sub(r'\s+', ' ', normalized).strip() normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized return normalized
def _find_fuzzy_match(self, card_name_normalized: str) -> Optional[int]: def _find_fuzzy_match(self, card_name_normalized: str) -> Optional[int]:
""" """
Find matching card number using fuzzy name matching. Find matching card number using fuzzy name matching.
This is a fallback when card names don't parse as numbers. This is a fallback when card names don't parse as numbers.
Args: Args:
card_name_normalized: Normalized card name card_name_normalized: Normalized card name
Returns: Returns:
Card number if a match is found, else None Card number if a match is found, else None
""" """
best_match = None best_match = None
best_score = 0 best_score = 0
threshold = 0.6 threshold = 0.6
# Check all parsed custom names # Check all parsed custom names
for card_num, (_, has_custom_name) in self.card_mapping.items(): for card_num, (_, has_custom_name) in self.card_mapping.items():
if not has_custom_name: if not has_custom_name:
continue continue
# Get the actual filename to extract custom name # Get the actual filename to extract custom name
for image_path in self.image_files: for image_path in self.image_files:
parsed_num, custom_name, _ = self._parse_filename(image_path.name) parsed_num, custom_name, _ = self._parse_filename(image_path.name)
if parsed_num == card_num and custom_name: if parsed_num == card_num and custom_name:
normalized_custom = self._normalize_card_name(custom_name) normalized_custom = self._normalize_card_name(custom_name)
# Simple similarity score: words that match # Simple similarity score: words that match
query_words = set(card_name_normalized.split()) query_words = set(card_name_normalized.split())
custom_words = set(normalized_custom.split()) custom_words = set(normalized_custom.split())
if query_words and custom_words: if query_words and custom_words:
intersection = len(query_words & custom_words) intersection = len(query_words & custom_words)
union = len(query_words | custom_words) union = len(query_words | custom_words)
score = intersection / union if union > 0 else 0 score = intersection / union if union > 0 else 0
if score > best_score and score >= threshold: if score > best_score and score >= threshold:
best_score = score best_score = score
best_match = card_num best_match = card_num
return best_match return best_match
def get_image_path(self, card: 'Card') -> Optional[str]: def get_image_path(self, card: "Card") -> Optional[str]:
""" """
Get the image path for a specific card. Get the image path for a specific card.
Matches cards by: Matches cards by:
1. Card number (primary method) 1. Card number (primary method)
2. Fuzzy matching on card name (fallback) 2. Fuzzy matching on card name (fallback)
Args: Args:
card: The Card object to find an image for card: The Card object to find an image for
Returns: Returns:
Full path to image file, or None if not found Full path to image file, or None if not found
""" """
@@ -192,80 +195,80 @@ class ImageDeckLoader:
if card.number in self.card_mapping: if card.number in self.card_mapping:
path, _ = self.card_mapping[card.number] path, _ = self.card_mapping[card.number]
return path return path
# Try fuzzy match on name as fallback # Try fuzzy match on name as fallback
normalized_name = self._normalize_card_name(card.name) normalized_name = self._normalize_card_name(card.name)
fuzzy_match = self._find_fuzzy_match(normalized_name) fuzzy_match = self._find_fuzzy_match(normalized_name)
if fuzzy_match is not None and fuzzy_match in self.card_mapping: if fuzzy_match is not None and fuzzy_match in self.card_mapping:
path, _ = self.card_mapping[fuzzy_match] path, _ = self.card_mapping[fuzzy_match]
return path return path
return None return None
def should_override_name(self, card_number: int) -> bool: def should_override_name(self, card_number: int) -> bool:
""" """
Check if card name should be overridden from filename. Check if card name should be overridden from filename.
Returns True only if: Returns True only if:
- Image file has a custom name component (##_name.jpg format) - Image file has a custom name component (##_name.jpg format)
- Not just a plain number (##.jpg format) - Not just a plain number (##.jpg format)
Args: Args:
card_number: The card's number card_number: The card's number
Returns: Returns:
True if name should be overridden from filename, False otherwise True if name should be overridden from filename, False otherwise
""" """
if card_number not in self.card_mapping: if card_number not in self.card_mapping:
return False return False
_, has_custom_name = self.card_mapping[card_number] _, has_custom_name = self.card_mapping[card_number]
return has_custom_name return has_custom_name
def get_custom_name(self, card_number: int) -> Optional[str]: def get_custom_name(self, card_number: int) -> Optional[str]:
""" """
Get the custom card name from the filename. Get the custom card name from the filename.
Args: Args:
card_number: The card's number card_number: The card's number
Returns: Returns:
Custom name if present, None otherwise Custom name if present, None otherwise
Example: Example:
If filename is "00_the_foolish.jpg", returns "the_foolish" If filename is "00_the_foolish.jpg", returns "the_foolish"
If filename is "00.jpg", returns None If filename is "00.jpg", returns None
""" """
if card_number not in self.card_mapping: if card_number not in self.card_mapping:
return None return None
# Find the image file for this card number # Find the image file for this card number
for image_path in self.image_files: for image_path in self.image_files:
_, custom_name, _ = self._parse_filename(image_path.name) _, custom_name, _ = self._parse_filename(image_path.name)
parsed_num, _, _ = self._parse_filename(image_path.name) parsed_num, _, _ = self._parse_filename(image_path.name)
if parsed_num == card_number and custom_name: if parsed_num == card_number and custom_name:
# Convert underscore-separated name to title case # Convert underscore-separated name to title case
name_words = custom_name.split('_') name_words = custom_name.split("_")
return ' '.join(word.capitalize() for word in name_words) return " ".join(word.capitalize() for word in name_words)
return None return None
def load_into_deck(self, deck: 'Deck', def load_into_deck(
override_names: bool = True, self, deck: "Deck", override_names: bool = True, verbose: bool = False
verbose: bool = False) -> int: ) -> int:
""" """
Load image paths into all cards in a deck. Load image paths into all cards in a deck.
Args: Args:
deck: The Deck to load images into deck: The Deck to load images into
override_names: If True, use custom names from filenames when available override_names: If True, use custom names from filenames when available
verbose: If True, print progress information verbose: If True, print progress information
Returns: Returns:
Number of cards that had images loaded Number of cards that had images loaded
Example: Example:
>>> loader = ImageDeckLoader("/path/to/deck") >>> loader = ImageDeckLoader("/path/to/deck")
>>> deck = Deck() >>> deck = Deck()
@@ -273,14 +276,14 @@ class ImageDeckLoader:
>>> print(f"Loaded {count} card images") >>> print(f"Loaded {count} card images")
""" """
loaded_count = 0 loaded_count = 0
for card in deck.cards: for card in deck.cards:
image_path = self.get_image_path(card) image_path = self.get_image_path(card)
if image_path: if image_path:
card.image_path = image_path card.image_path = image_path
loaded_count += 1 loaded_count += 1
# Override name if appropriate # Override name if appropriate
if override_names and self.should_override_name(card.number): if override_names and self.should_override_name(card.number):
custom_name = self.get_custom_name(card.number) custom_name = self.get_custom_name(card.number)
@@ -290,54 +293,53 @@ class ImageDeckLoader:
card.name = custom_name card.name = custom_name
elif verbose: elif verbose:
print(f"{card.number}: {card.name}") print(f"{card.number}: {card.name}")
return loaded_count return loaded_count
def get_summary(self) -> Dict[str, any]: def get_summary(self) -> Dict[str, any]:
""" """
Get a summary of loaded images and statistics. Get a summary of loaded images and statistics.
Returns: Returns:
Dictionary with loader statistics Dictionary with loader statistics
""" """
total_images = len(self.image_files) total_images = len(self.image_files)
mapped_cards = len(self.card_mapping) mapped_cards = len(self.card_mapping)
custom_named = sum(1 for _, has_custom in self.card_mapping.values() if has_custom) custom_named = sum(1 for _, has_custom in self.card_mapping.values() if has_custom)
return { return {
'deck_folder': str(self.deck_folder), "deck_folder": str(self.deck_folder),
'total_image_files': total_images, "total_image_files": total_images,
'total_image_filenames': len(set(f.name for f in self.image_files)), "total_image_filenames": len(set(f.name for f in self.image_files)),
'mapped_card_numbers': mapped_cards, "mapped_card_numbers": mapped_cards,
'cards_with_custom_names': custom_named, "cards_with_custom_names": custom_named,
'cards_with_generic_numbers': mapped_cards - custom_named, "cards_with_generic_numbers": mapped_cards - custom_named,
'image_extensions_found': list(set(f.suffix.lower() for f in self.image_files)), "image_extensions_found": list(set(f.suffix.lower() for f in self.image_files)),
} }
def load_deck_images(deck: 'Deck', def load_deck_images(
deck_folder: str, deck: "Deck", deck_folder: str, override_names: bool = True, verbose: bool = False
override_names: bool = True, ) -> int:
verbose: bool = False) -> int:
""" """
Convenience function to load deck images. Convenience function to load deck images.
Args: Args:
deck: The Deck object to load images into deck: The Deck object to load images into
deck_folder: Path to folder containing card images deck_folder: Path to folder containing card images
override_names: If True, use custom names from filenames when available override_names: If True, use custom names from filenames when available
verbose: If True, print progress information verbose: If True, print progress information
Returns: Returns:
Number of cards that had images loaded Number of cards that had images loaded
Raises: Raises:
ValueError: If deck_folder doesn't exist or is invalid ValueError: If deck_folder doesn't exist or is invalid
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> from tarot.card.image_loader import load_deck_images >>> from tarot.card.image_loader import load_deck_images
>>> >>>
>>> deck = Deck() >>> deck = Deck()
>>> count = load_deck_images(deck, "/path/to/deck/images") >>> count = load_deck_images(deck, "/path/to/deck/images")
>>> print(f"Loaded {count} card images") >>> print(f"Loaded {count} card images")

View File

@@ -6,12 +6,12 @@ into Card objects, supporting both individual cards and full decks.
Usage: Usage:
from tarot.card.loader import load_card_details, load_deck_details from tarot.card.loader import load_card_details, load_deck_details
from tarot.card.details import CardDetailsRegistry from tarot.card.details import CardDetailsRegistry
# Load single card # Load single card
loader = CardDetailsRegistry() loader = CardDetailsRegistry()
card = my_deck.minor.swords(11) card = my_deck.minor.swords(11)
load_card_details(card, loader) load_card_details(card, loader)
# Load entire deck # Load entire deck
load_deck_details(my_deck, loader) load_deck_details(my_deck, loader)
""" """
@@ -24,20 +24,17 @@ if TYPE_CHECKING:
from tarot.deck import Deck from tarot.deck import Deck
def load_card_details( def load_card_details(card: "Card", registry: Optional["CardDetailsRegistry"] = None) -> bool:
card: 'Card',
registry: Optional['CardDetailsRegistry'] = None
) -> bool:
""" """
Load details for a single card from the registry. Load details for a single card from the registry.
Args: Args:
card: The Card object to populate with details card: The Card object to populate with details
registry: Optional CardDetailsRegistry. If not provided, creates a new one. registry: Optional CardDetailsRegistry. If not provided, creates a new one.
Returns: Returns:
True if details were found and loaded, False otherwise True if details were found and loaded, False otherwise
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> deck = Deck() >>> deck = Deck()
@@ -49,27 +46,26 @@ def load_card_details(
""" """
if registry is None: if registry is None:
from tarot.card.details import CardDetailsRegistry from tarot.card.details import CardDetailsRegistry
registry = CardDetailsRegistry() registry = CardDetailsRegistry()
return registry.load_into_card(card) return registry.load_into_card(card)
def load_deck_details( def load_deck_details(
deck: 'Deck', deck: "Deck", registry: Optional["CardDetailsRegistry"] = None, verbose: bool = False
registry: Optional['CardDetailsRegistry'] = None,
verbose: bool = False
) -> int: ) -> int:
""" """
Load details for all cards in a deck. Load details for all cards in a deck.
Args: Args:
deck: The Deck object containing cards to populate deck: The Deck object containing cards to populate
registry: Optional CardDetailsRegistry. If not provided, creates a new one. registry: Optional CardDetailsRegistry. If not provided, creates a new one.
verbose: If True, prints information about each card loaded verbose: If True, prints information about each card loaded
Returns: Returns:
Number of cards successfully loaded with details Number of cards successfully loaded with details
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> deck = Deck() >>> deck = Deck()
@@ -78,11 +74,12 @@ def load_deck_details(
""" """
if registry is None: if registry is None:
from tarot.card.details import CardDetailsRegistry from tarot.card.details import CardDetailsRegistry
registry = CardDetailsRegistry() registry = CardDetailsRegistry()
loaded_count = 0 loaded_count = 0
failed_cards = [] failed_cards = []
# Load all cards from the deck # Load all cards from the deck
for card in deck.cards: for card in deck.cards:
if load_card_details(card, registry): if load_card_details(card, registry):
@@ -93,29 +90,26 @@ def load_deck_details(
failed_cards.append(card.name) failed_cards.append(card.name)
if verbose: if verbose:
print(f"✗ Failed: {card.name}") print(f"✗ Failed: {card.name}")
if verbose and failed_cards: if verbose and failed_cards:
print(f"\n{len(failed_cards)} cards failed to load:") print(f"\n{len(failed_cards)} cards failed to load:")
for name in failed_cards: for name in failed_cards:
print(f" - {name}") print(f" - {name}")
return loaded_count return loaded_count
def get_cards_by_suit( def get_cards_by_suit(deck: "Deck", suit_name: str) -> List["Card"]:
deck: 'Deck',
suit_name: str
) -> List['Card']:
""" """
Get all cards from a specific suit in the deck. Get all cards from a specific suit in the deck.
Args: Args:
deck: The Deck object deck: The Deck object
suit_name: The suit name ("Cups", "Pentacles", "Swords", "Wands") suit_name: The suit name ("Cups", "Pentacles", "Swords", "Wands")
Returns: Returns:
List of Card objects from that suit List of Card objects from that suit
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> from tarot.card.loader import get_cards_by_suit >>> from tarot.card.loader import get_cards_by_suit
@@ -124,29 +118,29 @@ def get_cards_by_suit(
>>> print(len(swords)) # Should be 14 >>> print(len(swords)) # Should be 14
14 14
""" """
if hasattr(deck, 'suit') and callable(deck.suit): if hasattr(deck, "suit") and callable(deck.suit):
# Deck has a suit method, use it # Deck has a suit method, use it
return deck.suit(suit_name) return deck.suit(suit_name)
# Fallback: filter cards manually # Fallback: filter cards manually
return [card for card in deck.cards if hasattr(card, 'suit') and return [
card.suit and card.suit.name == suit_name] card
for card in deck.cards
if hasattr(card, "suit") and card.suit and card.suit.name == suit_name
]
def filter_cards_by_keywords( def filter_cards_by_keywords(cards: List["Card"], keyword: str) -> List["Card"]:
cards: List['Card'],
keyword: str
) -> List['Card']:
""" """
Filter a list of cards by keyword. Filter a list of cards by keyword.
Args: Args:
cards: List of Card objects to filter cards: List of Card objects to filter
keyword: The keyword to search for (case-insensitive) keyword: The keyword to search for (case-insensitive)
Returns: Returns:
List of cards that have the keyword List of cards that have the keyword
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> deck = Deck() >>> deck = Deck()
@@ -155,20 +149,22 @@ def filter_cards_by_keywords(
""" """
keyword_lower = keyword.lower() keyword_lower = keyword.lower()
return [ return [
card for card in cards card
if hasattr(card, 'keywords') and card.keywords and for card in cards
any(keyword_lower in kw.lower() for kw in card.keywords) if hasattr(card, "keywords")
and card.keywords
and any(keyword_lower in kw.lower() for kw in card.keywords)
] ]
def print_card_details(card: 'Card', include_reversed: bool = False) -> None: def print_card_details(card: "Card", include_reversed: bool = False) -> None:
""" """
Pretty print card details to console. Pretty print card details to console.
Args: Args:
card: The Card object to print card: The Card object to print
include_reversed: If True, also print reversed keywords and interpretation include_reversed: If True, also print reversed keywords and interpretation
Example: Example:
>>> from tarot import Deck >>> from tarot import Deck
>>> deck = Deck() >>> deck = Deck()
@@ -178,35 +174,35 @@ def print_card_details(card: 'Card', include_reversed: bool = False) -> None:
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print(f" {card.name}") print(f" {card.name}")
print(f"{'=' * 60}") print(f"{'=' * 60}")
# Define attributes to print with their formatting # Define attributes to print with their formatting
attributes = { attributes = {
'explanation': ('Explanation', False), "explanation": ("Explanation", False),
'interpretation': ('Interpretation', False), "interpretation": ("Interpretation", False),
'guidance': ('Guidance', False), "guidance": ("Guidance", False),
} }
# Add reversed attributes only if requested # Add reversed attributes only if requested
if include_reversed: if include_reversed:
attributes['reversed_interpretation'] = ('Reversed Interpretation', False) attributes["reversed_interpretation"] = ("Reversed Interpretation", False)
# List attributes (joined with commas) # List attributes (joined with commas)
list_attributes = { list_attributes = {
'keywords': 'Keywords', "keywords": "Keywords",
'reversed_keywords': ('Reversed Keywords', include_reversed), "reversed_keywords": ("Reversed Keywords", include_reversed),
} }
# Numeric attributes # Numeric attributes
numeric_attributes = { numeric_attributes = {
'numerology': 'Numerology', "numerology": "Numerology",
} }
# Print text attributes # Print text attributes
for attr_name, (display_name, _) in attributes.items(): for attr_name, (display_name, _) in attributes.items():
if hasattr(card, attr_name): if hasattr(card, attr_name):
value = getattr(card, attr_name) value = getattr(card, attr_name)
if value: if value:
if attr_name == 'explanation' and isinstance(value, dict): if attr_name == "explanation" and isinstance(value, dict):
print(f"\n{display_name}:") print(f"\n{display_name}:")
if "summary" in value: if "summary" in value:
print(f"Summary: {value['summary']}") print(f"Summary: {value['summary']}")
@@ -218,7 +214,7 @@ def print_card_details(card: 'Card', include_reversed: bool = False) -> None:
print(f"{k.capitalize()}: {v}") print(f"{k.capitalize()}: {v}")
else: else:
print(f"\n{display_name}:\n{value}") print(f"\n{display_name}:\n{value}")
# Print list attributes # Print list attributes
for attr_name, display_info in list_attributes.items(): for attr_name, display_info in list_attributes.items():
if isinstance(display_info, tuple): if isinstance(display_info, tuple):
@@ -227,36 +223,35 @@ def print_card_details(card: 'Card', include_reversed: bool = False) -> None:
continue continue
else: else:
display_name = display_info display_name = display_info
if hasattr(card, attr_name): if hasattr(card, attr_name):
value = getattr(card, attr_name) value = getattr(card, attr_name)
if value: if value:
print(f"\n{display_name}: {', '.join(value)}") print(f"\n{display_name}: {', '.join(value)}")
# Print numeric attributes # Print numeric attributes
for attr_name, display_name in numeric_attributes.items(): for attr_name, display_name in numeric_attributes.items():
if hasattr(card, attr_name): if hasattr(card, attr_name):
value = getattr(card, attr_name) value = getattr(card, attr_name)
if value is not None: if value is not None:
print(f"\n{display_name}: {value}") print(f"\n{display_name}: {value}")
print(f"\n{'=' * 60}\n") print(f"\n{'=' * 60}\n")
def get_card_info( def get_card_info(
card_name: str, card_name: str, registry: Optional["CardDetailsRegistry"] = None
registry: Optional['CardDetailsRegistry'] = None
) -> Optional[dict]: ) -> Optional[dict]:
""" """
Get card information by card name. Get card information by card name.
Args: Args:
card_name: The name of the card (e.g., "Princess of Swords") card_name: The name of the card (e.g., "Princess of Swords")
registry: Optional CardDetailsRegistry. If not provided, creates a new one. registry: Optional CardDetailsRegistry. If not provided, creates a new one.
Returns: Returns:
Dictionary containing card details, or None if not found Dictionary containing card details, or None if not found
Example: Example:
>>> from tarot.card.loader import get_card_info >>> from tarot.card.loader import get_card_info
>>> info = get_card_info("Princess of Swords") >>> info = get_card_info("Princess of Swords")
@@ -265,6 +260,7 @@ def get_card_info(
""" """
if registry is None: if registry is None:
from tarot.card.details import CardDetailsRegistry from tarot.card.details import CardDetailsRegistry
registry = CardDetailsRegistry() registry = CardDetailsRegistry()
return registry.get(card_name) return registry.get(card_name)

View File

@@ -6,21 +6,21 @@ with position meanings and automatic card drawing.
Usage: Usage:
from tarot import Tarot from tarot import Tarot
# Draw cards for a spread # Draw cards for a spread
reading = Tarot.deck.card.spread('Celtic Cross') reading = Tarot.deck.card.spread('Celtic Cross')
print(reading) print(reading)
# Can also access spread with/without cards # Can also access spread with/without cards
from tarot.card.spread import Spread, draw_spread from tarot.card.spread import Spread, draw_spread
spread = Spread('Celtic Cross') spread = Spread('Celtic Cross')
reading = draw_spread(spread) # Returns list of (position, card) tuples reading = draw_spread(spread) # Returns list of (position, card) tuples
""" """
from typing import Dict, List, Optional, TYPE_CHECKING
from dataclasses import dataclass
import random import random
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from tarot.card import Card from tarot.card import Card
@@ -29,11 +29,12 @@ if TYPE_CHECKING:
@dataclass @dataclass
class SpreadPosition: class SpreadPosition:
"""Represents a position in a Tarot spread.""" """Represents a position in a Tarot spread."""
number: int number: int
name: str name: str
meaning: str meaning: str
reversed_meaning: Optional[str] = None reversed_meaning: Optional[str] = None
def __str__(self) -> str: def __str__(self) -> str:
result = f"{self.number}. {self.name}: {self.meaning}" result = f"{self.number}. {self.name}: {self.meaning}"
if self.reversed_meaning: if self.reversed_meaning:
@@ -44,192 +45,194 @@ class SpreadPosition:
@dataclass @dataclass
class DrawnCard: class DrawnCard:
"""Represents a card drawn for a spread position.""" """Represents a card drawn for a spread position."""
position: SpreadPosition position: SpreadPosition
card: 'Card' card: "Card"
is_reversed: bool is_reversed: bool
def __str__(self) -> str: def __str__(self) -> str:
"""Format the drawn card with position and interpretation.""" """Format the drawn card with position and interpretation."""
card_name = self.card.name card_name = self.card.name
if self.is_reversed: if self.is_reversed:
card_name += " (Reversed)" card_name += " (Reversed)"
return f"{self.position.number}. {self.position.name}\n" \ return (
f" └─ {card_name}\n" \ f"{self.position.number}. {self.position.name}\n"
f" └─ Position: {self.position.meaning}" f" └─ {card_name}\n"
f" └─ Position: {self.position.meaning}"
)
class Spread: class Spread:
"""Represents a Tarot spread with positions and meanings.""" """Represents a Tarot spread with positions and meanings."""
# Define all available spreads # Define all available spreads
SPREADS: Dict[str, Dict] = { SPREADS: Dict[str, Dict] = {
'three card': { "three card": {
'name': '3-Card Spread', "name": "3-Card Spread",
'description': 'Simple 3-card spread for past, present, future or situation, action, outcome', "description": (
'positions': [ "Simple 3-card spread for past, present, future "
SpreadPosition(1, 'First Position', 'Past, Foundation, or Situation'), "or situation, action, outcome"
SpreadPosition(2, 'Second Position', 'Present, Action, or Influence'), ),
SpreadPosition(3, 'Third Position', 'Future, Outcome, or Advice'), "positions": [
] SpreadPosition(1, "First Position", "Past, Foundation, or Situation"),
SpreadPosition(2, "Second Position", "Present, Action, or Influence"),
SpreadPosition(3, "Third Position", "Future, Outcome, or Advice"),
],
}, },
'golden dawn': { "golden dawn": {
'name': 'Golden Dawn 3-Card', "name": "Golden Dawn 3-Card",
'description': 'Three card spread used in Golden Dawn tradition', "description": "Three card spread used in Golden Dawn tradition",
'positions': [ "positions": [
SpreadPosition(1, 'Supernal Triangle', 'Spiritual/Divine aspect'), SpreadPosition(1, "Supernal Triangle", "Spiritual/Divine aspect"),
SpreadPosition(2, 'Pillar of Severity', 'Challenging/Active force'), SpreadPosition(2, "Pillar of Severity", "Challenging/Active force"),
SpreadPosition(3, 'Pillar of Mercy', 'Supportive/Passive force'), SpreadPosition(3, "Pillar of Mercy", "Supportive/Passive force"),
] ],
}, },
'celtic cross': { "celtic cross": {
'name': 'Celtic Cross', "name": "Celtic Cross",
'description': 'Classic 10-card spread for in-depth reading', "description": "Classic 10-card spread for in-depth reading",
'positions': [ "positions": [
SpreadPosition(1, 'The Significator', 'The main situation or person'), SpreadPosition(1, "The Significator", "The main situation or person"),
SpreadPosition(2, 'The Cross', 'The challenge or heart of the matter'), SpreadPosition(2, "The Cross", "The challenge or heart of the matter"),
SpreadPosition(3, 'Crowning Influence', 'Conscious hopes/ideals'), SpreadPosition(3, "Crowning Influence", "Conscious hopes/ideals"),
SpreadPosition(4, 'Beneath the Cross', 'Unconscious or hidden aspects'), SpreadPosition(4, "Beneath the Cross", "Unconscious or hidden aspects"),
SpreadPosition(5, 'Behind', 'Past influences'), SpreadPosition(5, "Behind", "Past influences"),
SpreadPosition(6, 'Before', 'Future influences'), SpreadPosition(6, "Before", "Future influences"),
SpreadPosition(7, 'Self/Attitude', 'How the querent sees themselves'), SpreadPosition(7, "Self/Attitude", "How the querent sees themselves"),
SpreadPosition(8, 'Others/Environment', 'External factors/opinions'), SpreadPosition(8, "Others/Environment", "External factors/opinions"),
SpreadPosition(9, 'Hopes and Fears', 'What the querent hopes for or fears'), SpreadPosition(9, "Hopes and Fears", "What the querent hopes for or fears"),
SpreadPosition(10, 'Outcome', 'Final outcome or resolution'), SpreadPosition(10, "Outcome", "Final outcome or resolution"),
] ],
}, },
'horseshoe': { "horseshoe": {
'name': 'Horseshoe', "name": "Horseshoe",
'description': '7-card spread in horseshoe formation for past, present, future insight', "description": "7-card spread in horseshoe formation for past, present, future insight",
'positions': [ "positions": [
SpreadPosition(1, 'Distant Past', 'Ancient influences and foundations'), SpreadPosition(1, "Distant Past", "Ancient influences and foundations"),
SpreadPosition(2, 'Recent Past', 'Recent events and circumstances'), SpreadPosition(2, "Recent Past", "Recent events and circumstances"),
SpreadPosition(3, 'Present Situation', 'Current state of affairs'), SpreadPosition(3, "Present Situation", "Current state of affairs"),
SpreadPosition(4, 'Immediate Future', 'Near-term developments'), SpreadPosition(4, "Immediate Future", "Near-term developments"),
SpreadPosition(5, 'Distant Future', 'Long-term outcome'), SpreadPosition(5, "Distant Future", "Long-term outcome"),
SpreadPosition(6, 'Inner Influence', 'Self/thoughts/emotions'), SpreadPosition(6, "Inner Influence", "Self/thoughts/emotions"),
SpreadPosition(7, 'Outer Influence', 'External forces and environment'), SpreadPosition(7, "Outer Influence", "External forces and environment"),
] ],
}, },
'pentagram': { "pentagram": {
'name': 'Pentagram', "name": "Pentagram",
'description': '5-card spread based on Earth element pentagram', "description": "5-card spread based on Earth element pentagram",
'positions': [ "positions": [
SpreadPosition(1, 'Spirit', 'Core essence or spiritual truth'), SpreadPosition(1, "Spirit", "Core essence or spiritual truth"),
SpreadPosition(2, 'Fire', 'Action and willpower'), SpreadPosition(2, "Fire", "Action and willpower"),
SpreadPosition(3, 'Water', 'Emotions and intuition'), SpreadPosition(3, "Water", "Emotions and intuition"),
SpreadPosition(4, 'Air', 'Intellect and communication'), SpreadPosition(4, "Air", "Intellect and communication"),
SpreadPosition(5, 'Earth', 'Physical manifestation and grounding'), SpreadPosition(5, "Earth", "Physical manifestation and grounding"),
] ],
}, },
'tree of life': { "tree of life": {
'name': 'Tree of Life', "name": "Tree of Life",
'description': '10-card spread mapping Sephiroth on the Tree of Life', "description": "10-card spread mapping Sephiroth on the Tree of Life",
'positions': [ "positions": [
SpreadPosition(1, 'Kether (Crown)', 'Divine will and unity'), SpreadPosition(1, "Kether (Crown)", "Divine will and unity"),
SpreadPosition(2, 'Chokmah (Wisdom)', 'Creative force and impulse'), SpreadPosition(2, "Chokmah (Wisdom)", "Creative force and impulse"),
SpreadPosition(3, 'Binah (Understanding)', 'Form and structure'), SpreadPosition(3, "Binah (Understanding)", "Form and structure"),
SpreadPosition(4, 'Chesed (Mercy)', 'Expansion and abundance'), SpreadPosition(4, "Chesed (Mercy)", "Expansion and abundance"),
SpreadPosition(5, 'Gevurah (Severity)', 'Reduction and discipline'), SpreadPosition(5, "Gevurah (Severity)", "Reduction and discipline"),
SpreadPosition(6, 'Tiphareth (Beauty)', 'Core self and integration'), SpreadPosition(6, "Tiphareth (Beauty)", "Core self and integration"),
SpreadPosition(7, 'Netzach (Victory)', 'Desire and passion'), SpreadPosition(7, "Netzach (Victory)", "Desire and passion"),
SpreadPosition(8, 'Hod (Splendor)', 'Intellect and communication'), SpreadPosition(8, "Hod (Splendor)", "Intellect and communication"),
SpreadPosition(9, 'Yesod (Foundation)', 'Subconscious and dreams'), SpreadPosition(9, "Yesod (Foundation)", "Subconscious and dreams"),
SpreadPosition(10, 'Malkuth (Kingdom)', 'Manifestation and physical reality'), SpreadPosition(10, "Malkuth (Kingdom)", "Manifestation and physical reality"),
] ],
}, },
'relationship': { "relationship": {
'name': 'Relationship', "name": "Relationship",
'description': '5-card spread for relationship insight', "description": "5-card spread for relationship insight",
'positions': [ "positions": [
SpreadPosition(1, 'You', 'Your position, feelings, or role'), SpreadPosition(1, "You", "Your position, feelings, or role"),
SpreadPosition(2, 'Them', 'Their position, feelings, or perspective'), SpreadPosition(2, "Them", "Their position, feelings, or perspective"),
SpreadPosition(3, 'The Relationship', 'The dynamic and connection'), SpreadPosition(3, "The Relationship", "The dynamic and connection"),
SpreadPosition(4, 'Challenge', 'Current challenge or friction point'), SpreadPosition(4, "Challenge", "Current challenge or friction point"),
SpreadPosition(5, 'Outcome', 'Where the relationship is heading'), SpreadPosition(5, "Outcome", "Where the relationship is heading"),
] ],
}, },
'yes or no': { "yes or no": {
'name': 'Yes or No', "name": "Yes or No",
'description': '1-card spread for simple yes/no answers', "description": "1-card spread for simple yes/no answers",
'positions': [ "positions": [
SpreadPosition(1, 'Answer', 'Major Arcana = Yes, Minor Arcana = No, Court Cards = Maybe'), SpreadPosition(
] 1, "Answer", "Major Arcana = Yes, Minor Arcana = No, Court Cards = Maybe"
),
],
}, },
} }
def __init__(self, spread_name: str) -> None: def __init__(self, spread_name: str) -> None:
""" """
Initialize a spread by name (case-insensitive). Initialize a spread by name (case-insensitive).
Args: Args:
spread_name: Name of the spread to use spread_name: Name of the spread to use
Raises: Raises:
ValueError: If spread name not found ValueError: If spread name not found
""" """
# Normalize name (case-insensitive, allow underscores or spaces) # Normalize name (case-insensitive, allow underscores or spaces)
normalized_name = spread_name.lower().replace('_', ' ') normalized_name = spread_name.lower().replace("_", " ")
# Find matching spread # Find matching spread
spread_data = None spread_data = None
for key, data in self.SPREADS.items(): for key, data in self.SPREADS.items():
if key == normalized_name or data['name'].lower() == normalized_name: if key == normalized_name or data["name"].lower() == normalized_name:
spread_data = data spread_data = data
break break
if not spread_data: if not spread_data:
available = ', '.join(f"'{k}'" for k in self.SPREADS.keys()) available = ", ".join(f"'{k}'" for k in self.SPREADS.keys())
raise ValueError( raise ValueError(f"Spread '{spread_name}' not found. Available spreads: {available}")
f"Spread '{spread_name}' not found. Available spreads: {available}"
) self.name = spread_data["name"]
self.description = spread_data["description"]
self.name = spread_data['name'] self.positions: List[SpreadPosition] = spread_data["positions"]
self.description = spread_data['description']
self.positions: List[SpreadPosition] = spread_data['positions']
def __str__(self) -> str: def __str__(self) -> str:
"""Return formatted spread information.""" """Return formatted spread information."""
lines = [ lines = [
f"═══════════════════════════════════════════", "═══════════════════════════════════════════",
f" {self.name}", f" {self.name}",
f"═══════════════════════════════════════════", "═══════════════════════════════════════════",
f"", "",
f"{self.description}", f"{self.description}",
f"", "",
f"Positions ({len(self.positions)} cards):", f"Positions ({len(self.positions)} cards):",
f"", "",
] ]
for pos in self.positions: for pos in self.positions:
lines.append(f" {pos}") lines.append(f" {pos}")
lines.append(f"") lines.append("")
lines.append(f"═══════════════════════════════════════════") lines.append("═══════════════════════════════════════════")
return "\n".join(lines) return "\n".join(lines)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Spread('{self.name}')" return f"Spread('{self.name}')"
@classmethod @classmethod
def available_spreads(cls) -> str: def available_spreads(cls) -> str:
"""Return list of all available spreads.""" """Return list of all available spreads."""
lines = [ lines = ["Available Tarot Spreads:", "" * 50, ""]
"Available Tarot Spreads:",
"" * 50,
""
]
for key, data in cls.SPREADS.items(): for key, data in cls.SPREADS.items():
lines.append(f"{data['name']}") lines.append(f"{data['name']}")
lines.append(f" Name for API: '{key}'") lines.append(f" Name for API: '{key}'")
lines.append(f" Positions: {len(data['positions'])}") lines.append(f" Positions: {len(data['positions'])}")
lines.append(f" {data['description']}") lines.append(f" {data['description']}")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
def get_position(self, position_number: int) -> Optional[SpreadPosition]: def get_position(self, position_number: int) -> Optional[SpreadPosition]:
"""Get a specific position by number.""" """Get a specific position by number."""
for pos in self.positions: for pos in self.positions:
@@ -241,96 +244,94 @@ class Spread:
def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]: def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]:
""" """
Draw cards for all positions in a spread. Draw cards for all positions in a spread.
Ensures all drawn cards are unique (no duplicates in a single spread). Ensures all drawn cards are unique (no duplicates in a single spread).
Args: Args:
spread: The Spread object with positions defined spread: The Spread object with positions defined
deck: Optional list of Card objects. If None, uses Tarot.deck.cards deck: Optional list of Card objects. If None, uses Tarot.deck.cards
Returns: Returns:
List of DrawnCard objects (one per position) with random cards and reversals List of DrawnCard objects (one per position) with random cards and reversals
Raises: Raises:
ValueError: If spread has more positions than cards in the deck ValueError: If spread has more positions than cards in the deck
""" """
import random
# Load deck if not provided # Load deck if not provided
if deck is None: if deck is None:
from tarot.deck import Deck from tarot.deck import Deck
deck_instance = Deck() deck_instance = Deck()
deck = deck_instance.cards deck = deck_instance.cards
# Validate that we have enough cards to draw from without duplicates # Validate that we have enough cards to draw from without duplicates
num_positions = len(spread.positions) num_positions = len(spread.positions)
if num_positions > len(deck): if num_positions > len(deck):
raise ValueError( raise ValueError(f"Cannot draw {num_positions} unique cards from deck of {len(deck)} cards")
f"Cannot draw {num_positions} unique cards from deck of {len(deck)} cards"
)
# Draw unique cards using random.sample (no replacements) # Draw unique cards using random.sample (no replacements)
drawn_deck = random.sample(deck, num_positions) drawn_deck = random.sample(deck, num_positions)
drawn_cards = [] drawn_cards = []
for position, card in zip(spread.positions, drawn_deck): for position, card in zip(spread.positions, drawn_deck):
# Random reversal (50% chance) # Random reversal (50% chance)
is_reversed = random.choice([True, False]) is_reversed = random.choice([True, False])
drawn_cards.append(DrawnCard(position, card, is_reversed)) drawn_cards.append(DrawnCard(position, card, is_reversed))
return drawn_cards return drawn_cards
class SpreadReading: class SpreadReading:
"""Represents a complete tarot reading with cards drawn for a spread.""" """Represents a complete tarot reading with cards drawn for a spread."""
def __init__(self, spread: Spread, drawn_cards: List[DrawnCard]) -> None: def __init__(self, spread: Spread, drawn_cards: List[DrawnCard]) -> None:
""" """
Initialize a reading with a spread and drawn cards. Initialize a reading with a spread and drawn cards.
Args: Args:
spread: The Spread object spread: The Spread object
drawn_cards: List of DrawnCard objects drawn_cards: List of DrawnCard objects
""" """
self.spread = spread self.spread = spread
self.drawn_cards = drawn_cards self.drawn_cards = drawn_cards
def __str__(self) -> str: def __str__(self) -> str:
"""Return formatted reading with all cards and interpretations.""" """Return formatted reading with all cards and interpretations."""
lines = [ lines = [
f"╔═══════════════════════════════════════════╗", "╔═══════════════════════════════════════════╗",
f"{self.spread.name:40}", f"{self.spread.name:40}",
f"╚═══════════════════════════════════════════╝", "╚═══════════════════════════════════════════╝",
f"", "",
f"{self.spread.description}", f"{self.spread.description}",
f"", "",
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f"", "",
] ]
for drawn in self.drawn_cards: for drawn in self.drawn_cards:
card = drawn.card card = drawn.card
card_name = card.name card_name = card.name
if drawn.is_reversed: if drawn.is_reversed:
card_name += " ◄ REVERSED" card_name += " ◄ REVERSED"
lines.append(f"Position {drawn.position.number}: {drawn.position.name}") lines.append(f"Position {drawn.position.number}: {drawn.position.name}")
lines.append(f" Card: {card_name}") lines.append(f" Card: {card_name}")
lines.append(f" Meaning: {drawn.position.meaning}") lines.append(f" Meaning: {drawn.position.meaning}")
# Add card details if available # Add card details if available
if hasattr(card, 'number'): if hasattr(card, "number"):
lines.append(f" Card #: {card.number}") lines.append(f" Card #: {card.number}")
if hasattr(card, 'arcana'): if hasattr(card, "arcana"):
lines.append(f" Arcana: {card.arcana}") lines.append(f" Arcana: {card.arcana}")
if hasattr(card, 'suit') and card.suit: if hasattr(card, "suit") and card.suit:
lines.append(f" Suit: {card.suit.name}") lines.append(f" Suit: {card.suit.name}")
lines.append("") lines.append("")
lines.append(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines) return "\n".join(lines)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"SpreadReading({self.spread.name}, {len(self.drawn_cards)} cards)" return f"SpreadReading({self.spread.name}, {len(self.drawn_cards)} cards)"

View File

@@ -1,21 +1,21 @@
""" """
Tarot deck module - Core card and deck classes. Tarot deck module - Core card and deck classes.
Provides the Deck class for managing Tarot cards and the Card, MajorCard, Provides the Deck class for managing Tarot cards and the Card, MajorCard,
MinorCard, and related classes for representing individual cards. MinorCard, and related classes for representing individual cards.
""" """
from .deck import ( from .deck import (
DLT,
AceCard,
Card, Card,
CardQuery,
CourtCard,
Deck,
MajorCard, MajorCard,
MinorCard, MinorCard,
PipCard, PipCard,
AceCard,
CourtCard,
CardQuery,
TemporalQuery, TemporalQuery,
DLT,
Deck,
) )
__all__ = [ __all__ = [

View File

@@ -5,19 +5,27 @@ This module defines the Deck class for managing Tarot cards and the Card,
MajorCard, and MinorCard classes for representing individual cards. MajorCard, and MinorCard classes for representing individual cards.
""" """
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, TYPE_CHECKING, Dict
import random import random
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from ..attributes import ( from ..attributes import (
Meaning, CardImage, Suit, Zodiac, Element, Path, CardImage,
Planet, Sephera, Color, PeriodicTable, ElementType, DoublLetterTrump Color,
Element,
ElementType,
Meaning,
Path,
PeriodicTable,
Planet,
Sephera,
Suit,
) )
from ..constants import ( from ..constants import (
COURT_RANKS, COURT_RANKS,
MAJOR_ARCANA_NAMES, MAJOR_ARCANA_NAMES,
PIP_INDEX_TO_NUMBER,
MINOR_RANK_NAMES, MINOR_RANK_NAMES,
PIP_INDEX_TO_NUMBER,
PIP_ORDER, PIP_ORDER,
SUITS_FIRST, SUITS_FIRST,
SUITS_LAST, SUITS_LAST,
@@ -35,6 +43,7 @@ def _get_card_data():
global _card_data global _card_data
if _card_data is None: if _card_data is None:
from ..card.data import CardDataLoader from ..card.data import CardDataLoader
_card_data = CardDataLoader() _card_data = CardDataLoader()
return _card_data return _card_data
@@ -42,16 +51,17 @@ def _get_card_data():
@dataclass @dataclass
class Card: class Card:
"""Base class representing a Tarot card.""" """Base class representing a Tarot card."""
number: int number: int
name: str name: str
meaning: Meaning meaning: Meaning
arcana: str # "Major" or "Minor" arcana: str # "Major" or "Minor"
image: Optional[CardImage] = None image: Optional[CardImage] = None
# These are overridden in subclasses but declared here for MinorCard compatibility # These are overridden in subclasses but declared here for MinorCard compatibility
suit: Optional[Suit] = None suit: Optional[Suit] = None
pip: int = 0 pip: int = 0
# Card-specific details # Card-specific details
explanation: Dict[str, str] = field(default_factory=dict) explanation: Dict[str, str] = field(default_factory=dict)
interpretation: str = "" interpretation: str = ""
@@ -59,41 +69,48 @@ class Card:
reversed_keywords: List[str] = field(default_factory=list) reversed_keywords: List[str] = field(default_factory=list)
guidance: str = "" guidance: str = ""
numerology: Optional[int] = None numerology: Optional[int] = None
# Image path for custom deck images # Image path for custom deck images
image_path: Optional[str] = None image_path: Optional[str] = None
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.number}. {self.name}" return f"{self.number}. {self.name}"
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Card({self.number}, '{self.name}')" return f"Card({self.number}, '{self.name}')"
def key(self) -> str: def key(self) -> str:
""" """
Get the card's key as a Roman numeral representation. Get the card's key as a Roman numeral representation.
Returns: Returns:
Roman numeral string (e.g., "I", "XXI") for Major Arcana, Roman numeral string (e.g., "I", "XXI") for Major Arcana,
or the pip number as string for Minor Arcana. or the pip number as string for Minor Arcana.
""" """
# Import here to avoid circular imports # Import here to avoid circular imports
from ..card.details import CardDetailsRegistry from ..card.details import CardDetailsRegistry
# For Major Arcana cards, convert the key to Roman numerals # For Major Arcana cards, convert the key to Roman numerals
if self.arcana == "Major": if self.arcana == "Major":
return CardDetailsRegistry.key_to_roman(self.number) return CardDetailsRegistry.key_to_roman(self.number)
# For Minor Arcana, return the pip number as a formatted string # For Minor Arcana, return the pip number as a formatted string
if hasattr(self, 'pip') and self.pip > 0: if hasattr(self, "pip") and self.pip > 0:
pip_names = { pip_names = {
2: "Two", 3: "Three", 4: "Four", 5: "Five", 2: "Two",
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten" 3: "Three",
4: "Four",
5: "Five",
6: "Six",
7: "Seven",
8: "Eight",
9: "Nine",
10: "Ten",
} }
return pip_names.get(self.pip, str(self.pip)) return pip_names.get(self.pip, str(self.pip))
return str(self.number) return str(self.number)
@property @property
def type(self) -> str: def type(self) -> str:
"""Get the specific card type (Major, Pip, Ace, Court).""" """Get the specific card type (Major, Pip, Ace, Court)."""
@@ -111,24 +128,30 @@ class Card:
@dataclass @dataclass
class MajorCard(Card): class MajorCard(Card):
"""Represents a Major Arcana card.""" """Represents a Major Arcana card."""
kabbalistic_number: Optional[int] = None kabbalistic_number: Optional[int] = None
tarot_letter: Optional[str] = None tarot_letter: Optional[str] = None
tree_of_life_path: Optional[int] = None tree_of_life_path: Optional[int] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Kabbalistic number should be 0-21, but deck position can be anywhere # Kabbalistic number should be 0-21, but deck position can be anywhere
if self.kabbalistic_number is not None and (self.kabbalistic_number < 0 or self.kabbalistic_number > 21): if self.kabbalistic_number is not None and (
raise ValueError(f"Major Arcana kabbalistic number must be 0-21, got {self.kabbalistic_number}") self.kabbalistic_number < 0 or self.kabbalistic_number > 21
):
raise ValueError(
f"Major Arcana kabbalistic number must be 0-21, got {self.kabbalistic_number}"
)
self.arcana = "Major" self.arcana = "Major"
@dataclass @dataclass
class MinorCard(Card): class MinorCard(Card):
"""Represents a Minor Arcana card - either Pip or Court card.""" """Represents a Minor Arcana card - either Pip or Court card."""
suit: Suit = None # type: ignore suit: Suit = None # type: ignore
astrological_influence: Optional[str] = None astrological_influence: Optional[str] = None
element: Optional[Element] = None element: Optional[Element] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.suit is None: if self.suit is None:
raise ValueError("suit must be provided for MinorCard") raise ValueError("suit must be provided for MinorCard")
@@ -138,12 +161,13 @@ class MinorCard(Card):
@dataclass @dataclass
class PipCard(MinorCard): class PipCard(MinorCard):
"""Represents a Pip card (2 through 10) - has a pip number. """Represents a Pip card (2 through 10) - has a pip number.
Pip cards represent numbered forces in their suit, from Two Pip cards represent numbered forces in their suit, from Two
through its full development (10). through its full development (10).
""" """
pip: int = 0 pip: int = 0
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not (2 <= self.pip <= 10): if not (2 <= self.pip <= 10):
raise ValueError(f"Pip card number must be 2-10, got {self.pip}") raise ValueError(f"Pip card number must be 2-10, got {self.pip}")
@@ -153,13 +177,14 @@ class PipCard(MinorCard):
@dataclass @dataclass
class AceCard(MinorCard): class AceCard(MinorCard):
"""Represents an Ace card - the root/foundation of the suit. """Represents an Ace card - the root/foundation of the suit.
The Ace is the initial force of the suit and contains the potential The Ace is the initial force of the suit and contains the potential
for all other cards within that suit. Aces have pip=1 but are not for all other cards within that suit. Aces have pip=1 but are not
technically pip cards. technically pip cards.
""" """
pip: int = 1 pip: int = 1
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.pip != 1: if self.pip != 1:
raise ValueError(f"AceCard must have pip 1, got {self.pip}") raise ValueError(f"AceCard must have pip 1, got {self.pip}")
@@ -169,22 +194,22 @@ class AceCard(MinorCard):
@dataclass @dataclass
class CourtCard(MinorCard): class CourtCard(MinorCard):
"""Represents a Court Card - Knight, Prince, Princess, or Queen. """Represents a Court Card - Knight, Prince, Princess, or Queen.
Court cards represent people/personalities and are the highest rank Court cards represent people/personalities and are the highest rank
in the minor arcana. They do NOT have pips - they are archetypes. in the minor arcana. They do NOT have pips - they are archetypes.
Each court card is associated with an element and Hebrew letter (Path): Each court card is associated with an element and Hebrew letter (Path):
- Knight: Fire + Yod (path 20) - Knight: Fire + Yod (path 20)
- Prince: Air + Vav (path 16) - Prince: Air + Vav (path 16)
- Princess: Earth + Heh (path 15) - Princess: Earth + Heh (path 15)
- Queen: Water + Heh (path 15) - Queen: Water + Heh (path 15)
""" """
COURT_RANKS = {"Knight": 12, "Prince": 11, "Princess": 13, "Queen": 14} COURT_RANKS = {"Knight": 12, "Prince": 11, "Princess": 13, "Queen": 14}
court_rank: str = "" court_rank: str = ""
associated_element: Optional[ElementType] = None associated_element: Optional[ElementType] = None
hebrew_letter_path: Optional['Path'] = None hebrew_letter_path: Optional["Path"] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.court_rank not in self.COURT_RANKS: if self.court_rank not in self.COURT_RANKS:
raise ValueError( raise ValueError(
@@ -194,75 +219,90 @@ class CourtCard(MinorCard):
super().__post_init__() super().__post_init__()
class CardQuery: class CardQuery:
"""Helper class for fluent card queries: deck.number(3).minor.wands""" """Helper class for fluent card queries: deck.number(3).minor.wands"""
def __init__(self, deck: 'Deck', number: Optional[int] = None, def __init__(
arcana: Optional[str] = None) -> None: self, deck: "Deck", number: Optional[int] = None, arcana: Optional[str] = None
) -> None:
self.deck = deck self.deck = deck
self.number = number self.number = number
self.arcana = arcana self.arcana = arcana
def _filter_cards(self) -> List[Card]: def _filter_cards(self) -> List[Card]:
"""Get filtered cards based on current query state.""" """Get filtered cards based on current query state."""
cards = self.deck.cards cards = self.deck.cards
if self.number is not None: if self.number is not None:
cards = [c for c in cards if c.number == self.number or cards = [
(hasattr(c, 'pip') and c.pip == self.number)] c
for c in cards
if c.number == self.number or (hasattr(c, "pip") and c.pip == self.number)
]
if self.arcana is not None: if self.arcana is not None:
cards = [c for c in cards if c.arcana == self.arcana] cards = [c for c in cards if c.arcana == self.arcana]
return cards return cards
@property @property
def major(self) -> List[Card]: def major(self) -> List[Card]:
"""Filter to Major Arcana only.""" """Filter to Major Arcana only."""
return [c for c in self._filter_cards() if c.arcana == "Major"] return [c for c in self._filter_cards() if c.arcana == "Major"]
@property @property
def minor(self) -> 'CardQuery': def minor(self) -> "CardQuery":
"""Filter to Minor Arcana, return new CardQuery for suit chaining.""" """Filter to Minor Arcana, return new CardQuery for suit chaining."""
return CardQuery(self.deck, self.number, "Minor") return CardQuery(self.deck, self.number, "Minor")
@property @property
def cups(self) -> List[Card]: def cups(self) -> List[Card]:
"""Get cards in Cups suit.""" """Get cards in Cups suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and return [
c.suit and c.suit.name == "Cups"] c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Cups"
]
@property @property
def swords(self) -> List[Card]: def swords(self) -> List[Card]:
"""Get cards in Swords suit.""" """Get cards in Swords suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and return [
c.suit and c.suit.name == "Swords"] c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Swords"
]
@property @property
def wands(self) -> List[Card]: def wands(self) -> List[Card]:
"""Get cards in Wands suit.""" """Get cards in Wands suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and return [
c.suit and c.suit.name == "Wands"] c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Wands"
]
@property @property
def pentacles(self) -> List[Card]: def pentacles(self) -> List[Card]:
"""Get cards in Pentacles suit.""" """Get cards in Pentacles suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and return [
c.suit and c.suit.name == "Pentacles"] c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Pentacles"
]
def __iter__(self): def __iter__(self):
"""Allow iteration over filtered cards.""" """Allow iteration over filtered cards."""
return iter(self._filter_cards()) return iter(self._filter_cards())
def __len__(self) -> int: def __len__(self) -> int:
"""Return count of filtered cards.""" """Return count of filtered cards."""
return len(self._filter_cards()) return len(self._filter_cards())
def __getitem__(self, index: int) -> Card: def __getitem__(self, index: int) -> Card:
"""Get card by index from filtered results.""" """Get card by index from filtered results."""
return self._filter_cards()[index] return self._filter_cards()[index]
def __repr__(self) -> str: def __repr__(self) -> str:
cards = self._filter_cards() cards = self._filter_cards()
names = [c.name for c in cards] names = [c.name for c in cards]
@@ -271,12 +311,17 @@ class CardQuery:
class TemporalQuery: class TemporalQuery:
"""Helper class for fluent temporal queries: loader.month(5).day(23).hour(15)""" """Helper class for fluent temporal queries: loader.month(5).day(23).hour(15)"""
def __init__(self, loader: 'CardDataLoader', month_num: Optional[int] = None, def __init__(
day_num: Optional[int] = None, hour_num: Optional[int] = None) -> None: self,
loader: "CardDataLoader",
month_num: Optional[int] = None,
day_num: Optional[int] = None,
hour_num: Optional[int] = None,
) -> None:
""" """
Initialize temporal query builder. Initialize temporal query builder.
Args: Args:
loader: CardDataLoader instance for fetching temporal data loader: CardDataLoader instance for fetching temporal data
month_num: Month number (1-12) month_num: Month number (1-12)
@@ -287,71 +332,74 @@ class TemporalQuery:
self.month_num = month_num self.month_num = month_num
self.day_num = day_num self.day_num = day_num
self.hour_num = hour_num self.hour_num = hour_num
def month(self, num: int) -> 'TemporalQuery': def month(self, num: int) -> "TemporalQuery":
"""Set month (1-12) and return new query for chaining.""" """Set month (1-12) and return new query for chaining."""
return TemporalQuery(self.loader, month_num=num, return TemporalQuery(
day_num=self.day_num, hour_num=self.hour_num) self.loader, month_num=num, day_num=self.day_num, hour_num=self.hour_num
)
def day(self, num: int) -> 'TemporalQuery':
def day(self, num: int) -> "TemporalQuery":
"""Set day (1-31) and return new query for chaining.""" """Set day (1-31) and return new query for chaining."""
if self.month_num is None: if self.month_num is None:
raise ValueError("Must set month before day") raise ValueError("Must set month before day")
return TemporalQuery(self.loader, month_num=self.month_num, return TemporalQuery(
day_num=num, hour_num=self.hour_num) self.loader, month_num=self.month_num, day_num=num, hour_num=self.hour_num
)
def hour(self, num: int) -> 'TemporalQuery':
def hour(self, num: int) -> "TemporalQuery":
"""Set hour (0-23) and return new query for chaining.""" """Set hour (0-23) and return new query for chaining."""
if self.month_num is None or self.day_num is None: if self.month_num is None or self.day_num is None:
raise ValueError("Must set month and day before hour") raise ValueError("Must set month and day before hour")
return TemporalQuery(self.loader, month_num=self.month_num, return TemporalQuery(
day_num=self.day_num, hour_num=num) self.loader, month_num=self.month_num, day_num=self.day_num, hour_num=num
)
def weekday(self) -> Optional[str]: def weekday(self) -> Optional[str]:
"""Get weekday name for current month/day combination using Zeller's congruence.""" """Get weekday name for current month/day combination using Zeller's congruence."""
if self.month_num is None or self.day_num is None: if self.month_num is None or self.day_num is None:
raise ValueError("Must set month and day to get weekday") raise ValueError("Must set month and day to get weekday")
# Zeller's congruence (adjusted for current calendar) # Zeller's congruence (adjusted for current calendar)
month = self.month_num month = self.month_num
day = self.day_num day = self.day_num
year = 2024 # Use current year as reference year = 2024 # Use current year as reference
# Adjust month and year for March-based calculation # Adjust month and year for March-based calculation
if month < 3: if month < 3:
month += 12 month += 12
year -= 1 year -= 1
# Zeller's formula # Zeller's formula
q = day q = day
m = month m = month
k = year % 100 k = year % 100
j = year // 100 j = year // 100
h = (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) % 7 h = (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) % 7
# Convert to weekday name (0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday) # Convert to weekday name (0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday)
day_names = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] day_names = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
return day_names[h] return day_names[h]
def month_info(self): def month_info(self):
"""Return month metadata for the configured query.""" """Return month metadata for the configured query."""
if self.month_num is None: if self.month_num is None:
return None return None
return self.loader.month_info(self.month_num) return self.loader.month_info(self.month_num)
def day_info(self): def day_info(self):
"""Return day metadata for the configured query.""" """Return day metadata for the configured query."""
if self.day_num is None: if self.day_num is None:
return None return None
return self.loader.day_info(self.day_num) return self.loader.day_info(self.day_num)
def hour_info(self): def hour_info(self):
"""Return the planetary hour metadata for the configured query.""" """Return the planetary hour metadata for the configured query."""
if self.hour_num is None: if self.hour_num is None:
return None return None
return self.loader.clock_hour(self.hour_num) return self.loader.clock_hour(self.hour_num)
def __repr__(self) -> str: def __repr__(self) -> str:
parts = [] parts = []
if self.month_num: if self.month_num:
@@ -366,47 +414,48 @@ class TemporalQuery:
class DLT: class DLT:
""" """
Double Letter Trump (DLT) accessor. Double Letter Trump (DLT) accessor.
Double Letter Trumps are Major Arcana cards 3-21 (19 cards total), Double Letter Trumps are Major Arcana cards 3-21 (19 cards total),
each associated with a Hebrew letter and planetary/astrological force. each associated with a Hebrew letter and planetary/astrological force.
Usage: Usage:
dlt = DLT(3) # Get the 3rd Double Letter Trump (The Empress) dlt = DLT(3) # Get the 3rd Double Letter Trump (The Empress)
dlt = DLT(7) # Get the 7th Double Letter Trump (The Chariot) dlt = DLT(7) # Get the 7th Double Letter Trump (The Chariot)
""" """
def __init__(self, trump_number: int) -> None: def __init__(self, trump_number: int) -> None:
""" """
Initialize a Double Letter Trump query. Initialize a Double Letter Trump query.
Args: Args:
trump_number: Position in DLT sequence (3-21) trump_number: Position in DLT sequence (3-21)
Raises: Raises:
ValueError: If trump_number is not 3-21 ValueError: If trump_number is not 3-21
""" """
if not 3 <= trump_number <= 21: if not 3 <= trump_number <= 21:
raise ValueError(f"DLT number must be 3-21, got {trump_number}") raise ValueError(f"DLT number must be 3-21, got {trump_number}")
self.trump_number = trump_number self.trump_number = trump_number
self._loader: Optional['CardDataLoader'] = None self._loader: Optional["CardDataLoader"] = None
self._deck: Optional[Deck] = None self._deck: Optional[Deck] = None
@property @property
def loader(self) -> 'CardDataLoader': def loader(self) -> "CardDataLoader":
"""Lazy-load CardDataLoader on first access.""" """Lazy-load CardDataLoader on first access."""
if self._loader is None: if self._loader is None:
from ..card.data import CardDataLoader from ..card.data import CardDataLoader
self._loader = CardDataLoader() self._loader = CardDataLoader()
return self._loader return self._loader
@property @property
def deck(self) -> 'Deck': def deck(self) -> "Deck":
"""Lazy-load Deck on first access.""" """Lazy-load Deck on first access."""
if self._deck is None: if self._deck is None:
self._deck = Deck() self._deck = Deck()
return self._deck return self._deck
def card(self) -> Optional[Card]: def card(self) -> Optional[Card]:
"""Get the Tarot card for this DLT.""" """Get the Tarot card for this DLT."""
# Major Arcana cards are numbered 0-21, so DLT(3) = Major card 3 # Major Arcana cards are numbered 0-21, so DLT(3) = Major card 3
@@ -414,61 +463,61 @@ class DLT:
if card.arcana == "Major" and card.number == self.trump_number: if card.arcana == "Major" and card.number == self.trump_number:
return card return card
return None return None
def periodic_entry(self) -> Optional[PeriodicTable]: def periodic_entry(self) -> Optional[PeriodicTable]:
"""Get the periodic table entry with cross-correspondences.""" """Get the periodic table entry with cross-correspondences."""
return self.loader.periodic_entry(self.trump_number) return self.loader.periodic_entry(self.trump_number)
def sephera(self) -> Optional[Sephera]: def sephera(self) -> Optional[Sephera]:
"""Get the Sephira associated with this DLT.""" """Get the Sephira associated with this DLT."""
return self.loader.sephera(self.trump_number) return self.loader.sephera(self.trump_number)
def planet(self) -> Optional[Planet]: def planet(self) -> Optional[Planet]:
"""Get the planetary ruler for this DLT.""" """Get the planetary ruler for this DLT."""
periodic = self.periodic_entry() periodic = self.periodic_entry()
return periodic.planet if periodic else None return periodic.planet if periodic else None
def element(self) -> Optional[ElementType]: def element(self) -> Optional[ElementType]:
"""Get the element associated with this DLT.""" """Get the element associated with this DLT."""
periodic = self.periodic_entry() periodic = self.periodic_entry()
return periodic.element if periodic else None return periodic.element if periodic else None
def hebrew_letter(self) -> Optional[str]: def hebrew_letter(self) -> Optional[str]:
"""Get the Hebrew letter associated with this DLT.""" """Get the Hebrew letter associated with this DLT."""
periodic = self.periodic_entry() periodic = self.periodic_entry()
return periodic.hebrew_letter if periodic else None return periodic.hebrew_letter if periodic else None
def color(self) -> Optional[Color]: def color(self) -> Optional[Color]:
"""Get the color associated with this DLT.""" """Get the color associated with this DLT."""
periodic = self.periodic_entry() periodic = self.periodic_entry()
return periodic.color if periodic else None return periodic.color if periodic else None
def __repr__(self) -> str: def __repr__(self) -> str:
card = self.card() card = self.card()
card_name = card.name if card else "Unknown" card_name = card.name if card else "Unknown"
return f"DLT({self.trump_number}) - {card_name}" return f"DLT({self.trump_number}) - {card_name}"
def __str__(self) -> str: def __str__(self) -> str:
return self.__repr__() return self.__repr__()
class Deck: class Deck:
"""Represents a standard 78-card Tarot deck.""" """Represents a standard 78-card Tarot deck."""
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize a standard Tarot deck with all 78 cards.""" """Initialize a standard Tarot deck with all 78 cards."""
self.cards: List[Card] = [] self.cards: List[Card] = []
self.discard_pile: List[Card] = [] self.discard_pile: List[Card] = []
self._initialize_deck() self._initialize_deck()
def _initialize_deck(self) -> None: def _initialize_deck(self) -> None:
"""Initialize the deck with all 78 Tarot cards. """Initialize the deck with all 78 Tarot cards.
Order: Cups (1-14), Pentacles/Disks (15-28), Swords (29-42), Order: Cups (1-14), Pentacles/Disks (15-28), Swords (29-42),
Major Arcana (43-64), Wands (65-78) Major Arcana (43-64), Wands (65-78)
Minor suit sequencing (per suit): Ace, 2-10, Prince, Knight, Princess, Queen. Minor suit sequencing (per suit): Ace, 2-10, Prince, Knight, Princess, Queen.
This puts Queen of Wands as card #78, the final card. This puts Queen of Wands as card #78, the final card.
""" """
# Minor Arcana - First three suits (Cups, Pentacles, Swords) # Minor Arcana - First three suits (Cups, Pentacles, Swords)
@@ -479,18 +528,18 @@ class Deck:
earth_element = card_data.element("Earth") earth_element = card_data.element("Earth")
air_element = card_data.element("Air") air_element = card_data.element("Air")
fire_element = card_data.element("Fire") fire_element = card_data.element("Fire")
if not water_element or not earth_element or not air_element or not fire_element: if not water_element or not earth_element or not air_element or not fire_element:
raise RuntimeError("Failed to load element data from CardDataLoader") raise RuntimeError("Failed to load element data from CardDataLoader")
# Get Hebrew letters (Paths) for court cards # Get Hebrew letters (Paths) for court cards
yod_path = card_data.path(20) # Yod yod_path = card_data.path(20) # Yod
vav_path = card_data.path(16) # Vav vav_path = card_data.path(16) # Vav
he_path = card_data.path(15) # He (Heh) he_path = card_data.path(15) # He (Heh)
if not yod_path or not vav_path or not he_path: if not yod_path or not vav_path or not he_path:
raise RuntimeError("Failed to load Hebrew letter/path data from CardDataLoader") raise RuntimeError("Failed to load Hebrew letter/path data from CardDataLoader")
# Map court ranks to their associated elements and Hebrew letter paths # Map court ranks to their associated elements and Hebrew letter paths
# Knight -> Fire + Yod, Prince -> Air + Vav, Princess -> Earth + Heh, Queen -> Water + Heh # Knight -> Fire + Yod, Prince -> Air + Vav, Princess -> Earth + Heh, Queen -> Water + Heh
court_rank_mappings = { court_rank_mappings = {
@@ -507,12 +556,16 @@ class Deck:
"Fire": fire_element, "Fire": fire_element,
} }
def _suit_specs(suit_defs: List[Tuple[str, str, int]]) -> List[Tuple[str, ElementType, int]]: def _suit_specs(
suit_defs: List[Tuple[str, str, int]],
) -> List[Tuple[str, ElementType, int]]:
specs: List[Tuple[str, ElementType, int]] = [] specs: List[Tuple[str, ElementType, int]] = []
for suit_name, element_key, suit_num in suit_defs: for suit_name, element_key, suit_num in suit_defs:
element_obj = element_lookup.get(element_key) element_obj = element_lookup.get(element_key)
if element_obj is None: if element_obj is None:
raise RuntimeError(f"Failed to resolve element '{element_key}' for suit '{suit_name}'") raise RuntimeError(
f"Failed to resolve element '{element_key}' for suit '{suit_name}'"
)
specs.append((suit_name, element_obj, suit_num)) specs.append((suit_name, element_obj, suit_num))
return specs return specs
@@ -530,7 +583,7 @@ class Deck:
card_number, card_number,
court_rank_mappings, court_rank_mappings,
) )
# Major Arcana (43-64) # Major Arcana (43-64)
# Names match filenames in src/tarot/deck/default/ # Names match filenames in src/tarot/deck/default/
for i, name in enumerate(MAJOR_ARCANA_NAMES): for i, name in enumerate(MAJOR_ARCANA_NAMES):
@@ -538,15 +591,14 @@ class Deck:
number=card_number, number=card_number,
name=name, name=name,
meaning=Meaning( meaning=Meaning(
upright=f"{name} upright meaning", upright=f"{name} upright meaning", reversed=f"{name} reversed meaning"
reversed=f"{name} reversed meaning"
), ),
arcana="Major", arcana="Major",
kabbalistic_number=i kabbalistic_number=i,
) )
self.cards.append(card) self.cards.append(card)
card_number += 1 card_number += 1
# Minor Arcana - Last suit (Wands, 65-78) # Minor Arcana - Last suit (Wands, 65-78)
# Organized logically: Ace, 2-10, then court cards Prince, Knight, Princess, Queen # Organized logically: Ace, 2-10, then court cards Prince, Knight, Princess, Queen
for suit_name, element_obj, suit_num in suits_data_last: for suit_name, element_obj, suit_num in suits_data_last:
@@ -557,16 +609,16 @@ class Deck:
card_number, card_number,
court_rank_mappings, court_rank_mappings,
) )
# Load detailed explanations and keywords from registry # Load detailed explanations and keywords from registry
try: try:
from ..card.loader import load_deck_details from ..card.loader import load_deck_details
load_deck_details(self) load_deck_details(self)
except ImportError: except ImportError:
# Handle case where loader might not be available or circular import issues # Handle case where loader might not be available or circular import issues
pass pass
def _add_minor_cards_for_suit( def _add_minor_cards_for_suit(
self, self,
suit_name: str, suit_name: str,
@@ -625,77 +677,77 @@ class Deck:
return card_number return card_number
def shuffle(self) -> None: def shuffle(self) -> None:
"""Shuffle the deck.""" """Shuffle the deck."""
random.shuffle(self.cards) random.shuffle(self.cards)
def draw(self, num_cards: int = 1) -> List[Card]: def draw(self, num_cards: int = 1) -> List[Card]:
""" """
Draw cards from the deck. Draw cards from the deck.
Args: Args:
num_cards: Number of cards to draw (default: 1) num_cards: Number of cards to draw (default: 1)
Returns: Returns:
List of drawn cards List of drawn cards
""" """
if num_cards < 1: if num_cards < 1:
raise ValueError("Must draw at least 1 card") raise ValueError("Must draw at least 1 card")
if num_cards > len(self.cards): if num_cards > len(self.cards):
raise ValueError(f"Cannot draw {num_cards} cards from deck with {len(self.cards)} cards") raise ValueError(
f"Cannot draw {num_cards} cards from deck with {len(self.cards)} cards"
)
drawn = [] drawn = []
for _ in range(num_cards): for _ in range(num_cards):
drawn.append(self.cards.pop(0)) drawn.append(self.cards.pop(0))
return drawn return drawn
def reset(self) -> None: def reset(self) -> None:
"""Reset the deck to its initial state.""" """Reset the deck to its initial state."""
self.cards.clear() self.cards.clear()
self.discard_pile.clear() self.discard_pile.clear()
self._initialize_deck() self._initialize_deck()
def remaining(self) -> int: def remaining(self) -> int:
"""Return the number of cards remaining in the deck.""" """Return the number of cards remaining in the deck."""
return len(self.cards) return len(self.cards)
def number(self, pip_value: int) -> CardQuery: def number(self, pip_value: int) -> CardQuery:
""" """
Query cards by number (pip value). Query cards by number (pip value).
Usage: Usage:
deck.number(3) # All cards with 3 deck.number(3) # All cards with 3
deck.number(3).minor # All minor 3s deck.number(3).minor # All minor 3s
deck.number(3).minor.wands # 3 of Wands deck.number(3).minor.wands # 3 of Wands
""" """
return CardQuery(self, pip_value) return CardQuery(self, pip_value)
def suit(self, suit_name: str) -> List[Card]: def suit(self, suit_name: str) -> List[Card]:
""" """
Get all cards from a specific suit. Get all cards from a specific suit.
Usage: Usage:
deck.suit("Wands") deck.suit("Wands")
""" """
return [c for c in self.cards if hasattr(c, 'suit') and return [c for c in self.cards if hasattr(c, "suit") and c.suit and c.suit.name == suit_name]
c.suit and c.suit.name == suit_name]
@property @property
def major(self) -> List[Card]: def major(self) -> List[Card]:
"""Get all Major Arcana cards.""" """Get all Major Arcana cards."""
return [c for c in self.cards if c.arcana == "Major"] return [c for c in self.cards if c.arcana == "Major"]
@property @property
def minor(self) -> List[Card]: def minor(self) -> List[Card]:
"""Get all Minor Arcana cards.""" """Get all Minor Arcana cards."""
return [c for c in self.cards if c.arcana == "Minor"] return [c for c in self.cards if c.arcana == "Minor"]
def __len__(self) -> int: def __len__(self) -> int:
"""Return the number of cards in the deck.""" """Return the number of cards in the deck."""
return len(self.cards) return len(self.cards)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Deck({len(self.cards)} cards remaining)" return f"Deck({len(self.cards)} cards remaining)"

View File

@@ -5,115 +5,121 @@ Unified accessor for Tarot-related data and operations.
Usage: Usage:
from tarot import Tarot from tarot import Tarot
# Deck and cards # Deck and cards
card = Tarot.deck.card(3) card = Tarot.deck.card(3)
major5 = Tarot.deck.card.major(5) major5 = Tarot.deck.card.major(5)
cups2 = Tarot.deck.card.minor.cups(2) cups2 = Tarot.deck.card.minor.cups(2)
# Letters (Hebrew with correspondences) # Letters (Hebrew with correspondences)
letter = Tarot.letters('aleph') letter = Tarot.letters('aleph')
simple_letters = Tarot.letters.filter(letter_type="Simple") simple_letters = Tarot.letters.filter(letter_type="Simple")
all_letters = Tarot.letters.display_all() all_letters = Tarot.letters.display_all()
# Tree of Life # Tree of Life
sephera = Tarot.tree.sephera(1) sephera = Tarot.tree.sephera(1)
path = Tarot.tree.path(11) path = Tarot.tree.path(11)
# Cube of Space # Cube of Space
wall = Tarot.cube.wall('North') wall = Tarot.cube.wall('North')
area = Tarot.cube.area('North', 'center') area = Tarot.cube.area('North', 'center')
""" """
from typing import Dict, Optional, Union, overload, TYPE_CHECKING from typing import TYPE_CHECKING, Dict, Optional, Union, overload
from .card import CardAccessor from kaballah import Cube, Tree
from kaballah import Tree, Cube
from letter import letters from letter import letters
from .card import CardAccessor
if TYPE_CHECKING: if TYPE_CHECKING:
from utils.attributes import Planet, God from utils.attributes import God, Planet
from .attributes import Hexagram from .attributes import Hexagram
from .card.data import CardDataLoader
class DeckAccessor: class DeckAccessor:
"""Accessor for deck and card operations.""" """Accessor for deck and card operations."""
# Card accessor (Tarot.deck.card, Tarot.deck.card.major, etc.) # Card accessor (Tarot.deck.card, Tarot.deck.card.major, etc.)
card = CardAccessor() card = CardAccessor()
def __str__(self) -> str: def __str__(self) -> str:
"""Return a nice summary of the deck accessor.""" """Return a nice summary of the deck accessor."""
return "Tarot Deck Accessor\n\nAccess methods:\n Tarot.deck.card(3) - Get card by number\n Tarot.deck.card.filter(...) - Filter cards\n Tarot.deck.card.display() - Display all cards\n Tarot.deck.card.spread(...) - Draw a spread" return (
"Tarot Deck Accessor\n\n"
"Access methods:\n"
" Tarot.deck.card(3) - Get card by number\n"
" Tarot.deck.card.filter(...) - Filter cards\n"
" Tarot.deck.card.display() - Display all cards\n"
" Tarot.deck.card.spread(...) - Draw a spread"
)
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return a nice representation of the deck accessor.""" """Return a nice representation of the deck accessor."""
return self.__str__() return self.__str__()
class Tarot: class Tarot:
""" """
Unified accessor for Tarot correspondences and data. Unified accessor for Tarot correspondences and data.
Provides access to cards, letters, tree of life, and cube of space. Provides access to cards, letters, tree of life, and cube of space.
Temporal and astrological functions are available through the temporal module: Temporal and astrological functions are available through the temporal module:
from temporal import ThalemaClock, Zodiac from temporal import ThalemaClock, Zodiac
Attributes: Attributes:
deck: CardAccessor for card operations deck: CardAccessor for card operations
letters: Hebrew letter accessor letters: Hebrew letter accessor
tree: Tree of Life accessor tree: Tree of Life accessor
cube: Cube of Space accessor cube: Cube of Space accessor
""" """
deck = DeckAccessor() deck = DeckAccessor()
letters = letters() letters = letters()
tree = Tree tree = Tree
cube = Cube cube = Cube
_loader: Optional['CardDataLoader'] = None # type: ignore _loader: Optional["CardDataLoader"] = None # type: ignore
_initialized: bool = False _initialized: bool = False
@classmethod @classmethod
def _ensure_initialized(cls) -> None: def _ensure_initialized(cls) -> None:
"""Lazy-load CardDataLoader on first access.""" """Lazy-load CardDataLoader on first access."""
if cls._initialized: if cls._initialized:
return return
from .card.data import CardDataLoader from .card.data import CardDataLoader
cls._loader = CardDataLoader() cls._loader = CardDataLoader()
cls._initialized = True cls._initialized = True
@classmethod @classmethod
@overload @overload
def planet(cls, name: str) -> Optional['Planet']: def planet(cls, name: str) -> Optional["Planet"]: ...
...
@classmethod @classmethod
@overload @overload
def planet(cls, name: None = ...) -> Dict[str, 'Planet']: def planet(cls, name: None = ...) -> Dict[str, "Planet"]: ...
...
@classmethod @classmethod
def planet(cls, name: Optional[str] = None) -> Union[Optional['Planet'], Dict[str, 'Planet']]: def planet(cls, name: Optional[str] = None) -> Union[Optional["Planet"], Dict[str, "Planet"]]:
"""Return a planet entry or all planets.""" """Return a planet entry or all planets."""
cls._ensure_initialized() cls._ensure_initialized()
return cls._loader.planet(name) # type: ignore return cls._loader.planet(name) # type: ignore
@classmethod @classmethod
def god(cls, name: Optional[str] = None) -> Union[Optional['God'], Dict[str, 'God']]: def god(cls, name: Optional[str] = None) -> Union[Optional["God"], Dict[str, "God"]]:
"""Return a god entry or all gods.""" """Return a god entry or all gods."""
cls._ensure_initialized() cls._ensure_initialized()
return cls._loader.god(name) # type: ignore return cls._loader.god(name) # type: ignore
@classmethod @classmethod
def hexagram(cls, number: Optional[int] = None) -> Union[Optional['Hexagram'], Dict[int, 'Hexagram']]: def hexagram(
cls, number: Optional[int] = None
) -> Union[Optional["Hexagram"], Dict[int, "Hexagram"]]:
"""Return a hexagram or all hexagrams.""" """Return a hexagram or all hexagrams."""
cls._ensure_initialized() cls._ensure_initialized()
return cls._loader.hexagram(number) # type: ignore return cls._loader.hexagram(number) # type: ignore

File diff suppressed because it is too large Load Diff

View File

@@ -8,46 +8,46 @@ Access Patterns:
from temporal import Year, Month, Day, Hour, Week from temporal import Year, Month, Day, Hour, Week
from temporal import Calendar, TimeUtil, TemporalCoordinates, Season from temporal import Calendar, TimeUtil, TemporalCoordinates, Season
from temporal import ThalemaClock, Zodiac, PlanetPosition from temporal import ThalemaClock, Zodiac, PlanetPosition
# Basic usage # Basic usage
year = Year(2025) year = Year(2025)
month = Month(11) # November month = Month(11) # November
day = Day(18) day = Day(18)
hour = Hour(14) hour = Hour(14)
# Calendar operations # Calendar operations
current = Calendar.now() current = Calendar.now()
is_leap = Calendar.is_leap_year(2025) is_leap = Calendar.is_leap_year(2025)
# Temporal coordinates # Temporal coordinates
season = TemporalCoordinates.get_season(11, 18) season = TemporalCoordinates.get_season(11, 18)
days_until_winter = TemporalCoordinates.days_until_event(11, 18, Season.WINTER) days_until_winter = TemporalCoordinates.days_until_event(11, 18, Season.WINTER)
# Astrological positions # Astrological positions
clock = ThalemaClock() clock = ThalemaClock()
print(clock) # Shows planetary positions print(clock) # Shows planetary positions
""" """
from .temporal import Year, Month, Day, Hour, Week from .astrology import PlanetPosition, ThalemaClock, Zodiac
from .calendar import Calendar from .calendar import Calendar
from .coordinates import Season, SolarEvent, TemporalCoordinates
from .temporal import Day, Hour, Month, Week, Year
from .time import TimeUtil from .time import TimeUtil
from .coordinates import TemporalCoordinates, Season, SolarEvent
from .astrology import ThalemaClock, Zodiac, PlanetPosition
__all__ = [ __all__ = [
# Temporal classes # Temporal classes
'Year', "Year",
'Month', "Month",
'Day', "Day",
'Hour', "Hour",
'Week', "Week",
'Calendar', "Calendar",
'TimeUtil', "TimeUtil",
'TemporalCoordinates', "TemporalCoordinates",
'Season', "Season",
'SolarEvent', "SolarEvent",
# Astrological classes # Astrological classes
'ThalemaClock', "ThalemaClock",
'Zodiac', "Zodiac",
'PlanetPosition', "PlanetPosition",
] ]

View File

@@ -5,27 +5,28 @@ Calculates current planetary positions with zodiac degrees and symbols.
Usage: Usage:
from clock import ThalemaClock from clock import ThalemaClock
from datetime import datetime from datetime import datetime
now = datetime.now() now = datetime.now()
clock = ThalemaClock(now) clock = ThalemaClock(now)
print(clock) # Display formatted with degrees and symbols print(clock) # Display formatted with degrees and symbols
# Get individual planet info # Get individual planet info
sun_info = clock.get_planet('Sun') sun_info = clock.get_planet('Sun')
print(sun_info) # "☉ 25°♏" print(sun_info) # "☉ 25°♏"
# Custom planet order # Custom planet order
clock.display_format(['Moon', 'Mercury', 'Mars', 'Venus', 'Sun']) clock.display_format(['Moon', 'Mercury', 'Mars', 'Venus', 'Sun'])
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, Optional, List
from enum import Enum from enum import Enum
from typing import Dict, List, Optional
class Zodiac(Enum): class Zodiac(Enum):
"""Zodiac signs with degree ranges (0-360°).""" """Zodiac signs with degree ranges (0-360°)."""
ARIES = ("", 0, 30) ARIES = ("", 0, 30)
TAURUS = ("", 30, 60) TAURUS = ("", 30, 60)
GEMINI = ("", 60, 90) GEMINI = ("", 60, 90)
@@ -63,6 +64,7 @@ class Zodiac(Enum):
@dataclass @dataclass
class PlanetPosition: class PlanetPosition:
"""Represents a planet's position with degree and zodiac.""" """Represents a planet's position with degree and zodiac."""
planet_name: str planet_name: str
planet_symbol: str planet_symbol: str
zodiac: Zodiac zodiac: Zodiac
@@ -219,7 +221,10 @@ class ThalemaClock:
return result return result
def display_compact(self) -> str: def display_compact(self) -> str:
"""Display in compact format like: ☉︎ 25°Scorpio : ☾︎ 23°Libra : ☿︎ 2°Sagittarius : ♀︎ 13°Scorpio : ♂︎ 9°Sagittarius""" """Display in compact format.
Example: ☉︎ 25°Scorpio : ☾︎ 23°Libra : ☿︎ 2°Sagittarius : ♀︎ 13°Scorpio : ♂︎ 9°Sagittarius
"""
return self.display_format() return self.display_format()
def display_verbose(self) -> str: def display_verbose(self) -> str:
@@ -228,7 +233,9 @@ class ThalemaClock:
for planet_name in ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]: for planet_name in ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]:
if planet_name in self.positions: if planet_name in self.positions:
pos = self.positions[planet_name] pos = self.positions[planet_name]
lines.append(f"{planet_name:9} {pos.zodiac.name:11} {pos.degree_in_sign:5.1f}° {pos}") lines.append(
f"{planet_name:9} {pos.zodiac.name:11} {pos.degree_in_sign:5.1f}° {pos}"
)
return "\n".join(lines) return "\n".join(lines)
def __str__(self) -> str: def __str__(self) -> str:

View File

@@ -6,12 +6,13 @@ including Zodiac, Time cycles, and Astrological influences.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List
@dataclass @dataclass
class Month: class Month:
"""Represents a calendar month.""" """Represents a calendar month."""
number: int number: int
name: str name: str
zodiac_start: str zodiac_start: str
@@ -21,6 +22,7 @@ class Month:
@dataclass @dataclass
class Weekday: class Weekday:
"""Represents weekday/weekend archetypes with planetary ties.""" """Represents weekday/weekend archetypes with planetary ties."""
number: int number: int
name: str name: str
planetary_correspondence: str planetary_correspondence: str
@@ -35,6 +37,7 @@ class Weekday:
@dataclass @dataclass
class Hour: class Hour:
"""Represents an hour with planetary correspondence.""" """Represents an hour with planetary correspondence."""
number: int number: int
name: str name: str
planetary_hours: List[str] = field(default_factory=list) planetary_hours: List[str] = field(default_factory=list)
@@ -43,6 +46,7 @@ class Hour:
@dataclass @dataclass
class ClockHour: class ClockHour:
"""Represents a clock hour with both 24-hour and 12-hour phases.""" """Represents a clock hour with both 24-hour and 12-hour phases."""
hour_24: int hour_24: int
hour_12: int hour_12: int
period: str # AM or PM period: str # AM or PM
@@ -63,6 +67,7 @@ class ClockHour:
@dataclass @dataclass
class Zodiac: class Zodiac:
"""Represents a zodiac sign.""" """Represents a zodiac sign."""
name: str name: str
symbol: str symbol: str
element: str element: str
@@ -73,6 +78,7 @@ class Zodiac:
@dataclass @dataclass
class Degree: class Degree:
"""Represents an astrological degree.""" """Represents an astrological degree."""
number: int number: int
constellation: str constellation: str
ruling_planet: str ruling_planet: str
@@ -82,6 +88,7 @@ class Degree:
@dataclass @dataclass
class AstrologicalInfluence: class AstrologicalInfluence:
"""Represents astrological influences.""" """Represents astrological influences."""
planet: str planet: str
sign: str sign: str
house: str house: str

View File

@@ -3,55 +3,56 @@
This module provides calendar-related operations and utilities. This module provides calendar-related operations and utilities.
""" """
from datetime import datetime, date from datetime import date, datetime
from typing import Optional, Dict, Any from typing import Any, Dict
from .temporal import Year, Month, Day, Week, Hour
from .temporal import Day, Hour, Month, Week, Year
class Calendar: class Calendar:
"""Calendar utilities for temporal calculations.""" """Calendar utilities for temporal calculations."""
@staticmethod @staticmethod
def now() -> Dict[str, Any]: def now() -> Dict[str, Any]:
"""Get current date and time components. """Get current date and time components.
Returns: Returns:
Dictionary with year, month, day, hour, minute, second Dictionary with year, month, day, hour, minute, second
""" """
now = datetime.now() now = datetime.now()
return { return {
'year': Year(now.year), "year": Year(now.year),
'month': Month(now.month), "month": Month(now.month),
'day': Day(now.day), "day": Day(now.day),
'hour': Hour(now.hour, now.minute, now.second), "hour": Hour(now.hour, now.minute, now.second),
'week': Calendar.get_week(now.year, now.month, now.day), "week": Calendar.get_week(now.year, now.month, now.day),
'datetime': now, "datetime": now,
} }
@staticmethod @staticmethod
def get_week(year: int, month: int, day: int) -> Week: def get_week(year: int, month: int, day: int) -> Week:
"""Get the week number for a given date. """Get the week number for a given date.
Args: Args:
year: The year year: The year
month: The month (1-12) month: The month (1-12)
day: The day of month (1-31) day: The day of month (1-31)
Returns: Returns:
Week object containing week number and year Week object containing week number and year
""" """
d = date(year, month, day) d = date(year, month, day)
week_num = d.isocalendar()[1] week_num = d.isocalendar()[1]
return Week(week_num, year) return Week(week_num, year)
@staticmethod @staticmethod
def days_in_month(year: int, month: int) -> int: def days_in_month(year: int, month: int) -> int:
"""Get the number of days in a month. """Get the number of days in a month.
Args: Args:
year: The year year: The year
month: The month (1-12) month: The month (1-12)
Returns: Returns:
Number of days in that month Number of days in that month
""" """
@@ -59,14 +60,14 @@ class Calendar:
# February - check for leap year # February - check for leap year
return 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28 return 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28
return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
@staticmethod @staticmethod
def is_leap_year(year: int) -> bool: def is_leap_year(year: int) -> bool:
"""Check if a year is a leap year. """Check if a year is a leap year.
Args: Args:
year: The year to check year: The year to check
Returns: Returns:
True if leap year, False otherwise True if leap year, False otherwise
""" """

View File

@@ -6,37 +6,39 @@ and other astronomical/calendrical coordinates.
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional, Dict, Any from typing import Optional
class Season(Enum): class Season(Enum):
"""The four seasons of the year.""" """The four seasons of the year."""
SPRING = "Spring" # Vernal Equinox (Mar 20/21)
SUMMER = "Summer" # Summer Solstice (Jun 20/21) SPRING = "Spring" # Vernal Equinox (Mar 20/21)
AUTUMN = "Autumn" # Autumnal Equinox (Sep 22/23) SUMMER = "Summer" # Summer Solstice (Jun 20/21)
WINTER = "Winter" # Winter Solstice (Dec 21/22) AUTUMN = "Autumn" # Autumnal Equinox (Sep 22/23)
WINTER = "Winter" # Winter Solstice (Dec 21/22)
@dataclass @dataclass
class SolarEvent: class SolarEvent:
"""Represents a solar event (solstice or equinox). """Represents a solar event (solstice or equinox).
Attributes: Attributes:
event_type: The type of solar event (solstice or equinox) event_type: The type of solar event (solstice or equinox)
date: The approximate date of the event date: The approximate date of the event
season: The associated season season: The associated season
""" """
event_type: str # "solstice" or "equinox" event_type: str # "solstice" or "equinox"
date: tuple # (month, day) date: tuple # (month, day)
season: Season season: Season
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.season.value} {self.event_type.title()} ({self.date[0]}/{self.date[1]})" return f"{self.season.value} {self.event_type.title()} ({self.date[0]}/{self.date[1]})"
class TemporalCoordinates: class TemporalCoordinates:
"""Temporal positioning and astronomical calculations.""" """Temporal positioning and astronomical calculations."""
# Approximate dates for solar events (can vary by ±1-2 days yearly) # Approximate dates for solar events (can vary by ±1-2 days yearly)
SOLAR_EVENTS = { SOLAR_EVENTS = {
Season.SPRING: SolarEvent("equinox", (3, 20), Season.SPRING), Season.SPRING: SolarEvent("equinox", (3, 20), Season.SPRING),
@@ -44,15 +46,15 @@ class TemporalCoordinates:
Season.AUTUMN: SolarEvent("equinox", (9, 22), Season.AUTUMN), Season.AUTUMN: SolarEvent("equinox", (9, 22), Season.AUTUMN),
Season.WINTER: SolarEvent("solstice", (12, 21), Season.WINTER), Season.WINTER: SolarEvent("solstice", (12, 21), Season.WINTER),
} }
@staticmethod @staticmethod
def get_season(month: int, day: int) -> Season: def get_season(month: int, day: int) -> Season:
"""Get the season for a given month and day. """Get the season for a given month and day.
Args: Args:
month: Month number (1-12) month: Month number (1-12)
day: Day of month (1-31) day: Day of month (1-31)
Returns: Returns:
The Season enum value The Season enum value
""" """
@@ -65,28 +67,28 @@ class TemporalCoordinates:
return Season.AUTUMN return Season.AUTUMN
else: # Winter else: # Winter
return Season.WINTER return Season.WINTER
@staticmethod @staticmethod
def get_solar_event(season: Season) -> Optional[SolarEvent]: def get_solar_event(season: Season) -> Optional[SolarEvent]:
"""Get the solar event for a given season. """Get the solar event for a given season.
Args: Args:
season: The Season enum value season: The Season enum value
Returns: Returns:
SolarEvent object for that season SolarEvent object for that season
""" """
return TemporalCoordinates.SOLAR_EVENTS.get(season) return TemporalCoordinates.SOLAR_EVENTS.get(season)
@staticmethod @staticmethod
def days_until_event(month: int, day: int, target_season: Season) -> int: def days_until_event(month: int, day: int, target_season: Season) -> int:
"""Calculate days until a solar event. """Calculate days until a solar event.
Args: Args:
month: Current month (1-12) month: Current month (1-12)
day: Current day (1-31) day: Current day (1-31)
target_season: Target season for calculation target_season: Target season for calculation
Returns: Returns:
Number of days until the target seasonal event Number of days until the target seasonal event
""" """
@@ -94,28 +96,28 @@ class TemporalCoordinates:
event = TemporalCoordinates.get_solar_event(target_season) event = TemporalCoordinates.get_solar_event(target_season)
if not event: if not event:
return -1 return -1
event_month, event_day = event.date event_month, event_day = event.date
# Simple day-of-year calculation # Simple day-of-year calculation
current_doy = TemporalCoordinates._day_of_year(month, day) current_doy = TemporalCoordinates._day_of_year(month, day)
event_doy = TemporalCoordinates._day_of_year(event_month, event_day) event_doy = TemporalCoordinates._day_of_year(event_month, event_day)
if event_doy >= current_doy: if event_doy >= current_doy:
return event_doy - current_doy return event_doy - current_doy
else: else:
return (365 - current_doy) + event_doy return (365 - current_doy) + event_doy
@staticmethod @staticmethod
def _day_of_year(month: int, day: int) -> int: def _day_of_year(month: int, day: int) -> int:
"""Get the day of year (1-365/366). """Get the day of year (1-365/366).
Args: Args:
month: Month (1-12) month: Month (1-12)
day: Day (1-31) day: Day (1-31)
Returns: Returns:
Day of year Day of year
""" """
days_in_months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] days_in_months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
return sum(days_in_months[:month-1]) + day return sum(days_in_months[: month - 1]) + day

View File

@@ -4,17 +4,17 @@ This module provides the core temporal domain classes used throughout the system
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, List
@dataclass @dataclass
class Year: class Year:
"""Represents a year in the Gregorian calendar.""" """Represents a year in the Gregorian calendar."""
value: int value: int
def __str__(self) -> str: def __str__(self) -> str:
return str(self.value) return str(self.value)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Year({self.value})" return f"Year({self.value})"
@@ -22,24 +22,35 @@ class Year:
@dataclass @dataclass
class Month: class Month:
"""Represents a month (1-12).""" """Represents a month (1-12)."""
value: int value: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 1 <= self.value <= 12: if not 1 <= self.value <= 12:
raise ValueError(f"Month must be 1-12, got {self.value}") raise ValueError(f"Month must be 1-12, got {self.value}")
@property @property
def name(self) -> str: def name(self) -> str:
"""Get the month name.""" """Get the month name."""
names = [ names = [
"January", "February", "March", "April", "May", "June", "January",
"July", "August", "September", "October", "November", "December" "February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] ]
return names[self.value - 1] return names[self.value - 1]
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Month({self.value})" return f"Month({self.value})"
@@ -47,16 +58,17 @@ class Month:
@dataclass @dataclass
class Week: class Week:
"""Represents a week in the calendar.""" """Represents a week in the calendar."""
number: int # 1-53 number: int # 1-53
year: int year: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 1 <= self.number <= 53: if not 1 <= self.number <= 53:
raise ValueError(f"Week must be 1-53, got {self.number}") raise ValueError(f"Week must be 1-53, got {self.number}")
def __str__(self) -> str: def __str__(self) -> str:
return f"Week {self.number} of {self.year}" return f"Week {self.number} of {self.year}"
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Week({self.number}, {self.year})" return f"Week({self.number}, {self.year})"
@@ -64,20 +76,21 @@ class Week:
@dataclass @dataclass
class Day: class Day:
"""Represents a day of the month (1-31).""" """Represents a day of the month (1-31)."""
value: int value: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 1 <= self.value <= 31: if not 1 <= self.value <= 31:
raise ValueError(f"Day must be 1-31, got {self.value}") raise ValueError(f"Day must be 1-31, got {self.value}")
@property @property
def day_of_week(self) -> str: def day_of_week(self) -> str:
"""Get day of week name - requires full date context.""" """Get day of week name - requires full date context."""
pass pass
def __str__(self) -> str: def __str__(self) -> str:
return str(self.value) return str(self.value)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Day({self.value})" return f"Day({self.value})"
@@ -85,10 +98,11 @@ class Day:
@dataclass @dataclass
class Hour: class Hour:
"""Represents an hour in 24-hour format (0-23).""" """Represents an hour in 24-hour format (0-23)."""
value: int value: int
minute: int = 0 minute: int = 0
second: int = 0 second: int = 0
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 0 <= self.value <= 23: if not 0 <= self.value <= 23:
raise ValueError(f"Hour must be 0-23, got {self.value}") raise ValueError(f"Hour must be 0-23, got {self.value}")
@@ -96,9 +110,9 @@ class Hour:
raise ValueError(f"Minute must be 0-59, got {self.minute}") raise ValueError(f"Minute must be 0-59, got {self.minute}")
if not 0 <= self.second <= 59: if not 0 <= self.second <= 59:
raise ValueError(f"Second must be 0-59, got {self.second}") raise ValueError(f"Second must be 0-59, got {self.second}")
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.value:02d}:{self.minute:02d}:{self.second:02d}" return f"{self.value:02d}:{self.minute:02d}:{self.second:02d}"
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Hour({self.value}, {self.minute}, {self.second})" return f"Hour({self.value}, {self.minute}, {self.second})"

View File

@@ -4,28 +4,28 @@ This module handles time-related operations and conversions.
""" """
from datetime import datetime, time from datetime import datetime, time
from typing import Dict, Any, Optional from typing import Dict
class TimeUtil: class TimeUtil:
"""Utilities for time operations.""" """Utilities for time operations."""
@staticmethod @staticmethod
def current_time() -> time: def current_time() -> time:
"""Get current time. """Get current time.
Returns: Returns:
Current time object Current time object
""" """
return datetime.now().time() return datetime.now().time()
@staticmethod @staticmethod
def seconds_to_hms(seconds: int) -> Dict[str, int]: def seconds_to_hms(seconds: int) -> Dict[str, int]:
"""Convert seconds to hours, minutes, seconds. """Convert seconds to hours, minutes, seconds.
Args: Args:
seconds: Total seconds seconds: Total seconds
Returns: Returns:
Dictionary with 'hours', 'minutes', 'seconds' keys Dictionary with 'hours', 'minutes', 'seconds' keys
""" """
@@ -33,34 +33,34 @@ class TimeUtil:
remaining = seconds % 3600 remaining = seconds % 3600
minutes = remaining // 60 minutes = remaining // 60
secs = remaining % 60 secs = remaining % 60
return { return {
'hours': hours, "hours": hours,
'minutes': minutes, "minutes": minutes,
'seconds': secs, "seconds": secs,
} }
@staticmethod @staticmethod
def hms_to_seconds(hours: int, minutes: int = 0, seconds: int = 0) -> int: def hms_to_seconds(hours: int, minutes: int = 0, seconds: int = 0) -> int:
"""Convert hours, minutes, seconds to total seconds. """Convert hours, minutes, seconds to total seconds.
Args: Args:
hours: Hours component hours: Hours component
minutes: Minutes component (default 0) minutes: Minutes component (default 0)
seconds: Seconds component (default 0) seconds: Seconds component (default 0)
Returns: Returns:
Total seconds Total seconds
""" """
return hours * 3600 + minutes * 60 + seconds return hours * 3600 + minutes * 60 + seconds
@staticmethod @staticmethod
def is_24_hour_format(hour: int) -> bool: def is_24_hour_format(hour: int) -> bool:
"""Check if hour is valid in 24-hour format. """Check if hour is valid in 24-hour format.
Args: Args:
hour: The hour value to check hour: The hour value to check
Returns: Returns:
True if 0-23, False otherwise True if 0-23, False otherwise
""" """

View File

@@ -1,29 +1,29 @@
"""Utility modules for the Tarot project.""" """Utility modules for the Tarot project."""
from .attributes import (
Cipher,
CipherResult,
Color,
Colorscale,
Element,
ElementType,
God,
Note,
Number,
Perfume,
Planet,
)
from .filter import ( from .filter import (
universal_filter, describe_filter_fields,
get_filterable_fields,
filter_by, filter_by,
format_results, format_results,
get_filter_autocomplete, get_filter_autocomplete,
describe_filter_fields, get_filterable_fields,
) universal_filter,
from .attributes import (
Note,
Element,
ElementType,
Number,
Color,
Colorscale,
Planet,
God,
Perfume,
Cipher,
CipherResult,
) )
from .misc import ( from .misc import (
Personality,
MBTIType, MBTIType,
Personality,
) )
__all__ = [ __all__ = [

View File

@@ -7,12 +7,13 @@ exclusively to any single namespace.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from typing import Dict, List, Optional, Sequence, Set, Tuple
@dataclass @dataclass
class Meaning: class Meaning:
"""Represents the meaning of a card.""" """Represents the meaning of a card."""
upright: str upright: str
reversed: str reversed: str
@@ -20,6 +21,7 @@ class Meaning:
@dataclass(frozen=True) @dataclass(frozen=True)
class Note: class Note:
"""Represents a musical note with its properties.""" """Represents a musical note with its properties."""
name: str # e.g., "C", "D", "E", "F#", "G", "A", "B" name: str # e.g., "C", "D", "E", "F#", "G", "A", "B"
frequency: float # Frequency in Hz (A4 = 440 Hz) frequency: float # Frequency in Hz (A4 = 440 Hz)
semitone: int # Position in chromatic scale (0-11) semitone: int # Position in chromatic scale (0-11)
@@ -29,7 +31,7 @@ class Note:
chakra: Optional[str] = None # Associated chakra if any chakra: Optional[str] = None # Associated chakra if any
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
description: str = "" description: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 0 <= self.semitone <= 11: if not 0 <= self.semitone <= 11:
raise ValueError(f"Semitone must be 0-11, got {self.semitone}") raise ValueError(f"Semitone must be 0-11, got {self.semitone}")
@@ -40,6 +42,7 @@ class Note:
@dataclass @dataclass
class Element: class Element:
"""Represents one of the four elements.""" """Represents one of the four elements."""
name: str name: str
symbol: str symbol: str
color: str color: str
@@ -50,6 +53,7 @@ class Element:
@dataclass @dataclass
class ElementType: class ElementType:
"""Represents an elemental force (Fire, Water, Air, Earth, Spirit).""" """Represents an elemental force (Fire, Water, Air, Earth, Spirit)."""
name: str name: str
symbol: str symbol: str
direction: str direction: str
@@ -68,16 +72,17 @@ class ElementType:
@dataclass @dataclass
class Number: class Number:
"""Represents a number (1-9) with Kabbalistic attributes.""" """Represents a number (1-9) with Kabbalistic attributes."""
value: int value: int
sephera: str sephera: str
element: str element: str
compliment: int compliment: int
color: Optional['Color'] = None color: Optional["Color"] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not (1 <= self.value <= 9): if not (1 <= self.value <= 9):
raise ValueError(f"Number value must be between 1 and 9, got {self.value}") raise ValueError(f"Number value must be between 1 and 9, got {self.value}")
# Auto-calculate compliment: numbers complement to sum to 9 # Auto-calculate compliment: numbers complement to sum to 9
# 1↔8, 2↔7, 3↔6, 4↔5, 9↔9 # 1↔8, 2↔7, 3↔6, 4↔5, 9↔9
self.compliment = 9 - self.value if self.value != 9 else 9 self.compliment = 9 - self.value if self.value != 9 else 9
@@ -87,13 +92,14 @@ class Number:
class Colorscale: class Colorscale:
""" """
Represents Golden Dawn color scales (King, Queen, Emperor, Empress). Represents Golden Dawn color scales (King, Queen, Emperor, Empress).
The four scales correspond to the four worlds/letters of Tetragrammaton: The four scales correspond to the four worlds/letters of Tetragrammaton:
- King Scale (Yod): Father, originating impulse, pure archetype - King Scale (Yod): Father, originating impulse, pure archetype
- Queen Scale (He): Mother, receptive, earthy counterpart - Queen Scale (He): Mother, receptive, earthy counterpart
- Emperor Scale (Vau): Son/Form, active expression, concrete manifestation - Emperor Scale (Vau): Son/Form, active expression, concrete manifestation
- Empress Scale (He final): Daughter, physical manifestation, receptivity in Assiah - Empress Scale (He final): Daughter, physical manifestation, receptivity in Assiah
""" """
name: str # Sephira/Path name (e.g., "Kether", "Path of Aleph") name: str # Sephira/Path name (e.g., "Kether", "Path of Aleph")
number: int # 1-10 for Sephiroth, 11-32 for Paths number: int # 1-10 for Sephiroth, 11-32 for Paths
king_scale: str # Yod - Father principle king_scale: str # Yod - Father principle
@@ -109,6 +115,7 @@ class Colorscale:
@dataclass @dataclass
class Color: class Color:
"""Represents a color with Kabbalistic correspondences.""" """Represents a color with Kabbalistic correspondences."""
name: str name: str
hex_value: str hex_value: str
rgb: Tuple[int, int, int] rgb: Tuple[int, int, int]
@@ -119,12 +126,12 @@ class Color:
meaning: str meaning: str
tarot_associations: List[str] = field(default_factory=list) tarot_associations: List[str] = field(default_factory=list)
description: str = "" description: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Validate hex color # Validate hex color
if not self.hex_value.startswith("#") or len(self.hex_value) != 7: if not self.hex_value.startswith("#") or len(self.hex_value) != 7:
raise ValueError(f"Invalid hex color: {self.hex_value}") raise ValueError(f"Invalid hex color: {self.hex_value}")
# Validate RGB values # Validate RGB values
if not all(0 <= c <= 255 for c in self.rgb): if not all(0 <= c <= 255 for c in self.rgb):
raise ValueError(f"RGB values must be between 0 and 255, got {self.rgb}") raise ValueError(f"RGB values must be between 0 and 255, got {self.rgb}")
@@ -133,6 +140,7 @@ class Color:
@dataclass @dataclass
class Planet: class Planet:
"""Represents a planetary correspondence entry.""" """Represents a planetary correspondence entry."""
name: str name: str
symbol: str symbol: str
element: str element: str
@@ -142,34 +150,35 @@ class Planet:
keywords: List[str] = field(default_factory=list) keywords: List[str] = field(default_factory=list)
color: Optional[Color] = None color: Optional[Color] = None
description: str = "" description: str = ""
def __str__(self) -> str: def __str__(self) -> str:
"""Return nicely formatted string representation of the Planet.""" """Return nicely formatted string representation of the Planet."""
lines = [] lines = []
lines.append(f"{self.name} ({self.symbol})") lines.append(f"{self.name} ({self.symbol})")
lines.append(f" element: {self.element}") lines.append(f" element: {self.element}")
if self.ruling_zodiac: if self.ruling_zodiac:
lines.append(f" ruling_zodiac: {', '.join(self.ruling_zodiac)}") lines.append(f" ruling_zodiac: {', '.join(self.ruling_zodiac)}")
if self.associated_letters: if self.associated_letters:
lines.append(f" associated_letters: {', '.join(self.associated_letters)}") lines.append(f" associated_letters: {', '.join(self.associated_letters)}")
if self.keywords: if self.keywords:
lines.append(f" keywords: {', '.join(self.keywords)}") lines.append(f" keywords: {', '.join(self.keywords)}")
if self.color: if self.color:
lines.append(f" color: {self.color.name}") lines.append(f" color: {self.color.name}")
if self.description: if self.description:
lines.append(f" description: {self.description}") lines.append(f" description: {self.description}")
return "\n".join(lines) return "\n".join(lines)
@dataclass @dataclass
class God: class God:
"""Unified deity representation that synchronizes multiple pantheons.""" """Unified deity representation that synchronizes multiple pantheons."""
name: str name: str
culture: str culture: str
pantheon: str pantheon: str
@@ -195,60 +204,65 @@ class God:
def primary_number(self) -> Optional[Number]: def primary_number(self) -> Optional[Number]:
"""Return the first associated number if one is available.""" """Return the first associated number if one is available."""
return self.associated_numbers[0] if self.associated_numbers else None return self.associated_numbers[0] if self.associated_numbers else None
def __str__(self) -> str: def __str__(self) -> str:
"""Return nicely formatted string representation of the God.""" """Return nicely formatted string representation of the God."""
lines = [] lines = []
lines.append(f"{self.name}") lines.append(f"{self.name}")
lines.append(f" culture: {self.culture}") lines.append(f" culture: {self.culture}")
lines.append(f" pantheon: {self.pantheon}") lines.append(f" pantheon: {self.pantheon}")
if self.domains: if self.domains:
lines.append(f" domains: {', '.join(self.domains)}") lines.append(f" domains: {', '.join(self.domains)}")
if self.epithets: if self.epithets:
lines.append(f" epithets: {', '.join(self.epithets)}") lines.append(f" epithets: {', '.join(self.epithets)}")
if self.mythology: if self.mythology:
lines.append(f" mythology: {self.mythology}") lines.append(f" mythology: {self.mythology}")
if self.sephera_numbers: if self.sephera_numbers:
lines.append(f" sephera_numbers: {', '.join(str(n) for n in self.sephera_numbers)}") lines.append(f" sephera_numbers: {', '.join(str(n) for n in self.sephera_numbers)}")
if self.path_numbers: if self.path_numbers:
lines.append(f" path_numbers: {', '.join(str(n) for n in self.path_numbers)}") lines.append(f" path_numbers: {', '.join(str(n) for n in self.path_numbers)}")
if self.planets: if self.planets:
lines.append(f" planets: {', '.join(self.planets)}") lines.append(f" planets: {', '.join(self.planets)}")
if self.elements: if self.elements:
lines.append(f" elements: {', '.join(self.elements)}") lines.append(f" elements: {', '.join(self.elements)}")
if self.zodiac_signs: if self.zodiac_signs:
lines.append(f" zodiac_signs: {', '.join(self.zodiac_signs)}") lines.append(f" zodiac_signs: {', '.join(self.zodiac_signs)}")
if self.associated_planet: if self.associated_planet:
lines.append(f" associated_planet: {self.associated_planet.name}") lines.append(f" associated_planet: {self.associated_planet.name}")
if self.associated_element: if self.associated_element:
elem_name = self.associated_element.name if hasattr(self.associated_element, 'name') else str(self.associated_element) elem_name = (
self.associated_element.name
if hasattr(self.associated_element, "name")
else str(self.associated_element)
)
lines.append(f" associated_element: {elem_name}") lines.append(f" associated_element: {elem_name}")
if self.tarot_trumps: if self.tarot_trumps:
lines.append(f" tarot_trumps: {', '.join(self.tarot_trumps)}") lines.append(f" tarot_trumps: {', '.join(self.tarot_trumps)}")
if self.keywords: if self.keywords:
lines.append(f" keywords: {', '.join(self.keywords)}") lines.append(f" keywords: {', '.join(self.keywords)}")
if self.description: if self.description:
lines.append(f" description: {self.description}") lines.append(f" description: {self.description}")
return "\n".join(lines) return "\n".join(lines)
@dataclass @dataclass
class Perfume: class Perfume:
"""Represents a perfume/incense correspondence in Kabbalah.""" """Represents a perfume/incense correspondence in Kabbalah."""
name: str name: str
alternative_names: List[str] = field(default_factory=list) alternative_names: List[str] = field(default_factory=list)
scent_profile: str = "" # e.g., "Resinous", "Floral", "Spicy", "Earthy" scent_profile: str = "" # e.g., "Resinous", "Floral", "Spicy", "Earthy"
@@ -263,49 +277,49 @@ class Perfume:
magical_uses: List[str] = field(default_factory=list) magical_uses: List[str] = field(default_factory=list)
description: str = "" description: str = ""
notes: str = "" notes: str = ""
def __str__(self) -> str: def __str__(self) -> str:
"""Return nicely formatted string representation of the Perfume.""" """Return nicely formatted string representation of the Perfume."""
lines = [] lines = []
lines.append(f"{self.name}") lines.append(f"{self.name}")
if self.alternative_names: if self.alternative_names:
lines.append(f" alternative_names: {', '.join(self.alternative_names)}") lines.append(f" alternative_names: {', '.join(self.alternative_names)}")
if self.scent_profile: if self.scent_profile:
lines.append(f" scent_profile: {self.scent_profile}") lines.append(f" scent_profile: {self.scent_profile}")
# Correspondences # Correspondences
if self.sephera_number is not None: if self.sephera_number is not None:
lines.append(f" sephera_number: {self.sephera_number}") lines.append(f" sephera_number: {self.sephera_number}")
if self.path_number is not None: if self.path_number is not None:
lines.append(f" path_number: {self.path_number}") lines.append(f" path_number: {self.path_number}")
if self.element: if self.element:
lines.append(f" element: {self.element}") lines.append(f" element: {self.element}")
if self.planet: if self.planet:
lines.append(f" planet: {self.planet}") lines.append(f" planet: {self.planet}")
if self.zodiac_sign: if self.zodiac_sign:
lines.append(f" zodiac_sign: {self.zodiac_sign}") lines.append(f" zodiac_sign: {self.zodiac_sign}")
if self.astrological_quality: if self.astrological_quality:
lines.append(f" astrological_quality: {self.astrological_quality}") lines.append(f" astrological_quality: {self.astrological_quality}")
if self.keywords: if self.keywords:
lines.append(f" keywords: {', '.join(self.keywords)}") lines.append(f" keywords: {', '.join(self.keywords)}")
if self.magical_uses: if self.magical_uses:
lines.append(f" magical_uses: {', '.join(self.magical_uses)}") lines.append(f" magical_uses: {', '.join(self.magical_uses)}")
if self.description: if self.description:
lines.append(f" description: {self.description}") lines.append(f" description: {self.description}")
if self.notes: if self.notes:
lines.append(f" notes: {self.notes}") lines.append(f" notes: {self.notes}")
return "\n".join(lines) return "\n".join(lines)
@@ -362,9 +376,7 @@ class Cipher:
expanded.append(self.pattern[idx % len(self.pattern)]) expanded.append(self.pattern[idx % len(self.pattern)])
idx += 1 idx += 1
return expanded return expanded
raise ValueError( raise ValueError("Cipher pattern length does not match alphabet and cycling is disabled")
"Cipher pattern length does not match alphabet and cycling is disabled"
)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -10,51 +10,57 @@ Provides a single, reusable filter mechanism that works across all modules:
Usage: Usage:
from utils.filter import universal_filter, get_filterable_fields from utils.filter import universal_filter, get_filterable_fields
# For TarotLetter # For TarotLetter
results = universal_filter(Tarot.letters.all(), letter_type="Mother") results = universal_filter(Tarot.letters.all(), letter_type="Mother")
# For Card # For Card
results = universal_filter(Tarot.deck.cards, arcana="Major") results = universal_filter(Tarot.deck.cards, arcana="Major")
# Get available fields for introspection # Get available fields for introspection
fields = get_filterable_fields(TarotLetter) fields = get_filterable_fields(TarotLetter)
""" """
from typing import List, Any, TypeVar, Union, Dict from dataclasses import fields, is_dataclass
from dataclasses import is_dataclass, fields from typing import Any, Dict, List, TypeVar
from utils.object_formatting import get_item_label, is_nested_object, get_object_attributes, format_value
T = TypeVar('T') # Generic type for any dataclass from utils.object_formatting import (
format_value,
get_item_label,
get_object_attributes,
is_nested_object,
)
T = TypeVar("T") # Generic type for any dataclass
def get_filterable_fields(dataclass_type) -> List[str]: def get_filterable_fields(dataclass_type) -> List[str]:
""" """
Dynamically get all filterable fields from a dataclass. Dynamically get all filterable fields from a dataclass.
Args: Args:
dataclass_type: A dataclass type (e.g., TarotLetter, Card) dataclass_type: A dataclass type (e.g., TarotLetter, Card)
Returns: Returns:
List of field names available for filtering List of field names available for filtering
Raises: Raises:
TypeError: If the type is not a dataclass TypeError: If the type is not a dataclass
Example: Example:
fields = get_filterable_fields(TarotLetter) fields = get_filterable_fields(TarotLetter)
# ['hebrew_letter', 'transliteration', 'letter_type', ...] # ['hebrew_letter', 'transliteration', 'letter_type', ...]
""" """
if not is_dataclass(dataclass_type): if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass") raise TypeError(f"{dataclass_type} is not a dataclass")
return [f.name for f in fields(dataclass_type)] return [f.name for f in fields(dataclass_type)]
def _matches_filter(obj: Any, key: str, value: Any) -> bool: def _matches_filter(obj: Any, key: str, value: Any) -> bool:
""" """
Check if an object matches a filter criterion. Check if an object matches a filter criterion.
Handles: Handles:
- String matching (case-insensitive) - String matching (case-insensitive)
- Numeric matching (exact) - Numeric matching (exact)
@@ -63,25 +69,25 @@ def _matches_filter(obj: Any, key: str, value: Any) -> bool:
- None/null matching - None/null matching
- Nested object attribute matching (e.g., suit="Cups" matches Suit(name="Cups")) - Nested object attribute matching (e.g., suit="Cups" matches Suit(name="Cups"))
- Multiple values (comma-separated strings or lists for OR logic) - Multiple values (comma-separated strings or lists for OR logic)
Args: Args:
obj: The object to check obj: The object to check
key: The attribute name key: The attribute name
value: The value to match against (string, int, list, or comma-separated string) value: The value to match against (string, int, list, or comma-separated string)
Returns: Returns:
True if the object matches the filter, False otherwise True if the object matches the filter, False otherwise
Examples: Examples:
_matches_filter(card, "number", 3) # Single number _matches_filter(card, "number", 3) # Single number
_matches_filter(card, "number", [3, 5, 6]) # Multiple numbers (OR) _matches_filter(card, "number", [3, 5, 6]) # Multiple numbers (OR)
_matches_filter(card, "number", "3,5,6") # Comma-separated (OR) _matches_filter(card, "number", "3,5,6") # Comma-separated (OR)
""" """
attr_value = getattr(obj, key, None) attr_value = getattr(obj, key, None)
if attr_value is None: if attr_value is None:
return False return False
# Parse multiple values (comma-separated string or list) # Parse multiple values (comma-separated string or list)
values_to_check = [] values_to_check = []
if isinstance(value, str) and "," in value: if isinstance(value, str) and "," in value:
@@ -91,60 +97,57 @@ def _matches_filter(obj: Any, key: str, value: Any) -> bool:
values_to_check = list(value) values_to_check = list(value)
else: else:
values_to_check = [value] values_to_check = [value]
# Check if attribute matches ANY of the provided values (OR logic) # Check if attribute matches ANY of the provided values (OR logic)
for check_value in values_to_check: for check_value in values_to_check:
# Handle list attributes (like keywords, colors, etc.) # Handle list attributes (like keywords, colors, etc.)
if isinstance(attr_value, list): if isinstance(attr_value, list):
if any( if any(str(check_value).lower() == str(item).lower() for item in attr_value):
str(check_value).lower() == str(item).lower()
for item in attr_value
):
return True return True
continue continue
# Handle numeric comparisons # Handle numeric comparisons
if isinstance(check_value, int) and isinstance(attr_value, int): if isinstance(check_value, int) and isinstance(attr_value, int):
if attr_value == check_value: if attr_value == check_value:
return True return True
continue continue
# Handle boolean comparisons # Handle boolean comparisons
if isinstance(check_value, bool) and isinstance(attr_value, bool): if isinstance(check_value, bool) and isinstance(attr_value, bool):
if attr_value == check_value: if attr_value == check_value:
return True return True
continue continue
# Handle nested object comparison: if attr_value has a 'name' attribute, # Handle nested object comparison: if attr_value has a 'name' attribute,
# try matching the value against that (e.g., suit="Cups" vs Suit(name="Cups")) # try matching the value against that (e.g., suit="Cups" vs Suit(name="Cups"))
if hasattr(attr_value, 'name'): if hasattr(attr_value, "name"):
nested_name = getattr(attr_value, 'name', None) nested_name = getattr(attr_value, "name", None)
if nested_name is not None: if nested_name is not None:
if str(nested_name).lower() == str(check_value).lower(): if str(nested_name).lower() == str(check_value).lower():
return True return True
continue continue
# Handle string comparisons # Handle string comparisons
if str(attr_value).lower() == str(check_value).lower(): if str(attr_value).lower() == str(check_value).lower():
return True return True
return False return False
def universal_filter(items: List[T], **kwargs) -> List[T]: def universal_filter(items: List[T], **kwargs) -> List[T]:
""" """
Universal filter function that works on any list of dataclass objects. Universal filter function that works on any list of dataclass objects.
Dynamically filters a list of objects by any combination of their attributes. Dynamically filters a list of objects by any combination of their attributes.
Works with any dataclass-based type throughout the project. Works with any dataclass-based type throughout the project.
Args: Args:
items: List of objects to filter (typically all objects from a collection) items: List of objects to filter (typically all objects from a collection)
**kwargs: Attribute filters (field_name=value or field_name=[value1, value2]) **kwargs: Attribute filters (field_name=value or field_name=[value1, value2])
Returns: Returns:
Filtered list containing only items matching ALL criteria Filtered list containing only items matching ALL criteria
Features: Features:
- Case-insensitive string matching - Case-insensitive string matching
- Numeric exact matching - Numeric exact matching
@@ -153,20 +156,20 @@ def universal_filter(items: List[T], **kwargs) -> List[T]:
- Multiple values per field (comma-separated string or list) for OR matching - Multiple values per field (comma-separated string or list) for OR matching
- Works with any dataclass object - Works with any dataclass object
- Field aliases (e.g., 'type' -> 'arcana' for Cards) - Field aliases (e.g., 'type' -> 'arcana' for Cards)
Examples: Examples:
# Single values # Single values
results = universal_filter(Tarot.deck.cards, arcana="Major") results = universal_filter(Tarot.deck.cards, arcana="Major")
# Multiple values (OR logic) - comma-separated # Multiple values (OR logic) - comma-separated
results = universal_filter(Tarot.deck.cards, number="3,5,6") results = universal_filter(Tarot.deck.cards, number="3,5,6")
# Multiple values (OR logic) - list # Multiple values (OR logic) - list
results = universal_filter(Tarot.deck.cards, number=[3, 5, 6]) results = universal_filter(Tarot.deck.cards, number=[3, 5, 6])
# Combine multiple fields (AND logic) # Combine multiple fields (AND logic)
results = universal_filter(Tarot.deck.cards, suit="Cups", type="Court") results = universal_filter(Tarot.deck.cards, suit="Cups", type="Court")
# Multiple values in one field + other filters # Multiple values in one field + other filters
results = universal_filter(Tarot.deck.cards, number="3,5,6", suit="Wands") results = universal_filter(Tarot.deck.cards, number="3,5,6", suit="Wands")
""" """
@@ -174,50 +177,47 @@ def universal_filter(items: List[T], **kwargs) -> List[T]:
field_aliases = { field_aliases = {
# No aliases - use direct property/field names # No aliases - use direct property/field names
} }
results = items results = items
for key, value in kwargs.items(): for key, value in kwargs.items():
if value is None: if value is None:
continue continue
# Apply field alias if it exists # Apply field alias if it exists
actual_key = field_aliases.get(key, key) actual_key = field_aliases.get(key, key)
results = [ results = [obj for obj in results if _matches_filter(obj, actual_key, value)]
obj for obj in results
if _matches_filter(obj, actual_key, value)
]
return results return results
def format_results(items: List[Any]) -> str: def format_results(items: List[Any]) -> str:
""" """
Format a list of objects for user-friendly display. Format a list of objects for user-friendly display.
Works with any object type and recursively formats nested structures. Works with any object type and recursively formats nested structures.
Each object is separated by a blank line. Each object is separated by a blank line.
Args: Args:
items: List of objects to format items: List of objects to format
Returns: Returns:
Formatted string with proper indentation and hierarchy Formatted string with proper indentation and hierarchy
Example: Example:
results = universal_filter(Tarot.letters.all(), element="Fire") results = universal_filter(Tarot.letters.all(), element="Fire")
print(format_results(results)) print(format_results(results))
""" """
if not items: if not items:
return "(no items)" return "(no items)"
lines = [] lines = []
for item in items: for item in items:
# Get label for item (handles name, transliteration, or str()) # Get label for item (handles name, transliteration, or str())
label = get_item_label(item, fallback=str(item)) label = get_item_label(item, fallback=str(item))
lines.append(f"--- {label} ---") lines.append(f"--- {label} ---")
# Format all attributes with proper nesting # Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item): for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value): if is_nested_object(attr_value):
@@ -227,9 +227,9 @@ def format_results(items: List[Any]) -> str:
lines.append(nested) lines.append(nested)
else: else:
lines.append(f" {attr_name}: {attr_value}") lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items lines.append("") # Blank line between items
return "\n".join(lines) return "\n".join(lines)
@@ -240,16 +240,16 @@ filter_by = universal_filter
def get_filter_autocomplete(dataclass_type) -> Dict[str, List[str]]: def get_filter_autocomplete(dataclass_type) -> Dict[str, List[str]]:
""" """
Get autocomplete suggestions for filtering a dataclass type. Get autocomplete suggestions for filtering a dataclass type.
Returns a dictionary mapping field names to example values found in the dataclass. Returns a dictionary mapping field names to example values found in the dataclass.
Useful for IDE autocomplete and CLI help. Useful for IDE autocomplete and CLI help.
Args: Args:
dataclass_type: A dataclass type (e.g., TarotLetter, Card, Wall) dataclass_type: A dataclass type (e.g., TarotLetter, Card, Wall)
Returns: Returns:
Dictionary with field names as keys and list of example values Dictionary with field names as keys and list of example values
Example: Example:
autocomplete = get_filter_autocomplete(TarotLetter) autocomplete = get_filter_autocomplete(TarotLetter)
# Returns: # Returns:
@@ -262,41 +262,41 @@ def get_filter_autocomplete(dataclass_type) -> Dict[str, List[str]]:
""" """
if not is_dataclass(dataclass_type): if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass") raise TypeError(f"{dataclass_type} is not a dataclass")
autocomplete = {} autocomplete = {}
field_names = [f.name for f in fields(dataclass_type)] field_names = [f.name for f in fields(dataclass_type)]
for field_name in field_names: for field_name in field_names:
autocomplete[field_name] = f"<value for {field_name}>" autocomplete[field_name] = f"<value for {field_name}>"
return autocomplete return autocomplete
def describe_filter_fields(dataclass_type) -> str: def describe_filter_fields(dataclass_type) -> str:
""" """
Get a human-readable description of all filterable fields. Get a human-readable description of all filterable fields.
Useful for help text and documentation. Useful for help text and documentation.
Args: Args:
dataclass_type: A dataclass type dataclass_type: A dataclass type
Returns: Returns:
Formatted string with field descriptions Formatted string with field descriptions
Example: Example:
print(describe_filter_fields(TarotLetter)) print(describe_filter_fields(TarotLetter))
""" """
if not is_dataclass(dataclass_type): if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass") raise TypeError(f"{dataclass_type} is not a dataclass")
field_list = get_filterable_fields(dataclass_type) field_list = get_filterable_fields(dataclass_type)
lines = [ lines = [
f"Filterable fields for {dataclass_type}:", f"Filterable fields for {dataclass_type}:",
"", "",
] ]
for field_name in field_list: for field_name in field_list:
lines.append(f"{field_name}") lines.append(f"{field_name}")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -5,8 +5,8 @@ This module contains specialized utilities that don't fit into other categories.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from tarot.deck.deck import CourtCard from tarot.deck.deck import CourtCard
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
class MBTIType(Enum): class MBTIType(Enum):
"""16 MBTI personality types.""" """16 MBTI personality types."""
ISTJ = "ISTJ" ISTJ = "ISTJ"
ISFJ = "ISFJ" ISFJ = "ISFJ"
INFJ = "INFJ" INFJ = "INFJ"
@@ -36,79 +37,76 @@ class MBTIType(Enum):
class Personality: class Personality:
""" """
MBTI Personality Type mapped to a specific Tarot Court Card. MBTI Personality Type mapped to a specific Tarot Court Card.
This class creates a direct 1-to-1 relationship between each of the 16 MBTI This class creates a direct 1-to-1 relationship between each of the 16 MBTI
personality types and their corresponding Tarot court cards. Based on the personality types and their corresponding Tarot court cards. Based on the
comprehensive system developed by Dante DiMatteo at 78 Revelations Per Minute: comprehensive system developed by Dante DiMatteo at 78 Revelations Per Minute:
https://78revelationsaminute.wordpress.com/2015/07/08/personality-types-the-tarot-court-cards-and-the-myers-briggs-type-indicator/ https://78revelationsaminute.wordpress.com/2015/07/08/personality-types-the-tarot-court-cards-and-the-myers-briggs-type-indicator/
The mapping is based on: The mapping is based on:
- SUITS correspond to Jung's 4 cognitive functions: - SUITS correspond to Jung's 4 cognitive functions:
* Wands: Intuition (N) * Wands: Intuition (N)
* Cups: Feeling (F) * Cups: Feeling (F)
* Swords: Thinking (T) * Swords: Thinking (T)
* Pentacles: Sensation (S) * Pentacles: Sensation (S)
- RANKS correspond to MBTI traits: - RANKS correspond to MBTI traits:
* Kings (E + J): Extraverted Judgers * Kings (E + J): Extraverted Judgers
* Queens (I + J): Introverted Judgers * Queens (I + J): Introverted Judgers
* Princes (E + P): Extraverted Perceivers * Princes (E + P): Extraverted Perceivers
* Princesses (I + P): Introverted Perceivers * Princesses (I + P): Introverted Perceivers
Attributes: Attributes:
mbti_type: The MBTI personality type (e.g., ENFP) mbti_type: The MBTI personality type (e.g., ENFP)
court_card: The single CourtCard object representing this personality court_card: The single CourtCard object representing this personality
description: Brief description of the personality archetype description: Brief description of the personality archetype
""" """
mbti_type: MBTIType mbti_type: MBTIType
court_card: Optional['CourtCard'] = None court_card: Optional["CourtCard"] = None
description: str = "" description: str = ""
# Direct MBTI-to-CourtCard mapping (1-to-1 relationship) # Direct MBTI-to-CourtCard mapping (1-to-1 relationship)
# Format: MBTI_TYPE -> (Rank, Suit) # Format: MBTI_TYPE -> (Rank, Suit)
_MBTI_TO_CARD_MAPPING = { _MBTI_TO_CARD_MAPPING = {
# KINGS (E + J) - Extraverted Judgers # KINGS (E + J) - Extraverted Judgers
"ENTJ": ("Knight", "Wands"), # Fiery, forceful leadership "ENTJ": ("Knight", "Wands"), # Fiery, forceful leadership
"ENFJ": ("Knight", "Cups"), # Sensitive, mission-driven "ENFJ": ("Knight", "Cups"), # Sensitive, mission-driven
"ESTJ": ("Knight", "Swords"), # Practical, pragmatic "ESTJ": ("Knight", "Swords"), # Practical, pragmatic
"ESFJ": ("Knight", "Pentacles"), # Sociable, consensus-seeking "ESFJ": ("Knight", "Pentacles"), # Sociable, consensus-seeking
# QUEENS (I + J) - Introverted Judgers # QUEENS (I + J) - Introverted Judgers
"INTJ": ("Queen", "Wands"), # Analytical, self-motivated "INTJ": ("Queen", "Wands"), # Analytical, self-motivated
"INFJ": ("Queen", "Cups"), # Sensitive, interconnected "INFJ": ("Queen", "Cups"), # Sensitive, interconnected
"ISTJ": ("Queen", "Swords"), # Pragmatic, duty-fulfiller "ISTJ": ("Queen", "Swords"), # Pragmatic, duty-fulfiller
"ISFJ": ("Queen", "Pentacles"), # Caring, earth-mother type "ISFJ": ("Queen", "Pentacles"), # Caring, earth-mother type
# PRINCES (E + P) - Extraverted Perceivers # PRINCES (E + P) - Extraverted Perceivers
"ENTP": ("Prince", "Wands"), # Visionary, quick-study "ENTP": ("Prince", "Wands"), # Visionary, quick-study
"ENFP": ("Prince", "Cups"), # Inspiring, intuitive "ENFP": ("Prince", "Cups"), # Inspiring, intuitive
"ESTP": ("Prince", "Swords"), # Action-oriented, risk-taker "ESTP": ("Prince", "Swords"), # Action-oriented, risk-taker
"ESFP": ("Prince", "Pentacles"), # Aesthete, sensualist "ESFP": ("Prince", "Pentacles"), # Aesthete, sensualist
# PRINCESSES (I + P) - Introverted Perceivers # PRINCESSES (I + P) - Introverted Perceivers
"INTP": ("Princess", "Wands"), # Thinker par excellence "INTP": ("Princess", "Wands"), # Thinker par excellence
"INFP": ("Princess", "Cups"), # Idealistic, devoted "INFP": ("Princess", "Cups"), # Idealistic, devoted
"ISTP": ("Princess", "Swords"), # Observer, mechanic "ISTP": ("Princess", "Swords"), # Observer, mechanic
"ISFP": ("Princess", "Pentacles"), # Aesthete, free spirit "ISFP": ("Princess", "Pentacles"), # Aesthete, free spirit
} }
@classmethod @classmethod
def from_mbti(cls, mbti_type: str, deck: Optional[object] = None) -> 'Personality': def from_mbti(cls, mbti_type: str, deck: Optional[object] = None) -> "Personality":
""" """
Create a Personality from an MBTI type string. Create a Personality from an MBTI type string.
Args: Args:
mbti_type: MBTI type as string (e.g., "ENFP", "ISTJ") mbti_type: MBTI type as string (e.g., "ENFP", "ISTJ")
deck: Optional Tarot Deck to fetch the court card from. If not provided, deck: Optional Tarot Deck to fetch the court card from. If not provided,
court card will be fetched dynamically when accessed. court card will be fetched dynamically when accessed.
Returns: Returns:
Personality object with associated court card Personality object with associated court card
Raises: Raises:
ValueError: If mbti_type is not a valid MBTI type ValueError: If mbti_type is not a valid MBTI type
Example: Example:
>>> from tarot import Tarot >>> from tarot import Tarot
>>> personality = Personality.from_mbti("ENFP", Tarot.deck) >>> personality = Personality.from_mbti("ENFP", Tarot.deck)
@@ -118,7 +116,7 @@ class Personality:
Prince of Cups Prince of Cups
""" """
mbti_type = mbti_type.upper() mbti_type = mbti_type.upper()
# Validate MBTI type # Validate MBTI type
try: try:
mbti_enum = MBTIType[mbti_type] mbti_enum = MBTIType[mbti_type]
@@ -127,63 +125,58 @@ class Personality:
f"Invalid MBTI type: {mbti_type}. Must be one of: " f"Invalid MBTI type: {mbti_type}. Must be one of: "
f"{', '.join([t.value for t in MBTIType])}" f"{', '.join([t.value for t in MBTIType])}"
) )
# Get the rank and suit for this MBTI type # Get the rank and suit for this MBTI type
rank, suit = cls._MBTI_TO_CARD_MAPPING.get(mbti_type, (None, None)) rank, suit = cls._MBTI_TO_CARD_MAPPING.get(mbti_type, (None, None))
if not rank or not suit: if not rank or not suit:
raise ValueError(f"No court card mapping found for MBTI type {mbti_type}") raise ValueError(f"No court card mapping found for MBTI type {mbti_type}")
# Get court card from deck if provided # Get court card from deck if provided
court_card = None court_card = None
if deck is not None: if deck is not None:
# Import here to avoid circular imports # Import here to avoid circular imports
from tarot import Tarot from tarot import Tarot
# Use provided deck or default to Tarot # Use provided deck or default to Tarot
d = deck if hasattr(deck, 'card') else Tarot.deck d = deck if hasattr(deck, "card") else Tarot.deck
cards = d.card.filter(type="Court", court_rank=rank, suit=suit) cards = d.card.filter(type="Court", court_rank=rank, suit=suit)
if cards: if cards:
court_card = cards[0] court_card = cards[0]
# Get description # Get description
descriptions = { descriptions = {
"ENTJ": "The Commander - Strategic, ambitious, leader of Wands", "ENTJ": "The Commander - Strategic, ambitious, leader of Wands",
"ENFJ": "The Protagonist - Inspiring, empathetic, leader of Cups", "ENFJ": "The Protagonist - Inspiring, empathetic, leader of Cups",
"ESTJ": "The Supervisor - Practical, decisive, leader of Swords", "ESTJ": "The Supervisor - Practical, decisive, leader of Swords",
"ESFJ": "The Consul - Sociable, cooperative, leader of Pentacles", "ESFJ": "The Consul - Sociable, cooperative, leader of Pentacles",
"INTJ": "The Architect - Strategic, logical, sage of Wands", "INTJ": "The Architect - Strategic, logical, sage of Wands",
"INFJ": "The Advocate - Insightful, idealistic, sage of Cups", "INFJ": "The Advocate - Insightful, idealistic, sage of Cups",
"ISTJ": "The Logistician - Practical, reliable, sage of Swords", "ISTJ": "The Logistician - Practical, reliable, sage of Swords",
"ISFJ": "The Defender - Caring, conscientious, sage of Pentacles", "ISFJ": "The Defender - Caring, conscientious, sage of Pentacles",
"ENTP": "The Debater - Innovative, quick-witted, explorer of Wands", "ENTP": "The Debater - Innovative, quick-witted, explorer of Wands",
"ENFP": "The Campaigner - Enthusiastic, social, explorer of Cups", "ENFP": "The Campaigner - Enthusiastic, social, explorer of Cups",
"ESTP": "The Entrepreneur - Energetic, bold, explorer of Swords", "ESTP": "The Entrepreneur - Energetic, bold, explorer of Swords",
"ESFP": "The Entertainer - Spontaneous, outgoing, explorer of Pentacles", "ESFP": "The Entertainer - Spontaneous, outgoing, explorer of Pentacles",
"INTP": "The Logician - Analytical, curious, seeker of Wands", "INTP": "The Logician - Analytical, curious, seeker of Wands",
"INFP": "The Mediator - Idealistic, authentic, seeker of Cups", "INFP": "The Mediator - Idealistic, authentic, seeker of Cups",
"ISTP": "The Virtuoso - Practical, observant, seeker of Swords", "ISTP": "The Virtuoso - Practical, observant, seeker of Swords",
"ISFP": "The Adventurer - Sensitive, spontaneous, seeker of Pentacles", "ISFP": "The Adventurer - Sensitive, spontaneous, seeker of Pentacles",
} }
return cls( return cls(
mbti_type=mbti_enum, mbti_type=mbti_enum, court_card=court_card, description=descriptions.get(mbti_type, "")
court_card=court_card,
description=descriptions.get(mbti_type, "")
) )
def __str__(self) -> str: def __str__(self) -> str:
"""Return string representation of personality with court card.""" """Return string representation of personality with court card."""
if self.court_card: if self.court_card:
card_str = f"{self.court_card.court_rank} of {self.court_card.suit.name}" card_str = f"{self.court_card.court_rank} of {self.court_card.suit.name}"
else: else:
card_str = "No court card loaded" card_str = "No court card loaded"
return f"{self.mbti_type.value} - {self.description}\n Court Card: {card_str}" return f"{self.mbti_type.value} - {self.description}\n Court Card: {card_str}"
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return detailed representation.""" """Return detailed representation."""
card_name = ( card_name = (

View File

@@ -16,27 +16,26 @@ Usage:
from typing import Any, List, Tuple from typing import Any, List, Tuple
# Type checking predicates # Type checking predicates
SCALAR_TYPES = (str, int, float, bool, list, dict, type(None)) SCALAR_TYPES = (str, int, float, bool, list, dict, type(None))
def is_dataclass(obj: Any) -> bool: def is_dataclass(obj: Any) -> bool:
"""Check if object is a dataclass.""" """Check if object is a dataclass."""
return hasattr(obj, '__dataclass_fields__') return hasattr(obj, "__dataclass_fields__")
def is_nested_object(obj: Any) -> bool: def is_nested_object(obj: Any) -> bool:
""" """
Check if object is a nested/complex object (not a scalar type). Check if object is a nested/complex object (not a scalar type).
Returns True for dataclasses, dicts, and objects with __dict__ that aren't scalars. Returns True for dataclasses, dicts, and objects with __dict__ that aren't scalars.
""" """
if isinstance(obj, dict): if isinstance(obj, dict):
return True return True
if is_dataclass(obj): if is_dataclass(obj):
return True return True
return hasattr(obj, '__dict__') and not isinstance(obj, SCALAR_TYPES) return hasattr(obj, "__dict__") and not isinstance(obj, SCALAR_TYPES)
def is_scalar(obj: Any) -> bool: def is_scalar(obj: Any) -> bool:
@@ -47,29 +46,29 @@ def is_scalar(obj: Any) -> bool:
def get_item_label(item: Any, fallback: str = "item") -> str: def get_item_label(item: Any, fallback: str = "item") -> str:
""" """
Extract a display label for an item using priority order. Extract a display label for an item using priority order.
Priority: Priority:
1. item.name (most common in Tarot data) 1. item.name (most common in Tarot data)
2. item.transliteration (used in letters/numbers) 2. item.transliteration (used in letters/numbers)
3. str(item) (fallback) 3. str(item) (fallback)
Args: Args:
item: The object to get a label for item: The object to get a label for
fallback: Value to use if no attributes found (rarely used) fallback: Value to use if no attributes found (rarely used)
Returns: Returns:
A string suitable for display as an item label A string suitable for display as an item label
""" """
if hasattr(item, 'name'): if hasattr(item, "name"):
return str(getattr(item, 'name', fallback)) return str(getattr(item, "name", fallback))
elif hasattr(item, 'transliteration'): elif hasattr(item, "transliteration"):
return str(getattr(item, 'transliteration', fallback)) return str(getattr(item, "transliteration", fallback))
return str(item) if item is not None else fallback return str(item) if item is not None else fallback
def get_dataclass_fields(obj: Any) -> List[str]: def get_dataclass_fields(obj: Any) -> List[str]:
"""Get list of field names from a dataclass.""" """Get list of field names from a dataclass."""
if hasattr(obj, '__dataclass_fields__'): if hasattr(obj, "__dataclass_fields__"):
return list(obj.__dataclass_fields__.keys()) return list(obj.__dataclass_fields__.keys())
return [] return []
@@ -77,64 +76,69 @@ def get_dataclass_fields(obj: Any) -> List[str]:
def get_object_attributes(obj: Any) -> List[Tuple[str, Any]]: def get_object_attributes(obj: Any) -> List[Tuple[str, Any]]:
""" """
Extract all public attributes from an object. Extract all public attributes from an object.
Returns list of (name, value) tuples, skipping private attributes (starting with '_'). Returns list of (name, value) tuples, skipping private attributes (starting with '_').
Works with dataclasses, dicts, and regular objects with __dict__. Works with dataclasses, dicts, and regular objects with __dict__.
""" """
attributes = [] attributes = []
if isinstance(obj, dict): if isinstance(obj, dict):
return list(obj.items()) return list(obj.items())
if is_dataclass(obj): if is_dataclass(obj):
for field_name in obj.__dataclass_fields__: for field_name in obj.__dataclass_fields__:
value = getattr(obj, field_name, None) value = getattr(obj, field_name, None)
attributes.append((field_name, value)) attributes.append((field_name, value))
elif hasattr(obj, '__dict__'): elif hasattr(obj, "__dict__"):
for field_name, value in obj.__dict__.items(): for field_name, value in obj.__dict__.items():
if not field_name.startswith('_'): if not field_name.startswith("_"):
attributes.append((field_name, value)) attributes.append((field_name, value))
return attributes return attributes
def format_value(value: Any, indent: int = 2) -> str: def format_value(value: Any, indent: int = 2) -> str:
""" """
Format a value for display, recursively handling nested objects. Format a value for display, recursively handling nested objects.
Handles: Handles:
- Nested dataclasses and objects with custom __str__ methods - Nested dataclasses and objects with custom __str__ methods
- Lists and dicts - Lists and dicts
- Scalar values - Scalar values
- Proper indentation for nested structures - Proper indentation for nested structures
Args: Args:
value: The value to format value: The value to format
indent: Number of spaces for indentation (increases for nested objects) indent: Number of spaces for indentation (increases for nested objects)
Returns: Returns:
Formatted string representation of the value Formatted string representation of the value
""" """
indent_str = " " * indent indent_str = " " * indent
# Check if object has a custom __str__ method (not the default object repr) # Check if object has a custom __str__ method (not the default object repr)
if is_nested_object(value): if is_nested_object(value):
# Classes that have custom __str__ implementations should use them # Classes that have custom __str__ implementations should use them
obj_class = type(value).__name__ obj_class = type(value).__name__
has_custom_str = ( has_custom_str = hasattr(value, "__str__") and type(value).__str__ is not object.__str__
hasattr(value, '__str__') and
type(value).__str__ is not object.__str__ if has_custom_str and obj_class in [
) "Path",
"Planet",
if has_custom_str and obj_class in ['Path', 'Planet', 'Perfume', 'God', 'Colorscale', 'Sephera', 'ElementType']: "Perfume",
"God",
"Colorscale",
"Sephera",
"ElementType",
]:
# Use the custom __str__ method and indent each line # Use the custom __str__ method and indent each line
custom_output = str(value) custom_output = str(value)
lines = [] lines = []
for line in custom_output.split('\n'): for line in custom_output.split("\n"):
if line.strip(): # Skip empty lines if line.strip(): # Skip empty lines
lines.append(f"{indent_str}{line}") lines.append(f"{indent_str}{line}")
return "\n".join(lines) return "\n".join(lines)
# Default behavior: iterate through attributes # Default behavior: iterate through attributes
lines = [] lines = []
for attr_name, attr_value in get_object_attributes(value): for attr_name, attr_value in get_object_attributes(value):
@@ -146,7 +150,7 @@ def format_value(value: Any, indent: int = 2) -> str:
else: else:
lines.append(f"{indent_str}{attr_name}: {attr_value}") lines.append(f"{indent_str}{attr_name}: {attr_value}")
return "\n".join(lines) return "\n".join(lines)
# Scalar values # Scalar values
return str(value) return str(value)
@@ -154,20 +158,20 @@ def format_value(value: Any, indent: int = 2) -> str:
def format_object_attributes(obj: Any, indent: int = 2) -> List[str]: def format_object_attributes(obj: Any, indent: int = 2) -> List[str]:
""" """
Format all attributes of an object as a list of formatted lines. Format all attributes of an object as a list of formatted lines.
Handles nested objects with proper indentation and section headers. Handles nested objects with proper indentation and section headers.
Used by display methods to format individual items consistently. Used by display methods to format individual items consistently.
Args: Args:
obj: The object to format obj: The object to format
indent: Base indentation level in spaces indent: Base indentation level in spaces
Returns: Returns:
List of formatted lines ready to join with newlines List of formatted lines ready to join with newlines
""" """
lines = [] lines = []
indent_str = " " * indent indent_str = " " * indent
for attr_name, attr_value in get_object_attributes(obj): for attr_name, attr_value in get_object_attributes(obj):
if is_nested_object(attr_value): if is_nested_object(attr_value):
# Nested object - add section header and format recursively # Nested object - add section header and format recursively
@@ -178,5 +182,5 @@ def format_object_attributes(obj: Any, indent: int = 2) -> List[str]:
else: else:
# Scalar value - just print # Scalar value - just print
lines.append(f"{indent_str}{attr_name}: {attr_value}") lines.append(f"{indent_str}{attr_name}: {attr_value}")
return lines return lines

View File

@@ -8,42 +8,43 @@ Usage:
# By name # By name
result = letter.iching().name('peace') result = letter.iching().name('peace')
result = letter.alphabet().name('english') result = letter.alphabet().name('english')
# By filter expressions # By filter expressions
result = letter.iching().filter('number:1') result = letter.iching().filter('number:1')
result = letter.alphabet().filter('name:hebrew') result = letter.alphabet().filter('name:hebrew')
result = number.number().filter('value:5') result = number.number().filter('value:5')
# Get all results # Get all results
results = letter.iching().all() # Dict[int, Hexagram] results = letter.iching().all() # Dict[int, Hexagram]
results = letter.iching().list() # List[Hexagram] results = letter.iching().list() # List[Hexagram]
""" """
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union
from utils.object_formatting import format_value, get_object_attributes, is_nested_object from utils.object_formatting import format_value, get_object_attributes, is_nested_object
T = TypeVar('T') T = TypeVar("T")
class QueryResult: class QueryResult:
"""Single result from a query.""" """Single result from a query."""
def __init__(self, data: Any) -> None: def __init__(self, data: Any) -> None:
self.data = data self.data = data
def __repr__(self) -> str: def __repr__(self) -> str:
if hasattr(self.data, '__repr__'): if hasattr(self.data, "__repr__"):
return repr(self.data) return repr(self.data)
return f"{self.__class__.__name__}({self.data})" return f"{self.__class__.__name__}({self.data})"
def __str__(self) -> str: def __str__(self) -> str:
if hasattr(self.data, '__str__'): if hasattr(self.data, "__str__"):
return str(self.data) return str(self.data)
return repr(self) return repr(self)
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
"""Pass through attribute access to the wrapped data.""" """Pass through attribute access to the wrapped data."""
if name.startswith('_'): if name.startswith("_"):
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
return getattr(self.data, name) return getattr(self.data, name)
@@ -51,40 +52,40 @@ class QueryResult:
class Query: class Query:
""" """
Fluent query builder for accessing and filtering tarot data. Fluent query builder for accessing and filtering tarot data.
Supports chaining: .filter() → .name() → .get() Supports chaining: .filter() → .name() → .get()
""" """
def __init__(self, data: Union[Dict[Any, T], List[T]]) -> None: def __init__(self, data: Union[Dict[Any, T], List[T]]) -> None:
"""Initialize with data source (dict or list).""" """Initialize with data source (dict or list)."""
self._original_data = data self._original_data = data
self._data = data if isinstance(data, list) else list(data.values()) self._data = data if isinstance(data, list) else list(data.values())
self._filters: List[Callable[[T], bool]] = [] self._filters: List[Callable[[T], bool]] = []
def filter(self, expression: str) -> 'Query': def filter(self, expression: str) -> "Query":
""" """
Filter by key:value expression. Filter by key:value expression.
Examples: Examples:
.filter('name:peace') .filter('name:peace')
.filter('number:1') .filter('number:1')
.filter('sephera:gevurah') .filter('sephera:gevurah')
.filter('value:5') .filter('value:5')
Supports multiple filters by chaining: Supports multiple filters by chaining:
.filter('number:1').filter('name:creative') .filter('number:1').filter('name:creative')
""" """
key, value = expression.split(':', 1) if ':' in expression else (expression, '') key, value = expression.split(":", 1) if ":" in expression else (expression, "")
def filter_func(item: T) -> bool: def filter_func(item: T) -> bool:
# Special handling for 'name' key # Special handling for 'name' key
if key == 'name': if key == "name":
if hasattr(item, 'name'): if hasattr(item, "name"):
value_lower = value.lower() value_lower = value.lower()
item_name = str(item.name).lower() item_name = str(item.name).lower()
return value_lower == item_name or value_lower in item_name return value_lower == item_name or value_lower in item_name
return False return False
if not hasattr(item, key): if not hasattr(item, key):
return False return False
item_value = getattr(item, key) item_value = getattr(item, key)
@@ -93,34 +94,34 @@ class Query:
return value.lower() in str(item_value).lower() return value.lower() in str(item_value).lower()
else: else:
return str(value) in str(item_value) return str(value) in str(item_value)
self._filters.append(filter_func) self._filters.append(filter_func)
return self return self
def name(self, value: str) -> Optional['QueryResult']: def name(self, value: str) -> Optional["QueryResult"]:
""" """
Deprecated: Use .filter('name:value') instead. Deprecated: Use .filter('name:value') instead.
Find item by name (exact or partial match, case-insensitive). Find item by name (exact or partial match, case-insensitive).
Returns QueryResult wrapping the found item, or None if not found. Returns QueryResult wrapping the found item, or None if not found.
""" """
return self.filter(f'name:{value}').first() return self.filter(f"name:{value}").first()
def get(self) -> Optional['QueryResult']: def get(self) -> Optional["QueryResult"]:
""" """
Get first result matching all applied filters. Get first result matching all applied filters.
Returns QueryResult or None if no match. Returns QueryResult or None if no match.
""" """
for item in self._data: for item in self._data:
if all(f(item) for f in self._filters): if all(f(item) for f in self._filters):
return QueryResult(item) return QueryResult(item)
return None return None
def all(self) -> Dict[Any, T]: def all(self) -> Dict[Any, T]:
""" """
Get all results matching filters as dict. Get all results matching filters as dict.
Returns original dict structure (if input was dict) with filtered values. Returns original dict structure (if input was dict) with filtered values.
""" """
filtered = {} filtered = {}
@@ -133,26 +134,26 @@ class Query:
if all(f(item) for f in self._filters): if all(f(item) for f in self._filters):
filtered[i] = item filtered[i] = item
return filtered return filtered
def list(self) -> List[T]: def list(self) -> List[T]:
""" """
Get all results matching filters as list. Get all results matching filters as list.
Returns list of filtered items. Returns list of filtered items.
""" """
return [item for item in self._data if all(f(item) for f in self._filters)] return [item for item in self._data if all(f(item) for f in self._filters)]
def first(self) -> Optional['QueryResult']: def first(self) -> Optional["QueryResult"]:
"""Alias for get() - returns first matching item.""" """Alias for get() - returns first matching item."""
return self.get() return self.get()
def count(self) -> int: def count(self) -> int:
"""Count items matching all filters.""" """Count items matching all filters."""
return len(self.list()) return len(self.list())
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Query({self.count()} items)" return f"Query({self.count()} items)"
def __str__(self) -> str: def __str__(self) -> str:
items = self.list() items = self.list()
if not items: if not items:
@@ -201,20 +202,18 @@ class CollectionAccessor(Generic[T]):
def display(self) -> str: def display(self) -> str:
""" """
Format all entries for user-friendly display with proper indentation. Format all entries for user-friendly display with proper indentation.
Returns a formatted string with each item separated by blank lines. Returns a formatted string with each item separated by blank lines.
Nested objects are indented and separated with their own sections. Nested objects are indented and separated with their own sections.
""" """
from utils.object_formatting import is_nested_object, get_object_attributes
data = self.all() data = self.all()
if not data: if not data:
return "(empty collection)" return "(empty collection)"
lines = [] lines = []
for key, item in data.items(): for key, item in data.items():
lines.append(f"--- {key} ---") lines.append(f"--- {key} ---")
# Format all attributes with proper nesting # Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item): for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value): if is_nested_object(attr_value):
@@ -224,9 +223,9 @@ class CollectionAccessor(Generic[T]):
lines.append(nested) lines.append(nested)
else: else:
lines.append(f" {attr_name}: {attr_value}") lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items lines.append("") # Blank line between items
return "\n".join(lines) return "\n".join(lines)
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -240,34 +239,32 @@ class CollectionAccessor(Generic[T]):
class FilterableDict(dict): class FilterableDict(dict):
"""Dict subclass that provides .filter() method for dynamic querying.""" """Dict subclass that provides .filter() method for dynamic querying."""
def filter(self, expression: str = '') -> Query: def filter(self, expression: str = "") -> Query:
""" """
Filter dict values by attribute:value expression. Filter dict values by attribute:value expression.
Examples: Examples:
data.filter('name:peace') data.filter('name:peace')
data.filter('number:1') data.filter('number:1')
data.filter('') # Returns query of all items data.filter('') # Returns query of all items
""" """
return Query(self).filter(expression) if expression else Query(self) return Query(self).filter(expression) if expression else Query(self)
def display(self) -> str: def display(self) -> str:
""" """
Format all items in the dict for user-friendly display. Format all items in the dict for user-friendly display.
Returns a formatted string with each item separated by blank lines. Returns a formatted string with each item separated by blank lines.
Nested objects are indented and separated with their own sections. Nested objects are indented and separated with their own sections.
""" """
from utils.object_formatting import is_nested_object, get_object_attributes, format_value
if not self: if not self:
return "(empty collection)" return "(empty collection)"
lines = [] lines = []
for key, item in self.items(): for key, item in self.items():
lines.append(f"--- {key} ---") lines.append(f"--- {key} ---")
# Format all attributes with proper nesting # Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item): for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value): if is_nested_object(attr_value):
@@ -277,16 +274,16 @@ class FilterableDict(dict):
lines.append(nested) lines.append(nested)
else: else:
lines.append(f" {attr_name}: {attr_value}") lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items lines.append("") # Blank line between items
return "\n".join(lines) return "\n".join(lines)
def make_filterable(data: Union[Dict[Any, T], List[T]]) -> Union['FilterableDict', Query]: def make_filterable(data: Union[Dict[Any, T], List[T]]) -> Union["FilterableDict", Query]:
""" """
Convert dict or list to a filterable object with .filter() support. Convert dict or list to a filterable object with .filter() support.
Examples: Examples:
walls = make_filterable(Cube.wall()) walls = make_filterable(Cube.wall())
peace = walls.filter('name:North').first() peace = walls.filter('name:North').first()
@@ -297,4 +294,4 @@ def make_filterable(data: Union[Dict[Any, T], List[T]]) -> Union['FilterableDict
return filterable return filterable
else: else:
# For lists, wrap in a Query # For lists, wrap in a Query
return Query(data) return Query(data)

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env python
import sys
sys.path.insert(0, 'src')
# Test parsing logic
test_cases = [
('2,11,20', [2, 11, 20]),
('1 5 10', [1, 5, 10]),
('3, 7, 14', [3, 7, 14]),
]
for test_input, expected in test_cases:
# Simulate the parsing logic
parts = ['display-cards'] + test_input.split()
nums = []
tokens = []
for tok in parts[1:]:
if ',' in tok:
tokens.extend(tok.split(','))
else:
tokens.append(tok)
for tok in tokens:
tok = tok.strip()
if tok:
try:
nums.append(int(tok))
except ValueError:
nums.append(-1)
status = '' if nums == expected else ''
print(f'{status} Input: "{test_input}" -> {nums} (expected {expected})')
# Test with actual cards
from tarot.tarot_api import Tarot
print("\n✓ Parsing logic works! Now test in REPL with:")
print(" display-cards 2,11,20")
print(" display-cards 1 5 10")

View File

@@ -1,10 +0,0 @@
from tarot import Tarot
from tarot.ui import display_spread
# Draw a spread
print("Drawing Celtic Cross spread...")
reading = Tarot.deck.card.spread("Celtic Cross")
# Display it
print("Displaying spread...")
display_spread(reading)

View File

@@ -3,19 +3,41 @@
from datetime import datetime from datetime import datetime
import pytest import pytest
from src.tarot.attributes import ( from src.tarot.attributes import (
Month, Day, Weekday, Hour, ClockHour, Zodiac, Suit, Meaning, Letter, Sephera, Degree, Element, AstrologicalInfluence,
AstrologicalInfluence, TreeOfLife, Correspondences, CardImage, CardImage,
EnglishAlphabet, GreekAlphabet, HebrewAlphabet, Number, Color, Planet, God, Cipher,
Cipher, CipherResult, CipherResult,
ClockHour,
Color,
Correspondences,
Day,
Degree,
Element,
EnglishAlphabet,
God,
GreekAlphabet,
HebrewAlphabet,
Hour,
Letter,
Meaning,
Month,
Number,
Planet,
Sephera,
Suit,
TreeOfLife,
Weekday,
Zodiac,
) )
from src.tarot.card.data import CardDataLoader, calculate_digital_root from src.tarot.card.data import CardDataLoader, calculate_digital_root
# ============================================================================ # ============================================================================
# Basic Attribute Tests # Basic Attribute Tests
# ============================================================================ # ============================================================================
class TestMonth: class TestMonth:
def test_month_creation(self): def test_month_creation(self):
month = Month(1, "January", "Capricorn", "Aquarius") month = Month(1, "January", "Capricorn", "Aquarius")
@@ -24,10 +46,7 @@ class TestMonth:
assert month.zodiac_start == "Capricorn" assert month.zodiac_start == "Capricorn"
def test_month_all_months(self): def test_month_all_months(self):
months = [ months = [Month(i, f"Month_{i}", "Sign_1", "Sign_2") for i in range(1, 13)]
Month(i, f"Month_{i}", "Sign_1", "Sign_2")
for i in range(1, 13)
]
assert len(months) == 12 assert len(months) == 12
assert months[0].number == 1 assert months[0].number == 1
assert months[11].number == 12 assert months[11].number == 12
@@ -41,10 +60,7 @@ class TestDay:
assert day.planetary_correspondence == "Sun" assert day.planetary_correspondence == "Sun"
def test_all_weekdays(self): def test_all_weekdays(self):
days = [ days = [Day(i, f"Day_{i}", f"Planet_{i}") for i in range(1, 8)]
Day(i, f"Day_{i}", f"Planet_{i}")
for i in range(1, 8)
]
assert len(days) == 7 assert len(days) == 7
@@ -99,6 +115,7 @@ class TestMeaning:
# Sepheric Tests # Sepheric Tests
# ============================================================================ # ============================================================================
class TestSephera: class TestSephera:
def test_sephera_creation(self): def test_sephera_creation(self):
sephera = Sephera(1, "Kether", "כתר", "Crown", "Metatron", "Chaioth", "Primum") sephera = Sephera(1, "Kether", "כתר", "Crown", "Metatron", "Chaioth", "Primum")
@@ -118,6 +135,7 @@ class TestSephera:
# Alphabet Tests # Alphabet Tests
# ============================================================================ # ============================================================================
class TestEnglishAlphabet: class TestEnglishAlphabet:
def test_english_letter_creation(self): def test_english_letter_creation(self):
letter = EnglishAlphabet("A", 1, "ay") letter = EnglishAlphabet("A", 1, "ay")
@@ -189,6 +207,7 @@ class TestHebrewAlphabet:
# Number Tests # Number Tests
# ============================================================================ # ============================================================================
class TestNumber: class TestNumber:
def test_number_creation(self): def test_number_creation(self):
num = Number(1, "Kether", "Spirit", 0) # compliment is auto-calculated num = Number(1, "Kether", "Spirit", 0) # compliment is auto-calculated
@@ -220,6 +239,7 @@ class TestNumber:
# Color Tests # Color Tests
# ============================================================================ # ============================================================================
class TestColor: class TestColor:
def test_color_creation(self): def test_color_creation(self):
color = Color("Red", "#FF0000", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") color = Color("Red", "#FF0000", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power")
@@ -248,7 +268,9 @@ class TestColor:
for r in [0, 128, 255]: for r in [0, 128, 255]:
for g in [0, 128, 255]: for g in [0, 128, 255]:
for b in [0, 128, 255]: for b in [0, 128, 255]:
color = Color("Test", "#000000", (r, g, b), "Sephera", 1, "Element", "Scale", "Meaning") color = Color(
"Test", "#000000", (r, g, b), "Sephera", 1, "Element", "Scale", "Meaning"
)
assert color.rgb == (r, g, b) assert color.rgb == (r, g, b)
@@ -260,7 +282,9 @@ class TestColor:
class TestPlanet: class TestPlanet:
def test_planet_creation(self): def test_planet_creation(self):
number = Number(6, "Tiphareth", "Fire", 0) number = Number(6, "Tiphareth", "Fire", 0)
color = Color("Gold", "#FFD700", (255, 215, 0), "Tiphareth", 6, "Fire", "Yetzirah", "Beauty") color = Color(
"Gold", "#FFD700", (255, 215, 0), "Tiphareth", 6, "Fire", "Yetzirah", "Beauty"
)
planet = Planet( planet = Planet(
name="Sun", name="Sun",
symbol="", symbol="",
@@ -342,6 +366,7 @@ class TestGod:
# Cipher Tests # Cipher Tests
# ============================================================================ # ============================================================================
class TestCipher: class TestCipher:
def test_cipher_mapping_basic(self): def test_cipher_mapping_basic(self):
cipher = Cipher("Test", "test", [1, 2, 3]) cipher = Cipher("Test", "test", [1, 2, 3])
@@ -374,6 +399,7 @@ class TestCipherResult:
# Digital Root Tests # Digital Root Tests
# ============================================================================ # ============================================================================
class TestDigitalRoot: class TestDigitalRoot:
def test_digital_root_single_digit(self): def test_digital_root_single_digit(self):
"""Single digits should return themselves.""" """Single digits should return themselves."""
@@ -389,7 +415,7 @@ class TestDigitalRoot:
def test_digital_root_large_numbers(self): def test_digital_root_large_numbers(self):
"""Test large numbers.""" """Test large numbers."""
assert calculate_digital_root(99) == 9 # 9+9 = 18, 1+8 = 9 assert calculate_digital_root(99) == 9 # 9+9 = 18, 1+8 = 9
assert calculate_digital_root(100) == 1 # 1+0+0 = 1 assert calculate_digital_root(100) == 1 # 1+0+0 = 1
assert calculate_digital_root(123) == 6 # 1+2+3 = 6 assert calculate_digital_root(123) == 6 # 1+2+3 = 6
@@ -398,13 +424,13 @@ class TestDigitalRoot:
# Major Arcana cards 0-21 # Major Arcana cards 0-21
assert calculate_digital_root(14) == 5 # Card 14 (Temperance) -> 5 assert calculate_digital_root(14) == 5 # Card 14 (Temperance) -> 5
assert calculate_digital_root(21) == 3 # Card 21 (The World) -> 3 assert calculate_digital_root(21) == 3 # Card 21 (The World) -> 3
assert calculate_digital_root(1) == 1 # Card 1 (Magician) -> 1 assert calculate_digital_root(1) == 1 # Card 1 (Magician) -> 1
def test_digital_root_invalid_input(self): def test_digital_root_invalid_input(self):
"""Test that invalid inputs raise errors.""" """Test that invalid inputs raise errors."""
with pytest.raises(ValueError): with pytest.raises(ValueError):
calculate_digital_root(0) calculate_digital_root(0)
with pytest.raises(ValueError): with pytest.raises(ValueError):
calculate_digital_root(-5) calculate_digital_root(-5)
@@ -413,6 +439,7 @@ class TestDigitalRoot:
# CardDataLoader Tests # CardDataLoader Tests
# ============================================================================ # ============================================================================
class TestCardDataLoader: class TestCardDataLoader:
@pytest.fixture @pytest.fixture
def loader(self): def loader(self):
@@ -443,33 +470,35 @@ class TestCardDataLoader:
sephera = loader.sephera(i) sephera = loader.sephera(i)
assert sephera is not None assert sephera is not None
assert sephera.number == i assert sephera.number == i
def test_load_ciphers(self, loader): def test_load_ciphers(self, loader):
"""Ensure cipher catalog is populated.""" """Ensure cipher catalog is populated."""
ciphers = loader.cipher() ciphers = loader.cipher()
assert "english_simple" in ciphers assert "english_simple" in ciphers
assert ciphers["english_simple"].default_alphabet == "english" assert ciphers["english_simple"].default_alphabet == "english"
def test_word_cipher_request(self, loader): def test_word_cipher_request(self, loader):
"""word().cipher() should return meaningful totals.""" """word().cipher() should return meaningful totals."""
result = loader.word("tarot").cipher("english_simple") result = loader.word("tarot").cipher("english_simple")
assert isinstance(result, CipherResult) assert isinstance(result, CipherResult)
assert result.total == 74 assert result.total == 74
assert result.alphabet_name == "english" assert result.alphabet_name == "english"
def test_word_cipher_custom_alphabet(self, loader): def test_word_cipher_custom_alphabet(self, loader):
result = loader.word("אמש").cipher("kabbalah_three_mother") result = loader.word("אמש").cipher("kabbalah_three_mother")
assert result.values == (1, 40, 300) assert result.values == (1, 40, 300)
def test_trigram_line_diagram(self, loader): def test_trigram_line_diagram(self, loader):
from letter import trigram from letter import trigram
tri = trigram.trigram.name("Zhen") # Thunder tri = trigram.trigram.name("Zhen") # Thunder
assert tri is not None assert tri is not None
assert tri.data.line_diagram == "|::" assert tri.data.line_diagram == "|::"
def test_hexagram_line_diagram(self, loader): def test_hexagram_line_diagram(self, loader):
from letter import hexagram from letter import hexagram
hex_result = hexagram.hexagram.filter('number:1').first()
hex_result = hexagram.hexagram.filter("number:1").first()
assert hex_result is not None assert hex_result is not None
assert hex_result.data.line_diagram == "||||||" assert hex_result.data.line_diagram == "||||||"
@@ -575,7 +604,7 @@ class TestCardDataLoader:
assert "english" in alphabets assert "english" in alphabets
assert "greek" in alphabets assert "greek" in alphabets
assert "hebrew" in alphabets assert "hebrew" in alphabets
assert len(alphabets["english"]) == 26 assert len(alphabets["english"]) == 26
assert len(alphabets["greek"]) == 24 assert len(alphabets["greek"]) == 24
assert len(alphabets["hebrew"]) == 22 assert len(alphabets["hebrew"]) == 22
@@ -592,16 +621,16 @@ class TestCardDataLoader:
class TestDigitalRootIntegration: class TestDigitalRootIntegration:
"""Integration tests for digital root with Tarot cards.""" """Integration tests for digital root with Tarot cards."""
def test_all_major_arcana_digital_roots(self): def test_all_major_arcana_digital_roots(self):
"""Test digital root for all Major Arcana cards (0-21).""" """Test digital root for all Major Arcana cards (0-21)."""
loader = CardDataLoader() loader = CardDataLoader()
# All Major Arcana cards should map to colors 1-9 # All Major Arcana cards should map to colors 1-9
for card_num in range(22): for card_num in range(22):
if card_num == 0: if card_num == 0:
continue # Skip The Fool (0) continue # Skip The Fool (0)
color = loader.color_by_number(card_num) color = loader.color_by_number(card_num)
assert color is not None assert color is not None
assert 1 <= color.number <= 9 assert 1 <= color.number <= 9
@@ -609,7 +638,7 @@ class TestDigitalRootIntegration:
def test_color_consistency(self): def test_color_consistency(self):
"""Test that equivalent numbers map to same color.""" """Test that equivalent numbers map to same color."""
loader = CardDataLoader() loader = CardDataLoader()
# 5 and 14 should map to same color (both have digital root 5) # 5 and 14 should map to same color (both have digital root 5)
color_5 = loader.color_by_number(5) color_5 = loader.color_by_number(5)
color_14 = loader.color_by_number(14) color_14 = loader.color_by_number(14)

View File

@@ -1,34 +1,37 @@
import pytest
from tarot.ui import CardDisplay
from tarot.deck import Card
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from tarot.deck import Card
from tarot.ui import CardDisplay
def test_card_display_delegation(): def test_card_display_delegation():
"""Test that CardDisplay delegates to SpreadDisplay correctly.""" """Test that CardDisplay delegates to SpreadDisplay correctly."""
with patch('tarot.ui.SpreadDisplay') as MockSpreadDisplay: with patch("tarot.ui.SpreadDisplay") as MockSpreadDisplay:
# Mock HAS_PILLOW to True to ensure we proceed # Mock HAS_PILLOW to True to ensure we proceed
with patch('tarot.ui.HAS_PILLOW', True): with patch("tarot.ui.HAS_PILLOW", True):
display = CardDisplay() display = CardDisplay()
# Create dummy card # Create dummy card
card = MagicMock(spec=Card) card = MagicMock(spec=Card)
card.name = "The Fool" card.name = "The Fool"
card.image_path = "fool.jpg" card.image_path = "fool.jpg"
cards = [card] cards = [card]
display.show_cards(cards, title="Test Spread") display.show_cards(cards, title="Test Spread")
# Verify SpreadDisplay was instantiated # Verify SpreadDisplay was instantiated
assert MockSpreadDisplay.call_count == 1 assert MockSpreadDisplay.call_count == 1
# Verify run was called # Verify run was called
MockSpreadDisplay.return_value.run.assert_called_once() MockSpreadDisplay.return_value.run.assert_called_once()
# Verify arguments passed to SpreadDisplay # Verify arguments passed to SpreadDisplay
args, _ = MockSpreadDisplay.call_args args, _ = MockSpreadDisplay.call_args
reading = args[0] reading = args[0]
deck_name = args[1] deck_name = args[1]
assert deck_name == "default" assert deck_name == "default"
assert reading.spread.name == "Card List" assert reading.spread.name == "Card List"
assert reading.spread.description == "Test Spread" assert reading.spread.description == "Test Spread"

View File

@@ -1,6 +1,8 @@
import pytest import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_cube_display_init(): def test_cube_display_init():
cube = Tarot.cube cube = Tarot.cube
@@ -8,38 +10,40 @@ def test_cube_display_init():
assert display.current_wall_name == "North" assert display.current_wall_name == "North"
assert display.deck_name == "default" assert display.deck_name == "default"
def test_cube_navigation(): def test_cube_navigation():
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
# North -> Right -> East # North -> Right -> East
display._navigate("Right") display._navigate("Right")
assert display.current_wall_name == "East" assert display.current_wall_name == "East"
# East -> Up -> Above # East -> Up -> Above
display._navigate("Up") display._navigate("Up")
assert display.current_wall_name == "Above" assert display.current_wall_name == "Above"
# Above -> Down -> North # Above -> Down -> North
display._navigate("Down") display._navigate("Down")
assert display.current_wall_name == "North" assert display.current_wall_name == "North"
# North -> Left -> West # North -> Left -> West
display._navigate("Left") display._navigate("Left")
assert display.current_wall_name == "West" assert display.current_wall_name == "West"
def test_find_card_for_direction(): def test_find_card_for_direction():
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
# North Wall, Center Direction -> Aleph -> The Fool # North Wall, Center Direction -> Aleph -> The Fool
wall = cube.wall("North") wall = cube.wall("North")
direction = wall.direction("Center") # Should be Aleph? direction = wall.direction("Center") # Should be Aleph?
# Wait, let's check what Center of North is. # Wait, let's check what Center of North is.
# Actually, let's just mock a direction # Actually, let's just mock a direction
from kaballah.cube.attributes import WallDirection from kaballah.cube.attributes import WallDirection
# Aleph -> The Fool # Aleph -> The Fool
d = WallDirection("Center", "Aleph") d = WallDirection("Center", "Aleph")
card = display._find_card_for_direction(d) card = display._find_card_for_direction(d)

View File

@@ -1,27 +1,30 @@
import pytest import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_cube_zoom(): def test_cube_zoom():
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
assert display.zoom_level == 1.0 assert display.zoom_level == 1.0
display._zoom(1.1) display._zoom(1.1)
assert display.zoom_level > 1.0 assert display.zoom_level > 1.0
display._zoom(0.5) display._zoom(0.5)
assert display.zoom_level < 1.0 assert display.zoom_level < 1.0
def test_cube_zoom_limits(): def test_cube_zoom_limits():
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
# Test upper limit # Test upper limit
for _ in range(20): for _ in range(20):
display._zoom(1.5) display._zoom(1.5)
assert display.zoom_level <= 3.0 assert display.zoom_level <= 3.0
# Test lower limit # Test lower limit
for _ in range(20): for _ in range(20):
display._zoom(0.5) display._zoom(0.5)

View File

@@ -1,60 +1,131 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
import tkinter as tk import tkinter as tk
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_zoom_limits(): def test_zoom_limits():
# Mock Tk root # Mock Tk root
class MockRoot: class MockRoot:
def __init__(self): def __init__(self):
self.bindings = {} self.bindings = {}
self.images = [] self.images = []
def bind(self, key, callback): pass
def title(self, _): pass def bind(self, key, callback):
def update_idletasks(self): pass pass
def winfo_reqwidth(self): return 800
def winfo_reqheight(self): return 600 def title(self, _):
def winfo_screenwidth(self): return 1920 pass
def winfo_screenheight(self): return 1080
def geometry(self, _): pass def update_idletasks(self):
def mainloop(self): pass pass
def focus_force(self): pass
def winfo_reqwidth(self):
return 800
def winfo_reqheight(self):
return 600
def winfo_screenwidth(self):
return 1920
def winfo_screenheight(self):
return 1080
def geometry(self, _):
pass
def mainloop(self):
pass
def focus_force(self):
pass
# Mock Frame # Mock Frame
class MockFrame: class MockFrame:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.children = [] self.children = []
self.master = master self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass def pack(self, **kwargs):
def grid(self, **kwargs): pass pass
def grid_propagate(self, flag): pass
def winfo_children(self): return self.children def place(self, **kwargs):
def destroy(self): pass pass
def update_idletasks(self): pass
def winfo_reqwidth(self): return 100 def grid(self, **kwargs):
def winfo_reqheight(self): return 100 pass
def bind(self, event, callback): pass
def grid_propagate(self, flag):
pass
def winfo_children(self):
return self.children
def destroy(self):
pass
def update_idletasks(self):
pass
def winfo_reqwidth(self):
return 100
def winfo_reqheight(self):
return 100
def bind(self, event, callback):
pass
# Mock Canvas # Mock Canvas
class MockCanvas: class MockCanvas:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.master = master self.master = master
def pack(self, **kwargs): pass
def bind(self, event, callback): pass def pack(self, **kwargs):
def create_window(self, coords, **kwargs): return 1 pass
def config(self, **kwargs): pass
def bbox(self, tag): return (0,0,100,100) def bind(self, event, callback):
def winfo_width(self): return 800 pass
def winfo_height(self): return 600
def coords(self, item, x, y): pass def create_window(self, coords, **kwargs):
def scan_mark(self, x, y): pass return 1
def scan_dragto(self, x, y, gain=1): pass
def canvasx(self, x): return x def config(self, **kwargs):
def canvasy(self, y): return y pass
def xview_moveto(self, fraction): pass
def yview_moveto(self, fraction): pass def bbox(self, tag):
return (0, 0, 100, 100)
def winfo_width(self):
return 800
def winfo_height(self):
return 600
def coords(self, item, x, y):
pass
def scan_mark(self, x, y):
pass
def scan_dragto(self, x, y, gain=1):
pass
def canvasx(self, x):
return x
def canvasy(self, y):
return y
def xview_moveto(self, fraction):
pass
def yview_moveto(self, fraction):
pass
# Monkey patch tk # Monkey patch tk
original_tk = tk.Tk original_tk = tk.Tk
@@ -62,60 +133,68 @@ def test_zoom_limits():
original_canvas = tk.Canvas original_canvas = tk.Canvas
original_label = tk.ttk.Label original_label = tk.ttk.Label
original_button = tk.ttk.Button original_button = tk.ttk.Button
# Mock Label and Button # Mock Label and Button
class MockWidget: class MockWidget:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.master = master self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass def pack(self, **kwargs):
def grid(self, **kwargs): pass pass
def grid_propagate(self, flag): pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
pass
def grid_propagate(self, flag):
pass
try: try:
tk.Tk = MockRoot tk.Tk = MockRoot
tk.ttk.Frame = MockFrame tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas tk.Canvas = MockCanvas
tk.ttk.Label = MockWidget tk.ttk.Label = MockWidget
tk.ttk.Button = MockWidget tk.ttk.Button = MockWidget
# Mock Image to avoid memory issues # Mock Image to avoid memory issues
with patch('PIL.Image.open') as mock_open: with patch("PIL.Image.open") as mock_open:
mock_img = MagicMock() mock_img = MagicMock()
mock_img.size = (100, 100) mock_img.size = (100, 100)
mock_img.resize.return_value = mock_img mock_img.resize.return_value = mock_img
mock_open.return_value = mock_img mock_open.return_value = mock_img
with patch('PIL.ImageTk.PhotoImage') as mock_photo: with patch("PIL.ImageTk.PhotoImage") as mock_photo:
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
display.root = MockRoot() display.root = MockRoot()
display.canvas = MockCanvas() display.canvas = MockCanvas()
display.content_frame = MockFrame() display.content_frame = MockFrame()
display.canvas_window = 1 # Mock window ID display.canvas_window = 1 # Mock window ID
# Test initial zoom # Test initial zoom
assert display.zoom_level == 1.0 assert display.zoom_level == 1.0
# Test zoom in # Test zoom in
display._zoom(1.22) display._zoom(1.22)
assert display.zoom_level == 1.22 assert display.zoom_level == 1.22
# Test max limit (should be 50.0) # Test max limit (should be 50.0)
# Zoom way in # Zoom way in
for _ in range(100): for _ in range(100):
display._zoom(1.22) display._zoom(1.22)
assert display.zoom_level == 50.0 assert display.zoom_level == 50.0
# Test min limit (should be 0.1) # Test min limit (should be 0.1)
# Zoom way out # Zoom way out
for _ in range(200): for _ in range(200):
display._zoom(0.5) display._zoom(0.5)
assert display.zoom_level == 0.1 assert display.zoom_level == 0.1
finally: finally:
tk.Tk = original_tk tk.Tk = original_tk
tk.ttk.Frame = original_frame tk.ttk.Frame = original_frame

View File

@@ -3,8 +3,9 @@ Tests for Tarot deck and card classes.
""" """
import pytest import pytest
from src.tarot.deck import Deck, Card, MajorCard, MinorCard, PipCard, AceCard, CourtCard
from src.tarot.attributes import Meaning, Suit, CardImage from src.tarot.attributes import CardImage, Meaning, Suit
from src.tarot.deck import AceCard, Card, CourtCard, Deck, MajorCard, MinorCard, PipCard
class TestCard: class TestCard:
@@ -30,7 +31,7 @@ class TestMajorCard:
name="The Magician", name="The Magician",
meaning=Meaning("Upright", "Reversed"), meaning=Meaning("Upright", "Reversed"),
arcana="Major", arcana="Major",
kabbalistic_number=1 kabbalistic_number=1,
) )
assert card.number == 1 assert card.number == 1
assert card.arcana == "Major" assert card.arcana == "Major"
@@ -42,7 +43,7 @@ class TestMajorCard:
name="Test", name="Test",
meaning=Meaning("Up", "Rev"), meaning=Meaning("Up", "Rev"),
arcana="Major", arcana="Major",
kabbalistic_number=-1 kabbalistic_number=-1,
) )
def test_major_card_invalid_high(self): def test_major_card_invalid_high(self):
@@ -52,16 +53,13 @@ class TestMajorCard:
name="Test", name="Test",
meaning=Meaning("Up", "Rev"), meaning=Meaning("Up", "Rev"),
arcana="Major", arcana="Major",
kabbalistic_number=22 kabbalistic_number=22,
) )
def test_major_card_valid_range(self): def test_major_card_valid_range(self):
for i in range(22): for i in range(22):
card = MajorCard( card = MajorCard(
number=i, number=i, name=f"Card {i}", meaning=Meaning("Up", "Rev"), arcana="Major"
name=f"Card {i}",
meaning=Meaning("Up", "Rev"),
arcana="Major"
) )
assert card.number == i assert card.number == i
@@ -75,7 +73,7 @@ class TestMinorCard:
meaning=Meaning("Upright", "Reversed"), meaning=Meaning("Upright", "Reversed"),
arcana="Minor", arcana="Minor",
suit=suit, suit=suit,
pip=1 pip=1,
) )
assert card.number == 1 assert card.number == 1
assert card.suit.name == "Cups" assert card.suit.name == "Cups"
@@ -90,7 +88,7 @@ class TestMinorCard:
meaning=Meaning("Up", "Rev"), meaning=Meaning("Up", "Rev"),
arcana="Minor", arcana="Minor",
suit=suit, suit=suit,
pip=0 pip=0,
) )
def test_minor_card_invalid_pip_high(self): def test_minor_card_invalid_pip_high(self):
@@ -102,7 +100,7 @@ class TestMinorCard:
meaning=Meaning("Up", "Rev"), meaning=Meaning("Up", "Rev"),
arcana="Minor", arcana="Minor",
suit=suit, suit=suit,
pip=15 pip=15,
) )
def test_minor_card_valid_pips(self): def test_minor_card_valid_pips(self):
@@ -114,7 +112,7 @@ class TestMinorCard:
meaning=Meaning("Up", "Rev"), meaning=Meaning("Up", "Rev"),
arcana="Minor", arcana="Minor",
suit=suit, suit=suit,
pip=i pip=i,
) )
assert card.pip == i assert card.pip == i
@@ -137,10 +135,10 @@ class TestDeck:
def test_deck_shuffle(self): def test_deck_shuffle(self):
deck1 = Deck() deck1 = Deck()
cards_before = [c.name for c in deck1.cards] cards_before = [c.name for c in deck1.cards]
deck1.shuffle() deck1.shuffle()
cards_after = [c.name for c in deck1.cards] cards_after = [c.name for c in deck1.cards]
# After shuffle, order should change (with high probability) # After shuffle, order should change (with high probability)
# We don't assert they're different since shuffle could randomly give same order # We don't assert they're different since shuffle could randomly give same order
assert len(cards_after) == 78 assert len(cards_after) == 78
@@ -148,18 +146,18 @@ class TestDeck:
def test_deck_draw_single(self): def test_deck_draw_single(self):
deck = Deck() deck = Deck()
initial_count = len(deck.cards) initial_count = len(deck.cards)
drawn = deck.draw(1) drawn = deck.draw(1)
assert len(drawn) == 1 assert len(drawn) == 1
assert len(deck.cards) == initial_count - 1 assert len(deck.cards) == initial_count - 1
def test_deck_draw_multiple(self): def test_deck_draw_multiple(self):
deck = Deck() deck = Deck()
initial_count = len(deck.cards) initial_count = len(deck.cards)
drawn = deck.draw(5) drawn = deck.draw(5)
assert len(drawn) == 5 assert len(drawn) == 5
assert len(deck.cards) == initial_count - 5 assert len(deck.cards) == initial_count - 5
@@ -177,28 +175,28 @@ class TestDeck:
deck = Deck() deck = Deck()
deck.draw(5) deck.draw(5)
assert len(deck.cards) < 78 assert len(deck.cards) < 78
deck.reset() deck.reset()
assert len(deck.cards) == 78 assert len(deck.cards) == 78
def test_deck_remaining(self): def test_deck_remaining(self):
deck = Deck() deck = Deck()
assert deck.remaining() == 78 assert deck.remaining() == 78
deck.draw(1) deck.draw(1)
assert deck.remaining() == 77 assert deck.remaining() == 77
def test_deck_len(self): def test_deck_len(self):
deck = Deck() deck = Deck()
assert len(deck) == 78 assert len(deck) == 78
deck.draw(1) deck.draw(1)
assert len(deck) == 77 assert len(deck) == 77
def test_deck_repr(self): def test_deck_repr(self):
deck = Deck() deck = Deck()
assert "78 cards" in repr(deck) assert "78 cards" in repr(deck)
deck.draw(1) deck.draw(1)
assert "77 cards" in repr(deck) assert "77 cards" in repr(deck)

View File

@@ -1,18 +1,25 @@
import pytest
from pathlib import Path from pathlib import Path
import pytest
from tarot.ui import CardDisplay from tarot.ui import CardDisplay
def test_card_display_init(): def test_card_display_init():
display = CardDisplay("default") display = CardDisplay("default")
assert display.deck_name == "default" assert display.deck_name == "default"
# Check if path resolves correctly relative to src/tarot/ui.py # Check if path resolves correctly relative to src/tarot/ui.py
# src/tarot/ui.py -> src/tarot -> src/tarot/deck/default # src/tarot/ui.py -> src/tarot -> src/tarot/deck/default
expected_suffix = os.path.join("src", "tarot", "deck", "default") expected_suffix = os.path.join("src", "tarot", "deck", "default")
assert str(display.deck_path).endswith(expected_suffix) or str(display.deck_path).endswith("default") assert str(display.deck_path).endswith(expected_suffix) or str(display.deck_path).endswith(
"default"
)
def test_card_display_resolve_path(): def test_card_display_resolve_path():
display = CardDisplay("thoth") display = CardDisplay("thoth")
assert display.deck_name == "thoth" assert display.deck_name == "thoth"
assert str(display.deck_path).endswith("thoth") assert str(display.deck_path).endswith("thoth")
import os import os

View File

@@ -1,60 +1,63 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
import tkinter as tk import tkinter as tk
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_recursive_binding(): def test_recursive_binding():
# Mock Tk root and widgets # Mock Tk root and widgets
class MockWidget: class MockWidget:
def __init__(self): def __init__(self):
self.children = [] self.children = []
self.bindings = {} self.bindings = {}
def bind(self, key, callback): def bind(self, key, callback):
self.bindings[key] = callback self.bindings[key] = callback
def winfo_children(self): def winfo_children(self):
return self.children return self.children
def add_child(self, child): def add_child(self, child):
self.children.append(child) self.children.append(child)
# Monkey patch tk # Monkey patch tk
original_tk = tk.Tk original_tk = tk.Tk
original_frame = tk.ttk.Frame original_frame = tk.ttk.Frame
try: try:
# We don't need to mock everything, just enough to test _bind_recursive # We don't need to mock everything, just enough to test _bind_recursive
cube = Tarot.cube cube = Tarot.cube
# We can instantiate CubeDisplay without showing it # We can instantiate CubeDisplay without showing it
display = CubeDisplay(cube) display = CubeDisplay(cube)
# Create a mock widget tree # Create a mock widget tree
parent = MockWidget() parent = MockWidget()
child1 = MockWidget() child1 = MockWidget()
child2 = MockWidget() child2 = MockWidget()
grandchild = MockWidget() grandchild = MockWidget()
parent.add_child(child1) parent.add_child(child1)
parent.add_child(child2) parent.add_child(child2)
child1.add_child(grandchild) child1.add_child(grandchild)
# Run recursive binding # Run recursive binding
display._bind_recursive(parent) display._bind_recursive(parent)
# Verify bindings # Verify bindings
assert "<ButtonPress-1>" in parent.bindings assert "<ButtonPress-1>" in parent.bindings
assert "<B1-Motion>" in parent.bindings assert "<B1-Motion>" in parent.bindings
assert "<ButtonPress-1>" in child1.bindings assert "<ButtonPress-1>" in child1.bindings
assert "<B1-Motion>" in child1.bindings assert "<B1-Motion>" in child1.bindings
assert "<ButtonPress-1>" in child2.bindings assert "<ButtonPress-1>" in child2.bindings
assert "<B1-Motion>" in child2.bindings assert "<B1-Motion>" in child2.bindings
assert "<ButtonPress-1>" in grandchild.bindings assert "<ButtonPress-1>" in grandchild.bindings
assert "<B1-Motion>" in grandchild.bindings assert "<B1-Motion>" in grandchild.bindings
finally: finally:
pass pass

View File

@@ -1,72 +1,99 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
import tkinter as tk import tkinter as tk
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_zoom_key_bindings(): def test_zoom_key_bindings():
# This test verifies that the bindings are set up, # This test verifies that the bindings are set up,
# but cannot easily simulate key presses in headless environment. # but cannot easily simulate key presses in headless environment.
# We check if the bind method was called with correct keys. # We check if the bind method was called with correct keys.
# Mock Tk root # Mock Tk root
class MockRoot: class MockRoot:
def __init__(self): def __init__(self):
self.bindings = {} self.bindings = {}
def bind(self, key, callback): def bind(self, key, callback):
self.bindings[key] = callback self.bindings[key] = callback
def title(self, _): pass def title(self, _):
def update_idletasks(self): pass pass
def winfo_reqwidth(self): return 800
def winfo_reqheight(self): return 600 def update_idletasks(self):
def winfo_screenwidth(self): return 1920 pass
def winfo_screenheight(self): return 1080
def geometry(self, _): pass def winfo_reqwidth(self):
def mainloop(self): pass return 800
def focus_force(self): pass
def winfo_reqheight(self):
return 600
def winfo_screenwidth(self):
return 1920
def winfo_screenheight(self):
return 1080
def geometry(self, _):
pass
def mainloop(self):
pass
def focus_force(self):
pass
# Mock Frame # Mock Frame
class MockFrame: class MockFrame:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.children = [] self.children = []
def pack(self, **kwargs): pass
def winfo_children(self): return self.children def pack(self, **kwargs):
def destroy(self): pass pass
def winfo_children(self):
return self.children
def destroy(self):
pass
# Monkey patch tk # Monkey patch tk
original_tk = tk.Tk original_tk = tk.Tk
original_frame = tk.ttk.Frame original_frame = tk.ttk.Frame
try: try:
tk.Tk = MockRoot tk.Tk = MockRoot
tk.ttk.Frame = MockFrame tk.ttk.Frame = MockFrame
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
# We need to call show() to trigger bindings, but avoid mainloop # We need to call show() to trigger bindings, but avoid mainloop
# We can't easily mock show() without refactoring, # We can't easily mock show() without refactoring,
# so we'll just inspect the code logic or trust the manual test. # so we'll just inspect the code logic or trust the manual test.
# However, we can manually call the binding logic if we extract it. # However, we can manually call the binding logic if we extract it.
# Since we can't easily mock the entire UI startup in a unit test without # Since we can't easily mock the entire UI startup in a unit test without
# a display, we'll rely on the fact that we added the bindings in the code. # a display, we'll rely on the fact that we added the bindings in the code.
pass pass
finally: finally:
tk.Tk = original_tk tk.Tk = original_tk
tk.ttk.Frame = original_frame tk.ttk.Frame = original_frame
def test_zoom_logic_direct(): def test_zoom_logic_direct():
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
display.zoom_level = 1.0 display.zoom_level = 1.0
# Simulate + key press effect # Simulate + key press effect
display._zoom(1.1) display._zoom(1.1)
assert display.zoom_level > 1.0 assert display.zoom_level > 1.0
# Simulate - key press effect # Simulate - key press effect
display._zoom(0.9) display._zoom(0.9)
assert display.zoom_level < 1.1 assert display.zoom_level < 1.1

View File

@@ -1,83 +1,140 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
import tkinter as tk import tkinter as tk
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_canvas_structure(): def test_canvas_structure():
# Mock Tk root # Mock Tk root
class MockRoot: class MockRoot:
def __init__(self): def __init__(self):
self.bindings = {} self.bindings = {}
def bind(self, key, callback): pass
def title(self, _): pass def bind(self, key, callback):
def update_idletasks(self): pass pass
def winfo_reqwidth(self): return 800
def winfo_reqheight(self): return 600 def title(self, _):
def winfo_screenwidth(self): return 1920 pass
def winfo_screenheight(self): return 1080
def geometry(self, _): pass def update_idletasks(self):
def mainloop(self): pass pass
def focus_force(self): pass
def winfo_reqwidth(self):
return 800
def winfo_reqheight(self):
return 600
def winfo_screenwidth(self):
return 1920
def winfo_screenheight(self):
return 1080
def geometry(self, _):
pass
def mainloop(self):
pass
def focus_force(self):
pass
# Mock Frame # Mock Frame
class MockFrame: class MockFrame:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.children = [] self.children = []
self.master = master self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass def pack(self, **kwargs):
def winfo_children(self): return self.children pass
def destroy(self): pass
def update_idletasks(self): pass def place(self, **kwargs):
def winfo_reqwidth(self): return 100 pass
def winfo_reqheight(self): return 100
def winfo_children(self):
return self.children
def destroy(self):
pass
def update_idletasks(self):
pass
def winfo_reqwidth(self):
return 100
def winfo_reqheight(self):
return 100
# Mock Canvas # Mock Canvas
class MockCanvas: class MockCanvas:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.master = master self.master = master
def pack(self, **kwargs): pass
def bind(self, event, callback): pass def pack(self, **kwargs):
def create_window(self, coords, **kwargs): return 1 pass
def config(self, **kwargs): pass
def bbox(self, tag): return (0,0,100,100) def bind(self, event, callback):
def winfo_width(self): return 800 pass
def winfo_height(self): return 600
def coords(self, item, x, y): pass def create_window(self, coords, **kwargs):
def scan_mark(self, x, y): pass return 1
def scan_dragto(self, x, y, gain=1): pass
def config(self, **kwargs):
pass
def bbox(self, tag):
return (0, 0, 100, 100)
def winfo_width(self):
return 800
def winfo_height(self):
return 600
def coords(self, item, x, y):
pass
def scan_mark(self, x, y):
pass
def scan_dragto(self, x, y, gain=1):
pass
# Monkey patch tk # Monkey patch tk
original_tk = tk.Tk original_tk = tk.Tk
original_frame = tk.ttk.Frame original_frame = tk.ttk.Frame
original_canvas = tk.Canvas original_canvas = tk.Canvas
try: try:
tk.Tk = MockRoot tk.Tk = MockRoot
tk.ttk.Frame = MockFrame tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas tk.Canvas = MockCanvas
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
# Trigger show to build UI # Trigger show to build UI
# We can't fully run show() because of mainloop, but we can instantiate parts # We can't fully run show() because of mainloop, but we can instantiate parts
# Actually, show() creates the root. # Actually, show() creates the root.
# Let's just verify the structure by inspecting the code or trusting the manual test. # Let's just verify the structure by inspecting the code or trusting the manual test.
# But we can test the pan methods directly. # But we can test the pan methods directly.
display.canvas = MockCanvas() display.canvas = MockCanvas()
# Test pan methods # Test pan methods
class MockEvent: class MockEvent:
x = 10 x = 10
y = 20 y = 20
x_root = 110 x_root = 110
y_root = 120 y_root = 120
display._start_pan(MockEvent()) display._start_pan(MockEvent())
display._pan(MockEvent()) display._pan(MockEvent())
finally: finally:
tk.Tk = original_tk tk.Tk = original_tk
tk.ttk.Frame = original_frame tk.ttk.Frame = original_frame

View File

@@ -1,68 +1,137 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
import tkinter as tk import tkinter as tk
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_wasd_panning(): def test_wasd_panning():
# Mock Tk root # Mock Tk root
class MockRoot: class MockRoot:
def __init__(self): def __init__(self):
self.bindings = {} self.bindings = {}
self.images = [] self.images = []
def bind(self, key, callback):
def bind(self, key, callback):
self.bindings[key] = callback self.bindings[key] = callback
def title(self, _): pass
def update_idletasks(self): pass def title(self, _):
def winfo_reqwidth(self): return 800 pass
def winfo_reqheight(self): return 600
def winfo_screenwidth(self): return 1920 def update_idletasks(self):
def winfo_screenheight(self): return 1080 pass
def geometry(self, _): pass
def mainloop(self): pass def winfo_reqwidth(self):
def focus_force(self): pass return 800
def winfo_reqheight(self):
return 600
def winfo_screenwidth(self):
return 1920
def winfo_screenheight(self):
return 1080
def geometry(self, _):
pass
def mainloop(self):
pass
def focus_force(self):
pass
# Mock Frame # Mock Frame
class MockFrame: class MockFrame:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.children = [] self.children = []
self.master = master self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass def pack(self, **kwargs):
def grid(self, **kwargs): pass pass
def grid_propagate(self, flag): pass
def winfo_children(self): return self.children def place(self, **kwargs):
def destroy(self): pass pass
def update_idletasks(self): pass
def winfo_reqwidth(self): return 100 def grid(self, **kwargs):
def winfo_reqheight(self): return 100 pass
def bind(self, event, callback): pass
def grid_propagate(self, flag):
pass
def winfo_children(self):
return self.children
def destroy(self):
pass
def update_idletasks(self):
pass
def winfo_reqwidth(self):
return 100
def winfo_reqheight(self):
return 100
def bind(self, event, callback):
pass
# Mock Canvas # Mock Canvas
class MockCanvas: class MockCanvas:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.master = master self.master = master
self.x_scrolls = [] self.x_scrolls = []
self.y_scrolls = [] self.y_scrolls = []
def pack(self, **kwargs): pass def pack(self, **kwargs):
def bind(self, event, callback): pass pass
def create_window(self, coords, **kwargs): return 1
def config(self, **kwargs): pass def bind(self, event, callback):
def bbox(self, tag): return (0,0,100,100) pass
def winfo_width(self): return 800
def winfo_height(self): return 600 def create_window(self, coords, **kwargs):
def coords(self, item, x, y): pass return 1
def scan_mark(self, x, y): pass
def scan_dragto(self, x, y, gain=1): pass def config(self, **kwargs):
def canvasx(self, x): return x pass
def canvasy(self, y): return y
def xview_moveto(self, fraction): pass def bbox(self, tag):
def yview_moveto(self, fraction): pass return (0, 0, 100, 100)
def winfo_width(self):
return 800
def winfo_height(self):
return 600
def coords(self, item, x, y):
pass
def scan_mark(self, x, y):
pass
def scan_dragto(self, x, y, gain=1):
pass
def canvasx(self, x):
return x
def canvasy(self, y):
return y
def xview_moveto(self, fraction):
pass
def yview_moveto(self, fraction):
pass
def xview_scroll(self, number, what): def xview_scroll(self, number, what):
self.x_scrolls.append((number, what)) self.x_scrolls.append((number, what))
def yview_scroll(self, number, what): def yview_scroll(self, number, what):
self.y_scrolls.append((number, what)) self.y_scrolls.append((number, what))
@@ -72,54 +141,62 @@ def test_wasd_panning():
original_canvas = tk.Canvas original_canvas = tk.Canvas
original_label = tk.ttk.Label original_label = tk.ttk.Label
original_button = tk.ttk.Button original_button = tk.ttk.Button
# Mock Label and Button # Mock Label and Button
class MockWidget: class MockWidget:
def __init__(self, master=None, **kwargs): def __init__(self, master=None, **kwargs):
self.master = master self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass def pack(self, **kwargs):
def grid(self, **kwargs): pass pass
def grid_propagate(self, flag): pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
pass
def grid_propagate(self, flag):
pass
try: try:
tk.Tk = MockRoot tk.Tk = MockRoot
tk.ttk.Frame = MockFrame tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas tk.Canvas = MockCanvas
tk.ttk.Label = MockWidget tk.ttk.Label = MockWidget
tk.ttk.Button = MockWidget tk.ttk.Button = MockWidget
# Mock Image to avoid memory issues # Mock Image to avoid memory issues
with patch('PIL.Image.open') as mock_open: with patch("PIL.Image.open") as mock_open:
mock_img = MagicMock() mock_img = MagicMock()
mock_img.size = (100, 100) mock_img.size = (100, 100)
mock_img.resize.return_value = mock_img mock_img.resize.return_value = mock_img
mock_open.return_value = mock_img mock_open.return_value = mock_img
with patch('PIL.ImageTk.PhotoImage') as mock_photo: with patch("PIL.ImageTk.PhotoImage") as mock_photo:
cube = Tarot.cube cube = Tarot.cube
display = CubeDisplay(cube) display = CubeDisplay(cube)
display.root = MockRoot() display.root = MockRoot()
display.canvas = MockCanvas() display.canvas = MockCanvas()
display.content_frame = MockFrame() display.content_frame = MockFrame()
display.canvas_window = 1 display.canvas_window = 1
# Manually trigger bindings (since we can't easily simulate key press in mock root without event loop) # Manually trigger bindings (since we can't easily simulate key press in mock root without event loop)
# But we can call _pan_key directly to test logic # But we can call _pan_key directly to test logic
display._pan_key("up") display._pan_key("up")
assert display.canvas.y_scrolls[-1] == (-1, "units") assert display.canvas.y_scrolls[-1] == (-1, "units")
display._pan_key("down") display._pan_key("down")
assert display.canvas.y_scrolls[-1] == (1, "units") assert display.canvas.y_scrolls[-1] == (1, "units")
display._pan_key("left") display._pan_key("left")
assert display.canvas.x_scrolls[-1] == (-1, "units") assert display.canvas.x_scrolls[-1] == (-1, "units")
display._pan_key("right") display._pan_key("right")
assert display.canvas.x_scrolls[-1] == (1, "units") assert display.canvas.x_scrolls[-1] == (1, "units")
finally: finally:
tk.Tk = original_tk tk.Tk = original_tk
tk.ttk.Frame = original_frame tk.ttk.Frame = original_frame

View File

@@ -8,8 +8,8 @@ References:
- Weekday planetary rulers - Weekday planetary rulers
""" """
from datetime import datetime, date, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, Optional
# Planetary symbols for weekdays (Sun=0, Mon=1, ..., Sat=6) # Planetary symbols for weekdays (Sun=0, Mon=1, ..., Sat=6)