mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
467 lines
14 KiB
Python
467 lines
14 KiB
Python
"""A tarot deck API with card browsing and seeded deals.
|
|
|
|
Run it:
|
|
|
|
responder run examples/tarot.py
|
|
|
|
Try it with:
|
|
|
|
curl http://127.0.0.1:5042/cards
|
|
curl http://127.0.0.1:5042/cards/the-fool
|
|
curl -H "Content-Type: application/json" \
|
|
-d '{"spread": "past-present-future", "seed": "responder"}' \
|
|
http://127.0.0.1:5042/deal
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
import responder
|
|
|
|
Arcana = Literal["major", "minor"]
|
|
Orientation = Literal["upright", "reversed"]
|
|
Suit = Literal["wands", "cups", "swords", "pentacles"]
|
|
|
|
|
|
class TarotCard(BaseModel):
|
|
slug: str = Field(examples=["the-fool"])
|
|
name: str = Field(examples=["The Fool"])
|
|
arcana: Arcana = Field(examples=["major"])
|
|
suit: Suit | None = Field(default=None, examples=["cups"])
|
|
rank: str | None = Field(default=None, examples=["Ace"])
|
|
number: int | None = Field(default=None, examples=[0])
|
|
keywords: list[str] = Field(examples=[["beginnings", "trust", "leap"]])
|
|
upright: str = Field(examples=["Begin before the whole map is visible."])
|
|
reversed: str = Field(examples=["Pause before mistaking impulse for freedom."])
|
|
|
|
|
|
class DrawnCard(BaseModel):
|
|
position: str = Field(examples=["Past"])
|
|
orientation: Orientation = Field(examples=["upright"])
|
|
meaning: str
|
|
card: TarotCard
|
|
|
|
|
|
class DealRequest(BaseModel):
|
|
count: int = Field(default=3, ge=1, le=10, examples=[3])
|
|
spread: str = Field(default="past-present-future", examples=["past-present-future"])
|
|
seed: str | None = Field(
|
|
default=None,
|
|
description="Optional seed for a repeatable example deal.",
|
|
examples=["responder"],
|
|
)
|
|
allow_reversed: bool = Field(default=True)
|
|
|
|
|
|
class DealOut(BaseModel):
|
|
count: int
|
|
spread: str
|
|
seed: str | None
|
|
cards: list[DrawnCard]
|
|
|
|
|
|
class SpreadOut(BaseModel):
|
|
slug: str
|
|
positions: list[str]
|
|
|
|
|
|
MAJOR_ARCANA: tuple[tuple[str, int, list[str], str, str], ...] = (
|
|
(
|
|
"The Fool",
|
|
0,
|
|
["beginnings", "trust", "leap"],
|
|
"Begin before the whole map is visible.",
|
|
"Pause before mistaking impulse for freedom.",
|
|
),
|
|
(
|
|
"The Magician",
|
|
1,
|
|
["focus", "skill", "manifestation"],
|
|
"Use the tools already on the table.",
|
|
"Scattered attention is diluting real power.",
|
|
),
|
|
(
|
|
"The High Priestess",
|
|
2,
|
|
["intuition", "mystery", "listening"],
|
|
"Let the quiet signal outrank the obvious noise.",
|
|
"Do not outsource what your instincts already know.",
|
|
),
|
|
(
|
|
"The Empress",
|
|
3,
|
|
["growth", "care", "abundance"],
|
|
"Nurture the thing until it becomes generous.",
|
|
"Care has become control; loosen your grip.",
|
|
),
|
|
(
|
|
"The Emperor",
|
|
4,
|
|
["structure", "authority", "stability"],
|
|
"Give the work a container strong enough to hold it.",
|
|
"Rules are helping less than they are hardening.",
|
|
),
|
|
(
|
|
"The Hierophant",
|
|
5,
|
|
["tradition", "teaching", "belonging"],
|
|
"Learn the lineage before rewriting the ritual.",
|
|
"Inherited rules may be asking for fresh consent.",
|
|
),
|
|
(
|
|
"The Lovers",
|
|
6,
|
|
["choice", "union", "values"],
|
|
"Choose from alignment, not appetite alone.",
|
|
"A divided yes is quietly becoming a no.",
|
|
),
|
|
(
|
|
"The Chariot",
|
|
7,
|
|
["drive", "discipline", "direction"],
|
|
"Hold the reins and keep moving with intention.",
|
|
"Force without direction is burning the wheels.",
|
|
),
|
|
(
|
|
"Strength",
|
|
8,
|
|
["courage", "patience", "heart"],
|
|
"Gentleness is the durable form of power here.",
|
|
"Bravery is being confused with pressure.",
|
|
),
|
|
(
|
|
"The Hermit",
|
|
9,
|
|
["solitude", "search", "wisdom"],
|
|
"Step back far enough to hear your own lantern.",
|
|
"Isolation has stopped being restorative.",
|
|
),
|
|
(
|
|
"Wheel of Fortune",
|
|
10,
|
|
["cycles", "change", "timing"],
|
|
"The wheel is turning; move with the pattern.",
|
|
"Trying to freeze the cycle is creating friction.",
|
|
),
|
|
(
|
|
"Justice",
|
|
11,
|
|
["truth", "balance", "accountability"],
|
|
"Name the facts cleanly and let them rebalance the room.",
|
|
"Something is being weighed with a hidden thumb on the scale.",
|
|
),
|
|
(
|
|
"The Hanged Man",
|
|
12,
|
|
["pause", "surrender", "perspective"],
|
|
"A useful answer appears when you stop forcing motion.",
|
|
"Waiting has become avoidance in a softer coat.",
|
|
),
|
|
(
|
|
"Death",
|
|
13,
|
|
["ending", "transition", "release"],
|
|
"Let the finished thing be finished.",
|
|
"Refusing the ending is stretching the pain.",
|
|
),
|
|
(
|
|
"Temperance",
|
|
14,
|
|
["blend", "patience", "integration"],
|
|
"Mix carefully; the medicine is in the proportion.",
|
|
"Balance is being used as an excuse not to choose.",
|
|
),
|
|
(
|
|
"The Devil",
|
|
15,
|
|
["attachment", "shadow", "temptation"],
|
|
"Look directly at the bargain you keep renewing.",
|
|
"A chain is loosening, but habit still calls it necessary.",
|
|
),
|
|
(
|
|
"The Tower",
|
|
16,
|
|
["rupture", "truth", "liberation"],
|
|
"The unstable structure is making its condition known.",
|
|
"The warning signs are smaller now; listen before they grow.",
|
|
),
|
|
(
|
|
"The Star",
|
|
17,
|
|
["hope", "renewal", "guidance"],
|
|
"Recover under a sky that remembers the way forward.",
|
|
"Hope needs one practical gesture to become real again.",
|
|
),
|
|
(
|
|
"The Moon",
|
|
18,
|
|
["dreams", "uncertainty", "subconscious"],
|
|
"Move slowly while the path is silver and strange.",
|
|
"Fear is editing the story before the facts arrive.",
|
|
),
|
|
(
|
|
"The Sun",
|
|
19,
|
|
["joy", "clarity", "success"],
|
|
"Let the obvious good news be as simple as it is.",
|
|
"A bright outcome still needs honest stewardship.",
|
|
),
|
|
(
|
|
"Judgement",
|
|
20,
|
|
["calling", "reckoning", "awakening"],
|
|
"Answer the call from the self you are becoming.",
|
|
"Old guilt is trying to impersonate accountability.",
|
|
),
|
|
(
|
|
"The World",
|
|
21,
|
|
["completion", "wholeness", "arrival"],
|
|
"Close the circle and honor the distance traveled.",
|
|
"Completion is near, but one loose thread wants attention.",
|
|
),
|
|
)
|
|
|
|
SUIT_THEMES: dict[Suit, tuple[str, str]] = {
|
|
"wands": ("spark", "creative fire, will, and momentum"),
|
|
"cups": ("tide", "feeling, memory, and relationship"),
|
|
"swords": ("edge", "thought, truth, and conflict"),
|
|
"pentacles": ("root", "body, resources, and practical care"),
|
|
}
|
|
|
|
RANK_THEMES: tuple[tuple[str, list[str], str], ...] = (
|
|
("Ace", ["seed", "gift", "opening"], "a new current entering the story"),
|
|
("Two", ["choice", "meeting", "balance"], "two forces learning each other"),
|
|
("Three", ["growth", "collaboration", "signal"], "the first visible result"),
|
|
("Four", ["foundation", "pause", "shape"], "a stable pattern forming"),
|
|
("Five", ["friction", "loss", "lesson"], "pressure that reveals priorities"),
|
|
("Six", ["repair", "memory", "movement"], "a turn toward restoration"),
|
|
("Seven", ["test", "strategy", "threshold"], "a challenge asking for nerve"),
|
|
("Eight", ["motion", "practice", "change"], "momentum that rewards attention"),
|
|
("Nine", ["ripening", "resilience", "solitude"], "the late-stage lesson"),
|
|
("Ten", ["completion", "weight", "harvest"], "the full consequence arriving"),
|
|
("Page", ["message", "curiosity", "study"], "the beginner's signal"),
|
|
("Knight", ["pursuit", "force", "quest"], "motion with a pronounced agenda"),
|
|
("Queen", ["care", "mastery", "reception"], "mature inner authority"),
|
|
("King", ["leadership", "stewardship", "command"], "outer authority in action"),
|
|
)
|
|
|
|
SPREADS: dict[str, tuple[str, ...]] = {
|
|
"single": ("Card",),
|
|
"past-present-future": ("Past", "Present", "Future"),
|
|
"mind-body-spirit": ("Mind", "Body", "Spirit"),
|
|
"situation-action-outcome": ("Situation", "Action", "Outcome"),
|
|
"celtic-cross": (
|
|
"Present",
|
|
"Challenge",
|
|
"Foundation",
|
|
"Recent Past",
|
|
"Possibility",
|
|
"Near Future",
|
|
"Self",
|
|
"Environment",
|
|
"Hopes and Fears",
|
|
"Outcome",
|
|
),
|
|
}
|
|
|
|
|
|
def _slugify(name: str) -> str:
|
|
return name.lower().replace(" ", "-").replace("'", "")
|
|
|
|
|
|
def _minor_card_name(rank: str, suit: Suit) -> str:
|
|
return f"{rank} of {suit.title()}"
|
|
|
|
|
|
def _build_deck() -> tuple[TarotCard, ...]:
|
|
cards: list[TarotCard] = []
|
|
for name, number, keywords, upright, reversed_text in MAJOR_ARCANA:
|
|
cards.append(
|
|
TarotCard(
|
|
slug=_slugify(name),
|
|
name=name,
|
|
arcana="major",
|
|
number=number,
|
|
keywords=keywords,
|
|
upright=upright,
|
|
reversed=reversed_text,
|
|
)
|
|
)
|
|
|
|
for suit, (symbol, theme) in SUIT_THEMES.items():
|
|
for rank, keywords, rank_meaning in RANK_THEMES:
|
|
name = _minor_card_name(rank, suit)
|
|
cards.append(
|
|
TarotCard(
|
|
slug=_slugify(name),
|
|
name=name,
|
|
arcana="minor",
|
|
suit=suit,
|
|
rank=rank,
|
|
keywords=[symbol, *keywords],
|
|
upright=f"{name} brings {rank_meaning} through {theme}.",
|
|
reversed=f"{name} asks where {theme} has become blocked.",
|
|
)
|
|
)
|
|
return tuple(cards)
|
|
|
|
|
|
def _positions_for(spread: str, count: int) -> list[str]:
|
|
positions = list(SPREADS.get(spread, ()))
|
|
if not positions:
|
|
return [f"Card {number}" for number in range(1, count + 1)]
|
|
if len(positions) >= count:
|
|
return positions[:count]
|
|
return positions + [
|
|
f"Card {number}" for number in range(len(positions) + 1, count + 1)
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class TarotDeck:
|
|
cards: tuple[TarotCard, ...] = field(default_factory=_build_deck)
|
|
_by_slug: dict[str, TarotCard] = field(init=False, repr=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self._by_slug = {card.slug: card for card in self.cards}
|
|
|
|
def list(
|
|
self,
|
|
*,
|
|
arcana: Arcana | None = None,
|
|
suit: Suit | None = None,
|
|
) -> list[TarotCard]:
|
|
rows = list(self.cards)
|
|
if arcana is not None:
|
|
rows = [card for card in rows if card.arcana == arcana]
|
|
if suit is not None:
|
|
rows = [card for card in rows if card.suit == suit]
|
|
return [card.model_copy(deep=True) for card in rows]
|
|
|
|
def get(self, slug: str) -> TarotCard | None:
|
|
card = self._by_slug.get(slug)
|
|
return None if card is None else card.model_copy(deep=True)
|
|
|
|
def deal(self, request: DealRequest) -> DealOut:
|
|
rng = random.Random(request.seed) # noqa: S311 - tarot shuffles are not security.
|
|
spread = request.spread.strip().lower() or "custom"
|
|
positions = _positions_for(spread, request.count)
|
|
sampled_cards = rng.sample(list(self.cards), k=request.count)
|
|
|
|
draws: list[DrawnCard] = []
|
|
for position, card in zip(positions, sampled_cards, strict=True):
|
|
orientation: Orientation = "upright"
|
|
if request.allow_reversed and rng.choice([False, True]): # noqa: S311
|
|
orientation = "reversed"
|
|
draws.append(
|
|
DrawnCard(
|
|
position=position,
|
|
orientation=orientation,
|
|
meaning=card.reversed if orientation == "reversed" else card.upright,
|
|
card=card,
|
|
)
|
|
)
|
|
|
|
return DealOut(
|
|
count=len(draws),
|
|
spread=spread,
|
|
seed=request.seed,
|
|
cards=draws,
|
|
)
|
|
|
|
|
|
def create_api(*, deck: TarotDeck | None = None) -> responder.API:
|
|
deck = deck or TarotDeck()
|
|
api = responder.API(
|
|
title="Tarot Deck API",
|
|
version="1.0",
|
|
openapi="3.1.0",
|
|
docs_route="/docs",
|
|
sessions=False,
|
|
)
|
|
|
|
@api.get("/", include_in_schema=False)
|
|
def index(req, resp):
|
|
resp.media = {
|
|
"name": "Tarot Deck API",
|
|
"cards": "/cards",
|
|
"deal": "/deal",
|
|
"spreads": "/spreads",
|
|
"docs": "/docs",
|
|
}
|
|
|
|
@api.get(
|
|
"/cards",
|
|
operation_id="list_tarot_cards",
|
|
tags=["tarot"],
|
|
summary="List tarot cards",
|
|
response_model=list[TarotCard],
|
|
)
|
|
def list_cards(
|
|
req,
|
|
resp,
|
|
*,
|
|
arcana: Arcana | None = responder.Query(
|
|
None,
|
|
description="Filter by major or minor arcana.",
|
|
),
|
|
suit: Suit | None = responder.Query(
|
|
None,
|
|
description="Filter minor cards by suit.",
|
|
),
|
|
):
|
|
resp.media = deck.list(arcana=arcana, suit=suit)
|
|
|
|
@api.get(
|
|
"/cards/{slug}",
|
|
operation_id="get_tarot_card",
|
|
tags=["tarot"],
|
|
summary="Fetch a tarot card",
|
|
response_model=TarotCard,
|
|
responses={404: "Card not found"},
|
|
)
|
|
def get_card(req, resp, *, slug: str):
|
|
card = deck.get(slug)
|
|
if card is None:
|
|
resp.problem(404, f"Tarot card {slug!r} does not exist.", slug=slug)
|
|
return
|
|
resp.media = card
|
|
|
|
@api.get(
|
|
"/spreads",
|
|
operation_id="list_tarot_spreads",
|
|
tags=["tarot"],
|
|
summary="List built-in spreads",
|
|
response_model=list[SpreadOut],
|
|
)
|
|
def list_spreads(req, resp):
|
|
resp.media = [
|
|
SpreadOut(slug=slug, positions=list(positions))
|
|
for slug, positions in SPREADS.items()
|
|
]
|
|
|
|
@api.post(
|
|
"/deal",
|
|
operation_id="deal_tarot_cards",
|
|
tags=["tarot"],
|
|
summary="Deal tarot cards",
|
|
response_model=DealOut,
|
|
)
|
|
def deal_cards(req, resp, *, deal: DealRequest):
|
|
resp.media = deck.deal(deal)
|
|
|
|
return api
|
|
|
|
|
|
api = create_api()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
api.run()
|