diff --git a/homeassistant/components/jewish_calendar/calendar.py b/homeassistant/components/jewish_calendar/calendar.py index 6fcb04921381..4261631c3d56 100644 --- a/homeassistant/components/jewish_calendar/calendar.py +++ b/homeassistant/components/jewish_calendar/calendar.py @@ -8,6 +8,8 @@ from typing import override from hdate import HDateInfo, Zmanim from hdate.parasha import Parasha +from hdate.translator import TranslatorMixin, set_language +from hdate.zmanim import Zman from homeassistant.components.calendar import ( CalendarEntity, @@ -51,6 +53,26 @@ class JewishCalendarCalendarEntityDescription(CalendarEntityDescription): ] +def _all_day_event(target_date: date, item: TranslatorMixin) -> CalendarEvent: + """Create an all-day event from a translatable hdate object.""" + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(item), + description=item.description, + ) + + +def _timed_event(zman: Zman) -> CalendarEvent: + """Create an instantaneous event from a zman.""" + return CalendarEvent( + start=zman.utc, + end=zman.utc, + summary=str(zman), + description=zman.description, + ) + + def _create_daily_event( event_type: JewishCalendarEventType, target_date: date, @@ -58,28 +80,13 @@ def _create_daily_event( zmanim: Zmanim, ) -> CalendarEvent | None: """Create a daily calendar event.""" - # Hebrew date if event_type == DailyCalendarEventType.DATE: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.hdate), - description=f"Hebrew date: {info.hdate}", - ) + return _all_day_event(target_date, info.hdate) - # Time-based daily events using enum properties daily_event = DailyCalendarEventType(event_type) - time_value = zmanim.zmanim.get(daily_event.value) - - if time_value is not None: - return CalendarEvent( - start=time_value.utc, - end=time_value.utc, - summary=daily_event.summary, - description=f"{daily_event.description_prefix}: {time_value.local.strftime('%H:%M')}", - ) - - return None # Should never happen + if (zman := zmanim.zmanim.get(daily_event.value)) is None: + return None # Should never happen + return _timed_event(zman) def _create_yearly_event( @@ -89,56 +96,30 @@ def _create_yearly_event( zmanim: Zmanim, ) -> list[CalendarEvent] | CalendarEvent | None: """Create a yearly calendar event.""" - if event_type == YearlyCalendarEventType.HOLIDAY and info.holidays: - return [ - CalendarEvent( - start=target_date, - end=target_date, - summary=str(holiday), - description=( - f"Jewish Holiday: {holiday}\nHoliday Type: {holiday.type}" - ), - ) - for holiday in info.holidays - ] + if event_type == YearlyCalendarEventType.HOLIDAY: + return [_all_day_event(target_date, holiday) for holiday in info.holidays] if event_type == YearlyCalendarEventType.WEEKLY_PORTION: is_shabbat = target_date.weekday() == _SATURDAY is_simchat_torah = any( holiday.name == _SIMCHAT_TORAH for holiday in info.holidays ) - if (is_shabbat or is_simchat_torah) and info.parasha != str(Parasha.NONE): - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.parasha), - description=f"Parshat Hashavua: {info.parasha}", - ) + parasha = info.parasha_obj + if (is_shabbat or is_simchat_torah) and parasha is not Parasha.NONE: + return _all_day_event(target_date, parasha) return None - if event_type == YearlyCalendarEventType.OMER_COUNT and info.omer.total_days > 0: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.omer), - description=f"Sefirat HaOmer: {info.omer.count_str()}", - ) + if event_type == YearlyCalendarEventType.OMER_COUNT: + omer = info.omer + return _all_day_event(target_date, omer) if omer.total_days > 0 else None - if event_type == YearlyCalendarEventType.CANDLE_LIGHTING and zmanim.candle_lighting: - return CalendarEvent( - start=zmanim.candle_lighting.astimezone(UTC), - end=zmanim.candle_lighting.astimezone(UTC), - summary="Candle Lighting", - description=f"Candle lighting time: {zmanim.candle_lighting.strftime('%H:%M')}", - ) + if event_type == YearlyCalendarEventType.CANDLE_LIGHTING: + zman = zmanim.candle_lighting_obj + return _timed_event(zman) if zman is not None else None - if event_type == YearlyCalendarEventType.HAVDALAH and zmanim.havdalah: - return CalendarEvent( - start=zmanim.havdalah.astimezone(UTC), - end=zmanim.havdalah.astimezone(UTC), - summary="Havdalah", - description=f"Havdalah time: {zmanim.havdalah.strftime('%H:%M')}", - ) + if event_type == YearlyCalendarEventType.HAVDALAH: + zman = zmanim.havdalah_obj + return _timed_event(zman) if zman is not None else None return None @@ -150,13 +131,8 @@ def _create_learning_event( zmanim: Zmanim, ) -> CalendarEvent | None: """Create a learning schedule event.""" - if event_type == LearningScheduleEventType.DAF_YOMI and info.daf_yomi: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.daf_yomi), - description=f"Daf Yomi: {info.daf_yomi}", - ) + if event_type == LearningScheduleEventType.DAF_YOMI: + return _all_day_event(target_date, info.daf_yomi_obj) return None @@ -277,6 +253,10 @@ class JewishCalendar(JewishCalendarEntity, CalendarEntity): def _get_events_for_date(self, target_date: date) -> list[CalendarEvent]: """Get all configured events for a specific date.""" + # hdate holds its display language in a ContextVar that the coordinator sets + # in its own task, so it has to be re-applied in the task serving this request. + set_language(self.coordinator.data.language) + events = [] info = HDateInfo(target_date, self.coordinator.data.diaspora) diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 4780e5166015..69ab59153d37 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -1,7 +1,6 @@ """Jewish Calendar constants.""" from enum import StrEnum -from typing import TYPE_CHECKING, Self DOMAIN = "jewish_calendar" @@ -24,65 +23,21 @@ DEFAULT_LANGUAGE = "en" class DailyCalendarEventType(StrEnum): - """Daily Calendar event types with metadata.""" + """Daily Calendar event types.""" DATE = "date" - ALOT_HASHACHAR = ( - "alot_hashachar", - "Alot Hashachar", # codespell:ignore alot - "Halachic dawn", - ) - NETZ_HACHAMA = ("netz_hachama", "Netz Hachama", "Halachic sunrise") - SOF_ZMAN_SHEMA_GRA = ( - "sof_zman_shema_gra", - 'Sof Zman Shema (Gr"A)', # codespell:ignore shema - "Latest time for Shema", # codespell:ignore shema - ) - SOF_ZMAN_SHEMA_MGA = ( - "sof_zman_shema_mga", - 'Sof Zman Shema (Mg"A)', # codespell:ignore shema - "Latest time for Shema", # codespell:ignore shema - ) - SOF_ZMAN_TFILLA_GRA = ( - "sof_zman_tfilla_gra", - 'Sof Zman Tefilla (Gr"A)', - "Latest time for Tefilla", - ) - SOF_ZMAN_TFILLA_MGA = ( - "sof_zman_tfilla_mga", - 'Sof Zman Tefilla (Mg"A)', - "Latest time for Tefilla", - ) - CHATZOT_HAYOM = ("chatzot_hayom", "Chatzot Hayom", "Halachic midday") - MINCHA_GEDOLA = ("mincha_gedola", "Mincha Gedola", "Earliest time for Mincha") - MINCHA_KETANA = ("mincha_ketana", "Mincha Ketana", "Preferable time for Mincha") - PLAG_HAMINCHA = ("plag_hamincha", "Plag Hamincha", "Plag Hamincha") - SHKIA = ("shkia", "Shkia", "Sunset") - TSET_HAKOHAVIM = ("tset_hakohavim_tsom", "T'set Hakochavim", "Nightfall") - - if TYPE_CHECKING: - _summary: str - _description_prefix: str - - def __new__( - cls, value: str, summary: str = "", description_prefix: str = "" - ) -> Self: - """Create new enum member with additional attributes.""" - obj = str.__new__(cls, value) - obj._value_ = value - obj._summary = summary # noqa: SLF001 - obj._description_prefix = description_prefix # noqa: SLF001 - return obj - - @property - def summary(self) -> str: - """Return the summary for the event.""" - return self._summary - - @property - def description_prefix(self) -> str: - """Return the description prefix for the event.""" - return self._description_prefix + ALOT_HASHACHAR = "alot_hashachar" + NETZ_HACHAMA = "netz_hachama" + SOF_ZMAN_SHEMA_GRA = "sof_zman_shema_gra" + SOF_ZMAN_SHEMA_MGA = "sof_zman_shema_mga" + SOF_ZMAN_TFILLA_GRA = "sof_zman_tfilla_gra" + SOF_ZMAN_TFILLA_MGA = "sof_zman_tfilla_mga" + CHATZOT_HAYOM = "chatzot_hayom" + MINCHA_GEDOLA = "mincha_gedola" + MINCHA_KETANA = "mincha_ketana" + PLAG_HAMINCHA = "plag_hamincha" + SHKIA = "shkia" + TSET_HAKOHAVIM = "tset_hakohavim_tsom" class YearlyCalendarEventType(StrEnum): diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index ff1ee29d3c80..e2a5cb7b2e53 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -2,7 +2,7 @@ "common": { "descr_diaspora": "Is the location outside of Israel?", "descr_elevation": "Elevation in meters above sea level. This is used to calculate the times correctly.", - "descr_language": "Language to use when displaying values in the UI. This does not affect the Hebrew date.", + "descr_language": "Language to use when displaying values in the UI.", "descr_location": "Location to use for the Jewish calendar calculations. By default, the location is set to the Home Assistant location.", "descr_time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations", "diaspora": "Outside of Israel?", diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index f69d6c3657ed..a0a171ec531a 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -2,6 +2,22 @@ from dataclasses import dataclass import datetime as dt +from typing import Protocol + +from homeassistant.core import HomeAssistant + + +class GetCalendarEvents(Protocol): + """Return the events of a calendar entity within a date range.""" + + async def __call__( + self, + hass: HomeAssistant, + entity_id: str, + start_date: dt.datetime, + end_date: dt.datetime | None = None, + ) -> list[dict[str, str]]: + """Return the events between the two dates.""" @dataclass(frozen=True) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 4bc6a6e1921a..cbdd445a7784 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -30,7 +30,7 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_LANGUAGE, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import TimeValue +from . import GetCalendarEvents, TimeValue from tests.common import MockConfigEntry, async_fire_time_changed @@ -209,7 +209,7 @@ async def setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @pytest.fixture -def get_calendar_events(): +def get_calendar_events() -> GetCalendarEvents: """Fixture that returns a function to get calendar events for a date range.""" async def _get_events( diff --git a/tests/components/jewish_calendar/snapshots/test_calendar.ambr b/tests/components/jewish_calendar/snapshots/test_calendar.ambr index 88aa9c3a7fb1..d7d0de9acd56 100644 --- a/tests/components/jewish_calendar/snapshots/test_calendar.ambr +++ b/tests/components/jewish_calendar/snapshots/test_calendar.ambr @@ -12,19 +12,19 @@ # name: test_daily_events[Jerusalem] list([ dict({ - 'description': 'Hebrew date: ה\' שבט ה\' תשפ"ד', + 'description': "Hebrew date: 5 Sh'vat 5784", 'end': '2024-01-16', 'start': '2024-01-15', - 'summary': 'ה\' שבט ה\' תשפ"ד', + 'summary': "5 Sh'vat 5784", }), dict({ - 'description': 'Halachic sunrise: 06:40', + 'description': 'Netz Hachama: 06:40', 'end': '2024-01-15T04:40:00+00:00', 'start': '2024-01-15T04:40:00+00:00', 'summary': 'Netz Hachama', }), dict({ - 'description': 'Sunset: 16:57', + 'description': 'Shkia: 16:57', 'end': '2024-01-15T14:57:00+00:00', 'start': '2024-01-15T14:57:00+00:00', 'summary': 'Shkia', @@ -33,26 +33,26 @@ 'description': 'Nightfall: 17:26', 'end': '2024-01-15T15:26:00+00:00', 'start': '2024-01-15T15:26:00+00:00', - 'summary': "T'set Hakochavim", + 'summary': 'End of fast', }), ]) # --- # name: test_daily_events[New York] list([ dict({ - 'description': 'Hebrew date: ה\' שבט ה\' תשפ"ד', + 'description': "Hebrew date: 5 Sh'vat 5784", 'end': '2024-01-16', 'start': '2024-01-15', - 'summary': 'ה\' שבט ה\' תשפ"ד', + 'summary': "5 Sh'vat 5784", }), dict({ - 'description': 'Halachic sunrise: 07:18', + 'description': 'Netz Hachama: 07:18', 'end': '2024-01-15T12:18:00+00:00', 'start': '2024-01-15T12:18:00+00:00', 'summary': 'Netz Hachama', }), dict({ - 'description': 'Sunset: 16:53', + 'description': 'Shkia: 16:53', 'end': '2024-01-15T21:53:00+00:00', 'start': '2024-01-15T21:53:00+00:00', 'summary': 'Shkia', @@ -61,7 +61,7 @@ 'description': 'Nightfall: 17:26', 'end': '2024-01-15T22:26:00+00:00', 'start': '2024-01-15T22:26:00+00:00', - 'summary': "T'set Hakochavim", + 'summary': 'End of fast', }), ]) # --- @@ -415,42 +415,42 @@ list([ dict({ 'description': ''' - Jewish Holiday: שושן פורים - Holiday Type: חג (מלאכה מותרת) + Jewish Holiday: Shushan Purim + Holiday type: Work-permitted holiday ''', 'end': '2024-03-26', 'start': '2024-03-25', - 'summary': 'שושן פורים', + 'summary': 'Shushan Purim', }), ]) # --- # name: test_learning_schedule_events[Jerusalem] list([ dict({ - 'description': 'Daf Yomi: בבא בתרא כ', + 'description': 'Daf Yomi: Bava Basra 20', 'end': '2024-07-16', 'start': '2024-07-15', - 'summary': 'בבא בתרא כ', + 'summary': 'Bava Basra 20', }), ]) # --- # name: test_omer_count[calendar_events0-New York] list([ dict({ - 'description': 'Sefirat HaOmer: היום יום אחד לעומר', + 'description': 'Sefirat HaOmer: Today is the first day of the Omer', 'end': '2024-04-25', 'start': '2024-04-24', - 'summary': "א' לעומר", + 'summary': '1 of the Omer', }), ]) # --- # name: test_weekly_portion_on_shabbat[Jerusalem] list([ dict({ - 'description': 'Parshat Hashavua: וארא', + 'description': 'Parshat Hashavua: Vaera', 'end': '2024-01-14', 'start': '2024-01-13', - 'summary': 'וארא', + 'summary': 'Vaera', }), dict({ 'description': 'Havdalah time: 17:35', @@ -463,10 +463,10 @@ # name: test_weekly_portion_on_shabbat[New York] list([ dict({ - 'description': 'Parshat Hashavua: וארא', + 'description': 'Parshat Hashavua: Vaera', 'end': '2024-01-14', 'start': '2024-01-13', - 'summary': 'וארא', + 'summary': 'Vaera', }), dict({ 'description': 'Havdalah time: 17:35', @@ -480,18 +480,18 @@ list([ dict({ 'description': ''' - Jewish Holiday: שמחת תורה - Holiday Type: יום טוב + Jewish Holiday: Simchat Torah + Holiday type: Yom Tov ''', 'end': '2024-10-26', 'start': '2024-10-25', - 'summary': 'שמחת תורה', + 'summary': 'Simchat Torah', }), dict({ - 'description': 'Parshat Hashavua: וזאת הברכה', + 'description': 'Parshat Hashavua: Vezot Habracha', 'end': '2024-10-26', 'start': '2024-10-25', - 'summary': 'וזאת הברכה', + 'summary': 'Vezot Habracha', }), dict({ 'description': 'Candle lighting time: 17:42', @@ -505,27 +505,27 @@ list([ dict({ 'description': ''' - Jewish Holiday: שמיני עצרת - Holiday Type: יום טוב + Jewish Holiday: Shmini Atzeret + Holiday type: Yom Tov ''', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'שמיני עצרת', + 'summary': 'Shmini Atzeret', }), dict({ 'description': ''' - Jewish Holiday: שמחת תורה - Holiday Type: יום טוב + Jewish Holiday: Simchat Torah + Holiday type: Yom Tov ''', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'שמחת תורה', + 'summary': 'Simchat Torah', }), dict({ - 'description': 'Parshat Hashavua: וזאת הברכה', + 'description': 'Parshat Hashavua: Vezot Habracha', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'וזאת הברכה', + 'summary': 'Vezot Habracha', }), dict({ 'description': 'Havdalah time: 18:34', diff --git a/tests/components/jewish_calendar/test_calendar.py b/tests/components/jewish_calendar/test_calendar.py index 035338faf3b8..89e8e2c3e62e 100644 --- a/tests/components/jewish_calendar/test_calendar.py +++ b/tests/components/jewish_calendar/test_calendar.py @@ -30,6 +30,8 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import GetCalendarEvents + from tests.common import MockConfigEntry, snapshot_platform # Entity IDs for the three calendars @@ -113,6 +115,90 @@ async def test_timed_event_format(hass: HomeAssistant, get_calendar_events) -> N ) +@pytest.mark.freeze_time("2024-01-15 12:00:00") +@pytest.mark.parametrize("location_data", ["Jerusalem"], indirect=True) +@pytest.mark.parametrize( + ( + "calendar_events", + "entity_id", + "query_date", + "language", + "summary", + "description", + ), + [ + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "en", + "Shkia", + "Shkia: 16:57", + id="zman-english", + ), + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "fr", + "Coucher du soleil", + "Coucher du soleil : 16:57", + id="zman-french", + ), + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "he", + "שקיעה", + "שקיעה: 16:57", + id="zman-hebrew", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "en", + "Vaera", + "Parshat Hashavua: Vaera", + id="weekly-portion-english", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "fr", + "Va'era", + "Parashat HaShavoua : Va'era", + id="weekly-portion-french", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "he", + "וארא", + "פרשת השבוע: וארא", + id="weekly-portion-hebrew", + ), + ], +) +@pytest.mark.usefixtures("setup") +async def test_events_use_configured_language( + hass: HomeAssistant, + get_calendar_events: GetCalendarEvents, + entity_id: str, + query_date: dt.datetime, + summary: str, + description: str, +) -> None: + """Test event text is rendered in the configured language.""" + events = await get_calendar_events(hass, entity_id, query_date) + assert len(events) == 1 + assert events[0]["summary"] == summary + assert events[0]["description"] == description + + # ─── Daily Events ──────────────────────────────────────────────────── # The daily events calendar produces the Hebrew date and configured # halachic times for each day. Times differ by location and timezone.