Render Jewish Calendar events with the library translations (#181226)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Amit Finkelstein
2026-09-04 09:52:09 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent a933a32d55
commit f6fe0d0aaf
7 changed files with 198 additions and 161 deletions
@@ -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)
@@ -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):
@@ -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?",
@@ -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)
+2 -2
View File
@@ -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(
@@ -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',
@@ -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.