2ed2fdc27fd24b1f81cb23c003c3d75e05312e1f
[pycalendar.git] / pycalendar / entry.py
1 # Copyright (C) 2013 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of pycalender.
4 #
5 # pycalender is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pycalender is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pycalender.  If not, see <http://www.gnu.org/licenses/>.
16
17 import logging as _logging
18
19 from . import text as _text
20
21
22 _LOG = _logging.getLogger(__name__)
23
24
25 class Entry (dict):
26     r"""An iCalendar entry (e.g. VEVENT)
27
28     Load example content.
29
30     >>> import codecs
31     >>> import os
32     >>> root_dir = os.curdir
33     >>> data_file = os.path.abspath(os.path.join(
34     ...         root_dir, 'test', 'data', 'geohash.ics'))
35     >>> with codecs.open(data_file, 'r', 'UTF-8') as f:
36     ...     content = f.read()
37
38     Make an entry.
39
40     >>> calendar = Entry(content=content)
41
42     Investigate the entry.
43
44     >>> print(calendar)  # doctest: +REPORT_UDIFF
45     BEGIN:VCALENDAR
46     VERSION:2.0
47     PRODID:-//Example Calendar//NONSGML v1.0//EN
48     BEGIN:VEVENT
49     UID:2013-06-30@geohash.invalid
50     DTSTAMP:2013-06-30T00:00:00Z
51     DTSTART;VALUE=DATE:20130630
52     DTEND;VALUE=DATE:20130701
53     SUMMARY:XKCD geohashing\, Boston graticule
54     URL:http://xkcd.com/426/
55     LOCATION:Snow Hill\, Dover\, Massachusetts
56     GEO:42.226663;-71.28676
57     END:VEVENT
58     END:VCALENDAR
59
60     >>> calendar.type
61     'VCALENDAR'
62
63     ``Entry`` subclasses Python's ``dict``, so you can access raw
64     field values in the usual ways.
65
66     >>> calendar['VERSION']
67     '2.0'
68     >>> calendar.get('missing')
69     >>> calendar.get('missing', 'some default')
70     'some default'
71     >>> sorted(calendar.keys())
72     ['PRODID', 'VERSION', 'VEVENT']
73
74
75     Dig into the children (which are always stored as lists):
76
77     >>> event = calendar['VEVENT'][0]
78
79     >>> event.type
80     'VEVENT'
81     >>> event.content  # doctest: +ELLIPSIS
82     'BEGIN:VEVENT\r\nUID:...\r\nEND:VEVENT\r\n'
83     >>> sorted(event.keys())
84     ['DTEND', 'DTSTAMP', 'DTSTART', 'GEO', 'LOCATION', 'SUMMARY', 'UID', 'URL']
85
86     >>> event['LOCATION']
87     'Snow Hill\\, Dover\\, Massachusetts'
88
89     You can also use ``get_text`` to unescape text fields.
90
91     >>> event.get_text('LOCATION')
92     'Snow Hill, Dover, Massachusetts'
93     """
94     def __init__(self, type=None, content=None):
95         super(Entry, self).__init__()
96         if type is None and content:
97             firstline = content.splitlines()[0]
98             type = firstline.split(':', 1)[1]
99         self.type = type
100         self.content = content
101         self._lines = None  # unwrapped semantic lines
102         if content:
103             self.process()
104
105     def __hash__(self):
106         if self.type in [
107                 'VEVENT',
108                 'VFREEBUSY',
109                 'VJOURNAL',
110                 'VTODO',
111                 ] or 'UID' in self:
112             return hash(_text.unescape(self['UID']))
113         return id(self)
114
115     def __str__(self):
116         if self.content:
117             return self.content.replace('\r\n', '\n').strip()
118         return ''
119
120     def __repr__(self):
121         return '<{} type:{}>'.format(type(self).__name__, self.type)
122
123     def process(self):
124         self.unfold()
125         self._parse()
126
127     def _parse(self):
128         self.clear()
129         for index,verb,expected in [
130                 [0, 'begin', 'BEGIN:{}'.format(self.type)],
131                 [-1, 'end', 'END:{}'.format(self.type)],
132                 ]:
133             if self._lines[index] != expected:
134                 raise ValueError('entry should {} with {!r}, not {!r}'.format(
135                     verb, expected, self._lines[index]))
136         stack = []
137         child_lines = []
138         for i,line in enumerate(self._lines[1:-1]):
139             key,parameters,value = self._parse_key_value(line)
140             if key == 'BEGIN':
141                 _LOG.debug('{!r}: begin {}'.format(self, value))
142                 stack.append(value)
143             if stack:
144                 child_lines.append(line)
145             if key == 'END':
146                 _LOG.debug('{!r}: end {}'.format(self, value))
147                 if not stack or value != stack[-1]:
148                     raise ValueError(
149                         ('closing {} on line {}, but current stack is {}'
150                          ).format(value, i+1, stack))
151                 stack.pop(-1)
152                 if not stack:
153                     child = Entry(
154                         type=value,
155                         content='\r\n'.join(child_lines) + '\r\n',
156                         )
157                     child._lines = child_lines
158                     child._parse()
159                     self._add_value(key=value, value=child, force_list=True)
160                     child_lines = []
161             elif not stack:  # our own data, not a child's
162                 if key == 'VERSION':
163                     v = _text.unescape(value)
164                     if v != '2.0':
165                         raise NotImplementedError(
166                             'cannot parse VERSION {} feed'.format(v))
167                 self._add_value(key=key, value=value)
168
169     def _parse_key_value(self, line):
170         key,value = [x.strip() for x in line.split(':', 1)]
171         parameters = key.split(';')
172         key = parameters.pop(0)
173         parameters = {tuple(x.split('=', 1)) for x in parameters}
174         for k,v in parameters:
175             if ',' in v:
176                 parameters = v.split(',')
177         if parameters and key in ['BEGIN', 'END']:
178             raise ValueError(
179                 'parameters are not allowed with {}: {}'.format(
180                     key, line))
181         return (key, parameters, value)
182
183     def _add_value(self, key, value, force_list=False):
184         if force_list and key not in self:
185             self[key] = []
186         if key in self:
187             if type(self[key]) == str:
188                 self[key] = [self[key]]
189             self[key].append(value)
190         else:
191             self[key] = value
192
193     def unfold(self):
194         """Unfold wrapped lines
195
196         Following :RFC:`5545`, section 3.1 (Content Lines)
197         """
198         self._lines = []
199         semantic_line_chunks = []
200         for line in self.content.splitlines():
201             lstrip = line.lstrip()
202             if lstrip != line:
203                 if not semantic_line_chunks:
204                     raise ValueError(
205                         ('whitespace-prefixed line {!r} is not a continuation '
206                          'of a previous line').format(line))
207                 semantic_line_chunks.append(lstrip)
208             else:
209                 if semantic_line_chunks:
210                     self._lines.append(''.join(semantic_line_chunks))
211                 semantic_line_chunks = [line]
212         if semantic_line_chunks:
213             self._lines.append(''.join(semantic_line_chunks))
214
215     def get_text(self, *args, **kwargs):
216         """Get and unescape a text value
217
218         As described in :RFC:`5545`, section 3.3.11 (Text)
219         """
220         value = self.get(*args, **kwargs)
221         return _text.unescape(value)
222
223     def get_geo(self, key='GEO', *args, **kwargs):
224         """Get and unescape a GEO value
225
226         As described in :RFC:`5545`, section 3.8.1.6 (Geographic
227         Position).
228         """
229         value = self.get(key, *args, **kwargs)
230         lat,lon = [float(x) for x in value.split(';')]
231         return (lat, lon)
232
233     def write(self, stream):
234         stream.write(self.content)