1 # Copyright (C) 2013 W. Trevor King <wking@tremily.us>
3 # This file is part of pycalender.
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
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.
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/>.
17 import logging as _logging
19 from . import text as _text
22 _LOG = _logging.getLogger(__name__)
26 r"""An iCalendar entry (e.g. VEVENT)
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()
40 >>> calendar = Entry(content=content)
42 Investigate the entry.
44 >>> print(calendar) # doctest: +REPORT_UDIFF
47 PRODID:-//Example Calendar//NONSGML v1.0//EN
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
63 ``Entry`` subclasses Python's ``dict``, so you can access raw
64 field values in the usual ways.
66 >>> calendar['VERSION']
68 >>> calendar.get('missing')
69 >>> calendar.get('missing', 'some default')
71 >>> sorted(calendar.keys())
72 ['PRODID', 'VERSION', 'VEVENT']
75 Dig into the children (which are always stored as lists):
77 >>> event = calendar['VEVENT'][0]
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']
87 'Snow Hill\\, Dover\\, Massachusetts'
89 You can also use ``get_text`` to unescape text fields.
91 >>> event.get_text('LOCATION')
92 'Snow Hill, Dover, Massachusetts'
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]
100 self.content = content
101 self._lines = None # unwrapped semantic lines
112 return hash(_text.unescape(self['UID']))
117 return self.content.replace('\r\n', '\n').strip()
121 return '<{} type:{}>'.format(type(self).__name__, self.type)
129 for index,verb,expected in [
130 [0, 'begin', 'BEGIN:{}'.format(self.type)],
131 [-1, 'end', 'END:{}'.format(self.type)],
133 if self._lines[index] != expected:
134 raise ValueError('entry should {} with {!r}, not {!r}'.format(
135 verb, expected, self._lines[index]))
138 for i,line in enumerate(self._lines[1:-1]):
139 key,parameters,value = self._parse_key_value(line)
141 _LOG.debug('{!r}: begin {}'.format(self, value))
144 child_lines.append(line)
146 _LOG.debug('{!r}: end {}'.format(self, value))
147 if not stack or value != stack[-1]:
149 ('closing {} on line {}, but current stack is {}'
150 ).format(value, i+1, stack))
155 content='\r\n'.join(child_lines) + '\r\n',
157 child._lines = child_lines
159 self._add_value(key=value, value=child, force_list=True)
161 elif not stack: # our own data, not a child's
163 v = _text.unescape(value)
165 raise NotImplementedError(
166 'cannot parse VERSION {} feed'.format(v))
167 self._add_value(key=key, value=value)
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:
176 parameters = v.split(',')
177 if parameters and key in ['BEGIN', 'END']:
179 'parameters are not allowed with {}: {}'.format(
181 return (key, parameters, value)
183 def _add_value(self, key, value, force_list=False):
184 if force_list and key not in self:
187 if type(self[key]) == str:
188 self[key] = [self[key]]
189 self[key].append(value)
194 """Unfold wrapped lines
196 Following :RFC:`5545`, section 3.1 (Content Lines)
199 semantic_line_chunks = []
200 for line in self.content.splitlines():
201 lstrip = line.lstrip()
203 if not semantic_line_chunks:
205 ('whitespace-prefixed line {!r} is not a continuation '
206 'of a previous line').format(line))
207 semantic_line_chunks.append(lstrip)
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))
215 def get_text(self, *args, **kwargs):
216 """Get and unescape a text value
218 As described in :RFC:`5545`, section 3.3.11 (Text)
220 value = self.get(*args, **kwargs)
221 return _text.unescape(value)
223 def get_geo(self, key='GEO', *args, **kwargs):
224 """Get and unescape a GEO value
226 As described in :RFC:`5545`, section 3.8.1.6 (Geographic
229 value = self.get(key, *args, **kwargs)
230 lat,lon = [float(x) for x in value.split(';')]
233 def write(self, stream):
234 stream.write(self.content)