Files
dodo/backend/lunar_support.py
T
2026-09-06 16:09:54 +08:00

60 lines
2.1 KiB
Python

"""Pure lunar-calendar conversion helpers for countdowns.
Lunar months use lunar-python's convention: 1..12 are regular months and
-1..-12 are leap months. A missing leap month or invalid lunar day returns None.
"""
from datetime import date
from lunar_python import Lunar, Solar
def lunar_to_solar_safe(year: int, month: int, day: int) -> date | None:
"""Convert a lunar date without allowing library exceptions to escape."""
try:
solar = Lunar.fromYmd(year, month, day).getSolar()
result = date(solar.getYear(), solar.getMonth(), solar.getDay())
lunar = solar.getLunar()
if (lunar.getYear(), lunar.getMonth(), lunar.getDay()) != (year, month, day):
return None
return result
except Exception: # noqa: BLE001 - library raises plain Exception for invalid leap months.
return None
def _lunar_date(value: date):
return Solar.fromYmd(value.year, value.month, value.day).getLunar()
def solar_to_lunar_parts(value: date) -> tuple[int, int, int]:
lunar = _lunar_date(value)
return lunar.getYear(), lunar.getMonth(), lunar.getDay()
def solar_to_lunar_text(value: date) -> str:
lunar = _lunar_date(value)
return f"农历{lunar.getMonthInChinese()}{lunar.getDayInChinese()}"
def lunar_label_with_year(value: date) -> str:
lunar = _lunar_date(value)
return f"农历{lunar.getYearInChinese()}{lunar.getMonthInChinese()}{lunar.getDayInChinese()}"
def next_lunar_occurrence(
month: int,
day: int,
ignore_year: bool,
repeat_rule: str,
today: date,
) -> date | None:
"""Find the next matching lunar month/day, including sparse leap months."""
del ignore_year, repeat_rule # Both make lunar month/day recur by lunar year.
# Leap months can be separated by more than a decade. The library supports a
# bounded year range, so search far enough for all practical countdowns.
for year in range(today.year - 1, today.year + 101):
candidate = lunar_to_solar_safe(year, month, day)
if candidate is not None and candidate >= today:
return candidate
return None