unadulterated faff

Creating `.ics` files for iCalendar in Python

Man, it’s a bummer how hard it is to create an .ics file on Linux. None of the tools that can do it have particularly intuitive interfaces. One calendar tool defaults to creating an ics file for your whole calendar, and the only way to specify a single event only is to know the events uid! (I’m looking at you, Orage Calendar). Allegedly Thunderbird supports creating single event .ics files out-of-the-box, but I haven’t had an opportunity to try it out, so probably that’s where you should look first.

But it ended up being relatively fast to throw together a script which can populate an event and then write it out using Python’s icalendar package. Here is the script:

create_ical_event.py script
#!/usr/bin/env python3
'''
create_ical_event.py

Use the icalendar package to create an iCalendar `.ics` file for a single event.


usage: create_ical_event.py [-h] --start START --end END --name EVENT_NAME
                           --description DESCRIPTION --organizer ORGANIZER
                           --attendees [ATTENDEES [ATTENDEES ...]]
                           [--file OUTPUT]


optional arguments:
  -h, --help            show this help message and exit
  --start START, -s START
                        The start time of the meeting, given as an ISO-8601
                        timestamp (UTC).
  --end END, -e END     The end time of the meeting, given as an ISO-8601
                        timestamp (UTC).
  --name EVENT_NAME, -n EVENT_NAME
                        The name of the event, used to populate the SUMMARY
                        field.
  --description DESCRIPTION, -d DESCRIPTION
                        Description/notes for event. Used to populate the
                        DESCRIPTION field.
  --organizer ORGANIZER, --chair ORGANIZER, -c ORGANIZER
                        Name and email of the event-organizer, separated by a
                        comma.
  --attendees [ATTENDEES [ATTENDEES ...]], -a [ATTENDEES [ATTENDEES ...]]
                        Name and email of the attendees; given like so --
                        "Attendee1,attendee1@email.com"
                        "Attendee2,attendee2@email.com"
  --file OUTPUT, --output OUTPUT, -o OUTPUT
                        Where to write out the .ics file
'''
import argparse
import icalendar
import sys
import uuid
from validate_email import validate_email
from datetime import datetime, timezone
from typing import List, Tuple, Optional


class EmailValidationError(Exception):
    '''Throw this error if an email address is found to be invalid.'''
    def __init__(self, message):
        '''Constructor that calls the base class (Exception) constructor.'''
        super(EmailValidationError, self).__init__(message)

    def __str__(self):
        return str(self.message)


def create_attendee_record(name: str, email_address: str,
                           rsvp: bool=True,
                           optional: bool=False,
                           do_validation: bool=False) -> icalendar.vCalAddress:
    '''Create an attendee record for a given name, email pair.'''
    if do_validation and not validate_email(email_address, check_mx=True):
        raise EmailValidationError('Address `' + email_address + '` is not a valid email address')
    base_string = 'MAILTO:'
    attendee: icalendar.vCalAddress = icalendar.vCalAddress(base_string + email_address)
    attendee.params['cn'] = icalendar.vText(name)
    attendee.params['partstat'] = icalendar.vText('NEEDS-ACTION')
    # Set ROLE value
    if optional:
        attendee.params['role'] = icalendar.vText('OPT-PARTICIPANT')
    else:
        attendee.params['role'] = icalendar.vText('REQ-PARTICIPANT')
    # Set RSVP value
    if rsvp:
        attendee.params['rsvp'] = icalendar.vText('TRUE')
    else:
        attendee.params['rsvp'] = icalendar.vText('FALSE')
    return attendee


def create_organizer_record(name: str, email_address: str) -> icalendar.vCalAddress:
    '''Create an organizer record for a given name, email pair.'''
    attendee: icalendar.vCalAddress = create_attendee_record(name, email_address)
    attendee.params['partstat'] = icalendar.vText('ACCEPTED')
    attendee.params['role'] = icalendar.vText('CHAIR')
    return attendee


def format_timestamp(datetime_obj: datetime) -> str:
    '''Create a nicely formatted timestamp.'''
    return datetime_obj.strftime('%Y%m%dT%H%M%SZ')


def create_new_event(start: datetime, end: datetime, event_name: str, description: str,
                     organizer: Tuple[str, str],
                     attendee_list: List[Tuple[str, str]]) -> icalendar.Event:
    '''Create a new 'subcomponent' for icalendar.'''
    event: icalendar.Event = icalendar.Event()
    current_time: datetime = datetime.now(timezone.utc)
    uid: str = str(uuid.uuid1())
    event.add('transp', 'TRANSPARENT')
    event.add('summary', event_name)
    event.add('description', description)
    event.add('uid', uid)
    # Add various times to the event:
    event['dtstart'] = format_timestamp(start)
    event['dtend'] = format_timestamp(end)
    event['created'] = format_timestamp(current_time)
    event['last-modified'] = format_timestamp(current_time)
    event['dtstamp'] = format_timestamp(current_time)
    try:
        event.add('organizer', create_organizer_record(*organizer))
        for attendee in attendee_list:
            attendee_record = create_attendee_record(*attendee)
            event.add('attendee', attendee_record, encode=0)
    except EmailValidationError as err:
        print(err)
    return event


def write_event(event: icalendar.Event, filename: str) -> None:
    '''Write out an .ics file for an event.'''
    # Create a calendar
    cal: icalendar.Calendar = icalendar.Calendar()
    # Add event to calendar
    cal.add_component(event)
    with open(filename, 'wb') as o:
        o.write(cal.to_ical())


def write_event_tempfile(event: icalendar.Event) -> None:
    '''Write out an .ics file, in a temporary directory.'''
    # Create a calendar
    cal: icalendar.Calendar = icalendar.Calendar()
    # Add event to calendar
    cal.add_component(event)
    import tempfile
    with tempfile.TemporaryFile() as fp:
        fp.write(cal.to_ical())


def main() -> None:
    '''This function is responsible for grabbing the commandline arguments.'''
    parser_description = 'Create an iCalendar .ics file for a single event.'
    parser = argparse.ArgumentParser(description=parser_description)
    parser.add_argument('--start', '-s', dest='start', type=str, required=True,
                        help='The start time of the meeting, given as an ISO-8601 timestamp (UTC).')
    parser.add_argument('--end', '-e', dest='end', type=str, required=True,
                        help='The end time of the meeting, given as an ISO-8601 timestamp (UTC).')
    parser.add_argument('--name', '-n', dest='event_name', type=str, required=True,
                        help='The name of the event, used to populate the SUMMARY field.')
    parser.add_argument('--description', '-d', dest='description', type=str, required=True,
                        help='Description/notes for event. Used to populate the DESCRIPTION field.')
    parser.add_argument('--organizer', '--chair', '-c', dest='organizer', type=str, required=True,
                        help='Name and email of the event-organizer, separated by a comma.')
    parser.add_argument('--attendees', '-a', dest='attendees', type=str, nargs='*', required=True,
                        help='Name and email of the attendees; given like so -- ' +
                        '"Attendee1,attendee1@email.com" "Attendee2,attendee2@email.com"')
    parser.add_argument('--file', '--output', '-o', dest='output', type=str, required=False,
                        help='Where to write out the .ics file')
    args = parser.parse_args()
    # Now handle the args:
    try:
        start: datetime = datetime.strptime(args.start, '%Y%m%dT%H%M%SZ')
        end: datetime = datetime.strptime(args.end, '%Y%m%dT%H%M%SZ')
        event_name: str = args.event_name
        description: str = args.description
        organizer = tuple([x.strip() for x in args.organizer.rsplit(',', 1)])
        attendee_list = []
        for attendee_str in args.attendees:
            current_attendee = tuple([x.strip() for x in attendee_str.rsplit(',', 1)])
            attendee_list.append(current_attendee)
        output_file: Optional[str] = args.output
    except ValueError as err:
        print(err)
        sys.exit(1)
    event: icalendar.Event = create_new_event(start, end, event_name, description, organizer,
                                              attendee_list)
    if output_file:
        write_event(event, output_file)
    else:
        write_event_tempfile(event)


if __name__ == '__main__':
    main()
Example usage of the script:
# Using full length args:
./create_ical_event.py --start "20200212T004500Z" --end "20200212T013000Z" --name "Test Event" \
  --description "This is a test event" --chair "Elliott Indiran,eindiran@promptu.com" --attendees \
  "Attendee1,attendee1@gmail.com" "Attendee2,attendee2@hotmail.com" --output "$HOME/new.ics"

# Using short args:
./create_ical_event.py -s "20200212T004500Z" -e "20200212T013000Z" -n "Test Event" \
  -d "This is a test event" -c "Elliott Indiran,eindiran@promptu.com" -a "Attendee1,attendee1@gmail.com" \
  "Attendee2,attendee2@hotmail.com" -o "$HOME/new.ics"
More on the iCal format

Take a look at the Python package icalendar’s documentation if you want to see what the package supports. To find out more on the iCalendar format and .ics files, check out the iCalendar specification.