diff options
author | Guido Guenther <agx@sigxcpu.org> | 2006-09-09 17:15:23 +0200 |
---|---|---|
committer | Guido Guenther <agx@bogon.sigxcpu.org> | 2006-09-09 17:15:23 +0200 |
commit | 0f963fa4c3d05e0f8b297aeb20aa5d719f7ab31d (patch) | |
tree | 8a580ddfbfbab89549d8e086f175fd0284652421 |
Import upstream version 0.0.svn147
-rw-r--r-- | ACKNOWLEDGEMENTS.txt | 4 | ||||
-rw-r--r-- | LICENSE.txt | 57 | ||||
-rw-r--r-- | README.txt | 227 | ||||
-rw-r--r-- | ez_setup.py | 221 | ||||
-rwxr-xr-x | setup.py | 53 | ||||
-rw-r--r-- | src/vobject/__init__.py | 86 | ||||
-rw-r--r-- | src/vobject/base.py | 978 | ||||
-rw-r--r-- | src/vobject/behavior.py | 161 | ||||
-rw-r--r-- | src/vobject/hcalendar.py | 125 | ||||
-rw-r--r-- | src/vobject/icalendar.py | 1696 | ||||
-rw-r--r-- | src/vobject/vcard.py | 225 | ||||
-rw-r--r-- | src/vobject/win32tz.py | 156 | ||||
-rw-r--r-- | tests/recurrence.ics | 30 | ||||
-rw-r--r-- | tests/tests.py | 711 | ||||
-rw-r--r-- | tests/utf8_test.ics | 39 |
15 files changed, 4769 insertions, 0 deletions
diff --git a/ACKNOWLEDGEMENTS.txt b/ACKNOWLEDGEMENTS.txt new file mode 100644 index 0000000..100138a --- /dev/null +++ b/ACKNOWLEDGEMENTS.txt @@ -0,0 +1,4 @@ +Enormous thanks to:
+Gustavo Niemeyer, for all his work on dateutil
+Dave Cridland, for helping talk about vobject and working on vcard
+TJ Gabbour, for putting his heart into parsing
diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..bbd7e18 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,57 @@ +/* ==================================================================== + * The Apache Software License, Version 1.1 + * + * Copyright (c) 2004-2006 Jeffrey Harris. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. The end-user documentation included with the redistribution, + * if any, must include the following acknowledgment: + * "This product includes software developed by the + * Apache Software Foundation (http://www.apache.org/)." + * Alternately, this acknowledgment may appear in the software itself, + * if and wherever such third-party acknowledgments normally appear. + * + * 4. The names "Apache" and "Apache Software Foundation" must + * not be used to endorse or promote products derived from this + * software without prior written permission. For written + * permission, please contact apache@apache.org. + * + * 5. Products derived from this software may not be called "Apache", + * nor may "Apache" appear in their name, without prior written + * permission of the Apache Software Foundation. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF + * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT + * OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * <http://www.apache.org/>. + * + * Portions of this software are based upon public domain software + * originally written at the National Center for Supercomputing Applications, + * University of Illinois, Urbana-Champaign. + */ + diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..bc94dae --- /dev/null +++ b/README.txt @@ -0,0 +1,227 @@ +======= +VObject +======= + +VObject simplifies the process of parsing and creating iCalendar and +vCard objects. + +-------------- + Installation +-------------- + +To install vobject, run:: + + python setup.py install + +vobject requires the dateutil package, which can be installed via +easy_install or downloaded from http://labix.org/python-dateutil + +--------------- + Running tests +--------------- + +Unit tests live in doctests throughout the source code, to run all tests, use:: + + python tests/tests.py + +------- + Usage +------- + +Creating iCalendar objects +.......................... + +vobject has a basic datastructure for working with iCalendar-like +syntaxes. Additionally, it defines specialized behaviors for many of +the commonly used iCalendar objects. + +To create an object that already has a behavior defined, run: + +>>> import vobject +>>> cal = vobject.newFromBehavior('vcalendar') +>>> cal.behavior +<class 'vobject.icalendar.VCalendar2_0'> + +Convenience functions exist to create iCalendar and vCard objects: + +>>> cal = vobject.iCalendar() +>>> cal.behavior +<class 'vobject.icalendar.VCalendar2_0'> +>>> card = vobject.vCard() +>>> card.behavior +<class 'vobject.vcard.VCard3_0'> + +Once you have an object, you can use the add method to create +children: + +>>> cal.add('vevent') +<VEVENT| []> +>>> cal.vevent.add('summary').value = "This is a note" +>>> cal.prettyPrint() + VCALENDAR + VEVENT + SUMMARY: This is a note + +Note that summary is a little different from vevent, it's a +ContentLine, not a Component. It can't have children, and it has a +special value attribute. + +ContentLines can also have parameters. They can be accessed with +regular attribute names with _param appended: + +>>> cal.vevent.summary.x_random_param = 'Random parameter' +>>> cal.prettyPrint() + VCALENDAR + VEVENT + SUMMARY: This is a note + params for SUMMARY: + X-RANDOM ['Random parameter'] + +There are a few things to note about this example + + * The underscore in x_random is converted to a dash (dashes are + legal in iCalendar, underscores legal in Python) + * X-RANDOM's value is a list. + +If you want to access the full list of parameters, not just the first, +use <paramname>_paramlist: + +>>> cal.vevent.summary.x_random_paramlist +['Random parameter'] +>>> cal.vevent.summary.x_random_paramlist.append('Other param') +>>> cal.vevent.summary +<SUMMARY{'X-RANDOM': ['Random parameter', 'Other param']}This is a note> + +Similar to parameters, If you want to access more than just the first +child of a Component, you can access the full list of children of a +given name by appending _list to the attribute name: + +>>> cal.add('vevent').add('summary').value = "Second VEVENT" +>>> for ev in cal.vevent_list: +... print ev.summary.value +This is a note +Second VEVENT + +The interaction between the del operator and the hiding of the +underlying list is a little tricky, del cal.vevent and del +cal.vevent_list both delete all vevent children: + +>>> first_ev = cal.vevent +>>> del cal.vevent +>>> cal +<VCALENDAR| []> +>>> cal.vevent = first_ev + +vobject understands Python's datetime module and tzinfo classes. + +>>> import datetime +>>> utc = vobject.icalendar.utc +>>> start = cal.vevent.add('dtstart') +>>> start.value = datetime.datetime(2006, 2, 16, tzinfo = utc) +>>> first_ev.prettyPrint() + VEVENT + DTSTART: 2006-02-16 00:00:00+00:00 + SUMMARY: This is a note + params for SUMMARY: + X-RANDOM ['Random parameter', 'Other param'] + +Components and ContentLines have serialize methods: + +>>> cal.vevent.add('uid').value = 'Sample UID' +>>> icalstream = cal.serialize() +>>> print icalstream +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//PYVOBJECT//NONSGML Version 1//EN +BEGIN:VEVENT +UID:Sample UID +DTSTART:20060216T000000Z +SUMMARY;X-RANDOM=Random parameter,Other param:This is a note +END:VEVENT +END:VCALENDAR + +Observe that serializing adds missing required lines like version and +prodid. A random UID would be generated, too, if one didn't exist. + +If dtstart's tzinfo had been something other than UTC, an appropriate +vtimezone would be created for it. + + +Parsing iCalendar objects +......................... + +To parse one top level component from an existing iCalendar stream or +string, use the readOne function: + +>>> parsedCal = vobject.readOne(icalstream) +>>> parsedCal.vevent.dtstart.value +datetime.datetime(2006, 2, 16, 0, 0, tzinfo=tzutc()) + +Similarly, readComponents is a generator yielding one top level +component at a time from a stream or string. + +>>> vobject.readComponents(icalstream).next().vevent.dtstart.value +datetime.datetime(2006, 2, 16, 0, 0, tzinfo=tzutc()) + +More examples can be found in source code doctests. + +vCards +...... + +Making vCards proceeds in much the same way. Note that the 'N' and 'FN' +attributes are required. + +>>> j = vobject.vCard() +>>> j.add('n') + <N{} > +>>> j.n.value = vobject.vcard.Name( family='Harris', given='Jeffrey' ) +>>> j.add('fn') + <FN{}> +>>> j.fn.value ='Jeffrey Harris' +>>> j.add('email') + <EMAIL{}> +>>> j.email.value = 'jeffrey@osafoundation.org' +>>> j.email.type_param = 'INTERNET' +>>> j.prettyPrint() + VCARD + EMAIL: jeffrey@osafoundation.org + params for EMAIL: + TYPE ['INTERNET'] + FN: Jeffrey Harris + N: Jeffrey Harris + +serializing will add any required computable attributes (like 'VERSION') + +>>> j.serialize() +u'BEGIN:VCARD\r\nVERSION:3.0\r\nEMAIL;TYPE=INTERNET:jeffrey@osafoundation.org\r\nFN:Jeffrey Harris\r\nN:Harris;Jeffrey;;;\r\nEND:VCARD\r\n' +>>> j.prettyPrint() + VCARD + VERSION: 3.0 + EMAIL: jeffrey@osafoundation.org + params for EMAIL: + TYPE ['INTERNET'] + FN: Jeffrey Harris + N: Jeffrey Harris + +Parsing vCards +.............. + +>>> s = """ +... BEGIN:VCARD +... VERSION:3.0 +... EMAIL;TYPE=INTERNET:jeffrey@osafoundation.org +... FN:Jeffrey Harris +... N:Harris;Jeffrey;;; +... END:VCARD +... """ +>>> v = vobject.readOne( s ) +>>> v.prettyPrint() + VCARD + VERSION: 3.0 + EMAIL: jeffrey@osafoundation.org + params for EMAIL: + TYPE [u'INTERNET'] + FN: Jeffrey Harris + N: Jeffrey Harris +>>> v.n.value.family +u'Harris'
\ No newline at end of file diff --git a/ez_setup.py b/ez_setup.py new file mode 100644 index 0000000..4789e37 --- /dev/null +++ b/ez_setup.py @@ -0,0 +1,221 @@ +#!python +"""Bootstrap setuptools installation + +If you want to use setuptools in your package's setup.py, just include this +file in the same directory with it, and add this to the top of your setup.py:: + + from ez_setup import use_setuptools + use_setuptools() + +If you want to require a specific version of setuptools, set a download +mirror, or use an alternate download directory, you can do so by supplying +the appropriate options to ``use_setuptools()``. + +This file can also be run as a script to install or upgrade setuptools. +""" +import sys +DEFAULT_VERSION = "0.6a9" +DEFAULT_URL = "http://cheeseshop.python.org/packages/%s/s/setuptools/" % sys.version[:3] + +md5_data = { + 'setuptools-0.5a13-py2.3.egg': '85edcf0ef39bab66e130d3f38f578c86', + 'setuptools-0.5a13-py2.4.egg': 'ede4be600e3890e06d4ee5e0148e092a', + 'setuptools-0.6a1-py2.3.egg': 'ee819a13b924d9696b0d6ca6d1c5833d', + 'setuptools-0.6a1-py2.4.egg': '8256b5f1cd9e348ea6877b5ddd56257d', + 'setuptools-0.6a2-py2.3.egg': 'b98da449da411267c37a738f0ab625ba', + 'setuptools-0.6a2-py2.4.egg': 'be5b88bc30aed63fdefd2683be135c3b', + 'setuptools-0.6a3-py2.3.egg': 'ee0e325de78f23aab79d33106dc2a8c8', + 'setuptools-0.6a3-py2.4.egg': 'd95453d525a456d6c23e7a5eea89a063', + 'setuptools-0.6a4-py2.3.egg': 'e958cbed4623bbf47dd1f268b99d7784', + 'setuptools-0.6a4-py2.4.egg': '7f33c3ac2ef1296f0ab4fac1de4767d8', + 'setuptools-0.6a5-py2.3.egg': '748408389c49bcd2d84f6ae0b01695b1', + 'setuptools-0.6a5-py2.4.egg': '999bacde623f4284bfb3ea77941d2627', + 'setuptools-0.6a6-py2.3.egg': '7858139f06ed0600b0d9383f36aca24c', + 'setuptools-0.6a6-py2.4.egg': 'c10d20d29acebce0dc76219dc578d058', + 'setuptools-0.6a7-py2.3.egg': 'cfc4125ddb95c07f9500adc5d6abef6f', + 'setuptools-0.6a7-py2.4.egg': 'c6d62dab4461f71aed943caea89e6f20', + 'setuptools-0.6a8-py2.3.egg': '2f18eaaa3f544f5543ead4a68f3b2e1a', + 'setuptools-0.6a8-py2.4.egg': '799018f2894f14c9f8bcb2b34e69b391', + 'setuptools-0.6a9-py2.3.egg': '8e438ad70438b07b0d8f82cae42b278f', + 'setuptools-0.6a9-py2.4.egg': '8f6e01fc12fb1cd006dc0d6c04327ec1', +} + +import sys, os + +def _validate_md5(egg_name, data): + if egg_name in md5_data: + from md5 import md5 + digest = md5(data).hexdigest() + if digest != md5_data[egg_name]: + print >>sys.stderr, ( + "md5 validation of %s failed! (Possible download problem?)" + % egg_name + ) + sys.exit(2) + return data + + +def use_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, + download_delay=15 +): + """Automatically find/download setuptools and make it available on sys.path + + `version` should be a valid setuptools version number that is available + as an egg for download under the `download_base` URL (which should end with + a '/'). `to_dir` is the directory where setuptools will be downloaded, if + it is not already available. If `download_delay` is specified, it should + be the number of seconds that will be paused before initiating a download, + should one be required. If an older version of setuptools is installed, + this routine will print a message to ``sys.stderr`` and raise SystemExit in + an attempt to abort the calling script. + """ + try: + import setuptools + if setuptools.__version__ == '0.0.1': + print >>sys.stderr, ( + "You have an obsolete version of setuptools installed. Please\n" + "remove it from your system entirely before rerunning this script." + ) + sys.exit(2) + except ImportError: + egg = download_setuptools(version, download_base, to_dir, download_delay) + sys.path.insert(0, egg) + import setuptools; setuptools.bootstrap_install_from = egg + + import pkg_resources + try: + pkg_resources.require("setuptools>="+version) + + except pkg_resources.VersionConflict: + # XXX could we install in a subprocess here? + print >>sys.stderr, ( + "The required version of setuptools (>=%s) is not available, and\n" + "can't be installed while this script is running. Please install\n" + " a more recent version first." + ) % version + sys.exit(2) + +def download_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, + delay = 15 +): + """Download setuptools from a specified location and return its filename + + `version` should be a valid setuptools version number that is available + as an egg for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download attempt. + """ + import urllib2, shutil + egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3]) + url = download_base + egg_name + saveto = os.path.join(to_dir, egg_name) + src = dst = None + if not os.path.exists(saveto): # Avoid repeated downloads + try: + from distutils import log + if delay: + log.warn(""" +--------------------------------------------------------------------------- +This script requires setuptools version %s to run (even to display +help). I will attempt to download it for you (from +%s), but +you may need to enable firewall access for this script first. +I will start the download in %d seconds. +---------------------------------------------------------------------------""", + version, download_base, delay + ); from time import sleep; sleep(delay) + log.warn("Downloading %s", url) + src = urllib2.urlopen(url) + # Read/write all in one block, so we don't create a corrupt file + # if the download is interrupted. + data = _validate_md5(egg_name, src.read()) + dst = open(saveto,"wb"); dst.write(data) + finally: + if src: src.close() + if dst: dst.close() + return os.path.realpath(saveto) + +def main(argv, version=DEFAULT_VERSION): + """Install or upgrade setuptools and EasyInstall""" + + try: + import setuptools + except ImportError: + import tempfile, shutil + tmpdir = tempfile.mkdtemp(prefix="easy_install-") + try: + egg = download_setuptools(version, to_dir=tmpdir, delay=0) + sys.path.insert(0,egg) + from setuptools.command.easy_install import main + main(list(argv)+[egg]) + finally: + shutil.rmtree(tmpdir) + else: + if setuptools.__version__ == '0.0.1': + # tell the user to uninstall obsolete version + use_setuptools(version) + + req = "setuptools>="+version + import pkg_resources + try: + pkg_resources.require(req) + except pkg_resources.VersionConflict: + try: + from setuptools.command.easy_install import main + except ImportError: + from easy_install import main + main(list(argv)+[download_setuptools(delay=0)]) + sys.exit(0) # try to force an exit + else: + if argv: + from setuptools.command.easy_install import main + main(argv) + else: + print "Setuptools version",version,"or greater has been installed." + print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)' + + + +def update_md5(filenames): + """Update our built-in md5 registry""" + + import re + from md5 import md5 + + for name in filenames: + base = os.path.basename(name) + f = open(name,'rb') + md5_data[base] = md5(f.read()).hexdigest() + f.close() + + data = [" %r: %r,\n" % it for it in md5_data.items()] + data.sort() + repl = "".join(data) + + import inspect + srcfile = inspect.getsourcefile(sys.modules[__name__]) + f = open(srcfile, 'rb'); src = f.read(); f.close() + + match = re.search("\nmd5_data = {\n([^}]+)}", src) + if not match: + print >>sys.stderr, "Internal error!" + sys.exit(2) + + src = src[:match.start(1)] + repl + src[match.end(1):] + f = open(srcfile,'w') + f.write(src) + f.close() + + +if __name__=='__main__': + if len(sys.argv)>2 and sys.argv[1]=='--md5update': + update_md5(sys.argv[2:]) + else: + main(sys.argv[1:]) + + + + + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..fd44be0 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python
+"""VObject: module for reading vCard and vCalendar files
+
+Parses iCalendar and vCard files into Python data structures, decoding the relevant encodings. Also serializes vobject data structures to valid iCalendar or vCard unicode strings.
+
+Requires dateutil (https://moin.conectiva.com.br/DateUtil) 0.9 or later.
+"""
+
+# not using setuptools until Chandler's ready for eggs
+# from ez_setup import use_setuptools
+# use_setuptools()
+
+# from setuptools import setup, find_packages
+
+from distutils.core import setup
+
+# Metadata
+PACKAGE_NAME = "vobject"
+PACKAGE_VERSION = "0.3.0"
+
+ALL_EXTS = ['*.py', '*.ics', '*.txt']
+
+packages = ['vobject']
+
+doclines = __doc__.splitlines()
+
+setup(name = "vobject",
+ version = PACKAGE_VERSION,
+ author = "Jeffrey Harris",
+ author_email = "jeffrey@osafoundation.org",
+ license = "BSD",
+ zip_safe = True,
+ url = "http://vobject.skyhouseconsulting.com",
+
+ package_dir = {'':'src'},
+ package_data = {'': ALL_EXTS},
+
+ install_requires = ['python-dateutil >= 1.1'],
+
+ platforms = ["any"],
+ packages = ["vobject"],
+ description = doclines[0],
+ long_description = "\n".join(doclines[2:]),
+ classifiers = """
+ Development Status :: 3 - Alpha
+ Environment :: Console
+ License :: OSI Approved :: BSD License
+ Intended Audience :: Developers
+ Natural Language :: English
+ Programming Language :: Python
+ Operating System :: OS Independent
+ Topic :: Text Processing""".strip().splitlines()
+ )
diff --git a/src/vobject/__init__.py b/src/vobject/__init__.py new file mode 100644 index 0000000..d5daf30 --- /dev/null +++ b/src/vobject/__init__.py @@ -0,0 +1,86 @@ +""" +VObject Overview +================ + vobject parses vCard or vCalendar files, returning a tree of Python objects. + It also provids an API to create vCard or vCalendar data structures which + can then be serialized. + + Parsing existing streams + ------------------------ + Streams containing one or many L{Component<base.Component>}s can be + parsed using L{readComponents<base.readComponents>}. As each Component + is parsed, vobject will attempt to give it a L{Behavior<behavior.Behavior>}. + If an appropriate Behavior is found, any base64, quoted-printable, or + backslash escaped data will automatically be decoded. Dates and datetimes + will be transformed to datetime.date or datetime.datetime instances. + Components containing recurrence information will have a special rruleset + attribute (a dateutil.rrule.rruleset instance). + + Validation + ---------- + L{Behavior<behavior.Behavior>} classes implement validation for + L{Component<base.Component>}s. To validate, an object must have all + required children. There (TODO: will be) a toggle to raise an exception or + just log unrecognized, non-experimental children and parameters. + + Creating objects programatically + -------------------------------- + A L{Component<base.Component>} can be created from scratch. No encoding + is necessary, serialization will encode data automatically. Factory + functions (TODO: will be) available to create standard objects. + + Serializing objects + ------------------- + Serialization: + - Looks for missing required children that can be automatically generated, + like a UID or a PRODID, and adds them + - Encodes all values that can be automatically encoded + - Checks to make sure the object is valid (unless this behavior is + explicitly disabled) + - Appends the serialized object to a buffer, or fills a new + buffer and returns it + + Examples + -------- + + >>> import datetime + >>> import dateutil.rrule as rrule + >>> x = iCalendar() + >>> x.add('vevent') + <VEVENT| []> + >>> x + <VCALENDAR| [<VEVENT| []>]> + >>> v = x.vevent + >>> utc = icalendar.utc + >>> v.add('dtstart').value = datetime.datetime(2004, 12, 15, 14, tzinfo = utc) + >>> v + <VEVENT| [<DTSTART{}2004-12-15 14:00:00+00:00>]> + >>> x + <VCALENDAR| [<VEVENT| [<DTSTART{}2004-12-15 14:00:00+00:00>]>]> + >>> newrule = rrule.rruleset() + >>> newrule.rrule(rrule.rrule(rrule.WEEKLY, count=2, dtstart=v.dtstart.value)) + >>> v.rruleset = newrule + >>> list(v.rruleset) + [datetime.datetime(2004, 12, 15, 14, 0, tzinfo=tzutc()), datetime.datetime(2004, 12, 22, 14, 0, tzinfo=tzutc())] + >>> v.add('uid').value = "randomuid@MYHOSTNAME" + >>> print x.serialize() + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//PYVOBJECT//NONSGML Version 1//EN + BEGIN:VEVENT + UID:randomuid@MYHOSTNAME + DTSTART:20041215T140000Z + RRULE:FREQ=WEEKLY;COUNT=2 + END:VEVENT + END:VCALENDAR + +""" + +import base, icalendar, vcard +from base import readComponents, readOne, newFromBehavior + +def iCalendar(): + return newFromBehavior('vcalendar', '2.0') + +def vCard(): + return newFromBehavior('vcard', '3.0')
\ No newline at end of file diff --git a/src/vobject/base.py b/src/vobject/base.py new file mode 100644 index 0000000..102e38f --- /dev/null +++ b/src/vobject/base.py @@ -0,0 +1,978 @@ +"""vobject module for reading vCard and vCalendar files.""" + +import re +import sys +import logging +import StringIO +import string +import exceptions +import codecs + +#------------------------------------ Logging ---------------------------------- +logger = logging.getLogger('vobject') +if not logger.handlers: + handler=logging.StreamHandler() + formatter = logging.Formatter('%(name)s %(levelname)s %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) +logger.setLevel(logging.ERROR) # Log errors +DEBUG = False # Don't waste time on debug calls +#----------------------------------- Constants --------------------------------- +CR = unichr(13) +LF = unichr(10) +CRLF = CR + LF +SPACE = unichr(32) +TAB = unichr(9) +SPACEORTAB = SPACE + TAB +#-------------------------------- Useful modules ------------------------------- +# use doctest, it kills two birds with one stone and docstrings often become +# more readable to boot (see parseLine's docstring). +# use logging, then when debugging we can just set our verbosity. +# use epydoc syntax for documenting code, please document every class and non- +# trivial method (see http://epydoc.sourceforge.net/epytext.html +# and http://epydoc.sourceforge.net/fields.html). Also, please +# follow http://www.python.org/peps/pep-0257.html for docstrings. +#------------------------------------------------------------------------------- + +#--------------------------------- Main classes -------------------------------- +class VBase(object): + """Base class for ContentLine and Component. + + @ivar behavior: + The Behavior class associated with this object, which controls + validation, transformations, and encoding. + @ivar parentBehavior: + The object's parent's behavior, or None if no behaviored parent exists. + @ivar isNative: + Boolean describing whether this component is a Native instance. + @ivar group: + An optional group prefix, should be used only to indicate sort order in + vCards, according to RFC2426 + """ + def __init__(self, group=None, *args, **kwds): + super(VBase, self).__init__(*args, **kwds) + self.group = group + self.behavior = None + self.parentBehavior = None + self.isNative = False + + def validate(self, *args, **kwds): + """Call the behavior's validate method, or return True.""" + if self.behavior: + return self.behavior.validate(self, *args, **kwds) + else: return True + + def getChildren(self): + """Return an iterable containing the contents of the object.""" + return [] + + def clearBehavior(self, cascade=True): + """Set behavior to None. Do for all descendants if cascading.""" + self.behavior=None + if cascade: self.transformChildrenFromNative() + + def autoBehavior(self, cascade=False): + """Set behavior if name is in self.parentBehavior.knownChildren. + + If cascade is True, unset behavior and parentBehavior for all + descendants, then recalculate behavior and parentBehavior. + + """ + parentBehavior = self.parentBehavior + if parentBehavior is not None: + knownChildTup = parentBehavior.knownChildren.get(self.name, None) + if knownChildTup is not None: + behavior = getBehavior(self.name, knownChildTup[2]) + if behavior is not None: + self.setBehavior(behavior, cascade) + if isinstance(self, ContentLine) and self.encoded: + self.behavior.decode(self) + + def setBehavior(self, behavior, cascade=True): + """Set behavior. If cascade is True, autoBehavior all descendants.""" + self.behavior=behavior + if cascade: + for obj in self.getChildren(): + obj.parentBehavior=behavior + obj.autoBehavior(True) + + def transformToNative(self): + """Transform this object into a custom VBase subclass. + + transformToNative should always return a representation of this object. + It may do so by modifying self in place then returning self, or by + creating a new object. + + """ + if self.isNative or not self.behavior or not self.behavior.hasNative: + return self + else: + try: + return self.behavior.transformToNative(self) + except Exception, e: + # wrap errors in transformation in a ParseError + lineNumber = getattr(self, 'lineNumber', None) + if isinstance(e, ParseError): + if lineNumber is not None: + e.lineNumber = lineNumber + raise + else: + msg = "In transformToNative, unhandled exception: %s: %s" + msg = msg % (sys.exc_info()[0], sys.exc_info()[1]) + new_error = ParseError(msg, lineNumber) + raise ParseError, new_error, sys.exc_info()[2] + + + def transformFromNative(self): + """Return self transformed into a ContentLine or Component if needed. + + May have side effects. If it does, transformFromNative and + transformToNative MUST have perfectly inverse side effects. Allowing + such side effects is convenient for objects whose transformations only + change a few attributes. + + Note that it isn't always possible for transformFromNative to be a + perfect inverse of transformToNative, in such cases transformFromNative + should return a new object, not self after modifications. + + """ + if self.isNative and self.behavior and self.behavior.hasNative: + try: + return self.behavior.transformFromNative(self) + except Exception, e: + # wrap errors in transformation in a NativeError + lineNumber = getattr(self, 'lineNumber', None) + if isinstance(e, NativeError): + if lineNumber is not None: + e.lineNumber = lineNumber + raise + else: + msg = "In transformFromNative, unhandled exception: %s: %s" + msg = msg % (sys.exc_info()[0], sys.exc_info()[1]) + new_error = NativeError(msg, lineNumber) + raise NativeError, new_error, sys.exc_info()[2] + else: return self + + def transformChildrenToNative(self): + """Recursively replace children with their native representation.""" + pass + + def transformChildrenFromNative(self, clearBehavior=True): + """Recursively transform native children to vanilla representations.""" + pass + + def serialize(self, buf=None, lineLength=75, validate=True, behavior=None): + """Serialize to buf if it exists, otherwise return a string. + + Use self.behavior.serialize if behavior exists. + + """ + if not behavior: + behavior = self.behavior + + if behavior: + if DEBUG: logger.debug("serializing %s with behavior" % self.name) + return behavior.serialize(self, buf, lineLength, validate) + else: + if DEBUG: logger.debug("serializing %s without behavior" % self.name) + return defaultSerialize(self, buf, lineLength) + +def ascii(s): + """Turn s into a printable string. Won't work for 8-bit ASCII.""" + return unicode(s).encode('ascii', 'replace') + +def toVName(name, stripNum = 0, upper = False): + """ + Turn a Python name into an iCalendar style name, optionally uppercase and + with characters stripped off. + """ + if upper: + name = name.upper() + if stripNum != 0: + name = name[:-stripNum] + return name.replace('_', '-') + +class ContentLine(VBase): + """Holds one content line for formats like vCard and vCalendar. + + For example:: + <SUMMARY{u'param1' : [u'val1'], u'param2' : [u'val2']}Bastille Day Party> + + @ivar name: + The uppercased name of the contentline. + @ivar params: + A dictionary of parameters and associated lists of values (the list may + be empty for empty parameters). + @ivar value: + The value of the contentline. + @ivar singletonparams: + A list of parameters for which it's unclear if the string represents the + parameter name or the parameter value. In vCard 2.1, "The value string + can be specified alone in those cases where the value is unambiguous". + This is crazy, but we have to deal with it. + @ivar encoded: + A boolean describing whether the data in the content line is encoded. + Generally, text read from a serialized vCard or vCalendar should be + considered encoded. Data added programmatically should not be encoded. + @ivar lineNumber: + An optional line number associated with the contentline. + """ + def __init__(self, name, params, value, group=None, + encoded=False, isNative=False, + lineNumber = None, *args, **kwds): + """Take output from parseLine, convert params list to dictionary.""" + # group is used as a positional argument to match parseLine's return + super(ContentLine, self).__init__(group, *args, **kwds) + self.name = name.upper() + self.value = value + self.encoded = encoded + self.params = {} + self.singletonparams = [] + self.isNative = isNative + self.lineNumber = lineNumber + def updateTable(x): + if len(x) == 1: + self.singletonparams += x + else: + paramlist = self.params.setdefault(x[0].upper(), []) + paramlist.extend(x[1:]) + map(updateTable, params) + qp = False + if 'ENCODING' in self.params: + if 'QUOTED-PRINTABLE' in self.params['ENCODING']: + qp = True + self.params['ENCODING'].remove('QUOTED-PRINTABLE') + if 0==len(self.params['ENCODING']): + del self.params['ENCODING'] + if 'QUOTED-PRINTABLE' in self.singletonparams: + qp = True + self.singletonparams.remove('QUOTED-PRINTABLE') + if qp: + self.value = str(self.value).decode('quoted-printable') + + def __eq__(self, other): + try: + return (self.name == other.name) and (self.params == other.params) and (self.value == other.value) + except: + return False + + def __getattr__(self, name): + """Make params accessible via self.foo_param or self.foo_paramlist. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + try: + if name.endswith('_param'): + return self.params[toVName(name, 6, True)][0] + elif name.endswith('_paramlist'): + return self.params[toVName(name, 10, True)] + else: + raise exceptions.AttributeError, name + except KeyError: + raise exceptions.AttributeError, name + + def __setattr__(self, name, value): + """Make params accessible via self.foo_param or self.foo_paramlist. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + if name.endswith('_param'): + if type(value) == list: + self.params[toVName(name, 6, True)] = value + else: + self.params[toVName(name, 6, True)] = [value] + elif name.endswith('_paramlist'): + if type(value) == list: + self.params[toVName(name, 10, True)] = value + else: + raise VObjectError("Parameter list set to a non-list") + else: + prop = getattr(self.__class__, name, None) + if isinstance(prop, property): + prop.fset(self, value) + else: + object.__setattr__(self, name, value) + + def __delattr__(self, name): + try: + if name.endswith('_param'): + del self.params[toVName(name, 6, True)] + elif name.endswith('_paramlist'): + del self.params[toVName(name, 10, True)] + else: + object.__delattr__(self, name) + except KeyError: + raise exceptions.AttributeError, name + + def valueRepr( self ): + """transform the representation of the value according to the behavior, + if any""" + v = self.value + if self.behavior: + v = self.behavior.valueRepr( self ) + return ascii( v ) + + def __str__(self): + return "<"+ascii(self.name)+ascii(self.params)+self.valueRepr()+">" + + def __repr__(self): + return self.__str__().replace('\n', '\\n') + + def prettyPrint(self, level = 0, tabwidth=3): + pre = ' ' * level * tabwidth + print pre, self.name + ":", self.valueRepr() + if self.params: + lineKeys= self.params.keys() + print pre, "params for ", self.name +':' + for aKey in lineKeys: + print pre + ' ' * tabwidth, aKey, ascii(self.params[aKey]) + +class Component(VBase): + """A complex property that can contain multiple ContentLines. + + For our purposes, a component must start with a BEGIN:xxxx line and end with + END:xxxx, or have a PROFILE:xxx line if a top-level component. + + @ivar contents: + A dictionary of lists of Component or ContentLine instances. The keys + are the lowercased names of child ContentLines or Components. + Note that BEGIN and END ContentLines are not included in contents. + @ivar name: + Uppercase string used to represent this Component, i.e VCARD if the + serialized object starts with BEGIN:VCARD. + @ivar useBegin: + A boolean flag determining whether BEGIN: and END: lines should + be serialized. + + """ + def __init__(self, name=None, *args, **kwds): + super(Component, self).__init__(*args, **kwds) + self.contents = {} + if name: + self.name=name.upper() + self.useBegin = True + else: + self.name = '' + self.useBegin = False + + self.autoBehavior() + + def setProfile(self, name): + """Assign a PROFILE to this unnamed component. + + Used by vCard, not by vCalendar. + + """ + if self.name or self.useBegin: + if self.name == name: return + raise VObjectError("This component already has a PROFILE or uses BEGIN.") + self.name = name.upper() + + def __getattr__(self, name): + """For convenience, make self.contents directly accessible. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + try: + if name.endswith('_list'): + return self.contents[toVName(name, 5)] + else: + return self.contents[toVName(name)][0] + except KeyError: + raise exceptions.AttributeError, name + + normal_attributes = ['contents','name','behavior','parentBehavior','group'] + def __setattr__(self, name, value): + """For convenience, make self.contents directly accessible. + + Underscores, legal in python variable names, are converted to dashes, + which are legal in IANA tokens. + + """ + if name not in self.normal_attributes and name.lower()==name: + if type(value) == list: + self.contents[toVName(name)] = value + elif name.endswith('_list'): + raise VObjectError("Component list set to a non-list") + else: + self.contents[toVName(name)] = [value] + else: + prop = getattr(self.__class__, name, None) + if isinstance(prop, property): + prop.fset(self, value) + else: + object.__setattr__(self, name, value) + + def __delattr__(self, name): + try: + if name not in self.normal_attributes and name.lower()==name: + if name.endswith('_list'): + del self.contents[toVName(name, 5)] + else: + del self.contents[toVName(name)] + else: + object.__delattr__(self, name) + except KeyError: + raise exceptions.AttributeError, name + + def getChildValue(self, childName, default = None, childNumber = 0): + """Return a child's value (the first, by default), or None.""" + child = self.contents.get(toVName(childName)) + if child is None: + return default + else: + return child[childNumber].value + + def add(self, objOrName, group = None): + """Add objOrName to contents, set behavior if it can be inferred. + + If objOrName is a string, create an empty component or line based on + behavior. If no behavior is found for the object, add a ContentLine. + + group is an optional prefix to the name of the object (see + RFC 2425). + """ + if isinstance(objOrName, VBase): + obj = objOrName + if self.behavior: + obj.parentBehavior = self.behavior + obj.autoBehavior(True) + else: + name = objOrName.upper() + try: + id=self.behavior.knownChildren[name][2] + behavior = getBehavior(name, id) + if behavior.isComponent: + obj = Component(name) + else: + obj = ContentLine(name, [], '', group) + obj.parentBehavior = self.behavior + obj.behavior = behavior + obj = obj.transformToNative() + except (KeyError, AttributeError): + obj = ContentLine(objOrName, [], '', group) + self.contents.setdefault(obj.name.lower(), []).append(obj) + return obj + + def remove(self, obj): + """Remove obj from contents.""" + named = self.contents.get(obj.name.lower()) + if named: + try: + named.remove(obj) + if len(named) == 0: + del self.contents[obj.name.lower()] + except ValueError: + pass; + + def getChildren(self): + """Return an iterable of all children.""" + for objList in self.contents.values(): + for obj in objList: yield obj + + def components(self): + """Return an iterable of all Component children.""" + return (i for i in self.getChildren() if isinstance(i, Component)) + + def lines(self): + """Return an iterable of all ContentLine children.""" + return (i for i in self.getChildren() if isinstance(i, ContentLine)) + + def sortChildKeys(self): + try: + first = [s for s in self.behavior.sortFirst if s in self.contents] + except: + first = [] + return first + sorted(k for k in self.contents.keys() if k not in first) + + def getSortedChildren(self): + return [obj for k in self.sortChildKeys() for obj in self.contents[k]] + + def setBehaviorFromVersionLine(self, versionLine): + """Set behavior if one matches name, versionLine.value.""" + v=getBehavior(self.name, versionLine.value) + if v: self.setBehavior(v) + + def transformChildrenToNative(self): + """Recursively replace children with their native representation.""" + #sort to get dependency order right, like vtimezone before vevent + for childArray in (self.contents[k] for k in self.sortChildKeys()): + for i in xrange(len(childArray)): + childArray[i]=childArray[i].transformToNative() + childArray[i].transformChildrenToNative() + + def transformChildrenFromNative(self, clearBehavior=True): + """Recursively transform native children to vanilla representations.""" + for childArray in self.contents.values(): + for i in xrange(len(childArray)): + childArray[i]=childArray[i].transformFromNative() + childArray[i].transformChildrenFromNative(clearBehavior) + if clearBehavior: + childArray[i].behavior = None + childArray[i].parentBehavior = None + + def __str__(self): + if self.name: + return "<" + self.name + "| " + str(self.getSortedChildren()) + ">" + else: + return '<' + '*unnamed*' + '| ' + str(self.getSortedChildren()) + '>' + + def __repr__(self): + return self.__str__() + + def prettyPrint(self, level = 0, tabwidth=3): + pre = ' ' * level * tabwidth + print pre, self.name + if isinstance(self, Component): + for line in self.getChildren(): + line.prettyPrint(level + 1, tabwidth) + print + +class VObjectError(Exception): + def __init__(self, message, lineNumber=None): + self.message = message + if lineNumber is not None: + self.lineNumber = lineNumber + def __str__(self): + if hasattr(self, 'lineNumber'): + return "At line %s: %s" % \ + (self.lineNumber, self.message) + else: + return repr(self.message) + +class ParseError(VObjectError): + pass + +class ValidateError(VObjectError): + pass + +class NativeError(VObjectError): + pass + +#-------------------------- Parsing functions ---------------------------------- + +# parseLine regular expressions + +patterns = {} + +patterns['name'] = '[a-zA-Z0-9\-]+' +patterns['safe_char'] = '[^";:,]' +patterns['qsafe_char'] = '[^"]' + +# the combined Python string replacement and regex syntax is a little confusing; +# remember that %(foobar)s is replaced with patterns['foobar'], so for instance +# param_value is any number of safe_chars or any number of qsaf_chars surrounded +# by double quotes. + +patterns['param_value'] = ' "%(qsafe_char)s * " | %(safe_char)s * ' % patterns + + +# get a tuple of two elements, one will be empty, the other will have the value +patterns['param_value_grouped'] = """ +" ( %(qsafe_char)s * )" | ( %(safe_char)s + ) +""" % patterns + +# get a parameter and its values, without any saved groups +patterns['param'] = r""" +; (?: %(name)s ) # parameter name +(?: + (?: = (?: %(param_value)s ) )? # 0 or more parameter values, multiple + (?: , (?: %(param_value)s ) )* # parameters are comma separated +)* +""" % patterns + +# get a parameter, saving groups for name and value (value still needs parsing) +patterns['params_grouped'] = r""" +; ( %(name)s ) + +(?: = + ( + (?: (?: %(param_value)s ) )? # 0 or more parameter values, multiple + (?: , (?: %(param_value)s ) )* # parameters are comma separated + ) +)? +""" % patterns + +# get a full content line, break it up into group, name, parameters, and value +patterns['line'] = r""" +^ ((?P<group> %(name)s)\.)?(?P<name> %(name)s) # name group + (?P<params> (?: %(param)s )* ) # params group (may be empty) +: (?P<value> .* )$ # value group +""" % patterns + +' "%(qsafe_char)s*" | %(safe_char)s* ' + +param_values_re = re.compile(patterns['param_value_grouped'], re.VERBOSE) +params_re = re.compile(patterns['params_grouped'], re.VERBOSE) +line_re = re.compile(patterns['line'], re.VERBOSE) + +def parseParams(string): + """ + >>> parseParams(';ALTREP="http://www.wiz.org"') + [['ALTREP', 'http://www.wiz.org']] + >>> parseParams('') + [] + >>> parseParams(';ALTREP="http://www.wiz.org;;",Blah,Foo;NEXT=Nope;BAR') + [['ALTREP', 'http://www.wiz.org;;', 'Blah', 'Foo'], ['NEXT', 'Nope'], ['BAR']] + """ + all = params_re.findall(string) + allParameters = [] + for tup in all: + paramList = [tup[0]] # tup looks like (name, valuesString) + for pair in param_values_re.findall(tup[1]): + # pair looks like ('', value) or (value, '') + if pair[0] != '': + paramList.append(pair[0]) + else: + paramList.append(pair[1]) + allParameters.append(paramList) + return allParameters + + +def parseLine(line, lineNumber = None): + """ + >>> parseLine("BLAH:") + ('BLAH', [], '', None) + >>> parseLine("RDATE:VALUE=DATE:19970304,19970504,19970704,19970904") + ('RDATE', [], 'VALUE=DATE:19970304,19970504,19970704,19970904', None) + >>> parseLine('DESCRIPTION;ALTREP="http://www.wiz.org":The Fall 98 Wild Wizards Conference - - Las Vegas, NV, USA') + ('DESCRIPTION', [['ALTREP', 'http://www.wiz.org']], 'The Fall 98 Wild Wizards Conference - - Las Vegas, NV, USA', None) + >>> parseLine("EMAIL;PREF;INTERNET:john@nowhere.com") + ('EMAIL', [['PREF'], ['INTERNET']], 'john@nowhere.com', None) + >>> parseLine('EMAIL;TYPE="blah",hah;INTERNET="DIGI",DERIDOO:john@nowhere.com') + ('EMAIL', [['TYPE', 'blah', 'hah'], ['INTERNET', 'DIGI', 'DERIDOO']], 'john@nowhere.com', None) + >>> parseLine('item1.ADR;type=HOME;type=pref:;;Reeperbahn 116;Hamburg;;20359;') + ('ADR', [['type', 'HOME'], ['type', 'pref']], ';;Reeperbahn 116;Hamburg;;20359;', 'item1') + >>> parseLine(":") + Traceback (most recent call last): + ... + ParseError: 'Failed to parse line: :' + """ + + match = line_re.match(line) + if match is None: + raise ParseError("Failed to parse line: %s" % line, lineNumber) + return (match.group('name'), + parseParams(match.group('params')), + match.group('value'), match.group('group')) + +# logical line regular expressions + +patterns['lineend'] = r'(?:\r\n|\r|\n|$)' +patterns['wrap'] = r'%(lineend)s [\t ]' % patterns +patterns['logicallines'] = r""" +( + (?: [^\r\n] | %(wrap)s )* + %(lineend)s +) +""" % patterns + +patterns['wraporend'] = r'(%(wrap)s | %(lineend)s )' % patterns + +wrap_re = re.compile(patterns['wraporend'], re.VERBOSE) +logical_lines_re = re.compile(patterns['logicallines'], re.VERBOSE) + +testLines=""" +Line 0 text + , Line 0 continued. +Line 1;encoding=quoted-printable:this is an evil= + evil= + format. +Line 2 is a new line, it does not start with whitespace. +""" + +def getLogicalLines(fp, allowQP=True): + """Iterate through a stream, yielding one logical line at a time. + + Because many applications still use vCard 2.1, we have to deal with the + quoted-printable encoding for long lines, as well as the vCard 3.0 and + vCalendar line folding technique, a whitespace character at the start + of the line. + + Quoted-printable data will be decoded in the Behavior decoding phase. + + >>> import StringIO + >>> f=StringIO.StringIO(testLines) + >>> for n, l in enumerate(getLogicalLines(f)): + ... print "Line %s: %s" % (n, l[0]) + ... + Line 0: Line 0 text, Line 0 continued. + Line 1: Line 1;encoding=quoted-printable:this is an evil= + evil= + format. + Line 2: Line 2 is a new line, it does not start with whitespace. + + """ + if not allowQP: + val = fp.read(-1) + lineNumber = 1 + for match in logical_lines_re.finditer(val): + line, n = wrap_re.subn('', match.group()) + if line != '': + if type(line[0]) != unicode: + line = line.decode('utf-8') + yield line, lineNumber + lineNumber += n + + else: + quotedPrintable=False + newbuffer = StringIO.StringIO + logicalLine = newbuffer() + lineNumber = 0 + lineStartNumber = 0 + while True: + line = fp.readline() + if line == '': + break + else: + if type(line[0]) != unicode: + line = line.decode('utf-8') + line = line.rstrip(CRLF) + lineNumber += 1 + if line.rstrip() == '': + if logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + lineStartNumber = lineNumber + logicalLine = newbuffer() + quotedPrintable=False + continue + + if quotedPrintable and allowQP: + logicalLine.write('\n') + logicalLine.write(line) + quotedPrintable=False + elif line[0] in SPACEORTAB: + logicalLine.write(line[1:]) + elif logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + lineStartNumber = lineNumber + logicalLine = newbuffer() + logicalLine.write(line) + else: + logicalLine = newbuffer() + logicalLine.write(line) + + # hack to deal with the fact that vCard 2.1 allows parameters to be + # encoded without a parameter name. False positives are unlikely, but + # possible. + val = logicalLine.getvalue() + if val[-1]=='=' and val.lower().find('quoted-printable') >= 0: + quotedPrintable=True + + if logicalLine.pos > 0: + yield logicalLine.getvalue(), lineStartNumber + + +def textLineToContentLine(text, n=None): + return ContentLine(*parseLine(text, n), **{'encoded':True, 'lineNumber' : n}) + +def dquoteEscape(param): + """Return param, or "param" if ',' or ';' or ':' is in param.""" + if param.find('"') >= 0: + raise VObjectError("Double quotes aren't allowed in parameter values.") + for char in ',;:': + if param.find(char) >= 0: + return '"'+ param + '"' + return param + +def foldOneLine(outbuf, input, lineLength = 75): + if isinstance(input, basestring): input = StringIO.StringIO(input) + input.seek(0) + outbuf.write(input.read(lineLength) + CRLF) + brokenline = input.read(lineLength - 1) + while brokenline: + outbuf.write(' ' + brokenline + CRLF) + brokenline = input.read(lineLength - 1) + +def defaultSerialize(obj, buf, lineLength): + """Encode and fold obj and its children, write to buf or return a string.""" + + outbuf = buf or StringIO.StringIO() + + if isinstance(obj, Component): + if obj.group is None: + groupString = '' + else: + groupString = obj.group + '.' + if obj.useBegin: + foldOneLine(outbuf, groupString + u"BEGIN:" + obj.name, lineLength) + for child in obj.getSortedChildren(): + #validate is recursive, we only need to validate once + child.serialize(outbuf, lineLength, validate=False) + if obj.useBegin: + foldOneLine(outbuf, groupString + u"END:" + obj.name, lineLength) + if DEBUG: logger.debug("Finished %s" % obj.name.upper()) + + elif isinstance(obj, ContentLine): + startedEncoded = obj.encoded + #TODO: X- lines should be considered TEXT, and should be encoded as such + if obj.behavior and not startedEncoded: obj.behavior.encode(obj) + s=StringIO.StringIO() #unfolded buffer + if obj.group is not None: + s.write(obj.group + '.') + if DEBUG: logger.debug("Serializing line" + str(obj)) + s.write(obj.name.upper()) + for key, paramvals in obj.params.iteritems(): + s.write(';' + key + '=' + ','.join(map(dquoteEscape, paramvals))) + s.write(':' + obj.value) + if obj.behavior and not startedEncoded: obj.behavior.decode(obj) + foldOneLine(outbuf, s, lineLength) + if DEBUG: logger.debug("Finished %s line" % obj.name.upper()) + + return buf or outbuf.getvalue() + + +testVCalendar=""" +BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY;blah=hi!:Bastille Day Party +END:VEVENT +END:VCALENDAR""" + +class Stack: + def __init__(self): + self.stack = [] + def __len__(self): + return len(self.stack) + def top(self): + if len(self) == 0: return None + else: return self.stack[-1] + def topName(self): + if len(self) == 0: return None + else: return self.stack[-1].name + def modifyTop(self, item): + top = self.top() + if top: + top.add(item) + else: + new = Component() + self.push(new) + new.add(item) #add sets behavior for item and children + def push(self, obj): self.stack.append(obj) + def pop(self): return self.stack.pop() + +def readComponents(streamOrString, validate=False, transform=True): + """Generate one Component at a time from a stream. + + >>> import StringIO + >>> f = StringIO.StringIO(testVCalendar) + >>> cal=readComponents(f).next() + >>> cal + <VCALENDAR| [<VEVENT| [<SUMMARY{u'BLAH': [u'hi!']}Bastille Day Party>]>]> + >>> cal.vevent.summary + <SUMMARY{u'BLAH': [u'hi!']}Bastille Day Party> + + """ + if isinstance(streamOrString, basestring): + stream = StringIO.StringIO(streamOrString) + else: + stream = streamOrString + stack = Stack() + versionLine = None + n = 0 + for line, n in getLogicalLines(stream, False): # not allowing vCard 2.1 + vline = textLineToContentLine(line, n) + if vline.name == "VERSION": + versionLine = vline + stack.modifyTop(vline) + elif vline.name == "BEGIN": + stack.push(Component(vline.value, group=vline.group)) + elif vline.name == "PROFILE": + if not stack.top(): stack.push(Component()) + stack.top().setProfile(vline.value) + elif vline.name == "END": + if len(stack) == 0: + err = "Attempted to end the %s component, \ + but it was never opened" % vline.value + raise ParseError(err, n) + if vline.value.upper() == stack.topName(): #START matches END + if len(stack) == 1: + component=stack.pop() + if versionLine is not None: + component.setBehaviorFromVersionLine(versionLine) + if validate: component.validate(raiseException=True) + if transform: component.transformChildrenToNative() + yield component #EXIT POINT + else: stack.modifyTop(stack.pop()) + else: + err = "%s component wasn't closed" + raise ParseError(err % stack.topName(), n) + else: stack.modifyTop(vline) #not a START or END line + if stack.top(): + if stack.topName() is None: + logger.warning("Top level component was never named") + elif stack.top().useBegin: + raise ParseError("Component %s was never closed" % (stack.topName()), n) + yield stack.pop() + + +def readOne(stream, validate=False, transform=True): + """Return the first component from stream.""" + return readComponents(stream, validate, transform).next() + +#--------------------------- version registry ---------------------------------- +__behaviorRegistry={} + +def registerBehavior(behavior, name=None, default=False, id=None): + """Register the given behavior. + + If default is True (or if this is the first version registered with this + name), the version will be the default if no id is given. + + """ + if not name: name=behavior.name.upper() + if id is None: id=behavior.versionString + if name in __behaviorRegistry: + if default: + __behaviorRegistry[name].insert(0, (id, behavior)) + else: + __behaviorRegistry[name].append((id, behavior)) + else: + __behaviorRegistry[name]=[(id, behavior)] + +def getBehavior(name, id=None): + """Return a matching behavior if it exists, or None. + + If id is None, return the default for name. + + """ + name=name.upper() + if name in __behaviorRegistry: + if id: + for n, behavior in __behaviorRegistry[name]: + if n==id: + return behavior + else: + return __behaviorRegistry[name][0][1] + return None + +def newFromBehavior(name, id=None): + """Given a name, return a behaviored ContentLine or Component.""" + name = name.upper() + behavior = getBehavior(name, id) + if behavior is None: + raise VObjectError("No behavior found named %s" % name) + if behavior.isComponent: + obj = Component(name) + else: + obj = ContentLine(name, [], '') + obj.behavior = behavior + obj.isNative = True + return obj + + +#--------------------------- Helper function ----------------------------------- +def backslashEscape(s): + s=s.replace("\\","\\\\").replace(";","\;").replace(",","\,") + return s.replace("\r\n", "\\n").replace("\n","\\n").replace("\r","\\n") + +#------------------- Testing and running functions ----------------------------- +if __name__ == '__main__': + import tests + tests._test() diff --git a/src/vobject/behavior.py b/src/vobject/behavior.py new file mode 100644 index 0000000..e2a2d56 --- /dev/null +++ b/src/vobject/behavior.py @@ -0,0 +1,161 @@ +"""Behavior (validation, encoding, and transformations) for vobjects.""" + +import base + +#------------------------ Abstract class for behavior -------------------------- +class Behavior(object): + """Abstract class to describe vobject options, requirements and encodings. + + Behaviors are used for root components like VCALENDAR, for subcomponents + like VEVENT, and for individual lines in components. + + Behavior subclasses are not meant to be instantiated, all methods should + be classmethods. + + @cvar name: + The uppercase name of the object described by the class, or a generic + name if the class defines behavior for many objects. + @cvar description: + A brief excerpt from the RFC explaining the function of the component or + line. + @cvar versionString: + The string associated with the component, for instance, 2.0 if there's a + line like VERSION:2.0, an empty string otherwise. + @cvar knownChildren: + A dictionary with uppercased component/property names as keys and a + tuple (min, max, id) as value, where id is the id used by + L{registerBehavior}, min and max are the limits on how many of this child + must occur. None is used to denote no max or no id. + @cvar quotedPrintable: + A boolean describing whether the object should be encoded and decoded + using quoted printable line folding and character escaping. + @cvar hasNative: + A boolean describing whether the object can be transformed into a more + Pythonic object. + @cvar isComponent: + A boolean, True if the object should be a Component. + @cvar sortFirst: + The lower-case list of children which should come first when sorting. + @cvar allowGroup: + Whether or not vCard style group prefixes are allowed. + """ + name='' + description='' + versionString='' + knownChildren = {} + quotedPrintable = False + hasNative= False + isComponent = False + allowGroup = False + forceUTC = False + sortFirst = [] + + def __init__(self): + err="Behavior subclasses are not meant to be instantiated" + raise base.VObjectError(err) + + @classmethod + def validate(cls, obj, raiseException=False, complainUnrecognized=False): + """Check if the object satisfies this behavior's requirements. + + @param obj: + The L{ContentLine<base.ContentLine>} or + L{Component<base.Component>} to be validated. + @param raiseException: + If True, raise a L{base.ValidateError} on validation failure. + Otherwise return a boolean. + @param complainUnrecognized: + If True, fail to validate if an uncrecognized parameter or child is + found. Otherwise log the lack of recognition. + + """ + if not cls.allowGroup and obj.group is not None: + err = str(obj) + " has a group, but this object doesn't support groups" + raise base.VObjectError(err) + if isinstance(obj, base.ContentLine): + return cls.lineValidate(obj, raiseException, complainUnrecognized) + elif isinstance(obj, base.Component): + count = {} + for child in obj.getChildren(): + if not child.validate(raiseException, complainUnrecognized): + return False + name=child.name.upper() + count[name] = count.get(name, 0) + 1 + for key, val in cls.knownChildren.iteritems(): + if count.get(key,0) < val[0]: + if raiseException: + m = "%s components must contain at least %i %s" + raise base.ValidateError(m % (cls.name, val[0], key)) + return False + if val[1] and count.get(key,0) > val[1]: + if raiseException: + m = "%s components cannot contain more than %i %s" + raise base.ValidateError(m % (cls.name, val[1], key)) + return False + return True + else: + err = str(obj) + " is not a Component or Contentline" + raise base.VObjectError(err) + + @classmethod + def lineValidate(cls, line, raiseException, complainUnrecognized): + """Examine a line's parameters and values, return True if valid.""" + return True + + @classmethod + def decode(cls, line): + if line.encoded: line.encoded=0 + + @classmethod + def encode(cls, line): + if not line.encoded: line.encoded=1 + + @classmethod + def transformToNative(cls, obj): + """Turn a ContentLine or Component into a Python-native representation. + + If appropriate, turn dates or datetime strings into Python objects. + Components containing VTIMEZONEs turn into VtimezoneComponents. + + """ + return obj + + @classmethod + def transformFromNative(cls, obj): + """Inverse of transformToNative.""" + raise base.NativeError("No transformFromNative defined") + + @classmethod + def generateImplicitParameters(cls, obj): + """Generate any required information that don't yet exist.""" + pass + + @classmethod + def serialize(cls, obj, buf, lineLength, validate=True): + """Set implicit parameters, do encoding, return unicode string. + + If validate is True, raise VObjectError if the line doesn't validate + after implicit parameters are generated. + + Default is to call base.defaultSerialize. + + """ + + cls.generateImplicitParameters(obj) + if validate: cls.validate(obj, raiseException=True) + + if obj.isNative: + transformed = obj.transformFromNative() + undoTransform = True + else: + transformed = obj + undoTransform = False + + out = base.defaultSerialize(transformed, buf, lineLength) + if undoTransform: obj.transformToNative() + return out + + @classmethod + def valueRepr( cls, line ): + """return the representation of the given content line value""" + return line.value
\ No newline at end of file diff --git a/src/vobject/hcalendar.py b/src/vobject/hcalendar.py new file mode 100644 index 0000000..93614ab --- /dev/null +++ b/src/vobject/hcalendar.py @@ -0,0 +1,125 @@ +""" +hCalendar: A microformat for serializing iCalendar data + (http://microformats.org/wiki/hcalendar) + +Here is a sample event in an iCalendar: + +BEGIN:VCALENDAR +PRODID:-//XYZproduct//EN +VERSION:2.0 +BEGIN:VEVENT +URL:http://www.web2con.com/ +DTSTART:20051005 +DTEND:20051008 +SUMMARY:Web 2.0 Conference +LOCATION:Argent Hotel\, San Francisco\, CA +END:VEVENT +END:VCALENDAR + +and an equivalent event in hCalendar format with various elements optimized appropriately. + +<span class="vevent"> + <a class="url" href="http://www.web2con.com/"> + <span class="summary">Web 2.0 Conference</span>: + <abbr class="dtstart" title="2005-10-05">October 5</abbr>- + <abbr class="dtend" title="2005-10-08">7</abbr>, + at the <span class="location">Argent Hotel, San Francisco, CA</span> + </a> +</span> +""" + +from base import foldOneLine, CRLF, registerBehavior +from icalendar import VCalendar2_0 +from datetime import date, datetime, timedelta +import StringIO + +class HCalendar(VCalendar2_0): + name = 'HCALENDAR' + + @classmethod + def serialize(cls, obj, buf=None, lineLength=None, validate=True): + """ + Serialize iCalendar to HTML using the hCalendar microformat (http://microformats.org/wiki/hcalendar) + """ + + outbuf = buf or StringIO.StringIO() + level = 0 # holds current indentation level + tabwidth = 3 + + def indent(): + return ' ' * level * tabwidth + + def out(s): + outbuf.write(indent()) + outbuf.write(s) + + # not serializing optional vcalendar wrapper + + vevents = obj.vevent_list + + for event in vevents: + out('<span class="vevent">' + CRLF) + level += 1 + + # URL + url = event.getChildValue("url") + if url: + out('<a class="url" href="' + url + '">' + CRLF) + level += 1 + # SUMMARY + summary = event.getChildValue("summary") + if summary: + out('<span class="summary">' + summary + '</span>:' + CRLF) + + # DTSTART + dtstart = event.getChildValue("dtstart") + if dtstart: + if type(dtstart) == date: + timeformat = "%A, %B %e" + machine = "%Y%m%d" + elif type(dtstart) == datetime: + timeformat = "%A, %B %e, %H:%M" + machine = "%Y%m%dT%H%M%S%z" + + #TODO: Handle non-datetime formats? + #TODO: Spec says we should handle when dtstart isn't included + + out('<abbr class="dtstart", title="%s">%s</abbr>\r\n' % + (dtstart.strftime(machine), dtstart.strftime(timeformat))) + + # DTEND + dtend = event.getChildValue("dtend") + if not dtend: + duration = event.getChildValue("duration") + if duration: + dtend = duration + dtstart + # TODO: If lacking dtend & duration? + + if dtend: + human = dtend + # TODO: Human readable part could be smarter, excluding repeated data + if type(dtend) == date: + human = dtend - timedelta(days=1) + + out('- <abbr class="dtend", title="%s">%s</abbr>\r\n' % + (dtend.strftime(machine), human.strftime(timeformat))) + + # LOCATION + location = event.getChildValue("location") + if location: + out('at <span class="location">' + location + '</span>' + CRLF) + + description = event.getChildValue("description") + if description: + out('<div class="description">' + description + '</div>' + CRLF) + + if url: + level -= 1 + out('</a>' + CRLF) + + level -= 1 + out('</span>' + CRLF) # close vevent + + return buf or outbuf.getvalue() + +registerBehavior(HCalendar)
\ No newline at end of file diff --git a/src/vobject/icalendar.py b/src/vobject/icalendar.py new file mode 100644 index 0000000..9350131 --- /dev/null +++ b/src/vobject/icalendar.py @@ -0,0 +1,1696 @@ +"""Definitions and behavior for iCalendar, also known as vCalendar 2.0""" + +import string +import behavior +import dateutil.rrule +import dateutil.tz +import StringIO +import datetime +import socket, random #for generating a UID +import itertools + +from base import VObjectError, NativeError, ValidateError, ParseError, \ + VBase, Component, ContentLine, logger, defaultSerialize, \ + registerBehavior, backslashEscape, foldOneLine, \ + newFromBehavior, CRLF, LF + +#------------------------------- Constants ------------------------------------- +DATENAMES = ("rdate", "exdate") +RULENAMES = ("exrule", "rrule") +DATESANDRULES = ("exrule", "rrule", "rdate", "exdate") +PRODID = u"-//PYVOBJECT//NONSGML Version 1//EN" + +WEEKDAYS = "MO", "TU", "WE", "TH", "FR", "SA", "SU" +FREQUENCIES = ('YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', + 'SECONDLY') + +zeroDelta = datetime.timedelta(0) +twoHours = datetime.timedelta(hours=2) + +#---------------------------- TZID registry ------------------------------------ +__tzidMap={} + +def registerTzid(tzid, tzinfo): + """Register a tzid -> tzinfo mapping.""" + __tzidMap[tzid]=tzinfo + +def getTzid(tzid): + """Return the tzid if it exists, or None.""" + return __tzidMap.get(tzid, None) + +utc = dateutil.tz.tzutc() +registerTzid("UTC", utc) + +#-------------------- Helper subclasses ---------------------------------------- + +class TimezoneComponent(Component): + """A VTIMEZONE object. + + VTIMEZONEs are parsed by dateutil.tz.tzical, the resulting datetime.tzinfo + subclass is stored in self.tzinfo, self.tzid stores the TZID associated + with this timezone. + + @ivar name: + The uppercased name of the object, in this case always 'VTIMEZONE'. + @ivar tzinfo: + A datetime.tzinfo subclass representing this timezone. + @ivar tzid: + The string used to refer to this timezone. + + """ + def __init__(self, tzinfo=None, *args, **kwds): + """Accept an existing Component or a tzinfo class.""" + super(TimezoneComponent, self).__init__(*args, **kwds) + self.isNative=True + # hack to make sure a behavior is assigned + if self.behavior is None: + self.behavior = VTimezone + if tzinfo is not None: + self.tzinfo = tzinfo + if not hasattr(self, 'name') or self.name == '': + self.name = 'VTIMEZONE' + self.useBegin = True + + @classmethod + def registerTzinfo(obj, tzinfo): + """Register tzinfo if it's not already registered, return its tzid.""" + tzid = obj.pickTzid(tzinfo) + if tzid and not getTzid(tzid): + registerTzid(tzid, tzinfo) + return tzid + + def gettzinfo(self): + # workaround for dateutil failing to parse some experimental properties + good_lines = ('rdate', 'rrule', 'dtstart', 'tzname', 'tzoffsetfrom', + 'tzoffsetto', 'tzid') + buffer = StringIO.StringIO() + def customSerialize(obj): + if isinstance(obj, Component): + foldOneLine(buffer, u"BEGIN:" + obj.name) + for child in obj.lines(): + if child.name.lower() in good_lines: + child.serialize(buffer, 75, validate=False) + for comp in obj.components(): + customSerialize(comp) + foldOneLine(buffer, u"END:" + obj.name) + customSerialize(self) + return dateutil.tz.tzical(StringIO.StringIO(str(buffer.getvalue()))).get() + + def settzinfo(self, tzinfo, start=2000, end=2030): + """Create appropriate objects in self to represent tzinfo. + + Collapse DST transitions to rrules as much as possible. + + Assumptions: + - DST <-> Standard transitions occur on the hour + - never within a month of one another + - twice or fewer times a year + - never in the month of December + - DST always moves offset exactly one hour later + - tzinfo classes dst method always treats times that could be in either + offset as being in the later regime + + """ + def fromLastWeek(dt): + """How many weeks from the end of the month dt is, starting from 1.""" + weekDelta = datetime.timedelta(weeks=1) + n = 1 + current = dt + weekDelta + while current.month == dt.month: + n += 1 + current += weekDelta + return n + + # lists of dictionaries defining rules which are no longer in effect + completed = {'daylight' : [], 'standard' : []} + + # dictionary defining rules which are currently in effect + working = {'daylight' : None, 'standard' : None} + + # rule may be based on the nth week of the month or the nth from the last + for year in xrange(start, end + 1): + newyear = datetime.datetime(year, 1, 1) + for transitionTo in 'daylight', 'standard': + transition = getTransition(transitionTo, year, tzinfo) + oldrule = working[transitionTo] + + if transition == newyear: + # transitionTo is in effect for the whole year + rule = {'end' : None, + 'start' : newyear, + 'month' : 1, + 'weekday' : None, + 'hour' : None, + 'plus' : None, + 'minus' : None, + 'name' : tzinfo.tzname(newyear), + 'offset' : tzinfo.utcoffset(newyear), + 'offsetfrom' : tzinfo.utcoffset(newyear)} + if oldrule is None: + # transitionTo was not yet in effect + working[transitionTo] = rule + else: + # transitionTo was already in effect + if (oldrule['offset'] != + tzinfo.utcoffset(newyear)): + # old rule was different, it shouldn't continue + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = rule + elif transition is None: + # transitionTo is not in effect + if oldrule is not None: + # transitionTo used to be in effect + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = None + else: + # an offset transition was found + old_offset = tzinfo.utcoffset(transition - twoHours) + rule = {'end' : None, # None, or an integer year + 'start' : transition, # the datetime of transition + 'month' : transition.month, + 'weekday' : transition.weekday(), + 'hour' : transition.hour, + 'name' : tzinfo.tzname(transition), + 'plus' : (transition.day - 1)/ 7 + 1,#nth week of the month + 'minus' : fromLastWeek(transition), #nth from last week + 'offset' : tzinfo.utcoffset(transition), + 'offsetfrom' : old_offset} + + if oldrule is None: + working[transitionTo] = rule + else: + plusMatch = rule['plus'] == oldrule['plus'] + minusMatch = rule['minus'] == oldrule['minus'] + truth = plusMatch or minusMatch + for key in 'month', 'weekday', 'hour', 'offset': + truth = truth and rule[key] == oldrule[key] + if truth: + # the old rule is still true, limit to plus or minus + if not plusMatch: + oldrule['plus'] = None + if not minusMatch: + oldrule['minus'] = None + else: + # the new rule did not match the old + oldrule['end'] = year - 1 + completed[transitionTo].append(oldrule) + working[transitionTo] = rule + + for transitionTo in 'daylight', 'standard': + if working[transitionTo] is not None: + completed[transitionTo].append(working[transitionTo]) + + self.tzid = [] + self.daylight = [] + self.standard = [] + + self.add('tzid').value = self.pickTzid(tzinfo) + + old = None + for transitionTo in 'daylight', 'standard': + for rule in completed[transitionTo]: + comp = self.add(transitionTo) + dtstart = comp.add('dtstart') + dtstart.value = rule['start'] + if rule['name'] is not None: + comp.add('tzname').value = rule['name'] + line = comp.add('tzoffsetto') + line.value = deltaToOffset(rule['offset']) + line = comp.add('tzoffsetfrom') + line.value = deltaToOffset(rule['offsetfrom']) + + if rule['plus'] is not None: + num = rule['plus'] + elif rule['minus'] is not None: + num = -1 * rule['minus'] + else: + num = None + if num is not None: + dayString = ";BYDAY=" + str(num) + WEEKDAYS[rule['weekday']] + else: + dayString = "" + if rule['end'] is not None: + if rule['hour'] is None: + # all year offset, with no rule + endDate = datetime.datetime(rule['end'], 1, 1) + else: + weekday = dateutil.rrule.weekday(rule['weekday'], num) + du_rule = dateutil.rrule.rrule(dateutil.rrule.YEARLY, + bymonth = rule['month'],byweekday = weekday, + dtstart = datetime.datetime( + rule['end'], 1, 1, rule['hour']) + ) + endDate = du_rule[0] + endDate = endDate.replace(tzinfo = utc) - rule['offsetfrom'] + endString = ";UNTIL="+ dateTimeToString(endDate) + else: + endString = '' + rulestring = "FREQ=YEARLY%s;BYMONTH=%s%s" % \ + (dayString, str(rule['month']), endString) + + comp.add('rrule').value = rulestring + + tzinfo = property(gettzinfo, settzinfo) + # prevent Component's __setattr__ from overriding the tzinfo property + normal_attributes = Component.normal_attributes + ['tzinfo'] + + @staticmethod + def pickTzid(tzinfo): + """ + Given a tzinfo class, use known APIs to determine TZID, or use tzname. + """ + if tzinfo is None or tzinfo_eq(tzinfo, utc): + #If tzinfo is UTC, we don't need a TZID + return None + # try PyICU's tzid key + if hasattr(tzinfo, 'tzid'): + return tzinfo.tzid + + # try tzical's tzid key + elif hasattr(tzinfo, '_tzid'): + return tzinfo._tzid + else: + # return tzname for standard (non-DST) time + notDST = datetime.timedelta(0) + for month in xrange(1,13): + dt = datetime.datetime(2000, month, 1) + if tzinfo.dst(dt) == notDST: + return tzinfo.tzname(dt) + # there was no standard time in 2000! + raise VObjectError("Unable to guess TZID for tzinfo %s" % str(tzinfo)) + + def __str__(self): + return "<VTIMEZONE | " + str(getattr(self, 'tzid', 'No TZID')) +">" + + def __repr__(self): + return self.__str__() + + def prettyPrint(self, level, tabwidth): + pre = ' ' * level * tabwidth + print pre, self.name + print pre, "TZID:", self.tzid + print + +class RecurringComponent(Component): + """A vCalendar component like VEVENT or VTODO which may recur. + + Any recurring component can have one or multiple RRULE, RDATE, + EXRULE, or EXDATE lines, and one or zero DTSTART lines. It can also have a + variety of children that don't have any recurrence information. + + In the example below, note that dtstart is included in the rruleset. + This is not the default behavior for dateutil's rrule implementation unless + dtstart would already have been a member of the recurrence rule, and as a + result, COUNT is wrong. This can be worked around when getting rruleset by + adjusting count down by one if an rrule has a count and dtstart isn't in its + result set, but by default, the rruleset property doesn't do this work + around, to access it getrruleset must be called with addRDate set True. + + >>> import dateutil.rrule, datetime + >>> vevent = RecurringComponent(name='VEVENT') + >>> vevent.add('rrule').value =u"FREQ=WEEKLY;COUNT=2;INTERVAL=2;BYDAY=TU,TH" + >>> vevent.add('dtstart').value = datetime.datetime(2005, 1, 19, 9) + + When creating rrule's programmatically it should be kept in + mind that count doesn't necessarily mean what rfc2445 says. + + >>> list(vevent.rruleset) + [datetime.datetime(2005, 1, 20, 9, 0), datetime.datetime(2005, 2, 1, 9, 0)] + >>> list(vevent.getrruleset(addRDate=True)) + [datetime.datetime(2005, 1, 19, 9, 0), datetime.datetime(2005, 1, 20, 9, 0)] + + Also note that dateutil will expand all-day events (datetime.date values) to + datetime.datetime value with time 0 and no timezone. + + >>> vevent.dtstart.value = datetime.date(2005,3,18) + >>> list(vevent.rruleset) + [datetime.datetime(2005, 3, 29, 0, 0), datetime.datetime(2005, 3, 31, 0, 0)] + >>> list(vevent.getrruleset(True)) + [datetime.datetime(2005, 3, 18, 0, 0), datetime.datetime(2005, 3, 29, 0, 0)] + + @ivar rruleset: + A U{rruleset<https://moin.conectiva.com.br/DateUtil>}. + """ + def __init__(self, *args, **kwds): + super(RecurringComponent, self).__init__(*args, **kwds) + self.isNative=True + #self.clobberedRDates=[] + + + def getrruleset(self, addRDate = False): + """Get an rruleset created from self. + + If addRDate is True, add an RDATE for dtstart if it's not included in + an RRULE, and count is decremented if it exists. + + Note that for rules which don't match DTSTART, DTSTART may not appear + in list(rruleset), although it should. By default, an RDATE is not + created in these cases, and count isn't updated, so dateutil may list + a spurious occurrence. + + """ + rruleset = None + for name in DATESANDRULES: + addfunc = None + for line in self.contents.get(name, ()): + # don't bother creating a rruleset unless there's a rule + if rruleset is None: + rruleset = dateutil.rrule.rruleset() + if addfunc is None: + addfunc=getattr(rruleset, name) + + if name in DATENAMES: + if type(line.value[0]) == datetime.datetime: + map(addfunc, line.value) + elif type(line.value) == datetime.date: + for dt in line.value: + addfunc(datetime.datetime(dt.year, dt.month, dt.day)) + else: + # ignore RDATEs with PERIOD values for now + pass + elif name in RULENAMES: + try: + dtstart = self.dtstart.value + except AttributeError, KeyError: + # if there's no dtstart, just return None + return None + # rrulestr complains about unicode, so cast to str + rule = dateutil.rrule.rrulestr(str(line.value), + dtstart=dtstart) + until = rule._until + if until is not None and until.tzinfo != dtstart.tzinfo: + # dateutil converts the UNTIL date to a datetime, + # check to see if the UNTIL parameter value was a date + vals = dict(pair.split('=') for pair in + line.value.upper().split(';')) + if len(vals.get('UNTIL', '')) == 8: + # it's not entirely clear, but presumably a date + # valued UNTIL should include that date + until = datetime.datetime.combine(until.date(), + dtstart.time()) + rule._until = until.replace(tzinfo=dtstart.tzinfo) + + # add the rrule or exrule to the rruleset + addfunc(rule) + + if name == 'rrule' and addRDate: + try: + # dateutils does not work with all-day (datetime.date) items + # so we need to convert to a datetime.datetime + # (which is what dateutils does internally) + if not isinstance(dtstart, datetime.datetime): + adddtstart = datetime.datetime.fromordinal(dtstart.toordinal()) + else: + adddtstart = dtstart + if rruleset._rrule[-1][0] != adddtstart: + rruleset.rdate(adddtstart) + added = True + else: + added = False + except IndexError: + # it's conceivable that an rrule might have 0 datetimes + added = False + if added and rruleset._rrule[-1]._count != None: + rruleset._rrule[-1]._count -= 1 + return rruleset + + def setrruleset(self, rruleset): + dtstart = self.dtstart.value + isDate = datetime.date == type(dtstart) + if isDate: + dtstart = datetime.datetime(dtstart.year,dtstart.month, dtstart.day) + untilSerialize = dateToString + else: + # make sure to convert time zones to UTC + untilSerialize = lambda x: dateTimeToString(x, True) + + for name in DATESANDRULES: + if hasattr(self.contents, name): + del self.contents[name] + setlist = getattr(rruleset, '_' + name) + if name in DATENAMES: + setlist = list(setlist) # make a copy of the list + if name == 'rdate' and dtstart in setlist: + setlist.remove(dtstart) + if isDate: + setlist = [dt.date() for dt in setlist] + if len(setlist) > 0: + self.add(name).value = setlist + elif name in RULENAMES: + for rule in setlist: + buf = StringIO.StringIO() + buf.write('FREQ=') + buf.write(FREQUENCIES[rule._freq]) + + values = {} + + if rule._interval != 1: + values['INTERVAL'] = [str(rule._interval)] + if rule._wkst != 0: # wkst defaults to Monday + values['WKST'] = [WEEKDAYS[rule._wkst]] + if rule._bysetpos is not None: + values['BYSETPOS'] = [str(i) for i in rule._bysetpos] + + if rule._count is not None: + values['COUNT'] = [str(rule._count)] + elif rule._until is not None: + values['UNTIL'] = [untilSerialize(rule._until)] + + days = [] + if (rule._byweekday is not None and ( + dateutil.rrule.WEEKLY != rule._freq or + len(rule._byweekday) != 1 or + rule._dtstart.weekday() != rule._byweekday[0])): + # ignore byweekday if freq is WEEKLY and day correlates + # with dtstart because it was automatically set by + # dateutil + days.extend(WEEKDAYS[n] for n in rule._byweekday) + + if rule._bynweekday is not None: + days.extend(str(n) + WEEKDAYS[day] for day, n in rule._bynweekday) + + if len(days) > 0: + values['BYDAY'] = days + + if rule._bymonthday is not None and len(rule._bymonthday) > 0: + if not (rule._freq <= dateutil.rrule.MONTHLY and + len(rule._bymonthday) == 1 and + rule._bymonthday[0] == rule._dtstart.day): + # ignore bymonthday if it's generated by dateutil + values['BYMONTHDAY'] = [str(n) for n in rule._bymonthday] + + if rule._bymonth is not None and len(rule._bymonth) > 0: + if not (rule._freq == dateutil.rrule.YEARLY and + len(rule._bymonth) == 1 and + rule._bymonth[0] == rule._dtstart.month): + # ignore bymonth if it's generated by dateutil + values['BYMONTH'] = [str(n) for n in rule._bymonth] + + if rule._byyearday is not None: + values['BYYEARDAY'] = [str(n) for n in rule._byyearday] + if rule._byweekno is not None: + values['BYWEEKNO'] = [str(n) for n in rule._byweekno] + + # byhour, byminute, bysecond are always ignored for now + + + for key, paramvals in values.iteritems(): + buf.write(';') + buf.write(key) + buf.write('=') + buf.write(','.join(paramvals)) + + self.add(name).value = buf.getvalue() + + + + rruleset = property(getrruleset, setrruleset) + + def __setattr__(self, name, value): + """For convenience, make self.contents directly accessible.""" + if name == 'rruleset': + self.setrruleset(value) + else: + super(RecurringComponent, self).__setattr__(name, value) + +class RecurringBehavior(behavior.Behavior): + """Parent Behavior for components which should be RecurringComponents.""" + hasNative = True + isComponent = True + + @staticmethod + def transformToNative(obj): + """Turn a recurring Component into a RecurringComponent.""" + if not obj.isNative: + object.__setattr__(obj, '__class__', RecurringComponent) + obj.isNative = True + return obj + + @staticmethod + def transformFromNative(obj): + if obj.isNative: + object.__setattr__(obj, '__class__', Component) + obj.isNative = False + return obj + + @staticmethod + def generateImplicitParameters(obj): + """Generate a UID if one does not exist. + + This is just a dummy implementation, for now. + + """ + if not hasattr(obj, 'uid'): + rand = str(int(random.random() * 100000)) + now = datetime.datetime.now(utc) + now = dateTimeToString(now) + host = socket.gethostname() + obj.add(ContentLine('UID', [], now + '-' + rand + '@' + host)) + + +class DateTimeBehavior(behavior.Behavior): + """Parent Behavior for ContentLines containing one DATE-TIME.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a datetime. + + RFC2445 allows times without time zone information, "floating times" + in some properties. Mostly, this isn't what you want, but when parsing + a file, real floating times are noted by setting to 'TRUE' the + X-VOBJ-FLOATINGTIME-ALLOWED parameter. + + """ + if obj.isNative: return obj + obj.isNative = True + if obj.value == '': return obj + obj.value=str(obj.value) + #we're cheating a little here, parseDtstart allows DATE + obj.value=parseDtstart(obj) + if obj.value.tzinfo is None: + obj.params['X-VOBJ-FLOATINGTIME-ALLOWED'] = ['TRUE'] + if obj.params.get('TZID'): + del obj.params['TZID'] + return obj + + @classmethod + def transformFromNative(cls, obj): + """Replace the datetime in obj.value with an ISO 8601 string.""" + if obj.isNative: + obj.isNative = False + tzid = TimezoneComponent.registerTzinfo(obj.value.tzinfo) + obj.value = dateTimeToString(obj.value, cls.forceUTC) + if not cls.forceUTC and tzid is not None: + obj.tzid_param = tzid + + return obj + +class UTCDateTimeBehavior(DateTimeBehavior): + """A value which must be specified in UTC.""" + forceUTC = True + +class DateOrDateTimeBehavior(behavior.Behavior): + """Parent Behavior for ContentLines containing one DATE or DATE-TIME.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a date or datetime.""" + if obj.isNative: return obj + obj.isNative = True + if obj.value == '': return obj + obj.value=str(obj.value) + obj.value=parseDtstart(obj) + if getattr(obj, 'value_param', 'DATE-TIME').upper() == 'DATE-TIME': + if hasattr(obj, 'tzid_param'): del obj.tzid_param + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the date or datetime in obj.value with an ISO 8601 string.""" + if type(obj.value) == datetime.date: + obj.isNative = False + obj.value_param = 'DATE' + obj.value = dateToString(obj.value) + return obj + else: return DateTimeBehavior.transformFromNative(obj) + +class MultiDateBehavior(behavior.Behavior): + """ + Parent Behavior for ContentLines containing one or more DATE, DATE-TIME, or + PERIOD. + + """ + hasNative = True + + @staticmethod + def transformToNative(obj): + """ + Turn obj.value into a list of dates, datetimes, or + (datetime, timedelta) tuples. + + """ + if obj.isNative: + return obj + obj.isNative = True + if obj.value == '': + obj.value = [] + return obj + tzinfo = getTzid(getattr(obj, 'tzid_param', None)) + valueParam = getattr(obj, 'value_param', "DATE-TIME").upper() + valTexts = obj.value.split(",") + if valueParam == "DATE": + obj.value = [stringToDate(x) for x in valTexts] + elif valueParam == "DATE-TIME": + obj.value = [stringToDateTime(x, tzinfo) for x in valTexts] + elif valueParam == "PERIOD": + obj.value = [stringToPeriod(x, tzinfo) for x in valTexts] + return obj + + @staticmethod + def transformFromNative(obj): + """ + Replace the date, datetime or period tuples in obj.value with + appropriate strings. + + """ + # Fixme: obj.value should be a list, so this test should never succeed + if type(obj.value) == datetime.date: + obj.isNative = False + obj.value_param = 'DATE' + obj.value = ','.join([dateToString(val) for val in obj.value]) + return obj + # Fixme: handle PERIOD case + else: + if obj.isNative: + obj.isNative = False + transformed = [] + tzid = None + for val in obj.value: + if tzid is None and type(val) == datetime.datetime: + tzid = TimezoneComponent.registerTzinfo(val.tzinfo) + if tzid is not None: + obj.tzid_param = tzid + transformed.append(dateTimeToString(val)) + obj.value = ','.join(transformed) + return obj + +class TextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding for single valued properties. + + TextBehavior also deals with base64 encoding if the ENCODING parameter is + explicitly set to BASE64. + + """ + base64string = 'BASE64' # vCard uses B + + @classmethod + def decode(cls, line): + """Remove backslash escaping from line.value.""" + if line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.decode('base64') + else: + line.value = stringToTextValues(line.value)[0] + line.encoded=False + + @classmethod + def encode(cls, line): + """Backslash escape line.value.""" + if not line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.encode('base64').replace('\n', '') + else: + line.value = backslashEscape(line.value) + line.encoded=True + +class MultiTextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding of each of several values. + + After transformation, value is a list of strings. + + """ + + @staticmethod + def decode(line): + """Remove backslash escaping from line.value, then split on commas.""" + if line.encoded: + line.value = stringToTextValues(line.value) + line.encoded=False + + @staticmethod + def encode(line): + """Backslash escape line.value.""" + if not line.encoded: + line.value = ','.join(backslashEscape(val) for val in line.value) + line.encoded=True + + +#------------------------ Registered Behavior subclasses ----------------------- +class VCalendar2_0(behavior.Behavior): + """vCalendar 2.0 behavior.""" + name = 'VCALENDAR' + description = 'vCalendar 2.0, also known as iCalendar.' + versionString = '2.0' + isComponent = True + sortFirst = ('version', 'calscale', 'method', 'prodid', 'vtimezone') + knownChildren = {'CALSCALE': (0, 1, None),#min, max, behaviorRegistry id + 'METHOD': (0, 1, None), + 'VERSION': (0, 1, None),#required, but auto-generated + 'PRODID': (1, 1, None), + 'VTIMEZONE': (0, None, None), + 'VEVENT': (0, None, None), + 'VTODO': (0, None, None), + 'VJOURNAL': (0, None, None), + 'VFREEBUSY': (0, None, None) + } + + @classmethod + def generateImplicitParameters(cls, obj): + """Create PRODID, VERSION, and VTIMEZONEs if needed. + + VTIMEZONEs will need to exist whenever TZID parameters exist or when + datetimes with tzinfo exist. + + """ + for comp in obj.components(): + if comp.behavior is not None: + comp.behavior.generateImplicitParameters(comp) + if not hasattr(obj, 'prodid'): + obj.add(ContentLine('PRODID', [], PRODID)) + if not hasattr(obj, 'version'): + obj.add(ContentLine('VERSION', [], cls.versionString)) + tzidsUsed = {} + + def findTzids(obj, table): + if isinstance(obj, ContentLine) and (obj.behavior is None or + not obj.behavior.forceUTC): + if getattr(obj, 'tzid_param', None): + table[obj.tzid_param] = 1 + else: + if type(obj.value) == list: + for item in obj.value: + tzinfo = getattr(obj.value, 'tzinfo', None) + tzid = TimezoneComponent.registerTzinfo(tzinfo) + if tzid: + table[tzid] = 1 + else: + tzinfo = getattr(obj.value, 'tzinfo', None) + tzid = TimezoneComponent.registerTzinfo(tzinfo) + if tzid: + table[tzid] = 1 + for child in obj.getChildren(): + if obj.name != 'VTIMEZONE': + findTzids(child, table) + + findTzids(obj, tzidsUsed) + oldtzids = [x.tzid.value for x in getattr(obj, 'vtimezone_list', [])] + for tzid in tzidsUsed.keys(): + if tzid == 'UTC' or tzid in oldtzids: continue + obj.add(TimezoneComponent(tzinfo=getTzid(tzid))) +registerBehavior(VCalendar2_0) + +class VTimezone(behavior.Behavior): + """Timezone behavior.""" + name = 'VTIMEZONE' + hasNative = True + isComponent = True + description = 'A grouping of component properties that defines a time zone.' + sortFirst = ('tzid', 'last-modified', 'tzurl', 'standard', 'daylight') + knownChildren = {'TZID': (1, 1, None),#min, max, behaviorRegistry id + 'LAST-MODIFIED':(0, 1, None), + 'TZURL': (0, 1, None), + 'STANDARD': (0, None, None),#NOTE: One of Standard or + 'DAYLIGHT': (0, None, None) # Daylight must appear + } + + @classmethod + def validate(cls, obj, raiseException, *args): + return True #TODO: FIXME + if obj.contents.has_key('standard') or obj.contents.has_key('daylight'): + return super(VTimezone, cls).validate(obj, raiseException, *args) + else: + if raiseException: + m = "VTIMEZONE components must contain a STANDARD or a DAYLIGHT\ + component" + raise ValidateError(m) + return False + + + @staticmethod + def transformToNative(obj): + if not obj.isNative: + object.__setattr__(obj, '__class__', TimezoneComponent) + obj.isNative = True + obj.registerTzinfo(obj.tzinfo) + return obj + + @staticmethod + def transformFromNative(obj): + return obj + + +registerBehavior(VTimezone) + +class DaylightOrStandard(behavior.Behavior): + hasNative = False + isComponent = True + knownChildren = {'DTSTART': (1, 1, None)}#min, max, behaviorRegistry id + +registerBehavior(DaylightOrStandard, 'STANDARD') +registerBehavior(DaylightOrStandard, 'DAYLIGHT') + + +class VEvent(RecurringBehavior): + """Event behavior.""" + name='VEVENT' + sortFirst = ('uid', 'recurrence-id', 'dtstart', 'duration', 'dtend') + + description='A grouping of component properties, and possibly including \ + "VALARM" calendar components, that represents a scheduled \ + amount of time on a calendar.' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'GEO': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'LOCATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'PRIORITY': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'TRANSP': (0, 1, None), + 'UID': (1, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'DTEND': (0, 1, None), #NOTE: Only one of DtEnd or + 'DURATION': (0, 1, None), # Duration can appear + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RESOURCES': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None), + 'VALARM': (0, None, None) + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if obj.contents.has_key('DTEND') and obj.contents.has_key('DURATION'): + if raiseException: + m = "VEVENT components cannot contain both DTEND and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VEvent, cls).validate(obj, raiseException, *args) + +registerBehavior(VEvent) + + +class VTodo(RecurringBehavior): + """To-do behavior.""" + name='VTODO' + description='A grouping of component properties and possibly "VALARM" \ + calendar components that represent an action-item or \ + assignment.' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'COMPLETED': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'GEO': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'LOCATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'PERCENT': (0, 1, None), + 'PRIORITY': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'DUE': (0, 1, None), #NOTE: Only one of Due or + 'DURATION': (0, 1, None), # Duration can appear + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RESOURCES': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None), + 'VALARM': (0, None, None) + } + + @classmethod + def validate(cls, obj, raiseException, *args): + if obj.contents.has_key('DUE') and obj.contents.has_key('DURATION'): + if raiseException: + m = "VTODO components cannot contain both DUE and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VTodo, cls).validate(obj, raiseException, *args) + +registerBehavior(VTodo) + + +class VJournal(RecurringBehavior): + """Journal entry behavior.""" + name='VJOURNAL' + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CLASS': (0, 1, None), + 'CREATED': (0, 1, None), + 'DESCRIPTION': (0, 1, None), + 'LAST-MODIFIED':(0, 1, None), + 'ORGANIZER': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'SEQUENCE': (0, 1, None), + 'STATUS': (0, 1, None), + 'SUMMARY': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'RECURRENCE-ID':(0, 1, None), + 'ATTACH': (0, None, None), + 'ATTENDEE': (0, None, None), + 'CATEGORIES': (0, None, None), + 'COMMENT': (0, None, None), + 'CONTACT': (0, None, None), + 'EXDATE': (0, None, None), + 'EXRULE': (0, None, None), + 'REQUEST-STATUS': (0, None, None), + 'RELATED-TO': (0, None, None), + 'RDATE': (0, None, None), + 'RRULE': (0, None, None) + } +registerBehavior(VJournal) + + +class VFreeBusy(behavior.Behavior): + """Free/busy state behavior. + + >>> vfb = newFromBehavior('VFREEBUSY') + >>> vfb.add('uid').value = 'test' + >>> vfb.add('dtstart').value = datetime.datetime(2006, 2, 16, 1, tzinfo=utc) + >>> vfb.add('dtend').value = vfb.dtstart.value + twoHours + >>> vfb.add('freebusy').value = [(vfb.dtstart.value, twoHours / 2)] + >>> print vfb.serialize() + BEGIN:VFREEBUSY + UID:test + DTSTART:20060216T010000Z + DTEND:20060216T030000Z + FREEBUSY:20060216T010000Z/PT1H + END:VFREEBUSY + + """ + name='VFREEBUSY' + isComponent = True + description='A grouping of component properties that describe either a \ + request for free/busy time, describe a response to a request \ + for free/busy time or describe a published set of busy time.' + sortFirst = ('uid', 'dtstart', 'duration', 'dtend') + knownChildren = {'DTSTART': (0, 1, None),#min, max, behaviorRegistry id + 'CONTACT': (0, 1, None), + 'DTEND': (0, 1, None), + 'DURATION': (0, 1, None), + 'ORGANIZER': (0, 1, None), + 'DTSTAMP': (0, 1, None), + 'UID': (0, 1, None), + 'URL': (0, 1, None), + 'ATTENDEE': (0, None, None), + 'COMMENT': (0, None, None), + 'FREEBUSY': (0, None, None), + 'REQUEST-STATUS': (0, None, None) + } +registerBehavior(VFreeBusy) + + +class VAlarm(behavior.Behavior): + """Alarm behavior.""" + name='VALARM' + isComponent = True + description='Alarms describe when and how to provide alerts about events \ + and to-dos.' + knownChildren = {'ACTION': (1, 1, None),#min, max, behaviorRegistry id + 'TRIGGER': (1, 1, None), + 'DURATION': (0, 1, None), + 'REPEAT': (0, 1, None), + 'DESCRIPTION': (0, 1, None) + } + + @staticmethod + def generateImplicitParameters(obj): + """Create default ACTION and TRIGGER if they're not set.""" + try: + obj.action + except AttributeError: + obj.add('action').value = 'AUDIO' + try: + obj.trigger + except AttributeError: + obj.add('trigger').value = datetime.timedelta(0) + + + @classmethod + def validate(cls, obj, raiseException, *args): + """ + #TODO + audioprop = 2*( + + ; 'action' and 'trigger' are both REQUIRED, + ; but MUST NOT occur more than once + + action / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + ; the following is optional, + ; but MUST NOT occur more than once + + attach / + + dispprop = 3*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / description / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + emailprop = 5*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / description / trigger / summary + + ; the following is REQUIRED, + ; and MAY occur more than once + + attendee / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + procprop = 3*( + + ; the following are all REQUIRED, + ; but MUST NOT occur more than once + + action / attach / trigger / + + ; 'duration' and 'repeat' are both optional, + ; and MUST NOT occur more than once each, + ; but if one occurs, so MUST the other + + duration / repeat / + + ; 'description' is optional, + ; and MUST NOT occur more than once + + description / + if obj.contents.has_key('DTEND') and obj.contents.has_key('DURATION'): + if raiseException: + m = "VEVENT components cannot contain both DTEND and DURATION\ + components" + raise ValidateError(m) + return False + else: + return super(VEvent, cls).validate(obj, raiseException, *args) + """ + return True + +registerBehavior(VAlarm) + +class Duration(behavior.Behavior): + """Behavior for Duration ContentLines. Transform to datetime.timedelta.""" + name = 'DURATION' + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a datetime.timedelta.""" + if obj.isNative: return obj + obj.isNative = True + obj.value=str(obj.value) + if obj.value == '': + return obj + else: + deltalist=stringToDurations(obj.value) + #When can DURATION have multiple durations? For now: + if len(deltalist) == 1: + obj.value = deltalist[0] + return obj + else: + raise ParseError("DURATION must have a single duration string.") + + @staticmethod + def transformFromNative(obj): + """Replace the datetime.timedelta in obj.value with an RFC2445 string. + """ + if not obj.isNative: return obj + obj.isNative = False + obj.value = timedeltaToString(obj.value) + return obj + +registerBehavior(Duration) + +class Trigger(behavior.Behavior): + """DATE-TIME or DURATION""" + name='TRIGGER' + description='This property specifies when an alarm will trigger.' + hasNative = True + forceUTC = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a timedelta or datetime.""" + if obj.isNative: return obj + value = getattr(obj, 'value_param', 'DURATION').upper() + if hasattr(obj, 'value_param'): + del obj.value_param + if obj.value == '': + obj.isNative = True + return obj + elif value == 'DURATION': + try: + return Duration.transformToNative(obj) + except ParseError: + logger.warn("TRIGGER not recognized as DURATION, trying " + "DATE-TIME, because iCal sometimes exports " + "DATE-TIMEs without setting VALUE=DATE-TIME") + try: + obj.isNative = False + dt = DateTimeBehavior.transformToNative(obj) + return dt + except: + msg = "TRIGGER with no VALUE not recognized as DURATION " \ + "or as DATE-TIME" + raise ParseError(msg) + elif value == 'DATE-TIME': + #TRIGGERs with DATE-TIME values must be in UTC, we could validate + #that fact, for now we take it on faith. + return DateTimeBehavior.transformToNative(obj) + else: + raise ParseError("VALUE must be DURATION or DATE-TIME") + + @staticmethod + def transformFromNative(obj): + if type(obj.value) == datetime.datetime: + obj.value_param = 'DATE-TIME' + return UTCDateTimeBehavior.transformFromNative(obj) + elif type(obj.value) == datetime.timedelta: + return Duration.transformFromNative(obj) + else: + raise NativeError("Native TRIGGER values must be timedelta or datetime") + +registerBehavior(Trigger) + +class PeriodBehavior(behavior.Behavior): + """A list of (date-time, timedelta) tuples. + + >>> line = ContentLine('test', [], '', isNative=True) + >>> line.behavior = PeriodBehavior + >>> line.value = [(datetime.datetime(2006, 2, 16, 10), twoHours)] + >>> line.transformFromNative().value + '20060216T100000/PT2H' + >>> line.transformToNative().value + [(datetime.datetime(2006, 2, 16, 10, 0), datetime.timedelta(0, 7200))] + >>> line.value.append((datetime.datetime(2006, 5, 16, 10), twoHours)) + >>> print line.serialize().strip() + TEST:20060216T100000/PT2H,20060516T100000/PT2H + """ + hasNative = True + + @staticmethod + def transformToNative(obj): + """Convert comma separated periods into tuples.""" + if obj.isNative: + return obj + obj.isNative = True + if obj.value == '': + obj.value = [] + return obj + tzinfo = getTzid(getattr(obj, 'tzid_param', None)) + obj.value = [stringToPeriod(x, tzinfo) for x in obj.value.split(",")] + return obj + + @classmethod + def transformFromNative(cls, obj): + """Convert the list of tuples in obj.value to strings.""" + if obj.isNative: + obj.isNative = False + transformed = [] + for tup in obj.value: + transformed.append(periodToString(tup, cls.forceUTC)) + if len(transformed) > 0: + tzid = TimezoneComponent.registerTzinfo(tup[0].tzinfo) + if not cls.forceUTC and tzid is not None: + obj.tzid_param = tzid + + obj.value = ','.join(transformed) + + return obj + +class FreeBusy(PeriodBehavior): + """Free or busy period of time, must be specified in UTC.""" + name = 'FREEBUSY' + forceUTC = True +registerBehavior(FreeBusy) + +#------------------------ Registration of common classes ----------------------- + +utcDateTimeList = ['LAST-MODIFIED', 'CREATED', 'COMPLETED', 'DTSTAMP'] +map(lambda x: registerBehavior(UTCDateTimeBehavior, x), utcDateTimeList) + +dateTimeOrDateList = ['DTEND', 'DTSTART', 'DUE', 'RECURRENCE-ID'] +map(lambda x: registerBehavior(DateOrDateTimeBehavior, x), + dateTimeOrDateList) + +registerBehavior(MultiDateBehavior, 'RDATE') +registerBehavior(MultiDateBehavior, 'EXDATE') + + +textList = ['CALSCALE', 'METHOD', 'PRODID', 'CLASS', 'COMMENT', 'DESCRIPTION', + 'LOCATION', 'STATUS', 'SUMMARY', 'TRANSP', 'CONTACT', 'RELATED-TO', + 'UID', 'ACTION', 'REQUEST-STATUS', 'TZID'] +map(lambda x: registerBehavior(TextBehavior, x), textList) + +multiTextList = ['CATEGORIES', 'RESOURCES'] +map(lambda x: registerBehavior(MultiTextBehavior, x), multiTextList) + +#------------------------ Serializing helper functions ------------------------- + +def numToDigits(num, places): + """Helper, for converting numbers to textual digits.""" + s = str(num) + if len(s) < places: + return ("0" * (places - len(s))) + s + elif len(s) > places: + return s[len(s)-places: ] + else: + return s + +def timedeltaToString(delta): + """Convert timedelta to an rfc2445 DURATION.""" + if delta.days == 0: sign = 1 + else: sign = delta.days / abs(delta.days) + delta = abs(delta) + days = delta.days + hours = delta.seconds / 3600 + minutes = (delta.seconds % 3600) / 60 + seconds = delta.seconds % 60 + out = '' + if sign == -1: out = '-' + out += 'P' + if days: out += str(days) + 'D' + if hours or minutes or seconds: out += 'T' + elif not days: #Deal with zero duration + out += '0S' + if hours: out += str(hours) + 'H' + if minutes: out += str(minutes) + 'M' + if seconds: out += str(seconds) + 'S' + return out + +def timeToString(dateOrDateTime): + """ + Wraps dateToString and dateTimeToString, returning the results + of either based on the type of the argument + """ + # Didn't use isinstance here as date and datetime sometimes evalutes as both + if (type(dateOrDateTime) == datetime.date): + return dateToString(dateOrDateTime) + elif(type(dateOrDateTime) == datetime.datetime): + return dateTimeToString(dateOrDateTime) + + +def dateToString(date): + year = numToDigits( date.year, 4 ) + month = numToDigits( date.month, 2 ) + day = numToDigits( date.day, 2 ) + return year + month + day + +def dateTimeToString(dateTime, convertToUTC=False): + """Ignore tzinfo unless convertToUTC. Output string.""" + if dateTime.tzinfo and convertToUTC: + dateTime = dateTime.astimezone(utc) + if tzinfo_eq(dateTime.tzinfo, utc): utcString = "Z" + else: utcString = "" + + year = numToDigits( dateTime.year, 4 ) + month = numToDigits( dateTime.month, 2 ) + day = numToDigits( dateTime.day, 2 ) + hour = numToDigits( dateTime.hour, 2 ) + mins = numToDigits( dateTime.minute, 2 ) + secs = numToDigits( dateTime.second, 2 ) + + return year + month + day + "T" + hour + mins + secs + utcString + +def deltaToOffset(delta): + absDelta = abs(delta) + hours = absDelta.seconds / 3600 + hoursString = numToDigits(hours, 2) + minutesString = '00' + if absDelta == delta: + signString = "+" + else: + signString = "-" + return signString + hoursString + minutesString + +def periodToString(period, convertToUTC=False): + txtstart = dateTimeToString(period[0], convertToUTC) + if isinstance(period[1], datetime.timedelta): + txtend = timedeltaToString(period[1]) + else: + txtend = dateTimeToString(period[1], convertToUTC) + return txtstart + "/" + txtend + +#----------------------- Parsing functions ------------------------------------- + +def isDuration(s): + s = string.upper(s) + return (string.find(s, "P") != -1) and (string.find(s, "P") < 2) + +def stringToDate(s, tzinfos=None): + if tzinfos != None: print "Didn't expect a tzinfos here" + year = int( s[0:4] ) + month = int( s[4:6] ) + day = int( s[6:8] ) + return datetime.date(year,month,day) + +def stringToDateTime(s, tzinfo=None): + """Returns datetime.datetime object.""" + try: + year = int( s[0:4] ) + month = int( s[4:6] ) + day = int( s[6:8] ) + hour = int( s[9:11] ) + minute = int( s[11:13] ) + second = int( s[13:15] ) + if len(s) > 15: + if s[15] == 'Z': + tzinfo = utc + except: + raise ParseError("'%s' is not a valid DATE-TIME" % s) + return datetime.datetime(year, month, day, hour, minute, second, 0, tzinfo) + + +escapableCharList = "\\;,Nn" + +def stringToTextValues(s, strict=False): + """Returns list of strings.""" + + def escapableChar (c): + return c in escapableCharList + + def error(msg): + if strict: + raise ParseError(msg) + else: + #logger.error(msg) + print msg + + #vars which control state machine + charIterator = enumerate(s) + state = "read normal" + + current = "" + results = [] + + while True: + try: + charIndex, char = charIterator.next() + except: + char = "eof" + + if state == "read normal": + if char == '\\': + state = "read escaped char" + elif char == ',': + state = "read normal" + results.append(current) + current = "" + elif char == "eof": + state = "end" + else: + state = "read normal" + current = current + char + + elif state == "read escaped char": + if escapableChar(char): + state = "read normal" + if char in 'nN': + current = current + '\n' + else: + current = current + char + else: + state = "read normal" + current = current + char #this is an error, but whatever + + elif state == "end": #an end state + if current != "" or len(results) == 0: + results.append(current) + return results + + elif state == "error": #an end state + return results + + else: + state = "error" + error("error: unknown state: '%s' reached in %s" % (state, s)) + +def stringToDurations(s, strict=False): + """Returns list of timedelta objects.""" + def makeTimedelta(sign, week, day, hour, minute, sec): + if sign == "-": sign = -1 + else: sign = 1 + week = int(week) + day = int(day) + hour = int(hour) + minute = int(minute) + sec = int(sec) + return sign * datetime.timedelta(weeks=week, days=day, hours=hour, minutes=minute, seconds=sec) + + def error(msg): + if strict: + raise ParseError(msg) + else: + raise ParseError(msg) + #logger.error(msg) + + #vars which control state machine + charIterator = enumerate(s) + state = "start" + + durations = [] + current = "" + sign = None + week = 0 + day = 0 + hour = 0 + minute = 0 + sec = 0 + + while True: + try: + charIndex, char = charIterator.next() + except: + charIndex += 1 + char = "eof" + + if state == "start": + if char == '+': + state = "start" + sign = char + elif char == '-': + state = "start" + sign = char + elif char.upper() == 'P': + state = "read field" + elif char == "eof": + state = "error" + error("got end-of-line while reading in duration: " + s) + elif char in string.digits: + state = "read field" + current = current + char #update this part when updating "read field" + else: + state = "error" + print "got unexpected character %s reading in duration: %s" % (char, s) + error("got unexpected character %s reading in duration: %s" % (char, s)) + + elif state == "read field": + if (char in string.digits): + state = "read field" + current = current + char #update part above when updating "read field" + elif char.upper() == 'T': + state = "read field" + elif char.upper() == 'W': + state = "read field" + week = current + current = "" + elif char.upper() == 'D': + state = "read field" + day = current + current = "" + elif char.upper() == 'H': + state = "read field" + hour = current + current = "" + elif char.upper() == 'M': + state = "read field" + minute = current + current = "" + elif char.upper() == 'S': + state = "read field" + sec = current + current = "" + elif char == ",": + state = "start" + durations.append( makeTimedelta(sign, week, day, hour, minute, sec) ) + current = "" + sign = None + week = None + day = None + hour = None + minute = None + sec = None + elif char == "eof": + state = "end" + else: + state = "error" + error("got unexpected character reading in duration: " + s) + + elif state == "end": #an end state + #print "stuff: %s, durations: %s" % ([current, sign, week, day, hour, minute, sec], durations) + + if (sign or week or day or hour or minute or sec): + durations.append( makeTimedelta(sign, week, day, hour, minute, sec) ) + return durations + + elif state == "error": #an end state + error("in error state") + return durations + + else: + state = "error" + error("error: unknown state: '%s' reached in %s" % (state, s)) + +def parseDtstart(contentline): + tzinfo = getTzid(getattr(contentline, 'tzid_param', None)) + valueParam = getattr(contentline, 'value_param', 'DATE-TIME').upper() + if valueParam == "DATE": + return stringToDate(contentline.value) + elif valueParam == "DATE-TIME": + return stringToDateTime(contentline.value, tzinfo) + +def stringToPeriod(s, tzinfo=None): + values = string.split(s, "/") + start = stringToDateTime(values[0], tzinfo) + valEnd = values[1] + if isDuration(valEnd): #period-start = date-time "/" dur-value + delta = stringToDurations(valEnd)[0] + return (start, delta) + else: + return (start, stringToDateTime(valEnd, tzinfo) - start) + + +def getTransition(transitionTo, year, tzinfo): + """Return the datetime of the transition to/from DST, or None.""" + + def firstTransition(iterDates, test): + """ + Return the last date not matching test, or None if all tests matched. + """ + success = None + for dt in iterDates: + if not test(dt): + success = dt + else: + if success is not None: + return success + return success # may be None + + def generateDates(year, month=None, day=None): + """Iterate over possible dates with unspecified values.""" + months = range(1, 13) + days = range(1, 32) + hours = range(0, 24) + if month is None: + for month in months: + yield datetime.datetime(year, month, 1) + elif day is None: + for day in days: + try: + yield datetime.datetime(year, month, day) + except ValueError: + pass + else: + for hour in hours: + yield datetime.datetime(year, month, day, hour) + + assert transitionTo in ('daylight', 'standard') + if transitionTo == 'daylight': + def test(dt): return tzinfo.dst(dt) != zeroDelta + elif transitionTo == 'standard': + def test(dt): return tzinfo.dst(dt) == zeroDelta + newyear = datetime.datetime(year, 1, 1) + monthDt = firstTransition(generateDates(year), test) + if monthDt is None: + return newyear + elif monthDt.month == 12: + return None + else: + # there was a good transition somewhere in a non-December month + month = monthDt.month + day = firstTransition(generateDates(year, month), test).day + uncorrected = firstTransition(generateDates(year, month, day), test) + if transitionTo == 'standard': + # assuming tzinfo.dst returns a new offset for the first + # possible hour, we need to add one hour for the offset change + # and another hour because firstTransition returns the hour + # before the transition + return uncorrected + datetime.timedelta(hours=2) + else: + return uncorrected + datetime.timedelta(hours=1) + +def tzinfo_eq(tzinfo1, tzinfo2, startYear = 2000, endYear=2020): + """Compare offsets and DST transitions from startYear to endYear.""" + if tzinfo1 == tzinfo2: + return True + elif tzinfo1 is None or tzinfo2 is None: + return False + + def dt_test(dt): + if dt is None: + return True + return tzinfo1.utcoffset(dt) == tzinfo2.utcoffset(dt) + + if not dt_test(datetime.datetime(startYear, 1, 1)): + return False + for year in xrange(startYear, endYear): + for transitionTo in 'daylight', 'standard': + t1=getTransition(transitionTo, year, tzinfo1) + t2=getTransition(transitionTo, year, tzinfo2) + if t1 != t2 or not dt_test(t1): + return False + return True + + +#------------------- Testing and running functions ----------------------------- +if __name__ == '__main__': + import tests + tests._test() diff --git a/src/vobject/vcard.py b/src/vobject/vcard.py new file mode 100644 index 0000000..2aa30cb --- /dev/null +++ b/src/vobject/vcard.py @@ -0,0 +1,225 @@ +"""Definitions and behavior for vCard 3.0""" + +import behavior +import itertools + +from base import VObjectError, NativeError, ValidateError, ParseError, \ + VBase, Component, ContentLine, logger, defaultSerialize, \ + registerBehavior, backslashEscape +from icalendar import stringToTextValues + +#------------------------ vCard structs ---------------------------------------- + +class Name(object): + def __init__(self, family = '', given = '', additional = '', prefix = '', + suffix = ''): + """Each name attribute can be a string or a list of strings.""" + self.family = family + self.given = given + self.additional = additional + self.prefix = prefix + self.suffix = suffix + + @staticmethod + def toString(val): + """Turn a string or array value into a string.""" + if type(val) in (list, tuple): + return ' '.join(val) + return val + + def __str__(self): + eng_order = ('prefix', 'given', 'additional', 'family', 'suffix') + return ' '.join(self.toString(getattr(self, val)) for val in eng_order) + + def __repr__(self): + return "<Name: %s>" % self.__str__() + +class Address(object): + def __init__(self, street = '', city = '', region = '', code = '', + country = '', box = '', extended = ''): + """Each name attribute can be a string or a list of strings.""" + self.box = box + self.extended = extended + self.street = street + self.city = city + self.region = region + self.code = code + self.country = country + + @staticmethod + def toString(val, join_char='\n'): + """Turn a string or array value into a string.""" + if type(val) in (list, tuple): + return join_char.join(val) + return val + + lines = ('box', 'extended', 'street') + one_line = ('city', 'region', 'code') + + def __str__(self): + lines = '\n'.join(self.toString(getattr(self, val)) for val in self.lines if getattr(self, val)) + one_line = tuple(self.toString(getattr(self, val), ' ') for val in self.one_line) + lines += "\n%s, %s %s" % one_line + if self.country: + lines += '\n' + self.toString(self.country) + return lines + + def __repr__(self): + return "<Address: %s>" % repr(str(self))[1:-1] + +#------------------------ Registered Behavior subclasses ----------------------- +class VCardBehavior(behavior.Behavior): + allowGroup = True + +class VCard3_0(VCardBehavior): + """vCard 3.0 behavior.""" + name = 'VCARD' + description = 'vCard 3.0, defined in rfc2426' + versionString = '3.0' + isComponent = True + sortFirst = ('version', 'prodid', 'uid') + knownChildren = {'N': (1, 1, None),#min, max, behaviorRegistry id + 'FN': (1, 1, None), + 'VERSION': (1, 1, None),#required, auto-generated + 'PRODID': (0, 1, None), + 'LABEL': (0, None, None), + 'UID': (0, None, None), + 'ADR': (0, None, None), + 'PHOTO': (0, None, None), + 'CATEGORIES':(0, None, None) + } + + @classmethod + def generateImplicitParameters(cls, obj): + """Create PRODID, VERSION, and VTIMEZONEs if needed. + + VTIMEZONEs will need to exist whenever TZID parameters exist or when + datetimes with tzinfo exist. + + """ + if not hasattr(obj, 'version'): + obj.add(ContentLine('VERSION', [], cls.versionString)) +registerBehavior(VCard3_0, default=True) + +class VCardTextBehavior(behavior.Behavior): + """Provide backslash escape encoding/decoding for single valued properties. + + TextBehavior also deals with base64 encoding if the ENCODING parameter is + explicitly set to BASE64. + + """ + allowGroup = True + base64string = 'B' + + @classmethod + def decode(cls, line): + """Remove backslash escaping from line.valueDecode line, either to remove + backslash espacing, or to decode base64 encoding. The content line should + contain a ENCODING=b for base64 encoding, but Apple Addressbook seems to + export a singleton parameter of 'BASE64', which does not match the 3.0 + vCard spec. If we encouter that, then we transform the parameter to + ENCODING=b""" + if line.encoded: + if 'BASE64' in line.singletonparams: + line.singletonparams.remove('BASE64') + line.encoding_param = cls.base64string + encoding = getattr(line, 'encoding_param', None) + if encoding: + line.value = line.value.decode('base64') + else: + line.value = stringToTextValues(line.value)[0] + line.encoded=False + + @classmethod + def encode(cls, line): + """Backslash escape line.value.""" + if not line.encoded: + encoding = getattr(line, 'encoding_param', None) + if encoding and encoding.upper() == cls.base64string: + line.value = line.value.encode('base64').replace('\n', '') + else: + line.value = backslashEscape(line.value) + line.encoded=True + +class FN(VCardTextBehavior): + name = "FN" + description = 'Formatted name' +registerBehavior(FN) + +class Label(VCardTextBehavior): + name = "Label" + description = 'Formatted address' +registerBehavior(FN) + +class Photo(VCardTextBehavior): + name = "Photo" + description = 'Photograph' + @classmethod + def valueRepr( cls, line ): + return " (BINARY PHOTO DATA at 0x%s) " % id( line.value ) + +registerBehavior(Photo) + +def toListOrString(string): + if string.find(',') >= 0: + return string.split(',') + return string + +def splitFields(string): + """Return a list of strings or lists from a Name or Address.""" + return [toListOrString(i) for i in string.split(';')] + +def toList(stringOrList): + if isinstance(stringOrList, basestring): + return [stringOrList] + return stringOrList + +def serializeFields(obj, order): + """Turn an object's fields into a ';' and ',' seperated string.""" + return ';'.join([','.join(toList(getattr(obj, val))) for val in order]) + +NAME_ORDER = ('family', 'given', 'additional', 'prefix', 'suffix') + +class NameBehavior(VCardBehavior): + """A structured name.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into a Name.""" + if obj.isNative: return obj + obj.isNative = True + obj.value = Name(**dict(zip(NAME_ORDER, splitFields(obj.value)))) + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the Name in obj.value with a string.""" + obj.isNative = False + obj.value = serializeFields(obj.value, NAME_ORDER) + return obj +registerBehavior(NameBehavior, 'N') + +ADDRESS_ORDER = ('box', 'extended', 'street', 'city', 'region', 'code', + 'country') + +class AddressBehavior(VCardBehavior): + """A structured address.""" + hasNative = True + + @staticmethod + def transformToNative(obj): + """Turn obj.value into an Address.""" + if obj.isNative: return obj + obj.isNative = True + obj.value = Address(**dict(zip(ADDRESS_ORDER, splitFields(obj.value)))) + return obj + + @staticmethod + def transformFromNative(obj): + """Replace the Address in obj.value with a string.""" + obj.isNative = False + obj.value = serializeFields(obj.value, ADDRESS_ORDER) + return obj +registerBehavior(AddressBehavior, 'ADR') + diff --git a/src/vobject/win32tz.py b/src/vobject/win32tz.py new file mode 100644 index 0000000..35f997b --- /dev/null +++ b/src/vobject/win32tz.py @@ -0,0 +1,156 @@ +import _winreg +import struct +import datetime + +handle=_winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) +tzparent=_winreg.OpenKey(handle, + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones") +parentsize=_winreg.QueryInfoKey(tzparent)[0] + +localkey=_winreg.OpenKey(handle, + "SYSTEM\\CurrentControlSet\\Control\\TimeZoneInformation") +WEEKS=datetime.timedelta(7) + +def list_timezones(): + """Return a list of all time zones known to the system.""" + l=[] + for i in xrange(parentsize): + l.append(_winreg.EnumKey(tzparent, i)) + return l + +class win32tz(datetime.tzinfo): + """tzinfo class based on win32's timezones available in the registry. + + >>> local = win32tz('Central Standard Time') + >>> oct1 = datetime.datetime(month=10, year=2004, day=1, tzinfo=local) + >>> dec1 = datetime.datetime(month=12, year=2004, day=1, tzinfo=local) + >>> oct1.dst() + datetime.timedelta(0, 3600) + >>> dec1.dst() + datetime.timedelta(0) + >>> braz = win32tz('E. South America Standard Time') + >>> braz.dst(oct1) + datetime.timedelta(0) + >>> braz.dst(dec1) + datetime.timedelta(0, 3600) + + """ + def __init__(self, name): + self.data=win32tz_data(name) + + def utcoffset(self, dt): + if self._isdst(dt): + return datetime.timedelta(minutes=self.data.dstoffset) + else: + return datetime.timedelta(minutes=self.data.stdoffset) + + def dst(self, dt): + if self._isdst(dt): + minutes = self.data.dstoffset - self.data.stdoffset + return datetime.timedelta(minutes=minutes) + else: + return datetime.timedelta(0) + + def tzname(self, dt): + if self._isdst(dt): return self.data.dstname + else: return self.data.stdname + + def _isdst(self, dt): + dat=self.data + dston = pickNthWeekday(dt.year, dat.dstmonth, dat.dstdayofweek, + dat.dsthour, dat.dstminute, dat.dstweeknumber) + dstoff = pickNthWeekday(dt.year, dat.stdmonth, dat.stddayofweek, + dat.stdhour, dat.stdminute, dat.stdweeknumber) + if dston < dstoff: + if dston <= dt.replace(tzinfo=None) < dstoff: return True + else: return False + else: + if dstoff <= dt.replace(tzinfo=None) < dston: return False + else: return True + + def __repr__(self): + return "<win32tz - %s>" % self.data.display + +def pickNthWeekday(year, month, dayofweek, hour, minute, whichweek): + """dayofweek == 0 means Sunday, whichweek > 4 means last instance""" + first = datetime.datetime(year=year, month=month, hour=hour, minute=minute, + day=1) + weekdayone = first.replace(day=((dayofweek - first.isoweekday()) % 7 + 1)) + for n in xrange(whichweek - 1, -1, -1): + dt=weekdayone + n * WEEKS + if dt.month == month: return dt + + +class win32tz_data(object): + """Read a registry key for a timezone, expose its contents.""" + + def __init__(self, path): + """Load path, or if path is empty, load local time.""" + if path: + keydict=valuesToDict(_winreg.OpenKey(tzparent, path)) + self.display = keydict['Display'] + self.dstname = keydict['Dlt'] + self.stdname = keydict['Std'] + + #see http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm + tup = struct.unpack('=3l16h', keydict['TZI']) + self.stdoffset = -tup[0]-tup[1] #Bias + StandardBias * -1 + self.dstoffset = self.stdoffset - tup[2] # + DaylightBias * -1 + + offset=3 + self.stdmonth = tup[1 + offset] + self.stddayofweek = tup[2 + offset] #Sunday=0 + self.stdweeknumber = tup[3 + offset] #Last = 5 + self.stdhour = tup[4 + offset] + self.stdminute = tup[5 + offset] + + offset=11 + self.dstmonth = tup[1 + offset] + self.dstdayofweek = tup[2 + offset] #Sunday=0 + self.dstweeknumber = tup[3 + offset] #Last = 5 + self.dsthour = tup[4 + offset] + self.dstminute = tup[5 + offset] + + else: + keydict=valuesToDict(localkey) + + self.stdname = keydict['StandardName'] + self.dstname = keydict['DaylightName'] + + sourcekey=_winreg.OpenKey(tzparent, self.stdname) + self.display = valuesToDict(sourcekey)['Display'] + + self.stdoffset = -keydict['Bias']-keydict['StandardBias'] + self.dstoffset = self.stdoffset - keydict['DaylightBias'] + + #see http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm + tup = struct.unpack('=8h', keydict['StandardStart']) + + offset=0 + self.stdmonth = tup[1 + offset] + self.stddayofweek = tup[2 + offset] #Sunday=0 + self.stdweeknumber = tup[3 + offset] #Last = 5 + self.stdhour = tup[4 + offset] + self.stdminute = tup[5 + offset] + + tup = struct.unpack('=8h', keydict['DaylightStart']) + self.dstmonth = tup[1 + offset] + self.dstdayofweek = tup[2 + offset] #Sunday=0 + self.dstweeknumber = tup[3 + offset] #Last = 5 + self.dsthour = tup[4 + offset] + self.dstminute = tup[5 + offset] + +def valuesToDict(key): + """Convert a registry key's values to a dictionary.""" + dict={} + size=_winreg.QueryInfoKey(key)[1] + for i in xrange(size): + dict[_winreg.EnumValue(key, i)[0]]=_winreg.EnumValue(key, i)[1] + return dict + +def _test(): + import win32tz, doctest + doctest.testmod(win32tz, verbose=0) + +if __name__ == '__main__': + _test()
\ No newline at end of file diff --git a/tests/recurrence.ics b/tests/recurrence.ics new file mode 100644 index 0000000..f592234 --- /dev/null +++ b/tests/recurrence.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR
+VERSION
+ :2.0
+PRODID
+ :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN
+BEGIN:VEVENT
+CREATED
+ :20060327T214227Z
+LAST-MODIFIED
+ :20060313T080829Z
+DTSTAMP
+ :20060116T231602Z
+UID
+ :70922B3051D34A9E852570EC00022388
+SUMMARY
+ :Monthly - All Hands Meeting with Joe Smith
+STATUS
+ :CONFIRMED
+CLASS
+ :PUBLIC
+RRULE
+ :FREQ=MONTHLY;UNTIL=20061228;INTERVAL=1;BYDAY=4TH
+DTSTART
+ :20060126T230000Z
+DTEND
+ :20060127T000000Z
+DESCRIPTION
+ :Repeat Meeting: - Occurs every 4th Thursday of each month
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..16ef0c2 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,711 @@ +"""Long or boring tests for vobjects."""
+
+# add source directory to front of sys path
+import sys, os
+basepath = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert( 0, os.path.join( basepath, 'src', 'vobject' ) )
+
+import base, icalendar, behavior, vcard, hcalendar
+import StringIO, re, dateutil.tz, datetime
+
+
+#------------------- Testing and running functions -----------------------------
+def _test():
+ import doctest, base, tests, icalendar, __init__, re
+ flags = doctest.NORMALIZE_WHITESPACE | doctest.REPORT_ONLY_FIRST_FAILURE
+ for mod in base, tests, icalendar, __init__, vcard:
+ doctest.testmod(mod, verbose=0, optionflags=flags)
+ try:
+ sys.path.pop()
+ sys.path.insert( 0, os.path.join( basepath, 'src' ) )
+ doctest.testfile('../README.txt', optionflags=flags)
+ except IOError: #allow this test to fail if we can't find README.txt
+ pass
+
+
+if __name__ == '__main__':
+ _test()
+
+
+testSilly="""
+sillyname:name
+profile:sillyprofile
+stuff:folded
+ line
+""" + "morestuff;asinine:this line is not folded, \
+but in practice probably ought to be, as it is exceptionally long, \
+and moreover demonstratively stupid"
+
+icaltest=r"""BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+X-WR-TIMEZONE;VALUE=TEXT:US/Pacific
+METHOD:PUBLISH
+PRODID:-//Apple Computer\, Inc//iCal 1.0//EN
+X-WR-CALNAME;VALUE=TEXT:Example
+VERSION:2.0
+BEGIN:VEVENT
+SEQUENCE:5
+DTSTART;TZID=US/Pacific:20021028T140000
+RRULE:FREQ=Weekly;COUNT=10
+DTSTAMP:20021028T011706Z
+SUMMARY:Coffee with Jason
+UID:EC9439B1-FF65-11D6-9973-003065F99D04
+DTEND;TZID=US/Pacific:20021028T150000
+BEGIN:VALARM
+TRIGGER;VALUE=DURATION:-P1D
+ACTION:DISPLAY
+DESCRIPTION:Event reminder\, with comma\nand line feed
+END:VALARM
+END:VEVENT
+BEGIN:VTIMEZONE
+X-LIC-LOCATION:Random location
+TZID:US/Pacific
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0700
+TZOFFSETTO:-0800
+TZNAME:PST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+TZOFFSETFROM:-0800
+TZOFFSETTO:-0700
+TZNAME:PDT
+END:DAYLIGHT
+END:VTIMEZONE
+END:VCALENDAR"""
+
+vcardtest = """BEGIN:VCARD
+VERSION:3.0
+FN:Daffy Duck Knudson (with Bugs Bunny and Mr. Pluto)
+N:Knudson;Daffy Duck (with Bugs Bunny and Mr. Pluto)
+NICKNAME:gnat and gnu and pluto
+BDAY;value=date:02-10;11-05;01-01
+TEL;type=HOME:+01-(0)2-765.43.21
+TEL;type=CELL:+01-(0)5-555.55.55
+ACCOUNT;type=HOME:010-1234567-05
+ADR;type=HOME:;;Haight Street 512;Novosibirsk;;80214;Gnuland
+TEL;type=HOME:+01-(0)2-876.54.32
+ORG:University of Novosibirsk, Department of Octopus
+ Parthenogenesis
+END:VCARD"""
+
+vcardWithGroups = r"""home.begin:vcard
+version:3.0
+source:ldap://cn=Meister%20Berger,o=Universitaet%20Goerlitz,c=DE
+name:Meister Berger
+fn:Meister Berger
+n:Berger;Meister
+bday;value=date:1963-09-21
+o:Universit=E6t G=F6rlitz
+title:Mayor
+title;language=de;value=text:Burgermeister
+note:The Mayor of the great city of
+ Goerlitz in the great country of Germany.
+email;internet:mb@goerlitz.de
+home.tel;type=fax,voice;type=msg:+49 3581 123456
+home.label:Hufenshlagel 1234\n
+ 02828 Goerlitz\n
+ Deutschland
+END:VCARD"""
+
+lowercaseComponentNames = r"""begin:vcard
+fn:Anders Bobo
+n:Bobo;Anders
+org:Bobo A/S;Vice President, Technical Support
+adr:Rockfeller Center;;Mekastreet;Bobocity;;2100;Myworld
+email;internet:bobo@example.com
+tel;work:+123455
+tel;fax:+123456
+tel;cell:+123457
+x-mozilla-html:FALSE
+url:http://www.example.com
+version:2.1
+end:vcard"""
+
+icalWeirdTrigger = r"""BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+X-WR-TIMEZONE;VALUE=TEXT:US/Pacific
+METHOD:PUBLISH
+PRODID:-//Apple Computer\, Inc//iCal 1.0//EN
+X-WR-CALNAME;VALUE=TEXT:Example
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART:20021028T140000Z
+BEGIN:VALARM
+TRIGGER:20021028T120000Z
+ACTION:DISPLAY
+DESCRIPTION:This trigger is a date-time without a VALUE=DATE-TIME parameter
+END:VALARM
+END:VEVENT
+END:VCALENDAR"""
+
+badstream = r"""BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+X-WR-TIMEZONE;VALUE=TEXT:US/Pacific
+METHOD:PUBLISH
+PRODID:-//Apple Computer\, Inc//iCal 1.0//EN
+X-WR-CALNAME;VALUE=TEXT:Example
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART:20021028T140000Z
+BEGIN:VALARM
+TRIGGER:a20021028120000
+ACTION:DISPLAY
+DESCRIPTION:This trigger has a nonsensical value
+END:VALARM
+END:VEVENT
+END:VCALENDAR"""
+
+timezones = r"""
+
+BEGIN:VTIMEZONE
+TZID:US/Pacific
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0700
+TZOFFSETTO:-0800
+TZNAME:PST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+TZOFFSETFROM:-0800
+TZOFFSETTO:-0700
+TZNAME:PDT
+END:DAYLIGHT
+END:VTIMEZONE
+
+BEGIN:VTIMEZONE
+TZID:US/Eastern
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+
+BEGIN:VTIMEZONE
+TZID:Santiago
+BEGIN:STANDARD
+DTSTART:19700314T000000
+TZOFFSETFROM:-0300
+TZOFFSETTO:-0400
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SA
+TZNAME:Pacific SA Standard Time
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19701010T000000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0300
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2SA
+TZNAME:Pacific SA Daylight Time
+END:DAYLIGHT
+END:VTIMEZONE
+
+BEGIN:VTIMEZONE
+TZID:W. Europe
+BEGIN:STANDARD
+DTSTART:19701025T030000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+TZNAME:W. Europe Standard Time
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19700329T020000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+TZNAME:W. Europe Daylight Time
+END:DAYLIGHT
+END:VTIMEZONE
+
+BEGIN:VTIMEZONE
+TZID:US/Fictitious-Eastern
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=20050403T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+
+BEGIN:VTIMEZONE
+TZID:America/Montreal
+LAST-MODIFIED:20051013T233643Z
+BEGIN:DAYLIGHT
+DTSTART:20050403T070000
+TZOFFSETTO:-0400
+TZOFFSETFROM:+0000
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20051030T020000
+TZOFFSETTO:-0500
+TZOFFSETFROM:-0400
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+
+"""
+
+__test__ = { "Test readOne" :
+ r"""
+ >>> silly = base.readOne(testSilly)
+ >>> silly
+ <SILLYPROFILE| [<MORESTUFF{}this line is not folded, but in practice probably ought to be, as it is exceptionally long, and moreover demonstratively stupid>, <SILLYNAME{}name>, <STUFF{}foldedline>]>
+ >>> silly.stuff
+ <STUFF{}foldedline>
+ >>> original = silly.serialize()
+ >>> f3 = StringIO.StringIO(original)
+ >>> silly2 = base.readOne(f3)
+ >>> silly2.serialize()==original
+ True
+ >>> s3 = StringIO.StringIO('cn:Babs Jensen\r\ncn:Barbara J Jensen\r\nsn:Jensen\r\nemail:babs@umich.edu\r\nphone:+1 313 747-4454\r\nx-id:1234567890\r\n')
+ >>> ex1 = base.readOne(s3)
+ >>> ex1
+ <*unnamed*| [<CN{}Babs Jensen>, <CN{}Barbara J Jensen>, <EMAIL{}babs@umich.edu>, <PHONE{}+1 313 747-4454>, <SN{}Jensen>, <X-ID{}1234567890>]>
+ >>> ex1.serialize()
+ u'CN:Babs Jensen\r\nCN:Barbara J Jensen\r\nEMAIL:babs@umich.edu\r\nPHONE:+1 313 747-4454\r\nSN:Jensen\r\nX-ID:1234567890\r\n'
+ """,
+
+ "Import icaltest" :
+ r"""
+ >>> c = base.readOne(icaltest, validate=True)
+ >>> c.vevent.valarm.trigger
+ <TRIGGER{}-1 day, 0:00:00>
+ >>> c.vevent.dtstart.value
+ datetime.datetime(2002, 10, 28, 14, 0, tzinfo=<tzicalvtz 'US/Pacific'>)
+ >>> c.vevent.dtend.value
+ datetime.datetime(2002, 10, 28, 15, 0, tzinfo=<tzicalvtz 'US/Pacific'>)
+ >>> c.vevent.dtstamp.value
+ datetime.datetime(2002, 10, 28, 1, 17, 6, tzinfo=tzutc())
+ >>> c.vevent.valarm.description.value
+ u'Event reminder, with comma\nand line feed'
+ >>> c.vevent.valarm.description.serialize()
+ u'DESCRIPTION:Event reminder\\, with comma\\nand line feed\r\n'
+ >>> vevent = c.vevent.transformFromNative()
+ >>> vevent.rrule
+ <RRULE{}FREQ=Weekly;COUNT=10>
+ """,
+
+ "Parsing tests" :
+ """
+ >>> parseRDate = icalendar.MultiDateBehavior.transformToNative
+ >>> icalendar.stringToTextValues('')
+ ['']
+ >>> icalendar.stringToTextValues('abcd,efgh')
+ ['abcd', 'efgh']
+ >>> icalendar.stringToPeriod("19970101T180000Z/19970102T070000Z")
+ (datetime.datetime(1997, 1, 1, 18, 0, tzinfo=tzutc()), datetime.timedelta(0, 46800))
+ >>> parseRDate(base.textLineToContentLine("RDATE;VALUE=DATE:19970304,19970504,19970704,19970904"))
+ <RDATE{'VALUE': ['DATE']}[datetime.date(1997, 3, 4), datetime.date(1997, 5, 4), datetime.date(1997, 7, 4), datetime.date(1997, 9, 4)]>
+ >>> parseRDate(base.textLineToContentLine("RDATE;VALUE=PERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H"))
+ <RDATE{'VALUE': ['PERIOD']}[(datetime.datetime(1996, 4, 3, 2, 0, tzinfo=tzutc()), datetime.timedelta(0, 7200)), (datetime.datetime(1996, 4, 4, 1, 0, tzinfo=tzutc()), datetime.timedelta(0, 10800))]>
+ """,
+
+ "read failure" :
+ """
+ >>> vevent = base.readOne(badstream)
+ Traceback (most recent call last):
+ ...
+ ParseError: At line 11: TRIGGER with no VALUE not recognized as DURATION or as DATE-TIME
+ """,
+
+ "ical trigger workaround" :
+ """
+
+ >>> badical = base.readOne(icalWeirdTrigger)
+ >>> badical.vevent.valarm.description.value
+ u'This trigger is a date-time without a VALUE=DATE-TIME parameter'
+ >>> badical.vevent.valarm.trigger.value
+ datetime.datetime(2002, 10, 28, 12, 0, tzinfo=tzutc())
+ """,
+
+ "unicode test" :
+ r"""
+ >>> f = open(os.path.join(basepath, 'tests', 'utf8_test.ics'))
+ >>> vevent = base.readOne(f).vevent
+ >>> vevent.summary.value
+ u'The title \u3053\u3093\u306b\u3061\u306f\u30ad\u30c6\u30a3'
+ >>> summary = vevent.summary.value
+ """,
+
+ # make sure date valued UNTILs in rrules are in a reasonable timezone,
+ # and include that day (12/28 in this test)
+ "recurrence test" :
+ r"""
+ >>> f = file(os.path.join(basepath, 'tests', 'recurrence.ics'))
+ >>> cal = base.readOne(f)
+ >>> dates = list(cal.vevent.rruleset)
+ >>> dates[0]
+ datetime.datetime(2006, 1, 26, 23, 0, tzinfo=tzutc())
+ >>> dates[1]
+ datetime.datetime(2006, 2, 23, 23, 0, tzinfo=tzutc())
+ >>> dates[-1]
+ datetime.datetime(2006, 12, 28, 23, 0, tzinfo=tzutc())
+ """,
+
+
+ "regular expression test" :
+ """
+ >>> re.findall(base.patterns['name'], '12foo-bar:yay')
+ ['12foo-bar', 'yay']
+ >>> re.findall(base.patterns['safe_char'], 'a;b"*,cd')
+ ['a', 'b', '*', 'c', 'd']
+ >>> re.findall(base.patterns['qsafe_char'], 'a;b"*,cd')
+ ['a', ';', 'b', '*', ',', 'c', 'd']
+ >>> re.findall(base.patterns['param_value'], '"quoted";not-quoted;start"after-illegal-quote', re.VERBOSE)
+ ['"quoted"', '', 'not-quoted', '', 'start', '', 'after-illegal-quote', '']
+ >>> match = base.line_re.match('TEST;ALTREP="http://www.wiz.org":value:;"')
+ >>> match.group('value')
+ 'value:;"'
+ >>> match.group('name')
+ 'TEST'
+ >>> match.group('params')
+ ';ALTREP="http://www.wiz.org"'
+ """,
+
+ "VTIMEZONE creation test:" :
+
+ """
+ >>> f = StringIO.StringIO(timezones)
+ >>> tzs = dateutil.tz.tzical(f)
+ >>> tzs.get("US/Pacific")
+ <tzicalvtz 'US/Pacific'>
+ >>> icalendar.TimezoneComponent(_)
+ <VTIMEZONE | <TZID{}US/Pacific>>
+ >>> pacific = _
+ >>> print pacific.serialize()
+ BEGIN:VTIMEZONE
+ TZID:US/Pacific
+ BEGIN:STANDARD
+ DTSTART:20001029T020000
+ RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+ TZNAME:PST
+ TZOFFSETFROM:-0700
+ TZOFFSETTO:-0800
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20000402T020000
+ RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+ TZNAME:PDT
+ TZOFFSETFROM:-0800
+ TZOFFSETTO:-0700
+ END:DAYLIGHT
+ END:VTIMEZONE
+ >>> (_)
+ <VTIMEZONE | <TZID{}US/Pacific>>
+ >>> santiago = icalendar.TimezoneComponent(tzs.get('Santiago'))
+ >>> ser = santiago.serialize()
+ >>> print ser
+ BEGIN:VTIMEZONE
+ TZID:Santiago
+ BEGIN:STANDARD
+ DTSTART:20000311T000000
+ RRULE:FREQ=YEARLY;BYDAY=2SA;BYMONTH=3
+ TZNAME:Pacific SA Standard Time
+ TZOFFSETFROM:-0300
+ TZOFFSETTO:-0400
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20001014T000000
+ RRULE:FREQ=YEARLY;BYDAY=2SA;BYMONTH=10
+ TZNAME:Pacific SA Daylight Time
+ TZOFFSETFROM:-0400
+ TZOFFSETTO:-0300
+ END:DAYLIGHT
+ END:VTIMEZONE
+ >>> roundtrip = dateutil.tz.tzical(StringIO.StringIO(str(ser))).get()
+ >>> for year in range(2001, 2010):
+ ... for month in (2, 9):
+ ... dt = datetime.datetime(year, month, 15, tzinfo = roundtrip)
+ ... if dt.replace(tzinfo=tzs.get('Santiago')) != dt:
+ ... print "Failed for:", dt
+ >>> fict = icalendar.TimezoneComponent(tzs.get('US/Fictitious-Eastern'))
+ >>> print fict.serialize()
+ BEGIN:VTIMEZONE
+ TZID:US/Fictitious-Eastern
+ BEGIN:STANDARD
+ DTSTART:20001029T020000
+ RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+ TZNAME:EST
+ TZOFFSETFROM:-0400
+ TZOFFSETTO:-0500
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20000402T020000
+ RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=20050403T070000Z
+ TZNAME:EDT
+ TZOFFSETFROM:-0500
+ TZOFFSETTO:-0400
+ END:DAYLIGHT
+ END:VTIMEZONE
+ """,
+
+ "Create iCalendar from scratch" :
+
+ """
+ >>> cal = base.newFromBehavior('vcalendar', '2.0')
+ >>> cal.add('vevent')
+ <VEVENT| []>
+ >>> cal.vevent.add('dtstart').value = datetime.datetime(2006, 5, 9)
+ >>> cal.vevent.add('description').value = "Test event"
+ >>> pacific = dateutil.tz.tzical(StringIO.StringIO(timezones)).get('US/Pacific')
+ >>> cal.vevent.add('created').value = datetime.datetime(2006, 1, 1, 10, tzinfo=pacific)
+ >>> cal.vevent.add('uid').value = "Not very random UID"
+ >>> print cal.serialize()
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:-//PYVOBJECT//NONSGML Version 1//EN
+ BEGIN:VEVENT
+ UID:Not very random UID
+ DTSTART:20060509T000000
+ CREATED:20060101T180000Z
+ DESCRIPTION:Test event
+ END:VEVENT
+ END:VCALENDAR
+ """,
+
+ "Serializing with timezones test" :
+
+ """
+ >>> from dateutil.rrule import rrule, rruleset, WEEKLY
+ >>> pacific = dateutil.tz.tzical(StringIO.StringIO(timezones)).get('US/Pacific')
+ >>> cal = base.Component('VCALENDAR')
+ >>> cal.setBehavior(icalendar.VCalendar2_0)
+ >>> ev = cal.add('vevent')
+ >>> ev.add('dtstart').value = datetime.datetime(2005, 10, 12, 9, tzinfo = pacific)
+ >>> set = rruleset()
+ >>> set.rrule(rrule(WEEKLY, interval=2, byweekday=[2,4], until=datetime.datetime(2005, 12, 15, 9)))
+ >>> set.exdate(datetime.datetime(2005, 10, 14, 9, tzinfo = pacific))
+ >>> ev.rruleset = set
+ >>> ev.add('uid').value = "uid could be generated but doctest complains"
+ >>> ev.add('duration').value = datetime.timedelta(hours=1)
+ >>> print cal.serialize()
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:-//PYVOBJECT//NONSGML Version 1//EN
+ BEGIN:VTIMEZONE
+ TZID:US/Pacific
+ BEGIN:STANDARD
+ DTSTART:20001029T020000
+ RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+ TZNAME:PST
+ TZOFFSETFROM:-0700
+ TZOFFSETTO:-0800
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20000402T020000
+ RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+ TZNAME:PDT
+ TZOFFSETFROM:-0800
+ TZOFFSETTO:-0700
+ END:DAYLIGHT
+ END:VTIMEZONE
+ BEGIN:VEVENT
+ UID:uid could be generated but doctest complains
+ DTSTART;TZID=US/Pacific:20051012T090000
+ DURATION:PT1H
+ EXDATE;TZID=US/Pacific:20051014T090000
+ RRULE:FREQ=WEEKLY;BYDAY=WE,FR;INTERVAL=2;UNTIL=20051215T090000
+ END:VEVENT
+ END:VCALENDAR
+ >>> apple = dateutil.tz.tzical(StringIO.StringIO(timezones)).get('America/Montreal')
+ >>> ev.dtstart.value = datetime.datetime(2005, 10, 12, 9, tzinfo = apple)
+ >>> print cal.serialize()
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:-//PYVOBJECT//NONSGML Version 1//EN
+ BEGIN:VTIMEZONE
+ TZID:US/Pacific
+ BEGIN:STANDARD
+ DTSTART:20001029T020000
+ RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+ TZNAME:PST
+ TZOFFSETFROM:-0700
+ TZOFFSETTO:-0800
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20000402T020000
+ RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
+ TZNAME:PDT
+ TZOFFSETFROM:-0800
+ TZOFFSETTO:-0700
+ END:DAYLIGHT
+ END:VTIMEZONE
+ BEGIN:VTIMEZONE
+ TZID:America/Montreal
+ BEGIN:STANDARD
+ DTSTART:20000101T000000
+ RRULE:FREQ=YEARLY;BYMONTH=1;UNTIL=20040101T050000Z
+ TZNAME:EST
+ TZOFFSETFROM:-0500
+ TZOFFSETTO:-0500
+ END:STANDARD
+ BEGIN:STANDARD
+ DTSTART:20051030T020000
+ RRULE:FREQ=YEARLY;BYDAY=5SU;BYMONTH=10
+ TZNAME:EST
+ TZOFFSETFROM:-0400
+ TZOFFSETTO:-0500
+ END:STANDARD
+ BEGIN:DAYLIGHT
+ DTSTART:20050403T070000
+ RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=20050403T120000Z
+ TZNAME:EDT
+ TZOFFSETFROM:-0500
+ TZOFFSETTO:-0400
+ END:DAYLIGHT
+ END:VTIMEZONE
+ BEGIN:VEVENT
+ UID:uid could be generated but doctest complains
+ DTSTART;TZID=America/Montreal:20051012T090000
+ DURATION:PT1H
+ EXDATE;TZID=US/Pacific:20051014T090000
+ RRULE:FREQ=WEEKLY;BYDAY=WE,FR;INTERVAL=2;UNTIL=20051215T090000
+ END:VEVENT
+ END:VCALENDAR
+ """,
+
+ "Serializing iCalendar to hCalendar" :
+
+ """
+ >>> cal = base.newFromBehavior('hcalendar')
+ >>> cal.behavior
+ <class 'hcalendar.HCalendar'>
+ >>> pacific = dateutil.tz.tzical(StringIO.StringIO(timezones)).get('US/Pacific')
+ >>> cal.add('vevent')
+ <VEVENT| []>
+ >>> cal.vevent.add('summary').value = "this is a note"
+ >>> cal.vevent.add('url').value = "http://microformats.org/code/hcalendar/creator"
+ >>> cal.vevent.add('dtstart').value = datetime.date(2006,2,27)
+ >>> cal.vevent.add('location').value = "a place"
+ >>> cal.vevent.add('dtend').value = datetime.date(2006,2,27) + datetime.timedelta(days = 2)
+ >>> event2 = cal.add('vevent')
+ >>> event2.add('summary').value = "Another one"
+ >>> event2.add('description').value = "The greatest thing ever!"
+ >>> event2.add('dtstart').value = datetime.datetime(1998, 12, 17, 16, 42, tzinfo = pacific)
+ >>> event2.add('location').value = "somewhere else"
+ >>> event2.add('dtend').value = event2.dtstart.value + datetime.timedelta(days = 6)
+ >>> hcal = cal.serialize()
+ >>> print hcal
+ <span class="vevent">
+ <a class="url" href="http://microformats.org/code/hcalendar/creator">
+ <span class="summary">this is a note</span>:
+ <abbr class="dtstart", title="20060227">Monday, February 27</abbr>
+ - <abbr class="dtend", title="20060301">Tuesday, February 28</abbr>
+ at <span class="location">a place</span>
+ </a>
+ </span>
+ <span class="vevent">
+ <span class="summary">Another one</span>:
+ <abbr class="dtstart", title="19981217T164200-0800">Thursday, December 17, 16:42</abbr>
+ - <abbr class="dtend", title="19981223T164200-0800">Wednesday, December 23, 16:42</abbr>
+ at <span class="location">somewhere else</span>
+ <div class="description">The greatest thing ever!</div>
+ </span>
+ """,
+
+ "Generate UIDs automatically test:" :
+
+ """
+ >>> cal = base.newFromBehavior('vcalendar')
+ >>> cal.add('vevent').add('dtstart').value = datetime.datetime(2006,2,2,10)
+ >>> ser = cal.serialize()
+ >>> len(cal.vevent.uid_list)
+ 1
+ """,
+
+ "VCARD 3.0 parse test:" :
+
+ r"""
+ >>> card = base.readOne(vcardtest)
+ >>> card.adr.value
+ <Address: Haight Street 512\nNovosibirsk, 80214\nGnuland>
+ >>> print card.adr.value
+ Haight Street 512
+ Novosibirsk, 80214
+ Gnuland
+ >>> card.org.value
+ u'University of Novosibirsk, Department of Octopus Parthenogenesis'
+ >>> print card.serialize()
+ BEGIN:VCARD
+ VERSION:3.0
+ ACCOUNT;TYPE=HOME:010-1234567-05
+ ADR;TYPE=HOME:;;Haight Street 512;Novosibirsk;;80214;Gnuland
+ BDAY;VALUE=date:02-10;11-05;01-01
+ FN:Daffy Duck Knudson (with Bugs Bunny and Mr. Pluto)
+ N:Knudson;Daffy Duck (with Bugs Bunny and Mr. Pluto);;;
+ NICKNAME:gnat and gnu and pluto
+ ORG:University of Novosibirsk, Department of Octopus Parthenogenesis
+ TEL;TYPE=HOME:+01-(0)2-765.43.21
+ TEL;TYPE=CELL:+01-(0)5-555.55.55
+ TEL;TYPE=HOME:+01-(0)2-876.54.32
+ END:VCARD
+ """,
+
+ "Multi-text serialization test:" :
+
+ """
+ >>> category = base.newFromBehavior('categories')
+ >>> category.value = ['Random category']
+ >>> print category.serialize().strip()
+ CATEGORIES:Random category
+ >>> category.value.append('Other category')
+ >>> print category.serialize().strip()
+ CATEGORIES:Random category,Other category
+ """,
+
+ "vCard groups test:" :
+
+ """
+ >>> card = base.readOne(vcardWithGroups)
+ >>> card.group
+ u'home'
+ >>> card.tel.group
+ u'home'
+ >>> card.group = card.tel.group = 'new'
+ >>> card.tel.serialize().strip()
+ u'new.TEL;TYPE=fax,voice,msg:+49 3581 123456'
+ >>> card.serialize().splitlines()[0]
+ u'new.BEGIN:VCARD'
+ >>> dtstart = base.newFromBehavior('dtstart')
+ >>> dtstart.group = "badgroup"
+ >>> dtstart.serialize()
+ Traceback (most recent call last):
+ ...
+ VObjectError: "<DTSTART{}> has a group, but this object doesn't support groups"
+ """,
+
+ "Lowercase components test:" :
+
+ """
+ >>> card = base.readOne(lowercaseComponentNames)
+ >>> card.version
+ <VERSION{}2.1>
+ """
+ }
diff --git a/tests/utf8_test.ics b/tests/utf8_test.ics new file mode 100644 index 0000000..fbb9ada --- /dev/null +++ b/tests/utf8_test.ics @@ -0,0 +1,39 @@ +BEGIN:VCALENDAR
+METHOD:PUBLISH
+CALSCALE:GREGORIAN
+PRODID:-//EVDB//www.evdb.com//EN
+VERSION:2.0
+X-WR-CALNAME:EVDB Event Feed
+BEGIN:VEVENT
+DTSTART:20060922T000100Z
+DTEND:20060922T050100Z
+DTSTAMP:20050914T163414Z
+SUMMARY:The title こんにちはキティ
+DESCRIPTION:hello\nHere is a description\n\n\nこんにちはキティ
+ \n\n\n\nZwei Java-schwere Entwicklerpositionen und irgendeine Art sond
+ erbar-klingende Netzsichtbarmachungöffnung\, an einer interessanten F
+ irma im Gebäude\, in dem ich angerufenen Semantic Research bearbeite.
+ 1. Zauberer Des Semantica Software Engineer 2. Älterer Semantica Sof
+ tware-Englisch-3. Graph/Semantica Netz-Visualization/Navigation Sie ei
+ ngestufte Software-Entwicklung für die Regierung. Die Firma ist stark
+ und die Projekte sind sehr kühl und schließen irgendeinen Spielraum
+ ein. Wenn ich Ihnen irgendwie mehr erkläre\, muß ich Sie töten. Ps
+ . Tat schnell -- jemand ist\, wenn es hier interviewt\, wie ich dieses
+ schreibe. Er schaut intelligent (er trägt Kleidhosen) Semantica Soft
+ ware Engineer FIRMA: Semantische Forschung\, Inc. REPORTS ZU: Vizeprä
+ sident\, Produkt-Entwicklung POSITION: San Diego (Pint Loma) WEB SITE:
+ www.semanticresearch.com email: dorie@semanticresearch.com FIRMA-HINT
+ ERGRUND Semantische Forschung ist der führende Versorger der semantis
+ cher Netzwerkanschluß gegründeten nicht linearen Wissen Darstellung
+ Werkzeuge. Die Firma stellt diese Werkzeuge zum Intel\, zur reg.\, zum
+ EDU und zu den kommerziellen Märkten zur Verfügung. BRINGEN SIE ZUS
+ AMMENFASSUNG IN POSITION Semantische Forschung\, Inc. basiert in San D
+ iego\, Ca im alten realen Weltsan Diego Haus...\, das wir den Weltbest
+ en Platz haben zum zu arbeiten. Wir suchen nach Superstarentwicklern\,
+ um uns in der fortfahrenden Entwicklung unserer Semantica Produktseri
+ e zu unterstützen.
+LOCATION:こんにちはキティ
+SEQUENCE:0
+UID:E0-001-000276068-2
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file |