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:
from tarot import number, letter, kaballah, Tarot
# Number
num = number.number(5)
root = number.digital_root(256)
# Letter
letter_obj = letter.letter('A')
result = letter.word('MAGICK').cipher('english_simple')
# Kaballah
sephera = kaballah.Tree.sephera(1)
wall = kaballah.Cube.wall('North')
# Tarot
card = Tarot.deck.card(3)
major5 = Tarot.deck.card.major(5)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,18 +5,17 @@ including Tarot correspondences and binary representations.
Usage:
from letter.iChing import trigram, hexagram
qian = trigram.trigram('Qian')
creative = hexagram.hexagram(1)
"""
from typing import TYPE_CHECKING, Dict, Optional
from typing import TYPE_CHECKING, Dict
from utils.query import CollectionAccessor
if TYPE_CHECKING:
from tarot.card.data import CardDataLoader
from tarot.attributes import Trigram, Hexagram
from tarot.attributes import Hexagram, Trigram
def _line_diagram_from_binary(binary: str) -> str:
@@ -36,14 +35,14 @@ class _Trigram:
def __init__(self) -> None:
self._initialized: bool = False
self._trigrams: Dict[str, 'Trigram'] = {}
self._trigrams: Dict[str, "Trigram"] = {}
self.trigram = CollectionAccessor(self._get_trigrams)
def _ensure_initialized(self) -> None:
"""Load trigrams on first access."""
if self._initialized:
return
self._load_trigrams()
self._initialized = True
@@ -54,16 +53,80 @@ class _Trigram:
def _load_trigrams(self) -> None:
"""Load the eight I Ching trigrams."""
from tarot.attributes import Trigram
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": "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."},
{
"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": "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 = {}
for spec in trigram_specs:
@@ -88,14 +151,14 @@ class _Hexagram:
def __init__(self) -> None:
self._initialized: bool = False
self._hexagrams: Dict[int, 'Hexagram'] = {}
self._hexagrams: Dict[int, "Hexagram"] = {}
self.hexagram = CollectionAccessor(self._get_hexagrams)
def _ensure_initialized(self) -> None:
"""Load hexagrams on first access."""
if self._initialized:
return
self._load_hexagrams()
self._initialized = True
@@ -107,78 +170,718 @@ class _Hexagram:
"""Load all 64 I Ching hexagrams."""
from tarot.attributes import Hexagram
from tarot.card.data import CardDataLoader, calculate_digital_root
# Ensure trigrams are loaded first
trigram._ensure_initialized()
# Load planets for hexagram correspondences
loader = CardDataLoader()
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": 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"},
{"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"},
{"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"},
{"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"},
{"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"},
{
"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": 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",
},
{
"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",
},
{
"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",
},
{
"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",
},
{
"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"]
self._hexagrams = {}

View File

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

View File

@@ -13,7 +13,7 @@ class Letter:
def __init__(self) -> None:
self._initialized: bool = False
self._loader: 'CardDataLoader | None' = None
self._loader: "CardDataLoader | None" = None
self.alphabet = CollectionAccessor(self._get_alphabets)
self.cipher = CollectionAccessor(self._get_ciphers)
self.letter = CollectionAccessor(self._get_letters)
@@ -26,10 +26,11 @@ class Letter:
return
from tarot.card.data import CardDataLoader
self._loader = CardDataLoader()
self._initialized = True
def _require_loader(self) -> 'CardDataLoader':
def _require_loader(self) -> "CardDataLoader":
self._ensure_initialized()
assert self._loader is not None, "Loader not initialized"
return self._loader
@@ -54,10 +55,10 @@ class Letter:
loader = self._require_loader()
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.
Usage:
letter.word('MAGICK').cipher('english_simple')
letter.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard')

View File

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

View File

@@ -8,25 +8,26 @@ if TYPE_CHECKING:
class _Word:
"""Fluent accessor for word analysis and cipher operations."""
_loader: 'CardDataLoader | None' = None
_loader: "CardDataLoader | None" = None
_initialized: bool = False
@classmethod
def _ensure_initialized(cls) -> None:
"""Lazy-load CardDataLoader on first access."""
if cls._initialized:
return
from tarot.card.data import CardDataLoader
cls._loader = CardDataLoader()
cls._initialized = True
@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.
Usage:
word.word('MAGICK').cipher('english_simple')
word.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard')

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,54 +8,54 @@ attribute classes for cards.
from dataclasses import dataclass
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
from kaballah.attributes import (
Sephera,
PeriodicTable,
TreeOfLife,
Correspondences,
Path,
PeriodicTable,
Sephera,
TreeOfLife,
)
from letter.attributes import (
Letter,
EnglishAlphabet,
GreekAlphabet,
HebrewAlphabet,
DoublLetterTrump,
EnglishAlphabet,
EnochianArchetype,
EnochianGridPosition,
EnochianLetter,
EnochianSpirit,
EnochianTablet,
EnochianGridPosition,
EnochianArchetype,
GreekAlphabet,
HebrewAlphabet,
Letter,
)
from letter.iChing_attributes import (
Trigram,
Hexagram,
Trigram,
)
from temporal.attributes import (
AstrologicalInfluence,
ClockHour,
Degree,
Hour,
Month,
Weekday,
Hour,
ClockHour,
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)
@@ -113,8 +113,9 @@ __all__ = [
@dataclass
class Suit:
"""Represents a tarot suit."""
name: str
element: 'ElementType'
element: "ElementType"
tarot_correspondence: str
number: int
@@ -122,8 +123,8 @@ class Suit:
@dataclass
class CardImage:
"""Represents an image associated with a card."""
filename: str
artist: str
deck_name: str
url: Optional[str] = None

View File

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

View File

@@ -5,7 +5,7 @@ Provides fluent access to Tarot cards through Tarot.deck namespace.
Usage:
from tarot.card import Deck, Card
card = Deck.card(3) # Get card 3
cards = Deck.card.filter(arcana="Major") # Get all Major 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
"""
from typing import List, Optional
from utils.filter import universal_filter, format_results
from utils.object_formatting import is_nested_object, get_object_attributes, format_value
from typing import TYPE_CHECKING, List, Optional
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):
"""Custom list class for cards that formats nicely when printed."""
def __str__(self) -> str:
"""Format card list for display."""
if not self:
return "(no cards)"
return _format_cards(self)
def __repr__(self) -> str:
"""Return string representation."""
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.
Args:
cards: List of Card objects to format
Returns:
Formatted string with each card separated by blank lines
"""
from utils.object_formatting import is_nested_object, get_object_attributes, format_value
lines = []
for card in cards:
card_num = getattr(card, 'number', '?')
card_name = getattr(card, 'name', 'Unknown')
card_num = getattr(card, "number", "?")
card_name = getattr(card, "name", "Unknown")
lines.append(f"--- {card_num}: {card_name} ---")
# Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(card):
if is_nested_object(attr_value):
@@ -59,16 +61,16 @@ def _format_cards(cards: List['Card']) -> str:
lines.append(nested)
else:
lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items
return "\n".join(lines)
class CardAccessor:
"""
Fluent accessor for Tarot cards in the deck.
Usage:
Tarot.deck.card(3) # Get card 3
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.display_filter(arcana="Major") # Display Major Arcana
"""
_deck: Optional['Deck'] = None
_deck: Optional["Deck"] = None
_initialized: bool = False
def _ensure_initialized(self) -> None:
"""Lazy-load the Deck on first access."""
if not self._initialized:
from tarot.deck import Deck as DeckClass
CardAccessor._deck = DeckClass()
CardAccessor._initialized = True
def __call__(self, number: int) -> Optional['Card']:
def __call__(self, number: int) -> Optional["Card"]:
"""Get a card by number."""
self._ensure_initialized()
if self._deck is None:
@@ -97,11 +100,11 @@ class CardAccessor:
if card.number == number:
return card
return None
def filter(self, **kwargs) -> CardList:
"""
Filter cards by any Card attribute.
Uses the universal filter from utils.filter for consistency
across the entire project.
@@ -122,11 +125,11 @@ class CardAccessor:
if self._deck is None:
return CardList()
return CardList(universal_filter(self._deck.cards, **kwargs))
def display_filter(self, **kwargs) -> str:
"""
Filter cards and display results nicely formatted.
Combines filtering and formatting in one call.
Args:
@@ -140,11 +143,11 @@ class CardAccessor:
"""
results = self.filter(**kwargs)
return format_results(results)
def display(self) -> str:
"""
Format all cards in the deck for user-friendly display.
Returns a formatted string with each card separated by blank lines.
Nested objects are indented and separated with their own sections.
"""
@@ -152,13 +155,13 @@ class CardAccessor:
if self._deck is None:
return "(deck not initialized)"
return _format_cards(self._deck.cards)
def __str__(self) -> str:
"""Return the complete Tarot deck structure built from actual cards."""
self._ensure_initialized()
if self._deck is None:
return "CardAccessor (deck not initialized)"
lines = [
"Tarot Deck Structure",
"=" * 60,
@@ -166,166 +169,168 @@ class CardAccessor:
"The 78-card Tarot deck organized by structure and correspondence:",
"",
]
# Build structure from actual cards
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"]
# Major Arcana
if major_arcana:
lines.append(f"MAJOR ARCANA ({len(major_arcana)} cards):")
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)
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]
lines.append(f" Double Letter Trumps ({len(double_letter_trumps)} cards): Cards 3-21")
lines.append("")
# Minor Arcana
if minor_arcana:
lines.append(f"MINOR ARCANA ({len(minor_arcana)} cards - 4 suits × 14 ranks):")
lines.append("")
# 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:
lines.append(f" ACES ({len(aces)} cards - The Root Powers):")
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("")
# 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:
lines.append(f" PIPS ({len(pips)} cards - 2-10 of each suit):")
# Group by suit
suits_dict = {}
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:
suits_dict[suit_name] = []
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:
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("")
# 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:
lines.append(f" COURT CARDS ({len(courts)} cards - 4 ranks × 4 suits):")
# Get unique ranks and their order
rank_order = {"Knight": 0, "Prince": 1, "Princess": 2, "Queen": 3}
lines.append(" Rank order per suit: Knight, Prince, Princess, Queen")
lines.append("")
# Group by suit
suits_dict = {}
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:
suits_dict[suit_name] = []
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:
suit_courts = sorted(suits_dict[suit_name],
key=lambda c: rank_order.get(c.court_rank, 99))
suit_courts = sorted(
suits_dict[suit_name], key=lambda c: rank_order.get(c.court_rank, 99)
)
court_names = [c.court_rank for c in suit_courts]
lines.append(f" {suit_name}: {', '.join(court_names)}")
lines.append("")
# Element correspondences
lines.append("SUIT CORRESPONDENCES:")
suits_info = {}
for card in minor_arcana:
if hasattr(card, 'suit') and card.suit:
suit_name = card.suit.name if hasattr(card.suit, 'name') else str(card.suit)
if hasattr(card, "suit") and card.suit:
suit_name = card.suit.name if hasattr(card.suit, "name") else str(card.suit)
if suit_name not in suits_info:
# Extract element info
element_name = "Unknown"
if hasattr(card.suit, 'element') and card.suit.element:
if hasattr(card.suit.element, 'name'):
if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, "name"):
element_name = card.suit.element.name
else:
element_name = str(card.suit.element)
# Extract zodiac signs
zodiac_signs = []
if hasattr(card.suit, 'element') and card.suit.element:
if hasattr(card.suit.element, 'zodiac_signs'):
if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, "zodiac_signs"):
zodiac_signs = card.suit.element.zodiac_signs
# Extract keywords
keywords = []
if hasattr(card.suit, 'element') and card.suit.element:
if hasattr(card.suit.element, 'keywords'):
if hasattr(card.suit, "element") and card.suit.element:
if hasattr(card.suit.element, "keywords"):
keywords = card.suit.element.keywords
suits_info[suit_name] = {
'element': element_name,
'zodiac': zodiac_signs,
'keywords': keywords
"element": element_name,
"zodiac": zodiac_signs,
"keywords": keywords,
}
for suit_name in ['Cups', 'Pentacles', 'Swords', 'Wands']:
for suit_name in ["Cups", "Pentacles", "Swords", "Wands"]:
if suit_name in suits_info:
info = suits_info[suit_name]
lines.append(f" {suit_name} ({info['element']}):")
if info['zodiac']:
if info["zodiac"]:
lines.append(f" Zodiac: {', '.join(info['zodiac'])}")
if info['keywords']:
if info["keywords"]:
lines.append(f" Keywords: {', '.join(info['keywords'])}")
lines.append("")
lines.append(f"Total: {len(self._deck.cards)} cards")
return "\n".join(lines)
def __repr__(self) -> str:
"""Return a nice representation of the deck accessor."""
return self.__str__()
def spread(self, spread_name: str):
"""
Draw a Tarot card reading for a spread.
Automatically draws random cards for each position in the spread,
with random reversals. Returns formatted reading with card details.
Args:
spread_name: Name of the spread (case-insensitive, underscores or spaces)
Examples: 'Celtic Cross', 'golden dawn', 'three_card', 'tree of life'
Returns:
SpreadReading object containing the spread and drawn cards
Raises:
ValueError: If spread name not found
Examples:
print(Tarot.deck.card.spread('Celtic Cross'))
print(Tarot.deck.card.spread('golden dawn'))
print(Tarot.deck.card.spread('three card'))
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
self._ensure_initialized()
# Create spread object
spread = Spread(spread_name)
# Draw cards for the spread
drawn_cards = draw_spread(spread, self._deck.cards if self._deck else None)
# Create and return reading
reading = SpreadReading(spread, drawn_cards)
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:
from tarot.card.image_loader import load_deck_images
deck = Deck()
count = load_deck_images(deck, "/path/to/deck/folder")
print(f"Loaded {count} card images")
"""
import os
import re
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:
from tarot.deck import Card, Deck
@@ -25,58 +24,62 @@ if TYPE_CHECKING:
class ImageDeckLoader:
"""Loader for matching Tarot card images to deck cards."""
# Supported image extensions
SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
# 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:
"""
Initialize the image deck loader.
Args:
deck_folder: Path to the folder containing card images
Raises:
ValueError: If folder doesn't exist or is not a directory
"""
self.deck_folder = Path(deck_folder)
if not self.deck_folder.exists():
raise ValueError(f"Deck folder does not exist: {deck_folder}")
if not self.deck_folder.is_dir():
raise ValueError(f"Deck path is not a directory: {deck_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()
def _scan_folder(self) -> List[Path]:
"""Scan folder for image files."""
images = []
for ext in self.SUPPORTED_EXTENSIONS:
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}"))
images.extend(self.deck_folder.glob(f"*{ext.upper()}"))
# Sort by filename for consistent ordering
return sorted(images)
def _parse_filename(self, filename: str) -> Tuple[Optional[int], Optional[str], bool]:
"""
Parse image filename to extract card number and optional custom name.
Args:
filename: The filename (without path)
Returns:
Tuple of (card_number, custom_name, has_custom_name)
- card_number: Parsed number if found, else None
- custom_name: Custom name if present (e.g., "foolish" from "00_foolish.jpg")
- has_custom_name: True if custom name was found
Examples:
"0.jpg" -> (0, None, False)
"00_foolish.jpg" -> (0, "foolish", True)
@@ -84,37 +87,37 @@ class ImageDeckLoader:
"invalid.jpg" -> (None, None, False)
"""
match = self.NUMBERED_PATTERN.match(filename)
if not match:
return None, None, False
card_number = int(match.group(1))
custom_name = match.group(2)
has_custom_name = custom_name is not None
return card_number, custom_name, has_custom_name
def _build_mapping(self) -> None:
"""Build mapping from card numbers to image file paths."""
for image_path in self.image_files:
card_num, custom_name, has_custom_name = self._parse_filename(image_path.name)
if card_num is not None:
# Store path and whether it has a custom name
self.card_mapping[card_num] = (str(image_path), has_custom_name)
def _normalize_card_name(self, name: str) -> str:
"""
Normalize card name for matching.
Converts to lowercase, removes special characters, collapses whitespace.
Args:
name: Original card name
Returns:
Normalized name
Examples:
"The Fool" -> "the fool"
"Princess of Swords" -> "princess of swords"
@@ -122,69 +125,69 @@ class ImageDeckLoader:
"""
# Convert to lowercase
normalized = name.lower()
# Replace special characters with spaces
normalized = re.sub(r'[^\w\s]', ' ', normalized)
normalized = re.sub(r"[^\w\s]", " ", normalized)
# Collapse multiple spaces
normalized = re.sub(r'\s+', ' ', normalized).strip()
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def _find_fuzzy_match(self, card_name_normalized: str) -> Optional[int]:
"""
Find matching card number using fuzzy name matching.
This is a fallback when card names don't parse as numbers.
Args:
card_name_normalized: Normalized card name
Returns:
Card number if a match is found, else None
"""
best_match = None
best_score = 0
threshold = 0.6
# Check all parsed custom names
for card_num, (_, has_custom_name) in self.card_mapping.items():
if not has_custom_name:
continue
# Get the actual filename to extract custom name
for image_path in self.image_files:
parsed_num, custom_name, _ = self._parse_filename(image_path.name)
if parsed_num == card_num and custom_name:
normalized_custom = self._normalize_card_name(custom_name)
# Simple similarity score: words that match
query_words = set(card_name_normalized.split())
custom_words = set(normalized_custom.split())
if query_words and custom_words:
intersection = len(query_words & custom_words)
union = len(query_words | custom_words)
score = intersection / union if union > 0 else 0
if score > best_score and score >= threshold:
best_score = score
best_match = card_num
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.
Matches cards by:
1. Card number (primary method)
2. Fuzzy matching on card name (fallback)
Args:
card: The Card object to find an image for
Returns:
Full path to image file, or None if not found
"""
@@ -192,80 +195,80 @@ class ImageDeckLoader:
if card.number in self.card_mapping:
path, _ = self.card_mapping[card.number]
return path
# Try fuzzy match on name as fallback
normalized_name = self._normalize_card_name(card.name)
fuzzy_match = self._find_fuzzy_match(normalized_name)
if fuzzy_match is not None and fuzzy_match in self.card_mapping:
path, _ = self.card_mapping[fuzzy_match]
return path
return None
def should_override_name(self, card_number: int) -> bool:
"""
Check if card name should be overridden from filename.
Returns True only if:
- Image file has a custom name component (##_name.jpg format)
- Not just a plain number (##.jpg format)
Args:
card_number: The card's number
Returns:
True if name should be overridden from filename, False otherwise
"""
if card_number not in self.card_mapping:
return False
_, has_custom_name = self.card_mapping[card_number]
return has_custom_name
def get_custom_name(self, card_number: int) -> Optional[str]:
"""
Get the custom card name from the filename.
Args:
card_number: The card's number
Returns:
Custom name if present, None otherwise
Example:
If filename is "00_the_foolish.jpg", returns "the_foolish"
If filename is "00.jpg", returns None
"""
if card_number not in self.card_mapping:
return None
# Find the image file for this card number
for image_path in self.image_files:
_, custom_name, _ = self._parse_filename(image_path.name)
parsed_num, _, _ = self._parse_filename(image_path.name)
if parsed_num == card_number and custom_name:
# Convert underscore-separated name to title case
name_words = custom_name.split('_')
return ' '.join(word.capitalize() for word in name_words)
name_words = custom_name.split("_")
return " ".join(word.capitalize() for word in name_words)
return None
def load_into_deck(self, deck: 'Deck',
override_names: bool = True,
verbose: bool = False) -> int:
def load_into_deck(
self, deck: "Deck", override_names: bool = True, verbose: bool = False
) -> int:
"""
Load image paths into all cards in a deck.
Args:
deck: The Deck to load images into
override_names: If True, use custom names from filenames when available
verbose: If True, print progress information
Returns:
Number of cards that had images loaded
Example:
>>> loader = ImageDeckLoader("/path/to/deck")
>>> deck = Deck()
@@ -273,14 +276,14 @@ class ImageDeckLoader:
>>> print(f"Loaded {count} card images")
"""
loaded_count = 0
for card in deck.cards:
image_path = self.get_image_path(card)
if image_path:
card.image_path = image_path
loaded_count += 1
# Override name if appropriate
if override_names and self.should_override_name(card.number):
custom_name = self.get_custom_name(card.number)
@@ -290,54 +293,53 @@ class ImageDeckLoader:
card.name = custom_name
elif verbose:
print(f"{card.number}: {card.name}")
return loaded_count
def get_summary(self) -> Dict[str, any]:
"""
Get a summary of loaded images and statistics.
Returns:
Dictionary with loader statistics
"""
total_images = len(self.image_files)
mapped_cards = len(self.card_mapping)
custom_named = sum(1 for _, has_custom in self.card_mapping.values() if has_custom)
return {
'deck_folder': str(self.deck_folder),
'total_image_files': total_images,
'total_image_filenames': len(set(f.name for f in self.image_files)),
'mapped_card_numbers': mapped_cards,
'cards_with_custom_names': custom_named,
'cards_with_generic_numbers': mapped_cards - custom_named,
'image_extensions_found': list(set(f.suffix.lower() for f in self.image_files)),
"deck_folder": str(self.deck_folder),
"total_image_files": total_images,
"total_image_filenames": len(set(f.name for f in self.image_files)),
"mapped_card_numbers": mapped_cards,
"cards_with_custom_names": custom_named,
"cards_with_generic_numbers": mapped_cards - custom_named,
"image_extensions_found": list(set(f.suffix.lower() for f in self.image_files)),
}
def load_deck_images(deck: 'Deck',
deck_folder: str,
override_names: bool = True,
verbose: bool = False) -> int:
def load_deck_images(
deck: "Deck", deck_folder: str, override_names: bool = True, verbose: bool = False
) -> int:
"""
Convenience function to load deck images.
Args:
deck: The Deck object to load images into
deck_folder: Path to folder containing card images
override_names: If True, use custom names from filenames when available
verbose: If True, print progress information
Returns:
Number of cards that had images loaded
Raises:
ValueError: If deck_folder doesn't exist or is invalid
Example:
>>> from tarot import Deck
>>> from tarot.card.image_loader import load_deck_images
>>>
>>>
>>> deck = Deck()
>>> count = load_deck_images(deck, "/path/to/deck/images")
>>> print(f"Loaded {count} card images")

View File

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

View File

@@ -6,21 +6,21 @@ with position meanings and automatic card drawing.
Usage:
from tarot import Tarot
# Draw cards for a spread
reading = Tarot.deck.card.spread('Celtic Cross')
print(reading)
# Can also access spread with/without cards
from tarot.card.spread import Spread, draw_spread
spread = Spread('Celtic Cross')
reading = draw_spread(spread) # Returns list of (position, card) tuples
"""
from typing import Dict, List, Optional, TYPE_CHECKING
from dataclasses import dataclass
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional
if TYPE_CHECKING:
from tarot.card import Card
@@ -29,11 +29,12 @@ if TYPE_CHECKING:
@dataclass
class SpreadPosition:
"""Represents a position in a Tarot spread."""
number: int
name: str
meaning: str
reversed_meaning: Optional[str] = None
def __str__(self) -> str:
result = f"{self.number}. {self.name}: {self.meaning}"
if self.reversed_meaning:
@@ -44,192 +45,194 @@ class SpreadPosition:
@dataclass
class DrawnCard:
"""Represents a card drawn for a spread position."""
position: SpreadPosition
card: 'Card'
card: "Card"
is_reversed: bool
def __str__(self) -> str:
"""Format the drawn card with position and interpretation."""
card_name = self.card.name
if self.is_reversed:
card_name += " (Reversed)"
return f"{self.position.number}. {self.position.name}\n" \
f" └─ {card_name}\n" \
f" └─ Position: {self.position.meaning}"
return (
f"{self.position.number}. {self.position.name}\n"
f" └─ {card_name}\n"
f" └─ Position: {self.position.meaning}"
)
class Spread:
"""Represents a Tarot spread with positions and meanings."""
# Define all available spreads
SPREADS: Dict[str, Dict] = {
'three card': {
'name': '3-Card Spread',
'description': 'Simple 3-card spread for past, present, future or situation, action, outcome',
'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'),
]
"three card": {
"name": "3-Card Spread",
"description": (
"Simple 3-card spread for past, present, future "
"or situation, action, outcome"
),
"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': {
'name': 'Golden Dawn 3-Card',
'description': 'Three card spread used in Golden Dawn tradition',
'positions': [
SpreadPosition(1, 'Supernal Triangle', 'Spiritual/Divine aspect'),
SpreadPosition(2, 'Pillar of Severity', 'Challenging/Active force'),
SpreadPosition(3, 'Pillar of Mercy', 'Supportive/Passive force'),
]
"golden dawn": {
"name": "Golden Dawn 3-Card",
"description": "Three card spread used in Golden Dawn tradition",
"positions": [
SpreadPosition(1, "Supernal Triangle", "Spiritual/Divine aspect"),
SpreadPosition(2, "Pillar of Severity", "Challenging/Active force"),
SpreadPosition(3, "Pillar of Mercy", "Supportive/Passive force"),
],
},
'celtic cross': {
'name': 'Celtic Cross',
'description': 'Classic 10-card spread for in-depth reading',
'positions': [
SpreadPosition(1, 'The Significator', 'The main situation or person'),
SpreadPosition(2, 'The Cross', 'The challenge or heart of the matter'),
SpreadPosition(3, 'Crowning Influence', 'Conscious hopes/ideals'),
SpreadPosition(4, 'Beneath the Cross', 'Unconscious or hidden aspects'),
SpreadPosition(5, 'Behind', 'Past influences'),
SpreadPosition(6, 'Before', 'Future influences'),
SpreadPosition(7, 'Self/Attitude', 'How the querent sees themselves'),
SpreadPosition(8, 'Others/Environment', 'External factors/opinions'),
SpreadPosition(9, 'Hopes and Fears', 'What the querent hopes for or fears'),
SpreadPosition(10, 'Outcome', 'Final outcome or resolution'),
]
"celtic cross": {
"name": "Celtic Cross",
"description": "Classic 10-card spread for in-depth reading",
"positions": [
SpreadPosition(1, "The Significator", "The main situation or person"),
SpreadPosition(2, "The Cross", "The challenge or heart of the matter"),
SpreadPosition(3, "Crowning Influence", "Conscious hopes/ideals"),
SpreadPosition(4, "Beneath the Cross", "Unconscious or hidden aspects"),
SpreadPosition(5, "Behind", "Past influences"),
SpreadPosition(6, "Before", "Future influences"),
SpreadPosition(7, "Self/Attitude", "How the querent sees themselves"),
SpreadPosition(8, "Others/Environment", "External factors/opinions"),
SpreadPosition(9, "Hopes and Fears", "What the querent hopes for or fears"),
SpreadPosition(10, "Outcome", "Final outcome or resolution"),
],
},
'horseshoe': {
'name': 'Horseshoe',
'description': '7-card spread in horseshoe formation for past, present, future insight',
'positions': [
SpreadPosition(1, 'Distant Past', 'Ancient influences and foundations'),
SpreadPosition(2, 'Recent Past', 'Recent events and circumstances'),
SpreadPosition(3, 'Present Situation', 'Current state of affairs'),
SpreadPosition(4, 'Immediate Future', 'Near-term developments'),
SpreadPosition(5, 'Distant Future', 'Long-term outcome'),
SpreadPosition(6, 'Inner Influence', 'Self/thoughts/emotions'),
SpreadPosition(7, 'Outer Influence', 'External forces and environment'),
]
"horseshoe": {
"name": "Horseshoe",
"description": "7-card spread in horseshoe formation for past, present, future insight",
"positions": [
SpreadPosition(1, "Distant Past", "Ancient influences and foundations"),
SpreadPosition(2, "Recent Past", "Recent events and circumstances"),
SpreadPosition(3, "Present Situation", "Current state of affairs"),
SpreadPosition(4, "Immediate Future", "Near-term developments"),
SpreadPosition(5, "Distant Future", "Long-term outcome"),
SpreadPosition(6, "Inner Influence", "Self/thoughts/emotions"),
SpreadPosition(7, "Outer Influence", "External forces and environment"),
],
},
'pentagram': {
'name': 'Pentagram',
'description': '5-card spread based on Earth element pentagram',
'positions': [
SpreadPosition(1, 'Spirit', 'Core essence or spiritual truth'),
SpreadPosition(2, 'Fire', 'Action and willpower'),
SpreadPosition(3, 'Water', 'Emotions and intuition'),
SpreadPosition(4, 'Air', 'Intellect and communication'),
SpreadPosition(5, 'Earth', 'Physical manifestation and grounding'),
]
"pentagram": {
"name": "Pentagram",
"description": "5-card spread based on Earth element pentagram",
"positions": [
SpreadPosition(1, "Spirit", "Core essence or spiritual truth"),
SpreadPosition(2, "Fire", "Action and willpower"),
SpreadPosition(3, "Water", "Emotions and intuition"),
SpreadPosition(4, "Air", "Intellect and communication"),
SpreadPosition(5, "Earth", "Physical manifestation and grounding"),
],
},
'tree of life': {
'name': 'Tree of Life',
'description': '10-card spread mapping Sephiroth on the Tree of Life',
'positions': [
SpreadPosition(1, 'Kether (Crown)', 'Divine will and unity'),
SpreadPosition(2, 'Chokmah (Wisdom)', 'Creative force and impulse'),
SpreadPosition(3, 'Binah (Understanding)', 'Form and structure'),
SpreadPosition(4, 'Chesed (Mercy)', 'Expansion and abundance'),
SpreadPosition(5, 'Gevurah (Severity)', 'Reduction and discipline'),
SpreadPosition(6, 'Tiphareth (Beauty)', 'Core self and integration'),
SpreadPosition(7, 'Netzach (Victory)', 'Desire and passion'),
SpreadPosition(8, 'Hod (Splendor)', 'Intellect and communication'),
SpreadPosition(9, 'Yesod (Foundation)', 'Subconscious and dreams'),
SpreadPosition(10, 'Malkuth (Kingdom)', 'Manifestation and physical reality'),
]
"tree of life": {
"name": "Tree of Life",
"description": "10-card spread mapping Sephiroth on the Tree of Life",
"positions": [
SpreadPosition(1, "Kether (Crown)", "Divine will and unity"),
SpreadPosition(2, "Chokmah (Wisdom)", "Creative force and impulse"),
SpreadPosition(3, "Binah (Understanding)", "Form and structure"),
SpreadPosition(4, "Chesed (Mercy)", "Expansion and abundance"),
SpreadPosition(5, "Gevurah (Severity)", "Reduction and discipline"),
SpreadPosition(6, "Tiphareth (Beauty)", "Core self and integration"),
SpreadPosition(7, "Netzach (Victory)", "Desire and passion"),
SpreadPosition(8, "Hod (Splendor)", "Intellect and communication"),
SpreadPosition(9, "Yesod (Foundation)", "Subconscious and dreams"),
SpreadPosition(10, "Malkuth (Kingdom)", "Manifestation and physical reality"),
],
},
'relationship': {
'name': 'Relationship',
'description': '5-card spread for relationship insight',
'positions': [
SpreadPosition(1, 'You', 'Your position, feelings, or role'),
SpreadPosition(2, 'Them', 'Their position, feelings, or perspective'),
SpreadPosition(3, 'The Relationship', 'The dynamic and connection'),
SpreadPosition(4, 'Challenge', 'Current challenge or friction point'),
SpreadPosition(5, 'Outcome', 'Where the relationship is heading'),
]
"relationship": {
"name": "Relationship",
"description": "5-card spread for relationship insight",
"positions": [
SpreadPosition(1, "You", "Your position, feelings, or role"),
SpreadPosition(2, "Them", "Their position, feelings, or perspective"),
SpreadPosition(3, "The Relationship", "The dynamic and connection"),
SpreadPosition(4, "Challenge", "Current challenge or friction point"),
SpreadPosition(5, "Outcome", "Where the relationship is heading"),
],
},
'yes or no': {
'name': 'Yes or No',
'description': '1-card spread for simple yes/no answers',
'positions': [
SpreadPosition(1, 'Answer', 'Major Arcana = Yes, Minor Arcana = No, Court Cards = Maybe'),
]
"yes or no": {
"name": "Yes or No",
"description": "1-card spread for simple yes/no answers",
"positions": [
SpreadPosition(
1, "Answer", "Major Arcana = Yes, Minor Arcana = No, Court Cards = Maybe"
),
],
},
}
def __init__(self, spread_name: str) -> None:
"""
Initialize a spread by name (case-insensitive).
Args:
spread_name: Name of the spread to use
Raises:
ValueError: If spread name not found
"""
# Normalize name (case-insensitive, allow underscores or spaces)
normalized_name = spread_name.lower().replace('_', ' ')
normalized_name = spread_name.lower().replace("_", " ")
# Find matching spread
spread_data = None
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
break
if not spread_data:
available = ', '.join(f"'{k}'" for k in self.SPREADS.keys())
raise ValueError(
f"Spread '{spread_name}' not found. Available spreads: {available}"
)
self.name = spread_data['name']
self.description = spread_data['description']
self.positions: List[SpreadPosition] = spread_data['positions']
available = ", ".join(f"'{k}'" for k in self.SPREADS.keys())
raise ValueError(f"Spread '{spread_name}' not found. Available spreads: {available}")
self.name = spread_data["name"]
self.description = spread_data["description"]
self.positions: List[SpreadPosition] = spread_data["positions"]
def __str__(self) -> str:
"""Return formatted spread information."""
lines = [
f"═══════════════════════════════════════════",
"═══════════════════════════════════════════",
f" {self.name}",
f"═══════════════════════════════════════════",
f"",
"═══════════════════════════════════════════",
"",
f"{self.description}",
f"",
"",
f"Positions ({len(self.positions)} cards):",
f"",
"",
]
for pos in self.positions:
lines.append(f" {pos}")
lines.append(f"")
lines.append(f"═══════════════════════════════════════════")
lines.append("")
lines.append("═══════════════════════════════════════════")
return "\n".join(lines)
def __repr__(self) -> str:
return f"Spread('{self.name}')"
@classmethod
def available_spreads(cls) -> str:
"""Return list of all available spreads."""
lines = [
"Available Tarot Spreads:",
"" * 50,
""
]
lines = ["Available Tarot Spreads:", "" * 50, ""]
for key, data in cls.SPREADS.items():
lines.append(f"{data['name']}")
lines.append(f" Name for API: '{key}'")
lines.append(f" Positions: {len(data['positions'])}")
lines.append(f" {data['description']}")
lines.append("")
return "\n".join(lines)
def get_position(self, position_number: int) -> Optional[SpreadPosition]:
"""Get a specific position by number."""
for pos in self.positions:
@@ -241,96 +244,94 @@ class Spread:
def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]:
"""
Draw cards for all positions in a spread.
Ensures all drawn cards are unique (no duplicates in a single spread).
Args:
spread: The Spread object with positions defined
deck: Optional list of Card objects. If None, uses Tarot.deck.cards
Returns:
List of DrawnCard objects (one per position) with random cards and reversals
Raises:
ValueError: If spread has more positions than cards in the deck
"""
import random
# Load deck if not provided
if deck is None:
from tarot.deck import Deck
deck_instance = Deck()
deck = deck_instance.cards
# Validate that we have enough cards to draw from without duplicates
num_positions = len(spread.positions)
if num_positions > len(deck):
raise ValueError(
f"Cannot draw {num_positions} unique cards from deck of {len(deck)} cards"
)
raise ValueError(f"Cannot draw {num_positions} unique cards from deck of {len(deck)} cards")
# Draw unique cards using random.sample (no replacements)
drawn_deck = random.sample(deck, num_positions)
drawn_cards = []
for position, card in zip(spread.positions, drawn_deck):
# Random reversal (50% chance)
is_reversed = random.choice([True, False])
drawn_cards.append(DrawnCard(position, card, is_reversed))
return drawn_cards
class SpreadReading:
"""Represents a complete tarot reading with cards drawn for a spread."""
def __init__(self, spread: Spread, drawn_cards: List[DrawnCard]) -> None:
"""
Initialize a reading with a spread and drawn cards.
Args:
spread: The Spread object
drawn_cards: List of DrawnCard objects
"""
self.spread = spread
self.drawn_cards = drawn_cards
def __str__(self) -> str:
"""Return formatted reading with all cards and interpretations."""
lines = [
f"╔═══════════════════════════════════════════╗",
"╔═══════════════════════════════════════════╗",
f"{self.spread.name:40}",
f"╚═══════════════════════════════════════════╝",
f"",
"╚═══════════════════════════════════════════╝",
"",
f"{self.spread.description}",
f"",
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f"",
"",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"",
]
for drawn in self.drawn_cards:
card = drawn.card
card_name = card.name
if drawn.is_reversed:
card_name += " ◄ REVERSED"
lines.append(f"Position {drawn.position.number}: {drawn.position.name}")
lines.append(f" Card: {card_name}")
lines.append(f" Meaning: {drawn.position.meaning}")
# Add card details if available
if hasattr(card, 'number'):
if hasattr(card, "number"):
lines.append(f" Card #: {card.number}")
if hasattr(card, 'arcana'):
if hasattr(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("")
lines.append(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
return "\n".join(lines)
def __repr__(self) -> str:
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.
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.
"""
from .deck import (
DLT,
AceCard,
Card,
CardQuery,
CourtCard,
Deck,
MajorCard,
MinorCard,
PipCard,
AceCard,
CourtCard,
CardQuery,
TemporalQuery,
DLT,
Deck,
)
__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.
"""
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, TYPE_CHECKING, Dict
import random
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from ..attributes import (
Meaning, CardImage, Suit, Zodiac, Element, Path,
Planet, Sephera, Color, PeriodicTable, ElementType, DoublLetterTrump
CardImage,
Color,
Element,
ElementType,
Meaning,
Path,
PeriodicTable,
Planet,
Sephera,
Suit,
)
from ..constants import (
COURT_RANKS,
MAJOR_ARCANA_NAMES,
PIP_INDEX_TO_NUMBER,
MINOR_RANK_NAMES,
PIP_INDEX_TO_NUMBER,
PIP_ORDER,
SUITS_FIRST,
SUITS_LAST,
@@ -35,6 +43,7 @@ def _get_card_data():
global _card_data
if _card_data is None:
from ..card.data import CardDataLoader
_card_data = CardDataLoader()
return _card_data
@@ -42,16 +51,17 @@ def _get_card_data():
@dataclass
class Card:
"""Base class representing a Tarot card."""
number: int
name: str
meaning: Meaning
arcana: str # "Major" or "Minor"
image: Optional[CardImage] = None
# These are overridden in subclasses but declared here for MinorCard compatibility
suit: Optional[Suit] = None
pip: int = 0
# Card-specific details
explanation: Dict[str, str] = field(default_factory=dict)
interpretation: str = ""
@@ -59,41 +69,48 @@ class Card:
reversed_keywords: List[str] = field(default_factory=list)
guidance: str = ""
numerology: Optional[int] = None
# Image path for custom deck images
image_path: Optional[str] = None
def __str__(self) -> str:
return f"{self.number}. {self.name}"
def __repr__(self) -> str:
return f"Card({self.number}, '{self.name}')"
def key(self) -> str:
"""
Get the card's key as a Roman numeral representation.
Returns:
Roman numeral string (e.g., "I", "XXI") for Major Arcana,
or the pip number as string for Minor Arcana.
"""
# Import here to avoid circular imports
from ..card.details import CardDetailsRegistry
# For Major Arcana cards, convert the key to Roman numerals
if self.arcana == "Major":
return CardDetailsRegistry.key_to_roman(self.number)
# 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 = {
2: "Two", 3: "Three", 4: "Four", 5: "Five",
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten"
2: "Two",
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 str(self.number)
@property
def type(self) -> str:
"""Get the specific card type (Major, Pip, Ace, Court)."""
@@ -111,24 +128,30 @@ class Card:
@dataclass
class MajorCard(Card):
"""Represents a Major Arcana card."""
kabbalistic_number: Optional[int] = None
tarot_letter: Optional[str] = None
tree_of_life_path: Optional[int] = None
def __post_init__(self) -> None:
# 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):
raise ValueError(f"Major Arcana kabbalistic number must be 0-21, got {self.kabbalistic_number}")
if self.kabbalistic_number is not None and (
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"
@dataclass
class MinorCard(Card):
"""Represents a Minor Arcana card - either Pip or Court card."""
suit: Suit = None # type: ignore
astrological_influence: Optional[str] = None
element: Optional[Element] = None
def __post_init__(self) -> None:
if self.suit is None:
raise ValueError("suit must be provided for MinorCard")
@@ -138,12 +161,13 @@ class MinorCard(Card):
@dataclass
class PipCard(MinorCard):
"""Represents a Pip card (2 through 10) - has a pip number.
Pip cards represent numbered forces in their suit, from Two
through its full development (10).
"""
pip: int = 0
def __post_init__(self) -> None:
if not (2 <= self.pip <= 10):
raise ValueError(f"Pip card number must be 2-10, got {self.pip}")
@@ -153,13 +177,14 @@ class PipCard(MinorCard):
@dataclass
class AceCard(MinorCard):
"""Represents an Ace card - the root/foundation of the suit.
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
technically pip cards.
"""
pip: int = 1
def __post_init__(self) -> None:
if self.pip != 1:
raise ValueError(f"AceCard must have pip 1, got {self.pip}")
@@ -169,22 +194,22 @@ class AceCard(MinorCard):
@dataclass
class CourtCard(MinorCard):
"""Represents a Court Card - Knight, Prince, Princess, or Queen.
Court cards represent people/personalities and are the highest rank
in the minor arcana. They do NOT have pips - they are archetypes.
Each court card is associated with an element and Hebrew letter (Path):
- Knight: Fire + Yod (path 20)
- Prince: Air + Vav (path 16)
- Princess: Earth + Heh (path 15)
- Queen: Water + Heh (path 15)
"""
COURT_RANKS = {"Knight": 12, "Prince": 11, "Princess": 13, "Queen": 14}
court_rank: str = ""
associated_element: Optional[ElementType] = None
hebrew_letter_path: Optional['Path'] = None
hebrew_letter_path: Optional["Path"] = None
def __post_init__(self) -> None:
if self.court_rank not in self.COURT_RANKS:
raise ValueError(
@@ -194,75 +219,90 @@ class CourtCard(MinorCard):
super().__post_init__()
class CardQuery:
"""Helper class for fluent card queries: deck.number(3).minor.wands"""
def __init__(self, deck: 'Deck', number: Optional[int] = None,
arcana: Optional[str] = None) -> None:
def __init__(
self, deck: "Deck", number: Optional[int] = None, arcana: Optional[str] = None
) -> None:
self.deck = deck
self.number = number
self.arcana = arcana
def _filter_cards(self) -> List[Card]:
"""Get filtered cards based on current query state."""
cards = self.deck.cards
if self.number is not None:
cards = [c for c in cards if c.number == self.number or
(hasattr(c, 'pip') and c.pip == self.number)]
cards = [
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:
cards = [c for c in cards if c.arcana == self.arcana]
return cards
@property
def major(self) -> List[Card]:
"""Filter to Major Arcana only."""
return [c for c in self._filter_cards() if c.arcana == "Major"]
@property
def minor(self) -> 'CardQuery':
def minor(self) -> "CardQuery":
"""Filter to Minor Arcana, return new CardQuery for suit chaining."""
return CardQuery(self.deck, self.number, "Minor")
@property
def cups(self) -> List[Card]:
"""Get cards in Cups suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and
c.suit and c.suit.name == "Cups"]
return [
c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Cups"
]
@property
def swords(self) -> List[Card]:
"""Get cards in Swords suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and
c.suit and c.suit.name == "Swords"]
return [
c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Swords"
]
@property
def wands(self) -> List[Card]:
"""Get cards in Wands suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and
c.suit and c.suit.name == "Wands"]
return [
c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Wands"
]
@property
def pentacles(self) -> List[Card]:
"""Get cards in Pentacles suit."""
return [c for c in self._filter_cards() if hasattr(c, 'suit') and
c.suit and c.suit.name == "Pentacles"]
return [
c
for c in self._filter_cards()
if hasattr(c, "suit") and c.suit and c.suit.name == "Pentacles"
]
def __iter__(self):
"""Allow iteration over filtered cards."""
return iter(self._filter_cards())
def __len__(self) -> int:
"""Return count of filtered cards."""
return len(self._filter_cards())
def __getitem__(self, index: int) -> Card:
"""Get card by index from filtered results."""
return self._filter_cards()[index]
def __repr__(self) -> str:
cards = self._filter_cards()
names = [c.name for c in cards]
@@ -271,12 +311,17 @@ class CardQuery:
class TemporalQuery:
"""Helper class for fluent temporal queries: loader.month(5).day(23).hour(15)"""
def __init__(self, loader: 'CardDataLoader', month_num: Optional[int] = None,
day_num: Optional[int] = None, hour_num: Optional[int] = None) -> None:
def __init__(
self,
loader: "CardDataLoader",
month_num: Optional[int] = None,
day_num: Optional[int] = None,
hour_num: Optional[int] = None,
) -> None:
"""
Initialize temporal query builder.
Args:
loader: CardDataLoader instance for fetching temporal data
month_num: Month number (1-12)
@@ -287,71 +332,74 @@ class TemporalQuery:
self.month_num = month_num
self.day_num = day_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."""
return TemporalQuery(self.loader, month_num=num,
day_num=self.day_num, hour_num=self.hour_num)
def day(self, num: int) -> 'TemporalQuery':
return TemporalQuery(
self.loader, month_num=num, day_num=self.day_num, hour_num=self.hour_num
)
def day(self, num: int) -> "TemporalQuery":
"""Set day (1-31) and return new query for chaining."""
if self.month_num is None:
raise ValueError("Must set month before day")
return TemporalQuery(self.loader, month_num=self.month_num,
day_num=num, hour_num=self.hour_num)
def hour(self, num: int) -> 'TemporalQuery':
return TemporalQuery(
self.loader, month_num=self.month_num, day_num=num, hour_num=self.hour_num
)
def hour(self, num: int) -> "TemporalQuery":
"""Set hour (0-23) and return new query for chaining."""
if self.month_num is None or self.day_num is None:
raise ValueError("Must set month and day before hour")
return TemporalQuery(self.loader, month_num=self.month_num,
day_num=self.day_num, hour_num=num)
return TemporalQuery(
self.loader, month_num=self.month_num, day_num=self.day_num, hour_num=num
)
def weekday(self) -> Optional[str]:
"""Get weekday name for current month/day combination using Zeller's congruence."""
if self.month_num is None or self.day_num is None:
raise ValueError("Must set month and day to get weekday")
# Zeller's congruence (adjusted for current calendar)
month = self.month_num
day = self.day_num
year = 2024 # Use current year as reference
# Adjust month and year for March-based calculation
if month < 3:
month += 12
year -= 1
# Zeller's formula
q = day
m = month
k = year % 100
j = year // 100
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)
day_names = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
return day_names[h]
def month_info(self):
"""Return month metadata for the configured query."""
if self.month_num is None:
return None
return self.loader.month_info(self.month_num)
def day_info(self):
"""Return day metadata for the configured query."""
if self.day_num is None:
return None
return self.loader.day_info(self.day_num)
def hour_info(self):
"""Return the planetary hour metadata for the configured query."""
if self.hour_num is None:
return None
return self.loader.clock_hour(self.hour_num)
def __repr__(self) -> str:
parts = []
if self.month_num:
@@ -366,47 +414,48 @@ class TemporalQuery:
class DLT:
"""
Double Letter Trump (DLT) accessor.
Double Letter Trumps are Major Arcana cards 3-21 (19 cards total),
each associated with a Hebrew letter and planetary/astrological force.
Usage:
dlt = DLT(3) # Get the 3rd Double Letter Trump (The Empress)
dlt = DLT(7) # Get the 7th Double Letter Trump (The Chariot)
"""
def __init__(self, trump_number: int) -> None:
"""
Initialize a Double Letter Trump query.
Args:
trump_number: Position in DLT sequence (3-21)
Raises:
ValueError: If trump_number is not 3-21
"""
if not 3 <= trump_number <= 21:
raise ValueError(f"DLT number must be 3-21, got {trump_number}")
self.trump_number = trump_number
self._loader: Optional['CardDataLoader'] = None
self._loader: Optional["CardDataLoader"] = None
self._deck: Optional[Deck] = None
@property
def loader(self) -> 'CardDataLoader':
def loader(self) -> "CardDataLoader":
"""Lazy-load CardDataLoader on first access."""
if self._loader is None:
from ..card.data import CardDataLoader
self._loader = CardDataLoader()
return self._loader
@property
def deck(self) -> 'Deck':
def deck(self) -> "Deck":
"""Lazy-load Deck on first access."""
if self._deck is None:
self._deck = Deck()
return self._deck
def card(self) -> Optional[Card]:
"""Get the Tarot card for this DLT."""
# 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:
return card
return None
def periodic_entry(self) -> Optional[PeriodicTable]:
"""Get the periodic table entry with cross-correspondences."""
return self.loader.periodic_entry(self.trump_number)
def sephera(self) -> Optional[Sephera]:
"""Get the Sephira associated with this DLT."""
return self.loader.sephera(self.trump_number)
def planet(self) -> Optional[Planet]:
"""Get the planetary ruler for this DLT."""
periodic = self.periodic_entry()
return periodic.planet if periodic else None
def element(self) -> Optional[ElementType]:
"""Get the element associated with this DLT."""
periodic = self.periodic_entry()
return periodic.element if periodic else None
def hebrew_letter(self) -> Optional[str]:
"""Get the Hebrew letter associated with this DLT."""
periodic = self.periodic_entry()
return periodic.hebrew_letter if periodic else None
def color(self) -> Optional[Color]:
"""Get the color associated with this DLT."""
periodic = self.periodic_entry()
return periodic.color if periodic else None
def __repr__(self) -> str:
card = self.card()
card_name = card.name if card else "Unknown"
return f"DLT({self.trump_number}) - {card_name}"
def __str__(self) -> str:
return self.__repr__()
class Deck:
"""Represents a standard 78-card Tarot deck."""
def __init__(self) -> None:
"""Initialize a standard Tarot deck with all 78 cards."""
self.cards: List[Card] = []
self.discard_pile: List[Card] = []
self._initialize_deck()
def _initialize_deck(self) -> None:
"""Initialize the deck with all 78 Tarot cards.
Order: Cups (1-14), Pentacles/Disks (15-28), Swords (29-42),
Major Arcana (43-64), Wands (65-78)
Minor suit sequencing (per suit): Ace, 2-10, Prince, Knight, Princess, Queen.
This puts Queen of Wands as card #78, the final card.
"""
# Minor Arcana - First three suits (Cups, Pentacles, Swords)
@@ -479,18 +528,18 @@ class Deck:
earth_element = card_data.element("Earth")
air_element = card_data.element("Air")
fire_element = card_data.element("Fire")
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")
# Get Hebrew letters (Paths) for court cards
yod_path = card_data.path(20) # Yod
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:
raise RuntimeError("Failed to load Hebrew letter/path data from CardDataLoader")
# Map court ranks to their associated elements and Hebrew letter paths
# Knight -> Fire + Yod, Prince -> Air + Vav, Princess -> Earth + Heh, Queen -> Water + Heh
court_rank_mappings = {
@@ -507,12 +556,16 @@ class Deck:
"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]] = []
for suit_name, element_key, suit_num in suit_defs:
element_obj = element_lookup.get(element_key)
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))
return specs
@@ -530,7 +583,7 @@ class Deck:
card_number,
court_rank_mappings,
)
# Major Arcana (43-64)
# Names match filenames in src/tarot/deck/default/
for i, name in enumerate(MAJOR_ARCANA_NAMES):
@@ -538,15 +591,14 @@ class Deck:
number=card_number,
name=name,
meaning=Meaning(
upright=f"{name} upright meaning",
reversed=f"{name} reversed meaning"
upright=f"{name} upright meaning", reversed=f"{name} reversed meaning"
),
arcana="Major",
kabbalistic_number=i
kabbalistic_number=i,
)
self.cards.append(card)
card_number += 1
# Minor Arcana - Last suit (Wands, 65-78)
# Organized logically: Ace, 2-10, then court cards Prince, Knight, Princess, Queen
for suit_name, element_obj, suit_num in suits_data_last:
@@ -557,16 +609,16 @@ class Deck:
card_number,
court_rank_mappings,
)
# Load detailed explanations and keywords from registry
try:
from ..card.loader import load_deck_details
load_deck_details(self)
except ImportError:
# Handle case where loader might not be available or circular import issues
pass
def _add_minor_cards_for_suit(
self,
suit_name: str,
@@ -625,77 +677,77 @@ class Deck:
return card_number
def shuffle(self) -> None:
"""Shuffle the deck."""
random.shuffle(self.cards)
def draw(self, num_cards: int = 1) -> List[Card]:
"""
Draw cards from the deck.
Args:
num_cards: Number of cards to draw (default: 1)
Returns:
List of drawn cards
"""
if num_cards < 1:
raise ValueError("Must draw at least 1 card")
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 = []
for _ in range(num_cards):
drawn.append(self.cards.pop(0))
return drawn
def reset(self) -> None:
"""Reset the deck to its initial state."""
self.cards.clear()
self.discard_pile.clear()
self._initialize_deck()
def remaining(self) -> int:
"""Return the number of cards remaining in the deck."""
return len(self.cards)
def number(self, pip_value: int) -> CardQuery:
"""
Query cards by number (pip value).
Usage:
deck.number(3) # All cards with 3
deck.number(3).minor # All minor 3s
deck.number(3).minor.wands # 3 of Wands
"""
return CardQuery(self, pip_value)
def suit(self, suit_name: str) -> List[Card]:
"""
Get all cards from a specific suit.
Usage:
deck.suit("Wands")
"""
return [c for c in self.cards if hasattr(c, 'suit') and
c.suit and c.suit.name == suit_name]
return [c for c in self.cards if hasattr(c, "suit") and c.suit and c.suit.name == suit_name]
@property
def major(self) -> List[Card]:
"""Get all Major Arcana cards."""
return [c for c in self.cards if c.arcana == "Major"]
@property
def minor(self) -> List[Card]:
"""Get all Minor Arcana cards."""
return [c for c in self.cards if c.arcana == "Minor"]
def __len__(self) -> int:
"""Return the number of cards in the deck."""
return len(self.cards)
def __repr__(self) -> str:
return f"Deck({len(self.cards)} cards remaining)"

View File

@@ -5,115 +5,121 @@ Unified accessor for Tarot-related data and operations.
Usage:
from tarot import Tarot
# Deck and cards
card = Tarot.deck.card(3)
major5 = Tarot.deck.card.major(5)
cups2 = Tarot.deck.card.minor.cups(2)
# Letters (Hebrew with correspondences)
letter = Tarot.letters('aleph')
simple_letters = Tarot.letters.filter(letter_type="Simple")
all_letters = Tarot.letters.display_all()
# Tree of Life
sephera = Tarot.tree.sephera(1)
path = Tarot.tree.path(11)
# Cube of Space
wall = Tarot.cube.wall('North')
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 Tree, Cube
from kaballah import Cube, Tree
from letter import letters
from .card import CardAccessor
if TYPE_CHECKING:
from utils.attributes import Planet, God
from utils.attributes import God, Planet
from .attributes import Hexagram
from .card.data import CardDataLoader
class DeckAccessor:
"""Accessor for deck and card operations."""
# Card accessor (Tarot.deck.card, Tarot.deck.card.major, etc.)
card = CardAccessor()
def __str__(self) -> str:
"""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:
"""Return a nice representation of the deck accessor."""
return self.__str__()
class Tarot:
"""
Unified accessor for Tarot correspondences and data.
Provides access to cards, letters, tree of life, and cube of space.
Temporal and astrological functions are available through the temporal module:
from temporal import ThalemaClock, Zodiac
Attributes:
deck: CardAccessor for card operations
letters: Hebrew letter accessor
tree: Tree of Life accessor
cube: Cube of Space accessor
"""
deck = DeckAccessor()
letters = letters()
tree = Tree
cube = Cube
_loader: Optional['CardDataLoader'] = None # type: ignore
_loader: Optional["CardDataLoader"] = None # type: ignore
_initialized: bool = False
@classmethod
def _ensure_initialized(cls) -> None:
"""Lazy-load CardDataLoader on first access."""
if cls._initialized:
return
from .card.data import CardDataLoader
cls._loader = CardDataLoader()
cls._initialized = True
@classmethod
@overload
def planet(cls, name: str) -> Optional['Planet']:
...
def planet(cls, name: str) -> Optional["Planet"]: ...
@classmethod
@overload
def planet(cls, name: None = ...) -> Dict[str, 'Planet']:
...
def planet(cls, name: None = ...) -> Dict[str, "Planet"]: ...
@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."""
cls._ensure_initialized()
return cls._loader.planet(name) # type: ignore
@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."""
cls._ensure_initialized()
return cls._loader.god(name) # type: ignore
@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."""
cls._ensure_initialized()
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 Calendar, TimeUtil, TemporalCoordinates, Season
from temporal import ThalemaClock, Zodiac, PlanetPosition
# Basic usage
year = Year(2025)
month = Month(11) # November
day = Day(18)
hour = Hour(14)
# Calendar operations
current = Calendar.now()
is_leap = Calendar.is_leap_year(2025)
# Temporal coordinates
season = TemporalCoordinates.get_season(11, 18)
days_until_winter = TemporalCoordinates.days_until_event(11, 18, Season.WINTER)
# Astrological positions
clock = ThalemaClock()
print(clock) # Shows planetary positions
"""
from .temporal import Year, Month, Day, Hour, Week
from .astrology import PlanetPosition, ThalemaClock, Zodiac
from .calendar import Calendar
from .coordinates import Season, SolarEvent, TemporalCoordinates
from .temporal import Day, Hour, Month, Week, Year
from .time import TimeUtil
from .coordinates import TemporalCoordinates, Season, SolarEvent
from .astrology import ThalemaClock, Zodiac, PlanetPosition
__all__ = [
# Temporal classes
'Year',
'Month',
'Day',
'Hour',
'Week',
'Calendar',
'TimeUtil',
'TemporalCoordinates',
'Season',
'SolarEvent',
"Year",
"Month",
"Day",
"Hour",
"Week",
"Calendar",
"TimeUtil",
"TemporalCoordinates",
"Season",
"SolarEvent",
# Astrological classes
'ThalemaClock',
'Zodiac',
'PlanetPosition',
"ThalemaClock",
"Zodiac",
"PlanetPosition",
]

View File

@@ -5,27 +5,28 @@ Calculates current planetary positions with zodiac degrees and symbols.
Usage:
from clock import ThalemaClock
from datetime import datetime
now = datetime.now()
clock = ThalemaClock(now)
print(clock) # Display formatted with degrees and symbols
# Get individual planet info
sun_info = clock.get_planet('Sun')
print(sun_info) # "☉ 25°♏"
# Custom planet order
clock.display_format(['Moon', 'Mercury', 'Mars', 'Venus', 'Sun'])
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Optional, List
from enum import Enum
from typing import Dict, List, Optional
class Zodiac(Enum):
"""Zodiac signs with degree ranges (0-360°)."""
ARIES = ("", 0, 30)
TAURUS = ("", 30, 60)
GEMINI = ("", 60, 90)
@@ -63,6 +64,7 @@ class Zodiac(Enum):
@dataclass
class PlanetPosition:
"""Represents a planet's position with degree and zodiac."""
planet_name: str
planet_symbol: str
zodiac: Zodiac
@@ -219,7 +221,10 @@ class ThalemaClock:
return result
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()
def display_verbose(self) -> str:
@@ -228,7 +233,9 @@ class ThalemaClock:
for planet_name in ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]:
if planet_name in self.positions:
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)
def __str__(self) -> str:

View File

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

View File

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

View File

@@ -6,37 +6,39 @@ and other astronomical/calendrical coordinates.
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Dict, Any
from typing import Optional
class Season(Enum):
"""The four seasons of the year."""
SPRING = "Spring" # Vernal Equinox (Mar 20/21)
SUMMER = "Summer" # Summer Solstice (Jun 20/21)
AUTUMN = "Autumn" # Autumnal Equinox (Sep 22/23)
WINTER = "Winter" # Winter Solstice (Dec 21/22)
SPRING = "Spring" # Vernal Equinox (Mar 20/21)
SUMMER = "Summer" # Summer Solstice (Jun 20/21)
AUTUMN = "Autumn" # Autumnal Equinox (Sep 22/23)
WINTER = "Winter" # Winter Solstice (Dec 21/22)
@dataclass
class SolarEvent:
"""Represents a solar event (solstice or equinox).
Attributes:
event_type: The type of solar event (solstice or equinox)
date: The approximate date of the event
season: The associated season
"""
event_type: str # "solstice" or "equinox"
date: tuple # (month, day)
date: tuple # (month, day)
season: Season
def __str__(self) -> str:
return f"{self.season.value} {self.event_type.title()} ({self.date[0]}/{self.date[1]})"
class TemporalCoordinates:
"""Temporal positioning and astronomical calculations."""
# Approximate dates for solar events (can vary by ±1-2 days yearly)
SOLAR_EVENTS = {
Season.SPRING: SolarEvent("equinox", (3, 20), Season.SPRING),
@@ -44,15 +46,15 @@ class TemporalCoordinates:
Season.AUTUMN: SolarEvent("equinox", (9, 22), Season.AUTUMN),
Season.WINTER: SolarEvent("solstice", (12, 21), Season.WINTER),
}
@staticmethod
def get_season(month: int, day: int) -> Season:
"""Get the season for a given month and day.
Args:
month: Month number (1-12)
day: Day of month (1-31)
Returns:
The Season enum value
"""
@@ -65,28 +67,28 @@ class TemporalCoordinates:
return Season.AUTUMN
else: # Winter
return Season.WINTER
@staticmethod
def get_solar_event(season: Season) -> Optional[SolarEvent]:
"""Get the solar event for a given season.
Args:
season: The Season enum value
Returns:
SolarEvent object for that season
"""
return TemporalCoordinates.SOLAR_EVENTS.get(season)
@staticmethod
def days_until_event(month: int, day: int, target_season: Season) -> int:
"""Calculate days until a solar event.
Args:
month: Current month (1-12)
day: Current day (1-31)
target_season: Target season for calculation
Returns:
Number of days until the target seasonal event
"""
@@ -94,28 +96,28 @@ class TemporalCoordinates:
event = TemporalCoordinates.get_solar_event(target_season)
if not event:
return -1
event_month, event_day = event.date
# Simple day-of-year calculation
current_doy = TemporalCoordinates._day_of_year(month, day)
event_doy = TemporalCoordinates._day_of_year(event_month, event_day)
if event_doy >= current_doy:
return event_doy - current_doy
else:
return (365 - current_doy) + event_doy
@staticmethod
def _day_of_year(month: int, day: int) -> int:
"""Get the day of year (1-365/366).
Args:
month: Month (1-12)
day: Day (1-31)
Returns:
Day of year
"""
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 typing import Optional, List
@dataclass
class Year:
"""Represents a year in the Gregorian calendar."""
value: int
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f"Year({self.value})"
@@ -22,24 +22,35 @@ class Year:
@dataclass
class Month:
"""Represents a month (1-12)."""
value: int
def __post_init__(self) -> None:
if not 1 <= self.value <= 12:
raise ValueError(f"Month must be 1-12, got {self.value}")
@property
def name(self) -> str:
"""Get the month name."""
names = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
return names[self.value - 1]
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return f"Month({self.value})"
@@ -47,16 +58,17 @@ class Month:
@dataclass
class Week:
"""Represents a week in the calendar."""
number: int # 1-53
year: int
def __post_init__(self) -> None:
if not 1 <= self.number <= 53:
raise ValueError(f"Week must be 1-53, got {self.number}")
def __str__(self) -> str:
return f"Week {self.number} of {self.year}"
def __repr__(self) -> str:
return f"Week({self.number}, {self.year})"
@@ -64,20 +76,21 @@ class Week:
@dataclass
class Day:
"""Represents a day of the month (1-31)."""
value: int
def __post_init__(self) -> None:
if not 1 <= self.value <= 31:
raise ValueError(f"Day must be 1-31, got {self.value}")
@property
def day_of_week(self) -> str:
"""Get day of week name - requires full date context."""
pass
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f"Day({self.value})"
@@ -85,10 +98,11 @@ class Day:
@dataclass
class Hour:
"""Represents an hour in 24-hour format (0-23)."""
value: int
minute: int = 0
second: int = 0
def __post_init__(self) -> None:
if not 0 <= self.value <= 23:
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}")
if not 0 <= self.second <= 59:
raise ValueError(f"Second must be 0-59, got {self.second}")
def __str__(self) -> str:
return f"{self.value:02d}:{self.minute:02d}:{self.second:02d}"
def __repr__(self) -> str:
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 typing import Dict, Any, Optional
from typing import Dict
class TimeUtil:
"""Utilities for time operations."""
@staticmethod
def current_time() -> time:
"""Get current time.
Returns:
Current time object
"""
return datetime.now().time()
@staticmethod
def seconds_to_hms(seconds: int) -> Dict[str, int]:
"""Convert seconds to hours, minutes, seconds.
Args:
seconds: Total seconds
Returns:
Dictionary with 'hours', 'minutes', 'seconds' keys
"""
@@ -33,34 +33,34 @@ class TimeUtil:
remaining = seconds % 3600
minutes = remaining // 60
secs = remaining % 60
return {
'hours': hours,
'minutes': minutes,
'seconds': secs,
"hours": hours,
"minutes": minutes,
"seconds": secs,
}
@staticmethod
def hms_to_seconds(hours: int, minutes: int = 0, seconds: int = 0) -> int:
"""Convert hours, minutes, seconds to total seconds.
Args:
hours: Hours component
minutes: Minutes component (default 0)
seconds: Seconds component (default 0)
Returns:
Total seconds
"""
return hours * 3600 + minutes * 60 + seconds
@staticmethod
def is_24_hour_format(hour: int) -> bool:
"""Check if hour is valid in 24-hour format.
Args:
hour: The hour value to check
Returns:
True if 0-23, False otherwise
"""

View File

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

View File

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

View File

@@ -10,51 +10,57 @@ Provides a single, reusable filter mechanism that works across all modules:
Usage:
from utils.filter import universal_filter, get_filterable_fields
# For TarotLetter
results = universal_filter(Tarot.letters.all(), letter_type="Mother")
# For Card
results = universal_filter(Tarot.deck.cards, arcana="Major")
# Get available fields for introspection
fields = get_filterable_fields(TarotLetter)
"""
from typing import List, Any, TypeVar, Union, Dict
from dataclasses import is_dataclass, fields
from utils.object_formatting import get_item_label, is_nested_object, get_object_attributes, format_value
from dataclasses import fields, is_dataclass
from typing import Any, Dict, List, TypeVar
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]:
"""
Dynamically get all filterable fields from a dataclass.
Args:
dataclass_type: A dataclass type (e.g., TarotLetter, Card)
Returns:
List of field names available for filtering
Raises:
TypeError: If the type is not a dataclass
Example:
fields = get_filterable_fields(TarotLetter)
# ['hebrew_letter', 'transliteration', 'letter_type', ...]
"""
if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass")
return [f.name for f in fields(dataclass_type)]
def _matches_filter(obj: Any, key: str, value: Any) -> bool:
"""
Check if an object matches a filter criterion.
Handles:
- String matching (case-insensitive)
- Numeric matching (exact)
@@ -63,25 +69,25 @@ def _matches_filter(obj: Any, key: str, value: Any) -> bool:
- None/null matching
- Nested object attribute matching (e.g., suit="Cups" matches Suit(name="Cups"))
- Multiple values (comma-separated strings or lists for OR logic)
Args:
obj: The object to check
key: The attribute name
value: The value to match against (string, int, list, or comma-separated string)
Returns:
True if the object matches the filter, False otherwise
Examples:
_matches_filter(card, "number", 3) # Single number
_matches_filter(card, "number", [3, 5, 6]) # Multiple numbers (OR)
_matches_filter(card, "number", "3,5,6") # Comma-separated (OR)
"""
attr_value = getattr(obj, key, None)
if attr_value is None:
return False
# Parse multiple values (comma-separated string or list)
values_to_check = []
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)
else:
values_to_check = [value]
# Check if attribute matches ANY of the provided values (OR logic)
for check_value in values_to_check:
# Handle list attributes (like keywords, colors, etc.)
if isinstance(attr_value, list):
if any(
str(check_value).lower() == str(item).lower()
for item in attr_value
):
if any(str(check_value).lower() == str(item).lower() for item in attr_value):
return True
continue
# Handle numeric comparisons
if isinstance(check_value, int) and isinstance(attr_value, int):
if attr_value == check_value:
return True
continue
# Handle boolean comparisons
if isinstance(check_value, bool) and isinstance(attr_value, bool):
if attr_value == check_value:
return True
continue
# 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"))
if hasattr(attr_value, 'name'):
nested_name = getattr(attr_value, 'name', None)
if hasattr(attr_value, "name"):
nested_name = getattr(attr_value, "name", None)
if nested_name is not None:
if str(nested_name).lower() == str(check_value).lower():
return True
continue
# Handle string comparisons
if str(attr_value).lower() == str(check_value).lower():
return True
return False
def universal_filter(items: List[T], **kwargs) -> List[T]:
"""
Universal filter function that works on any list of dataclass objects.
Dynamically filters a list of objects by any combination of their attributes.
Works with any dataclass-based type throughout the project.
Args:
items: List of objects to filter (typically all objects from a collection)
**kwargs: Attribute filters (field_name=value or field_name=[value1, value2])
Returns:
Filtered list containing only items matching ALL criteria
Features:
- Case-insensitive string 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
- Works with any dataclass object
- Field aliases (e.g., 'type' -> 'arcana' for Cards)
Examples:
# Single values
results = universal_filter(Tarot.deck.cards, arcana="Major")
# Multiple values (OR logic) - comma-separated
results = universal_filter(Tarot.deck.cards, number="3,5,6")
# Multiple values (OR logic) - list
results = universal_filter(Tarot.deck.cards, number=[3, 5, 6])
# Combine multiple fields (AND logic)
results = universal_filter(Tarot.deck.cards, suit="Cups", type="Court")
# Multiple values in one field + other filters
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 = {
# No aliases - use direct property/field names
}
results = items
for key, value in kwargs.items():
if value is None:
continue
# Apply field alias if it exists
actual_key = field_aliases.get(key, key)
results = [
obj for obj in results
if _matches_filter(obj, actual_key, value)
]
results = [obj for obj in results if _matches_filter(obj, actual_key, value)]
return results
def format_results(items: List[Any]) -> str:
"""
Format a list of objects for user-friendly display.
Works with any object type and recursively formats nested structures.
Each object is separated by a blank line.
Args:
items: List of objects to format
Returns:
Formatted string with proper indentation and hierarchy
Example:
results = universal_filter(Tarot.letters.all(), element="Fire")
print(format_results(results))
"""
if not items:
return "(no items)"
lines = []
for item in items:
# Get label for item (handles name, transliteration, or str())
label = get_item_label(item, fallback=str(item))
lines.append(f"--- {label} ---")
# Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value):
@@ -227,9 +227,9 @@ def format_results(items: List[Any]) -> str:
lines.append(nested)
else:
lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items
return "\n".join(lines)
@@ -240,16 +240,16 @@ filter_by = universal_filter
def get_filter_autocomplete(dataclass_type) -> Dict[str, List[str]]:
"""
Get autocomplete suggestions for filtering a dataclass type.
Returns a dictionary mapping field names to example values found in the dataclass.
Useful for IDE autocomplete and CLI help.
Args:
dataclass_type: A dataclass type (e.g., TarotLetter, Card, Wall)
Returns:
Dictionary with field names as keys and list of example values
Example:
autocomplete = get_filter_autocomplete(TarotLetter)
# Returns:
@@ -262,41 +262,41 @@ def get_filter_autocomplete(dataclass_type) -> Dict[str, List[str]]:
"""
if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass")
autocomplete = {}
field_names = [f.name for f in fields(dataclass_type)]
for field_name in field_names:
autocomplete[field_name] = f"<value for {field_name}>"
return autocomplete
def describe_filter_fields(dataclass_type) -> str:
"""
Get a human-readable description of all filterable fields.
Useful for help text and documentation.
Args:
dataclass_type: A dataclass type
Returns:
Formatted string with field descriptions
Example:
print(describe_filter_fields(TarotLetter))
"""
if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass")
field_list = get_filterable_fields(dataclass_type)
lines = [
f"Filterable fields for {dataclass_type}:",
"",
]
for field_name in field_list:
lines.append(f"{field_name}")
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 typing import Optional, TYPE_CHECKING
from enum import Enum
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from tarot.deck.deck import CourtCard
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
class MBTIType(Enum):
"""16 MBTI personality types."""
ISTJ = "ISTJ"
ISFJ = "ISFJ"
INFJ = "INFJ"
@@ -36,79 +37,76 @@ class MBTIType(Enum):
class Personality:
"""
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
personality types and their corresponding Tarot court cards. Based on the
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/
The mapping is based on:
- SUITS correspond to Jung's 4 cognitive functions:
* Wands: Intuition (N)
* Cups: Feeling (F)
* Swords: Thinking (T)
* Pentacles: Sensation (S)
- RANKS correspond to MBTI traits:
* Kings (E + J): Extraverted Judgers
* Queens (I + J): Introverted Judgers
* Princes (E + P): Extraverted Perceivers
* Princesses (I + P): Introverted Perceivers
Attributes:
mbti_type: The MBTI personality type (e.g., ENFP)
court_card: The single CourtCard object representing this personality
description: Brief description of the personality archetype
"""
mbti_type: MBTIType
court_card: Optional['CourtCard'] = None
court_card: Optional["CourtCard"] = None
description: str = ""
# Direct MBTI-to-CourtCard mapping (1-to-1 relationship)
# Format: MBTI_TYPE -> (Rank, Suit)
_MBTI_TO_CARD_MAPPING = {
# KINGS (E + J) - Extraverted Judgers
"ENTJ": ("Knight", "Wands"), # Fiery, forceful leadership
"ENFJ": ("Knight", "Cups"), # Sensitive, mission-driven
"ESTJ": ("Knight", "Swords"), # Practical, pragmatic
"ENTJ": ("Knight", "Wands"), # Fiery, forceful leadership
"ENFJ": ("Knight", "Cups"), # Sensitive, mission-driven
"ESTJ": ("Knight", "Swords"), # Practical, pragmatic
"ESFJ": ("Knight", "Pentacles"), # Sociable, consensus-seeking
# QUEENS (I + J) - Introverted Judgers
"INTJ": ("Queen", "Wands"), # Analytical, self-motivated
"INFJ": ("Queen", "Cups"), # Sensitive, interconnected
"ISTJ": ("Queen", "Swords"), # Pragmatic, duty-fulfiller
"ISFJ": ("Queen", "Pentacles"), # Caring, earth-mother type
"INTJ": ("Queen", "Wands"), # Analytical, self-motivated
"INFJ": ("Queen", "Cups"), # Sensitive, interconnected
"ISTJ": ("Queen", "Swords"), # Pragmatic, duty-fulfiller
"ISFJ": ("Queen", "Pentacles"), # Caring, earth-mother type
# PRINCES (E + P) - Extraverted Perceivers
"ENTP": ("Prince", "Wands"), # Visionary, quick-study
"ENFP": ("Prince", "Cups"), # Inspiring, intuitive
"ESTP": ("Prince", "Swords"), # Action-oriented, risk-taker
"ENTP": ("Prince", "Wands"), # Visionary, quick-study
"ENFP": ("Prince", "Cups"), # Inspiring, intuitive
"ESTP": ("Prince", "Swords"), # Action-oriented, risk-taker
"ESFP": ("Prince", "Pentacles"), # Aesthete, sensualist
# PRINCESSES (I + P) - Introverted Perceivers
"INTP": ("Princess", "Wands"), # Thinker par excellence
"INFP": ("Princess", "Cups"), # Idealistic, devoted
"ISTP": ("Princess", "Swords"), # Observer, mechanic
"ISFP": ("Princess", "Pentacles"), # Aesthete, free spirit
"INTP": ("Princess", "Wands"), # Thinker par excellence
"INFP": ("Princess", "Cups"), # Idealistic, devoted
"ISTP": ("Princess", "Swords"), # Observer, mechanic
"ISFP": ("Princess", "Pentacles"), # Aesthete, free spirit
}
@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.
Args:
mbti_type: MBTI type as string (e.g., "ENFP", "ISTJ")
deck: Optional Tarot Deck to fetch the court card from. If not provided,
court card will be fetched dynamically when accessed.
Returns:
Personality object with associated court card
Raises:
ValueError: If mbti_type is not a valid MBTI type
Example:
>>> from tarot import Tarot
>>> personality = Personality.from_mbti("ENFP", Tarot.deck)
@@ -118,7 +116,7 @@ class Personality:
Prince of Cups
"""
mbti_type = mbti_type.upper()
# Validate MBTI type
try:
mbti_enum = MBTIType[mbti_type]
@@ -127,63 +125,58 @@ class Personality:
f"Invalid MBTI type: {mbti_type}. Must be one of: "
f"{', '.join([t.value for t in MBTIType])}"
)
# Get the rank and suit for this MBTI type
rank, suit = cls._MBTI_TO_CARD_MAPPING.get(mbti_type, (None, None))
if not rank or not suit:
raise ValueError(f"No court card mapping found for MBTI type {mbti_type}")
# Get court card from deck if provided
court_card = None
if deck is not None:
# Import here to avoid circular imports
from tarot import 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)
if cards:
court_card = cards[0]
# Get description
descriptions = {
"ENTJ": "The Commander - Strategic, ambitious, leader of Wands",
"ENFJ": "The Protagonist - Inspiring, empathetic, leader of Cups",
"ESTJ": "The Supervisor - Practical, decisive, leader of Swords",
"ESFJ": "The Consul - Sociable, cooperative, leader of Pentacles",
"INTJ": "The Architect - Strategic, logical, sage of Wands",
"INFJ": "The Advocate - Insightful, idealistic, sage of Cups",
"ISTJ": "The Logistician - Practical, reliable, sage of Swords",
"ISFJ": "The Defender - Caring, conscientious, sage of Pentacles",
"ENTP": "The Debater - Innovative, quick-witted, explorer of Wands",
"ENFP": "The Campaigner - Enthusiastic, social, explorer of Cups",
"ESTP": "The Entrepreneur - Energetic, bold, explorer of Swords",
"ESFP": "The Entertainer - Spontaneous, outgoing, explorer of Pentacles",
"INTP": "The Logician - Analytical, curious, seeker of Wands",
"INFP": "The Mediator - Idealistic, authentic, seeker of Cups",
"ISTP": "The Virtuoso - Practical, observant, seeker of Swords",
"ISFP": "The Adventurer - Sensitive, spontaneous, seeker of Pentacles",
}
return cls(
mbti_type=mbti_enum,
court_card=court_card,
description=descriptions.get(mbti_type, "")
mbti_type=mbti_enum, court_card=court_card, description=descriptions.get(mbti_type, "")
)
def __str__(self) -> str:
"""Return string representation of personality with court card."""
if self.court_card:
card_str = f"{self.court_card.court_rank} of {self.court_card.suit.name}"
else:
card_str = "No court card loaded"
return f"{self.mbti_type.value} - {self.description}\n Court Card: {card_str}"
def __repr__(self) -> str:
"""Return detailed representation."""
card_name = (

View File

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

View File

@@ -8,42 +8,43 @@ Usage:
# By name
result = letter.iching().name('peace')
result = letter.alphabet().name('english')
# By filter expressions
result = letter.iching().filter('number:1')
result = letter.alphabet().filter('name:hebrew')
result = number.number().filter('value:5')
# Get all results
results = letter.iching().all() # Dict[int, Hexagram]
results = letter.iching().list() # List[Hexagram]
"""
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union
from utils.object_formatting import format_value, get_object_attributes, is_nested_object
T = TypeVar('T')
T = TypeVar("T")
class QueryResult:
"""Single result from a query."""
def __init__(self, data: Any) -> None:
self.data = data
def __repr__(self) -> str:
if hasattr(self.data, '__repr__'):
if hasattr(self.data, "__repr__"):
return repr(self.data)
return f"{self.__class__.__name__}({self.data})"
def __str__(self) -> str:
if hasattr(self.data, '__str__'):
if hasattr(self.data, "__str__"):
return str(self.data)
return repr(self)
def __getattr__(self, name: str) -> Any:
"""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}'")
return getattr(self.data, name)
@@ -51,40 +52,40 @@ class QueryResult:
class Query:
"""
Fluent query builder for accessing and filtering tarot data.
Supports chaining: .filter() → .name() → .get()
"""
def __init__(self, data: Union[Dict[Any, T], List[T]]) -> None:
"""Initialize with data source (dict or list)."""
self._original_data = data
self._data = data if isinstance(data, list) else list(data.values())
self._filters: List[Callable[[T], bool]] = []
def filter(self, expression: str) -> 'Query':
def filter(self, expression: str) -> "Query":
"""
Filter by key:value expression.
Examples:
.filter('name:peace')
.filter('number:1')
.filter('sephera:gevurah')
.filter('value:5')
Supports multiple filters by chaining:
.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:
# Special handling for 'name' key
if key == 'name':
if hasattr(item, 'name'):
if key == "name":
if hasattr(item, "name"):
value_lower = value.lower()
item_name = str(item.name).lower()
return value_lower == item_name or value_lower in item_name
return False
if not hasattr(item, key):
return False
item_value = getattr(item, key)
@@ -93,34 +94,34 @@ class Query:
return value.lower() in str(item_value).lower()
else:
return str(value) in str(item_value)
self._filters.append(filter_func)
return self
def name(self, value: str) -> Optional['QueryResult']:
def name(self, value: str) -> Optional["QueryResult"]:
"""
Deprecated: Use .filter('name:value') instead.
Find item by name (exact or partial match, case-insensitive).
Returns QueryResult wrapping the found item, or None if not found.
"""
return self.filter(f'name:{value}').first()
def get(self) -> Optional['QueryResult']:
return self.filter(f"name:{value}").first()
def get(self) -> Optional["QueryResult"]:
"""
Get first result matching all applied filters.
Returns QueryResult or None if no match.
"""
for item in self._data:
if all(f(item) for f in self._filters):
return QueryResult(item)
return None
def all(self) -> Dict[Any, T]:
"""
Get all results matching filters as dict.
Returns original dict structure (if input was dict) with filtered values.
"""
filtered = {}
@@ -133,26 +134,26 @@ class Query:
if all(f(item) for f in self._filters):
filtered[i] = item
return filtered
def list(self) -> List[T]:
"""
Get all results matching filters as list.
Returns list of filtered items.
"""
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."""
return self.get()
def count(self) -> int:
"""Count items matching all filters."""
return len(self.list())
def __repr__(self) -> str:
return f"Query({self.count()} items)"
def __str__(self) -> str:
items = self.list()
if not items:
@@ -201,20 +202,18 @@ class CollectionAccessor(Generic[T]):
def display(self) -> str:
"""
Format all entries for user-friendly display with proper indentation.
Returns a formatted string with each item separated by blank lines.
Nested objects are indented and separated with their own sections.
"""
from utils.object_formatting import is_nested_object, get_object_attributes
data = self.all()
if not data:
return "(empty collection)"
lines = []
for key, item in data.items():
lines.append(f"--- {key} ---")
# Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value):
@@ -224,9 +223,9 @@ class CollectionAccessor(Generic[T]):
lines.append(nested)
else:
lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items
return "\n".join(lines)
def __repr__(self) -> str:
@@ -240,34 +239,32 @@ class CollectionAccessor(Generic[T]):
class FilterableDict(dict):
"""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.
Examples:
data.filter('name:peace')
data.filter('number:1')
data.filter('') # Returns query of all items
"""
return Query(self).filter(expression) if expression else Query(self)
def display(self) -> str:
"""
Format all items in the dict for user-friendly display.
Returns a formatted string with each item separated by blank lines.
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:
return "(empty collection)"
lines = []
for key, item in self.items():
lines.append(f"--- {key} ---")
# Format all attributes with proper nesting
for attr_name, attr_value in get_object_attributes(item):
if is_nested_object(attr_value):
@@ -277,16 +274,16 @@ class FilterableDict(dict):
lines.append(nested)
else:
lines.append(f" {attr_name}: {attr_value}")
lines.append("") # Blank line between items
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.
Examples:
walls = make_filterable(Cube.wall())
peace = walls.filter('name:North').first()
@@ -297,4 +294,4 @@ def make_filterable(data: Union[Dict[Any, T], List[T]]) -> Union['FilterableDict
return filterable
else:
# 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
import pytest
from src.tarot.attributes import (
Month, Day, Weekday, Hour, ClockHour, Zodiac, Suit, Meaning, Letter, Sephera, Degree, Element,
AstrologicalInfluence, TreeOfLife, Correspondences, CardImage,
EnglishAlphabet, GreekAlphabet, HebrewAlphabet, Number, Color, Planet, God,
Cipher, CipherResult,
AstrologicalInfluence,
CardImage,
Cipher,
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
# ============================================================================
# Basic Attribute Tests
# ============================================================================
class TestMonth:
def test_month_creation(self):
month = Month(1, "January", "Capricorn", "Aquarius")
@@ -24,10 +46,7 @@ class TestMonth:
assert month.zodiac_start == "Capricorn"
def test_month_all_months(self):
months = [
Month(i, f"Month_{i}", "Sign_1", "Sign_2")
for i in range(1, 13)
]
months = [Month(i, f"Month_{i}", "Sign_1", "Sign_2") for i in range(1, 13)]
assert len(months) == 12
assert months[0].number == 1
assert months[11].number == 12
@@ -41,10 +60,7 @@ class TestDay:
assert day.planetary_correspondence == "Sun"
def test_all_weekdays(self):
days = [
Day(i, f"Day_{i}", f"Planet_{i}")
for i in range(1, 8)
]
days = [Day(i, f"Day_{i}", f"Planet_{i}") for i in range(1, 8)]
assert len(days) == 7
@@ -99,6 +115,7 @@ class TestMeaning:
# Sepheric Tests
# ============================================================================
class TestSephera:
def test_sephera_creation(self):
sephera = Sephera(1, "Kether", "כתר", "Crown", "Metatron", "Chaioth", "Primum")
@@ -118,6 +135,7 @@ class TestSephera:
# Alphabet Tests
# ============================================================================
class TestEnglishAlphabet:
def test_english_letter_creation(self):
letter = EnglishAlphabet("A", 1, "ay")
@@ -189,6 +207,7 @@ class TestHebrewAlphabet:
# Number Tests
# ============================================================================
class TestNumber:
def test_number_creation(self):
num = Number(1, "Kether", "Spirit", 0) # compliment is auto-calculated
@@ -220,6 +239,7 @@ class TestNumber:
# Color Tests
# ============================================================================
class TestColor:
def test_color_creation(self):
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 g 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)
@@ -260,7 +282,9 @@ class TestColor:
class TestPlanet:
def test_planet_creation(self):
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(
name="Sun",
symbol="",
@@ -342,6 +366,7 @@ class TestGod:
# Cipher Tests
# ============================================================================
class TestCipher:
def test_cipher_mapping_basic(self):
cipher = Cipher("Test", "test", [1, 2, 3])
@@ -374,6 +399,7 @@ class TestCipherResult:
# Digital Root Tests
# ============================================================================
class TestDigitalRoot:
def test_digital_root_single_digit(self):
"""Single digits should return themselves."""
@@ -389,7 +415,7 @@ class TestDigitalRoot:
def test_digital_root_large_numbers(self):
"""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(123) == 6 # 1+2+3 = 6
@@ -398,13 +424,13 @@ class TestDigitalRoot:
# Major Arcana cards 0-21
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(1) == 1 # Card 1 (Magician) -> 1
assert calculate_digital_root(1) == 1 # Card 1 (Magician) -> 1
def test_digital_root_invalid_input(self):
"""Test that invalid inputs raise errors."""
with pytest.raises(ValueError):
calculate_digital_root(0)
with pytest.raises(ValueError):
calculate_digital_root(-5)
@@ -413,6 +439,7 @@ class TestDigitalRoot:
# CardDataLoader Tests
# ============================================================================
class TestCardDataLoader:
@pytest.fixture
def loader(self):
@@ -443,33 +470,35 @@ class TestCardDataLoader:
sephera = loader.sephera(i)
assert sephera is not None
assert sephera.number == i
def test_load_ciphers(self, loader):
"""Ensure cipher catalog is populated."""
ciphers = loader.cipher()
assert "english_simple" in ciphers
assert ciphers["english_simple"].default_alphabet == "english"
def test_word_cipher_request(self, loader):
"""word().cipher() should return meaningful totals."""
result = loader.word("tarot").cipher("english_simple")
assert isinstance(result, CipherResult)
assert result.total == 74
assert result.alphabet_name == "english"
def test_word_cipher_custom_alphabet(self, loader):
result = loader.word("אמש").cipher("kabbalah_three_mother")
assert result.values == (1, 40, 300)
def test_trigram_line_diagram(self, loader):
from letter import trigram
tri = trigram.trigram.name("Zhen") # Thunder
assert tri is not None
assert tri.data.line_diagram == "|::"
def test_hexagram_line_diagram(self, loader):
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.data.line_diagram == "||||||"
@@ -575,7 +604,7 @@ class TestCardDataLoader:
assert "english" in alphabets
assert "greek" in alphabets
assert "hebrew" in alphabets
assert len(alphabets["english"]) == 26
assert len(alphabets["greek"]) == 24
assert len(alphabets["hebrew"]) == 22
@@ -592,16 +621,16 @@ class TestCardDataLoader:
class TestDigitalRootIntegration:
"""Integration tests for digital root with Tarot cards."""
def test_all_major_arcana_digital_roots(self):
"""Test digital root for all Major Arcana cards (0-21)."""
loader = CardDataLoader()
# All Major Arcana cards should map to colors 1-9
for card_num in range(22):
if card_num == 0:
continue # Skip The Fool (0)
color = loader.color_by_number(card_num)
assert color is not None
assert 1 <= color.number <= 9
@@ -609,7 +638,7 @@ class TestDigitalRootIntegration:
def test_color_consistency(self):
"""Test that equivalent numbers map to same color."""
loader = CardDataLoader()
# 5 and 14 should map to same color (both have digital root 5)
color_5 = loader.color_by_number(5)
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
import pytest
from tarot.deck import Card
from tarot.ui import CardDisplay
def test_card_display_delegation():
"""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
with patch('tarot.ui.HAS_PILLOW', True):
with patch("tarot.ui.HAS_PILLOW", True):
display = CardDisplay()
# Create dummy card
card = MagicMock(spec=Card)
card.name = "The Fool"
card.image_path = "fool.jpg"
cards = [card]
display.show_cards(cards, title="Test Spread")
# Verify SpreadDisplay was instantiated
assert MockSpreadDisplay.call_count == 1
# Verify run was called
MockSpreadDisplay.return_value.run.assert_called_once()
# Verify arguments passed to SpreadDisplay
args, _ = MockSpreadDisplay.call_args
reading = args[0]
deck_name = args[1]
assert deck_name == "default"
assert reading.spread.name == "Card List"
assert reading.spread.description == "Test Spread"

View File

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

View File

@@ -1,27 +1,30 @@
import pytest
from tarot.ui import CubeDisplay
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_cube_zoom():
cube = Tarot.cube
display = CubeDisplay(cube)
assert display.zoom_level == 1.0
display._zoom(1.1)
assert display.zoom_level > 1.0
display._zoom(0.5)
assert display.zoom_level < 1.0
def test_cube_zoom_limits():
cube = Tarot.cube
display = CubeDisplay(cube)
# Test upper limit
for _ in range(20):
display._zoom(1.5)
assert display.zoom_level <= 3.0
# Test lower limit
for _ in range(20):
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
from unittest.mock import MagicMock, patch
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_zoom_limits():
# Mock Tk root
class MockRoot:
def __init__(self):
self.bindings = {}
self.images = []
def bind(self, key, callback): pass
def title(self, _): pass
def update_idletasks(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
def bind(self, key, callback):
pass
def title(self, _):
pass
def update_idletasks(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
class MockFrame:
def __init__(self, master=None, **kwargs):
self.children = []
self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass
def grid(self, **kwargs): 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
def pack(self, **kwargs):
pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
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
class MockCanvas:
def __init__(self, master=None, **kwargs):
self.master = master
def pack(self, **kwargs): pass
def bind(self, event, callback): pass
def create_window(self, coords, **kwargs): return 1
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
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 pack(self, **kwargs):
pass
def bind(self, event, callback):
pass
def create_window(self, coords, **kwargs):
return 1
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
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
original_tk = tk.Tk
@@ -62,60 +133,68 @@ def test_zoom_limits():
original_canvas = tk.Canvas
original_label = tk.ttk.Label
original_button = tk.ttk.Button
# Mock Label and Button
class MockWidget:
def __init__(self, master=None, **kwargs):
self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass
def grid(self, **kwargs): pass
def grid_propagate(self, flag): pass
def pack(self, **kwargs):
pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
pass
def grid_propagate(self, flag):
pass
try:
tk.Tk = MockRoot
tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas
tk.ttk.Label = MockWidget
tk.ttk.Button = MockWidget
# 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.size = (100, 100)
mock_img.resize.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
display = CubeDisplay(cube)
display.root = MockRoot()
display.canvas = MockCanvas()
display.content_frame = MockFrame()
display.canvas_window = 1 # Mock window ID
display.canvas_window = 1 # Mock window ID
# Test initial zoom
assert display.zoom_level == 1.0
# Test zoom in
display._zoom(1.22)
assert display.zoom_level == 1.22
# Test max limit (should be 50.0)
# Zoom way in
for _ in range(100):
display._zoom(1.22)
assert display.zoom_level == 50.0
# Test min limit (should be 0.1)
# Zoom way out
for _ in range(200):
display._zoom(0.5)
assert display.zoom_level == 0.1
finally:
tk.Tk = original_tk
tk.ttk.Frame = original_frame

View File

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

View File

@@ -1,18 +1,25 @@
import pytest
from pathlib import Path
import pytest
from tarot.ui import CardDisplay
def test_card_display_init():
display = CardDisplay("default")
assert display.deck_name == "default"
# Check if path resolves correctly relative to src/tarot/ui.py
# src/tarot/ui.py -> src/tarot -> 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():
display = CardDisplay("thoth")
assert display.deck_name == "thoth"
assert str(display.deck_path).endswith("thoth")
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 pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_recursive_binding():
# Mock Tk root and widgets
class MockWidget:
def __init__(self):
self.children = []
self.bindings = {}
def bind(self, key, callback):
self.bindings[key] = callback
def winfo_children(self):
return self.children
def add_child(self, child):
self.children.append(child)
# Monkey patch tk
original_tk = tk.Tk
original_frame = tk.ttk.Frame
try:
# We don't need to mock everything, just enough to test _bind_recursive
cube = Tarot.cube
# We can instantiate CubeDisplay without showing it
display = CubeDisplay(cube)
# Create a mock widget tree
parent = MockWidget()
child1 = MockWidget()
child2 = MockWidget()
grandchild = MockWidget()
parent.add_child(child1)
parent.add_child(child2)
child1.add_child(grandchild)
# Run recursive binding
display._bind_recursive(parent)
# Verify bindings
assert "<ButtonPress-1>" in parent.bindings
assert "<B1-Motion>" in parent.bindings
assert "<ButtonPress-1>" in child1.bindings
assert "<B1-Motion>" in child1.bindings
assert "<ButtonPress-1>" in child2.bindings
assert "<B1-Motion>" in child2.bindings
assert "<ButtonPress-1>" in grandchild.bindings
assert "<B1-Motion>" in grandchild.bindings
finally:
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 pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
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.
# We check if the bind method was called with correct keys.
# Mock Tk root
class MockRoot:
def __init__(self):
self.bindings = {}
def bind(self, key, callback):
self.bindings[key] = callback
def title(self, _): pass
def update_idletasks(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
def title(self, _):
pass
def update_idletasks(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
class MockFrame:
def __init__(self, master=None, **kwargs):
self.children = []
def pack(self, **kwargs): pass
def winfo_children(self): return self.children
def destroy(self): pass
def pack(self, **kwargs):
pass
def winfo_children(self):
return self.children
def destroy(self):
pass
# Monkey patch tk
original_tk = tk.Tk
original_frame = tk.ttk.Frame
try:
tk.Tk = MockRoot
tk.ttk.Frame = MockFrame
cube = Tarot.cube
display = CubeDisplay(cube)
# 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.
# 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
# a display, we'll rely on the fact that we added the bindings in the code.
pass
finally:
tk.Tk = original_tk
tk.ttk.Frame = original_frame
def test_zoom_logic_direct():
cube = Tarot.cube
display = CubeDisplay(cube)
display.zoom_level = 1.0
# Simulate + key press effect
display._zoom(1.1)
assert display.zoom_level > 1.0
# Simulate - key press effect
display._zoom(0.9)
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 pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_canvas_structure():
# Mock Tk root
class MockRoot:
def __init__(self):
self.bindings = {}
def bind(self, key, callback): pass
def title(self, _): pass
def update_idletasks(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
def bind(self, key, callback):
pass
def title(self, _):
pass
def update_idletasks(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
class MockFrame:
def __init__(self, master=None, **kwargs):
self.children = []
self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): 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 pack(self, **kwargs):
pass
def place(self, **kwargs):
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
# Mock Canvas
class MockCanvas:
def __init__(self, master=None, **kwargs):
self.master = master
def pack(self, **kwargs): pass
def bind(self, event, callback): pass
def create_window(self, coords, **kwargs): return 1
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
def pack(self, **kwargs):
pass
def bind(self, event, callback):
pass
def create_window(self, coords, **kwargs):
return 1
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
original_tk = tk.Tk
original_frame = tk.ttk.Frame
original_canvas = tk.Canvas
try:
tk.Tk = MockRoot
tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas
cube = Tarot.cube
display = CubeDisplay(cube)
# Trigger show to build UI
# We can't fully run show() because of mainloop, but we can instantiate parts
# Actually, show() creates the root.
# Let's just verify the structure by inspecting the code or trusting the manual test.
# But we can test the pan methods directly.
display.canvas = MockCanvas()
# Test pan methods
class MockEvent:
x = 10
y = 20
x_root = 110
y_root = 120
display._start_pan(MockEvent())
display._pan(MockEvent())
finally:
tk.Tk = original_tk
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
from unittest.mock import MagicMock, patch
import pytest
from tarot.tarot_api import Tarot
from tarot.ui import CubeDisplay
def test_wasd_panning():
# Mock Tk root
class MockRoot:
def __init__(self):
self.bindings = {}
self.images = []
def bind(self, key, callback):
def bind(self, key, callback):
self.bindings[key] = callback
def title(self, _): pass
def update_idletasks(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
def title(self, _):
pass
def update_idletasks(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
class MockFrame:
def __init__(self, master=None, **kwargs):
self.children = []
self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass
def grid(self, **kwargs): 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
def pack(self, **kwargs):
pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
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
class MockCanvas:
def __init__(self, master=None, **kwargs):
self.master = master
self.x_scrolls = []
self.y_scrolls = []
def pack(self, **kwargs): pass
def bind(self, event, callback): pass
def create_window(self, coords, **kwargs): return 1
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
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 pack(self, **kwargs):
pass
def bind(self, event, callback):
pass
def create_window(self, coords, **kwargs):
return 1
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
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):
self.x_scrolls.append((number, what))
def yview_scroll(self, number, what):
self.y_scrolls.append((number, what))
@@ -72,54 +141,62 @@ def test_wasd_panning():
original_canvas = tk.Canvas
original_label = tk.ttk.Label
original_button = tk.ttk.Button
# Mock Label and Button
class MockWidget:
def __init__(self, master=None, **kwargs):
self.master = master
def pack(self, **kwargs): pass
def place(self, **kwargs): pass
def grid(self, **kwargs): pass
def grid_propagate(self, flag): pass
def pack(self, **kwargs):
pass
def place(self, **kwargs):
pass
def grid(self, **kwargs):
pass
def grid_propagate(self, flag):
pass
try:
tk.Tk = MockRoot
tk.ttk.Frame = MockFrame
tk.Canvas = MockCanvas
tk.ttk.Label = MockWidget
tk.ttk.Button = MockWidget
# 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.size = (100, 100)
mock_img.resize.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
display = CubeDisplay(cube)
display.root = MockRoot()
display.canvas = MockCanvas()
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)
# But we can call _pan_key directly to test logic
display._pan_key("up")
assert display.canvas.y_scrolls[-1] == (-1, "units")
display._pan_key("down")
assert display.canvas.y_scrolls[-1] == (1, "units")
display._pan_key("left")
assert display.canvas.x_scrolls[-1] == (-1, "units")
display._pan_key("right")
assert display.canvas.x_scrolls[-1] == (1, "units")
finally:
tk.Tk = original_tk
tk.ttk.Frame = original_frame

View File

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