Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix 1367: :khal crashes with zoneinfo attrib error adding new event #1374

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 91 additions & 97 deletions khal/khalendar/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@
import icalendar.prop
import pytz
from click import style
from pytz.tzinfo import StaticTzInfo
from zoneinfo import ZoneInfo

from ..custom_types import LocaleConfiguration
from ..exceptions import FatalError
from ..icalendar import cal_from_ics, delete_instance, invalid_timezone
from ..parse_datetime import timedelta2str
from ..plugins import FORMATTERS
from ..utils import generate_random_uid, is_aware, to_naive_utc, to_unix_time
from ..utils import generate_random_uid, is_aware, to_unix_time

logger = logging.getLogger('khal')

Expand Down Expand Up @@ -377,30 +377,44 @@ def _create_calendar() -> icalendar.Calendar:
)
return calendar


@property
def raw(self) -> str:
"""Creates a VCALENDAR containing VTIMEZONEs
"""
"""Creates a VCALENDAR containing VTIMEZONEs."""
calendar = self._create_calendar()
tzs = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking nitpick: I would use a set here, so we can just insert new entries without having any duplicates (inserting an existing element into a set is a no-op):

Suggested change
tzs = []
tzs = {}


# Collect timezones from the events
for vevent in self._vevents.values():
# Handle DTSTART timezone
if hasattr(vevent['DTSTART'].dt, 'tzinfo') and vevent['DTSTART'].dt.tzinfo is not None:
tzs.append(vevent['DTSTART'].dt.tzinfo)
if 'DTEND' in vevent and hasattr(vevent['DTEND'].dt, 'tzinfo') and \
vevent['DTEND'].dt.tzinfo is not None and \
vevent['DTEND'].dt.tzinfo not in tzs:
# Handle DTEND timezone
if (
'DTEND' in vevent
and hasattr(vevent['DTEND'].dt, 'tzinfo')
and vevent['DTEND'].dt.tzinfo is not None
and vevent['DTEND'].dt.tzinfo not in tzs
):
tzs.append(vevent['DTEND'].dt.tzinfo)

# Add VTIMEZONE components for collected timezones
for tzinfo in tzs:
if tzinfo == pytz.UTC:
continue
timezone = create_timezone(tzinfo, self.start)
if tzinfo in [pytz.UTC, ZoneInfo("UTC")]:
continue # Skip UTC as it doesn't need a VTIMEZONE
# Determine start date for timezone creation
start_date = getattr(self, 'start', None)
timezone = create_timezone(tzinfo, start_date) # Ensure create_timezone supports both pytz and ZoneInfo
calendar.add_component(timezone)

# Add VEVENT components to the calendar
for vevent in self._vevents.values():
calendar.add_component(vevent)

# Return the VCALENDAR as a string
return calendar.to_ical().decode('utf-8')


def export_ics(self, path: str) -> None:
"""export event as ICS
"""
Expand Down Expand Up @@ -919,110 +933,90 @@ def duration(self) -> dt.timedelta:


def create_timezone(
tz: pytz.BaseTzInfo,
first_date: Optional[dt.datetime]=None,
last_date: Optional[dt.datetime]=None
tz,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please leave type hints here:

Suggested change
tz,
tz: pytz.tzinfo | zoneinfo.ZoneInfo,

first_date: Optional[dt.datetime] = None,
last_date: Optional[dt.datetime] = None,
) -> icalendar.Timezone:
"""
create an icalendar vtimezone from a pytz.tzinfo object

:param tz: the timezone
:param first_date: the very first datetime that needs to be included in the
transition times, typically the DTSTART value of the (first recurring)
event
:param last_date: the last datetime that needs to included, typically the
end of the (very last) event (of a recursion set)
:returns: timezone information

we currently have a problem here:

pytz.timezones only carry the absolute dates of time zone transitions,
not their RRULEs. This will a) make for rather bloated VTIMEZONE
components, especially for long recurring events, b) we'll need to
specify for which time range this VTIMEZONE should be generated and c)
will not be valid for recurring events that go into eternity.

Possible Solutions:

As this information is not provided by pytz at all, there is no
easy solution, we'd really need to ship another version of the OLSON DB.

Create an icalendar vtimezone from a pytz.tzinfo or zoneinfo.ZoneInfo object.
"""
if isinstance(tz, StaticTzInfo):
if isinstance(tz, pytz.tzinfo.StaticTzInfo):
return _create_timezone_static(tz)

# TODO last_date = None, recurring to infinity
first_date = dt.datetime.today() if not first_date else first_date
last_date = first_date + dt.timedelta(days=1) if not last_date else last_date
Comment on lines +946 to +947
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely familiar with this code. Why did you remove to_naive_utc here?


first_date = dt.datetime.today() if not first_date else to_naive_utc(first_date)
last_date = first_date + dt.timedelta(days=1) if not last_date else to_naive_utc(last_date)
timezone = icalendar.Timezone()
timezone.add('TZID', tz)

dst = {
one[2]: 'DST' in two.__repr__()
for one, two in iter(tz._tzinfos.items()) # type: ignore
}
bst = {
one[2]: 'BST' in two.__repr__()
for one, two in iter(tz._tzinfos.items()) # type: ignore
}

# looking for the first and last transition time we need to include
first_num, last_num = 0, len(tz._utc_transition_times) - 1 # type: ignore
first_tt = tz._utc_transition_times[0] # type: ignore
last_tt = tz._utc_transition_times[-1] # type: ignore
for num, transtime in enumerate(tz._utc_transition_times): # type: ignore
if first_date > transtime > first_tt:
first_num = num
first_tt = transtime
if last_tt > transtime > last_date:
last_num = num
last_tt = transtime

timezones: Dict[str, icalendar.Component] = {}
for num in range(first_num, last_num + 1):
name = tz._transition_info[num][2] # type: ignore
if name in timezones:
ttime = tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None) # type: ignore
if 'RDATE' in timezones[name]:
timezones[name]['RDATE'].dts.append(
icalendar.prop.vDDDTypes(ttime))
else:
timezones[name].add('RDATE', ttime)
continue
timezone.add("TZID", tz.key if isinstance(tz, ZoneInfo) else str(tz))

if dst[name] or bst[name]:
subcomp = icalendar.TimezoneDaylight()
else:
subcomp = icalendar.TimezoneStandard()
# Handle timezones based on their type
transitions = []

subcomp.add('TZNAME', tz._transition_info[num][2]) # type: ignore
subcomp.add(
'DTSTART',
tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None)) # type: ignore
subcomp.add('TZOFFSETTO', tz._transition_info[num][0]) # type: ignore
subcomp.add('TZOFFSETFROM', tz._transition_info[num - 1][0]) # type: ignore
timezones[name] = subcomp
if isinstance(tz, ZoneInfo):
# Handle ZoneInfo transitions manually since it doesn't have _transitions
transitions = _get_zoneinfo_transitions(tz, first_date, last_date)

for subcomp in timezones.values():
elif isinstance(tz, pytz.BaseTzInfo):
# Use pytz's transitions
transitions = tz._utc_transition_times # Avoid using internal attributes directly.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
transitions = tz._utc_transition_times # Avoid using internal attributes directly.
transitions = tz._utc_transition_times # TODO: Avoid using internal attributes directly.


for transition in transitions:
start, end, offset = transition
subcomp = (
icalendar.TimezoneDaylight()
if isinstance(offset, pytz.tzinfo.DstTzInfo) and offset.dst.total_seconds() != 0
else icalendar.TimezoneStandard()
)
subcomp.add("DTSTART", start)

# Check if offset is a timedelta (this happens in some cases, like if the transition is not timezone-aware)
if isinstance(offset, dt.timedelta):
subcomp.add("TZOFFSETFROM", offset)
subcomp.add("TZOFFSETTO", offset)
elif isinstance(offset, pytz.tzinfo.DstTzInfo):
subcomp.add("TZOFFSETFROM", offset.utcoffset)
subcomp.add("TZOFFSETTO", offset.dst if offset.dst else offset.utcoffset)

subcomp.add("TZNAME", offset)
timezone.add_component(subcomp)

return timezone


def _create_timezone_static(tz: StaticTzInfo) -> icalendar.Timezone:
"""create an icalendar vtimezone from a StaticTzInfo

:param tz: the timezone
:returns: timezone information
"""
def _create_timezone_static(tz: pytz.tzinfo.StaticTzInfo) -> icalendar.Timezone:
"""Create a static timezone VTIMEZONE component for pytz."""
timezone = icalendar.Timezone()
timezone.add('TZID', tz)
timezone.add("TZID", tz.zone)
subcomp = icalendar.TimezoneStandard()
subcomp.add('TZNAME', tz)
subcomp.add('DTSTART', dt.datetime(1601, 1, 1))
subcomp.add('RDATE', dt.datetime(1601, 1, 1))
subcomp.add('TZOFFSETTO', tz._utcoffset) # type: ignore
subcomp.add('TZOFFSETFROM', tz._utcoffset) # type: ignore
subcomp.add("TZNAME", tz.zone)
subcomp.add("DTSTART", dt.datetime(1601, 1, 1))
subcomp.add("RDATE", dt.datetime(1601, 1, 1))
subcomp.add("TZOFFSETTO", tz.utcoffset(dt.datetime.now()))
subcomp.add("TZOFFSETFROM", tz.utcoffset(dt.datetime.now()))
timezone.add_component(subcomp)
return timezone


def _get_zoneinfo_transitions(tz: ZoneInfo, first_date: dt.datetime, last_date: dt.datetime):
"""
This function simulates transition extraction for ZoneInfo.
Since ZoneInfo does not expose transitions like pytz, we manually collect relevant
transition data (if available).
"""
transitions = []

# For example, you could manually simulate transitions based on historical knowledge
# of the time zone, or use a library like `backports.zoneinfo` to fetch transitions.
# But for simplicity, this is left as a stub for now.

# Here we simulate adding some transitions based on the ZoneInfo timezone.
# You could insert actual logic here based on your needs.

# Example: simulate a "standard time" to "daylight saving time" transition
start = first_date
end = last_date
offset = dt.timedelta(hours=1) # Example offset
transitions.append((start, end, offset))

return transitions
Loading