jakubroztocil/httpie

View on GitHub
httpie/internal/update_warnings.py

Summary

Maintainability
A
0 mins
Test Coverage
import json
from contextlib import nullcontext, suppress
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional, Callable

import requests

import httpie
from httpie.context import Environment, LogLevel
from httpie.internal.__build_channel__ import BUILD_CHANNEL
from httpie.internal.daemons import spawn_daemon
from httpie.utils import is_version_greater, open_with_lockfile

# Automatically updated package version index.
PACKAGE_INDEX_LINK = 'https://packages.httpie.io/latest.json'

FETCH_INTERVAL = timedelta(weeks=2)
WARN_INTERVAL = timedelta(weeks=1)

UPDATE_MESSAGE_FORMAT = """\
A new HTTPie release ({last_released_version}) is available.
To see how you can update, please visit https://httpie.io/docs/cli/{installation_method}
"""

ALREADY_UP_TO_DATE_MESSAGE = """\
You are already up-to-date.
"""


def _read_data_error_free(file: Path) -> Any:
    # If the file is broken / non-existent, ignore it.
    try:
        with open(file) as stream:
            return json.load(stream)
    except (ValueError, OSError):
        return {}


def _fetch_updates(env: Environment) -> str:
    file = env.config.version_info_file
    data = _read_data_error_free(file)

    response = requests.get(PACKAGE_INDEX_LINK, verify=False)
    response.raise_for_status()

    data.setdefault('last_warned_date', None)
    data['last_fetched_date'] = datetime.now().isoformat()
    data['last_released_versions'] = response.json()

    with open_with_lockfile(file, 'w') as stream:
        json.dump(data, stream)


def fetch_updates(env: Environment, lazy: bool = True):
    if lazy:
        spawn_daemon('fetch_updates')
    else:
        _fetch_updates(env)


def maybe_fetch_updates(env: Environment) -> None:
    if env.config.get('disable_update_warnings'):
        return None

    data = _read_data_error_free(env.config.version_info_file)

    if data:
        current_date = datetime.now()
        last_fetched_date = datetime.fromisoformat(data['last_fetched_date'])
        earliest_fetch_date = last_fetched_date + FETCH_INTERVAL
        if current_date < earliest_fetch_date:
            return None

    fetch_updates(env)


def _get_suppress_context(env: Environment) -> Any:
    """Return a context manager that suppress
    all possible errors.

    Note: if you have set the developer_mode=True in
    your config, then it will show all errors for easier
    debugging."""
    if env.config.developer_mode:
        return nullcontext()
    else:
        return suppress(BaseException)


def _update_checker(
    func: Callable[[Environment], None]
) -> Callable[[Environment], None]:
    """Control the execution of the update checker (suppress errors, trigger
    auto updates etc.)"""

    def wrapper(env: Environment) -> None:
        with _get_suppress_context(env):
            func(env)

        with _get_suppress_context(env):
            maybe_fetch_updates(env)

    return wrapper


def _get_update_status(env: Environment) -> Optional[str]:
    """If there is a new update available, return the warning text.
    Otherwise just return None."""
    file = env.config.version_info_file
    if not file.exists():
        return None

    with _get_suppress_context(env):
        # If the user quickly spawns multiple httpie processes
        # we don't want to end in a race.
        with open_with_lockfile(file) as stream:
            version_info = json.load(stream)

        available_channels = version_info['last_released_versions']
        if BUILD_CHANNEL not in available_channels:
            return None

        current_version = httpie.__version__
        last_released_version = available_channels[BUILD_CHANNEL]
        if not is_version_greater(last_released_version, current_version):
            return None

        text = UPDATE_MESSAGE_FORMAT.format(
            last_released_version=last_released_version,
            installation_method=BUILD_CHANNEL,
        )
        return text


def get_update_status(env: Environment) -> str:
    return _get_update_status(env) or ALREADY_UP_TO_DATE_MESSAGE


@_update_checker
def check_updates(env: Environment) -> None:
    if env.config.get('disable_update_warnings'):
        return None

    file = env.config.version_info_file
    update_status = _get_update_status(env)

    if not update_status:
        return None

    # If the user quickly spawns multiple httpie processes
    # we don't want to end in a race.
    with open_with_lockfile(file) as stream:
        version_info = json.load(stream)

    # We don't want to spam the user with too many warnings,
    # so we'll only warn every once a while (WARN_INTERNAL).
    current_date = datetime.now()
    last_warned_date = version_info['last_warned_date']
    if last_warned_date is not None:
        earliest_warn_date = (
            datetime.fromisoformat(last_warned_date) + WARN_INTERVAL
        )
        if current_date < earliest_warn_date:
            return None

    env.log_error(update_status, level=LogLevel.INFO)
    version_info['last_warned_date'] = current_date.isoformat()

    with open_with_lockfile(file, 'w') as stream:
        json.dump(version_info, stream)