4e5db9f6cf99b39279ffbf762286d422522d5524
[pycalendar.git] / pycalendar / entry.py
1 # Copyright
2
3 import logging as _logging
4
5 from . import text as _text
6
7
8 _LOG = _logging.getLogger(__name__)
9
10
11 class Entry (dict):
12     r"""An iCalendar entry (e.g. VEVENT)
13
14     Load example content.
15
16     >>> import codecs
17     >>> import os
18     >>> root_dir = os.curdir
19     >>> data_file = os.path.abspath(os.path.join(
20     ...         root_dir, 'test', 'data', 'geohash.ics'))
21     >>> with codecs.open(data_file, 'r', 'UTF-8') as f:
22     ...     content = f.read()
23
24     Make an entry.
25
26     >>> calendar = Entry(content=content)
27
28     Investigate the entry.
29
30     >>> print(calendar)  # doctest: +REPORT_UDIFF
31     BEGIN:VCALENDAR
32     VERSION:2.0
33     PRODID:-//Example Calendar//NONSGML v1.0//EN
34     BEGIN:VEVENT
35     UID:2013-06-30@geohash.invalid
36     DTSTAMP:2013-06-30T00:00:00Z
37     DTSTART;VALUE=DATE:20130630
38     DTEND;VALUE=DATE:20130701
39     SUMMARY:XKCD geohashing\, Boston graticule
40     URL:http://xkcd.com/426/
41     LOCATION:Snow Hill\, Dover\, Massachusetts
42     GEO:42.226663,-71.28676
43     END:VEVENT
44     END:VCALENDAR
45
46     >>> calendar.type
47     'VCALENDAR'
48
49     ``Entry`` subclasses Python's ``dict``, so you can access raw
50     field values in the usual ways.
51
52     >>> calendar['VERSION']
53     '2.0'
54     >>> calendar.get('missing')
55     >>> calendar.get('missing', 'some default')
56     'some default'
57     >>> sorted(calendar.keys())
58     ['PRODID', 'VERSION', 'VEVENT']
59
60
61     Dig into the children (which are always stored as lists):
62
63     >>> event = calendar['VEVENT'][0]
64
65     >>> event.type
66     'VEVENT'
67     >>> event.content  # doctest: +ELLIPSIS
68     'BEGIN:VEVENT\r\nUID:...\r\nEND:VEVENT\r\n'
69     >>> sorted(event.keys())  # doctest: +NORMALIZE_WHITESPACE
70     ['DTEND;VALUE=DATE', 'DTSTAMP', 'DTSTART;VALUE=DATE', 'GEO',
71      'LOCATION', 'SUMMARY', 'UID', 'URL']
72
73     >>> event['LOCATION']
74     'Snow Hill\\, Dover\\, Massachusetts'
75
76     You can also use ``get_text`` to unescape text fields.
77
78     >>> event.get_text('LOCATION')
79     'Snow Hill, Dover, Massachusetts'
80     """
81     def __init__(self, type=None, content=None):
82         super(Entry, self).__init__()
83         if type is None and content:
84             firstline = content.splitlines()[0]
85             type = firstline.split(':', 1)[1]
86         self.type = type
87         self.content = content
88         self._lines = None  # unwrapped semantic lines
89         if content:
90             self.process()
91
92     def __hash__(self):
93         if self.type in [
94                 'VEVENT',
95                 'VFREEBUSY',
96                 'VJOURNAL',
97                 'VTODO',
98                 ] or 'UID' in self:
99             return hash(_text.unescape(self['UID']))
100         return id(self)
101
102     def __str__(self):
103         if self.content:
104             return self.content.replace('\r\n', '\n').strip()
105         return ''
106
107     def __repr__(self):
108         return '<{} type:{}>'.format(type(self).__name__, self.type)
109
110     def process(self):
111         self.unfold()
112         self._parse()
113
114     def _parse(self):
115         self.clear()
116         for index,verb,expected in [
117                 [0, 'begin', 'BEGIN:{}'.format(self.type)],
118                 [-1, 'end', 'END:{}'.format(self.type)],
119                 ]:
120             if self._lines[index] != expected:
121                 raise ValueError('entry should {} with {!r}, not {!r}'.format(
122                     verb, expected, self._lines[index]))
123         stack = []
124         child_lines = []
125         for i,line in enumerate(self._lines[1:-1]):
126             key,value = [x.strip() for x in line.split(':', 1)]
127             if key == 'BEGIN':
128                 _LOG.debug('{!r}: begin {}'.format(self, value))
129                 stack.append(value)
130             if stack:
131                 child_lines.append(line)
132             if key == 'END':
133                 _LOG.debug('{!r}: end {}'.format(self, value))
134                 if not stack or value != stack[-1]:
135                     raise ValueError(
136                         ('closing {} on line {}, but current stack is {}'
137                          ).format(value, i+1, stack))
138                 stack.pop(-1)
139                 if not stack:
140                     child = Entry(
141                         type=value,
142                         content='\r\n'.join(child_lines) + '\r\n',
143                         )
144                     child._lines = child_lines
145                     child._parse()
146                     self._add_value(key=value, value=child, force_list=True)
147                     child_lines = []
148             elif not stack:  # our own data, not a child's
149                 self._add_value(key=key, value=value)
150
151     def _add_value(self, key, value, force_list=False):
152         if force_list and key not in self:
153             self[key] = []
154         if key in self:
155             if type(self[key]) == str:
156                 self[key] = [self[key]]
157             self[key].append(value)
158         else:
159             self[key] = value
160
161     def unfold(self):
162         """Unfold wrapped lines
163
164         Following :RFC:`5545`, section 3.1 (Content Lines)
165         """
166         self._lines = []
167         semantic_line_chunks = []
168         for line in self.content.splitlines():
169             lstrip = line.lstrip()
170             if lstrip != line:
171                 if not semantic_line_chunks:
172                     raise ValueError(
173                         ('whitespace-prefixed line {!r} is not a continuation '
174                          'of a previous line').format(line))
175                 semantic_line_chunks.append(lstrip)
176             else:
177                 if semantic_line_chunks:
178                     self._lines.append(''.join(semantic_line_chunks))
179                 semantic_line_chunks = [line]
180         if semantic_line_chunks:
181             self._lines.append(''.join(semantic_line_chunks))
182
183     def get_text(self, *args, **kwargs):
184         value = self.get(*args, **kwargs)
185         return _text.unescape(value)
186
187     def write(self, stream):
188         stream.write(self.content)