diff --git a/main.py b/main.py
index 3be0cf2c..2d4c420c 100755
--- a/main.py
+++ b/main.py
@@ -7,12 +7,13 @@ import sys
import pytz
from sanic import Sanic
+from sanic.signals import Event
from wrolpi import root_api, BEFORE_STARTUP_FUNCTIONS, after_startup, limit_concurrent, admin
from wrolpi.common import logger, get_config, import_modules, check_media_directory
from wrolpi.dates import set_timezone
from wrolpi.downloader import download_manager
-from wrolpi.vars import PROJECT_DIR, DOCKERIZED
+from wrolpi.vars import PROJECT_DIR, DOCKERIZED, PYTEST
from wrolpi.version import get_version_string
logger = logger.getChild('wrolpi-main')
@@ -191,16 +192,21 @@ def periodic_downloads(app: Sanic, loop):
logger.info('Starting download manager.')
async def _periodic_download():
- download_manager.renew_recurring_downloads()
- download_manager.delete_old_once_downloads()
+ download_manager.start_workers()
await download_manager.do_downloads()
- seconds = 60
- await asyncio.sleep(seconds)
- app.add_task(_periodic_download())
+ await asyncio.sleep(60)
+ app.add_task(_periodic_download()) # noqa
app.add_task(_periodic_download())
+@root_api.api_app.signal(Event.SERVER_SHUTDOWN_BEFORE)
+def handle_server_shutdown(*args, **kwargs):
+ """Stop downloads when server is shutting down."""
+ if not PYTEST:
+ download_manager.stop()
+
+
if __name__ == '__main__':
loop_ = asyncio.get_event_loop()
result_ = loop_.run_until_complete(main(loop_))
diff --git a/modules/archive/__init__.py b/modules/archive/__init__.py
index 1a0afe57..1d059e8a 100644
--- a/modules/archive/__init__.py
+++ b/modules/archive/__init__.py
@@ -30,17 +30,16 @@ class ArchiveDownloader(Downloader, ABC):
"""
return True, None
- def do_download(self, download: Download) -> DownloadResult:
+ async def do_download(self, download: Download) -> DownloadResult:
if download.attempts > 3:
raise UnrecoverableDownloadError(f'Max download attempts reached for {download.url}')
- archive = lib.do_archive(download.url)
+ archive = await lib.do_archive(download.url)
return DownloadResult(success=True, location=f'/archive/{archive.id}')
@optional_session
- def already_downloaded(self, url: str, session: Session = None) -> Optional[Archive]:
- archive = session.query(Archive).filter_by(url=url).one_or_none()
- return archive
+ def already_downloaded(self, url: str, session: Session = None) -> bool:
+ return bool(session.query(Archive).filter_by(url=url).count())
# Archive downloader is the last downloader which should be used.
diff --git a/modules/archive/api.py b/modules/archive/api.py
index b8a62e28..de25c386 100644
--- a/modules/archive/api.py
+++ b/modules/archive/api.py
@@ -1,8 +1,7 @@
import asyncio
from http import HTTPStatus
-from requests import Request
-from sanic import response
+from sanic import response, Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
diff --git a/modules/archive/lib.py b/modules/archive/lib.py
index 7c9590c2..1a325a96 100644
--- a/modules/archive/lib.py
+++ b/modules/archive/lib.py
@@ -10,14 +10,15 @@ from datetime import datetime
from itertools import groupby
from typing import Iterator, Optional, Tuple, List
-import requests
+import aiohttp
from bs4 import BeautifulSoup
from selenium import webdriver
from sqlalchemy.orm import Session
from modules.archive.models import Domain, Archive
from wrolpi.cmd import which
-from wrolpi.common import get_media_directory, logger, chunks, extract_domain, chdir, escape_file_name, walk
+from wrolpi.common import get_media_directory, logger, chunks, extract_domain, chdir, escape_file_name, walk, \
+ aiohttp_post
from wrolpi.dates import now, Seconds, local_timezone
from wrolpi.db import get_db_session, get_db_curs, get_ranked_models, optional_session
from wrolpi.errors import InvalidDomain, UnknownURL, InvalidArchive
@@ -102,7 +103,7 @@ def get_new_archive_files(url: str, title: Optional[str]) -> ArchiveFiles:
ARCHIVE_TIMEOUT = Seconds.minute * 10 # Wait at most 10 minutes for response.
-def request_archive(url: str):
+async def request_archive(url: str):
"""
Send a request to the archive service to archive the URL.
"""
@@ -110,18 +111,17 @@ def request_archive(url: str):
data = {'url': url}
try:
- resp = requests.post(f'{ARCHIVE_SERVICE}/json', json=data, timeout=ARCHIVE_TIMEOUT)
+ contents, status = await aiohttp_post(f'{ARCHIVE_SERVICE}/json', json_=data, timeout=ARCHIVE_TIMEOUT)
+ readability = contents['readability']
+ # Compressed base64
+ singlefile = contents['singlefile']
+ screenshot = contents['screenshot']
+
+ logger.debug(f'archive request status code {status}')
except Exception as e:
logger.error('Error when requesting archive', exc_info=e)
raise
- logger.debug(f'archive request status {resp.status_code=}')
-
- readability = resp.json()['readability']
- # Compressed base64
- singlefile = resp.json()['singlefile']
- screenshot = resp.json()['screenshot']
-
if not (screenshot or singlefile or readability):
raise Exception('singlefile response was empty!')
@@ -147,8 +147,8 @@ def request_archive(url: str):
SINGLE_FILE_BIN = which('single-file',
- '/usr/bin/single-file', # rpi ubuntu
- '/usr/local/bin/single-file', # debian
+ '/usr/bin/single-file', # rpi ubuntu
+ '/usr/local/bin/single-file', # debian
warn=True)
CHROMIUM = which('chromium-browser', 'chromium',
'/usr/bin/chromium-browser', # rpi ubuntu
@@ -194,8 +194,8 @@ def local_screenshot(url: str) -> bytes:
READABILITY_BIN = which('readability-extractor',
- '/usr/bin/readability-extractor', # rpi ubuntu
- '/usr/local/bin/readability-extractor', # debian
+ '/usr/bin/readability-extractor', # rpi ubuntu
+ '/usr/local/bin/readability-extractor', # debian
warn=True)
@@ -227,7 +227,7 @@ def local_archive(url: str):
return singlefile, readability, screenshot
-def do_archive(url: str) -> Archive:
+async def do_archive(url: str) -> Archive:
"""
Perform the real archive request to the archiving service. Store the resulting data into files. Create an Archive
record in the DB. Create Domain/URL if missing.
@@ -236,7 +236,7 @@ def do_archive(url: str) -> Archive:
if DOCKERIZED or PYTEST:
# Perform the archive in the Archive docker container. (Typically in the development environment).
- singlefile, readability, screenshot = request_archive(url)
+ singlefile, readability, screenshot = await request_archive(url)
else:
# Perform the archive using locally installed executables.
singlefile, readability, screenshot = local_archive(url)
diff --git a/modules/archive/test/test_lib.py b/modules/archive/test/test_lib.py
index 2bd134dd..560272fa 100644
--- a/modules/archive/test/test_lib.py
+++ b/modules/archive/test/test_lib.py
@@ -14,11 +14,11 @@ from wrolpi.dates import local_timezone
from wrolpi.db import get_db_session
from wrolpi.media_path import MediaPath
from wrolpi.root_api import CustomJSONEncoder
-from wrolpi.test.common import TestAPI, wrap_test_db, skip_circleci
+from wrolpi.test.common import skip_circleci
def make_fake_request_archive(readability=True, screenshot=True, title=True):
- def fake_request_archive(_):
+ async def fake_request_archive(_):
singlefile = '\ntest single-file\nジにてこちら\n
some title'
r = dict(
content=f'test readability content',
@@ -31,63 +31,61 @@ def make_fake_request_archive(readability=True, screenshot=True, title=True):
return fake_request_archive
-class TestArchive(TestAPI):
+@pytest.mark.asyncio
+async def test_no_screenshot(test_session):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(screenshot=False)):
+ archive = await do_archive('https://example.com')
+ assert isinstance(archive.singlefile_path, MediaPath)
+ assert isinstance(archive.readability_path, MediaPath)
+ # Screenshot was empty
+ assert archive.screenshot_path is None
- def setUp(self) -> None:
- super().setUp()
- (pathlib.Path(self.tmp_dir.name) / 'archive').mkdir(exist_ok=True)
- @wrap_test_db
- def test_no_screenshot(self):
- with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(screenshot=False)):
- archive = do_archive('https://example.com')
- self.assertIsInstance(archive.singlefile_path, MediaPath)
- self.assertIsInstance(archive.readability_path, MediaPath)
- # Screenshot was empty
- self.assertIsNone(archive.screenshot_path)
+@pytest.mark.asyncio
+async def test_no_readability(test_session):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(readability=False)):
+ archive = await do_archive('https://example.com')
+ assert isinstance(archive.singlefile_path, MediaPath)
+ assert isinstance(archive.screenshot_path, MediaPath)
+ # Readability empty
+ assert archive.readability_path is None
+ assert archive.readability_txt_path is None
- @wrap_test_db
- def test_no_readability(self):
- with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(readability=False)):
- archive = do_archive('https://example.com')
- self.assertIsInstance(archive.singlefile_path, MediaPath)
- self.assertIsInstance(archive.screenshot_path, MediaPath)
- # Readability empty
- self.assertIsNone(archive.readability_path)
- self.assertIsNone(archive.readability_txt_path)
- @wrap_test_db
- def test_dict(self):
- with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
- d = do_archive('https://example.com').dict()
- self.assertIsInstance(d, dict)
- json.dumps(d, cls=CustomJSONEncoder)
+@pytest.mark.asyncio
+async def test_dict(test_session):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ d = (await do_archive('https://example.com')).dict()
+ assert isinstance(d, dict)
+ json.dumps(d, cls=CustomJSONEncoder)
- @wrap_test_db
- def test_relationships(self):
- with get_db_session(commit=True) as session:
- url = 'https://wrolpi.org:443'
- domain = get_or_create_domain(session, url)
- archive = Archive(
- singlefile_path=f'{self.tmp_dir.name}/wrolpi.org:443/foo',
- title='bar',
- url=url,
- domain_id=domain.id,
- )
- session.add(archive)
- session.flush()
- self.assertEqual(archive.domain, domain)
+@pytest.mark.asyncio
+async def test_relationships(test_session, test_directory):
+ with get_db_session(commit=True) as session:
+ url = 'https://wrolpi.org:443'
+ domain = get_or_create_domain(session, url)
+ archive = Archive(
+ singlefile_path=f'{test_directory.name}/wrolpi.org:443/foo',
+ title='bar',
+ url=url,
+ domain_id=domain.id,
+ )
+ session.add(archive)
+ session.flush()
- @wrap_test_db
- def test_validate_paths(self):
- with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
- archive = do_archive('https://example.com')
- try:
- with get_db_session(commit=True):
- archive.singlefile_path = 'asdf'
- except ValueError as e:
- self.assertIn('relative', str(e), f'Relative path error was not raised')
+ assert archive.domain == domain
+
+
+@pytest.mark.asyncio
+async def test_validate_paths(test_session):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ archive = await do_archive('https://example.com')
+ try:
+ with get_db_session(commit=True):
+ archive.singlefile_path = 'asdf'
+ except ValueError as e:
+ assert 'relative' in str(e), f'Relative path error was not raised'
def test_archive_refresh_deleted_archive(test_session, archive_directory, archive_factory):
@@ -225,10 +223,11 @@ def test_get_domains(test_session, archive_factory):
assert [i['domain'] for i in get_domains()] == []
-def test_new_archive(test_session, fake_now):
+@pytest.mark.asycio
+async def test_new_archive(test_session, fake_now):
with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
fake_now(datetime(2000, 1, 1))
- archive1 = do_archive('https://example.com')
+ archive1 = await do_archive('https://example.com')
# Everything is filled out.
assert isinstance(archive1, Archive)
assert archive1.archive_datetime is not None
@@ -251,18 +250,19 @@ def test_new_archive(test_session, fake_now):
assert json.load(fh) == {'title': 'ジにてこちら', 'url': 'https://example.com'}
fake_now(datetime(2000, 1, 2))
- archive2 = do_archive('https://example.com')
+ archive2 = await do_archive('https://example.com')
# Domain is reused.
assert archive1.domain == archive2.domain
-def test_get_title_from_html(test_session, fake_now):
+@pytest.mark.asyncio
+async def test_get_title_from_html(test_session, fake_now):
fake_now(datetime(2000, 1, 1))
with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
- archive = do_archive('example.com')
+ archive = await do_archive('example.com')
assert archive.title == 'ジにてこちら'
- def fake_request_archive(_):
+ async def fake_request_archive(_):
singlefile = '\ntest single-file\nジにてこちら\nsome title'
r = dict(
content=f'test readability content',
@@ -273,10 +273,10 @@ def test_get_title_from_html(test_session, fake_now):
fake_now(datetime(2000, 1, 2))
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
- archive = do_archive('example.com')
+ archive = await do_archive('example.com')
assert archive.title == 'some title'
- def fake_request_archive(_):
+ async def fake_request_archive(_):
singlefile = ''
r = dict(
content=f'missing a title',
@@ -287,7 +287,7 @@ def test_get_title_from_html(test_session, fake_now):
fake_now(datetime(2000, 1, 3))
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
- archive = do_archive('example.com')
+ archive = await do_archive('example.com')
assert archive.title is None
@@ -313,14 +313,15 @@ def test_get_new_archive_files(fake_now):
@skip_circleci
-def test_title_in_filename(test_session, fake_now, test_directory):
+@pytest.mark.asyncio
+async def test_title_in_filename(test_session, fake_now, test_directory):
"""
The Archive files have the title in the path.
"""
fake_now(datetime(2000, 1, 1))
with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
- archive1 = do_archive('example.com')
+ archive1 = await do_archive('example.com')
assert archive1.title == 'ジにてこちら'
@@ -335,7 +336,7 @@ def test_title_in_filename(test_session, fake_now, test_directory):
assert str(archive1.screenshot_path.path.relative_to(test_directory)) == \
'archive/2000-01-01-00-00-00_ジにてこちら.png'
- def fake_request_archive(_):
+ async def fake_request_archive(_):
singlefile = '\ntest single-file\nジにてこちら\n' # no title in HTML
r = dict(
# No Title from Readability.
@@ -346,7 +347,7 @@ def test_title_in_filename(test_session, fake_now, test_directory):
return singlefile, r, s
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
- archive2 = do_archive('example.com')
+ archive2 = await do_archive('example.com')
assert str(archive2.singlefile_path.path.relative_to(test_directory)) == \
'archive/2000-01-01-00-00-00_NA.html'
@@ -359,7 +360,7 @@ def test_title_in_filename(test_session, fake_now, test_directory):
assert str(archive2.screenshot_path.path.relative_to(test_directory)) == \
'archive/2000-01-01-00-00-00_NA.png'
- def fake_request_archive(_):
+ async def fake_request_archive(_):
singlefile = '\ntest single-file\nジにてこちら\ndangerous ;\\//_title