167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
"""Business-day provider bridging HA's Workday integration (#83 follow-up).
|
|
|
|
The day-of-month schedule's ``business`` flag rolls weekend dates back to the
|
|
previous business day. Out of the box that means a plain Mon-Fri rule — but
|
|
when the user has Home Assistant's **Workday** integration configured, "last
|
|
business day" should honour that configuration: country/region public
|
|
holidays, custom working weekdays (e.g. Mon-Sat), and the add/remove-holiday
|
|
overrides.
|
|
|
|
Layering: ``helpers/dates.py`` and ``helpers/schedule.py`` stay hass-free and
|
|
purely testable. This module keeps a process-global provider (the same
|
|
pattern Home Assistant itself uses for ``dt_util``'s default timezone):
|
|
``async_setup_business_days`` installs a predicate built from the first
|
|
Workday config entry during integration setup; ``roll_back_to_business_day``
|
|
consults it via :func:`is_business_day` and falls back to ``weekday < 5``
|
|
when none is installed (no Workday integration, unit tests).
|
|
|
|
Note: edits to the Workday configuration are picked up the next time this
|
|
integration is (re)loaded — holiday calendars change rarely enough that a
|
|
live listener isn't worth the coupling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Callable, Mapping
|
|
from datetime import date
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
BusinessDayFn = Callable[[date], bool]
|
|
|
|
WORKDAY_DOMAIN = "workday"
|
|
|
|
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
_DEFAULT_WORKDAYS = ("mon", "tue", "wed", "thu", "fri")
|
|
|
|
_provider: BusinessDayFn | None = None
|
|
|
|
|
|
def set_business_day_provider(fn: BusinessDayFn | None) -> None:
|
|
"""Install (or clear, with ``None``) the global business-day predicate."""
|
|
global _provider # deliberate module-global, see docstring
|
|
_provider = fn
|
|
|
|
|
|
def is_business_day(d: date) -> bool:
|
|
"""True when *d* is a business day.
|
|
|
|
Uses the installed Workday-backed provider when present; otherwise the
|
|
plain Mon-Fri rule. A crashing provider must never break due-date
|
|
computation, so it degrades to the weekday rule.
|
|
"""
|
|
if _provider is not None:
|
|
try:
|
|
return _provider(d)
|
|
except Exception: # noqa: BLE001 - degrade, never break scheduling
|
|
_LOGGER.warning(
|
|
"Business-day provider raised; falling back to Mon-Fri rule",
|
|
exc_info=True,
|
|
)
|
|
return d.weekday() < 5
|
|
return d.weekday() < 5
|
|
|
|
|
|
def build_provider_from_workday_options(
|
|
options: Mapping[str, Any],
|
|
) -> BusinessDayFn | None:
|
|
"""Build a business-day predicate from a Workday config entry's options.
|
|
|
|
Mirrors the Workday integration's semantics: a date is a business day when
|
|
its weekday is in ``workdays`` and — if ``"holiday"`` is excluded — the
|
|
date is not a public holiday (per ``country``/``province``, adjusted by
|
|
``add_holidays``/``remove_holidays``). Returns ``None`` when the
|
|
``holidays`` package is unavailable (it ships as a Workday requirement, so
|
|
this only happens when Workday isn't actually installed).
|
|
"""
|
|
try:
|
|
import holidays as holidays_pkg # Workday's own dependency
|
|
except ImportError:
|
|
return None
|
|
|
|
raw_workdays = options.get("workdays")
|
|
if raw_workdays is None:
|
|
raw_workdays = _DEFAULT_WORKDAYS
|
|
workday_names = set(raw_workdays)
|
|
workdays = {i for i, key in enumerate(_WEEKDAY_KEYS) if key in workday_names}
|
|
if not workdays:
|
|
return None # a config with no working days can't drive roll-back
|
|
|
|
excludes = set(options.get("excludes") or ("sat", "sun", "holiday"))
|
|
holidays_excluded = "holiday" in excludes
|
|
|
|
calendar: Any = None
|
|
country = options.get("country")
|
|
if holidays_excluded and country:
|
|
try:
|
|
calendar = holidays_pkg.country_holidays(country, subdiv=options.get("province") or None)
|
|
except Exception: # noqa: BLE001 - unknown country/subdiv in options
|
|
_LOGGER.warning(
|
|
"Could not build a holiday calendar for Workday config %r",
|
|
country,
|
|
exc_info=True,
|
|
)
|
|
calendar = None
|
|
|
|
# add_holidays / remove_holidays: ISO dates are honoured exactly; the
|
|
# Workday integration additionally allows *name fragments* in
|
|
# remove_holidays — matched case-insensitively against the holiday name.
|
|
added: set[date] = set()
|
|
for raw in options.get("add_holidays") or ():
|
|
try:
|
|
added.add(date.fromisoformat(str(raw)))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
removed: set[date] = set()
|
|
removed_names: list[str] = []
|
|
for raw in options.get("remove_holidays") or ():
|
|
try:
|
|
removed.add(date.fromisoformat(str(raw)))
|
|
except (ValueError, TypeError):
|
|
removed_names.append(str(raw).casefold())
|
|
|
|
def provider(d: date) -> bool:
|
|
if d.weekday() not in workdays:
|
|
return False
|
|
if not holidays_excluded:
|
|
return True
|
|
if d in added:
|
|
return False
|
|
# `d in calendar` lazily populates the year on demand.
|
|
if calendar is not None and d in calendar:
|
|
if d in removed:
|
|
return True
|
|
if removed_names:
|
|
name = str(calendar.get(d) or "").casefold()
|
|
if any(fragment in name for fragment in removed_names):
|
|
return True
|
|
return False
|
|
return True
|
|
|
|
return provider
|
|
|
|
|
|
def async_setup_business_days(hass: HomeAssistant) -> None:
|
|
"""Install the business-day provider from the first Workday config entry.
|
|
|
|
Called during integration setup. Without a (loadable) Workday entry the
|
|
provider is cleared and business-day rolling uses the plain Mon-Fri rule.
|
|
"""
|
|
for entry in hass.config_entries.async_entries(WORKDAY_DOMAIN):
|
|
options: dict[str, Any] = {**entry.data, **entry.options}
|
|
fn = build_provider_from_workday_options(options)
|
|
if fn is not None:
|
|
set_business_day_provider(fn)
|
|
_LOGGER.debug(
|
|
"Business days follow Workday config %r (country=%s)",
|
|
entry.title,
|
|
options.get("country"),
|
|
)
|
|
return
|
|
set_business_day_provider(None)
|