1 # Copyright (C) 2004-2014 Aaron Swartz
5 # Etienne Millon <me@emillon.org>
7 # Lindsey Smith <lindsey.smith@gmail.com>
9 # Martin 'Joey' Schulze
11 # W. Trevor King <wking@tremily.us>
13 # This file is part of rss2email.
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
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.
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/>.
27 """Define the ``Feed`` class for handling a list of feeds
30 import codecs as _codecs
31 import collections as _collections
34 import pickle as _pickle
37 from . import LOG as _LOG
38 from . import config as _config
39 from . import error as _error
40 from . import feed as _feed
44 import fcntl as _fcntl
45 # A pox on SunOS file locking methods
46 if 'sunos' not in _sys.platform:
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
57 """Utility class for rss2email activity.
63 >>> from .feed import Feed
65 Setup a temporary directory to load.
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:
82 ... Feed(name='f1').get_state(),
83 ... Feed(name='f2').get_state(),
87 >>> feeds = Feeds(configfiles=[configfile,], datafile=datafile)
89 >>> for feed in feeds:
91 f1 (http://a.net/feed.atom -> x@y.net)
92 f2 (http://b.com/rss.atom -> a@b.com)
94 You can index feeds by array index or by feed name.
97 <Feed f1 (http://a.net/feed.atom -> x@y.net)>
99 <Feed f2 (http://b.com/rss.atom -> a@b.com)>
101 <Feed f1 (http://a.net/feed.atom -> x@y.net)>
103 Traceback (most recent call last):
107 Tweak the feed configuration and save.
109 >>> feeds[0].to = None
111 >>> print(open(configfile, 'r').read().rstrip('\\n'))
112 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
114 from = user@rss2email.invalid
119 url = http://a.net/feed.atom
122 url = http://b.com/rss.atom
124 Cleanup the temporary directory.
129 datafile_encoding = 'utf-8'
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
137 datafile = self._get_datafile()
138 self.datafile = datafile
140 config = _config.CONFIG
142 self._datafile_lock = None
144 def __getitem__(self, key):
150 except ValueError as e:
151 raise IndexError(key) from e
152 return super(Feeds, self).__getitem__(index)
154 def __append__(self, feed):
155 feed.load_from_config(self.config)
156 feed = super(Feeds, self).append(feed)
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)
164 def index(self, index):
165 if isinstance(index, int):
168 except IndexError as e:
169 raise _error.FeedIndexError(index=index, feeds=self) from e
170 elif isinstance(index, str):
176 return self.index(index)
178 if feed.name == index:
181 super(Feeds, self).index(index)
182 except (IndexError, ValueError) as e:
183 raise _error.FeedIndexError(index=index, feeds=self) from e
185 def remove(self, feed):
186 super(Feeds, self).remove(feed)
187 if feed.section in self.config:
188 self.config.pop(feed.section)
194 def _get_configfiles(self):
195 """Get configuration file paths
197 Following the XDG Base Directory Specification.
199 config_home = _os.environ.get(
201 _os.path.expanduser(_os.path.join('~', '.config')))
202 config_dirs = [config_home]
206 _os.path.join(ROOT_PATH, 'etc', 'xdg'),
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]))
213 def _get_datafile(self):
214 """Get the data file path
216 Following the XDG Base Directory Specification.
218 data_home = _os.environ.get(
220 _os.path.expanduser(_os.path.join('~', '.local', 'share')))
221 data_dirs = [data_home]
226 _os.path.join(ROOT_PATH, 'usr', 'local', 'share'),
227 _os.path.join(ROOT_PATH, 'usr', 'share'),
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):
237 def load(self, lock=True, require=False):
238 _LOG.debug('load feed configuration from {}'.format(self.configfiles))
240 self.read_configfiles = self.config.read(self.configfiles)
242 self.read_configfiles = []
243 _LOG.debug('loaded configuration from {}'.format(
244 self.read_configfiles))
245 self._load_feeds(lock=lock, require=require)
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):
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)
260 self._datafile_lock = _codecs.open(
261 self.datafile, 'r', self.datafile_encoding)
263 raise _error.DataFileError(feeds=self) from e
267 locktype = _fcntl.LOCK_EX
268 _fcntl.flock(self._datafile_lock.fileno(), locktype)
273 handlers = list(_LOG.handlers)
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(
289 message='missing feed name in datafile {}'.format(
293 _LOG.handlers = handlers
297 self._datafile_lock.close()
298 self._datafile_lock = None
301 feed.load_from_config(self.config)
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:
311 ('feed {} not found in feed file, '
312 'initializing from config').format(name))
313 self.append(_feed.Feed(name=name, config=self.config))
316 return order[feed.name]
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))
324 'version': self.datafile_version,
328 def _upgrade_state_data(self, data):
329 version = data.get('version', 'unknown')
331 for feed in data['feeds']:
333 for guid,id_ in seen.items():
334 seen[guid] = {'id': id_}
336 raise NotImplementedError(
337 'cannot convert data file from version {} to {}'.format(
338 version, self.datafile_version))
341 _LOG.debug('save feed configuration to {}'.format(self.configfiles[-1]))
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:
351 _os.fsync(f.fileno())
352 _os.rename(tmpfile, self.configfiles[-1])
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)
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
370 def _save_feed_states(self, feeds, stream):
372 {'version': self.datafile_version,
373 'feeds': list(feed.get_state() for feed in feeds),
377 separators=(',', ': '),
381 def new_feed(self, name=None, prefix='feed-', **kwargs):
382 """Return a new feed, possibly auto-generating a name.
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):
394 rss2email.error.DuplicateFeedName: duplicate feed name 'feed-1'
396 feed_names = [feed.name for feed in self]
400 name = '{}{}'.format(prefix, i)
401 if name not in feed_names:
404 elif name in feed_names:
406 raise _error.DuplicateFeedName(name=feed.name, feed=feed)
407 feed = _feed.Feed(name=name, **kwargs)