diff --git a/CHANGES.rst b/CHANGES.rst index 9015a3bd..d2d9949b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,7 +14,7 @@ Breaking changes: New features: -- ... +- Added ``Event.end``, ``Event.start``, ``Event.dtstart``, and ``Event.dtend`` attributes. See `Issue 662 `_. Bug fixes: diff --git a/pyproject.toml b/pyproject.toml index 9730e465..c2cae8b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,7 @@ ignore = [ "PERF401", # Use a list comprehension to create a transformed list "ARG002", # Unused method argument: ... "ARG001", # Unused function argument: ... + "UP007", # Optional -> X | None remove when migrated to py39+ ] extend-safe-fixes = [ "PT006", # Wrong type passed to first argument of @pytest.mark.parametrize; expected {expected_string} diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index b2f24e66..d0e5f10e 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -5,6 +5,8 @@ ComponentFactory, Event, FreeBusy, + IncompleteComponent, + InvalidCalendar, Journal, Timezone, TimezoneDaylight, @@ -34,6 +36,7 @@ vFrequency, vGeo, vInt, + vMonth, vPeriod, vRecur, vText, @@ -88,4 +91,7 @@ "version_tuple", "TypesFactory", "Component", + "vMonth", + "IncompleteComponent", + "InvalidCalendar", ] diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 83553f2a..eec6e6d1 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -4,21 +4,18 @@ These are the defined components. """ from __future__ import annotations -from datetime import datetime, timedelta + +import os +from datetime import date, datetime, timedelta +from typing import List, Optional, Tuple + +import dateutil.rrule +import dateutil.tz from icalendar.caselessdict import CaselessDict -from icalendar.parser import Contentline -from icalendar.parser import Contentlines -from icalendar.parser import Parameters -from icalendar.parser import q_join -from icalendar.parser import q_split +from icalendar.parser import Contentline, Contentlines, Parameters, q_join, q_split from icalendar.parser_tools import DEFAULT_ENCODING -from icalendar.prop import TypesFactory -from icalendar.prop import vText, vDDDLists +from icalendar.prop import TypesFactory, vDDDLists, vDDDTypes, vText, vDuration from icalendar.timezone import tzp -from typing import Tuple, List -import dateutil.rrule -import dateutil.tz -import os def get_example(component_directory: str, example_name: str) -> bytes: @@ -67,6 +64,21 @@ def __init__(self, *args, **kwargs): _marker = [] +class InvalidCalendar(ValueError): + """The calendar given is not valid. + + This calendar does not conform with RFC 5545 or breaks other RFCs. + """ + +class IncompleteComponent(ValueError): + """The component is missing attributes. + + The attributes are not required, otherwise this would be + an InvalidCalendar. But in order to perform calculations, + this attribute is required. + """ + + class Component(CaselessDict): """Component is the base object for calendar, Event and the other @@ -485,6 +497,57 @@ def __eq__(self, other): ####################################### # components defined in RFC 5545 +def create_single_property(prop:str, value_attr:str, value_type:tuple[type], type_def:type, doc:str): + """Create a single property getter and setter.""" + + def p_get(self : Component): + default = object() + result = self.get(prop, default) + if result is default: + return None + if isinstance(result, list): + raise InvalidCalendar(f"Multiple {prop} defined.") + value = getattr(result, value_attr, result) + if not isinstance(value, value_type): + raise InvalidCalendar(f"{prop} must be either a date or a datetime, not {value}.") + return value + + def p_set(self:Component, value) -> None: + if value is None: + p_del(self) + return + if not isinstance(value, value_type): + raise TypeError(f"Use {' or '.join(t.__name__ for t in value_type)}, not {type(value).__name__}.") + self[prop] = vDDDTypes(value) + if prop in self.exclusive: + for other_prop in self.exclusive: + if other_prop != prop: + self.pop(other_prop, None) + p_set.__annotations__["value"] = p_get.__annotations__["return"] = Optional[type_def] + + def p_del(self:Component): + self.pop(prop) + + p_doc = f"""The {prop} property. + + {doc} + + Accepted values: {', '.join(t.__name__ for t in value_type)}. + If the attribute has invalid values, we raise InvalidCalendar. + If the value is absent, we return None. + You can also delete the value with del or by setting it to None. + """ + return property(p_get, p_set, p_del, p_doc) + + +def is_date(dt: date) -> bool: + """Whether this is a date and not a datetime.""" + return isinstance(dt, date) and not isinstance(dt, datetime) + +def is_datetime(dt: date) -> bool: + """Whether this is a date and not a datetime.""" + return isinstance(dt, datetime) + class Event(Component): name = 'VEVENT' @@ -510,10 +573,123 @@ class Event(Component): ignore_exceptions = True @classmethod - def example(cls, name) -> Event: + def example(cls, name:str) -> Event: """Return the calendar example with the given name.""" return cls.from_ical(get_example("events", name)) + DTSTART = create_single_property("DTSTART", "dt", (datetime, date), date, 'The "DTSTART" property for a "VEVENT" specifies the inclusive start of the event.') + DTEND = create_single_property("DTEND", "dt", (datetime, date), date, 'The "DTEND" property for a "VEVENT" calendar component specifies the non-inclusive end of the event.') + + def _get_start_end_duration(self): + """Verify the calendar validity and return the right attributes.""" + start = self.DTSTART + end = self.DTEND + duration = self.DURATION + if duration is not None and end is not None: + raise InvalidCalendar("Only one of DTEND and DURATION may be in a VEVENT, not both.") + if isinstance(start, date) and not isinstance(start, datetime) and duration is not None and duration.seconds != 0: + raise InvalidCalendar("When DTSTART is a date, DURATION must be of days or weeks.") + if start is not None and end is not None and is_date(start) != is_date(end): + raise InvalidCalendar("DTSTART and DTEND must be of the same type, either date or datetime.") + return start, end, duration + + @property + def DURATION(self) -> Optional[timedelta]: # noqa: N802 + """The DURATION of the component. + + The "DTSTART" property for a "VEVENT" specifies the inclusive start of the event. + The "DURATION" property in conjunction with the DTSTART property + for a "VEVENT" calendar component specifies the non-inclusive end + of the event. + + If you would like to calculate the duration of an event do not use this. + Instead use the difference between DTSTART and DTEND. + """ + default = object() + duration = self.get("duration", default) + if isinstance(duration, vDDDTypes): + return duration.dt + if isinstance(duration, vDuration): + return duration.td + if duration is not default and not isinstance(duration, timedelta): + raise InvalidCalendar(f"DURATION must be a timedelta, not {type(duration).__name__}.") + return None + + @DURATION.setter + def DURATION(self, value: Optional[timedelta]): # noqa: N802 + if value is None: + self.pop("duration", None) + return + if not isinstance(value, timedelta): + raise TypeError(f"Use timedelta, not {type(value).__name__}.") + self["duration"] = vDuration(value) + del self.DTEND + + @property + def duration(self) -> timedelta: + """The duration of the component. + + This duration is calculated from the start and end of the event. + You cannot set the duration as it is unclear what happens to start and end. + """ + return self.end - self.start + + @property + def start(self) -> date | datetime: + """The start of the component. + + Invalid values raise an InvalidCalendar. + If there is no start, we also raise an IncompleteComponent error. + + You can get the start, end and duration of an event as follows: + + >>> from datetime import datetime + >>> from icalendar import Event + >>> event = Event() + >>> event.start = datetime(2021, 1, 1, 12) + >>> event.end = datetime(2021, 1, 1, 12, 30) # 30 minutes + >>> event.end - event.start # 1800 seconds == 30 minutes + datetime.timedelta(seconds=1800) + >>> print(event.to_ical()) + BEGIN:VEVENT + DTSTART:20210101T120000 + DTEND:20210101T123000 + END:VEVENT + """ + start = self._get_start_end_duration()[0] + if start is None: + raise IncompleteComponent("No DTSTART given.") + return start + + @start.setter + def start(self, start: Optional[date | datetime]): + """Set the start.""" + self.DTSTART = start + + @property + def end(self) -> date | datetime: + """The end of the component. + + Invalid values raise an InvalidCalendar error. + If there is no end, we also raise an IncompleteComponent error. + """ + start, end, duration = self._get_start_end_duration() + if end is None and duration is None: + if start is None: + raise IncompleteComponent("No DTEND or DURATION+DTSTART given.") + if is_date(start): + return start + timedelta(days=1) + return start + if duration is not None: + if start is not None: + return start + duration + raise IncompleteComponent("No DTEND or DURATION+DTSTART given.") + return end + + @end.setter + def end(self, end: date | datetime | None): + """Set the end.""" + self.DTEND = end class Todo(Component): @@ -535,6 +711,19 @@ class Todo(Component): class Journal(Component): + """A descriptive text at a certain time or associated with a component. + + A "VJOURNAL" calendar component is a grouping of + component properties that represent one or more descriptive text + notes associated with a particular calendar date. The "DTSTART" + property is used to specify the calendar date with which the + journal entry is associated. Generally, it will have a DATE value + data type, but it can also be used to specify a DATE-TIME value + data type. Examples of a journal entry include a daily record of + a legislative body or a journal entry of individual telephone + contacts for the day or an ordered list of accomplishments for the + day. + """ name = 'VJOURNAL' @@ -548,6 +737,34 @@ class Journal(Component): 'RELATED', 'RDATE', 'RRULE', 'RSTATUS', 'DESCRIPTION', ) + DTSTART = create_single_property( + "DTSTART", "dt", (datetime, date), date, + 'The "DTSTART" property for a "VJOURNAL" that specifies the exact date at which the journal entry was made.') + + @property + def start(self) -> date: + """The start of the Journal. + + The "DTSTART" + property is used to specify the calendar date with which the + journal entry is associated. + """ + start = self.DTSTART + if start is None: + raise IncompleteComponent("No DTSTART given.") + return start + + @start.setter + def start(self, value: datetime|date) -> None: + """Set the start of the journal.""" + self.DTSTART = value + + end = start + + @property + def duration(self) -> timedelta: + """The journal has no duration.""" + return timedelta(0) class FreeBusy(Component): @@ -568,7 +785,7 @@ class Timezone(Component): singletons = ('TZID', 'LAST-MODIFIED', 'TZURL',) @classmethod - def example(cls, name) -> Calendar: + def example(cls, name: str) -> Calendar: """Return the calendar example with the given name.""" return cls.from_ical(get_example("timezones", name)) @@ -757,7 +974,7 @@ class Calendar(Component): singletons = ('PRODID', 'VERSION', 'CALSCALE', 'METHOD') @classmethod - def example(cls, name) -> Calendar: + def example(cls, name: str) -> Calendar: """Return the calendar example with the given name.""" return cls.from_ical(get_example("calendars", name)) @@ -767,4 +984,5 @@ def example(cls, name) -> Calendar: __all__ = ["Alarm", "Calendar", "Component", "ComponentFactory", "Event", "FreeBusy", "INLINE", "Journal", "Timezone", "TimezoneDaylight", - "TimezoneStandard", "Todo", "component_factory", "get_example"] + "TimezoneStandard", "Todo", "component_factory", "get_example", + "IncompleteComponent", "InvalidCalendar"] diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index e7f58aba..2b1d8b74 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -642,6 +642,7 @@ class vMonth(int): In :rfc:`5545`, this is just an int. In :rfc:`7529`, this can be followed by `L` to indicate a leap month. + >>> from icalendar import vMonth >>> vMonth(1) # first month January vMonth('1') >>> vMonth("5L") # leap month in Hebrew calendar diff --git a/src/icalendar/tests/test_issue_662_component_properties.py b/src/icalendar/tests/test_issue_662_component_properties.py new file mode 100644 index 00000000..4a151e24 --- /dev/null +++ b/src/icalendar/tests/test_issue_662_component_properties.py @@ -0,0 +1,403 @@ +"""This tests the properties of components and their types.""" +from datetime import date, datetime, timedelta + +import pytest + +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo # type: ignore PGH003 + +from icalendar import ( + Event, + IncompleteComponent, + InvalidCalendar, + Journal, + vDDDTypes, +) +from icalendar.prop import vDuration + + +@pytest.fixture() +def event(): + """The event to test.""" + return Event() + +@pytest.fixture(params=[ + datetime(2022, 7, 22, 12, 7), + date(2022, 7, 22), + datetime(2022, 7, 22, 13, 7, tzinfo=ZoneInfo("Europe/Paris")), + ]) +def dtstart(request, set_event_start, event): + """Start of the event.""" + set_event_start(event, request.param) + return request.param + + +def _set_event_start_init(event, start): + """Create the event with the __init__ method.""" + d = dict(event) + d["dtstart"] = vDDDTypes(start) + event.clear() + event.update(Event(d)) + +def _set_event_dtstart(event, start): + """Create the event with the dtstart property.""" + event.DTSTART = start + +def _set_event_start_attr(event, start): + """Create the event with the dtstart property.""" + event.start = start + +def _set_event_start_ics(event, start): + """Create the event with the start property.""" + event.add("dtstart", start) + ics = event.to_ical().decode() + print(ics) + event.clear() + event.update(Event.from_ical(ics)) + +@pytest.fixture(params=[_set_event_start_init, _set_event_start_ics, _set_event_dtstart, _set_event_start_attr]) +def set_event_start(request): + """Create a new event.""" + return request.param + +def test_event_dtstart(dtstart, event): + """Test the start of events.""" + assert event.DTSTART == dtstart + + +def test_event_start(dtstart, event): + """Test the start of events.""" + assert event.start == dtstart + + +invalid_start_event_1 = Event() +invalid_start_event_1.add("dtstart", datetime(2022, 7, 22, 12, 7)) +invalid_start_event_1.add("dtstart", datetime(2022, 7, 22, 12, 8)) +invalid_start_event_2 = Event.from_ical(invalid_start_event_1.to_ical()) +invalid_start_event_3 = Event() +invalid_start_event_3.add("DTSTART", (date(2018, 1, 1), date(2018, 2, 1))) + +@pytest.mark.parametrize("invalid_event", [invalid_start_event_1, invalid_start_event_2, invalid_start_event_3]) +def test_multiple_dtstart(invalid_event): + """Check that we get the right error.""" + with pytest.raises(InvalidCalendar): + invalid_event.start # noqa: B018 + with pytest.raises(InvalidCalendar): + invalid_event.DTSTART # noqa: B018 + +def test_no_dtstart(): + """DTSTART is optional. + + The following is REQUIRED if the component + appears in an iCalendar object that doesn't + specify the "METHOD" property; otherwise, it + is OPTIONAL; in any case, it MUST NOT occur + more than once. + """ + assert Event().DTSTART is None + with pytest.raises(IncompleteComponent): + Event().start # noqa: B018 + + +@pytest.fixture(params=[ + datetime(2022, 7, 22, 12, 8), + date(2022, 7, 23), + datetime(2022, 7, 22, 14, 7, tzinfo=ZoneInfo("Europe/Paris")), + ]) +def dtend(request, set_event_end, event): + """end of the event.""" + set_event_end(event, request.param) + return request.param + + +def _set_event_end_init(event, end): + """Create the event with the __init__ method.""" + d = dict(event) + d["dtend"] = vDDDTypes(end) + event.clear() + event.update(Event(d)) + +def _set_event_dtend(event, end): + """Create the event with the dtend property.""" + event.DTEND = end + +def _set_event_end_attr(event, end): + """Create the event with the dtend property.""" + event.end = end + +def _set_event_end_ics(event, end): + """Create the event with the end property.""" + event.add("dtend", end) + ics = event.to_ical().decode() + print(ics) + event.clear() + event.update(Event.from_ical(ics)) + +@pytest.fixture(params=[_set_event_end_init, _set_event_end_ics, _set_event_dtend, _set_event_end_attr]) +def set_event_end(request): + """Create a new event.""" + return request.param + +def test_event_dtend(dtend, event): + """Test the end of events.""" + assert event.DTEND == dtend # noqa: SIM300 + + +def test_event_end(dtend, event): + """Test the end of events.""" + assert event.end == dtend + + +@pytest.mark.parametrize("attr", ["DTSTART", "DTEND"]) +def test_delete_attr(event, dtstart, dtend, attr): + delattr(event, attr) + assert getattr(event, attr) is None + delattr(event, attr) + + +def _set_duration_vdddtypes(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event["DURATION"] = vDDDTypes(duration) + +def _set_duration_add(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event.add("DURATION", duration) + +def _set_duration_vduration(event:Event, duration:timedelta): + """Set the vDDDTypes value""" + event["DURATION"] = vDuration(duration) + +@pytest.fixture(params=[_set_duration_vdddtypes, _set_duration_add, _set_duration_vduration]) +def duration(event, dtstart, request): + """... events have a DATE value type for the "DTSTART" property ... + If such a "VEVENT" has a "DURATION" + property, it MUST be specified as a "dur-day" or "dur-week" value. + """ + duration = timedelta(hours=1) if isinstance(dtstart, datetime) else timedelta(days=2) + request.param(event, duration) + return duration + +def test_start_and_duration(event, dtstart, duration): + """Check calculation of end with duration.""" + dur = event.end - event.start + assert dur == duration + assert event.duration == duration + +# The "VEVENT" is also the calendar component used to specify an +# anniversary or daily reminder within a calendar. These events +# have a DATE value type for the "DTSTART" property instead of the +# default value type of DATE-TIME. If such a "VEVENT" has a "DTEND" +# property, it MUST be specified as a DATE value also. +invalid_event_end_1 = Event() +invalid_event_end_1.add("DTSTART", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_1.add("DTEND", date(2024, 1, 1)) +invalid_event_end_2 = Event() +invalid_event_end_2.add("DTEND", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_2.add("DTSTART", date(2024, 1, 1)) +invalid_event_end_3 = Event() +invalid_event_end_3.add("DTEND", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_3.add("DTSTART", datetime(2024, 1, 1, 10, 20)) +invalid_event_end_3.add("DURATION", timedelta(days=1)) +invalid_event_end_4 = Event() +invalid_event_end_4.add("DTSTART", date(2024, 1, 1)) +invalid_event_end_4.add("DURATION", timedelta(hours=1)) +@pytest.mark.parametrize( + ("invalid_event", "message"), + [ + (invalid_event_end_1, "DTSTART and DTEND must be of the same type, either date or datetime."), + (invalid_event_end_2, "DTSTART and DTEND must be of the same type, either date or datetime."), + (invalid_event_end_3, "Only one of DTEND and DURATION may be in a VEVENT, not both."), + (invalid_event_end_4, "When DTSTART is a date, DURATION must be of days or weeks."), + ] +) +@pytest.mark.parametrize("attr", ["start", "end"]) +def test_invalid_event(invalid_event, message, attr): + """Test that the end and start throuw the right error.""" + with pytest.raises(InvalidCalendar) as e: + getattr(invalid_event, attr) + assert e.value.args[0] == message + +def test_duration_zero(): + """ + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. + """ + event = Event() + event.start = datetime(2024, 10, 11, 10, 20) + assert event.end == event.start + assert event.duration == timedelta(days=0) + +def test_duration_one_day(): + """ + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day + """ + event = Event() + event.start = date(2024, 10, 11) + assert event.end == event.start + timedelta(days=1) + assert event.duration == timedelta(days=1) + + +incomplete_event_1 = Event() +incomplete_event_2 = Event() +incomplete_event_2.add("DURATION", timedelta(hours=1)) + +@pytest.mark.parametrize("incomplete_event_end", [incomplete_event_1, incomplete_event_2]) +@pytest.mark.parametrize("attr", ["start", "end", "duration"]) +def test_incomplete_event(incomplete_event_end, attr): + """Test that the end throws the right error.""" + with pytest.raises(IncompleteComponent): + getattr(incomplete_event_end, attr) + + +@pytest.mark.parametrize( + "invalid_value", + [ + object(), + timedelta(days=1), + (datetime(2024, 10, 11, 10, 20), timedelta(days=1)), + ] +) +@pytest.mark.parametrize( + ("Component", "attr"), + [ + (Event,"start"), + (Event,"end"), + (Event,"DTSTART"), + (Event,"DTEND"), + (Journal,"start"), + (Journal,"end"), + (Journal,"DTSTART"), + ] +) +def test_set_invalid_start(invalid_value, attr, Component): + """Check that we get the right error. + + - other types that vDDDTypes accepts + - object + """ + event = Component() + with pytest.raises(TypeError) as e: + setattr(event, attr, invalid_value) + assert e.value.args[0] == f"Use datetime or date, not {type(invalid_value).__name__}." + + +def setitem(d:dict, key, value): + d[key] = value + +@pytest.mark.parametrize( + "invalid_value", + [ + object(), + None, + (datetime(2024, 10, 11, 10, 20), timedelta(days=1)), + date(2012, 2, 2), + datetime(2022, 2, 2), + ] +) +def test_check_invalid_duration(invalid_value): + """Check that we get the right error.""" + event = Event() + event["DURATION"] = invalid_value + with pytest.raises(InvalidCalendar) as e: + event.DURATION # noqa: B018 + assert e.value.args[0] == f"DURATION must be a timedelta, not {type(invalid_value).__name__}." + + +def test_setting_the_end_deletes_the_duration(): + """Setting the end should not break the event.""" + event = Event() + event.DTSTART = datetime(2024, 10, 11, 10, 20) + event.DURATION = timedelta(days=1) + event.DTEND = datetime(2024, 10, 11, 10, 21) + assert "DURATION" not in event + assert event.DURATION is None + assert event.DTEND == datetime(2024, 10, 11, 10, 21) + + +def test_setting_duration_deletes_the_end(): + """Setting the duration should not break the event.""" + event = Event() + event.DTSTART = datetime(2024, 10, 11, 10, 20) + event.DTEND = datetime(2024, 10, 11, 10, 21) + event.DURATION = timedelta(days=1) + assert "DTEND" not in event + assert event.DTEND is None + assert event.DURATION == timedelta(days=1) + +valid_values = pytest.mark.parametrize( + ("attr", "value"), + [ + ("DTSTART", datetime(2024, 10, 11, 10, 20)), + ("DTEND", datetime(2024, 10, 11, 10, 20)), + ("DURATION", timedelta(days=1)), + ] +) +@valid_values +def test_setting_to_none_deletes_value(attr, value): + """Setting attributes to None deletes them.""" + event = Event() + setattr(event, attr, value) + assert attr in event + assert getattr(event, attr) == value + setattr(event, attr, None) + assert attr not in event + + +@valid_values +def test_setting_a_value_twice(attr, value): + """Setting attributes twice replaces them.""" + event = Event() + setattr(event, attr, value + timedelta(days=1)) + setattr(event, attr, value) + assert getattr(event, attr) == value + + +@pytest.mark.parametrize("attr", ["DTSTART", "DTEND", "DURATION"]) +def test_invalid_none(attr): + """Special case for None.""" + event = Event() + event[attr] = None + with pytest.raises(InvalidCalendar): + getattr(event, attr) + +@pytest.mark.parametrize("attr", ["DTSTART", "end", "start"]) +@pytest.mark.parametrize("start", [ + datetime(2024, 10, 11, 10, 20), + date(2024, 10, 11), + datetime(2024, 10, 11, 10, 20, tzinfo=ZoneInfo("Europe/Paris")), +]) +def test_journal_start(start, attr): + """Test that we can set the start of a journal.""" + j = Journal() + setattr(j, attr, start) + assert start == j.DTSTART + assert j.start == start + assert j.end == start + assert j.duration == timedelta(0) + +@pytest.mark.parametrize("attr", ["start", "end"]) +def test_delete_journal_start(attr): + """Delete the start of the journal.""" + j = Journal() + j.start = datetime(2010, 11, 12, 13, 14) + j.DTSTART = None + assert j.DTSTART is None + assert "DTSTART" not in j + with pytest.raises(IncompleteComponent): + getattr(j, attr) + +def setting_twice_does_not_duplicate_the_entry(): + j = Journal() + j.DTSTART = date(2024, 1,1 ) + j.DTSTART = date(2024, 1, 3) + assert date(2024, 1, 3) == j.DTSTART + assert j.start == date(2024, 1, 3) + assert j.end == date(2024, 1, 3) + diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index 5912d6f7..8842912f 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -35,7 +35,7 @@ def test_this_module_is_among_them(): assert __name__ in MODULE_NAMES @pytest.mark.parametrize("module_name", MODULE_NAMES) -def test_docstring_of_python_file(module_name): +def test_docstring_of_python_file(module_name, env_for_doctest): """This test runs doctest on the Python module.""" try: module = importlib.import_module(module_name) @@ -43,7 +43,7 @@ def test_docstring_of_python_file(module_name): if e.name == "pytz": pytest.skip("pytz is not installed, skipping this module.") raise - test_result = doctest.testmod(module, name=module_name) + test_result = doctest.testmod(module, name=module_name, globs=env_for_doctest) assert test_result.failed == 0, f"{test_result.failed} errors in {module_name}"