commit ee533bd510079c4c14b1895bf6cd5553177d88fb Author: Martijn Faassen Date: Wed Mar 23 13:49:29 2005 +0000 Initial import of iCalendar, by Max M. diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..78a4141 --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,125 @@ +------------------------------------------------------------------------ +r314 | maxm | 2005-03-22 13:43:04 +0100 (Tue, 22 Mar 2005) | 1 line + + +------------------------------------------------------------------------ +r294 | maxm | 2005-02-25 03:22:27 +0100 (Fri, 25 Feb 2005) | 1 line + + +------------------------------------------------------------------------ +r283 | maxm | 2005-02-17 23:51:40 +0100 (Thu, 17 Feb 2005) | 1 line + + +------------------------------------------------------------------------ +r282 | maxm | 2005-02-17 23:50:51 +0100 (Thu, 17 Feb 2005) | 1 line + + +------------------------------------------------------------------------ +r274 | maxm | 2005-02-07 14:41:37 +0100 (Mon, 07 Feb 2005) | 1 line + + +------------------------------------------------------------------------ +r273 | maxm | 2005-02-07 12:05:38 +0100 (Mon, 07 Feb 2005) | 1 line + + +------------------------------------------------------------------------ +r272 | maxm | 2005-02-07 11:09:48 +0100 (Mon, 07 Feb 2005) | 1 line + +Added default value to the "decoded()" method. +------------------------------------------------------------------------ +r271 | maxm | 2005-02-07 09:31:42 +0100 (Mon, 07 Feb 2005) | 1 line + +- Fixed bug with propertyValues resetting themself when being read. +------------------------------------------------------------------------ +r270 | maxm | 2005-02-07 09:21:40 +0100 (Mon, 07 Feb 2005) | 1 line + +- Fixed bug with propertyValues resetting themself when being read. +------------------------------------------------------------------------ +r249 | maxm | 2005-01-27 13:44:59 +0100 (Thu, 27 Jan 2005) | 1 line + +Fixed problem with Unicode in Property values +------------------------------------------------------------------------ +r184 | maxm | 2005-01-22 13:33:08 +0100 (Sat, 22 Jan 2005) | 1 line + + +------------------------------------------------------------------------ +r183 | maxm | 2005-01-22 13:32:14 +0100 (Sat, 22 Jan 2005) | 1 line + +Fixed bug when adding unicode paramter values +------------------------------------------------------------------------ +r182 | maxm | 2005-01-22 13:06:21 +0100 (Sat, 22 Jan 2005) | 1 line + +added smal unicode test +------------------------------------------------------------------------ +r181 | maxm | 2005-01-21 16:18:15 +0100 (Fri, 21 Jan 2005) | 1 line + +added more descriptive errormessage +------------------------------------------------------------------------ +r180 | maxm | 2005-01-21 16:04:32 +0100 (Fri, 21 Jan 2005) | 1 line + +- changed a few defaults +------------------------------------------------------------------------ +r159 | maxm | 2005-01-17 16:16:53 +0100 (Mon, 17 Jan 2005) | 1 line + +Parameters where not being added when parsing +------------------------------------------------------------------------ +r158 | maxm | 2005-01-17 01:31:42 +0100 (Mon, 17 Jan 2005) | 1 line + +Moved remotely +------------------------------------------------------------------------ +r157 | maxm | 2005-01-17 01:30:00 +0100 (Mon, 17 Jan 2005) | 1 line + +This is the first public release +------------------------------------------------------------------------ +r156 | maxm | 2005-01-16 23:33:46 +0100 (Sun, 16 Jan 2005) | 1 line + +Added examples +------------------------------------------------------------------------ +r155 | maxm | 2005-01-16 03:13:03 +0100 (Sun, 16 Jan 2005) | 1 line + +Getting ready for Gold! +------------------------------------------------------------------------ +r147 | maxm | 2005-01-11 23:24:52 +0100 (Tue, 11 Jan 2005) | 1 line + +Last step before the parser is complete. All subtypes can parse from ical. +------------------------------------------------------------------------ +r146 | maxm | 2005-01-10 03:38:11 +0100 (Mon, 10 Jan 2005) | 1 line + + +------------------------------------------------------------------------ +r145 | maxm | 2005-01-10 03:23:35 +0100 (Mon, 10 Jan 2005) | 1 line + +Added parsing to Property Value Data Types. So they can parse themself. +------------------------------------------------------------------------ +r144 | maxm | 2005-01-09 21:05:13 +0100 (Sun, 09 Jan 2005) | 1 line + + +------------------------------------------------------------------------ +r143 | maxm | 2005-01-09 18:15:08 +0100 (Sun, 09 Jan 2005) | 1 line + +Fixed a few parameters that needed objects with ical() method. +------------------------------------------------------------------------ +r142 | maxm | 2005-01-09 17:44:08 +0100 (Sun, 09 Jan 2005) | 1 line + +non functional +------------------------------------------------------------------------ +r141 | maxm | 2005-01-09 15:39:44 +0100 (Sun, 09 Jan 2005) | 1 line + +Changed to use ical() method instead of __str__() for representation. +------------------------------------------------------------------------ +r140 | maxm | 2005-01-09 09:52:58 +0100 (Sun, 09 Jan 2005) | 1 line + +Fixed minor import bugs +------------------------------------------------------------------------ +r139 | maxm | 2005-01-09 09:40:53 +0100 (Sun, 09 Jan 2005) | 1 line + + +------------------------------------------------------------------------ +r138 | maxm | 2005-01-09 09:28:18 +0100 (Sun, 09 Jan 2005) | 1 line + +First version. Can only generate iCalendar files. Not parse. +------------------------------------------------------------------------ +r137 | maxm | 2005-01-09 09:26:05 +0100 (Sun, 09 Jan 2005) | 1 line + +Created folder remotely +------------------------------------------------------------------------ diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..76239bf --- /dev/null +++ b/README.txt @@ -0,0 +1,91 @@ +iCalendar package for Python + +The iCalendar package is a parser/generator of iCalender files for use with +Python. It follows the RFC 2445 spec. + + +Summary + + I have often needed to parse and generate iCalendar files. Finally I got + tired of writing ad-hoc tools. + + So this is my attempt at making an iCalendar package for Python. The + inspiration has come from the email package in the standard lib, which I + think is pretty simple, yet efficient and powerfull. + + The aim is to make a package that is fully compliant to RFC 2445, well + designed, simple to use and well documented. + + Look in + "doc/example.py":http://www.mxm.dk/products/public/ical/example.py/file_view + for introductory doctests and explanations. + + All modules and classes have doctest that shows how they work, so it is all + pretty well documented. + + It can generate and parse iCalender files, and can easily be used as is. + + But it does needs a bit more polish before i will considder it finished. I + would say that it's about 95% done. + +Examples + + To open and parse a file:: + + >>> from iCalendar import Calendar, Event + >>> cal = Calendar.from_string(open('test.ics','rb').read()) + >>> cal + VCALENDAR({'VERSION': '2.0', 'METHOD': 'Request', 'PRODID': '-//My product//mxm.dk/'}) + + >>> for component in cal.walk(): + ... component.name + 'VCALENDAR' + 'VEVENT' + 'VEVENT' + + To create a calendar and write it to disc:: + + + >>> cal = Calendar() + >>> from datetime import datetime + >>> from iCalendar import UTC # timezone + >>> cal.add('prodid', '-//My calendar product//mxm.dk//') + >>> cal.add('version', '2.0') + + >>> event = Event() + >>> event.add('summary', 'Python meeting about calendaring') + >>> event.add('dtstart', datetime(2005,4,4,8,0,0,tzinfo=UTC())) + >>> event.add('dtend', datetime(2005,4,4,10,0,0,tzinfo=UTC())) + >>> event.add('dtstamp', datetime(2005,4,4,0,10,0,tzinfo=UTC())) + >>> event['uid'] = '20050115T101010/27346262376@mxm.dk' + >>> event.add('priority', 5) + + >>> cal.add_component(event) + + >>> f = open('example.ics', 'wb') + >>> f.write(cal.as_string()) + >>> f.close() + + +Note! + + This is the first public release, so it is most likely buggy in some degree. + But it is usable for production. + + It is dependent on the datetime package, so it requires Python >= 2.2 + +Feedback/contact + + If you have any comments or feedback on the module, please contact me at: + "maxm@mxm.dk":maxm@mxm.dk + + I would love to hear use cases, or get ideas for improvements. + +Download + + Get the latest version from the + "download page":http://www.mxm.dk/products/public/ical/downloads + +License + + LGPL \ No newline at end of file diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..b98e47d --- /dev/null +++ b/TODO.txt @@ -0,0 +1,5 @@ +- Automatic En- & de-coding of parameter values + + Most of the work is done allread. Just need to get it finished. Look at line + 153 in 'ContentlinesParser.py' + \ No newline at end of file diff --git a/doc/example.ics b/doc/example.ics new file mode 100644 index 0000000..079b064 --- /dev/null +++ b/doc/example.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +PRODID:-//My calendar product//mxm.dk// +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:maxm@example.com +ATTENDEE;CN=The Dude;ROLE=REQ-PARTICIPANT:MAILTO:the-dude@example.com +DTEND:20050404T100000Z +DTSTAMP:20050404T001000Z +DTSTART:20050404T080000Z +LOCATION:Odense\, Denmark +ORGANIZER;CN=Max Rasmussen;ROLE=CHAIR:MAILTO:noone@example.com +PRIORITY:5 +SUMMARY:Python meeting about calendaring +UID:20050115T101010/27346262376@mxm.dk +END:VEVENT +END:VCALENDAR diff --git a/doc/example.py b/doc/example.py new file mode 100644 index 0000000..63da920 --- /dev/null +++ b/doc/example.py @@ -0,0 +1,307 @@ +# -*- coding: latin-1 -*- +""" + +iCalendar package +================= + + +This package is used for parsing and generating iCalendar files following the +standard in RFC 2445. + +It should be fully compliant, but it is possible to generate and parse invalid +files if you really want to. + + +File structure +-------------- + +An iCalendar file is a text file (utf-8) with a special format. Basically it +consists of content lines. + +Each content line defines a property that has 3 parts (name, parameters, +values). Parameters are optional. + +A simple content line with only name and value could look like this: + + BEGIN:VCALENDAR + +A content line with parameters can look like this: + + ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:example@example.com + +And the parts are: + + Name: ATTENDEE + Params: CN=Max Rasmussen;ROLE=REQ-PARTICIPANT + Value: MAILTO:example@example.com + +Long content lines are usually "folded" to less than 75 character, but the +package takes care of that. + + + +Overview +-------- + +On a higher level iCalendar files consists of components. Components can have +sub components. + +The root component is the VCALENDAR. + + BEGIN:VCALENDAR + ... vcalendar properties ... + END:VCALENDAR + +The most frequent subcomponent to a VCALENDAR is a VEVENT. They are nested like +this: + + BEGIN:VCALENDAR + ... vcalendar properties ... + BEGIN:VEVENT + ... vevent properties ... + END:VEVENT + END:VCALENDAR + +Inside the components there are properties with values. The values have special +types. like integer, text, datetime etc. These values are encoded in a special +text format in an iCalendar file. + +There are methods for converting to and from these encodings in the package. + + +These are the most important imports. + + >>> from iCalendar import Calendar, Event + +Components +---------- + +Components are like (Case Insensitive) dicts. So if you want to set a property +you do it like this. The calendar is a component. + + >>> cal = Calendar() + >>> cal['dtstart'] = '20050404T080000' + >>> cal['summary'] = 'Python meeting about calendaring' + >>> for k,v in cal.items(): + ... k,v + ('DTSTART', '20050404T080000') + ('SUMMARY', 'Python meeting about calendaring') + +You can generate a string for a file with the as_string() method. (Calling +str(cal) does the same): + + >>> cal.as_string() + 'BEGIN:VCALENDAR\\r\\nDTSTART:20050404T080000\\r\\nSUMMARY:Python meeting about calendaring\\r\\nEND:VCALENDAR\\r\\n' + + >>> str(cal) + 'BEGIN:VCALENDAR\\r\\nDTSTART:20050404T080000\\r\\nSUMMARY:Python meeting about calendaring\\r\\nEND:VCALENDAR\\r\\n' + +in the calendar examples below the as_string() is implied. The rendered view is +easier to read: + +BEGIN:VCALENDAR +DTSTART:20050404T080000 +SUMMARY:Python meeting about calendaring +END:VCALENDAR + +You can set multiple properties like this: + + >>> cal = Calendar() + >>> cal['attendee'] = ['MAILTO:maxm@mxm.dk','MAILTO:test@example.com'] + +BEGIN:VCALENDAR +ATTENDEE:MAILTO:maxm@mxm.dk +ATTENDEE:MAILTO:test@example.com +END:VCALENDAR + + +if you don't want to care about whether a property value is a list or a single +value, just use the add() method. It will automatically convert the property to +a list of values if more than one value is added. + + >>> cal = Calendar() + >>> cal.add('attendee', 'MAILTO:maxm@mxm.dk') + >>> cal.add('attendee', 'MAILTO:test@example.com') + +BEGIN:VCALENDAR +ATTENDEE:MAILTO:maxm@mxm.dk +ATTENDEE:MAILTO:test@example.com +END:VCALENDAR + + +Note: this version doesn't check for compliance, so you should look in the RFC +2445 spec for legal properties for each component, or look in the iCalendar.py +file, where it is at least defined for each component. + +Subcomponents +------------- + +Any component can have subcomponents. Eg. inside a calendar there can be events. +They can be arbitrarily nested. First by making a new component: + + >>> event = Event() + >>> event['uid'] = '42' + >>> event['dtstart'] = '20050404T080000' + +And then appending it to a "parent". + + >>> cal.add_component(event) + +BEGIN:VCALENDAR +ATTENDEE:MAILTO:maxm@mxm.dk +ATTENDEE:MAILTO:test@example.com +BEGIN:VEVENT +DTSTART:20050404T080000 +UID:42 +END:VEVENT +END:VCALENDAR + +Subcomponents are appended to the subcomponents property on the component. + + >>> cal.subcomponents + [VEVENT({'DTSTART': '20050404T080000', 'UID': '42'})] + +Value types +----------- + +Property values are utf-8 encoded strings. + +This is impractical if you want to use the data for further computation. Eg. the +datetime format looks like this: '20050404T080000'. But the package makes it +simple to Parse and generate iCalendar formatted strings. + +Basically you can make the add() method do the thinking, or you can do it +yourself. + +To add a datetime value, you can use Pythons built in datetime types, and the +set the encode parameter to true, and it will convert to the type defined in the +spec. + + >>> from datetime import datetime + >>> cal.add('dtstart', datetime(2005,4,4,8,0,0)) + >>> str(cal['dtstart']) + '20050404T080000' + +If that doesn't work satisfactorily for some reason, you can also do it +manually. + +In 'PropertyValues.py', all the iCalendar data types are defined. Each type has +a class that can parse and encode the type. + +So if you want to do it manually ... + + >>> from iCalendar import vDatetime + >>> now = datetime(2005,4,4,8,0,0) + >>> vDatetime(now).ical() + '20050404T080000' + +So the drill is to initialise the object with a python built in type, and then +call the "ical()" method on the object. That will return an ical encoded string. + +You can do it the other way around too. To parse an encoded string, just call +the "from_ical()" method, and it will return an instance of the corresponding +Python type. + + >>> vDatetime.from_ical('20050404T080000') + datetime.datetime(2005, 4, 4, 8, 0) + + >>> dt = vDatetime.from_ical('20050404T080000Z') + >>> repr(dt)[:85] + repr(dt)[85+8:] + 'datetime.datetime(2005, 4, 4, 8, 0, tzinfo=)' + + +You can also choose to use the decoded() method, which will return a decoded +value directly. + + >>> cal = Calendar() + >>> cal.add('dtstart', datetime(2005,4,4,8,0,0)) + >>> str(cal['dtstart']) + '20050404T080000' + >>> cal.decoded('dtstart') + datetime.datetime(2005, 4, 4, 8, 0) + + +Example +------- + +Here is an example generating a complete iCal calendar file with a single event +that can be loaded into the Mozilla calendar + + Init the calendar + >>> cal = Calendar() + >>> from datetime import datetime + >>> from iCalendar import UTC # timezone + + Som properties are required to be complient + >>> cal.add('prodid', '-//My calendar product//mxm.dk//') + >>> cal.add('version', '2.0') + + We need at least one subcomponent for a calendar to be complient + >>> event = Event() + >>> event.add('summary', 'Python meeting about calendaring') + >>> event.add('dtstart', datetime(2005,4,4,8,0,0,tzinfo=UTC())) + >>> event.add('dtend', datetime(2005,4,4,10,0,0,tzinfo=UTC())) + >>> event.add('dtstamp', datetime(2005,4,4,0,10,0,tzinfo=UTC())) + + + A property with parameters. Notice that they are an attribute on the value. + >>> from iCalendar import vCalAddress, vText + >>> organizer = vCalAddress('MAILTO:noone@example.com') + + Automatic encoding is not yet implemented for parameter values. + So you must use the 'v*' types defined in PropertyValues.py + >>> organizer.params['cn'] = vText('Max Rasmussen') + >>> organizer.params['role'] = vText('CHAIR') + >>> event['organizer'] = organizer + >>> event['location'] = vText('Odense, Denmark') + + >>> event['uid'] = '20050115T101010/27346262376@mxm.dk' + >>> event.add('priority', 5) + + >>> attendee = vCalAddress('MAILTO:maxm@example.com') + >>> attendee.params['cn'] = vText('Max Rasmussen') + >>> attendee.params['ROLE'] = vText('REQ-PARTICIPANT') + >>> event.add('attendee', attendee, encode=0) + + >>> attendee = vCalAddress('MAILTO:the-dude@example.com') + >>> attendee.params['cn'] = vText('The Dude') + >>> attendee.params['ROLE'] = vText('REQ-PARTICIPANT') + >>> event.add('attendee', attendee, encode=0) + + Add the event to the calendar + >>> cal.add_component(event) + + Write to disc + >>> f = open('example.ics', 'wb') + >>> f.write(cal.as_string()) + >>> f.close() + +And that generates this file. + + BEGIN:VCALENDAR + PRODID:-//My calendar product//mxm.dk// + VERSION:2.0 + BEGIN:VEVENT + ATTENDEE:MAILTO:maxm@example.com + ATTENDEE:MAILTO:the-dude@example.com + DTEND:20050404T100000Z + DTSTAMP:20050404T001000Z + DTSTART:20050404T080000Z + LOCATION:Odense, Denmark + ORGANIZER;CN=Max Rasmussen;ROLE=CHAIR:MAILTO:noone@example.com + PRIORITY:5 + SUMMARY:Python meeting about calendaring + UID:20050115T101010/27346262376@mxm.dk + END:VEVENT + END:VCALENDAR + +-- + + Enjoy, Max M, maxm@mxm.dk + +""" + +if __name__ == "__main__": + import os.path, doctest, example + # import and test this file + doctest.testmod(example) diff --git a/doc/groupscheduled.ics b/doc/groupscheduled.ics new file mode 100644 index 0000000..f62d3dd --- /dev/null +++ b/doc/groupscheduled.ics @@ -0,0 +1,36 @@ +BEGIN:VCALENDAR +PRODID:-//RDU Software//NONSGML HandCal//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:US-Eastern +BEGIN:STANDARD +DTSTART:19981025T020000 +RDATE:19981025T020000 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19990404T020000 +RDATE:19990404T020000 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:19980309T231000Z +UID:guid-1.host1.com +ORGANIZER;ROLE=CHAIR:MAILTO:mrbig@host.com +ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP: +MAILTO:employee-A@host.com +DESCRIPTION:Project XYZ Review Meeting +CATEGORIES:MEETING +CLASS:PUBLIC +CREATED:19980309T130000Z +SUMMARY:XYZ Project Review +DTSTART;TZID=US-Eastern:19980312T083000 +DTEND;TZID=US-Eastern:19980312T093000 +LOCATION:1CP Conference Room 4350 +END:VEVENT +END:VCALENDAR diff --git a/doc/groupscheduled.py b/doc/groupscheduled.py new file mode 100644 index 0000000..801bbda --- /dev/null +++ b/doc/groupscheduled.py @@ -0,0 +1,28 @@ +# -*- coding: latin-1 -*- +""" + +An example from the RFC 2445 spec. + + >>> from iCalendar import Calendar + >>> cal = Calendar.from_string(open('groupscheduled.ics','rb').read()) + >>> cal + VCALENDAR({'VERSION': '2.0', 'PRODID': '-//RDU Software//NONSGML HandCal//EN'}) + + >>> timezones = cal.walk('VTIMEZONE') + >>> len(timezones) + 1 + + >>> tz = timezones[0] + >>> tz + VTIMEZONE({'TZID': 'US-Eastern'}) + + >>> std = tz.walk('STANDARD')[0] + >>> std.decoded('TZOFFSETFROM') + datetime.timedelta(-1, 72000) + +""" + +if __name__ == "__main__": + import os.path, doctest, groupscheduled + # import and test this file + doctest.testmod(groupscheduled) diff --git a/doc/test.ics b/doc/test.ics new file mode 100644 index 0000000..0efd14f --- /dev/null +++ b/doc/test.ics @@ -0,0 +1,25 @@ +BEGIN:VCALENDAR +METHOD:Request +PRODID:-//My product//mxm.dk/ +VERSION:2.0 +BEGIN:VEVENT +DESCRIPTION:This is a very long description that will be folded This is a + very long description that will be folded This is a very long description + that will be folded This is a very long description that will be folded Th + is is a very long description that will be folded This is a very long desc + ription that will be folded This is a very long description that will be f + olded This is a very long description that will be folded This is a very l + ong description that will be folded This is a very long description that w + ill be folded +PARTICIPANT;CN=Max M:MAILTO:maxm@mxm.dk +DTEND:20050107T160000 +DTSTART:20050107T120000 +SUMMARY:A second event +END:VEVENT +BEGIN:VEVENT +DTEND:20050108T235900 +DTSTART:20050108T230000 +SUMMARY:A single event +UID:42 +END:VEVENT +END:VCALENDAR diff --git a/doc/test.py b/doc/test.py new file mode 100644 index 0000000..3eefa79 --- /dev/null +++ b/doc/test.py @@ -0,0 +1,35 @@ +# -*- coding: latin-1 -*- +""" + +A small example + + >>> from iCalendar import Calendar + >>> cal = Calendar.from_string(open('test.ics','rb').read()) + >>> cal + VCALENDAR({'VERSION': '2.0', 'METHOD': 'Request', 'PRODID': '-//My product//mxm.dk/'}) + + >>> for component in cal.walk(): + ... component.name + 'VCALENDAR' + 'VEVENT' + 'VEVENT' + + >>> cal['prodid'] + '-//My product//mxm.dk/' + + >>> cal.decoded('prodid') + u'-//My product//mxm.dk/' + + >>> first_event = cal.walk('vevent')[0] + >>> first_event['description'][:75] + 'This is a very long description that will be folded This is a very long des' + + >>> first_event['summary'] + 'A second event' + +""" + +if __name__ == "__main__": + import os.path, doctest, test + # import and test this file + doctest.testmod(test) diff --git a/example.ics b/example.ics new file mode 100644 index 0000000..079b064 --- /dev/null +++ b/example.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +PRODID:-//My calendar product//mxm.dk// +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:maxm@example.com +ATTENDEE;CN=The Dude;ROLE=REQ-PARTICIPANT:MAILTO:the-dude@example.com +DTEND:20050404T100000Z +DTSTAMP:20050404T001000Z +DTSTART:20050404T080000Z +LOCATION:Odense\, Denmark +ORGANIZER;CN=Max Rasmussen;ROLE=CHAIR:MAILTO:noone@example.com +PRIORITY:5 +SUMMARY:Python meeting about calendaring +UID:20050115T101010/27346262376@mxm.dk +END:VEVENT +END:VCALENDAR diff --git a/src/iCalendar/CaselessDict.py b/src/iCalendar/CaselessDict.py new file mode 100644 index 0000000..0665948 --- /dev/null +++ b/src/iCalendar/CaselessDict.py @@ -0,0 +1,102 @@ +# -*- coding: latin-1 -*- + +class CaselessDict(dict): + + """ + A dictionary that isn't case sensitive, and only use string as keys. + + >>> ncd = CaselessDict(key1='val1', key2='val2') + >>> ncd + CaselessDict({'KEY2': 'val2', 'KEY1': 'val1'}) + >>> ncd['key1'] + 'val1' + >>> ncd['KEY1'] + 'val1' + >>> ncd['KEY3'] = 'val3' + >>> ncd['key3'] + 'val3' + >>> ncd.setdefault('key3', 'FOUND') + 'val3' + >>> ncd.setdefault('key4', 'NOT FOUND') + 'NOT FOUND' + >>> ncd['key4'] + 'NOT FOUND' + >>> ncd.get('key1') + 'val1' + >>> ncd.get('key3', 'NOT FOUND') + 'val3' + >>> ncd.get('key4', 'NOT FOUND') + 'NOT FOUND' + >>> 'key4' in ncd + True + >>> del ncd['key4'] + >>> ncd.has_key('key4') + False + >>> ncd.update({'key5':'val5', 'KEY6':'val6', 'KEY5':'val7'}) + >>> ncd['key6'] + 'val6' + >>> keys = ncd.keys() + >>> keys.sort() + >>> keys + ['KEY1', 'KEY2', 'KEY3', 'KEY5', 'KEY6'] + """ + + def __init__(self, *args, **kwargs): + "Set keys to upper for initial dict" + dict.__init__(self, *args, **kwargs) + for k,v in self.items(): + k_upper = k.upper() + if k != k_upper: + dict.__delitem__(self, k) + self[k_upper] = v + + def __getitem__(self, key): + return dict.__getitem__(self, key.upper()) + + def __setitem__(self, key, value): + dict.__setitem__(self, key.upper(), value) + + def __delitem__(self, key): + dict.__delitem__(self, key.upper()) + + def __contains__(self, item): + return dict.__contains__(self, item.upper()) + + def get(self, key, default=None): + return dict.get(self, key.upper(), default) + + def setdefault(self, key, value=None): + return dict.setdefault(self, key.upper(), value) + + def pop(self, key, default=None): + return dict.pop(self, key.upper(), default) + + def popitem(self): + return dict.popitem(self, key.upper()) + + def has_key(self, key): + return dict.has_key(self, key.upper()) + + def update(self, indict): + """ + Multiple keys where key1.upper() == key2.upper() will be lost. + """ + for entry in indict: + self[entry] = indict[entry] + + def copy(self): + return CaselessDict(dict.copy(self)) + + def clear(self): + dict.clear(self) + + def __repr__(self): + return 'CaselessDict(' + dict.__repr__(self) + ')' + + +if __name__ == "__main__": + import os.path, doctest, CaselessDict + # import and test this file + doctest.testmod(CaselessDict) + + diff --git a/src/iCalendar/ContentlinesParser.py b/src/iCalendar/ContentlinesParser.py new file mode 100644 index 0000000..8caf179 --- /dev/null +++ b/src/iCalendar/ContentlinesParser.py @@ -0,0 +1,415 @@ +# -*- coding: latin-1 -*- + +""" +This module parses and generates contentlines as defined in RFC 2445 +(iCalendar), but will probably work for other MIME types with similar syntax. +Eg. RFC 2426 (vCard) + +It is stupid in the sense that it treats the content purely as strings. No type +conversion is attempted. + +Copyright, 2005: Max M +License: GPL (Just contact med if and why you would like it changed) +""" + +# from python +from types import TupleType, ListType +SequenceTypes = [TupleType, ListType] +import re +# from this package +from CaselessDict import CaselessDict + + +################################################################# +# Property parameter stuff + +def paramVal(val): + "Returns a parameter value" + if type(val) in SequenceTypes: + return q_join(val) + return dQuote(val) + +QUOTABLE = re.compile('[,;:].') +def dQuote(val): + """ + Parameter values containing [,;:] must be double quoted + >>> dQuote('Max') + 'Max' + >>> dQuote('Rasmussen, Max') + '"Rasmussen, Max"' + >>> dQuote('name:value') + '"name:value"' + """ + if QUOTABLE.search(val): + return '"%s"' % val + return val + +# parsing helper +def q_split(st, sep=','): + """ + Splits a string on char, taking double (q)uotes into considderation + >>> q_split('Max,Moller,"Rasmussen, Max"') + ['Max', 'Moller', '"Rasmussen, Max"'] + """ + result = [] + cursor = 0 + length = len(st) + inquote = 0 + for i in range(length): + ch = st[i] + if ch == '"': + inquote = not inquote + if not inquote and ch == sep: + result.append(st[cursor:i]) + cursor = i + 1 + if i + 1 == length: + result.append(st[cursor:]) + return result + +def q_join(lst, sep=','): + """ + Joins a list on sep, quoting strings with QUOTABLE chars + >>> s = ['Max', 'Moller', 'Rasmussen, Max'] + >>> q_join(s) + 'Max,Moller,"Rasmussen, Max"' + """ + return sep.join([dQuote(itm) for itm in lst]) + +class Parameters(CaselessDict): + + """ + Parser and generator of Property parameter strings. It knows nothing of + datatypes. It's main concern is textual structure. + + + Simple parameter:value pair + >>> p = Parameters(parameter1='Value1') + >>> str(p) + 'PARAMETER1=Value1' + + + keys are converted to upper + >>> p.keys() + ['PARAMETER1'] + + + Parameters are case insensitive + >>> p['parameter1'] + 'Value1' + >>> p['PARAMETER1'] + 'Value1' + + + Parameter with list of values must be seperated by comma + >>> p = Parameters({'parameter1':['Value1', 'Value2']}) + >>> str(p) + 'PARAMETER1=Value1,Value2' + + + Multiple parameters must be seperated by a semicolon + >>> p = Parameters({'RSVP':'TRUE', 'ROLE':'REQ-PARTICIPANT'}) + >>> str(p) + 'ROLE=REQ-PARTICIPANT;RSVP=TRUE' + + + Parameter values containing ',;:' must be double quoted + >>> p = Parameters({'ALTREP':'http://www.wiz.org'}) + >>> str(p) + 'ALTREP="http://www.wiz.org"' + + + list items must be quoted seperately + >>> p = Parameters({'MEMBER':['MAILTO:projectA@host.com', 'MAILTO:projectB@host.com', ]}) + >>> str(p) + 'MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"' + + Now the whole sheebang + >>> p = Parameters({'parameter1':'Value1', 'parameter2':['Value2', 'Value3'],\ + 'ALTREP':['http://www.wiz.org', 'value4']}) + >>> str(p) + 'ALTREP="http://www.wiz.org",value4;PARAMETER1=Value1;PARAMETER2=Value2,Value3' + + We can also parse parameter strings + >>> Parameters.from_string('PARAMETER1=Value 1;param2=Value 2') + Parameters({'PARAMETER1': 'Value 1', 'PARAM2': 'Value 2'}) + + We can also parse parameter strings + >>> Parameters.from_string('MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"') + Parameters({'MEMBER': ['MAILTO:projectA@host.com', 'MAILTO:projectB@host.com']}) + + We can also parse parameter strings + >>> Parameters.from_string('ALTREP="http://www.wiz.org",value4;PARAMETER1=Value1;PARAMETER2=Value2,Value3') + Parameters({'PARAMETER1': 'Value1', 'ALTREP': ['http://www.wiz.org', 'value4'], 'PARAMETER2': ['Value2', 'Value3']}) + """ + + + def params(self): + """ + in rfc2445 keys are called parameters, so this is to be consitent with + the naming conventions + """ + return self.keys() + +### Later, when I get more time... need to finish this off now. The last majot thing missing. +### def _encode(self, name, value, cond=1): +### # internal, for conditional convertion of values. +### if cond: +### klass = types_factory.for_property(name) +### return klass(value) +### return value +### +### def add(self, name, value, encode=0): +### "Add a parameter value and optionally encode it." +### if encode: +### value = self._encode(name, value, encode) +### self[name] = value +### +### def decoded(self, name): +### "returns a decoded value, or list of same" + + def __repr__(self): + return 'Parameters(' + dict.__repr__(self) + ')' + + + def __str__(self): + result = [] + items = self.items() + items.sort() # To make doctests work + for key, value in items: + value = paramVal(value) + result.append('%s=%s' % (key.upper(), value)) + return ';'.join(result) + + + def from_string(st): + "Parses the parameter format from ical text format" + try: + # parse into strings + result = Parameters() + for param in q_split(st, ';'): + key, val = q_split(param, '=') + # parsed and " stripped, but just strings + vals = [v.strip('"') for v in q_split(val, ',')] + if len(vals) == 1: + result[key] = vals[0] + else: + result[key] = vals + return result + except: + raise ValueError, 'Not a valid parameter string' + from_string = staticmethod(from_string) + + +######################################### +# parsing and generation of content lines + +class Contentline(str): + + """ + A content line is basically a string that can be folded and parsed into + parts. + + >>> c = Contentline('Si meliora dies, ut vina, poemata reddit') + >>> str(c) + 'Si meliora dies, ut vina, poemata reddit' + + A long line gets folded + >>> c = Contentline(''.join(['123456789 ']*10)) + >>> str(c) + '123456789 123456789 123456789 123456789 123456789 123456789 123456789 1234\\r\\n 56789 123456789 123456789 ' + + A folded line gets unfolded + >>> c = Contentline.from_string(str(c)) + >>> c + '123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 ' + + It can parse itself into parts. Which is a tuple of (name, params, vals) + + >>> c = Contentline('dtstart:20050101T120000') + >>> c.parts() + ('dtstart', Parameters({}), '20050101T120000') + + >>> c = Contentline('dtstart;value=datetime:20050101T120000') + >>> c.parts() + ('dtstart', Parameters({'VALUE': 'datetime'}), '20050101T120000') + + >>> c = Contentline('ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:maxm@example.com') + >>> c.parts() + ('ATTENDEE', Parameters({'ROLE': 'REQ-PARTICIPANT', 'CN': 'Max Rasmussen'}), 'MAILTO:maxm@example.com') + >>> str(c) + 'ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:maxm@example.com' + + and back again + >>> parts = ('ATTENDEE', Parameters({'ROLE': 'REQ-PARTICIPANT', 'CN': 'Max Rasmussen'}), 'MAILTO:maxm@example.com') + >>> Contentline.from_parts(parts) + 'ATTENDEE;CN=Max Rasmussen;ROLE=REQ-PARTICIPANT:MAILTO:maxm@example.com' + + and again + >>> parts = ('ATTENDEE', Parameters(), 'MAILTO:maxm@example.com') + >>> Contentline.from_parts(parts) + 'ATTENDEE:MAILTO:maxm@example.com' + + A value can also be any of the types defined in PropertyValues + >>> from PropertyValues import vText + >>> parts = ('ATTENDEE', Parameters(), vText('MAILTO:test@example.com')) + >>> Contentline.from_parts(parts) + 'ATTENDEE:MAILTO:test@example.com' + + A value can also be unicode + >>> from PropertyValues import vText + >>> parts = ('SUMMARY', Parameters(), vText(u'INternational char æ ø å')) + >>> Contentline.from_parts(parts) + 'SUMMARY:INternational char \\xc3\\xa6 \\xc3\\xb8 \\xc3\\xa5' + + Traversing could look like this. + >>> name, params, vals = c.parts() + >>> name + 'ATTENDEE' + >>> vals + 'MAILTO:maxm@example.com' + >>> for key, val in params.items(): + ... (key, val) + ('ROLE', 'REQ-PARTICIPANT') + ('CN', 'Max Rasmussen') + + And the traditional failure + >>> c = Contentline('ATTENDEE;maxm@example.com') + >>> c.parts() + Traceback (most recent call last): + ... + ValueError: Content line could not be parsed into parts + + """ + + def from_parts(parts): + "Turns a tuple of parts into a content line" + (name, params, values) = [str(p) for p in parts] + try: + if params: + return Contentline('%s;%s:%s' % (name, params, values)) + return Contentline('%s:%s' % (name, values)) + except: + raise ValueError, 'Property: %s Wrong values "%s" or "%s"' % (repr(name), + repr(params), + repr(values)) + from_parts = staticmethod(from_parts) + + def parts(self): + """ Splits the content line up into (name, parameters, values) parts + """ + try: + name_split = None + value_split = None + inquotes = 0 + for i in range(len(self)): + ch = self[i] + if not inquotes: + if ch in ':;' and not name_split: + name_split = i + if ch == ':' and not value_split: + value_split = i + if ch == '"': + inquotes = not inquotes + name = self[:name_split] + params = Parameters.from_string(self[name_split+1:value_split]) + values = self[value_split+1:] + return (name, params, values) + except: + raise ValueError, 'Content line could not be parsed into parts' + + def from_string(st): + "Unfolds the content lines in an iCalendar into long content lines" + try: + # a fold is carriage return followed by either a space or a tab + a_fold = re.compile('\r\n[ \t]{1}') + return Contentline(a_fold.sub('', st)) + except: + raise ValueError, 'Expected StringType with content line' + from_string = staticmethod(from_string) + + def __str__(self): + "Long content lines are folded so they are less than 75 characters wide" + l_line = len(self) + new_lines = [] + for i in range(0, l_line, 74): + new_lines.append(self[i:i+74]) + return '\r\n '.join(new_lines) + + + +class Contentlines(list): + + """ + I assume that iCalendar files generally are a few kilobytes in size. Then + this should be efficient. for Huge files, an iterator should probably be + used instead. + + >>> c = Contentlines([Contentline('BEGIN:VEVENT\\r\\n')]) + >>> str(c) + 'BEGIN:VEVENT\\r\\n' + + Lets try appending it with a 100 charater wide string + >>> c.append(Contentline(''.join(['123456789 ']*10)+'\\r\\n')) + >>> str(c) + 'BEGIN:VEVENT\\r\\n\\r\\n123456789 123456789 123456789 123456789 123456789 123456789 123456789 1234\\r\\n 56789 123456789 123456789 \\r\\n' + + Notice that there is an extra empty string in the end of the content lines. + That is so they can be easily joined with: '\r\n'.join(contentlines)). + >>> Contentlines.from_string('A short line\\r\\n') + ['A short line', ''] + >>> Contentlines.from_string('A faked\\r\\n long line\\r\\n') + ['A faked long line', ''] + >>> Contentlines.from_string('A faked\\r\\n long line\\r\\nAnd another lin\\r\\n\\te that is folded\\r\\n') + ['A faked long line', 'And another line that is folded', ''] + """ + + def __str__(self): + "Simply join self." + return '\r\n'.join(map(str, self)) + + def from_string(st): + "Parses a string into content lines" + try: + # a fold is carriage return followed by either a space or a tab + a_fold = re.compile('\r\n[ \t]{1}') + unfolded = a_fold.sub('', st) + lines = [Contentline(line) for line in unfolded.split('\r\n') if line] + lines.append('') # we need a '\r\n' in the end of every content line + return Contentlines(lines) + except: + raise ValueError, 'Expected StringType with content lines' + from_string = staticmethod(from_string) + + + +if __name__ == "__main__": + import os.path, doctest, ContentlinesParser + # import and test this file + doctest.testmod(ContentlinesParser) + + +# ran this: +# sample = open('./samples/test.ics', 'rb').read() # binary file in windows! +# lines = Contentlines.from_string(sample) +# for line in lines[:-1]: +# print line.parts() + +# got this: +#('BEGIN', Parameters({}), 'VCALENDAR') +#('METHOD', Parameters({}), 'Request') +#('PRODID', Parameters({}), '-//My product//mxm.dk/') +#('VERSION', Parameters({}), '2.0') +#('BEGIN', Parameters({}), 'VEVENT') +#('DESCRIPTION', Parameters({}), 'This is a very long description that ...') +#('PARTICIPANT', Parameters({'CN': 'Max M'}), 'MAILTO:maxm@mxm.dk') +#('DTEND', Parameters({}), '20050107T160000') +#('DTSTART', Parameters({}), '20050107T120000') +#('SUMMARY', Parameters({}), 'A second event') +#('END', Parameters({}), 'VEVENT') +#('BEGIN', Parameters({}), 'VEVENT') +#('DTEND', Parameters({}), '20050108T235900') +#('DTSTART', Parameters({}), '20050108T230000') +#('SUMMARY', Parameters({}), 'A single event') +#('UID', Parameters({}), '42') +#('END', Parameters({}), 'VEVENT') +#('END', Parameters({}), 'VCALENDAR') \ No newline at end of file diff --git a/src/iCalendar/PropertyValues.py b/src/iCalendar/PropertyValues.py new file mode 100644 index 0000000..3eda82b --- /dev/null +++ b/src/iCalendar/PropertyValues.py @@ -0,0 +1,1403 @@ +# -*- coding: latin-1 -*- + +""" + +This module contains the parser/generators (or coders/encoders if you prefer) +for the classes/datatypes that are used in Icalendar: + +########################################################################### +# This module defines these property value data types and property parameters + +4.2 Defined property parameters are: + + ALTREP, CN, CUTYPE, DELEGATED-FROM, DELEGATED-TO, DIR, ENCODING, FMTTYPE, + FBTYPE, LANGUAGE, MEMBER, PARTSTAT, RANGE, RELATED, RELTYPE, ROLE, RSVP, + SENT-BY, TZID, VALUE + +4.3 Defined value data types are: + + BINARY, BOOLEAN, CAL-ADDRESS, DATE, DATE-TIME, DURATION, FLOAT, INTEGER, + PERIOD, RECUR, TEXT, TIME, URI, UTC-OFFSET + +########################################################################### + + +iCalendar properties has values. The values are strongly typed. This module +defines these types, calling val.ical() on them, Will render them as defined in +rfc2445. + +If you pass any of these classes a Python primitive, you will have an object +that can render itself as iCalendar formatted date. + +Property Value Data Types starts with a 'v'. they all have an ical() and +from_ical() method. The ical() method generates a text string in the iCalendar +format. The from_ical() method can parse this format and return a primitive +Python datatype. So it should allways be true that: + + x == vDataType.from_ical(VDataType(x).ical()) + +These types are mainly used for parsing and file generation. But you can set +them directly. + +""" + +# from python > 2.3 +from datetime import datetime, timedelta, time, date, tzinfo +from types import IntType, StringType, UnicodeType, TupleType, ListType +SequenceTypes = [TupleType, ListType] +import re +import time as _time + +# from this package +from CaselessDict import CaselessDict +from ContentlinesParser import Parameters + + + +class vBinary: + """ + Binary property values are base 64 encoded + >>> b = vBinary('This is gibberish') + >>> b.ical() + 'VGhpcyBpcyBnaWJiZXJpc2g=' + >>> b = vBinary.from_ical('VGhpcyBpcyBnaWJiZXJpc2g=') + >>> b + 'This is gibberish' + + The roundtrip test + >>> x = 'Binary data æ ø å \x13 \x56' + >>> vBinary(x).ical() + 'QmluYXJ5IGRhdGEg5iD4IOUgEyBW' + >>> vBinary.from_ical('QmluYXJ5IGRhdGEg5iD4IOUgEyBW') + 'Binary data \\xe6 \\xf8 \\xe5 \\x13 V' + + >>> b = vBinary('txt') + >>> b.params + Parameters({'VALUE': 'BINARY', 'ENCODING': 'BASE64'}) + """ + + def __init__(self, obj): + self.obj = obj + self.params = Parameters(encoding='BASE64', value="BINARY") + + def __repr__(self): + return "vCalAddress(%s)" % str.__repr__(self.obj) + + def ical(self): + return self.obj.encode('base-64')[:-1] + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return ical.decode('base-64') + except: + raise ValueError, 'Not valid base 64 encoding.' + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vBoolean(int): + """ + Returns specific string according to state + >>> bin = vBoolean(True) + >>> bin.ical() + 'TRUE' + >>> bin = vBoolean(0) + >>> bin.ical() + 'FALSE' + + The roundtrip test + >>> x = True + >>> x == vBoolean.from_ical(vBoolean(x).ical()) + True + >>> vBoolean.from_ical('true') + True + """ + + def __init__(self, *args, **kwargs): + int.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + if self: + return 'TRUE' + return 'FALSE' + + bool_map = CaselessDict(true=True, false=False) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return vBoolean.bool_map[ical] + except: + raise ValueError, "Expected 'TRUE' or 'FALSE'. Got %s" % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vCalAddress(str): + """ + This just returns an unquoted string + >>> a = vCalAddress('MAILTO:maxm@mxm.dk') + >>> a.params['cn'] = 'Max M' + >>> a.ical() + 'MAILTO:maxm@mxm.dk' + >>> str(a) + 'MAILTO:maxm@mxm.dk' + >>> a.params + Parameters({'CN': 'Max M'}) + >>> vCalAddress.from_ical('MAILTO:maxm@mxm.dk') + 'MAILTO:maxm@mxm.dk' + """ + + def __init__(self, *args, **kwargs): + str.__init__(self, *args, **kwargs) + self.params = Parameters() + + def __repr__(self): + return u"vCalAddress(%s)" % str.__repr__(self) + + def ical(self): + return str(self) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return str(ical) + except: + raise ValueError, 'Expected vCalAddress, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return str.__str__(self) + +#################################################### +# handy tzinfo classes you can use. + +ZERO = timedelta(0) +HOUR = timedelta(hours=1) +STDOFFSET = timedelta(seconds = -_time.timezone) +if _time.daylight: + DSTOFFSET = timedelta(seconds = -_time.altzone) +else: + DSTOFFSET = STDOFFSET +DSTDIFF = DSTOFFSET - STDOFFSET + + +class FixedOffset(tzinfo): + + """Fixed offset in minutes east from UTC.""" + + def __init__(self, offset, name): + self.__offset = timedelta(minutes = offset) + self.__name = name + + def utcoffset(self, dt): + return self.__offset + + def tzname(self, dt): + return self.__name + + def dst(self, dt): + return ZERO + + +class UTC(tzinfo): + """UTC tzinfo subclass""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +class LocalTimezone(tzinfo): + + """ + Timezone of the machine where the code is running + """ + + def utcoffset(self, dt): + if self._isdst(dt): + return DSTOFFSET + else: + return STDOFFSET + + def dst(self, dt): + if self._isdst(dt): + return DSTDIFF + else: + return ZERO + + def tzname(self, dt): + return _time.tzname[self._isdst(dt)] + + def _isdst(self, dt): + tt = (dt.year, dt.month, dt.day, + dt.hour, dt.minute, dt.second, + dt.weekday(), 0, -1) + stamp = _time.mktime(tt) + tt = _time.localtime(stamp) + return tt.tm_isdst > 0 + +#################################################### + + + +class vDatetime: + """ + Render and generates iCalendar datetime format. + + Important: if tzinfo is defined it renders itself as "date with utc time" + Meaning that it has a 'Z' appended, and is in absolute time. + + >>> d = datetime(2001, 1,1, 12, 30, 0) + + >>> dt = vDatetime(d) + >>> dt.ical() + '20010101T123000' + + >>> vDatetime.from_ical('20000101T120000') + datetime.datetime(2000, 1, 1, 12, 0) + + >>> dutc = datetime(2001, 1,1, 12, 30, 0, tzinfo=UTC()) + >>> vDatetime(dutc).ical() + '20010101T123000Z' + + >>> vDatetime.from_ical('20010101T000000') + datetime.datetime(2001, 1, 1, 0, 0) + + >>> utc = vDatetime.from_ical('20010101T000000Z') + >>> vDatetime(utc).ical() + '20010101T000000Z' + """ + + def __init__(self, dt): + self.dt = dt + self.params = Parameters() + + def ical(self): + if self.dt.tzinfo: + offset = self.dt.tzinfo.utcoffset(datetime.now()) + utc_time = self.dt - self.dt.tzinfo.utcoffset(datetime.now()) + return utc_time.strftime("%Y%m%dT%H%M%SZ") + return self.dt.strftime("%Y%m%dT%H%M%S") + + def from_ical(ical): + "Parses the data format from ical text format" + try: + timetuple = map(int, (( + ical[:4], # year + ical[4:6], # month + ical[6:8], # day + ical[9:11], # hour + ical[11:13], # minute + ical[13:15], # second + ))) + if ical[-1] == 'Z': + timetuple += [0, UTC()] + return datetime(*timetuple) + else: + return datetime(*timetuple) + except: + raise ValueError, 'Wrong datetime format %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vDate: + """ + Render and generates iCalendar date format. + >>> d = date(2001, 1,1) + >>> vDate(d).ical() + '20010101' + + >>> vDate.from_ical('20010102') + datetime.date(2001, 1, 2) + + >>> vDate('d').ical() + Traceback (most recent call last): + ... + ValueError: Value MUST be a date instance + """ + + def __init__(self, dt): + if not isinstance(dt, date): + raise ValueError('Value MUST be a date instance') + self.dt = dt + self.params = Parameters() + + def ical(self): + return self.dt.strftime("%Y%m%d") + + def from_ical(ical): + "Parses the data format from ical text format" + try: + timetuple = map(int, (( + ical[:4], # year + ical[4:6], # month + ical[6:8], # day + ))) + return date(*timetuple) + except: + raise ValueError, 'Wrong date format %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vDuration: + """ + Subclass of timedelta that renders itself in the iCalendar DURATION format. + + >>> vDuration(timedelta(11)).ical() + 'P11D' + >>> vDuration(timedelta(-14)).ical() + '-P14D' + >>> vDuration(timedelta(1, 7384)).ical() + 'P1DT2H3M4S' + >>> vDuration(timedelta(1, 7380)).ical() + 'P1DT2H3M' + >>> vDuration(timedelta(1, 7200)).ical() + 'P1DT2H' + >>> vDuration(timedelta(0, 7200)).ical() + 'PT2H' + >>> vDuration(timedelta(0, 7384)).ical() + 'PT2H3M4S' + >>> vDuration(timedelta(0, 184)).ical() + 'PT3M4S' + >>> vDuration(timedelta(0, 22)).ical() + 'PT22S' + >>> vDuration(timedelta(0, 3622)).ical() + 'PT1H0M22S' + + How does the parsing work? + >>> vDuration.from_ical('PT1H0M22S') + datetime.timedelta(0, 3622) + + >>> vDuration.from_ical('kox') + Traceback (most recent call last): + ... + ValueError: Invalid iCalendar duration: kox + + >>> vDuration.from_ical('-P14D') + datetime.timedelta(-14) + + >>> vDuration(11) + Traceback (most recent call last): + ... + ValueError: Value MUST be a timedelta instance + """ + + def __init__(self, td): + if not isinstance(td, timedelta): + raise ValueError('Value MUST be a timedelta instance') + self.td = td + self.params = Parameters() + + def ical(self): + sign = "" + if self.td.days < 0: + sign = "-" + timepart = "" + if self.td.seconds: + timepart = "T" + hours = self.td.seconds // 3600 + minutes = self.td.seconds % 3600 // 60 + seconds = self.td.seconds % 60 + if hours: + timepart += "%dH" % hours + if minutes or (hours and seconds): + timepart += "%dM" % minutes + if seconds: + timepart += "%dS" % seconds + if self.td.days == 0 and timepart: + return "%sP%s" % (sign, timepart) + else: + return "%sP%dD%s" % (sign, abs(self.td.days), timepart) + + def from_ical(ical): + """ + Parses the data format from ical text format. + """ + try: + date_part = r'(\d+)D' + time_part = r'T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?' + datetime_part = '(?:%s)?(?:%s)?' % (date_part, time_part) + weeks_part = r'(\d+)W' + duration_rx = re.compile(r'([-+]?)P(?:%s|%s)$' + % (weeks_part, datetime_part)) + match = duration_rx.match(ical) + sign, weeks, days, hours, minutes, seconds = match.groups() + if weeks: + value = timedelta(weeks=int(weeks)) + else: + value = timedelta(days=int(days or 0), + hours=int(hours or 0), + minutes=int(minutes or 0), + seconds=int(seconds or 0)) + if sign == '-': + value = -value + return value + except: + raise ValueError, 'Invalid iCalendar duration: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vFloat(float): + """ + Just a float. + >>> f = vFloat(1.0) + >>> f.ical() + '1.0' + >>> vFloat.from_ical('42') + 42.0 + >>> vFloat(42).ical() + '42.0' + """ + + def __init__(self, *args, **kwargs): + float.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + return str(self) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return float(ical) + except: + raise ValueError, 'Expected float value, got: %s' % ical + from_ical = staticmethod(from_ical) + + + +class vInt(int): + """ + Just an int. + >>> f = vInt(42) + >>> f.ical() + '42' + >>> vInt.from_ical('13') + 13 + >>> vInt.from_ical('1s3') + Traceback (most recent call last): + ... + ValueError: Expected int, got: 1s3 + """ + + def __init__(self, *args, **kwargs): + int.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + return str(self) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return int(ical) + except: + raise ValueError, 'Expected int, got: %s' % ical + from_ical = staticmethod(from_ical) + + + +class vDDDTypes: + + """ + + A combined Datetime, Date or Duration parser/generator. Their format cannot + be confused, and often values can be of either types. So this is practical. + + >>> d = vDDDTypes.from_ical('20010101T123000') + >>> type(d) + + + >>> repr(vDDDTypes.from_ical('20010101T123000Z'))[:75] + 'datetime.datetime(2001, 1, 1, 12, 30, tzinfo=>> d = vDDDTypes.from_ical('20010101') + >>> type(d) + + + >>> vDDDTypes.from_ical('P31D') + datetime.timedelta(31) + + Bad input + >>> vDDDTypes(42) + Traceback (most recent call last): + ... + ValueError: You must use datetime, date or timedelta + """ + + def __init__(self, dt): + "Returns vDate from" + wrong_type_used = 1 + for typ in (datetime, date, timedelta): + if isinstance(dt, typ): + wrong_type_used = 0 + if wrong_type_used: + raise ValueError ('You must use datetime, date or timedelta') + self.dt = dt + + def ical(self): + dt = self.dt + if isinstance(dt, datetime): + return vDatetime(dt).ical() + elif isinstance(dt, date): + return vDate(dt).ical() + elif isinstance(dt, timedelta): + return vDuration(dt).ical() + else: + raise ValueEror ('Unknown date type') + + def from_ical(ical): + "Parses the data format from ical text format" + if ical[0].upper() == 'P': + return vDuration.from_ical(ical) + try: + return vDatetime.from_ical(ical) + except: + return vDate.from_ical(ical) + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vPeriod: + + """ + A precise period of time. + One day in exact datetimes + >>> per = (datetime(2000,1,1), datetime(2000,1,2)) + >>> p = vPeriod(per) + >>> p.ical() + '20000101T000000/20000102T000000' + + >>> per = (datetime(2000,1,1), timedelta(days=31)) + >>> p = vPeriod(per) + >>> p.ical() + '20000101T000000/P31D' + + Roundtrip + >>> p = vPeriod.from_ical('20000101T000000/20000102T000000') + >>> p + (datetime.datetime(2000, 1, 1, 0, 0), datetime.datetime(2000, 1, 2, 0, 0)) + >>> vPeriod(p).ical() + '20000101T000000/20000102T000000' + + >>> vPeriod.from_ical('20000101T000000/P31D') + (datetime.datetime(2000, 1, 1, 0, 0), datetime.timedelta(31)) + + Roundtrip with absolute time + >>> p = vPeriod.from_ical('20000101T000000Z/20000102T000000Z') + >>> vPeriod(p).ical() + '20000101T000000Z/20000102T000000Z' + + And an error + >>> vPeriod.from_ical('20000101T000000/Psd31D') + Traceback (most recent call last): + ... + ValueError: Expected period format, got: 20000101T000000/Psd31D + + Utc datetime + >>> da_tz = FixedOffset(+1.0, 'da_DK') + >>> start = datetime(2000,1,1, tzinfo=da_tz) + >>> end = datetime(2000,1,2, tzinfo=da_tz) + >>> per = (start, end) + >>> vPeriod(per).ical() + '19991231T235900Z/20000101T235900Z' + + >>> p = vPeriod((datetime(2000,1,1, tzinfo=da_tz), timedelta(days=31))) + >>> p.ical() + '19991231T235900Z/P31D' + """ + + def __init__(self, per): + start, end_or_duration = per + if not (isinstance(start, datetime) or isinstance(start, date)): + raise ValueError('Start value MUST be a datetime or date instance') + if not (isinstance(end_or_duration, datetime) or + isinstance(end_or_duration, date) or + isinstance(end_or_duration, timedelta)): + raise ValueError('end_or_duration MUST be a datetime, date or timedelta instance') + self.start = start + self.end_or_duration = end_or_duration + self.by_duration = 0 + if isinstance(end_or_duration, timedelta): + self.by_duration = 1 + self.duration = end_or_duration + self.end = self.start + self.duration + else: + self.end = end_or_duration + self.duration = self.end - self.start + if self.start > self.end: + raise ValueError("Start time is greater than end time") + self.params = Parameters() + + def __cmp__(self, other): + if not isinstance(other, vPeriod): + raise NotImplementedError('Cannot compare vPeriod with %r' % other) + return cmp((self.start, self.end), (other.start, other.end)) + + def overlaps(self, other): + if self.start > other.start: + return other.overlaps(self) + if self.start <= other.start < self.end: + return True + return False + + def ical(self): + if self.by_duration: + return '%s/%s' % (vDatetime(self.start).ical(), vDuration(self.duration).ical()) + return '%s/%s' % (vDatetime(self.start).ical(), vDatetime(self.end).ical()) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + start, end_or_duration = ical.split('/') + start = vDDDTypes.from_ical(start) + end_or_duration = vDDDTypes.from_ical(end_or_duration) + return (start, end_or_duration) + except: + raise ValueError, 'Expected period format, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vWeekday(str): + """ + This returns an unquoted weekday abbrevation + >>> a = vWeekday('mo') + >>> a.ical() + 'MO' + + >>> a = vWeekday('erwer') + Traceback (most recent call last): + ... + ValueError: Expected weekday abbrevation, got: ERWER + + >>> vWeekday.from_ical('mo') + 'MO' + + >>> vWeekday.from_ical('Saturday') + Traceback (most recent call last): + ... + ValueError: Expected weekday abbrevation, got: Saturday + + >>> a = vWeekday('+mo') + >>> a.ical() + '+MO' + + >>> a = vWeekday('-tu') + >>> a.ical() + '-TU' + """ + + week_days = CaselessDict({"SU":0, "MO":1, "TU":2, "WE":3, "TH":4, "FR":5, "SA":6}) + + def __init__(self, *args, **kwargs): + str.__init__(self, *args, **kwargs) + if len(self) == 2: + sign = '+' + weekday = self + else: + sign = self[0] + weekday = self[1:] + if not weekday in vWeekday.week_days or sign not in '+-': + raise ValueError, 'Expected weekday abbrevation, got: %s' % self + self.params = Parameters() + + def ical(self): + return self.upper() + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return vWeekday(ical.upper()) + except: + raise ValueError, 'Expected weekday abbrevation, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vFrequency(str): + """ + A simple class that catches illegal values. + >>> f = vFrequency('bad test') + Traceback (most recent call last): + ... + ValueError: Expected frequency, got: BAD TEST + >>> vFrequency('daily').ical() + 'DAILY' + >>> vFrequency('daily').from_ical('MONTHLY') + 'MONTHLY' + """ + + frequencies = CaselessDict({ + "SECONDLY":"SECONDLY", + "MINUTELY":"MINUTELY", + "HOURLY":"HOURLY", + "DAILY":"DAILY", + "WEEKLY":"WEEKLY", + "MONTHLY":"MONTHLY", + "YEARLY":"YEARLY", + }) + + def __init__(self, *args, **kwargs): + str.__init__(self, *args, **kwargs) + if not self in vFrequency.frequencies: + raise ValueError, 'Expected frequency, got: %s' % self + self.params = Parameters() + + def ical(self): + return self.upper() + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return vFrequency(ical.upper()) + except: + raise ValueError, 'Expected weekday abbrevation, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vRecur(CaselessDict): + """ + Let's see how close we can get to one from the rfc: + FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30 + + >>> r = dict(freq='yearly', interval=2) + >>> r['bymonth'] = 1 + >>> r['byday'] = 'su' + >>> r['byhour'] = [8,9] + >>> r['byminute'] = 30 + >>> r = vRecur(r) + >>> r.ical() + 'BYHOUR=8,9;BYDAY=SU;BYMINUTE=30;BYMONTH=1;FREQ=YEARLY;INTERVAL=2' + + >>> r = vRecur(FREQ='yearly', INTERVAL=2) + >>> r['BYMONTH'] = 1 + >>> r['BYDAY'] = 'su' + >>> r['BYHOUR'] = [8,9] + >>> r['BYMINUTE'] = 30 + >>> r.ical() + 'BYDAY=SU;BYMINUTE=30;BYMONTH=1;INTERVAL=2;FREQ=YEARLY;BYHOUR=8,9' + + >>> r = vRecur(freq='DAILY', count=10) + >>> r['bysecond'] = [0, 15, 30, 45] + >>> r.ical() + 'COUNT=10;FREQ=DAILY;BYSECOND=0,15,30,45' + + >>> r = vRecur(freq='DAILY', until=datetime(2005,1,1,12,0,0)) + >>> r.ical() + 'FREQ=DAILY;UNTIL=20050101T120000' + + How do we fare with regards to parsing? + >>> r = vRecur.from_ical('FREQ=DAILY;INTERVAL=2;COUNT=10') + >>> r + {'COUNT': [10], 'FREQ': ['DAILY'], 'INTERVAL': [2]} + >>> vRecur(r).ical() + 'COUNT=10;FREQ=DAILY;INTERVAL=2' + + >>> r = vRecur.from_ical('FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=-SU;BYHOUR=8,9;BYMINUTE=30') + >>> r + {'BYHOUR': [8, 9], 'BYDAY': ['-SU'], 'BYMINUTE': [30], 'BYMONTH': [1], 'FREQ': ['YEARLY'], 'INTERVAL': [2]} + >>> vRecur(r).ical() + 'BYDAY=-SU;BYMINUTE=30;INTERVAL=2;BYMONTH=1;FREQ=YEARLY;BYHOUR=8,9' + + Some examples from the spec + + >>> r = vRecur.from_ical('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1') + >>> vRecur(r).ical() + 'BYSETPOS=-1;FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR' + + >>> r = vRecur.from_ical('FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30') + >>> vRecur(r).ical() + 'BYDAY=SU;BYMINUTE=30;INTERVAL=2;BYMONTH=1;FREQ=YEARLY;BYHOUR=8,9' + + and some errors + >>> r = vRecur.from_ical('BYDAY=12') + Traceback (most recent call last): + ... + ValueError: Error in recurrence rule: BYDAY=12 + + """ + + frequencies = ["SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", + "MONTHLY", "YEARLY"] + + types = CaselessDict({ + 'COUNT':vInt, + 'INTERVAL':vInt, + 'BYSECOND':vInt, + 'BYMINUTE':vInt, + 'BYHOUR':vInt, + 'BYMONTHDAY':vInt, + 'BYYEARDAY':vInt, + 'BYMONTH':vInt, + 'UNTIL':vDDDTypes, + 'BYSETPOS':vInt, + 'WKST':vWeekday, + 'BYDAY':vWeekday, + 'FREQ':vFrequency + }) + + def __init__(self, *args, **kwargs): + CaselessDict.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + # SequenceTypes + result = [] + for key, vals in self.items(): + typ = self.types[key] + if not type(vals) in SequenceTypes: + vals = [vals] + vals = ','.join([typ(val).ical() for val in vals]) + result.append('%s=%s' % (key, vals)) + return ';'.join(result) + + def parse_type(key, values): + # integers + parser = vRecur.types.get(key, vText) + return [parser.from_ical(v) for v in values.split(',')] + parse_type = staticmethod(parse_type) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + recur = vRecur() + for pairs in ical.split(';'): + key, vals = pairs.split('=') + recur[key] = vRecur.parse_type(key, vals) + return dict(recur) + except: + raise ValueError, 'Error in recurrence rule: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vText(unicode): + """ + Simple text + >>> t = vText(u'Simple text') + >>> t.ical() + 'Simple text' + + Escaped text + >>> t = vText('Text ; with escaped, chars') + >>> t.ical() + 'Text \\\\; with escaped\\\\, chars' + + If you pass a unicode object, it will be utf-8 encoded. As this is the + (only) standard that RFC 2445 support. + + >>> t = vText(u'international chars æøå ÆØÅ ü') + >>> t.ical() + 'international chars \\xc3\\xa6\\xc3\\xb8\\xc3\\xa5 \\xc3\\x86\\xc3\\x98\\xc3\\x85 \\xc3\\xbc' + + Unicode is converted to utf-8 + >>> t = vText(u'international æ ø å') + >>> str(t) + 'international \\xc3\\xa6 \\xc3\\xb8 \\xc3\\xa5' + + and parsing? + >>> vText.from_ical('Text \\; with escaped\\, chars') + u'Text ; with escaped, chars' + """ + + encoding = 'utf-8' + + def __init__(self, *args, **kwargs): + unicode.__init__(self, *args, **kwargs) + self.params = Parameters() + + def escape(self, value): + """ + Format value according to iCalendar TEXT escaping rules. + """ + return (value.replace('\\', '\\\\') + .replace(';', r'\;') + .replace(',', r'\,') + .replace('\n', r'\n')) + + def __repr__(self): + return u"vText(%s)" % unicode.__repr__(self) + + def ical(self): + return self.escape(self).encode(self.encoding) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + ical = (ical.replace(r'\n', '\n') + .replace(r'\,', ',') + .replace(r'\;', ';') + .replace('\\\\', '\\')) + return ical.decode(vText.encoding) + except: + raise ValueError, 'Expected ical text, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vTime(time): + """ + A subclass of datetime, that renders itself in the iCalendar time + format. + >>> dt = vTime(12, 30, 0) + >>> dt.ical() + '123000' + + >>> vTime.from_ical('123000') + datetime.time(12, 30) + + We should also fail, right? + >>> vTime.from_ical('263000') + Traceback (most recent call last): + ... + ValueError: Expected time, got: 263000 + """ + + def __init__(self, *args, **kwargs): + time.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + return self.strftime("%H%M%S") + + def from_ical(ical): + "Parses the data format from ical text format" + try: + timetuple = map(int, (ical[:2],ical[2:4],ical[4:6])) + return time(*timetuple) + except: + raise ValueError, 'Expected time, got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vUri(str): + """ + Uniform resource identifier is basically just an unquoted string. + >>> u = vUri('http://www.example.com/') + >>> u.ical() + 'http://www.example.com/' + >>> vUri.from_ical('http://www.example.com/') # doh! + 'http://www.example.com/' + """ + + def __init__(self, *args, **kwargs): + str.__init__(self, *args, **kwargs) + self.params = Parameters() + + def ical(self): + return str(self) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + return str(ical) + except: + raise ValueError, 'Expected , got: %s' % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return str.__str__(self) + + + +class vGeo: + """ + A special type that is only indirectly defined in the rfc. + + >>> g = vGeo((1.2, 3.0)) + >>> g.ical() + '1.2;3.0' + + >>> g = vGeo.from_ical('37.386013;-122.082932') + >>> g + (37.386012999999998, -122.082932) + + >>> vGeo(g).ical() + '37.386013;-122.082932' + + >>> vGeo('g').ical() + Traceback (most recent call last): + ... + ValueError: Input must be (float, float) for latitude and longitude + """ + + def __init__(self, geo): + try: + latitude, longitude = geo + latitude = float(latitude) + longitude = float(longitude) + except: + raise ValueError('Input must be (float, float) for latitude and longitude') + self.latitude = latitude + self.longitude = longitude + self.params = Parameters() + + def ical(self): + return '%s;%s' % (self.latitude, self.longitude) + + def from_ical(ical): + "Parses the data format from ical text format" + try: + latitude, longitude = ical.split(';') + return (float(latitude), float(longitude)) + except: + raise ValueError, "Expected 'float;float' , got: %s" % ical + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vUTCOffset: + """ + Renders itself as a utc offset + + >>> u = vUTCOffset(timedelta(hours=2)) + >>> u.ical() + '+0200' + + >>> u = vUTCOffset(timedelta(hours=-5)) + >>> u.ical() + '-0500' + + >>> u = vUTCOffset(timedelta()) + >>> u.ical() + '0000' + + >>> u = vUTCOffset(timedelta(minutes=-30)) + >>> u.ical() + '-0030' + + >>> u = vUTCOffset(timedelta(hours=2, minutes=-30)) + >>> u.ical() + '+0130' + + >>> u = vUTCOffset(timedelta(hours=1, minutes=30)) + >>> u.ical() + '+0130' + + Parsing + + >>> vUTCOffset.from_ical('0000') + datetime.timedelta(0) + + >>> vUTCOffset.from_ical('-0030') + datetime.timedelta(-1, 84600) + + >>> vUTCOffset.from_ical('+0200') + datetime.timedelta(0, 7200) + + >>> o = vUTCOffset.from_ical('+0230') + >>> vUTCOffset(o).ical() + '+0230' + + And a few failures + >>> vUTCOffset.from_ical('+323k') + Traceback (most recent call last): + ... + ValueError: Expected utc offset, got: +323k + + >>> vUTCOffset.from_ical('+2400') + Traceback (most recent call last): + ... + ValueError: Offset must be less than 24 hours, was +2400 + """ + + def __init__(self, td): + if not isinstance(td, timedelta): + raise ValueError('Offset value MUST be a timedelta instance') + self.td = td + self.params = Parameters() + + def ical(self): + td = self.td + day_in_minutes = (td.days * 24 * 60) + seconds_in_minutes = td.seconds // 60 + total_minutes = day_in_minutes + seconds_in_minutes + if total_minutes == 0: + sign = '%s' + elif total_minutes < 0: + sign = '-%s' + else: + sign = '+%s' + hours = abs(total_minutes) // 60 + minutes = total_minutes % 60 + duration = '%02i%02i' % (hours, minutes) + return sign % duration + + def from_ical(ical): + "Parses the data format from ical text format" + try: + sign, hours, minutes = (ical[-5:-4], int(ical[-4:-2]), int(ical[-2:])) + offset = timedelta(hours=hours, minutes=minutes) + except: + raise ValueError, 'Expected utc offset, got: %s' % ical + if offset >= timedelta(hours=24): + raise ValueError, 'Offset must be less than 24 hours, was %s' % ical + if sign == '-': + return -offset + return offset + from_ical = staticmethod(from_ical) + + def __str__(self): + return self.ical() + + + +class vInline(str): + """ + This is an especially dumb class that just holds raw unparsed text and has + parameters. Conversion of inline values are handled by the Component class, + so no further processing is needed. + + >>> vInline('Some text') + 'Some text' + + >>> vInline.from_ical('Some text') + 'Some text' + + >>> t2 = vInline('other text') + >>> t2.params['cn'] = 'Test Osterone' + >>> t2.params + Parameters({'CN': 'Test Osterone'}) + + """ + + def __init__(self,obj): + self.obj = obj + self.params = Parameters() + + def ical(self): + return str(self) + + def from_ical(ical): + return str(ical) + from_ical = staticmethod(from_ical) + + def __str__(self): + return str(self.obj) + + +class TypesFactory(CaselessDict): + + """ + All Value types defined in rfc 2445 are registered in this factory class. To + get a type you can use it like this. + >>> factory = TypesFactory() + >>> datetime_parser = factory['date-time'] + >>> dt = datetime_parser(datetime(2001, 1, 1)) + >>> dt.ical() + '20010101T000000' + + A typical use is when the parser tries to find a content type and use text + as the default + >>> value = '20050101T123000' + >>> value_type = 'date-time' + >>> typ = factory.get(value_type, 'text') + >>> typ.from_ical(value) + datetime.datetime(2005, 1, 1, 12, 30) + + It can also be used to directly encode property and parameter values + >>> comment = factory.ical('comment', u'by Rasmussen, Max Møller') + >>> str(comment) + 'by Rasmussen\\\\, Max M\\xc3\\xb8ller' + >>> factory.ical('priority', 1) + '1' + >>> factory.ical('cn', u'Rasmussen, Max Møller') + 'Rasmussen\\\\, Max M\\xc3\\xb8ller' + + >>> factory.from_ical('cn', 'Rasmussen\\\\, Max M\\xc3\\xb8ller') + u'Rasmussen, Max M\\xf8ller' + + The value and parameter names don't overlap. So one factory is enough for + both kinds. + """ + + def __init__(self, *args, **kwargs): + "Set keys to upper for initial dict" + CaselessDict.__init__(self, *args, **kwargs) + self['binary'] = vBinary + self['boolean'] = vBoolean + self['cal-address'] = vCalAddress + self['date'] = vDDDTypes + self['date-time'] = vDDDTypes + self['duration'] = vDDDTypes + self['float'] = vFloat + self['integer'] = vInt + self['period'] = vPeriod + self['recur'] = vRecur + self['text'] = vText + self['time'] = vTime + self['uri'] = vUri + self['utc-offset'] = vUTCOffset + self['geo'] = vGeo + self['inline'] = vInline + + + ################################################# + # Property types + + # These are the default types + types_map = CaselessDict({ + #################################### + # Property valye types + # Calendar Properties + 'calscale' : 'text', + 'method' : 'text', + 'prodid' : 'text', + 'version' : 'text', + # Descriptive Component Properties + 'attach' : 'uri', + 'categories' : 'text', + 'class' : 'text', + 'comment' : 'text', + 'description' : 'text', + 'geo' : 'geo', + 'location' : 'text', + 'percent-complete' : 'integer', + 'priority' : 'integer', + 'resources' : 'text', + 'status' : 'text', + 'summary' : 'text', + # Date and Time Component Properties + 'completed' : 'date-time', + 'dtend' : 'date-time', + 'due' : 'date-time', + 'dtstart' : 'date-time', + 'duration' : 'duration', + 'freebusy' : 'period', + 'transp' : 'text', + # Time Zone Component Properties + 'tzid' : 'text', + 'tzname' : 'text', + 'tzoffsetfrom' : 'utc-offset', + 'tzoffsetto' : 'utc-offset', + 'tzurl' : 'uri', + # Relationship Component Properties + 'attendee' : 'cal-address', + 'contact' : 'text', + 'organizer' : 'cal-address', + 'recurrence-id' : 'date-time', + 'related-to' : 'text', + 'url' : 'uri', + 'uid' : 'text', + # Recurrence Component Properties + 'exdate' : 'date-time', + 'exrule' : 'recur', + 'rdate' : 'date-time', + 'rrule' : 'recur', + # Alarm Component Properties + 'action' : 'text', + 'repeat' : 'integer', + 'trigger' : 'duration', + # Change Management Component Properties + 'created' : 'date-time', + 'dtstamp' : 'date-time', + 'last-modified' : 'date-time', + 'sequence' : 'integer', + # Miscellaneous Component Properties + 'request-status' : 'text', + #################################### + # parameter types (luckilly there is no name overlap) + 'altrep' : 'uri', + 'cn' : 'text', + 'cutype' : 'text', + 'delegated-from' : 'cal-address', + 'delegated-to' : 'cal-address', + 'dir' : 'uri', + 'encoding' : 'text', + 'fmttype' : 'text', + 'fbtype' : 'text', + 'language' : 'text', + 'member' : 'cal-address', + 'partstat' : 'text', + 'range' : 'text', + 'related' : 'text', + 'reltype' : 'text', + 'role' : 'text', + 'rsvp' : 'boolean', + 'sent-by' : 'cal-address', + 'tzid' : 'text', + 'value' : 'text', + }) + + + def for_property(self, name): + "Returns a the default type for a property or parameter" + return self[self.types_map.get(name, 'text')] + + def ical(self, name, value): + """ + Encodes a named value from a primitive python type to an + icalendar encoded string. + """ + type_class = self.for_property(name) + return type_class(value).ical() + + def from_ical(self, name, value): + """ + Decodes a named property or parameter value from an icalendar encoded + string to a primitive python type. + """ + type_class = self.for_property(name) + decoded = type_class.from_ical(str(value)) + return decoded + + + +if __name__ == "__main__": + import os.path, doctest, PropertyValues + # import and test this file + doctest.testmod(PropertyValues) + diff --git a/src/iCalendar/__init__.py b/src/iCalendar/__init__.py new file mode 100644 index 0000000..8e50c92 --- /dev/null +++ b/src/iCalendar/__init__.py @@ -0,0 +1,16 @@ +# Components +from iCalendar import Calendar, Event, Todo, Journal, FreeBusy, Timezone, Alarm, \ + ComponentFactory + +# Property Data Value Types +from PropertyValues import vBinary, vBoolean, vCalAddress, vDatetime, vDate, \ + vDDDTypes, vDuration, vFloat, vInt, vPeriod, \ + vWeekday, vFrequency, vRecur, vText, vTime, vUri, \ + vGeo, vUTCOffset, TypesFactory + +# usefull tzinfo subclasses +from PropertyValues import FixedOffset, UTC, LocalTimezone + +# Parameters and helper methods for splitting and joining string with escaped +# chars. +from ContentlinesParser import Parameters, q_split, q_join \ No newline at end of file diff --git a/src/iCalendar/iCalendar.py b/src/iCalendar/iCalendar.py new file mode 100644 index 0000000..38a47c3 --- /dev/null +++ b/src/iCalendar/iCalendar.py @@ -0,0 +1,532 @@ +# -*- coding: latin-1 -*- + +""" + +Calendar is a dictionary like Python object that can render itself as VCAL +files according to rfc2445. + +These are the defined components. + +""" + +# from python +from types import ListType, TupleType +SequenceTypes = (ListType, TupleType) +import re + +# from this package +from CaselessDict import CaselessDict +from ContentlinesParser import Contentlines, Contentline, Parameters,\ + q_split, q_join +from PropertyValues import TypesFactory + + +###################################### +# The component factory + +class ComponentFactory(CaselessDict): + + """ + All components defined in rfc 2445 are registered in this factory class. To + get a component you can use it like this. + + >>> factory = ComponentFactory() + >>> component = factory['VEVENT'] + >>> event = component(dtstart='19700101') + >>> event.as_string() + 'BEGIN:VEVENT\\r\\nDTSTART:19700101\\r\\nEND:VEVENT\\r\\n' + + >>> factory.get('VCALENDAR', Component) + + """ + + def __init__(self, *args, **kwargs): + "Set keys to upper for initial dict" + CaselessDict.__init__(self, *args, **kwargs) + self['VEVENT'] = Event + self['VTODO'] = Todo + self['VJOURNAL'] = Journal + self['VFREEBUSY'] = FreeBusy + self['VTIMEZONE'] = Timezone + self['VALARM'] = Alarm + self['VCALENDAR'] = Calendar + + +# These Properties have multiple property values inlined in one propertyline +# seperated by comma. Use CaselessDict as simple caseless set. +INLINE = CaselessDict( + [(cat, 1) for cat in ('CATEGORIES', 'RESOURCES', 'FREEBUSY')] +) + +_marker = [] + +class Component(CaselessDict): + + """ + + Component is the base object for calendar, Event and the other components + defined in RFC 2445. normally you will not use this class directy, but + rather one of the subclasses. + + A component is like a dictionary with extra methods and attributes. + >>> c = Component() + >>> c.name = 'VCALENDAR' + + Every key defines a property. A property can consist of either a single + item. This can be set with a single value + >>> c['prodid'] = '-//max m//icalendar.mxm.dk/' + >>> c + VCALENDAR({'PRODID': '-//max m//icalendar.mxm.dk/'}) + + or with a list + >>> c['ATTENDEE'] = ['Max M', 'Rasmussen'] + + if you use the add method you don't have to considder if a value is a list + or not. + >>> c = Component() + >>> c.name = 'VEVENT' + >>> c.add('attendee', 'maxm@mxm.dk') + >>> c.add('attendee', 'test@example.dk') + >>> c + VEVENT({'ATTENDEE': [vCalAddress('maxm@mxm.dk'), vCalAddress('test@example.dk')]}) + + You can get the values back directly + >>> c.add('prodid', '-//my product//') + >>> c['prodid'] + vText(u'-//my product//') + + or decoded to a python type + >>> c.decoded('prodid') + u'-//my product//' + + With default values for non existing properties + >>> c.decoded('version', 'No Version') + 'No Version' + + The component can render itself in the RFC 2445 format. + >>> c = Component() + >>> c.name = 'VCALENDAR' + >>> c.add('attendee', 'Max M') + >>> c.as_string() + 'BEGIN:VCALENDAR\\r\\nATTENDEE:Max M\\r\\nEND:VCALENDAR\\r\\n' + + >>> from PropertyValues import vDatetime + + Components can be nested, so You can add a subcompont. Eg a calendar holds events. + >>> e = Component(summary='A brief history of time') + >>> e.name = 'VEVENT' + >>> e.add('dtend', '20000102T000000', encode=0) + >>> e.add('dtstart', '20000101T000000', encode=0) + >>> e.as_string() + 'BEGIN:VEVENT\\r\\nDTEND:20000102T000000\\r\\nDTSTART:20000101T000000\\r\\nSUMMARY:A brief history of time\\r\\nEND:VEVENT\\r\\n' + + >>> c.add_component(e) + >>> c.subcomponents + [VEVENT({'DTEND': '20000102T000000', 'DTSTART': '20000101T000000', 'SUMMARY': 'A brief history of time'})] + + We can walk over nested componentes with the walk method. + >>> [i.name for i in c.walk()] + ['VCALENDAR', 'VEVENT'] + + We can also just walk over specific component types, by filtering them on + their name. + >>> [i.name for i in c.walk('VEVENT')] + ['VEVENT'] + + >>> [i['dtstart'] for i in c.walk('VEVENT')] + ['20000101T000000'] + + INLINE properties have their values on one property line. Note the double + quoting of the value with a colon in it. + >>> c = Calendar() + >>> c['resources'] = 'Chair, Table, "Room: 42"' + >>> c + VCALENDAR({'RESOURCES': 'Chair, Table, "Room: 42"'}) + + >>> c.as_string() + 'BEGIN:VCALENDAR\\r\\nRESOURCES:Chair, Table, "Room: 42"\\r\\nEND:VCALENDAR\\r\\n' + + The inline values must be handled by the get_inline() and set_inline() + methods. + + >>> c.get_inline('resources', decode=0) + ['Chair', 'Table', 'Room: 42'] + + These can also be decoded + >>> c.get_inline('resources', decode=1) + [u'Chair', u'Table', u'Room: 42'] + + You can set them directly + >>> c.set_inline('resources', ['A', 'List', 'of', 'some, recources'], encode=1) + >>> c['resources'] + 'A,List,of,"some, recources"' + + and back again + >>> c.get_inline('resources', decode=0) + ['A', 'List', 'of', 'some, recources'] + + >>> c['freebusy'] = '19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z' + >>> c.get_inline('freebusy', decode=0) + ['19970308T160000Z/PT3H', '19970308T200000Z/PT1H', '19970308T230000Z/19970309T000000Z'] + + >>> freebusy = c.get_inline('freebusy', decode=1) + >>> type(freebusy[0][0]), type(freebusy[0][1]) + (, ) + """ + + name = '' # must be defined in each component + required = () # These properties are required + singletons = () # These properties must only appear once + multiple = () # may occur more than once + exclusive = () # These properties are mutually exclusive + inclusive = () # if any occurs the other(s) MUST occur ('duration', 'repeat') + + def __init__(self, *args, **kwargs): + "Set keys to upper for initial dict" + CaselessDict.__init__(self, *args, **kwargs) + # set parameters here for properties that use non-default values + self.subcomponents = [] # Components can be nested. + + +# def non_complience(self, warnings=0): +# """ +# not implemented yet! +# Returns a dict describing non compliant properties, if any. +# If warnings is true it also returns warnings. +# +# If the parser is too strict it might prevent parsing erroneous but +# otherwise compliant properties. So the parser is pretty lax, but it is +# possible to test for non-complience by calling this method. +# """ +# nc = {} +# if not getattr(self, 'name', ''): +# nc['name'] = {'type':'ERROR', 'description':'Name is not defined'} +# return nc + + + ############################# + # handling of property values + + def _encode(self, name, value, cond=1): + # internal, for conditional convertion of values. + if cond: + klass = types_factory.for_property(name) + return klass(value) + return value + + + def set(self, name, value, encode=1): + if type(value) == ListType: + self[name] = [self._encode(name, v, encode) for v in value] + else: + self[name] = self._encode(name, value, encode) + + + def add(self, name, value, encode=1): + "If property exists append, else create and set it" + if name in self: + oldval = self[name] + value = self._encode(name, value, encode) + if type(oldval) == ListType: + oldval.append(value) + else: + self.set(name, [oldval, value], encode=0) + else: + self.set(name, value, encode) + + + def _decode(self, name, value): + # internal for decoding property values + decoded = types_factory.from_ical(name, value) + return decoded + + + def decoded(self, name, default=_marker): + "Returns decoded value of property" + if name in self: + value = self[name] + if type(value) == ListType: + return [self._decode(name, v) for v in value] + return self._decode(name, value) + else: + if default is _marker: + raise KeyError, name + else: + return default + + + ######################################################################## + # Inline values. A few properties have multiple values inlined in in one + # property line. These methods are used for splitting and joining these. + + def get_inline(self, name, decode=1): + """ + Returns a list of values (split on comma). + """ + vals = [v.strip('" ') for v in q_split(self[name])] + if decode: + return [self._decode(name, val) for val in vals] + return vals + + + def set_inline(self, name, values, encode=1): + """ + Converts a list of values into comma seperated string and sets value to + that. + """ + if encode: + values = [self._encode(name, value, 1) for value in values] + self[name] = types_factory['inline'](q_join(values)) + + + ######################### + # Handling of components + + def add_component(self, component): + "add a subcomponent to this component" + self.subcomponents.append(component) + + + def _walk(self, name): + # private! + result = [] + if name is None or self.name == name: + result.append(self) + for subcomponent in self.subcomponents: + result += subcomponent._walk(name) + return result + + + def walk(self, name=None): + """ + Recursively traverses component and subcomponents. Returns sequence of + same. If name is passed, only components with name will be returned. + """ + if not name is None: + name = name.upper() + return self._walk(name) + + ##################### + # Generation + + def property_items(self): + """ + Returns properties in this component and subcomponents as: + [(name, value), ...] + """ + vText = types_factory['text'] + properties = [('BEGIN', vText(self.name).ical())] + property_names = self.keys() + property_names.sort() + for name in property_names: + values = self[name] + if type(values) == ListType: + # normally one property is one line + for value in values: + properties.append((name, value)) + else: + properties.append((name, values)) + # recursion is fun! + for subcomponent in self.subcomponents: + properties += subcomponent.property_items() + properties.append(('END', vText(self.name).ical())) + return properties + + + def from_string(st): + """ + Populates the component recursively from a string + """ + stack = [] # a stack of components + for line in Contentlines.from_string(st): # raw parsing + if line: # last content line is empty string + name, params, vals = line.parts() + uname = name.upper() + # check for start of component + if uname == 'BEGIN': + # try and create one of the components defined in the spec, + # otherwise get a general Components for robustness. + component_name = vals.upper() + component_class = component_factory.get(component_name, Component) + component = component_class() + if not getattr(component, 'name', ''): # for undefined components + component.name = component_name + stack.append(component) + # check for end of event + elif uname == 'END': + # we are done adding properties to this component + # so pop it from the stack and add it to the new top. + component = stack.pop() + if not stack: # we are at the end + return component + else: + stack[-1].add_component(component) + # we are adding properties to the current top of the stack + else: + vals = types_factory['inline'](vals) + vals.params = params + stack[-1].add(name, vals, encode=0) + from_string = staticmethod(from_string) + + + def __repr__(self): + return '%s(' % self.name + dict.__repr__(self) + ')' + +# def content_line(self, name): +# "Returns property as content line" +# value = self[name] +# params = getattr(value, 'params', Parameters()) +# return Contentline.from_parts((name, params, value)) + + def content_lines(self): + "Converts the Component and subcomponents into content lines" + contentlines = Contentlines() + for name, values in self.property_items(): + params = getattr(values, 'params', Parameters()) + contentlines.append(Contentline.from_parts((name, params, values))) + contentlines.append('') # remember the empty string in the end + return contentlines + + + def as_string(self): + return str(self.content_lines()) + + + def __str__(self): + "Returns rendered iCalendar" + return self.as_string() + + + +####################################### +# components defined in RFC 2445 + + +class Event(Component): + + name = 'VEVENT' + + required = ('UID',) + singletons = ( + 'CLASS', 'CREATED', 'DESCRIPTION', 'DTSTART', 'GEO', + 'LAST-MOD', 'LOCATION', 'ORGANIZER', 'PRIORITY', 'DTSTAMP', 'SEQUENCE', + 'STATUS', 'SUMMARY', 'TRANSP', 'URL', 'RECURID', 'DTEND', 'DURATION', + 'DTSTART', + ) + exclusive = ('DTEND', 'DURATION', ) + multiple = ( + 'ATTACH', 'ATTENDEE', 'CATEGORIES', 'COMMENT','CONTACT', 'EXDATE', + 'EXRULE', 'RSTATUS', 'RELATED', 'RESOURCES', 'RDATE', 'RRULE' + ) + + + +class Todo(Component): + + name = 'VTODO' + + required = ('UID',) + singletons = ( + 'CLASS', 'COMPLETED', 'CREATED', 'DESCRIPTION', 'DTSTAMP', 'DTSTART', + 'GEO', 'LAST-MOD', 'LOCATION', 'ORGANIZER', 'PERCENT', 'PRIORITY', + 'RECURID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'UID', 'URL', 'DUE', 'DURATION', + ) + exclusive = ('DUE', 'DURATION',) + multiple = ( + 'ATTACH', 'ATTENDEE', 'CATEGORIES', 'COMMENT', 'CONTACT', 'EXDATE', + 'EXRULE', 'RSTATUS', 'RELATED', 'RESOURCES', 'RDATE', 'RRULE' + ) + + + +class Journal(Component): + + name = 'VJOURNAL' + + required = ('UID',) + singletons = ( + 'CLASS', 'CREATED', 'DESCRIPTION', 'DTSTART', 'DTSTAMP', 'LAST-MOD', + 'ORGANIZER', 'RECURID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'UID', 'URL', + ) + multiple = ( + 'ATTACH', 'ATTENDEE', 'CATEGORIES', 'COMMENT', 'CONTACT', 'EXDATE', + 'EXRULE', 'RELATED', 'RDATE', 'RRULE', 'RSTATUS', + ) + + +class FreeBusy(Component): + + name = 'VFREEBUSY' + + required = ('UID',) + singletons = ( + 'CONTACT', 'DTSTART', 'DTEND', 'DURATION', 'DTSTAMP', 'ORGANIZER', + 'UID', 'URL', + ) + multiple = ('ATTENDEE', 'COMMENT', 'FREEBUSY', 'RSTATUS',) + + +class Timezone(Component): + + name = 'VTIMEZONE' + + required = ( + 'TZID', 'STANDARDC', 'DAYLIGHTC', 'DTSTART', 'TZOFFSETTO', + 'TZOFFSETFROM' + ) + singletons = ('LAST-MOD', 'TZURL', 'TZID',) + multiple = ('COMMENT', 'RDATE', 'RRULE', 'TZNAME',) + + +class Alarm(Component): + + name = 'VALARM' + # not quite shure about these ... + required = ('ACTION', 'TRIGGER',) + singletons = ('ATTACH', 'ACTION', 'TRIGGER', 'DURATION', 'REPEAT',) + inclusive = (('DURATION', 'REPEAT',),) + multiple = ('STANDARDC', 'DAYLIGHTC') + + +class Calendar(Component): + + """ + This is the base object for an iCalendar file. + + Setting up a minimal calendar component looks like this + >>> cal = Calendar() + + Som properties are required to be compliant + >>> cal['prodid'] = '-//My calendar product//mxm.dk//' + >>> cal['version'] = '2.0' + + We also need at least one subcomponent for a calendar to be compliant + >>> from datetime import datetime + >>> event = Event() + >>> event['summary'] = 'Python meeting about calendaring' + >>> event['uid'] = '42' + >>> event.set('dtstart', datetime(2005,4,4,8,0,0)) + >>> cal.add_component(event) + >>> cal.subcomponents[0].as_string() + 'BEGIN:VEVENT\\r\\nDTSTART:20050404T080000\\r\\nSUMMARY:Python meeting about calendaring\\r\\nUID:42\\r\\nEND:VEVENT\\r\\n' + + Write to disc + >>> open('test.ics', 'wb').write(cal.as_string()) + """ + + name = 'VCALENDAR' + required = ('prodid', 'version', ) + singletons = ('prodid', 'version', ) + multiple = ('calscale', 'method', ) + + +# These are read only singleton, so one instance is enough for the module +types_factory = TypesFactory() +component_factory = ComponentFactory() + + +if __name__ == "__main__": + + import os.path, doctest, iCalendar + # import and test this file + doctest.testmod(iCalendar) diff --git a/src/iCalendar/test.ics b/src/iCalendar/test.ics new file mode 100644 index 0000000..6767a69 --- /dev/null +++ b/src/iCalendar/test.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +PRODID:-//My calendar product//mxm.dk// +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20050404T080000 +SUMMARY:Python meeting about calendaring +UID:42 +END:VEVENT +END:VCALENDAR diff --git a/src/iCalendar/tests/__init__.py b/src/iCalendar/tests/__init__.py new file mode 100644 index 0000000..a003759 --- /dev/null +++ b/src/iCalendar/tests/__init__.py @@ -0,0 +1 @@ +# this is a package diff --git a/src/iCalendar/tests/test_icalendar.py b/src/iCalendar/tests/test_icalendar.py new file mode 100644 index 0000000..467604f --- /dev/null +++ b/src/iCalendar/tests/test_icalendar.py @@ -0,0 +1,14 @@ +import unittest, doctest +from iCalendar import iCalendar, CaselessDict, ContentlinesParser +from iCalendar import PropertyValues, tools + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(CaselessDict)) + suite.addTest(doctest.DocTestSuite(ContentlinesParser)) + suite.addTest(doctest.DocTestSuite(PropertyValues)) + suite.addTest(doctest.DocTestSuite(iCalendar)) + + # only has a disabled doctest + # suite.addTest(doctest.DocTestSuite(tools)) + return suite diff --git a/src/iCalendar/tools.py b/src/iCalendar/tools.py new file mode 100644 index 0000000..c67be57 --- /dev/null +++ b/src/iCalendar/tools.py @@ -0,0 +1,53 @@ +from string import ascii_letters, digits +import random + +""" +This module contains non-essential tools for iCalendar. Pretty thin so far eh? + +""" + +class UIDGenerator: + + """ + If you are too lazy to create real uid's. Notice, this doctest is disabled! + + Automatic semi-random uid + >> g = UIDGenerator() + >> uid = g.uid() + >> uid.ical() + '20050109T153222-7ekDDHKcw46QlwZK@example.com' + + You Should at least insert your own hostname to be more complient + >> g = UIDGenerator() + >> uid = g.uid('Example.ORG') + >> uid.ical() + '20050109T153549-NbUItOPDjQj8Ux6q@Example.ORG' + + You can also insert a path or similar + >> g = UIDGenerator() + >> uid = g.uid('Example.ORG', '/path/to/content') + >> uid.ical() + '20050109T153415-/path/to/content@Example.ORG' + """ + + chars = list(ascii_letters + digits) + + def rnd_string(self, length=16): + "Generates a string with random characters of length" + return ''.join([random.choice(self.chars) for i in range(length)]) + + def uid(self, host_name='example.com', unique=''): + """ + Generates a unique id consisting of: + datetime-uniquevalue@host. Like: + 20050105T225746Z-HKtJMqUgdO0jDUwm@example.com + """ + from PropertyValues import vText, vDatetime + unique = unique or self.rnd_string() + return vText('%s-%s@%s' % (vDatetime.today().ical(), unique, host_name)) + + +if __name__ == "__main__": + import os.path, doctest, tools + # import and test this file + doctest.testmod(tools) diff --git a/test.ics b/test.ics new file mode 100644 index 0000000..6767a69 --- /dev/null +++ b/test.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +PRODID:-//My calendar product//mxm.dk// +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20050404T080000 +SUMMARY:Python meeting about calendaring +UID:42 +END:VEVENT +END:VCALENDAR diff --git a/test.py b/test.py new file mode 100755 index 0000000..6479973 --- /dev/null +++ b/test.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python2.3 +# +# SchoolTool - common information systems platform for school administration +# Copyright (c) 2003 Shuttleworth Foundation +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +""" +SchoolTool test runner. + +Syntax: test.py [options] [pathname-regexp [test-regexp]] + +There are two kinds of tests: + - unit tests (or programmer tests) test the internal workings of various + components of the system + - functional tests (acceptance tests, customer tests) test only externaly + visible system behaviour + +You can choose to run unit tests (this is the default mode), functional tests +(by giving a -f option to test.py) or both (by giving both -u and -f options). + +Test cases are located in the directory tree starting at the location of this +script, in subdirectories named 'tests' for unit tests and 'ftests' for +functional tests, in Python modules named 'test*.py'. They are then filtered +according to pathname and test regexes. Alternatively, packages may just have +'tests.py' and 'ftests.py' instead of subpackages 'tests' and 'ftests' +respectively. + +A leading "!" in a regexp is stripped and negates the regexp. Pathname +regexp is applied to the whole path (package/package/module.py). Test regexp +is applied to a full test id (package.package.module.class.test_method). + +Options: + -h, --help print this help message + -v verbose (print dots for each test run) + -vv very verbose (print test names) + -q quiet (do not print anything on success) + -w enable warnings about omitted test cases + -d invoke pdb when an exception occurs + -p show progress bar (can be combined with -v or -vv) + -u select unit tests (default) + -f select functional tests + --level n select only tests at level n or lower + --all-levels select all tests + --list-files list all selected test files + --list-tests list all selected test cases + --list-hooks list all loaded test hooks + --coverage create code coverage reports + --search-in dir limit directory tree walk to dir (optimisation) + --immediate-errors show errors as soon as they happen (default) + --delayed-errors show errors after all unit tests were run +""" +# +# This script borrows ideas from Zope 3's test runner heavily. It is smaller +# and cleaner though, at the expense of more limited functionality. +# + +import re +import os +import sys +import time +import types +import getopt +import unittest +import traceback +import linecache +import pdb +from sets import Set + +__metaclass__ = type + + +class Options: + """Configurable properties of the test runner.""" + + # test location + basedir = '' # base directory for tests (defaults to + # basedir of argv[0] + 'src'), must be absolute + search_in = () # list of subdirs to traverse (defaults to + # basedir) + follow_symlinks = True # should symlinks to subdirectories be + # followed? (hardcoded, may cause loops) + + # which tests to run + unit_tests = False # unit tests (default if both are false) + functional_tests = False # functional tests + + # test filtering + level = 1 # run only tests at this or lower level + # (if None, runs all tests) + pathname_regex = '' # regexp for filtering filenames + test_regex = '' # regexp for filtering test cases + + # actions to take + list_files = False # --list-files + list_tests = False # --list-tests + list_hooks = False # --list-hooks + run_tests = True # run tests (disabled by --list-foo) + postmortem = False # invoke pdb when an exception occurs + + # output verbosity + verbosity = 0 # verbosity level (-v) + quiet = 0 # do not print anything on success (-q) + warn_omitted = False # produce warnings when a test case is + # not included in a test suite (-w) + print_import_time = True # print time taken to import test modules + # (currently hardcoded) + progress = False # show running progress (-p) + coverage = False # produce coverage reports (--coverage) + coverdir = 'coverage' # where to put them (currently hardcoded) + immediate_errors = True # show tracebacks twice (--immediate-errors, + # --delayed-errors) + screen_width = 80 # screen width (autodetected) + + +def compile_matcher(regex): + """Returns a function that takes one argument and returns True or False. + + Regex is a regular expression. Empty regex matches everything. There + is one expression: if the regex starts with "!", the meaning of it is + reversed. + """ + if not regex: + return lambda x: True + elif regex == '!': + return lambda x: False + elif regex.startswith('!'): + rx = re.compile(regex[1:]) + return lambda x: rx.search(x) is None + else: + rx = re.compile(regex) + return lambda x: rx.search(x) is not None + + +def walk_with_symlinks(top, func, arg): + """Like os.path.walk, but follows symlinks on POSIX systems. + + If the symlinks create a loop, this function will never finish. + """ + try: + names = os.listdir(top) + except os.error: + return + func(arg, top, names) + exceptions = ('.', '..') + for name in names: + if name not in exceptions: + name = os.path.join(top, name) + if os.path.isdir(name): + walk_with_symlinks(name, func, arg) + + +def get_test_files(cfg): + """Returns a list of test module filenames.""" + matcher = compile_matcher(cfg.pathname_regex) + results = [] + test_names = [] + if cfg.unit_tests: + test_names.append('tests') + if cfg.functional_tests: + test_names.append('ftests') + baselen = len(cfg.basedir) + 1 + def visit(ignored, dir, files): + # Ignore files starting with a dot. + # Do not not descend into subdirs containing with a dot. + remove = [] + for idx, file in enumerate(files): + if file.startswith('.'): + remove.append(idx) + elif '.' in file and os.path.isdir(os.path.join(dir, file)): + remove.append(idx) + remove.reverse() + for idx in remove: + del files[idx] + # Look for tests.py and/or ftests.py + if os.path.basename(dir) not in test_names: + for name in test_names: + if name + '.py' in files: + path = os.path.join(dir, name + '.py') + if matcher(path[baselen:]): + results.append(path) + return + if '__init__.py' not in files: + print >> sys.stderr, "%s is not a package" % dir + return + for file in files: + if file.startswith('test') and file.endswith('.py'): + path = os.path.join(dir, file) + if matcher(path[baselen:]): + results.append(path) + if cfg.follow_symlinks: + walker = walk_with_symlinks + else: + walker = os.path.walk + for dir in cfg.search_in: + walker(dir, visit, None) + results.sort() + return results + + +def import_module(filename, cfg, tracer=None): + """Imports and returns a module.""" + filename = os.path.splitext(filename)[0] + modname = filename[len(cfg.basedir):].replace(os.path.sep, '.') + if modname.startswith('.'): + modname = modname[1:] + if tracer is not None: + mod = tracer.runfunc(__import__, modname) + else: + mod = __import__(modname) + components = modname.split('.') + for comp in components[1:]: + mod = getattr(mod, comp) + return mod + + +def filter_testsuite(suite, matcher, level=None): + """Returns a flattened list of test cases that match the given matcher.""" + if not isinstance(suite, unittest.TestSuite): + raise TypeError('not a TestSuite', suite) + results = [] + for test in suite._tests: + if level is not None and getattr(test, 'level', 0) > level: + continue + if isinstance(test, unittest.TestCase): + testname = test.id() # package.module.class.method + if matcher(testname): + results.append(test) + else: + filtered = filter_testsuite(test, matcher, level) + results.extend(filtered) + return results + + +def get_all_test_cases(module): + """Returns a list of all test case classes defined in a given module.""" + results = [] + for name in dir(module): + if not name.startswith('Test'): + continue + item = getattr(module, name) + if (isinstance(item, (type, types.ClassType)) and + issubclass(item, unittest.TestCase)): + results.append(item) + return results + + +def get_test_classes_from_testsuite(suite): + """Returns a set of test case classes used in a test suite.""" + if not isinstance(suite, unittest.TestSuite): + raise TypeError('not a TestSuite', suite) + results = Set() + for test in suite._tests: + if isinstance(test, unittest.TestCase): + results.add(test.__class__) + else: + classes = get_test_classes_from_testsuite(test) + results.update(classes) + return results + + +def get_test_cases(test_files, cfg, tracer=None): + """Returns a list of test cases from a given list of test modules.""" + matcher = compile_matcher(cfg.test_regex) + results = [] + startTime = time.time() + for file in test_files: + module = import_module(file, cfg, tracer=tracer) + try: + func = module.test_suite + except AttributeError: + print >> sys.stderr + print >> sys.stderr, ("%s: WARNING: there is no test_suite" + " function" % file) + print >> sys.stderr + continue + if tracer is not None: + test_suite = tracer.runfunc(func) + else: + test_suite = func() + if test_suite is None: + continue + if cfg.warn_omitted: + all_classes = Set(get_all_test_cases(module)) + classes_in_suite = get_test_classes_from_testsuite(test_suite) + difference = all_classes - classes_in_suite + for test_class in difference: + # surround the warning with blank lines, otherwise it tends + # to get lost in the noise + print >> sys.stderr + print >> sys.stderr, ("%s: WARNING: %s not in test suite" + % (file, test_class.__name__)) + print >> sys.stderr + if (cfg.level is not None and + getattr(test_suite, 'level', 0) > cfg.level): + continue + filtered = filter_testsuite(test_suite, matcher, cfg.level) + results.extend(filtered) + stopTime = time.time() + timeTaken = float(stopTime - startTime) + if cfg.print_import_time: + nmodules = len(test_files) + print "Imported %d modules in %.3fs" % (nmodules, timeTaken) + print + return results + + +def get_test_hooks(test_files, cfg, tracer=None): + """Returns a list of test hooks from a given list of test modules.""" + results = [] + dirs = Set(map(os.path.dirname, test_files)) + for dir in list(dirs): + if os.path.basename(dir) == 'ftests': + dirs.add(os.path.join(os.path.dirname(dir), 'tests')) + dirs = list(dirs) + dirs.sort() + for dir in dirs: + filename = os.path.join(dir, 'checks.py') + if os.path.exists(filename): + module = import_module(filename, cfg, tracer=tracer) + if tracer is not None: + hooks = tracer.runfunc(module.test_hooks) + else: + hooks = module.test_hooks() + results.extend(hooks) + return results + + +def extract_tb(tb, limit=None): + """Improved version of traceback.extract_tb. + + Includes a dict with locals in every stack frame instead of the line. + """ + list = [] + while tb is not None and (limit is None or len(list) < limit): + frame = tb.tb_frame + code = frame.f_code + name = code.co_name + filename = code.co_filename + lineno = tb.tb_lineno + locals = frame.f_locals + list.append((filename, lineno, name, locals)) + tb = tb.tb_next + return list + +def format_exception(etype, value, tb, limit=None, basedir=None): + """Improved version of traceback.format_exception. + + Includes Zope-specific extra information in tracebacks. + """ + list = [] + if tb: + list = ['Traceback (most recent call last):\n'] + w = list.append + + for filename, lineno, name, locals in extract_tb(tb, limit): + w(' File "%s", line %s, in %s\n' % (filename, lineno, name)) + line = linecache.getline(filename, lineno) + if line: + w(' %s\n' % line.strip()) + tb_info = locals.get('__traceback_info__') + if tb_info is not None: + w(' Extra information: %s\n' % repr(tb_info)) + tb_supplement = locals.get('__traceback_supplement__') + if tb_supplement is not None: + tb_supplement = tb_supplement[0](*tb_supplement[1:]) + # XXX these should be hookable + from zope.tales.tales import TALESTracebackSupplement + from zope.pagetemplate.pagetemplate \ + import PageTemplateTracebackSupplement + if isinstance(tb_supplement, PageTemplateTracebackSupplement): + template = tb_supplement.manageable_object.pt_source_file() + if template: + w(' Template "%s"\n' % template) + elif isinstance(tb_supplement, TALESTracebackSupplement): + w(' Template "%s", line %s, column %s\n' + % (tb_supplement.source_url, tb_supplement.line, + tb_supplement.column)) + line = linecache.getline(tb_supplement.source_url, + tb_supplement.line) + if line: + w(' %s\n' % line.strip()) + w(' Expression: %s\n' % tb_supplement.expression) + else: + w(' __traceback_supplement__ = %r\n' % (tb_supplement, )) + list += traceback.format_exception_only(etype, value) + return list + + +class CustomTestResult(unittest._TextTestResult): + """Customised TestResult. + + It can show a progress bar, and displays tracebacks for errors and failures + as soon as they happen, in addition to listing them all at the end. + """ + + __super = unittest._TextTestResult + __super_init = __super.__init__ + __super_startTest = __super.startTest + __super_stopTest = __super.stopTest + __super_printErrors = __super.printErrors + __super_printErrorList = __super.printErrorList + + def __init__(self, stream, descriptions, verbosity, count, cfg, hooks): + self.__super_init(stream, descriptions, verbosity) + self.count = count + self.cfg = cfg + self.hooks = hooks + if cfg.progress: + self.dots = False + self._lastWidth = 0 + self._maxWidth = cfg.screen_width - len("xxxx/xxxx (xxx.x%): ") - 1 + + def startTest(self, test): + n = self.testsRun + 1 + if self.cfg.progress: + # verbosity == 0: 'xxxx/xxxx (xxx.x%)' + # verbosity == 1: 'xxxx/xxxx (xxx.x%): test name' + # verbosity >= 2: 'xxxx/xxxx (xxx.x%): test name ... ok' + self.stream.write("\r%4d" % n) + if self.count: + self.stream.write("/%d (%5.1f%%)" + % (self.count, n * 100.0 / self.count)) + if self.showAll: # self.cfg.verbosity == 1 + self.stream.write(": ") + elif self.cfg.verbosity: + name = self.getShortDescription(test) + width = len(name) + if width < self._lastWidth: + name += " " * (self._lastWidth - width) + self.stream.write(": %s" % name) + self._lastWidth = width + self.stream.flush() + self.__super_startTest(test) # increments testsRun by one and prints + self.testsRun = n # override the testsRun calculation + for hook in self.hooks: + hook.startTest(test) + + def stopTest(self, test): + for hook in self.hooks: + hook.stopTest(test) + self.__super_stopTest(test) + + def getDescription(self, test): + return test.id() # package.module.class.method + + def getShortDescription(self, test): + s = test.id() # package.module.class.method + if len(s) > self._maxWidth: + namelen = len(s.split('.')[-1]) + left = max(0, (self._maxWidth - namelen) / 2 - 1) + right = self._maxWidth - left - 3 + s = "%s...%s" % (s[:left], s[-right:]) + return s + + def printErrors(self): + if self.cfg.progress and not (self.dots or self.showAll): + self.stream.writeln() + if self.cfg.immediate_errors and (self.errors or self.failures): + self.stream.writeln(self.separator1) + self.stream.writeln("Tests that failed") + self.stream.writeln(self.separator2) + self.__super_printErrors() + + def formatError(self, err): + return "".join(format_exception(basedir=self.cfg.basedir, *err)) + + def printTraceback(self, kind, test, err): + self.stream.writeln() + self.stream.writeln(self.separator1) + self.stream.writeln("%s: %s" % (kind, self.getDescription(test))) + self.stream.writeln(self.separator2) + self.stream.writeln(self.formatError(err)) + self.stream.writeln() + + def addFailure(self, test, err): + if self.cfg.immediate_errors: + self.printTraceback("FAIL", test, err) + if self.cfg.postmortem: + pdb.post_mortem(sys.exc_info()[2]) + self.failures.append((test, self.formatError(err))) + + def addError(self, test, err): + if self.cfg.immediate_errors: + self.printTraceback("ERROR", test, err) + if self.cfg.postmortem: + pdb.post_mortem(sys.exc_info()[2]) + self.errors.append((test, self.formatError(err))) + + def printErrorList(self, flavour, errors): + if self.cfg.immediate_errors: + for test, err in errors: + description = self.getDescription(test) + self.stream.writeln("%s: %s" % (flavour, description)) + else: + self.__super_printErrorList(flavour, errors) + + +class CustomTestRunner(unittest.TextTestRunner): + """Customised TestRunner. + + See CustomisedTextResult for a list of extensions. + """ + + __super = unittest.TextTestRunner + __super_init = __super.__init__ + __super_run = __super.run + + def __init__(self, cfg, hooks=None, stream=sys.stderr, count=None): + self.__super_init(verbosity=cfg.verbosity, stream=stream) + self.cfg = cfg + if hooks is not None: + self.hooks = hooks + else: + self.hooks = [] + self.count = count + + def run(self, test): + """Run the given test case or test suite.""" + if self.count is None: + self.count = test.countTestCases() + result = self._makeResult() + startTime = time.time() + test(result) + stopTime = time.time() + timeTaken = float(stopTime - startTime) + result.printErrors() + run = result.testsRun + if not self.cfg.quiet: + self.stream.writeln(result.separator2) + self.stream.writeln("Ran %d test%s in %.3fs" % + (run, run != 1 and "s" or "", timeTaken)) + self.stream.writeln() + if not result.wasSuccessful(): + self.stream.write("FAILED (") + failed, errored = map(len, (result.failures, result.errors)) + if failed: + self.stream.write("failures=%d" % failed) + if errored: + if failed: self.stream.write(", ") + self.stream.write("errors=%d" % errored) + self.stream.writeln(")") + elif not self.cfg.quiet: + self.stream.writeln("OK") + return result + + def _makeResult(self): + return CustomTestResult(self.stream, self.descriptions, self.verbosity, + cfg=self.cfg, count=self.count, + hooks=self.hooks) + + +def main(argv): + """Main program.""" + + # Environment + if sys.version_info < (2, 3): + print >> sys.stderr, '%s: need Python 2.3 or later' % argv[0] + print >> sys.stderr, 'your python is %s' % sys.version + return 1 + + # Defaults + cfg = Options() + cfg.basedir = os.path.join(os.path.dirname(argv[0]), 'src') + cfg.basedir = os.path.abspath(cfg.basedir) + + # Figure out terminal size + try: + import curses + except ImportError: + pass + else: + try: + curses.setupterm() + cols = curses.tigetnum('cols') + if cols > 0: + cfg.screen_width = cols + except curses.error: + pass + + # Option processing + try: + opts, args = getopt.gnu_getopt(argv[1:], 'hvpqufwd', + ['list-files', 'list-tests', 'list-hooks', + 'level=', 'all-levels', 'coverage', + 'search-in=', 'immediate-errors', + 'delayed-errors', 'help']) + except getopt.error, e: + print >> sys.stderr, '%s: %s' % (argv[0], e) + print >> sys.stderr, 'run %s -h for help' % argv[0] + return 1 + for k, v in opts: + if k in ['-h', '--help']: + print __doc__ + return 0 + elif k == '-v': + cfg.verbosity += 1 + cfg.quiet = False + elif k == '-p': + cfg.progress = True + cfg.quiet = False + elif k == '-q': + cfg.verbosity = 0 + cfg.progress = False + cfg.quiet = True + elif k == '-u': + cfg.unit_tests = True + elif k == '-f': + cfg.functional_tests = True + elif k == '-d': + cfg.postmortem = True + elif k == '-w': + cfg.warn_omitted = True + elif k == '--list-files': + cfg.list_files = True + cfg.run_tests = False + elif k == '--list-tests': + cfg.list_tests = True + cfg.run_tests = False + elif k == '--list-hooks': + cfg.list_hooks = True + cfg.run_tests = False + elif k == '--coverage': + cfg.coverage = True + elif k == '--level': + try: + cfg.level = int(v) + except ValueError: + print >> sys.stderr, '%s: invalid level: %s' % (argv[0], v) + print >> sys.stderr, 'run %s -h for help' % argv[0] + return 1 + elif k == '--all-levels': + cfg.level = None + elif k == '--search-in': + dir = os.path.abspath(v) + if not dir.startswith(cfg.basedir): + print >> sys.stderr, ('%s: argument to --search-in (%s) must' + ' be a subdir of %s' + % (argv[0], v, cfg.basedir)) + return 1 + cfg.search_in += (dir, ) + elif k == '--immediate-errors': + cfg.immediate_errors = True + elif k == '--delayed-errors': + cfg.immediate_errors = False + else: + print >> sys.stderr, '%s: invalid option: %s' % (argv[0], k) + print >> sys.stderr, 'run %s -h for help' % argv[0] + return 1 + if args: + cfg.pathname_regex = args[0] + if len(args) > 1: + cfg.test_regex = args[1] + if len(args) > 2: + print >> sys.stderr, '%s: too many arguments: %s' % (argv[0], args[2]) + print >> sys.stderr, 'run %s -h for help' % argv[0] + return 1 + if not cfg.unit_tests and not cfg.functional_tests: + cfg.unit_tests = True + + if not cfg.search_in: + cfg.search_in = (cfg.basedir, ) + + # Do not print "Imported %d modules in %.3fs" if --list-XXX was specified + if cfg.list_tests or cfg.list_hooks or cfg.list_files: + cfg.print_import_time = False + + # Set up the python path + sys.path[0] = cfg.basedir + # XXX The following bit is SchoolTool specific: we need the Zope3 tree in + # sys.path, in addition to basedir. + sys.path.insert(1, os.path.join(os.path.dirname(cfg.basedir), + 'Zope3', 'src')) + + # Set up tracing before we start importing things + tracer = None + if cfg.run_tests and cfg.coverage: + import trace + # trace.py in Python 2.3.1 is buggy: + # 1) Despite sys.prefix being in ignoredirs, a lot of system-wide + # modules are included in the coverage reports + # 2) Some module file names do not have the first two characters, + # and in general the prefix used seems to be arbitrary + # These bugs are fixed in src/trace.py which should be in PYTHONPATH + # before the official one. + ignoremods = ['test'] + ignoredirs = [sys.prefix, sys.exec_prefix] + tracer = trace.Trace(count=True, trace=False, + ignoremods=ignoremods, ignoredirs=ignoredirs) + + # Finding and importing + test_files = get_test_files(cfg) + if cfg.list_tests or cfg.run_tests: + test_cases = get_test_cases(test_files, cfg, tracer=tracer) + if cfg.list_hooks or cfg.run_tests: + test_hooks = get_test_hooks(test_files, cfg, tracer=tracer) + + # Configure the logging module + import logging + logging.basicConfig() + logging.root.setLevel(logging.CRITICAL) + + # Running + success = True + if cfg.list_files: + baselen = len(cfg.basedir) + 1 + print "\n".join([fn[baselen:] for fn in test_files]) + if cfg.list_tests: + print "\n".join([test.id() for test in test_cases]) + if cfg.list_hooks: + print "\n".join([str(hook) for hook in test_hooks]) + if cfg.run_tests: + runner = CustomTestRunner(cfg, test_hooks, count=len(test_cases)) + suite = unittest.TestSuite() + suite.addTests(test_cases) + if tracer is not None: + success = tracer.runfunc(runner.run, suite).wasSuccessful() + results = tracer.results() + results.write_results(show_missing=True, coverdir=cfg.coverdir) + else: + success = runner.run(suite).wasSuccessful() + + # That's all + if success: + return 0 + else: + return 1 + + +if __name__ == '__main__': + exitcode = main(sys.argv) + sys.exit(exitcode) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..2bd77c7 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.9.4 \ No newline at end of file