Add Holiday integration (#103795)

* Add Holiday integration

* Localize holiday names

* Changes based on review feedback

* Add tests

* Add device info

* Bump holidays to 0.36

* Default to Home Assistant country setting

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/holiday/config_flow.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* black

* Move time

* Stop creating duplicate holiday calendars

* Set default language using python-holiday

* Use common translation

* Set _attr_name to None to fix friendly name

* Fix location

* Update homeassistant/components/holiday/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update tests/components/holiday/test_init.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* cleanup

* Set up the integration and test the state

* Test that configuring more than one instance is rejected

* Set default_language to user's language, fallback to country's default language

* Improve tests

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Cleanup

* Add next year so we don't run out

* Update tests/components/holiday/test_init.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Cleanup

* Set default language in `__init__`

* Add strict typing

* Change default language: HA's language `en` is `en_US` in holidays, apart from Canada

* CONF_PROVINCE can be None

* Fix test

* Fix default_language

* Refactor tests

* Province can be None

* Add test for translated title

* Address feedback

* Address feedback

* Change test to use service call

* Address feedback

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Changes based on review feedback

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/holiday/calendar.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Add a test if next event is missing

* Rebase

* Set device to service

* Remove not needed translation key

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Jan Rieger 2023-12-03 16:28:53 +01:00 committed by GitHub
parent 67784def13
commit 244edb488b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 716 additions and 0 deletions

View File

@ -152,6 +152,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.holiday.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.*

View File

@ -522,6 +522,8 @@ build.json @home-assistant/supervisor
/tests/components/hive/ @Rendili @KJonline
/homeassistant/components/hlk_sw16/ @jameshilliard
/tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger
/tests/components/holiday/ @jrieger
/homeassistant/components/home_connect/ @DavidMStraub
/tests/components/home_connect/ @DavidMStraub
/homeassistant/components/home_plus_control/ @chemaaa

View File

@ -0,0 +1,20 @@
"""The Holiday integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.CALENDAR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Holiday from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,134 @@
"""Holiday Calendar."""
from __future__ import annotations
from datetime import datetime
from holidays import HolidayBase, country_holidays
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_PROVINCE, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Holiday Calendar config entry."""
country: str = config_entry.data[CONF_COUNTRY]
province: str | None = config_entry.data.get(CONF_PROVINCE)
language = hass.config.language
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=language,
)
if language == "en":
for lang in obj_holidays.supported_languages:
if lang.startswith("en"):
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=lang,
)
language = lang
break
async_add_entities(
[
HolidayCalendarEntity(
config_entry.title,
country,
province,
language,
obj_holidays,
config_entry.entry_id,
)
],
True,
)
class HolidayCalendarEntity(CalendarEntity):
"""Representation of a Holiday Calendar element."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
name: str,
country: str,
province: str | None,
language: str,
obj_holidays: HolidayBase,
unique_id: str,
) -> None:
"""Initialize HolidayCalendarEntity."""
self._country = country
self._province = province
self._location = name
self._language = language
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
entry_type=DeviceEntryType.SERVICE,
name=name,
)
self._obj_holidays = obj_holidays
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
next_holiday = None
for holiday_date, holiday_name in sorted(
self._obj_holidays.items(), key=lambda x: x[0]
):
if holiday_date >= dt_util.now().date():
next_holiday = (holiday_date, holiday_name)
break
if next_holiday is None:
return None
return CalendarEvent(
summary=next_holiday[1],
start=next_holiday[0],
end=next_holiday[0],
location=self._location,
)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
obj_holidays = country_holidays(
self._country,
subdiv=self._province,
years=list({start_date.year, end_date.year}),
language=self._language,
)
event_list: list[CalendarEvent] = []
for holiday_date, holiday_name in obj_holidays.items():
if start_date.date() <= holiday_date <= end_date.date():
event = CalendarEvent(
summary=holiday_name,
start=holiday_date,
end=holiday_date,
location=self._location,
)
event_list.append(event)
return event_list

View File

@ -0,0 +1,99 @@
"""Config flow for Holiday integration."""
from __future__ import annotations
from typing import Any
from babel import Locale
from holidays import list_supported_countries
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_COUNTRY
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_PROVINCE, DOMAIN
SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False)
class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Holiday."""
VERSION = 1
data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is not None:
self.data = user_input
selected_country = self.data[CONF_COUNTRY]
if SUPPORTED_COUNTRIES[selected_country]:
return await self.async_step_province()
self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]})
locale = Locale(self.hass.config.language)
title = locale.territories[selected_country]
return self.async_create_entry(title=title, data=self.data)
user_schema = vol.Schema(
{
vol.Optional(
CONF_COUNTRY, default=self.hass.config.country
): CountrySelector(
CountrySelectorConfig(
countries=list(SUPPORTED_COUNTRIES),
)
),
}
)
return self.async_show_form(step_id="user", data_schema=user_schema)
async def async_step_province(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the province step."""
if user_input is not None:
combined_input: dict[str, Any] = {**self.data, **user_input}
country = combined_input[CONF_COUNTRY]
province = combined_input.get(CONF_PROVINCE)
self._async_abort_entries_match(
{
CONF_COUNTRY: country,
CONF_PROVINCE: province,
}
)
locale = Locale(self.hass.config.language)
province_str = f", {province}" if province else ""
name = f"{locale.territories[country]}{province_str}"
return self.async_create_entry(title=name, data=combined_input)
province_schema = vol.Schema(
{
vol.Optional(CONF_PROVINCE): SelectSelector(
SelectSelectorConfig(
options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
)
return self.async_show_form(step_id="province", data_schema=province_schema)

View File

@ -0,0 +1,6 @@
"""Constants for the Holiday integration."""
from typing import Final
DOMAIN: Final = "holiday"
CONF_PROVINCE: Final = "province"

View File

@ -0,0 +1,9 @@
{
"domain": "holiday",
"name": "Holiday",
"codeowners": ["@jrieger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.37", "babel==2.13.1"]
}

View File

@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "Already configured. Only a single configuration for country/province combination possible."
},
"step": {
"user": {
"data": {
"country": "Country"
}
},
"province": {
"data": {
"province": "Province"
}
}
}
}
}

View File

@ -201,6 +201,7 @@ FLOWS = {
"hisense_aehw4a1",
"hive",
"hlk_sw16",
"holiday",
"home_connect",
"home_plus_control",
"homeassistant_sky_connect",

View File

@ -2408,6 +2408,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"holiday": {
"name": "Holiday",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"home_connect": {
"name": "Home Connect",
"integration_type": "hub",

View File

@ -1281,6 +1281,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.holiday.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.homeassistant.exposed_entities]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -504,6 +504,9 @@ azure-eventhub==5.11.1
# homeassistant.components.azure_service_bus
azure-servicebus==7.10.0
# homeassistant.components.holiday
babel==2.13.1
# homeassistant.components.baidu
baidu-aip==1.6.6
@ -1013,6 +1016,7 @@ hlk-sw16==0.0.9
# homeassistant.components.pi_hole
hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.37

View File

@ -438,6 +438,9 @@ axis==48
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
# homeassistant.components.holiday
babel==2.13.1
# homeassistant.components.homekit
base36==0.1.1
@ -800,6 +803,7 @@ hlk-sw16==0.0.9
# homeassistant.components.pi_hole
hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.37

View File

@ -0,0 +1 @@
"""Tests for the Holiday integration."""

View File

@ -0,0 +1,14 @@
"""Common fixtures for the Holiday tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.holiday.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,229 @@
"""Tests for calendar platform of Holiday integration."""
from datetime import datetime, timedelta
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
)
from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN
from homeassistant.const import CONF_COUNTRY
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_holiday_calendar_entity(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test HolidayCalendarEntity functionality."""
freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"},
title="United States, AK",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await async_setup_component(hass, "calendar", {})
await hass.async_block_till_done()
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.united_states_ak",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.united_states_ak": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "New Year's Day",
"location": "United States, AK",
}
]
}
}
state = hass.states.get("calendar.united_states_ak")
assert state is not None
assert state.state == "on"
# Test holidays for the next year
freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC))
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.united_states_ak",
"end_date_time": dt_util.now() + timedelta(days=1),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.united_states_ak": {
"events": [
{
"start": "2024-01-01",
"end": "2024-01-02",
"summary": "New Year's Day",
"location": "United States, AK",
}
]
}
}
async def test_default_language(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test default language."""
freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"},
title="France, BL",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# Test French calendar with English language
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.france_bl",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.france_bl": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "New Year's Day",
"location": "France, BL",
}
]
}
}
# Test French calendar with French language
hass.config.language = "fr"
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.france_bl",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.france_bl": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "Jour de l'an",
"location": "France, BL",
}
]
}
}
async def test_no_language(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test language defaults to English if language not exist."""
freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_COUNTRY: "AL"},
title="Albania",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
"entity_id": "calendar.albania",
"end_date_time": dt_util.now(),
},
blocking=True,
return_response=True,
)
assert response == {
"calendar.albania": {
"events": [
{
"start": "2023-01-01",
"end": "2023-01-02",
"summary": "New Year's Day",
"location": "Albania",
}
]
}
}
async def test_no_next_event(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test if there is no next event."""
freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC))
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_COUNTRY: "DE"},
title="Germany",
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# Move time to out of reach
freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC))
async_fire_time_changed(hass)
state = hass.states.get("calendar.germany")
assert state is not None
assert state.state == "off"
assert state.attributes == {"friendly_name": "Germany"}

View File

@ -0,0 +1,128 @@
"""Test the Holiday config flow."""
from unittest.mock import AsyncMock
import pytest
from homeassistant import config_entries
from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN
from homeassistant.const import CONF_COUNTRY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_COUNTRY: "DE",
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_PROVINCE: "BW",
},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Germany, BW"
assert result3["data"] == {
"country": "DE",
"province": "BW",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_no_subdivision(hass: HomeAssistant) -> None:
"""Test we get the forms correctly without subdivision."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_COUNTRY: "SE",
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Sweden"
assert result2["data"] == {
"country": "SE",
}
async def test_form_translated_title(hass: HomeAssistant) -> None:
"""Test the title gets translated."""
hass.config.language = "de"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_COUNTRY: "SE",
},
)
await hass.async_block_till_done()
assert result2["title"] == "Schweden"
async def test_single_combination_country_province(hass: HomeAssistant) -> None:
"""Test that configuring more than one instance is rejected."""
data_de = {
CONF_COUNTRY: "DE",
CONF_PROVINCE: "BW",
}
data_se = {
CONF_COUNTRY: "SE",
}
MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass)
MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass)
# Test for country without subdivisions
result_se = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=data_se,
)
assert result_se["type"] == FlowResultType.ABORT
assert result_se["reason"] == "already_configured"
# Test for country with subdivisions
result_de_step1 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=data_de,
)
assert result_de_step1["type"] == FlowResultType.FORM
result_de_step2 = await hass.config_entries.flow.async_configure(
result_de_step1["flow_id"],
{
CONF_PROVINCE: data_de[CONF_PROVINCE],
},
)
assert result_de_step2["type"] == FlowResultType.ABORT
assert result_de_step2["reason"] == "already_configured"

View File

@ -0,0 +1,29 @@
"""Tests for the Holiday integration."""
from homeassistant.components.holiday.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_CONFIG_DATA = {
"country": "Germany",
"province": "BW",
}
async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test removing integration."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
state: ConfigEntryState = entry.state
assert state == ConfigEntryState.NOT_LOADED