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