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