├── tests ├── __init__.py ├── commands │ ├── __init__.py │ ├── test_read_commands.py │ ├── test_write_commands.py │ ├── test_sse.py │ ├── test_completion.py │ ├── test_download.py │ └── test_helpers.py ├── conftest.py ├── test_did_you_mean.py ├── helpers.py ├── test_default_command_behaviour.py ├── test_options.py ├── test_configuration.py ├── test_root_cli_options.py ├── test_parameters.py └── test_helpers.py ├── httpcli ├── __init__.py ├── commands │ ├── __init__.py │ ├── read_commands.py │ ├── completion.py │ ├── sse.py │ ├── write_commands.py │ ├── download.py │ └── helpers.py ├── version.py ├── types.py ├── console.py ├── configuration.py ├── models.py ├── http.py ├── did_you_mean.py ├── https.py ├── options.py ├── parameters.py └── helpers.py ├── .gitignore ├── pyproject.toml ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /httpcli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /httpcli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /httpcli/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.0' 2 | -------------------------------------------------------------------------------- /httpcli/types.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union, List 2 | 3 | HttpProperty = Union[Tuple[Tuple[str, str], ...], List[Tuple[str, str]]] 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asyncclick.testing import CliRunner 3 | 4 | 5 | @pytest.fixture() 6 | def runner(): 7 | """CLI test runner""" 8 | return CliRunner() 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .pytest_cache 3 | .coverage 4 | htmlcov 5 | .nox 6 | .python-version 7 | coverage.xml 8 | 9 | # byte-compiled / optimized 10 | __pycache__ 11 | *.pyc 12 | *.egg-info 13 | 14 | # packaging / distribution 15 | site 16 | dist 17 | build -------------------------------------------------------------------------------- /httpcli/console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.style import Style 3 | from rich.theme import Theme 4 | 5 | custom_theme = Theme({ 6 | 'error': Style(color='red'), 7 | 'warning': Style(color='yellow'), 8 | 'info': Style(color='blue') 9 | }) 10 | 11 | console = Console(theme=custom_theme) 12 | -------------------------------------------------------------------------------- /tests/test_did_you_mean.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from httpcli.http import http 4 | from httpcli.https import https 5 | 6 | 7 | @pytest.mark.parametrize('command', [http, https]) 8 | async def test_should_print_suggestion_when_user_makes_typo(runner, command): 9 | result = await runner.invoke(command, ['gett', 'https://example.com']) 10 | 11 | assert result.exit_code == 2 12 | assert '\n\nDid you mean one of these?\n' in result.output 13 | assert ' • get' in result.output 14 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import anyio 4 | from starlette.applications import Starlette 5 | from starlette.responses import StreamingResponse 6 | from starlette.routing import Route 7 | 8 | 9 | async def number_generator(): 10 | i = 0 11 | while True: 12 | yield 'event: number\n' 13 | yield f'data: {json.dumps({"number": i})}\n' 14 | yield '\n\n' 15 | i += 1 16 | await anyio.sleep(1) 17 | 18 | 19 | async def sse(_): 20 | headers = {'cache-control': 'no-cache'} 21 | return StreamingResponse(number_generator(), headers=headers, media_type='text/event-stream') 22 | 23 | 24 | app = Starlette(routes=[Route('/sse', sse)]) 25 | -------------------------------------------------------------------------------- /httpcli/configuration.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional, Union, Any 3 | 4 | from pydantic import BaseSettings, AnyHttpUrl, validator, FilePath 5 | from typing_extensions import Literal 6 | 7 | from .models import BasicAuth, DigestAuth, OAuth2PasswordBearer 8 | 9 | 10 | class Configuration(BaseSettings): 11 | proxy: Optional[AnyHttpUrl] = None 12 | version: Literal['h1', 'h2'] = 'h1' 13 | auth: Optional[Union[BasicAuth, DigestAuth, OAuth2PasswordBearer]] = None 14 | follow_redirects: bool = True 15 | verify: Union[bool, FilePath] = True 16 | timeout: Optional[float] = 5.0 17 | 18 | @validator('auth', pre=True) 19 | def convert_str_to_dict(cls, value: Any) -> Any: 20 | if isinstance(value, str): 21 | try: 22 | return json.loads(value) 23 | except json.JSONDecodeError: 24 | raise ValueError(f'{value} is not a valid json string') 25 | return value 26 | 27 | class Config: 28 | env_prefix = 'http_cli_' 29 | -------------------------------------------------------------------------------- /httpcli/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, List 3 | 4 | from pydantic import BaseModel, validator, AnyHttpUrl 5 | from typing_extensions import Literal 6 | 7 | 8 | class UrlModel(BaseModel): 9 | url: AnyHttpUrl 10 | 11 | @validator('url', pre=True) 12 | def check_shortcut_url(cls, value: str) -> Any: 13 | if re.match(r'^:\d+', value): 14 | return f'http://localhost{value}' 15 | return value 16 | 17 | 18 | class Auth(BaseModel): 19 | type: str 20 | 21 | 22 | class UserMixin(BaseModel): 23 | username: str 24 | password: str 25 | 26 | 27 | class BasicAuth(UserMixin, Auth): 28 | type: Literal['basic'] = 'basic' 29 | 30 | 31 | class DigestAuth(UserMixin, Auth): 32 | type: Literal['digest'] = 'digest' 33 | 34 | 35 | class OAuth2(Auth): 36 | type: Literal['oauth2'] = 'oauth2' 37 | flow: str 38 | 39 | 40 | class OAuth2PasswordBearer(UserMixin, OAuth2): 41 | token_url: AnyHttpUrl 42 | flow: Literal['password'] = 'password' 43 | scopes: List[str] = [] 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "httpcli" 3 | version = "0.1.0" 4 | description = "A simple HTTP CLI" 5 | authors = ["le_woudar "] 6 | license = "MIT" 7 | 8 | packages = [ 9 | { include = "httpcli" } 10 | ] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.7" 14 | rich = "^10.7.0" 15 | asyncclick = "^8.0.1" 16 | pydantic = { version = "^1.8.2", extras = ["dotenv"] } 17 | httpx = { version = "^0.19.0", extras = ["http2"] } 18 | PyYAML = "^5.4.1" 19 | uvloop = { version = "^0.16.0", markers = "sys_platform != 'win32'" } 20 | shellingham = "^1.4.0" 21 | 22 | [tool.poetry.dev-dependencies] 23 | starlette = "^0.16.0" 24 | pytest = "^6.2.4" 25 | pytest-trio = "^0.7.0" 26 | flake8 = "^3.9.2" 27 | bandit = "^1.7.0" 28 | respx = "^0.17.1" 29 | pytest-cov = "^2.12.1" 30 | pytest-mock = "^3.6.1" 31 | mock = "^4.0.3" 32 | Hypercorn = "^0.11.2" 33 | 34 | [tool.poetry.scripts] 35 | http = "httpcli.http:http" 36 | https = "httpcli.https:https" 37 | 38 | [tool.pytest.ini_options] 39 | testpaths = ["tests"] 40 | addopts = "--cov=httpcli --cov-report html --cov-report xml" 41 | trio_mode = true 42 | 43 | [build-system] 44 | requires = ["poetry-core>=1.0.0"] 45 | build-backend = "poetry.core.masonry.api" 46 | -------------------------------------------------------------------------------- /tests/test_default_command_behaviour.py: -------------------------------------------------------------------------------- 1 | """In this module we test command behaviour to all http/https subcommands""" 2 | import anyio 3 | import httpx 4 | import pytest 5 | 6 | from httpcli.http import http 7 | from httpcli.https import https 8 | 9 | 10 | @pytest.mark.parametrize('method', ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'POST', 'PATCH', 'PUT']) 11 | @pytest.mark.parametrize('command', [http, https]) 12 | async def test_should_print_error_when_request_timeout_expired(runner, respx_mock, autojump_clock, method, command): 13 | async def side_effect(_): 14 | await anyio.sleep(6) 15 | 16 | respx_mock.route(method=method, host='example.com').mock(side_effect=side_effect) 17 | result = await runner.invoke(command, [method.lower(), 'https://example.com']) 18 | 19 | assert result.exit_code == 1 20 | assert result.output == 'the request timeout has expired\nAborted!\n' 21 | 22 | 23 | @pytest.mark.parametrize('method', ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'POST', 'PATCH', 'PUT']) 24 | @pytest.mark.parametrize('command', [http, https]) 25 | async def test_should_print_error_when_unexpected_httpx_error_happened(runner, respx_mock, method, command): 26 | respx_mock.route(method=method, host='example.com').mock(side_effect=httpx.TransportError('just a test error')) 27 | result = await runner.invoke(command, [method.lower(), 'https://example.com']) 28 | 29 | assert result.exit_code == 1 30 | assert result.output == 'unexpected error: just a test error\nAborted!\n' 31 | -------------------------------------------------------------------------------- /httpcli/http.py: -------------------------------------------------------------------------------- 1 | from typing import TextIO 2 | 3 | import asyncclick as click 4 | from pydantic import AnyHttpUrl 5 | 6 | from .commands.completion import install_completion 7 | from .commands.download import download 8 | from .commands.read_commands import get, head, options 9 | from .commands.sse import sse 10 | from .commands.write_commands import delete, post, put, patch 11 | from .configuration import Configuration 12 | from .did_you_mean import DYMGroup 13 | from .helpers import load_config_from_yaml, set_configuration_options 14 | from .models import Auth 15 | from .options import global_cli_options 16 | from .version import __version__ 17 | 18 | 19 | @click.version_option(__version__, message='%(prog)s version %(version)s') 20 | @click.group(cls=DYMGroup) 21 | @global_cli_options 22 | @click.pass_context 23 | def http( 24 | context: click.Context, 25 | proxy: AnyHttpUrl, 26 | http_version: str, 27 | auth: Auth, 28 | follow_redirects: bool, 29 | timeout: float, 30 | config_file: TextIO 31 | ): 32 | """HTTP CLI""" 33 | if config_file: 34 | config = load_config_from_yaml(config_file) 35 | config.verify = False 36 | context.obj = config 37 | return 38 | config = context.ensure_object(Configuration) 39 | set_configuration_options(config, proxy, http_version, auth, follow_redirects, timeout, verify=False) 40 | 41 | 42 | # add subcommands 43 | for command in [get, post, put, patch, delete, head, options, download, sse, install_completion]: 44 | http.add_command(command) # type: ignore 45 | -------------------------------------------------------------------------------- /httpcli/did_you_mean.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | import asyncclick as click 4 | 5 | 6 | class DYMMixin: 7 | """ 8 | Mixin class for click MultiCommand inherited classes to provide git-like *did-you-mean* functionality when 9 | a certain command is not registered. 10 | """ 11 | 12 | def __init__(self, *args, **kwargs): 13 | self.max_suggestions = kwargs.pop('max_suggestions', 3) 14 | self.cutoff = kwargs.pop('cutoff', 0.5) 15 | super(DYMMixin, self).__init__(*args, **kwargs) 16 | 17 | async def resolve_command(self, ctx, args): 18 | """ 19 | Overrides asyncclick resolve_command method and appends *Did you mean ...* suggestions 20 | to the raised exception message. 21 | """ 22 | try: 23 | return await super(DYMMixin, self).resolve_command(ctx, args) 24 | except click.UsageError as error: 25 | error_message = str(error) 26 | original_cmd_name = click.utils.make_str(args[0]) 27 | matches = difflib.get_close_matches( 28 | original_cmd_name, self.list_commands(ctx), self.max_suggestions, self.cutoff 29 | ) 30 | 31 | if matches: 32 | error_message += f'\n\nDid you mean one of these?\n' 33 | for command_name in matches: 34 | error_message += f' • {command_name}\n' 35 | 36 | raise click.UsageError(error_message[:-1], error.ctx) 37 | 38 | 39 | class DYMGroup(DYMMixin, click.Group): 40 | """ 41 | click Group to provide git-like *did-you-mean* functionality when a certain 42 | command is not found in the group. 43 | """ 44 | -------------------------------------------------------------------------------- /httpcli/https.py: -------------------------------------------------------------------------------- 1 | from typing import TextIO 2 | 3 | import asyncclick as click 4 | from pydantic import AnyHttpUrl 5 | 6 | from .commands.completion import install_completion 7 | from .commands.download import download 8 | from .commands.read_commands import get, head, options 9 | from .commands.sse import sse 10 | from .commands.write_commands import delete, post, put, patch 11 | from .configuration import Configuration 12 | from .did_you_mean import DYMGroup 13 | from .helpers import load_config_from_yaml, set_configuration_options 14 | from .models import Auth 15 | from .options import global_cli_options 16 | from .version import __version__ 17 | 18 | 19 | @click.version_option(__version__, message='%(prog)s version %(version)s') 20 | @click.group(cls=DYMGroup) 21 | @global_cli_options 22 | @click.option( 23 | '--cert', 24 | help='Path to certificate used to authenticate hosts.', 25 | type=click.Path(exists=True, dir_okay=False) 26 | ) 27 | @click.pass_context 28 | def https( 29 | context: click.Context, 30 | proxy: AnyHttpUrl, 31 | http_version: str, 32 | auth: Auth, 33 | follow_redirects: bool, 34 | timeout: float, 35 | config_file: TextIO, 36 | cert: str, 37 | ): 38 | """HTTP CLI with certificate validation.""" 39 | if config_file: 40 | config = load_config_from_yaml(config_file) 41 | if cert: 42 | config.verify = cert 43 | context.obj = config 44 | return 45 | 46 | config = context.ensure_object(Configuration) 47 | set_configuration_options(config, proxy, http_version, auth, follow_redirects, timeout, verify=cert or True) 48 | 49 | 50 | # add subcommands 51 | for command in [get, post, put, patch, delete, head, options, download, sse, install_completion]: 52 | https.add_command(command) # type: ignore 53 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | import asyncclick as click 2 | 3 | from httpcli.models import DigestAuth 4 | from httpcli.options import global_cli_options, http_query_options, http_write_options 5 | 6 | 7 | @click.command() 8 | @global_cli_options 9 | def debug_global_options(proxy, http_version, auth, follow_redirects, timeout, config_file): 10 | click.echo(proxy) 11 | click.echo(http_version) 12 | click.echo(auth) 13 | click.echo(follow_redirects) 14 | click.echo(timeout) 15 | click.echo(config_file) 16 | 17 | 18 | @click.command() 19 | @http_query_options 20 | def debug_http_query_options(query_params, headers, cookies): 21 | click.echo(query_params) 22 | click.echo(headers) 23 | click.echo(cookies) 24 | 25 | 26 | @click.command() 27 | @http_write_options 28 | def debug_http_write_options(form, json_data, raw): 29 | click.echo(form) 30 | click.echo(json_data) 31 | click.echo(raw) 32 | 33 | 34 | async def test_global_cli_options_is_correctly_formed(runner): 35 | auth = DigestAuth(username='user', password='pass') 36 | proxy = 'http://proxy.com' 37 | arguments = ['--http-version', 'h2', '--auth', auth.json(), '--proxy', proxy, '-N', '-t', 3] 38 | result = await runner.invoke(debug_global_options, arguments) 39 | 40 | assert result.exit_code == 0 41 | assert result.output == f'{proxy}\nh2\n{auth}\nFalse\n3.0\n\n' 42 | 43 | 44 | async def test_http_query_options_is_correctly_formed(runner): 45 | arguments = ['--header', 'foo:bar', '--cookie', 'foo:bar', '--query', 'foo:bar'] 46 | result = await runner.invoke(debug_http_query_options, arguments) 47 | 48 | assert result.exit_code == 0 49 | assert result.output == f'{(("foo", "bar"),)}\n' * 3 50 | 51 | 52 | async def test_http_write_options_is_correctly_formed(runner): 53 | arguments = ['-f', 'foo:bar', '-j', 'foo:bar', '-r', 'pineapple'] 54 | result = await runner.invoke(debug_http_write_options, arguments) 55 | foo_tuple = (('foo', 'bar'),) 56 | 57 | assert result.exit_code == 0 58 | assert result.output == f'{foo_tuple}\n{foo_tuple}\npineapple\n' 59 | -------------------------------------------------------------------------------- /tests/commands/test_read_commands.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | 4 | from httpcli.http import http 5 | from httpcli.https import https 6 | 7 | 8 | @pytest.mark.parametrize('command', [http, https]) 9 | async def test_should_get_print_response_in_normal_cases(runner, respx_mock, command): 10 | data = '

Hello world

' 11 | respx_mock.get('https://example.com') % httpx.Response(status_code=200, html=data) 12 | result = await runner.invoke(command, ['get', 'https://example.com']) 13 | 14 | assert result.exit_code == 0 15 | output = result.output 16 | assert 'content-type: text/html; charset=utf-8' in output 17 | assert f'content-length: {len(data)}' in output 18 | assert '

Hello' in output 19 | assert 'world

' in output 20 | 21 | 22 | @pytest.mark.parametrize('command', [http, https]) 23 | async def test_should_print_head_response_in_normal_cases(runner, respx_mock, command): 24 | headers = { 25 | 'content-encoding': 'gzip', 26 | 'accept-ranges': 'bytes', 27 | 'content-length': '648' 28 | } 29 | respx_mock.head('https://example.com') % httpx.Response(status_code=200, headers=headers) 30 | result = await runner.invoke(command, ['head', 'https://example.com']) 31 | 32 | assert result.exit_code == 0 33 | output = result.output 34 | assert 'HTTP/1.1 200 OK' in output 35 | assert 'content-encoding: gzip' in output 36 | assert 'accept-ranges: bytes' in output 37 | assert 'content-length: 648' in output 38 | 39 | 40 | @pytest.mark.parametrize('command', [http, https]) 41 | async def test_should_print_options_response_in_normal_cases(runner, respx_mock, command): 42 | headers = { 43 | 'allow': 'OPTIONS, GET, HEAD, POST', 44 | 'content-type': 'text/html; charset=utf-8', 45 | 'server': 'EOS (vny/0452)' 46 | } 47 | respx_mock.options('https://example.com') % httpx.Response(status_code=200, headers=headers) 48 | result = await runner.invoke(command, ['options', 'https://example.com']) 49 | 50 | assert result.exit_code == 0 51 | output = result.output 52 | assert 'HTTP/1.1 200 OK' in output 53 | assert 'allow: OPTIONS, GET, HEAD, POST' in output 54 | assert 'content-type: text/html; charset=utf-8' in output 55 | assert 'server: EOS (vny/0452)' in output 56 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pydantic 4 | import pytest 5 | 6 | from httpcli.configuration import Configuration, BasicAuth, DigestAuth, OAuth2PasswordBearer 7 | 8 | 9 | def test_default_configuration(): 10 | config = Configuration() 11 | assert config.proxy is None 12 | assert config.version == 'h1' 13 | assert config.auth is None 14 | assert config.follow_redirects is True 15 | assert config.verify is True 16 | assert config.timeout == 5.0 17 | 18 | 19 | @pytest.mark.parametrize('proxy_url', ['http://proxy.com', 'https://proxy.com']) 20 | def test_proxy_configuration(proxy_url): 21 | config = Configuration(proxy=proxy_url) 22 | assert config.proxy == proxy_url 23 | 24 | 25 | @pytest.mark.parametrize(('auth_config', 'auth_type'), [ 26 | ({'type': 'basic', 'username': 'foo', 'password': 'bar'}, BasicAuth), 27 | ({'type': 'digest', 'username': 'foo', 'password': 'bar'}, DigestAuth), 28 | ({'type': 'oauth2', 'token_url': 'https://token.com', 'flow': 'password', 'username': 'foo', 'password': 'bar'}, 29 | OAuth2PasswordBearer), 30 | ]) 31 | def test_auth_configurations(auth_config, auth_type): 32 | config = Configuration(auth=auth_config) 33 | assert isinstance(config.auth, auth_type) 34 | assert config.auth.type in ['basic', 'digest', 'oauth2'] 35 | assert config.auth.username == 'foo' 36 | assert config.auth.password == 'bar' 37 | 38 | 39 | def test_environment_config(monkeypatch): 40 | monkeypatch.setenv('http_cli_version', 'h2') 41 | monkeypatch.setenv('http_cli_proxy', 'http://proxy.com') 42 | monkeypatch.setenv('HTTP_CLI_FOLLOW_REDIRECTS', 'false') 43 | monkeypatch.setenv('HTTP_CLI_AUTH', json.dumps({'type': 'basic', 'username': 'foo', 'password': 'bar'})) 44 | 45 | config = Configuration() 46 | assert config.version == 'h2' 47 | assert config.proxy.host == 'proxy.com' 48 | assert config.follow_redirects is False 49 | assert isinstance(config.auth, BasicAuth) 50 | 51 | 52 | def test_config_raises_error_when_auth_is_not_valid_json(monkeypatch): 53 | monkeypatch.setenv('http_cli_auth', str({'type': 'basic', 'username': 'foo', 'password': 'bar'})) 54 | with pytest.raises(pydantic.ValidationError) as exc_info: 55 | Configuration() 56 | 57 | assert 'is not a valid json string' in str(exc_info.value) 58 | -------------------------------------------------------------------------------- /httpcli/commands/read_commands.py: -------------------------------------------------------------------------------- 1 | import anyio 2 | import asyncclick as click 3 | from pydantic import AnyHttpUrl 4 | 5 | from httpcli.configuration import Configuration 6 | from httpcli.options import http_query_options 7 | from httpcli.parameters import URL 8 | from httpcli.types import HttpProperty 9 | from .helpers import perform_read_request, function_runner, signal_handler 10 | 11 | 12 | @click.command() 13 | @click.argument('url', type=URL) 14 | @http_query_options 15 | @click.pass_obj 16 | async def get( 17 | config: Configuration, url: AnyHttpUrl, headers: HttpProperty, query_params: HttpProperty, cookies: HttpProperty 18 | ): 19 | """ 20 | Performs http GET request. 21 | 22 | URL is the target url. 23 | """ 24 | async with anyio.create_task_group() as tg: 25 | tg.start_soon( 26 | function_runner, tg.cancel_scope, perform_read_request, 'GET', str(url), config, headers, query_params, 27 | cookies 28 | ) 29 | tg.start_soon(signal_handler, tg.cancel_scope) 30 | 31 | 32 | @click.command() 33 | @click.argument('url', type=URL) 34 | @http_query_options 35 | @click.pass_obj 36 | async def head( 37 | config: Configuration, url: AnyHttpUrl, headers: HttpProperty, query_params: HttpProperty, cookies: HttpProperty 38 | ): 39 | """ 40 | Performs http HEAD request. 41 | 42 | URL is the target url. 43 | """ 44 | async with anyio.create_task_group() as tg: 45 | tg.start_soon( 46 | function_runner, tg.cancel_scope, perform_read_request, 'HEAD', str(url), config, headers, query_params, 47 | cookies 48 | ) 49 | tg.start_soon(signal_handler, tg.cancel_scope) 50 | 51 | 52 | @click.command() 53 | @click.argument('url', type=URL) 54 | @http_query_options 55 | @click.pass_obj 56 | async def options( 57 | config: Configuration, url: AnyHttpUrl, headers: HttpProperty, query_params: HttpProperty, cookies: HttpProperty 58 | ): 59 | """ 60 | Performs http OPTIONS request. 61 | 62 | URL is the target url. 63 | """ 64 | async with anyio.create_task_group() as tg: 65 | tg.start_soon( 66 | function_runner, tg.cancel_scope, perform_read_request, 'OPTIONS', str(url), config, headers, query_params, 67 | cookies 68 | ) 69 | tg.start_soon(signal_handler, tg.cancel_scope) 70 | -------------------------------------------------------------------------------- /tests/commands/test_write_commands.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | 4 | from httpcli.http import http 5 | from httpcli.https import https 6 | 7 | 8 | @pytest.mark.parametrize('command', [http, https]) 9 | async def test_should_print_delete_response_in_normal_cases(runner, respx_mock, command): 10 | respx_mock.delete('https://foo.com') % 204 11 | result = await runner.invoke(command, ['delete', 'https://foo.com']) 12 | 13 | assert result.exit_code == 0 14 | assert 'HTTP/1.1 204 No Content' in result.output 15 | 16 | 17 | @pytest.mark.parametrize('method', ['POST', 'PATCH', 'PUT']) 18 | @pytest.mark.parametrize(('mock_argument', 'cli_argument'), [ 19 | ({'data': {'foo': 'bar', 'hello': 'world'}}, ['-f', 'foo:bar', '-f', 'hello:world']), 20 | ({'json': {'foo': 'bar', 'hello': 'world'}}, ['-j', 'foo:bar', '-j', 'hello:world']), 21 | ({'content': b'pineapple'}, ['-r', 'pineapple']) 22 | ]) 23 | @pytest.mark.parametrize('command', [http, https]) 24 | async def test_should_print_response_in_normal_cases(runner, respx_mock, method, mock_argument, cli_argument, command): 25 | respx_mock.route(method=method, host='pie.dev', **mock_argument) % dict(json={'hello': 'world'}) 26 | result = await runner.invoke(command, [method.lower(), 'https://pie.dev', *cli_argument]) 27 | 28 | assert result.exit_code == 0 29 | output = result.output 30 | lines = [ 31 | 'content-length: 18', 32 | 'content-type: application/json', 33 | '{', 34 | 'hello', 35 | 'world', 36 | '}' 37 | ] 38 | for line in lines: 39 | assert line in output 40 | 41 | 42 | @pytest.mark.parametrize('method', ['POST', 'PUT', 'PATCH']) 43 | @pytest.mark.parametrize('command', [http, https]) 44 | async def test_should_print_response_when_sending_file(runner, tmp_path, respx_mock, method, command): 45 | async def side_effect(request: httpx.Request) -> httpx.Response: 46 | assert request.headers['content-type'].startswith('multipart/form-data') 47 | return httpx.Response(200, json={'hello': 'world'}) 48 | 49 | path = tmp_path / 'file.txt' 50 | path.write_text('hello') 51 | respx_mock.route(method=method, host='pie.dev').mock(side_effect=side_effect) 52 | result = await runner.invoke(command, [method.lower(), 'https://pie.dev', '-f', f'file:@{path}']) 53 | 54 | assert result.exit_code == 0 55 | output = result.output 56 | lines = [ 57 | 'content-length: 18', 58 | 'content-type: application/json', 59 | '{', 60 | 'hello', 61 | 'world', 62 | '}' 63 | ] 64 | for line in lines: 65 | assert line in output 66 | -------------------------------------------------------------------------------- /httpcli/commands/completion.py: -------------------------------------------------------------------------------- 1 | import subprocess # nosec 2 | from pathlib import Path 3 | 4 | import asyncclick as click 5 | import shellingham 6 | 7 | from httpcli.console import console 8 | 9 | SHELLS = ['bash', 'zsh', 'fish'] 10 | 11 | 12 | def install_bash_zsh(bash: bool = True) -> None: 13 | home = Path.home() 14 | completion_dir = home / '.cli_completions' 15 | if bash: 16 | shell = 'bash' 17 | shell_config_file = home / '.bashrc' 18 | else: 19 | shell = 'zsh' 20 | shell_config_file = home / '.zshrc' 21 | 22 | if not completion_dir.exists(): 23 | completion_dir.mkdir() 24 | 25 | for cli in ['http', 'https']: 26 | try: 27 | command = f'_{cli.upper()}_COMPLETE={shell}_source {cli}' 28 | # bandit complains for shell injection, but we are not using untrusted string here, so it is fine. 29 | result = subprocess.run(command, shell=True, capture_output=True, check=True) # nosec 30 | except subprocess.CalledProcessError: 31 | console.print(f'[error]unable to get completion script for {cli} cli.') 32 | raise click.Abort() 33 | 34 | completion_script = completion_dir / f'{cli}-complete.{shell}' 35 | completion_script.write_text(result.stdout.decode()) 36 | 37 | with shell_config_file.open('a') as f: 38 | f.write(f'\n. {completion_script.absolute()}\n') 39 | 40 | 41 | def install_fish() -> None: 42 | home = Path.home() 43 | completion_dir = home / '.config/fish/completions' 44 | if not completion_dir.exists(): 45 | completion_dir.mkdir(parents=True) 46 | 47 | for cli in ['http', 'https']: 48 | try: 49 | command = f'_{cli.upper()}_COMPLETE=fish_source {cli}' 50 | # bandit complains for shell injection, but we are not using untrusted string here, so it is fine. 51 | result = subprocess.run(command, shell=True, capture_output=True, check=True) # nosec 52 | except subprocess.CalledProcessError: 53 | console.print(f'[error]unable to get completion script for {cli} cli.') 54 | raise click.Abort() 55 | 56 | completion_script = completion_dir / f'{cli}.fish' 57 | completion_script.write_text(result.stdout.decode()) 58 | 59 | 60 | def _install_completion(shell: str) -> None: 61 | if shell == 'bash': 62 | install_bash_zsh() 63 | elif shell == 'zsh': 64 | install_bash_zsh(bash=False) 65 | else: 66 | install_fish() 67 | 68 | 69 | @click.command('install-completion') 70 | def install_completion(): 71 | """ 72 | Install completion script for bash, zsh and fish shells. 73 | You will need to restart the shell for the changes to be loaded. 74 | You don't need to it twice for "http" and "https" cli. Doing it one one will install the other. 75 | """ 76 | try: 77 | shell, _ = shellingham.detect_shell() 78 | except shellingham.ShellDetectionFailure: 79 | console.print('[error]unable to detect the current shell') 80 | raise click.Abort() 81 | except RuntimeError as e: 82 | click.echo(f'[error]{e}') 83 | raise click.Abort() 84 | 85 | if shell not in SHELLS: 86 | console.print(f'[error]Your shell is not supported. Shells supported are: {", ".join(SHELLS)}') 87 | raise click.Abort() 88 | 89 | _install_completion(shell) 90 | -------------------------------------------------------------------------------- /httpcli/commands/sse.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import anyio 5 | import asyncclick as click 6 | import httpx 7 | from rich.markup import escape 8 | from rich.syntax import Syntax 9 | 10 | from httpcli.commands.helpers import print_response_headers, function_runner, signal_handler 11 | from httpcli.configuration import Configuration 12 | from httpcli.console import console 13 | from httpcli.helpers import build_base_httpx_arguments 14 | from httpcli.parameters import URL 15 | 16 | 17 | async def handle_sse(config: Configuration, url: str) -> None: 18 | event_regex = re.compile(r'event:\s*(.+)') 19 | data_regex = re.compile(r'data:\s*(.+)') 20 | arguments = build_base_httpx_arguments(config) 21 | allow_redirects = arguments.pop('allow_redirects') 22 | try: 23 | async with httpx.AsyncClient(**arguments) as client: 24 | async with client.stream('GET', url, allow_redirects=allow_redirects) as response: 25 | if 300 < response.status_code < 400: 26 | console.print('[warning]the request was interrupted because redirection was not followed') 27 | raise click.Abort() 28 | 29 | elif response.status_code >= 400: 30 | await response.aread() 31 | console.print(f'[error]unexpected error: {response.text}') 32 | raise click.Abort() 33 | 34 | else: 35 | print_response_headers(response) 36 | console.print() 37 | async for line in response.aiter_lines(): 38 | line = line.strip() 39 | if not line: 40 | continue 41 | match = event_regex.match(line) 42 | if match: 43 | console.print(f'[blue]event:[/] [green]{escape(match.group(1))}[/]') 44 | else: 45 | match = data_regex.match(line) 46 | if match: 47 | try: 48 | line = match.group(1) 49 | data = json.loads(line) 50 | console.print(Syntax(json.dumps(data, indent=4), 'json')) 51 | except json.JSONDecodeError: 52 | # we print the line as it if it is not a json string 53 | console.print(line) 54 | else: 55 | console.print(line) 56 | console.print() 57 | except httpx.HTTPError as e: 58 | console.print(f'[error]unexpected error: {e}') 59 | raise click.Abort() 60 | 61 | 62 | @click.command() 63 | @click.argument('url', type=URL) 64 | @click.pass_obj 65 | # well, technically url is not a str but a pydantic.AnyHttpUrl object inheriting from str 66 | # but it does not seem to bother httpx, so we can use the convenient str for signature 67 | async def sse(config: Configuration, url: str): 68 | """ 69 | Reads and print SSE events on a given url. 70 | 71 | URL is the url where SSE events will be read. 72 | """ 73 | async with anyio.create_task_group() as tg: 74 | tg.start_soon(function_runner, tg.cancel_scope, handle_sse, config, url) 75 | tg.start_soon(signal_handler, tg.cancel_scope) 76 | -------------------------------------------------------------------------------- /httpcli/commands/write_commands.py: -------------------------------------------------------------------------------- 1 | import anyio 2 | import asyncclick as click 3 | from pydantic import AnyHttpUrl 4 | 5 | from httpcli.configuration import Configuration 6 | from httpcli.options import http_query_options, http_write_options 7 | from httpcli.parameters import URL 8 | from httpcli.types import HttpProperty 9 | from .helpers import perform_read_request, perform_write_request, function_runner, signal_handler 10 | 11 | 12 | @click.command() 13 | @click.argument('url', type=URL) 14 | @http_query_options 15 | @click.pass_obj 16 | async def delete( 17 | config: Configuration, url: AnyHttpUrl, headers: HttpProperty, query_params: HttpProperty, cookies: HttpProperty 18 | ): 19 | """ 20 | Performs http DELETE request. 21 | 22 | URL is the target url. 23 | """ 24 | async with anyio.create_task_group() as tg: 25 | tg.start_soon( 26 | function_runner, tg.cancel_scope, perform_read_request, 'DELETE', str(url), config, headers, query_params, 27 | cookies 28 | ) 29 | tg.start_soon(signal_handler, tg.cancel_scope) 30 | 31 | 32 | @click.command() 33 | @click.argument('url', type=URL) 34 | @http_query_options 35 | @http_write_options 36 | @click.pass_obj 37 | async def post( 38 | config: Configuration, 39 | url: AnyHttpUrl, headers: HttpProperty, 40 | query_params: HttpProperty, 41 | cookies: HttpProperty, 42 | form: HttpProperty, 43 | json_data: HttpProperty, 44 | raw: bytes 45 | ): 46 | """ 47 | Performs http POST request. 48 | 49 | URL is the target url. 50 | """ 51 | async with anyio.create_task_group() as tg: 52 | tg.start_soon( 53 | function_runner, tg.cancel_scope, perform_write_request, 'POST', str(url), config, headers, query_params, 54 | cookies, form, json_data, raw 55 | ) 56 | tg.start_soon(signal_handler, tg.cancel_scope) 57 | 58 | 59 | @click.command() 60 | @click.argument('url', type=URL) 61 | @http_query_options 62 | @http_write_options 63 | @click.pass_obj 64 | async def patch( 65 | config: Configuration, 66 | url: AnyHttpUrl, headers: HttpProperty, 67 | query_params: HttpProperty, 68 | cookies: HttpProperty, 69 | form: HttpProperty, 70 | json_data: HttpProperty, 71 | raw: bytes 72 | ): 73 | """ 74 | Performs http PATCH request. 75 | 76 | URL is the target url. 77 | """ 78 | async with anyio.create_task_group() as tg: 79 | tg.start_soon( 80 | function_runner, tg.cancel_scope, perform_write_request, 'PATCH', str(url), config, headers, query_params, 81 | cookies, form, json_data, raw 82 | ) 83 | tg.start_soon(signal_handler, tg.cancel_scope) 84 | 85 | 86 | @click.command() 87 | @click.argument('url', type=URL) 88 | @http_query_options 89 | @http_write_options 90 | @click.pass_obj 91 | async def put( 92 | config: Configuration, 93 | url: AnyHttpUrl, headers: HttpProperty, 94 | query_params: HttpProperty, 95 | cookies: HttpProperty, 96 | form: HttpProperty, 97 | json_data: HttpProperty, 98 | raw: bytes 99 | ): 100 | """ 101 | Performs http PUT request. 102 | 103 | URL is the target url. 104 | """ 105 | async with anyio.create_task_group() as tg: 106 | tg.start_soon( 107 | function_runner, tg.cancel_scope, perform_write_request, 'PUT', str(url), config, headers, query_params, 108 | cookies, form, json_data, raw 109 | ) 110 | tg.start_soon(signal_handler, tg.cancel_scope) 111 | -------------------------------------------------------------------------------- /tests/test_root_cli_options.py: -------------------------------------------------------------------------------- 1 | import asyncclick as click 2 | import pytest 3 | 4 | from httpcli.configuration import Configuration 5 | from httpcli.http import http 6 | from httpcli.https import https 7 | from httpcli.models import OAuth2PasswordBearer, BasicAuth 8 | 9 | 10 | @click.command() 11 | @click.pass_obj 12 | def debug(obj: Configuration): 13 | click.echo(obj) 14 | 15 | 16 | http.add_command(debug) 17 | https.add_command(debug) 18 | 19 | YAML_DATA = """ 20 | httpcli: 21 | version: h2 22 | proxy: https://proxy.com 23 | timeout: null 24 | auth: 25 | type: basic 26 | username: foo 27 | password: bar 28 | """ 29 | 30 | command_parametrize = pytest.mark.parametrize(('command', 'verify'), [ 31 | (http, False), 32 | (https, True) 33 | ]) 34 | 35 | 36 | @command_parametrize 37 | async def test_should_print_default_configuration(runner, command, verify): 38 | result = await runner.invoke(command, ['debug']) 39 | config = Configuration(verify=verify) 40 | 41 | assert result.exit_code == 0 42 | assert result.output == f'{config}\n' 43 | 44 | 45 | @command_parametrize 46 | async def test_should_print_correct_configuration_with_env_variables_set(monkeypatch, runner, command, verify): 47 | follow_redirects = False 48 | proxy_url = 'https://proxy.com' 49 | monkeypatch.setenv('http_cli_follow_redirects', str(follow_redirects)) 50 | monkeypatch.setenv('http_cli_proxy', proxy_url) 51 | monkeypatch.setenv('http_cli_timeout', '3') 52 | config = Configuration(follow_redirects=follow_redirects, proxy=proxy_url, verify=verify, timeout=3) # type: ignore 53 | result = await runner.invoke(command, ['debug']) 54 | 55 | assert result.exit_code == 0 56 | assert result.output == f'{config}\n' 57 | 58 | 59 | @command_parametrize 60 | async def test_should_print_correct_configuration_with_given_configuration_file(runner, tmp_path, command, verify): 61 | config_file = tmp_path / 'config.yaml' 62 | config_file.write_text(YAML_DATA) 63 | auth = BasicAuth(username='foo', password='bar') 64 | config = Configuration(proxy='https://proxy.com', version='h2', timeout=None, auth=auth, verify=verify) 65 | result = await runner.invoke(command, ['--config-file', f'{config_file}', '--http-version', 'h1', 'debug']) 66 | 67 | assert result.exit_code == 0 68 | assert result.output == f'{config}\n' 69 | 70 | 71 | @command_parametrize 72 | async def test_should_print_correct_information_given_user_input(runner, command, verify): 73 | auth = OAuth2PasswordBearer(username='foo', password='bar', token_url='http://token.com') # type: ignore 74 | http_version = 'h2' 75 | config = Configuration(version=http_version, auth=auth, verify=verify, timeout=None) # type: ignore 76 | result = await runner.invoke(command, ['--auth', auth.json(), '--http-version', http_version, '-t', -1, 'debug']) 77 | 78 | assert result.exit_code == 0 79 | assert result.output == f'{config}\n' 80 | 81 | 82 | # https cert option tests 83 | 84 | async def test_should_print_error_when_given_certificate_does_not_exist(runner): 85 | file = '/path/to/file' 86 | result = await runner.invoke(https, ['--cert', file, 'debug']) 87 | 88 | assert result.exit_code == 2 89 | assert f"'{file}' does not exist" in result.output 90 | 91 | 92 | async def test_should_print_correct_info_when_given_certificate_exist(runner, tmp_path): 93 | path = tmp_path / 'cert.pem' 94 | path.write_text('fake certificate') 95 | config = Configuration() 96 | config.verify = str(path) 97 | result = await runner.invoke(https, ['--cert', str(path), 'debug']) 98 | 99 | assert result.exit_code == 0 100 | assert result.output == f'{config}\n' 101 | -------------------------------------------------------------------------------- /httpcli/options.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar, Any 2 | 3 | import asyncclick as click 4 | 5 | from .parameters import AUTH_PARAM, URL, HEADER, COOKIE, QUERY, FORM, JSON, RAW_PAYLOAD 6 | 7 | # copying this from click code 8 | FC = TypeVar("FC", Callable[..., Any], click.Command) 9 | 10 | 11 | def proxy_option(f: FC) -> FC: 12 | return click.option('--proxy', type=URL, help='Proxy url.')(f) 13 | 14 | 15 | def http_version_option(f: FC) -> FC: 16 | return click.option( 17 | '--http-version', 18 | type=click.Choice(['h1', 'h2']), 19 | help='Version of http used to make the request.', 20 | )(f) 21 | 22 | 23 | def auth_option(f: FC) -> FC: 24 | return click.option('--auth', type=AUTH_PARAM, help='A json string representing authentication information.')(f) 25 | 26 | 27 | def follow_redirects_option(f: FC) -> FC: 28 | return click.option( 29 | '--follow-redirects/--no-follow-redirects', ' /-N', 30 | help='flag to decide if http redirections must be followed', 31 | default=None 32 | )(f) 33 | 34 | 35 | def timeout_option(f: FC) -> FC: 36 | return click.option( 37 | '-t', '--timeout', 38 | type=float, 39 | help='Time for request to complete, a negative value means there is no timeout.' 40 | )(f) 41 | 42 | 43 | def config_file_option(f: FC) -> FC: 44 | return click.option( 45 | '--config-file', 46 | type=click.File(), 47 | help='A configuration file with options used to set the cli. ' 48 | 'Note that the file takes precedence over the other options.' 49 | )(f) 50 | 51 | 52 | def global_cli_options(f: FC) -> FC: 53 | options = [ 54 | proxy_option, http_version_option, auth_option, follow_redirects_option, timeout_option, config_file_option 55 | ] 56 | for callable_option in options: 57 | f = callable_option(f) 58 | return f 59 | 60 | 61 | def query_option(f: FC) -> FC: 62 | return click.option( 63 | '-q', '--query', 'query_params', 64 | type=QUERY, 65 | multiple=True, 66 | help='Querystring argument passed to the request, can by passed multiple times.' 67 | )(f) 68 | 69 | 70 | def header_option(f: FC) -> FC: 71 | return click.option( 72 | '-H', '--header', 'headers', 73 | type=HEADER, 74 | multiple=True, 75 | help='Header passed to the request, can by passed multiple times.' 76 | )(f) 77 | 78 | 79 | def cookie_option(f: FC) -> FC: 80 | return click.option( 81 | '-c', '--cookie', 'cookies', 82 | type=COOKIE, 83 | multiple=True, 84 | help='Cookie passed to the request, can by passed multiple times.' 85 | )(f) 86 | 87 | 88 | def http_query_options(f: FC) -> FC: 89 | for option in [query_option, header_option, cookie_option]: 90 | f = option(f) 91 | return f 92 | 93 | 94 | def form_option(f: FC) -> FC: 95 | return click.option( 96 | '-f', '--form', 97 | type=FORM, 98 | multiple=True, 99 | help='Form data passed to the request, can be passed multiple times.' 100 | )(f) 101 | 102 | 103 | def json_option(f: FC) -> FC: 104 | return click.option( 105 | '-j', '--json', 'json_data', 106 | type=JSON, 107 | multiple=True, 108 | help='Json data passed to the request, can be passed multiple times.' 109 | )(f) 110 | 111 | 112 | def raw_payload_option(f: FC) -> FC: 113 | return click.option( 114 | '-r', '--raw', 115 | type=RAW_PAYLOAD, 116 | help='Raw data passed to the request. It cannot be used with --json and --form options.' 117 | )(f) 118 | 119 | 120 | def http_write_options(f: FC) -> FC: 121 | for option in [form_option, json_option, raw_payload_option]: 122 | f = option(f) 123 | 124 | return f 125 | -------------------------------------------------------------------------------- /httpcli/parameters.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as t 3 | from pathlib import Path 4 | 5 | import asyncclick as click 6 | from pydantic import ValidationError, AnyHttpUrl 7 | 8 | from .configuration import Configuration 9 | from .models import Auth 10 | from .models import UrlModel 11 | 12 | 13 | class AuthParam(click.ParamType): 14 | name = 'json_auth' 15 | 16 | def convert(self, value: t.Any, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context]) -> Auth: 17 | try: 18 | auth_info = json.loads(value) 19 | except json.JSONDecodeError: 20 | self.fail(f'{value} is not a valid json string') 21 | 22 | try: 23 | config = Configuration(auth=auth_info) 24 | return config.auth 25 | except ValidationError: 26 | self.fail('authentication information is not valid') 27 | 28 | 29 | class UrlParam(click.ParamType): 30 | name = 'url' 31 | 32 | def convert(self, value: t.Any, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context]) -> AnyHttpUrl: 33 | try: 34 | url_model = UrlModel(url=value) 35 | return url_model.url 36 | except ValidationError: 37 | self.fail(f'{value} is not a valid url') 38 | 39 | 40 | class HTTPParameter(click.ParamType): 41 | 42 | def convert( 43 | self, value: str, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context] 44 | ) -> t.Tuple[str, str]: 45 | parts = value.split(':') 46 | if len(parts) != 2: 47 | self.fail(f'{value} is not in the form key:value') 48 | return parts[0], parts[1] 49 | 50 | 51 | class QueryParam(HTTPParameter): 52 | name = 'query' 53 | 54 | 55 | class CookieParam(HTTPParameter): 56 | name = 'cookie' 57 | 58 | 59 | class HeaderParam(HTTPParameter): 60 | name = 'header' 61 | 62 | 63 | class FormParam(HTTPParameter): 64 | name = 'form' 65 | 66 | def convert( 67 | self, value: str, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context] 68 | ) -> t.Tuple[str, str]: 69 | field_name, field_value = super().convert(value, param, ctx) 70 | 71 | if field_value.startswith('@'): 72 | path = Path(field_value[1:]) 73 | if not path.is_file(): 74 | self.fail(f'{field_value[1:]} file does not exist') 75 | 76 | return field_name, field_value 77 | 78 | 79 | class JsonParam(HTTPParameter): 80 | name = 'json' 81 | 82 | def convert( 83 | self, value: str, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context] 84 | ) -> t.Tuple[str, str]: 85 | field_name, field_value = super().convert(value, param, ctx) 86 | 87 | if field_value.startswith('@'): 88 | path = Path(field_value[1:]) 89 | if not path.is_file(): 90 | self.fail(f'{field_value[1:]} file does not exist') 91 | with path.open() as f: 92 | try: 93 | field_value = json.load(f) 94 | except json.JSONDecodeError: 95 | self.fail(f'{field_value[1:]} is not a valid json file') 96 | elif field_value.startswith('='): 97 | try: 98 | field_value = json.loads(field_value[1:]) 99 | except json.JSONDecodeError: 100 | self.fail(f'{field_value} is not a valid json value') 101 | 102 | return field_name, field_value 103 | 104 | 105 | class RawPayloadParam(click.ParamType): 106 | name = 'RAW_PAYLOAD' 107 | 108 | def convert( 109 | self, value: str, param: t.Optional[click.Parameter], ctx: t.Optional[click.Context] 110 | ) -> bytes: 111 | if value.startswith('@'): 112 | path = Path(value[1:]) 113 | if not path.is_file(): 114 | self.fail(f'{value[1:]} file does not exist') 115 | 116 | return path.read_bytes() 117 | return value.encode() 118 | 119 | 120 | AUTH_PARAM = AuthParam() 121 | URL = UrlParam() 122 | QUERY = QueryParam() 123 | COOKIE = CookieParam() 124 | HEADER = HeaderParam() 125 | FORM = FormParam() 126 | JSON = JsonParam() 127 | RAW_PAYLOAD = RawPayloadParam() 128 | -------------------------------------------------------------------------------- /httpcli/commands/download.py: -------------------------------------------------------------------------------- 1 | import mailbox 2 | import mimetypes 3 | from pathlib import Path 4 | from typing import IO, Tuple, Set, List 5 | 6 | import anyio 7 | import asyncclick as click 8 | import httpx 9 | from pydantic import BaseModel, AnyHttpUrl, ValidationError 10 | from rich.progress import Progress, TaskID 11 | 12 | from httpcli.commands.helpers import function_runner, signal_handler 13 | from httpcli.configuration import Configuration 14 | from httpcli.console import console 15 | from httpcli.helpers import build_base_httpx_arguments 16 | from httpcli.parameters import URL 17 | 18 | 19 | def get_filename_from_content_disposition(response: httpx.Response) -> str: 20 | disposition = response.headers.get('content-disposition') 21 | 22 | if disposition is None: 23 | return '' 24 | 25 | message = mailbox.Message(f'content-disposition: {disposition}') 26 | return message.get_filename(failobj='') 27 | 28 | 29 | def get_filename_from_url(response: httpx.Response) -> str: 30 | url = response.request.url 31 | filename = url.path.split('/')[-1] 32 | 33 | if Path(filename).suffix: 34 | return filename 35 | 36 | content_type = response.headers.get('content-type') 37 | if content_type is None: 38 | return filename 39 | 40 | extension = mimetypes.guess_extension(content_type) 41 | if extension is None: 42 | return filename 43 | 44 | return f'{filename.rstrip(".")}{extension}' 45 | 46 | 47 | def get_filename(response: httpx.Response) -> str: 48 | filename = get_filename_from_content_disposition(response) 49 | if filename: 50 | return filename 51 | 52 | return get_filename_from_url(response) 53 | 54 | 55 | class FileModel(BaseModel): 56 | urls: List[AnyHttpUrl] 57 | 58 | 59 | def get_urls_from_file(file: IO[str]) -> Set[str]: 60 | urls = [line.strip() for line in file] 61 | 62 | try: 63 | FileModel(urls=urls) # type: ignore 64 | except ValidationError as e: 65 | raise click.UsageError(str(e)) 66 | 67 | return set(urls) 68 | 69 | 70 | async def download_file( 71 | client: httpx.AsyncClient, 72 | url: str, 73 | allow_redirects: bool, 74 | destination: Path, 75 | progress: Progress, 76 | task_id: TaskID 77 | ) -> None: 78 | try: 79 | response = await client.get(url, allow_redirects=allow_redirects) 80 | filename = get_filename(response) 81 | if response.status_code >= 300: # we take in account cases where users deny redirects 82 | progress.console.print(f':cross_mark: {url} ({filename})') 83 | progress.update(task_id, advance=1) 84 | else: 85 | path = destination / filename 86 | path.write_bytes(response.content) 87 | progress.console.print(f':white_heavy_check_mark: {url} ({filename})') 88 | progress.update(task_id, advance=1) 89 | except httpx.HTTPError as e: 90 | progress.console.print(f'[error]unable to fetch {url}, reason: {e}') 91 | progress.update(task_id, advance=1) 92 | 93 | 94 | async def handle_downloads(config: Configuration, destination: str, file: IO[str], url: Tuple[str, ...]) -> None: 95 | urls = set(url) 96 | if file: 97 | other_urls = get_urls_from_file(file) 98 | urls = urls.union(other_urls) 99 | 100 | destination = Path(destination) if destination else Path.cwd() 101 | arguments = build_base_httpx_arguments(config) 102 | allow_redirects = arguments.pop('allow_redirects') 103 | 104 | with Progress(console=console) as progress: 105 | task_id = progress.add_task('Downloading', total=len(urls)) 106 | async with httpx.AsyncClient(**arguments) as client: 107 | async with anyio.create_task_group() as tg: 108 | for url in urls: 109 | tg.start_soon(download_file, client, url, allow_redirects, destination, progress, task_id) 110 | 111 | console.print('[info]Downloads completed! :glowing_star:') 112 | 113 | 114 | @click.command() 115 | @click.option( 116 | '-d', '--destination', 117 | help='The directory where downloaded files will be saved. If not provided, default to the current directory.', 118 | type=click.Path(exists=True, file_okay=False) 119 | ) 120 | @click.option( 121 | '-f', '--file', 122 | help='File containing one url per line. Each url corresponds to a file to download.', 123 | type=click.File() 124 | ) 125 | @click.argument('url', type=URL, nargs=-1) 126 | @click.pass_obj 127 | # well, technically url is not a str but a pydantic.AnyHttpUrl object inheriting from str 128 | # but it does not seem to bother httpx, so we can use the convenient str for signature 129 | async def download(config: Configuration, destination: str, file: IO[str], url: Tuple[str, ...]): 130 | """ 131 | Process download of urls given as arguments. 132 | 133 | URL is an url targeting a file to download. It can be passed multiple times. 134 | 135 | You can combine url arguments with --file option. 136 | """ 137 | async with anyio.create_task_group() as tg: 138 | tg.start_soon(function_runner, tg.cancel_scope, handle_downloads, config, destination, file, url) 139 | tg.start_soon(signal_handler, tg.cancel_scope) 140 | -------------------------------------------------------------------------------- /tests/commands/test_sse.py: -------------------------------------------------------------------------------- 1 | import json 2 | import signal 3 | import subprocess 4 | import sys 5 | from typing import AsyncIterator 6 | 7 | import httpx 8 | import pytest 9 | import trio 10 | from hypercorn.config import Config 11 | from hypercorn.trio import serve 12 | 13 | from httpcli.http import http 14 | from httpcli.https import https 15 | from tests.helpers import app 16 | 17 | command_parametrize = pytest.mark.parametrize('command', [http, https]) 18 | 19 | 20 | @command_parametrize 21 | async def test_should_print_error_when_redirection_is_not_allowed(runner, respx_mock, command): 22 | url = 'https://foo.com/sse' 23 | respx_mock.get(url) % 307 24 | result = await runner.invoke(command, ['sse', url]) 25 | 26 | assert result.exit_code == 1 27 | lines = [ 28 | 'the request was interrupted because redirection was not followed', 29 | 'Aborted!' 30 | ] 31 | assert result.output == '\n'.join(lines) + '\n' 32 | 33 | 34 | @command_parametrize 35 | async def test_should_print_error_when_request_failed(runner, respx_mock, command): 36 | url = 'https://foo.com/sse' 37 | message = 'request failed' 38 | respx_mock.get(url) % {'status_code': 400, 'text': message} 39 | result = await runner.invoke(command, ['sse', url]) 40 | 41 | assert result.exit_code == 1 42 | lines = [ 43 | f'unexpected error: {message}', 44 | 'Aborted!' 45 | ] 46 | assert result.output == '\n'.join(lines) + '\n' 47 | 48 | 49 | @command_parametrize 50 | async def test_should_print_error_when_unexpected_error_happened(runner, respx_mock, command): 51 | url = 'https://foo.com/sse' 52 | message = 'just a test error' 53 | respx_mock.get(url).mock(side_effect=httpx.TransportError(message)) 54 | result = await runner.invoke(command, ['sse', url]) 55 | 56 | assert result.exit_code == 1 57 | lines = [ 58 | f'unexpected error: {message}', 59 | 'Aborted!' 60 | ] 61 | assert result.output == '\n'.join(lines) + '\n' 62 | 63 | 64 | @command_parametrize 65 | async def test_should_print_correct_output_for_json_data(runner, respx_mock, command): 66 | class NumberStream(httpx.AsyncByteStream): 67 | 68 | async def __aiter__(self) -> AsyncIterator[bytes]: 69 | for number in range(1, 6): 70 | yield b'event: number\n' 71 | yield f'data: {json.dumps({"number": number})}\n'.encode() 72 | yield b'\n\n' 73 | 74 | url = 'https://foo.com/sse' 75 | headers = {'content-type': 'text/event-stream'} 76 | respx_mock.get(url) % httpx.Response(200, headers=headers, stream=NumberStream()) 77 | result = await runner.invoke(command, ['sse', url]) 78 | 79 | assert result.exit_code == 0 80 | output = result.output 81 | assert 'HTTP/1.1 200 OK' in output 82 | assert 'content-type: text/event-stream' in output 83 | assert 'event: number' in output 84 | assert '{' in output 85 | assert '"number"' in output 86 | assert '}' in output 87 | for i in range(1, 6): 88 | assert str(i) in output 89 | 90 | 91 | @command_parametrize 92 | @pytest.mark.parametrize(('data', 'expected_output'), [ 93 | (b'data: hello world\n', 'hello world'), 94 | (b'hello world\n', 'hello world') 95 | ]) 96 | async def test_should_print_correct_output_for_non_json_data(runner, respx_mock, command, data, expected_output): 97 | class HelloStream(httpx.AsyncByteStream): 98 | 99 | async def __aiter__(self) -> AsyncIterator[bytes]: 100 | yield b'event: hello\n' 101 | yield data 102 | yield b'\n\n' 103 | 104 | url = 'https://foo.com/sse' 105 | headers = {'content-type': 'text/event-stream'} 106 | respx_mock.get(url) % httpx.Response(200, headers=headers, stream=HelloStream()) 107 | result = await runner.invoke(command, ['sse', url]) 108 | 109 | assert result.exit_code == 0 110 | output = result.output 111 | assert 'HTTP/1.1 200 OK' in output 112 | assert 'content-type: text/event-stream' in output 113 | assert 'event: hello' in output 114 | assert expected_output in output 115 | 116 | 117 | async def terminate_process(proc: trio.Process, signal_number: int): 118 | await trio.sleep(1) 119 | proc.send_signal(signal_number) 120 | 121 | 122 | async def test_should_handle_user_interruption_by_ctrl_c(nursery): 123 | config = Config() 124 | output = '' 125 | 126 | await nursery.start(serve, app, config) 127 | 128 | async with await trio.open_process(['http', 'sse', ':8000/sse'], stdout=subprocess.PIPE) as process: 129 | nursery.start_soon(terminate_process, process, signal.SIGINT) 130 | async for data in process.stdout: 131 | output += data.decode() 132 | 133 | assert 'Program was interrupted by Ctrl+C, good bye! 👋' in output 134 | 135 | 136 | @pytest.mark.skipif(sys.platform == 'win32', reason='there is no signal SIGTERM on windows') 137 | async def test_should_handle_user_interruption_by_sigterm(nursery): 138 | config = Config() 139 | output = '' 140 | 141 | await nursery.start(serve, app, config) 142 | 143 | async with await trio.open_process(['http', 'sse', ':8000/sse'], stdout=subprocess.PIPE) as process: 144 | nursery.start_soon(terminate_process, process, signal.SIGTERM) 145 | async for data in process.stdout: 146 | output += data.decode() 147 | 148 | assert 'Program was interrupted by the SIGTERM signal, good bye! 👋' in output 149 | -------------------------------------------------------------------------------- /httpcli/commands/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import signal 3 | from typing import Dict, Any, Optional, Callable 4 | 5 | import anyio 6 | import asyncclick as click 7 | import httpx 8 | from pygments.lexers import get_lexer_for_mimetype 9 | from pygments.util import ClassNotFound 10 | from rich.syntax import Syntax 11 | from typing_extensions import Literal 12 | 13 | from httpcli.configuration import Configuration 14 | from httpcli.console import console 15 | from httpcli.helpers import build_read_method_arguments, build_write_method_arguments 16 | from httpcli.types import HttpProperty 17 | 18 | 19 | def guess_lexer_name(response: httpx.Response) -> str: 20 | content_type = response.headers.get('Content-Type') 21 | if content_type is not None: 22 | mime_type, _, _ = content_type.partition(';') 23 | try: 24 | return get_lexer_for_mimetype(mime_type.strip()).name 25 | except ClassNotFound: 26 | pass 27 | return '' 28 | 29 | 30 | def get_response_headers_text(response: httpx.Response) -> str: 31 | lines = [f'{response.http_version} {response.status_code} {response.reason_phrase}'] 32 | for name, value in response.headers.items(): 33 | lines.append(f'{name}: {value}') 34 | return '\n'.join(lines) 35 | 36 | 37 | def print_delimiter() -> None: 38 | syntax = Syntax('', 'http') 39 | console.print(syntax) 40 | 41 | 42 | def print_response_headers(response: httpx.Response) -> None: 43 | http_headers = get_response_headers_text(response) 44 | syntax = Syntax(http_headers, 'http') 45 | console.print(syntax) 46 | 47 | 48 | def print_response(response: httpx.Response) -> None: 49 | print_response_headers(response) 50 | print_delimiter() 51 | lexer = guess_lexer_name(response) 52 | if lexer: 53 | text = response.text 54 | if lexer.lower() == 'json': 55 | try: 56 | data = response.json() 57 | text = json.dumps(data, indent=4) 58 | except json.JSONDecodeError: 59 | pass 60 | syntax = Syntax(text, lexer) 61 | console.print(syntax) 62 | else: 63 | console.print(response.text) 64 | 65 | 66 | async def _perform_request( 67 | method: Literal['GET', 'HEAD', 'OPTIONS', 'DELETE', 'POST', 'PUT', 'PATCH'], 68 | url: str, 69 | config: Configuration, 70 | base_arguments: Dict[str, Any], 71 | method_arguments: Dict[str, Any] 72 | ) -> None: 73 | with anyio.move_on_after(config.timeout) as scope: 74 | try: 75 | async with httpx.AsyncClient(**base_arguments, timeout=None) as client: 76 | response = await client.request(method, url, **method_arguments) 77 | print_response(response) 78 | except httpx.HTTPError as e: 79 | console.print(f'[error]unexpected error: {e}') 80 | raise click.Abort() 81 | 82 | if scope.cancel_called: 83 | console.print('[error]the request timeout has expired') 84 | raise click.Abort() 85 | 86 | 87 | # delete is not a read method but it takes the same http parameters as the read methods. 88 | async def perform_read_request( 89 | method: Literal['GET', 'HEAD', 'OPTIONS', 'DELETE'], 90 | url: str, 91 | config: Configuration, 92 | headers: Optional[HttpProperty] = None, 93 | query_params: Optional[HttpProperty] = None, 94 | cookies: Optional[HttpProperty] = None 95 | ): 96 | arguments = await build_read_method_arguments(config, headers, cookies, query_params) 97 | method_arguments = {'allow_redirects': arguments.pop('allow_redirects')} 98 | 99 | await _perform_request(method, url, config, arguments, method_arguments) 100 | 101 | 102 | async def perform_write_request( 103 | method: Literal['POST', 'PUT', 'PATCH'], 104 | url: str, 105 | config: Configuration, 106 | headers: Optional[HttpProperty] = None, 107 | query_params: Optional[HttpProperty] = None, 108 | cookies: Optional[HttpProperty] = None, 109 | form: Optional[HttpProperty] = None, 110 | json_data: Optional[HttpProperty] = None, 111 | raw: Optional[bytes] = None 112 | ): 113 | arguments = await build_write_method_arguments(config, headers, cookies, query_params, form, json_data, raw) 114 | method_arguments = { 115 | 'allow_redirects': arguments.pop('allow_redirects'), 116 | 'files': arguments.pop('files', {}) 117 | } 118 | for item in ['data', 'json', 'content']: 119 | if item in arguments: 120 | method_arguments[item] = arguments.pop(item) 121 | break 122 | 123 | await _perform_request(method, url, config, arguments, method_arguments) 124 | 125 | 126 | async def signal_handler(scope: anyio.CancelScope) -> None: 127 | with anyio.open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signals: 128 | async for signum in signals: 129 | if signum == signal.SIGINT: 130 | console.print('[info]Program was interrupted by Ctrl+C, good bye! :waving_hand:') 131 | else: 132 | console.print('[info]Program was interrupted by the SIGTERM signal, good bye! :waving_hand:') 133 | 134 | # noinspection PyAsyncCall 135 | scope.cancel() 136 | return 137 | 138 | 139 | async def function_runner(scope: anyio.CancelScope, function: Callable, *args: Any) -> None: 140 | await function(*args) 141 | # noinspection PyAsyncCall 142 | scope.cancel() 143 | -------------------------------------------------------------------------------- /tests/commands/test_completion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import pytest 5 | from shellingham import ShellDetectionFailure 6 | 7 | from httpcli.commands.completion import SHELLS 8 | from httpcli.http import http 9 | from httpcli.https import https 10 | 11 | command_parametrize = pytest.mark.parametrize('command', [http, https]) 12 | 13 | 14 | @command_parametrize 15 | async def test_should_print_error_when_shell_is_not_detected(mocker, runner, command): 16 | mocker.patch('shellingham.detect_shell', side_effect=ShellDetectionFailure) 17 | result = await runner.invoke(command, ['install-completion']) 18 | 19 | assert result.exit_code == 1 20 | assert 'unable to detect the current shell\nAborted!\n' == result.output 21 | 22 | 23 | @command_parametrize 24 | async def test_should_print_error_when_os_name_is_unknown(monkeypatch, runner, command): 25 | os_name = 'foo' 26 | monkeypatch.setattr(os, 'name', os_name) 27 | result = await runner.invoke(command, ['install-completion']) 28 | 29 | assert result.exit_code == 1 30 | assert os_name in result.output 31 | assert 'Aborted!\n' in result.output 32 | 33 | 34 | @command_parametrize 35 | async def test_should_print_error_if_shell_is_not_supported(mocker, runner, command): 36 | mocker.patch('shellingham.detect_shell', return_value=('pwsh', 'C:\\bin\\pwsh')) 37 | result = await runner.invoke(command, ['install-completion']) 38 | 39 | assert result.exit_code == 1 40 | shells_string = ', '.join(SHELLS) 41 | assert f'Your shell is not supported. Shells supported are: {shells_string}\nAborted!\n' == result.output 42 | 43 | 44 | @command_parametrize 45 | @pytest.mark.parametrize('shell', [ 46 | ('bash', '/bin/bash'), 47 | ('zsh', '/bin/zsh'), 48 | ('fish', '/bin/fish') 49 | ]) 50 | async def test_should_print_error_when_user_cannot_retrieve_completion_script(tmp_path, mocker, runner, command, shell): 51 | mocker.patch('pathlib.Path.home', return_value=tmp_path) 52 | mocker.patch('shellingham.detect_shell', return_value=shell) 53 | mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(returncode=1, cmd='http')) 54 | result = await runner.invoke(command, ['install-completion']) 55 | 56 | assert result.exit_code == 1 57 | assert 'unable to get completion script for http cli.\nAborted!\n' == result.output 58 | 59 | 60 | @command_parametrize 61 | async def test_should_create_completion_file_and_install_it_for_bash_shell(tmp_path, mocker, runner, command): 62 | mocker.patch('pathlib.Path.home', return_value=tmp_path) 63 | mocker.patch('shellingham.detect_shell', return_value=('bash', '/bin/bash')) 64 | cli_completion_dir = tmp_path / '.cli_completions' 65 | completion_file = cli_completion_dir / f'{command.name}-complete.bash' 66 | bashrc_file = tmp_path / '.bashrc' 67 | 68 | result = await runner.invoke(command, ['install-completion']) 69 | 70 | assert result.exit_code == 0 71 | # completion files check 72 | assert cli_completion_dir.is_dir() 73 | assert completion_file.is_file() 74 | content = completion_file.read_text() 75 | 76 | assert content.startswith('_%s_completion() {' % command.name) 77 | assert content.endswith(f'_{command.name}_completion_setup;\n\n') 78 | 79 | # .bashrc check 80 | lines = [line for line in bashrc_file.read_text().split('\n') if line] 81 | expected = [f'. {cli_completion_dir / "http-complete.bash"}', f'. {cli_completion_dir / "https-complete.bash"}'] 82 | assert lines == expected 83 | 84 | 85 | @command_parametrize 86 | async def test_should_create_completion_file_and_install_it_for_zsh_shell(tmp_path, mocker, runner, command): 87 | mocker.patch('pathlib.Path.home', return_value=tmp_path) 88 | mocker.patch('shellingham.detect_shell', return_value=('zsh', '/bin/zsh')) 89 | cli_completion_dir = tmp_path / '.cli_completions' 90 | completion_file = cli_completion_dir / f'{command.name}-complete.zsh' 91 | zshrc_file = tmp_path / '.zshrc' 92 | 93 | result = await runner.invoke(command, ['install-completion']) 94 | 95 | assert result.exit_code == 0 96 | # completion files check 97 | assert cli_completion_dir.is_dir() 98 | assert completion_file.is_file() 99 | content = completion_file.read_text() 100 | 101 | assert content.startswith(f'#compdef {command.name}') 102 | assert content.endswith(f'compdef _{command.name}_completion {command.name};\n\n') 103 | 104 | # .bashrc check 105 | lines = [line for line in zshrc_file.read_text().split('\n') if line] 106 | expected = [f'. {cli_completion_dir / "http-complete.zsh"}', f'. {cli_completion_dir / "https-complete.zsh"}'] 107 | assert lines == expected 108 | 109 | 110 | @command_parametrize 111 | async def test_should_create_completion_file_and_install_it_for_fish_shell(tmp_path, mocker, runner, command): 112 | mocker.patch('pathlib.Path.home', return_value=tmp_path) 113 | mocker.patch('shellingham.detect_shell', return_value=('fish', '/bin/fish')) 114 | completion_dir = tmp_path / '.config/fish/completions' 115 | 116 | result = await runner.invoke(command, ['install-completion']) 117 | 118 | assert result.exit_code == 0 119 | assert completion_dir.is_dir() 120 | 121 | for cli in ['http', 'https']: 122 | completion_file = completion_dir / f'{cli}.fish' 123 | assert completion_file.is_file() 124 | content = completion_file.read_text() 125 | assert content.startswith(f'function _{cli}_completion') 126 | assert content.endswith(f'"(_{cli}_completion)";\n\n') 127 | -------------------------------------------------------------------------------- /httpcli/helpers.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, Any, TextIO, Optional, Union 3 | 4 | import anyio 5 | import asyncclick as click 6 | import httpx 7 | import pydantic 8 | import yaml 9 | 10 | from httpcli.configuration import Configuration 11 | from httpcli.console import console 12 | from httpcli.models import BasicAuth, DigestAuth, Auth, OAuth2PasswordBearer 13 | from httpcli.types import HttpProperty 14 | 15 | 16 | def build_base_httpx_arguments(config: Configuration) -> Dict[str, Any]: 17 | arguments: Dict[str, Any] = { 18 | 'allow_redirects': config.follow_redirects, 19 | 'verify': str(config.verify) if isinstance(config.verify, Path) else config.verify, 20 | 'http1': config.version == 'h1', 21 | 'http2': config.version == 'h2' 22 | } 23 | if config.auth is not None: 24 | auth = config.auth 25 | if isinstance(auth, BasicAuth): 26 | arguments['auth'] = httpx.BasicAuth(auth.username, auth.password) 27 | elif isinstance(auth, DigestAuth): 28 | arguments['auth'] = httpx.DigestAuth(auth.username, auth.password) 29 | 30 | if config.proxy is not None: 31 | arguments['proxies'] = str(config.proxy) 32 | 33 | return arguments 34 | 35 | 36 | def build_http_property_arguments( 37 | headers: Optional[HttpProperty] = None, 38 | cookies: Optional[HttpProperty] = None, 39 | query_params: Optional[HttpProperty] = None 40 | ) -> Dict[str, HttpProperty]: 41 | arguments = {} 42 | if headers is not None: 43 | arguments['headers'] = headers 44 | 45 | if cookies is not None: 46 | # we get tuple of tuples from click but httpx only handles list of tuples 47 | arguments['cookies'] = list(cookies) 48 | 49 | if query_params is not None: 50 | arguments['params'] = query_params 51 | 52 | return arguments 53 | 54 | 55 | async def get_oauth2_bearer_token(auth: OAuth2PasswordBearer) -> str: 56 | # just decide to use of timeout of 5s because it seems reasonable.. 57 | # this should probably be configurable but I will not do it in this POC 58 | with anyio.move_on_after(5) as scope: 59 | async with httpx.AsyncClient(base_url=auth.token_url, timeout=None) as client: 60 | response = await client.post('/', data={'username': auth.username, 'password': auth.password}) 61 | if response.status_code >= 400: 62 | console.print(f'[error]unable to fetch token, reason: {response.text}') 63 | raise click.Abort() 64 | else: 65 | return response.json()['access_token'] 66 | 67 | if scope.cancel_called: 68 | console.print('[error]the request timeout has expired') 69 | raise click.Abort() 70 | 71 | 72 | async def build_read_method_arguments( 73 | config: Configuration, 74 | headers: Optional[HttpProperty] = None, 75 | cookies: Optional[HttpProperty] = None, 76 | query_params: Optional[HttpProperty] = None 77 | ) -> Dict[str, Any]: 78 | base_arguments = build_base_httpx_arguments(config) 79 | http_arguments = build_http_property_arguments(headers, cookies, query_params) 80 | 81 | if isinstance(config.auth, OAuth2PasswordBearer): 82 | token = await get_oauth2_bearer_token(config.auth) 83 | headers = list(http_arguments.get('headers', [])) 84 | headers.append(('Authorization', f'Bearer {token}')) 85 | http_arguments['headers'] = headers # type: ignore 86 | 87 | return {**base_arguments, **http_arguments} 88 | 89 | 90 | async def build_write_method_arguments( 91 | config: Configuration, 92 | headers: Optional[HttpProperty] = None, 93 | cookies: Optional[HttpProperty] = None, 94 | query_params: Optional[HttpProperty] = None, 95 | form: Optional[HttpProperty] = None, 96 | json_data: Optional[HttpProperty] = None, 97 | raw: Optional[bytes] = None 98 | ) -> Dict[str, Any]: 99 | arguments = await build_read_method_arguments(config, headers, cookies, query_params) 100 | presence_info = [(data is not None and data != ()) for data in [form, json_data, raw]] 101 | if presence_info.count(True) > 1: 102 | raise click.UsageError( 103 | 'you cannot mix different types of data, you must choose between one between form, json or raw' 104 | ) 105 | 106 | if form: 107 | data = {} 108 | files = {} 109 | for key, value in form: 110 | if value.startswith('@'): 111 | files[key] = open(value[1:], 'rb') 112 | else: 113 | data[key] = value 114 | arguments['data'] = data 115 | arguments['files'] = files 116 | 117 | if json_data: 118 | arguments['json'] = dict(json_data) 119 | if raw: 120 | arguments['content'] = raw 121 | 122 | return arguments 123 | 124 | 125 | def set_configuration_options( 126 | config: Configuration, 127 | proxy: Optional[pydantic.AnyHttpUrl] = None, 128 | http_version: Optional[str] = None, 129 | auth: Optional[Auth] = None, 130 | follow_redirects: Optional[bool] = None, 131 | timeout: Optional[float] = None, 132 | verify: Optional[Union[bool, str]] = True 133 | ) -> None: 134 | if http_version is not None: 135 | config.version = http_version 136 | 137 | if proxy is not None: 138 | config.proxy = proxy 139 | 140 | if auth is not None: 141 | config.auth = auth 142 | 143 | if follow_redirects is not None: 144 | config.follow_redirects = follow_redirects 145 | 146 | if timeout is not None: 147 | config.timeout = None if timeout < 0 else timeout 148 | config.verify = verify 149 | 150 | 151 | def load_config_from_yaml(file: TextIO) -> Configuration: 152 | data = yaml.load(file, Loader=yaml.SafeLoader) 153 | try: 154 | return Configuration.parse_obj(data['httpcli']) 155 | except pydantic.ValidationError as e: 156 | raise click.UsageError(str(e)) 157 | -------------------------------------------------------------------------------- /tests/commands/test_download.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from respx.patterns import M 4 | 5 | from httpcli.commands.download import ( 6 | get_filename_from_content_disposition, get_filename_from_url, get_filename 7 | ) 8 | from httpcli.http import http 9 | from httpcli.https import https 10 | 11 | 12 | class TestGetFilenameFromContentDisposition: 13 | """Tests function get_filename_from_content_disposition""" 14 | 15 | def test_should_return_filename_when_content_disposition_sets_filename(self): 16 | response = httpx.Response(200, headers={'Content-Disposition': 'attachment; filename="filename.jpg"'}) 17 | 18 | assert get_filename_from_content_disposition(response) == 'filename.jpg' 19 | 20 | def test_should_return_empty_filename_when_filename_not_present_in_content_disposition(self): 21 | response = httpx.Response(200, headers={'Content-Disposition': 'attachment'}) 22 | 23 | assert get_filename_from_content_disposition(response) == '' 24 | 25 | def test_should_return_empty_filename_when_content_disposition_header_is_not_set(self): 26 | response = httpx.Response(200) 27 | 28 | assert get_filename_from_content_disposition(response) == '' 29 | 30 | 31 | class TestGetFilenameFromUrl: 32 | """Tests get_filename_from_url""" 33 | 34 | def test_should_return_filename_with_extension_when_extension_present_in_url(self): 35 | request = httpx.Request('GET', 'https://download/image.png') 36 | response = httpx.Response(200, request=request) 37 | 38 | assert get_filename_from_url(response) == 'image.png' 39 | 40 | @pytest.mark.parametrize(('content_type', 'extension'), [ 41 | ('image/png', 'png'), 42 | ('text/html', 'html'), 43 | ('text/plain', 'txt') 44 | ]) 45 | def test_should_filename_with_extension_when_content_type_is_set(self, content_type, extension): 46 | request = httpx.Request('GET', 'https://download/image') 47 | response = httpx.Response(200, request=request, headers={'content-type': content_type}) 48 | 49 | assert get_filename_from_url(response) == f'image.{extension}' 50 | 51 | def test_should_return_url_last_part_when_no_content_type_is_set(self): 52 | request = httpx.Request('GET', 'https://download/image') 53 | response = httpx.Response(200, request=request) 54 | 55 | assert get_filename_from_url(response) == 'image' 56 | 57 | def test_should_return_url_last_part_when_content_type_is_unknown(self): 58 | request = httpx.Request('GET', 'https://download/image') 59 | response = httpx.Response(200, request=request, headers={'content-type': 'text/unknown'}) 60 | 61 | assert get_filename_from_url(response) == 'image' 62 | 63 | 64 | class TestGetFilename: 65 | """Tests get_filename""" 66 | 67 | def test_should_return_filename_from_content_disposition_header(self, mocker): 68 | filename_from_url_mock = mocker.patch('httpcli.commands.download.get_filename_from_url') 69 | response = httpx.Response(200, headers={'Content-Disposition': 'attachment; filename="filename.jpg"'}) 70 | 71 | assert get_filename(response) == 'filename.jpg' 72 | filename_from_url_mock.assert_not_called() 73 | 74 | def test_should_return_filename_from_url(self): 75 | request = httpx.Request('GET', 'https://download/image.png') 76 | response = httpx.Response(200, request=request) 77 | 78 | assert get_filename(response) == 'image.png' 79 | 80 | 81 | class TestDownloadCommand: 82 | """Tests download command""" 83 | 84 | @pytest.mark.parametrize('command', [http, https]) 85 | async def test_should_print_error_when_destination_is_not_a_directory(self, runner, tmp_path, command): 86 | path = tmp_path / 'file.txt' 87 | path.write_text('a file') 88 | result = await runner.invoke(command, ['download', '-d', f'{path}']) 89 | 90 | assert result.exit_code == 2 91 | assert 'is a file' in result.output 92 | 93 | @pytest.mark.parametrize('command', [http, https]) 94 | async def test_should_print_error_when_destination_does_not_exist(self, runner, command): 95 | result = await runner.invoke(command, ['download', '-d', '/path/to/destination']) 96 | 97 | assert result.exit_code == 2 98 | assert 'does not exist' in result.output 99 | 100 | @pytest.mark.parametrize('command', [http, https]) 101 | async def test_should_print_error_when_url_in_file_is_not_correct(self, runner, tmp_path, command): 102 | urls = ['https://foo.com/image.png', 'image2.png'] 103 | path = tmp_path / 'file.txt' 104 | path.write_text('\n'.join(urls)) 105 | 106 | result = await runner.invoke(command, ['download', '-f', f'{path}']) 107 | 108 | assert result.exit_code == 2 109 | assert 'validation error for FileModel' in result.output 110 | 111 | @pytest.mark.parametrize('command', [http, https]) 112 | @pytest.mark.parametrize('status_code', [307, 400]) 113 | async def test_should_print_correct_error_when_download_fail( 114 | self, runner, respx_mock, tmp_path, command, status_code 115 | ): 116 | respx_mock.route(method='GET', host='images.com') % status_code 117 | url = 'https://images.com/image.png' 118 | result = await runner.invoke(command, ['download', url, '-d', f'{tmp_path}']) 119 | 120 | assert result.exit_code == 0 121 | output = result.output 122 | assert f'❌ {url} (image.png)\n' in output 123 | assert 'Downloading ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00Downloads completed! 🌟\n' in output 124 | 125 | @pytest.mark.parametrize('command', [http, https]) 126 | async def test_should_print_error_when_unexpected_error_happens(self, runner, respx_mock, tmp_path, command): 127 | respx_mock.route(method='GET', host='images.com').mock(side_effect=httpx.TransportError('boom!')) 128 | url = 'https://images.com/image.png' 129 | result = await runner.invoke(command, ['download', url, '-d', f'{tmp_path}']) 130 | 131 | assert result.exit_code == 0 132 | output = result.output 133 | assert f'unable to fetch {url}, reason: boom!\n' in output 134 | assert 'Downloading ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00Downloads completed! 🌟\n' in output 135 | 136 | @pytest.mark.parametrize('command', [http, https]) 137 | @pytest.mark.parametrize('current_dir', [True, False]) 138 | async def test_should_print_correct_output_when_downloads_are_ok( 139 | self, runner, respx_mock, tmp_path, command, current_dir 140 | ): 141 | path = tmp_path / 'urls.txt' 142 | # urls in file 143 | urls = ['https://dummy.com/file1', 'https://dummy.com/file2'] 144 | path.write_text('\n'.join(urls)) 145 | respx_mock.route(method='GET', host='dummy.com') % dict(headers={'content-type': 'text/plain'}) 146 | 147 | # urls passed on the command line 148 | command_line_urls = ['https://images.com/image.png', 'https://foo.com/image1.png'] 149 | hosts_pattern = M(host='images.com') | M(host='foo.com') 150 | respx_mock.route(hosts_pattern, method='GET') % 200 151 | 152 | dir_argument = [] if current_dir else ['-d', f'{tmp_path}'] 153 | result = await runner.invoke(command, ['download', *command_line_urls, '-f', f'{path}', *dir_argument]) 154 | 155 | assert result.exit_code == 0 156 | output = result.output 157 | assert '✅ https://dummy.com/file1 (file1.txt)\n' in output 158 | assert '✅ https://dummy.com/file2 (file2.txt)\n' in output 159 | assert '✅ https://images.com/image.png (image.png)\n' in output 160 | assert '✅ https://foo.com/image1.png (image1.png)\n' in output 161 | assert 'Downloading ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00Downloads completed! 🌟\n' in output 162 | 163 | # cleanup 164 | if current_dir: 165 | path = tmp_path.cwd() 166 | for file in path.glob('*.png'): 167 | file.unlink() 168 | for file in path.glob('*.txt'): 169 | file.unlink() 170 | -------------------------------------------------------------------------------- /tests/commands/test_helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import anyio 4 | import asyncclick as click 5 | import httpx 6 | import pytest 7 | 8 | from httpcli.commands.helpers import ( 9 | guess_lexer_name, get_response_headers_text, print_response, perform_read_request, perform_write_request 10 | ) 11 | from httpcli.configuration import Configuration 12 | 13 | 14 | class TestGuessLexerName: 15 | """Tests function guess_lexer_name""" 16 | 17 | def test_should_return_empty_lexer_name_when_no_content_type_is_given(self): 18 | response = httpx.Response(status_code=200, content='hello world') 19 | assert guess_lexer_name(response) == '' 20 | 21 | @pytest.mark.parametrize(('arguments', 'lexer'), [ 22 | ({'content': 'print("hello world")', 'headers': {'Content-Type': 'application/x-python'}}, 'Python'), 23 | ({'json': {'hello': 'world'}}, 'JSON') 24 | ]) 25 | def test_should_return_correct_lexer_name_given_content_type(self, arguments, lexer): 26 | response = httpx.Response(status_code=200, **arguments) 27 | 28 | assert guess_lexer_name(response) == lexer 29 | 30 | 31 | class TestGetResponseHeadersText: 32 | """Tests function get_response_headers_text""" 33 | 34 | def test_should_return_correct_output(self): 35 | data = {'hello': 'world'} 36 | response = httpx.Response(status_code=200, json=data) 37 | output = ( 38 | 'HTTP/1.1 200 OK\n' 39 | 'content-length: 18\n' 40 | 'content-type: application/json' 41 | ) 42 | assert get_response_headers_text(response) == output 43 | 44 | 45 | class TestPrintResponse: 46 | """Tests function print_response""" 47 | 48 | @pytest.mark.parametrize(('arguments', 'output_lines'), [ 49 | ({'content': 'print("hello world")', 'headers': {'Content-Type': 'application/x-python'}}, [ 50 | 'content-length: 20', 51 | 'content-type: application/x-python', 52 | 'print("hello world")' 53 | ]), 54 | ({'json': {'hello': 'world'}}, [ 55 | 'content-length: 18', 56 | 'content-type: application/json', 57 | '{', 58 | 'hello', 59 | 'world', 60 | '}' 61 | ]) 62 | ]) 63 | def test_should_print_correct_output(self, capsys, arguments, output_lines): 64 | response = httpx.Response(status_code=200, **arguments) 65 | 66 | lines = [ 67 | 'HTTP/1.1 200 OK', 68 | *output_lines 69 | ] 70 | print_response(response) 71 | output = capsys.readouterr().out 72 | 73 | for line in lines: 74 | assert line in output 75 | 76 | def test_should_print_correct_output_when_json_is_badly_formed(self, capsys, mocker): 77 | data = {'hello': 'world'} 78 | mocker.patch('json.dumps', side_effect=json.JSONDecodeError('hum', str(data), 2)) 79 | response = httpx.Response(status_code=200, json=data) 80 | print_response(response) 81 | 82 | output = capsys.readouterr().out 83 | lines = [ 84 | 'HTTP/1.1 200 OK', 85 | 'content-length: 18', 86 | 'content-type: application/json', 87 | '{"hello": "world"}' 88 | ] 89 | 90 | for line in lines: 91 | assert line in output 92 | 93 | 94 | class TestPerformReadRequest: 95 | """Tests function perform_read_request""" 96 | 97 | @pytest.mark.parametrize('method', ['GET', 'HEAD', 'OPTIONS', 'DELETE']) 98 | async def test_should_raise_error_when_request_timeout_expired(self, capsys, respx_mock, autojump_clock, method): 99 | async def side_effect(_): 100 | await anyio.sleep(6) 101 | 102 | respx_mock.route(method=method, host='example.com').side_effect = side_effect 103 | 104 | with pytest.raises(click.Abort): 105 | # noinspection PyTypeChecker 106 | await perform_read_request(method, 'https://example.com', Configuration()) 107 | 108 | assert capsys.readouterr().out == 'the request timeout has expired\n' 109 | 110 | @pytest.mark.parametrize('method', ['GET', 'HEAD', 'OPTIONS', 'DELETE']) 111 | async def test_should_raise_click_error_when_unexpected_httpx_error_happens(self, capsys, respx_mock, method): 112 | respx_mock.route(method=method, host='example.com').side_effect = httpx.TransportError('just a test error') 113 | 114 | with pytest.raises(click.Abort): 115 | # noinspection PyTypeChecker 116 | await perform_read_request(method, 'https://example.com', Configuration()) 117 | 118 | assert capsys.readouterr().out == 'unexpected error: just a test error\n' 119 | 120 | # this is not a realistic example, but just prove the function works as expected 121 | @pytest.mark.parametrize('method', ['GET', 'HEAD', 'OPTIONS', 'DELETE']) 122 | async def test_should_print_response_given_correct_input(self, capsys, respx_mock, method): 123 | cookies = headers = params = [('foo', 'bar')] 124 | respx_mock.route( 125 | method=method, host='example.com', cookies=cookies, headers=headers, params=params 126 | ) % dict(json={'hello': 'world'}) 127 | # noinspection PyTypeChecker 128 | await perform_read_request(method, 'https://example.com', Configuration(), headers, params, cookies) 129 | 130 | output = capsys.readouterr().out 131 | lines = [ 132 | 'HTTP/1.1 200 OK', 133 | 'content-length: 18', 134 | 'content-type: application/json', 135 | '{', 136 | 'hello', 137 | 'world', 138 | '}' 139 | ] 140 | for line in lines: 141 | assert line in output 142 | 143 | 144 | class TestPerformWriteRequest: 145 | """Tests function perform_write_request""" 146 | 147 | @pytest.mark.parametrize('method', ['POST', 'PATCH', 'PUT']) 148 | async def test_should_raise_error_when_request_timeout_expired(self, capsys, respx_mock, autojump_clock, method): 149 | async def side_effect(_): 150 | await anyio.sleep(6) 151 | 152 | respx_mock.route(method=method, host='example.com').side_effect = side_effect 153 | 154 | with pytest.raises(click.Abort): 155 | # noinspection PyTypeChecker 156 | await perform_write_request(method, 'https://example.com', Configuration()) 157 | 158 | assert capsys.readouterr().out == 'the request timeout has expired\n' 159 | 160 | @pytest.mark.parametrize('method', ['POST', 'PATCH', 'PUT']) 161 | async def test_should_raise_click_error_when_unexpected_httpx_error_happens(self, capsys, respx_mock, method): 162 | respx_mock.route(method=method, host='example.com').side_effect = httpx.TransportError('just a test error') 163 | 164 | with pytest.raises(click.Abort): 165 | # noinspection PyTypeChecker 166 | await perform_write_request(method, 'https://example.com', Configuration()) 167 | 168 | assert capsys.readouterr().out == 'unexpected error: just a test error\n' 169 | 170 | @pytest.mark.parametrize('method', ['POST', 'PATCH', 'PUT']) 171 | @pytest.mark.parametrize(('mock_argument', 'request_argument'), [ 172 | ({'data': {'foo': 'bar'}}, {'form': [('foo', 'bar')]}), 173 | ({'json': {'foo': 'bar'}}, {'json_data': [('foo', 'bar')]}), 174 | ({'content': b'hello'}, {'raw': b'hello'}) 175 | ]) 176 | async def test_should_print_response_given_correct_input( 177 | self, capsys, respx_mock, method, mock_argument, request_argument 178 | ): 179 | respx_mock.route(method=method, host='example.com', **mock_argument) % dict(json={'hello': 'world'}) 180 | # noinspection PyTypeChecker 181 | await perform_write_request(method, 'https://example.com', Configuration(), **request_argument) 182 | 183 | output = capsys.readouterr().out 184 | lines = [ 185 | 'HTTP/1.1 200 OK', 186 | 'content-length: 18', 187 | 'content-type: application/json', 188 | '{', 189 | 'hello', 190 | 'world', 191 | '}' 192 | ] 193 | for line in lines: 194 | assert line in output 195 | -------------------------------------------------------------------------------- /tests/test_parameters.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import asyncclick as click 4 | import pytest 5 | 6 | from httpcli.models import BasicAuth 7 | from httpcli.parameters import AUTH_PARAM, URL, QUERY, HEADER, COOKIE, JSON, FORM, RAW_PAYLOAD 8 | 9 | 10 | @click.command() 11 | @click.option('--auth', type=AUTH_PARAM) 12 | def debug_auth(auth): 13 | click.echo(auth) 14 | 15 | 16 | @click.command() 17 | @click.option('--url', type=URL) 18 | def debug_url(url): 19 | click.echo(url) 20 | 21 | 22 | @click.command() 23 | @click.option('--header', type=HEADER) 24 | @click.option('--cookie', type=COOKIE) 25 | @click.option('--query', type=QUERY) 26 | def debug_http_param(header, cookie, query): 27 | click.echo(header) 28 | click.echo(cookie) 29 | click.echo(query) 30 | 31 | 32 | @click.command() 33 | @click.option('-f', '--form', type=FORM) 34 | def debug_form_param(form): 35 | click.echo(form) 36 | 37 | 38 | @click.command() 39 | @click.option('-j', '--json', 'json_param', type=JSON) 40 | def debug_json_param(json_param): 41 | click.echo(json_param) 42 | 43 | 44 | @click.command() 45 | @click.option('-r', '--raw', type=RAW_PAYLOAD) 46 | def debug_raw_payload(raw): 47 | click.echo(raw) 48 | 49 | 50 | class TestAuthParam: 51 | """Tests AuthParam class""" 52 | 53 | @pytest.mark.parametrize(('auth', 'error_message'), [ 54 | ('4', 'authentication information is not valid'), 55 | (str({'type': 'basic', 'username': 'foo'}), 'is not a valid json string') 56 | ]) 57 | async def test_should_print_error_given_wrong_input(self, runner, auth, error_message): 58 | result = await runner.invoke(debug_auth, ['--auth', auth]) 59 | assert 2 == result.exit_code 60 | assert error_message in result.output 61 | 62 | async def test_should_print_auth_info_given_correct_input(self, runner): 63 | auth = BasicAuth(username='foo', password='bar') 64 | result = await runner.invoke(debug_auth, ['--auth', auth.json()]) 65 | 66 | assert result.exit_code == 0 67 | assert result.output == f'{auth}\n' 68 | 69 | 70 | class TestUrlParam: 71 | """Tests UrlParam class""" 72 | 73 | @pytest.mark.parametrize('url', ['4', 'hi://foo.com']) 74 | async def test_should_print_error_given_wrong_input(self, runner, url): 75 | result = await runner.invoke(debug_url, ['--url', url]) 76 | 77 | assert result.exit_code == 2 78 | assert f'{url} is not a valid url' in result.output 79 | 80 | @pytest.mark.parametrize('url', ['http://url.com', 'https://url.com']) 81 | async def test_should_print_url_given_correct_input(self, runner, url): 82 | result = await runner.invoke(debug_url, ['--url', url]) 83 | 84 | assert result.exit_code == 0 85 | assert result.output == f'{url}\n' 86 | 87 | 88 | class TestHttpParam: 89 | """Tests HTTPParameter subclasses (query, cookie and header)""" 90 | 91 | @pytest.mark.parametrize('value', ['foo:bar:tar', 'foo']) 92 | @pytest.mark.parametrize('http_param', ['--header', '--cookie', '--query']) 93 | async def test_should_print_error_when_input_is_wrong(self, runner, value, http_param): 94 | result = await runner.invoke(debug_http_param, [http_param, value]) 95 | 96 | assert result.exit_code == 2 97 | assert f"'{http_param}': {value} is not in the form key:value" in result.output 98 | 99 | async def test_should_print_http_params_given_correct_input(self, runner): 100 | arguments = ['--header', 'foo:bar', '--cookie', 'foo:bar', '--query', 'foo:bar'] 101 | result = await runner.invoke(debug_http_param, arguments) 102 | 103 | assert result.exit_code == 0 104 | assert result.output == f'{("foo", "bar")}\n' * 3 105 | 106 | 107 | class TestFormParam: 108 | """Tests FormParam class""" 109 | 110 | @pytest.mark.parametrize('value', ['a', 'a:b:c']) 111 | async def test_should_print_error_when_param_is_badly_formed(self, runner, value): 112 | result = await runner.invoke(debug_form_param, ['-f', value]) 113 | 114 | assert result.exit_code == 2 115 | assert f'{value} is not in the form key:value' in result.output 116 | 117 | async def test_should_print_error_when_given_file_does_not_exist(self, runner): 118 | result = await runner.invoke(debug_form_param, ['-f', 'foo:@foo.txt']) 119 | 120 | assert result.exit_code == 2 121 | assert 'foo.txt file does not exist' in result.output 122 | 123 | async def test_should_print_correct_value_when_given_correct_filename(self, runner, tmp_path): 124 | path = tmp_path / 'file.txt' 125 | path.write_text('just a test file') 126 | result = await runner.invoke(debug_form_param, ['-f', f'foo:@{path}']) 127 | 128 | assert result.exit_code == 0 129 | assert result.output == str(('foo', f'@{path}')) + '\n' 130 | 131 | async def test_should_print_correct_value_when_given_correct_input(self, runner): 132 | result = await runner.invoke(debug_form_param, ['-f', 'foo:bar']) 133 | 134 | assert result.exit_code == 0 135 | assert result.output == str(('foo', 'bar')) + '\n' 136 | 137 | 138 | class TestJsonParam: 139 | """Tests JsonParam class""" 140 | 141 | @pytest.mark.parametrize('value', ['a', 'a:b:c']) 142 | async def test_should_print_error_when_param_is_badly_formed(self, runner, value): 143 | result = await runner.invoke(debug_json_param, ['-j', value]) 144 | 145 | assert result.exit_code == 2 146 | assert f'{value} is not in the form key:value' in result.output 147 | 148 | async def test_should_print_error_when_given_json_file_does_not_exist(self, runner): 149 | result = await runner.invoke(debug_json_param, ['-j', 'foo:@foo.json']) 150 | 151 | assert result.exit_code == 2 152 | assert 'foo.json file does not exist' in result.output 153 | 154 | async def test_should_print_error_when_given_value_is_not_valid_json(self, runner): 155 | value = [1, 2, '3'] 156 | result = await runner.invoke(debug_json_param, ['-j', f'foo:={value}']) 157 | 158 | assert result.exit_code == 2 159 | assert f'{value} is not a valid json value' in result.output 160 | 161 | async def test_should_print_value_when_given_correct_json_file(self, runner, tmp_path): 162 | json_file = tmp_path / 'data.json' 163 | data = [1, 2, 3] 164 | output = ('a', data) 165 | with json_file.open('w') as f: 166 | json.dump(data, f) 167 | 168 | result = await runner.invoke(debug_json_param, ['-j', f'a:@{json_file}']) 169 | 170 | assert result.exit_code == 0 171 | assert result.output == f'{output}\n' 172 | 173 | @pytest.mark.parametrize(('value', 'output'), [ 174 | ('foo:bar', f"{('foo', 'bar')}\n"), 175 | ("a:=2", f"{('a', 2)}\n"), 176 | ('foo:=[1, 2, "3"]', f"{('foo', [1, 2, '3'])}\n") 177 | ]) 178 | async def test_should_print_value_when_given_correct_input(self, runner, value, output): 179 | result = await runner.invoke(debug_json_param, ['-j', value]) 180 | 181 | assert result.exit_code == 0 182 | assert result.output == output 183 | 184 | 185 | class TestRawPayloadParam: 186 | """Tests RawPayloadParam class""" 187 | 188 | async def test_should_print_error_when_given_file_does_not_exist(self, runner): 189 | result = await runner.invoke(debug_raw_payload, ['-r', '@foo.txt']) 190 | 191 | assert result.exit_code == 2 192 | assert 'foo.txt file does not exist' in result.output 193 | 194 | @pytest.mark.parametrize('value', ['Just some random data', b'Just some random data']) 195 | async def test_should_print_correct_output_given_a_file_as_input(self, runner, value, tmp_path): 196 | path = tmp_path / 'data.txt' 197 | if isinstance(value, str): 198 | path.write_text(value) 199 | else: 200 | path.write_bytes(value) 201 | value = value.decode() 202 | 203 | result = await runner.invoke(debug_raw_payload, ['-r', f'@{path}']) 204 | 205 | assert result.exit_code == 0 206 | assert result.output == f'{value}\n' 207 | 208 | async def test_should_print_correct_output_given_correct_input(self, runner): 209 | value = 'Just some random data' 210 | result = await runner.invoke(debug_raw_payload, ['-r', value]) 211 | 212 | assert result.exit_code == 0 213 | assert result.output == f'{value}\n' 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpcli 2 | 3 | This project is a proof of concept of a modern python networking cli which can be *simple* and *easy* to maintain using 4 | some of the best packages in the python ecosystem: 5 | 6 | - [click](https://click.palletsprojects.com/) for the foundation of a CLI application. There is also 7 | [asyncclick](https://github.com/python-trio/asyncclick) that I used in this project which is a tiny wrapper around 8 | click to provide asynchronous support. 9 | - [rich](https://github.com/willmcgugan/rich) for pretty printing in the terminal. 10 | - [httpx](https://www.python-httpx.org/) for HTTP protocol stuff. 11 | - [anyio](https://anyio.readthedocs.io/en/stable/) for concurrency. 12 | - [pytest](https://docs.pytest.org/en/latest/contents.html) 13 | and [pytest-trio](https://pytest-trio.readthedocs.io/en/stable/) 14 | for easy testing. 15 | 16 | This is not a complete and mature project like [httpie](https://httpie.io/) but I want to implement some features not 17 | present in this beautiful package like: 18 | 19 | - [HTTP2](https://fr.wikipedia.org/wiki/Hypertext_Transfer_Protocol/2) support 20 | - more authentication scheme support like digest and oauth2 21 | - easy cookies support 22 | - support of posix signals like SIGINT and SIGTERM 23 | - completion feature 24 | - git "did you mean" like feature 25 | - [sse](https://fr.wikipedia.org/wiki/Server-sent_events) support 26 | 27 | ## Evolution 28 | 29 | I'm not quite sure if I will continue improving it without any motivation (sponsoring?) but it is already useful if you 30 | want to test it, you just need to have [poetry](https://python-poetry.org/docs/) dependency manager and install the 31 | project locally (`poetry install`). This will install two commands: 32 | 33 | - `http` useful when you don't want the cli to verify server certificate. 34 | - `https` when you need to verify server certificate. 35 | 36 | ## Usage 37 | 38 | Hopefully subcommand usage should be straightforward, but I will point some specific cases. 39 | 40 | ```shell 41 | Usage: http [OPTIONS] COMMAND [ARGS]... 42 | 43 | HTTP CLI 44 | 45 | Options: 46 | --config-file FILENAME A configuration file with options used to 47 | set the cli. Note that the file takes 48 | precedence over the other options. 49 | -t, --timeout FLOAT Time for request to complete, a negative 50 | value means there is no timeout. 51 | --follow-redirects / -N, --no-follow-redirects 52 | flag to decide if http redirections must be 53 | followed 54 | --auth JSON_AUTH A json string representing authentication 55 | information. 56 | --http-version [h1|h2] Version of http used to make the request. 57 | --proxy URL Proxy url. 58 | --version Show the version and exit. 59 | --help Show this message and exit. 60 | 61 | Commands: 62 | delete Performs http DELETE request. 63 | download Process download of urls given as arguments. 64 | get Performs http GET request. 65 | head Performs http HEAD request. 66 | install-completion Install completion script for bash, zsh and fish... 67 | options Performs http OPTIONS request. 68 | patch Performs http PATCH request. 69 | post Performs http POST request. 70 | put Performs http PUT request. 71 | sse Reads and print SSE events on a given url. 72 | ``` 73 | 74 | ### Global cli configuration 75 | 76 | There are some options that can be configured on the root command. These options can be read from a `yaml` file using 77 | option `--config-file`. The config file looks lie the following: 78 | 79 | ```yaml 80 | # all options have default values, no need to specify them all 81 | httpcli: 82 | http_version: h2 83 | follow_redirects: true 84 | proxy: https://proxy.com 85 | # timeout may be null to specify that you don't want a timeout 86 | timeout: 5.0 87 | auth: 88 | type: oauth2 89 | flow: password 90 | username: user 91 | password: pass 92 | # for https you also have the verify option to pass a custom certificate used to authenticate the server 93 | verify: /path/to/certificate 94 | ``` 95 | 96 | Those options can also be configured via environment variables. They are all prefixed with `HTTP_CLI_` and they can be 97 | in lowercase or uppercase. Here is the same configuration as above but using environment variables: 98 | 99 | ```shell 100 | HTTP_CLI_HTTP_VERSION=h2 101 | HTTP_CLI_FOLLOW_REDIRECTS=true 102 | HTTP_CLI_PROXY=https://proxy.com 103 | HTTP_CLI_TIMEOUT=5.0 104 | # here value is passed as json 105 | HTTP_CLI_AUTH={"type": "oauth2", "flow": "password", "username": "user", "password": "pass"} 106 | HTTP_CLI_VERIFY=/path/to/certificate 107 | ``` 108 | 109 | ### Commands 110 | 111 | #### install-completion 112 | 113 | This is obviously the first command you will want to use to have subcommand and option autocompletion. You don't need to 114 | do that for the two cli `http` and `https`. Doing it with one will install the other. The current shells supported 115 | are `bash`, `zsh` and `fish`. To use autocompletion for subcommands, just enter the first letter and use `TAB` key 116 | twice. For option autocompletion, enter the first dash and use `TAB` twice. 117 | 118 | #### get, head, option, delete 119 | 120 | The usage should be pretty straightforward for these commands. 121 | 122 | ```shell 123 | http get --help 124 | Usage: http get [OPTIONS] URL 125 | 126 | Performs http GET request. 127 | 128 | URL is the target url. 129 | 130 | Options: 131 | -c, --cookie COOKIE Cookie passed to the request, can by passed multiple 132 | times. 133 | -H, --header HEADER Header passed to the request, can by passed multiple 134 | times. 135 | -q, --query QUERY Querystring argument passed to the request, can by 136 | passed multiple times. 137 | --help Show this message and exit. 138 | ``` 139 | 140 | You can play with it using https://pie.dev. Here is a simple example: 141 | 142 | ```shell 143 | http get https://pie.dev/get -c my:cookie -q my:query -H X-MY:HEADER 144 | ``` 145 | 146 | #### post, put, patch 147 | 148 | There are some subtleties with these commands. I will use `post` in the following examples but the same apply to `put` 149 | and `patch`. 150 | 151 | **json data** 152 | 153 | If you play with json, in case you only have string values, you can do this: 154 | 155 | ```shell 156 | # here we are sending {"foo": "bar", "hello": "world"} to https://pie.dev/post 157 | http post https://pie.dev/post -j foo:bar -j hello:world 158 | ``` 159 | 160 | If you need to send other values than strings, you will need to pass the json encoded value with a slightly different 161 | syntax, `:=` instead of `=`. 162 | 163 | ```shell 164 | http post https://pie.dev/post -j number:='2' -j boolean:='true' -j fruits:='["apple", "pineapple"]' 165 | ``` 166 | 167 | If you have a deeply nested structure you can't write simple in the terminal, you can use of json file instead. 168 | Considering we have a file *fruits.json* with the following content: 169 | 170 | ```json 171 | [ 172 | "apple", 173 | "pineapple" 174 | ] 175 | ``` 176 | 177 | You can use the file like it: 178 | 179 | ```shell 180 | http post https://pie.dev/post -j fruits:@fruits.json 181 | ``` 182 | 183 | **form data** 184 | 185 | First you need to know that you can't pass **form data and json data in the same request**. You must choose between the 186 | two methods. The basic usage is the following: 187 | 188 | ```shell 189 | https post https://pie.dev/post -f foo:bar -f number:2 190 | ``` 191 | 192 | If you need to send files, here is what you can do: 193 | 194 | ```shell 195 | # this will send the key "foo" with the value "bar" and the key "photo" with the file photo.jpg 196 | https post https://pie.dev/post -f foo:bar -f photo:@photo.jpg 197 | ``` 198 | 199 | If you want to send raw data, use the following form: 200 | 201 | ```shell 202 | https post https://pie.dev/post --raw 'raw content' 203 | ``` 204 | 205 | You can also pass the raw content in a file: 206 | 207 | ```shell 208 | # you can put what you want in your file, just be sure to set the correct content-type 209 | https post https://pie.dev/post --raw @hello.txt 210 | ``` 211 | 212 | #### download 213 | 214 | You can pass urls as arguments. Files will be downloaded in the current directory. If you wish to change the directory 215 | where files should be put, pass the `-d` option with the path of the desired destination folder. 216 | 217 | ```shell 218 | # this will downloads two files and put them in the downloads directory of the current user 219 | https download https://pie.dev/image/jpeg https://pie.dev/image/png -d ~/downloads 220 | ``` 221 | 222 | You can use a file to specify all the resources to download. There should be **one url per line**. Consider a file 223 | `urls.txt` having the following content: 224 | 225 | ```text 226 | https://pie.dev/image/svg 227 | https://pie.def/image/webp 228 | ``` 229 | 230 | You can download urls from the file and urls from the command line at the same time: 231 | 232 | ```shell 233 | https download https://pie.dev/image/jpeg -f urls.txt 234 | ``` 235 | 236 | #### sse 237 | 238 | If you want to listen sse events from an endpoint, you can simply do this: 239 | 240 | ```shell 241 | # The sse command will not stop if the data are sent without interruption, which is almost always the case 242 | # with sse, so if you want to stop it, just Ctrl + C ;) 243 | https sse https://endpoint.com/sse 244 | ``` 245 | 246 | ## What needs to be improved? 247 | 248 | If I were to continue the development of the project, here are the points to review/enhance: 249 | 250 | - adapt code to support httpx 1.0 . At the moment of writing it is still in beta, but there is at least one breaking 251 | change concerning `allow_redirects` option. 252 | - add more authentication schemes, mainly all the oauth2 flows, but may be some others like 253 | [macaroon](https://neilmadden.blog/2020/07/29/least-privilege-with-less-effort-macaroon-access-tokens-in-am-7-0/)... 254 | - support multiple proxy values 255 | - session support 256 | - add CI/CD 257 | - improve code coverage (not 100% yet) 258 | - refactor a bit the code, currently I don't like the structure of my helpers modules. Also auth support can be 259 | refactored using this [technique](https://www.python-httpx.org/advanced/#customizing-authentication) I was not aware 260 | of when starting this project. 261 | - add autocompletion featurefor other shells like ksh, powershell or powercore 262 | - and probably more... :) -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | import anyio 4 | import asyncclick as click 5 | import httpx 6 | import mock 7 | import pytest 8 | 9 | from httpcli.configuration import Configuration, BasicAuth, DigestAuth, OAuth2PasswordBearer 10 | from httpcli.helpers import ( 11 | build_base_httpx_arguments, load_config_from_yaml, build_http_property_arguments, get_oauth2_bearer_token, 12 | build_read_method_arguments, build_write_method_arguments 13 | ) 14 | 15 | CONFIGURATIONS = [ 16 | Configuration(), 17 | Configuration(verify=False, version='h2'), 18 | Configuration(proxy='https://proxy.com', follow_redirects=False), # type: ignore 19 | Configuration(auth=DigestAuth(username='foo', password='bar'), follow_redirects=False) 20 | ] 21 | 22 | DEFAULT_ARGUMENT = { 23 | 'allow_redirects': True, 24 | 'verify': True, 25 | 'http1': True, 26 | 'http2': False 27 | } 28 | 29 | ARGUMENTS = [ 30 | DEFAULT_ARGUMENT, 31 | {**DEFAULT_ARGUMENT, 'verify': False, 'http1': False, 'http2': True}, 32 | {**DEFAULT_ARGUMENT, 'proxies': 'https://proxy.com', 'allow_redirects': False}, 33 | ] 34 | 35 | 36 | class TestBuildBaseHttpxArguments: 37 | """Tests build_base_httpx_arguments function""" 38 | 39 | @pytest.mark.parametrize(('config', 'arguments'), [ 40 | (CONFIGURATIONS[0], ARGUMENTS[0]), 41 | (CONFIGURATIONS[1], ARGUMENTS[1]), 42 | (CONFIGURATIONS[2], ARGUMENTS[2]) 43 | ]) 44 | def test_should_return_correct_arguments(self, config, arguments): 45 | assert build_base_httpx_arguments(config) == arguments 46 | 47 | @pytest.mark.parametrize(('auth_argument', 'httpx_auth_class'), [ 48 | (DigestAuth(username='foo', password='bar'), httpx.DigestAuth), 49 | (BasicAuth(username='foo', password='bar'), httpx.BasicAuth) 50 | ]) 51 | def test_should_return_correct_arguments_with_auth_configuration(self, auth_argument, httpx_auth_class): 52 | config = Configuration(auth=auth_argument) 53 | arguments = build_base_httpx_arguments(config) 54 | 55 | assert set(arguments.keys()) == {'allow_redirects', 'verify', 'http1', 'http2', 'auth'} 56 | assert arguments['allow_redirects'] is True 57 | assert arguments['verify'] is True 58 | assert arguments['http1'] is True 59 | assert arguments['http2'] is False 60 | auth = arguments['auth'] 61 | assert isinstance(auth, httpx_auth_class) 62 | 63 | def test_should_return_correct_arguments_with_a_custom_certificate(self, tmp_path): 64 | fake_cert = tmp_path / 'cert.pem' 65 | fake_cert.write_bytes(b'fake cert') 66 | config = Configuration(verify=fake_cert) 67 | arguments = build_base_httpx_arguments(config) 68 | 69 | assert set(arguments.keys()) == {'allow_redirects', 'verify', 'http1', 'http2'} 70 | assert arguments['http1'] is True 71 | assert arguments['http2'] is False 72 | assert arguments['allow_redirects'] is True 73 | assert arguments['verify'] == str(fake_cert) 74 | 75 | 76 | HTTP_ARGUMENT = (('foo', 'bar'),) 77 | 78 | 79 | class TestBuildHttpPropertyArguments: 80 | """Tests function build_http_property_arguments""" 81 | 82 | @pytest.mark.parametrize(('input_arguments', 'output_arguments'), [ 83 | ({'headers': HTTP_ARGUMENT}, {'headers': HTTP_ARGUMENT}), 84 | ({'cookies': HTTP_ARGUMENT}, {'cookies': list(HTTP_ARGUMENT)}), 85 | ({'query_params': HTTP_ARGUMENT}, {'params': HTTP_ARGUMENT}), 86 | ({'headers': HTTP_ARGUMENT, 'cookies': HTTP_ARGUMENT, 'query_params': HTTP_ARGUMENT}, 87 | {'headers': HTTP_ARGUMENT, 'cookies': list(HTTP_ARGUMENT), 'params': HTTP_ARGUMENT}) 88 | ]) 89 | def test_should_return_correct_arguments_given_correct_input(self, input_arguments, output_arguments): 90 | assert build_http_property_arguments(**input_arguments) == output_arguments 91 | 92 | 93 | BAD_YAML_DATA = """ 94 | httpcli: 95 | timeout: 2 96 | version: h4 97 | """ 98 | 99 | CORRECT_YAML_DATA = """ 100 | httpcli: 101 | timeout: 2 102 | version: h2 103 | """ 104 | 105 | 106 | class TestLoadConfigFromYaml: 107 | """Tests function load_config_from_yaml""" 108 | 109 | def test_should_raise_error_given_file_with_wrong_configuration(self, tmp_path): 110 | config_file = tmp_path / 'config.yaml' 111 | config_file.write_text(BAD_YAML_DATA) 112 | with pytest.raises(click.UsageError) as exc_info: 113 | with config_file.open() as f: 114 | load_config_from_yaml(f) 115 | 116 | assert 'version' in str(exc_info.value) 117 | 118 | def test_should_return_correct_configuration_given_correct_configuration_file(self, tmp_path): 119 | config_file = tmp_path / 'config.yaml' 120 | config_file.write_text(CORRECT_YAML_DATA) 121 | with config_file.open() as f: 122 | config = load_config_from_yaml(f) 123 | assert config.timeout == 2 124 | assert config.version == 'h2' 125 | 126 | 127 | class TestGetOauth2BearerToken: 128 | """Tests function get_oauth2_bearer_token""" 129 | 130 | async def test_should_raise_error_when_receiving_errored_status_code(self, respx_mock, capsys): 131 | auth = OAuth2PasswordBearer(token_url='https://token.com', username='foo', password='bar') # type: ignore 132 | json_response = {'detail': 'not allowed'} 133 | httpx_response = httpx.Response(400, json=json_response) 134 | respx_mock.post('https://token.com', data=auth.dict(include={'username', 'password'})) % httpx_response 135 | 136 | with pytest.raises(click.Abort): 137 | await get_oauth2_bearer_token(auth) 138 | 139 | output = capsys.readouterr().out 140 | # click changed quotes used when printing the json output, so I can't test the whole string directly 141 | assert f'unable to fetch token, reason:' in output 142 | assert 'detail' in output 143 | assert 'not allowed' in output 144 | 145 | async def test_should_raise_error_when_request_timeout_expired(self, capsys, respx_mock, autojump_clock): 146 | async def side_effect(_): 147 | await anyio.sleep(6) 148 | 149 | auth = OAuth2PasswordBearer(token_url='https://token.com', username='foo', password='bar') # type: ignore 150 | route = respx_mock.post('https://token.com', data=auth.dict(include={'username', 'password'})) 151 | route.side_effect = side_effect 152 | 153 | with pytest.raises(click.Abort): 154 | await get_oauth2_bearer_token(auth) 155 | 156 | assert capsys.readouterr().out == 'the request timeout has expired\n' 157 | 158 | async def test_should_return_access_token(self, respx_mock): 159 | auth = OAuth2PasswordBearer(token_url='https://token.com', username='foo', password='bar') # type: ignore 160 | access_token = secrets.token_hex(16) 161 | route = respx_mock.post('https://token.com', data=auth.dict(include={'username', 'password'})) 162 | route.return_value = httpx.Response( 163 | status_code=200, json={'token_type': 'bearer', 'access_token': access_token} 164 | ) 165 | 166 | token = await get_oauth2_bearer_token(auth) 167 | assert access_token == token 168 | 169 | 170 | class TestBuildReadMethodArguments: 171 | """Tests function build_read_method_arguments""" 172 | 173 | @pytest.mark.parametrize(('auth_argument', 'http_auth_class'), [ 174 | (BasicAuth(username='foo', password='bar'), httpx.BasicAuth), 175 | (DigestAuth(username='foo', password='bar'), httpx.DigestAuth) 176 | ]) 177 | async def test_should_return_httpx_config_given_basic_digest_auth(self, auth_argument, http_auth_class): 178 | proxy = 'http://proxy.com' 179 | config = Configuration(auth=auth_argument, proxy=proxy) # type: ignore 180 | cookies = [('hello', 'world')] 181 | query_params = (('search', 'bar'),) 182 | 183 | arguments = await build_read_method_arguments(config, cookies=cookies, query_params=query_params) 184 | 185 | assert arguments['proxies'] == proxy 186 | assert isinstance(arguments['auth'], http_auth_class) 187 | assert arguments['cookies'] == cookies 188 | assert arguments['params'] == query_params 189 | 190 | async def test_should_return_httpx_given_oauth2_password_auth(self, respx_mock): 191 | token_url = 'https://token.com' 192 | access_token = secrets.token_hex(16) 193 | auth = OAuth2PasswordBearer(username='foo', password='bar', token_url=token_url) # type: ignore 194 | config = Configuration(auth=auth) 195 | route = respx_mock.post(token_url, data=auth.dict(include={'username', 'password'})) 196 | route.return_value = httpx.Response( 197 | status_code=200, json={'token_type': 'bearer', 'access_token': access_token} 198 | ) 199 | 200 | arguments = await build_read_method_arguments(config) 201 | 202 | assert arguments['http1'] is True 203 | assert arguments['http2'] is False 204 | assert arguments['headers'] == [('Authorization', f'Bearer {access_token}')] 205 | 206 | 207 | class TestBuildWriteMethodArguments: 208 | """Tests function build_write_method_arguments""" 209 | 210 | async def test_should_call_function_build_read_method_arguments(self, mocker): 211 | build_read_mock = mocker.patch('httpcli.helpers.build_read_method_arguments', new=mock.AsyncMock()) 212 | headers = cookies = query_params = [('foo', 'bar')] 213 | config = Configuration() 214 | await build_write_method_arguments(config, headers, cookies, query_params) 215 | 216 | build_read_mock.assert_awaited_once_with(config, headers, cookies, query_params) 217 | 218 | @pytest.mark.parametrize('arguments', [ 219 | {'json_data': [('foo', 'bar')], 'form': [('foo', 'bar')]}, 220 | {'json_data': [('foo', 'bar')], 'raw': b'boom'}, 221 | {'raw': b'boom', 'form': [('foo', 'bar')]}, 222 | {'json_data': [('foo', 'bar')], 'form': [('foo', 'bar')], 'raw': b'boom'}, 223 | ]) 224 | async def test_should_raise_error_when_setting_more_than_one_data_type(self, arguments): 225 | with pytest.raises(click.UsageError) as exc_info: 226 | await build_write_method_arguments(Configuration(), **arguments) 227 | 228 | message = 'you cannot mix different types of data, you must choose between one between form, json or raw' 229 | assert message == str(exc_info.value) 230 | 231 | async def test_should_return_data_dict_and_files_info_when_given_form_info_as_input(self, tmp_path): 232 | path = tmp_path / 'file.txt' 233 | message = 'just a text file' 234 | path.write_text('just a text file') 235 | form = (('foo', 'bar'), ('file', f'@{path}')) 236 | arguments = await build_write_method_arguments(Configuration(), form=form) 237 | 238 | assert arguments['data'] == {'foo': 'bar'} 239 | files = arguments['files'] 240 | assert len(files) == 1 241 | assert files['file'].read() == message.encode() 242 | 243 | async def test_should_return_json_dict_when_given_json_data_as_input(self): 244 | json_data = (('foo', 'bar'),) 245 | arguments = await build_write_method_arguments(Configuration(), json_data=json_data) 246 | 247 | assert arguments['json'] == dict(json_data) 248 | 249 | async def test_should_return_raw_data_when_given_raw_data_as_input(self): 250 | raw = b'hello' 251 | arguments = await build_write_method_arguments(Configuration(), raw=raw) 252 | 253 | assert arguments['content'] == raw 254 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "3.3.1" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | optional = false 8 | python-versions = ">=3.6.2" 9 | files = [ 10 | {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"}, 11 | {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"}, 12 | ] 13 | 14 | [package.dependencies] 15 | idna = ">=2.8" 16 | sniffio = ">=1.1" 17 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 18 | 19 | [package.extras] 20 | doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 21 | test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] 22 | trio = ["trio (>=0.16)"] 23 | 24 | [[package]] 25 | name = "async-generator" 26 | version = "1.10" 27 | description = "Async generators and context managers for Python 3.5+" 28 | optional = false 29 | python-versions = ">=3.5" 30 | files = [ 31 | {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, 32 | {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, 33 | ] 34 | 35 | [[package]] 36 | name = "asyncclick" 37 | version = "8.0.1.3" 38 | description = "Composable command line interface toolkit, async version" 39 | optional = false 40 | python-versions = ">=3.6" 41 | files = [ 42 | {file = "asyncclick-8.0.1.3.tar.gz", hash = "sha256:30e4d61ad272640e21d3d819ac57facddf07437e431589f1e64a967a64f562aa"}, 43 | ] 44 | 45 | [package.dependencies] 46 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 47 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 48 | 49 | [[package]] 50 | name = "atomicwrites" 51 | version = "1.4.0" 52 | description = "Atomic file writes." 53 | optional = false 54 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 55 | files = [ 56 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 57 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 58 | ] 59 | 60 | [[package]] 61 | name = "attrs" 62 | version = "21.2.0" 63 | description = "Classes Without Boilerplate" 64 | optional = false 65 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 66 | files = [ 67 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 68 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 69 | ] 70 | 71 | [package.extras] 72 | dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] 73 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 74 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] 75 | tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] 76 | 77 | [[package]] 78 | name = "bandit" 79 | version = "1.7.0" 80 | description = "Security oriented static analyser for python code." 81 | optional = false 82 | python-versions = ">=3.5" 83 | files = [ 84 | {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"}, 85 | {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, 86 | ] 87 | 88 | [package.dependencies] 89 | colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} 90 | GitPython = ">=1.0.1" 91 | PyYAML = ">=5.3.1" 92 | six = ">=1.10.0" 93 | stevedore = ">=1.20.0" 94 | 95 | [[package]] 96 | name = "certifi" 97 | version = "2021.5.30" 98 | description = "Python package for providing Mozilla's CA Bundle." 99 | optional = false 100 | python-versions = "*" 101 | files = [ 102 | {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, 103 | {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, 104 | ] 105 | 106 | [[package]] 107 | name = "cffi" 108 | version = "1.14.6" 109 | description = "Foreign Function Interface for Python calling C code." 110 | optional = false 111 | python-versions = "*" 112 | files = [ 113 | {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, 114 | {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, 115 | {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, 116 | {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, 117 | {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, 118 | {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, 119 | {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, 120 | {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, 121 | {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, 122 | {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, 123 | {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, 124 | {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, 125 | {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, 126 | {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, 127 | {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, 128 | {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, 129 | {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, 130 | {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, 131 | {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, 132 | {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, 133 | {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, 134 | {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, 135 | {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, 136 | {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, 137 | {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, 138 | {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, 139 | {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, 140 | {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, 141 | {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, 142 | {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, 143 | {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, 144 | {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, 145 | {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, 146 | {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, 147 | {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, 148 | {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, 149 | {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, 150 | {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, 151 | {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, 152 | {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, 153 | {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, 154 | {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, 155 | {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, 156 | {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, 157 | {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, 158 | ] 159 | 160 | [package.dependencies] 161 | pycparser = "*" 162 | 163 | [[package]] 164 | name = "charset-normalizer" 165 | version = "2.0.4" 166 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 167 | optional = false 168 | python-versions = ">=3.5.0" 169 | files = [ 170 | {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, 171 | {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, 172 | ] 173 | 174 | [package.extras] 175 | unicode-backport = ["unicodedata2"] 176 | 177 | [[package]] 178 | name = "colorama" 179 | version = "0.4.4" 180 | description = "Cross-platform colored terminal text." 181 | optional = false 182 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 183 | files = [ 184 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 185 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 186 | ] 187 | 188 | [[package]] 189 | name = "commonmark" 190 | version = "0.9.1" 191 | description = "Python parser for the CommonMark Markdown spec" 192 | optional = false 193 | python-versions = "*" 194 | files = [ 195 | {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, 196 | {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, 197 | ] 198 | 199 | [package.extras] 200 | test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] 201 | 202 | [[package]] 203 | name = "coverage" 204 | version = "5.5" 205 | description = "Code coverage measurement for Python" 206 | optional = false 207 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 208 | files = [ 209 | {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, 210 | {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, 211 | {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, 212 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, 213 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, 214 | {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, 215 | {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, 216 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, 217 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, 218 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, 219 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, 220 | {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, 221 | {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, 222 | {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, 223 | {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, 224 | {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, 225 | {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, 226 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, 227 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, 228 | {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, 229 | {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, 230 | {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, 231 | {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, 232 | {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, 233 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, 234 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, 235 | {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, 236 | {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, 237 | {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, 238 | {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, 239 | {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, 240 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, 241 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, 242 | {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, 243 | {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, 244 | {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, 245 | {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, 246 | {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, 247 | {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, 248 | {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, 249 | {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, 250 | {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, 251 | {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, 252 | {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, 253 | {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, 254 | {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, 255 | {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, 256 | {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, 257 | {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, 258 | {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, 259 | {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, 260 | {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, 261 | ] 262 | 263 | [package.extras] 264 | toml = ["toml"] 265 | 266 | [[package]] 267 | name = "flake8" 268 | version = "3.9.2" 269 | description = "the modular source code checker: pep8 pyflakes and co" 270 | optional = false 271 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 272 | files = [ 273 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 274 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 275 | ] 276 | 277 | [package.dependencies] 278 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 279 | mccabe = ">=0.6.0,<0.7.0" 280 | pycodestyle = ">=2.7.0,<2.8.0" 281 | pyflakes = ">=2.3.0,<2.4.0" 282 | 283 | [[package]] 284 | name = "gitdb" 285 | version = "4.0.7" 286 | description = "Git Object Database" 287 | optional = false 288 | python-versions = ">=3.4" 289 | files = [ 290 | {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, 291 | {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, 292 | ] 293 | 294 | [package.dependencies] 295 | smmap = ">=3.0.1,<5" 296 | 297 | [[package]] 298 | name = "gitpython" 299 | version = "3.1.41" 300 | description = "GitPython is a Python library used to interact with Git repositories" 301 | optional = false 302 | python-versions = ">=3.7" 303 | files = [ 304 | {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, 305 | {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, 306 | ] 307 | 308 | [package.dependencies] 309 | gitdb = ">=4.0.1,<5" 310 | typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} 311 | 312 | [package.extras] 313 | test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] 314 | 315 | [[package]] 316 | name = "h11" 317 | version = "0.12.0" 318 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 319 | optional = false 320 | python-versions = ">=3.6" 321 | files = [ 322 | {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, 323 | {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, 324 | ] 325 | 326 | [[package]] 327 | name = "h2" 328 | version = "4.0.0" 329 | description = "HTTP/2 State-Machine based protocol implementation" 330 | optional = false 331 | python-versions = ">=3.6.1" 332 | files = [ 333 | {file = "h2-4.0.0-py3-none-any.whl", hash = "sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25"}, 334 | {file = "h2-4.0.0.tar.gz", hash = "sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"}, 335 | ] 336 | 337 | [package.dependencies] 338 | hpack = ">=4.0,<5" 339 | hyperframe = ">=6.0,<7" 340 | 341 | [[package]] 342 | name = "hpack" 343 | version = "4.0.0" 344 | description = "Pure-Python HPACK header compression" 345 | optional = false 346 | python-versions = ">=3.6.1" 347 | files = [ 348 | {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, 349 | {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, 350 | ] 351 | 352 | [[package]] 353 | name = "httpcore" 354 | version = "0.13.6" 355 | description = "A minimal low-level HTTP client." 356 | optional = false 357 | python-versions = ">=3.6" 358 | files = [ 359 | {file = "httpcore-0.13.6-py3-none-any.whl", hash = "sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff"}, 360 | {file = "httpcore-0.13.6.tar.gz", hash = "sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e"}, 361 | ] 362 | 363 | [package.dependencies] 364 | anyio = "==3.*" 365 | h11 = ">=0.11,<0.13" 366 | sniffio = "==1.*" 367 | 368 | [package.extras] 369 | http2 = ["h2 (>=3,<5)"] 370 | 371 | [[package]] 372 | name = "httpx" 373 | version = "0.19.0" 374 | description = "The next generation HTTP client." 375 | optional = false 376 | python-versions = ">=3.6" 377 | files = [ 378 | {file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"}, 379 | {file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"}, 380 | ] 381 | 382 | [package.dependencies] 383 | certifi = "*" 384 | charset-normalizer = "*" 385 | h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} 386 | httpcore = ">=0.13.3,<0.14.0" 387 | rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} 388 | sniffio = "*" 389 | 390 | [package.extras] 391 | brotli = ["brotli", "brotlicffi"] 392 | http2 = ["h2 (>=3,<5)"] 393 | 394 | [[package]] 395 | name = "hypercorn" 396 | version = "0.11.2" 397 | description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn." 398 | optional = false 399 | python-versions = ">=3.7" 400 | files = [ 401 | {file = "Hypercorn-0.11.2-py3-none-any.whl", hash = "sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821"}, 402 | {file = "Hypercorn-0.11.2.tar.gz", hash = "sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a"}, 403 | ] 404 | 405 | [package.dependencies] 406 | h11 = "*" 407 | h2 = ">=3.1.0" 408 | priority = "*" 409 | toml = "*" 410 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 411 | wsproto = ">=0.14.0" 412 | 413 | [package.extras] 414 | h3 = ["aioquic (>=0.9.0,<1.0)"] 415 | tests = ["hypothesis", "mock", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"] 416 | trio = ["trio (>=0.11.0)"] 417 | uvloop = ["uvloop"] 418 | 419 | [[package]] 420 | name = "hyperframe" 421 | version = "6.0.1" 422 | description = "HTTP/2 framing layer for Python" 423 | optional = false 424 | python-versions = ">=3.6.1" 425 | files = [ 426 | {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, 427 | {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, 428 | ] 429 | 430 | [[package]] 431 | name = "idna" 432 | version = "3.2" 433 | description = "Internationalized Domain Names in Applications (IDNA)" 434 | optional = false 435 | python-versions = ">=3.5" 436 | files = [ 437 | {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, 438 | {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, 439 | ] 440 | 441 | [[package]] 442 | name = "importlib-metadata" 443 | version = "4.8.1" 444 | description = "Read metadata from Python packages" 445 | optional = false 446 | python-versions = ">=3.6" 447 | files = [ 448 | {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, 449 | {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, 450 | ] 451 | 452 | [package.dependencies] 453 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 454 | zipp = ">=0.5" 455 | 456 | [package.extras] 457 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 458 | perf = ["ipython"] 459 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy", "pytest-perf (>=0.9.2)"] 460 | 461 | [[package]] 462 | name = "iniconfig" 463 | version = "1.1.1" 464 | description = "iniconfig: brain-dead simple config-ini parsing" 465 | optional = false 466 | python-versions = "*" 467 | files = [ 468 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 469 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 470 | ] 471 | 472 | [[package]] 473 | name = "mccabe" 474 | version = "0.6.1" 475 | description = "McCabe checker, plugin for flake8" 476 | optional = false 477 | python-versions = "*" 478 | files = [ 479 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 480 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 481 | ] 482 | 483 | [[package]] 484 | name = "mock" 485 | version = "4.0.3" 486 | description = "Rolling backport of unittest.mock for all Pythons" 487 | optional = false 488 | python-versions = ">=3.6" 489 | files = [ 490 | {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, 491 | {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, 492 | ] 493 | 494 | [package.extras] 495 | build = ["blurb", "twine", "wheel"] 496 | docs = ["sphinx"] 497 | test = ["pytest (<5.4)", "pytest-cov"] 498 | 499 | [[package]] 500 | name = "outcome" 501 | version = "1.1.0" 502 | description = "Capture the outcome of Python function calls." 503 | optional = false 504 | python-versions = ">=3.6" 505 | files = [ 506 | {file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"}, 507 | {file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"}, 508 | ] 509 | 510 | [package.dependencies] 511 | attrs = ">=19.2.0" 512 | 513 | [[package]] 514 | name = "packaging" 515 | version = "21.0" 516 | description = "Core utilities for Python packages" 517 | optional = false 518 | python-versions = ">=3.6" 519 | files = [ 520 | {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, 521 | {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, 522 | ] 523 | 524 | [package.dependencies] 525 | pyparsing = ">=2.0.2" 526 | 527 | [[package]] 528 | name = "pbr" 529 | version = "5.6.0" 530 | description = "Python Build Reasonableness" 531 | optional = false 532 | python-versions = ">=2.6" 533 | files = [ 534 | {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"}, 535 | {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, 536 | ] 537 | 538 | [[package]] 539 | name = "pluggy" 540 | version = "1.0.0" 541 | description = "plugin and hook calling mechanisms for python" 542 | optional = false 543 | python-versions = ">=3.6" 544 | files = [ 545 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 546 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 547 | ] 548 | 549 | [package.dependencies] 550 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 551 | 552 | [package.extras] 553 | dev = ["pre-commit", "tox"] 554 | testing = ["pytest", "pytest-benchmark"] 555 | 556 | [[package]] 557 | name = "priority" 558 | version = "2.0.0" 559 | description = "A pure-Python implementation of the HTTP/2 priority tree" 560 | optional = false 561 | python-versions = ">=3.6.1" 562 | files = [ 563 | {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, 564 | {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, 565 | ] 566 | 567 | [[package]] 568 | name = "py" 569 | version = "1.10.0" 570 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 571 | optional = false 572 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 573 | files = [ 574 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 575 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 576 | ] 577 | 578 | [[package]] 579 | name = "pycodestyle" 580 | version = "2.7.0" 581 | description = "Python style guide checker" 582 | optional = false 583 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 584 | files = [ 585 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 586 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 587 | ] 588 | 589 | [[package]] 590 | name = "pycparser" 591 | version = "2.20" 592 | description = "C parser in Python" 593 | optional = false 594 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 595 | files = [ 596 | {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, 597 | {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, 598 | ] 599 | 600 | [[package]] 601 | name = "pydantic" 602 | version = "1.8.2" 603 | description = "Data validation and settings management using python 3.6 type hinting" 604 | optional = false 605 | python-versions = ">=3.6.1" 606 | files = [ 607 | {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, 608 | {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, 609 | {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, 610 | {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, 611 | {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, 612 | {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, 613 | {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, 614 | {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, 615 | {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, 616 | {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, 617 | {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, 618 | {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, 619 | {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, 620 | {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, 621 | {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, 622 | {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, 623 | {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, 624 | {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, 625 | {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, 626 | {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, 627 | {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, 628 | {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, 629 | ] 630 | 631 | [package.dependencies] 632 | python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""} 633 | typing-extensions = ">=3.7.4.3" 634 | 635 | [package.extras] 636 | dotenv = ["python-dotenv (>=0.10.4)"] 637 | email = ["email-validator (>=1.0.3)"] 638 | 639 | [[package]] 640 | name = "pyflakes" 641 | version = "2.3.1" 642 | description = "passive checker of Python programs" 643 | optional = false 644 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 645 | files = [ 646 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 647 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 648 | ] 649 | 650 | [[package]] 651 | name = "pygments" 652 | version = "2.10.0" 653 | description = "Pygments is a syntax highlighting package written in Python." 654 | optional = false 655 | python-versions = ">=3.5" 656 | files = [ 657 | {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, 658 | {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, 659 | ] 660 | 661 | [[package]] 662 | name = "pyparsing" 663 | version = "2.4.7" 664 | description = "Python parsing module" 665 | optional = false 666 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 667 | files = [ 668 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 669 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 670 | ] 671 | 672 | [[package]] 673 | name = "pytest" 674 | version = "6.2.5" 675 | description = "pytest: simple powerful testing with Python" 676 | optional = false 677 | python-versions = ">=3.6" 678 | files = [ 679 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 680 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 681 | ] 682 | 683 | [package.dependencies] 684 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 685 | attrs = ">=19.2.0" 686 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 687 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 688 | iniconfig = "*" 689 | packaging = "*" 690 | pluggy = ">=0.12,<2.0" 691 | py = ">=1.8.2" 692 | toml = "*" 693 | 694 | [package.extras] 695 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 696 | 697 | [[package]] 698 | name = "pytest-cov" 699 | version = "2.12.1" 700 | description = "Pytest plugin for measuring coverage." 701 | optional = false 702 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 703 | files = [ 704 | {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, 705 | {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, 706 | ] 707 | 708 | [package.dependencies] 709 | coverage = ">=5.2.1" 710 | pytest = ">=4.6" 711 | toml = "*" 712 | 713 | [package.extras] 714 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 715 | 716 | [[package]] 717 | name = "pytest-mock" 718 | version = "3.6.1" 719 | description = "Thin-wrapper around the mock package for easier use with pytest" 720 | optional = false 721 | python-versions = ">=3.6" 722 | files = [ 723 | {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, 724 | {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, 725 | ] 726 | 727 | [package.dependencies] 728 | pytest = ">=5.0" 729 | 730 | [package.extras] 731 | dev = ["pre-commit", "pytest-asyncio", "tox"] 732 | 733 | [[package]] 734 | name = "pytest-trio" 735 | version = "0.7.0" 736 | description = "Pytest plugin for trio" 737 | optional = false 738 | python-versions = ">=3.6" 739 | files = [ 740 | {file = "pytest-trio-0.7.0.tar.gz", hash = "sha256:c01b741819aec2c419555f28944e132d3c711dae1e673d63260809bf92c30c31"}, 741 | ] 742 | 743 | [package.dependencies] 744 | async_generator = ">=1.9" 745 | outcome = "*" 746 | pytest = ">=3.6" 747 | trio = ">=0.15.0" 748 | 749 | [[package]] 750 | name = "python-dotenv" 751 | version = "0.19.0" 752 | description = "Read key-value pairs from a .env file and set them as environment variables" 753 | optional = false 754 | python-versions = ">=3.5" 755 | files = [ 756 | {file = "python-dotenv-0.19.0.tar.gz", hash = "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"}, 757 | {file = "python_dotenv-0.19.0-py2.py3-none-any.whl", hash = "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1"}, 758 | ] 759 | 760 | [package.extras] 761 | cli = ["click (>=5.0)"] 762 | 763 | [[package]] 764 | name = "pyyaml" 765 | version = "5.4.1" 766 | description = "YAML parser and emitter for Python" 767 | optional = false 768 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 769 | files = [ 770 | {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, 771 | {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, 772 | {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, 773 | {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, 774 | {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, 775 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, 776 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, 777 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, 778 | {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, 779 | {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, 780 | {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, 781 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, 782 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, 783 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, 784 | {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, 785 | {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, 786 | {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, 787 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, 788 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, 789 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, 790 | {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, 791 | {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, 792 | {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, 793 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, 794 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, 795 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, 796 | {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, 797 | {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, 798 | {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, 799 | ] 800 | 801 | [[package]] 802 | name = "respx" 803 | version = "0.17.1" 804 | description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." 805 | optional = false 806 | python-versions = ">=3.6" 807 | files = [ 808 | {file = "respx-0.17.1-py2.py3-none-any.whl", hash = "sha256:34b28dacaa8e0c1bced38d9d183d7633df1f7c06db9802b9157bafa68a11755b"}, 809 | {file = "respx-0.17.1.tar.gz", hash = "sha256:7bde9b6f311ba51f4651618ccd4c5034df628fe44bc28102b98235c429df68fb"}, 810 | ] 811 | 812 | [package.dependencies] 813 | httpx = ">=0.18.0" 814 | 815 | [[package]] 816 | name = "rfc3986" 817 | version = "1.5.0" 818 | description = "Validating URI References per RFC 3986" 819 | optional = false 820 | python-versions = "*" 821 | files = [ 822 | {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, 823 | {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, 824 | ] 825 | 826 | [package.dependencies] 827 | idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} 828 | 829 | [package.extras] 830 | idna2008 = ["idna"] 831 | 832 | [[package]] 833 | name = "rich" 834 | version = "10.9.0" 835 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 836 | optional = false 837 | python-versions = ">=3.6,<4.0" 838 | files = [ 839 | {file = "rich-10.9.0-py3-none-any.whl", hash = "sha256:2c84d9b3459c16bf413fe0f9644c7ae1791971e0bb944dfae56e7c7634b187ab"}, 840 | {file = "rich-10.9.0.tar.gz", hash = "sha256:ba285f1c519519490034284e6a9d2e6e3f16dc7690f2de3d9140737d81304d22"}, 841 | ] 842 | 843 | [package.dependencies] 844 | colorama = ">=0.4.0,<0.5.0" 845 | commonmark = ">=0.9.0,<0.10.0" 846 | pygments = ">=2.6.0,<3.0.0" 847 | typing-extensions = {version = ">=3.7.4,<4.0.0", markers = "python_version < \"3.8\""} 848 | 849 | [package.extras] 850 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] 851 | 852 | [[package]] 853 | name = "shellingham" 854 | version = "1.4.0" 855 | description = "Tool to Detect Surrounding Shell" 856 | optional = false 857 | python-versions = "!=3.0,!=3.1,!=3.2,!=3.3,>=2.6" 858 | files = [ 859 | {file = "shellingham-1.4.0-py2.py3-none-any.whl", hash = "sha256:536b67a0697f2e4af32ab176c00a50ac2899c5a05e0d8e2dadac8e58888283f9"}, 860 | {file = "shellingham-1.4.0.tar.gz", hash = "sha256:4855c2458d6904829bd34c299f11fdeed7cfefbf8a2c522e4caea6cd76b3171e"}, 861 | ] 862 | 863 | [[package]] 864 | name = "six" 865 | version = "1.16.0" 866 | description = "Python 2 and 3 compatibility utilities" 867 | optional = false 868 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 869 | files = [ 870 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 871 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 872 | ] 873 | 874 | [[package]] 875 | name = "smmap" 876 | version = "4.0.0" 877 | description = "A pure Python implementation of a sliding window memory map manager" 878 | optional = false 879 | python-versions = ">=3.5" 880 | files = [ 881 | {file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"}, 882 | {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"}, 883 | ] 884 | 885 | [[package]] 886 | name = "sniffio" 887 | version = "1.2.0" 888 | description = "Sniff out which async library your code is running under" 889 | optional = false 890 | python-versions = ">=3.5" 891 | files = [ 892 | {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, 893 | {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, 894 | ] 895 | 896 | [[package]] 897 | name = "sortedcontainers" 898 | version = "2.4.0" 899 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 900 | optional = false 901 | python-versions = "*" 902 | files = [ 903 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 904 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 905 | ] 906 | 907 | [[package]] 908 | name = "starlette" 909 | version = "0.16.0" 910 | description = "The little ASGI library that shines." 911 | optional = false 912 | python-versions = ">=3.6" 913 | files = [ 914 | {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, 915 | {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, 916 | ] 917 | 918 | [package.dependencies] 919 | anyio = ">=3.0.0,<4" 920 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 921 | 922 | [package.extras] 923 | full = ["graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] 924 | 925 | [[package]] 926 | name = "stevedore" 927 | version = "3.4.0" 928 | description = "Manage dynamic plugins for Python applications" 929 | optional = false 930 | python-versions = ">=3.6" 931 | files = [ 932 | {file = "stevedore-3.4.0-py3-none-any.whl", hash = "sha256:920ce6259f0b2498aaa4545989536a27e4e4607b8318802d7ddc3a533d3d069e"}, 933 | {file = "stevedore-3.4.0.tar.gz", hash = "sha256:59b58edb7f57b11897f150475e7bc0c39c5381f0b8e3fa9f5c20ce6c89ec4aa1"}, 934 | ] 935 | 936 | [package.dependencies] 937 | importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""} 938 | pbr = ">=2.0.0,<2.1.0 || >2.1.0" 939 | 940 | [[package]] 941 | name = "toml" 942 | version = "0.10.2" 943 | description = "Python Library for Tom's Obvious, Minimal Language" 944 | optional = false 945 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 946 | files = [ 947 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 948 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 949 | ] 950 | 951 | [[package]] 952 | name = "trio" 953 | version = "0.19.0" 954 | description = "A friendly Python library for async concurrency and I/O" 955 | optional = false 956 | python-versions = ">=3.6" 957 | files = [ 958 | {file = "trio-0.19.0-py3-none-any.whl", hash = "sha256:c27c231e66336183c484fbfe080fa6cc954149366c15dc21db8b7290081ec7b8"}, 959 | {file = "trio-0.19.0.tar.gz", hash = "sha256:895e318e5ec5e8cea9f60b473b6edb95b215e82d99556a03eb2d20c5e027efe1"}, 960 | ] 961 | 962 | [package.dependencies] 963 | async-generator = ">=1.9" 964 | attrs = ">=19.2.0" 965 | cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} 966 | idna = "*" 967 | outcome = "*" 968 | sniffio = "*" 969 | sortedcontainers = "*" 970 | 971 | [[package]] 972 | name = "typing-extensions" 973 | version = "3.10.0.2" 974 | description = "Backported and Experimental Type Hints for Python 3.5+" 975 | optional = false 976 | python-versions = "*" 977 | files = [ 978 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 979 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 980 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 981 | ] 982 | 983 | [[package]] 984 | name = "uvloop" 985 | version = "0.16.0" 986 | description = "Fast implementation of asyncio event loop on top of libuv" 987 | optional = false 988 | python-versions = ">=3.7" 989 | files = [ 990 | {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"}, 991 | {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"}, 992 | {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"}, 993 | {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"}, 994 | {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"}, 995 | {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"}, 996 | {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"}, 997 | {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"}, 998 | {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"}, 999 | {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"}, 1000 | {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"}, 1001 | {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"}, 1002 | {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"}, 1003 | {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"}, 1004 | {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"}, 1005 | {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"}, 1006 | ] 1007 | 1008 | [package.extras] 1009 | dev = ["Cython (>=0.29.24,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=19.0.0,<19.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] 1010 | docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] 1011 | test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=19.0.0,<19.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] 1012 | 1013 | [[package]] 1014 | name = "wsproto" 1015 | version = "1.0.0" 1016 | description = "WebSockets state-machine based protocol implementation" 1017 | optional = false 1018 | python-versions = ">=3.6.1" 1019 | files = [ 1020 | {file = "wsproto-1.0.0-py3-none-any.whl", hash = "sha256:d8345d1808dd599b5ffb352c25a367adb6157e664e140dbecba3f9bc007edb9f"}, 1021 | {file = "wsproto-1.0.0.tar.gz", hash = "sha256:868776f8456997ad0d9720f7322b746bbe9193751b5b290b7f924659377c8c38"}, 1022 | ] 1023 | 1024 | [package.dependencies] 1025 | h11 = ">=0.9.0,<1" 1026 | 1027 | [[package]] 1028 | name = "zipp" 1029 | version = "3.5.0" 1030 | description = "Backport of pathlib-compatible object wrapper for zip files" 1031 | optional = false 1032 | python-versions = ">=3.6" 1033 | files = [ 1034 | {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, 1035 | {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, 1036 | ] 1037 | 1038 | [package.extras] 1039 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 1040 | testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 1041 | 1042 | [metadata] 1043 | lock-version = "2.0" 1044 | python-versions = "^3.7" 1045 | content-hash = "99884c3cc3bdcf517f4fe1ba1f2c9a3d1325b050d540b3febb8f7a4e33364586" 1046 | --------------------------------------------------------------------------------