From 003f2095d4e98b26220802f016a56be38bf9bd8d Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Thu, 5 May 2022 21:18:20 +0300 Subject: [PATCH] Automatic release update warnings. (#1336) * Hide pretty help * Automatic release update warnings. * `httpie cli check-updates` * adapt to the new loglevel construct * Don't make the pie-colors the bold * Apply review feedback. Co-authored-by: Jakub Roztocil --- .github/workflows/release-pypi.yml | 5 +- Makefile | 11 +- docs/README.md | 12 ++ extras/packaging/linux/Dockerfile | 1 + extras/packaging/linux/build.py | 5 +- httpie/config.py | 20 ++- httpie/core.py | 7 + httpie/internal/__build_channel__.py | 5 + httpie/internal/__init__.py | 0 httpie/internal/daemon_runner.py | 49 ++++++ httpie/internal/daemons.py | 121 +++++++++++++ httpie/internal/update_warnings.py | 171 +++++++++++++++++++ httpie/manager/cli.py | 3 + httpie/manager/tasks/__init__.py | 2 + httpie/manager/tasks/check_updates.py | 10 ++ httpie/manager/tasks/sessions.py | 21 +-- httpie/utils.py | 48 +++++- setup.py | 1 + tests/test_update_warnings.py | 237 ++++++++++++++++++++++++++ tests/test_uploads.py | 2 +- tests/utils/__init__.py | 13 +- 21 files changed, 708 insertions(+), 36 deletions(-) create mode 100644 httpie/internal/__build_channel__.py create mode 100644 httpie/internal/__init__.py create mode 100644 httpie/internal/daemon_runner.py create mode 100644 httpie/internal/daemons.py create mode 100644 httpie/internal/update_warnings.py create mode 100644 httpie/manager/tasks/check_updates.py create mode 100644 tests/test_update_warnings.py diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index f11b830c..dfa8afd4 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -21,11 +21,8 @@ jobs: with: python-version: 3.9 - - name: Install pypa/build - run: python -m pip install build - - name: Build a binary wheel and a source tarball - run: python -m build --sdist --wheel --outdir dist/ + run: make build - name: Release on PyPI uses: pypa/gh-action-pypi-publish@master diff --git a/Makefile b/Makefile index 207f02ff..ff4cb745 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ install: venv install-reqs install-reqs: @echo $(H1)Updating package tools$(H1END) - $(VENV_PIP) install --upgrade pip wheel + $(VENV_PIP) install --upgrade pip wheel build @echo $(H1)Installing dev requirements$(H1END) $(VENV_PIP) install --upgrade --editable '.[dev]' @@ -153,8 +153,11 @@ doc-check: build: - rm -rf build/ - $(VENV_PYTHON) setup.py sdist bdist_wheel + rm -rf build/ dist/ + mv httpie/internal/__build_channel__.py httpie/internal/__build_channel__.py.original + echo 'BUILD_CHANNEL = "pip"' > httpie/internal/__build_channel__.py + $(VENV_PYTHON) -m build --sdist --wheel --outdir dist/ + mv httpie/internal/__build_channel__.py.original httpie/internal/__build_channel__.py publish: test-all publish-no-test @@ -198,7 +201,7 @@ brew-test: - brew uninstall httpie @echo $(H1)Building from source…$(H1END) - - brew install --build-from-source ./docs/packaging/brew/httpie.rb + - brew install --HEAD --build-from-source ./docs/packaging/brew/httpie.rb @echo $(H1)Verifying…$(H1END) http --version diff --git a/docs/README.md b/docs/README.md index dd8424de..364e0a85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1655,6 +1655,10 @@ If you’d like to silence warnings as well, use `-q` or `--quiet` twice: $ http -qq --check-status pie.dev/post enjoy='the silence without warnings' ``` +### Update warnings + +When there is a new release available for your platform (for example; if you installed HTTPie through `pip`, it will check the latest version on `PyPI`), HTTPie will regularly warn you about the new update (once a week). If you want to disable this behavior, you can set `disable_update_warnings` to `true` in your [config](#config) file. + ### Viewing intermediary requests/responses To see all the HTTP communication, i.e. the final request/response as well as any possible intermediary requests/responses, use the `--all` option. @@ -2400,6 +2404,14 @@ This command is currently in beta. ### `httpie cli` +#### `httpie cli check-updates` + +You can check whether a new update is available for your system by running `httpie cli check-updates`: + +```bash-termible +$ httpie cli check-updates +```` + #### `httpie cli export-args` `httpie cli export-args` command can expose the parser specification of `http`/`https` commands diff --git a/extras/packaging/linux/Dockerfile b/extras/packaging/linux/Dockerfile index bd554dd3..ea441fd6 100644 --- a/extras/packaging/linux/Dockerfile +++ b/extras/packaging/linux/Dockerfile @@ -27,6 +27,7 @@ RUN python -m pip install /app RUN python -m pip install pyinstaller wheel RUN python -m pip install --force-reinstall --upgrade pip +RUN echo 'BUILD_CHANNEL="pypi"' > /app/httpie/internal/__build_channel__.py RUN python build.py ENTRYPOINT ["mv", "/app/extras/packaging/linux/dist/", "/artifacts"] diff --git a/extras/packaging/linux/build.py b/extras/packaging/linux/build.py index 534708bb..5dc2e611 100644 --- a/extras/packaging/linux/build.py +++ b/extras/packaging/linux/build.py @@ -92,8 +92,9 @@ def main(): build_packages(binaries['http_cli'], binaries['httpie_cli']) # Rename http_cli/httpie_cli to http/httpie - binaries['http_cli'].rename('http') - binaries['httpie_cli'].rename('httpie') + binaries['http_cli'].rename(DIST_DIR / 'http') + binaries['httpie_cli'].rename(DIST_DIR / 'httpie') + if __name__ == '__main__': diff --git a/httpie/config.py b/httpie/config.py index f7fee5bd..27bc0a78 100644 --- a/httpie/config.py +++ b/httpie/config.py @@ -149,6 +149,24 @@ class Config(BaseConfigDict): def default_options(self) -> list: return self['default_options'] + def _configured_path(self, config_option: str, default: str) -> None: + return Path( + self.get(config_option, self.directory / default) + ).expanduser().resolve() + @property def plugins_dir(self) -> Path: - return Path(self.get('plugins_dir', self.directory / 'plugins')).resolve() + return self._configured_path('plugins_dir', 'plugins') + + @property + def version_info_file(self) -> Path: + return self._configured_path('version_info_file', 'version_info.json') + + @property + def developer_mode(self) -> bool: + """This is a special setting for the development environment. It is + different from the --debug mode in the terms that it might change + the behavior for certain parameters (e.g updater system) that + we usually ignore.""" + + return self.get('developer_mode') diff --git a/httpie/core.py b/httpie/core.py index 2259c4ad..c90452a0 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -24,6 +24,8 @@ from .output.writer import write_message, write_stream, write_raw_data, MESSAGE_ from .plugins.registry import plugin_manager from .status import ExitStatus, http_status_to_exit_status from .utils import unwrap_context +from .internal.update_warnings import check_updates +from .internal.daemon_runner import is_daemon_mode, run_daemon_task # noinspection PyDefaultArgument @@ -37,6 +39,10 @@ def raw_main( program_name, *args = args env.program_name = os.path.basename(program_name) args = decode_raw_args(args, env.stdin_encoding) + + if is_daemon_mode(args): + return run_daemon_task(env, args) + plugin_manager.load_installed_plugins(env.config.plugins_dir) if use_default_options and env.config.default_options: @@ -89,6 +95,7 @@ def raw_main( raise exit_status = ExitStatus.ERROR else: + check_updates(env) try: exit_status = main_program( args=parsed_args, diff --git a/httpie/internal/__build_channel__.py b/httpie/internal/__build_channel__.py new file mode 100644 index 00000000..f56ce598 --- /dev/null +++ b/httpie/internal/__build_channel__.py @@ -0,0 +1,5 @@ +# Represents the packaging method. This file should +# be overridden by every build system we support on +# the packaging step. + +BUILD_CHANNEL = 'unknown' diff --git a/httpie/internal/__init__.py b/httpie/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/httpie/internal/daemon_runner.py b/httpie/internal/daemon_runner.py new file mode 100644 index 00000000..1998ba17 --- /dev/null +++ b/httpie/internal/daemon_runner.py @@ -0,0 +1,49 @@ +import argparse +from contextlib import redirect_stderr, redirect_stdout +from typing import List + +from httpie.context import Environment +from httpie.internal.update_warnings import _fetch_updates +from httpie.status import ExitStatus + +STATUS_FILE = '.httpie-test-daemon-status' + + +def _check_status(env): + # This function is used only for the testing (test_update_warnings). + # Since we don't want to trigger the fetch_updates (which would interact + # with real world resources), we'll only trigger this pseudo task + # and check whether the STATUS_FILE is created or not. + import tempfile + from pathlib import Path + + status_file = Path(tempfile.gettempdir()) / STATUS_FILE + status_file.touch() + + +DAEMONIZED_TASKS = { + 'check_status': _check_status, + 'fetch_updates': _fetch_updates, +} + + +def _parse_options(args: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument('task_id') + parser.add_argument('--daemon', action='store_true') + return parser.parse_known_args(args)[0] + + +def is_daemon_mode(args: List[str]) -> bool: + return '--daemon' in args + + +def run_daemon_task(env: Environment, args: List[str]) -> ExitStatus: + options = _parse_options(args) + + assert options.daemon + assert options.task_id in DAEMONIZED_TASKS + with redirect_stdout(env.devnull), redirect_stderr(env.devnull): + DAEMONIZED_TASKS[options.task_id](env) + + return ExitStatus.SUCCESS diff --git a/httpie/internal/daemons.py b/httpie/internal/daemons.py new file mode 100644 index 00000000..bdf1be52 --- /dev/null +++ b/httpie/internal/daemons.py @@ -0,0 +1,121 @@ +""" +This module provides an interface to spawn a detached task to be +runned with httpie.internal.daemon_runner on a separate process. It is +based on DVC's daemon system. +https://github.com/iterative/dvc/blob/main/dvc/daemon.py +""" + +import inspect +import os +import platform +import sys +import httpie.__main__ +from contextlib import suppress +from subprocess import Popen +from typing import Dict, List +from httpie.compat import is_frozen, is_windows + + +ProcessContext = Dict[str, str] + + +def _start_process(cmd: List[str], **kwargs) -> Popen: + prefix = [sys.executable] + # If it is frozen, sys.executable points to the binary (http). + # Otherwise it points to the python interpreter. + if not is_frozen: + main_entrypoint = httpie.__main__.__file__ + prefix += [main_entrypoint] + return Popen(prefix + cmd, close_fds=True, shell=False, **kwargs) + + +def _spawn_windows(cmd: List[str], process_context: ProcessContext) -> None: + from subprocess import ( + CREATE_NEW_PROCESS_GROUP, + CREATE_NO_WINDOW, + STARTF_USESHOWWINDOW, + STARTUPINFO, + ) + + # https://stackoverflow.com/a/7006424 + # https://bugs.python.org/issue41619 + creationflags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + + startupinfo = STARTUPINFO() + startupinfo.dwFlags |= STARTF_USESHOWWINDOW + + _start_process( + cmd, + env=process_context, + creationflags=creationflags, + startupinfo=startupinfo, + ) + + +def _spawn_posix(args: List[str], process_context: ProcessContext) -> None: + """ + Perform a double fork procedure* to detach from the parent + process so that we don't block the user even if their original + command's execution is done but the release fetcher is not. + + [1]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11_01_03 + """ + + from httpie.core import main + + try: + pid = os.fork() + if pid > 0: + return + except OSError: + os._exit(1) + + os.setsid() + + try: + pid = os.fork() + if pid > 0: + os._exit(0) + except OSError: + os._exit(1) + + # Close all standard inputs/outputs + sys.stdin.close() + sys.stdout.close() + sys.stderr.close() + + if platform.system() == 'Darwin': + # Double-fork is not reliable on MacOS, so we'll use a subprocess + # to ensure the task is isolated properly. + process = _start_process(args, env=process_context) + # Unlike windows, since we already completed the fork procedure + # we can simply join the process and wait for it. + process.communicate() + else: + os.environ.update(process_context) + with suppress(BaseException): + main(['http'] + args) + + os._exit(0) + + +def _spawn(args: List[str], process_context: ProcessContext) -> None: + """ + Spawn a new process to run the given command. + """ + if is_windows: + _spawn_windows(args, process_context) + else: + _spawn_posix(args, process_context) + + +def spawn_daemon(task: str) -> None: + args = [task, '--daemon'] + process_context = os.environ.copy() + if not is_frozen: + file_path = os.path.abspath(inspect.stack()[0][1]) + process_context['PYTHONPATH'] = os.path.dirname( + os.path.dirname(os.path.dirname(file_path)) + ) + + _spawn(args, process_context) diff --git a/httpie/internal/update_warnings.py b/httpie/internal/update_warnings.py new file mode 100644 index 00000000..a4b80d46 --- /dev/null +++ b/httpie/internal/update_warnings.py @@ -0,0 +1,171 @@ +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) diff --git a/httpie/manager/cli.py b/httpie/manager/cli.py index aab33003..248cb8bb 100644 --- a/httpie/manager/cli.py +++ b/httpie/manager/cli.py @@ -24,6 +24,9 @@ COMMANDS = { 'default': 'json' } ], + 'check-updates': [ + 'Check for updates' + ], 'sessions': { 'help': 'Manage HTTPie sessions', 'upgrade': [ diff --git a/httpie/manager/tasks/__init__.py b/httpie/manager/tasks/__init__.py index 9c591a24..b9b30fb3 100644 --- a/httpie/manager/tasks/__init__.py +++ b/httpie/manager/tasks/__init__.py @@ -1,9 +1,11 @@ from httpie.manager.tasks.sessions import cli_sessions from httpie.manager.tasks.export_args import cli_export_args from httpie.manager.tasks.plugins import cli_plugins +from httpie.manager.tasks.check_updates import cli_check_updates CLI_TASKS = { 'sessions': cli_sessions, 'export-args': cli_export_args, 'plugins': cli_plugins, + 'check-updates': cli_check_updates } diff --git a/httpie/manager/tasks/check_updates.py b/httpie/manager/tasks/check_updates.py new file mode 100644 index 00000000..07fd1240 --- /dev/null +++ b/httpie/manager/tasks/check_updates.py @@ -0,0 +1,10 @@ +import argparse +from httpie.context import Environment +from httpie.status import ExitStatus +from httpie.internal.update_warnings import fetch_updates, get_update_status + + +def cli_check_updates(env: Environment, args: argparse.Namespace) -> ExitStatus: + fetch_updates(env, lazy=False) + env.stdout.write(get_update_status(env)) + return ExitStatus.SUCCESS diff --git a/httpie/manager/tasks/sessions.py b/httpie/manager/tasks/sessions.py index bc74ec15..f84a1c44 100644 --- a/httpie/manager/tasks/sessions.py +++ b/httpie/manager/tasks/sessions.py @@ -1,11 +1,11 @@ import argparse -from typing import Tuple from httpie.sessions import SESSIONS_DIR_NAME, get_httpie_session from httpie.status import ExitStatus from httpie.context import Environment from httpie.legacy import v3_1_0_session_cookie_format, v3_2_0_session_header_format from httpie.manager.cli import missing_subcommand, parser +from httpie.utils import is_version_greater FIXERS_TO_VERSIONS = { @@ -27,25 +27,6 @@ def cli_sessions(env: Environment, args: argparse.Namespace) -> ExitStatus: raise ValueError(f'Unexpected action: {action}') -def is_version_greater(version_1: str, version_2: str) -> bool: - # In an ideal scenario, we would depend on `packaging` in order - # to offer PEP 440 compatible parsing. But since it might not be - # commonly available for outside packages, and since we are only - # going to parse HTTPie's own version it should be fine to compare - # this in a SemVer subset fashion. - - def split_version(version: str) -> Tuple[int, ...]: - parts = [] - for part in version.split('.')[:3]: - try: - parts.append(int(part)) - except ValueError: - break - return tuple(parts) - - return split_version(version_1) > split_version(version_2) - - def upgrade_session(env: Environment, args: argparse.Namespace, hostname: str, session_name: str): session = get_httpie_session( env=env, diff --git a/httpie/utils.py b/httpie/utils.py index 5f2b15fd..5588e947 100644 --- a/httpie/utils.py +++ b/httpie/utils.py @@ -1,16 +1,20 @@ +import os +import base64 import json import mimetypes import re import sys import time +import tempfile import sysconfig from collections import OrderedDict +from contextlib import contextmanager from http.cookiejar import parse_ns_headers from pathlib import Path from pprint import pformat from urllib.parse import urlsplit -from typing import Any, List, Optional, Tuple, Callable, Iterable, TypeVar +from typing import Any, List, Optional, Tuple, Generator, Callable, Iterable, IO, TypeVar import requests.auth @@ -261,3 +265,45 @@ def unwrap_context(exc: Exception) -> Optional[Exception]: def url_as_host(url: str) -> str: return urlsplit(url).netloc.split('@')[-1] + + +class LockFileError(ValueError): + pass + + +@contextmanager +def open_with_lockfile(file: Path, *args, **kwargs) -> Generator[IO[Any], None, None]: + file_id = base64.b64encode(os.fsencode(file)).decode() + target_file = Path(tempfile.gettempdir()) / file_id + + # Have an atomic-like touch here, so we'll tighten the possibility of + # a race occuring between multiple processes accessing the same file. + try: + target_file.touch(exist_ok=False) + except FileExistsError as exc: + raise LockFileError("Can't modify a locked file.") from exc + + try: + with open(file, *args, **kwargs) as stream: + yield stream + finally: + target_file.unlink() + + +def is_version_greater(version_1: str, version_2: str) -> bool: + # In an ideal scenario, we would depend on `packaging` in order + # to offer PEP 440 compatible parsing. But since it might not be + # commonly available for outside packages, and since we are only + # going to parse HTTPie's own version it should be fine to compare + # this in a SemVer subset fashion. + + def split_version(version: str) -> Tuple[int, ...]: + parts = [] + for part in version.split('.')[:3]: + try: + parts.append(int(part)) + except ValueError: + break + return tuple(parts) + + return split_version(version_1) > split_version(version_2) diff --git a/setup.py b/setup.py index aa1406fc..f506f2d0 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ tests_require = [ 'pytest-httpbin>=0.0.6', 'pytest-lazy-fixture>=0.0.6', 'responses', + 'pytest-mock', 'werkzeug<2.1.0' ] dev_require = [ diff --git a/tests/test_update_warnings.py b/tests/test_update_warnings.py new file mode 100644 index 00000000..b2c24c36 --- /dev/null +++ b/tests/test_update_warnings.py @@ -0,0 +1,237 @@ +import json +import tempfile +import time +from contextlib import suppress +from datetime import datetime +from pathlib import Path + +import pytest + +from httpie.internal.daemon_runner import STATUS_FILE +from httpie.internal.daemons import spawn_daemon +from httpie.status import ExitStatus + +from .utils import PersistentMockEnvironment, http, httpie + +BUILD_CHANNEL = 'test' +BUILD_CHANNEL_2 = 'test2' +UNKNOWN_BUILD_CHANNEL = 'test3' + +HIGHEST_VERSION = '999.999.999' +LOWEST_VERSION = '1.1.1' + +FIXED_DATE = datetime(1970, 1, 1).isoformat() + +MAX_ATTEMPT = 40 +MAX_TIMEOUT = 2.0 + + +def check_update_warnings(text): + return 'A new HTTPie release' in text + + +@pytest.mark.requires_external_processes +def test_daemon_runner(): + # We have a pseudo daemon task called 'check_status' + # which creates a temp file called STATUS_FILE under + # user's temp directory. This test simply ensures that + # we create a daemon that successfully performs the + # external task. + + status_file = Path(tempfile.gettempdir()) / STATUS_FILE + with suppress(FileNotFoundError): + status_file.unlink() + + spawn_daemon('check_status') + + for attempt in range(MAX_ATTEMPT): + time.sleep(MAX_TIMEOUT / MAX_ATTEMPT) + if status_file.exists(): + break + else: + pytest.fail( + 'Maximum number of attempts failed for daemon status check.' + ) + + assert status_file.exists() + + +def test_fetch(static_fetch_data, without_warnings): + http('fetch_updates', '--daemon', env=without_warnings) + + with open(without_warnings.config.version_info_file) as stream: + version_data = json.load(stream) + + assert version_data['last_warned_date'] is None + assert version_data['last_fetched_date'] is not None + assert ( + version_data['last_released_versions'][BUILD_CHANNEL] + == HIGHEST_VERSION + ) + assert ( + version_data['last_released_versions'][BUILD_CHANNEL_2] + == LOWEST_VERSION + ) + + +def test_fetch_dont_override_existing_layout( + static_fetch_data, without_warnings +): + with open(without_warnings.config.version_info_file, 'w') as stream: + existing_layout = { + 'last_warned_date': FIXED_DATE, + 'last_fetched_date': FIXED_DATE, + 'last_released_versions': {BUILD_CHANNEL: LOWEST_VERSION}, + } + json.dump(existing_layout, stream) + + http('fetch_updates', '--daemon', env=without_warnings) + + with open(without_warnings.config.version_info_file) as stream: + version_data = json.load(stream) + + # The "last updated at" field should not be modified, but the + # rest need to be updated. + assert version_data['last_warned_date'] == FIXED_DATE + assert version_data['last_fetched_date'] != FIXED_DATE + assert ( + version_data['last_released_versions'][BUILD_CHANNEL] + == HIGHEST_VERSION + ) + + +def test_fetch_broken_json(static_fetch_data, without_warnings): + with open(without_warnings.config.version_info_file, 'w') as stream: + stream.write('$$broken$$') + + http('fetch_updates', '--daemon', env=without_warnings) + + with open(without_warnings.config.version_info_file) as stream: + version_data = json.load(stream) + + assert ( + version_data['last_released_versions'][BUILD_CHANNEL] + == HIGHEST_VERSION + ) + + +def test_check_updates_disable_warnings( + without_warnings, httpbin, fetch_update_mock +): + r = http(httpbin + '/get', env=without_warnings) + assert not fetch_update_mock.called + assert not check_update_warnings(r.stderr) + + +def test_check_updates_first_invocation( + with_warnings, httpbin, fetch_update_mock +): + r = http(httpbin + '/get', env=with_warnings) + assert fetch_update_mock.called + assert not check_update_warnings(r.stderr) + + +@pytest.mark.parametrize( + 'should_issue_warning, build_channel', + [ + (False, pytest.lazy_fixture('lower_build_channel')), + (True, pytest.lazy_fixture('higher_build_channel')), + ], +) +def test_check_updates_first_time_after_data_fetch( + with_warnings, + httpbin, + fetch_update_mock, + static_fetch_data, + should_issue_warning, + build_channel, +): + http('fetch_updates', '--daemon', env=with_warnings) + r = http(httpbin + '/get', env=with_warnings) + + assert not fetch_update_mock.called + assert (not should_issue_warning) or check_update_warnings(r.stderr) + + +def test_check_updates_first_time_after_data_fetch_unknown_build_channel( + with_warnings, + httpbin, + fetch_update_mock, + static_fetch_data, + unknown_build_channel, +): + http('fetch_updates', '--daemon', env=with_warnings) + r = http(httpbin + '/get', env=with_warnings) + + assert not fetch_update_mock.called + assert not check_update_warnings(r.stderr) + + +def test_cli_check_updates( + static_fetch_data, higher_build_channel +): + r = httpie('cli', 'check-updates') + assert r.exit_status == ExitStatus.SUCCESS + assert check_update_warnings(r) + + +@pytest.mark.parametrize( + "build_channel", [ + pytest.lazy_fixture("lower_build_channel"), + pytest.lazy_fixture("unknown_build_channel") + ] +) +def test_cli_check_updates_not_shown( + static_fetch_data, build_channel +): + r = httpie('cli', 'check-updates') + assert r.exit_status == ExitStatus.SUCCESS + assert not check_update_warnings(r) + + +@pytest.fixture +def with_warnings(tmp_path): + env = PersistentMockEnvironment() + env.config['version_info_file'] = tmp_path / 'version.json' + env.config['disable_update_warnings'] = False + return env + + +@pytest.fixture +def without_warnings(tmp_path): + env = PersistentMockEnvironment() + env.config['version_info_file'] = tmp_path / 'version.json' + env.config['disable_update_warnings'] = True + return env + + +@pytest.fixture +def fetch_update_mock(mocker): + mock_fetch = mocker.patch('httpie.internal.update_warnings.fetch_updates') + return mock_fetch + + +@pytest.fixture +def static_fetch_data(mocker): + mock_get = mocker.patch('requests.get') + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + BUILD_CHANNEL: HIGHEST_VERSION, + BUILD_CHANNEL_2: LOWEST_VERSION, + } + return mock_get + + +@pytest.fixture +def unknown_build_channel(mocker): + mocker.patch('httpie.internal.update_warnings.BUILD_CHANNEL', UNKNOWN_BUILD_CHANNEL) + + +@pytest.fixture +def higher_build_channel(mocker): + mocker.patch('httpie.internal.update_warnings.BUILD_CHANNEL', BUILD_CHANNEL) + + +@pytest.fixture +def lower_build_channel(mocker): + mocker.patch('httpie.internal.update_warnings.BUILD_CHANNEL', BUILD_CHANNEL_2) diff --git a/tests/test_uploads.py b/tests/test_uploads.py index d1fbaedc..d0156063 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -18,7 +18,7 @@ from .utils import ( ) from .fixtures import FILE_PATH_ARG, FILE_PATH, FILE_CONTENT -MAX_RESPONSE_WAIT_TIME = 2 +MAX_RESPONSE_WAIT_TIME = 5 def test_chunked_json(httpbin_with_chunked_support): diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 7d2557b6..ada0905f 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -49,6 +49,10 @@ HTTP_OK_COLOR = ( DUMMY_URL = 'http://this-should.never-resolve' # Note: URL never fetched DUMMY_HOST = url_as_host(DUMMY_URL) +# We don't want hundreds of subprocesses trying to access GitHub API +# during the tests. +Config.DEFAULTS['disable_update_warnings'] = True + def strip_colors(colorized_msg: str) -> str: return COLOR_RE.sub('', colorized_msg) @@ -163,6 +167,7 @@ class MockEnvironment(Environment): self._delete_config_dir = True def cleanup(self): + self.devnull.close() self.stdout.close() self.stderr.close() warnings.resetwarnings() @@ -179,6 +184,11 @@ class MockEnvironment(Environment): pass +class PersistentMockEnvironment(MockEnvironment): + def cleanup(self): + pass + + class BaseCLIResponse: """ Represents the result of simulated `$ http' invocation via `http()`. @@ -442,7 +452,4 @@ def http( return r finally: - devnull.close() - stdout.close() - stderr.close() env.cleanup()