$ python setup.py install
$ pip install pycalendar
-There are no dependencies outside Python's standard library.
+The only dependency outside Python's standard library is pytz_.
Testing
=======
.. _feedparser: https://pypi.python.org/pypi/feedparser/
.. _Google Calendar: https://support.google.com/calendar/
.. _nose: https://nose.readthedocs.org/en/latest/
+.. _pytz: https://pypi.python.org/pypi/pytz/
# You should have received a copy of the GNU General Public License along with
# pycalender. If not, see <http://www.gnu.org/licenses/>.
-from . import text as _text
+
+from .component import calendar as _component_calendar
+from .property import calendar as _property_calendar
class Aggregator (list):
>>> a.write(stream=stream)
>>> value = stream.getvalue()
>>> value # doctest: +ELLIPSIS
- 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...END:VCALENDAR\r\n'
- >>> print(value.replace('\r\n', '\n'))
+ 'BEGIN:VCALENDAR\r\nPRODID:...END:VCALENDAR\r\n'
+ >>> print(value.replace('\r\n', '\n')) # doctest: +REPORT_UDIFF
BEGIN:VCALENDAR
- VERSION:2.0
PRODID:-//pycalendar//NONSGML testing//EN
+ VERSION:2.0
BEGIN:VEVENT
- UID:2013-06-30@geohash.invalid
DTSTAMP:20130630T000000Z
+ UID:2013-06-30@geohash.invalid
DTSTART;VALUE=DATE:20130630
- DTEND;VALUE=DATE:20130701
+ GEO:42.226663;-71.286760
+ LOCATION:Snow Hill\, Dover\, Massachusetts
SUMMARY:XKCD geohashing\, Boston graticule
URL:http://xkcd.com/426/
- LOCATION:Snow Hill\, Dover\, Massachusetts
- GEO:42.226663;-71.28676
+ DTEND;VALUE=DATE:20130701
END:VEVENT
END:VCALENDAR
<BLANKLINE>
"""
def __init__(self, prodid, version='2.0', feeds=None, processors=None):
super(Aggregator, self).__init__()
- self.prodid = prodid
- self.version = version
+ self.calendar = _component_calendar.Calendar()
+ self.calendar.add_property(_property_calendar.Version(value=version))
+ self.calendar.add_property(
+ _property_calendar.ProductIdentifier(value=prodid))
if feeds:
self.extend(feeds)
if not processors:
feed.fetch()
for processor in self.processors:
processor(feed)
+ for name in feed.subcomponents:
+ if name not in self.calendar:
+ self.calendar[name] = []
+ for component in feed.get(name, []):
+ self.calendar[name].append(component)
def write(self, stream):
- stream.write('BEGIN:VCALENDAR\r\n')
- stream.write('VERSION:{}\r\n'.format(_text.escape(self.version)))
- stream.write('PRODID:{}\r\n'.format(_text.escape(self.prodid)))
- for feed in self:
- for key in [
- 'VEVENT',
- 'VFREEBUSY',
- 'VJOURNAL',
- 'VTODO',
- ]:
- for entry in feed.get(key, []):
- entry.write(stream=stream)
- stream.write('END:VCALENDAR\r\n')
+ self.calendar.write(stream=stream)
--- /dev/null
+# Copyright
+
+"""Classes representing calendar components
+
+As defined in :RFC:`5545`, section 3.6 (Calendar Components)
+
+Usage
+-----
+
+Locate the example content.
+
+>>> import codecs
+>>> import os
+>>> root_dir = os.curdir
+>>> data_file = os.path.abspath(os.path.join(
+... root_dir, 'test', 'data', 'geohash.ics'))
+
+Read a calendar.
+
+>>> with codecs.open(data_file, 'r', 'UTF-8') as f:
+... calendar = parse(stream=f)
+
+Investigate the entry.
+
+>>> calendar.name
+'VCALENDAR'
+
+>>> for key,value in sorted(calendar.items()):
+... print((key, value))
+... # doctest: +ELLIPSIS, +REPORT_UDIFF
+('PRODID', <ProductIdentifier name:PRODID at 0x...>)
+('VERSION', <Version name:VERSION at 0x...>)
+('VEVENT', [<Event name:VEVENT at 0x...>])
+
+>>> print(calendar) # doctest: +REPORT_UDIFF
+BEGIN:VCALENDAR
+PRODID:-//Example Calendar//NONSGML v1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20130630T000000Z
+UID:2013-06-30@geohash.invalid
+DTSTART;VALUE=DATE:20130630
+GEO:42.226663;-71.286760
+LOCATION:Snow Hill\, Dover\, Massachusetts
+SUMMARY:XKCD geohashing\, Boston graticule
+URL:http://xkcd.com/426/
+DTEND;VALUE=DATE:20130701
+END:VEVENT
+END:VCALENDAR
+
+``Entry`` subclasses Python's ``dict``, so you can access raw
+field values in the usual ways.
+
+>>> calendar['VERSION'].value
+'2.0'
+>>> calendar.get('missing')
+>>> calendar.get('missing', 'some default')
+'some default'
+>>> sorted(calendar.keys())
+['PRODID', 'VERSION', 'VEVENT']
+
+Dig into the subcomponents (which are always stored as lists):
+
+>>> event = calendar['VEVENT'][0]
+
+>>> event.name
+'VEVENT'
+
+>>> for key,value in sorted(calendar['VEVENT'][0].items()):
+... print((key, value))
+... # doctest: +ELLIPSIS, +REPORT_UDIFF
+('DTEND', <DateTimeEnd name:DTEND at 0x...>)
+('DTSTAMP', <DateTimeStamp name:DTSTAMP at 0x...>)
+('DTSTART', <DateTimeStart name:DTSTART at 0x...>)
+('GEO', <GeographicPosition name:GEO at 0x...>)
+('LOCATION', <Location name:LOCATION at 0x...>)
+('SUMMARY', <Summary name:SUMMARY at 0x...>)
+('UID', <UniqueIdentifier name:UID at 0x...>)
+('URL', <UniformResourceLocator name:URL at 0x...>)
+
+>>> event['LOCATION'].value
+'Snow Hill, Dover, Massachusetts'
+"""
+
+from .. import property as _property
+from .. import unfold as _unfold
+
+from . import base as _base
+
+from . import alarm as _alarm
+from . import base as _base
+from . import calendar as _calendar
+from . import event as _event
+from . import freebusy as _freebusy
+from . import journal as _journal
+from . import timezone as _timezone
+from . import todo as _todo
+
+
+COMPONENT = _base._COMPONENT
+
+
+def register(component):
+ """Register a component class
+ """
+ COMPONENT[component.name] = component
+
+
+def parse(stream):
+ """Load a single component from a stream
+ """
+ lines = _unfold.unfold(stream=stream)
+ line = next(lines)
+ prop = _property.parse(line=line)
+ if prop.name != 'BEGIN':
+ raise ValueError(
+ "stream {} must start with 'BEGIN:...', not {!r}".format(
+ stream, line))
+ component_class = COMPONENT[prop.value]
+ component = component_class()
+ component.read(lines=lines)
+ return component
+
+
+for module in [
+ _alarm,
+ _base,
+ _calendar,
+ _event,
+ _freebusy,
+ _journal,
+ _timezone,
+ _todo,
+ ]:
+ for name in dir(module):
+ if name.startswith('_'):
+ continue
+ obj = getattr(module, name)
+ if isinstance(obj, type) and issubclass(obj, _base.Component):
+ register(component=obj)
+del module, name, obj
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class Alarm (_base.Component):
+ """An alarm event
+
+ As defined in :RFC:`5545`, section 3.6.6 (Alarm Component).
+ """
+ name = 'VALARM'
--- /dev/null
+# Copyright
+
+import io as _io
+import itertools as _itertools
+
+from .. import property as _property
+from .. import unfold as _unfold
+
+
+_COMPONENT = {}
+
+
+class Component (dict):
+ r"""A base class for calendar components
+
+ As defined in :RFC:`5545`, section 3.6 (Calendar Components).
+ """
+ name = None
+ # properties
+ required = []
+ optional = []
+ multiple = []
+ # sub components
+ subcomponents = []
+
+ def __hash__(self):
+ if self.name in [
+ 'VEVENT',
+ 'VFREEBUSY',
+ 'VJOURNAL',
+ 'VTODO',
+ ] or 'UID' in self:
+ return hash(self['UID'])
+ return id(self)
+
+ def __str__(self):
+ with _io.StringIO() as stream:
+ self.write(stream=stream, newline='\n')
+ return stream.getvalue()[:-1] # strip the trailing newline
+
+ def __repr__(self):
+ return '<{}.{} name:{} at {:#x}>'.format(
+ self.__module__, type(self).__name__, self.name, id(self))
+
+ def read(self, stream=None, lines=None):
+ """Read an input stream and parse into properties and subcomponents
+ """
+ if lines is None:
+ lines = _unfold.unfold(stream=stream)
+ elif stream is not None:
+ raise ValueError("cannot specify both 'stream' and 'lines'")
+ for line in lines:
+ prop = _property.parse(line)
+ if prop.name == 'BEGIN': # a subcomponent
+ if prop.value not in self.subcomponents:
+ raise ValueError('invalid component {} for {!r}'.format(
+ prop.value, self))
+ component_class = _COMPONENT[prop.value]
+ component = component_class()
+ component.read(lines=lines)
+ self.add_component(component)
+ elif prop.name == 'END': # we're done with this component
+ if prop.value != self.name:
+ raise ValueError('cannot close {!r} with {}'.format(
+ self, line))
+ return
+ else: # a property
+ self.add_property(property=prop)
+
+ def add_component(self, component):
+ name = component.name
+ if name not in self.subcomponents:
+ raise ValueError('invalid component {} for {!r}'.format(
+ name, self))
+ if name not in self:
+ self[name] = []
+ self[name].append(component)
+
+ def add_property(self, property):
+ name = property.name
+ if name not in self.required and name not in self.optional:
+ raise ValueError('invalid property {} for {!r}'.format(name, self))
+ if name in self.multiple:
+ if name not in self:
+ self[name] = []
+ self[name].append(property)
+ else:
+ self[name] = property
+
+ def write(self, stream, newline='\r\n'):
+ stream.write('BEGIN:{}{}'.format(self.name, newline))
+ for prop in _itertools.chain(self.required, self.optional):
+ self._write_property_by_name(
+ name=prop, stream=stream, newline=newline)
+ for component in self.subcomponents:
+ self._write_component_by_name(
+ name=component, stream=stream, newline=newline)
+ stream.write('END:{}{}'.format(self.name, newline))
+
+ def _write_component_by_name(self, name, stream, newline='\r\n'):
+ component = self.get(name, [])
+ if isinstance(component, list):
+ for c in component:
+ c.write(stream=stream, newline=newline)
+ else:
+ component.write(stream=stream, newline=newline)
+
+ def _write_property_by_name(self, name, stream, newline='\r\n'):
+ prop = self.get(name, [])
+ if isinstance(prop, list):
+ for p in prop:
+ p.write(stream=stream, newline=newline)
+ else:
+ prop.write(stream=stream, newline=newline)
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class Calendar (_base.Component):
+ """A calendar
+
+ As defined in :RFC:`5545`, section 3.4 (iCalendar Object).
+ """
+ name = 'VCALENDAR'
+ # contents defined in RFC 5545, section 3.6 (Calendar Components)
+ required = [
+ 'PRODID',
+ 'VERSION',
+ ]
+ optional = [
+ 'CALSCALE',
+ 'METHOD',
+ 'X-PROP',
+ 'IANA-PROP',
+ ]
+ multiple = [
+ 'X-PROP',
+ 'IANA-PROP',
+ ]
+ subcomponents = [
+ 'VEVENT',
+ 'VTODO',
+ 'VJOURNAL',
+ 'VFREEBUSY',
+ 'VTIMEZONE',
+ 'VIANA-COMP',
+ 'VXCOMP',
+ ]
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class Event (_base.Component):
+ """A calendar event
+
+ As defined in :RFC:`5545`, section 3.6.1 (Event Component).
+ """
+ name = 'VEVENT'
+ required=[
+ 'DTSTAMP',
+ 'UID',
+ ]
+ optional=[
+ 'DTSTART', # required if VACLENDAR doesn't set METHOD
+ # must not occur more than once
+ 'CLASS',
+ 'CREATED',
+ 'DESCRIPTION',
+ 'GEO',
+ 'LAST-MOD',
+ 'LOCATION',
+ 'ORGANIZER',
+ 'PRIORITY',
+ 'SEQUENCE',
+ 'STATUS',
+ 'SUMMARY',
+ 'TRANSP',
+ 'URL',
+ 'RECURID',
+ # should not occur more than once
+ 'RRULE',
+ # must not occur more than once
+ 'DTEND',
+ 'DURATION', # but not when DTEND is also specified
+ # may occur more than once
+ 'ATTACH',
+ 'ATTENDEE',
+ 'CATEGORIES',
+ 'COMMENT',
+ 'CONTACT',
+ 'EXDATE',
+ 'RSTATUS',
+ 'RELATED',
+ 'RESOURCES',
+ 'RDATE',
+ 'X-PROP',
+ 'IANA-PROP',
+ ]
+ multiple=[
+ 'ATTACH',
+ 'ATTENDEE',
+ 'CATEGORIES',
+ 'COMMENT',
+ 'CONTACT',
+ 'EXDATE',
+ 'RSTATUS',
+ 'RELATED',
+ 'RESOURCES',
+ 'RDATE',
+ 'X-PROP',
+ 'IANA-PROP',
+ ]
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class FreeBusy (_base.Component):
+ """A request for, response to, or description of free/busy time
+
+ As defined in :RFC:`5545`, section 3.6.4 (Free/Busy Component).
+ """
+ name = 'VFREEBUSY'
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class Journal (_base.Component):
+ """A journal entry
+
+ As defined in :RFC:`5545`, section 3.6.3 (Journal Component).
+ """
+ name = 'VJOURNAL'
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class TimeZone (_base.Component):
+ """A time zone
+
+ As defined in :RFC:`5545`, section 3.6.5 (Time Zone Component).
+ """
+ name = 'VTIMEZONE'
--- /dev/null
+# Copyright
+
+from . import base as _base
+
+
+class ToDo (_base.Component):
+ """A to-do entry
+
+ As defined in :RFC:`5545`, section 3.6.2 (To-Do Component).
+ """
+ name = 'VTODO'
--- /dev/null
+# Copyright
+
+"""Classes for processing data types
+
+As defined in :RFC:`5545`, section 3.3 (Property Value Data Types).
+"""
+
+from . import base as _base
+
+from . import date as _date
+from . import datetime as _datetime
+from . import geo as _geo
+from . import numeric as _numeric
+from . import text as _text
+from . import time as _time
+
+
+DTYPE = {}
+
+
+def register(dtype):
+ DTYPE[dtype.name] = dtype
+
+
+for module in [
+ _date,
+ _datetime,
+ _geo,
+ _numeric,
+ _text,
+ _time,
+ ]:
+ for name in dir(module):
+ if name.startswith('_'):
+ continue
+ obj = getattr(module, name)
+ if isinstance(obj, type) and issubclass(obj, _base.DataType):
+ register(dtype=obj)
+del module, name, obj
--- /dev/null
+# Copyright
+
+
+class DataType (object):
+ """Base class for processing data types
+
+ As defined in :RFC:`5545`, section 3.3 (Property Value Data
+ Types).
+ """
+ name = None
+
+ def __str__(self):
+ return self.name
+
+ def __repr__(self):
+ return '<{}.{} name:{} at {:#x}>'.format(
+ self.__module__, type(self).__name__, self.name, id(self))
+
+ @classmethod
+ def decode(cls, property, value):
+ raise NotImplementedError('cannot decode {}'.format(cls))
+
+ @classmethod
+ def encode(cls, property, value):
+ raise NotImplementedError('cannot encode {}'.format(cls))
--- /dev/null
+# Copyright
+
+"""Functions for processing dates without times
+
+As defined in :RFC:`5545`, section 3.3.4 (Date).
+"""
+
+import datetime as _datetime
+
+from . import base as _base
+
+
+class Date (_base.DataType):
+ name = 'DATE'
+
+ @classmethod
+ def decode(cls, property, value):
+ """Decode dates without times
+
+ As defined in :RFC:`5545`, section 3.3.4 (Date).
+
+ >>> Date.decode(property={}, value='19970714')
+ datetime.date(1997, 7, 14)
+ """
+ if len(value) != 8:
+ raise ValueError(value)
+ year = int(value[0:4])
+ month = int(value[4:6])
+ day = int(value[6:8])
+ return _datetime.date(year=year, month=month, day=day)
+
+ @classmethod
+ def encode(cls, property, value):
+ return value.strftime('%Y%m%d')
--- /dev/null
+# Copyright
+
+"""Functions for processing dates with times
+
+As defined in :RFC:`5545`, section 3.3.5 (Date-Time).
+"""
+
+import datetime as _datetime
+
+from . import base as _base
+from . import date as _date
+from . import time as _time
+
+
+class DateTime (_base.DataType):
+ name = 'DATE-TIME'
+
+ @classmethod
+ def decode(cls, property, value):
+ """Parse dates with times
+
+ As defined in :RFC:`5545`, section 3.3.5 (Date-Time).
+
+ >>> import pytz
+
+ >>> DateTime.decode(property={}, value='19980118T230000')
+ datetime.datetime(1998, 1, 18, 23, 0)
+ >>> DateTime.decode(property={}, value='19980119T070000Z')
+ datetime.datetime(1998, 1, 19, 7, 0, tzinfo=datetime.timezone.utc)
+
+ The following represents 2:00 A.M. in New York on January 19,
+ 1998:
+
+ >>> ny = {'TZID': 'America/New_York'}
+ >>> DateTime.decode(property=ny, value='19980119T020000')
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.datetime(1998, 1, 19, 2, 0,
+ tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
+
+ If, based on the definition of the referenced time zone, the local
+ time described occurs more than once (when changing from daylight
+ to standard time), the ``DATE-TIME`` value refers to the first
+ occurrence of the referenced time. Thus,
+ ``TZID=America/New_York:20071104T013000`` indicates November 4,
+ 2007 at 1:30 A.M. EDT (UTC-04:00).
+
+ >>> DateTime.decode(property=ny, value='20071104T013000')
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.datetime(2007, 11, 4, 1, 30,
+ tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
+
+ If the local time described does not occur (when changing from
+ standard to daylight time), the ``DATE-TIME`` value is interpreted
+ using the UTC offset before the gap in local times. Thus,
+ ``TZID=America/New_York:20070311T023000`` indicates March 11, 2007
+ at 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST
+ (UTC-05:00).
+
+ >>> DateTime.decode(property=ny, value='20070311T023000')
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.datetime(2007, 3, 11, 2, 30,
+ tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
+
+ A time value MUST only specify the second 60 when specifying a
+ positive leap second. For example:
+
+ >>> DateTime.decode(property={}, value='19970630T235960Z')
+ datetime.datetime(1997, 6, 30, 23, 59, 59, tzinfo=datetime.timezone.utc)
+
+ Implementations that do not support leap seconds SHOULD interpret
+ the second 60 as equivalent to the second 59.
+
+ The following represents July 14, 1997, at 1:30 PM in New York
+ City in each of the three time formats
+
+ >>> DateTime.decode(property={}, value='19970714T133000')
+ datetime.datetime(1997, 7, 14, 13, 30)
+ >>> d = DateTime.decode(property={}, value='19970714T173000Z')
+ >>> d
+ datetime.datetime(1997, 7, 14, 17, 30, tzinfo=datetime.timezone.utc)
+ >>> d.astimezone(pytz.timezone('America/New_York'))
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.datetime(1997, 7, 14, 13, 30,
+ tzinfo=<DstTzInfo 'America/New_York' EDT-1 day, 20:00:00 DST>)
+ >>> DateTime.decode(property=ny, value='19970714T133000')
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.datetime(1997, 7, 14, 13, 30,
+ tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
+ """
+ date,time = value.split('T')
+ date = _date.Date.decode(property=property, value=date)
+ time = _time.Time.decode(property=property, value=time)
+ return _datetime.datetime.combine(date=date, time=time)
+
+ @classmethod
+ def encode(cls, property, value):
+ return '{}T{}'.format(
+ _date.Date.encode(property=property, value=value.date()),
+ _time.Time.encode(property=property, value=value.timetz()),
+ )
--- /dev/null
+# Copyright
+
+"""Functions for processing geographic position
+
+As defined in :RFC:`5545`, section 3.8.1.6 (Geographic Position).
+"""
+
+from . import base as _base
+
+
+class Geo (_base.DataType):
+ name = 'GEO'
+
+ @classmethod
+ def decode(cls, property, value):
+ """Parse geographic position
+
+ As defined in :RFC:`5545`, section 3.8.1.6 (Geographic
+ Position).
+
+ >>> Geo.decode(property={}, value='37.386013;-122.082932')
+ (37.386013, -122.082932)
+ """
+ geo = tuple(float(x) for x in value.split(';'))
+ if len(geo) != 2:
+ raise ValueError(value)
+ return geo
+
+ @classmethod
+ def encode(cls, property, value):
+ return '{:.6f};{:.6f}'.format(*value)
--- /dev/null
+# Copyright
+
+"""Functions for processing numeric types
+
+As defined in :RFC:`5545`, sections 3.3.7 (Float) and 3.3.8 (Integer).
+"""
+
+from . import base as _base
+
+
+class Integer (_base.DataType):
+ name = 'INTEGER'
+
+ @classmethod
+ def decode(cls, property, value):
+ return int(value)
+
+ @classmethod
+ def encode(cls, property, value):
+ return '{:d}'.format(value)
+
+
+class Float (_base.DataType):
+ name = 'FLOAT'
+
+ @classmethod
+ def decode(cls, property, value):
+ return int(value)
+
+ @classmethod
+ def encode(cls, property, value):
+ return '{:d}'.format(value)
import logging as _logging
import re as _re
+from . import base as _base
+
_LOG = _logging.getLogger(__name__)
"""
return _UNESCAPE_REGEXP.subn(
repl=_unescape_replacer, string=text)[0]
+
+
+class Text (_base.DataType):
+ name = 'TEXT'
+
+ @classmethod
+ def decode(cls, property, value):
+ return unescape(text=value)
+
+ @classmethod
+ def encode(cls, property, value):
+ return escape(text=value)
+
+
+class UniversalResourceLocator (Text):
+ name = 'URI'
--- /dev/null
+# Copyright
+
+"""Functions for processing times without dates
+
+As defined in :RFC:`5545`, section 3.3.12 (Time).
+"""
+
+import datetime as _datetime
+
+import pytz as _pytz
+
+from . import base as _base
+
+
+class Time (_base.DataType):
+ name = 'TIME'
+
+ @classmethod
+ def decode(cls, property, value):
+ """Decode times without dates
+
+ As defined in :RFC:`5545`, section 3.3.12 (Time).
+
+ >>> Time.decode(property={}, value='230000')
+ datetime.time(23, 0)
+ >>> Time.decode(property={}, value='070000Z')
+ datetime.time(7, 0, tzinfo=datetime.timezone.utc)
+ >>> Time.decode(property={}, value='083000')
+ datetime.time(8, 30)
+ >>> Time.decode(property={}, value='133000Z')
+ datetime.time(13, 30, tzinfo=datetime.timezone.utc)
+ >>> Time.decode(property={'TZID': 'America/New_York'}, value='083000')
+ ... # doctest: +NORMALIZE_WHITESPACE
+ datetime.time(8, 30,
+ tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
+ """
+ tzinfo = property.get('TZID', None)
+ if len(value) not in [6,7]:
+ raise ValueError(value)
+ hour = int(value[0:2])
+ minute = int(value[2:4])
+ second = int(value[4:6])
+ if second == 60: # positive leap second not supported by Python
+ second = 59
+ if value.endswith('Z'):
+ tzinfo = _datetime.timezone.utc
+ elif tzinfo:
+ tzinfo = _pytz.timezone(tzinfo)
+ return _datetime.time(
+ hour=hour, minute=minute, second=second, tzinfo=tzinfo)
+
+ @classmethod
+ def encode(cls, property, value):
+ if value.tzinfo == _datetime.timezone.utc:
+ return value.strftime('%H%M%SZ')
+ return value.strftime('%H%M%S')
+++ /dev/null
-# Copyright (C) 2013 W. Trevor King <wking@tremily.us>
-#
-# This file is part of pycalender.
-#
-# pycalender is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# pycalender is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along with
-# pycalender. If not, see <http://www.gnu.org/licenses/>.
-
-import logging as _logging
-
-from . import text as _text
-
-
-_LOG = _logging.getLogger(__name__)
-
-
-class Entry (dict):
- r"""An iCalendar entry (e.g. VEVENT)
-
- Load example content.
-
- >>> import codecs
- >>> import os
- >>> root_dir = os.curdir
- >>> data_file = os.path.abspath(os.path.join(
- ... root_dir, 'test', 'data', 'geohash.ics'))
- >>> with codecs.open(data_file, 'r', 'UTF-8') as f:
- ... content = f.read()
-
- Make an entry.
-
- >>> calendar = Entry(content=content)
-
- Investigate the entry.
-
- >>> print(calendar) # doctest: +REPORT_UDIFF
- BEGIN:VCALENDAR
- VERSION:2.0
- PRODID:-//Example Calendar//NONSGML v1.0//EN
- BEGIN:VEVENT
- UID:2013-06-30@geohash.invalid
- DTSTAMP:20130630T000000Z
- DTSTART;VALUE=DATE:20130630
- DTEND;VALUE=DATE:20130701
- SUMMARY:XKCD geohashing\, Boston graticule
- URL:http://xkcd.com/426/
- LOCATION:Snow Hill\, Dover\, Massachusetts
- GEO:42.226663;-71.28676
- END:VEVENT
- END:VCALENDAR
-
- >>> calendar.type
- 'VCALENDAR'
-
- ``Entry`` subclasses Python's ``dict``, so you can access raw
- field values in the usual ways.
-
- >>> calendar['VERSION']
- '2.0'
- >>> calendar.get('missing')
- >>> calendar.get('missing', 'some default')
- 'some default'
- >>> sorted(calendar.keys())
- ['PRODID', 'VERSION', 'VEVENT']
-
-
- Dig into the children (which are always stored as lists):
-
- >>> event = calendar['VEVENT'][0]
-
- >>> event.type
- 'VEVENT'
- >>> event.content # doctest: +ELLIPSIS
- 'BEGIN:VEVENT\r\nUID:...\r\nEND:VEVENT\r\n'
- >>> sorted(event.keys())
- ['DTEND', 'DTSTAMP', 'DTSTART', 'GEO', 'LOCATION', 'SUMMARY', 'UID', 'URL']
-
- >>> event['LOCATION']
- 'Snow Hill\\, Dover\\, Massachusetts'
-
- You can also use ``get_text`` to unescape text fields.
-
- >>> event.get_text('LOCATION')
- 'Snow Hill, Dover, Massachusetts'
- """
- def __init__(self, type=None, content=None):
- super(Entry, self).__init__()
- if type is None and content:
- firstline = content.splitlines()[0]
- type = firstline.split(':', 1)[1]
- self.type = type
- self.content = content
- self._lines = None # unwrapped semantic lines
- if content:
- self.process()
-
- def __hash__(self):
- if self.type in [
- 'VEVENT',
- 'VFREEBUSY',
- 'VJOURNAL',
- 'VTODO',
- ] or 'UID' in self:
- return hash(_text.unescape(self['UID']))
- return id(self)
-
- def __str__(self):
- if self.content:
- return self.content.replace('\r\n', '\n').strip()
- return ''
-
- def __repr__(self):
- return '<{} type:{}>'.format(type(self).__name__, self.type)
-
- def process(self):
- self.unfold()
- self._parse()
-
- def _parse(self):
- self.clear()
- for index,verb,expected in [
- [0, 'begin', 'BEGIN:{}'.format(self.type)],
- [-1, 'end', 'END:{}'.format(self.type)],
- ]:
- if self._lines[index] != expected:
- raise ValueError('entry should {} with {!r}, not {!r}'.format(
- verb, expected, self._lines[index]))
- stack = []
- child_lines = []
- for i,line in enumerate(self._lines[1:-1]):
- key,parameters,value = self._parse_key_value(line)
- if key == 'BEGIN':
- _LOG.debug('{!r}: begin {}'.format(self, value))
- stack.append(value)
- if stack:
- child_lines.append(line)
- if key == 'END':
- _LOG.debug('{!r}: end {}'.format(self, value))
- if not stack or value != stack[-1]:
- raise ValueError(
- ('closing {} on line {}, but current stack is {}'
- ).format(value, i+1, stack))
- stack.pop(-1)
- if not stack:
- child = Entry(
- type=value,
- content='\r\n'.join(child_lines) + '\r\n',
- )
- child._lines = child_lines
- child._parse()
- self._add_value(key=value, value=child, force_list=True)
- child_lines = []
- elif not stack: # our own data, not a child's
- if key == 'VERSION':
- v = _text.unescape(value)
- if v != '2.0':
- raise NotImplementedError(
- 'cannot parse VERSION {} feed'.format(v))
- self._add_value(key=key, value=value)
-
- def _parse_key_value(self, line):
- key,value = [x.strip() for x in line.split(':', 1)]
- parameters = key.split(';')
- key = parameters.pop(0)
- parameters = {tuple(x.split('=', 1)) for x in parameters}
- for k,v in parameters:
- if ',' in v:
- parameters = v.split(',')
- if parameters and key in ['BEGIN', 'END']:
- raise ValueError(
- 'parameters are not allowed with {}: {}'.format(
- key, line))
- return (key, parameters, value)
-
- def _add_value(self, key, value, force_list=False):
- if force_list and key not in self:
- self[key] = []
- if key in self:
- if type(self[key]) == str:
- self[key] = [self[key]]
- self[key].append(value)
- else:
- self[key] = value
-
- def unfold(self):
- """Unfold wrapped lines
-
- Following :RFC:`5545`, section 3.1 (Content Lines)
- """
- self._lines = []
- semantic_line_chunks = []
- for line in self.content.splitlines():
- lstrip = line.lstrip()
- if lstrip != line:
- if not semantic_line_chunks:
- raise ValueError(
- ('whitespace-prefixed line {!r} is not a continuation '
- 'of a previous line').format(line))
- semantic_line_chunks.append(lstrip)
- else:
- if semantic_line_chunks:
- self._lines.append(''.join(semantic_line_chunks))
- semantic_line_chunks = [line]
- if semantic_line_chunks:
- self._lines.append(''.join(semantic_line_chunks))
-
- def get_text(self, *args, **kwargs):
- """Get and unescape a text value
-
- As described in :RFC:`5545`, section 3.3.11 (Text)
- """
- value = self.get(*args, **kwargs)
- return _text.unescape(value)
-
- def get_geo(self, key='GEO', *args, **kwargs):
- """Get and unescape a GEO value
-
- As described in :RFC:`5545`, section 3.8.1.6 (Geographic
- Position).
- """
- value = self.get(key, *args, **kwargs)
- lat,lon = [float(x) for x in value.split(';')]
- return (lat, lon)
-
- def write(self, stream):
- stream.write(self.content)
# You should have received a copy of the GNU General Public License along with
# pycalender. If not, see <http://www.gnu.org/licenses/>.
+import codecs as _codecs
import logging as _logging
import urllib.request as _urllib_request
from . import USER_AGENT as _USER_AGENT
-from . import entry as _entry
+from . import property as _property
+from . import unfold as _unfold
+from .component import calendar as _calendar
_LOG = _logging.getLogger(__name__)
-class Feed (_entry.Entry):
+class Feed (_calendar.Calendar):
r"""An iCalendar feed (:RFC:`5545`)
Figure out where the example feed is located, relative to the
>>> f = Feed(url=url)
>>> f # doctest: +ELLIPSIS
<Feed url:file://.../test/data/geohash.ics>
- >>> print(f)
- <BLANKLINE>
Load the feed content.
>>> print(f) # doctest: +REPORT_UDIFF
BEGIN:VCALENDAR
- VERSION:2.0
PRODID:-//Example Calendar//NONSGML v1.0//EN
+ VERSION:2.0
BEGIN:VEVENT
- UID:2013-06-30@geohash.invalid
DTSTAMP:20130630T000000Z
+ UID:2013-06-30@geohash.invalid
DTSTART;VALUE=DATE:20130630
- DTEND;VALUE=DATE:20130701
+ GEO:42.226663;-71.286760
+ LOCATION:Snow Hill\, Dover\, Massachusetts
SUMMARY:XKCD geohashing\, Boston graticule
URL:http://xkcd.com/426/
- LOCATION:Snow Hill\, Dover\, Massachusetts
- GEO:42.226663;-71.28676
+ DTEND;VALUE=DATE:20130701
END:VEVENT
END:VCALENDAR
>>> stream = io.StringIO()
>>> f.write(stream=stream)
>>> stream.getvalue() # doctest: +ELLIPSIS
- 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...END:VCALENDAR\r\n'
+ 'BEGIN:VCALENDAR\r\nPRODID:...END:VCALENDAR\r\n'
You can also iterate through events:
>>> for event in f['VEVENT']:
... print(repr(event))
... print(event)
- <Entry type:VEVENT>
+ ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+ <Event name:VEVENT at 0x...>
BEGIN:VEVENT
- UID:2013-06-30@geohash.invalid
DTSTAMP:20130630T000000Z
+ UID:2013-06-30@geohash.invalid
DTSTART;VALUE=DATE:20130630
- DTEND;VALUE=DATE:20130701
+ GEO:42.226663;-71.286760
+ LOCATION:Snow Hill\, Dover\, Massachusetts
SUMMARY:XKCD geohashing\, Boston graticule
URL:http://xkcd.com/426/
- LOCATION:Snow Hill\, Dover\, Massachusetts
- GEO:42.226663;-71.28676
+ DTEND;VALUE=DATE:20130701
END:VEVENT
"""
def __init__(self, url, user_agent=None):
self.user_agent = user_agent
def __repr__(self):
- return '<{} url:{}>'.format(type(self).__name__, self.url)
-
- def fetch(self, force=False):
- if self.content is None or force:
- self._fetch()
- self.process()
+ return '<{}.{} url:{}>'.format(
+ self.__module__, type(self).__name__, self.url)
- def _fetch(self):
+ def fetch(self):
+ self.clear()
request = _urllib_request.Request(
url=self.url,
headers={
content_type = info.get('Content-type', None)
if content_type != 'text/calendar':
raise ValueError(content_type)
- byte_content = f.read()
- self.content = str(byte_content, encoding='UTF-8')
+ codec = _codecs.lookup('UTF-8')
+ with codec.streamreader(stream=f) as stream:
+ self.parse(stream=stream)
+
+ def parse(self, stream):
+ lines = _unfold.unfold(stream=stream)
+ line = next(lines)
+ prop = _property.parse(line=line)
+ if prop.name != 'BEGIN' or prop.value != self.name:
+ raise ValueError(
+ "stream {} must start with 'BEGIN:VCALENDAR', not {!r}".format(
+ stream, line))
+ self.read(lines=lines)
--- /dev/null
+# Copyright
+
+"""Classes representing calendar properties
+
+As defined in :RFC:`5545`, sections 3.7 (Calendar Properties) and 3.8
+(Component Properties).
+"""
+
+from . import base as _base
+
+from . import alarm as _alarm
+from . import calendar as _calendar
+from . import change as _change
+from . import component as _component
+from . import datetime as _datetime
+from . import descriptive as _descriptive
+from . import misc as _misc
+from . import recurrence as _recurrence
+from . import relationship as _relationship
+from . import timezone as _timezone
+
+
+PROPERTY = {}
+
+
+def register(property):
+ """Register a property class
+ """
+ PROPERTY[property.name] = property
+
+
+def parse(line):
+ name_param,value = [x.strip() for x in line.split(':', 1)]
+ parameters = name_param.split(';')
+ name = parameters.pop(0).upper() # names are case insensitive
+ parameters = dict(tuple(x.split('=', 1)) for x in parameters)
+ for k,v in parameters.items():
+ if ',' in v:
+ parameters[k] = v.split(',')
+ prop_class = PROPERTY[name]
+ prop = prop_class(parameters=parameters)
+ prop.check_parameters()
+ prop.value = prop.decode(value=value)
+ prop.check_value()
+ return prop
+
+
+for module in [
+ _alarm,
+ _calendar,
+ _change,
+ _component,
+ _datetime,
+ _descriptive,
+ _misc,
+ _recurrence,
+ _relationship,
+ _timezone,
+ ]:
+ for name in dir(module):
+ if name.startswith('_'):
+ continue
+ obj = getattr(module, name)
+ if isinstance(obj, type) and issubclass(obj, _base.Property):
+ register(property=obj)
+del module, name, obj
--- /dev/null
+ ## RFC 5545, section 3.8.6 (Alarm Component Properties)
+ ### RFC 5545, section 3.8.6.1 (Action)
+ ### RFC 5545, section 3.8.6.2 (Repeat Count)
+ ### RFC 5545, section 3.8.6.3 (Trigger)
--- /dev/null
+# Copyright
+
+import io as _io
+import itertools as _itertools
+
+from .. import dtype as _dtype
+
+
+class Property (dict):
+ """An iCalendar property (e.g. VERSION)
+
+ As defined in :RFC:`5545`, section 3.5 (Property). Property names
+ are defined in sections 3.7 (Calendar Properties) and 3.8
+ (Component Properties). Parameters are defined in section 3.2
+ (Property Parameters), and value data types are defined in section
+ 3.3 (Property Value Data Types).
+ """
+ name = None
+ parameters = []
+ dtypes = []
+ separator = None
+
+ def __init__(self, parameters=None, value=None):
+ if not parameters:
+ parameters = {}
+ super(Property, self).__init__()
+ self.update(parameters)
+ self.value = value
+
+ def __hash__(self):
+ return id(self)
+
+ def __str__(self):
+ with _io.StringIO() as stream:
+ self.write(stream=stream, newline='\n')
+ return stream.getvalue()[:-1] # strip the trailing newline
+
+ def __repr__(self):
+ return '<{}.{} name:{} at {:#x}>'.format(
+ self.__module__, type(self).__name__, self.name, id(self))
+
+ def decode(self, value):
+ dtype = self._get_dtype()
+ return dtype.decode(property=self, value=value)
+
+ def encode(self, value):
+ dtype = self._get_dtype()
+ return dtype.encode(property=self, value=value)
+
+ def _get_dtype(self, dtype=None):
+ if not dtype:
+ if not self.dtypes:
+ raise NotImplementedError('no default types for {!r}'.format(
+ self))
+ dtype = self.get('VALUE', self.dtypes[0])
+ if dtype not in self.dtypes:
+ raise ValueError('invalid type {} for {!r}'.format(
+ dtype, self.name))
+ return _dtype.DTYPE[dtype]
+
+ def check_parameters(self):
+ for parameter in self.keys():
+ if parameter not in self.parameters:
+ raise ValueError(
+ 'invalid parameter {} for {!r}'.format(parameter, self))
+
+ def check_value(self):
+ pass
+
+ def write(self, stream, newline='\r\n', width=75):
+ name_param = self.name
+ line = '{}:{}'.format(
+ ';'.join(_itertools.chain(
+ [name_param],
+ ['{}={}'.format(key, value)
+ for key,value in sorted(self.items())])),
+ self.encode(self.value))
+ lines = []
+ if width:
+ while len(line) > width:
+ front = line[0:width]
+ line = line[width:]
+ if not lines:
+ width -= 1 # make room for the indent space
+ else:
+ front = ' {}'.format(front) # add the indent space
+ lines.append(front)
+ if lines: # indent the last line
+ line = ' {}'.format(line)
+ lines.append(line)
+ for line in lines:
+ stream.write('{}{}'.format(line, newline))
--- /dev/null
+# Copyright
+
+"""Classes representing calendar properties
+
+As defined in :RFC:`5545`, section 3.7 (Calendar Properties).
+"""
+
+from . import base as _base
+
+
+class CalendarScale (_base.Property):
+ ## RFC 5545, section 3.7.1 (Calendar Scale)
+ name = 'CALSCALE'
+ dtypes = ['TEXT']
+
+
+class Method (_base.Property):
+ ## RFC 5545, section 3.7.2 (Method)
+ name = 'METHOD'
+ dtypes = ['TEXT']
+
+
+class ProductIdentifier (_base.Property):
+ ## RFC 5545, section 3.7.3 (Product Identifier)
+ name = 'PRODID'
+ dtypes = ['TEXT']
+
+
+class Version (_base.Property):
+ ## RFC 5545, section 3.7.4 (Version)
+ name = 'VERSION'
+ dtypes = ['TEXT']
+
+ def _check_value(self):
+ if self.value != '2.0':
+ raise NotImplementedError(
+ 'cannot parse {} {}'.format(self.name, self.value))
--- /dev/null
+# Copyright
+
+"""Classes representing change management properties
+
+As defined in :RFC:`5545`, section 3.8.7 (Change Management Component
+Properties).
+"""
+
+from . import base as _base
+
+
+ ## RFC 5545, section 3.8.7 (Change Management Component Properties)
+ ### RFC 5545, section 3.8.7.1 (Date-Time Created)
+
+
+class DateTimeStamp (_base.Property):
+ ### RFC 5545, section 3.8.7.2 (Date-Time Stamp)
+ name = 'DTSTAMP'
+ dtypes = ['DATE-TIME']
+
+
+ ### RFC 5545, section 3.8.7.3 (Last Modified)
+
+
+class SequenceNumber (_base.Property):
+ ### RFC 5545, section 3.8.7.4 (Sequence Number)
+ name = 'SEQUENCE'
+ dtypes = ['INTEGER']
--- /dev/null
+# Copyright
+
+"""Classes representing calendar compenents
+
+As defined in :RFC:`5545`, section 3.6 (Calendar Components). These
+aren't really properties, but the component parsing logic is simpler
+if we pretend that they are.
+"""
+
+from . import base as _base
+
+
+class BeginComponent (_base.Property):
+ ## RFC 5545, section 3.6 (Calendar Components)
+ name = 'BEGIN'
+ dtypes = ['TEXT']
+
+
+class EndComponent (_base.Property):
+ ## RFC 5545, section 3.6 (Calendar Components)
+ name = 'END'
+ dtypes = ['TEXT']
--- /dev/null
+# Copyright
+
+"""Classes representing date and time properties
+
+As defined in :RFC:`5545`, section 3.8.2 (Date and Time Component
+Properties).
+"""
+
+from . import base as _base
+
+
+class DateTimeCompleted (_base.Property):
+ ### RFC 5545, section 3.8.2.1 (Date-Time Completed)
+ name = 'COMPLETED'
+ parameters = ['TZID']
+ dtypes = ['DATE-TIME']
+
+
+class DateTimeEnd (_base.Property):
+ ### RFC 5545, section 3.8.2.2 (Date-Time End)
+ name = 'DTEND'
+ parameters = ['TZID', 'VALUE']
+ dtypes = ['DATE-TIME', 'DATE']
+
+
+class DateTimeDue (_base.Property):
+ ### RFC 5545, section 3.8.2.3 (Date-Time Due)
+ name = 'DUE'
+ parameters = ['TZID', 'VALUE']
+ dtypes = ['DATE-TIME', 'DATE']
+
+
+class DateTimeStart (_base.Property):
+ ### RFC 5545, section 3.8.2.4 (Date-Time Start)
+ name = 'DTSTART'
+ parameters = ['TZID', 'VALUE']
+ dtypes = ['DATE-TIME', 'DATE']
+
+
+ ### RFC 5545, section 3.8.2.5 (Duration)
+ ### RFC 5545, section 3.8.2.6 (Free/Busy Time)
+
+
+class TimeTransparency (_base.Property):
+ ### RFC 5545, section 3.8.2.7 (Time Transparency)
+ name = 'TRANSP'
+ dtypes = ['TEXT']
--- /dev/null
+# Copyright
+
+"""Classes representing descriptive component properties
+
+As defined in :RFC:`5545`, section 3.8.1 (Descriptive Component
+Properties).
+"""
+
+from . import base as _base
+
+
+class Attachment (_base.Property):
+ ### RFC 5545, section 3.8.1.1 (Attachment)
+ name = 'ATTACH'
+ dtypes = ['URI', 'BINARY']
+
+ def _decode_value(self, property):
+ type = property.parameters.get('VALUE', self.type)
+ if type not in ['BINARY', 'URI']:
+ raise ValueError('unregognized {} value: {}'.format(
+ self.name, type))
+ decoder = self.decoder or DECODER.get(self.type, None)
+ if decoder:
+ property.value = decoder(property=property)
+
+
+class Categories (_base.Property):
+ ### RFC 5545, section 3.8.1.2 (Categories)
+ name = 'CATEGORIES'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class Classification (_base.Property):
+ ### RFC 5545, section 3.8.1.3 (Classification)
+ name = 'CLASSIFICATION'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class Comment (_base.Property):
+ ### RFC 5545, section 3.8.1.4 (Comment)
+ name = 'COMMENT'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class Description (_base.Property):
+ ### RFC 5545, section 3.8.1.5 (Description)
+ name = 'DESCRIPTION'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class GeographicPosition (_base.Property):
+ ### RFC 5545, section 3.8.1.6 (Geographic Position)
+ name = 'GEO'
+ dtypes = ['GEO']
+
+
+class Location (_base.Property):
+ ### RFC 5545, section 3.8.1.7 (Location)
+ name = 'LOCATION'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class PercentComplete (_base.Property):
+ ### RFC 5545, section 3.8.1.8 (Percent Complete)
+ name = 'PERCENT-COMPLETE'
+ dtypes = 'INTEGER'
+
+
+class Priority (_base.Property):
+ ### RFC 5545, section 3.8.1.9 (Priority)
+ name = 'PRIORITY'
+ dtypes = ['INTEGER']
+
+
+class Resources (_base.Property):
+ ### RFC 5545, section 3.8.1.10 (Resources)
+ name = 'RESOURCES'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class Status (_base.Property):
+ ### RFC 5545, section 3.8.1.11 (Status)
+ name = 'STATUS'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
+
+
+class Summary (_base.Property):
+ ### RFC 5545, section 3.8.1.12 (Summary)
+ name = 'SUMMARY'
+ parameters = ['ALTREP']
+ dtypes = ['TEXT']
--- /dev/null
+ ## RFC 5545, section 3.8.8 (Miscellaneous Component Properties)
+ ### RFC 5545, section 3.8.8.1 (IANA Properties)
+ ### RFC 5545, section 3.8.8.2 (Non-Standard Properties)
+ ### RFC 5545, section 3.8.8.3 (Request Status)
--- /dev/null
+ ## RFC 5545, section 3.8.5 (Recurrence Component Properties)
+ ### RFC 5545, section 3.8.5.1 (Exception Date-Times)
+ ### RFC 5545, section 3.8.5.1 (Recurrence Date-Times)
+ ### RFC 5545, section 3.8.5.1 (Recurrence Rule)
--- /dev/null
+# Copyright
+
+"""Classes representing relationship properties
+
+As defined in :RFC:`5545`, section 3.8.4 (Relationship Component
+Properties).
+"""
+
+from . import base as _base
+
+
+ ## RFC 5545, section 3.8.4 (Relationship Component Properties)
+ ### RFC 5545, section 3.8.4.1 (Attendee)
+ ### RFC 5545, section 3.8.4.2 (Contact)
+ ### RFC 5545, section 3.8.4.3 (Organizer)
+ ### RFC 5545, section 3.8.4.4 (Recurrence ID)
+ ### RFC 5545, section 3.8.4.5 (Related To)
+
+
+class UniformResourceLocator (_base.Property):
+ ### RFC 5545, section 3.8.4.6 (Uniform Resource Locator)
+ name = 'URL'
+ dtypes = ['URI']
+
+
+class UniqueIdentifier (_base.Property):
+ ### RFC 5545, section 3.8.4.7 (Unique Identifier)
+ name = 'UID'
+ dtypes = ['TEXT']
--- /dev/null
+ ## RFC 5545, section 3.8.3 (Time Zone Component Properties)
+ ### RFC 5545, section 3.8.3.1 (Time Zone Identifier)
+ ### RFC 5545, section 3.8.3.2 (Time Zone Name)
+ ### RFC 5545, section 3.8.3.3 (Time Zone Offset From)
+ ### RFC 5545, section 3.8.3.4 (Time Zone Offset To)
+ ### RFC 5545, section 3.8.3.5 (Time Zone URL)
--- /dev/null
+# Copyright
+
+def _remove_newline(line):
+ for newline in ['\r\n', '\n']:
+ if line.endswith(newline):
+ return line[:-len(newline)]
+ raise ValueError('invalid line ending in {!r}'.format(line))
+
+
+def unfold(stream):
+ r"""Iterate through semantic lines, unfolding as neccessary
+
+ Following :RFC:`5545`, section 3.1 (Content Lines).
+
+ >>> import io
+ >>> stream = io.StringIO('\r\n'.join([
+ ... 'BEGIN:VCALENDER',
+ ... r'DESCRIPTION:Discuss how we can test c&s interoperability\n',
+ ... ' using iCalendar and other IETF standards.',
+ ... 'ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIH'
+ ... ' F1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4',
+ ... '']))
+ >>> for line in unfold(stream=stream):
+ ... print(repr(line))
+ ... # doctest: +REPORT_UDIFF
+ 'BEGIN:VCALENDER'
+ 'DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.'
+ 'ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIH F1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4'
+ """
+ semantic_line_chunks = []
+ for line in stream:
+ line = _remove_newline(line)
+ lstrip = line.lstrip()
+ if lstrip != line:
+ if not semantic_line_chunks:
+ raise ValueError(
+ ('whitespace-prefixed line {!r} is not a continuation '
+ 'of a previous line').format(line))
+ semantic_line_chunks.append(lstrip)
+ else:
+ if semantic_line_chunks:
+ yield ''.join(semantic_line_chunks)
+ semantic_line_chunks = [line]
+ if semantic_line_chunks:
+ yield ''.join(semantic_line_chunks)
'Topic :: Office/Business :: Scheduling',
'Topic :: Software Development :: Libraries :: Python Modules',
],
- packages=[_name],
+ packages=[
+ _name,
+ '{}.component'.format(_name),
+ '{}.dtype'.format(_name),
+ '{}.property'.format(_name),
+ ],
provides=[_name],
)
def add_event(self, event):
if 'GEO' in event:
- lat,lon = event.get_geo()
+ lat,lon = event['GEO'].value
self.stream.write('{} at lat {}, lon {}\n'.format(
event['UID'], lat, lon))