1
0
mirror of https://github.com/httpie/cli.git synced 2025-08-10 22:42:05 +02:00

cmd: Implement httpie plugins interface (#1200)

This commit is contained in:
Batuhan Taskaya
2021-11-30 11:12:51 +03:00
committed by GitHub
parent 6bdcdf1eba
commit 245cede2c2
23 changed files with 1075 additions and 62 deletions

View File

View File

@@ -0,0 +1,61 @@
import argparse
import sys
from typing import List, Union
from httpie.context import Environment
from httpie.status import ExitStatus
from httpie.manager.cli import parser
from httpie.manager.core import MSG_COMMAND_CONFUSION, program as main_program
def is_http_command(args: List[Union[str, bytes]], env: Environment) -> bool:
"""Check whether http/https parser can parse the arguments."""
from httpie.cli.definition import parser as http_parser
from httpie.manager.cli import COMMANDS
# If the user already selected a top-level sub-command, never
# show the http/https version. E.g httpie plugins pie.dev/post
if len(args) >= 1 and args[0] in COMMANDS:
return False
with env.as_silent():
try:
http_parser.parse_args(env=env, args=args)
except (Exception, SystemExit):
return False
else:
return True
def main(args: List[Union[str, bytes]] = sys.argv, env: Environment = Environment()) -> ExitStatus:
from httpie.core import raw_main
try:
return raw_main(
parser=parser,
main_program=main_program,
args=args,
env=env
)
except argparse.ArgumentError:
program_args = args[1:]
if is_http_command(program_args, env):
env.stderr.write(MSG_COMMAND_CONFUSION.format(args=' '.join(program_args)) + "\n")
return ExitStatus.ERROR
def program():
try:
exit_status = main()
except KeyboardInterrupt:
from httpie.status import ExitStatus
exit_status = ExitStatus.ERROR_CTRL_C
return exit_status
if __name__ == '__main__': # pragma: nocover
sys.exit(program())

93
httpie/manager/cli.py Normal file
View File

@@ -0,0 +1,93 @@
from textwrap import dedent
from httpie.cli.argparser import HTTPieManagerArgumentParser
COMMANDS = {
'plugins': {
'help': 'Manage HTTPie plugins.',
'install': [
'Install the given targets from PyPI '
'or from a local paths.',
{
'dest': 'targets',
'nargs': '+',
'help': 'targets to install'
}
],
'uninstall': [
'Uninstall the given HTTPie plugins.',
{
'dest': 'targets',
'nargs': '+',
'help': 'targets to install'
}
],
'list': [
'List all installed HTTPie plugins.'
],
},
}
def missing_subcommand(*args) -> str:
base = COMMANDS
for arg in args:
base = base[arg]
assert isinstance(base, dict)
subcommands = ', '.join(map(repr, base.keys()))
return f'Please specify one of these: {subcommands}'
def generate_subparsers(root, parent_parser, definitions):
action_dest = '_'.join(parent_parser.prog.split()[1:] + ['action'])
actions = parent_parser.add_subparsers(
dest=action_dest
)
for command, properties in definitions.items():
is_subparser = isinstance(properties, dict)
descr = properties.pop('help', None) if is_subparser else properties.pop(0)
command_parser = actions.add_parser(command, description=descr)
command_parser.root = root
if is_subparser:
generate_subparsers(root, command_parser, properties)
continue
for argument in properties:
command_parser.add_argument(**argument)
parser = HTTPieManagerArgumentParser(
prog='httpie',
description=dedent(
'''
Managing interface for the HTTPie itself. <https://httpie.io/docs#manager>
Be aware that you might be looking for http/https commands for sending
HTTP requests. This command is only available for managing the HTTTPie
plugins and the configuration around it.
'''
),
)
parser.add_argument(
'--debug',
action='store_true',
default=False,
help='''
Prints the exception traceback should one occur, as well as other
information useful for debugging HTTPie itself and for reporting bugs.
'''
)
parser.add_argument(
'--traceback',
action='store_true',
default=False,
help='''
Prints the exception traceback should one occur.
'''
)
generate_subparsers(parser, parser, COMMANDS)

33
httpie/manager/core.py Normal file
View File

@@ -0,0 +1,33 @@
import argparse
from httpie.context import Environment
from httpie.manager.plugins import PluginInstaller
from httpie.status import ExitStatus
from httpie.manager.cli import missing_subcommand, parser
MSG_COMMAND_CONFUSION = '''\
This command is only for managing HTTPie plugins.
To send a request, please use the http/https commands:
$ http {args}
$ https {args}
'''
# noinspection PyStringFormat
MSG_NAKED_INVOCATION = f'''\
{missing_subcommand()}
{MSG_COMMAND_CONFUSION}
'''.rstrip("\n").format(args='POST pie.dev/post hello=world')
def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
if args.action is None:
parser.error(MSG_NAKED_INVOCATION)
if args.action == 'plugins':
plugins = PluginInstaller(env, debug=args.debug)
return plugins.run(args.plugins_action, args)
return ExitStatus.SUCCESS

188
httpie/manager/plugins.py Normal file
View File

@@ -0,0 +1,188 @@
import argparse
import os
import subprocess
import sys
import textwrap
from collections import defaultdict
from contextlib import suppress
from pathlib import Path
from typing import Optional, List
import importlib_metadata
from httpie.manager.cli import parser, missing_subcommand
from httpie.compat import get_dist_name
from httpie.context import Environment
from httpie.status import ExitStatus
class PluginInstaller:
def __init__(self, env: Environment, debug: bool = False) -> None:
self.env = env
self.dir = env.config.plugins_dir
self.debug = debug
self.setup_plugins_dir()
def setup_plugins_dir(self) -> None:
try:
self.dir.mkdir(
exist_ok=True,
parents=True
)
except OSError:
self.env.stderr.write(
f'Couldn\'t create "{self.dir!s}"'
' directory for plugin installation.'
' Please re-check the permissions for that directory,'
' and if needed, allow write-access.'
)
raise
def fail(
self,
command: str,
target: Optional[str] = None,
reason: Optional[str] = None
) -> ExitStatus:
message = f'Can\'t {command}'
if target:
message += f' {target!r}'
if reason:
message += f': {reason}'
self.env.stderr.write(message + '\n')
return ExitStatus.ERROR
def pip(self, *args, **kwargs) -> subprocess.CompletedProcess:
options = {
'check': True,
'shell': False,
'stdout': self.env.stdout,
'stderr': subprocess.PIPE,
}
options.update(kwargs)
cmd = [sys.executable, '-m', 'pip', *args]
return subprocess.run(
cmd,
**options
)
def install(self, targets: List[str]) -> Optional[ExitStatus]:
self.env.stdout.write(f"Installing {', '.join(targets)}...\n")
self.env.stdout.flush()
try:
self.pip(
'install',
f'--prefix={self.dir}',
'--no-warn-script-location',
*targets,
)
except subprocess.CalledProcessError as error:
reason = None
if error.stderr:
stderr = error.stderr.decode()
if self.debug:
self.env.stderr.write('Command failed: ')
self.env.stderr.write(' '.join(error.cmd) + '\n')
self.env.stderr.write(textwrap.indent(' ', stderr))
last_line = stderr.strip().splitlines()[-1]
severity, _, message = last_line.partition(': ')
if severity == 'ERROR':
reason = message
return self.fail('install', ', '.join(targets), reason)
def _uninstall(self, target: str) -> Optional[ExitStatus]:
try:
distribution = importlib_metadata.distribution(target)
except importlib_metadata.PackageNotFoundError:
return self.fail('uninstall', target, 'package is not installed')
base_dir = Path(distribution.locate_file('.')).resolve()
if self.dir not in base_dir.parents:
# If the package is installed somewhere else (e.g on the site packages
# of the real python interpreter), than that means this package is not
# installed through us.
return self.fail('uninstall', target,
'package is not installed through httpie plugins'
' interface')
files = distribution.files
if files is None:
return self.fail('uninstall', target, 'couldn\'t locate the package')
# TODO: Consider handling failures here (e.g if it fails,
# just rever the operation and leave the site-packages
# in a proper shape).
for file in files:
with suppress(FileNotFoundError):
os.unlink(distribution.locate_file(file))
metadata_path = getattr(distribution, '_path', None)
if (
metadata_path
and metadata_path.exists()
and not any(metadata_path.iterdir())
):
metadata_path.rmdir()
self.env.stdout.write(f'Successfully uninstalled {target}\n')
def uninstall(self, targets: List[str]) -> ExitStatus:
# Unfortunately uninstall doesn't work with custom pip schemes. See:
# - https://github.com/pypa/pip/issues/5595
# - https://github.com/pypa/pip/issues/4575
# so we have to implement our own uninstalling logic. Which works
# on top of the importlib_metadata.
exit_code = ExitStatus.SUCCESS
for target in targets:
exit_code |= self._uninstall(target) or ExitStatus.SUCCESS
return ExitStatus(exit_code)
def list(self) -> None:
from httpie.plugins.registry import plugin_manager
known_plugins = defaultdict(list)
for entry_point in plugin_manager.iter_entry_points(self.dir):
ep_info = (entry_point.group, entry_point.name)
ep_name = get_dist_name(entry_point) or entry_point.module
known_plugins[ep_name].append(ep_info)
for plugin, entry_points in known_plugins.items():
self.env.stdout.write(plugin)
version = importlib_metadata.version(plugin)
if version is not None:
self.env.stdout.write(f' ({version})')
self.env.stdout.write('\n')
for group, entry_point in sorted(entry_points):
self.env.stdout.write(f' {entry_point} ({group})\n')
def run(
self,
action: Optional[str],
args: argparse.Namespace,
) -> ExitStatus:
from httpie.plugins.manager import enable_plugins
if action is None:
parser.error(missing_subcommand('plugins'))
with enable_plugins(self.dir):
if action == 'install':
status = self.install(args.targets)
elif action == 'uninstall':
status = self.uninstall(args.targets)
elif action == 'list':
status = self.list()
return status or ExitStatus.SUCCESS