"""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 async 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. The build runs in the executor: ``holidays.country_holidays`` lazily ``import_module``s the country submodule (e.g. ``holidays.countries.canada``), which HA flags as a blocking call when done on the event loop (issue #87). """ for entry in hass.config_entries.async_entries(WORKDAY_DOMAIN): options: dict[str, Any] = {**entry.data, **entry.options} fn = await hass.async_add_executor_job( 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)