Merge lp:~salgado/launchpad/remove-crap into lp:launchpad

Proposed by Guilherme Salgado
Status: Merged
Approved by: Jonathan Lange
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~salgado/launchpad/remove-crap
Merge into: lp:launchpad
Diff against target: 3508 lines (+0/-3401)
17 files modified
lib/canonical/launchpad/components/cal.py (+0/-89)
lib/canonical/launchpad/components/crowd.py (+0/-80)
lib/canonical/launchpad/doc/crowd.txt (+0/-124)
lib/canonical/launchpad/interfaces/launchpad.py (+0/-20)
lib/canonical/launchpad/zcml/configure.zcml (+0/-1)
lib/canonical/launchpad/zcml/crowd.zcml (+0/-17)
lib/schoolbell/README.txt (+0/-7)
lib/schoolbell/__init__.py (+0/-38)
lib/schoolbell/browser.py (+0/-71)
lib/schoolbell/icalendar.py (+0/-1127)
lib/schoolbell/interfaces.py (+0/-396)
lib/schoolbell/mixins.py (+0/-314)
lib/schoolbell/simple.py (+0/-96)
lib/schoolbell/tests/__init__.py (+0/-1)
lib/schoolbell/tests/test_icalendar.py (+0/-782)
lib/schoolbell/tests/test_schoolbell.py (+0/-74)
lib/schoolbell/utils.py (+0/-164)
To merge this branch: bzr merge lp:~salgado/launchpad/remove-crap
Reviewer Review Type Date Requested Status
Jonathan Lange (community) Approve
Review via email: mp+21549@code.launchpad.net

Description of the change

Just remove some crap that was no longer used:

  lib/schoolbell/
  lib/canonical/launchpad/components/cal.py
  lib/canonical/launchpad/components/crowd.py

= Launchpad lint =

Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.

Linting changed files:
  lib/canonical/launchpad/interfaces/launchpad.py
  lib/canonical/launchpad/zcml/configure.zcml

== Pyflakes notices ==

lib/canonical/launchpad/interfaces/launchpad.py
    25: 'UnexpectedFormData' imported but unused
    25: 'UnsafeFormGetSubmissionError' imported but unused
    25: 'NotFoundError' imported but unused
    25: 'IBasicLaunchpadRequest' imported but unused
    25: 'IOpenLaunchBag' imported but unused
    25: 'ILaunchpadRoot' imported but unused
    25: 'ILaunchBag' imported but unused

== Pylint notices ==

lib/canonical/launchpad/interfaces/launchpad.py
    17: [F0401] Unable to import 'lazr.restful.interfaces' (No module named restful)

To post a comment you must log in.
Revision history for this message
Jonathan Lange (jml) wrote :

LAND IT!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== removed file 'lib/canonical/launchpad/components/cal.py'
--- lib/canonical/launchpad/components/cal.py 2009-07-17 00:26:05 +0000
+++ lib/canonical/launchpad/components/cal.py 1970-01-01 00:00:00 +0000
@@ -1,89 +0,0 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""
5Calendaring for Launchpad
6
7This package contains various components that don't fit into database/
8or browser/.
9"""
10
11__metaclass__ = type
12
13from zope.interface import implements
14from zope.component import getUtility
15
16from canonical.launchpad import _
17from schoolbell.interfaces import ICalendar
18from canonical.launchpad.interfaces import (
19 ILaunchBag, ILaunchpadCalendar, ILaunchpadMergedCalendar,
20 ICalendarSubscriptionSubset)
21
22from schoolbell.mixins import CalendarMixin, EditableCalendarMixin
23from schoolbell.icalendar import convert_calendar_to_ical
24
25
26def calendarFromCalendarOwner(calendarowner):
27 """Adapt ICalendarOwner to ICalendar."""
28 return calendarowner.calendar
29
30
31############# Merged Calendar #############
32
33
34class MergedCalendar(CalendarMixin, EditableCalendarMixin):
35 implements(ILaunchpadCalendar, ILaunchpadMergedCalendar)
36
37 def __init__(self):
38 self.id = None
39 self.revision = 0
40 self.owner = getUtility(ILaunchBag).user
41 if self.owner is None:
42 # The merged calendar can not be accessed when the user is
43 # not logged in. However this object still needs to be
44 # instantiable when not logged in, so that the user gets
45 # redirected to the login page when trying to access the
46 # calendar, rather than seeing an error page.
47 return
48 self.subscriptions = ICalendarSubscriptionSubset(self.owner)
49 self.title = _('Merged Calendar for %s') % self.owner.displayname
50
51 def __iter__(self):
52 for calendar in self.subscriptions:
53 for event in calendar:
54 yield event
55
56 def expand(self, first, last):
57 for calendar in self.subscriptions:
58 for event in calendar.expand(first, last):
59 yield event
60
61 def addEvent(self, event):
62 calendar = self.owner.getOrCreateCalendar()
63 calendar.addEvent(event)
64
65 def removeEvent(self, event):
66 calendar = event.calendar
67 calendar.removeEvent(event)
68
69
70############# iCalendar export ###################
71
72class ViewICalendar:
73 """Publish an object implementing the ICalendar interface in
74 the iCalendar format. This allows desktop calendar clients to
75 display the events."""
76 __used_for__ = ICalendar
77
78 def __init__(self, context, request):
79 self.context = context
80 self.request = request
81
82 def __call__(self):
83 result = convert_calendar_to_ical(self.context)
84 result = '\r\n'.join(result)
85
86 self.request.response.setHeader('Content-Type', 'text/calendar')
87 self.request.response.setHeader('Content-Length', len(result))
88
89 return result
900
=== removed file 'lib/canonical/launchpad/components/crowd.py'
--- lib/canonical/launchpad/components/crowd.py 2009-06-25 05:30:52 +0000
+++ lib/canonical/launchpad/components/crowd.py 1970-01-01 00:00:00 +0000
@@ -1,80 +0,0 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from zope.interface import implements
7
8from canonical.launchpad.interfaces import ICrowd, IPerson, ITeam
9
10
11class CrowdOfOnePerson:
12 implements(ICrowd)
13 __used_for__ = IPerson
14
15 def __init__(self, person):
16 self.person = person
17
18 def __contains__(self, person_or_team):
19 return person_or_team.id == self.person.id
20
21 def __add__(self, crowd):
22 return CrowdsAddedTogether(crowd, self)
23
24
25class CrowdOfOneTeam:
26 implements(ICrowd)
27 __used_for__ = ITeam
28
29 def __init__(self, team):
30 self.team = team
31
32 def __contains__(self, person_or_team):
33 if person_or_team.id == self.team.id:
34 return True
35 return person_or_team.inTeam(self.team)
36
37 def __add__(self, crowd):
38 return CrowdsAddedTogether(crowd, self)
39
40
41class CrowdsAddedTogether:
42
43 implements(ICrowd)
44
45 def __init__(self, *crowds):
46 self.crowds = crowds
47
48 def __contains__(self, person_or_team):
49 for crowd in self.crowds:
50 if person_or_team in crowd:
51 return True
52 return False
53
54 def __add__(self, crowd):
55 return CrowdsAddedTogether(crowd, *self.crowds)
56
57
58# XXX ddaa 2005-04-01: This shouldn't be in components
59class AnyPersonCrowd:
60
61 implements(ICrowd)
62
63 def __contains__(self, person_or_team):
64 return IPerson.providedBy(person_or_team)
65
66 def __add__(self, crowd):
67 return CrowdsAddedTogether(crowd, self)
68
69# XXX ddaa 2005-04-01: This shouldn't be in components
70class EmptyCrowd:
71
72 implements(ICrowd)
73
74 def __contains__(self, person_or_team):
75 return False
76
77 def __add__(self, crowd):
78 return crowd
79
80
810
=== removed file 'lib/canonical/launchpad/doc/crowd.txt'
--- lib/canonical/launchpad/doc/crowd.txt 2009-08-13 15:12:16 +0000
+++ lib/canonical/launchpad/doc/crowd.txt 1970-01-01 00:00:00 +0000
@@ -1,124 +0,0 @@
1
2
3A person is adaptable to ICrowd.
4
5 >>> from canonical.launchpad.interfaces import IPersonSet, ICrowd
6 >>> from zope.component import getUtility
7 >>> personset = getUtility(IPersonSet)
8 >>> mark = personset.getByName('mark')
9 >>> print mark.name
10 mark
11 >>> mark_crowd = ICrowd(mark)
12
13The person is in that crowd.
14
15 >>> mark in mark_crowd
16 True
17
18
19A team is adaptable to ICrowd, but it gets a different adapter than that used
20for a person.
21
22 >>> vcs_imports = personset.getByName('vcs-imports')
23 >>> print vcs_imports.name
24 vcs-imports
25 >>> vcs_imports_crowd = ICrowd(vcs_imports)
26
27The team is in that crowd.
28
29 >>> vcs_imports in vcs_imports_crowd
30 True
31
32mark is not in that crowd, because he is not in the vcs-imports team.
33
34 >>> mark in vcs_imports_crowd
35 False
36
37lifeless is in the vcs-imports team. So, lifeless is in that crowd.
38
39 >>> lifeless = personset.getByName('lifeless')
40 >>> print lifeless.name
41 lifeless
42 >>> lifeless in vcs_imports_crowd
43 True
44
45Adding mark_crowd to the vcs_imports_crowd gives us a crowd that contains
46mark and the vcs-imports team and lifeless, but not stevea.
47This tests ICrowd(team).__add__.
48
49 >>> combined_crowd = vcs_imports_crowd + mark_crowd
50 >>> stevea = personset.getByName('stevea')
51 >>> stevea not in combined_crowd
52 True
53 >>> lifeless in combined_crowd
54 True
55 >>> mark in combined_crowd
56 True
57 >>> vcs_imports in combined_crowd
58 True
59
60Now, to try it the other way around: adding vcs_imports_crowd to mark_crowd.
61This tests ICrowd(person).__add__.
62
63 >>> combined_crowd = mark_crowd + vcs_imports_crowd
64 >>> lifeless in combined_crowd
65 True
66 >>> mark in combined_crowd
67 True
68 >>> vcs_imports in combined_crowd
69 True
70
71There is an AnyPersonCrowd that contains any person or team.
72
73 >>> from canonical.launchpad.components.crowd import AnyPersonCrowd
74 >>> mark in AnyPersonCrowd()
75 True
76 >>> vcs_imports in AnyPersonCrowd()
77 True
78 >>> vcs_imports_crowd in AnyPersonCrowd()
79 False
80
81Adding an AnyPersonCrowd to some other crowd works as expected.
82
83 >>> combined_crowd = mark_crowd + AnyPersonCrowd()
84 >>> mark in combined_crowd
85 True
86 >>> stevea in combined_crowd
87 True
88
89Same goes for the other way around.
90
91 >>> combined_crowd = AnyPersonCrowd() + mark_crowd
92 >>> mark in combined_crowd
93 True
94 >>> stevea in combined_crowd
95 True
96
97
98The EmptyCrowd doens't contain anything.
99
100 >>> from canonical.launchpad.components.crowd import EmptyCrowd
101 >>> mark in EmptyCrowd()
102 False
103 >>> vcs_imports_crowd in EmptyCrowd()
104 False
105
106Adding a crowd to EmptyCrowd, and vice versa, gives you essentially that crowd.
107
108 >>> combined_crowd = EmptyCrowd() + vcs_imports_crowd
109 >>> vcs_imports in combined_crowd
110 True
111 >>> mark in combined_crowd
112 False
113 >>> lifeless in combined_crowd
114 True
115
116 >>> combined_crowd = vcs_imports_crowd + EmptyCrowd()
117 >>> vcs_imports in combined_crowd
118 True
119 >>> mark in combined_crowd
120 False
121 >>> lifeless in combined_crowd
122 True
123
124
1250
=== modified file 'lib/canonical/launchpad/interfaces/launchpad.py'
--- lib/canonical/launchpad/interfaces/launchpad.py 2010-02-24 23:18:40 +0000
+++ lib/canonical/launchpad/interfaces/launchpad.py 2010-03-17 12:06:32 +0000
@@ -34,7 +34,6 @@
34 'IAuthServerApplication',34 'IAuthServerApplication',
35 'IBasicLaunchpadRequest',35 'IBasicLaunchpadRequest',
36 'IBazaarApplication',36 'IBazaarApplication',
37 'ICrowd',
38 'IFeedsApplication',37 'IFeedsApplication',
39 'IHasAppointedDriver',38 'IHasAppointedDriver',
40 'IHasAssignee',39 'IHasAssignee',
@@ -244,25 +243,6 @@
244 """243 """
245244
246245
247class ICrowd(Interface):
248
249 def __contains__(person_or_team_or_anything):
250 """Return True if person_or_team_or_anything is in the crowd.
251
252 Note that a particular crowd can choose to answer 'True' to this
253 question, if that is what it is supposed to do. So, crowds that
254 contain other crowds will want to allow the other crowds the
255 opportunity to answer __contains__ before that crowd does.
256 """
257
258 def __add__(crowd):
259 """Return a new ICrowd that is this crowd added to the given crowd.
260
261 The returned crowd contains the person or teams in
262 both this crowd and the given crowd.
263 """
264
265
266class IPrivateMaloneApplication(ILaunchpadApplication):246class IPrivateMaloneApplication(ILaunchpadApplication):
267 """Private application root for malone."""247 """Private application root for malone."""
268248
269249
=== modified file 'lib/canonical/launchpad/zcml/configure.zcml'
--- lib/canonical/launchpad/zcml/configure.zcml 2010-02-18 17:00:54 +0000
+++ lib/canonical/launchpad/zcml/configure.zcml 2010-03-17 12:06:32 +0000
@@ -12,7 +12,6 @@
12 <include file="account.zcml" />12 <include file="account.zcml" />
13 <include file="batchnavigator.zcml" />13 <include file="batchnavigator.zcml" />
14 <include file="binaryandsourcepackagename.zcml" />14 <include file="binaryandsourcepackagename.zcml" />
15 <include file="crowd.zcml" />
16 <include file="datetime.zcml" />15 <include file="datetime.zcml" />
17 <include file="decoratedresultset.zcml" />16 <include file="decoratedresultset.zcml" />
18 <include file="emailaddress.zcml" />17 <include file="emailaddress.zcml" />
1918
=== removed file 'lib/canonical/launchpad/zcml/crowd.zcml'
--- lib/canonical/launchpad/zcml/crowd.zcml 2009-07-13 18:15:02 +0000
+++ lib/canonical/launchpad/zcml/crowd.zcml 1970-01-01 00:00:00 +0000
@@ -1,17 +0,0 @@
1<!-- Copyright 2009 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->
4
5<configure xmlns="http://namespaces.zope.org/zope">
6 <adapter
7 for="canonical.launchpad.interfaces.IPerson"
8 provides="canonical.launchpad.interfaces.ICrowd"
9 factory="canonical.launchpad.components.crowd.CrowdOfOnePerson"
10 />
11 <adapter
12 for="canonical.launchpad.interfaces.ITeam"
13 provides="canonical.launchpad.interfaces.ICrowd"
14 factory="canonical.launchpad.components.crowd.CrowdOfOneTeam"
15 />
16</configure>
17
180
=== removed symlink 'lib/mercurial'
=== target was u'../sourcecode/mercurial/mercurial'
=== removed directory 'lib/schoolbell'
=== removed file 'lib/schoolbell/README.txt'
--- lib/schoolbell/README.txt 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/README.txt 1970-01-01 00:00:00 +0000
@@ -1,7 +0,0 @@
1SchoolBell
2==========
3
4SchoolBell is a calendaring library for Zope 3.
5
6See the docstring of __init__.py for a list of features and shortcomings.
7
80
=== removed file 'lib/schoolbell/__init__.py'
--- lib/schoolbell/__init__.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,38 +0,0 @@
1"""
2Calendaring for Zope 3 applications.
3
4SchoolBell is a calendaring library for Zope 3. Its main features are
5(currently most of these features are science fiction):
6
7- It can parse and generate iCalendar files. Only a subset of the iCalendar
8 spec is supported, however it is a sensible subset that should be enough for
9 interoperation with desktop calendaring applications like Apple's iCal,
10 Mozilla Calendar, Evolution, and KOrganizer.
11
12- It has browser views for presenting calendars in various ways (daily, weekly,
13 monthly, yearly views).
14
15- It is storage independent -- your application could store the calendar in
16 ZODB, in a relational database, or elsewhere, as long as the storage
17 component provides the necessary interface. A default content component
18 that stores data in ZODB is provided.
19
20- You can also generate calendars on the fly from other data (e.g. a bug
21 tracking system). These calendars can be read-only (simpler) or read-write.
22
23- You can display several calendars in a single view by using calendar
24 composition.
25
26- It supports recurring events (daily, weekly, monthly and yearly).
27
28Things that are not currently supported:
29
30- Timezone handling (UTC times are converted into server's local time in the
31 iCalendar parser, but that's all).
32
33- All-day events (that is, events that only specify the date but not the time).
34
35- Informing the user when uploaded iCalendar files use features that are not
36 supported by SchoolBell.
37
38"""
390
=== removed file 'lib/schoolbell/browser.py'
--- lib/schoolbell/browser.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/browser.py 1970-01-01 00:00:00 +0000
@@ -1,71 +0,0 @@
1r"""
2Browser views for schoolbell.
3
4iCalendar views
5---------------
6
7CalendarICalendarView can export calendars in iCalendar format
8
9 >>> from datetime import datetime, timedelta
10 >>> from schoolbell.simple import ImmutableCalendar, SimpleCalendarEvent
11 >>> event = SimpleCalendarEvent(datetime(2004, 12, 16, 11, 46, 16),
12 ... timedelta(hours=1), "doctests",
13 ... location=u"Matar\u00f3",
14 ... unique_id="12345678-5432@example.com")
15 >>> calendar = ImmutableCalendar([event])
16
17 >>> from zope.publisher.browser import TestRequest
18 >>> view = CalendarICalendarView()
19 >>> view.context = calendar
20 >>> view.request = TestRequest()
21 >>> output = view.show()
22
23 >>> lines = output.splitlines(True)
24 >>> from pprint import pprint
25 >>> pprint(lines)
26 ['BEGIN:VCALENDAR\r\n',
27 'VERSION:2.0\r\n',
28 'PRODID:-//SchoolTool.org/NONSGML SchoolBell//EN\r\n',
29 'BEGIN:VEVENT\r\n',
30 'UID:12345678-5432@example.com\r\n',
31 'SUMMARY:doctests\r\n',
32 'LOCATION:Matar\xc3\xb3\r\n',
33 'DTSTART:20041216T114616\r\n',
34 'DURATION:PT1H\r\n',
35 'DTSTAMP:...\r\n',
36 'END:VEVENT\r\n',
37 'END:VCALENDAR']
38
39XXX: Should the last line also end in '\r\n'? Go read RFC 2445 and experiment
40with calendaring clients.
41
42Register the iCalendar read view in ZCML as
43
44 <browser:page
45 for="schoolbell.interfaces.ICalendar"
46 name="calendar.ics"
47 permission="zope.Public"
48 class="schoolbell.browser.CalendarICalendarView"
49 attribute="show"
50 />
51
52"""
53
54from schoolbell.icalendar import convert_calendar_to_ical
55
56__metaclass__ = type
57
58
59class CalendarICalendarView:
60 """RFC 2445 (ICalendar) view for calendars."""
61
62 def show(self):
63 data = "\r\n".join(convert_calendar_to_ical(self.context))
64 request = self.request
65 if request is not None:
66 request.response.setHeader('Content-Type',
67 'text/calendar; charset=UTF-8')
68 request.response.setHeader('Content-Length', len(data))
69
70 return data
71
720
=== removed file 'lib/schoolbell/icalendar.py'
--- lib/schoolbell/icalendar.py 2010-02-09 01:31:05 +0000
+++ lib/schoolbell/icalendar.py 1970-01-01 00:00:00 +0000
@@ -1,1127 +0,0 @@
1r"""
2iCalendar parsing and generating.
3
4iCalendar (RFC 2445) is a big and hard-to-read specification. This module
5supports only a subset of it: VEVENT components with a limited set of
6attributes and a limited recurrence model. The subset should be sufficient
7for interoperation with desktop calendaring applications like Apple's iCal,
8Mozilla Calendar, Evolution and KOrganizer.
9
10If you have a calendar, you can convert it to an iCalendar file like this:
11
12 >>> from datetime import datetime, timedelta
13 >>> from schoolbell.simple import ImmutableCalendar, SimpleCalendarEvent
14 >>> event = SimpleCalendarEvent(datetime(2004, 12, 16, 10, 58, 47),
15 ... timedelta(hours=1), "doctests",
16 ... location=u"Matar\u00f3",
17 ... unique_id="12345678-5432@example.com")
18 >>> calendar = ImmutableCalendar([event])
19
20 >>> ical_file_as_string = "\r\n".join(convert_calendar_to_ical(calendar))
21
22The returned string is in UTF-8.
23
24 >>> event.location.encode("UTF-8") in ical_file_as_string
25 True
26
27You can also parse iCalendar files back into calendars:
28
29 >>> event_iterator = read_icalendar(ical_file_as_string)
30 >>> new_calendar = ImmutableCalendar(event_iterator)
31 >>> [e.title for e in new_calendar]
32 [u'doctests']
33
34"""
35
36import datetime
37import calendar
38import re
39import pytz
40from cStringIO import StringIO
41from schoolbell.simple import SimpleCalendarEvent
42
43_utc_tz = pytz.timezone('UTC')
44
45def convert_event_to_ical(event):
46 r"""Convert an ICalendarEvent to iCalendar VEVENT component.
47
48 Returns a list of strings (without newlines) in UTF-8.
49
50 >>> from datetime import datetime, timedelta
51 >>> event = SimpleCalendarEvent(datetime(2004, 12, 16, 10, 7, 29),
52 ... timedelta(hours=1), "iCal rendering",
53 ... location="Big room",
54 ... unique_id="12345678-5432@example.com")
55 >>> lines = convert_event_to_ical(event)
56 >>> print "\n".join(lines)
57 BEGIN:VEVENT
58 UID:12345678-5432@example.com
59 SUMMARY:iCal rendering
60 LOCATION:Big room
61 DTSTART:20041216T100729
62 DURATION:PT1H
63 DTSTAMP:...
64 END:VEVENT
65
66 """
67 dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
68 result = [
69 "BEGIN:VEVENT",
70 "UID:%s" % ical_text(event.unique_id),
71 "SUMMARY:%s" % ical_text(event.title)]
72 if event.description:
73 result.append("DESCRIPTION:%s" % ical_text(event.description))
74 if event.location:
75 result.append("LOCATION:%s" % ical_text(event.location))
76### if event.recurrence is not None: # TODO
77### start = event.dtstart
78### result.extend(event.recurrence.iCalRepresentation(start))
79 result += [
80 "DTSTART:%s" % ical_datetime(event.dtstart),
81 "DURATION:%s" % ical_duration(event.duration),
82 "DTSTAMP:%s" % dtstamp,
83 "END:VEVENT",
84 ]
85 return result
86
87
88def convert_calendar_to_ical(calendar):
89 r"""Convert an ICalendar to iCalendar VCALENDAR component.
90
91 Returns a list of strings (without newlines) in UTF-8. They should be
92 joined with '\r\n' to get a valid iCalendar file.
93
94 >>> from schoolbell.simple import ImmutableCalendar
95 >>> from schoolbell.simple import SimpleCalendarEvent
96 >>> from datetime import datetime, timedelta
97 >>> event = SimpleCalendarEvent(datetime(2004, 12, 16, 10, 7, 29),
98 ... timedelta(hours=1), "iCal rendering",
99 ... location="Big room",
100 ... unique_id="12345678-5432@example.com")
101 >>> calendar = ImmutableCalendar([event])
102 >>> lines = convert_calendar_to_ical(calendar)
103 >>> print "\n".join(lines)
104 BEGIN:VCALENDAR
105 VERSION:2.0
106 PRODID:-//SchoolTool.org/NONSGML SchoolBell//EN
107 BEGIN:VEVENT
108 UID:12345678-5432@example.com
109 SUMMARY:iCal rendering
110 LOCATION:Big room
111 DTSTART:20041216T100729
112 DURATION:PT1H
113 DTSTAMP:...
114 END:VEVENT
115 END:VCALENDAR
116
117 Empty calendars are not allowed by RFC 2445, so we have to invent a dummy
118 event:
119
120 >>> lines = convert_calendar_to_ical(ImmutableCalendar())
121 >>> print "\n".join(lines)
122 BEGIN:VCALENDAR
123 VERSION:2.0
124 PRODID:-//SchoolTool.org/NONSGML SchoolBell//EN
125 BEGIN:VEVENT
126 UID:...
127 SUMMARY:Empty calendar
128 DTSTART:19700101T000000
129 DURATION:P0D
130 DTSTAMP:...
131 END:VEVENT
132 END:VCALENDAR
133
134 """
135 header = [
136 "BEGIN:VCALENDAR",
137 "VERSION:2.0",
138 "PRODID:-//SchoolTool.org/NONSGML SchoolBell//EN",
139 ]
140 footer = [
141 "END:VCALENDAR"
142 ]
143 events = []
144 for event in calendar:
145 events += convert_event_to_ical(event)
146 if not events:
147 placeholder = SimpleCalendarEvent(datetime.datetime(1970, 1, 1),
148 datetime.timedelta(0),
149 "Empty calendar")
150 events += convert_event_to_ical(placeholder)
151 return header + events + footer
152
153
154def ical_text(value):
155 r"""Format value according to iCalendar TEXT escaping rules.
156
157 Converts Unicode strings to UTF-8 as well.
158
159 >>> ical_text('Foo')
160 'Foo'
161 >>> ical_text(u'Matar\u00f3')
162 'Matar\xc3\xb3'
163 >>> ical_text('\\')
164 '\\\\'
165 >>> ical_text(';')
166 '\\;'
167 >>> ical_text(',')
168 '\\,'
169 >>> ical_text('\n')
170 '\\n'
171 """
172 return (value.encode('UTF-8')
173 .replace('\\', '\\\\')
174 .replace(';', '\\;')
175 .replace(',', '\\,')
176 .replace('\n', '\\n'))
177
178
179def ical_datetime(value):
180 """Format a datetime as an iCalendar DATETIME value.
181
182 >>> from datetime import datetime
183 >>> from pytz import timezone
184 >>> ical_datetime(datetime(2004, 12, 16, 10, 45, 07))
185 '20041216T104507'
186 >>> ical_datetime(datetime(2004, 12, 16, 10, 45, 07,
187 ... tzinfo=timezone('Australia/Perth')))
188 '20041216T024507Z'
189
190 """
191 if value.tzinfo:
192 value = value.astimezone(_utc_tz)
193 return value.strftime('%Y%m%dT%H%M%SZ')
194 return value.strftime('%Y%m%dT%H%M%S')
195
196
197def ical_duration(value):
198 """Format a timedelta as an iCalendar DURATION value.
199
200 >>> from datetime import timedelta
201 >>> ical_duration(timedelta(11))
202 'P11D'
203 >>> ical_duration(timedelta(-14))
204 '-P14D'
205 >>> ical_duration(timedelta(1, 7384))
206 'P1DT2H3M4S'
207 >>> ical_duration(timedelta(1, 7380))
208 'P1DT2H3M'
209 >>> ical_duration(timedelta(1, 7200))
210 'P1DT2H'
211 >>> ical_duration(timedelta(0, 7200))
212 'PT2H'
213 >>> ical_duration(timedelta(0, 7384))
214 'PT2H3M4S'
215 >>> ical_duration(timedelta(0, 184))
216 'PT3M4S'
217 >>> ical_duration(timedelta(0, 22))
218 'PT22S'
219 >>> ical_duration(timedelta(0, 3622))
220 'PT1H0M22S'
221 """
222 sign = ""
223 if value.days < 0:
224 sign = "-"
225 timepart = ""
226 if value.seconds:
227 timepart = "T"
228 hours = value.seconds // 3600
229 minutes = value.seconds % 3600 // 60
230 seconds = value.seconds % 60
231 if hours:
232 timepart += "%dH" % hours
233 if minutes or (hours and seconds):
234 timepart += "%dM" % minutes
235 if seconds:
236 timepart += "%dS" % seconds
237 if value.days == 0 and timepart:
238 return "%sP%s" % (sign, timepart)
239 else:
240 return "%sP%dD%s" % (sign, abs(value.days), timepart)
241
242
243def read_icalendar(icalendar_text):
244 """Read an iCalendar file and return calendar events.
245
246 Returns an iterator over calendar events.
247
248 `icalendar_text` can be a file object or a string. It is assumed that
249 the iCalendar file contains UTF-8 text.
250
251 Unsuppored features of the iCalendar file (e.g. VTODO components, complex
252 recurrence rules, unknown properties) are silently ignored.
253 """
254 if isinstance(icalendar_text, str):
255 icalendar_text = StringIO(icalendar_text)
256 reader = ICalReader(icalendar_text)
257 for vevent in reader.iterEvents():
258 # TODO: ignore empty calendar placeholder
259
260 # Currently SchoolBell does not support all-day events, so we must
261 # convert them into ordinary events that last 24 hours
262 dtstart = vevent.dtstart
263 if not isinstance(dtstart, datetime.datetime):
264 dtstart = datetime.datetime.combine(dtstart,
265 datetime.time(0))
266
267 yield SimpleCalendarEvent(dtstart, vevent.duration, vevent.summary,
268 location=vevent.location,
269 unique_id=vevent.uid,
270 recurrence=vevent.rrule)
271
272
273#
274# The rest of this module could use some review and refactoring
275#
276
277class ICalReader:
278 """An object which reads in an iCalendar file.
279
280 The `iterEvents` method returns an iterator over all VEvent objects
281 corresponding to the events in the iCalendar file.
282
283 Short grammar of iCalendar files (RFC 2445 is the full spec):
284
285 contentline = name *(";" param ) ":" value CRLF
286 ; content line first must be unfolded by replacing CRLF followed by a
287 ; single WSP with an empty string
288 name = x-name / iana-token
289 x-name = "X-" [vendorid "-"] 1*(ALPHA / DIGIT / "-")
290 iana-token = 1*(ALPHA / DIGIT / "-")
291 vendorid = 3*(ALPHA / DIGIT)
292 param = param-name "=" param-value *("," param-value)
293 param-name = iana-token / x-token
294 param-value = paramtext / quoted-string
295 paramtext = *SAFE-CHAR
296 value = *VALUE-CHAR
297 quoted-string = DQUOTE *QSAFE-CHAR DQUOTE
298
299 NON-US-ASCII = %x80-F8
300 QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII
301 ; Any character except CTLs and DQUOTE
302 SAFE-CHAR = WSP / %x21 / %x23-2B / %x2D-39 / %x3C-7E
303 / NON-US-ASCII
304 ; Any character except CTLs, DQUOTE, ";", ":", ","
305 VALUE-CHAR = WSP / %x21-7E / NON-US-ASCII ; anything except CTLs
306 CR = %x0D
307 LF = %x0A
308 CRLF = CR LF
309 CTL = %x00-08 / %x0A-1F / %x7F
310 ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
311 DIGIT = %x30-39 ; 0-9
312 DQUOTE = %x22 ; Quotation Mark
313 WSP = SPACE / HTAB
314 SPACE = %x20
315 HTAB = %x09
316 """
317
318 def __init__(self, file, charset='UTF-8'):
319 self.file = file
320 self.charset = charset
321
322 def _parseRow(record_str):
323 """Parse a single content line.
324
325 A content line consists of a property name (optionally followed by a
326 number of parameters) and a value, separated by a colon. Parameters
327 (if present) are separated from the property name and from each other
328 with semicolons. Parameters are of the form name=value; value
329 can be double-quoted.
330
331 Returns a tuple (name, value, param_dict). Case-insensitive values
332 (i.e. property names, parameter names, unquoted parameter values) are
333 uppercased.
334
335 Raises ICalParseError on syntax errors.
336
337 >>> ICalReader._parseRow('foo:bar')
338 ('FOO', 'bar', {})
339 >>> ICalReader._parseRow('foo;value=bar:BAZFOO')
340 ('FOO', 'BAZFOO', {'VALUE': 'BAR'})
341 """
342
343 it = iter(record_str)
344 getChar = it.next
345
346 def err(msg):
347 raise ICalParseError("%s in line:\n%s" % (msg, record_str))
348
349 try:
350 c = getChar()
351 # name
352 key = ''
353 while c.isalnum() or c == '-':
354 key += c
355 c = getChar()
356 if not key:
357 err("Missing property name")
358 key = key.upper()
359 # optional parameters
360 params = {}
361 while c == ';':
362 c = getChar()
363 # param name
364 param = ''
365 while c.isalnum() or c == '-':
366 param += c
367 c = getChar()
368 if not param:
369 err("Missing parameter name")
370 param = param.upper()
371 # =
372 if c != '=':
373 err("Expected '='")
374 # value (or rather a list of values)
375 pvalues = []
376 while True:
377 c = getChar()
378 if c == '"':
379 c = getChar()
380 pvalue = ''
381 while c >= ' ' and c not in ('\177', '"'):
382 pvalue += c
383 c = getChar()
384 # value is case-sensitive in this case
385 if c != '"':
386 err("Expected '\"'")
387 c = getChar()
388 else:
389 # unquoted value
390 pvalue = ''
391 while c >= ' ' and c not in ('\177', '"', ';', ':',
392 ','):
393 pvalue += c
394 c = getChar()
395 pvalue = pvalue.upper()
396 pvalues.append(pvalue)
397 if c != ',':
398 break
399 if len(pvalues) > 1:
400 params[param] = pvalues
401 else:
402 params[param] = pvalues[0]
403 # colon and value
404 if c != ':':
405 err("Expected ':'")
406 value = ''.join(it)
407 except StopIteration:
408 err("Syntax error")
409 else:
410 return (key, value, params)
411
412 _parseRow = staticmethod(_parseRow)
413
414 def _iterRow(self):
415 """A generator that returns one record at a time, as a tuple of
416 (name, value, params).
417 """
418 record = []
419 for line in self.file.readlines():
420 if line[0] in '\t ':
421 line = line[1:]
422 elif record:
423 row = "".join(record).decode(self.charset)
424 yield self._parseRow(row)
425 record = []
426 if line.endswith('\r\n'):
427 record.append(line[:-2])
428 elif line.endswith('\n'):
429 # strictly speaking this is a violation of RFC 2445
430 record.append(line[:-1])
431 else:
432 # strictly speaking this is a violation of RFC 2445
433 record.append(line)
434 if record:
435 row = "".join(record).decode(self.charset)
436 yield self._parseRow(row)
437
438 def iterEvents(self):
439 """Iterate over all VEVENT objects in an ICalendar file."""
440 iterator = self._iterRow()
441
442 # Check that the stream begins with BEGIN:VCALENDAR
443 try:
444 key, value, params = iterator.next()
445 if (key, value, params) != ('BEGIN', 'VCALENDAR', {}):
446 raise ICalParseError('This is not iCalendar')
447 except StopIteration:
448 # The file is empty. Mozilla produces a 0-length file when
449 # publishing an empty calendar. Let's accept it as a valid
450 # calendar that has no events. I'm not sure if a 0-length
451 # file is a valid text/calendar object according to RFC 2445.
452 raise
453 component_stack = ['VCALENDAR']
454
455 # Extract all VEVENT components
456 obj = None
457 for key, value, params in iterator:
458 if key == "BEGIN":
459 if obj is not None:
460 # Subcomponents terminate the processing of a VEVENT
461 # component. We can get away with this now, because we're
462 # not interested in alarms and RFC 2445 specifies, that all
463 # properties inside a VEVENT component ought to precede any
464 # VALARM subcomponents.
465 obj.validate()
466 yield obj
467 obj = None
468 if not component_stack and value != "VCALENDAR":
469 raise ICalParseError("Text outside VCALENDAR component")
470 if value == "VEVENT":
471 obj = VEvent()
472 component_stack.append(value)
473 elif key == "END":
474 if obj is not None and value == "VEVENT":
475 obj.validate()
476 yield obj
477 obj = None
478 if not component_stack or component_stack[-1] != value:
479 raise ICalParseError("Mismatched BEGIN/END")
480 component_stack.pop()
481 elif obj is not None:
482 obj.add(key, value, params)
483 elif not component_stack:
484 raise ICalParseError("Text outside VCALENDAR component")
485 if component_stack:
486 raise ICalParseError("Unterminated components")
487
488
489def parse_text(value):
490 r"""Parse iCalendar TEXT value.
491
492 >>> parse_text('Foo')
493 'Foo'
494 >>> parse_text('\\\\')
495 '\\'
496 >>> parse_text('\\;')
497 ';'
498 >>> parse_text('\\,')
499 ','
500 >>> parse_text('\\n')
501 '\n'
502 >>> parse_text('A string with\\; some\\\\ characters\\nin\\Nit')
503 'A string with; some\\ characters\nin\nit'
504 >>> parse_text('Unterminated \\')
505 Traceback (most recent call last):
506 ...
507 IndexError: string index out of range
508
509 """
510 if '\\' not in value:
511 return value
512 out = []
513 prev = 0
514 while True:
515 idx = value.find('\\', prev)
516 if idx == -1:
517 break
518 out.append(value[prev:idx])
519 if value[idx + 1] in 'nN':
520 out.append('\n')
521 else:
522 out.append(value[idx + 1])
523 prev = idx + 2
524 out.append(value[prev:])
525 return "".join(out)
526
527
528def parse_date(value):
529 """Parse iCalendar DATE value. Returns a date instance.
530
531 >>> parse_date('20030405')
532 datetime.date(2003, 4, 5)
533 >>> parse_date('20030405T060708')
534 Traceback (most recent call last):
535 ...
536 ValueError: Invalid iCalendar date: '20030405T060708'
537 >>> parse_date('')
538 Traceback (most recent call last):
539 ...
540 ValueError: Invalid iCalendar date: ''
541 >>> parse_date('yyyymmdd')
542 Traceback (most recent call last):
543 ...
544 ValueError: Invalid iCalendar date: 'yyyymmdd'
545 """
546 if len(value) != 8:
547 raise ValueError('Invalid iCalendar date: %r' % value)
548 try:
549 y, m, d = int(value[0:4]), int(value[4:6]), int(value[6:8])
550 except ValueError:
551 raise ValueError('Invalid iCalendar date: %r' % value)
552 else:
553 return datetime.date(y, m, d)
554
555
556def parse_date_time(value):
557 """Parse iCalendar DATE-TIME value. Returns a datetime instance.
558
559 A simple usage example:
560
561 >>> parse_date_time('20030405T060708')
562 datetime.datetime(2003, 4, 5, 6, 7, 8)
563
564 Examples of invalid arguments:
565
566 >>> parse_date_time('20030405T060708A')
567 Traceback (most recent call last):
568 ...
569 ValueError: Invalid iCalendar date-time: '20030405T060708A'
570 >>> parse_date_time('')
571 Traceback (most recent call last):
572 ...
573 ValueError: Invalid iCalendar date-time: ''
574
575 For timezone tests see tests.test_icalendar.TestParseDateTime.
576
577 """
578 datetime_rx = re.compile(r'(\d{4})(\d{2})(\d{2})'
579 r'T(\d{2})(\d{2})(\d{2})(Z?)$')
580 match = datetime_rx.match(value)
581 if match is None:
582 raise ValueError('Invalid iCalendar date-time: %r' % value)
583 y, m, d, hh, mm, ss, utc = match.groups()
584 dt = datetime.datetime(int(y), int(m), int(d),
585 int(hh), int(mm), int(ss))
586 if utc:
587 # In the future we might want to get the timezone from the iCalendar
588 # file, but for now using the local timezone of the server should
589 # be adequate.
590 timetuple = dt.timetuple()
591 ticks = calendar.timegm(timetuple)
592 dt = datetime.datetime.fromtimestamp(ticks)
593
594 return dt
595
596
597def parse_duration(value):
598 """Parse iCalendar DURATION value. Returns a timedelta instance.
599
600 >>> parse_duration('+P11D')
601 datetime.timedelta(11)
602 >>> parse_duration('-P2W')
603 datetime.timedelta(-14)
604 >>> parse_duration('P1DT2H3M4S')
605 datetime.timedelta(1, 7384)
606 >>> parse_duration('P1DT2H3M')
607 datetime.timedelta(1, 7380)
608 >>> parse_duration('P1DT2H')
609 datetime.timedelta(1, 7200)
610 >>> parse_duration('PT2H')
611 datetime.timedelta(0, 7200)
612 >>> parse_duration('PT2H3M4S')
613 datetime.timedelta(0, 7384)
614 >>> parse_duration('PT3M4S')
615 datetime.timedelta(0, 184)
616 >>> parse_duration('PT22S')
617 datetime.timedelta(0, 22)
618 >>> parse_duration('')
619 Traceback (most recent call last):
620 ...
621 ValueError: Invalid iCalendar duration: ''
622 >>> parse_duration('xyzzy')
623 Traceback (most recent call last):
624 ...
625 ValueError: Invalid iCalendar duration: 'xyzzy'
626 >>> parse_duration('P')
627 Traceback (most recent call last):
628 ...
629 ValueError: Invalid iCalendar duration: 'P'
630 >>> parse_duration('P1WT2H')
631 Traceback (most recent call last):
632 ...
633 ValueError: Invalid iCalendar duration: 'P1WT2H'
634 """
635 date_part = r'(\d+)D'
636 time_part = r'T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?'
637 datetime_part = '(?:%s)?(?:%s)?' % (date_part, time_part)
638 weeks_part = r'(\d+)W'
639 duration_rx = re.compile(r'([-+]?)P(?:%s|%s)$'
640 % (weeks_part, datetime_part))
641 match = duration_rx.match(value)
642 if match is None:
643 raise ValueError('Invalid iCalendar duration: %r' % value)
644 sign, weeks, days, hours, minutes, seconds = match.groups()
645 if weeks:
646 value = datetime.timedelta(weeks=int(weeks))
647 else:
648 if (days is None and hours is None
649 and minutes is None and seconds is None):
650 raise ValueError('Invalid iCalendar duration: %r'
651 % value)
652 value = datetime.timedelta(days=int(days or 0),
653 hours=int(hours or 0),
654 minutes=int(minutes or 0),
655 seconds=int(seconds or 0))
656 if sign == '-':
657 value = -value
658 return value
659
660
661def parse_period(value):
662 """Parse iCalendar PERIOD value. Returns a Period instance.
663
664 >>> p = parse_period('20030405T060708/20030405T060709')
665 >>> print repr(p).replace('),', '),\\n ')
666 Period(datetime.datetime(2003, 4, 5, 6, 7, 8),
667 datetime.datetime(2003, 4, 5, 6, 7, 9))
668 >>> parse_period('20030405T060708/PT1H1M1S')
669 Period(datetime.datetime(2003, 4, 5, 6, 7, 8), datetime.timedelta(0, 3661))
670 >>> parse_period('xyzzy')
671 Traceback (most recent call last):
672 ...
673 ValueError: Invalid iCalendar period: 'xyzzy'
674 >>> parse_period('foo/foe')
675 Traceback (most recent call last):
676 ...
677 ValueError: Invalid iCalendar period: 'foo/foe'
678 """
679 parts = value.split('/')
680 if len(parts) != 2:
681 raise ValueError('Invalid iCalendar period: %r' % value)
682 try:
683 start = parse_date_time(parts[0])
684 try:
685 end_or_duration = parse_date_time(parts[1])
686 except ValueError:
687 end_or_duration = parse_duration(parts[1])
688 except ValueError:
689 raise ValueError('Invalid iCalendar period: %r' % value)
690 else:
691 return Period(start, end_or_duration)
692
693
694## TODO: implement recurrences
695
696## def _parse_recurrence_weekly(args):
697## """Parse iCalendar weekly recurrence rule.
698##
699## args is a mapping from attribute names in RRULE to their string values.
700##
701## >>> _parse_recurrence_weekly({})
702## WeeklyRecurrenceRule(1, None, None, (), ())
703## >>> _parse_recurrence_weekly({'BYDAY': 'WE'})
704## WeeklyRecurrenceRule(1, None, None, (), (2,))
705## >>> _parse_recurrence_weekly({'BYDAY': 'MO,WE,SU'})
706## WeeklyRecurrenceRule(1, None, None, (), (0, 2, 6))
707##
708## """
709## from schooltool.cal import WeeklyRecurrenceRule
710## weekdays = []
711## days = args.get('BYDAY', None)
712## if days is not None:
713## for day in days.split(','):
714## weekdays.append(ical_weekdays.index(day))
715## return WeeklyRecurrenceRule(weekdays=weekdays)
716##
717##
718## def _parse_recurrence_monthly(args):
719## """Parse iCalendar monthly recurrence rule.
720##
721## args is a mapping from attribute names in RRULE to their string values.
722##
723## Month-day recurrency is the default:
724##
725## >>> _parse_recurrence_monthly({})
726## MonthlyRecurrenceRule(1, None, None, (), 'monthday')
727##
728## 3rd Tuesday in a month:
729##
730## >>> _parse_recurrence_monthly({'BYDAY': '3TU'})
731## MonthlyRecurrenceRule(1, None, None, (), 'weekday')
732##
733## Last Wednesday:
734##
735## >>> _parse_recurrence_monthly({'BYDAY': '-1WE'})
736## MonthlyRecurrenceRule(1, None, None, (), 'lastweekday')
737## """
738## from schooltool.cal import MonthlyRecurrenceRule
739## if 'BYDAY' in args:
740## if args['BYDAY'][0] == '-':
741## monthly = 'lastweekday'
742## else:
743## monthly = 'weekday'
744## else:
745## monthly = 'monthday'
746## return MonthlyRecurrenceRule(monthly=monthly)
747##
748##
749## def parse_recurrence_rule(value):
750## """Parse iCalendar RRULE entry.
751##
752## Returns the corresponding subclass of RecurrenceRule.
753##
754## params is a mapping from attribute names in RRULE to their string values,
755##
756## A trivial example of a daily recurrence:
757##
758## >>> parse_recurrence_rule('FREQ=DAILY')
759## DailyRecurrenceRule(1, None, None, ())
760##
761## A slightly more complex example:
762##
763## >>> parse_recurrence_rule('FREQ=DAILY;INTERVAL=5;COUNT=7')
764## DailyRecurrenceRule(5, 7, None, ())
765##
766## An example that includes use of UNTIL:
767##
768## >>> parse_recurrence_rule('FREQ=DAILY;UNTIL=20041008T000000')
769## DailyRecurrenceRule(1, None, datetime.datetime(2004, 10, 8, 0, 0), ())
770## >>> parse_recurrence_rule('FREQ=DAILY;UNTIL=20041008')
771## DailyRecurrenceRule(1, None, datetime.datetime(2004, 10, 8, 0, 0), ())
772##
773## Of course, other recurrence frequencies may be used:
774##
775## >>> parse_recurrence_rule('FREQ=WEEKLY;BYDAY=MO,WE,SU')
776## WeeklyRecurrenceRule(1, None, None, (), (0, 2, 6))
777## >>> parse_recurrence_rule('FREQ=MONTHLY')
778## MonthlyRecurrenceRule(1, None, None, (), 'monthday')
779## >>> parse_recurrence_rule('FREQ=YEARLY')
780## YearlyRecurrenceRule(1, None, None, ())
781##
782## You have to provide a valid recurrence frequency, or you will get an error:
783##
784## >>> parse_recurrence_rule('')
785## Traceback (most recent call last):
786## ...
787## ValueError: Invalid frequency of recurrence: None
788## >>> parse_recurrence_rule('FREQ=bogus')
789## Traceback (most recent call last):
790## ...
791## ValueError: Invalid frequency of recurrence: 'bogus'
792##
793## Unknown keys in params are ignored silently:
794##
795## >>> parse_recurrence_rule('FREQ=DAILY;WHATEVER=IGNORED')
796## DailyRecurrenceRule(1, None, None, ())
797##
798## """
799## from schooltool.cal import DailyRecurrenceRule, YearlyRecurrenceRule
800##
801## # split up the given value into parameters
802## params = {}
803## if value:
804## for pair in value.split(';'):
805## k, v = pair.split('=', 1)
806## params[k] = v
807##
808## # parse common recurrency attributes
809## interval = int(params.pop('INTERVAL', '1'))
810## count = params.pop('COUNT', None)
811## if count is not None:
812## count = int(count)
813## until = params.pop('UNTIL', None)
814## if until is not None:
815## if len(until) == 8:
816## until = datetime.datetime.combine(parse_date(until),
817## datetime.time(0, 0))
818## else:
819## until = parse_date_time(until)
820##
821## # instantiate the corresponding recurrence rule
822## freq = params.pop('FREQ', None)
823## if freq == 'DAILY':
824## rule = DailyRecurrenceRule()
825## elif freq == 'WEEKLY':
826## rule = _parse_recurrence_weekly(params)
827## elif freq == 'MONTHLY':
828## rule = _parse_recurrence_monthly(params)
829## elif freq == 'YEARLY':
830## rule = YearlyRecurrenceRule()
831## else:
832## raise ValueError('Invalid frequency of recurrence: %r' % freq)
833##
834## return rule.replace(interval=interval, count=count, until=until)
835##
836
837def parse_recurrence_rule(value):
838 return None # XXX
839
840
841class VEvent:
842 """iCalendar event.
843
844 Life cycle: when a VEvent is created, a number of properties should be
845 added to it using the add method. Then validate should be called.
846 After that you can start using query methods (getOne, hasProp, iterDates).
847
848 Events are classified into two kinds:
849 - normal events
850 - all-day events
851
852 All-day events are identified by their DTSTART property having a DATE value
853 instead of the default DATE-TIME. All-day events should satisfy the
854 following requirements (otherwise an exception will be raised):
855 - DURATION property (if defined) should be an integral number of days
856 - DTEND property (if defined) should have a DATE value
857 - any RDATE and EXDATE properties should only contain DATE values
858
859 The first two requirements are stated in RFC 2445; I'm not so sure about
860 the third one.
861 """
862
863 default_type = {
864 # Default value types for some properties
865 'DTSTAMP': 'DATE-TIME',
866 'DTSTART': 'DATE-TIME',
867 'CREATED': 'DATE-TIME',
868 'DTEND': 'DATE-TIME',
869 'DURATION': 'DURATION',
870 'LAST-MODIFIED': 'DATE-TIME',
871 'PRIORITY': 'INTEGER',
872 'RECURRENCE-ID': 'DATE-TIME',
873 'SEQUENCE': 'INTEGER',
874 'URL': 'URI',
875 'ATTACH': 'URI',
876 'EXDATE': 'DATE-TIME',
877 'EXRULE': 'RECUR',
878 'RDATE': 'DATE-TIME',
879 'RRULE': 'RECUR',
880 'LOCATION': 'TEXT',
881 'UID': 'TEXT',
882 }
883
884 converters = {
885 'INTEGER': int,
886 'DATE': parse_date,
887 'DATE-TIME': parse_date_time,
888 'DURATION': parse_duration,
889 'PERIOD': parse_period,
890 'TEXT': parse_text,
891 'RECUR': parse_recurrence_rule,
892 }
893
894 singleton_properties = set([
895 'DTSTAMP',
896 'DTSTART',
897 'UID',
898 'CLASS',
899 'CREATED',
900 'DESCRIPTION',
901 'DTEND',
902 'DURATION',
903 'GEO',
904 'LAST-MODIFIED',
905 'LOCATION',
906 'ORGANIZER',
907 'PRIORITY',
908 'RECURRENCE-ID',
909 'SEQUENCE',
910 'STATUS',
911 'SUMMARY',
912 'TRANSP',
913 'URL',
914 ])
915
916 rdate_types = set(['DATE', 'DATE-TIME', 'PERIOD'])
917 exdate_types = set(['DATE', 'DATE-TIME'])
918
919 def __init__(self):
920 self._props = {}
921
922 def add(self, property, value, params=None):
923 """Add a property.
924
925 Property name is case insensitive. Params should be a dict from
926 uppercased parameter names to their values.
927
928 Multiple calls to add with the same property name override the
929 value. This is sufficient for now, but will have to be changed
930 soon.
931 """
932 if params is None:
933 params = {}
934 key = property.upper()
935 if key in self._props:
936 if key in self.singleton_properties:
937 raise ICalParseError("Property %s cannot occur more than once"
938 % key)
939 self._props[key].append((value, params))
940 else:
941 self._props[key] = [(value, params)]
942
943 def validate(self):
944 """Check that this VEvent has all the necessary properties.
945
946 Also sets the following attributes:
947 uid The unique id of this event
948 summary Textual summary of this event
949 all_day_event True if this is an all-day event
950 dtstart start of the event (inclusive)
951 dtend end of the event (not inclusive)
952 duration length of the event
953 location location of the event
954 rrule recurrency rule
955 rdates a list of recurrence dates or periods
956 exdates a list of exception dates
957 """
958 if not self.hasProp('UID'):
959 raise ICalParseError("VEVENT must have a UID property")
960 if not self.hasProp('DTSTART'):
961 raise ICalParseError("VEVENT must have a DTSTART property")
962 if self._getType('DTSTART') not in ('DATE', 'DATE-TIME'):
963 raise ICalParseError("DTSTART property should have a DATE or"
964 " DATE-TIME value")
965 if self.hasProp('DTEND'):
966 if self._getType('DTEND') != self._getType('DTSTART'):
967 raise ICalParseError("DTEND property should have the same type"
968 " as DTSTART")
969 if self.hasProp('DURATION'):
970 raise ICalParseError("VEVENT cannot have both a DTEND"
971 " and a DURATION property")
972 if self.hasProp('DURATION'):
973 if self._getType('DURATION') != 'DURATION':
974 raise ICalParseError("DURATION property should have type"
975 " DURATION")
976
977 self.uid = self.getOne('UID')
978 self.summary = self.getOne('SUMMARY')
979
980 self.all_day_event = self._getType('DTSTART') == 'DATE'
981 self.dtstart = self.getOne('DTSTART')
982 if self.hasProp('DURATION'):
983 self.duration = self.getOne('DURATION')
984 self.dtend = self.dtstart + self.duration
985 else:
986 self.dtend = self.getOne('DTEND', None)
987 if self.dtend is None:
988 self.dtend = self.dtstart
989 if self.all_day_event:
990 self.dtend += datetime.date.resolution
991 self.duration = self.dtend - self.dtstart
992
993 self.location = self.getOne('LOCATION', None)
994
995 if self.dtstart > self.dtend:
996 raise ICalParseError("Event start time should precede end time")
997 elif self.all_day_event and self.dtstart == self.dtend:
998 raise ICalParseError("Event start time should precede end time")
999
1000 self.rdates = self._extractListOfDates('RDATE', self.rdate_types,
1001 self.all_day_event)
1002 self.exdates = self._extractListOfDates('EXDATE', self.exdate_types,
1003 self.all_day_event)
1004
1005 self.rrule = self.getOne('RRULE', None)
1006 if self.rrule is not None and self.exdates:
1007 if self._getType('EXDATE') == 'DATE-TIME':
1008 exceptions = [dt.date() for dt in self.exdates]
1009 else:
1010 exceptions = self.exdates
1011 self.rrule = self.rrule.replace(exceptions=exceptions)
1012
1013 def _extractListOfDates(self, key, accepted_types, all_day_event):
1014 """Parse a comma separated list of values.
1015
1016 If all_day_event is True, only accepts DATE values. Otherwise accepts
1017 all value types listed in 'accepted_types'.
1018 """
1019 dates = []
1020 default_type = self.default_type[key]
1021 for value, params in self._props.get(key, []):
1022 value_type = params.get('VALUE', default_type)
1023 if value_type not in accepted_types:
1024 raise ICalParseError('Invalid value type for %s: %s'
1025 % (key, value_type))
1026 if all_day_event and value_type != 'DATE':
1027 raise ICalParseError('I do not understand how to interpret '
1028 '%s values in %s for all-day events.'
1029 % (value_type, key))
1030 converter = self.converters.get(value_type)
1031 dates.extend(map(converter, value.split(',')))
1032 return dates
1033
1034 def _getType(self, property):
1035 """Return the type of the property value."""
1036 key = property.upper()
1037 values = self._props[key]
1038 assert len(values) == 1
1039 value, params = values[0]
1040 default_type = self.default_type.get(key, 'TEXT')
1041 return params.get('VALUE', default_type)
1042
1043 def getOne(self, property, default=None):
1044 """Return the value of a property as an appropriate Python object.
1045
1046 Only call getOne for properties that do not occur more than once.
1047 """
1048 try:
1049 values = self._props[property.upper()]
1050 assert len(values) == 1
1051 value, params = values[0]
1052 except KeyError:
1053 return default
1054 else:
1055 converter = self.converters.get(self._getType(property))
1056 if converter is None:
1057 return value
1058 else:
1059 return converter(value)
1060
1061 def hasProp(self, property):
1062 """Return True if this VEvent has a named property."""
1063 return property.upper() in self._props
1064
1065 def iterDates(self):
1066 """Iterate over all dates within this event.
1067
1068 This is only valid for all-day events at the moment.
1069 """
1070 if not self.all_day_event:
1071 raise ValueError('iterDates is only defined for all-day events')
1072
1073 # Find out the set of start dates
1074 start_set = {self.dtstart: None}
1075 for rdate in self.rdates:
1076 start_set[rdate] = rdate
1077 for exdate in self.exdates:
1078 if exdate in start_set:
1079 del start_set[exdate]
1080
1081 # Find out the set of all dates
1082 date_set = set(start_set)
1083 duration = self.duration.days
1084 for d in start_set:
1085 for n in range(1, duration):
1086 d += datetime.date.resolution
1087 date_set.add(d)
1088
1089 # Yield all dates in chronological order
1090 dates = list(date_set)
1091 dates.sort()
1092 for d in dates:
1093 yield d
1094
1095
1096class Period:
1097 """A period of time"""
1098
1099 def __init__(self, start, end_or_duration):
1100 self.start = start
1101 self.end_or_duration = end_or_duration
1102 if isinstance(end_or_duration, datetime.timedelta):
1103 self.duration = end_or_duration
1104 self.end = self.start + self.duration
1105 else:
1106 self.end = end_or_duration
1107 self.duration = self.end - self.start
1108 if self.start > self.end:
1109 raise ValueError("Start time is greater than end time")
1110
1111 def __repr__(self):
1112 return "Period(%r, %r)" % (self.start, self.end_or_duration)
1113
1114 def __cmp__(self, other):
1115 if not isinstance(other, Period):
1116 raise NotImplementedError('Cannot compare Period with %r' % other)
1117 return cmp((self.start, self.end), (other.start, other.end))
1118
1119 def overlaps(self, other):
1120 if self.start > other.start:
1121 return other.overlaps(self)
1122 if self.start <= other.start < self.end:
1123 return True
1124 return False
1125
1126class ICalParseError(Exception):
1127 """Invalid syntax in an iCalendar file."""
11280
=== removed file 'lib/schoolbell/interfaces.py'
--- lib/schoolbell/interfaces.py 2005-11-17 21:44:26 +0000
+++ lib/schoolbell/interfaces.py 1970-01-01 00:00:00 +0000
@@ -1,396 +0,0 @@
1"""
2Interface definitions for SchoolBell.
3
4There are two interfaces for calendars: `ICalendar` for read-only calendars,
5and `IEditCalendar` for read-write calendars.
6
7Semantically calendars are unordered sets of events. Events themselves
8(`ICalendarEvent`) are immutable and comparable. If you have an editable
9calendar, and want to change an event in it, you need to create a new event
10object and put it into the calendar:
11
12 calendar.removeEvent(event)
13 replacement_event = event.replace(title=u"New title", ...)
14 calendar.addEvent(replacement_event)
15
16Calendars have globally unique IDs. If you are changing an event in the
17fashion demonstrated above, you should preserve its unique_id attribute.
18
19"""
20
21from zope.interface import Interface
22from zope.schema import Text, TextLine, Int, Datetime, Date, List, Set, Choice
23from zope.schema import Field, Object
24
25
26class ICalendar(Interface):
27 """Calendar.
28
29 A calendar is a set of calendar events (see ICalendarEvent). Recurring
30 events are listed only once.
31 """
32
33 def __iter__():
34 """Return an iterator over all events in this calendar.
35
36 The order of events is not defined.
37 """
38
39 def find(unique_id):
40 """Return an event with the given unique id.
41
42 Raises a KeyError if there is no event with this id.
43 """
44
45 def expand(first, last):
46 """Return an iterator over all expanded events in a given time period.
47
48 "Expanding" here refers to expanding recurring events, that is,
49 creating objects for all occurrences of recurring events. If a
50 recurring event has occurreces that overlap the specified time
51 interval, every such occurrence is represented as a new calendar event
52 with the `dtstart` attribute replaced with the date and time of that
53 occurrence. These events provide IExpandedCalendarEvent and have an
54 additional attribute which points to the original event.
55
56 `first` and `last` are datetime.datetimes and define a half-open
57 time interval.
58
59 The order of returned events is not defined.
60 """
61
62
63class IEditCalendar(ICalendar):
64 """Editable calendar.
65
66 Calendar events are read-only, so to change an event you need to remove
67 the old event, and add a replacement event in the calendar.
68 """
69
70 def clear():
71 """Remove all events."""
72
73 def addEvent(event):
74 """Add an event to the calendar.
75
76 Raises ValueError if an event with the same unique_id already exists
77 in the calendar.
78
79 Returns the newly added event (which may be a copy of the argument,
80 e.g. if the calendar needs its events to be instances of a particular
81 class).
82
83 It is perhaps not a good idea to add calendar events that have no
84 occurrences into calendars (see ICalendarEvent.hasOccurrences), as they
85 will be invisible in date-based of calendar views.
86
87 Do not call addEvent while iterating over the calendar.
88 """
89
90 def removeEvent(event):
91 """Remove event from the calendar.
92
93 Raises ValueError if event is not present in the calendar.
94
95 Do not call removeEvent while iterating over the calendar.
96 """
97
98 def update(calendar):
99 """Add all events from another calendar.
100
101 cal1.update(cal2)
102
103 is equivalent to
104
105 for event in cal2:
106 cal1.addEvent(event)
107 """
108
109
110class IRecurrenceRule(Interface):
111 """Base interface of the recurrence rules.
112
113 Recurrence rules are stored as attributes of ICalendarEvent. They
114 are also immutable and comparable. To modify the recurrence
115 rule of an event, you need to create a new recurrence rule, and a new
116 event:
117
118 replacement_rule = event.recurrence.replace(count=3, until=None)
119 replacement_event = event.replace(recurrence=replacement_rule)
120 calendar.removeEvent(event)
121 calendar.addEvent(replacement_event)
122
123 """
124
125 interval = Int(
126 title=u"Interval",
127 min=1,
128 description=u"""
129 Interval of recurrence (a positive integer).
130
131 For example, to indicate that an event occurs every second day,
132 you would create a DailyRecurrenceRule witl interval equal to 2.
133 """)
134
135 count = Int(
136 title=u"Count",
137 required=False,
138 description=u"""
139 Number of times the event is repeated.
140
141 Can be None or an integer value. If count is not None then
142 until must be None. If both count and until are None the
143 event repeats forever.
144 """)
145
146 until = Date(
147 title=u"Until",
148 required=False,
149 description=u"""
150 The date of the last recurrence of the event.
151
152 Can be None or a datetime.date instance. If until is not None
153 then count must be None. If both count and until are None the
154 event repeats forever.
155 """)
156
157 exceptions = List(
158 title=u"Exceptions",
159 value_type=Date(),
160 description=u"""
161 A list of days when this event does not occur.
162
163 Values in this list must be instances of datetime.date.
164 """)
165
166 def replace(**kw):
167 """Return a copy of this recurrence rule with new specified fields."""
168
169 def __eq__(other):
170 """See if self == other."""
171
172 def __ne__(other):
173 """See if self != other."""
174
175 def apply(event, enddate=None):
176 """Apply this rule to an event.
177
178 This is a generator that returns the dates on which the event should
179 recur. Be careful when iterating over these dates -- rules that do not
180 have either 'until' or 'count' attributes will go on forever.
181
182 The optional enddate attribute can be used to set a range on the dates
183 generated by this function (inclusive).
184 """
185
186 def iCalRepresentation(dtstart):
187 """Return the rule in iCalendar format.
188
189 Returns a list of strings. XXX more details, please
190
191 dtstart is a datetime representing the date that the recurring
192 event starts on.
193 """
194
195
196class IDailyRecurrenceRule(IRecurrenceRule):
197 """Daily recurrence."""
198
199
200class IYearlyRecurrenceRule(IRecurrenceRule):
201 """Yearly recurrence."""
202
203
204class IWeeklyRecurrenceRule(IRecurrenceRule):
205 """Weekly recurrence."""
206
207 weekdays = Set(
208 title=u"Weekdays",
209 value_type=Int(min=0, max=6),
210 description=u"""
211 A set of weekdays when this event occurs.
212
213 Weekdays are represented as integers from 0 (Monday) to 6 (Sunday).
214 This is what the `calendar` and `datetime` modules use.
215
216 The event repeats on the weekday of the first occurence even
217 if that weekday is not in this set.
218 """)
219
220
221class IMonthlyRecurrenceRule(IRecurrenceRule):
222 """Monthly recurrence."""
223
224 monthly = Choice(
225 title=u"Type",
226 values=('monthday', 'weekday', 'lastweekday'),
227 description=u"""
228 Specification of a monthly occurence.
229
230 Can be one of three values: 'monthday', 'weekday', 'lastweekday'.
231
232 'monthday' specifies that the event recurs on the same day of month
233 (e.g., 25th day of a month).
234
235 'weekday' specifies that the event recurs on the same week
236 within a month on the same weekday, indexed from the
237 first (e.g. 3rd Friday of a month).
238
239 'lastweekday' specifies that the event recurs on the same week
240 within a month on the same weekday, indexed from the
241 end of month (e.g. 2nd last Friday of a month).
242 """)
243
244
245class ICalendarEvent(Interface):
246 """A calendar event.
247
248 Calendar events are immutable and comparable.
249
250 Events are compared in chronological order, so lists of events can be
251 sorted. If two events start at the same time, they are ordered according
252 to their titles.
253
254 While `unique_id` is a globally unique ID of a calendar event, you can
255 have several calendar event objects with the same value of `unique_id`,
256 and they will not be equal if any their attributes are different.
257 Semantically these objects are different versions of the same calendar
258 event.
259
260 If you need to modify a calendar event in a calendar, you should do
261 the following:
262
263 calendar.removeEvent(event)
264 replacement_event = event.replace(title=u"New title", ...)
265 calendar.addEvent(replacement_event)
266
267 """
268
269 id = Int(
270 title=u"An internal ID for the event",
271 required=True, readonly=True,
272 description=u"""An ID for the event, guaranteed to be unique locally
273 but not globally.""")
274
275 calendar = Object(
276 title=u"Calendar",
277 schema=ICalendar,
278 description=u"""
279 The calendar this event belongs to.
280 """)
281
282 unique_id = TextLine(
283 title=u"UID",
284 description=u"""
285 A globally unique id for this calendar event.
286
287 iCalendar (RFC 2445) recommeds using the RFC 822 addr-spec syntax
288 for unique IDs. Put the current timestamp and a random number
289 on the left of the @ sign, and put the hostname on the right.
290 """)
291
292 dtstart = Datetime(
293 title=u"Starting date and time",
294 description=u"""Format: yyyy-mm-dd hh:mm"""
295 )
296
297 duration = Field(title=u"Duration")
298 # The duration of the event (datetime.timedelta).
299 # You can compute the event end date/time by adding duration to dtstart.
300 # zope.schema does not have TimeInterval.
301
302 title = TextLine(title=u"Name")
303
304 description = Text(title=u"Description")
305
306 location = TextLine(
307 title=u"Location",
308 required=False,
309 description=u"""Where the event will take place.""")
310
311 recurrence = Object(
312 title=u"Recurrence",
313 schema=IRecurrenceRule,
314 required=False,
315 description=u"""
316 The recurrence rule, if this is a recurring event, otherwise None.
317 """)
318
319 def replace(**kw):
320 """Return a calendar event with new specified fields.
321
322 This is useful for editing calendars. For example, to change the
323 title and location of an event in a calendar, you would do
324
325 calendar.removeEvent(event)
326 replacement_event = event.replace(title=u"New title",
327 location=None)
328 calendar.addEvent(replacement_event)
329
330 """
331
332 def __eq__(other):
333 """See if self == other."""
334
335 def __ne__(other):
336 """See if self != other."""
337
338 def __lt__(other):
339 """See if self < other."""
340
341 def __gt__(other):
342 """See if self > other."""
343
344 def __le__(other):
345 """See if self <= other."""
346
347 def __ge__(other):
348 """See if self >= other."""
349
350 def hasOccurrences():
351 """Does the event have any occurrences?
352
353 Normally all events have at least one occurrence. However if you have
354 a repeating event that repeats a finite number of times, and all those
355 repetitions are listed as exceptions, then hasOccurrences() will return
356 False. There are other corner cases as well (e.g. a recurring event
357 with until date that is earlier than dtstart).
358 """
359
360
361class IExpandedCalendarEvent(ICalendarEvent):
362 """A single occurrence of a recurring calendar event.
363
364 The original event is stored in the `original` attribute. The `dtstart`
365 attribute contains the date and time of this occurrence and may differ
366 from the `dtstart` attribute of the original event. All other attributes
367 are the same.
368 """
369
370 dtstart = Datetime(
371 title=u"Start",
372 description=u"""
373 Date and time when this occurrence of the event starts.
374 """)
375
376 original = Object(
377 title=u"Original",
378 schema=ICalendarEvent,
379 description=u"""
380 The recurring event that generated this occurrence.
381 """)
382
383 def replace(**kw):
384 """Return a calendar event with new specified fields.
385
386 expanded_event.replace(**kw)
387
388 is (almost) equivalent to
389
390 expanded_event.original.replace(**kw)
391
392 In other words, the returned event will not provide
393 IExpandedCalendarEvent and its dtstart attribute will be the date and
394 time of the original event rather than this specific occurrence.
395 """
396
3970
=== removed file 'lib/schoolbell/mixins.py'
--- lib/schoolbell/mixins.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/mixins.py 1970-01-01 00:00:00 +0000
@@ -1,314 +0,0 @@
1"""
2Mixins for implementing calendars.
3"""
4
5__metaclass__ = type
6
7
8class CalendarMixin:
9 """Mixin for implementing ICalendar methods.
10
11 You do not have to use this mixin, however it might make implementation
12 easier, albeit potentially slower.
13
14 A class that uses this mixin must already implement ICalendar.__iter__.
15
16 >>> from schoolbell.interfaces import ICalendar
17 >>> from zope.interface import implements
18 >>> class MyCalendar(CalendarMixin):
19 ... implements(ICalendar)
20 ... def __iter__(self):
21 ... return iter([])
22 >>> from zope.interface.verify import verifyObject
23 >>> verifyObject(ICalendar, MyCalendar())
24 True
25
26 """
27
28 def find(self, unique_id):
29 """Find a calendar event with a given UID.
30
31 This particular implementation simply performs a linear search by
32 iterating over all events and looking at their UIDs.
33
34 >>> from schoolbell.interfaces import ICalendar
35 >>> from zope.interface import implements
36
37 >>> class Event:
38 ... def __init__(self, uid):
39 ... self.unique_id = uid
40
41 >>> class MyCalendar(CalendarMixin):
42 ... implements(ICalendar)
43 ... def __iter__(self):
44 ... return iter([Event(uid) for uid in 'a', 'b'])
45 >>> cal = MyCalendar()
46
47 >>> cal.find('a').unique_id
48 'a'
49 >>> cal.find('b').unique_id
50 'b'
51 >>> cal.find('c')
52 Traceback (most recent call last):
53 ...
54 KeyError: 'c'
55
56 """
57 for event in self:
58 if event.unique_id == unique_id:
59 return event
60 raise KeyError(unique_id)
61
62 def expand(self, first, last):
63 """Return an iterator over all expanded events in a given time period.
64
65 See ICalendar for more details.
66
67 >>> from datetime import datetime, timedelta
68 >>> from schoolbell.interfaces import ICalendar
69 >>> from zope.interface import implements
70
71 >>> class Event:
72 ... def __init__(self, dtstart, duration, title):
73 ... self.dtstart = dtstart
74 ... self.duration = duration
75 ... self.title = title
76
77 >>> class MyCalendar(CalendarMixin):
78 ... implements(ICalendar)
79 ... def __iter__(self):
80 ... return iter([Event(datetime(2004, 12, 14, 12, 30),
81 ... timedelta(hours=1), 'a'),
82 ... Event(datetime(2004, 12, 15, 16, 30),
83 ... timedelta(hours=1), 'b'),
84 ... Event(datetime(2004, 12, 15, 14, 30),
85 ... timedelta(hours=1), 'c'),
86 ... Event(datetime(2004, 12, 16, 17, 30),
87 ... timedelta(hours=1), 'd'),
88 ... ])
89 >>> cal = MyCalendar()
90
91 We will define a convenience function for showing all events returned
92 by expand:
93
94 >>> def show(first, last):
95 ... events = cal.expand(first, last)
96 ... print '[%s]' % ', '.join([e.title for e in events])
97
98 Events that fall inside the interval
99
100 >>> show(datetime(2004, 12, 1), datetime(2004, 12, 31))
101 [a, b, c, d]
102
103 >>> show(datetime(2004, 12, 15), datetime(2004, 12, 16))
104 [b, c]
105
106 Events that fall partially in the interval
107
108 >>> show(datetime(2004, 12, 15, 17, 0),
109 ... datetime(2004, 12, 16, 18, 0))
110 [b, d]
111
112 Corner cases: if event.dtstart + event.duration == last, or
113 event.dtstart == first, the event is not included.
114
115 >>> show(datetime(2004, 12, 15, 15, 30),
116 ... datetime(2004, 12, 15, 16, 30))
117 []
118
119 TODO: recurring events
120
121 """
122 for event in self:
123 # TODO: recurring events
124 dtstart = event.dtstart
125 dtend = dtstart + event.duration
126 if dtend > first and dtstart < last:
127 yield event
128
129
130class EditableCalendarMixin:
131 """Mixin for implementing some IEditCalendar methods.
132
133 This mixin implements `clear` and `update` by using `addEvent` and
134 `removeEvent`.
135
136 >>> class Event:
137 ... def __init__(self, uid):
138 ... self.unique_id = uid
139
140 >>> class SampleCalendar(EditableCalendarMixin):
141 ... def __init__(self):
142 ... self._events = {}
143 ... def __iter__(self):
144 ... return self._events.itervalues()
145 ... def addEvent(self, event):
146 ... self._events[event.unique_id] = event
147 ... def removeEvent(self, event):
148 ... del self._events[event.unique_id]
149
150 >>> cal = SampleCalendar()
151 >>> cal.addEvent(Event('a'))
152 >>> cal.addEvent(Event('b'))
153 >>> cal.addEvent(Event('c'))
154 >>> len(list(cal))
155 3
156
157 >>> cal2 = SampleCalendar()
158 >>> cal2.update(cal)
159 >>> len(list(cal2))
160 3
161
162 >>> cal.clear()
163 >>> list(cal)
164 []
165
166 """
167
168 def update(self, calendar):
169 """Add all events from another calendar to this calendar."""
170 for event in calendar:
171 self.addEvent(event)
172
173 def clear(self):
174 """Remove all events from the calendar."""
175 for event in list(self):
176 self.removeEvent(event)
177
178
179class CalendarEventMixin:
180 """Mixin for implementing ICalendarEvent comparison methods.
181
182 Calendar events are equal iff all their attributes are equal. We can get a
183 list of those attributes easily because ICalendarEvent is a schema.
184
185 >>> from schoolbell.interfaces import ICalendarEvent
186 >>> from zope.schema import getFieldNames
187 >>> all_attrs = getFieldNames(ICalendarEvent)
188 >>> 'unique_id' in all_attrs
189 True
190 >>> '__eq__' not in all_attrs
191 True
192
193 We will create a bunch of Event objects that differ in exactly one
194 attribute and compare them.
195
196 >>> class Event(CalendarEventMixin):
197 ... def __init__(self, **kw):
198 ... for attr in all_attrs:
199 ... setattr(self, attr, '%s_default_value' % attr)
200 ... for attr, value in kw.items():
201 ... setattr(self, attr, value)
202
203 >>> e1 = Event()
204 >>> for attr in all_attrs:
205 ... e2 = Event()
206 ... setattr(e2, attr, 'some other value')
207 ... assert e1 != e2, 'change in %s was not noticed' % attr
208
209 If you have two events with the same values in all ICalendarEvent
210 attributes, they are equal
211
212 >>> e1 = Event()
213 >>> e2 = Event()
214 >>> e1 == e2
215 True
216
217 even if they have extra attributes
218
219 >>> e1 = Event()
220 >>> e1.annotation = 'a'
221 >>> e2 = Event()
222 >>> e2.annotation = 'b'
223 >>> e1 == e2
224 True
225
226 Events are ordered by their date and time, title and, finally, UID (to
227 break any ties and provide a stable consistent ordering).
228
229 >>> from datetime import datetime
230
231 >>> e1 = Event(dtstart=datetime(2004, 12, 15))
232 >>> e2 = Event(dtstart=datetime(2004, 12, 16))
233 >>> e1 < e2
234 True
235
236 >>> e1 = Event(dtstart=datetime(2004, 12, 15), title="A")
237 >>> e2 = Event(dtstart=datetime(2004, 12, 15), title="B")
238 >>> e1 < e2
239 True
240
241 >>> e1 = Event(dtstart=datetime(2004, 12, 1), title="A", unique_id="X")
242 >>> e2 = Event(dtstart=datetime(2004, 12, 1), title="A", unique_id="Y")
243 >>> e1 < e2
244 True
245
246 """
247
248 def __eq__(self, other):
249 """Check whether two calendar events are equal."""
250 return (self.unique_id, self.dtstart, self.duration, self.title,
251 self.location, self.recurrence) \
252 == (other.unique_id, other.dtstart, other.duration, other.title,
253 other.location, other.recurrence)
254
255 def __ne__(self, other):
256 return not self.__eq__(other)
257
258 def __lt__(self, other):
259 return (self.dtstart, self.title, self.unique_id) \
260 < (other.dtstart, other.title, other.unique_id)
261
262 def __gt__(self, other):
263 return (self.dtstart, self.title, self.unique_id) \
264 > (other.dtstart, other.title, other.unique_id)
265
266 def __le__(self, other):
267 return (self.dtstart, self.title, self.unique_id) \
268 <= (other.dtstart, other.title, other.unique_id)
269
270 def __ge__(self, other):
271 return (self.dtstart, self.title, self.unique_id) \
272 >= (other.dtstart, other.title, other.unique_id)
273
274 def hasOccurrences(self):
275 raise NotImplementedError # TODO
276
277 def replace(self, **kw):
278 r"""Return a copy of this event with some attributes replaced.
279
280 >>> from schoolbell.interfaces import ICalendarEvent
281 >>> from zope.schema import getFieldNames
282 >>> all_attrs = getFieldNames(ICalendarEvent)
283 >>> class Event(CalendarEventMixin):
284 ... def __init__(self, **kw):
285 ... for attr in all_attrs:
286 ... setattr(self, attr, '%s_default_value' % attr)
287 ... for attr, value in kw.items():
288 ... setattr(self, attr, value)
289
290 >>> from datetime import datetime, timedelta
291 >>> e1 = Event(dtstart=datetime(2004, 12, 15, 18, 57),
292 ... duration=timedelta(minutes=15),
293 ... title='Work on schoolbell.simple',
294 ... location=None)
295
296 >>> e2 = e1.replace(location=u'Matar\u00f3')
297 >>> e2 == e1
298 False
299 >>> e2.title == e1.title
300 True
301 >>> e2.location
302 u'Matar\xf3'
303
304 >>> e3 = e2.replace(location=None)
305 >>> e3 == e1
306 True
307
308 """
309 # The import is here to avoid cyclic dependencies
310 from schoolbell.simple import SimpleCalendarEvent
311 for attr in ['dtstart', 'duration', 'title', 'description',
312 'location', 'unique_id', 'recurrence']:
313 kw.setdefault(attr, getattr(self, attr))
314 return SimpleCalendarEvent(**kw)
3150
=== removed file 'lib/schoolbell/simple.py'
--- lib/schoolbell/simple.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/simple.py 1970-01-01 00:00:00 +0000
@@ -1,96 +0,0 @@
1"""
2Simple calendar events and calendars.
3"""
4
5import datetime
6import random
7import email.Utils
8from zope.interface import implements
9from schoolbell.interfaces import ICalendar, ICalendarEvent
10from schoolbell.mixins import CalendarEventMixin, CalendarMixin
11
12__metaclass__ = type
13
14
15class SimpleCalendarEvent(CalendarEventMixin):
16 """A simple implementation of ICalendarEvent.
17
18 >>> from datetime import datetime, timedelta
19 >>> from zope.interface.verify import verifyObject
20 >>> e = SimpleCalendarEvent(datetime(2004, 12, 15, 18, 57),
21 ... timedelta(minutes=15),
22 ... 'Work on schoolbell.simple')
23 >>> verifyObject(ICalendarEvent, e)
24 True
25
26 If you do not specify a unique ID, a random one is generated
27
28 >>> e.unique_id is not None
29 True
30
31 """
32
33 implements(ICalendarEvent)
34
35 def __init__(self, dtstart, duration, title, description=None, location=None, unique_id=None,
36 recurrence=None):
37 self.dtstart = dtstart
38 self.duration = duration
39 self.title = title
40 self.description=description
41 self.location = location
42 self.recurrence = recurrence
43 self.unique_id = unique_id
44 if not self.unique_id:
45 self.unique_id = new_unique_id()
46
47
48class ImmutableCalendar(CalendarMixin):
49 """A simple read-only calendar.
50
51 >>> from datetime import datetime, timedelta
52 >>> from zope.interface.verify import verifyObject
53 >>> e = SimpleCalendarEvent(datetime(2004, 12, 15, 18, 57),
54 ... timedelta(minutes=15),
55 ... 'Work on schoolbell.simple')
56 >>> calendar = ImmutableCalendar([e])
57 >>> verifyObject(ICalendar, calendar)
58 True
59
60 >>> [e.title for e in calendar]
61 ['Work on schoolbell.simple']
62
63 """
64
65 implements(ICalendar)
66
67 def __init__(self, events=()):
68 self._events = tuple(events)
69
70 def __iter__(self):
71 return iter(self._events)
72
73
74def new_unique_id():
75 """Generate a new unique ID for a calendar event.
76
77 UID is randomly generated and follows RFC 822 addr-spec:
78
79 >>> uid = new_unique_id()
80 >>> '@' in uid
81 True
82
83 Note that it does not have the angle brackets
84
85 >>> '<' not in uid
86 True
87 >>> '>' not in uid
88 True
89
90 """
91 more_uniqueness = '%d.%d' % (datetime.datetime.now().microsecond,
92 random.randrange(10 ** 6, 10 ** 7))
93 # generate an rfc-822 style id and strip angle brackets
94 unique_id = email.Utils.make_msgid(more_uniqueness)[1:-1]
95 return unique_id
96
970
=== removed directory 'lib/schoolbell/tests'
=== removed file 'lib/schoolbell/tests/__init__.py'
--- lib/schoolbell/tests/__init__.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/tests/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,1 +0,0 @@
1"""Unit tests for SchoolBell"""
20
=== removed file 'lib/schoolbell/tests/test_icalendar.py'
--- lib/schoolbell/tests/test_icalendar.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/tests/test_icalendar.py 1970-01-01 00:00:00 +0000
@@ -1,782 +0,0 @@
1"""
2Unit tests for schoolbell.icalendar
3"""
4
5import unittest
6import difflib
7import time
8import os
9from pprint import pformat
10from textwrap import dedent
11from datetime import date, timedelta, datetime
12from StringIO import StringIO
13
14from zope.testing import doctest
15
16
17def diff(old, new, oldlabel="expected output", newlabel="actual output"):
18 """Display a unified diff between old text and new text."""
19 old = old.splitlines()
20 new = new.splitlines()
21
22 diff = ['--- %s' % oldlabel, '+++ %s' % newlabel]
23
24 def dump(tag, x, lo, hi):
25 for i in xrange(lo, hi):
26 diff.append(tag + x[i])
27
28 differ = difflib.SequenceMatcher(a=old, b=new)
29 for tag, alo, ahi, blo, bhi in differ.get_opcodes():
30 if tag == 'replace':
31 dump('-', old, alo, ahi)
32 dump('+', new, blo, bhi)
33 elif tag == 'delete':
34 dump('-', old, alo, ahi)
35 elif tag == 'insert':
36 dump('+', new, blo, bhi)
37 elif tag == 'equal':
38 dump(' ', old, alo, ahi)
39 else:
40 raise AssertionError('unknown tag %r' % tag)
41 return "\n".join(diff)
42
43
44class TimezoneTestMixin:
45 """A mixin for tests that fiddle with timezones."""
46
47 def setUp(self):
48 self.have_tzset = hasattr(time, 'tzset')
49 self.touched_tz = False
50 self.old_tz = os.getenv('TZ')
51
52 def tearDown(self):
53 if self.touched_tz:
54 self.setTZ(self.old_tz)
55
56 def setTZ(self, tz):
57 self.touched_tz = True
58 if tz is None:
59 os.unsetenv('TZ')
60 else:
61 os.putenv('TZ', tz)
62 time.tzset()
63
64
65class TestParseDateTime(TimezoneTestMixin, unittest.TestCase):
66
67 def test_timezones(self):
68 # The simple tests are in the doctest of parse_date_time.
69 from schoolbell.icalendar import parse_date_time
70
71 if not self.have_tzset:
72 return # Do not run this test on Windows
73
74 self.setTZ('UTC')
75 dt = parse_date_time('20041029T125031Z')
76 self.assertEquals(dt, datetime(2004, 10, 29, 12, 50, 31))
77
78 self.setTZ('EET-2EEST')
79 dt = parse_date_time('20041029T095031Z') # daylight savings
80 self.assertEquals(dt, datetime(2004, 10, 29, 12, 50, 31))
81 dt = parse_date_time('20041129T095031Z') # no daylight savings
82 self.assertEquals(dt, datetime(2004, 11, 29, 11, 50, 31))
83
84
85class TestPeriod(unittest.TestCase):
86
87 def test(self):
88 from schoolbell.icalendar import Period
89 dt1 = datetime(2001, 2, 3, 14, 30, 5)
90 dt2 = datetime(2001, 2, 3, 16, 35, 20)
91 td = dt2 - dt1
92 p1 = Period(dt1, dt2)
93 self.assertEquals(p1.start, dt1)
94 self.assertEquals(p1.end, dt2)
95 self.assertEquals(p1.duration, td)
96
97 p2 = Period(dt1, td)
98 self.assertEquals(p2.start, dt1)
99 self.assertEquals(p2.end, dt2)
100 self.assertEquals(p2.duration, td)
101
102 self.assertEquals(p1, p2)
103
104 p = Period(dt1, timedelta(0))
105 self.assertEquals(p.start, dt1)
106 self.assertEquals(p.end, dt1)
107 self.assertEquals(p.duration, timedelta(0))
108
109 self.assertRaises(ValueError, Period, dt2, dt1)
110 self.assertRaises(ValueError, Period, dt1, -td)
111
112 def test_overlap(self):
113 from schoolbell.icalendar import Period
114 p1 = Period(datetime(2004, 1, 1, 12, 0), timedelta(hours=1))
115 p2 = Period(datetime(2004, 1, 1, 11, 30), timedelta(hours=1))
116 p3 = Period(datetime(2004, 1, 1, 12, 30), timedelta(hours=1))
117 p4 = Period(datetime(2004, 1, 1, 11, 0), timedelta(hours=3))
118
119 self.assert_(p1.overlaps(p2))
120 self.assert_(p2.overlaps(p1))
121
122 self.assert_(p1.overlaps(p3))
123 self.assert_(p3.overlaps(p1))
124
125 self.assert_(not p2.overlaps(p3))
126 self.assert_(not p3.overlaps(p2))
127
128 self.assert_(p1.overlaps(p4))
129 self.assert_(p4.overlaps(p1))
130
131 self.assert_(p1.overlaps(p1))
132
133
134class TestVEvent(unittest.TestCase):
135
136 def test_add(self):
137 from schoolbell.icalendar import VEvent, ICalParseError
138 vevent = VEvent()
139 value, params = 'bar', {'VALUE': 'TEXT'}
140 vevent.add('foo', value, params)
141 self.assertEquals(vevent._props, {'FOO': [(value, params)]})
142 value2 = 'guug'
143 vevent.add('fie', value2)
144 self.assertEquals(vevent._props, {'FOO': [(value, params)],
145 'FIE': [(value2, {})]})
146 vevent.add('fie', value, params)
147 self.assertEquals(vevent._props, {'FOO': [(value, params)],
148 'FIE': [(value2, {}),
149 (value, params)]})
150
151 # adding a singleton property twice
152 vevent.add('uid', '1')
153 self.assertRaises(ICalParseError, vevent.add, 'uid', '2')
154
155 def test_hasProp(self):
156 from schoolbell.icalendar import VEvent
157 vevent = VEvent()
158 vevent.add('foo', 'bar', {})
159 self.assert_(vevent.hasProp('foo'))
160 self.assert_(vevent.hasProp('Foo'))
161 self.assert_(not vevent.hasProp('baz'))
162
163 def test__getType(self):
164 from schoolbell.icalendar import VEvent
165 vevent = VEvent()
166 vevent.add('x-explicit', '', {'VALUE': 'INTEGER'})
167 vevent.add('dtstart', 'implicit type', {})
168 vevent.add('x-default', '', {})
169 self.assertEquals(vevent._getType('x-explicit'), 'INTEGER')
170 self.assertEquals(vevent._getType('dtstart'), 'DATE-TIME')
171 self.assertEquals(vevent._getType('x-default'), 'TEXT')
172 self.assertEquals(vevent._getType('X-Explicit'), 'INTEGER')
173 self.assertEquals(vevent._getType('DtStart'), 'DATE-TIME')
174 self.assertEquals(vevent._getType('X-Default'), 'TEXT')
175 self.assertRaises(KeyError, vevent._getType, 'nonexistent')
176
177 def test_getOne(self):
178 from schoolbell.icalendar import VEvent
179 vevent = VEvent()
180
181 vevent.add('foo', 'bar', {})
182 self.assertEquals(vevent.getOne('foo'), 'bar')
183 self.assertEquals(vevent.getOne('Foo'), 'bar')
184 self.assertEquals(vevent.getOne('baz'), None)
185 self.assertEquals(vevent.getOne('baz', 'quux'), 'quux')
186 self.assertEquals(vevent.getOne('dtstart', 'quux'), 'quux')
187
188 vevent.add('int-foo', '42', {'VALUE': 'INTEGER'})
189 vevent.add('int-bad', 'xyzzy', {'VALUE': 'INTEGER'})
190 self.assertEquals(vevent.getOne('int-foo'), 42)
191 self.assertEquals(vevent.getOne('Int-Foo'), 42)
192 self.assertRaises(ValueError, vevent.getOne, 'int-bad')
193
194 vevent.add('date-foo', '20030405', {'VALUE': 'DATE'})
195 vevent.add('date-bad1', '20030405T1234', {'VALUE': 'DATE'})
196 vevent.add('date-bad2', '2003', {'VALUE': 'DATE'})
197 vevent.add('date-bad3', '200301XX', {'VALUE': 'DATE'})
198 self.assertEquals(vevent.getOne('date-Foo'), date(2003, 4, 5))
199 self.assertRaises(ValueError, vevent.getOne, 'date-bad1')
200 self.assertRaises(ValueError, vevent.getOne, 'date-bad2')
201 self.assertRaises(ValueError, vevent.getOne, 'date-bad3')
202
203 vevent.add('datetime-foo1', '20030405T060708', {'VALUE': 'DATE-TIME'})
204 vevent.add('datetime-foo2', '20030405T060708', {'VALUE': 'DATE-TIME'})
205 vevent.add('datetime-bad1', '20030405T010203444444',
206 {'VALUE': 'DATE-TIME'})
207 vevent.add('datetime-bad2', '2003', {'VALUE': 'DATE-TIME'})
208 self.assertEquals(vevent.getOne('datetime-foo1'),
209 datetime(2003, 4, 5, 6, 7, 8))
210 self.assertEquals(vevent.getOne('Datetime-Foo2'),
211 datetime(2003, 4, 5, 6, 7, 8))
212 self.assertRaises(ValueError, vevent.getOne, 'datetime-bad1')
213 self.assertRaises(ValueError, vevent.getOne, 'datetime-bad2')
214
215 vevent.add('dur-foo1', '+P11D', {'VALUE': 'DURATION'})
216 vevent.add('dur-foo2', '-P2W', {'VALUE': 'DURATION'})
217 vevent.add('dur-foo3', 'P1DT2H3M4S', {'VALUE': 'DURATION'})
218 vevent.add('dur-foo4', 'PT2H', {'VALUE': 'DURATION'})
219 vevent.add('dur-bad1', 'xyzzy', {'VALUE': 'DURATION'})
220 self.assertEquals(vevent.getOne('dur-foo1'), timedelta(days=11))
221 self.assertEquals(vevent.getOne('Dur-Foo2'), -timedelta(weeks=2))
222 self.assertEquals(vevent.getOne('Dur-Foo3'),
223 timedelta(days=1, hours=2, minutes=3, seconds=4))
224 self.assertEquals(vevent.getOne('DUR-FOO4'), timedelta(hours=2))
225 self.assertRaises(ValueError, vevent.getOne, 'dur-bad1')
226
227 vevent.add('unknown', 'magic', {'VALUE': 'UNKNOWN-TYPE'})
228 self.assertEquals(vevent.getOne('unknown'), 'magic')
229
230 def test_iterDates(self):
231 from schoolbell.icalendar import VEvent
232 vevent = VEvent()
233 vevent.all_day_event = True
234 vevent.dtstart = date(2003, 1, 2)
235 vevent.dtend = date(2003, 1, 5)
236 vevent.duration = timedelta(days=3)
237 vevent.rdates = []
238 vevent.exdates = []
239 self.assertEquals(list(vevent.iterDates()),
240 [date(2003, 1, 2), date(2003, 1, 3), date(2003, 1, 4)])
241
242 vevent.all_day_event = False;
243 self.assertRaises(ValueError, list, vevent.iterDates())
244
245 def test_iterDates_with_rdate_exdate(self):
246 from schoolbell.icalendar import VEvent
247 vevent = VEvent()
248 vevent.all_day_event = True
249 vevent.dtstart = date(2003, 1, 5)
250 vevent.dtend = date(2003, 1, 6)
251 vevent.duration = timedelta(days=1)
252 vevent.rdates = [date(2003, 1, 2), date(2003, 1, 8), date(2003, 1, 8)]
253 vevent.exdates = []
254 expected = [date(2003, 1, 2), date(2003, 1, 5), date(2003, 1, 8)]
255 self.assertEquals(list(vevent.iterDates()), expected)
256
257 vevent.exdates = [date(2003, 1, 6)]
258 expected = [date(2003, 1, 2), date(2003, 1, 5), date(2003, 1, 8)]
259 self.assertEquals(list(vevent.iterDates()), expected)
260
261 vevent.exdates = [date(2003, 1, 2), date(2003, 1, 2)]
262 expected = [date(2003, 1, 5), date(2003, 1, 8)]
263 self.assertEquals(list(vevent.iterDates()), expected)
264
265 vevent.exdates = [date(2003, 1, 5)]
266 expected = [date(2003, 1, 2), date(2003, 1, 8)]
267 self.assertEquals(list(vevent.iterDates()), expected)
268
269 vevent.dtend = date(2003, 1, 7)
270 vevent.duration = timedelta(days=2)
271 vevent.exdates = [date(2003, 1, 5), date(2003, 1, 3)]
272 expected = [date(2003, 1, 2), date(2003, 1, 3),
273 date(2003, 1, 8), date(2003, 1, 9)]
274 self.assertEquals(list(vevent.iterDates()), expected)
275
276 def test_validate_error_cases(self):
277 from schoolbell.icalendar import VEvent, ICalParseError
278
279 vevent = VEvent()
280 self.assertRaises(ICalParseError, vevent.validate)
281
282 vevent = VEvent()
283 vevent.add('dtstart', 'xyzzy', {'VALUE': 'TEXT'})
284 self.assertRaises(ICalParseError, vevent.validate)
285
286 vevent = VEvent()
287 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
288 vevent.add('dtend', '20010203T0000', {'VALUE': 'DATE-TIME'})
289 self.assertRaises(ICalParseError, vevent.validate)
290
291 vevent = VEvent()
292 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
293 vevent.add('dtend', '20010203', {'VALUE': 'DATE'})
294 vevent.add('duration', 'P1D', {})
295 self.assertRaises(ICalParseError, vevent.validate)
296
297 vevent = VEvent()
298 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
299 vevent.add('duration', 'two years', {'VALUE': 'TEXT'})
300 self.assertRaises(ICalParseError, vevent.validate)
301
302 def test_validate_all_day_events(self):
303 from schoolbell.icalendar import VEvent, ICalParseError
304
305 vevent = VEvent()
306 vevent.add('summary', 'An event', {})
307 vevent.add('uid', 'unique', {})
308 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
309 vevent.validate()
310 self.assert_(vevent.all_day_event)
311 self.assertEquals(vevent.summary, 'An event')
312 self.assertEquals(vevent.uid, 'unique')
313 self.assertEquals(vevent.dtend, date(2001, 2, 4))
314 self.assertEquals(vevent.duration, timedelta(days=1))
315
316 vevent = VEvent()
317 vevent.add('summary', 'An\\nevent\\; with backslashes', {})
318 vevent.add('uid', 'unique2', {})
319 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
320 vevent.add('dtend', '20010205', {'VALUE': 'DATE'})
321 vevent.validate()
322 self.assertEquals(vevent.summary, 'An\nevent; with backslashes')
323 self.assert_(vevent.all_day_event)
324 self.assertEquals(vevent.dtstart, date(2001, 2, 3))
325 self.assertEquals(vevent.uid, 'unique2')
326 self.assertEquals(vevent.dtend, date(2001, 2, 5))
327 self.assertEquals(vevent.duration, timedelta(days=2))
328
329 vevent = VEvent()
330 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
331 vevent.add('uid', 'unique3', {})
332 vevent.add('duration', 'P2D')
333 vevent.validate()
334 self.assertEquals(vevent.summary, None)
335 self.assert_(vevent.all_day_event)
336 self.assertEquals(vevent.dtstart, date(2001, 2, 3))
337 self.assertEquals(vevent.uid, 'unique3')
338 self.assertEquals(vevent.dtend, date(2001, 2, 5))
339 self.assertEquals(vevent.duration, timedelta(days=2))
340
341 vevent = VEvent()
342 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
343 vevent.add('uid', 'unique4', {})
344 vevent.add('dtend', '20010201', {'VALUE': 'DATE'})
345 self.assertRaises(ICalParseError, vevent.validate)
346
347 vevent = VEvent()
348 vevent.add('dtstart', '20010203', {'VALUE': 'DATE'})
349 vevent.add('uid', 'unique5', {})
350 vevent.add('dtend', '20010203', {'VALUE': 'DATE'})
351 self.assertRaises(ICalParseError, vevent.validate)
352
353 def test_validate_not_all_day_events(self):
354 from schoolbell.icalendar import VEvent, ICalParseError
355
356 vevent = VEvent()
357 vevent.add('dtstart', '20010203T040506')
358 vevent.add('uid', 'unique', {})
359 vevent.validate()
360 self.assert_(not vevent.all_day_event)
361 self.assertEquals(vevent.dtstart, datetime(2001, 2, 3, 4, 5, 6))
362 self.assertEquals(vevent.dtend, datetime(2001, 2, 3, 4, 5, 6))
363 self.assertEquals(vevent.duration, timedelta(days=0))
364 self.assertEquals(vevent.rdates, [])
365
366 vevent = VEvent()
367 vevent.add('dtstart', '20010203T040000')
368 vevent.add('uid', 'unique', {})
369 vevent.add('dtend', '20010204T050102')
370 vevent.validate()
371 self.assert_(not vevent.all_day_event)
372 self.assertEquals(vevent.dtstart, datetime(2001, 2, 3, 4, 0, 0))
373 self.assertEquals(vevent.dtend, datetime(2001, 2, 4, 5, 1, 2))
374 self.assertEquals(vevent.duration, timedelta(days=1, hours=1,
375 minutes=1, seconds=2))
376
377 vevent = VEvent()
378 vevent.add('dtstart', '20010203T040000')
379 vevent.add('uid', 'unique', {})
380 vevent.add('duration', 'P1DT1H1M2S')
381 vevent.validate()
382 self.assert_(not vevent.all_day_event)
383 self.assertEquals(vevent.dtstart, datetime(2001, 2, 3, 4, 0, 0))
384 self.assertEquals(vevent.dtend, datetime(2001, 2, 4, 5, 1, 2))
385 self.assertEquals(vevent.duration, timedelta(days=1, hours=1,
386 minutes=1, seconds=2))
387
388 vevent = VEvent()
389 vevent.add('dtstart', '20010203T010203')
390 vevent.add('uid', 'unique', {})
391 vevent.add('rdate', '20010205T040506')
392 vevent.add('exdate', '20010206T040506')
393 vevent.validate()
394 self.assertEquals(vevent.rdates, [datetime(2001, 2, 5, 4, 5, 6)])
395 self.assertEquals(vevent.exdates, [datetime(2001, 2, 6, 4, 5, 6)])
396
397 vevent = VEvent()
398 vevent.add('dtstart', '20010203T010203')
399 vevent.add('uid', 'unique', {})
400 vevent.add('exdate', '20010206,20020307', {'VALUE': 'DATE'})
401 vevent.add('rrule', 'FREQ=DAILY')
402 vevent.validate()
403 self.assertEquals(vevent.exdates, [date(2001, 2, 6), date(2002, 3, 7)])
404
405 vevent = VEvent()
406 vevent.add('dtstart', '20010203T010203')
407 vevent.add('uid', 'unique', {})
408 vevent.add('dtend', '20010203T010202')
409 self.assertRaises(ICalParseError, vevent.validate)
410
411 def test_validate_location(self):
412 from schoolbell.icalendar import VEvent
413 vevent = VEvent()
414 vevent.add('dtstart', '20010203T040506')
415 vevent.add('uid', 'unique5', {})
416 vevent.add('location', 'Somewhere')
417 vevent.validate()
418 self.assertEquals(vevent.location, 'Somewhere')
419
420# TODO: recurring events
421## def test_validate_rrule(self):
422## from schoolbell.icalendar import VEvent
423## vevent = VEvent()
424## vevent.add('dtstart', '20010203T040506')
425## vevent.add('uid', 'unique5', {})
426## vevent.add('location', 'Somewhere')
427## vevent.add('rrule', 'FREQ=DAILY;COUNT=3')
428## vevent.validate()
429##
430## self.assertEquals(vevent.rrule.interval, 1)
431## self.assertEquals(vevent.rrule.count, 3)
432## self.assertEquals(vevent.rrule.until, None)
433## self.assertEquals(vevent.rrule.exceptions, ())
434##
435## def test_validate_rrule_exceptions(self):
436## from schoolbell.icalendar import VEvent
437## vevent = VEvent()
438## vevent.add('dtstart', '20010203T040506')
439## vevent.add('uid', 'unique5', {})
440## vevent.add('location', 'Somewhere')
441## vevent.add('rrule', 'FREQ=MONTHLY;BYDAY=3MO')
442## vevent.add('exdate', '19960402T010000,19960404T010000',)
443## vevent.validate()
444##
445## self.assertEquals(vevent.rrule.interval, 1)
446## self.assertEquals(vevent.rrule.count, None)
447## self.assertEquals(vevent.rrule.until, None)
448## self.assertEquals(vevent.rrule.monthly, 'weekday')
449## self.assertEquals(vevent.rrule.exceptions,
450## (date(1996, 4, 2), date(1996, 4, 4)))
451## self.assert_(not isinstance(vevent.rrule.exceptions[0], datetime))
452
453 def test_extractListOfDates(self):
454 from schoolbell.icalendar import VEvent, Period, ICalParseError
455
456 vevent = VEvent()
457 vevent.add('rdate', '20010205T040506')
458 vevent.add('rdate', '20010206T040506,20010207T000000')
459 vevent.add('rdate', '20010208', {'VALUE': 'DATE'})
460 vevent.add('rdate', '20010209T000000/20010210T000000',
461 {'VALUE': 'PERIOD'})
462 rdates = vevent._extractListOfDates('RDATE', vevent.rdate_types, False)
463 expected = [datetime(2001, 2, 5, 4, 5, 6),
464 datetime(2001, 2, 6, 4, 5, 6),
465 datetime(2001, 2, 7, 0, 0, 0),
466 date(2001, 2, 8),
467 Period(datetime(2001, 2, 9), datetime(2001, 2, 10)),
468 ]
469 self.assertEqual(expected, rdates,
470 diff(pformat(expected), pformat(rdates)))
471
472 vevent = VEvent()
473 vevent.add('rdate', '20010205T040506', {'VALUE': 'TEXT'})
474 self.assertRaises(ICalParseError, vevent._extractListOfDates, 'RDATE',
475 vevent.rdate_types, False)
476
477 vevent = VEvent()
478 vevent.add('exdate', '20010205T040506/P1D', {'VALUE': 'PERIOD'})
479 self.assertRaises(ICalParseError, vevent._extractListOfDates, 'EXDATE',
480 vevent.exdate_types, False)
481
482 vevent = VEvent()
483 vevent.add('rdate', '20010208', {'VALUE': 'DATE'})
484 rdates = vevent._extractListOfDates('RDATE', vevent.rdate_types, True)
485 expected = [date(2001, 2, 8)]
486 self.assertEqual(expected, rdates,
487 diff(pformat(expected), pformat(rdates)))
488
489 vevent = VEvent()
490 vevent.add('rdate', '20010205T040506', {'VALUE': 'DATE-TIME'})
491 self.assertRaises(ICalParseError, vevent._extractListOfDates, 'RDATE',
492 vevent.rdate_types, True)
493
494 vevent = VEvent()
495 vevent.add('rdate', '20010209T000000/20010210T000000',
496 {'VALUE': 'PERIOD'})
497 self.assertRaises(ICalParseError, vevent._extractListOfDates, 'RDATE',
498 vevent.rdate_types, True)
499
500
501class TestICalReader(unittest.TestCase):
502
503 example_ical = dedent("""\
504 BEGIN:VCALENDAR
505 VERSION
506 :2.0
507 PRODID
508 :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN
509 METHOD
510 :PUBLISH
511 BEGIN:VEVENT
512 UID
513 :956630271
514 SUMMARY
515 :Christmas Day
516 CLASS
517 :PUBLIC
518 X-MOZILLA-ALARM-DEFAULT-UNITS
519 :minutes
520 X-MOZILLA-ALARM-DEFAULT-LENGTH
521 :15
522 X-MOZILLA-RECUR-DEFAULT-UNITS
523 :weeks
524 X-MOZILLA-RECUR-DEFAULT-INTERVAL
525 :1
526 DTSTART
527 ;VALUE=DATE
528 :20031225
529 DTEND
530 ;VALUE=DATE
531 :20031226
532 DTSTAMP
533 :20020430T114937Z
534 END:VEVENT
535 END:VCALENDAR
536 BEGIN:VCALENDAR
537 VERSION
538 :2.0
539 PRODID
540 :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN
541 METHOD
542 :PUBLISH
543 BEGIN:VEVENT
544 UID
545 :911737808
546 SUMMARY
547 :Boxing Day
548 CLASS
549 :PUBLIC
550 X-MOZILLA-ALARM-DEFAULT-UNITS
551 :minutes
552 X-MOZILLA-ALARM-DEFAULT-LENGTH
553 :15
554 X-MOZILLA-RECUR-DEFAULT-UNITS
555 :weeks
556 X-MOZILLA-RECUR-DEFAULT-INTERVAL
557 :1
558 DTSTART
559 ;VALUE=DATE
560 :20030501
561 DTSTAMP
562 :20020430T114937Z
563 END:VEVENT
564 BEGIN:VEVENT
565 UID
566 :wh4t3v3r
567 DTSTART;VALUE=DATE:20031225
568 SUMMARY:Christmas again!
569 END:VEVENT
570 END:VCALENDAR
571 """)
572
573 def test_iterEvents(self):
574 from schoolbell.icalendar import ICalReader, ICalParseError
575 file = StringIO(self.example_ical)
576 reader = ICalReader(file)
577 result = list(reader.iterEvents())
578 self.assertEqual(len(result), 3)
579 vevent = result[0]
580 self.assertEqual(vevent.getOne('x-mozilla-recur-default-units'),
581 'weeks')
582 self.assertEqual(vevent.getOne('dtstart'), date(2003, 12, 25))
583 self.assertEqual(vevent.dtstart, date(2003, 12, 25))
584 self.assertEqual(vevent.getOne('dtend'), date(2003, 12, 26))
585 self.assertEqual(vevent.dtend, date(2003, 12, 26))
586 vevent = result[1]
587 self.assertEqual(vevent.getOne('dtstart'), date(2003, 05, 01))
588 self.assertEqual(vevent.dtstart, date(2003, 05, 01))
589 vevent = result[2]
590 self.assertEqual(vevent.getOne('dtstart'), date(2003, 12, 25))
591 self.assertEqual(vevent.dtstart, date(2003, 12, 25))
592
593 reader = ICalReader(StringIO(dedent("""\
594 BEGIN:VCALENDAR
595 BEGIN:VEVENT
596 UID:hello
597 DTSTART;VALUE=DATE:20010203
598 BEGIN:VALARM
599 X-PROP:foo
600 END:VALARM
601 END:VEVENT
602 END:VCALENDAR
603 """)))
604 result = list(reader.iterEvents())
605 self.assertEquals(len(result), 1)
606 vevent = result[0]
607 self.assert_(vevent.hasProp('uid'))
608 self.assert_(vevent.hasProp('dtstart'))
609 self.assert_(not vevent.hasProp('x-prop'))
610
611 reader = ICalReader(StringIO(dedent("""\
612 BEGIN:VCALENDAR
613 BEGIN:VEVENT
614 DTSTART;VALUE=DATE:20010203
615 END:VEVENT
616 END:VCALENDAR
617 """)))
618 # missing UID
619 self.assertRaises(ICalParseError, list, reader.iterEvents())
620
621 reader = ICalReader(StringIO(dedent("""\
622 BEGIN:VCALENDAR
623 BEGIN:VEVENT
624 DTSTART;VALUE=DATE:20010203
625 """)))
626 self.assertRaises(ICalParseError, list, reader.iterEvents())
627
628 reader = ICalReader(StringIO(dedent("""\
629 BEGIN:VCALENDAR
630 BEGIN:VEVENT
631 DTSTART;VALUE=DATE:20010203
632 END:VCALENDAR
633 END:VEVENT
634 """)))
635 self.assertRaises(ICalParseError, list, reader.iterEvents())
636
637 reader = ICalReader(StringIO(dedent("""\
638 BEGIN:VCALENDAR
639 END:VCALENDAR
640 X-PROP:foo
641 """)))
642 self.assertRaises(ICalParseError, list, reader.iterEvents())
643
644 reader = ICalReader(StringIO(dedent("""\
645 BEGIN:VCALENDAR
646 END:VCALENDAR
647 BEGIN:VEVENT
648 END:VEVENT
649 """)))
650 self.assertRaises(ICalParseError, list, reader.iterEvents())
651
652 reader = ICalReader(StringIO(dedent("""\
653 BEGIN:VCALENDAR
654 BEGIN:VEVENT
655 DTSTART;VALUE=DATE:20010203
656 END:VEVENT
657 END:VCALENDAR
658 END:UNIVERSE
659 """)))
660 self.assertRaises(ICalParseError, list, reader.iterEvents())
661
662 reader = ICalReader(StringIO(dedent("""\
663 DTSTART;VALUE=DATE:20010203
664 """)))
665 self.assertRaises(ICalParseError, list, reader.iterEvents())
666
667 reader = ICalReader(StringIO(dedent("""\
668 This is just plain text
669 """)))
670 self.assertRaises(ICalParseError, list, reader.iterEvents())
671
672 reader = ICalReader(StringIO(""))
673 self.assertEquals(list(reader.iterEvents()), [])
674
675 def test_iterRow(self):
676 from schoolbell.icalendar import ICalReader
677 file = StringIO("key1\n"
678 " :value1\n"
679 "key2\n"
680 " ;VALUE=foo\n"
681 " :value2\n"
682 "key3;VALUE=bar:value3\n")
683 reader = ICalReader(file)
684 self.assertEqual(list(reader._iterRow()),
685 [('KEY1', 'value1', {}),
686 ('KEY2', 'value2', {'VALUE': 'FOO'}),
687 ('KEY3', 'value3', {'VALUE': 'BAR'})])
688
689 file = StringIO("key1:value1\n"
690 "key2;VALUE=foo:value2\n"
691 "key3;VALUE=bar:value3\n")
692 reader = ICalReader(file)
693 self.assertEqual(list(reader._iterRow()),
694 [('KEY1', 'value1', {}),
695 ('KEY2', 'value2', {'VALUE': 'FOO'}),
696 ('KEY3', 'value3', {'VALUE': 'BAR'})])
697
698 file = StringIO("key1:value:with:colons:in:it\n")
699 reader = ICalReader(file)
700 self.assertEqual(list(reader._iterRow()),
701 [('KEY1', 'value:with:colons:in:it', {})])
702
703 reader = ICalReader(StringIO("ke\r\n y1\n\t:value\r\n 1 \r\n ."))
704 self.assertEqual(list(reader._iterRow()),
705 [('KEY1', 'value 1 .', {})])
706
707 reader = ICalReader(StringIO("key;param=\xe2\x98\xbb:\r\n"
708 " value \xe2\x98\xbb\r\n"))
709 self.assertEqual(list(reader._iterRow()),
710 [("KEY", u"value \u263B", {'PARAM': u'\u263B'})])
711
712 def test_parseRow(self):
713 from schoolbell.icalendar import ICalReader, ICalParseError
714 parseRow = ICalReader._parseRow
715 self.assertEqual(parseRow("key:"), ("KEY", "", {}))
716 self.assertEqual(parseRow("key:value"), ("KEY", "value", {}))
717 self.assertEqual(parseRow("key:va:lu:e"), ("KEY", "va:lu:e", {}))
718 self.assertRaises(ICalParseError, parseRow, "key but no value")
719 self.assertRaises(ICalParseError, parseRow, ":value but no key")
720 self.assertRaises(ICalParseError, parseRow, "bad name:")
721
722 self.assertEqual(parseRow("key;param=:value"),
723 ("KEY", "value", {'PARAM': ''}))
724 self.assertEqual(parseRow("key;param=pvalue:value"),
725 ("KEY", "value", {'PARAM': 'PVALUE'}))
726 self.assertEqual(parseRow('key;param=pvalue;param2=value2:value'),
727 ("KEY", "value", {'PARAM': 'PVALUE',
728 'PARAM2': 'VALUE2'}))
729 self.assertEqual(parseRow('key;param="pvalue":value'),
730 ("KEY", "value", {'PARAM': 'pvalue'}))
731 self.assertEqual(parseRow('key;param=pvalue;param2="value2":value'),
732 ("KEY", "value", {'PARAM': 'PVALUE',
733 'PARAM2': 'value2'}))
734 self.assertRaises(ICalParseError, parseRow, "k;:no param")
735 self.assertRaises(ICalParseError, parseRow, "k;a?=b:bad param")
736 self.assertRaises(ICalParseError, parseRow, "k;a=\":bad param")
737 self.assertRaises(ICalParseError, parseRow, "k;a=\"\177:bad param")
738 self.assertRaises(ICalParseError, parseRow, "k;a=\001:bad char")
739 self.assertEqual(parseRow("key;param=a,b,c:value"),
740 ("KEY", "value", {'PARAM': ['A', 'B', 'C']}))
741 self.assertEqual(parseRow('key;param=a,"b,c",d:value'),
742 ("KEY", "value", {'PARAM': ['A', 'b,c', 'D']}))
743def diff(old, new, oldlabel="expected output", newlabel="actual output"):
744 """Display a unified diff between old text and new text."""
745 old = old.splitlines()
746 new = new.splitlines()
747
748 diff = ['--- %s' % oldlabel, '+++ %s' % newlabel]
749
750 def dump(tag, x, lo, hi):
751 for i in xrange(lo, hi):
752 diff.append(tag + x[i])
753
754 differ = difflib.SequenceMatcher(a=old, b=new)
755 for tag, alo, ahi, blo, bhi in differ.get_opcodes():
756 if tag == 'replace':
757 dump('-', old, alo, ahi)
758 dump('+', new, blo, bhi)
759 elif tag == 'delete':
760 dump('-', old, alo, ahi)
761 elif tag == 'insert':
762 dump('+', new, blo, bhi)
763 elif tag == 'equal':
764 dump(' ', old, alo, ahi)
765 else:
766 raise AssertionError('unknown tag %r' % tag)
767 return "\n".join(diff)
768
769
770
771def test_suite():
772 suite = unittest.TestSuite()
773 suite.addTest(doctest.DocTestSuite('schoolbell.icalendar',
774 optionflags=doctest.ELLIPSIS | doctest.REPORT_UDIFF))
775 suite.addTest(unittest.makeSuite(TestParseDateTime))
776 suite.addTest(unittest.makeSuite(TestPeriod))
777 suite.addTest(unittest.makeSuite(TestVEvent))
778 suite.addTest(unittest.makeSuite(TestICalReader))
779 return suite
780
781if __name__ == '__main__':
782 unittest.main(defaultTest='test_suite')
7830
=== removed file 'lib/schoolbell/tests/test_schoolbell.py'
--- lib/schoolbell/tests/test_schoolbell.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/tests/test_schoolbell.py 1970-01-01 00:00:00 +0000
@@ -1,74 +0,0 @@
1"""
2Unit tests for schoolbell
3
4When this module grows too big, it will be split into a number of modules in
5a package called tests. Each of those new modules will be named test_foo.py
6and will test schoolbell.foo.
7"""
8
9import unittest
10from zope.testing import doctest
11
12
13def doctest_interfaces():
14 """Look for syntax errors in interfaces.py
15
16 >>> import schoolbell.interfaces
17
18 """
19
20
21def doctest_simple_CalendarEventMixin_replace():
22 """Make sure CalendarEventMixin.replace does not forget any attributes.
23
24 >>> from schoolbell.interfaces import ICalendarEvent
25 >>> from zope.schema import getFieldNames
26 >>> all_attrs = getFieldNames(ICalendarEvent)
27
28 We will use SimpleCalendarEvent which is a trivial subclass of
29 CalendarEventMixin
30
31 >>> from datetime import datetime, timedelta
32 >>> from schoolbell.simple import SimpleCalendarEvent
33 >>> e1 = SimpleCalendarEvent(datetime(2004, 12, 15, 18, 57),
34 ... timedelta(minutes=15),
35 ... 'Work on schoolbell.simple')
36
37 >>> for attr in all_attrs:
38 ... e2 = e1.replace(**{attr: 'new value'})
39 ... assert getattr(e2, attr) == 'new value', attr
40 ... assert e2 != e1, attr
41 ... assert e2.replace(**{attr: getattr(e1, attr)}) == e1, attr
42
43 """
44
45
46def doctest_weeknum_bounds():
47 """Unit test for schoolbell.utils.weeknum_bounds.
48
49 Check that weeknum_bounds is the reverse of datetime.isocalendar().
50
51 >>> from datetime import date
52 >>> from schoolbell.utils import weeknum_bounds
53 >>> d = date(2000, 1, 1)
54 >>> while d < date(2010, 1, 1):
55 ... year, weeknum, weekday = d.isocalendar()
56 ... l, h = weeknum_bounds(year, weeknum)
57 ... assert l <= d <= h
58 ... d += d.resolution
59
60 """
61
62
63def test_suite():
64 suite = unittest.TestSuite()
65 suite.addTest(doctest.DocTestSuite())
66 suite.addTest(doctest.DocTestSuite('schoolbell.mixins'))
67 suite.addTest(doctest.DocTestSuite('schoolbell.simple'))
68 suite.addTest(doctest.DocTestSuite('schoolbell.utils'))
69 suite.addTest(doctest.DocTestSuite('schoolbell.browser',
70 optionflags=doctest.ELLIPSIS | doctest.REPORT_UDIFF))
71 return suite
72
73if __name__ == '__main__':
74 unittest.main(defaultTest='test_suite')
750
=== removed file 'lib/schoolbell/utils.py'
--- lib/schoolbell/utils.py 2005-10-31 18:29:12 +0000
+++ lib/schoolbell/utils.py 1970-01-01 00:00:00 +0000
@@ -1,164 +0,0 @@
1"""
2Utility functions for SchoolBell.
3
4These include various date manipulation routines.
5"""
6
7import calendar
8from datetime import datetime, date, timedelta, tzinfo
9
10
11def prev_month(date):
12 """Calculate the first day of the previous month for a given date.
13
14 >>> prev_month(date(2004, 8, 1))
15 datetime.date(2004, 7, 1)
16 >>> prev_month(date(2004, 8, 31))
17 datetime.date(2004, 7, 1)
18 >>> prev_month(date(2004, 12, 15))
19 datetime.date(2004, 11, 1)
20 >>> prev_month(date(2005, 1, 28))
21 datetime.date(2004, 12, 1)
22
23 """
24 return (date.replace(day=1) - timedelta(1)).replace(day=1)
25
26
27def next_month(date):
28 """Calculate the first day of the next month for a given date.
29
30 >>> next_month(date(2004, 8, 1))
31 datetime.date(2004, 9, 1)
32 >>> next_month(date(2004, 8, 31))
33 datetime.date(2004, 9, 1)
34 >>> next_month(date(2004, 12, 15))
35 datetime.date(2005, 1, 1)
36 >>> next_month(date(2004, 2, 28))
37 datetime.date(2004, 3, 1)
38 >>> next_month(date(2004, 2, 29))
39 datetime.date(2004, 3, 1)
40 >>> next_month(date(2005, 2, 28))
41 datetime.date(2005, 3, 1)
42
43 """
44 return (date.replace(day=28) + timedelta(7)).replace(day=1)
45
46
47def week_start(date, first_day_of_week=0):
48 """Calculate the first day of the week for a given date.
49
50 Assuming that week starts on Mondays:
51
52 >>> week_start(date(2004, 8, 19))
53 datetime.date(2004, 8, 16)
54 >>> week_start(date(2004, 8, 15))
55 datetime.date(2004, 8, 9)
56 >>> week_start(date(2004, 8, 14))
57 datetime.date(2004, 8, 9)
58 >>> week_start(date(2004, 8, 21))
59 datetime.date(2004, 8, 16)
60 >>> week_start(date(2004, 8, 22))
61 datetime.date(2004, 8, 16)
62 >>> week_start(date(2004, 8, 23))
63 datetime.date(2004, 8, 23)
64
65 Assuming that week starts on Sundays:
66
67 >>> import calendar
68 >>> week_start(date(2004, 8, 19), calendar.SUNDAY)
69 datetime.date(2004, 8, 15)
70 >>> week_start(date(2004, 8, 15), calendar.SUNDAY)
71 datetime.date(2004, 8, 15)
72 >>> week_start(date(2004, 8, 14), calendar.SUNDAY)
73 datetime.date(2004, 8, 8)
74 >>> week_start(date(2004, 8, 21), calendar.SUNDAY)
75 datetime.date(2004, 8, 15)
76 >>> week_start(date(2004, 8, 22), calendar.SUNDAY)
77 datetime.date(2004, 8, 22)
78 >>> week_start(date(2004, 8, 23), calendar.SUNDAY)
79 datetime.date(2004, 8, 22)
80
81 """
82 assert 0 <= first_day_of_week < 7
83 delta = date.weekday() - first_day_of_week
84 if delta < 0:
85 delta += 7
86 return date - timedelta(delta)
87
88
89def weeknum_bounds(year, weeknum):
90 """Calculate the inclusive date bounds for a (year, weeknum) tuple.
91
92 Week numbers are as defined in ISO 8601 and returned by
93 datetime.date.isocalendar().
94
95 >>> weeknum_bounds(2003, 52)
96 (datetime.date(2003, 12, 22), datetime.date(2003, 12, 28))
97 >>> weeknum_bounds(2004, 1)
98 (datetime.date(2003, 12, 29), datetime.date(2004, 1, 4))
99 >>> weeknum_bounds(2004, 2)
100 (datetime.date(2004, 1, 5), datetime.date(2004, 1, 11))
101
102 """
103 # The first week of a year is at least 4 days long, so January 4th
104 # is in the first week.
105 firstweek = week_start(date(year, 1, 4), calendar.MONDAY)
106 # move forward to the right week number
107 weekstart = firstweek + timedelta(weeks=weeknum-1)
108 weekend = weekstart + timedelta(days=6)
109 return (weekstart, weekend)
110
111
112def check_weeknum(year, weeknum):
113 """Check to see whether a (year, weeknum) tuple refers to a real
114 ISO week number.
115
116 >>> check_weeknum(2004, 1)
117 True
118 >>> check_weeknum(2004, 53)
119 True
120 >>> check_weeknum(2004, 0)
121 False
122 >>> check_weeknum(2004, 54)
123 False
124 >>> check_weeknum(2003, 52)
125 True
126 >>> check_weeknum(2003, 53)
127 False
128
129 """
130 weekstart, weekend = weeknum_bounds(year, weeknum)
131 isoyear, isoweek, isoday = weekstart.isocalendar()
132 return (year, weeknum) == (isoyear, isoweek)
133
134class Slots(dict):
135 """A dict with automatic key selection.
136
137 The add method automatically selects the lowest unused numeric key
138 (starting from 0).
139
140 Example:
141
142 >>> s = Slots()
143 >>> s.add("first")
144 >>> s
145 {0: 'first'}
146
147 >>> s.add("second")
148 >>> s
149 {0: 'first', 1: 'second'}
150
151 The keys can be reused:
152
153 >>> del s[0]
154 >>> s.add("third")
155 >>> s
156 {0: 'third', 1: 'second'}
157
158 """
159
160 def add(self, obj):
161 i = 0
162 while i in self:
163 i += 1
164 self[i] = obj