This commit is contained in:
nose
2025-11-25 22:19:36 -08:00
commit 79d4f1a09e
132 changed files with 11052 additions and 0 deletions

49
src/utils/__init__.py Normal file
View File

@@ -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",
]

390
src/utils/attributes.py Normal file
View File

@@ -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

302
src/utils/filter.py Normal file
View File

@@ -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"<value for {field_name}>"
return autocomplete
def describe_filter_fields(dataclass_type) -> str:
"""
Get a human-readable description of all filterable fields.
Useful for help text and documentation.
Args:
dataclass_type: A dataclass type
Returns:
Formatted string with field descriptions
Example:
print(describe_filter_fields(TarotLetter))
"""
if not is_dataclass(dataclass_type):
raise TypeError(f"{dataclass_type} is not a dataclass")
field_list = get_filterable_fields(dataclass_type)
lines = [
f"Filterable fields for {dataclass_type}:",
"",
]
for field_name in field_list:
lines.append(f"{field_name}")
return "\n".join(lines)

194
src/utils/misc.py Normal file
View File

@@ -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})"

View File

@@ -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

300
src/utils/query.py Normal file
View File

@@ -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)