Pendulum extends Python datetime with timezone safety, arithmetic, and human output. pip install pendulum. import pendulum. Now: now = pendulum.now("America/New_York"). Today: pendulum.today("UTC"). UTC: pendulum.now("UTC"). Parse: dt = pendulum.parse("2024-06-15T10:30:00Z"). Instance: dt = pendulum.instance(datetime_obj). Timezone: dt.in_timezone("Europe/London"). Convert: dt.in_tz("Asia/Tokyo"). Create: pendulum.datetime(2024, 6, 15, 10, 30, tz="UTC"). Local: pendulum.local(2024, 6, 15). Add: dt.add(days=7, hours=3). Subtract: dt.subtract(months=1). Diff: dt1.diff(dt2).in_hours(). dt1.diff_for_humans() — “2 hours ago”. Absolute diff: dt1.diff(dt2, absolute=True).in_days(). Start/End: dt.start_of("month"), dt.end_of("week"), dt.start_of("year"). Between: dt.between(start, end). Is past/future: dt.is_past(), dt.is_future(). Is same day: dt.is_same_day(other). Period: period = pendulum.period(start, end), for day in period.range("days"):. Duration: d = pendulum.duration(days=5, hours=3). Format: dt.format("YYYY-MM-DD HH:mm:ss"). ISO: dt.to_iso8601_string(). Date string: dt.to_date_string(). Time string: dt.to_time_string(). Day name: dt.day_of_week, dt.format("dddd"). DST-safe: arithmetic respects DST transitions. Test now: pendulum.set_test_now(pendulum.datetime(2024, 1, 1)). Travel: with pendulum.travel(to=pendulum.datetime(2024, 12, 25)):. Claude Code generates Pendulum timezone-aware scheduling, reporting date ranges, and API timestamp helpers.
CLAUDE.md for Pendulum
## Pendulum Stack
- Version: pendulum >= 3.0
- Import: import pendulum
- Creation: pendulum.now("TZ") | pendulum.datetime(Y,M,D,tz=) | pendulum.parse(str)
- Timezone: .in_timezone("Region/City") | pendulum.timezone("UTC")
- Arithmetic: .add(days=, hours=) | .subtract(months=) — DST-aware
- Diff: .diff(other).in_hours() | .diff_for_humans() for human output
- Period: pendulum.period(start, end).range("days") for iteration
- Testing: pendulum.set_test_now(dt) | pendulum.travel(to=dt)
Pendulum Datetime Pipeline
# utils/pendulum_pipeline.py — datetime handling with Pendulum
from __future__ import annotations
from typing import Generator, Iterator
import pendulum
from pendulum import DateTime, Duration, Period
# ── 0. Creation helpers ───────────────────────────────────────────────────────
def now_utc() -> DateTime:
"""Current UTC datetime."""
return pendulum.now("UTC")
def now_local(timezone: str = "America/New_York") -> DateTime:
"""Current datetime in a specific timezone."""
return pendulum.now(timezone)
def parse_datetime(value: str, tz: str = "UTC") -> DateTime:
"""
Parse a datetime string robustly.
Handles ISO 8601, Unix timestamps, and many natural formats.
"""
if isinstance(value, (int, float)):
return pendulum.from_timestamp(value, tz=tz)
try:
dt = pendulum.parse(value, tz=tz)
return dt
except Exception:
# Fallback: try common formats
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%m/%d/%Y"]:
try:
import datetime
d = datetime.datetime.strptime(str(value), fmt)
return pendulum.instance(d, tz=tz)
except ValueError:
continue
raise ValueError(f"Cannot parse datetime: {value!r}")
def from_timestamp(ts: float, tz: str = "UTC") -> DateTime:
"""Create DateTime from Unix timestamp."""
return pendulum.from_timestamp(ts, tz=tz)
# ── 1. Timezone conversion ────────────────────────────────────────────────────
def convert_timezone(dt: DateTime, target_tz: str) -> DateTime:
"""Convert a datetime to a different timezone."""
return dt.in_timezone(target_tz)
def to_utc(dt: DateTime) -> DateTime:
"""Normalize any timezone-aware datetime to UTC."""
return dt.in_timezone("UTC")
def localize(dt: DateTime, user_timezone: str) -> str:
"""
Convert a UTC datetime to user's local time, formatted for display.
Returns ISO 8601 string with offset (e.g., "2024-06-15T14:30:00+05:30").
"""
local = dt.in_timezone(user_timezone)
return local.to_iso8601_string()
def multi_timezone_display(dt: DateTime, timezones: list[str]) -> dict[str, str]:
"""Show a UTC datetime in multiple timezones."""
result = {}
for tz in timezones:
local = dt.in_timezone(tz)
result[tz] = local.format("YYYY-MM-DD HH:mm:ss z")
return result
# ── 2. Date arithmetic ────────────────────────────────────────────────────────
def add_business_days(dt: DateTime, n: int) -> DateTime:
"""
Add N business days (Mon–Fri), skipping weekends.
Does not account for holidays — use workalendar for that.
"""
current = dt
added = 0
direction = 1 if n >= 0 else -1
target = abs(n)
while added < target:
current = current.add(days=direction)
if current.day_of_week not in (pendulum.SATURDAY, pendulum.SUNDAY):
added += 1
return current
def next_occurrence(dt: DateTime, weekday: int) -> DateTime:
"""
Return the next occurrence of a weekday (0=Mon, 6=Sun) after dt.
Example: next_occurrence(now, pendulum.FRIDAY)
"""
days_ahead = (weekday - dt.day_of_week + 7) % 7
if days_ahead == 0:
days_ahead = 7
return dt.add(days=days_ahead).start_of("day")
def quarter_range(year: int, quarter: int) -> tuple[DateTime, DateTime]:
"""Return (start, end) of a fiscal quarter."""
month_start = (quarter - 1) * 3 + 1
start = pendulum.datetime(year, month_start, 1, tz="UTC")
end = start.add(months=3).subtract(microseconds=1)
return start, end
def clamp_datetime(
dt: DateTime,
minimum: DateTime | None = None,
maximum: DateTime | None = None,
) -> DateTime:
"""Clamp a datetime to [minimum, maximum]."""
if minimum and dt < minimum:
return minimum
if maximum and dt > maximum:
return maximum
return dt
# ── 3. Periods and ranges ─────────────────────────────────────────────────────
def date_range(
start: DateTime,
end: DateTime,
unit: str = "days", # "days" | "weeks" | "months" | "hours"
) -> list[DateTime]:
"""Generate a list of datetimes between start and end."""
period = pendulum.period(start, end)
return list(period.range(unit))
def monthly_intervals(
start_date: DateTime,
n_months: int,
) -> list[tuple[DateTime, DateTime]]:
"""
Return a list of (month_start, month_end) tuples.
Useful for generating monthly report windows.
"""
intervals = []
current = start_date.start_of("month")
for _ in range(n_months):
month_end = current.end_of("month")
intervals.append((current, month_end))
current = current.add(months=1)
return intervals
def sliding_time_windows(
start: DateTime,
end: DateTime,
window_hours: int = 24,
step_hours: int = 6,
) -> Generator[tuple[DateTime, DateTime], None, None]:
"""Yield (window_start, window_end) tuples sliding across a period."""
current = start
while current + pendulum.duration(hours=window_hours) <= end:
yield current, current.add(hours=window_hours)
current = current.add(hours=step_hours)
def is_within_business_hours(
dt: DateTime,
start_hour: int = 9,
end_hour: int = 17,
timezone: str = "America/New_York",
) -> bool:
"""Check whether a UTC datetime falls within business hours in a timezone."""
local = dt.in_timezone(timezone)
is_weekday = local.day_of_week not in (pendulum.SATURDAY, pendulum.SUNDAY)
is_work_hours = start_hour <= local.hour < end_hour
return is_weekday and is_work_hours
# ── 4. Formatting and serialization ──────────────────────────────────────────
def format_duration(d: Duration) -> str:
"""Format a Duration as human-readable string."""
parts = []
if abs(d.days) >= 1:
parts.append(f"{abs(d.days)}d")
if d.hours:
parts.append(f"{d.hours}h")
if d.minutes:
parts.append(f"{d.minutes}m")
if d.seconds and not parts:
parts.append(f"{d.seconds}s")
return " ".join(parts) or "0s"
def relative_time(dt: DateTime, reference: DateTime = None) -> str:
"""
Human-readable relative time: "2 hours ago", "in 3 days", etc.
Uses Pendulum's diff_for_humans.
"""
ref = reference or pendulum.now("UTC")
return dt.diff_for_humans(ref)
def to_api_timestamp(dt: DateTime) -> str:
"""Serialize to RFC 3339 / ISO 8601 for API responses."""
return dt.to_iso8601_string()
def from_api_timestamp(s: str) -> DateTime:
"""Deserialize from API timestamp string."""
return pendulum.parse(s)
def format_for_display(dt: DateTime, timezone: str, locale: str = "en") -> str:
"""
Format datetime for user display with timezone abbreviation.
Examples: "Jun 15, 2024 at 10:30 AM EDT"
"""
local = dt.in_timezone(timezone)
return local.format("MMM D, YYYY [at] h:mm A z")
# ── 5. Scheduling helpers ─────────────────────────────────────────────────────
def next_run_times(
cron_hour: int,
cron_minute: int,
timezone: str,
n: int = 5,
) -> list[DateTime]:
"""
Return the next N scheduled run times for a daily job at HH:MM in timezone.
"""
now = pendulum.now(timezone)
runs = []
current = now.set(hour=cron_hour, minute=cron_minute, second=0, microsecond=0)
if current <= now:
current = current.add(days=1)
for _ in range(n):
runs.append(current.in_timezone("UTC"))
current = current.add(days=1)
return runs
def sla_deadline(
created_at: DateTime,
sla_hours: int,
business_only: bool = False,
timezone: str = "America/New_York",
) -> DateTime:
"""
Compute an SLA deadline, optionally counting only business hours.
business_only=True: skips weekends.
"""
if not business_only:
return created_at.add(hours=sla_hours)
return add_business_days(created_at, sla_hours // 8)
# ── 6. Testing helpers ────────────────────────────────────────────────────────
class FrozenTime:
"""Context manager to freeze time in tests using pendulum."""
def __init__(self, frozen_dt: DateTime):
self.frozen_dt = frozen_dt
def __enter__(self):
pendulum.set_test_now(self.frozen_dt)
return self.frozen_dt
def __exit__(self, *_):
pendulum.set_test_now() # clears test now
# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("Pendulum Datetime Demo")
print("=" * 50)
# Creation
now = now_utc()
eastern = now_local("America/New_York")
print(f"\nnow_utc(): {now.to_iso8601_string()}")
print(f"now Eastern: {eastern.to_iso8601_string()}")
# Parse
parsed = parse_datetime("2024-06-15T10:30:00Z")
print(f"parsed: {parsed}")
# Timezones
print("\nMulti-timezone display:")
display = multi_timezone_display(now, ["UTC", "America/Los_Angeles", "Asia/Tokyo", "Europe/Paris"])
for tz, ts in display.items():
print(f" {tz:<25} {ts}")
# Arithmetic
deadline = now.add(days=7, hours=9)
print(f"\n7 days + 9 hours: {deadline.to_date_string()}")
bizday = add_business_days(now, 5)
print(f"5 business days: {bizday.to_date_string()}")
# Diff
past = now.subtract(hours=27)
print(f"\ndiff_for_humans: {past.diff_for_humans()}")
print(f"diff in hours: {past.diff(now).in_hours():.1f}h")
# Period ranges
q_start, q_end = quarter_range(2024, 2)
print(f"\nQ2 2024: {q_start.to_date_string()} → {q_end.to_date_string()}")
months = monthly_intervals(pendulum.datetime(2024, 1, 1, tz="UTC"), 3)
for s, e in months:
print(f" Month: {s.format('MMMM YYYY')}: {s.to_date_string()} → {e.to_date_string()}")
# Frozen time for testing
with FrozenTime(pendulum.datetime(2024, 1, 1, 12, 0, tz="UTC")) as frozen:
print(f"\nFrozen now(): {pendulum.now('UTC')}")
print(f"After unfreeze: {pendulum.now('UTC').to_date_string()} (real time)")
# SLA
sla = sla_deadline(now, sla_hours=24)
print(f"\n24h SLA deadline: {sla.to_iso8601_string()}")
print(f"Relative: {relative_time(sla)}")
For the stdlib datetime + pytz alternative — pytz.localize() and datetime.astimezone() require careful pairing to avoid ambiguous DST times while Pendulum’s pendulum.now("America/New_York") always returns an unambiguous timezone-aware datetime, dt.add(months=1) handles month-end correctly (January 31 + 1 month = February 28/29), and dt.diff_for_humans() converts a duration to “3 hours ago” or “in 2 days” without building a formatter. For the dateutil alternative — dateutil.parser.parse is lenient to ambiguous formats while Pendulum’s pendulum.parse is strict by default (raises on ambiguous input), pendulum.period(start, end).range("days") generates timezone-aware date sequences without a timedelta loop, and pendulum.set_test_now(dt) or pendulum.travel(to=dt) makes pendulum.now() return a fixed value in tests without patching datetime.datetime. The Claude Skills 360 bundle includes Pendulum skill sets covering now/parse/instance creation, in_timezone conversions, add/subtract DST-safe arithmetic, diff and diff_for_humans, period.range iteration, monthly_intervals, business day arithmetic, quarter_range, SLA deadline, ISO 8601 serialization, format_for_display locale formatting, FrozenTime test helper, and set_test_now. Start with the free tier to try datetime code generation.