rss2email: Fix "Python 3.2 or newer" typo
[rss2email.git] / rss2email / feeds.py
1 # Copyright (C) 2004-2014 Aaron Swartz
2 #                         Brian Lalor
3 #                         Dean Jackson
4 #                         Erik Hetzner
5 #                         Etienne Millon <me@emillon.org>
6 #                         Joey Hess
7 #                         Lindsey Smith <lindsey.smith@gmail.com>
8 #                         Marcel Ackermann
9 #                         Martin 'Joey' Schulze
10 #                         Matej Cepl
11 #                         W. Trevor King <wking@tremily.us>
12 #
13 # This file is part of rss2email.
14 #
15 # rss2email is free software: you can redistribute it and/or modify it under
16 # the terms of the GNU General Public License as published by the Free Software
17 # Foundation, either version 2 of the License, or (at your option) version 3 of
18 # the License.
19 #
20 # rss2email is distributed in the hope that it will be useful, but WITHOUT ANY
21 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
22 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
23 #
24 # You should have received a copy of the GNU General Public License along with
25 # rss2email.  If not, see <http://www.gnu.org/licenses/>.
26
27 """Define the ``Feed`` class for handling a list of feeds
28 """
29
30 import codecs as _codecs
31 import collections as _collections
32 import os as _os
33 import json as _json
34 import pickle as _pickle
35 import sys as _sys
36
37 from . import LOG as _LOG
38 from . import config as _config
39 from . import error as _error
40 from . import feed as _feed
41
42 UNIX = False
43 try:
44     import fcntl as _fcntl
45     # A pox on SunOS file locking methods
46     if 'sunos' not in _sys.platform:
47         UNIX = True
48 except:
49     pass
50
51
52 # Path to the filesystem root, '/' on POSIX.1 (IEEE Std 1003.1-2008).
53 ROOT_PATH = _os.path.splitdrive(_sys.executable)[0] or _os.sep
54
55
56 class Feeds (list):
57     """Utility class for rss2email activity.
58
59     >>> import codecs
60     >>> import os.path
61     >>> import json
62     >>> import tempfile
63     >>> from .feed import Feed
64
65     Setup a temporary directory to load.
66
67     >>> tmpdir = tempfile.TemporaryDirectory(prefix='rss2email-test-')
68     >>> configfile = os.path.join(tmpdir.name, 'rss2email.cfg')
69     >>> with open(configfile, 'w') as f:
70     ...     count = f.write('[DEFAULT]\\n')
71     ...     count = f.write('to = a@b.com\\n')
72     ...     count = f.write('[feed.f1]\\n')
73     ...     count = f.write('url = http://a.net/feed.atom\\n')
74     ...     count = f.write('to = x@y.net\\n')
75     ...     count = f.write('[feed.f2]\\n')
76     ...     count = f.write('url = http://b.com/rss.atom\\n')
77     >>> datafile = os.path.join(tmpdir.name, 'rss2email.json')
78     >>> with codecs.open(datafile, 'w', Feeds.datafile_encoding) as f:
79     ...     json.dump({
80     ...             'version': 1,
81     ...             'feeds': [
82     ...                 Feed(name='f1').get_state(),
83     ...                 Feed(name='f2').get_state(),
84     ...                 ],
85     ...             }, f)
86
87     >>> feeds = Feeds(configfiles=[configfile,], datafile=datafile)
88     >>> feeds.load()
89     >>> for feed in feeds:
90     ...     print(feed)
91     f1 (http://a.net/feed.atom -> x@y.net)
92     f2 (http://b.com/rss.atom -> a@b.com)
93
94     You can index feeds by array index or by feed name.
95
96     >>> feeds[0]
97     <Feed f1 (http://a.net/feed.atom -> x@y.net)>
98     >>> feeds[-1]
99     <Feed f2 (http://b.com/rss.atom -> a@b.com)>
100     >>> feeds['f1']
101     <Feed f1 (http://a.net/feed.atom -> x@y.net)>
102     >>> feeds['missing']
103     Traceback (most recent call last):
104       ...
105     IndexError: missing
106
107     Tweak the feed configuration and save.
108
109     >>> feeds[0].to = None
110     >>> feeds.save()
111     >>> print(open(configfile, 'r').read().rstrip('\\n'))
112     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
113     [DEFAULT]
114     from = user@rss2email.invalid
115     ...
116     verbose = warning
117     <BLANKLINE>
118     [feed.f1]
119     url = http://a.net/feed.atom
120     <BLANKLINE>
121     [feed.f2]
122     url = http://b.com/rss.atom
123
124     Cleanup the temporary directory.
125
126     >>> tmpdir.cleanup()
127     """
128     datafile_version = 2
129     datafile_encoding = 'utf-8'
130
131     def __init__(self, configfiles=None, datafile=None, config=None):
132         super(Feeds, self).__init__()
133         if configfiles is None:
134             configfiles = self._get_configfiles()
135         self.configfiles = configfiles
136         if datafile is None:
137             datafile = self._get_datafile()
138         self.datafile = _os.path.realpath(datafile)
139         if config is None:
140             config = _config.CONFIG
141         self.config = config
142         self._datafile_lock = None
143
144     def __getitem__(self, key):
145         for feed in self:
146             if feed.name == key:
147                 return feed
148         try:
149             index = int(key)
150         except ValueError as e:
151             raise IndexError(key) from e
152         return super(Feeds, self).__getitem__(index)
153
154     def __append__(self, feed):
155         feed.load_from_config(self.config)
156         feed = super(Feeds, self).append(feed)
157
158     def __pop__(self, index=-1):
159         feed = super(Feeds, self).pop(index=index)
160         if feed.section in self.config:
161             self.config.pop(feed.section)
162         return feed
163
164     def index(self, index):
165         if isinstance(index, int):
166             try:
167                 return self[index]
168             except IndexError as e:
169                 raise _error.FeedIndexError(index=index, feeds=self) from e
170         elif isinstance(index, str):
171             try:
172                 index = int(index)
173             except ValueError:
174                 pass
175             else:
176                 return self.index(index)
177             for feed in self:
178                 if feed.name == index:
179                     return feed
180         try:
181             super(Feeds, self).index(index)
182         except (IndexError, ValueError) as e:
183             raise _error.FeedIndexError(index=index, feeds=self) from e
184
185     def remove(self, feed):
186         super(Feeds, self).remove(feed)
187         if feed.section in self.config:
188             self.config.pop(feed.section)
189
190     def clear(self):
191         while self:
192             self.pop(0)
193
194     def _get_configfiles(self):
195         """Get configuration file paths
196
197         Following the XDG Base Directory Specification.
198         """
199         config_home = _os.environ.get(
200             'XDG_CONFIG_HOME',
201             _os.path.expanduser(_os.path.join('~', '.config')))
202         config_dirs = [config_home]
203         config_dirs.extend(
204             _os.environ.get(
205                 'XDG_CONFIG_DIRS',
206                 _os.path.join(ROOT_PATH, 'etc', 'xdg'),
207                 ).split(':'))
208         # reverse because ConfigParser wants most significant last
209         return list(reversed(
210                 [_os.path.join(config_dir, 'rss2email.cfg')
211                  for config_dir in config_dirs]))
212
213     def _get_datafile(self):
214         """Get the data file path
215
216         Following the XDG Base Directory Specification.
217         """
218         data_home = _os.environ.get(
219             'XDG_DATA_HOME',
220             _os.path.expanduser(_os.path.join('~', '.local', 'share')))
221         data_dirs = [data_home]
222         data_dirs.extend(
223             _os.environ.get(
224                 'XDG_DATA_DIRS',
225                 ':'.join([
226                         _os.path.join(ROOT_PATH, 'usr', 'local', 'share'),
227                         _os.path.join(ROOT_PATH, 'usr', 'share'),
228                         ]),
229                 ).split(':'))
230         datafiles = [_os.path.join(data_dir, 'rss2email.json')
231                      for data_dir in data_dirs]
232         for datafile in datafiles:
233             if _os.path.isfile(datafile):
234                 return datafile
235         return datafiles[0]
236
237     def load(self, lock=True, require=False):
238         _LOG.debug('load feed configuration from {}'.format(self.configfiles))
239         if self.configfiles:
240             self.read_configfiles = self.config.read(self.configfiles)
241         else:
242             self.read_configfiles = []
243         _LOG.debug('loaded configuration from {}'.format(
244                 self.read_configfiles))
245         self._load_feeds(lock=lock, require=require)
246
247     def _load_feeds(self, lock, require):
248         _LOG.debug('load feed data from {}'.format(self.datafile))
249         if not _os.path.exists(self.datafile):
250             if require:
251                 raise _error.NoDataFile(feeds=self)
252             _LOG.info('feed data file not found at {}'.format(self.datafile))
253             _LOG.debug('creating an empty data file')
254             dirname = _os.path.dirname(self.datafile)
255             if dirname and not _os.path.isdir(dirname):
256                 _os.makedirs(dirname, mode=0o700, exist_ok=True)
257             with _codecs.open(self.datafile, 'w', self.datafile_encoding) as f:
258                 self._save_feed_states(feeds=[], stream=f)
259         try:
260             self._datafile_lock = _codecs.open(
261                 self.datafile, 'r', self.datafile_encoding)
262         except IOError as e:
263             raise _error.DataFileError(feeds=self) from e
264
265         locktype = 0
266         if lock and UNIX:
267             locktype = _fcntl.LOCK_EX
268             _fcntl.flock(self._datafile_lock.fileno(), locktype)
269
270         self.clear()
271
272         level = _LOG.level
273         handlers = list(_LOG.handlers)
274         feeds = []
275         try:
276             data = _json.load(self._datafile_lock)
277         except ValueError as e:
278             _LOG.info('could not load data file using JSON')
279             data = self._load_pickled_data(self._datafile_lock)
280         version = data.get('version', None)
281         if version != self.datafile_version:
282             data = self._upgrade_state_data(data)
283         for state in data['feeds']:
284             feed = _feed.Feed(name='dummy-name')
285             feed.set_state(state)
286             if 'name' not in state:
287                 raise _error.DataFileError(
288                     feeds=self,
289                     message='missing feed name in datafile {}'.format(
290                         self.datafile))
291             feeds.append(feed)
292         _LOG.setLevel(level)
293         _LOG.handlers = handlers
294         self.extend(feeds)
295
296         if locktype == 0:
297             self._datafile_lock.close()
298             self._datafile_lock = None
299
300         for feed in self:
301             feed.load_from_config(self.config)
302
303         feed_names = set(feed.name for feed in self)
304         order = _collections.defaultdict(lambda: (1e3, ''))
305         for i,section in enumerate(self.config.sections()):
306             if section.startswith('feed.'):
307                 name = section[len('feed.'):]
308                 order[name] = (i, name)
309                 if name not in feed_names:
310                     _LOG.debug(
311                         ('feed {} not found in feed file, '
312                          'initializing from config').format(name))
313                     self.append(_feed.Feed(name=name, config=self.config))
314                     feed_names.add(name)
315         def key(feed):
316             return order[feed.name]
317         self.sort(key=key)
318
319     def _load_pickled_data(self, stream):
320         _LOG.info('try and load data file using Pickle')
321         with open(self.datafile, 'rb') as f:
322             feeds = list(feed.get_state() for feed in _pickle.load(f))
323         return {
324             'version': self.datafile_version,
325             'feeds': feeds,
326             }
327
328     def _upgrade_state_data(self, data):
329         version = data.get('version', 'unknown')
330         if version == 1:
331             for feed in data['feeds']:
332                 seen = feed['seen']
333                 for guid,id_ in seen.items():
334                     seen[guid] = {'id': id_}
335             return data
336         raise NotImplementedError(
337             'cannot convert data file from version {} to {}'.format(
338                 version, self.datafile_version))
339
340     def save(self):
341         dst_config_file = _os.path.realpath(self.configfiles[-1])
342         _LOG.debug('save feed configuration to {}'.format(dst_config_file))
343         for feed in self:
344             feed.save_to_config()
345         dirname = _os.path.dirname(dst_config_file)
346         if dirname and not _os.path.isdir(dirname):
347             _os.makedirs(dirname, mode=0o700, exist_ok=True)
348         tmpfile = dst_config_file + '.tmp'
349         with open(tmpfile, 'w') as f:
350             self.config.write(f)
351             f.flush()
352             _os.fsync(f.fileno())
353         _os.rename(tmpfile, dst_config_file)
354         self._save_feeds()
355
356     def _save_feeds(self):
357         _LOG.debug('save feed data to {}'.format(self.datafile))
358         dirname = _os.path.dirname(self.datafile)
359         if dirname and not _os.path.isdir(dirname):
360             _os.makedirs(dirname, mode=0o700, exist_ok=True)
361         tmpfile = self.datafile + '.tmp'
362         with _codecs.open(tmpfile, 'w', self.datafile_encoding) as f:
363             self._save_feed_states(feeds=self, stream=f)
364             f.flush()
365             _os.fsync(f.fileno())
366         _os.rename(tmpfile, self.datafile)
367         if UNIX and self._datafile_lock is not None:
368             self._datafile_lock.close()  # release the lock
369             self._datafile_lock = None
370
371     def _save_feed_states(self, feeds, stream):
372         _json.dump(
373             {'version': self.datafile_version,
374              'feeds': list(feed.get_state() for feed in feeds),
375              },
376             stream,
377             indent=2,
378             separators=(',', ': '),
379             )
380         stream.write('\n')
381
382     def new_feed(self, name=None, prefix='feed-', **kwargs):
383         """Return a new feed, possibly auto-generating a name.
384
385         >>> feeds = Feeds()
386         >>> print(feeds.new_feed(name='my-feed'))
387         my-feed (None -> a@b.com)
388         >>> print(feeds.new_feed())
389         feed-0 (None -> a@b.com)
390         >>> print(feeds.new_feed())
391         feed-1 (None -> a@b.com)
392         >>> print(feeds.new_feed(name='feed-1'))
393         Traceback (most recent call last):
394           ...
395         rss2email.error.DuplicateFeedName: duplicate feed name 'feed-1'
396         """
397         feed_names = [feed.name for feed in self]
398         if name is None:
399             i = 0
400             while True:
401                 name = '{}{}'.format(prefix, i)
402                 if name not in feed_names:
403                     break
404                 i += 1
405         elif name in feed_names:
406             feed = self[name]
407             raise _error.DuplicateFeedName(name=feed.name, feed=feed)
408         feed = _feed.Feed(name=name, **kwargs)
409         self.append(feed)
410         return feed