k
This commit is contained in:
49
src/utils/__init__.py
Normal file
49
src/utils/__init__.py
Normal 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
390
src/utils/attributes.py
Normal 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
302
src/utils/filter.py
Normal 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
194
src/utils/misc.py
Normal 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})"
|
||||
177
src/utils/object_formatting.py
Normal file
177
src/utils/object_formatting.py
Normal 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
300
src/utils/query.py
Normal 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)
|
||||
Reference in New Issue
Block a user