redturtle.rssservice 2.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redturtle/rssservice/__init__.py +6 -0
- redturtle/rssservice/configure.zcml +20 -0
- redturtle/rssservice/interfaces.py +44 -0
- redturtle/rssservice/proxycacheserver/__init__.py +1 -0
- redturtle/rssservice/proxycacheserver/main.py +328 -0
- redturtle/rssservice/rss_mixer.py +378 -0
- redturtle/rssservice/testing.py +78 -0
- redturtle/rssservice/tests/__init__.py +0 -0
- redturtle/rssservice/tests/test_proxycache.py +91 -0
- redturtle/rssservice/tests/test_rss_mixer.py +334 -0
- redturtle.rssservice-2.2.2-py3.11-nspkg.pth +1 -0
- redturtle_rssservice-2.2.2.dist-info/METADATA +293 -0
- redturtle_rssservice-2.2.2.dist-info/RECORD +19 -0
- redturtle_rssservice-2.2.2.dist-info/WHEEL +5 -0
- redturtle_rssservice-2.2.2.dist-info/entry_points.txt +5 -0
- redturtle_rssservice-2.2.2.dist-info/licenses/LICENSE.GPL +339 -0
- redturtle_rssservice-2.2.2.dist-info/licenses/LICENSE.rst +15 -0
- redturtle_rssservice-2.2.2.dist-info/namespace_packages.txt +1 -0
- redturtle_rssservice-2.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from DateTime import DateTime
|
|
3
|
+
from DateTime.interfaces import SyntaxError
|
|
4
|
+
from os import environ
|
|
5
|
+
from plone.dexterity.utils import iterSchemata
|
|
6
|
+
from plone.restapi.serializer.converters import json_compatible
|
|
7
|
+
from plone.restapi.serializer.utils import uid_to_url
|
|
8
|
+
from plone.restapi.services import Service
|
|
9
|
+
from redturtle.rssservice import _
|
|
10
|
+
from redturtle.rssservice.interfaces import IRSSMixerFeed
|
|
11
|
+
from requests.exceptions import RequestException
|
|
12
|
+
from requests.exceptions import Timeout
|
|
13
|
+
from time import time
|
|
14
|
+
from zExceptions import BadRequest
|
|
15
|
+
from zExceptions import NotFound
|
|
16
|
+
from zope.i18n import translate
|
|
17
|
+
from zope.interface import implementer
|
|
18
|
+
from zope.schema import getFields
|
|
19
|
+
from App.config import getConfiguration
|
|
20
|
+
import feedparser
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Accept these bozo_exceptions encountered by feedparser when parsing
|
|
29
|
+
# the feed:
|
|
30
|
+
ACCEPTED_FEEDPARSER_EXCEPTIONS = (feedparser.CharacterEncodingOverride,)
|
|
31
|
+
|
|
32
|
+
# store the feeds here (which means in RAM)
|
|
33
|
+
FEED_DATA = {} # url: ({date, title, url, itemlist})
|
|
34
|
+
|
|
35
|
+
REQUESTS_TIMEOUT = int(environ.get("RSS_SERVICE_TIMEOUT", "5")) or 5
|
|
36
|
+
REQUESTS_USER_AGENT = environ.get("RSS_USER_AGENT")
|
|
37
|
+
RSSMIXER_HTTP_PROXY = environ.get("RSSMIXER_PROXY", "")
|
|
38
|
+
|
|
39
|
+
DEBUGMODE = getConfiguration().debug_mode
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RSSMixerService(Service):
|
|
43
|
+
""" """
|
|
44
|
+
|
|
45
|
+
def reply(self):
|
|
46
|
+
feed_config = self.get_feed_config()
|
|
47
|
+
limit = feed_config.get("limit", 20)
|
|
48
|
+
feeds = feed_config.get("feeds", [])
|
|
49
|
+
if not feeds:
|
|
50
|
+
raise BadRequest(
|
|
51
|
+
translate(
|
|
52
|
+
_(
|
|
53
|
+
"missing_feeds_parameter",
|
|
54
|
+
default="Missing required parameter: feeds",
|
|
55
|
+
),
|
|
56
|
+
context=self.request,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return self._getFeeds(feeds=feeds, limit=limit)
|
|
60
|
+
|
|
61
|
+
def get_feed_config(self):
|
|
62
|
+
""" """
|
|
63
|
+
query = self.request.form
|
|
64
|
+
if DEBUGMODE and query.get("rss_debug_uri"):
|
|
65
|
+
return {"feeds": [{"url": query.get("rss_debug_uri")}]}
|
|
66
|
+
|
|
67
|
+
block_id = query.get("block", "")
|
|
68
|
+
if not block_id:
|
|
69
|
+
raise BadRequest(
|
|
70
|
+
translate(
|
|
71
|
+
_(
|
|
72
|
+
"missing_block_id",
|
|
73
|
+
default="Missing required parameter: block",
|
|
74
|
+
),
|
|
75
|
+
context=self.request,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
block_data = self.get_block_data(block_id=block_id)
|
|
80
|
+
if not block_data:
|
|
81
|
+
raise NotFound(
|
|
82
|
+
translate(
|
|
83
|
+
_(
|
|
84
|
+
"block_not_found",
|
|
85
|
+
default='Block with id "{}" not found in this context.'.format(
|
|
86
|
+
block_id
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
context=self.request,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if block_data.get("@type", "") != "rssBlock":
|
|
93
|
+
raise BadRequest(
|
|
94
|
+
translate(
|
|
95
|
+
_(
|
|
96
|
+
"wrong_block_type",
|
|
97
|
+
default='Block with id "{}" is not an RSS block.'.format(
|
|
98
|
+
block_id
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
context=self.request,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return block_data
|
|
105
|
+
|
|
106
|
+
def get_block_data(self, block_id):
|
|
107
|
+
blocks = getattr(self.context, "blocks", {})
|
|
108
|
+
if not blocks:
|
|
109
|
+
return {}
|
|
110
|
+
if not isinstance(blocks, dict):
|
|
111
|
+
# plone < 6 support
|
|
112
|
+
blocks = json.loads(blocks)
|
|
113
|
+
rss_block = blocks.get(block_id, {})
|
|
114
|
+
if not rss_block:
|
|
115
|
+
# maybe is in some Block field
|
|
116
|
+
for schema in iterSchemata(self.context):
|
|
117
|
+
for name, field in getFields(schema).items():
|
|
118
|
+
value = field.get(self.context)
|
|
119
|
+
if not value:
|
|
120
|
+
continue
|
|
121
|
+
if not isinstance(value, dict):
|
|
122
|
+
continue
|
|
123
|
+
rss_block = value.get("blocks", {}).get(block_id, {})
|
|
124
|
+
return rss_block
|
|
125
|
+
|
|
126
|
+
def _getFeeds(self, feeds, limit=20):
|
|
127
|
+
"""Return all feeds"""
|
|
128
|
+
data = []
|
|
129
|
+
for feed_data in feeds:
|
|
130
|
+
url = feed_data.get("url", "")
|
|
131
|
+
source = feed_data.get("source", "")
|
|
132
|
+
feed = FEED_DATA.get(url, None)
|
|
133
|
+
if feed is None:
|
|
134
|
+
# create it
|
|
135
|
+
feed = FEED_DATA[url] = RSSMixerFeed(
|
|
136
|
+
url=url,
|
|
137
|
+
source=source,
|
|
138
|
+
timeout=100,
|
|
139
|
+
)
|
|
140
|
+
# if it's new, populate it, else try to see if it need to be updated
|
|
141
|
+
else:
|
|
142
|
+
# check if we need to update the source
|
|
143
|
+
if feed.source != source:
|
|
144
|
+
feed.source = source
|
|
145
|
+
feed.update()
|
|
146
|
+
data.append(feed)
|
|
147
|
+
return self._sortedFeeds(feeds=data, limit=limit)
|
|
148
|
+
|
|
149
|
+
def _sortedFeeds(self, feeds, limit):
|
|
150
|
+
"""Sort feed items by date"""
|
|
151
|
+
|
|
152
|
+
itemsWithDate = []
|
|
153
|
+
itemsWithoutDate = []
|
|
154
|
+
for feed in feeds:
|
|
155
|
+
for item in feed.items:
|
|
156
|
+
if "date" in item:
|
|
157
|
+
itemsWithDate.append(item)
|
|
158
|
+
else:
|
|
159
|
+
itemsWithoutDate.append(item)
|
|
160
|
+
sortedItems = sorted(itemsWithDate, key=lambda d: d["date"], reverse=True)
|
|
161
|
+
total = sortedItems + itemsWithoutDate
|
|
162
|
+
|
|
163
|
+
# fix date format
|
|
164
|
+
return total[:limit]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@implementer(IRSSMixerFeed)
|
|
168
|
+
class RSSMixerFeed(object):
|
|
169
|
+
"""An RSS feed."""
|
|
170
|
+
|
|
171
|
+
FAILURE_DELAY = 10
|
|
172
|
+
|
|
173
|
+
def __init__(self, url, source, timeout):
|
|
174
|
+
self.url = url
|
|
175
|
+
self.timeout = timeout
|
|
176
|
+
self.source = source
|
|
177
|
+
self._items = []
|
|
178
|
+
self._title = ""
|
|
179
|
+
self._siteurl = ""
|
|
180
|
+
self._loaded = False # is the feed loaded
|
|
181
|
+
self._failed = False # does it fail at the last update?
|
|
182
|
+
self._last_update_time_in_minutes = 0 # when was the feed updated?
|
|
183
|
+
self._last_update_time = None # time as DateTime or Nonw
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def last_update_time_in_minutes(self):
|
|
187
|
+
"""Return the time the last update was done in minutes."""
|
|
188
|
+
return self._last_update_time_in_minutes
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def last_update_time(self):
|
|
192
|
+
"""Return the time the last update was done in minutes."""
|
|
193
|
+
return self._last_update_time
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def update_failed(self):
|
|
197
|
+
return self._failed
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def ok(self):
|
|
201
|
+
return not self._failed and self._loaded
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def loaded(self):
|
|
205
|
+
"""Return whether this feed is loaded or not."""
|
|
206
|
+
return self._loaded
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def needs_update(self):
|
|
210
|
+
"""Check if this feed needs updating."""
|
|
211
|
+
now = time() / 6
|
|
212
|
+
return (self.last_update_time_in_minutes + self.timeout) < now
|
|
213
|
+
|
|
214
|
+
def update(self):
|
|
215
|
+
"""Update this feed."""
|
|
216
|
+
now = time() / 60 # time in minutes
|
|
217
|
+
# check for failure and retry
|
|
218
|
+
if self.update_failed:
|
|
219
|
+
if (self.last_update_time_in_minutes + self.FAILURE_DELAY) < now:
|
|
220
|
+
return self._retrieveFeed()
|
|
221
|
+
else:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
# check for regular update
|
|
225
|
+
if self.needs_update:
|
|
226
|
+
return self._retrieveFeed()
|
|
227
|
+
|
|
228
|
+
return self.ok
|
|
229
|
+
|
|
230
|
+
def _getFeedFromUrl(self, url):
|
|
231
|
+
"""
|
|
232
|
+
Use urllib to retrieve an rss feed.
|
|
233
|
+
In this way, we can manage timeouts.
|
|
234
|
+
"""
|
|
235
|
+
url = uid_to_url(url)
|
|
236
|
+
headers = {}
|
|
237
|
+
if REQUESTS_USER_AGENT:
|
|
238
|
+
headers["User-Agent"] = REQUESTS_USER_AGENT
|
|
239
|
+
try:
|
|
240
|
+
if RSSMIXER_HTTP_PROXY:
|
|
241
|
+
url = f"{RSSMIXER_HTTP_PROXY}/{url}"
|
|
242
|
+
response = requests.get(
|
|
243
|
+
url,
|
|
244
|
+
headers=headers,
|
|
245
|
+
timeout=REQUESTS_TIMEOUT,
|
|
246
|
+
)
|
|
247
|
+
except (Timeout, RequestException) as e:
|
|
248
|
+
logger.warning("exception %s during %s request", e, url)
|
|
249
|
+
return None
|
|
250
|
+
if response.status_code != 200:
|
|
251
|
+
message = response.text or response.reason
|
|
252
|
+
logger.error(
|
|
253
|
+
"Unable to retrieve feed from {url}: {message}".format(
|
|
254
|
+
url=url, message=message
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
return feedparser.parse(response.content)
|
|
259
|
+
|
|
260
|
+
def _retrieveFeed(self):
|
|
261
|
+
"""Do the actual work and try to retrieve the feed."""
|
|
262
|
+
url = self.url
|
|
263
|
+
if not url:
|
|
264
|
+
self._loaded = True
|
|
265
|
+
self._failed = True # no url set means failed
|
|
266
|
+
# no url set, although that actually should not really happen
|
|
267
|
+
return False
|
|
268
|
+
self._last_update_time_in_minutes = time() / 60
|
|
269
|
+
self._last_update_time = DateTime()
|
|
270
|
+
parsed_feed = self._getFeedFromUrl(url)
|
|
271
|
+
if not parsed_feed:
|
|
272
|
+
self._loaded = True # we tried at least but have a failed load
|
|
273
|
+
self._failed = True
|
|
274
|
+
return False
|
|
275
|
+
if parsed_feed.bozo == 1 and not isinstance(
|
|
276
|
+
parsed_feed.get("bozo_exception"),
|
|
277
|
+
ACCEPTED_FEEDPARSER_EXCEPTIONS,
|
|
278
|
+
):
|
|
279
|
+
self._loaded = True # we tried at least but have a failed load
|
|
280
|
+
self._failed = True
|
|
281
|
+
return False
|
|
282
|
+
self._title = parsed_feed.feed.title
|
|
283
|
+
self._siteurl = parsed_feed.feed.link
|
|
284
|
+
self._items = []
|
|
285
|
+
|
|
286
|
+
for item in parsed_feed["items"]:
|
|
287
|
+
itemdict = {
|
|
288
|
+
"title": item.title,
|
|
289
|
+
"url": item.get("link", ""),
|
|
290
|
+
"contentSnippet": item.get("description", ""),
|
|
291
|
+
"source": getattr(self, "source", ""),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
date = self.get_item_date(item=item)
|
|
295
|
+
if date:
|
|
296
|
+
itemdict["date"] = date
|
|
297
|
+
|
|
298
|
+
image = self.get_item_image(item=item)
|
|
299
|
+
if image:
|
|
300
|
+
# format needed in blocks to keep compatibility
|
|
301
|
+
itemdict["enclosure"] = image
|
|
302
|
+
|
|
303
|
+
categories = self.get_item_categories(item=item)
|
|
304
|
+
if categories:
|
|
305
|
+
itemdict["categories"] = categories
|
|
306
|
+
|
|
307
|
+
self._items.append(itemdict)
|
|
308
|
+
self._loaded = True
|
|
309
|
+
self._failed = False
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def get_item_categories(self, item):
|
|
313
|
+
categories = []
|
|
314
|
+
if getattr(item, "tags", None):
|
|
315
|
+
for tag in item["tags"]:
|
|
316
|
+
term = getattr(tag, "term", None)
|
|
317
|
+
if term:
|
|
318
|
+
categories.append(term)
|
|
319
|
+
return categories
|
|
320
|
+
|
|
321
|
+
def get_item_date(self, item):
|
|
322
|
+
if getattr(item, "updated", None):
|
|
323
|
+
try:
|
|
324
|
+
return json_compatible(DateTime(item.updated))
|
|
325
|
+
except SyntaxError:
|
|
326
|
+
return json_compatible(item.updated)
|
|
327
|
+
elif getattr(item, "published", None):
|
|
328
|
+
try:
|
|
329
|
+
return json_compatible(DateTime(item.published))
|
|
330
|
+
except SyntaxError:
|
|
331
|
+
return json_compatible(item.published)
|
|
332
|
+
|
|
333
|
+
return ""
|
|
334
|
+
|
|
335
|
+
def get_item_image(self, item):
|
|
336
|
+
image = ""
|
|
337
|
+
if item.get("media_thumbnail", []):
|
|
338
|
+
image = item["media_thumbnail"][0].get("url", "")
|
|
339
|
+
elif item.get("media_content", []):
|
|
340
|
+
images = [
|
|
341
|
+
x.get("url", "")
|
|
342
|
+
for x in item.media_content
|
|
343
|
+
if x.get("medium", "") == "image"
|
|
344
|
+
]
|
|
345
|
+
if images:
|
|
346
|
+
image = images[0]
|
|
347
|
+
elif item.get("links", []):
|
|
348
|
+
images = [
|
|
349
|
+
x.get("url", "")
|
|
350
|
+
for x in item.links
|
|
351
|
+
if x.get("rel", "") == "enclosure" and "image" in x.get("type", "")
|
|
352
|
+
]
|
|
353
|
+
if images:
|
|
354
|
+
image = images[0]
|
|
355
|
+
if not image:
|
|
356
|
+
return {}
|
|
357
|
+
return {"url": image}
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def items(self):
|
|
361
|
+
return self._items
|
|
362
|
+
|
|
363
|
+
# convenience methods for displaying
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def feed_link(self):
|
|
367
|
+
"""Return rss url of feed for tile."""
|
|
368
|
+
return self.url.replace("http://", "feed://")
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def title(self):
|
|
372
|
+
"""Return title of feed for tile."""
|
|
373
|
+
return self._title
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def siteurl(self):
|
|
377
|
+
"""Return the link to the site the RSS feed points to."""
|
|
378
|
+
return self._siteurl
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE
|
|
3
|
+
from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE
|
|
4
|
+
from plone.app.testing import applyProfile
|
|
5
|
+
from plone.app.testing import FunctionalTesting
|
|
6
|
+
from plone.app.testing import IntegrationTesting
|
|
7
|
+
from plone.app.testing import PloneSandboxLayer
|
|
8
|
+
from plone.restapi.testing import PloneRestApiDXLayer
|
|
9
|
+
from plone.testing import z2
|
|
10
|
+
|
|
11
|
+
import plone.restapi
|
|
12
|
+
import redturtle.rssservice
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RedTurtleRSSServiceLayer(PloneSandboxLayer):
|
|
16
|
+
defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)
|
|
17
|
+
|
|
18
|
+
def setUpZope(self, app, configurationContext):
|
|
19
|
+
# Load any other ZCML that is required for your tests.
|
|
20
|
+
# The z3c.autoinclude feature is disabled in the Plone fixture base
|
|
21
|
+
# layer.
|
|
22
|
+
self.loadZCML(package=plone.restapi)
|
|
23
|
+
self.loadZCML(package=redturtle.rssservice)
|
|
24
|
+
|
|
25
|
+
def setUpPloneSite(self, portal):
|
|
26
|
+
applyProfile(portal, "plone.restapi:default")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
REDTURTLE_RSSSERVICE_FIXTURE = RedTurtleRSSServiceLayer()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
REDTURTLE_RSSSERVICE_INTEGRATION_TESTING = IntegrationTesting(
|
|
33
|
+
bases=(REDTURTLE_RSSSERVICE_FIXTURE,),
|
|
34
|
+
name="RedTurtleRSSServiceLayer:IntegrationTesting",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
REDTURTLE_RSSSERVICE_FUNCTIONAL_TESTING = FunctionalTesting(
|
|
39
|
+
bases=(REDTURTLE_RSSSERVICE_FIXTURE,),
|
|
40
|
+
name="RedTurtleRSSServiceLayer:FunctionalTesting",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
REDTURTLE_RSSSERVICE_ACCEPTANCE_TESTING = FunctionalTesting(
|
|
45
|
+
bases=(
|
|
46
|
+
REDTURTLE_RSSSERVICE_FIXTURE,
|
|
47
|
+
REMOTE_LIBRARY_BUNDLE_FIXTURE,
|
|
48
|
+
z2.ZSERVER_FIXTURE,
|
|
49
|
+
),
|
|
50
|
+
name="RedTurtleRSSServiceLayer:AcceptanceTesting",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RedTurtleRSSServiceRestApiLayer(PloneRestApiDXLayer):
|
|
55
|
+
defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)
|
|
56
|
+
|
|
57
|
+
def setUpZope(self, app, configurationContext):
|
|
58
|
+
super(RedTurtleRSSServiceRestApiLayer, self).setUpZope(
|
|
59
|
+
app, configurationContext
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self.loadZCML(package=plone.restapi)
|
|
63
|
+
self.loadZCML(package=redturtle.rssservice)
|
|
64
|
+
|
|
65
|
+
def setUpPloneSite(self, portal):
|
|
66
|
+
applyProfile(portal, "plone.restapi:default")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
REDTURTLE_RSSSERVICE_API_FIXTURE = RedTurtleRSSServiceRestApiLayer()
|
|
70
|
+
REDTURTLE_RSSSERVICE_API_INTEGRATION_TESTING = IntegrationTesting(
|
|
71
|
+
bases=(REDTURTLE_RSSSERVICE_API_FIXTURE,),
|
|
72
|
+
name="RedTurtleRSSServiceRestApiLayer:Integration",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
REDTURTLE_RSSSERVICE_API_FUNCTIONAL_TESTING = FunctionalTesting(
|
|
76
|
+
bases=(REDTURTLE_RSSSERVICE_API_FIXTURE, z2.ZSERVER_FIXTURE),
|
|
77
|
+
name="RedTurtleRSSServiceRestApiLayer:Functional",
|
|
78
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import importlib
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
main = importlib.import_module("redturtle.rssservice.proxycacheserver.main")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProxyCacheServerTest(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.test_dir = tempfile.mkdtemp()
|
|
15
|
+
main.ACTIVE_REFRESH_THREADS.clear()
|
|
16
|
+
main.LAST_ACCESS_TIMES.clear()
|
|
17
|
+
|
|
18
|
+
def tearDown(self):
|
|
19
|
+
shutil.rmtree(self.test_dir, ignore_errors=True)
|
|
20
|
+
main.ACTIVE_REFRESH_THREADS.clear()
|
|
21
|
+
main.LAST_ACCESS_TIMES.clear()
|
|
22
|
+
|
|
23
|
+
def test_cache_path(self):
|
|
24
|
+
url = "https://example.com/rss.xml"
|
|
25
|
+
path = main.cache_path(url, self.test_dir)
|
|
26
|
+
self.assertTrue(path.startswith(self.test_dir))
|
|
27
|
+
self.assertTrue(path.endswith(".json"))
|
|
28
|
+
|
|
29
|
+
def test_safe_atomic_write_and_load_json(self):
|
|
30
|
+
file_path = os.path.join(self.test_dir, "test.json")
|
|
31
|
+
data = {"url": "https://example.com/rss", "status_code": 200, "body": "OK"}
|
|
32
|
+
main.safe_atomic_write_json(file_path, data)
|
|
33
|
+
|
|
34
|
+
loaded = main.load_json(file_path)
|
|
35
|
+
self.assertEqual(loaded, data)
|
|
36
|
+
# Ensure no temporary file left
|
|
37
|
+
files = os.listdir(self.test_dir)
|
|
38
|
+
self.assertEqual(files, ["test.json"])
|
|
39
|
+
|
|
40
|
+
def test_is_valid_url(self):
|
|
41
|
+
self.assertTrue(main.is_valid_url("https://example.com/feed"))
|
|
42
|
+
self.assertTrue(main.is_valid_url("http://news.google.com/rss"))
|
|
43
|
+
self.assertFalse(main.is_valid_url("ftp://example.com"))
|
|
44
|
+
self.assertFalse(main.is_valid_url("http://localhost:8080"))
|
|
45
|
+
self.assertFalse(main.is_valid_url("http://127.0.0.1/admin"))
|
|
46
|
+
self.assertFalse(main.is_valid_url("http://169.254.169.254/latest/meta-data/"))
|
|
47
|
+
|
|
48
|
+
def test_ensure_refresh_thread_deduplication(self):
|
|
49
|
+
url = "https://example.com/feed"
|
|
50
|
+
with mock.patch("threading.Thread") as mock_thread_cls:
|
|
51
|
+
mock_thread_instance = mock.MagicMock()
|
|
52
|
+
mock_thread_cls.return_value = mock_thread_instance
|
|
53
|
+
|
|
54
|
+
main.ensure_refresh_thread(url, self.test_dir, ttl=60)
|
|
55
|
+
self.assertIn(url, main.ACTIVE_REFRESH_THREADS)
|
|
56
|
+
self.assertEqual(mock_thread_cls.call_count, 1)
|
|
57
|
+
|
|
58
|
+
# Second call for the same URL should be deduplicated
|
|
59
|
+
main.ensure_refresh_thread(url, self.test_dir, ttl=60)
|
|
60
|
+
self.assertEqual(mock_thread_cls.call_count, 1)
|
|
61
|
+
|
|
62
|
+
@mock.patch("requests.get")
|
|
63
|
+
def test_fetch_and_cache_success(self, mock_requests_get):
|
|
64
|
+
mock_response = mock.MagicMock()
|
|
65
|
+
mock_response.status_code = 200
|
|
66
|
+
mock_response.headers = {"Content-Type": "application/rss+xml"}
|
|
67
|
+
mock_response.text = "<rss><channel><title>Test Feed</title></channel></rss>"
|
|
68
|
+
mock_requests_get.return_value = mock_response
|
|
69
|
+
|
|
70
|
+
url = "https://example.com/rss"
|
|
71
|
+
result = main.fetch_and_cache(url, self.test_dir)
|
|
72
|
+
|
|
73
|
+
self.assertEqual(result["status_code"], 200)
|
|
74
|
+
self.assertEqual(result["body"], mock_response.text)
|
|
75
|
+
|
|
76
|
+
# Check cached file
|
|
77
|
+
cache_file = main.cache_path(url, self.test_dir)
|
|
78
|
+
self.assertTrue(os.path.exists(cache_file))
|
|
79
|
+
loaded = main.load_json(cache_file)
|
|
80
|
+
self.assertEqual(loaded["status_code"], 200)
|
|
81
|
+
|
|
82
|
+
@mock.patch("requests.get")
|
|
83
|
+
def test_fetch_and_cache_connection_error_not_persisted(self, mock_requests_get):
|
|
84
|
+
mock_requests_get.side_effect = Exception("Connection refused")
|
|
85
|
+
|
|
86
|
+
url = "https://example.com/failing_rss"
|
|
87
|
+
result = main.fetch_and_cache(url, self.test_dir)
|
|
88
|
+
|
|
89
|
+
self.assertEqual(result["status_code"], 502)
|
|
90
|
+
cache_file = main.cache_path(url, self.test_dir)
|
|
91
|
+
self.assertFalse(os.path.exists(cache_file))
|