Source code for stellium.planner.builder

"""
PlannerBuilder - Fluent API for creating personalized astrological planners.

This module provides a builder pattern for configuring and generating
PDF planners with charts, transits, and daily astrological events.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from stellium.core.native import Native


[docs] @dataclass class PlannerConfig: """Configuration for planner generation.""" # Required native: Native timezone: str # Date range start_date: date | None = None end_date: date | None = None year: int | None = None # Location for angles/planetary hours (defaults to birth location) location: str | tuple[float, float] | None = None # Front matter options include_natal_chart: bool = True include_progressed_chart: bool = True include_solar_return: bool = True include_profections: bool = True include_zr_timeline: bool = True zr_lot: str = "Part of Fortune" include_graphic_ephemeris: bool = True graphic_ephemeris_harmonic: int = 360 # Daily content options natal_transit_planets: list[str] | None = None # None = all outer planets include_mundane_transits: bool = True include_moon_phases: bool = True include_voc: bool = True voc_mode: Literal["traditional", "modern"] = "traditional" ingress_planets: list[str] | None = None # None = all planets station_planets: list[str] | None = None # None = outer planets # Page layout page_size: Literal["a4", "a5", "letter", "half-letter"] = "a4" binding_margin: float = 0.0 # Extra margin for binding (inches) week_starts_on: Literal["sunday", "monday"] = "sunday"
[docs] class PlannerBuilder: """ Fluent builder for creating personalized astrological planners. Example: >>> from stellium import Native >>> from stellium.planner import PlannerBuilder >>> >>> native = Native("1990-05-15 14:30", "San Francisco, CA") >>> planner = (PlannerBuilder.for_native(native) ... .year(2025) ... .timezone("America/Los_Angeles") ... .with_natal_chart() ... .with_solar_return() ... .include_natal_transits() ... .generate("my_planner.pdf")) """ def __init__(self, native: Native) -> None: """Initialize builder with a native.""" self._native = native self._timezone: str | None = None self._year: int | None = None self._start_date: date | None = None self._end_date: date | None = None self._location: str | tuple[float, float] | None = None # Front matter flags self._natal_chart = True self._progressed_chart = True self._solar_return = True self._profections = True self._zr_timeline = True self._zr_lot = "Part of Fortune" self._graphic_ephemeris = True self._graphic_ephemeris_harmonic = 360 # Daily content flags self._natal_transit_planets: list[str] | None = None self._mundane_transits = True self._moon_phases = True self._voc = True self._voc_mode: Literal["traditional", "modern"] = "traditional" self._ingress_planets: list[str] | None = None self._station_planets: list[str] | None = None # Page layout self._page_size: Literal["a4", "a5", "letter", "half-letter"] = "a4" self._binding_margin = 0.0 self._week_starts_on: Literal["sunday", "monday"] = "sunday" # ===== Constructors =====
[docs] @classmethod def for_native(cls, native: Native) -> PlannerBuilder: """ Start building a planner for a native. Args: native: The Native whose planner to create Returns: PlannerBuilder instance for chaining """ return cls(native)
# ===== Date Range Configuration =====
[docs] def year(self, year: int) -> PlannerBuilder: """ Set the calendar year for the planner. Args: year: Calendar year (e.g., 2025) Returns: Self for chaining """ self._year = year self._start_date = date(year, 1, 1) self._end_date = date(year, 12, 31) return self
[docs] def date_range(self, start: date, end: date) -> PlannerBuilder: """ Set a custom date range for the planner. Args: start: Start date end: End date Returns: Self for chaining """ if end <= start: raise ValueError("End date must be after start date") self._start_date = start self._end_date = end self._year = None # Clear year if custom range return self
[docs] def timezone(self, tz: str) -> PlannerBuilder: """ Set the timezone for transit times. This is required - transit times will be displayed in this timezone. Args: tz: Timezone string (e.g., "America/Los_Angeles", "Europe/London") Returns: Self for chaining """ self._timezone = tz return self
[docs] def location(self, location: str | tuple[float, float]) -> PlannerBuilder: """ Set location for angle calculations and planetary hours. Defaults to the native's birth location if not specified. Args: location: City name or (latitude, longitude) tuple Returns: Self for chaining """ self._location = location return self
# ===== Front Matter Configuration =====
[docs] def with_natal_chart(self, enabled: bool = True) -> PlannerBuilder: """Include natal chart wheel in front matter.""" self._natal_chart = enabled return self
[docs] def with_progressed_chart(self, enabled: bool = True) -> PlannerBuilder: """Include secondary progressed chart in front matter.""" self._progressed_chart = enabled return self
[docs] def with_solar_return(self, enabled: bool = True) -> PlannerBuilder: """Include solar return chart for the planner year.""" self._solar_return = enabled return self
[docs] def with_profections(self, enabled: bool = True) -> PlannerBuilder: """Include annual profection info (Lord of the Year).""" self._profections = enabled return self
[docs] def with_zr_timeline( self, lot: str = "Part of Fortune", enabled: bool = True ) -> PlannerBuilder: """ Include Zodiacal Releasing timeline visualization. Args: lot: Which lot to release from (default: "Part of Fortune") enabled: Whether to include ZR timeline Returns: Self for chaining """ self._zr_timeline = enabled self._zr_lot = lot return self
[docs] def with_graphic_ephemeris( self, harmonic: int = 360, enabled: bool = True ) -> PlannerBuilder: """ Include graphic ephemeris for the planner period. Args: harmonic: Harmonic compression (360=full zodiac, 90=cardinal, 45=semi-square) enabled: Whether to include graphic ephemeris Returns: Self for chaining """ self._graphic_ephemeris = enabled self._graphic_ephemeris_harmonic = harmonic return self
# ===== Daily Content Configuration =====
[docs] def include_natal_transits( self, planets: list[str] | None = None ) -> PlannerBuilder: """ Include transits to natal planets. Args: planets: Which transiting planets to include. Default (None) uses outer planets: Jupiter, Saturn, Uranus, Neptune, Pluto Returns: Self for chaining """ self._natal_transit_planets = planets return self
[docs] def include_mundane_transits(self, enabled: bool = True) -> PlannerBuilder: """Include mundane transits (planet-to-planet in sky).""" self._mundane_transits = enabled return self
[docs] def include_moon_phases(self, enabled: bool = True) -> PlannerBuilder: """Include Moon phases (new, full, quarters).""" self._moon_phases = enabled return self
[docs] def include_voc( self, mode: Literal["traditional", "modern"] = "traditional" ) -> PlannerBuilder: """ Include Void of Course Moon periods. Args: mode: "traditional" (Sun-Saturn) or "modern" (includes outer planets) Returns: Self for chaining """ self._voc = True self._voc_mode = mode return self
[docs] def exclude_voc(self) -> PlannerBuilder: """Exclude Void of Course Moon periods.""" self._voc = False return self
[docs] def include_ingresses(self, planets: list[str] | None = None) -> PlannerBuilder: """ Include planet sign ingresses. Args: planets: Which planets to track. Default (None) includes all. Returns: Self for chaining """ self._ingress_planets = planets return self
[docs] def include_stations(self, planets: list[str] | None = None) -> PlannerBuilder: """ Include retrograde/direct stations. Args: planets: Which planets to track. Default (None) uses Mercury-Pluto. Returns: Self for chaining """ self._station_planets = planets return self
# ===== Page Layout Configuration =====
[docs] def page_size( self, size: Literal["a4", "a5", "letter", "half-letter"] ) -> PlannerBuilder: """ Set page size. Args: size: "a4" (default), "a5", "letter", or "half-letter" (alias for a5) Returns: Self for chaining """ self._page_size = size return self
[docs] def binding_margin(self, inches: float) -> PlannerBuilder: """ Add extra margin for binding. Args: inches: Extra margin in inches (added to inner edge) Returns: Self for chaining """ self._binding_margin = inches return self
[docs] def week_starts_on(self, day: Literal["sunday", "monday"]) -> PlannerBuilder: """ Set the first day of the week for calendar grids. Args: day: "sunday" (default) or "monday" Returns: Self for chaining """ self._week_starts_on = day return self
# ===== Build / Generate ===== def _validate(self) -> None: """Validate configuration before generation.""" if self._timezone is None: raise ValueError( "Timezone is required. Call .timezone('America/Los_Angeles') or similar." ) if self._start_date is None or self._end_date is None: raise ValueError( "Date range is required. Call .year(2025) or .date_range(start, end)." ) def _build_config(self) -> PlannerConfig: """Build the configuration object.""" self._validate() return PlannerConfig( native=self._native, timezone=self._timezone, # type: ignore (validated above) start_date=self._start_date, end_date=self._end_date, year=self._year, location=self._location, include_natal_chart=self._natal_chart, include_progressed_chart=self._progressed_chart, include_solar_return=self._solar_return, include_profections=self._profections, include_zr_timeline=self._zr_timeline, zr_lot=self._zr_lot, include_graphic_ephemeris=self._graphic_ephemeris, graphic_ephemeris_harmonic=self._graphic_ephemeris_harmonic, natal_transit_planets=self._natal_transit_planets, include_mundane_transits=self._mundane_transits, include_moon_phases=self._moon_phases, include_voc=self._voc, voc_mode=self._voc_mode, ingress_planets=self._ingress_planets, station_planets=self._station_planets, page_size=self._page_size, binding_margin=self._binding_margin, week_starts_on=self._week_starts_on, )
[docs] def generate(self, output_path: str | None = None) -> bytes: """ Generate the PDF planner. Args: output_path: Optional file path to save the PDF. If None, only returns bytes. Returns: PDF as bytes """ from stellium.planner.renderer import PlannerRenderer config = self._build_config() renderer = PlannerRenderer(config) pdf_bytes = renderer.render() if output_path: with open(output_path, "wb") as f: f.write(pdf_bytes) return pdf_bytes