commit 79d4f1a09ed5be774f882f4afa1bb28b21ce8440 Author: nose Date: Tue Nov 25 22:19:36 2025 -0800 k diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bfff778 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 PY-Tarot Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..220b2c1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +include pyproject.toml +recursive-include docs *.md +recursive-include tests *.py diff --git a/docs/FILTERING.md b/docs/FILTERING.md new file mode 100644 index 0000000..55e00ec --- /dev/null +++ b/docs/FILTERING.md @@ -0,0 +1,192 @@ +# Filtering Guide + +Universal filter syntax for all queryable objects in PY-Tarot. + +## Basic Filtering (AND Logic) + +Multiple filters use AND logic - all conditions must match. + +### Cards - Basic + +```python +from tarot import Tarot + +# Single filter +Tarot.deck.card.filter(suit="Cups") +# → All Cups cards (1-14) + +# Multiple filters (AND) +Tarot.deck.card.filter(suit="Cups", type="Court") +# → Knight, Prince, Princess, Queen of Cups (4 cards) + +# Filter by pip number +Tarot.deck.card.filter(pip=3) +# → 3 of Cups, Pentacles, Swords, Wands (4 cards) + +# Filter Aces +Tarot.deck.card.filter(type="Ace") +# → Ace of Cups, Pentacles, Swords, Wands (4 cards) + +# Filter specific court rank +Tarot.deck.card.filter(court_rank="Knight") +# → All 4 Knights (all suits) + +# Combine: Knights of Cups +Tarot.deck.card.filter(court_rank="Knight", suit="Cups") +# → Knight of Cups (1 card) +``` + +### Cards - By Card Type + +```python +# All Major Arcana +Tarot.deck.card.filter(type="Major") +# → The Fool through The World (22 cards) + +# All Court Cards +Tarot.deck.card.filter(type="Court") +# → 16 cards (4 ranks × 4 suits) + +# All Pips (2-10) +Tarot.deck.card.filter(type="Pip") +# → 36 cards + +# All Aces +Tarot.deck.card.filter(type="Ace") +# → 4 cards + +# Specific pip type + suit +Tarot.deck.card.filter(type="Pip", pip=5, suit="Wands") +# → 5 of Wands (1 card) +``` + +### Cards - Court Cards + +```python +# All Knights +Tarot.deck.card.filter(type="Court", court_rank="Knight") +# → Knight of Cups, Pentacles, Swords, Wands (4 cards) + +# All Queens +Tarot.deck.card.filter(type="Court", court_rank="Queen") +# → Queen of Cups, Pentacles, Swords, Wands (4 cards) + +# Queen of specific element +Tarot.deck.card.filter(type="Court", court_rank="Queen", suit="Wands") +# → Queen of Wands (1 card) + +# All Princes +Tarot.deck.card.filter(court_rank="Prince") +# → Prince of Cups, Pentacles, Swords, Wands (4 cards) + +# All Princesses +Tarot.deck.card.filter(court_rank="Princess") +# → Princess of Cups, Pentacles, Swords, Wands (4 cards) +``` + +## Advanced Filtering + +### Multiple Values (OR Logic) + +Use `filter()` multiple times and combine results: + +```python +# Aces OR Pips with value 3 +aces = Tarot.deck.card.filter(type="Ace") +threes = Tarot.deck.card.filter(pip=3) +result = aces + threes +# → Ace of all suits + 3 of all suits (8 cards) + +# Wands OR Pentacles +wands = Tarot.deck.card.filter(suit="Wands") +pentacles = Tarot.deck.card.filter(suit="Pentacles") +result = wands + pentacles +# → All Wands + all Pentacles (28 cards) + +# Knights OR Queens +knights = Tarot.deck.card.filter(court_rank="Knight") +queens = Tarot.deck.card.filter(court_rank="Queen") +result = knights + queens +# → All Knights + all Queens (8 cards) +``` + +### Complex Queries + +```python +# All Cups court cards +cups_court = Tarot.deck.card.filter(suit="Cups", type="Court") +# → Knight, Prince, Princess, Queen of Cups (4 cards) + +# All water element (Cups and lower pips) +water_cards = Tarot.deck.card.filter(suit="Cups") +# → All 14 Cups cards + +# Fire element court cards +fire_court = Tarot.deck.card.filter(suit="Wands", type="Court") +# → Knight, Prince, Princess, Queen of Wands (4 cards) + +# All numbered cards from 2-10 (pips) in specific suits +fives_in_water_earth = ( + Tarot.deck.card.filter(pip=5, suit="Cups") + + Tarot.deck.card.filter(pip=5, suit="Pentacles") +) +# → 5 of Cups + 5 of Pentacles (2 cards) +``` + +## Available Filter Fields + +### All Cards +- `type` → "Major", "Pip", "Ace", "Court" +- `arcana` → "Major", "Minor" +- `number` → Card's position in deck (1-78) +- `name` → Full card name (case-insensitive) + +### Minor Arcana Only +- `suit` → "Cups", "Pentacles", "Swords", "Wands" +- `pip` → 1-10 (1 for Ace, 2-10 for pips) +- `court_rank` → "Knight", "Prince", "Princess", "Queen" + +### Court Cards Only +- `court_rank` → "Knight", "Prince", "Princess", "Queen" +- `associated_element` → Element object for the court rank + +### Major Arcana Only +- `kabbalistic_number` → 0-21 + +## Display Results + +```python +# Print as formatted list +cards = Tarot.deck.card.filter(suit="Cups") +print(cards) + +# Print nicely formatted +cards_str = Tarot.deck.card.display_filter(suit="Cups") +print(cards_str) + +# Access individual cards +cups = Tarot.deck.card.filter(suit="Cups") +first_cup = cups[0] # Ace of Cups +print(f"{first_cup.number}. {first_cup.name}") +``` + +## Case Sensitivity + +All filters are case-insensitive: + +```python +Tarot.deck.card.filter(suit="cups") # Works +Tarot.deck.card.filter(suit="CUPS") # Works +Tarot.deck.card.filter(suit="Cups") # Works +Tarot.deck.card.filter(type="ace") # Works +Tarot.deck.card.filter(type="ACE") # Works +Tarot.deck.card.filter(type="Ace") # Works +``` + +## Tips + +- Use `type` to filter by card class (Major, Pip, Ace, Court) +- Use `suit` to filter by element (Cups/Water, Pentacles/Earth, Swords/Air, Wands/Fire) +- Multiple kwargs = AND logic +- For OR logic, call `filter()` separately and combine lists with `+` +- All string comparisons are case-insensitive diff --git a/docs/REGISTRY_MAPPING.md b/docs/REGISTRY_MAPPING.md new file mode 100644 index 0000000..7021363 --- /dev/null +++ b/docs/REGISTRY_MAPPING.md @@ -0,0 +1,110 @@ +## Registry System: Position-Based Card Details + +### Overview +The registry uses a **position-based lookup system** that maps card positions (1-78) to interpretive data stored in `CardDetailsRegistry`. This system is independent of card names, allowing the same registry entries to work across different deck variants. + +### Design Principle +**Card position is the permanent identifier**, not the card name. This means: +- Card #44 is always "Magus position" in the deck order +- If you rename card #44 to "Magus", "Magician", "Mage", or any other variant, it still maps to the same registry entry +- Different deck variants can have completely different names but use the same spiritual/interpretive data + +### File Organization +Your deck files follow this numbering (which drives card position): +- **1-14**: Cups (Ace, Ten, 2-9, Knight, Prince, Princess, Queen) +- **15-28**: Pentacles/Disks (same structure) +- **29-42**: Swords (same structure) +- **43-64**: Major Arcana (43=Fool, 44=Magus, ..., 64=Universe) +- **65-78**: Wands (same structure) + +Example: `44_Magus.webp` → Card at position 44 → Magus name → Registry position 44 → Details for position 44 + +### Position-Based Lookup Process + +``` +Card created with number (position) + ↓ +card.number = 44 + ↓ +load_into_card(card) + ↓ +get_by_position(44) + ↓ +Position 44 maps to registry key "I" (1st trump after Fool) + ↓ +registry.get("I") → Returns Magician/Magus details + ↓ +Details loaded into card object (independent of card.name) +``` + +### Position Mapping + +**Minor Arcana Positions:** +- 1-14: Cups - Maps to "Ace of Cups" through "Queen of Cups" +- 15-28: Pentacles - Maps to "Ace of Pentacles" through "Queen of Pentacles" +- 29-42: Swords - Maps to "Ace of Swords" through "Queen of Swords" +- 65-78: Wands - Maps to "Ace of Wands" through "Queen of Wands" + +**Major Arcana Positions:** +- 43 → "o" (Roman for 0/Fool) +- 44 → "I" (Roman for 1/Magus) +- 45 → "II" (Roman for 2) +- ...continuing through... +- 64 → "XXI" (Roman for 21/Universe) + +### Implementation + +```python +def _build_position_map(self) -> Dict[int, str]: + """ + Maps card position (1-78) to registry key: + - Minor Arcana: position → card name ("Ace of Cups", etc.) + - Major Arcana: position → Roman numeral ("o", "I", "II", etc.) + """ + # Builds complete 1-78 mapping + return position_map + +def get_by_position(self, position: int) -> Optional[Dict[str, Any]]: + """Get details for a card by its position (1-78).""" + registry_key = self._position_map.get(position) + return self._details.get(registry_key) + +def load_into_card(self, card: 'Card') -> bool: + """Load card details using position-based lookup.""" + details = self.get_by_position(card.number) + # Populate card with: explanation, interpretation, keywords, etc. +``` + +### Why Position-Based? + +1. **Deck Variant Independence**: Different decks can use completely different names +2. **Stable Identity**: Card position never changes across variants +3. **Scalable**: Easily support new deck variants by just changing card names +4. **Future Proof**: New interpretations can be added keyed to positions, not names + +### Example Flow + +File: `44_Magus.webp` +↓ +Card object: number=44, name="Magus", arcana="Major" +↓ +`load_into_card(card)` called +↓ +`get_by_position(44)` returns registry key "I" +↓ +Registry lookup: `registry.get("I")` +↓ +Populates card with Magician/Magus interpretation: +- keywords: ["manifestation", "resourcefulness", ...] +- interpretation: "Communication; Conscious Will; ..." +- guidance: "Focus your energy and intention..." + +### Key Point + +Even if you rename card #44 to something completely different, it will still load the same interpretation because the lookup is based on **position (44)**, not **name ("Magus")**. + +↓ +Registry lookup: `registry.get("o")` +↓ +Populates card with: explanation, interpretation, keywords, etc. + diff --git a/mytest.py b/mytest.py new file mode 100644 index 0000000..0500728 --- /dev/null +++ b/mytest.py @@ -0,0 +1,56 @@ +from tarot import Tarot, letter, number, kaballah +from temporal import ThalemaClock +from datetime import datetime +from utils import Personality, MBTIType + +# Tarot core functionality +card = Tarot.deck.card(3) +print(f"Card: {card}") + +# Spreads - now under Tarot.deck.card.spread() +print("\n" + Tarot.deck.card.spread("Celtic Cross")) + +# Temporal functionality (separate module) +clock = ThalemaClock(datetime.now()) +print(f"\nClock: {clock}") +print(Tarot.deck.card.filter(suit="Cups")) + +# Top-level namespaces with pretty printing +print("\n" + "=" * 60) +print("Letter Namespace:") +print(letter) + +print("\n" + "=" * 60) +print("Number Namespace:") +print(number) + +print("\nDigital root of 343:", number.digital_root(343)) + +print("\n" + "=" * 60) +print("Kaballah - Tree of Life:") +print(kaballah.Tree) + +print("\n" + "=" * 60) +print("Kaballah - Cube of Space:") +print(kaballah.Cube.wall.display_filter(side="Below")) + +# Filtering examples +print("\n" + "=" * 60) + +print(Tarot.deck.card.filter(type='court')) + +# MBTI Personality types mapped to Tarot court cards (1-to-1 direct mapping) +print("\n" + "=" * 60) +print("MBTI Personality Types & Tarot Court Cards") +print("=" * 60) + +# Create personalities for all 16 MBTI types +mbti_types = ['ENFP', 'ISTJ', 'INTJ', 'INFJ', 'ENTJ', 'ESFJ', 'ESTP', 'ISFJ', + 'ENTP', 'ISFP', 'INTP', 'INFP', 'ESTJ', 'ESFP', 'ISTP', 'INTJ'] +for mbti in mbti_types: + personality = Personality.from_mbti(mbti, Tarot.deck) + print(f"\n{personality}") +#prints court cards +print(Tarot.deck.card.filter(suit="cups,wands")) + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..178bcf5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "py-tarot" +version = "0.1.0" +description = "A Python library for Tarot card reading and interpretation" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +keywords = ["tarot", "divination", "cards", "spirituality"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "tomli>=1.2.0;python_version<'3.11'", + "tomli_w>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=3.0", + "black>=22.0", + "isort>=5.10", + "flake8>=4.0", + "mypy>=0.950", +] +docs = [ + "sphinx>=4.5", + "sphinx-rtd-theme>=1.0", +] + +[project.urls] +Homepage = "https://github.com/yourusername/py-tarot" +Documentation = "https://py-tarot.readthedocs.io" +Repository = "https://github.com/yourusername/py-tarot.git" +Issues = "https://github.com/yourusername/py-tarot/issues" + +[tool.setuptools] +packages = ["tarot"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.black] +line-length = 100 +target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..95e7d18 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,35 @@ +""" +PY-Tarot: Comprehensive Tarot library with hierarchical namespaces. + +Provides four root namespaces for different domains: + + number - Numerology (digital root, colors, Sepheric attributes) + letter - Alphabets, ciphers (English, Hebrew, Greek), words, I Ching + kaballah - Tree of Life and Cube of Space + tarot - Tarot-specific (deck, cards, temporal) + +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) + cups2 = Tarot.deck.card.minor.cups(2) +""" + +__version__ = "0.1.0" +__author__ = "PY-Tarot Contributors" +__all__ = [] diff --git a/src/kaballah/__init__.py b/src/kaballah/__init__.py new file mode 100644 index 0000000..84b4632 --- /dev/null +++ b/src/kaballah/__init__.py @@ -0,0 +1,22 @@ +""" +Kaballah namespace - Tree of Life and Cube of Space. + +Provides fluent query interface for: +- Tree of Life with Sephiroth and Paths +- Cube of Space with walls and areas +- Kabbalistic correspondences + +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 + +# Export classes for fluent access +__all__ = ["Tree", "Cube"] diff --git a/src/kaballah/attributes.py b/src/kaballah/attributes.py new file mode 100644 index 0000000..53ae6bd --- /dev/null +++ b/src/kaballah/attributes.py @@ -0,0 +1,215 @@ +""" +Kabbalistic attributes and data structures. + +This module defines attributes specific to the Kabbalah module, +including Sephira, Paths, and Tree of Life structures. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Any + +from utils.attributes import ( + Element, + ElementType, + Planet, + Color, + Colorscale, + Perfume, + God, +) + + +@dataclass +class Sephera: + """Represents a Sephira on the Tree of Life.""" + number: int + name: str + hebrew_name: str + meaning: str + archangel: str + order_of_angels: str + mundane_chakra: str + element: Optional['ElementType'] = None + planetary_ruler: Optional[str] = None + tarot_trump: Optional[str] = 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 + tarot_trump: Optional[str] = None + hebrew_letter: Optional[str] = None + divine_name: Optional[str] = None + archangel: Optional[str] = None + order_of_angels: Optional[str] = None + keywords: List[str] = field(default_factory=list) + + +@dataclass +class TreeOfLife: + """Represents the Tree of Life structure.""" + sephiroth: Dict[int, str] + paths: Dict[Tuple[int, int], str] + + +@dataclass +class Correspondences: + """Represents Kabbalistic correspondences.""" + number: int + sephira: str + element: Optional[str] + planet: Optional[str] + zodiac: Optional[str] + tarot_trump: Optional[str] + archangel: Optional[str] + order_of_angels: Optional[str] + divine_name: Optional[str] + + +@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 + 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) + 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: + """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: + """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']: + """Return all gods for this path, optionally filtered by culture.""" + if culture: + return list(self.gods.get(culture.lower(), [])) + merged: List['God'] = [] + for values in self.gods.values(): + merged.extend(values) + return merged + + 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) + 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("") + lines.append("--- Colorscale ---") + lines.append(f" name: {self.colorscale.name}") + lines.append(f" king_scale: {self.colorscale.king_scale}") + lines.append(f" queen_scale: {self.colorscale.queen_scale}") + lines.append(f" emperor_scale: {self.colorscale.emperor_scale}") + lines.append(f" empress_scale: {self.colorscale.empress_scale}") + if self.colorscale.sephirotic_color: + lines.append(f" sephirotic_color: {self.colorscale.sephirotic_color}") + lines.append(f" type: {self.colorscale.type}") + if self.colorscale.keywords: + 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("") + lines.append("--- Perfumes ---") + for perfume in self.perfumes: + for line in str(perfume).split("\n"): + lines.append(f" {line}") + lines.append("") + + # Gods + if self.gods: + lines.append("") + lines.append("--- Gods ---") + for culture, god_list in self.gods.items(): + lines.append(f" {culture}:") + for god in god_list: + 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) diff --git a/src/kaballah/cube/__init__.py b/src/kaballah/cube/__init__.py new file mode 100644 index 0000000..e751b32 --- /dev/null +++ b/src/kaballah/cube/__init__.py @@ -0,0 +1,6 @@ +"""Cube namespace - access Cube of Space walls and areas.""" + +from .cube import Cube +from .attributes import CubeOfSpace, Wall, WallDirection + +__all__ = ["Cube", "CubeOfSpace", "Wall", "WallDirection"] diff --git a/src/kaballah/cube/attributes.py b/src/kaballah/cube/attributes.py new file mode 100644 index 0000000..039e436 --- /dev/null +++ b/src/kaballah/cube/attributes.py @@ -0,0 +1,513 @@ +""" +Cube of Space attributes and data structures. + +Defines the CubeOfSpace, Wall, and WallDirection classes for the Cube of Space +Kabbalistic model with hierarchical wall and direction structure. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional + + +@dataclass(frozen=True, repr=False) +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 + element: Optional[str] = None # Associated element if any + 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( + f"Invalid direction name '{self.name}'. " + f"Valid names: {', '.join(sorted(self.VALID_DIRECTION_NAMES))}" + ) + 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})" + + +@dataclass(frozen=True, repr=False) +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) + element: Optional[str] = None # Associated element + planet: Optional[str] = None # Associated planet + archangel: Optional[str] = None # Associated archangel + 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", + "South": "North", + "East": "West", + "West": "East", + "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}'" + ) + + # 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" + lines = [ + f"Wall: {self.name}", + f" Side: {self.side}", + f" Opposite: {self.opposite}", + f" Element: {self.element}", + f" Planet: {self.planet}", + f" Archangel: {self.archangel}", + f" Keywords: {keywords_str}", + ] + + # Add directions with their details recursively + if self.directions: + lines.append(" Directions:") + # Order: Center, North, South, East, West + direction_order = ["Center", "North", "South", "East", "West"] + for dir_name in direction_order: + if dir_name in self.directions: + direction = self.directions[dir_name] + lines.append(f" --- {direction.name} ---") + lines.append(f" Letter: {direction.letter}") + if direction.zodiac: + lines.append(f" Zodiac: {direction.zodiac}") + if direction.element: + lines.append(f" Element: {direction.element}") + if direction.planet: + lines.append(f" Planet: {direction.planet}") + if direction.keywords: + keywords = ", ".join(direction.keywords) + 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 + + +@dataclass +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": { + "element": "Air", + "planet": "Mercury", + "archangel": "Raphael", + "keywords": ["Thought", "Communication", "Intellect"], + "description": "Northern Wall - Air element, Mercury correspondence", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["Integration", "Balance", "Foundation"], + "description": "Center of North wall - synthesis of thought", + }, + "above": { + "element": "Fire", + "keywords": ["Higher Mind", "Spiritual Thought", "Ascent"], + "description": "Above area - elevated intellectual consciousness", + }, + "below": { + "element": "Earth", + "keywords": ["Practical Thought", "Grounded Mind", "Implementation"], + "description": "Below area - material manifestation of thought", + }, + "east": { + "element": "Air", + "keywords": ["Clarity", "Awakening", "Dawn Thought"], + "description": "East area - morning mind, new perspectives", + }, + "west": { + "element": "Water", + "keywords": ["Emotional Thought", "Reflection", "Dreams"], + "description": "West area - intuitive mind, introspection", + }, + }, + }, + "South": { + "element": "Fire", + "planet": "Mars", + "archangel": "Samael", + "keywords": ["Will", "Action", "Passion"], + "description": "Southern Wall - Fire element, Mars correspondence", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["Pure Will", "Center of Power", "Drive"], + "description": "Center of South wall - focal point of action", + }, + "above": { + "element": "Fire", + "keywords": ["Divine Will", "Higher Purpose", "Spiritual Force"], + "description": "Above area - transcendent power and courage", + }, + "below": { + "element": "Earth", + "keywords": ["Physical Action", "Embodied Will", "Manifestation"], + "description": "Below area - action in material world", + }, + "east": { + "element": "Air", + "keywords": ["Active Mind", "Strategic Will", "Beginning Action"], + "description": "East area - dawn of new endeavors", + }, + "west": { + "element": "Water", + "keywords": ["Passionate Emotion", "Emotional Drive", "Desire"], + "description": "West area - feeling-guided action", + }, + }, + }, + "East": { + "element": "Air", + "planet": "Venus", + "archangel": "Haniel", + "keywords": ["Dawn", "Beginning", "Ascent"], + "description": "Eastern Wall - Air element, new beginnings", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["New Potential", "Morning Star", "Awakening"], + "description": "Center of East wall - point of emergence", + }, + "above": { + "element": "Fire", + "keywords": ["Spiritual Dawn", "Divine Light", "Inspiration"], + "description": "Above area - celestial promise", + }, + "below": { + "element": "Earth", + "keywords": ["Material Growth", "Physical Sunrise", "Earthly Beginning"], + "description": "Below area - manifestation of new potential", + }, + "east": { + "element": "Air", + "keywords": ["Pure Beginning", "First Breath", "Clarity"], + "description": "East area - absolute dawn principle", + }, + "west": { + "element": "Water", + "keywords": ["Emotional Renewal", "Feelings Awakening", "Hope"], + "description": "West area - emotional opening toward new day", + }, + }, + }, + "West": { + "element": "Water", + "planet": "Venus", + "archangel": "Uriel", + "keywords": ["Emotion", "Decline", "Closure"], + "description": "Western Wall - Water element, endings and emotions", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["Emotional Core", "Sunset Synthesis", "Integration"], + "description": "Center of West wall - emotional balance point", + }, + "above": { + "element": "Fire", + "keywords": ["Spiritual Emotion", "Divine Love", "Transcendence"], + "description": "Above area - love beyond form", + }, + "below": { + "element": "Earth", + "keywords": ["Physical Emotion", "Embodied Feeling", "Sensuality"], + "description": "Below area - emotion in material form", + }, + "east": { + "element": "Air", + "keywords": ["Mental Emotion", "Thoughts Felt", "Understanding Feeling"], + "description": "East area - intellectual understanding of emotion", + }, + "west": { + "element": "Water", + "keywords": ["Pure Emotion", "Deep Feeling", "Subconscious"], + "description": "West area - pure emotional depths", + }, + }, + }, + "Above": { + "element": "Fire", + "planet": "Sun", + "archangel": "Michael", + "keywords": ["Heaven", "Spirit", "Light"], + "description": "Upper Wall - Fire element, divine consciousness", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["Divine Center", "Oneness", "Source"], + "description": "Center of Above wall - point of divine unity", + }, + "above": { + "element": "Fire", + "keywords": ["Pure Spirit", "Infinite Light", "Transcendence"], + "description": "Above area - highest divine realm", + }, + "below": { + "element": "Earth", + "keywords": ["Spirit in Matter", "Divine Manifestation", "Incarnation"], + "description": "Below area - spirit descending to form", + }, + "east": { + "element": "Air", + "keywords": ["Divine Thought", "Holy Wisdom", "Inspiration"], + "description": "East area - divine intellect and inspiration", + }, + "west": { + "element": "Water", + "keywords": ["Divine Love", "Compassion", "Mercy"], + "description": "West area - divine love and compassion", + }, + }, + }, + "Below": { + "element": "Earth", + "planet": "Saturn", + "archangel": "Cassiel", + "keywords": ["Matter", "Foundation", "Manifestation"], + "description": "Lower Wall - Earth element, material foundation", + "areas": { + "center": { + "element": "Spirit", + "keywords": ["Material Foundation", "Grounding", "Embodiment"], + "description": "Center of Below wall - anchor point of manifestation", + }, + "above": { + "element": "Fire", + "keywords": ["Active Force", "Will to Build", "Transformation"], + "description": "Above area - active principle in matter", + }, + "below": { + "element": "Earth", + "keywords": ["Pure Earth", "Deep Matter", "Grounding Root"], + "description": "Below area - deepest material foundation", + }, + "east": { + "element": "Air", + "keywords": ["Material Structure", "Physical Form", "Manifestation"], + "description": "East area - form emerging into manifestation", + }, + "west": { + "element": "Water", + "keywords": ["Nurturing Matter", "Fertile Ground", "Sustenance"], + "description": "West area - material nourishment and growth", + }, + }, + }, + } + + 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())}" + ) + + @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 = { + "center": {"name": "Center", "letter": "Aleph", "zodiac": None}, + "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"} + } + + 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 + directions = {} + for old_name, direction_config in direction_map.items(): + if old_name in wall_data["areas"]: + direction_data = wall_data["areas"][old_name] + direction = WallDirection( + name=direction_config["name"], + letter=direction_config["letter"], + zodiac=direction_config.get("zodiac"), + element=direction_data.get("element"), + keywords=direction_data.get("keywords", []), + description=direction_data.get("description", ""), + ) + # 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, + side=wall_name.lower(), + opposite=Wall.OPPOSITE_WALLS[wall_name], + element=wall_data.get("element"), + planet=wall_data.get("planet"), + archangel=wall_data.get("archangel"), + keywords=wall_data.get("keywords", []), + description=wall_data.get("description", ""), + directions=directions, + ) + walls[wall_name] = wall + + # Create central core + central_core = WallDirection( + name="Center", + letter="Aleph", + element="Spirit", + 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) diff --git a/src/kaballah/cube/cube.py b/src/kaballah/cube/cube.py new file mode 100644 index 0000000..3f80736 --- /dev/null +++ b/src/kaballah/cube/cube.py @@ -0,0 +1,359 @@ +""" +Tarot Cube of Space module. + +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 + wall.filter(element="Fire") # Filter by attribute + wall.direction("East") # Get specific direction +""" + +from typing import Optional, Any + + +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 {} + lines = [ + "Cube of Space", + "=" * 60, + f"Walls: {len(walls)} (North, South, East, West, Above, Below)", + "", + "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 + 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 {} + return f"Cube(walls={len(walls)})" + + +class DirectionAccessor: + """Fluent accessor for filtering and accessing directions within a specific wall.""" + + _wall: Optional[Any] = None + + def __init__(self, wall: Any): + """Initialize with a Wall object.""" + self._wall = wall + + def all(self) -> list: + """Get all directions in this wall.""" + if self._wall is None or not hasattr(self._wall, 'directions'): + return [] + return list(self._wall.directions.values()) + + def filter(self, direction_name: Optional[str] = None, **kwargs) -> list: + """ + Filter directions in this wall by name or any WallDirection attribute. + + Args: + direction_name: Specific direction (North, South, East, West, Center) + **kwargs: Any WallDirection attribute + + Returns: + List of WallDirection objects matching filters + """ + from utils.filter import universal_filter + + all_dirs = self.all() + + # Filter by direction name if provided + if direction_name: + all_dirs = [ + d for d in all_dirs + if d.name.lower() == direction_name.lower() + ] + + # Apply other filters + if kwargs: + all_dirs = universal_filter(all_dirs, **kwargs) + + return all_dirs + + def display(self) -> str: + """Display all directions in this wall formatted.""" + from utils.filter import format_results + + return format_results(self.all()) + + def display_filter(self, direction_name: Optional[str] = None, **kwargs) -> str: + """ + Filter directions and display results nicely formatted. + + Args: + direction_name: Direction name to filter + **kwargs: Any WallDirection attribute + + Example: + print(wall.display_filter("East")) + """ + from utils.filter import format_results + + results = self.filter(direction_name, **kwargs) + return format_results(results) + + def __call__(self, direction_name: Optional[str] = None) -> Optional[Any]: + """Get specific direction by name.""" + if direction_name is None: + return self.all() + if self._wall is None or not hasattr(self._wall, 'directions'): + return None + return self._wall.directions.get(direction_name.capitalize()) + + def __repr__(self) -> str: + """Return friendly representation.""" + directions = self.all() + dir_names = ", ".join([d.name for d in directions]) + wall_name = self._wall.name if self._wall else "Unknown" + return f"DirectionAccessor({wall_name}: {dir_names})" + + def __str__(self) -> str: + """Return formatted string of all directions.""" + return self.display() + + +class WallWrapper: + """Wraps a Wall object to add DirectionAccessor for hierarchical access.""" + + def __init__(self, wall: Any): + """Initialize with a Wall object.""" + self._wall = wall + self._direction_accessor = DirectionAccessor(wall) + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to the wrapped wall.""" + if name in ('_wall', '_direction_accessor'): + return object.__getattribute__(self, name) + return getattr(self._wall, name) + + def filter(self, direction_name: Optional[str] = None, **kwargs) -> list: + """ + Filter directions in this wall. + + Usage: + wall.filter("East") # Get East direction + wall.filter(element="Fire") # Get Fire directions + """ + return self._direction_accessor.filter(direction_name, **kwargs) + + def display_filter(self, direction_name: Optional[str] = None, **kwargs) -> str: + """ + Filter directions and display results nicely formatted. + + Usage: + wall.display_filter("East") + wall.display_filter(element="Fire") + """ + return self._direction_accessor.display_filter(direction_name, **kwargs) + + def direction(self, direction_name: str) -> Optional[Any]: + """Get a specific direction.""" + return self._direction_accessor(direction_name) + + def all_directions(self) -> list: + """Get all directions in this wall.""" + return self._direction_accessor.all() + + def __repr__(self) -> str: + """Return friendly representation.""" + return f"Wall({self._wall.name}, {self._wall.element})" + + def __str__(self) -> str: + """Return formatted string of wall details.""" + wall = self._wall + lines = [ + f"--- {wall.name} ---", + f" name: {wall.name}", + f" side: {wall.side}", + f" element: {wall.element}", + f" planet: {wall.planet}", + f" opposite: {wall.opposite}", + f" archangel: {wall.archangel}", + f" keywords: {', '.join(wall.keywords) if wall.keywords else 'None'}", + f" description: {wall.description}", + f" directions: {', '.join(wall.directions.keys())}", + ] + return "\n".join(lines) + + +class WallAccessor: + """Fluent accessor for filtering and accessing Cube walls.""" + + _cube: Optional["CubeOfSpace"] = None # type: ignore + _initialized: bool = False + + @classmethod + def _ensure_initialized(cls) -> None: + """Lazy-initialize the Cube on first access.""" + if cls._initialized: + return + from kaballah.cube.attributes import CubeOfSpace + + WallAccessor._cube = CubeOfSpace.create_default() + WallAccessor._initialized = True + + def all(self) -> list: + """Get all walls.""" + self._ensure_initialized() + if WallAccessor._cube is None: + return [] + return WallAccessor._cube.all_walls() + + def filter(self, **kwargs) -> list: + """ + Filter walls by any Wall attribute. + + Uses the universal filter for consistency across the project. + + Args: + **kwargs: Any Wall attribute with its value + + Usage: + Cube.wall.filter(element="Air") + Cube.wall.filter(planet="Mercury") + + Returns: + List of Wall objects matching all filters + """ + from utils.filter import universal_filter + + return universal_filter(self.all(), **kwargs) + + def display(self) -> str: + """Display all walls formatted.""" + from utils.filter import format_results + + return format_results(self.all()) + + def display_filter(self, **kwargs) -> str: + """ + Filter walls and display results nicely formatted. + + Args: + **kwargs: Any Wall attribute with its value + + Returns: + Formatted string with filtered walls + + Example: + print(Cube.wall.display_filter(element="Air")) + """ + results = self.filter(**kwargs) + # Use the custom __str__ method of Wall objects for proper line-by-line formatting + return "\n\n".join([str(wall) for wall in results]) + + 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() + + if wall_name is None: + return self.all() + + if WallAccessor._cube is None: + return None + return WallAccessor._cube.wall(wall_name) + + def __repr__(self) -> str: + """Return friendly representation showing all walls.""" + walls = self.all() + wall_names = ", ".join([w.name for w in walls]) + return f"WallAccessor({wall_names})" + + def __str__(self) -> str: + """Return formatted string of all walls.""" + return self.display() + + +class Cube(metaclass=CubeMeta): + """ + Unified accessor for Cube of Space correspondences. + + Hierarchical structure: Cube > Wall > Direction + + Usage: + # 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 + fire_dirs = wall.filter(element="Fire") # Filter directions + """ + + _cube: Optional["CubeOfSpace"] = None # type: ignore + _initialized: bool = False + _wall_accessor: Optional[WallAccessor] = None + + @classmethod + def _ensure_initialized(cls) -> None: + """Lazy-initialize the Cube of Space on first access.""" + if cls._initialized: + return + + from kaballah.cube.attributes import CubeOfSpace + + cls._cube = CubeOfSpace.create_default() + cls._initialized = True + + @classmethod + def _get_wall_accessor(cls) -> "WallAccessor": + """Get or create the wall accessor.""" + cls._ensure_initialized() + 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 + def opposite_wall(cls, wall_name: str) -> Optional[object]: + """Get the opposite wall.""" + cls._ensure_initialized() + if cls._cube is None: + return None + return cls._cube.opposite_wall(wall_name) diff --git a/src/kaballah/tree/__init__.py b/src/kaballah/tree/__init__.py new file mode 100644 index 0000000..87cf31b --- /dev/null +++ b/src/kaballah/tree/__init__.py @@ -0,0 +1,5 @@ +"""Tree namespace - access Tree of Life, Sephiroth, and Paths.""" + +from .tree import Tree + +__all__ = ["Tree"] diff --git a/src/kaballah/tree/tree.py b/src/kaballah/tree/tree.py new file mode 100644 index 0000000..c504811 --- /dev/null +++ b/src/kaballah/tree/tree.py @@ -0,0 +1,136 @@ +""" +Tarot Tree of Life module. + +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 + +if TYPE_CHECKING: + from tarot.attributes import Sephera, Path + from tarot.card.data import CardDataLoader + from utils.query import QueryResult, 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') + lines = [ + "Tree of Life", + "=" * 60, + f"Sephiroth: {len(sepheras)} nodes", + f"Paths: {len(paths)} connections", + "", + "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') + 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 + _initialized: bool = False + _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']: + ... + + @classmethod + @overload + def sephera(cls, number: None = ...) -> Dict[int, 'Sephera']: + ... + + @classmethod + 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']: + ... + + @classmethod + @overload + def path(cls, number: None = ...) -> Dict[int, 'Path']: + ... + + @classmethod + 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': + """ + 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) diff --git a/src/letter/__init__.py b/src/letter/__init__.py new file mode 100644 index 0000000..7f1f1dc --- /dev/null +++ b/src/letter/__init__.py @@ -0,0 +1,27 @@ +""" +Letter namespace - Alphabets, Letters, Ciphers, I Ching, Periodic Table, Words. + +Provides fluent query interface for: +- Alphabets (English, Hebrew, Greek) +- Ciphers and word encoding +- Hebrew letters with Tarot correspondences (paths) +- I Ching trigrams and hexagrams +- Periodic table with Sephiroth +- Word analysis and cipher operations + +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 .letter import letter +from .iChing import trigram, hexagram +from .words import word +from .paths import letters + +__all__ = ["letter", "trigram", "hexagram", "word", "letters"] + diff --git a/src/letter/attributes.py b/src/letter/attributes.py new file mode 100644 index 0000000..2cc4a91 --- /dev/null +++ b/src/letter/attributes.py @@ -0,0 +1,249 @@ +""" +Letter attributes and data structures. + +This module defines attributes specific to the Letter module, +including Alphabets, Enochian letters, and Double Letter Trumps. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Any + +from utils.attributes import ( + Element, + ElementType, + Planet, + Meaning, +) + + +@dataclass +class Letter: + """Represents a letter with its attributes.""" + character: str + position: int + name: str + + +@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}") + if len(self.letter) != 1 or not self.letter.isalpha(): + raise ValueError(f"Letter must be a single alphabetic character, got {self.letter}") + + +@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}") + + +@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}") + + +@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 + 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 + number_value: Optional[int] = None # Numerological value + keywords: List[str] = field(default_factory=list) + 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}") + + +@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 + tarot_correspondence: Optional[str] = None + planet: Optional[str] = None + element: Optional[str] = None + keywords: List[str] = field(default_factory=list) + description: str = "" + + +@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 + direction: Optional[str] = None # e.g., "East", "South", etc. + sigil: Optional[str] = None # ASCII representation or description + keywords: List[str] = field(default_factory=list) + description: str = "" + + +@dataclass(frozen=True) +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 + 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) + keywords: List[str] = field(default_factory=list) + description: str = "" + + 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): + return self.col_correspondences[col] + return None + + +@dataclass(frozen=True) +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 + north_letter: Optional[str] = None # Letter above + south_letter: Optional[str] = None # Letter below + east_letter: Optional[str] = None # Letter to the right + west_letter: Optional[str] = None # Letter to the left + tarot_card: Optional[str] = None # Associated Tarot card (e.g., "Ace of Swords") + tarot_suit: Optional[str] = None # Suit correspondence (Swords, Wands, Cups, Pentacles) + tarot_number: Optional[int] = None # Card number (0-13, 0=Ace) + element: Optional[str] = None # Element correspondence + zodiac_sign: Optional[str] = None # Zodiac correspondence + 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} + if self.north_letter: + letters["north"] = self.north_letter + if self.south_letter: + letters["south"] = self.south_letter + if self.east_letter: + letters["east"] = self.east_letter + if self.west_letter: + letters["west"] = self.west_letter + return letters + + +@dataclass(frozen=True) +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 + 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 + "Tablet of Earth", + "Tablet of Water", + "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)}" + ) + # Tablet of Union uses 0, elemental tablets use 1-5 + valid_range = (0, 0) if "Union" in self.name else (1, 5) + if not valid_range[0] <= self.number <= valid_range[1]: + 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)] diff --git a/src/letter/iChing.py b/src/letter/iChing.py new file mode 100644 index 0000000..3ef30cc --- /dev/null +++ b/src/letter/iChing.py @@ -0,0 +1,220 @@ +"""I Ching trigrams and hexagrams module. + +Provides fluent query interface for I Ching trigrams and hexagrams, +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 utils.query import CollectionAccessor + +if TYPE_CHECKING: + from tarot.card.data import CardDataLoader + from tarot.attributes import Trigram, Hexagram + + +def _line_diagram_from_binary(binary: str) -> str: + """Render a sideways ASCII diagram where top lines appear on the right.""" + if not binary: + return "" + cleaned = [bit for bit in binary if bit in {"0", "1"}] + if not cleaned: + return "" + symbol_map = {"1": "|", "0": ":"} + # Reverse so the right-most character represents the top line. + return "".join(symbol_map[bit] for bit in reversed(cleaned)) + + +class _Trigram: + """Fluent query accessor for I Ching trigrams.""" + + def __init__(self) -> None: + self._initialized: bool = False + 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 + + def _get_trigrams(self): + self._ensure_initialized() + return self._trigrams.copy() + + 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": "Lí", "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: + name = spec.get("name") + if not name: + raise ValueError("Trigram spec missing 'name'") + binary = spec.get("binary", "") + self._trigrams[name.lower()] = Trigram( + name=name, + chinese_name=spec.get("chinese", ""), + pinyin=spec.get("pinyin", ""), + element=spec.get("element", ""), + attribute=spec.get("attribute", ""), + binary=binary, + description=spec.get("description", ""), + line_diagram=_line_diagram_from_binary(binary), + ) + + +class _Hexagram: + """Fluent query accessor for I Ching hexagrams.""" + + def __init__(self) -> None: + self._initialized: bool = False + 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 + + def _get_hexagrams(self): + self._ensure_initialized() + return self._hexagrams.copy() + + def _load_hexagrams(self) -> None: + """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": "Xū", "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": "Bǐ", "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": "Lǚ", "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": "Pǐ", "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": "Yù", "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": "Gǔ", "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": "Bì", "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": "Bō", "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": "Fù", "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": "Yí", "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": "Lí", "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": "Yì", "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": "Gé", "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": "Lǚ", "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 = {} + for spec in hex_specs: + number = spec.get("number") + name = spec.get("name") + upper_name = spec.get("upper") + lower_name = spec.get("lower") + if number is None or not name or not upper_name or not lower_name: + continue + upper = trigram._trigrams.get(upper_name.lower()) + lower = trigram._trigrams.get(lower_name.lower()) + if upper is None or lower is None: + continue + assoc_number = loader._numbers.get(calculate_digital_root(number)) + planet_name = spec.get("planet") or planet_cycle[(number - 1) % len(planet_cycle)] + planet = loader._planets.get(planet_name.lower()) if planet_name else None + keywords_field = spec.get("keywords") + keywords = keywords_field.split("|") if keywords_field else [] + line_diagram = _line_diagram_from_binary(upper.binary + lower.binary) + self._hexagrams[number] = Hexagram( + number=number, + name=name, + chinese_name=spec.get("chinese", ""), + pinyin=spec.get("pinyin", ""), + judgement=spec.get("judgement", ""), + image=spec.get("image", ""), + upper_trigram=upper, + lower_trigram=lower, + keywords=keywords, + associated_number=assoc_number, + planetary_influence=planet, + line_diagram=line_diagram, + ) + + +# Create singleton instances +trigram = _Trigram() +hexagram = _Hexagram() diff --git a/src/letter/iChing_attributes.py b/src/letter/iChing_attributes.py new file mode 100644 index 0000000..6e744fa --- /dev/null +++ b/src/letter/iChing_attributes.py @@ -0,0 +1,42 @@ +""" +I Ching attributes and data structures. + +This module defines attributes specific to the I Ching system, +including Trigrams and Hexagrams. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from utils.attributes import Number, Planet + + +@dataclass +class Trigram: + """Represents one of the eight I Ching trigrams.""" + name: str + chinese_name: str + pinyin: str + element: str + attribute: str + binary: str + description: str = "" + line_diagram: str = "" + + +@dataclass +class Hexagram: + """Represents an I Ching hexagram with Tarot correspondence.""" + number: int + name: str + chinese_name: str + pinyin: str + judgement: str + image: str + upper_trigram: Trigram + lower_trigram: Trigram + keywords: List[str] = field(default_factory=list) + associated_number: Optional[Number] = None + planetary_influence: Optional[Planet] = None + notes: str = "" + line_diagram: str = "" diff --git a/src/letter/letter.py b/src/letter/letter.py new file mode 100644 index 0000000..bf60e79 --- /dev/null +++ b/src/letter/letter.py @@ -0,0 +1,87 @@ +"""Tarot letter namespace - fluent query interface.""" + +from typing import TYPE_CHECKING + +from utils.query import CollectionAccessor + +if TYPE_CHECKING: + from tarot.card.data import CardDataLoader + + +class Letter: + """Fluent query accessor for letters, alphabets, ciphers, and correspondences.""" + + def __init__(self) -> None: + self._initialized: bool = False + self._loader: 'CardDataLoader | None' = None + self.alphabet = CollectionAccessor(self._get_alphabets) + self.cipher = CollectionAccessor(self._get_ciphers) + self.letter = CollectionAccessor(self._get_letters) + self.iching = CollectionAccessor(self._get_hexagrams) + self.periodic = CollectionAccessor(self._get_periodic) + + def _ensure_initialized(self) -> None: + """Lazy-load data from CardDataLoader on first access.""" + if self._initialized: + return + + from tarot.card.data import CardDataLoader + self._loader = CardDataLoader() + self._initialized = True + + def _require_loader(self) -> 'CardDataLoader': + self._ensure_initialized() + assert self._loader is not None, "Loader not initialized" + return self._loader + + def _get_alphabets(self): + loader = self._require_loader() + return loader._alphabets.copy() + + def _get_ciphers(self): + loader = self._require_loader() + return loader._ciphers.copy() + + def _get_letters(self): + loader = self._require_loader() + return dict(loader.letter()) + + def _get_hexagrams(self): + loader = self._require_loader() + return loader._hexagrams.copy() + + def _get_periodic(self): + loader = self._require_loader() + return loader._periodic_table.copy() + + 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') + """ + loader = self._require_loader() + return loader.word(text, alphabet=alphabet) + + def __str__(self) -> str: + """Return a nice summary of the letter accessor.""" + return ( + "Letter Namespace - Alphabets, Letters, Ciphers, I Ching, Periodic Table, Words\n\n" + "Access methods:\n" + " letter.alphabet - English, Hebrew, Greek alphabets\n" + " letter.cipher - Cipher systems (English simple, Hebrew, etc.)\n" + " letter.letter - Hebrew letters (Aleph through Tau)\n" + " letter.word(text) - Encode text with cipher systems\n" + " letter.iching - I Ching trigrams and hexagrams\n" + " letter.periodic - Periodic table with Sephiroth" + ) + + def __repr__(self) -> str: + """Return a nice representation of the letter accessor.""" + return self.__str__() + + +# Create singleton instance +letter = Letter() diff --git a/src/letter/paths.py b/src/letter/paths.py new file mode 100644 index 0000000..41a93ce --- /dev/null +++ b/src/letter/paths.py @@ -0,0 +1,340 @@ +""" +Tarot Letters namespace - Hebrew letters with full correspondences. + +Provides fluent access to Hebrew letters (Paths on Tree of Life) organized by type: +- Mother Letters (3): Aleph, Mem, Shin +- Double Letters (7): Beth through Tzadi +- Simple Letters (12): Yodh through Tau + +Data is sourced from CardDataLoader.paths() for a single source of truth. +Each letter has attributes like: + - Hebrew Letter + - Zodiac (for simple letters) + - Trump (Major Arcana card) + - Four Color System (King, Queen, Prince, Princess) + - Cube of Space correspondence + - Intelligence/Archangel + - 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 + +if TYPE_CHECKING: + from utils.query import CollectionAccessor + from tarot.attributes import Path + + +@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 + 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.""" + # Extract first god's name from the path's associated gods + all_gods = self.path.get_gods() + 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 = [ + f"Hebrew: {self.hebrew_letter}", + f"Name: {self.transliteration}", + f"Type: {self.letter_type}", + f"Position: {self.position}", + ] + + if self.trump: + lines.append(f"Trump: {self.trump}") + if self.zodiac: + lines.append(f"Zodiac: {self.zodiac}") + if self.planet: + lines.append(f"Planet: {self.planet}") + if self.element: + lines.append(f"Element: {self.element}") + if self.intelligence: + lines.append(f"Intelligence: {self.intelligence}") + if self.meaning: + 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): + # Get by position (1-22) + for letter in self._letters.values(): + if letter.position == key: + 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] + + 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()] + + 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) + + 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") + Tarot.letters.filter(letter_type="Double", planet="Mars") + 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 = [] + for letter in self.all(): + 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.""" + return IChing() + + +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' + + 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)" + + +class LettersRegistry: + """Registry and accessor for all Hebrew letters with Tarot correspondences.""" + + _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) + + +def letters() -> LetterAccessor: + """Get the letters accessor for fluent queries.""" + registry = LettersRegistry() + return registry.accessor() diff --git a/src/letter/words/__init__.py b/src/letter/words/__init__.py new file mode 100644 index 0000000..93b78c4 --- /dev/null +++ b/src/letter/words/__init__.py @@ -0,0 +1,5 @@ +"""Words namespace - word cipher and gematria operations.""" + +from .word import word + +__all__ = ["word"] diff --git a/src/letter/words/word.py b/src/letter/words/word.py new file mode 100644 index 0000000..ae0da62 --- /dev/null +++ b/src/letter/words/word.py @@ -0,0 +1,40 @@ +"""Tarot word namespace - fluent cipher operations.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tarot.card.data import CardDataLoader + + +class _Word: + """Fluent accessor for word analysis and cipher operations.""" + + _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'): + """ + Start a fluent cipher request for the given text. + + Usage: + word.word('MAGICK').cipher('english_simple') + word.word('MAGICK', alphabet='hebrew').cipher('hebrew_standard') + """ + cls._ensure_initialized() + assert cls._loader is not None, "Loader not initialized" + return cls._loader.word(text, alphabet=alphabet) + + +# Create singleton instance +word = _Word() diff --git a/src/number/__init__.py b/src/number/__init__.py new file mode 100644 index 0000000..d36646d --- /dev/null +++ b/src/number/__init__.py @@ -0,0 +1,19 @@ +""" +Number namespace - Numerology and number correspondences. + +Provides fluent query interface for: +- Numbers 1-9 with Sepheric attributes +- Digital root calculation +- Colors and correspondences + +Usage: + from tarot import number + + num = number.number(5) + root = number.digital_root(256) + colors = number.color() +""" + +from .number import number, calculate_digital_root + +__all__ = ["number", "calculate_digital_root"] diff --git a/src/number/loader.py b/src/number/loader.py new file mode 100644 index 0000000..7cf8f8e --- /dev/null +++ b/src/number/loader.py @@ -0,0 +1,198 @@ +"""Numbers loader - access to numerology and number correspondences.""" + +from typing import Dict, Optional, Union, overload +from utils.filter import universal_filter + + +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 + >>> calculate_digital_root(99) # 9+9 = 18, 1+8 = 9 + 9 + >>> calculate_digital_root(5) + 5 + """ + 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 + _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']: + ... + + @classmethod + @overload + def number(cls, value: None = ...) -> Dict[int, 'Number']: + ... + + @classmethod + 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']: + ... + + @classmethod + @overload + def color(cls, sephera_number: None = ...) -> Dict[int, 'Color']: + ... + + @classmethod + 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']: + """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']: + """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) diff --git a/src/number/number.py b/src/number/number.py new file mode 100644 index 0000000..9652fa5 --- /dev/null +++ b/src/number/number.py @@ -0,0 +1,84 @@ +"""Tarot number namespace - fluent query interface for numerology.""" + +from typing import TYPE_CHECKING + +from utils.query import CollectionAccessor + +if TYPE_CHECKING: + from tarot.card.data import CardDataLoader + + +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 + + +class _Number: + """Fluent query accessor for numerology and number correspondences.""" + + def __init__(self) -> None: + self._initialized: bool = False + self._loader: 'CardDataLoader | None' = None + self.number = CollectionAccessor(self._get_numbers) + self.color = CollectionAccessor(self._get_colors) + self.cipher = CollectionAccessor(self._get_ciphers) + + def _ensure_initialized(self) -> None: + """Lazy-load data from CardDataLoader on first access.""" + if self._initialized: + return + + from tarot.card.data import CardDataLoader + self._loader = CardDataLoader() + self._initialized = True + + def _require_loader(self) -> 'CardDataLoader': + self._ensure_initialized() + assert self._loader is not None, "Loader not initialized" + return self._loader + + def _get_numbers(self): + loader = self._require_loader() + return loader.number().copy() + + def _get_colors(self): + loader = self._require_loader() + return loader.color().copy() + + def _get_ciphers(self): + loader = self._require_loader() + return loader._ciphers.copy() + + def digital_root(self, value: int) -> int: + """Get the digital root of a value.""" + return calculate_digital_root(value) + + def __str__(self) -> str: + """Return a nice summary of the number accessor.""" + return ( + "Number Namespace - Numerology and Number Correspondences\n\n" + "Access methods:\n" + " number.number(n) - Get number 1-9 with correspondences\n" + " number.color() - Get color correspondences\n" + " number.cipher() - Get cipher systems\n" + " number.digital_root(n) - Calculate digital root of any number" + ) + + def __repr__(self) -> str: + """Return a nice representation of the number accessor.""" + return self.__str__() + + +# Create singleton instance +number = _Number() diff --git a/src/tarot/__init__.py b/src/tarot/__init__.py new file mode 100644 index 0000000..4a08c8b --- /dev/null +++ b/src/tarot/__init__.py @@ -0,0 +1,179 @@ +""" +PY-Tarot: A comprehensive Python library for Tarot card reading and interpretation. + +This library provides: +- Full 78-card Tarot deck with Major and Minor Arcana +- Kabbalistic correspondences and Tree of Life data +- Multiple configurable cipher systems (Hebrew, English, Greek, Reduction) +- Tarot alphabets (English, Greek, Hebrew) with meanings +- Numbers 1-9 with Sepheric attributes and digital root calculation +- Crowley 777 color system with Sephiroth correspondences +- Full type hints for IDE support and type checking + +Unified Namespaces (singular names): + number - Numerology and number correspondences + letter - Alphabets (English, Hebrew, Greek), ciphers, and word analysis + Tarot - Tarot-specific (deck, cards, tree, cube, temporal) + +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 shared attributes from utils +from utils.attributes import ( + Note, Element, ElementType, Number, Color, Colorscale, + Planet, God, Cipher, CipherResult, Perfume, +) +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, + load_card_details, + load_deck_details, + get_cards_by_suit, + filter_cards_by_keywords, + print_card_details, + get_card_info, + ImageDeckLoader, + load_deck_images, +) + +# 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 + + +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__'): + # It's a dataclass - show all fields + print(f"{obj.__class__.__name__}:") + for field in fields(obj): + value = getattr(obj, field.name) + print(f" {field.name}: {value}") + else: + print(obj) + + +__version__ = "0.1.0" +__author__ = "PY-Tarot Contributors" +__all__ = [ + # Namespaces (singular) + "number", + "letter", + "kaballah", + "Tarot", + "trigram", + "hexagram", + + # Temporal and astrological + "ThalemaClock", + "AstrologyZodiac", + "PlanetPosition", + + # Card details and loading + "CardDetailsRegistry", + "load_card_details", + "load_deck_details", + "get_cards_by_suit", + "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", + "Weekday", + "Hour", + "ClockHour", + "Zodiac", + "Suit", + "Meaning", + "Letter", + "Note", + "CubeOfSpace", + "WallDirection", + "Wall", + + # Sepheric classes + "Sephera", + "PeriodicTable", + "Degree", + "Element", + "ElementType", + "AstrologicalInfluence", + "TreeOfLife", + "Correspondences", + "CardImage", + "DoublLetterTrump", + "EnochianTablet", + "EnochianGridPosition", + "EnochianArchetype", + + # Alphabet classes + "EnglishAlphabet", + "GreekAlphabet", + "HebrewAlphabet", + + # Number and color classes + "Number", + "Color", + "Planet", + "God", + "Trigram", + "Hexagram", + "Cipher", + "CipherResult", + + # Data loader and functions + "CardDataLoader", + "calculate_digital_root", +] diff --git a/src/tarot/attributes.py b/src/tarot/attributes.py new file mode 100644 index 0000000..3485120 --- /dev/null +++ b/src/tarot/attributes.py @@ -0,0 +1,129 @@ +""" +Tarot card attributes and Kabbalistic data structures. + +This module re-exports shared attributes from utils and defines Tarot-specific +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, +) +from letter.attributes import ( + Letter, + EnglishAlphabet, + GreekAlphabet, + HebrewAlphabet, + DoublLetterTrump, + EnochianLetter, + EnochianSpirit, + EnochianTablet, + EnochianGridPosition, + EnochianArchetype, +) +from letter.iChing_attributes import ( + Trigram, + Hexagram, +) +from temporal.attributes import ( + Month, + Weekday, + Hour, + ClockHour, + Zodiac, + Degree, + AstrologicalInfluence, +) + +# Alias Day to Weekday for backward compatibility (Day in this context was Day of Week) +Day = Weekday + +__all__ = [ + # Re-exported from utils + "Element", + "ElementType", + "Number", + "Color", + "Colorscale", + "Planet", + "God", + "Cipher", + "CipherResult", + "Perfume", + "Note", + # Re-exported from kaballah + "Sephera", + "PeriodicTable", + "TreeOfLife", + "Correspondences", + "Path", + # Re-exported from letter + "Letter", + "EnglishAlphabet", + "GreekAlphabet", + "HebrewAlphabet", + "DoublLetterTrump", + "EnochianLetter", + "EnochianSpirit", + "EnochianTablet", + "EnochianGridPosition", + "EnochianArchetype", + # Re-exported from letter.iChing + "Trigram", + "Hexagram", + # Re-exported from temporal + "Month", + "Day", + "Weekday", + "Hour", + "ClockHour", + "Zodiac", + "Degree", + "AstrologicalInfluence", + # Tarot-core classes defined below + "Suit", + "Meaning", + "CardImage", +] + + +@dataclass +class Suit: + """Represents a tarot suit.""" + name: str + element: 'ElementType' + tarot_correspondence: str + number: int + + +@dataclass +class CardImage: + """Represents an image associated with a card.""" + filename: str + artist: str + deck_name: str + url: Optional[str] = None + diff --git a/src/tarot/card/__init__.py b/src/tarot/card/__init__.py new file mode 100644 index 0000000..5335df6 --- /dev/null +++ b/src/tarot/card/__init__.py @@ -0,0 +1,26 @@ +"""Card namespace - access Tarot cards and deck information.""" + +from .card import CardAccessor +from .details import CardDetailsRegistry +from .loader import ( + 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", + "CardDetailsRegistry", + "load_card_details", + "load_deck_details", + "get_cards_by_suit", + "filter_cards_by_keywords", + "print_card_details", + "get_card_info", + "ImageDeckLoader", + "load_deck_images", +] diff --git a/src/tarot/card/card.py b/src/tarot/card/card.py new file mode 100644 index 0000000..ee322f1 --- /dev/null +++ b/src/tarot/card/card.py @@ -0,0 +1,331 @@ +""" +Tarot deck and card accessor module. + +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 + cards = Deck.card.filter(suit="Cups") # Get all Cups + 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 + + +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: + """ + 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') + 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): + lines.append(f" {attr_name}:") + lines.append(f" --- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent=4) + 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 + Tarot.deck.card.filter(arcana="Minor") # Get all Minor Arcana + Tarot.deck.card.filter(suit="Cups") # Get all Cups + 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 + _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']: + """Get a card by number.""" + self._ensure_initialized() + if self._deck is None: + return None + for card in self._deck.cards: + 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. + + Args: + **kwargs: Any Card attribute with its value + + Usage: + Tarot.deck.card.filter(arcana="Major") + Tarot.deck.card.filter(arcana="Minor", suit="Cups") + Tarot.deck.card.filter(number=5) + Tarot.deck.card.filter(element="Fire") + Tarot.deck.card.filter(pip=3) + + Returns: + CardList of Card objects matching all filters + """ + self._ensure_initialized() + 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: + **kwargs: Any Card attribute with its value + + Returns: + Formatted string with filtered cards + + Example: + print(Tarot.deck.card.display_filter(arcana="Major")) + """ + 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. + """ + self._ensure_initialized() + 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, + "", + "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})") + + 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] + 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) + 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] + 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) + 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']: + 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] + 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) + 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']: + if suit_name in suits_dict: + 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 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'): + 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'): + 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'): + keywords = card.suit.element.keywords + + suits_info[suit_name] = { + 'element': element_name, + 'zodiac': zodiac_signs, + 'keywords': keywords + } + + 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']: + lines.append(f" Zodiac: {', '.join(info['zodiac'])}") + 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) -> 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: + Formatted string with spread positions, drawn cards, and interpretations + + 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 + + # 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 str(reading) diff --git a/src/tarot/card/data.py b/src/tarot/card/data.py new file mode 100644 index 0000000..a3fde08 --- /dev/null +++ b/src/tarot/card/data.py @@ -0,0 +1,1750 @@ +"""Data loader and numerology helpers for Tarot correspondences.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, TYPE_CHECKING, Union, overload + +from ..attributes import ( + EnglishAlphabet, GreekAlphabet, HebrewAlphabet, + Number, Color, Colorscale, Planet, Weekday, ClockHour, + Hexagram, Cipher, CipherResult, Sephera, ElementType, PeriodicTable, + Month, Day, God, Perfume, Path +) +from ..deck import Deck, Card + +if TYPE_CHECKING: + from ..deck import TemporalQuery + + +@dataclass(frozen=True) +class TemporalCorrespondence: + """Snapshot of Tarot correspondences for a particular moment in time.""" + + timestamp: datetime + weekday: Optional[Weekday] + clock_hour: Optional[ClockHour] + card: Card + planet: Optional[Planet] + number: Optional[Number] + color: Optional[Color] + letters: List[str] = field(default_factory=list) + hexagram: Optional[Hexagram] = None + + +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 + >>> calculate_digital_root(99) # 9+9 = 18, 1+8 = 9 + 9 + >>> calculate_digital_root(5) + 5 + """ + 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 WordCipherRequest: + """Fluent helper for applying ciphers to a given word.""" + + def __init__(self, loader: 'CardDataLoader', text: str, default_alphabet: Optional[str] = None) -> None: + self._loader = loader + self._text = text + self._default_alphabet = default_alphabet + + def cipher(self, cipher_name: str, *, alphabet: Optional[str] = None) -> CipherResult: + """Apply the named cipher, optionally overriding the alphabet.""" + return self._loader._apply_cipher( + text=self._text, + cipher_name=cipher_name, + alphabet_name=alphabet or self._default_alphabet, + ) + + +class CardDataLoader: + """Provides access to all Kabbalistic data and card attributes.""" + + _numbers: Dict[int, Number] = {} + _colors: Dict[int, Color] = {} + _colorscales: Dict[int, Colorscale] = {} + _ciphers: Dict[str, Cipher] = {} + _alphabets: Dict[str, List] = {} + _sephera: Dict[int, Sephera] = {} + _planets: Dict[str, Planet] = {} + _weekdays: Dict[str, Weekday] = {} + _clock_hours: Dict[int, ClockHour] = {} + _elements: Dict[str, ElementType] = {} + _gods: Dict[str, God] = {} + _perfumes: Dict[str, Perfume] = {} + _paths: Dict[int, Path] = {} + _periodic_table: Dict[int, PeriodicTable] = {} + _initialized: bool = False + + def __init__(self) -> None: + """Initialize the data loader.""" + if not CardDataLoader._initialized: + self._load_all_data() + CardDataLoader._initialized = True + + @classmethod + def _load_all_data(cls) -> None: + """Load all Kabbalistic data.""" + cls._load_elements() + cls._load_sephera() + cls._load_colorscales() + cls._color() + cls._load_numbers() + cls._load_ciphers() + cls._load_alphabets() + cls._load_planets() + cls._load_periodic_table() + cls._load_weekdays() + cls._load_clock_hours() + cls._load_gods() + cls._load_perfumes() + cls._load_paths() + cls._attach_colorscales_to_paths() + cls._attach_path_correspondences() + + @classmethod + def _load_elements(cls) -> None: + """Load the five elemental types with Kabbalistic properties.""" + specs = [ + { + "name": "Fire", + "symbol": "△", + "direction": "South", + "polarity": "Active", + "color": "Red", + "tarot_suits": ["Wands"], + "zodiac_signs": ["Aries", "Leo", "Sagittarius"], + "keywords": ["Passion", "Will", "Energy", "Transformation"], + "description": "Pure creative drive and spiritual momentum." + }, + { + "name": "Water", + "symbol": "▽", + "direction": "West", + "polarity": "Passive", + "color": "Blue", + "tarot_suits": ["Cups"], + "zodiac_signs": ["Cancer", "Scorpio", "Pisces"], + "keywords": ["Emotion", "Intuition", "Receptivity", "Flow"], + "description": "Feeling, compassion, and subconscious depths." + }, + { + "name": "Air", + "symbol": "△ with line", + "direction": "East", + "polarity": "Active", + "color": "Yellow", + "tarot_suits": ["Swords"], + "zodiac_signs": ["Gemini", "Libra", "Aquarius"], + "keywords": ["Intellect", "Communication", "Clarity", "Thought"], + "description": "Mind, speech, and intellectual clarity." + }, + { + "name": "Earth", + "symbol": "▽ with line", + "direction": "North", + "polarity": "Passive", + "color": "Green", + "tarot_suits": ["Pentacles"], + "zodiac_signs": ["Taurus", "Virgo", "Capricorn"], + "keywords": ["Body", "Matter", "Manifestation", "Abundance"], + "description": "Physical form, material wealth, and grounding." + }, + { + "name": "Spirit", + "symbol": "•", + "direction": "Center", + "polarity": "Neutral", + "color": "White", + "tarot_suits": ["Major Arcana"], + "zodiac_signs": [], + "keywords": ["Unity", "Consciousness", "Transcendence", "Source"], + "description": "Quintessential synthesis transcending the four elements." + }, + ] + cls._elements = {} + for spec in specs: + name = spec.get("name") + if not name: + raise ValueError("Element spec missing 'name'") + cls._elements[name.lower()] = ElementType( + name=name, + symbol=spec.get("symbol", ""), + direction=spec.get("direction", ""), + polarity=spec.get("polarity", ""), + color=spec.get("color", ""), + tarot_suits=spec.get("tarot_suits", []), + zodiac_signs=spec.get("zodiac_signs", []), + keywords=spec.get("keywords", []), + description=spec.get("description", ""), + ) # type: ignore[arg-type] + + @classmethod + def _load_sephera(cls) -> None: + """Load the ten Sephiroth of the Tree of Life.""" + specs = [ + { + "number": 1, + "name": "Kether", + "hebrew_name": "כתר", + "meaning": "The Crown", + "archangel": "Metatron", + "order_of_angels": "Chayot ha Qodesh", + "mundane_chakra": "Primum Mobile", + }, + { + "number": 2, + "name": "Chokmah", + "hebrew_name": "חכמה", + "meaning": "Wisdom", + "archangel": "Ratziel", + "order_of_angels": "Auphanim", + "mundane_chakra": "Zodiac", + }, + { + "number": 3, + "name": "Binah", + "hebrew_name": "בינה", + "meaning": "Understanding", + "archangel": "Tzaphqiel", + "order_of_angels": "Aralim", + "mundane_chakra": "Saturn", + }, + { + "number": 4, + "name": "Chesed", + "hebrew_name": "חסד", + "meaning": "Mercy", + "archangel": "Tzadkiel", + "order_of_angels": "Hashmalim", + "mundane_chakra": "Jupiter", + }, + { + "number": 5, + "name": "Geburah", + "hebrew_name": "גבורה", + "meaning": "Strength", + "archangel": "Samael", + "order_of_angels": "Seraphim", + "mundane_chakra": "Mars", + }, + { + "number": 6, + "name": "Tiphereth", + "hebrew_name": "תפארת", + "meaning": "Beauty", + "archangel": "Raphael", + "order_of_angels": "Malachim", + "mundane_chakra": "Sun", + }, + { + "number": 7, + "name": "Netzach", + "hebrew_name": "נצח", + "meaning": "Victory", + "archangel": "Haniel", + "order_of_angels": "Tarshishim", + "mundane_chakra": "Venus", + }, + { + "number": 8, + "name": "Hod", + "hebrew_name": "הוד", + "meaning": "Splendor", + "archangel": "Michael", + "order_of_angels": "Beni Elohim", + "mundane_chakra": "Mercury", + }, + { + "number": 9, + "name": "Yesod", + "hebrew_name": "יסוד", + "meaning": "Foundation", + "archangel": "Gabriel", + "order_of_angels": "Cherubim", + "mundane_chakra": "Moon", + }, + { + "number": 10, + "name": "Malkuth", + "hebrew_name": "מלכות", + "meaning": "Kingdom", + "archangel": "Sandalphon", + "order_of_angels": "Ashim", + "mundane_chakra": "Earth", + }, + ] + for spec in specs: + number = spec.get("number") + name = spec.get("name") + if number is None or not name: + raise ValueError("Sephera spec missing required fields") + self_hebrew = spec.get("hebrew_name", "") + self_meaning = spec.get("meaning", "") + self_archangel = spec.get("archangel", "") + self_order = spec.get("order_of_angels", "") + self_mundane = spec.get("mundane_chakra", "") + cls._sephera[number] = Sephera( + number=number, + name=name, + hebrew_name=self_hebrew, + meaning=self_meaning, + archangel=self_archangel, + order_of_angels=self_order, + mundane_chakra=self_mundane, + element=None, + planetary_ruler=None, + tarot_trump=None, + ) + + @classmethod + def _load_colorscales(cls) -> None: + """Load Golden Dawn color scales for Sephiroth and Paths.""" + # 10 Sephiroth color scales + sephera_colorscales = [ + ("Kether", 1, "Brilliance", "White brilliance", "White brilliance", "White flecked gold", "Concealed Light"), + ("Chokmah", 2, "Pure soft blue", "Grey", "Blue pearl grey", "White flecked red, blue, yellow", "Sky Blue"), + ("Binah", 3, "Crimson", "Black", "Dark brown", "Grey flecked pink", "Yellow"), + ("Chesed", 4, "Deep violet", "Blue", "Deep purple", "Deep azure flecked yellow", "White"), + ("Geburah", 5, "Orange", "Scarlet red", "Bright scarlet", "Red flecked black", "Red"), + ("Tiphareth", 6, "Clear pink rose", "Yellow (gold)", "Rich salmon", "Gold amber", "White-red"), + ("Netzach", 7, "Amber", "Emerald", "Bright yellow green", "Olive flecked gold", "Whitish-red"), + ("Hod", 8, "Violet purple", "Orange", "Red-russet", "Yellow-brown flecked white", "Reddish-white"), + ("Yesod", 9, "Indigo", "Violet", "Very dark purple", "Citrine flecked azure", "White-red-whitish-red-reddish-white"), + ("Malkuth", 10, "Yellow", "Citrine, olive, russet, black", "Citrine, olive, russet, black flecked gold", "Black rayed yellow", "Light reflecting all colours"), + ] + + for name, number, king, queen, emperor, empress, sephirotic in sephera_colorscales: + colorscale = Colorscale( + name=name, + number=number, + king_scale=king, + queen_scale=queen, + emperor_scale=emperor, + empress_scale=empress, + sephirotic_color=sephirotic, + type="Sephira", + ) + cls._colorscales[number] = colorscale + # Update the sephera in _sephera dict with colorscale + if number in cls._sephera: + seph = cls._sephera[number] + # Create a new Sephera with the colorscale attached + new_seph = Sephera( + number=seph.number, + name=seph.name, + hebrew_name=seph.hebrew_name, + meaning=seph.meaning, + archangel=seph.archangel, + order_of_angels=seph.order_of_angels, + mundane_chakra=seph.mundane_chakra, + element=seph.element, + planetary_ruler=seph.planetary_ruler, + tarot_trump=seph.tarot_trump, + colorscale=colorscale, + ) + cls._sephera[number] = new_seph + + # 22 Paths color scales (numbers 11-32) + path_colorscales = [ + ("Aleph", 11, "Bright pale yellow", "Sky blue", "Blue emerald green", "Emerald flecked gold"), + ("Beth", 12, "Yellow", "Purple", "Grey", "Indigo rayed violet"), + ("Gimel", 13, "Blue", "Silver", "Cold pale blue", "Silver rayed sky-blue"), + ("Daleth", 14, "Emerald green", "Sky blue", "Early spring green", "Bright rose or cerise rayed pale yellow"), + ("He", 15, "Scarlet", "Red", "Brilliant flame", "Glowing red"), + ("Vau", 16, "Red orange", "Deep indigo", "Deep warm olive", "Rich brown"), + ("Zain", 17, "Orange", "Pale Mauve", "New yellow leather", "Reddish grey inclined to mauve"), + ("Cheth", 18, "Amber", "Maroon", "Rich bright russet", "Dark greenish brown"), + ("Teth", 19, "Yellow, greenish", "Deep purple", "Grey", "Reddish amber"), + ("Yod", 20, "Green, yellowish", "Slate grey", "Green grey", "Plum"), + ("Kaph", 21, "Violet", "Blue", "Rich purple", "Bright blue rayed yellow"), + ("Lamed", 22, "Emerald Green", "Blue", "Deep Blue-green", "Pale green"), + ("Mem", 23, "Deep blue", "Sea-green", "Deep olive-green", "White flecked purple"), + ("Nun", 24, "Green blue", "Dull brown", "Very dark brown", "Livid indigo brown"), + ("Samekh", 25, "Blue", "Yellow", "Green", "Dark vivid blue"), + ("Ayin", 26, "Indigo", "Black", "Blue black", "Cold dark grey, near black"), + ("Pe", 27, "Scarlet", "Red", "Venetian red", "Bright red rayed azure or emerald"), + ("Tzaddi", 28, "Crimson (ultra violet)", "Sky blue", "Blueish mauve", "White tinged purple"), + ("Qoph", 29, "Violet", "Buff flecked silver-white", "Light translucent pinksh brown", "Stone color"), + ("Resh", 30, "Orange", "Gold yellow", "Rich amber", "Amber rayed red"), + ("Shin", 31, "Glowing orange scarlet", "Vermilion", "Scarlet flecked gold", "Vermilion flecked crimson & emerald"), + ("Tau", 32, "Indigo", "Black", "Blue black", "Black rayed blue"), + ] + + for name, number, king, queen, emperor, empress in path_colorscales: + colorscale = Colorscale( + name=f"Path of {name}", + number=number, + king_scale=king, + queen_scale=queen, + emperor_scale=emperor, + empress_scale=empress, + type="Path", + ) + cls._colorscales[number] = colorscale + # Update the path in _paths dict with colorscale (paths are loaded after this) + # so we'll do this in _attach_colorscales_to_paths() after paths are loaded + + @classmethod + def _load_periodic_table(cls) -> None: + """Load Sephirothic periodic table with cross-correspondences.""" + specs = [ + { + "number": 1, + "name": "Unity", + "element": "Spirit", + "sephera_number": 1, + "planet": "None", + "tarot_trump": "0 - The Fool", + "keywords": ["Potential", "Source", "Being"], + }, + { + "number": 2, + "name": "Duality", + "element": "Fire", + "sephera_number": 2, + "planet": "None", + "tarot_trump": "I - The Magician", + "keywords": ["Polarity", "Will", "Masculine"], + }, + { + "number": 3, + "name": "Triad", + "element": "Water", + "sephera_number": 3, + "planet": "None", + "tarot_trump": "II - The High Priestess", + "keywords": ["Understanding", "Receptivity", "Feminine"], + }, + { + "number": 4, + "name": "Stability", + "element": "Water", + "sephera_number": 4, + "planet": "Jupiter", + "tarot_trump": "IV - The Emperor", + "keywords": ["Foundation", "Order", "Justice"], + }, + { + "number": 5, + "name": "Severity", + "element": "Fire", + "sephera_number": 5, + "planet": "Mars", + "tarot_trump": "V - The Hierophant", + "keywords": ["Strength", "Power", "Discipline"], + }, + { + "number": 6, + "name": "Equilibrium", + "element": "Air", + "sephera_number": 6, + "planet": "Sun", + "tarot_trump": "VI - The Lovers", + "keywords": ["Balance", "Integration", "Self"], + }, + { + "number": 7, + "name": "Desire", + "element": "Water", + "sephera_number": 7, + "planet": "Venus", + "tarot_trump": "VII - The Chariot", + "keywords": ["Passion", "Victory", "Art"], + }, + { + "number": 8, + "name": "Intellect", + "element": "Air", + "sephera_number": 8, + "planet": "Mercury", + "tarot_trump": "VIII - Strength", + "keywords": ["Wisdom", "Communication", "Logic"], + }, + { + "number": 9, + "name": "Foundation", + "element": "Air", + "sephera_number": 9, + "planet": "Moon", + "tarot_trump": "IX - The Hermit", + "keywords": ["Dreams", "Subconscious", "Imagination"], + }, + { + "number": 10, + "name": "Kingdom", + "element": "Earth", + "sephera_number": 10, + "planet": "Earth", + "tarot_trump": "X - Wheel of Fortune", + "keywords": ["Matter", "Manifestation", "Completion"], + }, + ] + for spec in specs: + entry_number = spec.get("number") + name = spec.get("name") + element = spec.get("element") + planet = spec.get("planet") + sephera_number = spec.get("sephera_number") + if entry_number is None or not name: + raise ValueError("Periodic table spec missing required fields") + sephera = cls._sephera.get(sephera_number) if sephera_number else None + element = cls._elements.get(element.lower()) if element else None + planet = ( + cls._planets.get(planet.lower()) + if planet and planet.lower() != "none" + else None + ) + number = cls._numbers.get(entry_number) + color = number.color if number else None + + cls._periodic_table[entry_number] = PeriodicTable( + number=entry_number, + name=name, + sephera=sephera, + element=element, + planet=planet, + color=color, + tarot_trump=spec.get("tarot_trump"), + hebrew_letter=None, + archangel=sephera.archangel if sephera else None, + order_of_angels=sephera.order_of_angels if sephera else None, + keywords=spec.get("keywords", []), + ) # type: ignore[arg-type] + + @classmethod + def _color(cls) -> None: + """Load Crowley 777 colors for all 10 Sephiroth.""" + cls._colors = { + 1: Color("White", "#FFFFFF", (255, 255, 255), "Kether", 1, "Spirit", "Atziluth", "Pure being", ["0 - The Fool"]), + 2: Color("Grey", "#808080", (128, 128, 128), "Chokmah", 2, "Fire", "Atziluth", "Pure creative force", ["I - The Magician"]), + 3: Color("Black", "#000000", (0, 0, 0), "Binah", 3, "Water", "Atziluth", "Receptive understanding", ["II - The High Priestess"]), + 4: Color("Violet", "#EE82EE", (238, 130, 238), "Chesed", 4, "Water", "Briah", "Mercy and expansion", ["IV - The Emperor"]), + 5: Color("Red Scarlet", "#FF0000", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Strength and severity", ["V - The Hierophant"]), + 6: Color("Gold", "#FFD700", (255, 215, 0), "Tiphareth", 6, "Fire", "Yetzirah", "Beauty and harmony", ["VI - The Lovers", "XIX - The Sun"]), + 7: Color("Green Emerald", "#50C878", (80, 200, 120), "Netzach", 7, "Water", "Yetzirah", "Victory and creativity", ["VII - The Chariot"]), + 8: Color("Yellow", "#FFFF00", (255, 255, 0), "Hod", 8, "Air", "Yetzirah", "Intellect and communication", ["VIII - Strength"]), + 9: Color("Violet Purple", "#9400D3", (148, 0, 211), "Yesod", 9, "Air", "Assiah", "Dreams and subconscious", ["XVII - The Star"]), + 10: Color("Russet Citrine Olive", "#8B4513", (139, 69, 19), "Malkuth", 10, "Earth", "Assiah", "Material manifestation", ["XXI - The World"]), + } + + @classmethod + def _load_numbers(cls) -> None: + """Load numbers 1-9 with Kabbalistic attributes dynamically from Sephera.""" + cls._numbers = {} + + # Build Number objects dynamically from Sephera data (1-9 map to Sephera 1-9) + for num in range(1, 10): + sephera = cls._sephera.get(num) + periodic = cls._periodic_table.get(num) + color = cls._colors.get(num) + + if sephera: + # Calculate compliment (1↔8, 2↔7, 3↔6, 4↔5, 9↔9) + compliment = 9 - num if num != 9 else 9 + + # Get element from Periodic Table + element_name = periodic.element.name if periodic and periodic.element else "Spirit" + + cls._numbers[num] = Number( + value=num, + sephera=sephera.name, + element=element_name, + compliment=compliment, + color=color, + ) + + @classmethod + def _load_ciphers(cls) -> None: + """Load cipher patterns independent of alphabet association.""" + cipher_specs = [ + { + "key": "hebrew_standard", + "name": "Hebrew Standard Cipher", + "pattern": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 20, 30, 40, 50, 60, 70, 80, 90, + 100, 200, 300, 400, + ], + "default_alphabet": "hebrew", + }, + { + "key": "english_simple", + "name": "English Simple Cipher", + "pattern": list(range(1, 27)), + "default_alphabet": "english", + }, + { + "key": "greek_isopsephy", + "name": "Greek Isopsephy Cipher", + "pattern": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 20, 30, 40, 50, 60, 70, 80, 90, + 100, 200, 300, 400, 500, 600, 700, 800, + ], + "default_alphabet": "greek", + }, + { + "key": "kabbalah_three_mother", + "name": "Kabbalistic Three Mother Cipher", + "pattern": [1, 40, 300], + "default_alphabet": "hebrew", + "letter_subset": ["א", "מ", "ש"], + }, + { + "key": "reduction", + "name": "Pythagorean Reduction Cipher", + "pattern": [1, 2, 3, 4, 5, 6, 7, 8, 9], + "default_alphabet": "english", + "cycle": True, + }, + ] + cls._ciphers = {} + for spec in cipher_specs: + subset = spec.get("letter_subset") + key = spec["key"].lower() + cls._ciphers[key] = Cipher( + name=spec["name"], + key=key, + pattern=spec["pattern"], + cycle=spec.get("cycle", False), + default_alphabet=spec.get("default_alphabet"), + letter_subset=set(subset) if subset else None, + description=spec.get("description", ""), + ) + + @classmethod + def _load_alphabets(cls) -> None: + """Load all alphabets (English, Greek, Hebrew).""" + hebrew_specs = [ + ("א", "Aleph"), + ("ב", "Beth"), + ("ג", "Gimel"), + ("ד", "Daleth"), + ("ה", "Heh"), + ("ו", "Vav"), + ("ז", "Zayin"), + ("ח", "Cheth"), + ("ט", "Teth"), + ("י", "Yod"), + ("כ", "Kaph"), + ("ל", "Lamed"), + ("מ", "Mem"), + ("נ", "Nun"), + ("ס", "Samekh"), + ("ע", "Ayin"), + ("פ", "Pe"), + ("צ", "Tzaddi"), + ("ק", "Qoph"), + ("ר", "Resh"), + ("ש", "Shin"), + ("ת", "Tav"), + ] + + cls._alphabets = { + "english": [ + EnglishAlphabet(chr(65 + i), i + 1, "Standard phonetic") + for i in range(26) + ], + "greek": [ + GreekAlphabet(chr(0x0391 + i) if i < 17 else chr(0x03A3 + i - 17), + i + 1, f"Greek_{i+1}") + for i in range(24) + ], + "hebrew": [ + HebrewAlphabet(letter, idx + 1, name, f"Meaning_{idx + 1}") + for idx, (letter, name) in enumerate(hebrew_specs) + ] + } + + @classmethod + def _load_planets(cls) -> None: + """Load planetary correspondences with references to numbers and colors.""" + specs = [ + { + "name": "Sun", + "symbol": "☉", + "element": "Fire", + "ruling_zodiac": ["Leo"], + "numbers": [6], + "letters": ["Resh"], + "keywords": ["Vitality", "Consciousness", "Integration"], + "description": "Centering principle that radiates will and creativity.", + }, + { + "name": "Moon", + "symbol": "☾", + "element": "Water", + "ruling_zodiac": ["Cancer"], + "numbers": [9], + "letters": ["Gimel"], + "keywords": ["Reflection", "Dreams", "Cycles"], + "description": "Instinctual, reflective, and subconscious currents.", + }, + { + "name": "Mercury", + "symbol": "☿", + "element": "Air", + "ruling_zodiac": ["Gemini", "Virgo"], + "numbers": [8], + "letters": ["Beth"], + "keywords": ["Communication", "Analysis", "Movement"], + "description": "Messenger force linking ideas, language, and commerce.", + }, + { + "name": "Venus", + "symbol": "♀", + "element": "Water", + "ruling_zodiac": ["Taurus", "Libra"], + "numbers": [7], + "letters": ["Daleth"], + "keywords": ["Harmony", "Beauty", "Attraction"], + "description": "Creative cohesion through art, love, and receptivity.", + }, + { + "name": "Mars", + "symbol": "♂", + "element": "Fire", + "ruling_zodiac": ["Aries", "Scorpio"], + "numbers": [5], + "letters": ["Pe"], + "keywords": ["Assertion", "Courage", "Catalyst"], + "description": "Force of action that tests limits and forges change.", + }, + { + "name": "Jupiter", + "symbol": "♃", + "element": "Air", + "ruling_zodiac": ["Sagittarius", "Pisces"], + "numbers": [4], + "letters": ["Kaph"], + "keywords": ["Expansion", "Wisdom", "Benevolence"], + "description": "Amplifying principle that seeks growth and meaning.", + }, + { + "name": "Saturn", + "symbol": "♄", + "element": "Earth", + "ruling_zodiac": ["Capricorn", "Aquarius"], + "numbers": [3], + "letters": ["Tav"], + "keywords": ["Structure", "Discipline", "Time"], + "description": "Form-giving boundary that crystallizes responsibility.", + }, + { + "name": "Earth", + "symbol": "🜃", + "element": "Earth", + "ruling_zodiac": ["All"], + "numbers": [10], + "letters": ["Heh Final"], + "keywords": ["Manifestation", "Matter", "Stability"], + "description": "Physical plane of expression and culmination.", + }, + ] + cls._planets = {} + for spec in specs: + name = spec.get("name") + if not name: + raise ValueError("Planet spec missing 'name'") + number_refs = spec.get("numbers", []) + numbers: List[Number] = [] + for num in number_refs: + number = cls._numbers.get(num) + if number is not None: + numbers.append(number) + color_key = number_refs[0] if number_refs else None + color_ref = cls._colors.get(color_key) if color_key is not None else None + cls._planets[name.lower()] = Planet( + name=name, + symbol=spec.get("symbol", ""), + element=spec.get("element", ""), + ruling_zodiac=spec.get("ruling_zodiac", []), + associated_numbers=numbers, + associated_letters=spec.get("letters", []), + keywords=spec.get("keywords", []), + color=color_ref, + description=spec.get("description", ""), + ) + + @staticmethod + def _card_for_moment(moment: datetime) -> Card: + """Return a deterministic card for the provided datetime.""" + deck = Deck() + minutes_since_year_start = ( + (moment.timetuple().tm_yday - 1) * 24 * 60 + + moment.hour * 60 + + moment.minute + ) + index = minutes_since_year_start % len(deck.cards) + return deck.cards[index] + + @classmethod + def _load_weekdays(cls) -> None: + """Load weekdays/weekends with planetary ties.""" + specs = [ + {"number": 1, "name": "Monday", "planet": "Moon", "is_weekend": False, "keywords": ["Reflection", "Cycles", "Dreams"]}, + {"number": 2, "name": "Tuesday", "planet": "Mars", "is_weekend": False, "keywords": ["Action", "Courage", "Heat"]}, + {"number": 3, "name": "Wednesday", "planet": "Mercury", "is_weekend": False, "keywords": ["Communication", "Commerce", "Adapt"]}, + {"number": 4, "name": "Thursday", "planet": "Jupiter", "is_weekend": False, "keywords": ["Expansion", "Teaching", "Grace"]}, + {"number": 5, "name": "Friday", "planet": "Venus", "is_weekend": False, "keywords": ["Love", "Beauty", "Harmony"]}, + {"number": 6, "name": "Saturday", "planet": "Saturn", "is_weekend": True, "keywords": ["Discipline", "Boundaries", "Structure"]}, + {"number": 7, "name": "Sunday", "planet": "Sun", "is_weekend": True, "keywords": ["Vitality", "Renewal", "Community"]}, + ] + cls._weekdays = {} + for spec in specs: + name = spec.get("name") + number = spec.get("number") + planet = spec.get("planet") + if not name or number is None or not planet: + raise ValueError("Weekday spec missing required fields") + cls._weekdays[name.lower()] = Weekday( + number=number, + name=name, + planetary_correspondence=planet, + is_weekend=spec.get("is_weekend", False), + keywords=spec.get("keywords", []), + ) + + @classmethod + def _load_clock_hours(cls) -> None: + """Load a 24-hour clock table with AM/PM designations.""" + chaldean_order = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"] + cls._clock_hours = {} + for hour in range(24): + period = "AM" if hour < 12 else "PM" + hour_12 = hour % 12 or 12 + planet = chaldean_order[hour % len(chaldean_order)] + emphasis_map = { + "Saturn": "discipline", + "Jupiter": "vision", + "Mars": "courage", + "Sun": "radiance", + "Venus": "attraction", + "Mercury": "intellect", + "Moon": "intuition", + } + description = f"Hour ruled by {planet} emphasizing {emphasis_map.get(planet, 'balance')}." + cls._clock_hours[hour] = ClockHour( + hour_24=hour, + hour_12=hour_12, + period=period, + planetary_ruler=planet, + description=description, + ) + + + @classmethod + def _load_gods(cls) -> None: + """Load a unified god registry spanning Egyptian, Greek, and Roman pantheons.""" + specs = [ + { + "name": "Ptah", + "culture": "Egyptian", + "pantheon": "Memphite", + "domains": ["Creation", "Craftsmanship", "Intelligence"], + "epithets": ["Creator of All", "Father of the Gods"], + "mythology": "Primordial creator who brought forth all existence through spoken word.", + "sephera_numbers": [1], + "elements": ["Spirit"], + }, + { + "name": "Nuith", + "culture": "Egyptian", + "pantheon": "Thelemic", + "domains": ["Sky", "Infinity", "Wisdom"], + "epithets": ["Star Goddess", "Infinite Mother"], + "mythology": "Sky goddess representing boundless expansion and cosmic wisdom.", + "sephera_numbers": [2], + "elements": ["Air"], + }, + { + "name": "Maut", + "culture": "Egyptian", + "pantheon": "Theban", + "domains": ["Understanding", "Matter", "Manifestation"], + "epithets": ["Mother of All", "Cosmic Womb"], + "mythology": "Great mother goddess embodying the receptive principle of manifestation.", + "sephera_numbers": [3], + "planets": ["Saturn"], + "elements": ["Water"], + "tarot_trumps": ["II - The Priestess"], + }, + { + "name": "Amoun", + "culture": "Egyptian", + "pantheon": "Theban", + "domains": ["Mercy", "Providence", "Prosperity"], + "epithets": ["The Hidden One", "King of Gods"], + "mythology": "Hidden supreme force channeling mercy and abundance to all beings.", + "sephera_numbers": [4], + "planets": ["Jupiter"], + "elements": ["Water"], + "tarot_trumps": ["IV - The Emperor"], + }, + { + "name": "Horus", + "culture": "Egyptian", + "pantheon": "Solar", + "domains": ["Strength", "War", "Justice"], + "epithets": ["The Mighty One", "Avenger"], + "mythology": "Warrior god embodying divine justice and forceful manifestation.", + "sephera_numbers": [5], + "planets": ["Mars"], + "elements": ["Fire"], + "tarot_trumps": ["XVI - The Tower"], + }, + { + "name": "Ra", + "culture": "Egyptian", + "pantheon": "Solar", + "domains": ["Beauty", "Consciousness", "Solar Force"], + "epithets": ["Sun God", "Divine Child", "Daily Renewal"], + "mythology": "Solar deity representing the conscious self and daily renewal of spirit.", + "sephera_numbers": [6], + "planets": ["Sun"], + "elements": ["Fire"], + "tarot_trumps": ["XIX - The Sun"], + }, + { + "name": "Hathoor", + "culture": "Egyptian", + "pantheon": "Solar", + "domains": ["Victory", "Love", "Passion"], + "epithets": ["Lady of Love", "Mistress of Dance"], + "mythology": "Goddess of love, joy, and victory through emotional connection.", + "sephera_numbers": [7], + "planets": ["Venus"], + "elements": ["Water"], + "tarot_trumps": ["III - The Empress"], + }, + { + "name": "Thoth", + "culture": "Egyptian", + "pantheon": "Hermopolitan", + "domains": ["Splendor", "Intellect", "Writing"], + "epithets": ["The Scribe", "Master of Wisdom"], + "mythology": "God of wisdom, writing, and intellectual mastery of all knowledge.", + "sephera_numbers": [8], + "planets": ["Mercury"], + "elements": ["Air"], + "tarot_trumps": ["I - The Magus"], + "path_numbers": [12], + }, + { + "name": "Shu", + "culture": "Egyptian", + "pantheon": "Heliopolitan", + "domains": ["Foundation", "Air", "Clarity"], + "epithets": ["The Lifter", "Wind God"], + "mythology": "God of air and clarity, lifting consciousness between states of being.", + "sephera_numbers": [9], + "elements": ["Air"], + "planets": ["Moon"], + "path_numbers": [11], + }, + { + "name": "Seb", + "culture": "Egyptian", + "pantheon": "Heliopolitan", + "domains": ["Kingdom", "Earth", "Manifestation"], + "epithets": ["Earth God", "The Solid One"], + "mythology": "God of the earth, providing solid foundation for all physical manifestation.", + "sephera_numbers": [10], + "planets": ["Earth"], + "elements": ["Earth"], + "path_numbers": [32], + }, + { + "name": "Apollo", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Prophecy", "Music", "Healing", "Light"], + "epithets": ["Phoebus", "Far-shooter"], + "planets": ["Sun"], + "elements": ["Fire"], + "description": "Solar consciousness integrating harmony and order per Liber 777.", + "tarot_trumps": ["XIX - The Sun"], + }, + { + "name": "Artemis", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Cycles", "Protection", "Hunt", "Mysteries"], + "epithets": ["Selene", "Torch-Bearer"], + "planets": ["Moon"], + "elements": ["Water"], + "tarot_trumps": ["II - The Priestess"], + }, + { + "name": "Hermes", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Messages", "Alchemy", "Commerce", "Travel"], + "epithets": ["Trismegistus", "Psychopomp"], + "planets": ["Mercury"], + "elements": ["Air"], + "tarot_trumps": ["I - The Magus"], + }, + { + "name": "Aphrodite", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Attraction", "Beauty", "Artists", "Union"], + "epithets": ["Pandemos", "Anadyomene"], + "planets": ["Venus"], + "elements": ["Water"], + "tarot_trumps": ["III - The Empress"], + }, + { + "name": "Ares", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Conflict", "Courage", "Blood", "Guardianship"], + "epithets": ["Enyalius"], + "planets": ["Mars"], + "elements": ["Fire"], + "tarot_trumps": ["XVI - The Tower"], + }, + { + "name": "Zeus", + "culture": "Greek", + "pantheon": "Olympian", + "domains": ["Thunder", "Law", "Kingship", "Abundance"], + "epithets": ["Olympios", "Xenios"], + "planets": ["Jupiter"], + "elements": ["Air"], + "tarot_trumps": ["X - Wheel of Fortune"], + }, + { + "name": "Cronus", + "culture": "Greek", + "pantheon": "Titan", + "domains": ["Time", "Harvest", "Cycles", "Thresholds"], + "epithets": ["Lord of the Boundaries"], + "planets": ["Saturn"], + "elements": ["Earth"], + "tarot_trumps": ["XXI - The Universe"], + }, + { + "name": "Gaia", + "culture": "Greek", + "pantheon": "Primordial", + "domains": ["Fertility", "Stability", "All Life"], + "epithets": ["World Mother"], + "planets": ["Earth"], + "elements": ["Earth"], + "tarot_trumps": ["XXI - The Universe"], + }, + { + "name": "Sol", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Sovereignty", "Vitality", "Clarity"], + "epithets": ["Invictus"], + "planets": ["Sun"], + "elements": ["Fire"], + }, + { + "name": "Diana", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Moonlight", "Hunt", "Childbirth"], + "epithets": ["Lucifera"], + "planets": ["Moon"], + "elements": ["Water"], + }, + { + "name": "Mercury", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Trade", "Negotiation", "Speed"], + "epithets": ["Argeiphontes"], + "planets": ["Mercury"], + "elements": ["Air"], + }, + { + "name": "Venus", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Love", "Gardens", "Magnetism"], + "epithets": ["Genetrix"], + "planets": ["Venus"], + "elements": ["Water"], + }, + { + "name": "Mars", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Military", "Discipline", "Defense"], + "epithets": ["Gradivus"], + "planets": ["Mars"], + "elements": ["Fire"], + }, + { + "name": "Jupiter", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Oaths", "Weather", "Senate"], + "epithets": ["Optimus Maximus"], + "planets": ["Jupiter"], + "elements": ["Air"], + }, + { + "name": "Saturn", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Agriculture", "Ages", "Structure"], + "epithets": ["Senex"], + "planets": ["Saturn"], + "elements": ["Earth"], + }, + { + "name": "Terra", + "culture": "Roman", + "pantheon": "Imperial", + "domains": ["Soil", "Abundance", "Protection"], + "epithets": ["Mater"], + "planets": ["Earth"], + "elements": ["Earth"], + }, + ] + + cls._gods = {} + for spec in specs: + name = spec.get("name") + culture = spec.get("culture") + pantheon = spec.get("pantheon", culture or "") + if not name or not culture: + continue + sephera_numbers = spec.get("sephera_numbers", []) + path_numbers = spec.get("path_numbers", []) + planets = spec.get("planets", []) + elements = list(spec.get("elements", [])) + zodiac_signs = list(spec.get("zodiac_signs", [])) + associated_planet = None + associated_numbers: List[Number] = [] + if planets: + planet = cls._planets.get(planets[0].lower()) + if planet: + associated_planet = planet + associated_numbers = list(planet.associated_numbers) + if not elements and planet.element: + elements = [planet.element] + if not zodiac_signs: + zodiac_signs = list(planet.ruling_zodiac) + associated_element = None + if elements: + element_ref = cls._elements.get(elements[0].lower()) + if element_ref: + associated_element = element_ref + god = God( + name=name, + culture=culture, + pantheon=pantheon, + domains=spec.get("domains", []), + epithets=spec.get("epithets", []), + mythology=spec.get("mythology", spec.get("description", "")), + sephera_numbers=sephera_numbers, + path_numbers=path_numbers, + planets=planets, + elements=elements, + zodiac_signs=zodiac_signs, + associated_planet=associated_planet, + associated_element=associated_element, + associated_numbers=associated_numbers, + tarot_trumps=spec.get("tarot_trumps", []), + keywords=spec.get("keywords", []), + description=spec.get("description", spec.get("mythology", "")), + ) # type: ignore[arg-type] + cls._gods[name.lower()] = god + + @classmethod + def _attach_colorscales_to_paths(cls) -> None: + """Attach colorscales to paths after they are loaded.""" + for path_number, colorscale in list(cls._colorscales.items()): + if path_number in cls._paths and colorscale.type == "Path": + path = cls._paths[path_number] + # Create a new Path with the colorscale + cls._paths[path_number] = Path( + number=path.number, + hebrew_letter=path.hebrew_letter, + transliteration=path.transliteration, + tarot_trump=path.tarot_trump, + sephera_from=path.sephera_from, + sephera_to=path.sephera_to, + element=path.element, + planet=path.planet, + zodiac_sign=path.zodiac_sign, + colorscale=colorscale, + perfumes=path.perfumes, + gods=path.gods, + keywords=path.keywords, + description=path.description, + ) + + @classmethod + def _attach_path_correspondences(cls) -> None: + """Populate each Path container with its associated gods and perfumes.""" + if not cls._paths: + return + + for path in cls._paths.values(): + path.perfumes.clear() + path.gods.clear() + + for perfume in cls._perfumes.values(): + if perfume.path_number is None: + continue + path = cls._paths.get(perfume.path_number) + if path is not None: + path.add_perfume(perfume) + + god_entries = list(cls._gods.values()) + for path in cls._paths.values(): + sephera_numbers = [ + path.sephera_from.number if path.sephera_from else None, + path.sephera_to.number if path.sephera_to else None, + ] + element_name = path.element.name if path.element else None + planet_name = path.planet.name if path.planet else None + zodiac_sign = path.zodiac_sign + + for god in god_entries: + if path.number in god.path_numbers: + path.add_god(god) + continue + if planet_name and any(planet_name.lower() == p.lower() for p in god.planets): + path.add_god(god) + if element_name and any(element_name.lower() == e.lower() for e in god.elements): + path.add_god(god) + if zodiac_sign and any(zodiac_sign.lower() == z.lower() for z in god.zodiac_signs): + path.add_god(god) + if any(num is not None and num in god.sephera_numbers for num in sephera_numbers): + path.add_god(god) + + @classmethod + def _load_perfumes(cls) -> None: + """Load perfume/incense correspondences for Sephiroth and Paths.""" + specs = [ + # Sephiroth perfumes (Liber 777, Column XLII) + {"name": "Ambergris", "sephera": 1, "element": None, "scent": "Warm, animalic, oceanic", "magical_uses": ["Consecration", "Elevation", "Crown work"]}, + {"name": "Musk", "sephera": 2, "element": None, "scent": "Dense, earthy, masculine", "magical_uses": ["Wisdom work", "Sexuality", "Primal force"]}, + {"name": "Myrrh", "sephera": 3, "element": None, "scent": "Smoky, bitter, resinous", "magical_uses": ["Understanding", "Purification", "Death work"]}, + {"name": "Cedar", "sephera": 4, "element": None, "scent": "Woody, warm, noble", "magical_uses": ["Mercy", "Benevolence", "Wealth magic"]}, + {"name": "Tobacco", "sephera": 5, "element": None, "scent": "Smoky, acrid, masculine", "magical_uses": ["Strength", "War", "Courage"]}, + {"name": "Olibanum", "sephera": 6, "element": None, "scent": "Resinous, sacred, solar", "magical_uses": ["Illumination", "Consciousness", "Self-realization"]}, + {"name": "Benzoin", "sephera": 7, "element": None, "scent": "Sweet, balsamic, warm", "magical_uses": ["Love", "Victory", "Passion"]}, + {"name": "Storax", "sephera": 8, "element": None, "scent": "Sweet, spicy, intellectual", "magical_uses": ["Communication", "Intellect", "Mercury work"]}, + {"name": "Jasmine", "sephera": 9, "element": None, "scent": "Floral, sweet, nocturnal", "magical_uses": ["Dreams", "Psychism", "Moon work"]}, + {"name": "Dittany of Crete", "sephera": 10, "element": None, "scent": "Herbaceous, earthy, grounding", "magical_uses": ["Manifestation", "Grounding", "Physical presence"]}, + + # Path perfumes - Elements (Liber 777, Column XLII) + {"name": "Galbanum", "path": 11, "element": "Air", "scent": "Green, fresh, airy", "magical_uses": ["Air element work", "Communication", "Mental clarity"]}, + {"name": "Onycha", "path": 23, "element": "Water", "scent": "Marine, salty, aquatic", "magical_uses": ["Water element", "Emotions", "Subconscious"]}, + {"name": "Olibanum", "path": 31, "element": "Fire", "scent": "Resinous, sacred fire", "magical_uses": ["Fire element", "Spiritual vision", "Illumination"]}, + {"name": "Storax", "path": 32, "element": "Earth", "scent": "Earthy, heavy, grounding", "magical_uses": ["Earth element", "Manifestation", "Stability"]}, + + # Path perfumes - Planets (Liber 777, Column XLII) + {"name": "Mastic", "path": 12, "planet": "Mercury", "scent": "Resinous, clean, mercurial", "magical_uses": ["Communication", "Commerce", "Quick wit"]}, + {"name": "Camphor", "path": 13, "planet": "Moon", "scent": "Sharp, cool, lunar", "magical_uses": ["Dreams", "Psychism", "Nurturing"]}, + {"name": "Sandalwood", "path": 14, "planet": "Venus", "scent": "Sweet, woody, sensual", "magical_uses": ["Love", "Beauty", "Attraction"]}, + {"name": "Saffron", "path": 21, "planet": "Jupiter", "scent": "Precious, warm, royal", "magical_uses": ["Prosperity", "Luck", "Expansion"]}, + {"name": "Pepper", "path": 27, "planet": "Mars", "scent": "Hot, pungent, aggressive", "magical_uses": ["Courage", "Action", "War"]}, + {"name": "Cinnamon", "path": 30, "planet": "Sun", "scent": "Warm, spicy, solar", "magical_uses": ["Success", "Vitality", "Solar power"]}, + {"name": "Assafoetida", "path": 32, "planet": "Saturn", "scent": "Foul, sulfurous, dark", "magical_uses": ["Banishing", "Binding", "Protection"]}, + + # Path perfumes - Zodiac (sample from Liber 777, Column CLII-CLIV) + {"name": "Dragon's Blood", "path": 15, "zodiac": "Aries", "scent": "Warm, resinous, sharp", "magical_uses": ["Martial energy", "Courage", "Victory"]}, + {"name": "Storax", "path": 16, "zodiac": "Taurus", "scent": "Sweet, earthy, grounding", "magical_uses": ["Stability", "Prosperity", "Earth connection"]}, + {"name": "Wormwood", "path": 17, "zodiac": "Gemini", "scent": "Bitter, green, intellectual", "magical_uses": ["Communication", "Travel", "Wit"]}, + {"name": "Onycha", "path": 18, "zodiac": "Cancer", "scent": "Marine, salty, intuitive", "magical_uses": ["Emotions", "Home", "Protection"]}, + {"name": "Olibanum", "path": 19, "zodiac": "Leo", "scent": "Resinous, solar, regal", "magical_uses": ["Confidence", "Creativity", "Leadership"]}, + {"name": "Narcissus", "path": 20, "zodiac": "Virgo", "scent": "Floral, sweet, discriminating", "magical_uses": ["Analysis", "Health", "Service"]}, + {"name": "Galbanum", "path": 22, "zodiac": "Libra", "scent": "Fresh, balanced, airy", "magical_uses": ["Balance", "Justice", "Harmony"]}, + {"name": "Siamese Benzoin", "path": 24, "zodiac": "Scorpio", "scent": "Sweet, dark, transformative", "magical_uses": ["Transformation", "Depth", "Power"]}, + {"name": "Lign-aloes", "path": 25, "zodiac": "Sagittarius", "scent": "Woody, spicy, expansive", "magical_uses": ["Expansion", "Prophecy", "Adventure"]}, + {"name": "Musk", "path": 26, "zodiac": "Capricorn", "scent": "Dense, earthy, grounding", "magical_uses": ["Ambition", "Authority", "Structure"]}, + {"name": "Galbanum", "path": 28, "zodiac": "Aquarius", "scent": "Fresh, airy, visionary", "magical_uses": ["Innovation", "Friendship", "Vision"]}, + {"name": "Ambergris", "path": 29, "zodiac": "Pisces", "scent": "Warm, oceanic, mystical", "magical_uses": ["Spirituality", "Compassion", "Mysticism"]}, + ] + + cls._perfumes = {} + for spec in specs: + perfume_name = spec.get("name", "") + sephera_num = spec.get("sephera") + path_num = spec.get("path") + + perfume = Perfume( + name=perfume_name, + scent_profile=spec.get("scent", ""), + sephera_number=sephera_num, + path_number=path_num, + element=spec.get("element"), + planet=spec.get("planet"), + zodiac_sign=spec.get("zodiac"), + keywords=[], + magical_uses=spec.get("magical_uses", []), + description=spec.get("scent", ""), + ) + cls._perfumes[perfume_name.lower()] = perfume + + @classmethod + def _load_paths(cls) -> None: + """Load the 22 Paths with full Tree of Life correspondences.""" + # Path specifications mapping to Thelemapedia data + path_specs = [ + {"num": 11, "hebrew": "Aleph", "trans": "Aleph", "trump": "0 - The Fool", "from": 1, "to": 2, "element": "Air", "planet": None, "zodiac": None}, + {"num": 12, "hebrew": "Beth", "trans": "Beth", "trump": "I - The Magus", "from": 1, "to": 3, "element": None, "planet": "Mercury", "zodiac": None}, + {"num": 13, "hebrew": "Gimel", "trans": "Gimel", "trump": "II - The Priestess", "from": 2, "to": 3, "element": None, "planet": "Moon", "zodiac": None}, + {"num": 14, "hebrew": "Daleth", "trans": "Daleth", "trump": "III - The Empress", "from": 2, "to": 4, "element": None, "planet": "Venus", "zodiac": None}, + {"num": 15, "hebrew": "Hé", "trans": "He", "trump": "IV - The Emperor", "from": 2, "to": 5, "element": None, "planet": None, "zodiac": "Aries"}, + {"num": 16, "hebrew": "Vau", "trans": "Vav", "trump": "V - The Hierophant", "from": 3, "to": 4, "element": None, "planet": None, "zodiac": "Taurus"}, + {"num": 17, "hebrew": "Zain", "trans": "Zayin", "trump": "VI - The Lovers", "from": 3, "to": 5, "element": None, "planet": None, "zodiac": "Gemini"}, + {"num": 18, "hebrew": "Cheth", "trans": "Chet", "trump": "VII - The Chariot", "from": 4, "to": 5, "element": None, "planet": None, "zodiac": "Cancer"}, + {"num": 19, "hebrew": "Teth", "trans": "Tet", "trump": "VIII - Strength", "from": 4, "to": 6, "element": None, "planet": None, "zodiac": "Leo"}, + {"num": 20, "hebrew": "Yod", "trans": "Yod", "trump": "IX - The Hermit", "from": 5, "to": 6, "element": None, "planet": None, "zodiac": "Virgo"}, + {"num": 21, "hebrew": "Kaph", "trans": "Kaph", "trump": "X - Wheel of Fortune", "from": 4, "to": 7, "element": None, "planet": "Jupiter", "zodiac": None}, + {"num": 22, "hebrew": "Lamed", "trans": "Lamed", "trump": "XI - Justice", "from": 5, "to": 8, "element": None, "planet": None, "zodiac": "Libra"}, + {"num": 23, "hebrew": "Mem", "trans": "Mem", "trump": "XII - The Hanged Man", "from": 7, "to": 8, "element": "Water", "planet": None, "zodiac": None}, + {"num": 24, "hebrew": "Nun", "trans": "Nun", "trump": "XIII - Death", "from": 7, "to": 9, "element": None, "planet": None, "zodiac": "Scorpio"}, + {"num": 25, "hebrew": "Samekh", "trans": "Samekh", "trump": "XIV - Temperance", "from": 6, "to": 9, "element": None, "planet": None, "zodiac": "Sagittarius"}, + {"num": 26, "hebrew": "Ayin", "trans": "Ayin", "trump": "XV - The Devil", "from": 8, "to": 9, "element": None, "planet": None, "zodiac": "Capricorn"}, + {"num": 27, "hebrew": "Pé", "trans": "Pe", "trump": "XVI - The Tower", "from": 5, "to": 8, "element": None, "planet": "Mars", "zodiac": None}, + {"num": 28, "hebrew": "Tzaddi", "trans": "Tzade", "trump": "XVII - The Star", "from": 8, "to": 9, "element": None, "planet": None, "zodiac": "Aquarius"}, + {"num": 29, "hebrew": "Qoph", "trans": "Qof", "trump": "XVIII - The Moon", "from": 7, "to": 9, "element": None, "planet": None, "zodiac": "Pisces"}, + {"num": 30, "hebrew": "Resh", "trans": "Resh", "trump": "XIX - The Sun", "from": 6, "to": 9, "element": None, "planet": "Sun", "zodiac": None}, + {"num": 31, "hebrew": "Shin", "trans": "Shin", "trump": "XX - The Aeon", "from": 9, "to": 10, "element": "Fire", "planet": None, "zodiac": None}, + {"num": 32, "hebrew": "Tau", "trans": "Tau", "trump": "XXI - The Universe", "from": 10, "to": 1, "element": "Earth", "planet": None, "zodiac": None}, + ] + + cls._paths = {} + for spec in path_specs: + path_num = spec["num"] + sephera_from = cls._sephera.get(spec["from"]) + sephera_to = cls._sephera.get(spec["to"]) + element = cls._elements.get(spec["element"].lower()) if spec["element"] else None + planet = cls._planets.get(spec["planet"].lower()) if spec["planet"] else None + + path = Path( + number=path_num, + hebrew_letter=spec["hebrew"], + transliteration=spec["trans"], + tarot_trump=spec["trump"], + sephera_from=sephera_from, + sephera_to=sephera_to, + element=element, + planet=planet, + zodiac_sign=spec.get("zodiac"), + description=f"Path {path_num}: {spec['trump']}", + ) + cls._paths[path_num] = path + + @overload + def number(self, value: int) -> Optional[Number]: + ... + + @overload + def number(self, value: None = ...) -> Dict[int, Number]: + ... + + def number(self, value: Optional[int] = None) -> Union[Optional[Number], Dict[int, Number]]: + """Return an individual Number or the full numerology table.""" + if value is None: + return self._numbers.copy() + return self._numbers.get(value) + + @overload + def color(self, sephera_number: int) -> Optional[Color]: + ... + + @overload + def color(self, sephera_number: None = ...) -> Dict[int, Color]: + ... + + def color(self, sephera_number: Optional[int] = None) -> Union[Optional[Color], Dict[int, Color]]: + """Return a single color correspondence or the entire map.""" + if sephera_number is None: + return self._colors.copy() + return self._colors.get(sephera_number) + + @overload + def planet(self, name: str) -> Optional[Planet]: + ... + + @overload + def planet(self, name: None = ...) -> Dict[str, Planet]: + ... + + def planet(self, name: Optional[str] = None) -> Union[Optional[Planet], Dict[str, Planet]]: + """Return a planet entry or the full planetary registry.""" + if name is None: + return self._planets.copy() + return self._planets.get(name.lower()) + + @overload + def weekday(self, name: str) -> Optional[Weekday]: + ... + + @overload + def weekday(self, name: None = ...) -> Dict[str, Weekday]: + ... + + def weekday(self, name: Optional[str] = None) -> Union[Optional[Weekday], Dict[str, Weekday]]: + """Return a weekday correspondence or the entire set.""" + if name is None: + return self._weekdays.copy() + return self._weekdays.get(name.lower()) + + @overload + def clock_hour(self, hour_24: int) -> Optional[ClockHour]: + ... + + @overload + def clock_hour(self, hour_24: None = ...) -> Dict[int, ClockHour]: + ... + + def clock_hour(self, hour_24: Optional[int] = None) -> Union[Optional[ClockHour], Dict[int, ClockHour]]: + """Return a planetary hour or the entire 24-hour map.""" + if hour_24 is None: + return self._clock_hours.copy() + return self._clock_hours.get(hour_24) + + @overload + def sephera(self, number: int) -> Optional[Sephera]: + ... + + @overload + def sephera(self, number: None = ...) -> Dict[int, Sephera]: + ... + + def sephera(self, number: Optional[int] = None) -> Union[Optional[Sephera], Dict[int, Sephera]]: + """Return a Sephira or the entire Tree of Life mapping.""" + if number is None: + return self._sephera.copy() + return self._sephera.get(number) + + @overload + def element(self, name: str) -> Optional[ElementType]: + ... + + @overload + def element(self, name: None = ...) -> Dict[str, ElementType]: + ... + + def element(self, name: Optional[str] = None) -> Union[Optional[ElementType], Dict[str, ElementType]]: + """Return a single element or the registry of elements.""" + if name is None: + return self._elements.copy() + return self._elements.get(name.lower()) + + @overload + def periodic_entry(self, number: int) -> Optional[PeriodicTable]: + ... + + @overload + def periodic_entry(self, number: None = ...) -> Dict[int, PeriodicTable]: + ... + + def periodic_entry(self, number: Optional[int] = None) -> Union[Optional[PeriodicTable], Dict[int, PeriodicTable]]: + """Return a periodic table correspondence or the entire dataset.""" + if number is None: + return self._periodic_table.copy() + return self._periodic_table.get(number) + + def color_by_number(self, number: int) -> Optional[Color]: + """Get a Color by mapping a number through digital root.""" + dr = calculate_digital_root(number) + return self._colors.get(dr) + + def number_by_digital_root(self, value: int) -> Optional[Number]: + """Get a Number object using digital root calculation.""" + dr = calculate_digital_root(value) + return self._numbers.get(dr) + + def digital_root(self, value: int) -> int: + """Get the digital root of a value.""" + return calculate_digital_root(value) + + @overload + def god(self, name: str, *, culture: Optional[str] = None) -> Optional[God]: + ... + + @overload + def god(self, name: None = ..., *, culture: Optional[str] = None) -> Dict[str, God]: + ... + + def god(self, name: Optional[str] = None, *, culture: Optional[str] = None) -> Union[Optional[God], Dict[str, God]]: + """Return a god entry or the entire god registry, optionally filtered by culture.""" + if name is None: + if culture is None: + return self._gods.copy() + # Return all gods of the specified culture + return {k: v for k, v in self._gods.items() if v.culture and v.culture.lower() == culture.lower()} + + god_entry = self._gods.get(name.lower()) + if god_entry and culture and god_entry.culture and god_entry.culture.lower() != culture.lower(): + return None + return god_entry + + def gods_by_culture(self, culture: str) -> Dict[str, God]: + """Get all gods associated with a specific culture.""" + return {k: v for k, v in self._gods.items() if v.culture and v.culture.lower() == culture.lower()} + + @overload + def alphabet(self, name: str) -> Optional['EnglishAlphabet | GreekAlphabet | HebrewAlphabet']: + ... + + @overload + def alphabet(self, name: None = ...) -> Dict[str, 'EnglishAlphabet | GreekAlphabet | HebrewAlphabet']: + ... + + def alphabet(self, name: Optional[str] = None) -> Union[Optional['EnglishAlphabet | GreekAlphabet | HebrewAlphabet'], Dict[str, 'EnglishAlphabet | GreekAlphabet | HebrewAlphabet']]: + """Return an alphabet definition or all alphabets.""" + if name is None: + return self._alphabets.copy() + return self._alphabets.get(name.lower()) + + @overload + def letter(self, symbol: str) -> Optional['Letter']: + ... + + @overload + def letter(self, symbol: None = ...) -> Dict[str, 'Letter']: + ... + + def letter(self, symbol: Optional[str] = None) -> Union[Optional['Letter'], Dict[str, 'Letter']]: + """Return a letter entry or all letters.""" + if symbol is None: + return self._letters.copy() + return self._letters.get(symbol.upper()) + + @overload + def cipher(self, system_name: str) -> Optional[Cipher]: + ... + + @overload + def cipher(self, system_name: None = ...) -> Dict[str, Cipher]: + ... + + def cipher(self, system_name: Optional[str] = None) -> Union[Optional[Cipher], Dict[str, Cipher]]: + """Return a cipher definition or the full cipher registry.""" + if system_name is None: + return self._ciphers.copy() + return self._ciphers.get(system_name.lower()) + + @overload + def perfume(self, name: str) -> Optional[Perfume]: + ... + + @overload + def perfume(self, name: None = ...) -> Dict[str, Perfume]: + ... + + def perfume(self, name: Optional[str] = None) -> Union[Optional[Perfume], Dict[str, Perfume]]: + """Return a perfume entry or the entire perfume catalog.""" + if name is None: + return self._perfumes.copy() + if not name: + return None + return self._perfumes.get(name.lower()) + + def perfumes_by_sephera(self, sephera_number: int) -> List[Perfume]: + """Get all perfumes associated with a Sephira.""" + return [p for p in self._perfumes.values() if p.sephera_number == sephera_number] + + def perfumes_by_path(self, path_number: int) -> List[Perfume]: + """Get all perfumes associated with a Path.""" + return [p for p in self._perfumes.values() if p.path_number == path_number] + + def perfumes_by_element(self, element: str) -> List[Perfume]: + """Get all perfumes associated with an element.""" + return [p for p in self._perfumes.values() if p.element and p.element.lower() == element.lower()] + + def perfumes_by_zodiac(self, zodiac: str) -> List[Perfume]: + """Get all perfumes associated with a zodiac sign.""" + return [p for p in self._perfumes.values() if p.zodiac_sign and p.zodiac_sign.lower() == zodiac.lower()] + + @overload + def path(self, number: int) -> Optional[Path]: + ... + + @overload + def path(self, number: None = ...) -> Dict[int, Path]: + ... + + def path(self, number: Optional[int] = None) -> Union[Optional[Path], Dict[int, Path]]: + """Return a path or the complete path registry.""" + if number is None: + return self._paths.copy() + if not 11 <= number <= 32: + return None + return self._paths.get(number) + + def paths_by_element(self, element: str) -> List[Path]: + """Get all paths associated with an element.""" + return [p for p in self._paths.values() if p.element and p.element.name.lower() == element.lower()] + + def paths_by_planet(self, planet: str) -> List[Path]: + """Get all paths associated with a planet.""" + return [p for p in self._paths.values() if p.planet and p.planet.name.lower() == planet.lower()] + + def paths_by_zodiac(self, zodiac: str) -> List[Path]: + """Get all paths associated with a zodiac sign.""" + return [p for p in self._paths.values() if p.zodiac_sign and p.zodiac_sign.lower() == zodiac.lower()] + + def temporal_correspondence(self, moment: Optional[datetime] = None) -> TemporalCorrespondence: + """Combine zodiacal, planetary, and card data for a timestamp. + + Args: + moment: Optional datetime to evaluate. Defaults to current local time. + + Returns: + TemporalCorrespondence snapshot describing the aligned card, planet, + number, and related metadata for the supplied moment. + """ + + moment = moment or datetime.now() + weekday_name = moment.strftime("%A").lower() + weekday = self._weekdays.get(weekday_name) + clock_hour = self._clock_hours.get(moment.hour) + + planet: Optional[Planet] = None + if clock_hour: + planet = self._planets.get(clock_hour.planetary_ruler.lower()) + + number = planet.associated_numbers[0] if planet and planet.associated_numbers else None + color = number.color if number else None + letters = list(planet.associated_letters) if planet else [] + card = self._card_for_moment(moment) + + hexagram_number = ((moment.timetuple().tm_yday - 1) % 64) + 1 + # Lazy import to avoid circular dependency + from letter import hexagram as iching_hexagram + hexagram_result = iching_hexagram.hexagram.filter(f'number:{hexagram_number}').first() + hexagram = hexagram_result.data if hexagram_result else None + + return TemporalCorrespondence( + timestamp=moment, + weekday=weekday, + clock_hour=clock_hour, + card=card, + planet=planet, + number=number, + color=color, + letters=letters, + hexagram=hexagram, + ) + + + def word(self, text: str, *, alphabet: Optional[str] = None) -> WordCipherRequest: + """Start a fluent cipher request for the given text.""" + if not text: + raise ValueError("Text must be a non-empty string") + return WordCipherRequest(self, text, alphabet) + + def _alphabet_letters(self, alphabet_name: str) -> List[str]: + """Return the ordered letters for a named alphabet.""" + alphabet = self._alphabets.get(alphabet_name.lower()) + if not alphabet: + raise ValueError(f"Alphabet '{alphabet_name}' is not available") + letters: List[str] = [] + for entry in alphabet: + letter = getattr(entry, "letter", None) + if not letter: + raise ValueError("Alphabet entries must expose a 'letter' attribute") + letters.append(letter) + return letters + + def _apply_cipher(self, *, text: str, cipher_name: str, alphabet_name: Optional[str]) -> CipherResult: + """Execute the named cipher against the provided text.""" + cipher = self.cipher(cipher_name) + if not cipher: + raise ValueError(f"Cipher '{cipher_name}' is not defined") + target_alphabet = (alphabet_name or cipher.default_alphabet or "").lower() + if not target_alphabet: + raise ValueError("Alphabet must be provided when cipher has no default") + letters = self._alphabet_letters(target_alphabet) + values = tuple(cipher.encode(text=text, alphabet_letters=letters)) + return CipherResult(word=text, cipher=cipher, alphabet_name=target_alphabet, values=values) + + # Temporal query methods + def month_info(self, month_num: int) -> Optional[Month]: + """Return month metadata for the supplied number (1-12).""" + if 1 <= month_num <= 12: + month_data = [ + {"name": "January", "zodiac_start": "Capricorn", "zodiac_end": "Aquarius"}, + {"name": "February", "zodiac_start": "Aquarius", "zodiac_end": "Pisces"}, + {"name": "March", "zodiac_start": "Pisces", "zodiac_end": "Aries"}, + {"name": "April", "zodiac_start": "Aries", "zodiac_end": "Taurus"}, + {"name": "May", "zodiac_start": "Taurus", "zodiac_end": "Gemini"}, + {"name": "June", "zodiac_start": "Gemini", "zodiac_end": "Cancer"}, + {"name": "July", "zodiac_start": "Cancer", "zodiac_end": "Leo"}, + {"name": "August", "zodiac_start": "Leo", "zodiac_end": "Virgo"}, + {"name": "September", "zodiac_start": "Virgo", "zodiac_end": "Libra"}, + {"name": "October", "zodiac_start": "Libra", "zodiac_end": "Scorpio"}, + {"name": "November", "zodiac_start": "Scorpio", "zodiac_end": "Sagittarius"}, + {"name": "December", "zodiac_start": "Sagittarius", "zodiac_end": "Capricorn"}, + ] + data = month_data[month_num - 1] + name = data.get("name", "") + zodiac_start = data.get("zodiac_start", "") + zodiac_end = data.get("zodiac_end", "") + return Month( + number=month_num, + name=name, + zodiac_start=zodiac_start, + zodiac_end=zodiac_end + ) + return None + + def day_info(self, day_num: int) -> Optional[Day]: + """Return day metadata for the supplied number (1-31).""" + if 1 <= day_num <= 31: + day_names = [ + "First", "Second", "Third", "Fourth", "Fifth", + "Sixth", "Seventh", "Eighth", "Ninth", "Tenth", + "Eleventh", "Twelfth", "Thirteenth", "Fourteenth", "Fifteenth", + "Sixteenth", "Seventeenth", "Eighteenth", "Nineteenth", "Twentieth", + "Twenty-first", "Twenty-second", "Twenty-third", "Twenty-fourth", "Twenty-fifth", + "Twenty-sixth", "Twenty-seventh", "Twenty-eighth", "Twenty-ninth", "Thirtieth", + "Thirty-first" + ] + planets = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"] + planet = planets[(day_num - 1) % 7] + return Day( + number=day_num, + name=day_names[day_num - 1], + planetary_correspondence=planet + ) + return None + + def month(self, month_num: int) -> 'TemporalQuery': + """Start a temporal query for a given month.""" + from ..deck import TemporalQuery + return TemporalQuery(self, month_num=month_num) diff --git a/src/tarot/card/details.py b/src/tarot/card/details.py new file mode 100644 index 0000000..99426e4 --- /dev/null +++ b/src/tarot/card/details.py @@ -0,0 +1,557 @@ +"""Card details and interpretations for all 78 Tarot cards. + +This module provides interpretive data (explanations, keywords, guidance) for cards. +Registry is keyed by card position (1-78), independent of deck-specific names. + +Deck order: Cups (1-14), Pentacles (15-28), Swords (29-42), + Major Arcana (43-64), Wands (65-78) + +Usage: + from tarot.card.details import CardDetailsRegistry + + registry = CardDetailsRegistry() + details = registry.get_by_position(44) # Get details for card at position 44 + + # Or load into a card object: + from tarot.deck import Deck + deck = Deck() + card = deck.cards[43] # Card at position 44 (0-indexed) + registry.load_into_card(card) +""" + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from tarot.deck import Card + + +class CardDetailsRegistry: + """Registry for storing interpretive data for all 78 Tarot cards. + + Uses card position (1-78) as the unique identifier, independent of deck names. + This allows the same card details to apply across different deck variants. + + Deck order: + - 1-14: Cups (Ace, Ten, 2-9, Knight, Prince, Princess, Queen) + - 15-28: Pentacles (same structure) + - 29-42: Swords (same structure) + - 43-64: Major Arcana (Fool through Universe) + - 65-78: Wands (same structure) + """ + + def __init__(self) -> None: + """Initialize the card details registry with interpretive data.""" + self._details: Dict[str, Dict[str, Any]] = self._build_registry() + # Map card positions (1-78) to registry keys + self._position_map = self._build_position_map() + + @staticmethod + def key_to_roman(key: int) -> str: + """ + Convert a numeric key to Roman numerals. + + Args: + key: The numeric key (0-21 for major arcana) + + Returns: + Roman numeral representation (e.g., 21 -> "XXI", 0 -> "o") + """ + # Special case: 0 -> "o" (letter O for The Fool) + if key == 0: + return "o" + + val = [ + 1000, 900, 500, 400, + 100, 90, 50, 40, + 10, 9, 5, 4, + 1 + ] + syms = [ + "M", "CM", "D", "CD", + "C", "XC", "L", "XL", + "X", "IX", "V", "IV", + "I" + ] + roman_num = '' + i = 0 + while key > 0: + for _ in range(key // val[i]): + roman_num += syms[i] + key -= val[i] + i += 1 + return roman_num if roman_num else "o" + + def _build_position_map(self) -> Dict[int, str]: + """ + Build a mapping from card position (1-78) to registry key. + + Returns: + Dictionary mapping position to registry key + """ + position_map = {} + + # Positions 1-14: Cups (Ace, Ten, 2-9, Knight, Prince, Princess, Queen) + cups_names = ["Ace of Cups", "Ten of Cups", "Two of Cups", "Three of Cups", + "Four of Cups", "Five of Cups", "Six of Cups", "Seven of Cups", + "Eight of Cups", "Nine of Cups", "Knight of Cups", "Prince of Cups", + "Princess of Cups", "Queen of Cups"] + for pos, name in enumerate(cups_names, start=1): + position_map[pos] = name + + # Positions 15-28: Pentacles (same structure) + pentacles_names = ["Ace of Pentacles", "Ten of Pentacles", "Two of Pentacles", "Three of Pentacles", + "Four of Pentacles", "Five of Pentacles", "Six of Pentacles", "Seven of Pentacles", + "Eight of Pentacles", "Nine of Pentacles", "Knight of Pentacles", "Prince of Pentacles", + "Princess of Pentacles", "Queen of Pentacles"] + for pos, name in enumerate(pentacles_names, start=15): + position_map[pos] = name + + # Positions 29-42: Swords (same structure) + swords_names = ["Ace of Swords", "Ten of Swords", "Two of Swords", "Three of Swords", + "Four of Swords", "Five of Swords", "Six of Swords", "Seven of Swords", + "Eight of Swords", "Nine of Swords", "Knight of Swords", "Prince of Swords", + "Princess of Swords", "Queen of Swords"] + for pos, name in enumerate(swords_names, start=29): + position_map[pos] = name + + # Positions 43-64: Major Arcana (mapped to Roman numerals) + major_arcana_keys = ["o", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", + "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", + "XX", "XXI"] + for pos, key in enumerate(major_arcana_keys, start=43): + position_map[pos] = key + + # Positions 65-78: Wands (same structure) + wands_names = ["Ace of Wands", "Ten of Wands", "Two of Wands", "Three of Wands", + "Four of Wands", "Five of Wands", "Six of Wands", "Seven of Wands", + "Eight of Wands", "Nine of Wands", "Knight of Wands", "Prince of Wands", + "Princess of Wands", "Queen of Wands"] + for pos, name in enumerate(wands_names, start=65): + position_map[pos] = name + + return position_map + + def get_by_position(self, position: int) -> Optional[Dict[str, Any]]: + """ + Get details for a card by its position (1-78). + + Args: + position: Card position (1-78) + + Returns: + Dictionary containing card details, or None if not found + """ + registry_key = self._position_map.get(position) + if registry_key is None: + return None + return self._details.get(registry_key) + + def _build_registry(self) -> Dict[str, Dict[str, Any]]: + """Build the interpretive data registry (card structure comes from Deck). + + Stores only unique interpretive data (explanation, keywords, guidance). + Card names and structure are sourced from Deck for DRY compliance. + """ + return { + # Major Arcana (0-21) - Interpretive data only + "o": { + "explanation": "The Fool represents new beginnings, innocence, and spontaneity. This card signifies a fresh start or embarking on a new journey with optimism and faith.", + "interpretation": "Beginning of the Great Work, innocence; a fool for love; divine madness. Reason is transcended. Take the leap. Gain or loss through foolish actions.", + "keywords": ["new beginnings", "innocence", "faith", "spontaneity", "potential"], + "reversed_keywords": ["recklessness", "naivety", "poor judgment", "folly"], + "guidance": "Trust in the unfolding of your path. Embrace new opportunities with awareness and openness.", + }, + "I": { + "explanation": "The Magician embodies manifestation, resourcefulness, and personal power. This card shows mastery of skills and the ability to turn ideas into reality.", + "interpretation": "Communication; Conscious Will; the process of continuous creation; ambiguity; deceptionl Things may not be as they appear. Concentration, meditation; mind used to direct the Will. Manipulation; crafty maneuverings.", + "keywords": ["manifestation", "resourcefulness", "power", "inspired action", "concentration"], + "reversed_keywords": ["manipulation", "poor planning", "untapped talents", "lack of direction"], + "guidance": "Focus your energy and intention on what you want to manifest. You have the tools and talents you need.", + }, + "II": { + "explanation": "The High Priestess represents intuition, sacred knowledge, and the subconscious mind. She embodies mystery and inner wisdom.", + "interpretation": "Symbol of highest initiation; link between the archetypal and Formative Worlds. An initiatrixl Wooing by enchantment. possibility. The Idea behind the Form. Fluctuationl Time may not be right for a decision concerning mundane matters.", + "keywords": ["intuition", "sacred knowledge", "divine feminine", "the subconscious", "mystery"], + "reversed_keywords": ["hidden information", "silence", "disconnection from intuition", "superficiality"], + "guidance": "Listen to your inner voice. The answers you seek lie within. Trust the wisdom of your intuition.", + }, + "III": { + "explanation": "The Empress symbolizes abundance, fertility, and nurturing energy. She represents creativity, sensuality, and the power of manifestation through nurturing.", + "interpretation": "The Holy Grail. love unites the Will. Love; beauty; friendship; success; passive balance. The feminine point of view. The door is open. Disregard the details and concentrate on the big picture.", + "keywords": ["abundance", "fertility", "femininity", "beauty", "nature", "creativity"], + "reversed_keywords": ["dependency", "creative block", "neediness", "underdevelopment"], + "guidance": "Nurture yourself and others. Allow yourself to enjoy the fruits of your labor and appreciate beauty.", + }, + "IV": { + "explanation": "The Emperor represents authority, leadership, and established power. He embodies structure, discipline, and protection through strength and control.", + "interpretation": "Creative wisdom radiating upon the organized man and woman. Domination after conquest; quarrelsomeness; paternal love; ambition. Thought ruled by creative, masculine, fiery energy. Stubbornness; war; authority; energy in its most temporal form. Swift immpermaent action over confidence.", + "keywords": ["authority", "leadership", "power", "structure", "protection", "discipline"], + "reversed_keywords": ["weakness", "ineffectual leadership", "lack of discipline", "tyranny"], + "guidance": "Step into your power with confidence. Establish clear boundaries and structure. Lead by example.", + }, + "V": { + "explanation": "The Hierophant represents tradition, conventional wisdom, and spiritual authority. This card embodies education, ceremony, and moral values.", + "interpretation": "The Holy Guardian Angel. The uniting of t hat which is above with that which is below. Love is indicated, but the nature of that love is not yet to be revealed. Inspiration; teaching; organization; discipline; strength; endurance; toil; help from superiors.", + "keywords": ["tradition", "spirituality", "wisdom", "ritual", "morality", "ethics"], + "reversed_keywords": ["rebellion", "unconventionality", "questioning authority", "dogmatism"], + "guidance": "Seek guidance from established wisdom. Respect traditions while finding your own spiritual path.", + }, + "VI": { + "explanation": "The Lovers represents relationships, values alignment, and the union of opposites. It signifies choice, intimacy, and deep connection.", + "interpretation": "Intuition. Be open to your own inner voice. A well-intended, arranged marriage. An artificial union. The need to make a choice with awareness of consequences union; analysis followed by synthesis; indecision; instability; superficiality.", + "keywords": ["relationships", "love", "union", "values", "choice", "alignment"], + "reversed_keywords": ["disharmony", "misalignment", "conflict", "communication breakdown"], + "guidance": "Choose with your heart aligned with your values. Deep connection requires vulnerability and honesty.", + }, + "VII": { + "explanation": "The Chariot embodies willpower, determination, and control through focused intention. It represents triumph through discipline and forward momentum.", + "interpretation": "Light in the darkness. The burden you carry may be the Holy Grail. Faithfulness; hope; obedience; a protective relationship; firm, even violent adherance to dogma or tradition. Glory; riches; englightened civilization; victory; triumph; chain of command.", + "keywords": ["determination", "willpower", "control", "momentum", "victory", "focus"], + "reversed_keywords": ["lack of control", "haste", "resistance", "moving backward"], + "guidance": "Take the reins of your life. Move forward with determination and clear direction. You have the power.", + }, + "VIII": { + "explanation": "Strength represents inner power, courage, and compassion. It shows mastery through gentleness and the ability to face challenges with calm confidence.", + "interpretation": "Equilibrium; karmic law; the dance of life; all possibilities. The woman satisfied. Balance; weigh each thought against its opposite. Lawsuits; treaties. Pause and look before you leap.", + "keywords": ["strength", "courage", "patience", "compassion", "control", "confidence"], + "reversed_keywords": ["weakness", "self-doubt", "lack of composure", "poor control"], + "guidance": "True strength comes from within. Face challenges with courage and compassion for yourself and others.", + }, + "IX": { + "explanation": "The Hermit represents introspection, spiritual seeking, and inner guidance. This card embodies solitude, wisdom gained through reflection, and self-discovery.", + "interpretation": "Divine seed of all things. By silence comes inspiration and wisdom. Wandering alone; temporary solitude; creative contemplation; a virgin. Retirement from involvement in current events.", + "keywords": ["introspection", "spiritual seeking", "inner light", "wisdom", "solitude", "truth"], + "reversed_keywords": ["loneliness", "isolation", "lost", "paranoia", "disconnection"], + "guidance": "Take time for introspection and self-discovery. Your inner light guides your path. Seek solitude for wisdom.", + }, + "X": { + "explanation": "The Wheel of Fortune represents cycles, destiny, and the turning points of life. It embodies luck, karma, and the natural ebb and flow of existence.", + "interpretation": "Continual change. In the midst of revolving phenomena, reaach joyously the motionless center. Carefree love; wanton pleasure; amusement; fun; change of fortune, usually good.", + "keywords": ["fate", "destiny", "cycles", "fortune", "karma", "turning point"], + "reversed_keywords": ["bad luck", "resistance to change", "broken cycles", "misfortune"], + "guidance": "Trust in the cycles of life. What goes up must come down. Embrace change as part of your journey.", + }, + "XI": { + "explanation": "Justice represents fairness, truth, and balance. It embodies accountability, clear judgment, and the consequences of actions both past and present.", + "interpretation": "Understanding; the Will of New Aeon; passion; sense smitten with ecstasy. let love devour all. Energy independent of reason. Strength; courage; utilization of magical power.", + "keywords": ["justice", "fairness", "truth", "cause and effect", "balance", "accountability"], + "reversed_keywords": ["injustice", "bias", "lack of accountability", "dishonesty"], + "guidance": "Seek the truth and act with fairness. Take responsibility for your actions. Balance is key.", + }, + "XII": { + "explanation": "The Hanged Man represents suspension, letting go, and seeing things from a new perspective. It embodies surrender, pause, and gaining wisdom through sacrifice.", + "interpretation": "Redemption, sacrifice, annihilation in the beloved; martyrdom; loss; torment; suspension; death; suffering.", + "keywords": ["suspension", "restriction", "letting go", "new perspective", "surrender", "pause"], + "reversed_keywords": ["resistance", "stalling", "unwillingness to change", "impatience"], + "guidance": "Pause and reflect. What are you holding onto? Surrender control and trust the process.", + }, + "XIII": { + "explanation": "Death represents transformation, endings, and new beginnings. This card embodies major life transitions, the release of the old, and inevitable change.", + "interpretation": "End of cycle; transformation; raw sexuality. Sex is death. Stress becomes intolerable. Any change is welcome. Time; age; unexpected change; death.", + "keywords": ["transformation", "transition", "endings", "beginnings", "change", "acceptance"], + "reversed_keywords": ["resistance to change", "stagnation", "missed opportunity", "delay"], + "guidance": "Release what no longer serves you. Transformation is inevitable. Trust in the cycle of death and rebirth.", + }, + "XIV": { + "explanation": "Temperance represents balance, moderation, and harmony. It embodies blending of opposites, inner peace through balance, and finding your rhythm.", + "interpretation": "Transmutation through union of opposites. A perfect marriage exalts and transforms each partner. The scientific method. Success follows complex maneuvers.", + "keywords": ["balance", "moderation", "harmony", "patience", "timing", "peace"], + "reversed_keywords": ["imbalance", "excess", "conflict", "intemperance", "discord"], + "guidance": "Seek balance in all things. Blend opposing forces. Find your rhythm through moderation and patience.", + }, + "XV": { + "explanation": "The Devil represents bondage, materialism, and shadow aspects of self. It embodies addictions, illusions, and the consequences of giving away personal power.", + "interpretation": "Thou hast no right but to do thy will. Obession; temptation; ecstasy found in every phenomenon; creative action, yet sublimely careless of result; unscrupulous ambition; strength.", + "keywords": ["bondage", "materialism", "playfulness", "shadow self", "sexuality", "excess"], + "reversed_keywords": ["freedom", "detachment", "reclaiming power", "breaking free"], + "guidance": "Examine what binds you. Acknowledge your shadow. You hold the key to your own freedom.", + }, + "XVI": { + "explanation": "The Tower represents sudden disruption, revelation, and breakthrough through crisis. It embodies sudden change, truth revealed, and necessary destruction.", + "interpretation": "Escape from the prison of organized life; renunciation of love; quarreling. Plans are destroyed. War; danger; sudden death.", + "keywords": ["sudden change", "upheaval", "revelation", "breakdown", "breakthrough", "chaos"], + "reversed_keywords": ["resistance to change", "averted crisis", "delay", "stagnation"], + "guidance": "Crisis brings clarity. Though change is sudden and jarring, it clears away the false and brings truth.", + }, + "XVII": { + "explanation": "The Star represents hope, guidance, and inspiration. It embodies clarity of purpose, spiritual insight, and the light that guides your path forward.", + "interpretation": "Clairvoyance; visions; drams; hope; love; yearning; realization of inexhaustible possibilities; dreaminess; unexpected help; renewal.", + "keywords": ["hope", "faith", "inspiration", "vision", "guidance", "spirituality"], + "reversed_keywords": ["hopelessness", "despair", "lack of direction", "lost", "obscured"], + "guidance": "Let your inner light shine. Trust in your vision. Hope and guidance light your path forward.", + }, + "XVIII": { + "explanation": "The Moon represents illusion, intuition, and the subconscious mind. It embodies mystery, dreams, and navigating by inner knowing rather than sight.", + "interpretation": "The Dark night of the soul; deception; falsehood; illusion; madness; the threshold of significant change.", + "keywords": ["illusion", "intuition", "uncertainty", "subconscious", "dreams", "mystery"], + "reversed_keywords": ["clarity", "truth revealed", "release from illusion", "awakening"], + "guidance": "Trust your intuition to navigate mystery. What appears illusory contains deeper truths worth exploring.", + }, + "XIX": { + "explanation": "The Sun represents joy, clarity, and vitality. It embodies success, positive energy, and the radiance of authentic self-expression.", + "interpretation": "Lord of the New Aeon. Spiritual emancipation. Pleasure; shamelessness; vanity; frankness. Freedom brings sanity. Glory; riches; enlightened civilization.", + "keywords": ["success", "joy", "clarity", "vitality", "warmth", "authenticity"], + "reversed_keywords": ["temporary darkness", "lost vitality", "setback", "sadness"], + "guidance": "Celebrate your success. Let your authentic self shine. Joy and clarity light your way.", + }, + "XX": { + "explanation": "Judgement represents awakening, calling, and significant decisions. It embodies reckoning, rebirth, and responding to a higher calling.", + "interpretation": "Let every act be an act of Worship; let every act be an act of Love. Final decision; judgement. Learn from the past. Prepare for the future.", + "keywords": ["awakening", "calling", "judgment", "rebirth", "evaluation", "absolution"], + "reversed_keywords": ["doubt", "self-doubt", "harsh judgment", "reluctance to change"], + "guidance": "Answer your higher calling. Evaluate with compassion. A significant awakening or decision awaits.", + }, + "XXI": { + "explanation": "The World represents completion, wholeness, and fulfillment. It embodies the end of a cycle, achievement of goals, and a sense of unity.", + "interpretation": "Completion of the Greatk Work; patience; perseverance; stubbornness; serious meditation. Work accomplished.", + "keywords": ["completion", "fulfillment", "wholeness", "travel", "unity", "achievement"], + "reversed_keywords": ["incomplete", "blocked", "separation", "seeking closure"], + "guidance": "A significant cycle completes. You have achieved wholeness. Yet every ending is a new beginning.", + }, + + # Minor Arcana - Swords + "Ace of Swords": { + "explanation": "The Ace of Swords represents clarity, breakthrough, and new ideas. It embodies truth emerging, mental clarity, and the power of honest communication.", + "interpretation": "New idea or perspective, clarity and truth, breakthrough thinking, mental clarity", + "keywords": ["breakthrough", "clarity", "truth", "new ideas", "communication"], + "reversed_keywords": ["confusion", "unclear communication", "hidden truth", "mental fog"], + "guidance": "A breakthrough arrives. Speak your truth with clarity. Mental clarity reveals new possibilities.", + }, + "Two of Swords": { + "explanation": "The Two of Swords represents stalemate, difficult choices, and mental struggle. It embodies indecision, conflicting information, and the need for perspective.", + "interpretation": "Stalemate and indecision, difficult choices ahead, conflicting perspectives, mental struggle", + "keywords": ["stalemate", "indecision", "confusion", "difficult choice", "standoff"], + "reversed_keywords": ["clarity emerging", "decision made", "moving forward", "resolution"], + "guidance": "Step back from the conflict. You need more information or perspective before deciding.", + }, + "Three of Swords": { + "explanation": "The Three of Swords represents heartbreak, difficult truths, and mental anguish. It embodies challenging communication, painful revelations, and clarity that hurts.", + "interpretation": "Difficult truths and heartbreak, communication challenges, mental anguish, clarity through pain", + "keywords": ["heartbreak", "sorrow", "difficult truth", "mental anguish", "separation"], + "reversed_keywords": ["healing", "moving forward", "forgiveness", "reconciliation"], + "guidance": "Difficult truths are emerging. Allow yourself to feel the pain. Healing follows acknowledgment.", + }, + "Four of Swords": { + "explanation": "The Four of Swords represents rest, recovery, and mental respite. It embodies the need for pause, recuperation, and gathering strength.", + "interpretation": "Rest and recovery, pause and contemplation, gathering strength, needed respite", + "keywords": ["rest", "pause", "recovery", "contemplation", "respite"], + "reversed_keywords": ["restlessness", "stress", "unwillingness to rest", "agitation"], + "guidance": "Take time to rest and recover. Your mind and spirit need respite. Gather your strength.", + }, + "Five of Swords": { + "explanation": "The Five of Swords represents conflict, victory at a cost, and difficult truths after battle. It embodies competition with consequences and the emptiness of winning wrongly.", + "interpretation": "Conflict and competition, pyrrhic victory, harsh truths, aftermath of conflict", + "keywords": ["conflict", "defeat", "victory at cost", "awkwardness", "tension"], + "reversed_keywords": ["reconciliation", "resolution", "forgiveness", "peace"], + "guidance": "Sometimes victory costs more than it's worth. Seek reconciliation over conquest.", + }, + "Six of Swords": { + "explanation": "The Six of Swords represents moving forward, healing journey, and leaving troubles behind. It embodies transition, mental resolution, and the path to better days.", + "interpretation": "Moving forward and transition, leaving trouble behind, journey and travel, mental resolution", + "keywords": ["transition", "healing journey", "moving forward", "travel", "freedom"], + "reversed_keywords": ["stuck", "resistance to change", "delays", "unresolved issues"], + "guidance": "A journey of healing begins. Move forward. Leave the past behind. Better days await.", + }, + "Seven of Swords": { + "explanation": "The Seven of Swords represents deception, cunning, and strategic retreat. It embodies hidden agendas, betrayal, and escape from difficult situations.", + "interpretation": "Deception and cunning, hidden agendas, strategic retreat, betrayal or self-deception", + "keywords": ["deception", "cunning", "betrayal", "hidden agenda", "strategy"], + "reversed_keywords": ["coming clean", "honesty", "truth revealed", "facing consequences"], + "guidance": "Look for hidden truths. Deception may be at play. Where are you deceiving yourself?", + }, + "Eight of Swords": { + "explanation": "The Eight of Swords represents restriction, bondage, and self-imposed limitations. It embodies feeling trapped, mental imprisonment, and powerlessness.", + "interpretation": "Restriction and bondage, self-imposed limitations, feeling trapped, helplessness", + "keywords": ["bondage", "restriction", "trapped", "helplessness", "powerlessness"], + "reversed_keywords": ["freedom", "release", "empowerment", "breaking free"], + "guidance": "You have more power than you believe. The restrictions may be self-imposed. Free yourself.", + }, + "Nine of Swords": { + "explanation": "The Nine of Swords represents anxiety, nightmares, and mental torment. It embodies overthinking, worry, and the burden of negative thoughts.", + "interpretation": "Anxiety and worry, nightmares and turmoil, overthinking, mental burden", + "keywords": ["anxiety", "worry", "nightmares", "overthinking", "despair"], + "reversed_keywords": ["relief", "healing", "moving past", "mental clarity"], + "guidance": "Your mind is your greatest torment. Seek support. This darkness passes. Morning follows night.", + }, + "Ten of Swords": { + "explanation": "The Ten of Swords represents complete mental/emotional defeat, rock bottom, and the end of suffering. It embodies the culmination of difficulty and the promise of renewal.", + "interpretation": "Defeat and rock bottom, end of suffering, difficult conclusion, release from burden", + "keywords": ["defeat", "rock bottom", "ending", "relief", "betrayal"], + "reversed_keywords": ["recovery", "beginning again", "healing", "hope"], + "guidance": "The worst has passed. You've hit bottom. From here, only recovery is possible.", + }, + "Page of Swords": { + "explanation": "The Page of Swords represents curious inquiry, new ideas, and youthful intellectual energy. It embodies investigation, learning, and the drive to understand.", + "interpretation": "Curiosity and new learning, investigation and inquiry, youthful energy, intellectual development", + "keywords": ["curiosity", "inquiry", "new learning", "messages", "vigilance"], + "reversed_keywords": ["cynicism", "misinformation", "scattered thinking", "mischief"], + "guidance": "Curiosity leads to discovery. Ask questions and investigate. Knowledge empowers.", + }, + "Knight of Swords": { + "explanation": "The Knight of Swords represents swift action, directness, and intellectual courage. It embodies confrontation, truth-seeking, and the willingness to challenge.", + "interpretation": "Direct communication and action, intellectual courage, challenging situations, swift movement", + "keywords": ["action", "impulsiveness", "courage", "conflict", "truth"], + "reversed_keywords": ["scatter-brained", "dishonest", "confusion", "retreat"], + "guidance": "Speak your truth directly. Act with courage. Swift action brings results.", + }, + "Queen of Swords": { + "explanation": "The Queen of Swords represents intellectual power, clarity, and independent thinking. It embodies wisdom gained through experience and clear perception.", + "interpretation": "Intellectual power and clarity, independence and perception, wisdom and experience, communication", + "keywords": ["clarity", "intelligence", "independence", "truth", "perception"], + "reversed_keywords": ["bitter", "manipulative", "cold", "cruel"], + "guidance": "Trust your keen intellect. Speak your truth with grace. Clarity empowers.", + }, + "King of Swords": { + "explanation": "The King of Swords represents mental mastery, authority through intellect, and the power of truth. It embodies leadership, clear judgment, and strategic thinking.", + "interpretation": "Mental mastery and intellect, authority and leadership, justice and fairness, clear judgment", + "keywords": ["authority", "intellect", "truth", "leadership", "justice"], + "reversed_keywords": ["tyrant", "manipulation", "abuse of power", "cruelty"], + "guidance": "Lead with intellect and integrity. Your clarity creates order. Speak truth with authority.", + }, + "Princess of Swords": { + "explanation": "The Princess of Swords represents intellectual potential, youthful curiosity, and emerging clarity. It embodies the development of mental acuity and the pursuit of knowledge.", + "interpretation": "Intellectual development and potential, emerging clarity, youthful inquiry, pursuit of truth", + "keywords": ["clarity emerging", "intellectual potential", "investigation", "truth-seeking", "perception"], + "reversed_keywords": ["confusion", "scattered thoughts", "deception", "lack of focus"], + "guidance": "Your ability to perceive truth is developing. Stay curious and focused. Clarity is emerging.", + }, + + # Minor Arcana - Cups + "Ace of Cups": { + "explanation": "The Ace of Cups represents new emotional beginning, love, and spiritual awakening. It embodies the opening of the heart and new emotional connections.", + "interpretation": "New emotional beginning, love and compassion, spiritual awakening, emotional clarity", + "keywords": ["love", "new emotion", "compassion", "beginning", "spirituality"], + "reversed_keywords": ["blocked emotion", "closed heart", "emotional confusion"], + "guidance": "Your heart opens to new possibilities. Emotional connections deepen. Love flows.", + }, + "Two of Cups": { + "explanation": "The Two of Cups represents partnership, mutual respect, and emotional connection. It embodies balance, harmony, and the foundation of relationships.", + "interpretation": "Partnership and connection, mutual respect and harmony, emotional balance, agreements", + "keywords": ["partnership", "love", "connection", "harmony", "commitment"], + "reversed_keywords": ["imbalance", "separation", "misalignment", "broken agreement"], + "guidance": "Deep connection and harmony are possible. Mutual respect forms the foundation.", + }, + "Three of Cups": { + "explanation": "The Three of Cups represents celebration, friendship, and community. It embodies joy, shared experiences, and the warmth of connection.", + "interpretation": "Celebration and community, friendship and joy, shared experiences, social harmony", + "keywords": ["celebration", "community", "friendship", "joy", "creativity"], + "reversed_keywords": ["isolation", "loneliness", "overindulgence", "discord"], + "guidance": "Celebrate with friends. Community and connection bring joy. Share in the abundance.", + }, + + # Minor Arcana - Pentacles + "Ace of Pentacles": { + "explanation": "The Ace of Pentacles represents new prosperity, material opportunity, and earthly beginnings. It embodies abundance, security, and practical gifts.", + "interpretation": "New material opportunity, abundance and prosperity, earthly beginnings, practical gifts", + "keywords": ["abundance", "opportunity", "prosperity", "security", "gift"], + "reversed_keywords": ["lost opportunity", "scarcity", "blocked prosperity"], + "guidance": "Material opportunity arrives. Seize it. Abundance begins with gratitude.", + }, + "Two of Pentacles": { + "explanation": "The Two of Pentacles represents balance, flexibility, and managing resources. It embodies juggling priorities, adaptability, and resourcefulness.", + "interpretation": "Balance and flexibility, managing resources, adaptability, juggling priorities", + "keywords": ["balance", "flexibility", "adaptability", "resourcefulness", "management"], + "reversed_keywords": ["imbalance", "mismanagement", "chaos", "loss"], + "guidance": "Balance your priorities carefully. Flexibility allows you to manage what comes.", + }, + + # Minor Arcana - Wands + "Ace of Wands": { + "explanation": "The Ace of Wands represents new inspiration, creative spark, and passionate new beginning. It embodies potential, growth, and spiritual fire.", + "interpretation": "New creative spark, inspiration and potential, passionate beginning, growth opportunity", + "keywords": ["inspiration", "potential", "growth", "new beginning", "creativity"], + "reversed_keywords": ["blocked inspiration", "delays", "lost potential"], + "guidance": "Creative inspiration ignites. Channel this energy into action. Your passion becomes power.", + }, + "Two of Wands": { + "explanation": "The Two of Wands represents planning, future vision, and resourcefulness. It embodies potential growth, decisions about direction, and careful preparation.", + "interpretation": "Planning and vision, resource management, decisions about direction, future planning", + "keywords": ["vision", "planning", "potential", "resourcefulness", "future"], + "reversed_keywords": ["lack of vision", "poor planning", "blocked growth"], + "guidance": "Plan your future with vision. You have resources to build something great.", + }, + } + + def get(self, card_name: str) -> Optional[Dict[str, Any]]: + """ + Get details for a specific card by name. + + Args: + card_name: The card's name (e.g., "Princess of Swords", "Ace of Cups") + + Returns: + Dictionary containing card details, or None if not found + """ + return self._details.get(card_name) + + def get_key_as_roman(self, card_name: str) -> Optional[str]: + """ + Get the card's key displayed as Roman numerals. + + Args: + card_name: The card's name (e.g., "The Fool", "The Magician") + + Returns: + Roman numeral representation of the key (e.g., "XXI" for 21), or None if not found + """ + details = self.get(card_name) + if details and "key" in details: + return self.key_to_roman(details["key"]) + return None + + def get_all_by_suit(self, suit_name: str) -> Dict[str, Dict[str, Any]]: + """ + Get all details for cards in a specific suit. + + Args: + suit_name: The suit name ("Cups", "Pentacles", "Swords", "Wands") + + Returns: + Dictionary of card details for that suit + """ + return { + name: details for name, details in self._details.items() + if suit_name.lower() in name.lower() + } + + def _get_registry_key_for_card(self, card: 'Card') -> Optional[str]: + """ + Get the registry key for a card based on deck position (1-78). + + Card position is independent of deck-specific names, allowing different + deck variants to use the same registry entries. + + Args: + card: The Card object to look up + + Returns: + Registry key string, or None if card cannot be mapped + """ + return self._position_map.get(card.number) + + def load_into_card(self, card: 'Card') -> bool: + """ + Load details from registry into a Card object using its position. + + Args: + card: The Card object to populate + + Returns: + True if details were found and loaded, False otherwise + """ + # Use position-based lookup instead of name-based + details = self.get_by_position(card.number) + if not details: + return False + + card.explanation = details.get("explanation", "") + card.interpretation = details.get("interpretation", "") + card.keywords = details.get("keywords", []) + card.reversed_keywords = details.get("reversed_keywords", []) + card.guidance = details.get("guidance", "") + + return True + + def __getitem__(self, card_name: str) -> Optional[Dict[str, Any]]: + """Allow dict-like access: registry['Princess of Swords']""" + return self.get(card_name) diff --git a/src/tarot/card/image_loader.py b/src/tarot/card/image_loader.py new file mode 100644 index 0000000..54287f4 --- /dev/null +++ b/src/tarot/card/image_loader.py @@ -0,0 +1,346 @@ +"""Image deck loader for matching Tarot card images to cards. + +This module provides intelligent image matching and loading, supporting: +- Numbered format: 0.jpg, 1.jpg, ... or 00_foolish.jpg, 01_magic_man.jpg +- Custom naming with override: ##_custom_name.jpg overrides default card names +- Intelligent fuzzy matching for card identification +- Hybrid modes with intelligent fallbacks + +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 + +if TYPE_CHECKING: + from tarot.deck import Card, Deck + + +class ImageDeckLoader: + """Loader for matching Tarot card images to deck cards.""" + + # Supported image extensions + 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) + + 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._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()}')) + + # 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) + "01_magic_man.jpg" -> (1, "magic_man", True) + "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" + "Ace of Cups" -> "ace of cups" + """ + # Convert to lowercase + normalized = name.lower() + + # Replace special characters with spaces + normalized = re.sub(r'[^\w\s]', ' ', normalized) + + # Collapse multiple spaces + 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]: + """ + 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 + """ + # Try direct number match first + 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) + + return None + + 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() + >>> count = loader.load_into_deck(deck, override_names=True) + >>> 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) + if custom_name: + if verbose: + print(f" {card.number}: {card.name} -> {custom_name}") + 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)), + } + + +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") + """ + loader = ImageDeckLoader(deck_folder) + return loader.load_into_deck(deck, override_names=override_names, verbose=verbose) diff --git a/src/tarot/card/loader.py b/src/tarot/card/loader.py new file mode 100644 index 0000000..a4a2639 --- /dev/null +++ b/src/tarot/card/loader.py @@ -0,0 +1,259 @@ +"""Card loader for populating card details from the registry. + +This module provides utilities to load card details from the CardDetailsRegistry +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) +""" + +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from tarot.card.card import Card + from tarot.card.details import CardDetailsRegistry + from tarot.deck import Deck + + +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() + >>> card = deck.major[0] # The Fool + >>> load_card_details(card) + True + >>> print(card.keywords) + ['new beginnings', 'innocence', 'faith', ...] + """ + 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 +) -> 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() + >>> count = load_deck_details(deck, verbose=True) + >>> print(f"Loaded details for {count} cards") + """ + 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): + loaded_count += 1 + if verbose: + print(f"✓ Loaded: {card.name}") + else: + 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']: + """ + 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 + >>> deck = Deck() + >>> swords = get_cards_by_suit(deck, "Swords") + >>> print(len(swords)) # Should be 14 + 14 + """ + 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] + + +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() + >>> all_cards = list(deck.major.cards()) + list(deck.minor.cups.cards()) + >>> love_cards = filter_cards_by_keywords(all_cards, "love") + """ + 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) + ] + + +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() + >>> card = deck.major[0] # The Fool + >>> print_card_details(card) + """ + 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), + } + + # Add reversed attributes only if requested + if include_reversed: + attributes['reversed_interpretation'] = ('Reversed Interpretation', False) + + # List attributes (joined with commas) + list_attributes = { + 'keywords': 'Keywords', + 'reversed_keywords': ('Reversed Keywords', include_reversed), + } + + # Numeric attributes + numeric_attributes = { + '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: + print(f"\n{display_name}:\n{value}") + + # Print list attributes + for attr_name, display_info in list_attributes.items(): + if isinstance(display_info, tuple): + display_name, should_show = display_info + if not should_show: + 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 +) -> 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") + >>> if info: + ... print(info['explanation']) + """ + if registry is None: + from tarot.card.details import CardDetailsRegistry + registry = CardDetailsRegistry() + + return registry.get(card_name) diff --git a/src/tarot/card/spread.py b/src/tarot/card/spread.py new file mode 100644 index 0000000..ccdc689 --- /dev/null +++ b/src/tarot/card/spread.py @@ -0,0 +1,323 @@ +""" +Tarot spread definitions and management with card drawing. + +Provides predefined spreads like Celtic Cross, Golden Dawn (3-card), etc. +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 + +if TYPE_CHECKING: + from tarot.card import Card + + +@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: + result += f"\n (Reversed: {self.reversed_meaning})" + return result + + +@dataclass +class DrawnCard: + """Represents a card drawn for a spread position.""" + position: SpreadPosition + 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}" + + +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'), + ] + }, + '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'), + ] + }, + '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'), + ] + }, + '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'), + ] + }, + '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('_', ' ') + + # Find matching spread + spread_data = None + for key, data in self.SPREADS.items(): + 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'] + + 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"═══════════════════════════════════════════") + + 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, + "" + ] + + 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: + if pos.number == position_number: + return pos + return None + + +def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]: + """ + Draw cards for all positions in a 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 + """ + import random + + # Load deck if not provided + if deck is None: + from tarot.deck import Deck + deck_instance = Deck() + deck = deck_instance.cards + + drawn_cards = [] + for position in spread.positions: + # Draw random card + card = random.choice(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'): + lines.append(f" Card #: {card.number}") + if hasattr(card, 'arcana'): + lines.append(f" Arcana: {card.arcana}") + if hasattr(card, 'suit') and card.suit: + lines.append(f" Suit: {card.suit.name}") + + lines.append("") + + lines.append(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + return "\n".join(lines) + + def __repr__(self) -> str: + return f"SpreadReading({self.spread.name}, {len(self.drawn_cards)} cards)" diff --git a/src/tarot/deck/__init__.py b/src/tarot/deck/__init__.py new file mode 100644 index 0000000..251ddf1 --- /dev/null +++ b/src/tarot/deck/__init__.py @@ -0,0 +1,32 @@ +""" +Tarot deck module - Core card and deck classes. + +Provides the Deck class for managing Tarot cards and the Card, MajorCard, +MinorCard, and related classes for representing individual cards. +""" + +from .deck import ( + Card, + MajorCard, + MinorCard, + PipCard, + AceCard, + CourtCard, + CardQuery, + TemporalQuery, + DLT, + Deck, +) + +__all__ = [ + "Card", + "MajorCard", + "MinorCard", + "PipCard", + "AceCard", + "CourtCard", + "CardQuery", + "TemporalQuery", + "DLT", + "Deck", +] diff --git a/src/tarot/deck/deck.py b/src/tarot/deck/deck.py new file mode 100644 index 0000000..36697ff --- /dev/null +++ b/src/tarot/deck/deck.py @@ -0,0 +1,734 @@ +""" +Core Tarot deck and card classes. + +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 +import random + +from ..attributes import ( + Meaning, CardImage, Suit, Zodiac, Element, Path, + Planet, Sephera, Color, PeriodicTable, ElementType, DoublLetterTrump +) + +if TYPE_CHECKING: + from ..card.data import CardDataLoader + +# Global CardDataLoader instance for accessing elements +_card_data = None # Will be initialized lazily + + +def _get_card_data(): + """Get or initialize the global CardDataLoader instance.""" + global _card_data + if _card_data is None: + from ..card.data import CardDataLoader + _card_data = CardDataLoader() + return _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: str = "" + interpretation: str = "" + keywords: List[str] = field(default_factory=list) + 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: + pip_names = { + 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).""" + if isinstance(self, MajorCard): + return "Major" + elif isinstance(self, AceCard): + return "Ace" + elif isinstance(self, CourtCard): + return "Court" + elif isinstance(self, PipCard): + return "Pip" + return "Unknown" + + +@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}") + 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") + self.arcana = "Minor" + + +@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}") + super().__post_init__() + + +@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}") + super().__post_init__() + + +@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 + + def __post_init__(self) -> None: + if self.court_rank not in self.COURT_RANKS: + raise ValueError( + f"CourtCard must have court_rank in {list(self.COURT_RANKS.keys())}, " + f"got {self.court_rank}" + ) + 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: + 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)] + + 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': + """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"] + + @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"] + + @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"] + + @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"] + + 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] + return f"CardQuery({names})" + + +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: + """ + Initialize temporal query builder. + + Args: + loader: CardDataLoader instance for fetching temporal data + month_num: Month number (1-12) + day_num: Day number (1-31) + hour_num: Hour number (0-23) + """ + self.loader = loader + self.month_num = month_num + self.day_num = day_num + self.hour_num = hour_num + + 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': + """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': + """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) + + 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: + parts.append(f"month={self.month_num}") + if self.day_num: + parts.append(f"day={self.day_num}") + if self.hour_num: + parts.append(f"hour={self.hour_num}") + return f"TemporalQuery({', '.join(parts)})" + + +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._deck: Optional[Deck] = None + + @property + 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': + """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 + for card in self.deck.cards: + 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) + + This puts Queen of Wands as card #78, the final card. + """ + # Minor Arcana - First three suits (Cups, Pentacles, Swords) + # Organized logically: Ace, 10, 2-9, then court cards Knight, Prince, Princess, Queen + # Get ElementType instances from CardDataLoader + card_data = _get_card_data() + water_element = card_data.element("Water") + 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) + + 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 = { + "Knight": (fire_element, yod_path), + "Prince": (air_element, vav_path), + "Princess": (earth_element, he_path), + "Queen": (water_element, he_path), + } + + suits_data_first = [ + ("Cups", water_element, 2), + ("Pentacles", earth_element, 4), + ("Swords", air_element, 3), + ] + + # Pip order: Ace (1), Ten (10), Two-Nine (2-9), Knight (12), Prince (11), Princess (13), Queen (14) + pip_order = [1, 10, 2, 3, 4, 5, 6, 7, 8, 9, 12, 11, 13, 14] + pip_names = { + 1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five", + 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten", + 11: "Prince", 12: "Knight", 13: "Princess", 14: "Queen" + } + + card_number = 1 + # Pip order: Ace (1), Ten (10), Two-Nine (2-9), then Court cards Knight (12), Prince (11), Princess (13), Queen (14) + # Map pip_order indices to actual pip numbers (1-10 only for pips) + pip_index_to_number = { + 1: 1, # Ace + 10: 10, # Ten + 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9 # Two through Nine + } + court_ranks = { + 12: "Knight", 11: "Prince", 13: "Princess", 14: "Queen" + } + + # Loop through first three suits + for suit_name, element_name, suit_num in suits_data_first: + suit = Suit(name=suit_name, element=element_name, + tarot_correspondence=f"{suit_name} Suit", number=suit_num) + + # Then loop through each position in the custom order + for pip_index in pip_order: + # Create appropriate card type based on pip_index + if pip_index <= 10: + # Pip card (Ace through 10) + actual_pip = pip_index_to_number[pip_index] + if pip_index == 1: + # Ace card + card = AceCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + pip=actual_pip + ) + else: + # Regular pip card (2-10) + card = PipCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + pip=actual_pip + ) + else: + # Court card (no pip) + court_rank = court_ranks[pip_index] + associated_element, hebrew_letter_path = court_rank_mappings[court_rank] + card = CourtCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + court_rank=court_rank, + associated_element=associated_element, + hebrew_letter_path=hebrew_letter_path + ) + self.cards.append(card) + card_number += 1 + + # Major Arcana (43-64) + # Names match filenames in src/tarot/deck/default/ + major_arcana_names = [ + "Fool", "Magus", "Fortune", "Lust", "Hanged Man", "Death", + "Art", "Devil", "Tower", "Star", "Moon", "Sun", + "High Priestess", "Empress", "Emperor", "Hierophant", + "Lovers", "Chariot", "Justice", "Hermit", "Aeon", "Universe" + ] + + for i, name in enumerate(major_arcana_names): + card = MajorCard( + number=card_number, + name=name, + meaning=Meaning( + upright=f"{name} upright meaning", + reversed=f"{name} reversed meaning" + ), + arcana="Major", + kabbalistic_number=i + ) + self.cards.append(card) + card_number += 1 + + # Minor Arcana - Last suit (Wands, 65-78) + # Organized logically: Ace, 10, 2-9, then court cards Knight, Prince, Princess, Queen + suits_data_last = [ + ("Wands", fire_element, 1), + ] + + # Loop through last suit + for suit_name, element_name, suit_num in suits_data_last: + suit = Suit(name=suit_name, element=element_name, + tarot_correspondence=f"{suit_name} Suit", number=suit_num) + + # Then loop through each position in the custom order + for pip_index in pip_order: + # Create appropriate card type based on pip_index + if pip_index <= 10: + # Pip card (Ace through 10) + actual_pip = pip_index_to_number[pip_index] + if pip_index == 1: + # Ace card + card = AceCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + pip=actual_pip + ) + else: + # Regular pip card (2-10) + card = PipCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + pip=actual_pip + ) + else: + # Court card (no pip) + court_rank = court_ranks[pip_index] + associated_element, hebrew_letter_path = court_rank_mappings[court_rank] + card = CourtCard( + number=card_number, + name=f"{pip_names[pip_index]} of {suit_name}", + meaning=Meaning( + upright=f"{pip_names[pip_index]} of {suit_name} upright", + reversed=f"{pip_names[pip_index]} of {suit_name} reversed" + ), + arcana="Minor", + suit=suit, + court_rank=court_rank, + associated_element=associated_element, + hebrew_letter_path=hebrew_letter_path + ) + self.cards.append(card) + card_number += 1 + + + 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") + + 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] + + @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)" diff --git a/src/tarot/deck/default/01_Ace Cups.webp b/src/tarot/deck/default/01_Ace Cups.webp new file mode 100644 index 0000000..2b231b9 Binary files /dev/null and b/src/tarot/deck/default/01_Ace Cups.webp differ diff --git a/src/tarot/deck/default/02_Ten Cups.webp b/src/tarot/deck/default/02_Ten Cups.webp new file mode 100644 index 0000000..7b39af7 Binary files /dev/null and b/src/tarot/deck/default/02_Ten Cups.webp differ diff --git a/src/tarot/deck/default/03_Two Cups.webp b/src/tarot/deck/default/03_Two Cups.webp new file mode 100644 index 0000000..5eb7ec6 Binary files /dev/null and b/src/tarot/deck/default/03_Two Cups.webp differ diff --git a/src/tarot/deck/default/04_Three Cups.webp b/src/tarot/deck/default/04_Three Cups.webp new file mode 100644 index 0000000..83a1ad0 Binary files /dev/null and b/src/tarot/deck/default/04_Three Cups.webp differ diff --git a/src/tarot/deck/default/05_Four Cups.webp b/src/tarot/deck/default/05_Four Cups.webp new file mode 100644 index 0000000..5af9e84 Binary files /dev/null and b/src/tarot/deck/default/05_Four Cups.webp differ diff --git a/src/tarot/deck/default/06_Five Cups.webp b/src/tarot/deck/default/06_Five Cups.webp new file mode 100644 index 0000000..331e0cf Binary files /dev/null and b/src/tarot/deck/default/06_Five Cups.webp differ diff --git a/src/tarot/deck/default/07_Six Cups.webp b/src/tarot/deck/default/07_Six Cups.webp new file mode 100644 index 0000000..e578109 Binary files /dev/null and b/src/tarot/deck/default/07_Six Cups.webp differ diff --git a/src/tarot/deck/default/08_Seven Cups.webp b/src/tarot/deck/default/08_Seven Cups.webp new file mode 100644 index 0000000..412cf05 Binary files /dev/null and b/src/tarot/deck/default/08_Seven Cups.webp differ diff --git a/src/tarot/deck/default/09_Eight Cups.webp b/src/tarot/deck/default/09_Eight Cups.webp new file mode 100644 index 0000000..0ff39a8 Binary files /dev/null and b/src/tarot/deck/default/09_Eight Cups.webp differ diff --git a/src/tarot/deck/default/10_Nine Cups.webp b/src/tarot/deck/default/10_Nine Cups.webp new file mode 100644 index 0000000..6885b22 Binary files /dev/null and b/src/tarot/deck/default/10_Nine Cups.webp differ diff --git a/src/tarot/deck/default/11_Knight Cups.webp b/src/tarot/deck/default/11_Knight Cups.webp new file mode 100644 index 0000000..af3a464 Binary files /dev/null and b/src/tarot/deck/default/11_Knight Cups.webp differ diff --git a/src/tarot/deck/default/12_Prince Cups.webp b/src/tarot/deck/default/12_Prince Cups.webp new file mode 100644 index 0000000..7c55dcb Binary files /dev/null and b/src/tarot/deck/default/12_Prince Cups.webp differ diff --git a/src/tarot/deck/default/13_Princess Cups.webp b/src/tarot/deck/default/13_Princess Cups.webp new file mode 100644 index 0000000..b91323e Binary files /dev/null and b/src/tarot/deck/default/13_Princess Cups.webp differ diff --git a/src/tarot/deck/default/14_Queen Cups.webp b/src/tarot/deck/default/14_Queen Cups.webp new file mode 100644 index 0000000..74ea921 Binary files /dev/null and b/src/tarot/deck/default/14_Queen Cups.webp differ diff --git a/src/tarot/deck/default/15_Ace Disks.webp b/src/tarot/deck/default/15_Ace Disks.webp new file mode 100644 index 0000000..dd62e18 Binary files /dev/null and b/src/tarot/deck/default/15_Ace Disks.webp differ diff --git a/src/tarot/deck/default/16_Ten Disks.webp b/src/tarot/deck/default/16_Ten Disks.webp new file mode 100644 index 0000000..3f3c117 Binary files /dev/null and b/src/tarot/deck/default/16_Ten Disks.webp differ diff --git a/src/tarot/deck/default/17_Two Disks.webp b/src/tarot/deck/default/17_Two Disks.webp new file mode 100644 index 0000000..791ef2d Binary files /dev/null and b/src/tarot/deck/default/17_Two Disks.webp differ diff --git a/src/tarot/deck/default/18_Three Disks.webp b/src/tarot/deck/default/18_Three Disks.webp new file mode 100644 index 0000000..5f206cc Binary files /dev/null and b/src/tarot/deck/default/18_Three Disks.webp differ diff --git a/src/tarot/deck/default/19_Four Disks.webp b/src/tarot/deck/default/19_Four Disks.webp new file mode 100644 index 0000000..2114098 Binary files /dev/null and b/src/tarot/deck/default/19_Four Disks.webp differ diff --git a/src/tarot/deck/default/20_Five Disks.webp b/src/tarot/deck/default/20_Five Disks.webp new file mode 100644 index 0000000..a895ce4 Binary files /dev/null and b/src/tarot/deck/default/20_Five Disks.webp differ diff --git a/src/tarot/deck/default/21_Six Disks.webp b/src/tarot/deck/default/21_Six Disks.webp new file mode 100644 index 0000000..e882cb7 Binary files /dev/null and b/src/tarot/deck/default/21_Six Disks.webp differ diff --git a/src/tarot/deck/default/22_Seven Disks.webp b/src/tarot/deck/default/22_Seven Disks.webp new file mode 100644 index 0000000..7c7167a Binary files /dev/null and b/src/tarot/deck/default/22_Seven Disks.webp differ diff --git a/src/tarot/deck/default/23_Eight Disks.webp b/src/tarot/deck/default/23_Eight Disks.webp new file mode 100644 index 0000000..7f8c49c Binary files /dev/null and b/src/tarot/deck/default/23_Eight Disks.webp differ diff --git a/src/tarot/deck/default/24_Nine Disks.webp b/src/tarot/deck/default/24_Nine Disks.webp new file mode 100644 index 0000000..c62da98 Binary files /dev/null and b/src/tarot/deck/default/24_Nine Disks.webp differ diff --git a/src/tarot/deck/default/25_Knight Disks.webp b/src/tarot/deck/default/25_Knight Disks.webp new file mode 100644 index 0000000..5620579 Binary files /dev/null and b/src/tarot/deck/default/25_Knight Disks.webp differ diff --git a/src/tarot/deck/default/26_Prince Disks.webp b/src/tarot/deck/default/26_Prince Disks.webp new file mode 100644 index 0000000..11b55a5 Binary files /dev/null and b/src/tarot/deck/default/26_Prince Disks.webp differ diff --git a/src/tarot/deck/default/27_Princess Disks.webp b/src/tarot/deck/default/27_Princess Disks.webp new file mode 100644 index 0000000..961c02b Binary files /dev/null and b/src/tarot/deck/default/27_Princess Disks.webp differ diff --git a/src/tarot/deck/default/28_Queen Disks.webp b/src/tarot/deck/default/28_Queen Disks.webp new file mode 100644 index 0000000..7473f17 Binary files /dev/null and b/src/tarot/deck/default/28_Queen Disks.webp differ diff --git a/src/tarot/deck/default/29_Ace Swords.webp b/src/tarot/deck/default/29_Ace Swords.webp new file mode 100644 index 0000000..e1829f9 Binary files /dev/null and b/src/tarot/deck/default/29_Ace Swords.webp differ diff --git a/src/tarot/deck/default/30_Ten Swords.webp b/src/tarot/deck/default/30_Ten Swords.webp new file mode 100644 index 0000000..ad00dcb Binary files /dev/null and b/src/tarot/deck/default/30_Ten Swords.webp differ diff --git a/src/tarot/deck/default/31_Two Swords.webp b/src/tarot/deck/default/31_Two Swords.webp new file mode 100644 index 0000000..038d5a9 Binary files /dev/null and b/src/tarot/deck/default/31_Two Swords.webp differ diff --git a/src/tarot/deck/default/32_Three Swords.webp b/src/tarot/deck/default/32_Three Swords.webp new file mode 100644 index 0000000..d997cba Binary files /dev/null and b/src/tarot/deck/default/32_Three Swords.webp differ diff --git a/src/tarot/deck/default/33_Four Swords.webp b/src/tarot/deck/default/33_Four Swords.webp new file mode 100644 index 0000000..2811b3f Binary files /dev/null and b/src/tarot/deck/default/33_Four Swords.webp differ diff --git a/src/tarot/deck/default/34_Five Swords.webp b/src/tarot/deck/default/34_Five Swords.webp new file mode 100644 index 0000000..af8513e Binary files /dev/null and b/src/tarot/deck/default/34_Five Swords.webp differ diff --git a/src/tarot/deck/default/35_Six Swords.webp b/src/tarot/deck/default/35_Six Swords.webp new file mode 100644 index 0000000..db736d7 Binary files /dev/null and b/src/tarot/deck/default/35_Six Swords.webp differ diff --git a/src/tarot/deck/default/36_Seven Swords.webp b/src/tarot/deck/default/36_Seven Swords.webp new file mode 100644 index 0000000..f7d125c Binary files /dev/null and b/src/tarot/deck/default/36_Seven Swords.webp differ diff --git a/src/tarot/deck/default/37_Eight Swords.webp b/src/tarot/deck/default/37_Eight Swords.webp new file mode 100644 index 0000000..a65fdce Binary files /dev/null and b/src/tarot/deck/default/37_Eight Swords.webp differ diff --git a/src/tarot/deck/default/38_Nine Swords.webp b/src/tarot/deck/default/38_Nine Swords.webp new file mode 100644 index 0000000..6087ba8 Binary files /dev/null and b/src/tarot/deck/default/38_Nine Swords.webp differ diff --git a/src/tarot/deck/default/39_Knight Swords.webp b/src/tarot/deck/default/39_Knight Swords.webp new file mode 100644 index 0000000..24691ab Binary files /dev/null and b/src/tarot/deck/default/39_Knight Swords.webp differ diff --git a/src/tarot/deck/default/40_Prince Swords.webp b/src/tarot/deck/default/40_Prince Swords.webp new file mode 100644 index 0000000..f7f68eb Binary files /dev/null and b/src/tarot/deck/default/40_Prince Swords.webp differ diff --git a/src/tarot/deck/default/41_Princess Swords.webp b/src/tarot/deck/default/41_Princess Swords.webp new file mode 100644 index 0000000..8ee7d54 Binary files /dev/null and b/src/tarot/deck/default/41_Princess Swords.webp differ diff --git a/src/tarot/deck/default/42_Queen Swords.webp b/src/tarot/deck/default/42_Queen Swords.webp new file mode 100644 index 0000000..45183e4 Binary files /dev/null and b/src/tarot/deck/default/42_Queen Swords.webp differ diff --git a/src/tarot/deck/default/43_Fool.webp b/src/tarot/deck/default/43_Fool.webp new file mode 100644 index 0000000..c37d066 Binary files /dev/null and b/src/tarot/deck/default/43_Fool.webp differ diff --git a/src/tarot/deck/default/44_Magus.webp b/src/tarot/deck/default/44_Magus.webp new file mode 100644 index 0000000..3f4fea1 Binary files /dev/null and b/src/tarot/deck/default/44_Magus.webp differ diff --git a/src/tarot/deck/default/45_Fortune.webp b/src/tarot/deck/default/45_Fortune.webp new file mode 100644 index 0000000..fe849d8 Binary files /dev/null and b/src/tarot/deck/default/45_Fortune.webp differ diff --git a/src/tarot/deck/default/46_Lust.webp b/src/tarot/deck/default/46_Lust.webp new file mode 100644 index 0000000..90fda21 Binary files /dev/null and b/src/tarot/deck/default/46_Lust.webp differ diff --git a/src/tarot/deck/default/47_Hanged Man.webp b/src/tarot/deck/default/47_Hanged Man.webp new file mode 100644 index 0000000..3fc1345 Binary files /dev/null and b/src/tarot/deck/default/47_Hanged Man.webp differ diff --git a/src/tarot/deck/default/48_Death.webp b/src/tarot/deck/default/48_Death.webp new file mode 100644 index 0000000..6fb213f Binary files /dev/null and b/src/tarot/deck/default/48_Death.webp differ diff --git a/src/tarot/deck/default/49_Art.webp b/src/tarot/deck/default/49_Art.webp new file mode 100644 index 0000000..0e327e5 Binary files /dev/null and b/src/tarot/deck/default/49_Art.webp differ diff --git a/src/tarot/deck/default/50_Devil.webp b/src/tarot/deck/default/50_Devil.webp new file mode 100644 index 0000000..055a919 Binary files /dev/null and b/src/tarot/deck/default/50_Devil.webp differ diff --git a/src/tarot/deck/default/51_Tower.webp b/src/tarot/deck/default/51_Tower.webp new file mode 100644 index 0000000..d0d351d Binary files /dev/null and b/src/tarot/deck/default/51_Tower.webp differ diff --git a/src/tarot/deck/default/52_Star.webp b/src/tarot/deck/default/52_Star.webp new file mode 100644 index 0000000..4ae1096 Binary files /dev/null and b/src/tarot/deck/default/52_Star.webp differ diff --git a/src/tarot/deck/default/53_Moon.webp b/src/tarot/deck/default/53_Moon.webp new file mode 100644 index 0000000..f79f7ea Binary files /dev/null and b/src/tarot/deck/default/53_Moon.webp differ diff --git a/src/tarot/deck/default/54_Sun.webp b/src/tarot/deck/default/54_Sun.webp new file mode 100644 index 0000000..4a55d19 Binary files /dev/null and b/src/tarot/deck/default/54_Sun.webp differ diff --git a/src/tarot/deck/default/55_High Priestess.webp b/src/tarot/deck/default/55_High Priestess.webp new file mode 100644 index 0000000..67fb984 Binary files /dev/null and b/src/tarot/deck/default/55_High Priestess.webp differ diff --git a/src/tarot/deck/default/56_Aeon.webp b/src/tarot/deck/default/56_Aeon.webp new file mode 100644 index 0000000..52bfa0c Binary files /dev/null and b/src/tarot/deck/default/56_Aeon.webp differ diff --git a/src/tarot/deck/default/57_Universe.webp b/src/tarot/deck/default/57_Universe.webp new file mode 100644 index 0000000..cdd89ac Binary files /dev/null and b/src/tarot/deck/default/57_Universe.webp differ diff --git a/src/tarot/deck/default/58_Empress.webp b/src/tarot/deck/default/58_Empress.webp new file mode 100644 index 0000000..96fdc7b Binary files /dev/null and b/src/tarot/deck/default/58_Empress.webp differ diff --git a/src/tarot/deck/default/59_Emperor.webp b/src/tarot/deck/default/59_Emperor.webp new file mode 100644 index 0000000..f00530c Binary files /dev/null and b/src/tarot/deck/default/59_Emperor.webp differ diff --git a/src/tarot/deck/default/60_Hierophant.webp b/src/tarot/deck/default/60_Hierophant.webp new file mode 100644 index 0000000..3c06acf Binary files /dev/null and b/src/tarot/deck/default/60_Hierophant.webp differ diff --git a/src/tarot/deck/default/61_Lovers.webp b/src/tarot/deck/default/61_Lovers.webp new file mode 100644 index 0000000..b2550d9 Binary files /dev/null and b/src/tarot/deck/default/61_Lovers.webp differ diff --git a/src/tarot/deck/default/62_Chariot.webp b/src/tarot/deck/default/62_Chariot.webp new file mode 100644 index 0000000..c36de86 Binary files /dev/null and b/src/tarot/deck/default/62_Chariot.webp differ diff --git a/src/tarot/deck/default/63_Justice.webp b/src/tarot/deck/default/63_Justice.webp new file mode 100644 index 0000000..95ccb5f Binary files /dev/null and b/src/tarot/deck/default/63_Justice.webp differ diff --git a/src/tarot/deck/default/64_Hermit.webp b/src/tarot/deck/default/64_Hermit.webp new file mode 100644 index 0000000..89961d1 Binary files /dev/null and b/src/tarot/deck/default/64_Hermit.webp differ diff --git a/src/tarot/deck/default/65_Ace Wands.webp b/src/tarot/deck/default/65_Ace Wands.webp new file mode 100644 index 0000000..35a5a17 Binary files /dev/null and b/src/tarot/deck/default/65_Ace Wands.webp differ diff --git a/src/tarot/deck/default/66_Ten Wands.webp b/src/tarot/deck/default/66_Ten Wands.webp new file mode 100644 index 0000000..0726f9c Binary files /dev/null and b/src/tarot/deck/default/66_Ten Wands.webp differ diff --git a/src/tarot/deck/default/67_Two Wands.webp b/src/tarot/deck/default/67_Two Wands.webp new file mode 100644 index 0000000..8b25296 Binary files /dev/null and b/src/tarot/deck/default/67_Two Wands.webp differ diff --git a/src/tarot/deck/default/68_Three Wands.webp b/src/tarot/deck/default/68_Three Wands.webp new file mode 100644 index 0000000..10ac609 Binary files /dev/null and b/src/tarot/deck/default/68_Three Wands.webp differ diff --git a/src/tarot/deck/default/69_Four Wands.webp b/src/tarot/deck/default/69_Four Wands.webp new file mode 100644 index 0000000..f3d2377 Binary files /dev/null and b/src/tarot/deck/default/69_Four Wands.webp differ diff --git a/src/tarot/deck/default/70_Five Wands.webp b/src/tarot/deck/default/70_Five Wands.webp new file mode 100644 index 0000000..2e51bc3 Binary files /dev/null and b/src/tarot/deck/default/70_Five Wands.webp differ diff --git a/src/tarot/deck/default/71_Six Wands.webp b/src/tarot/deck/default/71_Six Wands.webp new file mode 100644 index 0000000..b09dbd9 Binary files /dev/null and b/src/tarot/deck/default/71_Six Wands.webp differ diff --git a/src/tarot/deck/default/72_Seven Wands.webp b/src/tarot/deck/default/72_Seven Wands.webp new file mode 100644 index 0000000..1e5adb9 Binary files /dev/null and b/src/tarot/deck/default/72_Seven Wands.webp differ diff --git a/src/tarot/deck/default/73_Eight Wands.webp b/src/tarot/deck/default/73_Eight Wands.webp new file mode 100644 index 0000000..f9a2ebb Binary files /dev/null and b/src/tarot/deck/default/73_Eight Wands.webp differ diff --git a/src/tarot/deck/default/74_Nine Wands.webp b/src/tarot/deck/default/74_Nine Wands.webp new file mode 100644 index 0000000..aecb255 Binary files /dev/null and b/src/tarot/deck/default/74_Nine Wands.webp differ diff --git a/src/tarot/deck/default/75_Knight Wands.webp b/src/tarot/deck/default/75_Knight Wands.webp new file mode 100644 index 0000000..c7627b2 Binary files /dev/null and b/src/tarot/deck/default/75_Knight Wands.webp differ diff --git a/src/tarot/deck/default/76_Prince Wands.webp b/src/tarot/deck/default/76_Prince Wands.webp new file mode 100644 index 0000000..ccfd86c Binary files /dev/null and b/src/tarot/deck/default/76_Prince Wands.webp differ diff --git a/src/tarot/deck/default/77_Princess Wands.webp b/src/tarot/deck/default/77_Princess Wands.webp new file mode 100644 index 0000000..f27dde3 Binary files /dev/null and b/src/tarot/deck/default/77_Princess Wands.webp differ diff --git a/src/tarot/deck/default/78_Queen Wands.webp b/src/tarot/deck/default/78_Queen Wands.webp new file mode 100644 index 0000000..2ce91f3 Binary files /dev/null and b/src/tarot/deck/default/78_Queen Wands.webp differ diff --git a/src/tarot/tarot_api.py b/src/tarot/tarot_api.py new file mode 100644 index 0000000..bfa05d6 --- /dev/null +++ b/src/tarot/tarot_api.py @@ -0,0 +1,119 @@ +""" +Tarot namespace API. + +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 .card import CardAccessor +from kaballah import Tree, Cube +from letter import letters + +if TYPE_CHECKING: + from utils.attributes import Planet, God + from .attributes import Hexagram + + +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" + + 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 + _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']: + ... + + @classmethod + @overload + def planet(cls, name: None = ...) -> Dict[str, 'Planet']: + ... + + @classmethod + 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']]: + """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']]: + """Return a hexagram or all hexagrams.""" + cls._ensure_initialized() + return cls._loader.hexagram(number) # type: ignore + + diff --git a/src/temporal/__init__.py b/src/temporal/__init__.py new file mode 100644 index 0000000..f4fa3f5 --- /dev/null +++ b/src/temporal/__init__.py @@ -0,0 +1,53 @@ +"""Clock module for temporal and time-related calculations. + +This module provides classes for working with temporal concepts in the Tarot system, +including calendrical systems, time cycles, astronomical coordinates, and astrological +planetary positions. + +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 .calendar import Calendar +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', + # Astrological classes + 'ThalemaClock', + 'Zodiac', + 'PlanetPosition', +] diff --git a/src/temporal/astrology.py b/src/temporal/astrology.py new file mode 100644 index 0000000..3ff62b8 --- /dev/null +++ b/src/temporal/astrology.py @@ -0,0 +1,241 @@ +"""Astrological module - Thelema Clock and planetary positions. + +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 + + +class Zodiac(Enum): + """Zodiac signs with degree ranges (0-360°).""" + ARIES = ("♈", 0, 30) + TAURUS = ("♉", 30, 60) + GEMINI = ("♊", 60, 90) + CANCER = ("♋", 90, 120) + LEO = ("♌", 120, 150) + VIRGO = ("♍", 150, 180) + LIBRA = ("♎", 180, 210) + SCORPIO = ("♏", 210, 240) + SAGITTARIUS = ("♐", 240, 270) + CAPRICORN = ("♑", 270, 300) + AQUARIUS = ("♒", 300, 330) + PISCES = ("♓", 330, 360) + + def __init__(self, symbol: str, start_degree: int, end_degree: int): + """Initialize zodiac with symbol and degree range.""" + self.symbol = symbol + self.start_degree = start_degree + self.end_degree = end_degree + + @classmethod + def from_degree(cls, degree: float) -> "Zodiac": + """Get zodiac sign from ecliptic degree (0-360).""" + degree = degree % 360 + for zodiac in cls: + if zodiac.start_degree <= degree < zodiac.end_degree: + return zodiac + return cls.ARIES # Fallback + + def get_degree_in_sign(self, ecliptic_degree: float) -> float: + """Get degree within the zodiac sign (0-30).""" + ecliptic_degree = ecliptic_degree % 360 + return ecliptic_degree - self.start_degree + + +@dataclass +class PlanetPosition: + """Represents a planet's position with degree and zodiac.""" + planet_name: str + planet_symbol: str + zodiac: Zodiac + degree_in_sign: float # 0-30 degrees + ecliptic_degree: float # 0-360 degrees + + def __str__(self) -> str: + """Format as: ☉︎ 25°Scorpio""" + return f"{self.planet_symbol} {self.degree_in_sign:.0f}° {self.zodiac.name.title()}" + + def __repr__(self) -> str: + """Detailed representation.""" + return f"PlanetPosition({self.planet_name}, {self})" + + +class ThalemaClock: + """Astrological Thelema Clock showing planetary positions with degrees. + + Calculates approximate planetary positions based on date/time for display + in the format: O 25 Scorpio (Sun at 25 degrees Scorpio). + + Note: Uses simplified ephemeris calculations, not precise astronomical data. + For precise calculations, use astronomy libraries like Skyfield or PyEphem. + """ + + # Planet symbols (plaintext astronomical notation) + PLANET_SYMBOLS = { + "Sun": "☉︎", + "Moon": "☾︎", + "Mercury": "☿︎", + "Venus": "♀︎", + "Mars": "♂︎", + "Jupiter": "♃︎", + "Saturn": "♄︎", + "Uranus": "♅︎", + "Neptune": "♆︎", + "Pluto": "♇︎", + } + + # Mean orbital periods (days) + ORBITAL_PERIODS = { + "Sun": 365.25, + "Moon": 27.32, # Sidereal month + "Mercury": 87.97, + "Venus": 224.70, + "Mars": 686.98, + "Jupiter": 11.86 * 365.25, + "Saturn": 29.46 * 365.25, + "Uranus": 84.01 * 365.25, + "Neptune": 164.79 * 365.25, + "Pluto": 247.94 * 365.25, + } + + # Mean longitudes at J2000.0 (Jan 1, 2000, 12:00 UT) + # Approximate positions in ecliptic longitude + MEAN_LONGITUDES_J2000 = { + "Sun": 100.47, + "Moon": 280.47, # Mean longitude + "Mercury": 149.03, + "Venus": 217.32, + "Mars": 241.46, + "Jupiter": 100.47, + "Saturn": 102.87, + "Uranus": 33.51, + "Neptune": 48.02, + "Pluto": 14.53, + } + + # Reference epoch (J2000.0) + J2000_EPOCH = datetime(2000, 1, 1, 12, 0, 0) + + def __init__(self, moment: Optional[datetime] = None): + """Initialize Thelema Clock for a given moment. + + Args: + moment: datetime object. Defaults to current time. + """ + self.moment = moment or datetime.now() + self.positions: Dict[str, PlanetPosition] = {} + self._calculate_positions() + + def _calculate_positions(self) -> None: + """Calculate approximate planetary positions.""" + # Days since J2000.0 epoch + days_since_j2000 = (self.moment - self.J2000_EPOCH).total_seconds() / 86400 + + for planet_name in self.PLANET_SYMBOLS.keys(): + # Get orbital period and mean longitude + orbital_period = self.ORBITAL_PERIODS.get(planet_name, 365.25) + mean_longitude_j2000 = self.MEAN_LONGITUDES_J2000.get(planet_name, 0) + + # Calculate mean motion (degrees per day) + mean_motion = 360 / orbital_period + + # Calculate current ecliptic longitude + ecliptic_degree = (mean_longitude_j2000 + mean_motion * days_since_j2000) % 360 + + # Determine zodiac sign and degree within sign + zodiac = Zodiac.from_degree(ecliptic_degree) + degree_in_sign = zodiac.get_degree_in_sign(ecliptic_degree) + + # Create position object + self.positions[planet_name] = PlanetPosition( + planet_name=planet_name, + planet_symbol=self.PLANET_SYMBOLS[planet_name], + zodiac=zodiac, + degree_in_sign=degree_in_sign, + ecliptic_degree=ecliptic_degree, + ) + + def get_planet(self, planet_name: str) -> Optional[PlanetPosition]: + """Get position for a specific planet. + + Args: + planet_name: Planet name (e.g., 'Sun', 'Moon', 'Mercury') + + Returns: + PlanetPosition or None if planet not found + """ + return self.positions.get(planet_name) + + def get_traditional_planets(self) -> Dict[str, PlanetPosition]: + """Get the 7 traditional planets (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn).""" + traditional = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"] + return {name: self.positions[name] for name in traditional if name in self.positions} + + def display_format( + self, planet_order: Optional[List[str]] = None, suffix: Optional[str] = None + ) -> str: + """Display in custom format. + + Args: + planet_order: List of planet names to display (e.g., ['Moon', 'Mercury', 'Mars']). + Defaults to traditional 7 planets. + suffix: Optional suffix text (e.g., 'Vx' for Vertex). + + Returns: + Formatted string like: "☽ 23°♎ : ☿ 2°♐ : ♂ 9°♐ : ♀ 13°♏ : ☉ 25°♏" or with suffix + """ + if planet_order is None: + planet_order = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"] + + parts = [] + for planet_name in planet_order: + if planet_name in self.positions: + parts.append(str(self.positions[planet_name])) + elif planet_name.lower() == "vertex": + # Special case for Vertex - could be calculated from Ascendant + Descendant + parts.append("Vx") + + result = " : ".join(parts) + if suffix: + result += f" : {suffix}" + return result + + def display_compact(self) -> str: + """Display in compact format like: ☉︎ 25°Scorpio : ☾︎ 23°Libra : ☿︎ 2°Sagittarius : ♀︎ 13°Scorpio : ♂︎ 9°Sagittarius""" + return self.display_format() + + def display_verbose(self) -> str: + """Display with full planet names and zodiac sign names.""" + lines = [] + 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}") + return "\n".join(lines) + + def __str__(self) -> str: + """Display as compact thelema clock format.""" + return self.display_compact() + + def __repr__(self) -> str: + """Detailed representation.""" + timestamp = self.moment.strftime("%Y-%m-%d %H:%M:%S") + return f"ThalemaClock({timestamp})\n{self.display_verbose()}" diff --git a/src/temporal/attributes.py b/src/temporal/attributes.py new file mode 100644 index 0000000..938df26 --- /dev/null +++ b/src/temporal/attributes.py @@ -0,0 +1,88 @@ +""" +Temporal and astrological attributes. + +This module defines attributes specific to the Temporal module, +including Zodiac, Time cycles, and Astrological influences. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class Month: + """Represents a calendar month.""" + number: int + name: str + zodiac_start: str + zodiac_end: str + + +@dataclass +class Weekday: + """Represents weekday/weekend archetypes with planetary ties.""" + number: int + name: str + planetary_correspondence: str + is_weekend: bool = False + keywords: List[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if not 1 <= self.number <= 7: + raise ValueError(f"Weekday number must be between 1 and 7, got {self.number}") + + +@dataclass +class Hour: + """Represents an hour with planetary correspondence.""" + number: int + name: str + planetary_hours: List[str] = field(default_factory=list) + + +@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 + planetary_ruler: str + description: str = "" + time_of_day: str = field(init=False) + + def __post_init__(self) -> None: + if not 0 <= self.hour_24 <= 23: + raise ValueError(f"24-hour value must be between 0 and 23, got {self.hour_24}") + if not 1 <= self.hour_12 <= 12: + raise ValueError(f"12-hour value must be between 1 and 12, got {self.hour_12}") + if self.period not in {"AM", "PM"}: + raise ValueError(f"Period must be AM or PM, got {self.period}") + self.time_of_day = "Day" if self.period == "AM" else "Night" + + +@dataclass +class Zodiac: + """Represents a zodiac sign.""" + name: str + symbol: str + element: str + ruling_planet: str + date_range: str + + +@dataclass +class Degree: + """Represents an astrological degree.""" + number: int + constellation: str + ruling_planet: str + tarot_card: str + + +@dataclass +class AstrologicalInfluence: + """Represents astrological influences.""" + planet: str + sign: str + house: str + aspect: str diff --git a/src/temporal/calendar.py b/src/temporal/calendar.py new file mode 100644 index 0000000..124bf11 --- /dev/null +++ b/src/temporal/calendar.py @@ -0,0 +1,73 @@ +"""Calendar calculations and date utilities. + +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 + + +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, + } + + @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 + """ + if month == 2: + # 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 + """ + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) diff --git a/src/temporal/coordinates.py b/src/temporal/coordinates.py new file mode 100644 index 0000000..b5058b4 --- /dev/null +++ b/src/temporal/coordinates.py @@ -0,0 +1,121 @@ +"""Temporal coordinates and astronomical calculations. + +This module provides temporal positioning including solstices, equinoxes, +and other astronomical/calendrical coordinates. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Dict, Any + + +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) + + +@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) + 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), + Season.SUMMER: SolarEvent("solstice", (6, 21), Season.SUMMER), + 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 + """ + # Define season boundaries + if (month == 3 and day >= 20) or (month in (4, 5)) or (month == 6 and day < 21): + return Season.SPRING + elif (month == 6 and day >= 21) or (month in (7, 8)) or (month == 9 and day < 22): + return Season.SUMMER + elif (month == 9 and day >= 22) or (month in (10, 11)) or (month == 12 and day < 21): + 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 + """ + # This is a simplified calculation + 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 diff --git a/src/temporal/temporal.py b/src/temporal/temporal.py new file mode 100644 index 0000000..1e304d3 --- /dev/null +++ b/src/temporal/temporal.py @@ -0,0 +1,104 @@ +"""Temporal classes for years, months, days, hours, and weeks. + +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})" + + +@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" + ] + return names[self.value - 1] + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"Month({self.value})" + + +@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})" + + +@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})" + + +@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}") + if not 0 <= self.minute <= 59: + 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})" diff --git a/src/temporal/time.py b/src/temporal/time.py new file mode 100644 index 0000000..181ec73 --- /dev/null +++ b/src/temporal/time.py @@ -0,0 +1,67 @@ +"""Time-specific utilities and calculations. + +This module handles time-related operations and conversions. +""" + +from datetime import datetime, time +from typing import Dict, Any, Optional + + +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 + """ + hours = seconds // 3600 + remaining = seconds % 3600 + minutes = remaining // 60 + secs = remaining % 60 + + return { + '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 + """ + return 0 <= hour <= 23 diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..d3770c7 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,49 @@ +"""Utility modules for the Tarot project.""" + +from .filter import ( + universal_filter, + get_filterable_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, +) +from .misc import ( + Personality, + MBTIType, +) + +__all__ = [ + "universal_filter", + "get_filterable_fields", + "filter_by", + "format_results", + "get_filter_autocomplete", + "describe_filter_fields", + "Note", + "Element", + "ElementType", + "Number", + "Color", + "Colorscale", + "Planet", + "God", + "Perfume", + "Cipher", + "CipherResult", + "Personality", + "MBTIType", +] diff --git a/src/utils/attributes.py b/src/utils/attributes.py new file mode 100644 index 0000000..9538c51 --- /dev/null +++ b/src/utils/attributes.py @@ -0,0 +1,390 @@ +""" +Shared attribute classes used across multiple namespaces. + +This module contains data structures that are shared between different +namespaces (tarot, letter, number, temporal, kaballah) and don't belong +exclusively to any single namespace. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + + +@dataclass +class Meaning: + """Represents the meaning of a card.""" + upright: str + reversed: str + + +@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) + octave: int = 4 # Default octave + element: Optional[str] = None # Associated element if any + planet: Optional[str] = None # Associated planet if any + 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}") + if not 0 <= self.frequency <= 20000: + raise ValueError(f"Frequency must be 0-20000 Hz, got {self.frequency}") + + +@dataclass +class Element: + """Represents one of the four elements.""" + name: str + symbol: str + color: str + direction: str + astrological_signs: List[str] = field(default_factory=list) + + +@dataclass +class ElementType: + """Represents an elemental force (Fire, Water, Air, Earth, Spirit).""" + name: str + symbol: str + direction: str + polarity: str # Active, Passive, or Neutral + color: str + tarot_suits: List[str] = field(default_factory=list) + zodiac_signs: List[str] = field(default_factory=list) + keywords: List[str] = field(default_factory=list) + description: str = "" + + def __post_init__(self) -> None: + if self.polarity not in {"Active", "Passive", "Neutral"}: + raise ValueError(f"Polarity must be Active, Passive, or Neutral, got {self.polarity}") + + +@dataclass +class Number: + """Represents a number (1-9) with Kabbalistic attributes.""" + value: int + sephera: str + element: str + compliment: int + 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 + + +@dataclass(frozen=True) +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 + queen_scale: str # He - Mother principle + emperor_scale: str # Vau - Son principle + empress_scale: str # He final - Daughter principle + sephirotic_color: Optional[str] = None # Collective color of Sephiroth + type: str = "Sephira" # "Sephira" or "Path" + keywords: List[str] = field(default_factory=list) + description: str = "" + + +@dataclass +class Color: + """Represents a color with Kabbalistic correspondences.""" + name: str + hex_value: str + rgb: Tuple[int, int, int] + sephera: str + number: int + element: str + scale: str # King, Queen, Prince, Princess scale + 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}") + + +@dataclass +class Planet: + """Represents a planetary correspondence entry.""" + name: str + symbol: str + element: str + ruling_zodiac: List[str] = field(default_factory=list) + associated_numbers: List[Number] = field(default_factory=list) + associated_letters: List[str] = field(default_factory=list) + 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 + domains: List[str] = field(default_factory=list) + epithets: List[str] = field(default_factory=list) + mythology: str = "" + sephera_numbers: List[int] = field(default_factory=list) + path_numbers: List[int] = field(default_factory=list) + planets: List[str] = field(default_factory=list) + elements: List[str] = field(default_factory=list) + zodiac_signs: List[str] = field(default_factory=list) + associated_planet: Optional[Planet] = None + associated_element: Optional[ElementType] = None + associated_numbers: List[Number] = field(default_factory=list) + tarot_trumps: List[str] = field(default_factory=list) + keywords: List[str] = field(default_factory=list) + description: str = "" + + def culture_key(self) -> str: + """Return a normalized key for dictionary grouping.""" + return self.culture.lower() + + 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) + 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" + sephera_number: Optional[int] = None # Sephira number (1-10) if sephera correspondence + path_number: Optional[int] = None # Path number (11-32) if path correspondence + element: Optional[str] = None # "Air", "Fire", "Water", "Earth", "Spirit" + planet: Optional[str] = None # Planetary association + zodiac_sign: Optional[str] = None # Zodiac sign association + astrological_quality: Optional[str] = None # "Ascendant", "Succedent", "Cadent" + keywords: List[str] = field(default_factory=list) + associated_number: Optional[Number] = None + 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) + + +@dataclass(frozen=True) +class Cipher: + """Pattern-based cipher that can be layered onto any alphabet order.""" + + name: str + key: str + pattern: Sequence[int] + cycle: bool = False + default_alphabet: Optional[str] = None + letter_subset: Optional[Set[str]] = None + description: str = "" + + def __post_init__(self) -> None: + if not self.pattern: + raise ValueError("Cipher pattern must include at least one value") + normalized_subset = None + if self.letter_subset: + normalized_subset = {letter.upper() for letter in self.letter_subset} + object.__setattr__(self, "pattern", tuple(self.pattern)) + object.__setattr__(self, "letter_subset", normalized_subset) + + def mapping_for_alphabet(self, alphabet_letters: Sequence[str]) -> Dict[str, int]: + """Build a lookup mapping for the provided alphabet order.""" + letters_in_scope: List[str] = [] + for letter in alphabet_letters: + normalized = letter.upper() + if self.letter_subset and normalized not in self.letter_subset: + continue + letters_in_scope.append(normalized) + if not letters_in_scope: + raise ValueError("Alphabet does not contain any letters applicable to this cipher") + values = self._expand_pattern(len(letters_in_scope)) + return {letter: value for letter, value in zip(letters_in_scope, values)} + + def encode(self, text: str, alphabet_letters: Sequence[str]) -> List[int]: + """Encode the supplied text using the alphabet ordering.""" + mapping = self.mapping_for_alphabet(alphabet_letters) + values: List[int] = [] + for char in text.upper(): + if char in mapping: + values.append(mapping[char]) + return values + + def _expand_pattern(self, target_length: int) -> List[int]: + if len(self.pattern) == target_length: + return list(self.pattern) + if self.cycle: + expanded: List[int] = [] + idx = 0 + while len(expanded) < target_length: + 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" + ) + + +@dataclass(frozen=True) +class CipherResult: + """Result of applying a cipher to a word/alphabet combination.""" + + word: str + cipher: Cipher + alphabet_name: str + values: Tuple[int, ...] + + @property + def total(self) -> int: + return sum(self.values) + + def as_string(self, separator: str = "") -> str: + return separator.join(str(value) for value in self.values) + + def __str__(self) -> str: + return self.as_string() + + def __int__(self) -> int: + return self.total diff --git a/src/utils/filter.py b/src/utils/filter.py new file mode 100644 index 0000000..3316a3f --- /dev/null +++ b/src/utils/filter.py @@ -0,0 +1,302 @@ +""" +Universal filter utility for any dataclass-based object in the Tarot project. + +Provides a single, reusable filter mechanism that works across all modules: +- Letters (TarotLetter) +- Cards (Card) +- Numbers (Number) +- Words (Word) +- Any custom dataclass + +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 + +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) + - List matching (any item match) + - Boolean matching + - 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: + # Split comma-separated string and strip whitespace + values_to_check = [v.strip() for v in value.split(",")] + elif isinstance(value, (list, tuple)): + 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 + ): + 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 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 + - List item matching (checks if value is in list attribute) + - Multiple filter support (AND logic between fields, OR logic within a field) + - 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") + """ + # Field aliases for convenience + 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) + ] + + 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): + lines.append(f" {attr_name}:") + lines.append(f" --- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent=4) + lines.append(nested) + else: + lines.append(f" {attr_name}: {attr_value}") + + lines.append("") # Blank line between items + + return "\n".join(lines) + + +# Convenience alias +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: + # { + # 'letter_type': ['Mother', 'Double', 'Simple'], + # 'element': ['Air', 'Fire', 'Water'], + # 'planet': ['Mercury', 'Mars', ...], + # ... + # } + """ + 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"" + + 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) diff --git a/src/utils/misc.py b/src/utils/misc.py new file mode 100644 index 0000000..71eac2a --- /dev/null +++ b/src/utils/misc.py @@ -0,0 +1,194 @@ +""" +Miscellaneous utilities for Tarot, including personality typing and other tools. + +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 + +if TYPE_CHECKING: + from tarot.deck.deck import CourtCard + + +class MBTIType(Enum): + """16 MBTI personality types.""" + ISTJ = "ISTJ" + ISFJ = "ISFJ" + INFJ = "INFJ" + INTJ = "INTJ" + ISTP = "ISTP" + ISFP = "ISFP" + INFP = "INFP" + INTP = "INTP" + ESTP = "ESTP" + ESFP = "ESFP" + ENFP = "ENFP" + ENTP = "ENTP" + ESTJ = "ESTJ" + ESFJ = "ESFJ" + ENFJ = "ENFJ" + ENTJ = "ENTJ" + + +@dataclass +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 + 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 + "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 + + # PRINCES (E + P) - Extraverted Perceivers + "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 + } + + @classmethod + 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) + >>> print(personality.mbti_type.value) + ENFP + >>> print(f"{personality.court_card.court_rank} of {personality.court_card.suit.name}") + Prince of Cups + """ + mbti_type = mbti_type.upper() + + # Validate MBTI type + try: + mbti_enum = MBTIType[mbti_type] + except KeyError: + raise ValueError( + 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 + + 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, "") + ) + + 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 = ( + f"{self.court_card.court_rank} of {self.court_card.suit.name}" + if self.court_card + else "None" + ) + return f"Personality(mbti_type={self.mbti_type.value}, court_card={card_name})" diff --git a/src/utils/object_formatting.py b/src/utils/object_formatting.py new file mode 100644 index 0000000..67baef2 --- /dev/null +++ b/src/utils/object_formatting.py @@ -0,0 +1,177 @@ +""" +Shared utilities for formatting and introspecting Python objects. + +Provides common functions for checking object types, extracting attributes, +and formatting nested structures - eliminating code duplication across +query.py, card.py, and filter.py. + +Usage: + from utils.object_formatting import ( + is_nested_object, + is_scalar_type, + format_value, + get_item_label, + ) +""" + +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__') + + +def is_nested_object(obj: Any) -> bool: + """ + Check if object is a nested/complex object (not a scalar type). + + Returns True for dataclasses and objects with __dict__ that aren't scalars. + """ + if is_dataclass(obj): + return True + return hasattr(obj, '__dict__') and not isinstance(obj, SCALAR_TYPES) + + +def is_scalar(obj: Any) -> bool: + """Check if object is a scalar type (str, int, float, bool, list, dict, None).""" + return isinstance(obj, SCALAR_TYPES) + + +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)) + 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__'): + return list(obj.__dataclass_fields__.keys()) + return [] + + +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 both dataclasses and regular objects with __dict__. + """ + attributes = [] + + 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__'): + for field_name, value in obj.__dict__.items(): + 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']: + # Use the custom __str__ method and indent each line + custom_output = str(value) + lines = [] + 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): + # Recursively format deeply nested objects + if is_nested_object(attr_value): + lines.append(f"{indent_str}--- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent + 2) + lines.append(nested) + else: + lines.append(f"{indent_str}{attr_name}: {attr_value}") + return "\n".join(lines) + + # Scalar values + return str(value) + + +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 + lines.append(f"{indent_str}{attr_name}:") + lines.append(f"{indent_str} --- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent=indent + 4) + lines.append(nested) + else: + # Scalar value - just print + lines.append(f"{indent_str}{attr_name}: {attr_value}") + + return lines diff --git a/src/utils/query.py b/src/utils/query.py new file mode 100644 index 0000000..02280f7 --- /dev/null +++ b/src/utils/query.py @@ -0,0 +1,300 @@ +""" +Generic query builder for fluent, chainable access to tarot data. + +Provides Query and QueryResult classes for building filters and accessing data +in a dynamic, expressive way. + +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') + + +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__'): + return repr(self.data) + return f"{self.__class__.__name__}({self.data})" + + def __str__(self) -> 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('_'): + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + return getattr(self.data, name) + + +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': + """ + 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, '') + + def filter_func(item: T) -> bool: + # Special handling for 'name' key + 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) + # Flexible comparison + if isinstance(item_value, str): + 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']: + """ + 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']: + """ + 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 = {} + if isinstance(self._original_data, dict): + for key, item in self._original_data.items(): + if all(f(item) for f in self._filters): + filtered[key] = item + else: + for i, item in enumerate(self._data): + 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']: + """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: + return "Query(0 items)" + return f"Query({self.count()} items): {items[0]}" + + +class CollectionAccessor(Generic[T]): + """Context-aware accessor that provides a scoped .query() interface.""" + + def __init__(self, data_provider: Callable[[], Union[Dict[Any, T], List[T]]]) -> None: + self._data_provider = data_provider + + def _new_query(self) -> Query: + data = self._data_provider() + return Query(data) + + def query(self) -> Query: + """Return a new Query scoped to this collection.""" + return self._new_query() + + def filter(self, expression: str) -> Query: + """Shortcut to run a filtered query.""" + return self.query().filter(expression) + + def name(self, value: str) -> Optional[QueryResult]: + """Shortcut to look up by name.""" + return self.query().name(value) + + def all(self) -> Dict[Any, T]: + """Return all entries (optionally filtered).""" + return self.query().all() + + def list(self) -> List[T]: + """Return all entries as a list.""" + return self.query().list() + + def first(self) -> Optional[QueryResult]: + """Return first matching entry.""" + return self.query().first() + + def count(self) -> int: + """Count entries for this collection.""" + return self.query().count() + + 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): + lines.append(f" {attr_name}:") + lines.append(f" --- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent=4) + 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: + """Full detailed representation.""" + return self.display() + + def __str__(self) -> str: + """Full detailed string representation.""" + return self.display() + + +class FilterableDict(dict): + """Dict subclass that provides .filter() method for dynamic querying.""" + + 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): + lines.append(f" {attr_name}:") + lines.append(f" --- {attr_name.replace('_', ' ').title()} ---") + nested = format_value(attr_value, indent=4) + 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]: + """ + Convert dict or list to a filterable object with .filter() support. + + Examples: + walls = make_filterable(Cube.wall()) + peace = walls.filter('name:North').first() + """ + if isinstance(data, dict): + # Create a FilterableDict from the regular dict + filterable = FilterableDict(data) + return filterable + else: + # For lists, wrap in a Query + return Query(data) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fb15f47 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for PY-Tarot library.""" diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000..9dcad45 --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,619 @@ +"""Comprehensive tests for Tarot attributes, alphabets, ciphers, and numerology.""" + +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, +) +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") + assert month.number == 1 + assert month.name == "January" + 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) + ] + assert len(months) == 12 + assert months[0].number == 1 + assert months[11].number == 12 + + +class TestDay: + def test_day_creation(self): + day = Day(1, "Sunday", "Sun") + assert day.number == 1 + assert day.name == "Sunday" + 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) + ] + assert len(days) == 7 + + +class TestWeekday: + def test_weekday_creation(self): + weekday = Weekday(1, "Monday", "Moon") + assert weekday.name == "Monday" + assert weekday.planetary_correspondence == "Moon" + assert weekday.is_weekend is False + + def test_weekday_invalid_number(self): + with pytest.raises(ValueError): + Weekday(0, "Zero", "Sun") + + +class TestClockHour: + def test_clock_hour_creation(self): + hour = ClockHour(13, 1, "PM", "Mars", "Afternoon surge") + assert hour.hour_24 == 13 + assert hour.hour_12 == 1 + assert hour.time_of_day == "Night" + + def test_clock_hour_invalid_period(self): + with pytest.raises(ValueError): + ClockHour(12, 12, "XX", "Sun") + + +class TestZodiac: + def test_zodiac_creation(self): + zodiac = Zodiac("Aries", "♈", "Fire", "Mars", "Mar 21 - Apr 19") + assert zodiac.name == "Aries" + assert zodiac.element == "Fire" + assert zodiac.ruling_planet == "Mars" + + +class TestSuit: + def test_suit_creation(self): + suit = Suit("Cups", "Water", "Chalice", 1) + assert suit.name == "Cups" + assert suit.element == "Water" + assert suit.number == 1 + + +class TestMeaning: + def test_meaning_creation(self): + meaning = Meaning("This is positive", "This is negative") + assert meaning.upright == "This is positive" + assert meaning.reversed == "This is negative" + + +# ============================================================================ +# Sepheric Tests +# ============================================================================ + +class TestSephera: + def test_sephera_creation(self): + sephera = Sephera(1, "Kether", "כתר", "Crown", "Metatron", "Chaioth", "Primum") + assert sephera.number == 1 + assert sephera.name == "Kether" + assert sephera.hebrew_name == "כתר" + + def test_all_sephera(self): + sephera_list = [ + Sephera(i, f"Sephera_{i}", "Hebrew", "Meaning", "Angel", "Order", "Chakra") + for i in range(1, 11) + ] + assert len(sephera_list) == 10 + + +# ============================================================================ +# Alphabet Tests +# ============================================================================ + +class TestEnglishAlphabet: + def test_english_letter_creation(self): + letter = EnglishAlphabet("A", 1, "ay") + assert letter.letter == "A" + assert letter.position == 1 + assert letter.sound == "ay" + + def test_english_invalid_position_low(self): + with pytest.raises(ValueError): + EnglishAlphabet("A", 0, "ay") + + def test_english_invalid_position_high(self): + with pytest.raises(ValueError): + EnglishAlphabet("A", 27, "ay") + + def test_english_invalid_letter(self): + with pytest.raises(ValueError): + EnglishAlphabet("AB", 1, "ay") + + def test_english_all_letters(self): + letters = [EnglishAlphabet(chr(65 + i), i + 1, "sound") for i in range(26)] + assert len(letters) == 26 + assert letters[0].letter == "A" + assert letters[25].letter == "Z" + + +class TestGreekAlphabet: + def test_greek_letter_creation(self): + letter = GreekAlphabet("Α", 1, "alpha") + assert letter.letter == "Α" + assert letter.position == 1 + assert letter.transliteration == "alpha" + + def test_greek_invalid_position_low(self): + with pytest.raises(ValueError): + GreekAlphabet("Α", 0, "alpha") + + def test_greek_invalid_position_high(self): + with pytest.raises(ValueError): + GreekAlphabet("Α", 25, "alpha") + + def test_greek_all_letters(self): + letters = [GreekAlphabet(f"Α{i}", i + 1, f"greek_{i}") for i in range(24)] + assert len(letters) == 24 + + +class TestHebrewAlphabet: + def test_hebrew_letter_creation(self): + letter = HebrewAlphabet("א", 1, "aleph", "Start") + assert letter.letter == "א" + assert letter.position == 1 + assert letter.transliteration == "aleph" + assert letter.meaning == "Start" + + def test_hebrew_invalid_position_low(self): + with pytest.raises(ValueError): + HebrewAlphabet("א", 0, "aleph", "Start") + + def test_hebrew_invalid_position_high(self): + with pytest.raises(ValueError): + HebrewAlphabet("א", 23, "aleph", "Start") + + def test_hebrew_all_letters(self): + letters = [HebrewAlphabet(f"א{i}", i + 1, f"hebrew_{i}", f"meaning_{i}") for i in range(22)] + assert len(letters) == 22 + + +# ============================================================================ +# Number Tests +# ============================================================================ + +class TestNumber: + def test_number_creation(self): + num = Number(1, "Kether", "Spirit", 0) # compliment is auto-calculated + assert num.value == 1 + assert num.sephera == "Kether" + assert num.compliment == 8 # 10 - 1 = 9, but 9->9, so 1->8 + + def test_number_compliments(self): + """Test that compliments are auto-calculated correctly.""" + test_cases = [(1, 8), (2, 7), (3, 6), (4, 5), (5, 4), (6, 3), (7, 2), (8, 1), (9, 9)] + for value, expected_compliment in test_cases: + num = Number(value, f"Sephera_{value}", "Element", 0) + assert num.compliment == expected_compliment + + def test_number_invalid_value_low(self): + with pytest.raises(ValueError): + Number(0, "Sephera", "Element", 0) + + def test_number_invalid_value_high(self): + with pytest.raises(ValueError): + Number(10, "Sephera", "Element", 0) + + def test_all_numbers(self): + numbers = [Number(i, f"Sephera_{i}", "Element", 0) for i in range(1, 10)] + assert len(numbers) == 9 + + +# ============================================================================ +# Color Tests +# ============================================================================ + +class TestColor: + def test_color_creation(self): + color = Color("Red", "#FF0000", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") + assert color.name == "Red" + assert color.hex_value == "#FF0000" + assert color.rgb == (255, 0, 0) + assert color.number == 5 + + def test_color_invalid_hex_format(self): + with pytest.raises(ValueError): + Color("Red", "FF0000", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") + + def test_color_invalid_hex_length(self): + with pytest.raises(ValueError): + Color("Red", "#FF00", (255, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") + + def test_color_invalid_rgb_low(self): + with pytest.raises(ValueError): + Color("Red", "#FF0000", (-1, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") + + def test_color_invalid_rgb_high(self): + with pytest.raises(ValueError): + Color("Red", "#FF0000", (256, 0, 0), "Gevurah", 5, "Fire", "Briah", "Power") + + def test_color_all_valid_rgb(self): + 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") + assert color.rgb == (r, g, b) + + +# ============================================================================ +# Planet Tests +# ============================================================================ + + +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") + planet = Planet( + name="Sun", + symbol="☉", + element="Fire", + ruling_zodiac=["Leo"], + associated_numbers=[number], + associated_letters=["Resh"], + keywords=["Vitality"], + color=color, + description="Center", + ) + assert planet.name == "Sun" + assert planet.color is color + assert planet.associated_numbers[0].value == 6 + + +class TestGod: + def test_god_creation(self): + number = Number(6, "Tiphareth", "Fire", 0) + planet = Planet( + name="Sun", + symbol="☉", + element="Fire", + ruling_zodiac=["Leo"], + associated_numbers=[number], + associated_letters=["Resh"], + keywords=["Vitality"], + color=None, + description="Center", + ) + god = God( + name="Ra", + culture="Egyptian", + pantheon="Solar", + domains=["Consciousness"], + epithets=["Sun God"], + mythology="Solar deity", + sephera_numbers=[6], + path_numbers=[15], + planets=["Sun"], + elements=["Fire"], + zodiac_signs=["Leo"], + associated_planet=planet, + associated_element=None, + associated_numbers=[number], + tarot_trumps=["XIX - The Sun"], + keywords=["Vitality"], + description="Solar current", + ) + assert god.culture_key() == "egyptian" + assert god.primary_number() is number + assert 6 in god.sephera_numbers + + def test_god_without_planet(self): + god = God( + name="Hecate", + culture="Greek", + pantheon="Chthonic", + domains=["Magic"], + epithets=["Triple Goddess"], + mythology="Guardian of crossroads", + sephera_numbers=[], + path_numbers=[29], + planets=[], + elements=["Water"], + zodiac_signs=["Pisces"], + associated_planet=None, + associated_element=None, + associated_numbers=[], + tarot_trumps=["XVIII - The Moon"], + keywords=["Mystery"], + description="Night guide", + ) + assert god.primary_number() is None + assert "water" in [elem.lower() for elem in god.elements] + + +# ============================================================================ +# Cipher Tests +# ============================================================================ + +class TestCipher: + def test_cipher_mapping_basic(self): + cipher = Cipher("Test", "test", [1, 2, 3]) + mapping = cipher.mapping_for_alphabet(["A", "B", "C"]) + assert mapping == {"A": 1, "B": 2, "C": 3} + + def test_cipher_cycle_expansion(self): + cipher = Cipher("Cycle", "cycle", [1, 2], cycle=True) + mapping = cipher.mapping_for_alphabet(["A", "B", "C", "D"]) + assert mapping["C"] == 1 + assert mapping["D"] == 2 + + def test_cipher_subset(self): + cipher = Cipher("Subset", "subset", [5, 6], letter_subset={"A", "C"}) + mapping = cipher.mapping_for_alphabet(["A", "B", "C"]) + assert "B" not in mapping + assert mapping["C"] == 6 + + +class TestCipherResult: + def test_cipher_result_totals(self): + cipher = Cipher("Test", "test", [1, 2, 3]) + result = CipherResult("abc", cipher, "english", (1, 2, 3)) + assert result.total == 6 + assert result.as_string("-") == "1-2-3" + assert str(result) == "123" + + +# ============================================================================ +# Digital Root Tests +# ============================================================================ + +class TestDigitalRoot: + def test_digital_root_single_digit(self): + """Single digits should return themselves.""" + for i in range(1, 10): + assert calculate_digital_root(i) == i + + def test_digital_root_two_digit(self): + """Test two-digit numbers.""" + assert calculate_digital_root(10) == 1 # 1+0 = 1 + assert calculate_digital_root(14) == 5 # 1+4 = 5 + assert calculate_digital_root(19) == 1 # 1+9 = 10, 1+0 = 1 + assert calculate_digital_root(18) == 9 # 1+8 = 9 + + 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(100) == 1 # 1+0+0 = 1 + assert calculate_digital_root(123) == 6 # 1+2+3 = 6 + + def test_digital_root_tarot_cards(self): + """Test digital root for Tarot card numbers.""" + # 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 + + 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) + + +# ============================================================================ +# CardDataLoader Tests +# ============================================================================ + +class TestCardDataLoader: + @pytest.fixture + def loader(self): + return CardDataLoader() + + def test_loader_initialization(self, loader): + """Test that loader initializes correctly.""" + assert loader is not None + + def test_load_numbers(self, loader): + """Test loading numbers.""" + for i in range(1, 10): + num = loader.number(i) + assert num is not None + assert num.value == i + assert num.compliment == (9 - i if i != 9 else 9) + + def test_load_colors(self, loader): + """Test loading colors.""" + for i in range(1, 11): + color = loader.color(i) + assert color is not None + assert color.number == i + + def test_load_sephera(self, loader): + """Test loading individual Sephiroth.""" + for i in range(1, 11): + 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() + assert hex_result is not None + assert hex_result.data.line_diagram == "||||||" + + def test_color_by_number(self, loader): + """Test getting color by number with digital root.""" + # 14 -> digital root 5 -> Gevurah -> Red Scarlet + color = loader.color_by_number(14) + assert color is not None + assert color.number == 5 + assert "Red" in color.name + + def test_number_by_digital_root(self, loader): + """Test getting number by digital root.""" + num = loader.number_by_digital_root(14) + assert num is not None + assert num.value == 5 + + def test_digital_root_method(self, loader): + """Test the digital root method on loader.""" + assert loader.digital_root(14) == 5 + assert loader.digital_root(99) == 9 + assert loader.digital_root(5) == 5 + + def test_cipher_lookup(self, loader): + """Test loading individual cipher definitions.""" + systems = ["hebrew_standard", "english_simple", "greek_isopsephy", "reduction"] + for system_name in systems: + cipher = loader.cipher(system_name) + assert cipher is not None + assert cipher.key == system_name + + def test_planet_lookup(self, loader): + """Test loading planetary correspondences.""" + planets = loader.planet() + assert "mercury" in planets + mercury = loader.planet("Mercury") + assert mercury is not None + assert mercury.associated_letters[0] == "Beth" + assert mercury.associated_numbers and mercury.associated_numbers[0].value == 8 + + def test_weekday_lookup(self, loader): + weekdays = loader.weekday() + assert "monday" in weekdays + sunday = loader.weekday("Sunday") + assert sunday is not None + assert sunday.is_weekend is True + assert sunday.planetary_correspondence == "Sun" + + def test_clock_hour_lookup(self, loader): + hours = loader.clock_hour() + assert len(hours) == 24 + midnight = loader.clock_hour(0) + assert midnight is not None + assert midnight.period == "AM" + assert midnight.time_of_day == "Day" + afternoon = loader.clock_hour(15) + assert afternoon is not None + assert afternoon.period == "PM" + assert afternoon.time_of_day == "Night" + + def test_god_registry(self, loader): + gods = loader.god() + assert "apollo" in gods + assert "ra" in gods + apollo = gods["apollo"] + assert apollo.culture == "Greek" + assert apollo.associated_planet is not None + assert apollo.associated_planet.name == "Sun" + + def test_gods_by_culture(self, loader): + greek_gods = loader.gods_by_culture("Greek") + assert greek_gods + assert all(god.culture == "Greek" for god in greek_gods.values()) + assert "apollo" in greek_gods + assert "ra" not in greek_gods + + def test_god_with_culture_filter(self, loader): + apollo = loader.god("Apollo", culture="Greek") + assert apollo is not None + assert apollo.culture == "Greek" + assert apollo.associated_planet is not None + assert loader.god("Apollo", culture="Roman") is None + + def test_temporal_correspondence(self, loader): + moment = datetime(2024, 3, 21, 15, 30) + snapshot = loader.temporal_correspondence(moment) + + assert snapshot.timestamp == moment + assert snapshot.weekday is not None + assert snapshot.weekday.name == "Thursday" + assert snapshot.clock_hour is not None + assert snapshot.clock_hour.hour_24 == 15 + assert snapshot.planet is not None + assert snapshot.planet.name == snapshot.clock_hour.planetary_ruler + if snapshot.number is not None: + assert snapshot.color is snapshot.number.color + assert snapshot.card.name # deterministic card returned + assert snapshot.hexagram is not None + + def test_alphabet_catalog(self, loader): + """Test loading alphabets.""" + alphabets = loader.alphabet() + 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 + + def test_sephera_loaded(self, loader): + """Test that all 10 Sephiroth are loaded.""" + sephera_count = 0 + for i in range(1, 11): + color = loader.color(i) + if color is not None and color.sephera: + sephera_count += 1 + assert sephera_count == 10 + + +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 + + 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) + assert color_5 is not None + assert color_14 is not None + assert color_5.number == color_14.number + assert color_5.hex_value == color_14.hex_value diff --git a/tests/test_deck.py b/tests/test_deck.py new file mode 100644 index 0000000..c9de1ad --- /dev/null +++ b/tests/test_deck.py @@ -0,0 +1,213 @@ +""" +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 + + +class TestCard: + def test_card_creation(self): + card = Card(1, "Test Card", Meaning("Upright", "Reversed"), "Minor") + assert card.number == 1 + assert card.name == "Test Card" + assert card.meaning.upright == "Upright" + + def test_card_str(self): + card = Card(1, "Test Card", Meaning("Upright", "Reversed"), "Minor") + assert str(card) == "1. Test Card" + + def test_card_repr(self): + card = Card(1, "Test Card", Meaning("Upright", "Reversed"), "Minor") + assert repr(card) == "Card(1, 'Test Card')" + + +class TestMajorCard: + def test_major_card_creation(self): + card = MajorCard( + number=1, + name="The Magician", + meaning=Meaning("Upright", "Reversed"), + arcana="Major", + kabbalistic_number=1 + ) + assert card.number == 1 + assert card.arcana == "Major" + + def test_major_card_invalid_low(self): + with pytest.raises(ValueError): + MajorCard( + number=1, + name="Test", + meaning=Meaning("Up", "Rev"), + arcana="Major", + kabbalistic_number=-1 + ) + + def test_major_card_invalid_high(self): + with pytest.raises(ValueError): + MajorCard( + number=1, + name="Test", + meaning=Meaning("Up", "Rev"), + arcana="Major", + 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" + ) + assert card.number == i + + +class TestMinorCard: + def test_minor_card_creation(self): + suit = Suit("Cups", "Water", "Chalice", 1) + card = MinorCard( + number=1, + name="Ace of Cups", + meaning=Meaning("Upright", "Reversed"), + arcana="Minor", + suit=suit, + pip=1 + ) + assert card.number == 1 + assert card.suit.name == "Cups" + assert card.pip == 1 + + def test_minor_card_invalid_pip_low(self): + suit = Suit("Cups", "Water", "Chalice", 1) + with pytest.raises(ValueError): + PipCard( + number=1, + name="Test", + meaning=Meaning("Up", "Rev"), + arcana="Minor", + suit=suit, + pip=0 + ) + + def test_minor_card_invalid_pip_high(self): + suit = Suit("Cups", "Water", "Chalice", 1) + with pytest.raises(ValueError): + PipCard( + number=1, + name="Test", + meaning=Meaning("Up", "Rev"), + arcana="Minor", + suit=suit, + pip=15 + ) + + def test_minor_card_valid_pips(self): + suit = Suit("Cups", "Water", "Chalice", 1) + for i in range(1, 15): + card = MinorCard( + number=i, + name=f"Card {i}", + meaning=Meaning("Up", "Rev"), + arcana="Minor", + suit=suit, + pip=i + ) + assert card.pip == i + + +class TestDeck: + def test_deck_creation(self): + deck = Deck() + assert len(deck.cards) == 78 + + def test_deck_major_arcana_count(self): + deck = Deck() + major_cards = [c for c in deck.cards if c.arcana == "Major"] + assert len(major_cards) == 22 + + def test_deck_minor_arcana_count(self): + deck = Deck() + minor_cards = [c for c in deck.cards if c.arcana == "Minor"] + assert len(minor_cards) == 56 + + 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 + + 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 + + def test_deck_draw_too_many(self): + deck = Deck() + with pytest.raises(ValueError): + deck.draw(100) + + def test_deck_draw_invalid_amount(self): + deck = Deck() + with pytest.raises(ValueError): + deck.draw(0) + + def test_deck_reset(self): + 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) + + def test_deck_all_suits(self): + deck = Deck() + suits = {c.suit.name for c in deck.cards if c.arcana == "Minor"} + assert suits == {"Cups", "Pentacles", "Swords", "Wands"} + + def test_deck_unique_cards(self): + deck = Deck() + card_names = [c.name for c in deck.cards] + assert len(card_names) == len(set(card_names)) # All unique diff --git a/thelema_calendar.py b/thelema_calendar.py new file mode 100644 index 0000000..4eae087 --- /dev/null +++ b/thelema_calendar.py @@ -0,0 +1,215 @@ +""" +Thelema Calendar calculation function. +Converts Gregorian calendar to Thelema Calendar (Year of the Beast). + +References: +- Year 1 of Thelema = 1904 CE (Spring Equinox) +- 22-year cycles (The Great Year) +- Weekday planetary rulers +""" + +from datetime import datetime, date, timedelta, timezone +from typing import Dict, List, Optional + + +# Planetary symbols for weekdays (Sun=0, Mon=1, ..., Sat=6) +WEEKDAY_PLANETS = ["☉︎", "☽︎", "♂︎", "☿︎", "♃︎", "♀︎", "♄︎"] + +# Luminary symbols +LUMINARY_SYMBOLS = { + "Moon": "☾︎", + "Mercury": "☿︎", + "Mars": "♂︎", + "Venus": "♀︎", + "Sun": "☉︎", +} + +# Luminary order +LUMINARIES = ["Moon", "Mercury", "Mars", "Venus", "Sun"] + + +def to_roman(num: int) -> str: + """Convert an integer to Roman numerals. + + Args: + num: Integer to convert (1-3999) + + Returns: + Roman numeral string + """ + val = [ + 1000, 900, 500, 400, + 100, 90, 50, 40, + 10, 9, 5, 4, + 1 + ] + syms = [ + "M", "CM", "D", "CD", + "C", "XC", "L", "XL", + "X", "IX", "V", "IV", + "I" + ] + roman_num = '' + i = 0 + while num > 0: + for _ in range(num // val[i]): + roman_num += syms[i] + num -= val[i] + i += 1 + return roman_num + + +def _apply_gmt_offset(moment: datetime, gmt_offset: str) -> datetime: + """Apply GMT offset to a datetime object. + + Args: + moment: datetime object to convert + gmt_offset: Offset as string (e.g., "+3:00", "-8:00", "+5:30") + + Returns: + datetime object adjusted to the specified timezone + + Example: + >>> utc_time = datetime(2025, 11, 16, 12, 0, 0) + >>> pst_time = _apply_gmt_offset(utc_time, "-8:00") + >>> pst_time.hour + 4 # 12:00 UTC - 8 hours = 04:00 PST + """ + # Parse GMT offset (e.g., "+3:00" or "-8:00") + gmt_offset = gmt_offset.strip() + + # Handle format like "+3:00", "-8:00", "+5:30" + if ":" in gmt_offset: + sign = 1 if gmt_offset[0] == "+" else -1 + parts = gmt_offset[1:].split(":") + hours = int(parts[0]) + minutes = int(parts[1]) if len(parts) > 1 else 0 + offset_seconds = sign * (hours * 3600 + minutes * 60) + else: + # Handle format like "+3" or "-8" + sign = 1 if gmt_offset[0] == "+" else -1 + hours = int(gmt_offset[1:]) + offset_seconds = sign * hours * 3600 + + # Create timezone with the offset + tz = timezone(timedelta(seconds=offset_seconds)) + + # Convert the naive datetime to aware datetime in UTC, then convert to target timezone + utc_dt = moment.replace(tzinfo=timezone.utc) + local_dt = utc_dt.astimezone(tz) + + # Return as naive datetime (without timezone info) for calculation + return local_dt.replace(tzinfo=None) + + +def get_astro_data() -> Dict[str, str]: + """Get current astrological data for luminaries. + + This is a placeholder that returns mock data. + In a real implementation, you would scrape astro.com or use an astrology library. + + Returns: + Dictionary mapping luminary names to their astrological positions + Format: "26°Taurus", "19°Sagittarius", etc. + """ + # Mock data - replace with actual astrological calculations + astro_data = { + "Moon": "24°Sagittarius", + "Mercury": "7°Aquarius", + "Mars": "4°Virgo", + "Venus": "30°Scorpio", + "Sun": "26°Taurus", + } + return astro_data + + +def thelema_calendar(moment: Optional[datetime] = None, gmt_offset: Optional[str] = None) -> str: + """Calculate and return the Thelema Calendar date. + + The Thelema Calendar counts from the Spring Equinox of 1904 CE (Year 1 of the Beast). + It divides time into 22-year cycles and displays planetary correspondences. + + Args: + moment: datetime object (defaults to current date/time) + gmt_offset: GMT offset as string (e.g., "+3:00", "-8:00", "+5:30") + If provided, converts the given moment to this timezone before calculating. + Example: "+3:00" for UTC+3, "-8:00" for PST (UTC-8), "+5:30" for IST + + Returns: + Formatted Thelema Calendar string with format: + "[Luminary positions] : [Weekday planet] : [Epoch][Year]" + + Example: + >>> # Current time in UTC + >>> Tarot.thelema_calendar() + + >>> # Specific time in PST (UTC-8) + >>> from datetime import datetime + >>> moment = datetime(2025, 11, 16, 14, 30, 0) + >>> Tarot.thelema_calendar(moment, "-8:00") + + >>> # IST (UTC+5:30) + >>> Tarot.thelema_calendar(gmt_offset="+5:30") + """ + if moment is None: + moment = datetime.now() + + # Apply timezone offset if provided + if gmt_offset is not None: + moment = _apply_gmt_offset(moment, gmt_offset) + + today = moment.date() + current_year = today.year + + # Calculate year difference from Thelema epoch (1904) + year_diff = current_year - 1904 + + # Check if we've passed the spring equinox (March 22) + # If yes, increment the year count + equinox_passed = (today.month > 3) or (today.month == 3 and today.day >= 22) + if equinox_passed: + year_diff += 1 + + # Calculate epoch (22-year cycles) + epoch_count = year_diff // 22 + epoch_roman = to_roman(epoch_count) + + # Calculate year within current 22-year cycle + thelema_year = (current_year - 12) % 22 + thelema_year_roman = to_roman(thelema_year).lower() + + # Get weekday (0=Monday, 6=Sunday in Python; we want 0=Sunday for alignment) + # Python's weekday(): Monday=0, Sunday=6 + # We want: Sunday=0, Monday=1, ..., Saturday=6 + python_weekday = today.weekday() # 0=Mon, 6=Sun + weekday_index = (python_weekday + 1) % 7 # Convert to Sun=0, Mon=1, etc. + + # Determine weekday based on UTC hour (or provided moment hour) + utc_hour = moment.hour # Note: this is local hour, not UTC in this simplified version + if utc_hour >= 0 and utc_hour < 14: + day_weekday = weekday_index % 7 + else: + day_weekday = (weekday_index + 1) % 7 + + weekday_symbol = WEEKDAY_PLANETS[day_weekday] + + # Get astrological data + astro_data = get_astro_data() + + # Build luminary string + luminary_strings = [] + for luminary in LUMINARIES: + symbol = LUMINARY_SYMBOLS[luminary] + position = astro_data.get(luminary, "unknown") + luminary_strings.append(f"{symbol} {position}") + + # Combine all cycles + cycles = [ + " : ".join(luminary_strings), + weekday_symbol, + f"{epoch_roman}{thelema_year_roman}", + ] + + return " : ".join(cycles) + +