1
0
mirror of https://github.com/httpie/cli.git synced 2026-04-24 19:53:55 +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
+7
View File
@@ -5,6 +5,13 @@ import pytest
from pytest_httpbin import certs
from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT
from .utils.plugins_cli import ( # noqa
dummy_plugin,
dummy_plugins,
httpie_plugins,
httpie_plugins_success,
interface,
)
from .utils.http_server import http_server # noqa
+132
View File
@@ -0,0 +1,132 @@
import pytest
from httpie.status import ExitStatus
from tests.utils import httpie
from tests.utils.plugins_cli import parse_listing
def test_plugins_installation(httpie_plugins_success, interface, dummy_plugin):
lines = httpie_plugins_success('install', dummy_plugin.path)
assert lines[0].startswith(
f'Installing {dummy_plugin.path}'
)
assert f'Successfully installed {dummy_plugin.name}-{dummy_plugin.version}' in lines
assert interface.is_installed(dummy_plugin.name)
def test_plugins_listing(httpie_plugins_success, interface, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
data = parse_listing(httpie_plugins_success('list'))
assert data == {
dummy_plugin.name: dummy_plugin.dump()
}
def test_plugins_listing_multiple(interface, httpie_plugins_success, dummy_plugins):
paths = [plugin.path for plugin in dummy_plugins]
httpie_plugins_success('install', *paths)
data = parse_listing(httpie_plugins_success('list'))
assert data == {
plugin.name: plugin.dump()
for plugin in dummy_plugins
}
def test_plugins_uninstall(interface, httpie_plugins_success, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
httpie_plugins_success('uninstall', dummy_plugin.name)
assert not interface.is_installed(dummy_plugin.name)
def test_plugins_listing_after_uninstall(interface, httpie_plugins_success, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
httpie_plugins_success('uninstall', dummy_plugin.name)
data = parse_listing(httpie_plugins_success('list'))
assert len(data) == 0
def test_plugins_uninstall_specific(interface, httpie_plugins_success):
new_plugin_1 = interface.make_dummy_plugin()
new_plugin_2 = interface.make_dummy_plugin()
target_plugin = interface.make_dummy_plugin()
httpie_plugins_success('install', new_plugin_1.path, new_plugin_2.path, target_plugin.path)
httpie_plugins_success('uninstall', target_plugin.name)
assert interface.is_installed(new_plugin_1.name)
assert interface.is_installed(new_plugin_2.name)
assert not interface.is_installed(target_plugin.name)
def test_plugins_installation_failed(httpie_plugins, interface):
plugin = interface.make_dummy_plugin(build=False)
result = httpie_plugins('install', plugin.path)
assert result.exit_status == ExitStatus.ERROR
assert result.stderr.splitlines()[-1].strip().startswith("Can't install")
def test_plugins_uninstall_non_existent(httpie_plugins, interface):
plugin = interface.make_dummy_plugin(build=False)
result = httpie_plugins('uninstall', plugin.name)
assert result.exit_status == ExitStatus.ERROR
assert (
result.stderr.splitlines()[-1].strip()
== f"Can't uninstall '{plugin.name}': package is not installed"
)
def test_plugins_double_uninstall(httpie_plugins, httpie_plugins_success, dummy_plugin):
httpie_plugins_success("install", dummy_plugin.path)
httpie_plugins_success("uninstall", dummy_plugin.name)
result = httpie_plugins("uninstall", dummy_plugin.name)
assert result.exit_status == ExitStatus.ERROR
assert (
result.stderr.splitlines()[-1].strip()
== f"Can't uninstall '{dummy_plugin.name}': package is not installed"
)
def test_plugins_cli_error_message_without_args():
# No arguments
result = httpie(no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert 'specify one of these' in result.stderr
assert 'please use the http/https commands:' in result.stderr
@pytest.mark.parametrize(
'example', [
'pie.dev/get',
'DELETE localhost:8000/delete',
'POST pie.dev/post header:value a=b header_2:value x:=1'
]
)
def test_plugins_cli_error_messages_with_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' in result.stderr
assert f'https {example}' in result.stderr
@pytest.mark.parametrize(
'example', [
'plugins unknown',
'plugins unknown.com A:B c=d',
'unknown.com UNPARSABLE????SYNTAX',
]
)
def test_plugins_cli_error_messages_invalid_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' not in result.stderr
assert f'https {example}' not in result.stderr
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest_httpbin.certs
import requests.exceptions
import urllib3
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.status import ExitStatus
from .utils import HTTP_OK, TESTS_ROOT, http
+47 -5
View File
@@ -7,12 +7,14 @@ import json
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Optional, Union, List
from typing import Any, Optional, Union, List, Iterable
import httpie.core as core
import httpie.manager.__main__ as manager
from httpie.status import ExitStatus
from httpie.config import Config
from httpie.context import Environment
from httpie.core import main
# pytest-httpbin currently does not support chunked requests:
@@ -58,10 +60,10 @@ class MockEnvironment(Environment):
stdout_isatty = True
is_windows = False
def __init__(self, create_temp_config_dir=True, **kwargs):
def __init__(self, create_temp_config_dir=True, *, stdout_mode='b', **kwargs):
if 'stdout' not in kwargs:
kwargs['stdout'] = tempfile.TemporaryFile(
mode='w+b',
mode=f'w+{stdout_mode}',
prefix='httpie_stdout'
)
if 'stderr' not in kwargs:
@@ -177,6 +179,46 @@ class ExitStatusError(Exception):
pass
def normalize_args(args: Iterable[Any]) -> List[str]:
return [str(arg) for arg in args]
def httpie(
*args,
**kwargs
) -> StrCLIResponse:
"""
Run HTTPie manager command with the given
args/kwargs, and capture stderr/out and exit
status.
"""
env = kwargs.setdefault('env', MockEnvironment())
cli_args = ['httpie']
if not kwargs.pop('no_debug', False):
cli_args.append('--debug')
cli_args += normalize_args(args)
exit_status = manager.main(
args=cli_args,
**kwargs
)
env.stdout.seek(0)
env.stderr.seek(0)
try:
response = StrCLIResponse(env.stdout.read())
response.stderr = env.stderr.read()
response.exit_status = exit_status
response.args = cli_args
finally:
env.stdout.truncate(0)
env.stderr.truncate(0)
env.stdout.seek(0)
env.stderr.seek(0)
return response
def http(
*args,
program_name='http',
@@ -254,7 +296,7 @@ def http(
try:
try:
exit_status = main(args=complete_args, **kwargs)
exit_status = core.main(args=complete_args, **kwargs)
if '--download' in args:
# Let the progress reporter thread finish.
time.sleep(.5)
+222
View File
@@ -0,0 +1,222 @@
import secrets
import site
import sys
import textwrap
import pytest
from collections import defaultdict
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, List, Dict, Tuple
from unittest.mock import patch
from httpie.context import Environment
from httpie.compat import importlib_metadata
from httpie.status import ExitStatus
from httpie.plugins.manager import (
enable_plugins,
ENTRY_POINT_CLASSES as CLASSES,
)
def make_name() -> str:
return 'httpie-' + secrets.token_hex(4)
@dataclass
class EntryPoint:
name: str
group: str
def dump(self) -> Dict[str, str]:
return asdict(self)
@dataclass
class Plugin:
interface: 'Interface'
name: str = field(default_factory=make_name)
version: str = '1.0.0'
entry_points: List[EntryPoint] = field(default_factory=list)
def build(self) -> None:
'''
Create an installable dummy plugin at the given path.
It will create a setup.py with the specified entry points,
as well as dummy classes in a python module to imitate
real plugins.
'''
groups = defaultdict(list)
for entry_point in self.entry_points:
groups[entry_point.group].append(entry_point.name)
setup_eps = {
group: [
f'{name} = {self.import_name}:{name.title()}'
for name in names
]
for group, names in groups.items()
}
self.path.mkdir(parents=True, exist_ok=True)
with open(self.path / 'setup.py', 'w') as stream:
stream.write(textwrap.dedent(f'''
from setuptools import setup
setup(
name='{self.name}',
version='{self.version}',
py_modules=['{self.import_name}'],
entry_points={setup_eps!r},
install_requires=['httpie']
)
'''))
with open(self.path / (self.import_name + '.py'), 'w') as stream:
stream.write('from httpie.plugins import *\n')
stream.writelines(
f'class {name.title()}({CLASSES[group].__name__}): ...\n'
for group, names in groups.items()
for name in names
)
def dump(self) -> Dict[str, Any]:
return {
'version': self.version,
'entry_points': [
entry_point.dump()
for entry_point in self.entry_points
]
}
@property
def path(self) -> Path:
return self.interface.path / self.name
@property
def import_name(self) -> str:
return self.name.replace('-', '_')
@dataclass
class Interface:
path: Path
environment: Environment
def get_plugin(self, target: str) -> importlib_metadata.Distribution:
with enable_plugins(self.environment.config.plugins_dir):
return importlib_metadata.distribution(target)
def is_installed(self, target: str) -> bool:
try:
self.get_plugin(target)
except ModuleNotFoundError:
return False
else:
return True
def make_dummy_plugin(self, build=True, **kwargs) -> Plugin:
kwargs.setdefault('entry_points', [EntryPoint('test', 'httpie.plugins.auth.v1')])
plugin = Plugin(self, **kwargs)
if build:
plugin.build()
return plugin
def parse_listing(lines: List[str]) -> Dict[str, Any]:
plugins = {}
current_plugin = None
def parse_entry_point(line: str) -> Tuple[str, str]:
entry_point, raw_group = line.strip().split()
return entry_point, raw_group[1:-1]
def parse_plugin(line: str) -> Tuple[str, str]:
plugin, raw_version = line.strip().split()
return plugin, raw_version[1:-1]
for line in lines:
if not line.strip():
continue
if line[0].isspace():
# <indent> $entry_point ($group)
assert current_plugin is not None
entry_point, group = parse_entry_point(line)
plugins[current_plugin]['entry_points'].append({
'name': entry_point,
'group': group
})
else:
# $plugin ($version)
current_plugin, version = parse_plugin(line)
plugins[current_plugin] = {
'version': version,
'entry_points': []
}
return plugins
@pytest.fixture(scope='function')
def interface(tmp_path):
from tests.utils import MockEnvironment
return Interface(
path=tmp_path / 'interface',
environment=MockEnvironment(stdout_mode='t')
)
@pytest.fixture(scope='function')
def dummy_plugin(interface):
return interface.make_dummy_plugin()
@pytest.fixture(scope='function')
def dummy_plugins(interface):
# Multiple plugins with different configurations
return [
interface.make_dummy_plugin(),
interface.make_dummy_plugin(
version='3.2.0'
),
interface.make_dummy_plugin(
entry_points=[
EntryPoint('test_1', 'httpie.plugins.converter.v1'),
EntryPoint('test_2', 'httpie.plugins.formatter.v1')
]
),
]
@pytest.fixture
def httpie_plugins(interface):
from tests.utils import httpie
from httpie.plugins.registry import plugin_manager
def runner(*args):
# Prevent installed plugins from showing up.
original_plugins = plugin_manager.copy()
clean_sys_path = set(sys.path).difference(site.getsitepackages())
with patch('sys.path', list(clean_sys_path)):
response = httpie('plugins', *args, env=interface.environment)
plugin_manager.clear()
plugin_manager.extend(original_plugins)
return response
return runner
@pytest.fixture
def httpie_plugins_success(httpie_plugins):
def runner(*args):
response = httpie_plugins(*args)
assert response.exit_status == ExitStatus.SUCCESS
return response.splitlines()
return runner