mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
1583 lines
49 KiB
Python
1583 lines
49 KiB
Python
import asyncio
|
|
import atexit
|
|
import contextlib
|
|
import ctypes
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import multiprocessing
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import string
|
|
import sys
|
|
import tempfile
|
|
from asyncio import Task
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
from functools import wraps
|
|
from http import HTTPStatus
|
|
from itertools import islice, filterfalse, tee
|
|
from multiprocessing import Lock, Manager
|
|
from pathlib import Path
|
|
from types import GeneratorType
|
|
from typing import Union, Callable, Tuple, Dict, List, Iterable, Optional, Generator, Any, Set
|
|
from urllib.parse import urlparse, urlunsplit
|
|
|
|
import aiohttp
|
|
import bs4
|
|
import yaml
|
|
from aiohttp import ClientResponse
|
|
from bs4 import BeautifulSoup
|
|
from selenium import webdriver
|
|
from sqlalchemy import types
|
|
from sqlalchemy.ext.compiler import compiles
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import Session
|
|
|
|
from wrolpi.dates import now, from_timestamp, seconds_to_timestamp
|
|
from wrolpi.errors import WROLModeEnabled, NativeOnly, UnrecoverableDownloadError, LogLevelError
|
|
from wrolpi.vars import PYTEST, DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY, DEFAULT_HTTP_HEADERS
|
|
|
|
LOG_LEVEL = multiprocessing.Value(ctypes.c_int, 20)
|
|
|
|
logger = logging.getLogger()
|
|
ch = logging.StreamHandler()
|
|
formatter = logging.Formatter('[%(asctime)s] [%(process)d] [%(name)s:%(lineno)d] [%(levelname)s] %(message)s')
|
|
ch.setFormatter(formatter)
|
|
logger.addHandler(ch)
|
|
|
|
logger_ = logger.getChild(__name__)
|
|
|
|
|
|
def set_log_level(level, warn_level: bool = True):
|
|
"""Set the level of the root logger so all children that have been created (or will be created) share the same
|
|
level.
|
|
|
|
@warning: Child processes will not share this level. See set_global_log_level
|
|
"""
|
|
logger.setLevel(level)
|
|
|
|
# Always warn about the log level, so we know what should have been logged.
|
|
effective_level = logger.getEffectiveLevel()
|
|
level_name = logging.getLevelName(effective_level)
|
|
if warn_level:
|
|
logger.warning(f'Logging level: {level_name}')
|
|
|
|
# Enable debug logging in SQLAlchemy when logging is NOTSET.
|
|
sa_logger = logging.getLogger('sqlalchemy.engine')
|
|
sa_level = logging.DEBUG if level == logging.NOTSET else logging.WARNING
|
|
sa_logger.setLevel(sa_level)
|
|
|
|
# Hide sanic access logs when not at least "INFO".
|
|
sanic_access = logging.getLogger('sanic.access')
|
|
sanic_level = logging.INFO if level <= 20 else logging.WARNING
|
|
sanic_access.setLevel(sanic_level)
|
|
|
|
|
|
def set_global_log_level(log_level: int):
|
|
"""Set the global (shared between processes) log level."""
|
|
if not isinstance(log_level, int) or 0 > log_level or log_level > 40:
|
|
raise LogLevelError()
|
|
with LOG_LEVEL.get_lock():
|
|
LOG_LEVEL.value = log_level
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def log_level_context(level):
|
|
starting_level = logger.getEffectiveLevel()
|
|
set_log_level(level, warn_level=False)
|
|
yield
|
|
set_log_level(starting_level, warn_level=False)
|
|
|
|
|
|
__all__ = [
|
|
'Base',
|
|
'ConfigFile',
|
|
'DownloadFileInfo',
|
|
'DownloadFileInfoLink',
|
|
'LOG_LEVEL',
|
|
'ModelHelper',
|
|
'WROLPI_CONFIG',
|
|
'aiohttp_get',
|
|
'aiohttp_head',
|
|
'aiohttp_post',
|
|
'api_param_limiter',
|
|
'apply_modelers',
|
|
'apply_refresh_cleanup',
|
|
'background_task',
|
|
'cancel_refresh_tasks',
|
|
'cancelable_wrapper',
|
|
'chain',
|
|
'chdir',
|
|
'check_media_directory',
|
|
'chunks',
|
|
'chunks_by_stem',
|
|
'compile_tsvector',
|
|
'cum_timer',
|
|
'date_range',
|
|
'disable_wrol_mode',
|
|
'download_file',
|
|
'enable_wrol_mode',
|
|
'escape_file_name',
|
|
'extract_domain',
|
|
'extract_headlines',
|
|
'extract_html_text',
|
|
'get_absolute_media_path',
|
|
'get_config',
|
|
'get_download_info',
|
|
'get_files_and_directories',
|
|
'get_html_soup',
|
|
'get_media_directory',
|
|
'get_relative_to_media_directory',
|
|
'get_warn_once',
|
|
'insert_parameter',
|
|
'iterify',
|
|
'limit_concurrent',
|
|
'logger',
|
|
'make_media_directory',
|
|
'minimize_dict',
|
|
'native_only',
|
|
'partition',
|
|
'recursive_map',
|
|
'register_modeler',
|
|
'register_refresh_cleanup',
|
|
'remove_whitespace',
|
|
'run_after',
|
|
'set_log_level',
|
|
'set_test_config',
|
|
'set_test_media_directory',
|
|
'slow_logger',
|
|
'split_lines_by_length',
|
|
'timer',
|
|
'truncate_generator_bytes',
|
|
'truncate_object_bytes',
|
|
'tsvector',
|
|
'walk',
|
|
'wrol_mode_check',
|
|
'wrol_mode_enabled',
|
|
'zig_zag',
|
|
'html_screenshot',
|
|
'format_html_string',
|
|
]
|
|
|
|
# Base is used for all SQLAlchemy models.
|
|
Base = declarative_base()
|
|
|
|
|
|
class ModelHelper:
|
|
|
|
def dict(self, *_, **__) -> dict:
|
|
d = {i.name: getattr(self, i.name) for i in self.__table__.columns} # noqa
|
|
return d
|
|
|
|
@classmethod
|
|
def upsert(cls, file, session: Session) -> Base:
|
|
raise NotImplementedError('This model has not defined this method.')
|
|
|
|
@staticmethod
|
|
def get_by_path(path, session) -> Base:
|
|
raise NotImplementedError('This model has not defined this method.')
|
|
|
|
@staticmethod
|
|
def find_by_paths(paths, session) -> List:
|
|
raise NotImplementedError('This model has not defined this method.')
|
|
|
|
@staticmethod
|
|
def get_by_id(id_: int, session: Session = None) -> Optional[Base]:
|
|
"""Attempts to get a model instance by its id. Returns None if no instance can be found."""
|
|
raise NotImplementedError('This model has not defined this method.')
|
|
|
|
@staticmethod
|
|
def find_by_id(id_: int, session: Session = None) -> Base:
|
|
"""Get a model instance by its id, raise an error if no instance is found."""
|
|
raise NotImplementedError('This model has not defined this method.')
|
|
|
|
def flush(self):
|
|
"""A convenience function which flushes this record using its DB Session."""
|
|
Session.object_session(self).flush([self])
|
|
|
|
|
|
class tsvector(types.TypeDecorator):
|
|
impl = types.UnicodeText
|
|
|
|
|
|
@compiles(tsvector, 'postgresql')
|
|
def compile_tsvector(element, compiler, **kw):
|
|
return 'tsvector'
|
|
|
|
|
|
URL_CHARS = string.ascii_lowercase + string.digits
|
|
|
|
|
|
class ConfigFile:
|
|
"""
|
|
This class keeps track of the contents of a config file. You can update the config (and it's file) by
|
|
calling .update().
|
|
"""
|
|
file_name: str = None
|
|
default_config: dict = None
|
|
width: int = None
|
|
|
|
def __init__(self):
|
|
self.file_lock = Lock()
|
|
self.width = self.width or 90
|
|
|
|
if PYTEST:
|
|
# Do not load a global config on import while testing. A global instance will never be used for testing.
|
|
self._config = self.default_config.copy()
|
|
return
|
|
self.initialize()
|
|
|
|
def __repr__(self):
|
|
return f'<{self.__class__.__name__} file={self.get_file()}>'
|
|
|
|
def initialize(self):
|
|
"""Initializes this config dict using the default config and the config file."""
|
|
config_file = self.get_file()
|
|
self._config = Manager().dict()
|
|
# Use the default settings to initialize the config.
|
|
self._config.update(deepcopy(self.default_config))
|
|
if config_file.is_file():
|
|
# Use the config file to get the values the user set.
|
|
with config_file.open('rt') as fh:
|
|
self._config.update(yaml.load(fh, Loader=yaml.Loader))
|
|
|
|
def save(self):
|
|
"""
|
|
Write this config to its file.
|
|
|
|
Use the existing config file as a template; if any values are missing in the new config, use the values from the
|
|
config file.
|
|
"""
|
|
config_file = self.get_file()
|
|
# Don't overwrite a real config while testing.
|
|
if PYTEST and not str(config_file).startswith('/tmp'):
|
|
raise ValueError(f'Refusing to save config file while testing: {config_file}')
|
|
|
|
# Only one process can write to the file.
|
|
acquired = self.file_lock.acquire(block=True, timeout=5.0)
|
|
|
|
try:
|
|
# Config directory may not exist.
|
|
if not config_file.parent.is_dir():
|
|
config_file.parent.mkdir()
|
|
|
|
# Read the existing config, replace all values, then save.
|
|
if config_file.is_file():
|
|
with config_file.open('rt') as fh:
|
|
config = yaml.load(fh, Loader=yaml.Loader)
|
|
else:
|
|
# Config file does not yet exist.
|
|
config = dict()
|
|
|
|
config.update({k: v for k, v in self._config.items() if v is not None})
|
|
with config_file.open('wt') as fh:
|
|
yaml.dump(config, fh, width=self.width)
|
|
finally:
|
|
if acquired:
|
|
self.file_lock.release()
|
|
|
|
def get_file(self) -> Path:
|
|
if not self.file_name:
|
|
raise NotImplementedError(f'You must define a file name for this {self.__class__.__name__} config.')
|
|
|
|
if PYTEST:
|
|
return get_media_directory() / f'config/{self.file_name}'
|
|
|
|
return CONFIG_DIR / self.file_name
|
|
|
|
def update(self, config: dict):
|
|
"""Update any values of this config. Save the config to its file."""
|
|
config = {k: v for k, v in config.items() if k in self._config}
|
|
self._config.update(config)
|
|
self.save()
|
|
|
|
def dict(self) -> dict:
|
|
"""Get a deepcopy of this config."""
|
|
if not hasattr(self, '_config'):
|
|
raise NotImplementedError('You cannot use a global config while testing!')
|
|
|
|
return deepcopy(self._config)
|
|
|
|
|
|
class WROLPiConfig(ConfigFile):
|
|
file_name = 'wrolpi.yaml'
|
|
default_config = dict(
|
|
download_on_startup=True,
|
|
download_timeout=0,
|
|
hotspot_device='wlan0',
|
|
hotspot_on_startup=True,
|
|
hotspot_password='wrolpi hotspot',
|
|
hotspot_ssid='WROLPi',
|
|
ignore_outdated_zims=False,
|
|
throttle_on_startup=False,
|
|
wrol_mode=False,
|
|
)
|
|
|
|
@property
|
|
def download_on_startup(self) -> bool:
|
|
return self._config['download_on_startup']
|
|
|
|
@download_on_startup.setter
|
|
def download_on_startup(self, value: bool):
|
|
self.update({'download_on_startup': value})
|
|
|
|
@property
|
|
def download_timeout(self) -> int:
|
|
return self._config['download_timeout']
|
|
|
|
@download_timeout.setter
|
|
def download_timeout(self, value: int):
|
|
self.update({'download_timeout': value})
|
|
|
|
@property
|
|
def hotspot_device(self) -> str:
|
|
return self._config['hotspot_device']
|
|
|
|
@hotspot_device.setter
|
|
def hotspot_device(self, value: str):
|
|
self.update({'hotspot_device': value})
|
|
|
|
@property
|
|
def hotspot_on_startup(self) -> bool:
|
|
return self._config['hotspot_on_startup']
|
|
|
|
@hotspot_on_startup.setter
|
|
def hotspot_on_startup(self, value: bool):
|
|
self.update({'hotspot_on_startup': value})
|
|
|
|
@property
|
|
def hotspot_password(self) -> str:
|
|
return self._config['hotspot_password']
|
|
|
|
@hotspot_password.setter
|
|
def hotspot_password(self, value: str):
|
|
self.update({'hotspot_password': value})
|
|
|
|
@property
|
|
def hotspot_ssid(self) -> str:
|
|
return self._config['hotspot_ssid']
|
|
|
|
@hotspot_ssid.setter
|
|
def hotspot_ssid(self, value: str):
|
|
self.update({'hotspot_ssid': value})
|
|
|
|
@property
|
|
def ignore_outdated_zims(self) -> bool:
|
|
return self._config['ignore_outdated_zims']
|
|
|
|
@ignore_outdated_zims.setter
|
|
def ignore_outdated_zims(self, value: bool):
|
|
self.update({'ignore_outdated_zims': value})
|
|
|
|
@property
|
|
def throttle_on_startup(self) -> bool:
|
|
return self._config['throttle_on_startup']
|
|
|
|
@throttle_on_startup.setter
|
|
def throttle_on_startup(self, value: bool):
|
|
self.update({'throttle_on_startup': value})
|
|
|
|
@property
|
|
def wrol_mode(self) -> bool:
|
|
return self._config['wrol_mode']
|
|
|
|
@wrol_mode.setter
|
|
def wrol_mode(self, value: bool):
|
|
self.update({'wrol_mode': value})
|
|
|
|
|
|
WROLPI_CONFIG: WROLPiConfig = WROLPiConfig()
|
|
TEST_WROLPI_CONFIG: WROLPiConfig = None
|
|
|
|
|
|
def get_config() -> WROLPiConfig:
|
|
"""Read the global WROLPi yaml config file."""
|
|
global TEST_WROLPI_CONFIG
|
|
if isinstance(TEST_WROLPI_CONFIG, WROLPiConfig):
|
|
return TEST_WROLPI_CONFIG
|
|
|
|
global WROLPI_CONFIG
|
|
return WROLPI_CONFIG
|
|
|
|
|
|
def set_test_config(enable: bool):
|
|
global TEST_WROLPI_CONFIG
|
|
TEST_WROLPI_CONFIG = WROLPiConfig() if enable else None
|
|
|
|
|
|
def wrol_mode_enabled() -> bool:
|
|
"""Return True if WROL Mode is enabled."""
|
|
return get_config().wrol_mode
|
|
|
|
|
|
def wrol_mode_check(func):
|
|
"""Wraps a function so that it cannot be called when WROL Mode is enabled."""
|
|
|
|
@wraps(func)
|
|
def check(*a, **kw):
|
|
if wrol_mode_enabled():
|
|
raise WROLModeEnabled()
|
|
|
|
# WROL Mode is not enabled, run the function as normal.
|
|
result = func(*a, **kw)
|
|
return result
|
|
|
|
return check
|
|
|
|
|
|
def enable_wrol_mode(download_manager=None):
|
|
"""
|
|
Modify config to enable WROL Mode.
|
|
|
|
Stop downloads and Download Manager.
|
|
"""
|
|
logger_.warning('ENABLING WROL MODE')
|
|
get_config().wrol_mode = True
|
|
if not download_manager:
|
|
from wrolpi.downloader import download_manager
|
|
download_manager.stop()
|
|
else:
|
|
# Testing.
|
|
download_manager.stop()
|
|
|
|
|
|
def disable_wrol_mode(download_manager=None):
|
|
"""
|
|
Modify config to disable WROL Mode.
|
|
|
|
Start downloads and Download Manager.
|
|
"""
|
|
logger_.warning('DISABLING WROL MODE')
|
|
get_config().wrol_mode = False
|
|
if not download_manager:
|
|
from wrolpi.downloader import download_manager
|
|
download_manager.enable()
|
|
else:
|
|
# Testing.
|
|
download_manager.enable()
|
|
|
|
|
|
def insert_parameter(func: Callable, parameter_name: str, item, args: Tuple, kwargs: Dict) -> Tuple[Tuple, Dict]:
|
|
"""Insert a parameter wherever it fits in the Callable's signature."""
|
|
sig = inspect.signature(func)
|
|
if parameter_name not in sig.parameters:
|
|
raise TypeError(f'Function {func} MUST have a {parameter_name} parameter!')
|
|
|
|
args = list(args)
|
|
|
|
index = list(sig.parameters).index(parameter_name)
|
|
args.insert(index, item)
|
|
args = tuple(args)
|
|
|
|
return args, kwargs
|
|
|
|
|
|
def iterify(kind: type = list):
|
|
"""Convenience function to convert the output of the wrapped function to the type provided."""
|
|
|
|
def wrapper(func):
|
|
@wraps(func)
|
|
def wrapped(*a, **kw):
|
|
result = func(*a, **kw)
|
|
return kind(result)
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|
|
|
|
|
|
dt_or_d = Union[datetime, date]
|
|
|
|
|
|
def date_range(start: dt_or_d, end: dt_or_d, steps: int) -> List[dt_or_d]:
|
|
delta = (end - start) // steps
|
|
return [start + (delta * i) for i in range(steps)]
|
|
|
|
|
|
WHITESPACE = re.compile(r'\s')
|
|
|
|
|
|
def remove_whitespace(s: str) -> str:
|
|
return WHITESPACE.sub('', s)
|
|
|
|
|
|
RUN_AFTER = False
|
|
|
|
|
|
def run_after(after: callable, *args, **kwargs) -> callable:
|
|
"""Run the `after` function sometime in the future ofter the wrapped function returns."""
|
|
if not inspect.iscoroutinefunction(after):
|
|
synchronous_after = after
|
|
|
|
async def after(*a, **kw):
|
|
return synchronous_after(*a, **kw)
|
|
|
|
def wrapper(func: callable):
|
|
if PYTEST and RUN_AFTER is False:
|
|
return func
|
|
|
|
if inspect.iscoroutinefunction(func):
|
|
@wraps(func)
|
|
async def wrapped(*a, **kw):
|
|
results = await func(*a, **kw)
|
|
coro = after(*args, **kwargs)
|
|
asyncio.ensure_future(coro)
|
|
return results
|
|
else:
|
|
@wraps(func)
|
|
def wrapped(*a, **kw):
|
|
results = func(*a, **kw)
|
|
coro = after(*args, **kwargs)
|
|
asyncio.ensure_future(coro)
|
|
return results
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|
|
|
|
|
|
TEST_MEDIA_DIRECTORY = None
|
|
|
|
|
|
def set_test_media_directory(path):
|
|
global TEST_MEDIA_DIRECTORY
|
|
if isinstance(path, pathlib.Path):
|
|
TEST_MEDIA_DIRECTORY = path
|
|
elif path:
|
|
TEST_MEDIA_DIRECTORY = pathlib.Path(path)
|
|
else:
|
|
TEST_MEDIA_DIRECTORY = None
|
|
|
|
|
|
def get_media_directory() -> Path:
|
|
"""Get the media directory.
|
|
|
|
This will typically be /opt/wrolpi, or something else from the .env.
|
|
|
|
During testing, this function returns TEST_MEDIA_DIRECTORY.
|
|
"""
|
|
global TEST_MEDIA_DIRECTORY
|
|
|
|
if PYTEST and not TEST_MEDIA_DIRECTORY:
|
|
raise ValueError('No test media directory set during testing!!')
|
|
|
|
if isinstance(TEST_MEDIA_DIRECTORY, pathlib.Path):
|
|
return TEST_MEDIA_DIRECTORY
|
|
|
|
return MEDIA_DIRECTORY
|
|
|
|
|
|
MEDIA_DIRECTORY_PERMISSIONS = 0o40755
|
|
|
|
|
|
def check_media_directory():
|
|
"""
|
|
Check that the media directory exists and has the correct permissions. Log an error if the directory
|
|
is unusable.
|
|
"""
|
|
result = True
|
|
media_directory = get_media_directory()
|
|
if not media_directory.is_dir():
|
|
logger_.error(f'Media directory does not exist: {media_directory}')
|
|
return False
|
|
|
|
permissions = media_directory.stat().st_mode
|
|
if permissions != MEDIA_DIRECTORY_PERMISSIONS:
|
|
logger_.error(f'Media directory has the wrong permissions: {oct(permissions)}')
|
|
result = False
|
|
|
|
if not media_directory.is_mount():
|
|
logger_.warning('Media directory is not a mount.')
|
|
|
|
try:
|
|
# Write a file into the media directory, log an error if this is not possible. This file should be cleaned up
|
|
# by tempfile.
|
|
with tempfile.NamedTemporaryFile(dir=media_directory) as tf:
|
|
tf.write(b'test')
|
|
except Exception as e:
|
|
logger_.error(f'Could not write to media directory: {media_directory}', exc_info=e)
|
|
result = False
|
|
|
|
return result
|
|
|
|
|
|
def get_absolute_media_path(path: str) -> Path:
|
|
"""
|
|
Get the absolute path of file/directory within the config media directory.
|
|
|
|
>>> get_media_directory()
|
|
Path('/media')
|
|
>>> get_absolute_media_path('videos/blender')
|
|
Path('/media/videos/blender')
|
|
|
|
:raises UnknownDirectory: the directory/path doesn't exist
|
|
"""
|
|
if not path:
|
|
raise ValueError('Path cannot be empty!')
|
|
path = get_media_directory() / path
|
|
return path
|
|
|
|
|
|
def get_relative_to_media_directory(path: Union[str, Path]) -> Path:
|
|
"""
|
|
Get the path for a file/directory relative to the config media directory.
|
|
|
|
>>> get_media_directory()
|
|
Path('/media')
|
|
>>> get_relative_to_media_directory('/media/videos/blender')
|
|
Path('videos/blender')
|
|
|
|
:raises UnknownDirectory: the directory/path doesn't exist
|
|
"""
|
|
absolute = get_absolute_media_path(path)
|
|
return absolute.relative_to(get_media_directory())
|
|
|
|
|
|
def minimize_dict(d: dict, keys: Iterable) -> Optional[dict]:
|
|
"""Return a new dictionary that contains only the keys provided."""
|
|
if d is None:
|
|
return
|
|
return {k: d[k] for k in set(keys) & d.keys()}
|
|
|
|
|
|
def make_media_directory(path: Union[str, Path]):
|
|
"""Make a directory relative within the media directory."""
|
|
media_dir = get_media_directory()
|
|
path = media_dir / str(path)
|
|
path.mkdir(parents=True)
|
|
|
|
|
|
def extract_domain(url):
|
|
"""
|
|
Extract the domain from a URL. Remove leading www.
|
|
|
|
>>> extract_domain('https://www.example.com/foo')
|
|
'example.com'
|
|
"""
|
|
parsed = urlparse(url)
|
|
domain = parsed.netloc
|
|
if not domain:
|
|
raise ValueError(f'URL does not have a domain: {url=}')
|
|
domain = domain.decode() if hasattr(domain, 'decode') else domain
|
|
if domain.startswith('www.'):
|
|
# Remove leading www.
|
|
domain = domain[4:]
|
|
return domain
|
|
|
|
|
|
def api_param_limiter(maximum: int, default: int = 20) -> callable:
|
|
"""Create a function which restricts the maximum number that can be returned.
|
|
Useful for restricting API limit params.
|
|
|
|
>>> limiter = api_param_limiter(100)
|
|
>>> limiter(0)
|
|
0
|
|
>>> limiter(100)
|
|
100
|
|
>>> limiter(150)
|
|
100
|
|
"""
|
|
|
|
def limiter_(i: int) -> int:
|
|
if not i:
|
|
# Limit is not valid, use the default.
|
|
return default
|
|
return min(int(i), maximum)
|
|
|
|
return limiter_
|
|
|
|
|
|
def partition(pred, iterable):
|
|
"""Use a predicate to partition entries into false entries and true entries
|
|
|
|
>>> is_even = lambda i: not i % 2
|
|
>>> partition(is_even, range(10))
|
|
([0, 2, 4, 6, 8], [1, 3, 5, 7, 9])
|
|
"""
|
|
t1, t2 = tee(iterable)
|
|
return list(filter(pred, t2)), list(filterfalse(pred, t1))
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def chdir(directory: Union[pathlib.Path, str, None] = None, with_home: bool = False):
|
|
"""
|
|
Change the Python working directory in this context. If no directory is passed, a TemporaryDirectory will be used.
|
|
|
|
This also changes the $HOME environment variable to match.
|
|
"""
|
|
home_exists = 'HOME' in os.environ
|
|
home = os.environ.get('HOME')
|
|
cwd = os.getcwd()
|
|
if directory is None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
try:
|
|
os.chdir(d)
|
|
if with_home:
|
|
os.environ['HOME'] = d
|
|
yield
|
|
finally:
|
|
os.chdir(cwd)
|
|
if home_exists:
|
|
os.environ['HOME'] = home
|
|
else:
|
|
del os.environ['HOME']
|
|
return
|
|
|
|
try:
|
|
os.chdir(directory)
|
|
if with_home:
|
|
os.environ['HOME'] = str(directory)
|
|
yield
|
|
finally:
|
|
os.chdir(cwd)
|
|
if home_exists:
|
|
os.environ['HOME'] = home
|
|
else:
|
|
del os.environ['HOME']
|
|
return
|
|
|
|
|
|
ZIG_TYPE = Union[int, float, complex, Decimal, datetime]
|
|
|
|
|
|
def zig_zag(low: ZIG_TYPE, high: ZIG_TYPE) -> Generator[ZIG_TYPE, None, None]:
|
|
"""
|
|
Generate numbers between `low` and `high` that are
|
|
spread out evenly. Produces infinite results.
|
|
|
|
>>> list(zig_zag(0, 10))
|
|
[0, 5, 2, 7, 1, 3, 6, 8, 0, 1, 3, 4, 5, 6, 8, 9]
|
|
>>> list(zig_zag(0, 5))
|
|
[0, 2, 1, 3, 0, 1, 3, 4]
|
|
>>> list(zig_zag(50.0, 100.0))
|
|
[50.0, 75.0, 62.5, 87.5, 56.25, 68.75, 81.25, 93.75, 53.125, 59.375, 65.625]
|
|
"""
|
|
output_type = type(low)
|
|
if not isinstance(high, type(low)):
|
|
raise ValueError(f'high and low must be same type')
|
|
if isinstance(low, datetime) and isinstance(high, datetime):
|
|
low, high = low.timestamp(), high.timestamp()
|
|
output_type = from_timestamp
|
|
|
|
# Special thanks to my wife for helping me solve this! :*
|
|
results = set()
|
|
num = low
|
|
divisor = 2
|
|
diff = high - low
|
|
while True:
|
|
if num not in results:
|
|
yield output_type(num)
|
|
results.add(num)
|
|
num += diff / divisor
|
|
if num >= high:
|
|
divisor *= 2
|
|
num = low + (diff / divisor)
|
|
|
|
|
|
def walk(path: Path) -> Generator[Path, None, None]:
|
|
"""Recursively walk a directory structure yielding all files and directories."""
|
|
if not path.is_dir():
|
|
raise ValueError('Can only walk a directory.')
|
|
|
|
for path in path.iterdir():
|
|
yield path
|
|
if path.is_dir():
|
|
yield from walk(path)
|
|
|
|
|
|
def get_files_and_directories(directory: Path):
|
|
"""Walk a directory, return a tuple of all files and all directories within. (Not recursive)."""
|
|
if not directory.exists():
|
|
raise FileNotFoundError(f'Directory does not exist: {directory}')
|
|
if not directory.is_dir():
|
|
raise ValueError('Can only walk a directory.')
|
|
|
|
directories, files = partition(lambda i: i.is_dir(), directory.iterdir())
|
|
return files, directories
|
|
|
|
|
|
# These characters are invalid in Windows or Linux.
|
|
INVALID_FILE_CHARS = re.compile(r'[/<>:\|"\\\?\*%!\n\r]')
|
|
SPACE_FILE_CHARS = re.compile(r'( +)|(\t+)')
|
|
|
|
|
|
def escape_file_name(name: str) -> str:
|
|
"""Remove any invalid characters in a file name."""
|
|
name = SPACE_FILE_CHARS.sub(' ', name)
|
|
name = INVALID_FILE_CHARS.sub('', name)
|
|
return name.strip()
|
|
|
|
|
|
def native_only(func: callable):
|
|
"""Wraps a function. Raises NativeOnly if run while Dockerized."""
|
|
|
|
@wraps(func)
|
|
def wrapped(*a, **kw):
|
|
if DOCKERIZED:
|
|
raise NativeOnly('Only supported on a Raspberry Pi')
|
|
return func(*a, **kw)
|
|
|
|
return wrapped
|
|
|
|
|
|
def recursive_map(obj: Any, func: callable):
|
|
"""
|
|
Apply `func` to all values of any dictionaries; or items in any list/set/tuple.
|
|
|
|
>>> recursive_map({'foo ': ' bar '}, lambda i: i.strip() if hasattr(i, 'strip') else i)
|
|
{'foo ': 'bar'}
|
|
>>> recursive_map(['foo ', ' bar '], lambda i: i.strip() if hasattr(i, 'strip') else i)
|
|
['foo', 'bar']
|
|
"""
|
|
if isinstance(obj, dict):
|
|
obj = obj.copy()
|
|
for key, value in obj.items():
|
|
obj[key] = recursive_map(value, func)
|
|
return obj
|
|
elif isinstance(obj, (list, set, tuple)):
|
|
type_ = type(obj)
|
|
return type_(map(lambda i: recursive_map(i, func), obj))
|
|
return func(obj)
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def aiohttp_session(timeout: int = None):
|
|
"""Convenience function because aiohttp timeout cannot be None."""
|
|
if timeout:
|
|
timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
yield session
|
|
else:
|
|
async with aiohttp.ClientSession() as session:
|
|
yield session
|
|
|
|
|
|
async def aiohttp_post(url: str, json_, timeout: int = None) -> Tuple[Dict, int]:
|
|
"""Perform an async aiohttp POST request. Return the json contents."""
|
|
async with aiohttp_session(timeout) as session:
|
|
async with session.post(url, json=json_) as response:
|
|
return await response.json(), response.status
|
|
|
|
|
|
async def aiohttp_get(url: str, timeout: int = None, headers: dict = None) -> Tuple[bytes, int]:
|
|
"""Perform an async aiohttp GET request. Return the contents."""
|
|
async with aiohttp_session(timeout) as session:
|
|
async with session.get(url, headers=headers) as response:
|
|
return await response.content.read(), response.status
|
|
|
|
|
|
async def aiohttp_head(url: str, timeout: int = None) -> Tuple[ClientResponse, int]:
|
|
"""Perform an async aiohttp HEAD request. Return the contents."""
|
|
async with aiohttp_session(timeout) as session:
|
|
async with session.head(url) as response:
|
|
return response, response.status
|
|
|
|
|
|
async def speed_test(url: str) -> int:
|
|
"""Request the last megabyte of the provided url, return the content length divided by the time elapsed (speed)."""
|
|
response, status = await aiohttp_head(url)
|
|
content_length = response.headers['Content-Length']
|
|
start_bytes = int(content_length) - 10485760
|
|
range_ = f'bytes={start_bytes}-{content_length}'
|
|
start_time = datetime.now()
|
|
content, status = await aiohttp_get(url, headers={'Range': range_})
|
|
elapsed = int((datetime.now() - start_time).total_seconds())
|
|
return len(content) // elapsed
|
|
|
|
|
|
async def get_fastest_mirror(urls: List[str]) -> str:
|
|
"""Perform a speed test on each URL, return the fastest."""
|
|
fastest_url = urls[0]
|
|
fastest_speed = 0
|
|
for url in urls:
|
|
try:
|
|
speed = await speed_test(url)
|
|
except Exception as e:
|
|
logger.error(f'Speedtest of {url} failed', exc_info=e)
|
|
continue
|
|
|
|
if speed > fastest_speed:
|
|
fastest_url = url
|
|
|
|
return fastest_url
|
|
|
|
|
|
@dataclass
|
|
class DownloadFileInfoLink:
|
|
"""Represents an HTTP "Link" Header."""
|
|
url: str
|
|
rel: str
|
|
type: str
|
|
priority: int
|
|
geo: str
|
|
|
|
|
|
@dataclass
|
|
class DownloadFileInfo:
|
|
"""Information about a file that can be downloaded."""
|
|
name: str = None
|
|
size: int = None
|
|
type: str = None
|
|
accept_ranges: str = None
|
|
status: int = None
|
|
location: str = None
|
|
links: List[DownloadFileInfoLink] = None
|
|
|
|
|
|
FILENAME_MATCHER = re.compile(r'.*filename="(.*)"')
|
|
|
|
|
|
async def get_download_info(url: str, timeout: int = 60) -> DownloadFileInfo:
|
|
"""Gets information (name, size, etc.) about a downloadable file at the provided URL."""
|
|
response, status = await aiohttp_head(url, timeout)
|
|
logger_.debug(f'{response.headers=}')
|
|
try:
|
|
links = response.headers.getall('Link')
|
|
except KeyError:
|
|
links = None
|
|
|
|
new_links = list()
|
|
if links:
|
|
# Convert "Link" header strings to DownloadFileInfoLink.
|
|
for idx, link in enumerate(links):
|
|
url, *props = link.split(';')
|
|
url = url[1:-1]
|
|
properties = dict()
|
|
for prop in props:
|
|
name, value = prop.strip().split('=')
|
|
properties[name] = value
|
|
new_links.append(DownloadFileInfoLink(
|
|
url,
|
|
properties.get('rel').strip() if 'rel' in properties else None,
|
|
properties.get('type').strip() if 'type' in properties else None,
|
|
int(properties.get('pri').strip()) if 'pri' in properties else None,
|
|
properties.get('geo').strip() if 'geo' in properties else None,
|
|
))
|
|
|
|
info = DownloadFileInfo(
|
|
type=response.headers.get('Content-Type'),
|
|
size=int(response.headers['Content-Length']) if 'Content-Length' in response.headers else None,
|
|
accept_ranges=response.headers.get('Accept-Ranges'),
|
|
status=response.status,
|
|
location=response.headers.get('Location'),
|
|
links=new_links,
|
|
)
|
|
|
|
disposition = response.headers.get('Content-Disposition')
|
|
|
|
if disposition and 'filename' in disposition:
|
|
if (match := FILENAME_MATCHER.match(disposition)) and (groups := match.groups()):
|
|
info.name = groups[0]
|
|
else:
|
|
# No Content-Disposition with filename, use the Location or URL name.
|
|
if info.location:
|
|
parsed = urlparse(info.location)
|
|
else:
|
|
parsed = urlparse(url)
|
|
info.name = parsed.path.split('/')[-1]
|
|
|
|
return info
|
|
|
|
|
|
download_logger = logger_.getChild('download')
|
|
|
|
|
|
async def download_file(url: str, output_path: pathlib.Path = None, info: DownloadFileInfo = None,
|
|
timeout: int = 7 * 24 * 60 * 60):
|
|
"""Uses aiohttp to download an HTTP file. Performs a speed test when mirrors are found, downloads from the fastest
|
|
mirror.
|
|
|
|
Attempts to resume the file if `output_path` already exists.
|
|
|
|
@warning: Timeout default is a week because of large downloads.
|
|
"""
|
|
info = info or await get_download_info(url, timeout)
|
|
|
|
if output_path.is_file() and info.size == output_path.stat().st_size:
|
|
download_logger.warning(f'Already downloaded {repr(str(url))} to {repr(str(output_path))}')
|
|
return
|
|
|
|
if info.links and (mirror_urls := [i.url for i in info.links if i.rel == 'duplicate']):
|
|
# Mirrors are available, find the fastest.
|
|
download_logger.info(f'Performing download speed test on mirrors: {mirror_urls}')
|
|
url = await get_fastest_mirror(mirror_urls)
|
|
info = await get_download_info(url, timeout)
|
|
|
|
logger_.debug(f'Final DownloadInfo fetched {info}')
|
|
total_size = info.size
|
|
|
|
download_logger.info(f'Starting download of {url} with {total_size} total bytes')
|
|
if info.accept_ranges == 'bytes' or not output_path.is_file():
|
|
with output_path.open('ab') as fh:
|
|
headers = DEFAULT_HTTP_HEADERS.copy()
|
|
# Check the position of append, if it is 0 then we do not need to resume.
|
|
position = fh.tell()
|
|
if position:
|
|
headers['Range'] = f'bytes={position}-'
|
|
|
|
async with aiohttp_session(timeout=timeout) as session:
|
|
async with session.get(url, headers=headers) as response:
|
|
response: ClientResponse
|
|
if response.status == HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE:
|
|
download_logger.warning(f'Server responded with 416, file is probably already downloaded')
|
|
return
|
|
|
|
if position and response.status != HTTPStatus.PARTIAL_CONTENT:
|
|
raise UnrecoverableDownloadError(
|
|
f'Tried to resume {repr(str(url))} but got status {response.status}')
|
|
|
|
# May or may not be using Range. Append each chunk to the output file.
|
|
last_report = datetime.now()
|
|
bytes_received = 0
|
|
async for data in response.content.iter_any():
|
|
fh.write(data)
|
|
|
|
bytes_received += len(data)
|
|
if (elapsed := (datetime.now() - last_report).total_seconds()) > 10:
|
|
# Report download speed every 10 seconds.
|
|
bytes_per_second = int(bytes_received // elapsed)
|
|
download_logger.debug(f'{bytes_received=} {elapsed=} {bytes_per_second=}')
|
|
size = fh.tell()
|
|
bytes_remaining = total_size - size
|
|
seconds_remaining = bytes_remaining // bytes_per_second
|
|
percent = int((size / total_size) * 100)
|
|
download_logger.info(
|
|
f'Downloading {url} at'
|
|
f' rate={human_bandwidth(bytes_per_second)}'
|
|
f' estimate={seconds_to_timestamp(seconds_remaining)}')
|
|
download_logger.debug(f'Downloading {url} {total_size=} {size=} {percent=}')
|
|
last_report = datetime.now()
|
|
bytes_received = 0
|
|
|
|
# Sleep to catch cancel.
|
|
await asyncio.sleep(0)
|
|
elif output_path.is_file():
|
|
# TODO support downloading files that cannot be resumed.
|
|
raise UnrecoverableDownloadError(f'Cannot resume download {url}')
|
|
|
|
|
|
def human_bandwidth(bps: int) -> str:
|
|
"""Convert bits per second to a more readable format.
|
|
|
|
>>> human_bandwidth(2000)
|
|
# '2 Kbps'
|
|
"""
|
|
units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps']
|
|
unit = 0
|
|
while bps > 1000 and unit <= len(units):
|
|
bps /= 1000
|
|
unit += 1
|
|
|
|
return f'{int(bps)} {units[unit]}'
|
|
|
|
|
|
modelers = []
|
|
|
|
|
|
def register_modeler(modeler: callable):
|
|
modelers.append(modeler)
|
|
return modeler
|
|
|
|
|
|
async def apply_modelers():
|
|
for modeler in modelers:
|
|
logger_.info(f'Applying modeler {modeler.__name__}')
|
|
try:
|
|
await modeler()
|
|
except Exception as e:
|
|
logger_.error(f'Modeler {modeler.__name__} raised error', exc_info=e)
|
|
if PYTEST:
|
|
raise
|
|
# Sleep to catch cancel.
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
REFRESH_CLEANUP = []
|
|
|
|
|
|
def register_refresh_cleanup(func: callable):
|
|
REFRESH_CLEANUP.append(func)
|
|
return func
|
|
|
|
|
|
async def apply_refresh_cleanup():
|
|
# TODO convert all functions to async so refresh functions can be used.
|
|
for func in REFRESH_CLEANUP:
|
|
slow_message = f'After refresh cleanup {func.__name__} took %(elapsed)s seconds'
|
|
with slow_logger(5, slow_message, logger__=logger_):
|
|
try:
|
|
logger_.info(f'Applying refresh cleanup {func.__name__}')
|
|
func()
|
|
except Exception as e:
|
|
logger_.error(f'Refresh cleanup {func.__name__} failed!', exc_info=e)
|
|
if PYTEST:
|
|
raise
|
|
# Sleep to catch cancel.
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
def chunks(it: Iterable, size: int):
|
|
"""Split an iterable into iterables of the defined length."""
|
|
it = iter(it)
|
|
return iter(lambda: tuple(islice(it, size)), ())
|
|
|
|
|
|
def chunks_by_stem(it: List[Union[pathlib.Path, str]], size: int) -> Generator[List[pathlib.Path], None, None]:
|
|
"""
|
|
Attempt to split a list of paths near the defined size. Keep groups of files together when they share
|
|
matching names.
|
|
|
|
>>> files = ['1.mp4', '1.txt', '2.mp4', '2.txt', '2.png', '3.mp4']
|
|
>>> chunks_by_stem(files, 2)
|
|
[['1.mp4', '1.txt'], ['2.mp4', '2.txt', '2.png'], ['3.mp4']]
|
|
>>> chunks_by_stem(files, 3)
|
|
[['1.mp4', '1.txt', '2.mp4', '2.txt', '2.png'], ['3.mp4']]
|
|
"""
|
|
if not isinstance(size, int) or size < 1:
|
|
raise ValueError('size must be a positive integer')
|
|
|
|
if not it or len(it) < size:
|
|
yield sorted(it)
|
|
return
|
|
|
|
if not isinstance(it, list):
|
|
raise ValueError('iterable must a list')
|
|
|
|
from wrolpi.files.lib import split_path_stem_and_suffix
|
|
it = sorted(it.copy())
|
|
index = size
|
|
last_stem = None
|
|
while it:
|
|
if index >= len(it):
|
|
# Ran out of items, yield what is left.
|
|
yield it
|
|
return
|
|
path = it[index]
|
|
stem, _ = split_path_stem_and_suffix(path)
|
|
if last_stem and stem != last_stem:
|
|
# Found a break in the path names, yield and reset.
|
|
chunk, it = it[:index], it[index:]
|
|
index = size
|
|
last_stem = None
|
|
yield chunk
|
|
continue
|
|
# Didn't find a name change, try again.
|
|
last_stem = stem
|
|
index += 1
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def timer(name):
|
|
"""Prints out the time elapsed during the call of some block."""
|
|
before = datetime.now()
|
|
try:
|
|
yield
|
|
finally:
|
|
logger_.warning(f'{name} elapsed {(datetime.now() - before).total_seconds()} seconds')
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def slow_logger(max_seconds: int, message: str, logger__=logger_, level=logging.WARNING):
|
|
"""Only logs when the context duration exceeds the `max_seconds`."""
|
|
before = now()
|
|
try:
|
|
yield
|
|
finally:
|
|
elapsed = (now() - before).total_seconds()
|
|
if elapsed >= max_seconds:
|
|
logger__.log(level, message % dict(elapsed=elapsed))
|
|
|
|
|
|
TIMERS = dict()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def cum_timer(name: str):
|
|
"""Track time usage within this context's name. The cumulative calls to this context will be printed on exit."""
|
|
before = now()
|
|
try:
|
|
yield
|
|
finally:
|
|
total_seconds = (now() - before).total_seconds()
|
|
existing_seconds, calls = TIMERS.get(name, (0, 0))
|
|
TIMERS[name] = (existing_seconds + total_seconds, calls + 1)
|
|
|
|
|
|
@atexit.register
|
|
def print_timer():
|
|
"""Print any cumulative timers that have been stored during exit."""
|
|
if not TIMERS:
|
|
return
|
|
|
|
total = sum(i[0] for i in TIMERS.values())
|
|
for name, (seconds, calls) in sorted(TIMERS.items(), key=lambda i: i[1]):
|
|
percent = int((seconds / total) * 100)
|
|
seconds = round(seconds, 5)
|
|
print(f'CUM_TIMER: {repr(name)} elapsed {seconds} cumulative seconds ({percent}%)'
|
|
f' {calls} calls', file=sys.stderr)
|
|
|
|
|
|
def limit_concurrent(limit: int, throw: bool = False):
|
|
"""Wrapper that limits the amount of concurrently running functions."""
|
|
sema = multiprocessing.Semaphore(value=limit)
|
|
|
|
def wrapper(func: callable):
|
|
if inspect.iscoroutinefunction(func):
|
|
@wraps(func)
|
|
async def wrapped(*a, **kw):
|
|
acquired = sema.acquire(block=False)
|
|
if not acquired:
|
|
if throw:
|
|
raise ValueError(f'Reached concurrent limit! {func=}')
|
|
return
|
|
try:
|
|
return await func(*a, **kw)
|
|
finally:
|
|
sema.release()
|
|
|
|
return wrapped
|
|
else:
|
|
@wraps(func)
|
|
def wrapped(*a, **kw):
|
|
acquired = sema.acquire(block=False)
|
|
if not acquired:
|
|
if throw:
|
|
raise ValueError(f'Reached concurrent limit! {func=}')
|
|
return
|
|
try:
|
|
return func(*a, **kw)
|
|
finally:
|
|
sema.release()
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|
|
|
|
|
|
def truncate_object_bytes(obj: Union[List[str], str, None], maximum_bytes: int) -> Union[List[str], str]:
|
|
"""
|
|
Shorten an object. This is useful when inserting something into a tsvector.
|
|
|
|
>>> i = ['foo', 'foo', 'foo', 'foo', 'foo', 'foo', 'foo', 'foo', 'foo', 'foo']
|
|
>>> truncate_object_bytes(i, 100)
|
|
['foo', 'foo', 'foo', 'foo', 'foo']
|
|
"""
|
|
if not obj:
|
|
# Can't decrease an empty object.
|
|
return obj
|
|
size = sys.getsizeof(obj)
|
|
if size < maximum_bytes:
|
|
return obj
|
|
index = -1 * round(len(obj) * .2)
|
|
obj = obj[:index or -1]
|
|
return truncate_object_bytes(obj, maximum_bytes)
|
|
|
|
|
|
def truncate_generator_bytes(gen: Generator, maximum_bytes: int) -> Generator:
|
|
"""Yield from a generator until the chunk sizes go over the maximum_bytes."""
|
|
total_size = 0
|
|
while total_size < maximum_bytes:
|
|
try:
|
|
chunk = next(gen)
|
|
total_size += sys.getsizeof(chunk)
|
|
yield chunk
|
|
except StopIteration:
|
|
return
|
|
|
|
|
|
BACKGROUND_TASKS = set()
|
|
|
|
|
|
def add_background_task(task: Task):
|
|
BACKGROUND_TASKS.add(task)
|
|
task.add_done_callback(BACKGROUND_TASKS.discard)
|
|
|
|
|
|
def background_task(coro) -> Task:
|
|
"""Convenience function which creates an asyncio task for the provided coroutine.
|
|
|
|
The task is stored in a global set of background tasks so the task will not be discarded by the garbage collector.
|
|
"""
|
|
|
|
async def error_logger():
|
|
try:
|
|
await coro
|
|
except Exception as e:
|
|
logger_.error('Background task had error:', exc_info=e)
|
|
|
|
task = asyncio.create_task(error_logger())
|
|
add_background_task(task)
|
|
return task
|
|
|
|
|
|
async def cancel_background_tasks():
|
|
"""Cancels any background async tasks, if any."""
|
|
if BACKGROUND_TASKS:
|
|
logger_.warning(f'Canceling {len(BACKGROUND_TASKS)} background tasks')
|
|
for task in BACKGROUND_TASKS:
|
|
task.cancel()
|
|
await asyncio.gather(*BACKGROUND_TASKS)
|
|
|
|
|
|
def get_warn_once(message: str, logger__: logging.Logger, level=logging.ERROR):
|
|
"""Create a function that will report an error only once.
|
|
|
|
This is used when a function will be called many times, but the error is not important."""
|
|
event = multiprocessing.Event()
|
|
|
|
def warn_once(exception: Exception):
|
|
if not event.is_set():
|
|
logger__.log(level, message, exc_info=exception)
|
|
event.set()
|
|
|
|
return warn_once
|
|
|
|
|
|
def get_global_statistics():
|
|
from wrolpi.db import get_db_curs
|
|
with get_db_curs() as curs:
|
|
curs.execute('select pg_database_size(current_database())')
|
|
db_size = curs.fetchall()[0][0]
|
|
|
|
return dict(
|
|
db_size=db_size,
|
|
)
|
|
|
|
|
|
REFRESH_TASKS: List[Task] = []
|
|
|
|
|
|
async def cancel_refresh_tasks():
|
|
"""Cancel all refresh tasks, if any."""
|
|
if REFRESH_TASKS:
|
|
logger_.warning(f'Canceling {len(REFRESH_TASKS)} refreshes')
|
|
for task in REFRESH_TASKS:
|
|
task.cancel()
|
|
await asyncio.gather(*REFRESH_TASKS)
|
|
|
|
|
|
def cancelable_wrapper(func: callable):
|
|
"""Wraps an async function so that it will be canceled by `cancel_refresh_tasks`."""
|
|
|
|
@wraps(func)
|
|
async def wrapped(*args, **kwargs):
|
|
if PYTEST:
|
|
return await func(*args, **kwargs)
|
|
|
|
task = background_task(func(*args, **kwargs))
|
|
REFRESH_TASKS.append(task)
|
|
|
|
return wrapped
|
|
|
|
|
|
WHITESPACE_SPLITTER = re.compile(r'\s+')
|
|
TAB = re.compile(r'\t+')
|
|
|
|
|
|
def split_lines_by_length(text: str, max_line_length: int = 38) -> str:
|
|
"""
|
|
Break up any long lines along word boundaries.
|
|
|
|
Newlines are preserved, but whitspace (spaces/tabs) is replaced with one space.
|
|
|
|
@param max_line_length: The most amount of characters a line will contain. 38 is default for mobile vertical.
|
|
|
|
>>> split_lines_by_length('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do')
|
|
'Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit, sed do'
|
|
"""
|
|
if not text:
|
|
return text
|
|
|
|
text = TAB.sub(' ', text)
|
|
new_text = ''
|
|
for line in text.splitlines():
|
|
if len(line) > max_line_length:
|
|
# This line is too long, break it up.
|
|
new_line = ''
|
|
words = WHITESPACE_SPLITTER.split(line)
|
|
while words:
|
|
word = words.pop(0)
|
|
if len(possible_line := f'{new_line} {word}') <= max_line_length:
|
|
new_line = possible_line
|
|
else:
|
|
# New word makes the line too long, start a new line.
|
|
new_text += f'\n{new_line.strip()}'
|
|
new_line = word
|
|
new_text += f'\n{new_line}'
|
|
else:
|
|
new_text += f'\n{line}'
|
|
return new_text.lstrip('\n')
|
|
|
|
|
|
def resolve_generators(obj: Union[Dict, List]) -> Any:
|
|
"""Recursively find generators within an object/list, resolve them.
|
|
|
|
Returns a new object/list without any generators."""
|
|
if isinstance(obj, str):
|
|
return obj
|
|
elif isinstance(obj, Tuple):
|
|
return tuple(resolve_generators(i) for i in obj)
|
|
elif isinstance(obj, Set):
|
|
return {resolve_generators(i) for i in obj}
|
|
elif isinstance(obj, Dict):
|
|
return {resolve_generators(i): resolve_generators(j) for i, j in obj.items()}
|
|
elif isinstance(obj, range):
|
|
return list(obj)
|
|
elif isinstance(obj, GeneratorType) or isinstance(obj, Iterable):
|
|
return [resolve_generators(i) for i in obj]
|
|
return obj
|
|
|
|
|
|
def url_strip_host(url: str) -> str:
|
|
"""Return a relative URL without the host or scheme.
|
|
|
|
>>> url_strip_host('https://example.com/foo')
|
|
'/foo'
|
|
"""
|
|
url = urlparse(url)
|
|
url = urlunsplit(('', '', url.path, url.query, url.fragment))
|
|
return url or '/'
|
|
|
|
|
|
def get_html_soup(html: Union[bytes, str]) -> bs4.BeautifulSoup:
|
|
soup = BeautifulSoup(html, features='html.parser')
|
|
return soup
|
|
|
|
|
|
def extract_html_text(html: str) -> str:
|
|
soup = get_html_soup(html)
|
|
|
|
# kill all script and style elements
|
|
for script in soup(["script", "style"]):
|
|
script.extract() # rip it out
|
|
|
|
text = soup.body.get_text()
|
|
|
|
# break into lines and remove leading and trailing space on each
|
|
lines = (line.strip() for line in text.splitlines())
|
|
# break multi-headlines into a line each
|
|
chunks_ = (phrase.strip() for line in lines for phrase in line.split(" "))
|
|
# drop blank lines
|
|
text = '\n'.join(chunk for chunk in chunks_ if chunk)
|
|
|
|
return text
|
|
|
|
|
|
def extract_headlines(entries: List[str], search_str: str) -> List[Tuple[str, float]]:
|
|
"""Use Postgres to extract Headlines and ranks of the `search_str` from the provided entries."""
|
|
from wrolpi.db import get_db_curs
|
|
|
|
source = json.dumps([{'content': i} for i in entries])
|
|
with get_db_curs() as curs:
|
|
stmt = '''
|
|
WITH vectored AS (
|
|
-- Convert the "source" json to a recordset.
|
|
with source as (select * from json_to_recordset(%s::json) AS (content TEXT))
|
|
select
|
|
to_tsvector('english'::regconfig, source.content) AS vector,
|
|
source.content
|
|
from source
|
|
)
|
|
SELECT
|
|
ts_headline(vectored.content, websearch_to_tsquery(%s), 'MaxFragments=10, MaxWords=8, MinWords=7'),
|
|
ts_rank(vectored.vector, websearch_to_tsquery(%s))
|
|
FROM vectored
|
|
'''
|
|
curs.execute(stmt, [source, search_str, search_str])
|
|
headlines = [tuple(i) for i in curs.fetchall()]
|
|
|
|
return headlines
|
|
|
|
|
|
def format_json_file(file: pathlib.Path, indent: int = 2):
|
|
"""Reformat the contents of a JSON file to include indentation to increase readability."""
|
|
if not indent or indent <= 0:
|
|
raise RuntimeError('Ident must be greater than 0')
|
|
if not file or not file.is_file():
|
|
raise RuntimeError(f'File does not exist: {file}')
|
|
|
|
with file.open('rt') as fh:
|
|
content = json.load(fh)
|
|
|
|
copy = file.with_suffix('.json2')
|
|
try:
|
|
with copy.open('wt') as fh:
|
|
json.dump(content, fh, indent=indent)
|
|
copy.rename(file)
|
|
finally:
|
|
if copy.is_file():
|
|
copy.unlink()
|
|
|
|
|
|
def format_html_string(html: str) -> str:
|
|
if not html.strip():
|
|
raise RuntimeError('Refusing to format empty HTMl string.')
|
|
|
|
soup = BeautifulSoup(html, features='lxml')
|
|
return soup.prettify()
|
|
|
|
|
|
def format_html_file(file: pathlib.Path):
|
|
"""Reformat the contents of an HTML file to include indentation to increase readability."""
|
|
if not file or not file.is_file():
|
|
raise RuntimeError(f'File does not exist: {file}')
|
|
|
|
pretty = format_html_string(file.read_text())
|
|
|
|
copy = file.with_suffix('.html2')
|
|
try:
|
|
with copy.open('wt') as fh:
|
|
fh.write(pretty)
|
|
copy.rename(file)
|
|
finally:
|
|
if copy.is_file():
|
|
copy.unlink()
|
|
|
|
|
|
def html_screenshot(html: bytes) -> bytes:
|
|
"""Return a PNG screenshot of the provided HTML."""
|
|
# Set Chromium to headless. Use a wide window size so that screenshot will be the "desktop" version of the page.
|
|
options = webdriver.ChromeOptions()
|
|
options.add_argument('headless')
|
|
options.add_argument('disable-gpu')
|
|
options.add_argument('window-size=1280x720')
|
|
|
|
with tempfile.NamedTemporaryFile('wb', suffix='.html') as fh:
|
|
fh.write(html)
|
|
fh.flush()
|
|
|
|
with webdriver.Chrome(chrome_options=options) as driver:
|
|
driver.get(f'file://{fh.name}')
|
|
driver.set_window_size(1280, 720)
|
|
screenshot = driver.get_screenshot_as_png()
|
|
return screenshot
|
|
|
|
|
|
def chain(iterable: Union[List, Tuple], length: int) -> List:
|
|
"""
|
|
Steps through the provided list and generates slices of the provided length.
|
|
|
|
>>> list(chain([1, 2, 3, 4], 2))
|
|
[[1, 2], [2, 3], [3, 4]]
|
|
|
|
>>> list(chain([1, 2, 3, 4], 3))
|
|
[[1, 2, 3], [2, 3, 4]]
|
|
"""
|
|
if not iterable:
|
|
return
|
|
|
|
position = 0
|
|
maximum = 1 + len(iterable) - length
|
|
yielded = False
|
|
while position < maximum:
|
|
tick = iterable[position:position + length]
|
|
yield tick
|
|
yielded = True
|
|
position += 1
|
|
if not yielded:
|
|
# Not enough items to iterate. Yield the original iterable.
|
|
yield iterable
|