Major restructuring to get automatic decoding/encoding
authorW. Trevor King <wking@tremily.us>
Tue, 2 Jul 2013 02:03:08 +0000 (22:03 -0400)
committerW. Trevor King <wking@tremily.us>
Tue, 2 Jul 2013 02:08:45 +0000 (22:08 -0400)
Storing raw iCalendar data or unfolded lines is silly.  We should only
store native Python types, and serialize/deserialize as needed.  This
monster commit reworks the whole package to make this possible, adding
sub-packages for a number of data-types, properties, and components
defined in RFC 5545.  The conversion is not complete yet, and there
are a number of placeholder comments waiting to be filled in still.

Some of the test output changed because now that we serialize from
scratch, we lose information about the input that was unimportant
(e.g. GEO precision is now rounded up to 6 decimals, properties are
re-ordered inside their component, etc.).

36 files changed:
README
pycalendar/aggregator.py
pycalendar/component/__init__.py [new file with mode: 0644]
pycalendar/component/alarm.py [new file with mode: 0644]
pycalendar/component/base.py [new file with mode: 0644]
pycalendar/component/calendar.py [new file with mode: 0644]
pycalendar/component/event.py [new file with mode: 0644]
pycalendar/component/freebusy.py [new file with mode: 0644]
pycalendar/component/journal.py [new file with mode: 0644]
pycalendar/component/timezone.py [new file with mode: 0644]
pycalendar/component/todo.py [new file with mode: 0644]
pycalendar/dtype/__init__.py [new file with mode: 0644]
pycalendar/dtype/base.py [new file with mode: 0644]
pycalendar/dtype/date.py [new file with mode: 0644]
pycalendar/dtype/datetime.py [new file with mode: 0644]
pycalendar/dtype/geo.py [new file with mode: 0644]
pycalendar/dtype/numeric.py [new file with mode: 0644]
pycalendar/dtype/text.py [moved from pycalendar/text.py with 91% similarity]
pycalendar/dtype/time.py [new file with mode: 0644]
pycalendar/entry.py [deleted file]
pycalendar/feed.py
pycalendar/property/__init__.py [new file with mode: 0644]
pycalendar/property/alarm.py [new file with mode: 0644]
pycalendar/property/base.py [new file with mode: 0644]
pycalendar/property/calendar.py [new file with mode: 0644]
pycalendar/property/change.py [new file with mode: 0644]
pycalendar/property/component.py [new file with mode: 0644]
pycalendar/property/datetime.py [new file with mode: 0644]
pycalendar/property/descriptive.py [new file with mode: 0644]
pycalendar/property/misc.py [new file with mode: 0644]
pycalendar/property/recurrence.py [new file with mode: 0644]
pycalendar/property/relationship.py [new file with mode: 0644]
pycalendar/property/timezone.py [new file with mode: 0644]
pycalendar/unfold.py [new file with mode: 0644]
setup.py
test/aggregate.py

diff --git a/README b/README
index 2818559e4ac48397339ab8c955c90627e48969bf..281547dd3af3d635e6e292454b3ed05fb1f6b837 100644 (file)
--- a/README
+++ b/README
@@ -22,7 +22,7 @@ the usual ways.  Any of the following should work::
   $ 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
 =======
@@ -44,3 +44,4 @@ to stdout and geographic positions to stderr.  Run it with::
 .. _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/
index 805a305aedb461d633ba284fdd06463f4a18de9c..7a78aabb6c357cf0467f422c82a87c30cd634f9f 100644 (file)
@@ -14,7 +14,9 @@
 # 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):
@@ -55,28 +57,30 @@ 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:
@@ -88,18 +92,11 @@ class Aggregator (list):
             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)
diff --git a/pycalendar/component/__init__.py b/pycalendar/component/__init__.py
new file mode 100644 (file)
index 0000000..c4ad542
--- /dev/null
@@ -0,0 +1,141 @@
+# 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
diff --git a/pycalendar/component/alarm.py b/pycalendar/component/alarm.py
new file mode 100644 (file)
index 0000000..fec3ada
--- /dev/null
@@ -0,0 +1,11 @@
+# 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'
diff --git a/pycalendar/component/base.py b/pycalendar/component/base.py
new file mode 100644 (file)
index 0000000..902ccd4
--- /dev/null
@@ -0,0 +1,114 @@
+# 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)
diff --git a/pycalendar/component/calendar.py b/pycalendar/component/calendar.py
new file mode 100644 (file)
index 0000000..31c60b8
--- /dev/null
@@ -0,0 +1,35 @@
+# 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',
+        ]
diff --git a/pycalendar/component/event.py b/pycalendar/component/event.py
new file mode 100644 (file)
index 0000000..bf1c878
--- /dev/null
@@ -0,0 +1,65 @@
+# 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',
+        ]
diff --git a/pycalendar/component/freebusy.py b/pycalendar/component/freebusy.py
new file mode 100644 (file)
index 0000000..428789f
--- /dev/null
@@ -0,0 +1,11 @@
+# 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'
diff --git a/pycalendar/component/journal.py b/pycalendar/component/journal.py
new file mode 100644 (file)
index 0000000..90d564b
--- /dev/null
@@ -0,0 +1,11 @@
+# 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'
diff --git a/pycalendar/component/timezone.py b/pycalendar/component/timezone.py
new file mode 100644 (file)
index 0000000..ab445cd
--- /dev/null
@@ -0,0 +1,11 @@
+# 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'
diff --git a/pycalendar/component/todo.py b/pycalendar/component/todo.py
new file mode 100644 (file)
index 0000000..6f2b36c
--- /dev/null
@@ -0,0 +1,11 @@
+# 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'
diff --git a/pycalendar/dtype/__init__.py b/pycalendar/dtype/__init__.py
new file mode 100644 (file)
index 0000000..83a8b86
--- /dev/null
@@ -0,0 +1,39 @@
+# 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
diff --git a/pycalendar/dtype/base.py b/pycalendar/dtype/base.py
new file mode 100644 (file)
index 0000000..a3a894f
--- /dev/null
@@ -0,0 +1,25 @@
+# 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))
diff --git a/pycalendar/dtype/date.py b/pycalendar/dtype/date.py
new file mode 100644 (file)
index 0000000..8c28755
--- /dev/null
@@ -0,0 +1,34 @@
+# 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')
diff --git a/pycalendar/dtype/datetime.py b/pycalendar/dtype/datetime.py
new file mode 100644 (file)
index 0000000..42b4934
--- /dev/null
@@ -0,0 +1,100 @@
+# 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()),
+            )
diff --git a/pycalendar/dtype/geo.py b/pycalendar/dtype/geo.py
new file mode 100644 (file)
index 0000000..4a7ac70
--- /dev/null
@@ -0,0 +1,31 @@
+# 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)
diff --git a/pycalendar/dtype/numeric.py b/pycalendar/dtype/numeric.py
new file mode 100644 (file)
index 0000000..4574098
--- /dev/null
@@ -0,0 +1,32 @@
+# 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)
similarity index 91%
rename from pycalendar/text.py
rename to pycalendar/dtype/text.py
index b04ffe2c1208aff6318645430d9dcbd2dabbca2a..57617d0092f8e33115e6292f1b6d2caf29e9b3c9 100644 (file)
@@ -22,6 +22,8 @@ As defined in :RFC:`5545`, section 3.3.11 (Text).
 import logging as _logging
 import re as _re
 
+from . import base as _base
+
 
 _LOG = _logging.getLogger(__name__)
 
@@ -117,3 +119,19 @@ def unescape(text):
     """
     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'
diff --git a/pycalendar/dtype/time.py b/pycalendar/dtype/time.py
new file mode 100644 (file)
index 0000000..f4c0178
--- /dev/null
@@ -0,0 +1,56 @@
+# 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')
diff --git a/pycalendar/entry.py b/pycalendar/entry.py
deleted file mode 100644 (file)
index 43ba494..0000000
+++ /dev/null
@@ -1,234 +0,0 @@
-# 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)
index 941b10d8c19b02038a2adbcfb863df4a776c3c46..12d684988757b5f530cbf6bc346e580849fa3800 100644 (file)
 # 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
@@ -42,8 +45,6 @@ class Feed (_entry.Entry):
     >>> f = Feed(url=url)
     >>> f  # doctest: +ELLIPSIS
     <Feed url:file://.../test/data/geohash.ics>
-    >>> print(f)
-    <BLANKLINE>
 
     Load the feed content.
 
@@ -54,17 +55,17 @@ class Feed (_entry.Entry):
 
     >>> 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
 
@@ -75,23 +76,24 @@ class Feed (_entry.Entry):
     >>> 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):
@@ -102,14 +104,11 @@ class Feed (_entry.Entry):
         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={
@@ -121,5 +120,16 @@ class Feed (_entry.Entry):
             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)
diff --git a/pycalendar/property/__init__.py b/pycalendar/property/__init__.py
new file mode 100644 (file)
index 0000000..23dbabb
--- /dev/null
@@ -0,0 +1,66 @@
+# 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
diff --git a/pycalendar/property/alarm.py b/pycalendar/property/alarm.py
new file mode 100644 (file)
index 0000000..d9d8524
--- /dev/null
@@ -0,0 +1,4 @@
+    ## 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)
diff --git a/pycalendar/property/base.py b/pycalendar/property/base.py
new file mode 100644 (file)
index 0000000..cc66bfb
--- /dev/null
@@ -0,0 +1,92 @@
+# 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))
diff --git a/pycalendar/property/calendar.py b/pycalendar/property/calendar.py
new file mode 100644 (file)
index 0000000..c198599
--- /dev/null
@@ -0,0 +1,37 @@
+# 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))
diff --git a/pycalendar/property/change.py b/pycalendar/property/change.py
new file mode 100644 (file)
index 0000000..44067b5
--- /dev/null
@@ -0,0 +1,28 @@
+# 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']
diff --git a/pycalendar/property/component.py b/pycalendar/property/component.py
new file mode 100644 (file)
index 0000000..9abb811
--- /dev/null
@@ -0,0 +1,22 @@
+# 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']
diff --git a/pycalendar/property/datetime.py b/pycalendar/property/datetime.py
new file mode 100644 (file)
index 0000000..04b36cb
--- /dev/null
@@ -0,0 +1,47 @@
+# 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']
diff --git a/pycalendar/property/descriptive.py b/pycalendar/property/descriptive.py
new file mode 100644 (file)
index 0000000..cfde2da
--- /dev/null
@@ -0,0 +1,98 @@
+# 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']
diff --git a/pycalendar/property/misc.py b/pycalendar/property/misc.py
new file mode 100644 (file)
index 0000000..b4452a4
--- /dev/null
@@ -0,0 +1,4 @@
+    ## 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)
diff --git a/pycalendar/property/recurrence.py b/pycalendar/property/recurrence.py
new file mode 100644 (file)
index 0000000..287899f
--- /dev/null
@@ -0,0 +1,4 @@
+    ## 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)
diff --git a/pycalendar/property/relationship.py b/pycalendar/property/relationship.py
new file mode 100644 (file)
index 0000000..3d51914
--- /dev/null
@@ -0,0 +1,29 @@
+# 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']
diff --git a/pycalendar/property/timezone.py b/pycalendar/property/timezone.py
new file mode 100644 (file)
index 0000000..2789ce6
--- /dev/null
@@ -0,0 +1,6 @@
+    ## 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)
diff --git a/pycalendar/unfold.py b/pycalendar/unfold.py
new file mode 100644 (file)
index 0000000..4aa2943
--- /dev/null
@@ -0,0 +1,45 @@
+# 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)
index f7a820f4b0275145c60dfaba42565050b50c6903..2f6f6ec52b92fbb739d023c22ad1a1d9673ee112 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -55,6 +55,11 @@ setup(
         '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],
     )
index 8c2a105fe0c81ba4fdb6d6943bf7f66c7989ab51..4ef38e06068f9bf7596056dfbad9d9c9dbe8cf4f 100755 (executable)
@@ -42,7 +42,7 @@ class Map (list):
 
     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))