Skip to content

Commit 9d8dc03

Browse files
committed
feat: save the feed URL on redirection
Automatically update a feed's URL when the server sends a redirect (a 301 or 308 status code) and --save-config option is set. There is a set of tests to confirm that the system correctly saves the new, redirected URL in the feed's settings. The tests verify that when a feed returns a 301 or 308 redirect status, the new URL is correctly updated in the feed's configuration and persisted. This finalises the implementation and ensures the feature works as intended. References: #286
1 parent a1c3009 commit 9d8dc03

File tree

5 files changed

+132
-10
lines changed

5 files changed

+132
-10
lines changed

rss2email/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def run(feeds, args):
9494
interval = interval
9595
))
9696
_time.sleep(interval)
97-
feed.run(send=args.send, clean=args.clean)
97+
feed.run(send=args.send, clean=args.clean, save_config=args.save_config)
9898
except _error.RSS2EmailError as e:
9999
e.log()
100100
last_server = current_server

rss2email/feed.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,9 @@ def _fetch(self):
386386
f = _util.TimeLimitedFunction('feed {}'.format(self.name), timeout, _feedparser.parse)
387387
return f(self.url, self.etag, modified=self.modified, agent=self.user_agent, **kwargs)
388388

389-
def _process(self, parsed):
389+
def _process(self, parsed, save_config=False):
390390
_LOG.info('process {}'.format(self))
391-
self._check_for_errors(parsed)
391+
self._check_for_errors(parsed, save_config=save_config)
392392
for entry in reversed(parsed.entries):
393393
_LOG.debug('processing {}'.format(entry.get('id', 'no-id')))
394394
processed = self._process_entry(parsed=parsed, entry=entry)
@@ -402,15 +402,16 @@ def _process(self, parsed):
402402
continue
403403
yield processed
404404

405-
def _check_for_errors(self, parsed):
405+
def _check_for_errors(self, parsed, save_config=False):
406406
warned = False
407407
status = getattr(parsed, 'status', 200)
408408
_LOG.debug('HTTP status {}'.format(status))
409409
if status in [301, 308]:
410410
_LOG.info('redirect {} from {} to {}'.format(
411411
self.name, self.url, parsed['url']))
412412
self.url = parsed['url']
413-
# TODO: `url` is not saved -- add config option to call feeds.save_config() in run command
413+
if save_config:
414+
self.save_to_config()
414415
elif status == 304:
415416
_LOG.info('skipping {}: feed was not modified since last update'.format(
416417
self.name, self.url))
@@ -917,7 +918,7 @@ def _send(self, sender, message):
917918
_email.send(recipient=self.to, message=message,
918919
config=self.config, section=section)
919920

920-
def run(self, send=True, clean=False):
921+
def run(self, send=True, clean=False, save_config=False):
921922
"""Fetch and process the feed, mailing entry emails.
922923
923924
>>> feed = Feed(
@@ -947,7 +948,7 @@ def run(self, send=True, clean=False):
947948
raise _error.InvalidDigestType(type)
948949
digest = self._new_digest()
949950
seen = []
950-
for (guid, state, sender, message) in self._process(parsed):
951+
for (guid, state, sender, message) in self._process(parsed, save_config=save_config):
951952
_LOG.debug('new message: {}'.format(message['Subject']))
952953
seen.append((guid, state))
953954
self._append_to_digest(digest=digest, message=message)
@@ -962,7 +963,7 @@ def run(self, send=True, clean=False):
962963
for (guid, state) in seen:
963964
self.seen[guid] = state
964965
else:
965-
for (guid, state, sender, message) in self._process(parsed):
966+
for (guid, state, sender, message) in self._process(parsed, save_config=save_config):
966967
_LOG.debug('new message: {}'.format(message['Subject']))
967968
if send:
968969
self._send(sender=sender, message=message)

rss2email/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ def run(*args, **kwargs):
112112
default=True, action='store_const', const=False,
113113
help="fetch feeds, but don't send email")
114114
run_parser.add_argument(
115-
'--clean', action='store_true',
116-
help='clean old feed entries')
115+
'--save-config', action='store_true',
116+
help='save feed configuration changes (e.g. new URL after a redirect)')
117117
run_parser.add_argument(
118118
'index', nargs='*',
119119
help='feeds to fetch (defaults to fetching all feeds)')

test/test_main.py.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test_main.py

test/test_redirect.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
4+
from rss2email.feed import Feed
5+
from rss2email.config import Config, CONFIG
6+
7+
8+
class TestFeedURLRedirect(unittest.TestCase):
9+
10+
def setUp(self):
11+
"""Set up a feed object for testing."""
12+
# Use a mock config to avoid touching the user's real configuration
13+
mock_config = Config()
14+
mock_config.read_dict({'DEFAULT': CONFIG['DEFAULT']})
15+
# Prevent the config from trying to load or save real files
16+
mock_config.load_from_file = MagicMock()
17+
mock_config.save_to_file = MagicMock()
18+
19+
self.feed = Feed(name='TestFeed', url='http://old-url.com/feed',
20+
config=mock_config)
21+
self.feed.to = '[email protected]'
22+
# Mock the save method on the feed object to isolate the test
23+
self.feed.save_to_config = MagicMock()
24+
25+
@patch('rss2email.feed._feedparser.parse')
26+
def test_redirect_updates_url_and_saves_config(self, mock_parse):
27+
# Simulate a 301 redirect response from feedparser
28+
data = {
29+
'status': 301,
30+
'url': 'http://new-url.com/feed',
31+
'entries': [],
32+
'bozo': 0,
33+
'headers': {},
34+
'etag': None,
35+
'modified': None,
36+
'bozo_exception': None,
37+
}
38+
mock_parsed = MagicMock()
39+
mock_parsed.status = data['status']
40+
mock_parsed.entries = data['entries']
41+
mock_parsed.bozo = data['bozo']
42+
43+
def getitem_side_effect(key):
44+
return data[key]
45+
mock_parsed.__getitem__.side_effect = getitem_side_effect
46+
47+
def get_side_effect(key, default=None):
48+
return data.get(key, default)
49+
mock_parsed.get.side_effect = get_side_effect
50+
mock_parse.return_value = mock_parsed
51+
52+
# Run the feed with save_config=True, disable sending emails
53+
self.feed.run(send=False, save_config=True)
54+
55+
# Check that the URL was updated in the feed object
56+
self.assertEqual(self.feed.url, 'http://new-url.com/feed')
57+
# Check that the change was persisted to config
58+
self.feed.save_to_config.assert_called_once()
59+
60+
@patch('rss2email.feed._feedparser.parse')
61+
def test_redirect_updates_url_without_saving_config(self, mock_parse):
62+
"""Test a 301 redirect updates the URL in memory but does not save."""
63+
# Simulate a 301 redirect response from feedparser
64+
data = {
65+
'status': 301,
66+
'url': 'http://new-url.com/feed',
67+
'entries': [],
68+
'bozo': 0,
69+
'headers': {},
70+
'etag': None,
71+
'modified': None,
72+
'bozo_exception': None,
73+
}
74+
mock_parsed = MagicMock()
75+
mock_parsed.status = data['status']
76+
mock_parsed.entries = data['entries']
77+
mock_parsed.bozo = data['bozo']
78+
79+
def getitem_side_effect(key):
80+
return data[key]
81+
mock_parsed.__getitem__.side_effect = getitem_side_effect
82+
83+
def get_side_effect(key, default=None):
84+
return data.get(key, default)
85+
mock_parsed.get.side_effect = get_side_effect
86+
mock_parse.return_value = mock_parsed
87+
88+
# Run the feed with save_config=False
89+
self.feed.run(send=False, save_config=False)
90+
91+
# Check that the URL was updated in the feed object for this session
92+
self.assertEqual(self.feed.url, 'http://new-url.com/feed')
93+
# Check that the change was NOT persisted to config
94+
self.feed.save_to_config.assert_not_called()
95+
96+
@patch('rss2email.feed._feedparser.parse')
97+
def test_no_redirect_does_not_update_url(self, mock_parse):
98+
"""Test that a normal (200 OK) response does not change the URL."""
99+
# Simulate a normal 200 OK response
100+
mock_parsed = MagicMock()
101+
mock_parsed.status = 200
102+
mock_parsed.url = 'http://old-url.com/feed'
103+
mock_parsed.entries = []
104+
mock_parsed.bozo = 0
105+
mock_parsed.headers = {}
106+
mock_parsed.etag = None
107+
mock_parsed.modified = None
108+
mock_parse.return_value = mock_parsed
109+
110+
# Run the feed, save_config can be true or false
111+
self.feed.run(send=False, save_config=True)
112+
113+
# Check that the URL was NOT updated
114+
self.assertEqual(self.feed.url, 'http://old-url.com/feed')
115+
# Check that save_to_config was NOT called for a non-redirect
116+
self.feed.save_to_config.assert_not_called()
117+
118+
119+
if __name__ == '__main__':
120+
unittest.main()

0 commit comments

Comments
 (0)