Run update-copyright.py
[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())  # doctest: +NORMALIZE_WHITESPACE
84     ['DTEND;VALUE=DATE', 'DTSTAMP', 'DTSTART;VALUE=DATE', 'GEO',
85      'LOCATION', 'SUMMARY', 'UID', 'URL']
86
87     >>> event['LOCATION']
88     'Snow Hill\\, Dover\\, Massachusetts'
89
90     You can also use ``get_text`` to unescape text fields.
91
92     >>> event.get_text('LOCATION')
93     'Snow Hill, Dover, Massachusetts'
94     """
95     def __init__(self, type=None, content=None):
96         super(Entry, self).__init__()
97         if type is None and content:
98             firstline = content.splitlines()[0]
99             type = firstline.split(':', 1)[1]
100         self.type = type
101         self.content = content
102         self._lines = None  # unwrapped semantic lines
103         if content:
104             self.process()
105
106     def __hash__(self):
107         if self.type in [
108                 'VEVENT',
109                 'VFREEBUSY',
110                 'VJOURNAL',
111                 'VTODO',
112                 ] or 'UID' in self:
113             return hash(_text.unescape(self['UID']))
114         return id(self)
115
116     def __str__(self):
117         if self.content:
118             return self.content.replace('\r\n', '\n').strip()
119         return ''
120
121     def __repr__(self):
122         return '<{} type:{}>'.format(type(self).__name__, self.type)
123
124     def process(self):
125         self.unfold()
126         self._parse()
127
128     def _parse(self):
129         self.clear()
130         for index,verb,expected in [
131                 [0, 'begin', 'BEGIN:{}'.format(self.type)],
132                 [-1, 'end', 'END:{}'.format(self.type)],
133                 ]:
134             if self._lines[index] != expected:
135                 raise ValueError('entry should {} with {!r}, not {!r}'.format(
136                     verb, expected, self._lines[index]))
137         stack = []
138         child_lines = []
139         for i,line in enumerate(self._lines[1:-1]):
140             key,value = [x.strip() for x in line.split(':', 1)]
141             if key == 'BEGIN':
142                 _LOG.debug('{!r}: begin {}'.format(self, value))
143                 stack.append(value)
144             if stack:
145                 child_lines.append(line)
146             if key == 'END':
147                 _LOG.debug('{!r}: end {}'.format(self, value))
148                 if not stack or value != stack[-1]:
149                     raise ValueError(
150                         ('closing {} on line {}, but current stack is {}'
151                          ).format(value, i+1, stack))
152                 stack.pop(-1)
153                 if not stack:
154                     child = Entry(
155                         type=value,
156                         content='\r\n'.join(child_lines) + '\r\n',
157                         )
158                     child._lines = child_lines
159                     child._parse()
160                     self._add_value(key=value, value=child, force_list=True)
161                     child_lines = []
162             elif not stack:  # our own data, not a child's
163                 if key == 'VERSION':
164                     v = _text.unescape(value)
165                     if v != '2.0':
166                         raise NotImplementedError(
167                             'cannot parse VERSION {} feed'.format(v))
168                 self._add_value(key=key, value=value)
169
170     def _add_value(self, key, value, force_list=False):
171         if force_list and key not in self:
172             self[key] = []
173         if key in self:
174             if type(self[key]) == str:
175                 self[key] = [self[key]]
176             self[key].append(value)
177         else:
178             self[key] = value
179
180     def unfold(self):
181         """Unfold wrapped lines
182
183         Following :RFC:`5545`, section 3.1 (Content Lines)
184         """
185         self._lines = []
186         semantic_line_chunks = []
187         for line in self.content.splitlines():
188             lstrip = line.lstrip()
189             if lstrip != line:
190                 if not semantic_line_chunks:
191                     raise ValueError(
192                         ('whitespace-prefixed line {!r} is not a continuation '
193                          'of a previous line').format(line))
194                 semantic_line_chunks.append(lstrip)
195             else:
196                 if semantic_line_chunks:
197                     self._lines.append(''.join(semantic_line_chunks))
198                 semantic_line_chunks = [line]
199         if semantic_line_chunks:
200             self._lines.append(''.join(semantic_line_chunks))
201
202     def get_text(self, *args, **kwargs):
203         value = self.get(*args, **kwargs)
204         return _text.unescape(value)
205
206     def write(self, stream):
207         stream.write(self.content)