diff --git a/docs/README.md b/docs/README.md index e515304c..59b42ff7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2224,6 +2224,14 @@ httpie_converter (1.0.0) httpie_konsole_konverter (httpie.plugins.converter.v1) ``` +#### `httpie plugins upgrade` + +For upgrading already installed plugins, use `httpie plugins upgrade`. + +```bash +$ httpie plugins upgrade httpie-plugin +``` + #### `httpie plugins uninstall` Uninstall plugins from the isolated plugins directory. If the plugin is not installed diff --git a/httpie/manager/cli.py b/httpie/manager/cli.py index eeeb2465..11c63d0a 100644 --- a/httpie/manager/cli.py +++ b/httpie/manager/cli.py @@ -14,6 +14,14 @@ COMMANDS = { 'help': 'targets to install' } ], + 'upgrade': [ + 'Upgrade the given plugins', + { + 'dest': 'targets', + 'nargs': '+', + 'help': 'targets to upgrade' + } + ], 'uninstall': [ 'Uninstall the given HTTPie plugins.', { diff --git a/httpie/manager/plugins.py b/httpie/manager/plugins.py index 7760ce23..899e9925 100644 --- a/httpie/manager/plugins.py +++ b/httpie/manager/plugins.py @@ -3,15 +3,20 @@ import os import subprocess import sys import textwrap +import re +import shutil from collections import defaultdict from contextlib import suppress from pathlib import Path -from typing import Optional, List +from typing import Tuple, Optional, List from httpie.manager.cli import parser, missing_subcommand from httpie.compat import importlib_metadata, get_dist_name from httpie.context import Environment from httpie.status import ExitStatus +from httpie.utils import as_site + +PEP_503 = re.compile(r"[-_.]+") class PluginInstaller: @@ -68,16 +73,22 @@ class PluginInstaller: **options ) - def install(self, targets: List[str]) -> Optional[ExitStatus]: - self.env.stdout.write(f"Installing {', '.join(targets)}...\n") - self.env.stdout.flush() + def _install(self, targets: List[str], mode='install', **process_options) -> Tuple[ + Optional[bytes], ExitStatus + ]: + pip_args = [ + 'install', + f'--prefix={self.dir}', + '--no-warn-script-location', + ] + if mode == 'upgrade': + pip_args.append('--upgrade') try: - self.pip( - 'install', - f'--prefix={self.dir}', - '--no-warn-script-location', + process = self.pip( + *pip_args, *targets, + **process_options, ) except subprocess.CalledProcessError as error: reason = None @@ -94,7 +105,58 @@ class PluginInstaller: if severity == 'ERROR': reason = message - return self.fail('install', ', '.join(targets), reason) + stdout = error.stdout + exit_status = self.fail(mode, ', '.join(targets), reason) + else: + stdout = process.stdout + exit_status = ExitStatus.SUCCESS + + return stdout, exit_status + + def install(self, targets: List[str]) -> ExitStatus: + self.env.stdout.write(f"Installing {', '.join(targets)}...\n") + self.env.stdout.flush() + _, exit_status = self._install(targets) + return exit_status + + def _clear_metadata(self, targets: List[str]) -> None: + # Due to an outstanding pip problem[0], we have to get rid of + # existing metadata for old versions manually. + # [0]: https://github.com/pypa/pip/issues/10727 + result_deps = defaultdict(list) + for child in as_site(self.dir).iterdir(): + if child.suffix in {'.dist-info', '.egg-info'}: + name, _, version = child.stem.rpartition('-') + result_deps[name].append((version, child)) + + for target in targets: + name, _, version = target.rpartition('-') + name = PEP_503.sub("-", name).lower().replace('-', '_') + if name not in result_deps: + continue + + for result_version, meta_path in result_deps[name]: + if version != result_version: + shutil.rmtree(meta_path) + + def upgrade(self, targets: List[str]) -> ExitStatus: + self.env.stdout.write(f"Upgrading {', '.join(targets)}...\n") + self.env.stdout.flush() + + raw_stdout, exit_status = self._install( + targets, + mode='upgrade', + stdout=subprocess.PIPE + ) + if not raw_stdout: + return exit_status + + stdout = raw_stdout.decode() + self.env.stdout.write(stdout) + + installation_line = stdout.splitlines()[-1] + if installation_line.startswith('Successfully installed'): + self._clear_metadata(installation_line.split()[2:]) def _uninstall(self, target: str) -> Optional[ExitStatus]: try: @@ -178,6 +240,8 @@ class PluginInstaller: with enable_plugins(self.dir): if action == 'install': status = self.install(args.targets) + elif action == 'upgrade': + status = self.upgrade(args.targets) elif action == 'uninstall': status = self.uninstall(args.targets) elif action == 'list': diff --git a/tests/test_plugins_cli.py b/tests/test_plugins_cli.py index d644aba7..af4a7746 100644 --- a/tests/test_plugins_cli.py +++ b/tests/test_plugins_cli.py @@ -93,6 +93,18 @@ def test_plugins_double_uninstall(httpie_plugins, httpie_plugins_success, dummy_ ) +def test_plugins_upgrade(httpie_plugins, httpie_plugins_success, dummy_plugin): + httpie_plugins_success("install", dummy_plugin.path) + + # Make a new version of the plugin + dummy_plugin.version = '2.0.0' + dummy_plugin.build() + + httpie_plugins_success("upgrade", dummy_plugin.path) + data = parse_listing(httpie_plugins_success('list')) + assert data[dummy_plugin.name]['version'] == '2.0.0' + + def test_broken_plugins(httpie_plugins, httpie_plugins_success, dummy_plugin, broken_plugin): httpie_plugins_success("install", dummy_plugin.path, broken_plugin.path)