Merge branch 'patch-1' of https://github.com/punchagan/rss2email
[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 = 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         _LOG.debug('save feed configuration to {}'.format(self.configfiles[-1]))
342         for feed in self:
343             feed.save_to_config()
344         dirname = _os.path.dirname(self.configfiles[-1])
345         if dirname and not _os.path.isdir(dirname):
346             _os.makedirs(dirname, mode=0o700, exist_ok=True)
347         tmpfile = self.configfiles[-1] + '.tmp'
348         with open(tmpfile, 'w') as f:
349             self.config.write(f)
350             f.flush()
351             _os.fsync(f.fileno())
352         _os.rename(tmpfile, self.configfiles[-1])
353         self._save_feeds()
354
355     def _save_feeds(self):
356         _LOG.debug('save feed data to {}'.format(self.datafile))
357         dirname = _os.path.dirname(self.datafile)
358         if dirname and not _os.path.isdir(dirname):
359             _os.makedirs(dirname, mode=0o700, exist_ok=True)
360         tmpfile = self.datafile + '.tmp'
361         with _codecs.open(tmpfile, 'w', self.datafile_encoding) as f:
362             self._save_feed_states(feeds=self, stream=f)
363             f.flush()
364             _os.fsync(f.fileno())
365         _os.rename(tmpfile, self.datafile)
366         if UNIX and self._datafile_lock is not None:
367             self._datafile_lock.close()  # release the lock
368             self._datafile_lock = None
369
370     def _save_feed_states(self, feeds, stream):
371         _json.dump(
372             {'version': self.datafile_version,
373              'feeds': list(feed.get_state() for feed in feeds),
374              },
375             stream,
376             indent=2,
377             separators=(',', ': '),
378             )
379         stream.write('\n')
380
381     def new_feed(self, name=None, prefix='feed-', **kwargs):
382         """Return a new feed, possibly auto-generating a name.
383
384         >>> feeds = Feeds()
385         >>> print(feeds.new_feed(name='my-feed'))
386         my-feed (None -> a@b.com)
387         >>> print(feeds.new_feed())
388         feed-0 (None -> a@b.com)
389         >>> print(feeds.new_feed())
390         feed-1 (None -> a@b.com)
391         >>> print(feeds.new_feed(name='feed-1'))
392         Traceback (most recent call last):
393           ...
394         rss2email.error.DuplicateFeedName: duplicate feed name 'feed-1'
395         """
396         feed_names = [feed.name for feed in self]
397         if name is None:
398             i = 0
399             while True:
400                 name = '{}{}'.format(prefix, i)
401                 if name not in feed_names:
402                     break
403                 i += 1
404         elif name in feed_names:
405             feed = self[name]
406             raise _error.DuplicateFeedName(name=feed.name, feed=feed)
407         feed = _feed.Feed(name=name, **kwargs)
408         self.append(feed)
409         return feed