You've already forked httpie-cli
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:
0
httpie/manager/__init__.py
Normal file
0
httpie/manager/__init__.py
Normal file
61
httpie/manager/__main__.py
Normal file
61
httpie/manager/__main__.py
Normal 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
93
httpie/manager/cli.py
Normal 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
33
httpie/manager/core.py
Normal 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
188
httpie/manager/plugins.py
Normal 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
|
Reference in New Issue
Block a user