├── tests ├── __init__.py ├── base_classes │ ├── __init__.py │ ├── test_base_class.py │ └── test_component.py ├── help_modules │ └── __init__.py ├── ical_components │ ├── __init__.py │ ├── test_v_event_rrule_with_exdate.py │ ├── test_child_component_mapping.py │ ├── test_recurring_components.py │ ├── test_v_timezone.py │ └── test_property_mapping.py ├── ical_properties │ ├── __init__.py │ ├── test_cal_address.py │ └── dt.py ├── test_client.py ├── test_timeline.py ├── resources │ ├── iCalender-without-anything.ics │ ├── iCalendar-with-berlin-timezone.ics │ ├── iCalender-recurring-date-events.ics │ ├── iCalendar-with-recurring-event-multiple-timezone-offsets.ics │ ├── iCalendar-exdate.ics │ ├── iCalendar-with-all-components-once.ics │ └── iCalendar-with-reoccurring-events.ics └── conftest.py ├── docs ├── timeline.md ├── ical-library.png ├── code │ ├── timeline.md │ ├── cache_client.md │ ├── components │ │ ├── simple_components.md │ │ ├── base_class.md │ │ ├── timezone_components.md │ │ └── recurring_components.md │ ├── exceptions.md │ ├── properties │ │ ├── base_class.md │ │ ├── help_classes.md │ │ └── all_properties.md │ ├── client.md │ ├── calendar.md │ ├── components.md │ └── properties.md ├── faq.md ├── remote-icalendars.md ├── release-notes.md └── index.md ├── src └── ical_library │ ├── help_modules │ ├── __init__.py │ ├── lru_cache.py │ ├── dt_utils.py │ ├── component_context.py │ └── timespan.py │ ├── py.typed │ ├── base_classes │ ├── __init__.py │ ├── base_class.py │ └── property.py │ ├── __init__.py │ ├── ical_components │ ├── __init__.py │ ├── v_alarm.py │ ├── v_free_busy.py │ ├── v_calendar.py │ ├── v_journal.py │ ├── abstract_components.py │ ├── v_todo.py │ ├── v_timezone.py │ └── v_event.py │ ├── ical_properties │ ├── geo.py │ ├── ical_duration.py │ ├── __init__.py │ ├── tz_offset.py │ ├── ints.py │ ├── trigger.py │ ├── cal_address.py │ ├── pass_properties.py │ ├── dt.py │ └── periods.py │ ├── exceptions.py │ ├── client.py │ ├── cache_client.py │ └── timeline.py ├── .flake8 ├── .github └── workflows │ ├── publish-docs.yml │ ├── deploy-pypi.yml │ ├── latest-changes.yml │ └── validate.yml ├── CONTRIBUTING.md ├── LICENSE ├── .pre-commit-config.yaml ├── pyproject.toml ├── mkdocs.yml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/timeline.md: -------------------------------------------------------------------------------- 1 | # TimeLine -------------------------------------------------------------------------------- /tests/base_classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/help_modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ical_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ical_properties/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ical_library/help_modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # @ToDo(jorrick) implement tests. 2 | -------------------------------------------------------------------------------- /tests/test_timeline.py: -------------------------------------------------------------------------------- 1 | # @ToDo(jorrick) implement tests. 2 | -------------------------------------------------------------------------------- /src/ical_library/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. iCal-library uses inline types. 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore=E203,W503 3 | max-line-length=120 4 | exclude = __init__.py 5 | -------------------------------------------------------------------------------- /docs/ical-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jorricks/iCal-library/HEAD/docs/ical-library.png -------------------------------------------------------------------------------- /src/ical_library/base_classes/__init__.py: -------------------------------------------------------------------------------- 1 | from ical_library.base_classes.base_class import ICalBaseClass 2 | from ical_library.base_classes.component import Component 3 | from ical_library.base_classes.property import Property 4 | -------------------------------------------------------------------------------- /src/ical_library/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fast, yet simple, iCalendar reader with excellent recurrence support. 3 | """ 4 | __version__ = "0.2.3" 5 | 6 | from ical_library.cache_client import CacheClient 7 | from ical_library.exceptions import * 8 | -------------------------------------------------------------------------------- /docs/code/timeline.md: -------------------------------------------------------------------------------- 1 | # Documentation for the Timeline 2 | 3 | From the `VCalendar` it is very easy to create a Timeline object. This will show you all the components you have in a specific time frame. 4 | 5 | ::: ical_library.timeline.Timeline 6 | -------------------------------------------------------------------------------- /docs/code/cache_client.md: -------------------------------------------------------------------------------- 1 | # The client for remote calendars with builtin cache support 2 | 3 | When you have a service that periodically runs, and you don't want to fetch a new version of your calendar each time, this is the go-to place. 4 | 5 | ::: ical_library.CacheClient 6 | -------------------------------------------------------------------------------- /docs/code/components/simple_components.md: -------------------------------------------------------------------------------- 1 | # Simple components 2 | 3 | We start out with the components that are relatively straight forward. There is no recurring option here. 4 | 5 | ::: ical_library.ical_components.VFreeBusy 6 | 7 | ::: ical_library.ical_components.VAlarm 8 | -------------------------------------------------------------------------------- /docs/code/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | 3 | These are all the custom exceptions you might encounter when using `iCal-library`. 4 | 5 | ::: ical_library.CalendarParentRelationError 6 | 7 | ::: ical_library.VEventExpansionFailed 8 | 9 | ::: ical_library.MissingRequiredProperty -------------------------------------------------------------------------------- /docs/code/properties/base_class.md: -------------------------------------------------------------------------------- 1 | # Property base class 2 | 3 | All Properties are extending the [Property](ical_library.base_classes.Property) class. Let's first start with the base class and then list all the other properties. 4 | 5 | ::: ical_library.base_classes.Property 6 | -------------------------------------------------------------------------------- /docs/code/client.md: -------------------------------------------------------------------------------- 1 | # The client 2 | 3 | When you are looking to integrate iCal-library with your project, the `client` is the first place to look. 4 | If you have a service that periodically restarts, you might want to take a look at the `CacheClient`. 5 | 6 | ::: ical_library.client 7 | -------------------------------------------------------------------------------- /tests/resources/iCalender-without-anything.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | END:VCALENDAR 10 | -------------------------------------------------------------------------------- /docs/code/calendar.md: -------------------------------------------------------------------------------- 1 | # Documentation for your first returned item: VCalendar 2 | 3 | Once you imported the iCal-library library, you can use either the `client` or `CacheCLient` to get a `VCalendar` object. 4 | This page explains all the functions and attributes of the `VCalendar` object. 5 | 6 | ::: ical_library.ical_components.VCalendar 7 | -------------------------------------------------------------------------------- /tests/ical_properties/test_cal_address.py: -------------------------------------------------------------------------------- 1 | from ical_library.ical_properties.cal_address import _CalAddress 2 | 3 | 4 | def test_cal_address(): 5 | ca = _CalAddress(name="ORGANIZER", property_parameters="CN=John Smith", value="mailto:jsmith@example.com") 6 | assert ca.email == "jsmith@example.com" 7 | assert ca.persons_name == "John Smith" 8 | -------------------------------------------------------------------------------- /tests/ical_properties/dt.py: -------------------------------------------------------------------------------- 1 | import pendulum 2 | from pendulum import DateTime 3 | 4 | from ical_library.ical_properties.dt import DTStart 5 | 6 | 7 | def test_dt_both(berlin_timezone_calendar): 8 | with berlin_timezone_calendar: 9 | a = DTStart(name="DTSTART", property_parameters="TZID=Europe/Berlin", value="20190307T020000") 10 | assert a.datetime_or_date_value == DateTime(2019, 3, 7, 2, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")) 11 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.8' 14 | - name: Install Flit 15 | run: pip install flit 16 | - name: Install Dependencies 17 | run: flit install --symlink --extras docs 18 | - run: mkdocs gh-deploy --force 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to iCal-library 2 | 3 | As an open source project, iCal-library welcomes contributions of many forms. 4 | This document summarizes the process. 5 | 6 | Examples of contributions include: 7 | 8 | * Code patches 9 | * Documentation improvements 10 | * Bug reports 11 | * Pull request reviews 12 | 13 | Contributions are managed using GitHub's Pull Requests. 14 | For a PR to be accepted: 15 | 16 | * All automated checks must pass 17 | * The changeset must have a reasonable patch test coverage 18 | -------------------------------------------------------------------------------- /docs/code/components/base_class.md: -------------------------------------------------------------------------------- 1 | # The base classes 2 | 3 | All iCalendar Component classes and iCalendar Property classes inherit from `ICalBaseClass`. 4 | Then [ICalBaseClass](ical_library.base_classes.ICalBaseClass) is extended by both [Component](ical_library.base_classes.Component) and [Property](ical_library.base_classes.Property). 5 | 6 | ::: ical_library.base_classes.ICalBaseClass 7 | 8 | 9 | ::: ical_library.base_classes.Component 10 | 11 | 12 | ::: ical_library.help_modules.component_context.ComponentContext 13 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from ical_library.ical_components.v_alarm import VAlarm 3 | from ical_library.ical_components.v_calendar import VCalendar 4 | from ical_library.ical_components.v_event import VEvent, VRecurringEvent 5 | from ical_library.ical_components.v_free_busy import VFreeBusy 6 | from ical_library.ical_components.v_journal import VJournal, VRecurringJournal 7 | from ical_library.ical_components.v_timezone import DayLight, Standard, VTimeZone 8 | from ical_library.ical_components.v_todo import VRecurringToDo, VToDo 9 | -------------------------------------------------------------------------------- /tests/resources/iCalendar-with-berlin-timezone.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | END:VCALENDAR 22 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/geo.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from ical_library.base_classes.property import Property 4 | 5 | 6 | class GEO(Property): 7 | """ 8 | The GEO property specifies information related to the global position for the activity specified by a calendar 9 | component. 10 | """ 11 | 12 | @property 13 | def geo_value(self) -> Tuple[float, float]: 14 | """Return the value as two floats representing the latitude and longitude.""" 15 | latitude, longitude = self.value.split(";") 16 | return float(latitude), float(longitude) 17 | -------------------------------------------------------------------------------- /docs/code/components.md: -------------------------------------------------------------------------------- 1 | # All Components 2 | 3 | If you haven't already, first check out [Component](ical_library.base_classes.Component) over at [base classes](The base classes). 4 | These are part of the components you can encounter in a iCalendar data file. The others are mentioned in [Recurring components](Recurring components) and [Timezone components](/iCal-library/code/timezone_components/). 5 | 6 | ## Simple components 7 | We start out with the components that are relatively straight forward. There is no recurring option here. 8 | 9 | ::: ical_library.ical_components.VFreeBusy 10 | 11 | ::: ical_library.ical_components.VAlarm 12 | -------------------------------------------------------------------------------- /docs/code/components/timezone_components.md: -------------------------------------------------------------------------------- 1 | # Timezone components 2 | These are the components that are related to TimeZone information. You generally don't use these directly but let iCal-library use it when there is a TZID parameter set for a property. 3 | 4 | The [DayLight](ical_library.ical_components.DayLight) and [Standard](ical_library.ical_components.Standard) class both extend the [_TimeOffsetPeriod](ical_library.ical_components.v_timezone._TimeOffsetPeriod). 5 | 6 | ::: ical_library.ical_components.VTimeZone 7 | 8 | ::: ical_library.ical_components.v_timezone._TimeOffsetPeriod 9 | 10 | ::: ical_library.ical_components.DayLight 11 | 12 | ::: ical_library.ical_components.Standard 13 | -------------------------------------------------------------------------------- /docs/code/properties/help_classes.md: -------------------------------------------------------------------------------- 1 | # Help classes 2 | 3 | Sometimes properties have a lot in common. Instead of constantly redefining all methods, we simply create an abstraction from the properties which we call help classes. 4 | 5 | This is the list of all help classes. 6 | 7 | ::: ical_library.ical_properties.ints._IntProperty 8 | 9 | ::: ical_library.ical_properties.dt._DTBoth 10 | 11 | ::: ical_library.ical_properties.dt._DTSingular 12 | 13 | ::: ical_library.ical_properties.cal_address._CalAddress 14 | 15 | ::: ical_library.ical_properties.periods._PeriodFunctionality 16 | 17 | ::: ical_library.ical_properties.periods._ExOrRDate 18 | 19 | ::: ical_library.ical_properties.tz_offset._TZOffset 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | build-n-publish: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Check out source-code repository 12 | uses: actions/checkout@v3 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.8" 18 | 19 | - name: Install Flit 20 | run: pip install flit 21 | - name: Install Dependencies 22 | run: flit install --symlink 23 | - name: Publish 24 | run: flit publish 25 | env: 26 | FLIT_USERNAME: __token__ 27 | FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/latest-changes.yml: -------------------------------------------------------------------------------- 1 | name: Latest Changes 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - main 7 | types: 8 | - closed 9 | # For manually triggering it 10 | workflow_dispatch: 11 | inputs: 12 | number: 13 | description: PR number 14 | required: true 15 | 16 | jobs: 17 | latest-changes: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out source-code repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Update docs/release-notes.md with newest PR that was merged. 24 | uses: docker://tiangolo/latest-changes:0.0.3 25 | with: 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | latest_changes_file: docs/release-notes.md 28 | latest_changes_header: '## Latest Changes\n\n' 29 | debug_logs: true 30 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/ical_duration.py: -------------------------------------------------------------------------------- 1 | import pendulum 2 | 3 | from ical_library.base_classes.property import Property 4 | 5 | 6 | class ICALDuration(Property): 7 | """The DURATION property specifies a positive duration of time.""" 8 | 9 | @property 10 | def duration(self) -> pendulum.Duration: 11 | """Return the value as a parsed pendulum.Duration. Example value: PT1H0M0S.""" 12 | parsed_value: pendulum.Duration = pendulum.parse(self.value) 13 | if not isinstance(parsed_value, pendulum.Duration): 14 | raise TypeError(f"Invalid value passed for Duration: {self.value=}") 15 | return parsed_value 16 | 17 | @classmethod 18 | def get_ical_name_of_class(cls) -> str: 19 | """Overwrite the iCal name of this class as it is not *ICALDURATION* but *DURATION*.""" 20 | return "DURATION" 21 | -------------------------------------------------------------------------------- /tests/ical_components/test_v_event_rrule_with_exdate.py: -------------------------------------------------------------------------------- 1 | import pendulum 2 | from pendulum import DateTime 3 | 4 | from ical_library.help_modules.timespan import Timespan 5 | from ical_library.ical_components import VCalendar 6 | from ical_library.timeline import Timeline 7 | 8 | 9 | def test_vevent_with_exdate(calendar_exdate: VCalendar): 10 | timeline: Timeline = calendar_exdate.get_limited_timeline( 11 | DateTime(2022, 12, 27).in_tz("Europe/Amsterdam"), DateTime(2022, 12, 31).in_tz("Europe/Amsterdam") 12 | ) 13 | all_events = list(timeline.iterate()) 14 | assert len(all_events) == 2 15 | assert all_events[0][0] == Timespan( 16 | pendulum.parse("2022-12-27T11:15:00+01:00"), pendulum.parse("2022-12-27T11:45:00+01:00") 17 | ) 18 | assert all_events[1][0] == Timespan( 19 | pendulum.parse("2022-12-29T11:15:00+01:00"), pendulum.parse("2022-12-29T11:45:00+01:00") 20 | ) 21 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | ???+ "Why did you create this library and not use one of the existing libraries?" 3 | 4 | I first tried several libraries for iCalendar events. However, none of them supported recurring events as well as they should be. For some libraries my calendar loaded but then didn't show my recurring events, while others simply threw stacktraces trying to load it. Furthermore, I noticed that my calendar (with over 2000 events) took ages to load. 5 | After traversing the code of the other libraries I decided I wanted to build my own. With some key principles that were lacking in most of the libraries: 6 | 7 | - Recurring components should work, always, FOREVER. 8 | - No strict evaluation that could lead to errors while parsing the file. 9 | - Lazy evaluation for iCalendar properties to speed up the process. 10 | - Perfect typing information. 11 | - Striving for no open issues & especially no open pull requests that are waiting for feedback! 12 | 13 | -------------------------------------------------------------------------------- /src/ical_library/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from ical_library.base_classes.component import Component 5 | 6 | 7 | class CalendarParentRelationError(ValueError): 8 | """Indicate finding the tree root failed as it did not find a VCalendar root.""" 9 | 10 | pass 11 | 12 | 13 | class VEventExpansionFailed(ValueError): 14 | """Indicate the expansion based on recurring properties failed.""" 15 | 16 | pass 17 | 18 | 19 | class MissingRequiredProperty(ValueError): 20 | """Indicate a required property is not set for a Component.""" 21 | 22 | def __init__(self, component: "Component", missing_property_name: str): 23 | self.component = component 24 | self.missing_property_name = missing_property_name 25 | 26 | def __repr__(self) -> str: 27 | """Overwrite the repr to create a better representation for the item.""" 28 | return ( 29 | f"The required property named {self.missing_property_name} was not set for " 30 | f"{self.component.__class__.__name__}" 31 | ) 32 | -------------------------------------------------------------------------------- /tests/base_classes/test_base_class.py: -------------------------------------------------------------------------------- 1 | from ical_library.base_classes.base_class import ICalBaseClass 2 | from ical_library.base_classes.component import Component 3 | from ical_library.ical_components import VCalendar 4 | from ical_library.ical_properties.dt import RecurrenceID 5 | 6 | 7 | def test_name(calendar_instance): 8 | assert ICalBaseClass(name="ABC", parent=calendar_instance).name == "ABC" 9 | 10 | 11 | def test_parent(calendar_instance): 12 | another_component = Component(name="ANOTHER-COMPONENT", parent=calendar_instance) 13 | assert ICalBaseClass(name="ABC", parent=another_component).parent == another_component 14 | 15 | 16 | def test_get_ical_name_of_class(): 17 | class SomeRandomClass(ICalBaseClass): 18 | pass 19 | 20 | assert SomeRandomClass.get_ical_name_of_class() == "SOMERANDOMCLASS" 21 | 22 | class SomeOtherClass(ICalBaseClass): 23 | pass 24 | 25 | assert SomeOtherClass.get_ical_name_of_class() == "SOMEOTHERCLASS" 26 | assert VCalendar.get_ical_name_of_class() == "VCALENDAR" 27 | assert RecurrenceID.get_ical_name_of_class() == "RECURRENCE-ID" 28 | -------------------------------------------------------------------------------- /tests/resources/iCalender-recurring-date-events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | BEGIN:VEVENT 10 | DTSTART;VALUE=DATE:20230308 11 | DTEND;VALUE=DATE:20230315 12 | RRULE:FREQ=WEEKLY;WKST=MO;COUNT=4;INTERVAL=12;BYDAY=WE 13 | DTSTAMP:20230208T071445Z 14 | UID:abc@google.com 15 | ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=abc@gmail.com;X-NUM-GUESTS=0:mailto:abc@gmail.com 16 | CREATED:20220223T123118Z 17 | DESCRIPTION: 18 | LAST-MODIFIED:20230118T224003Z 19 | LOCATION: 20 | SEQUENCE:3 21 | STATUS:CONFIRMED 22 | SUMMARY:Data Duty 24/7: @abc 23 | TRANSP:TRANSPARENT 24 | X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC 25 | BEGIN:VALARM 26 | ACTION:AUDIO 27 | TRIGGER:-PT15H 28 | X-WR-ALARMUID:9C6E4D2C-0153-4CE9-8121-FCB2933FF375 29 | UID:9C6E4D2C-0153-4CE9-8121-FCB2933FF375 30 | ATTACH;VALUE=URI:Chord 31 | X-APPLE-DEFAULT-ALARM:TRUE 32 | ACKNOWLEDGED:20221011T075815Z 33 | END:VALARM 34 | END:VEVENT 35 | END:VCALENDAR -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jorrick Sleijster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/resources/iCalendar-with-recurring-event-multiple-timezone-offsets.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | BEGIN:VTIMEZONE 10 | TZID:Europe/Amsterdam 11 | X-LIC-LOCATION:Europe/Amsterdam 12 | BEGIN:DAYLIGHT 13 | TZOFFSETFROM:+0100 14 | TZOFFSETTO:+0200 15 | TZNAME:CEST 16 | DTSTART:19700329T020000 17 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 18 | END:DAYLIGHT 19 | BEGIN:STANDARD 20 | TZOFFSETFROM:+0200 21 | TZOFFSETTO:+0100 22 | TZNAME:CET 23 | DTSTART:19701025T030000 24 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 25 | END:STANDARD 26 | END:VTIMEZONE 27 | BEGIN:VEVENT 28 | DTSTART;TZID=Europe/Amsterdam:20221018T120000 29 | DTEND;TZID=Europe/Amsterdam:20221018T170000 30 | RRULE:FREQ=WEEKLY;BYDAY=TU 31 | DTSTAMP:20221230T184331Z 32 | UID:29bs10j321234rlaggdj41966n@google.com 33 | CREATED:20221020T074107Z 34 | DESCRIPTION: 35 | LAST-MODIFIED:20221023T072955Z 36 | LOCATION: 37 | SEQUENCE:1 38 | STATUS:CONFIRMED 39 | SUMMARY:No meetings please <3? 40 | TRANSP:OPAQUE 41 | END:VEVENT 42 | END:VCALENDAR 43 | -------------------------------------------------------------------------------- /docs/code/components/recurring_components.md: -------------------------------------------------------------------------------- 1 | # Recurring components 2 | 3 | These are the components that have the possibility to be recurring. For each of them there is their standard class and their recurring class. The idea is that the original definition is represented by the standard class and any occurrence that is generated based on the recurring properties, is represented by the recurring class. 4 | 5 | Given all classes that have recurring options have a major overlap, there are two class abstracting the complexity away from them. This is again in the same concept, where the first one covers the standard class, and the other one covers the recurring class. These two are listed here first before the rest. 6 | 7 | ::: ical_library.ical_components.abstract_components.AbstractComponentWithRecurringProperties 8 | 9 | ::: ical_library.ical_components.abstract_components.AbstractRecurrence 10 | 11 | 12 | ::: ical_library.ical_components.VEvent 13 | 14 | ::: ical_library.ical_components.VRecurringEvent 15 | 16 | ::: ical_library.ical_components.VToDo 17 | 18 | ::: ical_library.ical_components.VRecurringToDo 19 | 20 | ::: ical_library.ical_components.VJournal 21 | 22 | ::: ical_library.ical_components.VRecurringJournal 23 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from ical_library.ical_properties.cal_address import Attendee, Organizer 3 | from ical_library.ical_properties.dt import Completed, Created, DTEnd, DTStamp, DTStart, Due, LastModified, RecurrenceID 4 | from ical_library.ical_properties.geo import GEO 5 | from ical_library.ical_properties.ical_duration import ICALDuration 6 | from ical_library.ical_properties.ints import PercentComplete, Priority, Repeat, Sequence 7 | from ical_library.ical_properties.pass_properties import ( 8 | Action, 9 | Attach, 10 | CalScale, 11 | Categories, 12 | Class, 13 | Comment, 14 | Contact, 15 | Description, 16 | Location, 17 | Method, 18 | ProdID, 19 | RelatedTo, 20 | RequestStatus, 21 | Resources, 22 | Status, 23 | Summary, 24 | TimeTransparency, 25 | TZID, 26 | TZName, 27 | TZURL, 28 | UID, 29 | URL, 30 | Version, 31 | ) 32 | from ical_library.ical_properties.periods import EXDate, FreeBusyProperty, RDate 33 | from ical_library.ical_properties.rrule import RRule 34 | from ical_library.ical_properties.trigger import Trigger 35 | from ical_library.ical_properties.tz_offset import TZOffsetFrom, TZOffsetTo 36 | -------------------------------------------------------------------------------- /tests/ical_components/test_child_component_mapping.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ical_library.ical_components import ( 4 | DayLight, 5 | Standard, 6 | VAlarm, 7 | VCalendar, 8 | VEvent, 9 | VFreeBusy, 10 | VJournal, 11 | VTimeZone, 12 | VToDo, 13 | ) 14 | 15 | 16 | def test_get_child_component_mapping_with_children(): 17 | assert VCalendar._get_child_component_mapping() == { 18 | "VEVENT": ("events", VEvent, True), 19 | "VFREEBUSY": ("free_busy_list", VFreeBusy, True), 20 | "VJOURNAL": ("journals", VJournal, True), 21 | "VTIMEZONE": ("time_zones", VTimeZone, True), 22 | "VTODO": ("todos", VToDo, True), 23 | } 24 | 25 | assert VTimeZone._get_child_component_mapping() == { 26 | "DAYLIGHT": ("daylightc", DayLight, True), 27 | "STANDARD": ("standardc", Standard, True), 28 | } 29 | 30 | assert VEvent._get_child_component_mapping() == { 31 | "VALARM": ("alarms", VAlarm, True), 32 | } 33 | 34 | assert VToDo._get_child_component_mapping() == { 35 | "VALARM": ("alarms", VAlarm, True), 36 | } 37 | 38 | 39 | @pytest.mark.parametrize("a_type", [VFreeBusy, VJournal, DayLight, Standard, VAlarm]) 40 | def test_get_child_component_mapping_without_children(a_type): 41 | assert a_type._get_child_component_mapping() == {} 42 | -------------------------------------------------------------------------------- /tests/resources/iCalendar-exdate.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | BEGIN:VTIMEZONE 10 | TZID:Europe/Amsterdam 11 | X-LIC-LOCATION:Europe/Amsterdam 12 | BEGIN:DAYLIGHT 13 | TZOFFSETFROM:+0100 14 | TZOFFSETTO:+0200 15 | TZNAME:CEST 16 | DTSTART:19700329T020000 17 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 18 | END:DAYLIGHT 19 | BEGIN:STANDARD 20 | TZOFFSETFROM:+0200 21 | TZOFFSETTO:+0100 22 | TZNAME:CET 23 | DTSTART:19701025T030000 24 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 25 | END:STANDARD 26 | END:VTIMEZONE 27 | BEGIN:VEVENT 28 | DTSTART;TZID=Europe/Amsterdam:20221118T111500 29 | DTEND;TZID=Europe/Amsterdam:20221118T114500 30 | RRULE:FREQ=WEEKLY;UNTIL=20230101T225959Z;BYDAY=FR,MO,TH,TU,WE 31 | EXDATE;TZID=Europe/Amsterdam:20221230T111500 32 | EXDATE;TZID=Europe/Amsterdam:20221228T111500 33 | EXDATE;TZID=Europe/Amsterdam:20221215T111500 34 | EXDATE;TZID=Europe/Amsterdam:20221214T111500 35 | DTSTAMP:20221230T184331Z 36 | UID:abcdef9pc89574f0m8a2j7795s5_R12345@google.com 37 | CREATED:20220803T220529Z 38 | DESCRIPTION:The usual.. 39 | SEQUENCE:0 40 | STATUS:CONFIRMED 41 | SUMMARY:DPD Standup 42 | TRANSP:OPAQUE 43 | END:VEVENT 44 | END:VCALENDAR 45 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/tz_offset.py: -------------------------------------------------------------------------------- 1 | from pendulum.tz.timezone import FixedTimezone 2 | 3 | from ical_library.base_classes.property import Property 4 | 5 | 6 | class _TZOffset(Property): 7 | """ 8 | Helper class to represent a UTC offset. This class should be inherited. 9 | 10 | Add functions to parse the value as a fixed timezone offset. 11 | """ 12 | 13 | def parse_value_as_seconds(self) -> int: 14 | """Parse the value as seconds difference from UTC.""" 15 | plus_or_minus = self.value[0] 16 | hour = int(self.value[1:3]) 17 | minute = int(self.value[3:5]) 18 | seconds = int(self.value[5:7]) if len(self.value) > 6 else 0 19 | summed = seconds + 60 * (minute + 60 * hour) 20 | return summed if plus_or_minus == "+" else 0 - summed 21 | 22 | def as_timezone_object(self) -> FixedTimezone: 23 | """Return the value of the property as a fixed timezone offset.""" 24 | return FixedTimezone(self.parse_value_as_seconds()) 25 | 26 | 27 | class TZOffsetTo(_TZOffset): 28 | """The TZOFFSETTO property specifies the offset that is in use prior to this time zone observance.""" 29 | 30 | pass 31 | 32 | 33 | class TZOffsetFrom(_TZOffset): 34 | """The TZOFFSETFROM property specifies the offset that is in use prior to this time zone observance.""" 35 | 36 | pass 37 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.8' 19 | - uses: pre-commit/action@v3.0.0 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | python-version: ["3.8", "3.9", "3.10"] 26 | fail-fast: false 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - uses: actions/cache@v2 35 | id: cache 36 | with: 37 | path: ${{ env.pythonLocation }} 38 | key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-test-v02 39 | - name: Install Flit 40 | if: steps.cache.outputs.cache-hit != 'true' 41 | run: pip install flit 42 | - name: Install Dependencies 43 | if: steps.cache.outputs.cache-hit != 'true' 44 | run: flit install --symlink --deps all 45 | - name: Test 46 | run: pytest --cov=src --cov-report=term-missing:skip-covered --cov-report=xml tests ${@} 47 | - name: Upload coverage 48 | uses: codecov/codecov-action@v2 49 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/ints.py: -------------------------------------------------------------------------------- 1 | from ical_library.base_classes.property import Property 2 | 3 | 4 | class _IntProperty(Property): 5 | """This property class should be inherited. It represents a property that contain just an int as value.""" 6 | 7 | @property 8 | def int_value(self) -> int: 9 | """Return the value as an int.""" 10 | return int(self.value) 11 | 12 | 13 | class Priority(_IntProperty): 14 | """The PRIORITY property represents the relative priority for a calendar component.""" 15 | 16 | pass 17 | 18 | 19 | class Sequence(_IntProperty): 20 | """ 21 | The SEQUENCE property defines the revision sequence number of the calendar component within a sequence of revisions. 22 | """ 23 | 24 | pass 25 | 26 | 27 | class Repeat(_IntProperty): 28 | """The REPEAT property defines the number of times the alarm should be repeated, after the initial trigger.""" 29 | 30 | pass 31 | 32 | 33 | class PercentComplete(_IntProperty): 34 | """ 35 | The PERCENT-COMPLETE property is used by an assignee or delegatee of a to-do to convey the percent completion of 36 | a to-do to the "Organizer". 37 | """ 38 | 39 | @property 40 | def percentage(self) -> int: 41 | return self.int_value 42 | 43 | @classmethod 44 | def get_ical_name_of_class(cls) -> str: 45 | """Overwrite the iCal name of this class as it is not *PERCENTCOMPLETE* but *PERCENT-COMPLETE*.""" 46 | return "PERCENT-COMPLETE" 47 | -------------------------------------------------------------------------------- /src/ical_library/help_modules/lru_cache.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import weakref 3 | 4 | 5 | def instance_lru_cache(*lru_args, **lru_kwargs): 6 | """ 7 | Add a lru_cache on an instance function without causing a memory leak. 8 | At the moment you add a standard lru_cache on an instance function (e.g. `def abc(self):`) it counts a reference for 9 | the python reference counter. This means that when the instance is deleted in the users code, the instance will not 10 | be collected by the garbage collector because the lru_cache keeps the reference counter for the instance at a 11 | positive value. 12 | This implementation prevents this by ensuring the reference to the self attribute is a weak reference. Thereby it is 13 | not counted in the reference counter and thus can be freely cleared by the garbage collector. 14 | Inspiration from: https://stackoverflow.com/a/33672499/2277445 15 | """ 16 | 17 | def decorator(func): 18 | @functools.wraps(func) 19 | def wrapped_func(self, *args, **kwargs): 20 | # We're storing the wrapped method inside the instance. If we had 21 | # a strong reference to self the instance would never die. 22 | self_weak = weakref.ref(self) 23 | 24 | @functools.wraps(func) 25 | @functools.lru_cache(*lru_args, **lru_kwargs) 26 | def cached_method(*args, **kwargs): 27 | return func(self_weak(), *args, **kwargs) 28 | 29 | return cached_method(*args, **kwargs) 30 | 31 | return wrapped_func 32 | 33 | return decorator 34 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/trigger.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | import pendulum 4 | from pendulum import DateTime, Duration 5 | 6 | from ical_library.base_classes.property import Property 7 | 8 | 9 | class Trigger(Property): 10 | """The TRIGGER property specifies when an alarm will trigger.""" 11 | 12 | @property 13 | def kind(self) -> Literal["DATE-TIME", "DURATION"]: 14 | """Return the type of the property value.""" 15 | kind_of_value = self.get_property_parameter("VALUE") 16 | return "DATE-TIME" if kind_of_value and kind_of_value == "DATE-TIME" else "DURATION" # noqa 17 | 18 | def parse_value(self) -> Union[Duration, DateTime]: 19 | """Parse the value of this property based on the VALUE property parameter.""" 20 | if self.kind == "DURATION": 21 | parsed_value: Duration = pendulum.parse(self.value) 22 | if not isinstance(parsed_value, Duration): 23 | raise TypeError(f"Invalid value passed for Duration: {self.value=}") 24 | return parsed_value 25 | else: 26 | parsed_value: DateTime = pendulum.parse(self.value) 27 | if not isinstance(parsed_value, DateTime): 28 | raise TypeError(f"Invalid value passed for DateTime: {self.value=}") 29 | return parsed_value 30 | 31 | def trigger_relation(self) -> Literal["START", "END"]: 32 | """Get the trigger relation, whether the duration should be relative to the start or the end of a component.""" 33 | return "START" if self.get_property_parameter_default("RELATED", "START") == "START" else "END" # noqa 34 | -------------------------------------------------------------------------------- /src/ical_library/help_modules/dt_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | import pendulum 4 | from pendulum import Date, DateTime 5 | from pendulum.tz import get_local_timezone 6 | from pendulum.tz.timezone import Timezone 7 | 8 | 9 | def parse_date_or_datetime(value: str) -> Union[Date, DateTime]: 10 | """Parse a string into a pendulum.Date or pendulum.Datetime.""" 11 | if len(value) == 8: 12 | return pendulum.Date(int(value[0:4]), int(value[4:6]), int(value[6:8])) 13 | return pendulum.parse(value, tz=None) 14 | 15 | 16 | def convert_time_object_to_datetime(time_value: Union[Date, DateTime]) -> DateTime: 17 | """Convert the argument to a pendulum.DateTime object whether it's a pendulum.Date or a pendulum.DateTime.""" 18 | if isinstance(time_value, DateTime): 19 | return time_value 20 | return DateTime(time_value.year, time_value.month, time_value.day) 21 | 22 | 23 | def make_datetime_aware(dt: DateTime, tz: Optional[Union[str, Timezone]] = None): 24 | """ 25 | Make a pendulum.DateTime timezone aware. 26 | 27 | When it already has a timezone, it doesn't change the timezone. If it doesn't, it will add the timezone. 28 | """ 29 | if dt.tz is not None: 30 | return dt 31 | timezone: Timezone = (tz if isinstance(tz, Timezone) else pendulum.timezone(tz)) if tz else get_local_timezone() 32 | return dt.in_timezone(timezone) 33 | 34 | 35 | def convert_time_object_to_aware_datetime( 36 | time_value: Union[Date, DateTime], tz: Optional[Union[str, Timezone]] = None 37 | ) -> DateTime: 38 | """Convert a time pendulum.Date or pendulum.DateTime to a timezone aware pendulum.DateTime.""" 39 | dt: DateTime = convert_time_object_to_datetime(time_value) 40 | return make_datetime_aware(dt, tz) 41 | -------------------------------------------------------------------------------- /src/ical_library/help_modules/component_context.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from ical_library import Component 5 | 6 | 7 | class ComponentContext: 8 | """ 9 | Component context is used to keep the current Component when Component is used as ContextManager. 10 | 11 | You can use components as context: `#!py3 with Component() as my_component:`. 12 | 13 | If you do this the context stores the Component and whenever a new component or property is created, it will use 14 | such stored Component as the parent Component. 15 | """ 16 | 17 | _context_managed_component: Optional["Component"] = None 18 | _previous_context_managed_components: List["Component"] = [] 19 | 20 | @classmethod 21 | def push_context_managed_component(cls, component: "Component"): 22 | """Set the current context managed component.""" 23 | if cls._context_managed_component: 24 | cls._previous_context_managed_components.append(cls._context_managed_component) 25 | cls._context_managed_component = component 26 | 27 | @classmethod 28 | def pop_context_managed_component(cls) -> Optional["Component"]: 29 | """Pop the current context managed component.""" 30 | old_component = cls._context_managed_component 31 | if cls._previous_context_managed_components: 32 | cls._context_managed_component = cls._previous_context_managed_components.pop() 33 | else: 34 | cls._context_managed_component = None 35 | return old_component 36 | 37 | @classmethod 38 | def get_current_component(cls) -> Optional["Component"]: 39 | """Get the current context managed component.""" 40 | return cls._context_managed_component 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This file contains the [pre-commit](https://pre-commit.com/) configuration of this repository. 2 | # More on which specific pre-commit hooks we use can be found in README.md. 3 | --- 4 | minimum_pre_commit_version: "2.9.2" 5 | repos: 6 | - repo: meta 7 | hooks: 8 | - id: identity 9 | - id: check-hooks-apply 10 | - repo: local 11 | hooks: 12 | - id: isort 13 | name: iSort - Sorts imports. 14 | description: Sorts your import for you. 15 | entry: isort 16 | language: python 17 | types: [python] 18 | require_serial: true 19 | additional_dependencies: 20 | - isort==5.10.1 21 | - id: black 22 | name: Black - Auto-formatter. 23 | description: Black is the uncompromising Python code formatter. Writing to files. 24 | entry: black 25 | language: python 26 | types: [python] 27 | require_serial: true 28 | additional_dependencies: 29 | - black==22.6.0 30 | - id: flake8 31 | name: Flake8 - Enforce code style and doc. 32 | description: A command-line utility for enforcing style consistency across Python projects. 33 | entry: flake8 34 | args: ["--config=.flake8"] 35 | language: python 36 | types: [python] 37 | require_serial: true 38 | additional_dependencies: 39 | - flake8==4.0.1 40 | # - id: pytype 41 | # name: pytype - A static type analyzer for Python code 42 | # description: Pytype checks and infers types for your Python code - without requiring type annotations. 43 | # entry: pytype 44 | # args: ["--config=pytype.cfg", "--jobs auto"] 45 | # language: python 46 | # types: [python] 47 | # require_serial: true 48 | # verbose: true 49 | # additional_dependencies: 50 | # - pytype==2022.6.30 51 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/cal_address.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ical_library.base_classes.property import Property 4 | 5 | 6 | class _CalAddress(Property): 7 | @property 8 | def persons_name(self) -> Optional[str]: 9 | """Return the persons name, identified by the CN property parameter.""" 10 | return self.get_property_parameter("CN") 11 | 12 | @property 13 | def email(self) -> Optional[str]: 14 | """Return the email if the value starts with `mailto:`. Otherwise return None.""" 15 | if self.value.startswith("mailto:"): 16 | return self.value[len("mailto:") :] 17 | return None 18 | 19 | @property 20 | def cu_type(self) -> str: 21 | """Return the CUTYPE.""" 22 | return self.get_property_parameter_default("CUTYPE", default="INDIVIDUAL") 23 | 24 | @property 25 | def member(self) -> Optional[str]: 26 | """Return the membership property parameter.""" 27 | return self.get_property_parameter("MEMBER") 28 | 29 | @property 30 | def role(self) -> str: 31 | """Return the role of the person.""" 32 | return self.get_property_parameter_default("ROLE", default="REQ-PARTICIPANT") 33 | 34 | @property 35 | def participation_status(self) -> str: 36 | """Return the participation status, indicating whether the person will be present or not.""" 37 | return self.get_property_parameter_default("PARTSTAT", default="NEEDS-ACTION") 38 | 39 | 40 | class Attendee(_CalAddress): 41 | """The ATTENDEE property defines an "Attendee" within a calendar component.""" 42 | 43 | pass 44 | 45 | 46 | class Organizer(_CalAddress): 47 | """The ORGANIZER property defines the organizer for a calendar component.""" 48 | 49 | pass 50 | 51 | 52 | # if __name__ == "__main__": 53 | # ca = _CalAddress.create_property_from_str(None, "ORGANIZER;CN=John Smith:mailto:jsmith@example.com") 54 | # print(ca.email) 55 | # print(ca.persons_name) 56 | -------------------------------------------------------------------------------- /src/ical_library/client.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Any, List, Union 4 | from urllib import request 5 | 6 | from ical_library.ical_components import VCalendar 7 | 8 | 9 | def parse_lines_into_calendar(raw_text: str) -> VCalendar: 10 | """ 11 | Given the lines of an iCalendar file, return a parsed VCalendar instance. 12 | :param raw_text: The raw text of the iCalendar file/website. 13 | :return: a VCalendar with all it's iCalendar components like VEvents, VToDos, VTimeZones etc. 14 | """ 15 | lines: List[str] = [] 16 | for line in re.split(r"\n", raw_text): 17 | line_content = re.sub(r"^\n|^\r|\n$|\r$", "", line) 18 | if len(line_content) > 0: 19 | lines.append(line_content) 20 | new_instance = VCalendar() 21 | if lines[0] != "BEGIN:VCALENDAR": 22 | raise ValueError(f"This is not a ICalendar as it started with {lines[0]=}.") 23 | new_instance.parse_component(lines, line_number=1) 24 | return new_instance 25 | 26 | 27 | def parse_icalendar_file(file: Union[str, Path]) -> VCalendar: 28 | """ 29 | Parse an iCalendar file and return a parsed VCalendar instance. 30 | :param file: A file on the local filesystem that contains the icalendar definition. 31 | :return: a VCalendar instance with all it's iCalendar components like VEvents, VToDos, VTimeZones etc. 32 | """ 33 | with open(file, "r") as ical_file: 34 | return parse_lines_into_calendar(ical_file.read()) 35 | 36 | 37 | def parse_icalendar_url(url: str, **kwargs: Any) -> VCalendar: 38 | """ 39 | Given a URL to an iCalendar file, return a parsed VCalendar instance. 40 | :param url: The URL to the iCalendar file. 41 | :param kwargs: Any keyword arguments to pass onto the `urllib.request.urlopen` call. 42 | :return: a VCalendar instance with all it's iCalendar components like VEvents, VToDos, VTimeZones etc. 43 | """ 44 | response = request.urlopen(url, **kwargs) 45 | text = response.read().decode("utf-8") 46 | return parse_lines_into_calendar(text) 47 | -------------------------------------------------------------------------------- /docs/code/properties/all_properties.md: -------------------------------------------------------------------------------- 1 | # All properties 2 | 3 | This is a list of all properties 4 | 5 | ::: ical_library.ical_properties.Action 6 | ::: ical_library.ical_properties.Attach 7 | ::: ical_library.ical_properties.Attendee 8 | ::: ical_library.ical_properties.CalScale 9 | ::: ical_library.ical_properties.Categories 10 | ::: ical_library.ical_properties.Class 11 | ::: ical_library.ical_properties.Comment 12 | ::: ical_library.ical_properties.Completed 13 | ::: ical_library.ical_properties.Contact 14 | ::: ical_library.ical_properties.Created 15 | ::: ical_library.ical_properties.DTEnd 16 | ::: ical_library.ical_properties.DTStamp 17 | ::: ical_library.ical_properties.DTStart 18 | ::: ical_library.ical_properties.Description 19 | ::: ical_library.ical_properties.Due 20 | ::: ical_library.ical_properties.EXDate 21 | ::: ical_library.ical_properties.FreeBusyProperty 22 | ::: ical_library.ical_properties.GEO 23 | ::: ical_library.ical_properties.ICALDuration 24 | ::: ical_library.ical_properties.LastModified 25 | ::: ical_library.ical_properties.Location 26 | ::: ical_library.ical_properties.Method 27 | ::: ical_library.ical_properties.Organizer 28 | ::: ical_library.ical_properties.PercentComplete 29 | ::: ical_library.ical_properties.Priority 30 | ::: ical_library.ical_properties.ProdID 31 | ::: ical_library.ical_properties.RDate 32 | ::: ical_library.ical_properties.RRule 33 | ::: ical_library.ical_properties.RecurrenceID 34 | ::: ical_library.ical_properties.RelatedTo 35 | ::: ical_library.ical_properties.Repeat 36 | ::: ical_library.ical_properties.RequestStatus 37 | ::: ical_library.ical_properties.Resources 38 | ::: ical_library.ical_properties.Sequence 39 | ::: ical_library.ical_properties.Status 40 | ::: ical_library.ical_properties.Summary 41 | ::: ical_library.ical_properties.TZID 42 | ::: ical_library.ical_properties.TZName 43 | ::: ical_library.ical_properties.TZOffsetFrom 44 | ::: ical_library.ical_properties.TZOffsetTo 45 | ::: ical_library.ical_properties.TZURL 46 | ::: ical_library.ical_properties.TimeTransparency 47 | ::: ical_library.ical_properties.Trigger 48 | ::: ical_library.ical_properties.UID 49 | ::: ical_library.ical_properties.URL 50 | ::: ical_library.ical_properties.Version -------------------------------------------------------------------------------- /docs/code/properties.md: -------------------------------------------------------------------------------- 1 | # All Properties 2 | 3 | All Properties are extending the [Property](ical_library.base_classes.Property) class. Let's first start with the base class and then list all the other properties. 4 | 5 | ::: ical_library.base_classes.Property 6 | 7 | ::: ical_library.ical_properties.Action 8 | ::: ical_library.ical_properties.Attach 9 | ::: ical_library.ical_properties.Attendee 10 | ::: ical_library.ical_properties.CalScale 11 | ::: ical_library.ical_properties.Categories 12 | ::: ical_library.ical_properties.Class 13 | ::: ical_library.ical_properties.Comment 14 | ::: ical_library.ical_properties.Completed 15 | ::: ical_library.ical_properties.Contact 16 | ::: ical_library.ical_properties.Created 17 | ::: ical_library.ical_properties.DTEnd 18 | ::: ical_library.ical_properties.DTStamp 19 | ::: ical_library.ical_properties.DTStart 20 | ::: ical_library.ical_properties.Description 21 | ::: ical_library.ical_properties.Due 22 | ::: ical_library.ical_properties.EXDate 23 | ::: ical_library.ical_properties.FreeBusyProperty 24 | ::: ical_library.ical_properties.GEO 25 | ::: ical_library.ical_properties.ICALDuration 26 | ::: ical_library.ical_properties.LastModified 27 | ::: ical_library.ical_properties.Location 28 | ::: ical_library.ical_properties.Method 29 | ::: ical_library.ical_properties.Organizer 30 | ::: ical_library.ical_properties.PercentComplete 31 | ::: ical_library.ical_properties.Priority 32 | ::: ical_library.ical_properties.ProdID 33 | ::: ical_library.ical_properties.RDate 34 | ::: ical_library.ical_properties.RRule 35 | ::: ical_library.ical_properties.RecurrenceID 36 | ::: ical_library.ical_properties.RelatedTo 37 | ::: ical_library.ical_properties.Repeat 38 | ::: ical_library.ical_properties.RequestStatus 39 | ::: ical_library.ical_properties.Resources 40 | ::: ical_library.ical_properties.Sequence 41 | ::: ical_library.ical_properties.Status 42 | ::: ical_library.ical_properties.Summary 43 | ::: ical_library.ical_properties.TZID 44 | ::: ical_library.ical_properties.TZName 45 | ::: ical_library.ical_properties.TZOffsetFrom 46 | ::: ical_library.ical_properties.TZOffsetTo 47 | ::: ical_library.ical_properties.TZURL 48 | ::: ical_library.ical_properties.TimeTransparency 49 | ::: ical_library.ical_properties.Trigger 50 | ::: ical_library.ical_properties.UID 51 | ::: ical_library.ical_properties.URL 52 | ::: ical_library.ical_properties.Version -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from ical_library import client 7 | from ical_library.ical_components import VCalendar 8 | from ical_library.ical_properties import ProdID 9 | 10 | 11 | @pytest.fixture 12 | def root_folder() -> Path: 13 | starting_place = Path(os.getcwd()).resolve() 14 | folder = starting_place 15 | while not (folder / ".git").is_dir() and not folder.name.lower() == "iCal-library": 16 | folder = folder.parent 17 | if folder.name == "": 18 | raise ValueError(f"Could not find the root folder starting from {starting_place=}.") 19 | return folder.resolve() 20 | 21 | 22 | @pytest.fixture 23 | def calendar_instance() -> VCalendar: 24 | return VCalendar( 25 | prodid=ProdID("-//Google Inc//Google Calendar 70.9054//EN"), 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def calendar_with_all_components_once(root_folder: Path) -> VCalendar: 31 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalendar-with-all-components-once.ics") 32 | 33 | 34 | @pytest.fixture 35 | def calendar_with_reoccurring_events_once(root_folder: Path) -> VCalendar: 36 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalendar-with-reoccurring-events.ics") 37 | 38 | 39 | @pytest.fixture 40 | def empty_calendar(root_folder: Path) -> VCalendar: 41 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalender-without-anything.ics") 42 | 43 | 44 | @pytest.fixture 45 | def berlin_timezone_calendar(root_folder: Path) -> VCalendar: 46 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalendar-with-berlin-timezone.ics") 47 | 48 | 49 | @pytest.fixture 50 | def multi_offset_calendar(root_folder: Path) -> VCalendar: 51 | filename = "iCalendar-with-recurring-event-multiple-timezone-offsets.ics" 52 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / filename) 53 | 54 | 55 | @pytest.fixture 56 | def recurring_date_events_calendar(root_folder: Path) -> VCalendar: 57 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalender-recurring-date-events.ics") 58 | 59 | 60 | @pytest.fixture 61 | def calendar_exdate(root_folder: Path) -> VCalendar: 62 | return client.parse_icalendar_file(root_folder / "tests" / "resources" / "iCalendar-exdate.ics") 63 | -------------------------------------------------------------------------------- /tests/resources/iCalendar-with-all-components-once.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | BEGIN:VTIMEZONE 10 | TZID:Europe/Amsterdam 11 | X-LIC-LOCATION:Europe/Amsterdam 12 | BEGIN:DAYLIGHT 13 | TZOFFSETFROM:+0100 14 | TZOFFSETTO:+0200 15 | TZNAME:CEST 16 | DTSTART:19700329T020000 17 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 18 | END:DAYLIGHT 19 | BEGIN:STANDARD 20 | TZOFFSETFROM:+0200 21 | TZOFFSETTO:+0100 22 | TZNAME:CET 23 | DTSTART:19701025T030000 24 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 25 | END:STANDARD 26 | END:VTIMEZONE 27 | BEGIN:VEVENT 28 | UID:19970901T130000Z-123401@example.com 29 | DTSTAMP:19970901T130000Z 30 | DTSTART:19970903T163000Z 31 | DTEND:19970903T190000Z 32 | SUMMARY:Annual Employee Review 33 | CLASS:PRIVATE 34 | CATEGORIES:BUSINESS,HUMA 35 | END:VEVENT 36 | BEGIN:VTODO 37 | UID:20070514T103211Z-123404@example.com 38 | DTSTAMP:20070514T103211Z 39 | DTSTART:20070514T110000Z 40 | DUE:20070709T130000Z 41 | COMPLETED:20070707T100000Z 42 | SUMMARY:Submit Revised Internet-Draft 43 | PRIORITY:1 44 | STATUS:NEEDS-ACTION 45 | BEGIN:VALARM 46 | TRIGGER;VALUE=DATE-TIME:19970317T133000Z 47 | REPEAT:4 48 | DURATION:PT15M 49 | ACTION:AUDIO 50 | ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/ 51 | sounds/bell-01.aud 52 | END:VALARM 53 | END:VTODO 54 | BEGIN:VJOURNAL 55 | UID:19970901T130000Z-123405@example.com 56 | DTSTAMP:19970901T130000Z 57 | DTSTART;VALUE=DATE:19970317 58 | SUMMARY:Staff meeting minutes 59 | DESCRIPTION:1. Staff meeting: Participants include Joe\, 60 | Lisa\, and Bob. Aurora project plans were reviewed. 61 | There is currently no budget reserves for this project. 62 | Lisa will escalate to management. Next meeting on Tuesday.\n 63 | 2. Telephone Conference: ABC Corp. sales representative 64 | called to discuss new printer. Promised to get us a demo by 65 | Friday.\n3. Henry Miller (Handsoff Insurance): Car was 66 | totaled by tree. Is looking into a loaner car. 555-2323 67 | (tel). 68 | END:VJOURNAL 69 | BEGIN:VFREEBUSY 70 | UID:19970901T082949Z-FA43EF@example.com 71 | ORGANIZER:mailto:jane_doe@example.com 72 | ATTENDEE:mailto:john_public@example.com 73 | DTSTART:19971015T050000Z 74 | DTEND:19971016T050000Z 75 | DTSTAMP:19970901T083000Z 76 | END:VFREEBUSY 77 | BEGIN:MYCUSTOMCOMPONENT 78 | HI-FROM: Jorrick Sleijster 79 | END:MYCUSTOMCOMPONENT 80 | END:VCALENDAR 81 | -------------------------------------------------------------------------------- /tests/ical_components/test_recurring_components.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pendulum 4 | from pendulum import Date, DateTime 5 | from pendulum.tz import get_local_timezone 6 | 7 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 8 | from ical_library.ical_components import VCalendar 9 | 10 | 11 | def test_recurrence_with_offset_changes(multi_offset_calendar: VCalendar) -> None: 12 | """ 13 | This test verifies that calendars that have a recurrence set, correctly handle time zone changes. 14 | In this case we have Amsterdam time zone which changes from +02:00 to +01:00 at 2022-10-30 15 | """ 16 | recurring_event = multi_offset_calendar.events[0] 17 | return_rage = Timespan(DateTime(2022, 10, 1), DateTime(2022, 11, 10)) 18 | components: List[TimespanWithParent] = list(recurring_event.expand_component_in_range(return_rage, [])) 19 | assert components[0].begin == pendulum.parse("2022-10-18T12:00:00+02:00") 20 | assert components[0].end == pendulum.parse("2022-10-18T17:00:00+02:00") 21 | assert components[1].begin == pendulum.parse("2022-10-25T12:00:00+02:00") 22 | assert components[1].end == pendulum.parse("2022-10-25T17:00:00+02:00") 23 | assert components[2].begin == pendulum.parse("2022-11-01T12:00:00+01:00") 24 | assert components[2].end == pendulum.parse("2022-11-01T17:00:00+01:00") 25 | assert components[3].begin == pendulum.parse("2022-11-08T12:00:00+01:00") 26 | assert components[3].end == pendulum.parse("2022-11-08T17:00:00+01:00") 27 | 28 | 29 | def test_recurrence_with_dates_includes_intersected_dates(recurring_date_events_calendar: VCalendar) -> None: 30 | """ 31 | This test verifies that calendars that have a recurrence set on a date, correctly expand in recurring events. 32 | When a new event occurs exactly on the end of the return range, it should be returned. 33 | """ 34 | recurring_event = recurring_date_events_calendar.events[0] 35 | return_rage = Timespan(Date(2023, 3, 4), Date(2023, 3, 8)) 36 | components: List[TimespanWithParent] = list(recurring_event.expand_component_in_range(return_rage, [])) 37 | assert components[0].begin == pendulum.parse("2023-03-08T00:00:00", tz=get_local_timezone()) 38 | assert components[0].end == pendulum.parse("2023-03-15T00:00:00", tz=get_local_timezone()) 39 | 40 | return_rage = Timespan(Date(2023, 3, 4), Date(2023, 5, 31)) 41 | components: List[TimespanWithParent] = list(recurring_event.expand_component_in_range(return_rage, [])) 42 | assert components[1].begin == pendulum.parse("2023-05-31T00:00:00", tz=get_local_timezone()) 43 | assert components[1].end == pendulum.parse("2023-06-07T00:00:00", tz=get_local_timezone()) 44 | -------------------------------------------------------------------------------- /docs/remote-icalendars.md: -------------------------------------------------------------------------------- 1 | # Remote iCalendars 2 | Here are some more in-depth use-cases 3 | 4 | ## Integration status. 5 | - Google Calendar: :material-check:. Works with private and public calendars. Also works when you are part of an organisation that does not allow plugins. 6 | - Microsoft Outlook: :material-check:. Requires you to make your calendar public. 7 | - Apple Calendar: :octicons-question-16:. Unable to find any info on how to make calendars public. 8 | 9 | ## Getting the iCalendar URL for remote calendars. 10 | Before we can actually load in the data of your calendar, we need to get the iCalendar URL. 11 | The steps are different per host, some of them are listed here: 12 | 13 | - Google Calendar: ["Getting your secret address in iCal format"](https://support.google.com/calendar/answer/37648?hl=en#zippy=%2Cget-your-calendar-view-only) 14 | - Microsoft Outlook: ["Publish your Calendar"]("https://support.microsoft.com/en-us/office/share-your-calendar-in-outlook-on-the-web-7ecef8ae-139c-40d9-bae2-a23977ee58d5") 15 | 16 | !!! info 17 | 18 | This package is actively tested with Google iCalendars. 19 | If you have any other calendars and encounter odd behaviour, please file a Github feature request or a Github Issue. 20 | 21 | ## Reading your iCalendar from a remote place 22 | 23 | 1. Follow one of the above-mentioned tutorial to get the iCalendar URL. 24 | 2. Verify that when you open the URL in your browser, it shows a page or downloads a file that begins with `BEGIN:VCALENDAR`. 25 | 3. Use the `client.parse_icalendar_url()` to get it directly. 26 | 27 | ```python 28 | from ical_library import client 29 | 30 | calendar = client.parse_icalendar_url("https://calendar.google.com/calendar/ical/xxxxxx/private-xxxxxx/basic.ics") 31 | print(calendar.events) 32 | ``` 33 | 34 | ## Reading your iCalendar from a remote place with rate limiting 35 | To help you avoid doing unnecessary requests to your iCalendar provider, there is a `CacheClient`. 36 | This helps you cache the result on a location on your Hard Drive to avoid the need to fetch it every time you restart 37 | your application. 38 | 39 | ```python 40 | from pathlib import Path 41 | from pendulum import Duration 42 | from ical_library.cache_client import CacheClient 43 | 44 | cache_client = CacheClient( 45 | url="https://calendar.google.com/calendar/ical/xxxxxx/private-xxxxxx/basic.ics", 46 | cache_location=Path.home() / "ical-library-cache", 47 | cache_ttl=Duration(hours=1), 48 | verbose=True, 49 | ) 50 | calendar = cache_client.get_icalendar() 51 | print(calendar.events) 52 | ``` 53 | 54 | 55 | !!! info 56 | 57 | If you have a production use-case for a 24/7 running service, you might be better of doing the caching/rate-limiting 58 | in your service. 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "ical-library" 7 | authors = [{name = "Jorrick Sleijster", email = "jorricks3@gmail.com"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | classifiers = [ 11 | "Intended Audience :: Information Technology", 12 | "Intended Audience :: Developers", 13 | "Operating System :: OS Independent", 14 | "Topic :: Communications", 15 | "Topic :: Communications :: Conferencing", 16 | "Topic :: Office/Business", 17 | "Topic :: Office/Business :: Office Suites", 18 | "Topic :: Internet", 19 | "Topic :: Software Development", 20 | "Topic :: Software Development :: Libraries", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Topic :: Software Development :: Libraries :: Application Frameworks", 23 | "Typing :: Typed", 24 | "License :: OSI Approved :: MIT License", 25 | "Development Status :: 4 - Beta", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | 35 | ] 36 | dynamic = ["version", "description"] 37 | requires-python = ">=3.8" 38 | dependencies = [ 39 | "pendulum>=2.0.0,<3.0.0", 40 | "python-dateutil>=2.8.0", 41 | ] 42 | 43 | [project.optional-dependencies] 44 | test = [ 45 | "pytest >=6.2.4,<7.0.0", 46 | "pytest-cov >=2.12.0,<4.0.0", 47 | "flake8 >=4.0.0,<5.0.0", 48 | "black >= 22.6.0,<23.0.0", 49 | "isort >=5.10.1,<6.0.0", 50 | "mypy ==0.910", 51 | "pytype >=2022.6.30", 52 | ] 53 | doc = [ 54 | "mkdocs >=1.3.0,<2.0.0", 55 | "mkdocs-material >=8.3.9,<9.0.0", 56 | "mkdocstrings[python] >=0.19.0,<1.0.0", 57 | "termynal >=0.2.0,<1.0.0", 58 | ] 59 | dev = [ 60 | "pre-commit >=2.19.0,<3.0.0", 61 | ] 62 | 63 | 64 | [project.urls] 65 | Home = "https://jorricks.github.io/iCal-library" 66 | Documentation = "https://jorricks.github.io/iCal-library" 67 | Source = "https://github.com/Jorricks/iCal-library" 68 | PullRequests = "https://github.com/Jorricks/iCal-library/pulls" 69 | Issues = "https://github.com/Jorricks/iCal-library/issues" 70 | 71 | [tool.flit.module] 72 | name = "ical_library" 73 | 74 | [tool.black] 75 | line-length=120 76 | target-version=['py38'] 77 | 78 | [tool.isort] 79 | line_length = 120 80 | multi_line_output = 3 81 | force_alphabetical_sort_within_sections = "True" 82 | force_sort_within_sections = "False" 83 | known_icalreader = ["ical_library"] 84 | sections=["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER", "ICALREADER"] 85 | profile = "black" 86 | 87 | [tool.mypy] 88 | python_version = "3.8" 89 | ignore_missing_imports = "True" 90 | scripts_are_modules = "True" 91 | -------------------------------------------------------------------------------- /docs/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## 0.2.3 Pendulum pinning 4 | This version is there to pin the pendulum version. Two changes: 5 | - 📌 Pin pendulum to <3.0.0 6 | - ✅ Update tests for compatibility with different timezones 7 | 8 | ## 0.2.2 BugFix release 9 | This is a minor release to solve a critical bug. 10 | It occurred when it is expanding a recurring event with a start date in dates for a range defined by dates. 11 | - 🐛 RRule expansion failing where event.start_date==return_range.start. 12 | 13 | ## 0.2.1 Documentation update 14 | A minor update to improve documentation: 15 | - 📝 Remove workflow badge 16 | - ⬆️ Add Python 3.11 & 3.12 support 17 | 18 | ## 0.2.0 Release to improve timezone offset changes support 19 | This release contains some bugfixes and a major improvement to also support timezone offset changes over time. 20 | Thereby, recurring events for timezones that have Daylight saving time now correctly change according to the VTIMEZONE definition. 21 | Furthermore, EXDATE (so excluding a single occurrence from a recurring event) now correctly handles timezones. Previously it did not exclude EXDATE's with a Timezone correctly. This release fixes that. 22 | 23 | - ✨ Support offset changes in a sequence of recurring events. 24 | - 🐛 Return only recurring items in Timespan range. 25 | - 🐛 EXDate now takes TZID into account. 26 | - 📝 Make pipeline name more generic. 27 | 28 | ## 0.1.0 Code structure release 29 | This release mostly contains general improvements to the code base with some minor bugfixes. 30 | 31 | - 📝 Add emoji to features docs. 32 | - ✅ Add tests for CalAddress. 33 | - 🐛 Defaultlist gave None when using `.get`. 34 | - 🐛 Function arg date type should match other arg. 35 | - 🐛 Remove unwanted commented code. 36 | - 🐛 Remove unwanted print. 37 | - 🎨 Update name of package on Pypi. 38 | - 📝 Update buttons. 39 | 40 | ## 0.0.1a1 BugFix release 41 | This release contains some updates to the release process. 42 | 43 | - 📝 Update PyPi package description. 44 | - 🔧 Remove auto tagging pipeline. 45 | 46 | ## 🚀 0.0.1a0 Initial release 47 | The initial release of the package. Some turbulence expected. 48 | 49 | - ✅ Easy python interface. It's as simple as '`client.load_ics_file("").timeline`' to show all your events of that week. 50 | - 📈 Timeline support. Show exactly what is planned for a specific week. 51 | - 👌 ***Fully functional*** support for recurring iCal components. E.g. Any recurring event will show up as intended within the timeline interface. This includes: 52 | - Recurring components/events based on RRule. 53 | - Recurring components/events based on RDate. 54 | - Excluding components/events based on EXDate. 55 | - Any combination of the above three. 56 | - Redefined/changed components/events correctly show the latest version. 57 | - ⚡️ Very fast parsing due to lazy evaluation of iCal properties. 58 | - ✨ Debugger supported. Any issues? Open up a debugger and inspect all values. 59 | - 🔥 Minimal dependencies. Only `python-dateutil` and `pendulum`. 60 | - 📝 Fully documented code base. 61 | - 🏷️ Fully typed code base. 62 | -------------------------------------------------------------------------------- /tests/ical_components/test_v_timezone.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pendulum import DateTime 4 | from pendulum.tz.zoneinfo.transition import Transition 5 | 6 | from ical_library.ical_components import VCalendar, VTimeZone 7 | 8 | 9 | def filter_transitions(start: DateTime, end: DateTime, transitions: List[Transition]) -> List[Transition]: 10 | start_epoch = int(start.timestamp()) 11 | end_epoch = int(end.timestamp()) 12 | return [transition for transition in transitions if start_epoch <= transition.at <= end_epoch] 13 | 14 | 15 | def test_get_ordered_timezone_overview_as_transition(berlin_timezone_calendar: VCalendar): 16 | """Test that the timezone components are correctly parsed.""" 17 | # For reference material, you can use the transitions that are present in this; 18 | # berlin = pendulum._safe_timezone("Europe/Berlin") 19 | berlin_timezone: VTimeZone = berlin_timezone_calendar.get_timezone("Europe/Berlin") 20 | generated_transitions = berlin_timezone.get_ordered_timezone_overview_as_transition(DateTime(2023, 1, 1)) 21 | year_1970 = filter_transitions(DateTime(1970, 1, 1), DateTime(1971, 1, 1), generated_transitions) 22 | march_1970_switch = year_1970[0] 23 | october_1970_switch = year_1970[1] 24 | assert march_1970_switch.at == 7520400 25 | assert march_1970_switch.ttype.offset == 7200 26 | assert march_1970_switch.ttype.is_dst() is True 27 | assert march_1970_switch.ttype.abbreviation == "CEST" 28 | assert march_1970_switch.previous is None 29 | assert october_1970_switch.at == 25664400 30 | assert october_1970_switch.ttype.offset == 3600 31 | assert october_1970_switch.ttype.is_dst() is False 32 | assert october_1970_switch.ttype.abbreviation == "CET" 33 | assert october_1970_switch.previous.at == 7520400 34 | assert october_1970_switch.previous.ttype.offset == 7200 35 | assert october_1970_switch.previous.ttype.is_dst() is True 36 | assert october_1970_switch.previous.ttype.abbreviation == "CEST" 37 | assert october_1970_switch.previous.previous is None 38 | 39 | year_2022 = filter_transitions(DateTime(2022, 1, 1), DateTime(2023, 1, 1), generated_transitions) 40 | march_2022_switch = year_2022[0] 41 | october_2022_switch = year_2022[1] 42 | 43 | assert march_2022_switch.at == 1648342800 44 | assert march_2022_switch.ttype.offset == 7200 45 | assert march_2022_switch.ttype.is_dst() is True 46 | assert march_2022_switch.ttype.abbreviation == "CEST" 47 | 48 | assert october_2022_switch.at == 1667091600 49 | assert october_2022_switch.ttype.offset == 3600 50 | assert october_2022_switch.ttype.is_dst() is False 51 | assert october_2022_switch.ttype.abbreviation == "CET" 52 | 53 | 54 | def test_custom_timezone(berlin_timezone_calendar: VCalendar): 55 | """Test that the offset switches correctly when there is a time change (which is around 2022-03-27).""" 56 | tz = berlin_timezone_calendar.get_timezone("Europe/Berlin").get_as_timezone_object(DateTime(2023, 1, 1)) 57 | assert DateTime(2022, 1, 1).in_timezone(tz).offset == 3600 58 | assert DateTime(2022, 4, 1).in_timezone(tz).offset == 7200 59 | -------------------------------------------------------------------------------- /src/ical_library/base_classes/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from ical_library.base_classes.component import Component 5 | 6 | 7 | class ICalBaseClass: 8 | """ 9 | This is the base class of all custom classes representing an iCal component or iCal property in our library. 10 | 11 | [ical_library.base_classes.Component][] and :class:`Property` are the only ones inheriting this class directly, the 12 | rest of the classes are inheriting from :class:`Component` and :class:`Property` based on whether they represent an 13 | iCal component or iCal property. 14 | 15 | :param name: the actual name of this property or component. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT, CUSTOMPROPERTY. 16 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 17 | """ 18 | 19 | def __init__(self, name: str, parent: Optional["Component"]): 20 | self._name = name 21 | if self._name is None: 22 | raise ValueError("Name of a Component or Property should not be None. Please specify it.") 23 | self._parent: Optional["Component"] = parent 24 | 25 | @property 26 | def parent(self) -> Optional["Component"]: 27 | """ 28 | Return the parent :class:`Component` that contains this :class:`Component`. 29 | :return: Return the parent :class:`Component` instance or None in the case there is no parent (for VCalender's). 30 | """ 31 | return self._parent 32 | 33 | @parent.setter 34 | def parent(self, value: "Component"): 35 | """ 36 | Setter for the parent :class:`Component`. This allows us to set the parent at a later moment. 37 | :param value: The parent :class:`Component`. 38 | """ 39 | self._parent = value 40 | 41 | @property 42 | def name(self) -> str: 43 | """ 44 | Return the actual name of this property or component. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT, CUSTOMPROPERTY. 45 | 46 | We inherit this class, for the general Property and Component but also for the specific VEvent component and 47 | the RRule property. Now what do we do with the `x-comp` or `iana-comp` components and `x-prop` and `iana-prop` 48 | properties? They also have an iCalendar name, e.g. VCUSTOMCOMPONENT. However, we can't specify them beforehand 49 | as we simply can't cover all cases. Therefore, we use `get_ical_name_of_class` to find and map all of our 50 | pre-defined Components and Properties but we still specify the name for all custom components. So the rule of 51 | thumb: 52 | Use `.name` on instantiated classes while we use `.get_ical_name_of_class()` for non-instantiated classes. 53 | """ 54 | return self._name 55 | 56 | @classmethod 57 | def get_ical_name_of_class(cls) -> str: 58 | """ 59 | Return the name of a pre-defined property or pre-defined component. E.g. VEVENT, RRULE, COMPONENT, PROPERTY. 60 | 61 | For a :class:`Property` this would be the value at the start of the line. Example: a property with the name of 62 | `ABC;def=ghi:jkl` would be `ABC`. 63 | For a :class:`Component` this would be the value at the start of the component after BEGIN. Example: a VEvent 64 | starts with `BEGIN:VEVENT`, hence this function would return `VEVENT`. 65 | """ 66 | return cls.__name__.upper() 67 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_alarm.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ical_library.base_classes.component import Component 4 | from ical_library.exceptions import MissingRequiredProperty 5 | from ical_library.ical_properties.ical_duration import ICALDuration 6 | from ical_library.ical_properties.ints import Repeat 7 | from ical_library.ical_properties.pass_properties import Action, Attach 8 | from ical_library.ical_properties.trigger import Trigger 9 | 10 | 11 | class VAlarm(Component): 12 | """ 13 | This class represents the VAlarm component specified in RFC 5545 in '3.6.6. Alarm Component'. 14 | 15 | A "VALARM" calendar component is a grouping of component properties that is a reminder or alarm for an event or a 16 | to-do. For example, it may be used to define a reminder for a pending event or an overdue to-do. 17 | The "VALARM" calendar component MUST only appear within either a "VEVENT" or "VTODO" calendar component 18 | 19 | :param action: The Action property. Required and must occur exactly once. 20 | :param trigger: The Trigger property. Required and must occur exactly once. 21 | :param duration: The ICALDuration property. Optional, but may occur at most once. If this item is 22 | present, repeat may not be present. 23 | :param repeat: The Repeat property. Optional, but may occur at most once. If this item is 24 | present, duration may not be present. 25 | :param attach: The Attach property. Optional, but may occur at most once. 26 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 27 | """ 28 | 29 | def __init__( 30 | self, 31 | action: Optional[Action] = None, 32 | trigger: Optional[Trigger] = None, 33 | duration: Optional[ICALDuration] = None, 34 | repeat: Optional[Repeat] = None, 35 | attach: Optional[Attach] = None, 36 | parent: Optional[Component] = None, 37 | ): 38 | super().__init__("VALARM", parent=parent) 39 | 40 | # Required 41 | self._action: Optional[Action] = self.as_parent(action) 42 | self._trigger: Optional[Trigger] = self.as_parent(trigger) 43 | 44 | # Both optional and may only occur once. But if one occurs, the other also has to occur. 45 | self.duration: Optional[ICALDuration] = self.as_parent(duration) 46 | self.repeat: Optional[Repeat] = self.as_parent(repeat) 47 | 48 | # Optional, may only occur once 49 | self.attach: Optional[Attach] = self.as_parent(attach) 50 | 51 | def __repr__(self) -> str: 52 | """Overwrite the repr to create a better representation for the item.""" 53 | return f"VAlarm({self.action.value}: {self.trigger.value})" 54 | 55 | @property 56 | def action(self) -> Action: 57 | """A getter to ensure the required property is set.""" 58 | if self._action is None: 59 | raise MissingRequiredProperty(self, "action") 60 | return self._action 61 | 62 | @action.setter 63 | def action(self, value: Action): 64 | """A setter to set the required property.""" 65 | self._action = value 66 | 67 | @property 68 | def trigger(self) -> Trigger: 69 | """Getter that ensures the required property is set.""" 70 | if self._trigger is None: 71 | raise MissingRequiredProperty(self, "trigger") 72 | return self._trigger 73 | 74 | @trigger.setter 75 | def trigger(self, value: Trigger): 76 | """A setter to set the required property.""" 77 | self._trigger = value 78 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: iCal-library 3 | site_url: https://jorricks.github.io/iCal-library 4 | site_author: Jorrick Sleijster 5 | site_description: ICal Reader - Fast, yet simple, iCalendar reader with excellent recurrence support 6 | 7 | # Repository 8 | repo_name: jorricks/iCal-library 9 | repo_url: https://github.com/jorricks/iCal-library 10 | 11 | 12 | # Copyright 13 | copyright: Copyright © 2022 Jorrick Sleijster 14 | 15 | # Configuration 16 | theme: 17 | icon: 18 | logo: material/calendar-heart 19 | name: material 20 | palette: 21 | # Palette toggle for light mode 22 | - scheme: default 23 | toggle: 24 | icon: material/brightness-7 25 | name: Switch to dark mode 26 | 27 | # Palette toggle for dark mode 28 | - scheme: slate 29 | toggle: 30 | icon: material/brightness-4 31 | name: Switch to light mode 32 | 33 | # Plugins 34 | plugins: 35 | - search 36 | - termynal 37 | - autorefs 38 | - mkdocstrings: 39 | enable_inventory: true 40 | handlers: 41 | python: 42 | import: 43 | - https://docs.python.org/3/objects.inv 44 | - https://dateutil.readthedocs.io/en/stable/objects.inv 45 | # Unfortunately pendulum does not offer an objects.inv as of now: 46 | # https://github.com/sdispater/pendulum/issues/190 47 | options: 48 | filters: 49 | - "!__repr__" 50 | - "!__eq__" 51 | annotations_path: brief 52 | show_root_heading: true 53 | show_root_full_path: false 54 | docstring_style: sphinx 55 | show_signature_annotations: false 56 | show_source: true 57 | docstring_options: 58 | ignore_init_summary: yes 59 | 60 | # Customization 61 | extra: 62 | social: 63 | - icon: fontawesome/brands/github 64 | link: https://github.com/Jorricks/iCal-library 65 | - icon: fontawesome/brands/python 66 | link: https://pypi.org/user/jorricks/ 67 | - icon: fontawesome/brands/linkedin 68 | link: https://www.linkedin.com/in/jorricks/ 69 | 70 | # Extensions 71 | markdown_extensions: 72 | - admonition 73 | - attr_list 74 | - md_in_html 75 | - pymdownx.emoji: 76 | emoji_index: !!python/name:materialx.emoji.twemoji 77 | emoji_generator: !!python/name:materialx.emoji.to_svg 78 | - pymdownx.highlight 79 | - pymdownx.inlinehilite 80 | - pymdownx.details 81 | - pymdownx.superfences 82 | 83 | # Page tree 84 | nav: 85 | - Home: index.md 86 | - User guide: 87 | - Remote iCalendar: remote-icalendars.md 88 | - Timeline: timeline.md 89 | - Code documentation: 90 | - Client: code/client.md 91 | - Cached Client: code/cache_client.md 92 | - Calendar: code/calendar.md 93 | - Timeline: code/timeline.md 94 | - Components: 95 | - Base classes: code/components/base_class.md 96 | - Simple components: code/components/simple_components.md 97 | - Recurring components: code/components/recurring_components.md 98 | - Timezone components: code/components/timezone_components.md 99 | - Properties: 100 | - Base classes: code/properties/base_class.md 101 | - Property classes: code/properties/all_properties.md 102 | - Property helper classes: code/properties/help_classes.md 103 | - Exceptions: code/exceptions.md 104 | - Frequently asked questions: faq.md 105 | - Release notes: release-notes.md 106 | -------------------------------------------------------------------------------- /tests/base_classes/test_component.py: -------------------------------------------------------------------------------- 1 | from ical_library.base_classes.component import Component 2 | from ical_library.base_classes.property import Property 3 | from ical_library.ical_components import VCalendar, VEvent, VFreeBusy, VJournal, VTimeZone, VToDo 4 | from ical_library.ical_properties.pass_properties import ProdID, Version 5 | 6 | 7 | def test_repr(calendar_instance): 8 | with calendar_instance: 9 | my_component = Component("MORTHY") 10 | my_component._extra_properties["prop"].append(Property(name="PROP", property_parameters=None, value="GHI")) 11 | assert repr(my_component) == "Component(prop=[Property(PROP:GHI)])" 12 | 13 | 14 | def test_extra_child_components(calendar_instance): 15 | with calendar_instance: 16 | with Component("MORTHY", None) as my_component: 17 | component_1 = Component("RICK", None) 18 | component_2 = Component("SUMMER", None) 19 | assert my_component.extra_child_components == { 20 | "RICK": [component_1], 21 | "SUMMER": [component_2], 22 | } 23 | 24 | 25 | def test_extra_properties(calendar_instance): 26 | my_component = Component("MORTHY", parent=calendar_instance) 27 | my_component.parse_property("RICK:SUMMER") 28 | assert set(my_component.properties.keys()) == {"rick"} 29 | print(dir(my_component.properties.values())) 30 | property = list(my_component.properties.values())[0][0] 31 | assert property.name == "RICK" 32 | assert property.value == "SUMMER" 33 | 34 | 35 | def test_parent(calendar_instance): 36 | some_component = Component("SOME-COMPONENT", calendar_instance) 37 | assert some_component.parent is calendar_instance 38 | with calendar_instance: 39 | a = Property(name="PROP", value="bcdef") 40 | assert a.parent is calendar_instance 41 | 42 | 43 | def test_children(calendar_with_all_components_once: VCalendar): 44 | assert len(calendar_with_all_components_once.children) == 6 45 | type_of_children = [type(child) for child in calendar_with_all_components_once.children] 46 | assert type_of_children == [VEvent, VToDo, VJournal, VFreeBusy, VTimeZone, Component] 47 | 48 | 49 | def test_original_ical_text(calendar_with_all_components_once: VCalendar): 50 | assert ( 51 | calendar_with_all_components_once.free_busy_list[0].original_ical_text 52 | == """ 53 | BEGIN:VFREEBUSY 54 | UID:19970901T082949Z-FA43EF@example.com 55 | ORGANIZER:mailto:jane_doe@example.com 56 | ATTENDEE:mailto:john_public@example.com 57 | DTSTART:19971015T050000Z 58 | DTEND:19971016T050000Z 59 | DTSTAMP:19970901T083000Z 60 | END:VFREEBUSY 61 | """.strip() 62 | ) 63 | 64 | 65 | def test_properties(empty_calendar): 66 | version_property = Version(name="VERSION", value="1.1") 67 | empty_calendar.version = version_property 68 | some_other_property = Property(name="AWESOME", value="VALUE") 69 | empty_calendar._extra_properties["awesome"].append(some_other_property) 70 | assert empty_calendar.properties["awesome"] == [some_other_property] 71 | assert empty_calendar.properties["version"] == version_property 72 | assert set(empty_calendar.properties.keys()) == { 73 | "prodid", 74 | "version", 75 | "calscale", 76 | "method", 77 | "x_wr_caldesc", 78 | "x_wr_timezone", 79 | "x_wr_calname", 80 | "awesome", 81 | } 82 | 83 | 84 | def test_print_tree_structure(capsys): 85 | root = VCalendar(prodid=ProdID(name="A", value="B"), version=Version(name="C", value="D")) 86 | an_event = VEvent(parent=root) 87 | a_journal = VJournal(parent=root) 88 | root.add_child(an_event) 89 | root.add_child(a_journal) 90 | root.print_tree_structure() 91 | captured = capsys.readouterr() 92 | assert captured.out == " - VCalendar(B, D)\n - VEvent()\n - VJournal(None: )\n" 93 | -------------------------------------------------------------------------------- /src/ical_library/cache_client.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | from pathlib import Path 4 | from typing import Any, Union 5 | from urllib import request 6 | 7 | from pendulum import DateTime, Duration 8 | 9 | from ical_library import client 10 | from ical_library.ical_components import VCalendar 11 | 12 | 13 | class CacheClient: 14 | """ 15 | A iCalendar client which takes care of caching the result for you. 16 | This avoids you needing to handle the caching. 17 | 18 | :param cache_location: A path to the cache. Can be relative or absolute references. When you pass in a value with 19 | a file extension, it is considered to be a directory, otherwise it's considered as a file reference. 20 | :param cache_ttl: The time-to-live for the cache. The cache will be deleted/refreshed once it is older than the TTL. 21 | :param verbose: Print verbose messages regarding cache usage. 22 | :param url: The URL to the iCalendar file. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | url: str, 28 | cache_location: Union[Path, str], 29 | cache_ttl: Union[Duration] = Duration(hours=1), 30 | verbose: bool = True, 31 | ): 32 | self.url: str = url 33 | self.cache_location: Path = Path(cache_location) 34 | self.cache_ttl: Duration = cache_ttl 35 | self.verbose = verbose 36 | 37 | @property 38 | def cache_file_path(self) -> Path: 39 | """Return the filepath to the cache for the given URL.""" 40 | if self.cache_location.suffix == "": 41 | return self.cache_location / hashlib.md5(self.url.encode()).hexdigest() 42 | return self.cache_location 43 | 44 | def get_icalendar(self, **kwargs: Any) -> VCalendar: 45 | """ 46 | Get a parsed VCalendar instance. If there is an active cache, return that, otherwise fetch and cache the result. 47 | :param kwargs: Any keyword arguments to pass onto the `urllib.request.urlopen` call. 48 | :return: a VCalendar instance with all it's iCalendar components like VEvents, VToDos, VTimeZones etc. 49 | """ 50 | if not self._is_cache_expired(): 51 | if self.verbose: 52 | print("Using cache to remove this folder.") 53 | return client.parse_icalendar_file(self.cache_file_path) 54 | 55 | self._purge_icalendar_cache() 56 | response = request.urlopen(self.url, **kwargs) 57 | if not (200 <= response.getcode() < 400): 58 | raise ValueError(f"Unable to execute request at {self.url=}. Response code was: {response.getcode()}.") 59 | text = response.read().decode("utf-8") 60 | self._write_response_to_cache(text) 61 | 62 | lines = text.split("\n") 63 | return client.parse_lines_into_calendar(lines) 64 | 65 | def _write_response_to_cache(self, text: str) -> None: 66 | """ 67 | Write the response of the fetched URL to cache. 68 | :param text: The fetched result. 69 | """ 70 | if self.verbose: 71 | print(f"Successfully loaded new iCalendar data and stored it at {self.cache_file_path}.") 72 | self.cache_file_path.parent.mkdir(parents=True, exist_ok=True) 73 | with open(self.cache_file_path, "w") as file: 74 | file.write(text) 75 | 76 | def _purge_icalendar_cache(self) -> None: 77 | """Purge the cache we have for this Calendar.""" 78 | if self.verbose: 79 | print(f"Cache was expired. Removed {self.cache_file_path}.") 80 | return self.cache_file_path.unlink() 81 | 82 | def _is_cache_expired(self) -> bool: 83 | """Return whether the cache is passed its expiration date.""" 84 | cutoff = DateTime.utcnow() - self.cache_ttl 85 | mtime = DateTime.utcfromtimestamp(os.path.getmtime(self.cache_file_path)) 86 | return mtime < cutoff 87 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # iCal-library 2 |

3 | iCal-library 4 |

5 |

6 | Fast, yet simple, iCalendar reader with excellent recurrence support. RFC 5545 compliant. 7 |

8 |

9 | 10 | Coverage 11 | 12 | 13 | Package version 14 | 15 | 16 | Supported Python versions 17 | 18 |

19 | 20 | --- 21 | 22 | **Documentation**: [https://jorricks.github.io/iCal-library/](https://jorricks.github.io/iCal-library/) 23 | 24 | **Source Code**: [https://github.com/Jorricks/iCal-library](https://github.com/Jorricks/iCal-library) 25 | 26 | --- 27 | 28 | **iCal-library** is a Python library for anyone who wishes to read any iCalendar file. 29 | 30 | It is one of the fastest iCalender python library out there and has excellent support for recurring events. Now there is truly no reason to miss an event, ever. 31 | 32 | !!! info "This project is under active development." 33 | 34 | You may encounter items on which we can improve, please file an issue if you encounter any issue or create a feature request for any missing feature. 35 | 36 | ## Features 37 | - 🚀 Easy python interface. It's as simple as '`client.load_ics_file("").timeline`' to show all your events of that week. 38 | - 📈 Timeline support. Show exactly what is planned for a specific week. 39 | - ✨ ***Fully functional*** support for recurring iCal components. E.g. Any recurring event will show up as intended within the timeline interface. This includes: 40 | 1. Recurring components/events based on RRule. 41 | 2. Recurring components/events based on RDate. 42 | 3. Excluding components/events based on EXDate. 43 | 4. Any combination of the above three. 44 | 5. Redefined/changed components/events correctly show the latest version. 45 | - ⏩ Very fast parsing due to lazy evaluation of iCal properties. 46 | - ⁉️ Debugger supported. Any issues? Open up a debugger and inspect all values. 47 | - 🔅 Minimal dependencies. Only `python-dateutil` and `pendulum`. 48 | - 🆎 Fully typed code base. 49 | 50 | 51 | 52 | ## Installation 53 | To use iCal-library, first install it using pip: 54 | 55 | 56 | ``` 57 | $ pip install iCal-library 58 | ---> 100% 59 | Installed 60 | ``` 61 | 62 | 63 | ## Requirements 64 | Python 3.8+ 65 | 66 | iCal-library uses two major libraries for their date and time utilities: 67 | - [Pendulum](https://github.com/sdispater/pendulum) for its extensions on datetime objects and parsing of durations. 68 | - [Python-Dateutil](https://github.com/dateutil/dateutil) for its RRule support. 69 | 70 | 71 | ## Example 72 | A simple example. Please look [in the docs](https://jorricks.github.io/iCal-library/) for more examples. 73 | 74 | ```python 75 | from ical_library import client 76 | 77 | calendar = client.parse_icalendar_file("/home/user/my_icalendar.ics") 78 | print(calendar.events) 79 | print(calendar.todos) 80 | print(calendar.journals) 81 | print(calendar.free_busy_list) 82 | print(calendar.time_zones) 83 | ``` 84 | 85 | 86 | ???+ info "During experimentation, it is recommended to use a Python Debugger." 87 | 88 | iCal-library is fully Debugger compliant, meaning it is very easy to use a debugger with this project. It will be much faster to see all the different attributes and functions from inside a Python debugger. If you are unsure whether your IDE supports it, take a look [here](https://wiki.python.org/moin/PythonDebuggingTools) under the sections 'IDEs with Debug Capabilities'. 89 | 90 | 91 | ## Limitations 92 | - Currently, it is not supported to write ICS files. If this is a deal-breaker for you, it should be relatively straight forward to add it, so please consider submitting a PR for this :). 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | iCal-library 3 |

4 |

5 | Fast, yet simple, iCalendar reader with excellent recurrence support. RFC 5545 compliant. 6 |

7 |

8 | 9 | Coverage 10 | 11 | 12 | Package version 13 | 14 | 15 | Supported Python versions 16 | 17 |

18 | 19 | 20 | --- 21 | 22 | **Documentation**: [https://jorricks.github.io/iCal-library/](https://jorricks.github.io/iCal-library/) 23 | 24 | **Source Code**: [https://github.com/Jorricks/iCal-library](https://github.com/Jorricks/iCal-library) 25 | 26 | 27 | ## Features 28 | - 🚀 Easy python interface. It's as simple as '`client.load_ics_file("").timeline`' to show all your events of that week. 29 | - 📈 Timeline support. Show exactly what is planned for a specific week. 30 | - ✨ ***Fully functional*** support for recurring iCal components. E.g. Any recurring event will show up as intended within the timeline interface. This includes: 31 | 1. Recurring components/events based on RRule. 32 | 2. Recurring components/events based on RDate. 33 | 3. Excluding components/events based on EXDate. 34 | 4. Any combination of the above three. 35 | 5. Redefined/changed components/events correctly show the latest version. 36 | - ⏩ Very fast parsing due to lazy evaluation of iCal properties. 37 | - ⁉️ Debugger supported. Any issues? Open up a debugger and inspect all values. 38 | - 🔅 Minimal dependencies. Only `python-dateutil` and `pendulum`. 39 | - 🆎 Fully typed code base. 40 | 41 | 42 | ## Requirements 43 | Python 3.8+ 44 | 45 | iCal-library uses two major libraries for their date and time utilities: 46 | - [Pendulum](https://github.com/sdispater/pendulum) for its extensions on datetime objects and parsing of durations. 47 | - [Python-Dateutil](https://github.com/dateutil/dateutil) for its RRule support. 48 | 49 | 50 | ## Installation 51 | 52 | To use iCal-library, first install it using pip: 53 | 54 | pip install iCal-library 55 | 56 | 57 | ## Example 58 | A simple example. Please look [in the docs](https://jorricks.github.io/iCal-library/) for more examples. 59 | 60 | ```python3 61 | from ical_library import client 62 | 63 | calendar = client.parse_icalendar_file("/home/user/my_icalendar.ics") 64 | print(calendar.events) 65 | print(calendar.todos) 66 | print(calendar.journals) 67 | print(calendar.free_busy_list) 68 | print(calendar.time_zones) 69 | ``` 70 | 71 | Note: iCal-library is fully Debugger compliant, meaning it is very easy to use a debugger with this project. It will be much faster to see all the different attributes and functions from inside a Python debugger. If you are unsure whether your IDE supports it, take a look [here](https://wiki.python.org/moin/PythonDebuggingTools) under the sections 'IDEs with Debug Capabilities'. 72 | 73 | 74 | ## Limitations 75 | - Currently, it is not supported to write ICS files. If this is a deal-breaker for you, it should be relatively straight forward to add it, so please consider submitting a PR for this :). However, this will be added shortly! 76 | 77 | 78 | ## Why yet another iCalendar library? 79 | 80 | I first tried several libraries for iCalendar events. However, none of them supported recurring events as well as they should be. For some libraries my calendar loaded but then didn't show my recurring events, while others simply threw stacktraces trying to load it. Furthermore, I noticed that my calendar (with over 2000 events) took ages to load. 81 | After traversing the code of the other libraries I decided I wanted to build my own. With some key principles: 82 | - Recurring components should be the main priority to get working. 83 | - No strict evaluation that could lead to errors while parsing the file. 84 | - Lazy evaluation for iCalendar properties to speed up the process. 85 | 86 | ## Ideas for additional features 87 | - Support quoted property parameters containing special characters. 88 | - Support the new Properties for iCalendar (RFC 7986). 89 | - Support CalDev (RFC 4791). 90 | 91 | -------------------------------------------------------------------------------- /src/ical_library/help_modules/timespan.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Optional, Tuple, TYPE_CHECKING, Union 3 | 4 | from pendulum import Date, DateTime 5 | 6 | from ical_library.help_modules import dt_utils 7 | 8 | if TYPE_CHECKING: 9 | from ical_library.base_classes.component import Component 10 | 11 | 12 | @functools.total_ordering 13 | class Timespan: 14 | """ 15 | Represents a time span with a beginning and end date. 16 | 17 | Contains multiple handy functions when working with a time span like the is_included_in or intersect function. 18 | :param begin: The beginning of the timespan. 19 | :param end: The end of the timespan. 20 | """ 21 | 22 | def __init__(self, begin: Union[Date, DateTime], end: Union[Date, DateTime]): 23 | """For simplicity, we convert the variables to timezone aware :class:`pendulum.DateTime` instances.""" 24 | self.begin: DateTime = dt_utils.convert_time_object_to_aware_datetime(begin) 25 | self.end: DateTime = dt_utils.convert_time_object_to_aware_datetime(end) 26 | 27 | def __repr__(self) -> str: 28 | return f"Timespan({self.begin}, {self.end})" 29 | 30 | @property 31 | def tuple(self) -> Tuple[DateTime, DateTime]: 32 | """ 33 | Return the timespan as a tuple. 34 | :return: The beginning and end in a tuple format. 35 | """ 36 | return self.begin, self.end 37 | 38 | def get_end_in_same_type(self, time: Union[Date, DateTime]) -> Union[Date, DateTime]: 39 | """ 40 | Return *self.end* in the same format as *time*. 41 | :param time: A time format in which we would like to return the *self.end*. 42 | :return: Return *self.end* in the same format as *time*. 43 | """ 44 | if not isinstance(time, DateTime): 45 | return Date(self.end.year, self.end.month, self.end.day) 46 | elif time.tz is None: # At this point we know it is a DateTime object. 47 | return self.end.replace(tzinfo=None) # type: ignore 48 | return self.end.in_timezone(time.tz) 49 | 50 | def __eq__(self, other: Any) -> bool: 51 | if isinstance(other, Timespan): 52 | return self.tuple == other.tuple 53 | else: 54 | return NotImplemented 55 | 56 | def __lt__(self, other: Any) -> bool: 57 | if isinstance(other, Timespan): 58 | return self.tuple < other.tuple 59 | else: 60 | return NotImplemented 61 | 62 | def __gt__(self, other: Any) -> bool: 63 | if isinstance(other, Timespan): 64 | return self.tuple > other.tuple 65 | else: 66 | return NotImplemented 67 | 68 | def __le__(self, other: Any) -> bool: 69 | if isinstance(other, Timespan): 70 | return self.tuple <= other.tuple 71 | else: 72 | return NotImplemented 73 | 74 | def __ge__(self, other: Any) -> bool: 75 | if isinstance(other, Timespan): 76 | return self.tuple >= other.tuple 77 | else: 78 | return NotImplemented 79 | 80 | def is_included_in(self, other: "Timespan") -> bool: 81 | """ 82 | Check whether the *other* timespan is included within this timespan. 83 | :param other: Another timespan. 84 | :return: Boolean whether the *other* timespan is included within this timespan. 85 | """ 86 | return other.begin <= self.begin and self.end <= other.end 87 | 88 | def intersects(self, other: "Timespan") -> bool: 89 | """ 90 | Check whether the *other* timespan intersects with this timespan. 91 | :param other: Another timespan. 92 | :return: boolean whether the *other* timespan intersects with this timespan. 93 | """ 94 | return ( 95 | self.begin <= other.begin < self.end 96 | or self.begin <= other.end < self.end 97 | or other.begin <= self.begin < other.end 98 | or other.begin <= self.end < other.end 99 | ) 100 | 101 | def includes(self, instant: Union[Date, DateTime]) -> bool: 102 | """ 103 | Check if the *instant* is included in this timespan. 104 | :param instant: An instance that represents a moment in time. 105 | :return: Boolean of whether the *instant* is included in this timespan. 106 | """ 107 | dt = dt_utils.convert_time_object_to_aware_datetime(instant) 108 | return self.begin <= dt < self.end 109 | 110 | 111 | class TimespanWithParent(Timespan): 112 | """ 113 | Init a Timespan object which represents the length of a specific component(marked as parent). 114 | :param parent: The component instance which this timespan represents. 115 | :param begin: The beginning of the timespan. 116 | :param end: The end of the timespan. 117 | """ 118 | 119 | def __init__(self, parent: Optional["Component"], begin: Union[Date, DateTime], end: Union[Date, DateTime]): 120 | super().__init__(begin, end) 121 | self.parent = parent 122 | -------------------------------------------------------------------------------- /tests/resources/iCalendar-with-reoccurring-events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test iCal-library 7 | X-WR-TIMEZONE:Europe/Amsterdam 8 | X-WR-CALDESC:Calendar to test iCal-library 9 | BEGIN:VTIMEZONE 10 | TZID:Europe/Amsterdam 11 | X-LIC-LOCATION:Europe/Amsterdam 12 | BEGIN:DAYLIGHT 13 | TZOFFSETFROM:+0100 14 | TZOFFSETTO:+0200 15 | TZNAME:CEST 16 | DTSTART:19700329T020000 17 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 18 | END:DAYLIGHT 19 | BEGIN:STANDARD 20 | TZOFFSETFROM:+0200 21 | TZOFFSETTO:+0100 22 | TZNAME:CET 23 | DTSTART:19701025T030000 24 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 25 | END:STANDARD 26 | END:VTIMEZONE 27 | BEGIN:VEVENT 28 | DTSTART;TZID=Europe/Amsterdam:20220715T125000 29 | DTEND;TZID=Europe/Amsterdam:20220715T135000 30 | DTSTAMP:20220707T191529Z 31 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 32 | RECURRENCE-ID;TZID=Europe/Amsterdam:20220715T120000 33 | CREATED:20220707T190802Z 34 | DESCRIPTION: 35 | LAST-MODIFIED:20220707T190847Z 36 | LOCATION: 37 | SEQUENCE:1 38 | STATUS:CONFIRMED 39 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 40 | y. 41 | TRANSP:OPAQUE 42 | END:VEVENT 43 | BEGIN:VEVENT 44 | DTSTART;TZID=Europe/Amsterdam:20220714T124000 45 | DTEND;TZID=Europe/Amsterdam:20220714T134000 46 | DTSTAMP:20220707T191529Z 47 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 48 | RECURRENCE-ID;TZID=Europe/Amsterdam:20220714T120000 49 | CREATED:20220707T190802Z 50 | DESCRIPTION: 51 | LAST-MODIFIED:20220707T190835Z 52 | LOCATION: 53 | SEQUENCE:1 54 | STATUS:CONFIRMED 55 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 56 | y. 57 | TRANSP:OPAQUE 58 | END:VEVENT 59 | BEGIN:VEVENT 60 | DTSTART;TZID=Europe/Amsterdam:20220713T123000 61 | DTEND;TZID=Europe/Amsterdam:20220713T133000 62 | DTSTAMP:20220707T191529Z 63 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 64 | RECURRENCE-ID;TZID=Europe/Amsterdam:20220713T120000 65 | CREATED:20220707T190802Z 66 | DESCRIPTION: 67 | LAST-MODIFIED:20220707T190828Z 68 | LOCATION: 69 | SEQUENCE:1 70 | STATUS:CONFIRMED 71 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 72 | y. 73 | TRANSP:OPAQUE 74 | END:VEVENT 75 | BEGIN:VEVENT 76 | DTSTART;TZID=Europe/Amsterdam:20220712T122000 77 | DTEND;TZID=Europe/Amsterdam:20220712T132000 78 | DTSTAMP:20220707T191529Z 79 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 80 | RECURRENCE-ID;TZID=Europe/Amsterdam:20220712T120000 81 | CREATED:20220707T190802Z 82 | DESCRIPTION: 83 | LAST-MODIFIED:20220707T190818Z 84 | LOCATION: 85 | SEQUENCE:1 86 | STATUS:CONFIRMED 87 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 88 | y. 89 | TRANSP:OPAQUE 90 | END:VEVENT 91 | BEGIN:VEVENT 92 | DTSTART;TZID=Europe/Amsterdam:20220711T121000 93 | DTEND;TZID=Europe/Amsterdam:20220711T131000 94 | DTSTAMP:20220707T191529Z 95 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 96 | RECURRENCE-ID;TZID=Europe/Amsterdam:20220711T120000 97 | CREATED:20220707T190802Z 98 | DESCRIPTION: 99 | LAST-MODIFIED:20220707T190812Z 100 | LOCATION: 101 | SEQUENCE:1 102 | STATUS:CONFIRMED 103 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 104 | y. 105 | TRANSP:OPAQUE 106 | END:VEVENT 107 | BEGIN:VEVENT 108 | DTSTART;TZID=Europe/Amsterdam:20220711T120000 109 | DTEND;TZID=Europe/Amsterdam:20220711T130000 110 | RRULE:FREQ=DAILY;COUNT=5 111 | DTSTAMP:20220707T191529Z 112 | UID:0vr599e34ql5d6433jgvl8jcol@google.com 113 | CREATED:20220707T190802Z 114 | DESCRIPTION: 115 | LAST-MODIFIED:20220707T190802Z 116 | LOCATION: 117 | SEQUENCE:0 118 | STATUS:CONFIRMED 119 | SUMMARY:Event that should start at 12:00 for 5 days but is changed every da 120 | y. 121 | TRANSP:OPAQUE 122 | END:VEVENT 123 | BEGIN:VEVENT 124 | DTSTART;VALUE=DATE:20220706 125 | DTEND;VALUE=DATE:20220707 126 | RRULE:FREQ=WEEKLY;WKST=MO;BYDAY=WE 127 | DTSTAMP:20220707T191529Z 128 | UID:0au6hqmh37p6qmicmbn6s8i6t6@google.com 129 | CREATED:20220707T190349Z 130 | DESCRIPTION: 131 | LAST-MODIFIED:20220707T190404Z 132 | LOCATION: 133 | SEQUENCE:1 134 | STATUS:CONFIRMED 135 | SUMMARY:Event at Wednesday for ever 136 | TRANSP:TRANSPARENT 137 | END:VEVENT 138 | BEGIN:VEVENT 139 | DTSTART;TZID=Europe/Amsterdam:20220702T100000 140 | DTEND;TZID=Europe/Amsterdam:20220702T103000 141 | RRULE:FREQ=MONTHLY;UNTIL=20221231T225959Z;BYDAY=1SA 142 | DTSTAMP:20220707T191529Z 143 | UID:170tlcaro6j8u45ore1umcpjc4@google.com 144 | CREATED:20220707T190244Z 145 | DESCRIPTION: 146 | LAST-MODIFIED:20220707T190325Z 147 | LOCATION: 148 | SEQUENCE:1 149 | STATUS:CONFIRMED 150 | SUMMARY:Event first Saturday of the month until new year 151 | TRANSP:TRANSPARENT 152 | END:VEVENT 153 | BEGIN:VEVENT 154 | DTSTART;TZID=Europe/Amsterdam:20220701T120000 155 | DTEND;TZID=Europe/Amsterdam:20220701T130000 156 | RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1 157 | DTSTAMP:20220707T191529Z 158 | UID:436d4ps06cb6jovild206hc53v@google.com 159 | CREATED:20220707T190150Z 160 | DESCRIPTION: 161 | LAST-MODIFIED:20220707T190150Z 162 | LOCATION: 163 | SEQUENCE:0 164 | STATUS:CONFIRMED 165 | SUMMARY:Event at start of the Month for 10 times 166 | TRANSP:OPAQUE 167 | END:VEVENT 168 | END:VCALENDAR 169 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/pass_properties.py: -------------------------------------------------------------------------------- 1 | from ical_library.base_classes.property import Property 2 | 3 | 4 | class ProdID(Property): 5 | """The PRODID property specifies the identifier for the product that created the iCalendar object.""" 6 | 7 | pass 8 | 9 | 10 | class Version(Property): 11 | """ 12 | The VERSION property specifies the identifier corresponding to the highest version number or the minimum and 13 | maximum range of the iCalendar specification that is required in order to interpret the iCalendar object. 14 | """ 15 | 16 | pass 17 | 18 | 19 | class CalScale(Property): 20 | """ 21 | The CALSCALE property defines the calendar scale used for the calendar information specified in the iCalendar 22 | object. 23 | """ 24 | 25 | pass 26 | 27 | 28 | class Method(Property): 29 | """The METHOD property defines the iCalendar object method associated with the calendar object.""" 30 | 31 | pass 32 | 33 | 34 | class Class(Property): 35 | """The CLASS property defines the access classification for a calendar component.""" 36 | 37 | pass 38 | 39 | 40 | class Description(Property): 41 | """ 42 | The DESCRIPTION property provides a more complete description of the calendar component than that provided by 43 | the "SUMMARY" property. 44 | """ 45 | 46 | pass 47 | 48 | 49 | class Location(Property): 50 | """The LOCATION property defines the intended venue for the activity defined by a calendar component.""" 51 | 52 | pass 53 | 54 | 55 | class Status(Property): 56 | """The STATUS property defines the overall status or confirmation for the calendar component.""" 57 | 58 | pass 59 | 60 | 61 | class TimeTransparency(Property): 62 | """The TRANSP property defines whether an event is transparent to busy time searches.""" 63 | 64 | @classmethod 65 | def get_ical_name_of_class(cls) -> str: 66 | """Overwrite the iCal name of this class as it is not *TIMETRANSPARANCY* but *TRANSP*.""" 67 | return "TRANSP" 68 | 69 | 70 | class URL(Property): 71 | """The URL property defines a Uniform Resource Locator (URL) associated with the iCalendar object.""" 72 | 73 | pass 74 | 75 | 76 | class Attach(Property): 77 | """The ATTACH property provides the capability to associate a document object with a calendar component.""" 78 | 79 | pass 80 | 81 | 82 | class Categories(Property): 83 | """The CATEGORIES property defines the categories for a calendar component.""" 84 | 85 | pass 86 | 87 | 88 | class Contact(Property): 89 | """ 90 | The CONTACT property is used to represent contact information or alternately a reference to contact information 91 | associated with the calendar component. 92 | """ 93 | 94 | pass 95 | 96 | 97 | class RequestStatus(Property): 98 | """The REQUEST-STATUS property defines the status code returned for a scheduling request.""" 99 | 100 | @classmethod 101 | def get_ical_name_of_class(cls) -> str: 102 | """Overwrite the iCal name of this class as it is not *REQUESTSTATUS* but *REQUEST-STATUS*.""" 103 | return "REQUEST-STATUS" 104 | 105 | 106 | class RelatedTo(Property): 107 | """ 108 | The RELATED-TO property is used to represent a relationship or reference between one calendar component and another. 109 | """ 110 | 111 | @classmethod 112 | def get_ical_name_of_class(cls) -> str: 113 | """Overwrite the iCal name of this class as it is not *RELATEDTO* but *RELATED-TO*.""" 114 | return "RELATED-TO" 115 | 116 | 117 | class Resources(Property): 118 | """ 119 | The RESOURCES property defines the equipment or resources anticipated for an activity specified by a calendar 120 | component. 121 | """ 122 | 123 | pass 124 | 125 | 126 | class Action(Property): 127 | """The ACTION property defines the action to be invoked when an alarm is triggered.""" 128 | 129 | pass 130 | 131 | 132 | class UID(Property): 133 | """The UID property defines the persistent, globally unique identifier for the calendar component.""" 134 | 135 | pass 136 | 137 | 138 | class Comment(Property): 139 | """The COMMENT property specifies non-processing information intended to provide a comment to the calendar user.""" 140 | 141 | pass 142 | 143 | 144 | class TZName(Property): 145 | """The TZNAME property specifies the customary designation for a time zone description.""" 146 | 147 | pass 148 | 149 | 150 | class TZID(Property): 151 | """ 152 | The TZID property specifies the text value that uniquely identifies the "VTIMEZONE" calendar component in the scope 153 | of an iCalendar object. 154 | """ 155 | 156 | pass 157 | 158 | 159 | class TZURL(Property): 160 | """ 161 | The TZURL property provides a means for a "VTIMEZONE" component to point to a network location that can be used to 162 | retrieve an up- to-date version of itself. 163 | """ 164 | 165 | pass 166 | 167 | 168 | class Summary(Property): 169 | """ 170 | The SUMMARY property defines a short summary or subject for the calendar component. 171 | """ 172 | 173 | pass 174 | -------------------------------------------------------------------------------- /src/ical_library/base_classes/property.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, TYPE_CHECKING 2 | 3 | from ical_library.base_classes.base_class import ICalBaseClass 4 | from ical_library.help_modules.component_context import ComponentContext 5 | from ical_library.help_modules.lru_cache import instance_lru_cache 6 | 7 | if TYPE_CHECKING: 8 | from ical_library.base_classes.component import Component 9 | 10 | 11 | class Property(ICalBaseClass): 12 | """ 13 | This is the base class for any property (according to the RFC 5545 specification) in iCal-library. 14 | 15 | A property always exists of three parts: 16 | 17 | - The name of the property. 18 | - The property parameters, this is optional and does not need to be present. 19 | - The value of the property. 20 | 21 | A line containing a property typically has the following format: 22 | `PROPERTY-NAME;parameterKey=parameterValue,anotherParameterKey=anotherValue:actual-value` 23 | 24 | Any property that is predefined according to the RFC 5545 should inherit this class, e.g. UID, RRule. 25 | Only x-properties or iana-properties should instantiate the Property class directly. 26 | 27 | :param value: The value of the property. 28 | :param name: The properties name, e.g. `RRULE`. 29 | :param property_parameters: The property parameters for this definition. 30 | :param parent: Instance of the :class:`Component` it is a part of. 31 | """ 32 | 33 | def __init__( 34 | self, 35 | value: Optional[str], 36 | name: Optional[str] = None, 37 | property_parameters: Optional[str] = None, 38 | parent: "Component" = None, 39 | ): 40 | name = name if self.__class__ == Property else self.__class__.get_ical_name_of_class() 41 | super().__init__(name=name, parent=parent or ComponentContext.get_current_component()) 42 | if parent is None and self.parent is not None: 43 | self.parent.set_property(self) 44 | 45 | self._property_parameters: Optional[str] = property_parameters 46 | self._value: Optional[str] = value 47 | 48 | def __repr__(self) -> str: 49 | """Overwrite the repr to create a better representation for the item.""" 50 | return f"{self.__class__.__name__}({self.as_original_string})" 51 | 52 | def __eq__(self: "Property", other: "Property") -> bool: 53 | """Return whether the current instance and the other instance are the same.""" 54 | if type(self) != type(other): 55 | return False 56 | return self.as_original_string == other.as_original_string 57 | 58 | @property 59 | @instance_lru_cache() 60 | def property_parameters(self) -> Dict[str, str]: 61 | """ 62 | Return (and cache) all the property's parameters as a dictionary of strings. 63 | 64 | Note: When the instance is collected by the garbage collection, the cache is automatically deleted as well. 65 | 66 | :return: all the property's parameters as a dictionary of strings 67 | """ 68 | property_parameters_str = self._property_parameters or "" 69 | return { 70 | key_and_value.split("=")[0]: key_and_value.split("=")[1] 71 | for key_and_value in property_parameters_str.split(",") 72 | if key_and_value.count("=") == 1 73 | } 74 | 75 | def has_property_parameter(self, key: str) -> Optional[bool]: 76 | """ 77 | Return whether this property has a property parameter with a specific *key*. 78 | 79 | :param key: What key to search for. 80 | :return: boolean whether this property has a property parameter with a specific *key*. 81 | """ 82 | return key in self.property_parameters 83 | 84 | def get_property_parameter(self, key: str) -> Optional[str]: 85 | """ 86 | Get a property parameter's value with a specific key. 87 | 88 | :param key: The identifier of the property parameter. 89 | :return: The requested property parameter, or if that is not present, the default value. 90 | """ 91 | return self.property_parameters.get(key, None) 92 | 93 | def get_property_parameter_default(self, key: str, default: str) -> str: 94 | """ 95 | Get a property parameter's value with a specific key, where the default may not be None. 96 | 97 | :param key: The identifier of the property parameter. 98 | :param default: A value to return when the property parameter is not present, which may not be None. 99 | :return: The requested property parameter, or if that is not present, the default value. 100 | """ 101 | return self.property_parameters.get(key, default) 102 | 103 | @property 104 | def value(self) -> Optional[str]: 105 | """Return the value of this property.""" 106 | return self._value 107 | 108 | @property 109 | def as_original_string(self) -> str: 110 | """ 111 | Return the iCalendar representation of the parameter. 112 | :return: the iCalendar string representation. 113 | """ 114 | add_subs = f";{self._property_parameters}" if self._property_parameters else "" 115 | return f"{self._name}{add_subs}:{self._value}" 116 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_free_busy.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from ical_library.base_classes.component import Component 4 | from ical_library.exceptions import MissingRequiredProperty 5 | from ical_library.help_modules.timespan import TimespanWithParent 6 | from ical_library.ical_properties.cal_address import Attendee, Organizer 7 | from ical_library.ical_properties.dt import DTEnd, DTStamp, DTStart 8 | from ical_library.ical_properties.pass_properties import Comment, Contact, RequestStatus, UID, URL 9 | from ical_library.ical_properties.periods import FreeBusyProperty 10 | 11 | 12 | class VFreeBusy(Component): 13 | """ 14 | This class represents the VFREEBUSY component specified in RFC 5545 in '3.6.4. Free/Busy Component'. 15 | 16 | A "VFREEBUSY" calendar component is a grouping of component properties that represents either a request for free 17 | or busy time information, a reply to a request for free or busy time information, or a published set of busy time 18 | information. 19 | 20 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 21 | :param dtstamp: The DTStamp property. Required and must occur exactly once. 22 | :param uid: The UID property. Required and must occur exactly once. 23 | :param contact: The Contact property. Optional, but may occur at most once. 24 | :param dtstart: The DTStart property. Optional, but may occur at most once. 25 | :param dtend: The DTEnd property. Optional, but may occur at most once. 26 | :param organizer: The Organizer property. Optional, but may occur at most once. 27 | :param url: The URL property. Optional, but may occur at most once. 28 | :param attendee: The Attendee property. Optional, but may occur multiple times. 29 | :param comment: The Comment property. Optional, but may occur multiple times. 30 | :param freebusy: The FreeBusyProperty property. Optional, but may occur multiple times. 31 | :param rstatus: The RequestStatus property. Optional, but may occur multiple times. 32 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 33 | """ 34 | 35 | def __init__( 36 | self, 37 | dtstamp: Optional[DTStamp] = None, 38 | uid: Optional[UID] = None, 39 | contact: Optional[Contact] = None, 40 | dtstart: Optional[DTStart] = None, 41 | dtend: Optional[DTEnd] = None, 42 | organizer: Optional[Organizer] = None, 43 | url: Optional[URL] = None, 44 | attendee: Optional[List[Attendee]] = None, 45 | comment: Optional[List[Comment]] = None, 46 | freebusy: Optional[List[FreeBusyProperty]] = None, 47 | rstatus: Optional[List[RequestStatus]] = None, 48 | parent: Optional[Component] = None, 49 | ): 50 | super().__init__("VFREEBUSY", parent=parent) 51 | 52 | # Required 53 | self._dtstamp: Optional[DTStamp] = self.as_parent(dtstamp) 54 | self._uid: Optional[UID] = self.as_parent(uid) 55 | 56 | # Optional, may only occur once 57 | self.contact: Optional[Contact] = self.as_parent(contact) 58 | self.dtstart: Optional[DTStart] = self.as_parent(dtstart) 59 | self.dtend: Optional[DTEnd] = self.as_parent(dtend) 60 | self.organizer: Optional[Organizer] = self.as_parent(organizer) 61 | self.url: Optional[URL] = self.as_parent(url) 62 | 63 | # Optional, may occur more than once 64 | self.attendee: Optional[List[Attendee]] = self.as_parent(attendee) 65 | self.comment: Optional[List[Comment]] = self.as_parent(comment) 66 | self.freebusy: Optional[List[FreeBusyProperty]] = self.as_parent(freebusy) 67 | self.rstatus: Optional[List[RequestStatus]] = self.as_parent(rstatus) 68 | 69 | def __repr__(self) -> str: 70 | """Overwrite the repr to create a better representation for the item.""" 71 | return f"VFreeBusy({self.dtstart.value if self.dtstart else ''}, {self.dtend.value if self.dtend else ''})" 72 | 73 | @property 74 | def dtstamp(self) -> DTStamp: 75 | """A getter to ensure the required property is set.""" 76 | if self._dtstamp is None: 77 | raise MissingRequiredProperty(self, "dtstamp") 78 | return self._dtstamp 79 | 80 | @dtstamp.setter 81 | def dtstamp(self, value: DTStamp): 82 | """A setter to set the required property.""" 83 | self._dtstamp = value 84 | 85 | @property 86 | def uid(self) -> UID: 87 | """A getter to ensure the required property is set.""" 88 | if self._uid is None: 89 | raise MissingRequiredProperty(self, "uid") 90 | return self._uid 91 | 92 | @uid.setter 93 | def uid(self, value: UID): 94 | """A setter to set the required property.""" 95 | self._uid = value 96 | 97 | @property 98 | def timespan(self) -> Optional[TimespanWithParent]: 99 | start = self.dtstart.datetime_or_date_value if self.dtstart else None 100 | end = self.dtend.datetime_or_date_value if self.dtend else None 101 | if start is None: 102 | return None 103 | if end is None: 104 | TimespanWithParent(parent=self, begin=start, end=start) 105 | return TimespanWithParent(parent=self, begin=start, end=end) 106 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/dt.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import pendulum 4 | from pendulum import Date, DateTime 5 | from pendulum.tz.timezone import FixedTimezone 6 | 7 | from ical_library.base_classes.property import Property 8 | from ical_library.help_modules import dt_utils 9 | 10 | 11 | class _DTBoth(Property): 12 | """This property class should be inherited. It represents a property that contain a datetime or date as value.""" 13 | 14 | @property 15 | def datetime_or_date_value(self) -> Union[Date, DateTime]: 16 | """Return the value as a DateTime or Date value taking into account the optional TZID property parameter.""" 17 | value = dt_utils.parse_date_or_datetime(self.value) 18 | if isinstance(value, DateTime): 19 | tz_id = self.get_property_parameter("TZID") 20 | if value.tz or not tz_id: 21 | return value 22 | return self.parent.tree_root.get_aware_dt_for_timezone(dt=value, tzid=tz_id) 23 | elif isinstance(value, Date): 24 | return value 25 | else: 26 | raise TypeError(f"Unknown {type(value)=} returned for {value=}.") 27 | 28 | def get_datetime_or_date_value_in_specific_tz(self, tz: FixedTimezone) -> Union[Date, DateTime]: 29 | """Return the value as a DateTime or Date value in a specific timezone.""" 30 | value = dt_utils.parse_date_or_datetime(self.value) 31 | if isinstance(value, DateTime): 32 | return value.in_timezone(tz) 33 | elif isinstance(value, Date): 34 | return value 35 | else: 36 | raise TypeError(f"Unknown {type(value)=} returned for {value=}.") 37 | 38 | 39 | class _DTSingular(Property): 40 | """This property class should be inherited. It represents a property that can only contain a datetime as value.""" 41 | 42 | @property 43 | def datetime(self) -> DateTime: 44 | """Return the value as a DateTime value taking into account the optional TZID property parameter.""" 45 | value = pendulum.parse(self.value, tz=None) 46 | tz_id = self.get_property_parameter("TZID") 47 | if value.tz or not tz_id: 48 | return value 49 | return self.parent.tree_root.get_aware_dt_for_timezone(dt=value, tzid=tz_id) 50 | 51 | 52 | # Value & TZInfo 53 | class DTStart(_DTBoth): 54 | """The DTSTART property specifies when the calendar component begins..""" 55 | 56 | pass 57 | 58 | 59 | # Value & TZInfo 60 | class DTEnd(_DTBoth): 61 | """The DTEND property specifies the date and time that a calendar component ends.""" 62 | 63 | pass 64 | 65 | 66 | # Value & TZInfo 67 | class Due(_DTBoth): 68 | """This DUE property defines the date and time that a to-do is expected to be completed..""" 69 | 70 | pass 71 | 72 | 73 | # Value & TZInfo 74 | class RecurrenceID(_DTBoth): 75 | """ 76 | The RECURRENCE-ID property is defined as followed. 77 | 78 | This property is used in conjunction with the "UID" and "SEQUENCE" properties to identify a specific instance of 79 | a recurring "VEVENT", "VTODO", or "VJOURNAL" calendar component. The property value is the original value of the 80 | "DTSTART" property of the recurrence instance. Value Type: The default value type is DATE-TIME. The value type 81 | can be set to a DATE value type. This property MUST have the same value type as the "DTSTART" property contained 82 | within the recurring component. Furthermore, this property MUST be specified as a date with local time if and 83 | only if the "DTSTART" property contained within the recurring component is specified as a date with local time. 84 | """ 85 | 86 | @classmethod 87 | def get_ical_name_of_class(cls) -> str: 88 | """Overwrite the iCal name of this class as it is not *LASTMODIFIED* but *LAST-MODIFIED*.""" 89 | return "RECURRENCE-ID" 90 | 91 | 92 | # Only date-time 93 | class DTStamp(_DTBoth): 94 | """The DTSTAMP property is defined as followed. 95 | 96 | In the case of an iCalendar object that specifies a "METHOD" property, this property specifies the date and time 97 | that the instance of the iCalendar object was created. In the case of an iCalendar object that doesn't specify a 98 | "METHOD" property, this property specifies the date and time that the information associated with the calendar 99 | component was last revised in the calendar store. 100 | """ 101 | 102 | pass 103 | 104 | 105 | # Only date-time 106 | class Completed(_DTSingular): 107 | """The COMPLETED property defines the date and time that a to-do was actually completed.""" 108 | 109 | pass 110 | 111 | 112 | # Only date-time 113 | class Created(_DTSingular): 114 | """ 115 | The CREATED property defines the date and time that the calendar information was created by the calendar 116 | user agent in the calendar store. 117 | """ 118 | 119 | pass 120 | 121 | 122 | # Only date-time 123 | class LastModified(_DTSingular): 124 | """ 125 | The LAST-MODIFIED property specifies the date and time that the information associated with the calendar component 126 | was last revised in the calendar store. 127 | """ 128 | 129 | @classmethod 130 | def get_ical_name_of_class(cls) -> str: 131 | """Overwrite the iCal name of this class as it is not *LASTMODIFIED* but *LAST-MODIFIED*.""" 132 | return "LAST-MODIFIED" 133 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_calendar.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, TYPE_CHECKING 2 | 3 | from pendulum import DateTime 4 | 5 | from ical_library.base_classes.component import Component 6 | from ical_library.exceptions import MissingRequiredProperty 7 | from ical_library.ical_components.v_event import VEvent 8 | from ical_library.ical_components.v_free_busy import VFreeBusy 9 | from ical_library.ical_components.v_journal import VJournal 10 | from ical_library.ical_components.v_timezone import VTimeZone 11 | from ical_library.ical_components.v_todo import VToDo 12 | from ical_library.ical_properties.pass_properties import CalScale, Method, ProdID, Version 13 | 14 | if TYPE_CHECKING: 15 | from ical_library.timeline import Timeline 16 | 17 | 18 | class VCalendar(Component): 19 | """ 20 | This class represents the VCALENDAR component specified in RFC 5545 in '3.6. Calendar Components'. 21 | 22 | The "VCALENDAR" component consists of a sequence of calendar properties and one or more calendar components. 23 | The calendar properties are attributes that apply to the calendar object as a whole. The calendar components are 24 | collections of properties that express a particular calendar semantic. For example, the calendar component can 25 | specify an event, a to-do, a journal entry, time zone information, free/busy time information, or an alarm. 26 | 27 | :param prodid: The ProdID property. Required and must occur exactly once. 28 | :param version: The Version property. Required and must occur exactly once. 29 | :param calscale: The CalScale property. Optional, but may occur at most once. 30 | :param method: The Method property. Optional, but may occur at most once. 31 | :param events: Optional list of VEvent components. Each component may occur multiple times. 32 | :param todos: Optional list of VToDo components. Each component may occur multiple times. 33 | :param journals: Optional list of VJournal components. Each component may occur multiple times. 34 | :param free_busy_list: Optional list of VFreeBusy components. Each component may occur multiple times. 35 | :param time_zones: Optional list of VTimeZone components. Each component may occur multiple times. 36 | """ 37 | 38 | def __init__( 39 | self, 40 | prodid: Optional[ProdID] = None, 41 | version: Optional[Version] = None, 42 | calscale: Optional[CalScale] = None, 43 | method: Optional[Method] = None, 44 | events: Optional[List[VEvent]] = None, 45 | todos: Optional[List[VToDo]] = None, 46 | journals: Optional[List[VJournal]] = None, 47 | free_busy_list: Optional[List[VFreeBusy]] = None, 48 | time_zones: Optional[List[VTimeZone]] = None, 49 | ): 50 | super().__init__("VCALENDAR") 51 | 52 | # Required properties, only one occurrence allowed. 53 | self._prodid: Optional[ProdID] = self.as_parent(prodid) 54 | self._version: Optional[Version] = self.as_parent(version) 55 | 56 | # Optional properties, must not occur more than once. 57 | self.calscale: Optional[CalScale] = self.as_parent(calscale) 58 | self.method: Optional[Method] = self.as_parent(method) 59 | 60 | # These are children Components 61 | self.events: List[VEvent] = events or [] 62 | self.todos: List[VToDo] = todos or [] 63 | self.journals: List[VJournal] = journals or [] 64 | self.free_busy_list: List[VFreeBusy] = free_busy_list or [] 65 | self.time_zones: List[VTimeZone] = time_zones or [] 66 | 67 | # Only the VCalender stores the entire list. 68 | self._lines: Optional[List[str]] = None 69 | 70 | def __repr__(self) -> str: 71 | """Overwrite the repr to create a better representation for the item.""" 72 | return f"VCalendar({self.prodid.value}, {self.version.value})" 73 | 74 | @property 75 | def prodid(self) -> ProdID: 76 | """A getter to ensure the required property is set.""" 77 | if self._prodid is None: 78 | raise MissingRequiredProperty(self, "prodid") 79 | return self._prodid 80 | 81 | @prodid.setter 82 | def prodid(self, value: ProdID): 83 | """A setter to set the required property.""" 84 | self._prodid = value 85 | 86 | @property 87 | def version(self) -> Version: 88 | """A getter to ensure the required property is set.""" 89 | if self._version is None: 90 | raise MissingRequiredProperty(self, "version") 91 | return self._version 92 | 93 | @version.setter 94 | def version(self, value: Version): 95 | """A setter to set the required property.""" 96 | self._version = value 97 | 98 | @property 99 | def calendar_scale(self) -> str: 100 | """Return the calendar scale according to RFC 5545.""" 101 | return self.calscale.value if self.calscale else "GREGORIAN" 102 | 103 | def get_timezone(self, tzid: str) -> VTimeZone: 104 | """Get the corresponding VTimeZone object based on the given timezone identifier.""" 105 | for timezone in self.time_zones: 106 | if timezone.tzid.value == tzid: 107 | return timezone 108 | raise ValueError(f"Could not find Timezone with {tzid=}.") 109 | 110 | def get_aware_dt_for_timezone(self, dt: DateTime, tzid: str) -> DateTime: 111 | """Return the timezone aware DateTime object for a given TimeZone identifier.""" 112 | return self.get_timezone(tzid).convert_naive_datetime_to_aware(dt) 113 | 114 | @property 115 | def timeline(self) -> "Timeline": 116 | """Return a timeline of VEvents from 1970-00-00T00:00:00 to 2100-00-00T00:00:00.""" 117 | from ical_library.timeline import Timeline 118 | 119 | return Timeline(self) 120 | 121 | def get_limited_timeline(self, start: Optional[DateTime], end: Optional[DateTime]) -> "Timeline": 122 | """ 123 | Return a timeline of VEvents limited by *start* and *end* 124 | 125 | :param start: Only include events in the timeline with a starting date later than this value. 126 | :param end: Only include events in the timeline with a starting date earlier than this value. 127 | """ 128 | from ical_library.timeline import Timeline 129 | 130 | return Timeline(self, start, end) 131 | 132 | def parse_component(self, lines: List[str], line_number: int) -> int: 133 | """ 134 | Parse a new component in the RAW string list. 135 | :param lines: A list of all the lines in the iCalendar file. 136 | :param line_number: The line number at which this component starts. 137 | :return: The line number at which this component ends. 138 | """ 139 | self._lines = lines 140 | return super().parse_component(lines=lines, line_number=line_number) 141 | 142 | def get_original_ical_text(self, start_line: int, end_line: int) -> str: 143 | """ 144 | Get the original iCAL text for your property from the RAW string list. 145 | :param start_line: The starting line index for the component you wish to show. 146 | :param end_line: The ending line index for the component you wish to show. 147 | :return: The complete string, as it was in the RAW string list, for the component you wish to show. 148 | """ 149 | lines = self._lines 150 | if lines is None: 151 | raise TypeError("We should first parse the component section before calling this function.") 152 | return "\n".join(line for line in self.tree_root._lines[max(0, start_line) : min(len(lines), end_line)]) 153 | -------------------------------------------------------------------------------- /src/ical_library/timeline.py: -------------------------------------------------------------------------------- 1 | import heapq 2 | from collections import defaultdict 3 | from typing import Dict, Iterator, List, Optional, Tuple, Union 4 | 5 | from pendulum import Date, DateTime 6 | 7 | from ical_library.base_classes.component import Component 8 | from ical_library.help_modules.lru_cache import instance_lru_cache 9 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 10 | from ical_library.ical_components import VCalendar, VFreeBusy 11 | from ical_library.ical_components.abstract_components import AbstractComponentWithRecurringProperties 12 | 13 | 14 | class Timeline: 15 | """ 16 | This class is a wrapper to make it easy to see what the order of each component based on the start date. 17 | 18 | Inside this class there are multiple methods to iterate over all the components present. However, one should note 19 | that often the recurrence properties for components specify only a lower bound and will therefore proceed to 20 | infinity. To prevent us having an infinite list of items to iterate on, we define upper and lower bounds. 21 | You should set these upper bounds to the absolute minimum start date and absolute maximum end date that you would 22 | ever need. So if you need to do 10 different queries, this start and end date should range all of these, so it 23 | doesn't need to compute the list of components in the range over and over again. The functions themselves(e.g. 24 | :function:`self.includes` and :function:`self.intersects`) help you to limit the exact range you want to return 25 | components for. 26 | 27 | :param v_calendar: The VCalendar object we are iterating over. 28 | :param start_date: The minimum ending date of each event that is returned inside this timeline. 29 | :param end_date: The maximum starting date of each event that is return inside this timeline. 30 | """ 31 | 32 | def __init__( 33 | self, v_calendar: VCalendar, start_date: Optional[DateTime] = None, end_date: Optional[DateTime] = None 34 | ): 35 | self.v_calendar: VCalendar = v_calendar 36 | self._start_date: DateTime = start_date or DateTime(1970, 1, 1) 37 | self._end_date: DateTime = end_date or DateTime(2100, 1, 1) 38 | 39 | def __repr__(self) -> str: 40 | return f"Timeline({self._start_date}, {self._end_date})" 41 | 42 | @property 43 | def start_date(self) -> DateTime: 44 | """Return the start date of the timeline. No event should end before this value.""" 45 | return self._start_date 46 | 47 | @start_date.setter 48 | def start_date(self, value) -> None: 49 | """Set the start date of the timeline.""" 50 | self._start_date = value 51 | self.get_timespan.cache_clear() 52 | 53 | @property 54 | def end_date(self) -> DateTime: 55 | """Return the end date of the timeline. No event should start before this value.""" 56 | return self._end_date 57 | 58 | @end_date.setter 59 | def end_date(self, value) -> None: 60 | """Set the end date of the timeline.""" 61 | self._end_date = value 62 | self.get_timespan.cache_clear() 63 | 64 | @instance_lru_cache() 65 | def get_timespan(self) -> Timespan: 66 | """Return the start and end date as a Timespan.""" 67 | return Timespan(self.start_date, self.end_date) 68 | 69 | @staticmethod 70 | def __get_items_to_exclude_from_recurrence( 71 | all_components: List[Component], 72 | ) -> Dict[str, Union[List[Date], List[DateTime]]]: 73 | """ 74 | Deduplicate recurring components. Sometimes it happens that recurring events are changed and this will cause 75 | them to both be present as a standard component and in the recurrence. 76 | :param all_components: The list of all component children of the VCalendar instance. 77 | :return: A deduplicated list of components. 78 | """ 79 | start_date_to_timespan_dict: Dict[str, Union[List[Date], List[DateTime]]] = defaultdict(list) 80 | for component in all_components: 81 | if isinstance(component, AbstractComponentWithRecurringProperties) and component.recurrence_id is not None: 82 | start_date_to_timespan_dict[component.uid.value].append(component.recurrence_id.datetime_or_date_value) 83 | return start_date_to_timespan_dict 84 | 85 | def __explode_recurring_components(self) -> List[TimespanWithParent]: 86 | """ 87 | Get a de-duplicated list of all components with a start date, including the recurring components. This means 88 | that we add all child component of the :class:`VCalendar` (except for the :class:`VTimeZone` instances) to a 89 | list and then add all extra occurrences (as recurring components) according to the recurrence properties: 90 | :class:`RRule`, :class:`RDate` and :class:`EXDate`. 91 | :return: A de-duplicated list of all components, including the recurring occurrences of the components. 92 | """ 93 | list_of_timestamps_with_parents: List[TimespanWithParent] = [] 94 | all_children = self.v_calendar.children 95 | uid_to_datetime_to_exclude = self.__get_items_to_exclude_from_recurrence(all_children) 96 | for c in all_children: 97 | # Do some initial filtering. 98 | if isinstance(c, AbstractComponentWithRecurringProperties): 99 | if c.max_recurring_timespan.intersects(self.get_timespan()): 100 | values_to_exclude = uid_to_datetime_to_exclude[c.uid.value] # .get defaults to None. 101 | list_of_timestamps_with_parents.extend( 102 | c.expand_component_in_range(self.get_timespan(), values_to_exclude) 103 | ) 104 | elif isinstance(c, VFreeBusy): 105 | if c.timespan.intersects(self.get_timespan()): 106 | list_of_timestamps_with_parents.append(c.timespan) 107 | else: 108 | # There is no way to extend iana-props or x-props for now. If you feel like implementing this, please 109 | # let me know and open a PR :). 110 | pass 111 | return list_of_timestamps_with_parents 112 | 113 | def iterate(self) -> Iterator[Tuple[TimespanWithParent, Component]]: 114 | """ 115 | Iterate over the `self.__explode_recurring_components()` in chronological order. 116 | 117 | Implementation detail: Using a heap is faster than sorting if the number of events (n) is much bigger than the 118 | number of events we extract from the iterator (k). Complexity: O(n + k log n). 119 | """ 120 | heap: List[TimespanWithParent] = self.__explode_recurring_components() 121 | heapq.heapify(heap) 122 | while heap: 123 | popped: TimespanWithParent = heapq.heappop(heap) 124 | yield popped, popped.parent 125 | 126 | def includes(self, start: DateTime, stop: DateTime) -> Iterator[Component]: 127 | """Iterate (in chronological order) over every component that is in the specified timespan.""" 128 | query_timespan = Timespan(start, stop) 129 | for timespan, event in self.iterate(): 130 | if timespan.is_included_in(query_timespan): 131 | yield event 132 | 133 | def overlapping(self, start: DateTime, stop: DateTime) -> Iterator[Component]: 134 | """Iterate (in chronological order) over every component that has an intersection with the timespan.""" 135 | query_timespan = Timespan(start, stop) 136 | for timespan, event in self.iterate(): 137 | if timespan.intersects(query_timespan): 138 | yield event 139 | 140 | def start_after(self, instant: DateTime) -> Iterator[Component]: 141 | """Iterate (in chronological order) on every component larger than instant in chronological order.""" 142 | for timespan, event in self.iterate(): 143 | if timespan.begin > instant: 144 | yield event 145 | 146 | def at(self, instant: DateTime) -> Iterator[Component]: 147 | """Iterate (in chronological order) over all component that are occurring during `instant`.""" 148 | for timespan, event in self.iterate(): 149 | if timespan.includes(instant): 150 | yield event 151 | -------------------------------------------------------------------------------- /tests/ical_components/test_property_mapping.py: -------------------------------------------------------------------------------- 1 | from ical_library.ical_components import ( 2 | DayLight, 3 | Standard, 4 | VAlarm, 5 | VCalendar, 6 | VEvent, 7 | VFreeBusy, 8 | VJournal, 9 | VTimeZone, 10 | VToDo, 11 | ) 12 | from ical_library.ical_properties.cal_address import Attendee, Organizer 13 | from ical_library.ical_properties.dt import Completed, Created, DTEnd, DTStamp, DTStart, Due, LastModified, RecurrenceID 14 | from ical_library.ical_properties.geo import GEO 15 | from ical_library.ical_properties.ical_duration import ICALDuration 16 | from ical_library.ical_properties.ints import PercentComplete, Priority, Repeat, Sequence 17 | from ical_library.ical_properties.pass_properties import ( 18 | Action, 19 | Attach, 20 | CalScale, 21 | Categories, 22 | Class, 23 | Comment, 24 | Contact, 25 | Description, 26 | Location, 27 | Method, 28 | ProdID, 29 | RelatedTo, 30 | RequestStatus, 31 | Resources, 32 | Status, 33 | Summary, 34 | TimeTransparency, 35 | TZID, 36 | TZName, 37 | TZURL, 38 | UID, 39 | URL, 40 | Version, 41 | ) 42 | from ical_library.ical_properties.periods import EXDate, FreeBusyProperty, RDate 43 | from ical_library.ical_properties.rrule import RRule 44 | from ical_library.ical_properties.trigger import Trigger 45 | from ical_library.ical_properties.tz_offset import TZOffsetFrom, TZOffsetTo 46 | 47 | 48 | def test_get_property_ical_names(): 49 | assert VCalendar.get_property_ical_names() == {"calscale", "prodid", "version", "method"} 50 | assert VAlarm.get_property_ical_names() == {"attach", "repeat", "trigger", "action", "duration"} 51 | 52 | 53 | def test_valarm_get_property_mapping(): 54 | assert VAlarm._get_property_mapping() == { 55 | "ACTION": ("action", Action, False), 56 | "ATTACH": ("attach", Attach, False), 57 | "DURATION": ("duration", ICALDuration, False), 58 | "REPEAT": ("repeat", Repeat, False), 59 | "TRIGGER": ("trigger", Trigger, False), 60 | } 61 | 62 | 63 | def test_vcalendar_get_property_mapping(): 64 | assert VCalendar._get_property_mapping() == { 65 | "CALSCALE": ("calscale", CalScale, False), 66 | "METHOD": ("method", Method, False), 67 | "PRODID": ("prodid", ProdID, False), 68 | "VERSION": ("version", Version, False), 69 | } 70 | 71 | 72 | def test_vevent_get_property_mapping(): 73 | assert VEvent._get_property_mapping() == { 74 | "ATTACH": ("attach", Attach, True), 75 | "ATTENDEE": ("attendee", Attendee, True), 76 | "CATEGORIES": ("categories", Categories, True), 77 | "CLASS": ("ical_class", Class, False), 78 | "COMMENT": ("comment", Comment, True), 79 | "CONTACT": ("contact", Contact, True), 80 | "CREATED": ("created", Created, False), 81 | "DESCRIPTION": ("description", Description, False), 82 | "DTEND": ("dtend", DTEnd, False), 83 | "DTSTAMP": ("dtstamp", DTStamp, False), 84 | "DTSTART": ("dtstart", DTStart, False), 85 | "DURATION": ("duration", ICALDuration, False), 86 | "EXDATE": ("exdate", EXDate, True), 87 | "GEO": ("geo", GEO, False), 88 | "LAST-MODIFIED": ("last_modified", LastModified, False), 89 | "LOCATION": ("location", Location, False), 90 | "ORGANIZER": ("organizer", Organizer, False), 91 | "PRIORITY": ("priority", Priority, False), 92 | "RDATE": ("rdate", RDate, True), 93 | "RECURRENCE-ID": ("recurrence_id", RecurrenceID, False), 94 | "RELATED-TO": ("related", RelatedTo, True), 95 | "REQUEST-STATUS": ("rstatus", RequestStatus, True), 96 | "RESOURCES": ("resources", Resources, True), 97 | "RRULE": ("rrule", RRule, False), 98 | "SEQUENCE": ("sequence", Sequence, False), 99 | "STATUS": ("status", Status, False), 100 | "SUMMARY": ("summary", Summary, False), 101 | "TRANSP": ("transp", TimeTransparency, False), 102 | "UID": ("uid", UID, False), 103 | "URL": ("url", URL, False), 104 | } 105 | 106 | 107 | def test_vfreebusy_get_property_mapping(): 108 | assert VFreeBusy._get_property_mapping() == { 109 | "ATTENDEE": ("attendee", Attendee, True), 110 | "COMMENT": ("comment", Comment, True), 111 | "CONTACT": ("contact", Contact, False), 112 | "DTEND": ("dtend", DTEnd, False), 113 | "DTSTAMP": ("dtstamp", DTStamp, False), 114 | "DTSTART": ("dtstart", DTStart, False), 115 | "FREEBUSY": ("freebusy", FreeBusyProperty, True), 116 | "ORGANIZER": ("organizer", Organizer, False), 117 | "REQUEST-STATUS": ("rstatus", RequestStatus, True), 118 | "UID": ("uid", UID, False), 119 | "URL": ("url", URL, False), 120 | } 121 | 122 | 123 | def test_vjournal_get_property_mapping(): 124 | assert VJournal._get_property_mapping() == { 125 | "ATTACH": ("attach", Attach, True), 126 | "ATTENDEE": ("attendee", Attendee, True), 127 | "CATEGORIES": ("categories", Categories, True), 128 | "CLASS": ("ical_class", Class, False), 129 | "COMMENT": ("comment", Comment, True), 130 | "CONTACT": ("contact", Contact, True), 131 | "CREATED": ("created", Created, False), 132 | "DESCRIPTION": ("description", Description, True), 133 | "DTSTAMP": ("dtstamp", DTStamp, False), 134 | "DTSTART": ("dtstart", DTStart, False), 135 | "EXDATE": ("exdate", EXDate, True), 136 | "LAST-MODIFIED": ("last_modified", LastModified, False), 137 | "ORGANIZER": ("organizer", Organizer, False), 138 | "RDATE": ("rdate", RDate, True), 139 | "RECURRENCE-ID": ("recurrence_id", RecurrenceID, False), 140 | "RELATED-TO": ("related", RelatedTo, True), 141 | "REQUEST-STATUS": ("rstatus", RequestStatus, True), 142 | "RRULE": ("rrule", RRule, False), 143 | "SEQUENCE": ("sequence", Sequence, False), 144 | "STATUS": ("status", Status, False), 145 | "SUMMARY": ("summary", Summary, False), 146 | "UID": ("uid", UID, False), 147 | "URL": ("url", URL, False), 148 | } 149 | 150 | 151 | def test_vtimezone_get_property_mapping(): 152 | assert VTimeZone._get_property_mapping() == { 153 | "LAST-MODIFIED": ("last_mod", LastModified, False), 154 | "TZID": ("tzid", TZID, False), 155 | "TZURL": ("tzurl", TZURL, False), 156 | } 157 | 158 | 159 | def test_vtodo_get_property_mapping(): 160 | assert VToDo._get_property_mapping() == { 161 | "ATTACH": ("attach", Attach, True), 162 | "ATTENDEE": ("attendee", Attendee, True), 163 | "CATEGORIES": ("categories", Categories, True), 164 | "CLASS": ("ical_class", Class, False), 165 | "COMMENT": ("comment", Comment, True), 166 | "COMPLETED": ("completed", Completed, False), 167 | "CONTACT": ("contact", Contact, True), 168 | "CREATED": ("created", Created, False), 169 | "DESCRIPTION": ("description", Description, False), 170 | "DTSTAMP": ("dtstamp", DTStamp, False), 171 | "DTSTART": ("dtstart", DTStart, False), 172 | "DUE": ("due", Due, False), 173 | "DURATION": ("duration", ICALDuration, False), 174 | "EXDATE": ("exdate", EXDate, True), 175 | "GEO": ("geo", GEO, False), 176 | "LAST-MODIFIED": ("last_modified", LastModified, False), 177 | "LOCATION": ("location", Location, False), 178 | "ORGANIZER": ("organizer", Organizer, False), 179 | "PERCENT-COMPLETE": ("percent", PercentComplete, False), 180 | "PRIORITY": ("priority", Priority, False), 181 | "RDATE": ("rdate", RDate, True), 182 | "RECURRENCE-ID": ("recurrence_id", RecurrenceID, False), 183 | "RELATED-TO": ("related", RelatedTo, True), 184 | "REQUEST-STATUS": ("rstatus", RequestStatus, True), 185 | "RESOURCES": ("resources", Resources, True), 186 | "RRULE": ("rrule", RRule, False), 187 | "SEQUENCE": ("sequence", Sequence, False), 188 | "STATUS": ("status", Status, False), 189 | "SUMMARY": ("summary", Summary, False), 190 | "UID": ("uid", UID, False), 191 | "URL": ("url", URL, False), 192 | } 193 | 194 | 195 | def test_daylight_and_get_property_mapping(): 196 | expected = { 197 | "COMMENT": ("comment", Comment, True), 198 | "DTSTART": ("dtstart", DTStart, False), 199 | "RDATE": ("rdate", RDate, True), 200 | "RRULE": ("rrule", RRule, False), 201 | "TZNAME": ("tzname", TZName, True), 202 | "TZOFFSETFROM": ("tzoffsetfrom", TZOffsetFrom, False), 203 | "TZOFFSETTO": ("tzoffsetto", TZOffsetTo, False), 204 | } 205 | assert DayLight._get_property_mapping() == expected 206 | assert Standard._get_property_mapping() == expected 207 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_journal.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Optional, Union 2 | 3 | from pendulum import Date, DateTime, Duration 4 | 5 | from ical_library.base_classes.component import Component 6 | from ical_library.help_modules import property_utils 7 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 8 | from ical_library.ical_components.abstract_components import ( 9 | AbstractComponentWithRecurringProperties, 10 | AbstractRecurrence, 11 | ) 12 | from ical_library.ical_properties.cal_address import Attendee, Organizer 13 | from ical_library.ical_properties.dt import _DTBoth, Created, DTStamp, DTStart, LastModified, RecurrenceID 14 | from ical_library.ical_properties.ints import Sequence 15 | from ical_library.ical_properties.pass_properties import ( 16 | Attach, 17 | Categories, 18 | Class, 19 | Comment, 20 | Contact, 21 | Description, 22 | RelatedTo, 23 | RequestStatus, 24 | Status, 25 | Summary, 26 | UID, 27 | URL, 28 | ) 29 | from ical_library.ical_properties.periods import EXDate, RDate 30 | from ical_library.ical_properties.rrule import RRule 31 | 32 | 33 | class VJournal(AbstractComponentWithRecurringProperties): 34 | """ 35 | This class represents the VJOURNAL component specified in RFC 5545 in '3.6.3. Journal Component'. 36 | 37 | A "VJOURNAL" calendar component is a grouping of component properties that represent one or more descriptive text 38 | notes associated with a particular calendar date. The "DTSTART" property is used to specify the calendar date with 39 | which the journal entry is associated. Generally, it will have a DATE value data type, but it can also be used to 40 | specify a DATE-TIME value data type. Examples of a journal entry include a daily record of a legislative body or 41 | a journal entry of individual telephone contacts for the day or an ordered list of accomplishments for the day. 42 | The "VJOURNAL" calendar component can also be used to associate a document with a calendar date. 43 | 44 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 45 | :param dtstamp: The DTStamp property. Required and must occur exactly once. 46 | :param uid: The UID property. Required and must occur exactly once. 47 | :param dtstart: The DTStart property. Optional and may occur at most once. 48 | :param rrule: The RRule property. Optional and may occur at most once. 49 | :param summary: The Summary property. Optional and may occur at most once. 50 | :param exdate: The EXDate property. Optional, but may occur multiple times. 51 | :param rdate: The RDate property. Optional, but may occur multiple times. 52 | :param comment: The Comment property. Optional, but may occur multiple times. 53 | :param ical_class: Optional Class property. Optional, but may occur at most once. 54 | :param created: The Created property. Optional, but may occur at most once. 55 | :param last_modified: Optional LastModified property. Optional, but may occur at most once. 56 | :param organizer: The Organizer property. Optional, but may occur at most once. 57 | :param sequence: The Sequence property. Optional, but may occur at most once. 58 | :param status: The Status property. Optional, but may occur at most once. 59 | :param url: The URL property. Optional, but may occur at most once. 60 | :param recurrence_id: Optional RecurrenceID property. Optional, but may occur at most once. 61 | :param attach: The Attach property. Optional, but may occur multiple times. 62 | :param attendee: The Attendee property. Optional, but may occur multiple times. 63 | :param categories: The Categories property. Optional, but may occur multiple times. 64 | :param contact: The Contact property. Optional, but may occur multiple times. 65 | :param description: The Description property. Optional, but may occur multiple times. 66 | :param related: The RelatedTo property. Optional, but may occur multiple times. 67 | :param rstatus: The RequestStatus property. Optional, but may occur multiple times. 68 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 69 | """ 70 | 71 | def __init__( 72 | self, 73 | dtstamp: Optional[DTStamp] = None, 74 | uid: Optional[UID] = None, 75 | dtstart: Optional[DTStart] = None, 76 | rrule: Optional[RRule] = None, 77 | summary: Optional[Summary] = None, 78 | exdate: Optional[List[EXDate]] = None, 79 | rdate: Optional[List[RDate]] = None, 80 | comment: Optional[List[Comment]] = None, 81 | ical_class: Optional[Class] = None, 82 | created: Optional[Created] = None, 83 | last_modified: Optional[LastModified] = None, 84 | organizer: Optional[Organizer] = None, 85 | sequence: Optional[Sequence] = None, 86 | status: Optional[Status] = None, 87 | url: Optional[URL] = None, 88 | recurrence_id: Optional[RecurrenceID] = None, 89 | attach: Optional[List[Attach]] = None, 90 | attendee: Optional[List[Attendee]] = None, 91 | categories: Optional[List[Categories]] = None, 92 | contact: Optional[List[Contact]] = None, 93 | description: Optional[List[Description]] = None, 94 | related: Optional[List[RelatedTo]] = None, 95 | rstatus: Optional[List[RequestStatus]] = None, 96 | parent: Optional[Component] = None, 97 | ): 98 | super().__init__( 99 | name="VJOURNAL", 100 | dtstamp=dtstamp, 101 | uid=uid, 102 | dtstart=dtstart, 103 | rrule=rrule, 104 | summary=summary, 105 | exdate=exdate, 106 | rdate=rdate, 107 | comment=comment, 108 | parent=parent, 109 | ) 110 | 111 | # Optional, may only occur once 112 | # As class is a reserved keyword in python, we prefixed it with `ical_`. 113 | self.ical_class: Optional[Class] = self.as_parent(ical_class) 114 | self.created: Optional[Created] = self.as_parent(created) 115 | self.last_modified: Optional[LastModified] = self.as_parent(last_modified) 116 | self.organizer: Optional[Organizer] = self.as_parent(organizer) 117 | self.sequence: Optional[Sequence] = self.as_parent(sequence) 118 | self.status: Optional[Status] = self.as_parent(status) 119 | self.url: Optional[URL] = self.as_parent(url) 120 | 121 | # Optional, may occur more than once 122 | self.attach: Optional[List[Attach]] = self.as_parent(attach) 123 | self.attendee: Optional[List[Attendee]] = self.as_parent(attendee) 124 | self.categories: Optional[List[Categories]] = self.as_parent(categories) 125 | self.contact: Optional[List[Contact]] = self.as_parent(contact) 126 | self.description: Optional[List[Description]] = self.as_parent(description) 127 | self.related: Optional[List[RelatedTo]] = self.as_parent(related) 128 | self.rstatus: Optional[List[RequestStatus]] = self.as_parent(rstatus) 129 | 130 | def __repr__(self) -> str: 131 | """Overwrite the repr to create a better representation for the item.""" 132 | return f"VJournal({self.start}: {self.summary.value if self.summary else ''})" 133 | 134 | @property 135 | def ending(self) -> Optional[_DTBoth]: 136 | """ 137 | Return the start time of the journal. This is because the Journal does not have a duration. 138 | 139 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 140 | """ 141 | return self.dtstart 142 | 143 | def get_duration(self) -> Optional[Duration]: 144 | """ 145 | Return an empty Duration as a Journal does not have a duration. 146 | 147 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 148 | """ 149 | return Duration() 150 | 151 | def expand_component_in_range( 152 | self, return_range: Timespan, starts_to_exclude: Union[List[Date], List[DateTime]] 153 | ) -> Iterator[TimespanWithParent]: 154 | """ 155 | Expand this VJournal in range according to its recurring *RDate*, *EXDate* and *RRule* properties. 156 | :param return_range: The timespan range on which we should return VJournal instances. 157 | :param starts_to_exclude: List of start Dates or list of start DateTimes of which we already know we should 158 | exclude them from our recurrence computation (as they have been completely redefined in another element). 159 | :return: Yield all recurring VJournal instances related to this VJournal in the given *return_range*. 160 | """ 161 | if self.timespan.intersects(return_range): 162 | yield self.timespan 163 | starts_to_exclude.append(self.start) 164 | 165 | start = self.start 166 | duration = self.computed_duration 167 | if not start or not duration: 168 | return None 169 | 170 | iterator = property_utils.expand_component_in_range( 171 | exdate_list=self.exdate or [], 172 | rdate_list=self.rdate or [], 173 | rrule=self.rrule, 174 | first_event_start=self.start, 175 | first_event_duration=self.computed_duration, 176 | starts_to_exclude=starts_to_exclude, 177 | return_range=return_range, 178 | make_tz_aware=None, 179 | ) 180 | 181 | for event_start_time, event_end_time in iterator: 182 | yield VRecurringJournal(original_component_instance=self, start=event_start_time).timespan 183 | 184 | 185 | class VRecurringJournal(AbstractRecurrence, VJournal): 186 | """ 187 | This class represents VJournal that are recurring. 188 | Inside the AbstractRecurrence class we overwrite specific dunder methods and property methods. 189 | This way our end users have a very similar interface to an actual VJournal but without us needing to code the exact 190 | same thing twice. 191 | 192 | :param original_component_instance: The original VJournal instance. 193 | :param start: The start of this occurrence. 194 | :param end: The end of this occurrence. 195 | """ 196 | 197 | def __init__(self, original_component_instance: VJournal, start: DateTime): 198 | self._original = original_component_instance 199 | self._start = start 200 | self._end = start 201 | super(VJournal, self).__init__("VJOURNAL", parent=original_component_instance) 202 | 203 | def __repr__(self) -> str: 204 | """Overwrite the repr to create a better representation for the item.""" 205 | return f"RVJournal({self._start}: {self.original.summary.value if self.original.summary else ''})" 206 | -------------------------------------------------------------------------------- /src/ical_library/ical_properties/periods.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal, Optional, Tuple, Union 2 | 3 | import pendulum 4 | from pendulum import Date, DateTime, Duration 5 | 6 | from ical_library.base_classes.property import Property 7 | from ical_library.help_modules import dt_utils 8 | 9 | 10 | class _PeriodFunctionality(Property): 11 | """ 12 | Provide methods to help to parse duration values. 13 | 14 | This class should be inherited by a Property. 15 | """ 16 | 17 | def _parse_period_values(self) -> List[Tuple[DateTime, DateTime]]: 18 | """ 19 | Parse multiple values, delimited by comma's, representing periods. 20 | 21 | Example value for self.value: 19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H 22 | :return: List of tuples containing two DateTimes representing the start and end of the duration respectively. 23 | """ 24 | list_of_periods: List[Tuple[DateTime, DateTime]] = [] 25 | for item in self.value.split(","): 26 | item = item.strip() 27 | instance = self._parse_individual_duration_str(item) 28 | if not isinstance(instance, tuple) or len(instance) != 2: 29 | raise TypeError(f"{instance} is of {type(instance)=} while it should be a tuple.") 30 | for index, sub_instance in enumerate(instance): 31 | if not isinstance(sub_instance, DateTime): 32 | raise TypeError( 33 | f"{instance[index]=} is of {type(sub_instance)=} while it should be of type " 34 | f"Tuple[DateTime, DateTime]." 35 | ) 36 | list_of_periods.append(instance) 37 | return list_of_periods 38 | 39 | def _parse_individual_datetime_or_duration_str(self, datetime_or_duration_str: str) -> Union[DateTime, Duration]: 40 | """ 41 | Parse an individual datetime or duration string. 42 | :param datetime_or_duration_str: A string represent either a datetime or duration. 43 | :return: A pendulum.DateTime if the string represented a datetime. Return a pendulum.Duration otherwise. 44 | """ 45 | tz_id = self.get_property_parameter("TZID") 46 | tz = self.parent.tree_root.get_timezone(tz_id).get_as_timezone_object() if tz_id else None 47 | return pendulum.parse(datetime_or_duration_str, tz=tz) 48 | 49 | def _parse_individual_duration_str(self, period_str: str) -> Tuple[DateTime, DateTime]: 50 | """ 51 | Parse an individual period represented by DateTime/DateTime or DateTime/Duration. 52 | 53 | :param period_str: The period to parse. Examples: 19960403T020000Z/19960403T040000Z or 19960404T010000Z/PT3H 54 | :return: A tuple containing two DateTimes representing the start and end of the duration respectively. 55 | """ 56 | first_str, second_str = period_str.split("/") 57 | first_instance: DateTime = self._parse_individual_datetime_or_duration_str(first_str) 58 | second_instance: Union[DateTime, Duration] = self._parse_individual_datetime_or_duration_str(second_str) 59 | if not isinstance(first_instance, DateTime): 60 | raise TypeError(f"Expected {period_str=} to contain a DateTime as first argument.") 61 | if isinstance(second_instance, DateTime): 62 | return first_instance, second_instance 63 | elif isinstance(second_instance, Duration): 64 | computed_datetime: DateTime = first_instance + second_instance # type: ignore # Pendulum at fault 65 | return first_instance, computed_datetime 66 | else: 67 | raise TypeError(f"Expected {period_str=} to contain a DateTime or Duration as second argument.") 68 | 69 | 70 | class _ExOrRDate(_PeriodFunctionality): 71 | """ 72 | Provide methods to help to parse different kind of values a Property could find. 73 | 74 | This class should be inherited by a Property. 75 | """ 76 | 77 | def _parse_datetime_values(self) -> List[DateTime]: 78 | """ 79 | Parses DateTime values. Example of a possible value of self.value: 19970714T123000Z,19970714T123300Z 80 | :return: A List of DateTimes representing the start of an event/component. 81 | """ 82 | list_of_datetimes: List[DateTime] = [] 83 | for item in self.value.split(","): 84 | item = item.strip() 85 | instance = self._parse_individual_datetime_or_duration_str(item) 86 | if not isinstance(instance, DateTime): 87 | raise TypeError(f"{instance} is of {type(instance)=} while it should be a DateTime.") 88 | list_of_datetimes.append(instance) 89 | return list_of_datetimes 90 | 91 | def _parse_date_values(self) -> List[Date]: 92 | """ 93 | Parse Date values. Example of a possible value of self.value: 19970101,19970120,19970217 94 | :return: A list of pendulum.Date representing the start of an event/component. 95 | """ 96 | list_of_dates: List[Date] = [] 97 | for item in self.value.split(","): 98 | instance = self._parse_individual_date_str(item) 99 | if not isinstance(instance, Date): 100 | raise TypeError(f"{instance} is of {type(instance)=} while it should be a Date.") 101 | list_of_dates.append(instance) 102 | return list_of_dates 103 | 104 | @staticmethod 105 | def _parse_individual_date_str(date: str) -> Date: 106 | """ 107 | Parse an individual date string. 108 | :param date: A string representing a date. 109 | :return: A pendulum.Date. 110 | """ 111 | return Date(int(date[0:4]), int(date[4:6]), int(date[6:8])) 112 | 113 | 114 | class FreeBusyProperty(_PeriodFunctionality): 115 | """ 116 | The FREEBUSY property defines one or more free or busy time intervals. 117 | 118 | Note: This class is called FreeBusyProperty to not be confused with the VFreeBusy component. 119 | """ 120 | 121 | @classmethod 122 | def get_ical_name_of_class(cls) -> str: 123 | """Overwrite the iCal name of this class as it is not *FREEBUSYPROPERTY* but *FREEBUSY*.""" 124 | return "FREEBUSY" 125 | 126 | @property 127 | def free_busy_type(self) -> str: 128 | """ 129 | Specifies the free or busy time type. 130 | 131 | Values are usually in the following list but can be anything: FREE, BUSY, BUSY-UNAVAILABLE, BUSY-TENTATIVE 132 | """ 133 | return self.get_property_parameter_default("FBTYPE", "BUSY") 134 | 135 | @property 136 | def periods(self) -> List[Tuple[DateTime, DateTime]]: 137 | """ 138 | All the periods present in this property for which we define a free or busy time. 139 | :return: A list of tuples, where each tuple values consists of two DateTimes indicating the start and end 140 | respectively. 141 | """ 142 | return self._parse_period_values() 143 | 144 | 145 | class EXDate(_ExOrRDate): 146 | """ 147 | The EXDATE property defines the list of DATE-TIME exceptions for recurring events, to-dos, journal entries, 148 | or time zone definitions. 149 | """ 150 | 151 | @property 152 | def kind(self) -> Optional[Literal["DATE-TIME", "DATE"]]: 153 | """The kind of the values. It is either DATE-TIME or DATE. The default is DATE-TIME.""" 154 | return self.property_parameters.get("VALUE", "DATE-TIME") 155 | 156 | @property 157 | def excluded_date_times(self) -> Union[List[DateTime], List[Date]]: 158 | """A list of all excluded Dates or DateTimes. The type will be according to kind reported by `self.kind()`.""" 159 | if self.kind == "DATE-TIME": 160 | return self._parse_datetime_values() 161 | elif self.kind == "DATE": 162 | return self._parse_date_values() 163 | else: 164 | raise ValueError(f"{self.kind=} should be one in ['DATE-TIME', 'DATE'].") 165 | 166 | 167 | class RDate(_ExOrRDate): 168 | """ 169 | The RDATE property defines the list of DATE-TIME values for recurring events, to-dos, journal entries, 170 | or time zone definitions. 171 | """ 172 | 173 | @property 174 | def kind(self) -> Optional[Literal["DATE-TIME", "DATE", "PERIOD"]]: 175 | """The kind of the values. It is either DATE-TIME, DATE or PERIOD. The default is DATE-TIME.""" 176 | return self.property_parameters.get("VALUE", "DATE-TIME") 177 | 178 | @property 179 | def all_values(self) -> Union[List[DateTime], List[Date], List[Tuple[DateTime, DateTime]]]: 180 | """ 181 | A list of all recurring Dates, DateTimes or Periods. The periods are defined by tuples containing two 182 | datetimes representing the start and stop respectively. The returned types in the list will be according to 183 | the kind reported by `self.kind()`. 184 | """ 185 | if self.kind == "DATE-TIME": 186 | return self._parse_datetime_values() 187 | elif self.kind == "DATE": 188 | return self._parse_date_values() 189 | elif self.kind == "PERIOD": 190 | return self._parse_period_values() 191 | else: 192 | raise ValueError(f"{self.kind=} should be one in ['DATE-TIME', 'DATE', 'PERIOD'].") 193 | 194 | def compute_max_end_date(self, component_duration: Duration) -> DateTime: 195 | """ 196 | To speed up the computation of the Timelines range, it's good to know the ending of the last recurring event 197 | of a recurrence property. This does not need to be perfect, it should just be an estimate (so we don't check 198 | EXDate and such). 199 | :param component_duration: The duration of the component which has the recurring properties. 200 | :return: An estimate of the maximum end date across all occurrences. This value should always be at least the 201 | actual highest recurrence end date 202 | """ 203 | max_value: Optional[DateTime] = None 204 | for value in self.all_values: 205 | if isinstance(value, Date): # This covers both Date and DateTime 206 | dt: DateTime = dt_utils.convert_time_object_to_datetime(value) 207 | dt: DateTime = dt + component_duration # type: ignore # Pendulum at fault here. 208 | dt = dt_utils.convert_time_object_to_aware_datetime(dt) 209 | if max_value is None or dt > max_value: 210 | max_value = dt 211 | elif isinstance(value, tuple) and len(value) == 2: 212 | dt: DateTime = dt_utils.convert_time_object_to_aware_datetime(value[1]) 213 | if max_value is None or dt > max_value: 214 | max_value = dt 215 | else: 216 | raise ValueError(f"Unexpected value encountered: {value}.") 217 | return max_value or DateTime.max 218 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/abstract_components.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Iterator, List, Optional, Union 3 | 4 | from pendulum import Date, DateTime, Duration, Period 5 | 6 | from ical_library.base_classes.component import Component 7 | from ical_library.exceptions import MissingRequiredProperty 8 | from ical_library.help_modules.lru_cache import instance_lru_cache 9 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 10 | from ical_library.ical_properties.dt import _DTBoth, DTStamp, DTStart, RecurrenceID 11 | from ical_library.ical_properties.pass_properties import Comment, Summary, UID 12 | from ical_library.ical_properties.periods import EXDate, RDate 13 | from ical_library.ical_properties.rrule import RRule 14 | 15 | 16 | class AbstractComponentWithRecurringProperties(Component, ABC): 17 | """ 18 | This class helps avoid code repetition with different :class:`Component` classes that have a duration and have 19 | recurring properties. 20 | 21 | This class is inherited by VEvent, VToDo and VJournal as these all have recurring properties like :class:`RRule`, 22 | :class:`RDate` and :class:`EXDate`. All properties they had in common are part of this class. 23 | Note: VJournal is the odd one out as these events don't have a duration. 24 | 25 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 26 | :param dtstamp: The DTStamp property. Required and must occur exactly once. 27 | :param uid: The UID property. Required and must occur exactly once. 28 | :param dtstart: The DTStart property. Optional and may occur at most once. 29 | :param rrule: The RRule property. Optional and may occur at most once. 30 | :param summary: The Summary property. Optional and may occur at most once. 31 | :param exdate: The EXDate property. Optional, but may occur multiple times. 32 | :param rdate: The RDate property. Optional, but may occur multiple times. 33 | :param comment: The Comment property. Optional, but may occur multiple times. 34 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 35 | """ 36 | 37 | def __init__( 38 | self, 39 | name: str, 40 | dtstamp: Optional[DTStamp] = None, 41 | uid: Optional[UID] = None, 42 | dtstart: Optional[DTStart] = None, 43 | rrule: Optional[RRule] = None, 44 | summary: Optional[Summary] = None, 45 | recurrence_id: Optional[RecurrenceID] = None, 46 | exdate: Optional[List[EXDate]] = None, 47 | rdate: Optional[List[RDate]] = None, 48 | comment: Optional[List[Comment]] = None, 49 | parent: Optional[Component] = None, 50 | ): 51 | super().__init__(name, parent=parent) 52 | 53 | # Required 54 | self._dtstamp: Optional[DTStamp] = self.as_parent(dtstamp) 55 | self._uid: Optional[UID] = self.as_parent(uid) 56 | 57 | # Optional, may only occur once 58 | self.dtstart: Optional[DTStart] = self.as_parent(dtstart) 59 | self.rrule: Optional[RRule] = self.as_parent(rrule) 60 | self.summary: Optional[Summary] = self.as_parent(summary) 61 | self.recurrence_id: Optional[RecurrenceID] = self.as_parent(recurrence_id) 62 | 63 | # Optional, may occur more than once 64 | self.exdate: Optional[List[EXDate]] = self.as_parent(exdate) 65 | self.rdate: Optional[List[RDate]] = self.as_parent(rdate) 66 | self.comment: Optional[List[Comment]] = self.as_parent(comment) 67 | 68 | @property 69 | def dtstamp(self) -> DTStamp: 70 | """A getter to ensure the required property is set.""" 71 | if self._dtstamp is None: 72 | raise MissingRequiredProperty(self, "dtstamp") 73 | return self._dtstamp 74 | 75 | @dtstamp.setter 76 | def dtstamp(self, value: DTStamp): 77 | """A setter to set the required property.""" 78 | self._dtstamp = value 79 | 80 | @property 81 | def uid(self) -> UID: 82 | """A getter to ensure the required property is set.""" 83 | if self._uid is None: 84 | raise MissingRequiredProperty(self, "uid") 85 | return self._uid 86 | 87 | @uid.setter 88 | def uid(self, value: UID): 89 | """A setter to set the required property.""" 90 | self._uid = value 91 | 92 | @property 93 | @abstractmethod 94 | def ending(self) -> _DTBoth: 95 | """ 96 | As the argument for this is different in each class, we ask this to be implemented. 97 | 98 | :return: The ending of the :class:`Component`, except for :class:`VJournal` which returns the start. 99 | """ 100 | pass 101 | 102 | @abstractmethod 103 | def get_duration(self) -> Optional[Duration]: 104 | """ 105 | As the duration is not present in each of them, we ask this to be implemented by the subclasses. 106 | 107 | :return: The duration of the :class:`Component`. 108 | """ 109 | pass 110 | 111 | @abstractmethod 112 | def expand_component_in_range( 113 | self, return_range: Timespan, starts_to_exclude: Union[List[Date], List[DateTime]] 114 | ) -> Iterator[TimespanWithParent]: 115 | """ 116 | Expand this component in range according to its recurring *RDate*, *EXDate* and *RRule* properties. 117 | :param return_range: The timespan range on which we should return VToDo instances. 118 | :param starts_to_exclude: List of start Dates or list of start DateTimes of which we already know we should 119 | exclude them from our recurrence computation (as they have been completely redefined in another element). 120 | :return: Yield all recurring VToDo instances related to this VToDo in the given *return_range*. 121 | """ 122 | pass 123 | 124 | def __eq__( 125 | self: "AbstractComponentWithRecurringProperties", other: "AbstractComponentWithRecurringProperties" 126 | ) -> bool: 127 | """Return whether the current instance and the other instance are the same.""" 128 | if type(self) != type(other): 129 | return False 130 | return ( 131 | self.dtstart == other.dtstart 132 | and self.ending == other.ending 133 | and self.summary == other.summary 134 | and self.comment == other.comment 135 | ) 136 | 137 | @property 138 | def timespan(self) -> Optional[TimespanWithParent]: 139 | """ 140 | Return a timespan as a property representing the start and end of the instance. 141 | :return: A timespan instance with this class instance as parent. 142 | """ 143 | if self.start is None: 144 | return None 145 | if self.end is None: 146 | TimespanWithParent(parent=self, begin=self.start, end=self.start) 147 | return TimespanWithParent(parent=self, begin=self.start, end=self.end) 148 | 149 | @property 150 | @instance_lru_cache() 151 | def start(self) -> Optional[Union[Date, DateTime]]: 152 | """Return the start of this Component as a :class:`Date` or :class:`DateTime` value.""" 153 | return self.dtstart.datetime_or_date_value if self.dtstart else None 154 | 155 | @property 156 | @instance_lru_cache() 157 | def end(self) -> Optional[Union[Date, DateTime]]: 158 | """Return the ending of this Component as a Date or DateTime value.""" 159 | if self.ending: 160 | return self.ending.datetime_or_date_value 161 | elif self.start and self.get_duration(): 162 | return self.start + self.get_duration() 163 | return None 164 | 165 | @property 166 | @instance_lru_cache() 167 | def computed_duration(self: "AbstractComponentWithRecurringProperties") -> Optional[Duration]: 168 | """Return the duration of this Component as a :class:`Date` or :class:`DateTime` value.""" 169 | if a_duration := self.get_duration(): 170 | return a_duration 171 | elif self.end and self.start: 172 | result: Period = self.end - self.start 173 | return result 174 | return None 175 | 176 | @property 177 | @instance_lru_cache() 178 | def max_recurring_timespan(self) -> Optional[Timespan]: 179 | if not self.start or not self.computed_duration: 180 | return None 181 | if not self.rrule and not self.rdate: 182 | return self.timespan 183 | max_dt: DateTime = DateTime.min 184 | if self.rdate: 185 | max_dt = max(max_dt, max([rdate.compute_max_end_date(self.computed_duration) for rdate in self.rdate])) 186 | if self.rrule: 187 | max_dt = max(max_dt, self.rrule.compute_max_end_date(self.start, self.computed_duration)) 188 | if max_dt != DateTime.min: 189 | return Timespan(self.start, max_dt) 190 | return None 191 | 192 | 193 | class AbstractRecurrence(AbstractComponentWithRecurringProperties, ABC): 194 | """ 195 | This class extends :class:`AbstractComponentWithRecurringProperties` to represent a recurring Component. 196 | 197 | This class is inherited by VRecurringEvent, VRecurringToDo and VRecurringJournal. When we compute the recurrence 198 | based on the :class:`RRule`, :class:`RDate` and :class:`EXDate` properties, we create new occurrences of that 199 | specific component. Instead of copying over all Properties (and using a lot of memory), this class overwrites the 200 | *__getattribute__* function to act like the original component for most attributes except for *start*, *end*, 201 | *original* and *parent*. 202 | """ 203 | 204 | def __getattribute__(self, var_name: str) -> Any: 205 | """ 206 | Overwrite this function to return the originals properties except for *start*, *end*, *original* and *parent*. 207 | 208 | Depending on the attributes *name* we are requesting, we either return its own properties or the original 209 | components properties. This way we don't need to copy over all the variables. 210 | :param var_name: Name of the attribute we are accessing. 211 | :return: The value of the attribute we are accessing either from the *original* or from this instance itself. 212 | """ 213 | if var_name in ("_start", "_end", "_original", "_parent", "start", "end", "original", "parent"): 214 | return object.__getattribute__(self, var_name) 215 | if var_name in ("_name", "_extra_child_components", "_extra_properties"): 216 | return object.__getattribute__(self._original, var_name) 217 | if var_name in self._original.get_property_ical_names(): 218 | return object.__getattribute__(self._original, var_name) 219 | return object.__getattribute__(self, var_name) 220 | 221 | def __setattr__(self, key: str, value: Any) -> None: 222 | """Overwrite the custom __setattr__ from Components to set it back to the standard behavior.""" 223 | object.__setattr__(self, key, value) 224 | 225 | @property 226 | def start(self) -> DateTime: 227 | """Return the start of this recurring event.""" 228 | return self._start 229 | 230 | @property 231 | def end(self) -> DateTime: 232 | """Return the end of this recurring event.""" 233 | return self._end 234 | 235 | @property 236 | def original(self) -> AbstractComponentWithRecurringProperties: 237 | """Return the original component that created this recurring component.""" 238 | return self._original 239 | 240 | @property 241 | def parent(self) -> Component: 242 | """Return the parent of the original component.""" 243 | return self._original.parent 244 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_todo.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Optional, Union 2 | 3 | from pendulum import Date, DateTime, Duration 4 | 5 | from ical_library.base_classes.component import Component 6 | from ical_library.help_modules import property_utils 7 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 8 | from ical_library.ical_components.abstract_components import ( 9 | AbstractComponentWithRecurringProperties, 10 | AbstractRecurrence, 11 | ) 12 | from ical_library.ical_components.v_alarm import VAlarm 13 | from ical_library.ical_properties.cal_address import Attendee, Organizer 14 | from ical_library.ical_properties.dt import ( 15 | _DTBoth, 16 | Completed, 17 | Created, 18 | DTStamp, 19 | DTStart, 20 | Due, 21 | LastModified, 22 | RecurrenceID, 23 | ) 24 | from ical_library.ical_properties.geo import GEO 25 | from ical_library.ical_properties.ical_duration import ICALDuration 26 | from ical_library.ical_properties.ints import PercentComplete, Priority, Sequence 27 | from ical_library.ical_properties.pass_properties import ( 28 | Attach, 29 | Categories, 30 | Class, 31 | Comment, 32 | Contact, 33 | Description, 34 | Location, 35 | RelatedTo, 36 | RequestStatus, 37 | Resources, 38 | Status, 39 | Summary, 40 | UID, 41 | URL, 42 | ) 43 | from ical_library.ical_properties.periods import EXDate, RDate 44 | from ical_library.ical_properties.rrule import RRule 45 | 46 | 47 | class VToDo(AbstractComponentWithRecurringProperties): 48 | """ 49 | This class represents the VTODO component specified in RFC 5545 in '3.6.2. To-Do Component'. 50 | 51 | A "VTODO" calendar component is a grouping of component properties and possibly "VALARM" calendar components that 52 | represent an action-item or assignment. For example, it can be used to represent an item of work assigned to an 53 | individual; such as "turn in travel expense today". 54 | 55 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 56 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 57 | :param dtstamp: The DTStamp property. Required and must occur exactly once. 58 | :param uid: The UID property. Required and must occur exactly once. 59 | :param dtstart: The DTStart property. Optional and may occur at most once. 60 | :param rrule: The RRule property. Optional and may occur at most once. 61 | :param summary: The Summary property. Optional and may occur at most once. 62 | :param exdate: The EXDate property. Optional, but may occur multiple times. 63 | :param rdate: The RDate property. Optional, but may occur multiple times. 64 | :param comment: The Comment property. Optional, but may occur multiple times. 65 | :param ical_class: Optional Class property. Optional, but may occur at most once. 66 | :param completed: The Completed property. Optional, but may occur at most once. 67 | :param created: The Created property. Optional, but may occur at most once. 68 | :param description: The Description property. Optional, but may occur at most once. 69 | :param duration: The ICALDuration property. Optional, but may occur at most once. 70 | :param geo: The GEO property. Optional, but may occur at most once. 71 | :param last_modified: Optional LastModified property. Optional, but may occur at most once. 72 | :param location: The Location property. Optional, but may occur at most once. 73 | :param organizer: The Organizer property. Optional, but may occur at most once. 74 | :param percent: The PercentComplete property. Optional, but may occur at most once. 75 | :param priority: The Priority property. Optional, but may occur at most once. 76 | :param sequence: The Sequence property. Optional, but may occur at most once. 77 | :param status: The Status property. Optional, but may occur at most once. 78 | :param url: The URL property. Optional, but may occur at most once. 79 | :param recurrence_id: Optional RecurrenceID property. Optional, but may occur at most once. 80 | :param due: The Due property. Optional, but may occur at most once. 81 | :param attach: The Attach property. Optional, but may occur multiple times. 82 | :param attendee: The Attendee property. Optional, but may occur multiple times. 83 | :param categories: The Categories property. Optional, but may occur multiple times. 84 | :param contact: The Contact property. Optional, but may occur multiple times. 85 | :param rstatus: The RequestStatus property. Optional, but may occur multiple times. 86 | :param related: The RelatedTo property. Optional, but may occur multiple times. 87 | :param resources: The Resources property. Optional, but may occur multiple times. 88 | """ 89 | 90 | def __init__( 91 | self, 92 | dtstamp: Optional[DTStamp] = None, 93 | uid: Optional[UID] = None, 94 | dtstart: Optional[DTStart] = None, 95 | rrule: Optional[RRule] = None, 96 | summary: Optional[Summary] = None, 97 | exdate: Optional[List[EXDate]] = None, 98 | rdate: Optional[List[RDate]] = None, 99 | comment: Optional[List[Comment]] = None, 100 | ical_class: Optional[Class] = None, 101 | completed: Optional[Completed] = None, 102 | created: Optional[Created] = None, 103 | description: Optional[Description] = None, 104 | duration: Optional[ICALDuration] = None, 105 | geo: Optional[GEO] = None, 106 | last_modified: Optional[LastModified] = None, 107 | location: Optional[Location] = None, 108 | organizer: Optional[Organizer] = None, 109 | percent: Optional[PercentComplete] = None, 110 | priority: Optional[Priority] = None, 111 | sequence: Optional[Sequence] = None, 112 | status: Optional[Status] = None, 113 | url: Optional[URL] = None, 114 | recurrence_id: Optional[RecurrenceID] = None, 115 | due: Optional[Due] = None, 116 | attach: Optional[List[Attach]] = None, 117 | attendee: Optional[List[Attendee]] = None, 118 | categories: Optional[List[Categories]] = None, 119 | contact: Optional[List[Contact]] = None, 120 | rstatus: Optional[List[RequestStatus]] = None, 121 | related: Optional[List[RelatedTo]] = None, 122 | resources: Optional[List[Resources]] = None, 123 | alarms: Optional[List[VAlarm]] = None, 124 | parent: Optional[Component] = None, 125 | ): 126 | super().__init__( 127 | name="VTODO", 128 | dtstamp=dtstamp, 129 | uid=uid, 130 | dtstart=dtstart, 131 | rrule=rrule, 132 | summary=summary, 133 | exdate=exdate, 134 | rdate=rdate, 135 | comment=comment, 136 | parent=parent, 137 | ) 138 | 139 | # Optional, may only occur once 140 | # As class is a reserved keyword in python, we prefixed it with `ical_`. 141 | self.ical_class: Optional[Class] = self.as_parent(ical_class) 142 | self.completed: Optional[Completed] = self.as_parent(completed) 143 | self.created: Optional[Created] = self.as_parent(created) 144 | self.description: Optional[Description] = self.as_parent(description) 145 | self.duration: Optional[ICALDuration] = self.as_parent(duration) 146 | self.geo: Optional[GEO] = self.as_parent(geo) 147 | self.last_modified: Optional[LastModified] = self.as_parent(last_modified) 148 | self.location: Optional[Location] = self.as_parent(location) 149 | self.organizer: Optional[Organizer] = self.as_parent(organizer) 150 | self.percent: Optional[PercentComplete] = self.as_parent(percent) 151 | self.priority: Optional[Priority] = self.as_parent(priority) 152 | self.sequence: Optional[Sequence] = self.as_parent(sequence) 153 | self.status: Optional[Status] = self.as_parent(status) 154 | self.url: Optional[URL] = self.as_parent(url) 155 | self.due: Optional[Due] = self.as_parent(due) 156 | 157 | # Optional, may occur more than once 158 | self.attach: Optional[List[Attach]] = self.as_parent(attach) 159 | self.attendee: Optional[List[Attendee]] = self.as_parent(attendee) 160 | self.categories: Optional[List[Categories]] = self.as_parent(categories) 161 | self.contact: Optional[List[Contact]] = self.as_parent(contact) 162 | self.rstatus: Optional[List[RequestStatus]] = self.as_parent(rstatus) 163 | self.related: Optional[List[RelatedTo]] = self.as_parent(related) 164 | self.resources: Optional[List[Resources]] = self.as_parent(resources) 165 | 166 | # This is a child component 167 | self.alarms: List[VAlarm] = alarms or [] 168 | 169 | def __repr__(self) -> str: 170 | """Overwrite the repr to create a better representation for the item.""" 171 | return ( 172 | f"VToDo({self.dtstart.value if self.dtstart else ''} - {self.due.value if self.due else ''}: " 173 | f"{self.summary.value if self.summary else ''})" 174 | ) 175 | 176 | @property 177 | def ending(self) -> Optional[_DTBoth]: 178 | """ 179 | Return the ending of the vtodo. 180 | 181 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 182 | """ 183 | return self.due 184 | 185 | def get_duration(self) -> Optional[Duration]: 186 | """ 187 | Return the duration of the vtodo. 188 | 189 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 190 | """ 191 | return self.duration.duration if self.duration else None 192 | 193 | def expand_component_in_range( 194 | self, return_range: Timespan, starts_to_exclude: Union[List[Date], List[DateTime]] 195 | ) -> Iterator[TimespanWithParent]: 196 | """ 197 | Expand this VToDo in range according to its recurring *RDate*, *EXDate* and *RRule* properties. 198 | :param return_range: The timespan range on which we should return VToDo instances. 199 | :param starts_to_exclude: List of start Dates or list of start DateTimes of which we already know we should 200 | exclude them from our recurrence computation (as they have been completely redefined in another element). 201 | :return: Yield all recurring VToDo instances related to this VToDo in the given *return_range*. 202 | """ 203 | if self.timespan.intersects(return_range): 204 | yield self.timespan 205 | starts_to_exclude.append(self.start) 206 | 207 | start = self.start 208 | duration = self.computed_duration 209 | if not start or not duration: 210 | return None 211 | 212 | iterator = property_utils.expand_component_in_range( 213 | exdate_list=self.exdate or [], 214 | rdate_list=self.rdate or [], 215 | rrule=self.rrule, 216 | first_event_start=self.start, 217 | first_event_duration=self.computed_duration, 218 | starts_to_exclude=starts_to_exclude, 219 | return_range=return_range, 220 | make_tz_aware=None, 221 | ) 222 | 223 | for event_start_time, event_end_time in iterator: 224 | yield VRecurringToDo( 225 | original_component_instance=self, 226 | start=event_start_time, 227 | end=event_end_time, 228 | ).timespan 229 | 230 | 231 | class VRecurringToDo(AbstractRecurrence, VToDo): 232 | """ 233 | This class represents VToDo that are recurring. 234 | Inside the AbstractRecurrence class we overwrite specific dunder methods and property methods. 235 | This way our end users have a very similar interface to an actual VToDo but without us needing to code the exact 236 | same thing twice. 237 | 238 | :param original_component_instance: The original VToDo instance. 239 | :param start: The start of this occurrence. 240 | :param end: The end of this occurrence. 241 | """ 242 | 243 | def __init__(self, original_component_instance: VToDo, start: DateTime, end: DateTime): 244 | self._original = original_component_instance 245 | self._start = start 246 | self._end = end 247 | super(VToDo, self).__init__("VTODO", parent=original_component_instance) 248 | 249 | def __repr__(self) -> str: 250 | """Overwrite the repr to create a better representation for the item.""" 251 | return f"RVToDo({self._start} - {self._end}: {self.original.summary.value if self.original.summary else ''})" 252 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_timezone.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Callable, Dict, Iterator, List, Optional, Tuple, Union 3 | 4 | from pendulum import Date, DateTime 5 | from pendulum.tz.timezone import Timezone 6 | from pendulum.tz.zoneinfo.transition import Transition 7 | from pendulum.tz.zoneinfo.transition_type import TransitionType 8 | 9 | from ical_library.base_classes.component import Component 10 | from ical_library.exceptions import MissingRequiredProperty 11 | from ical_library.help_modules import dt_utils, property_utils 12 | from ical_library.help_modules.lru_cache import instance_lru_cache 13 | from ical_library.help_modules.timespan import Timespan 14 | from ical_library.ical_properties.dt import DTStart, LastModified 15 | from ical_library.ical_properties.pass_properties import Comment, TZID, TZName, TZURL 16 | from ical_library.ical_properties.periods import RDate 17 | from ical_library.ical_properties.rrule import RRule 18 | from ical_library.ical_properties.tz_offset import TZOffsetFrom, TZOffsetTo 19 | 20 | 21 | class _TimeOffsetPeriod(Component): 22 | """ 23 | A _TimeOffsetPeriod representing either a Standard configuration or a Winter configuration. 24 | 25 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 26 | :param dtstart: The DTStart property. Required and must occur exactly once. 27 | :param tzoffsetto: The TZOffsetTo property. Required and must occur exactly once. 28 | :param tzoffsetfrom: The TZOffsetFrom property. Required and must occur exactly once. 29 | :param rrule: The RRule property. Optional, but may occur at most once. 30 | :param comment: The Comment property. Optional, but may occur multiple times. 31 | :param rdate: The RDate property. Optional, but may occur multiple times. 32 | :param tzname: The TZName property. Optional, but may occur multiple times. 33 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 34 | """ 35 | 36 | def __init__( 37 | self, 38 | name: str, 39 | dtstart: Optional[DTStart] = None, 40 | tzoffsetto: Optional[TZOffsetTo] = None, 41 | tzoffsetfrom: Optional[TZOffsetFrom] = None, 42 | rrule: Optional[RRule] = None, 43 | comment: Optional[List[Comment]] = None, 44 | rdate: Optional[List[RDate]] = None, 45 | tzname: Optional[List[TZName]] = None, 46 | parent: Optional[Component] = None, 47 | is_dst: bool = False, 48 | ): 49 | super().__init__(name, parent=parent) 50 | # Required, must occur only once. 51 | self.dtstart: Optional[DTStart] = self.as_parent(dtstart) 52 | self.tzoffsetto: Optional[TZOffsetTo] = self.as_parent(tzoffsetto) 53 | self.tzoffsetfrom: Optional[TZOffsetFrom] = self.as_parent(tzoffsetfrom) 54 | # Optional, may only occur once. 55 | self.rrule: Optional[RRule] = self.as_parent(rrule) 56 | # Optional, may occur more than once. 57 | self.comment: Optional[List[Comment]] = self.as_parent(comment) 58 | self.rdate: Optional[List[RDate]] = self.as_parent(rdate) 59 | self.tzname: Optional[List[TZName]] = self.as_parent(tzname) 60 | self.is_dst = is_dst 61 | 62 | def __repr__(self) -> str: 63 | """Overwrite the repr to create a better representation for the item.""" 64 | return f"{self.__class__.__name__}({self.dtstart.value}: {self.tzoffsetto.value})" 65 | 66 | def timezone_aware_start(self) -> DateTime: 67 | """Return a timezone aware start.""" 68 | dt: DateTime = dt_utils.convert_time_object_to_datetime(self.dtstart.datetime_or_date_value) 69 | return dt.in_timezone(tz=self.tzoffsetfrom.as_timezone_object()) 70 | 71 | def get_time_sequence( 72 | self, max_datetime: Optional[DateTime] = None 73 | ) -> Iterator[Tuple[DateTime, "_TimeOffsetPeriod"]]: 74 | """ 75 | Expand the TimeZone start date according to its recurring *RDate* and *RRule* properties. 76 | :param max_datetime: The maximum datetime value we wish to expand to. 77 | :return: Yield all the datetime values according to the recurring properties that are lower than *max_datetime*. 78 | """ 79 | for rtime in property_utils.expand_event_in_range_only_return_first( 80 | rdate_list=self.rdate or [], 81 | rrule=self.rrule, 82 | first_event_start=self.timezone_aware_start(), 83 | return_range=Timespan(self.timezone_aware_start() - timedelta(days=1), max_datetime), 84 | make_tz_aware=self.tzoffsetfrom.as_timezone_object(), 85 | ): 86 | if not isinstance(rtime, DateTime): 87 | raise TypeError(f"{rtime} was expected to be a DateTime object.") 88 | yield rtime, self 89 | 90 | 91 | class DayLight(_TimeOffsetPeriod): 92 | """A TimeOffsetPeriod representing a DayLight(a.k.a. Advanced Time, Summer Time or Legal Time) configuration.""" 93 | 94 | def __init__(self, parent: Optional[Component] = None, **kwargs): 95 | super().__init__("DAYLIGHT", parent=parent, is_dst=True, **kwargs) 96 | 97 | @classmethod 98 | def _get_init_method_for_var_mapping(cls) -> Callable: 99 | return _TimeOffsetPeriod.__init__ 100 | 101 | 102 | class Standard(_TimeOffsetPeriod): 103 | """A TimeOffsetPeriod representing a Standard(a.k.a. Winter Time) configuration.""" 104 | 105 | def __init__(self, parent: Optional[Component] = None, **kwargs): 106 | super().__init__("STANDARD", parent=parent, is_dst=False, **kwargs) 107 | 108 | @classmethod 109 | def _get_init_method_for_var_mapping(cls) -> Callable: 110 | return _TimeOffsetPeriod.__init__ 111 | 112 | 113 | class VTimeZone(Component): 114 | """ 115 | This class represents the VTIMEZONE component specified in RFC 5545 in '3.6.5. Time Zone Component'. 116 | 117 | If present, the "VTIMEZONE" calendar component defines the set of Standard Time and Daylight Saving Time 118 | observances (or rules) for a particular time zone for a given interval of time. The "VTIMEZONE" calendar component 119 | cannot be nested within other calendar components. Multiple "VTIMEZONE" calendar components can exist in an 120 | iCalendar object. In this situation, each "VTIMEZONE" MUST represent a unique time zone definition. This is 121 | necessary for some classes of events, such as airline flights, that start in one time zone and end in another. 122 | 123 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 124 | :param tzid: The TZID property. Required and must occur exactly once. 125 | :param last_mod: Optional LastModified property. Optional, but may occur at most once. 126 | :param tzurl: The TZURL property. Optional, but may occur at most once. 127 | :param standardc: Optional list of Standard components. Each component may occur multiple times. Either standardc 128 | or daylightc must contain at least one TimeOffsetPeriod. 129 | :param daylightc: Optional list of DayLight components. Each component may occur multiple times. Either standardc 130 | or daylightc must contain at least one TimeOffsetPeriod. 131 | """ 132 | 133 | def __init__( 134 | self, 135 | tzid: Optional[TZID] = None, 136 | last_mod: Optional[LastModified] = None, 137 | tzurl: Optional[TZURL] = None, 138 | standardc: Optional[List[Standard]] = None, 139 | daylightc: Optional[List[DayLight]] = None, 140 | parent: Optional[Component] = None, 141 | ): 142 | super().__init__("VTIMEZONE", parent=parent) 143 | 144 | # Required properties, must occur one. 145 | self._tzid: Optional[TZID] = self.as_parent(tzid) 146 | # Optional properties, may only occur once. 147 | self.last_mod: Optional[LastModified] = self.as_parent(last_mod) 148 | self.tzurl: Optional[TZURL] = self.as_parent(tzurl) 149 | # Either one of these components must have at least one record. May occur multiple times. 150 | self.standardc: List[Standard] = standardc or [] 151 | self.daylightc: List[DayLight] = daylightc or [] 152 | 153 | self.__storage_of_results: Dict[DateTime, List[Tuple[DateTime, _TimeOffsetPeriod]]] = {} 154 | 155 | def __repr__(self) -> str: 156 | """Overwrite the repr to create a better representation for the item.""" 157 | return f"VTimeZone({self.tzid.value})" 158 | 159 | @property 160 | def tzid(self) -> TZID: 161 | """A getter to ensure the required property is set.""" 162 | if self._tzid is None: 163 | raise MissingRequiredProperty(self, "tzid") 164 | return self._tzid 165 | 166 | @tzid.setter 167 | def tzid(self, value: TZID): 168 | """A setter to set the required property.""" 169 | self._tzid = value 170 | 171 | def get_ordered_timezone_overview(self, max_datetime: DateTime) -> List[Tuple[DateTime, _TimeOffsetPeriod]]: 172 | """ 173 | Expand all TimeOffsetPeriod configuration and return them in an ordered by time fashion. 174 | :param max_datetime: The maximum datetime value we wish to expand to. 175 | :return: A sorted list on datetime containing tuples of datetime and offset period where the datetime is 176 | lower than *max_datetime*. 177 | """ 178 | if max_datetime in self.__storage_of_results.keys(): 179 | return self.__storage_of_results[max_datetime] 180 | all_timezones: List[Tuple[Union[DateTime, Date], _TimeOffsetPeriod]] = [] 181 | for a_standard in self.standardc: 182 | all_timezones.extend(a_standard.get_time_sequence(max_datetime=max_datetime)) 183 | for a_daylight in self.daylightc: 184 | all_timezones.extend(a_daylight.get_time_sequence(max_datetime=max_datetime)) 185 | sorted_list = sorted(all_timezones, key=lambda tup: tup[0]) 186 | self.__storage_of_results[max_datetime] = sorted_list 187 | return sorted_list 188 | 189 | def convert_naive_datetime_to_aware(self, dt: DateTime) -> DateTime: 190 | """ 191 | Convert a naive datetime to an aware datetime using the configuration of this TimeZone object. 192 | :param dt: The (possibly naive) datetime to convert to this timezone configuration. 193 | :return: The timezone aware datetime. 194 | """ 195 | if dt.tzinfo is not None: 196 | return dt 197 | return dt.in_timezone(self.get_as_timezone_object()) 198 | 199 | def get_ordered_timezone_overview_as_transition(self, max_datetime: DateTime) -> List[Transition]: 200 | """ 201 | Get timezone components as a list of pendulum Transitions. 202 | :param max_datetime: The maximum datetime for which we include transitions. Any transitions after are excluded. 203 | :return: Return the list of pendulum Transitions for this VTimezone. 204 | """ 205 | timezones: List[Tuple[DateTime, _TimeOffsetPeriod]] = self.get_ordered_timezone_overview(max_datetime) 206 | transitions: List[Transition] = [] 207 | previous_transition: Optional[Transition] = None 208 | for time, offset_period in timezones: 209 | new_transition_type = TransitionType( 210 | offset=offset_period.tzoffsetto.parse_value_as_seconds(), 211 | is_dst=offset_period.is_dst, 212 | abbr=offset_period.tzname[0].value if offset_period.tzname else "unknown", 213 | ) 214 | at = int(time.in_tz("UTC").timestamp()) 215 | new_transition = Transition(at=at, ttype=new_transition_type, previous=previous_transition) 216 | previous_transition = new_transition 217 | transitions.append(new_transition) 218 | return transitions 219 | 220 | @instance_lru_cache() 221 | def get_as_timezone_object(self, max_datetime: DateTime = DateTime(2100, 1, 1)) -> Timezone: 222 | """ 223 | For a given maximum datetime, compute a pendulum Timezone object that contains all transition till max_datetime. 224 | :param max_datetime: The maximum datetime for which we include transitions. Any transitions after are excluded. 225 | :return: Returns a pendulum Timezone object that you can use for DateTimes. 226 | """ 227 | return CustomTimezone( 228 | name=self.tzid.value, transitions=self.get_ordered_timezone_overview_as_transition(max_datetime) 229 | ) 230 | 231 | 232 | class CustomTimezone(Timezone): 233 | def __init__(self, name: str, transitions: List[Transition]): # noqa 234 | self._name = name 235 | self._transitions = transitions 236 | self._hint = {True: None, False: None} 237 | -------------------------------------------------------------------------------- /src/ical_library/ical_components/v_event.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Optional, Union 2 | 3 | from pendulum import Date, DateTime, Duration 4 | 5 | from ical_library.base_classes.component import Component 6 | from ical_library.help_modules import property_utils 7 | from ical_library.help_modules.timespan import Timespan, TimespanWithParent 8 | from ical_library.ical_components.abstract_components import ( 9 | AbstractComponentWithRecurringProperties, 10 | AbstractRecurrence, 11 | ) 12 | from ical_library.ical_components.v_alarm import VAlarm 13 | from ical_library.ical_properties.cal_address import Attendee, Organizer 14 | from ical_library.ical_properties.dt import _DTBoth, Created, DTEnd, DTStamp, DTStart, LastModified, RecurrenceID 15 | from ical_library.ical_properties.geo import GEO 16 | from ical_library.ical_properties.ical_duration import ICALDuration 17 | from ical_library.ical_properties.ints import Priority, Sequence 18 | from ical_library.ical_properties.pass_properties import ( 19 | Attach, 20 | Categories, 21 | Class, 22 | Comment, 23 | Contact, 24 | Description, 25 | Location, 26 | RelatedTo, 27 | RequestStatus, 28 | Resources, 29 | Status, 30 | Summary, 31 | TimeTransparency, 32 | UID, 33 | URL, 34 | ) 35 | from ical_library.ical_properties.periods import EXDate, RDate 36 | from ical_library.ical_properties.rrule import RRule 37 | 38 | 39 | class VEvent(AbstractComponentWithRecurringProperties): 40 | """ 41 | This class represents the VEVENT component specified in RFC 5545 in '3.6.1. Event Component'. 42 | 43 | A "VEVENT" calendar component is a grouping of component properties, possibly including "VALARM" calendar 44 | components, that represents a scheduled amount of time on a calendar. For example, it can be an activity; such as 45 | a one-hour long, department meeting from 8:00 AM to 9:00 AM, tomorrow. Generally, an event will take up time on an 46 | individual calendar. Hence, the event will appear as an opaque interval in a search for busy time. Alternately, 47 | the event can have its Time Transparency set to "TRANSPARENT" in order to prevent blocking of the event in 48 | searches for busy time. 49 | 50 | :param name: The actual name of this component instance. E.g. VEVENT, RRULE, VCUSTOMCOMPONENT. 51 | :param parent: The Component this item is encapsulated by in the iCalendar data file. 52 | :param dtstamp: The DTStamp property. Required and must occur exactly once. 53 | :param uid: The UID property. Required and must occur exactly once. 54 | :param dtstart: The DTStart property. Optional and may occur at most once. 55 | :param rrule: The RRule property. Optional and may occur at most once. 56 | :param summary: The Summary property. Optional and may occur at most once. 57 | :param exdate: The EXDate property. Optional, but may occur multiple times. 58 | :param rdate: The RDate property. Optional, but may occur multiple times. 59 | :param comment: The Comment property. Optional, but may occur multiple times. 60 | :param ical_class: Optional Class property. Optional, but may occur at most once. 61 | :param created: The Created property. Optional, but may occur at most once. 62 | :param description: The Description property. Optional, but may occur at most once. 63 | :param duration: The ICALDuration property. Optional, but may occur at most once. 64 | :param geo: The GEO property. Optional, but may occur at most once. 65 | :param last_modified: Optional LastModified property. Optional, but may occur at most once. 66 | :param location: The Location property. Optional, but may occur at most once. 67 | :param organizer: The Organizer property. Optional, but may occur at most once. 68 | :param priority: The Priority property. Optional, but may occur at most once. 69 | :param sequence: The Sequence property. Optional, but may occur at most once. 70 | :param status: The Status property. Optional, but may occur at most once. 71 | :param transp: The TimeTransparency property. Optional, but may occur at most once. 72 | :param url: The URL property. Optional, but may occur at most once. 73 | :param recurrence_id: Optional RecurrenceID property. Optional, but may occur at most once. 74 | :param dtend: The DTEnd property. Optional, but may occur at most once. 75 | :param attach: The Attach property. Optional, but may occur multiple times. 76 | :param attendee: The Attendee property. Optional, but may occur multiple times. 77 | :param categories: The Categories property. Optional, but may occur multiple times. 78 | :param contact: The Contact property. Optional, but may occur multiple times. 79 | :param rstatus: The RequestStatus property. Optional, but may occur multiple times. 80 | :param related: The RelatedTo property. Optional, but may occur multiple times. 81 | :param resources: The Resources property. Optional, but may occur multiple times. 82 | """ 83 | 84 | def __init__( 85 | self, 86 | dtstamp: Optional[DTStamp] = None, 87 | uid: Optional[UID] = None, 88 | dtstart: Optional[DTStart] = None, 89 | rrule: Optional[RRule] = None, 90 | summary: Optional[Summary] = None, 91 | exdate: Optional[List[EXDate]] = None, 92 | rdate: Optional[List[RDate]] = None, 93 | comment: Optional[List[Comment]] = None, 94 | ical_class: Optional[Class] = None, 95 | created: Optional[Created] = None, 96 | description: Optional[Description] = None, 97 | duration: Optional[ICALDuration] = None, 98 | geo: Optional[GEO] = None, 99 | last_modified: Optional[LastModified] = None, 100 | location: Optional[Location] = None, 101 | organizer: Optional[Organizer] = None, 102 | priority: Optional[Priority] = None, 103 | sequence: Optional[Sequence] = None, 104 | status: Optional[Status] = None, 105 | transp: Optional[TimeTransparency] = None, 106 | url: Optional[URL] = None, 107 | recurrence_id: Optional[RecurrenceID] = None, 108 | dtend: Optional[DTEnd] = None, 109 | attach: Optional[List[Attach]] = None, 110 | attendee: Optional[List[Attendee]] = None, 111 | categories: Optional[List[Categories]] = None, 112 | contact: Optional[List[Contact]] = None, 113 | rstatus: Optional[List[RequestStatus]] = None, 114 | related: Optional[List[RelatedTo]] = None, 115 | resources: Optional[List[Resources]] = None, 116 | alarms: Optional[List[VAlarm]] = None, 117 | parent: Optional[Component] = None, 118 | ): 119 | super().__init__( 120 | name="VEVENT", 121 | dtstamp=dtstamp, 122 | uid=uid, 123 | dtstart=dtstart, 124 | rrule=rrule, 125 | summary=summary, 126 | recurrence_id=recurrence_id, 127 | exdate=exdate, 128 | rdate=rdate, 129 | comment=comment, 130 | parent=parent, 131 | ) 132 | 133 | # Optional, may only occur once 134 | # As class is a reserved keyword in python, we prefixed it with `ical_`. 135 | self.ical_class: Optional[Class] = self.as_parent(ical_class) 136 | self.created: Optional[Created] = self.as_parent(created) 137 | self.description: Optional[Description] = self.as_parent(description) 138 | self.duration: Optional[ICALDuration] = self.as_parent(duration) 139 | self.geo: Optional[GEO] = self.as_parent(geo) 140 | self.last_modified: Optional[LastModified] = self.as_parent(last_modified) 141 | self.location: Optional[Location] = self.as_parent(location) 142 | self.organizer: Optional[Organizer] = self.as_parent(organizer) 143 | self.priority: Optional[Priority] = self.as_parent(priority) 144 | self.sequence: Optional[Sequence] = self.as_parent(sequence) 145 | self.status: Optional[Status] = self.as_parent(status) 146 | self.transp: Optional[TimeTransparency] = self.as_parent(transp) 147 | self.url: Optional[URL] = self.as_parent(url) 148 | self.dtend: Optional[DTEnd] = self.as_parent(dtend) 149 | 150 | # Optional, may occur more than once 151 | self.attach: Optional[List[Attach]] = self.as_parent(attach) 152 | self.attendee: Optional[List[Attendee]] = self.as_parent(attendee) 153 | self.categories: Optional[List[Categories]] = self.as_parent(categories) 154 | self.contact: Optional[List[Contact]] = self.as_parent(contact) 155 | self.rstatus: Optional[List[RequestStatus]] = self.as_parent(rstatus) 156 | self.related: Optional[List[RelatedTo]] = self.as_parent(related) 157 | self.resources: Optional[List[Resources]] = self.as_parent(resources) 158 | 159 | # This is a child component 160 | self.alarms: List[VAlarm] = alarms or [] 161 | 162 | def __repr__(self) -> str: 163 | """Overwrite the repr to create a better representation for the item.""" 164 | if self.dtstart and self.dtend: 165 | return f"VEvent({self.start} - {self.end}: {self.summary.value if self.summary else ''})" 166 | else: 167 | return f"VEvent({self.summary.value if self.summary else ''})" 168 | 169 | @property 170 | def ending(self) -> Optional[_DTBoth]: 171 | """ 172 | Return the ending of the event. 173 | 174 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 175 | """ 176 | return self.dtend 177 | 178 | def get_duration(self) -> Optional[Duration]: 179 | """ 180 | Return the duration of the event. 181 | 182 | Note: This is an abstract method from :class:`AbstractComponentWithRecurringProperties` we have to implement. 183 | """ 184 | return self.duration.duration if self.duration else None 185 | 186 | def expand_component_in_range( 187 | self, return_range: Timespan, starts_to_exclude: Union[List[Date], List[DateTime]] 188 | ) -> Iterator[TimespanWithParent]: 189 | """ 190 | Expand this VEvent in range according to its recurring *RDate*, *EXDate* and *RRule* properties. 191 | :param return_range: The timespan range on which we should return VEvent instances. 192 | :param starts_to_exclude: List of start Dates or list of start DateTimes of which we should exclude as they were 193 | defined in EXDATE, have already been returned or have been completely redefined in another element. 194 | :return: Yield all recurring VEvent instances related to this VEvent in the given *return_range*. 195 | """ 196 | if self.timespan.intersects(return_range): 197 | yield self.timespan 198 | starts_to_exclude.append(self.start) 199 | 200 | start = self.start 201 | duration = self.computed_duration 202 | if not start or not duration: 203 | return None 204 | 205 | iterator = property_utils.expand_component_in_range( 206 | exdate_list=self.exdate or [], 207 | rdate_list=self.rdate or [], 208 | rrule=self.rrule, 209 | first_event_start=start, 210 | first_event_duration=duration, 211 | starts_to_exclude=starts_to_exclude, 212 | return_range=return_range, 213 | make_tz_aware=None, 214 | ) 215 | 216 | for event_start_time, event_end_time in iterator: 217 | yield VRecurringEvent( 218 | original_component_instance=self, 219 | start=event_start_time, 220 | end=event_end_time, 221 | ).timespan 222 | 223 | 224 | class VRecurringEvent(AbstractRecurrence, VEvent): 225 | """ 226 | This class represents VEvents that are recurring. 227 | Inside the AbstractRecurrence class we overwrite specific dunder methods and property methods. 228 | This way our end users have a very similar interface to an actual VEvent but without us needing to code the exact 229 | same thing twice. 230 | 231 | :param original_component_instance: The original VEvent instance. 232 | :param start: The start of this occurrence. 233 | :param end: The end of this occurrence. 234 | """ 235 | 236 | def __init__(self, original_component_instance: VEvent, start: DateTime, end: DateTime): 237 | self._original = original_component_instance 238 | self._start = start 239 | self._end = end 240 | super(VEvent, self).__init__("VEVENT", parent=original_component_instance) 241 | 242 | def __repr__(self) -> str: 243 | """Overwrite the repr to create a better representation for the item.""" 244 | return f"RVEvent({self._start} - {self._end}: {self.original.summary.value if self.original.summary else ''})" 245 | --------------------------------------------------------------------------------