You've already forked httpie-cli
							
							
				mirror of
				https://github.com/httpie/cli.git
				synced 2025-10-30 23:47:52 +02:00 
			
		
		
		
	cmd: Implement httpie plugins interface (#1200)
This commit is contained in:
		| @@ -1880,6 +1880,11 @@ $ cat ~/.config/httpie/config.json | |||||||
|         3) echo 'Unexpected HTTP 3xx Redirection!' ;; |         3) echo 'Unexpected HTTP 3xx Redirection!' ;; | ||||||
|         4) echo 'HTTP 4xx Client Error!' ;; |         4) echo 'HTTP 4xx Client Error!' ;; | ||||||
|         5) echo 'HTTP 5xx Server Error!' ;; |         5) echo 'HTTP 5xx Server Error!' ;; | ||||||
|  |         6) echo 'Exceeded --max-redirects=<n> redirects!' ;; | ||||||
|  |         *) echo 'Other Error!' ;; | ||||||
|  |     esac | ||||||
|  | fi | ||||||
|  | ``` | ||||||
|  |  | ||||||
| ### Best practices | ### Best practices | ||||||
|  |  | ||||||
| @@ -1919,6 +1924,70 @@ And since there’s neither data nor `EOF`, it will get stuck. So unless you’r | |||||||
| > By default the plugins (and their missing dependencies) will be stored under the configuration directory, | > By default the plugins (and their missing dependencies) will be stored under the configuration directory, | ||||||
| but this can be modified through `plugins_dir` variable on the config. | but this can be modified through `plugins_dir` variable on the config. | ||||||
|  |  | ||||||
|  | #### `httpie plugins install` | ||||||
|  |  | ||||||
|  | For installing plugins from [PyPI](https://pypi.org/) or from local paths, `httpie plugins install` | ||||||
|  | can be used. | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | $ httpie plugins install httpie-plugin | ||||||
|  | Installing httpie-plugin... | ||||||
|  | Successfully installed httpie-plugin-1.0.2 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | > Tip: Generally HTTPie plugins start with `httpie-` prefix. Try searching for it on [PyPI](https://pypi.org/search/?q=httpie-) | ||||||
|  | > to find out all plugins from the community. | ||||||
|  |  | ||||||
|  | #### `httpie plugins list` | ||||||
|  |  | ||||||
|  | List all installed plugins. | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | $ httpie plugins list | ||||||
|  | httpie_plugin (1.0.2) | ||||||
|  |   httpie_plugin (httpie.plugins.auth.v1) | ||||||
|  | httpie_plugin_2 (1.0.6) | ||||||
|  |   httpie_plugin_2 (httpie.plugins.auth.v1) | ||||||
|  | httpie_converter (1.0.0) | ||||||
|  |   httpie_iterm_converter (httpie.plugins.converter.v1) | ||||||
|  |   httpie_konsole_konverter (httpie.plugins.converter.v1) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### `httpie plugins uninstall` | ||||||
|  |  | ||||||
|  | Uninstall plugins from the isolated plugins directory. If the plugin is not installed | ||||||
|  | through `httpie plugins install`, it won't uninstall it. | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | $ httpie plugins uninstall httpie-plugin | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Meta | ||||||
|  |  | ||||||
|  | ### Interface design | ||||||
|  |  | ||||||
|  | The syntax of the command arguments closely correspond to the actual HTTP requests sent over the wire. | ||||||
|  | It has the advantage that it’s easy to remember and read. | ||||||
|  | You can often translate an HTTP request to an HTTPie argument list just by inlining the request elements. | ||||||
|  | For example, compare this HTTP request: | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | POST /post HTTP/1.1 | ||||||
|  | Host: pie.dev | ||||||
|  | X-API-Key: 123 | ||||||
|  | User-Agent: Bacon/1.0 | ||||||
|  | Content-Type: application/x-www-form-urlencoded | ||||||
|  |  | ||||||
|  | name=value&name2=value2 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | with the HTTPie command that sends it: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | $ http -f POST pie.dev/post \ | ||||||
|  |     X-API-Key:123 \ | ||||||
|  |     User-Agent:Bacon/1.0 \ | ||||||
|  |     name=value \ | ||||||
|     name2=value2 |     name2=value2 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|   | |||||||
| @@ -17,6 +17,9 @@ exclude_rule 'MD013' | |||||||
| # MD014 Dollar signs used before commands without showing output | # MD014 Dollar signs used before commands without showing output | ||||||
| exclude_rule 'MD014' | exclude_rule 'MD014' | ||||||
|  |  | ||||||
|  | # MD028 Blank line inside blockquote | ||||||
|  | exclude_rule 'MD028' | ||||||
|  |  | ||||||
| # Tell the linter to use ordered lists: | # Tell the linter to use ordered lists: | ||||||
| #   1. Foo | #   1. Foo | ||||||
| #   2. Bar | #   2. Bar | ||||||
|   | |||||||
| @@ -50,7 +50,64 @@ class HTTPieHelpFormatter(RawDescriptionHelpFormatter): | |||||||
|  |  | ||||||
| # TODO: refactor and design type-annotated data structures | # TODO: refactor and design type-annotated data structures | ||||||
| #       for raw args + parsed args and keep things immutable. | #       for raw args + parsed args and keep things immutable. | ||||||
| class HTTPieArgumentParser(argparse.ArgumentParser): | class BaseHTTPieArgumentParser(argparse.ArgumentParser): | ||||||
|  |     def __init__(self, *args, formatter_class=HTTPieHelpFormatter, **kwargs): | ||||||
|  |         super().__init__(*args, formatter_class=formatter_class, **kwargs) | ||||||
|  |         self.env = None | ||||||
|  |         self.args = None | ||||||
|  |         self.has_stdin_data = False | ||||||
|  |         self.has_input_data = False | ||||||
|  |  | ||||||
|  |     # noinspection PyMethodOverriding | ||||||
|  |     def parse_args( | ||||||
|  |         self, | ||||||
|  |         env: Environment, | ||||||
|  |         args=None, | ||||||
|  |         namespace=None | ||||||
|  |     ) -> argparse.Namespace: | ||||||
|  |         self.env = env | ||||||
|  |         self.args, no_options = self.parse_known_args(args, namespace) | ||||||
|  |         if self.args.debug: | ||||||
|  |             self.args.traceback = True | ||||||
|  |         self.has_stdin_data = ( | ||||||
|  |             self.env.stdin | ||||||
|  |             and not getattr(self.args, 'ignore_stdin', False) | ||||||
|  |             and not self.env.stdin_isatty | ||||||
|  |         ) | ||||||
|  |         self.has_input_data = self.has_stdin_data or getattr(self.args, 'raw', None) is not None | ||||||
|  |         return self.args | ||||||
|  |  | ||||||
|  |     # noinspection PyShadowingBuiltins | ||||||
|  |     def _print_message(self, message, file=None): | ||||||
|  |         # Sneak in our stderr/stdout. | ||||||
|  |         if hasattr(self, 'root'): | ||||||
|  |             env = self.root.env | ||||||
|  |         else: | ||||||
|  |             env = self.env | ||||||
|  |  | ||||||
|  |         if env is not None: | ||||||
|  |             file = { | ||||||
|  |                 sys.stdout: env.stdout, | ||||||
|  |                 sys.stderr: env.stderr, | ||||||
|  |                 None: env.stderr | ||||||
|  |             }.get(file, file) | ||||||
|  |  | ||||||
|  |         if not hasattr(file, 'buffer') and isinstance(message, str): | ||||||
|  |             message = message.encode(env.stdout_encoding) | ||||||
|  |         super()._print_message(message, file) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class HTTPieManagerArgumentParser(BaseHTTPieArgumentParser): | ||||||
|  |     def parse_known_args(self, args=None, namespace=None): | ||||||
|  |         try: | ||||||
|  |             return super().parse_known_args(args, namespace) | ||||||
|  |         except SystemExit as exc: | ||||||
|  |             if not hasattr(self, 'root') and exc.code == 2:  # Argument Parser Error | ||||||
|  |                 raise argparse.ArgumentError(None, None) | ||||||
|  |             raise | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class HTTPieArgumentParser(BaseHTTPieArgumentParser): | ||||||
|     """Adds additional logic to `argparse.ArgumentParser`. |     """Adds additional logic to `argparse.ArgumentParser`. | ||||||
|  |  | ||||||
|     Handles all input (CLI args, file args, stdin), applies defaults, |     Handles all input (CLI args, file args, stdin), applies defaults, | ||||||
| @@ -58,13 +115,9 @@ class HTTPieArgumentParser(argparse.ArgumentParser): | |||||||
|  |  | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, *args, formatter_class=HTTPieHelpFormatter, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         kwargs['add_help'] = False |         kwargs.setdefault('add_help', False) | ||||||
|         super().__init__(*args, formatter_class=formatter_class, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         self.env = None |  | ||||||
|         self.args = None |  | ||||||
|         self.has_stdin_data = False |  | ||||||
|         self.has_input_data = False |  | ||||||
|  |  | ||||||
|     # noinspection PyMethodOverriding |     # noinspection PyMethodOverriding | ||||||
|     def parse_args( |     def parse_args( | ||||||
| @@ -141,18 +194,6 @@ class HTTPieArgumentParser(argparse.ArgumentParser): | |||||||
|             else: |             else: | ||||||
|                 self.args.url = scheme + self.args.url |                 self.args.url = scheme + self.args.url | ||||||
|  |  | ||||||
|     # noinspection PyShadowingBuiltins |  | ||||||
|     def _print_message(self, message, file=None): |  | ||||||
|         # Sneak in our stderr/stdout. |  | ||||||
|         file = { |  | ||||||
|             sys.stdout: self.env.stdout, |  | ||||||
|             sys.stderr: self.env.stderr, |  | ||||||
|             None: self.env.stderr |  | ||||||
|         }.get(file, file) |  | ||||||
|         if not hasattr(file, 'buffer') and isinstance(message, str): |  | ||||||
|             message = message.encode(self.env.stdout_encoding) |  | ||||||
|         super()._print_message(message, file) |  | ||||||
|  |  | ||||||
|     def _setup_standard_streams(self): |     def _setup_standard_streams(self): | ||||||
|         """ |         """ | ||||||
|         Modify `env.stdout` and `env.stdout_isatty` based on args, if needed. |         Modify `env.stdout` and `env.stdout_isatty` based on args, if needed. | ||||||
|   | |||||||
| @@ -25,7 +25,7 @@ from ..output.formatters.colors import ( | |||||||
| from ..plugins.builtin import BuiltinAuthPlugin | from ..plugins.builtin import BuiltinAuthPlugin | ||||||
| from ..plugins.registry import plugin_manager | from ..plugins.registry import plugin_manager | ||||||
| from ..sessions import DEFAULT_SESSIONS_DIR | from ..sessions import DEFAULT_SESSIONS_DIR | ||||||
| from ..ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS | from ..ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS | ||||||
|  |  | ||||||
|  |  | ||||||
| parser = HTTPieArgumentParser( | parser = HTTPieArgumentParser( | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ from .encoding import UTF8 | |||||||
| from .models import RequestsMessage | from .models import RequestsMessage | ||||||
| from .plugins.registry import plugin_manager | from .plugins.registry import plugin_manager | ||||||
| from .sessions import get_httpie_session | from .sessions import get_httpie_session | ||||||
| from .ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter | from .ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter | ||||||
| from .uploads import ( | from .uploads import ( | ||||||
|     compress_request, prepare_request_body, |     compress_request, prepare_request_body, | ||||||
|     get_multipart_data_and_content_type, |     get_multipart_data_and_content_type, | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import sys | import sys | ||||||
|  | from typing import Any, Optional, Iterable | ||||||
|  |  | ||||||
|  |  | ||||||
| is_windows = 'win32' in str(sys.platform).lower() | is_windows = 'win32' in str(sys.platform).lower() | ||||||
| @@ -52,3 +53,38 @@ except ImportError: | |||||||
|                 return self |                 return self | ||||||
|             res = instance.__dict__[self.name] = self.func(instance) |             res = instance.__dict__[self.name] = self.func(instance) | ||||||
|             return res |             return res | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # importlib_metadata was a provisional module, so the APIs changed quite a few times | ||||||
|  | # between 3.8-3.10. It was also not included in the standard library until 3.8, so | ||||||
|  | # we install the backport for <3.8. | ||||||
|  |  | ||||||
|  | if sys.version_info >= (3, 8): | ||||||
|  |     import importlib.metadata as importlib_metadata | ||||||
|  | else: | ||||||
|  |     import importlib_metadata | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def find_entry_points(entry_points: Any, group: str) -> Iterable[importlib_metadata.EntryPoint]: | ||||||
|  |     if hasattr(entry_points, "select"):  # Python 3.10+ / importlib_metadata >= 3.9.0 | ||||||
|  |         return entry_points.select(group=group) | ||||||
|  |     else: | ||||||
|  |         return set(entry_points.get(group, ())) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_dist_name(entry_point: importlib_metadata.EntryPoint) -> Optional[str]: | ||||||
|  |     dist = getattr(entry_point, "dist", None) | ||||||
|  |     if dist is not None:  # Python 3.10+ | ||||||
|  |         return dist.name | ||||||
|  |  | ||||||
|  |     match = entry_point.pattern.match(entry_point.value) | ||||||
|  |     if not (match and match.group('module')): | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     package = match.group('module').split('.')[0] | ||||||
|  |     try: | ||||||
|  |         metadata = importlib_metadata.metadata(package) | ||||||
|  |     except importlib_metadata.PackageNotFoundError: | ||||||
|  |         return None | ||||||
|  |     else: | ||||||
|  |         return metadata.get('name') | ||||||
|   | |||||||
| @@ -128,3 +128,7 @@ class Config(BaseConfigDict): | |||||||
|     @property |     @property | ||||||
|     def default_options(self) -> list: |     def default_options(self) -> list: | ||||||
|         return self['default_options'] |         return self['default_options'] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def plugins_dir(self) -> Path: | ||||||
|  |         return Path(self.get('plugins_dir', self.directory / 'plugins')).resolve() | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import sys | import sys | ||||||
| import os | import os | ||||||
|  | from contextlib import contextmanager | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import IO, Optional | from typing import Iterator, IO, Optional | ||||||
|  |  | ||||||
|  |  | ||||||
| try: | try: | ||||||
| @@ -120,6 +121,19 @@ class Environment: | |||||||
|             self._devnull = open(os.devnull, 'w+') |             self._devnull = open(os.devnull, 'w+') | ||||||
|         return self._devnull |         return self._devnull | ||||||
|  |  | ||||||
|  |     @contextmanager | ||||||
|  |     def as_silent(self) -> Iterator[None]: | ||||||
|  |         original_stdout = self.stdout | ||||||
|  |         original_stderr = self.stderr | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             self.stdout = self.devnull | ||||||
|  |             self.stderr = self.devnull | ||||||
|  |             yield | ||||||
|  |         finally: | ||||||
|  |             self.stdout = original_stdout | ||||||
|  |             self.stderr = original_stderr | ||||||
|  |  | ||||||
|     def log_error(self, msg, level='error'): |     def log_error(self, msg, level='error'): | ||||||
|         assert level in ['error', 'warning'] |         assert level in ['error', 'warning'] | ||||||
|         self._orig_stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n') |         self._orig_stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n') | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import argparse | |||||||
| import os | import os | ||||||
| import platform | import platform | ||||||
| import sys | import sys | ||||||
| from typing import List, Optional, Tuple, Union | from typing import List, Optional, Tuple, Union, Callable | ||||||
|  |  | ||||||
| import requests | import requests | ||||||
| from pygments import __version__ as pygments_version | from pygments import __version__ as pygments_version | ||||||
| @@ -24,22 +24,16 @@ from .status import ExitStatus, http_status_to_exit_status | |||||||
|  |  | ||||||
|  |  | ||||||
| # noinspection PyDefaultArgument | # noinspection PyDefaultArgument | ||||||
| def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitStatus: | def raw_main( | ||||||
|     """ |     parser: argparse.ArgumentParser, | ||||||
|     The main function. |     main_program: Callable[[argparse.Namespace, Environment], ExitStatus], | ||||||
|  |     args: List[Union[str, bytes]] = sys.argv, | ||||||
|     Pre-process args, handle some special types of invocations, |     env: Environment = Environment() | ||||||
|     and run the main program with error handling. | ) -> ExitStatus: | ||||||
|  |  | ||||||
|     Return exit status code. |  | ||||||
|  |  | ||||||
|     """ |  | ||||||
|     program_name, *args = args |     program_name, *args = args | ||||||
|     env.program_name = os.path.basename(program_name) |     env.program_name = os.path.basename(program_name) | ||||||
|     args = decode_raw_args(args, env.stdin_encoding) |     args = decode_raw_args(args, env.stdin_encoding) | ||||||
|     plugin_manager.load_installed_plugins() |     plugin_manager.load_installed_plugins(env.config.plugins_dir) | ||||||
|  |  | ||||||
|     from .cli.definition import parser |  | ||||||
|  |  | ||||||
|     if env.config.default_options: |     if env.config.default_options: | ||||||
|         args = env.config.default_options + args |         args = env.config.default_options + args | ||||||
| @@ -72,7 +66,7 @@ def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitSta | |||||||
|             exit_status = ExitStatus.ERROR |             exit_status = ExitStatus.ERROR | ||||||
|     else: |     else: | ||||||
|         try: |         try: | ||||||
|             exit_status = program( |             exit_status = main_program( | ||||||
|                 args=parsed_args, |                 args=parsed_args, | ||||||
|                 env=env, |                 env=env, | ||||||
|             ) |             ) | ||||||
| @@ -114,6 +108,30 @@ def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitSta | |||||||
|     return exit_status |     return exit_status | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def main( | ||||||
|  |     args: List[Union[str, bytes]] = sys.argv, | ||||||
|  |     env: Environment = Environment() | ||||||
|  | ) -> ExitStatus: | ||||||
|  |     """ | ||||||
|  |     The main function. | ||||||
|  |  | ||||||
|  |     Pre-process args, handle some special types of invocations, | ||||||
|  |     and run the main program with error handling. | ||||||
|  |  | ||||||
|  |     Return exit status code. | ||||||
|  |  | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     from .cli.definition import parser | ||||||
|  |  | ||||||
|  |     return raw_main( | ||||||
|  |         parser=parser, | ||||||
|  |         main_program=program, | ||||||
|  |         args=args, | ||||||
|  |         env=env | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_output_options( | def get_output_options( | ||||||
|     args: argparse.Namespace, |     args: argparse.Namespace, | ||||||
|     message: RequestsMessage |     message: RequestsMessage | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								httpie/manager/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								httpie/manager/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										61
									
								
								httpie/manager/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								httpie/manager/__main__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | import argparse | ||||||
|  | import sys | ||||||
|  |  | ||||||
|  | from typing import List, Union | ||||||
|  |  | ||||||
|  | from httpie.context import Environment | ||||||
|  | from httpie.status import ExitStatus | ||||||
|  | from httpie.manager.cli import parser | ||||||
|  | from httpie.manager.core import MSG_COMMAND_CONFUSION, program as main_program | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def is_http_command(args: List[Union[str, bytes]], env: Environment) -> bool: | ||||||
|  |     """Check whether http/https parser can parse the arguments.""" | ||||||
|  |  | ||||||
|  |     from httpie.cli.definition import parser as http_parser | ||||||
|  |     from httpie.manager.cli import COMMANDS | ||||||
|  |  | ||||||
|  |     # If the user already selected a top-level sub-command, never | ||||||
|  |     # show the http/https version. E.g httpie plugins pie.dev/post | ||||||
|  |     if len(args) >= 1 and args[0] in COMMANDS: | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     with env.as_silent(): | ||||||
|  |         try: | ||||||
|  |             http_parser.parse_args(env=env, args=args) | ||||||
|  |         except (Exception, SystemExit): | ||||||
|  |             return False | ||||||
|  |         else: | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def main(args: List[Union[str, bytes]] = sys.argv, env: Environment = Environment()) -> ExitStatus: | ||||||
|  |     from httpie.core import raw_main | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         return raw_main( | ||||||
|  |             parser=parser, | ||||||
|  |             main_program=main_program, | ||||||
|  |             args=args, | ||||||
|  |             env=env | ||||||
|  |         ) | ||||||
|  |     except argparse.ArgumentError: | ||||||
|  |         program_args = args[1:] | ||||||
|  |         if is_http_command(program_args, env): | ||||||
|  |             env.stderr.write(MSG_COMMAND_CONFUSION.format(args=' '.join(program_args)) + "\n") | ||||||
|  |  | ||||||
|  |         return ExitStatus.ERROR | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def program(): | ||||||
|  |     try: | ||||||
|  |         exit_status = main() | ||||||
|  |     except KeyboardInterrupt: | ||||||
|  |         from httpie.status import ExitStatus | ||||||
|  |         exit_status = ExitStatus.ERROR_CTRL_C | ||||||
|  |  | ||||||
|  |     return exit_status | ||||||
|  |  | ||||||
|  |  | ||||||
|  | if __name__ == '__main__':  # pragma: nocover | ||||||
|  |     sys.exit(program()) | ||||||
							
								
								
									
										93
									
								
								httpie/manager/cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								httpie/manager/cli.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | |||||||
|  | from textwrap import dedent | ||||||
|  | from httpie.cli.argparser import HTTPieManagerArgumentParser | ||||||
|  |  | ||||||
|  | COMMANDS = { | ||||||
|  |     'plugins': { | ||||||
|  |         'help': 'Manage HTTPie plugins.', | ||||||
|  |         'install': [ | ||||||
|  |             'Install the given targets from PyPI ' | ||||||
|  |             'or from a local paths.', | ||||||
|  |             { | ||||||
|  |                 'dest': 'targets', | ||||||
|  |                 'nargs': '+', | ||||||
|  |                 'help': 'targets to install' | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         'uninstall': [ | ||||||
|  |             'Uninstall the given HTTPie plugins.', | ||||||
|  |             { | ||||||
|  |                 'dest': 'targets', | ||||||
|  |                 'nargs': '+', | ||||||
|  |                 'help': 'targets to install' | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         'list': [ | ||||||
|  |             'List all installed HTTPie plugins.' | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def missing_subcommand(*args) -> str: | ||||||
|  |     base = COMMANDS | ||||||
|  |     for arg in args: | ||||||
|  |         base = base[arg] | ||||||
|  |  | ||||||
|  |     assert isinstance(base, dict) | ||||||
|  |     subcommands = ', '.join(map(repr, base.keys())) | ||||||
|  |     return f'Please specify one of these: {subcommands}' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def generate_subparsers(root, parent_parser, definitions): | ||||||
|  |     action_dest = '_'.join(parent_parser.prog.split()[1:] + ['action']) | ||||||
|  |     actions = parent_parser.add_subparsers( | ||||||
|  |         dest=action_dest | ||||||
|  |     ) | ||||||
|  |     for command, properties in definitions.items(): | ||||||
|  |         is_subparser = isinstance(properties, dict) | ||||||
|  |         descr = properties.pop('help', None) if is_subparser else properties.pop(0) | ||||||
|  |         command_parser = actions.add_parser(command, description=descr) | ||||||
|  |         command_parser.root = root | ||||||
|  |         if is_subparser: | ||||||
|  |             generate_subparsers(root, command_parser, properties) | ||||||
|  |             continue | ||||||
|  |  | ||||||
|  |         for argument in properties: | ||||||
|  |             command_parser.add_argument(**argument) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | parser = HTTPieManagerArgumentParser( | ||||||
|  |     prog='httpie', | ||||||
|  |     description=dedent( | ||||||
|  |         ''' | ||||||
|  |         Managing interface for the HTTPie itself. <https://httpie.io/docs#manager> | ||||||
|  |  | ||||||
|  |         Be aware that you might be looking for http/https commands for sending | ||||||
|  |         HTTP requests. This command is only available for managing the HTTTPie | ||||||
|  |         plugins and the configuration around it. | ||||||
|  |         ''' | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | parser.add_argument( | ||||||
|  |     '--debug', | ||||||
|  |     action='store_true', | ||||||
|  |     default=False, | ||||||
|  |     help=''' | ||||||
|  |     Prints the exception traceback should one occur, as well as other | ||||||
|  |     information useful for debugging HTTPie itself and for reporting bugs. | ||||||
|  |  | ||||||
|  |     ''' | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | parser.add_argument( | ||||||
|  |     '--traceback', | ||||||
|  |     action='store_true', | ||||||
|  |     default=False, | ||||||
|  |     help=''' | ||||||
|  |     Prints the exception traceback should one occur. | ||||||
|  |  | ||||||
|  |     ''' | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | generate_subparsers(parser, parser, COMMANDS) | ||||||
							
								
								
									
										33
									
								
								httpie/manager/core.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								httpie/manager/core.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | import argparse | ||||||
|  |  | ||||||
|  | from httpie.context import Environment | ||||||
|  | from httpie.manager.plugins import PluginInstaller | ||||||
|  | from httpie.status import ExitStatus | ||||||
|  | from httpie.manager.cli import missing_subcommand, parser | ||||||
|  |  | ||||||
|  | MSG_COMMAND_CONFUSION = '''\ | ||||||
|  | This command is only for managing HTTPie plugins. | ||||||
|  | To send a request, please use the http/https commands: | ||||||
|  |  | ||||||
|  |   $ http {args} | ||||||
|  |  | ||||||
|  |   $ https {args} | ||||||
|  | ''' | ||||||
|  |  | ||||||
|  | # noinspection PyStringFormat | ||||||
|  | MSG_NAKED_INVOCATION = f'''\ | ||||||
|  | {missing_subcommand()} | ||||||
|  |  | ||||||
|  | {MSG_COMMAND_CONFUSION} | ||||||
|  | '''.rstrip("\n").format(args='POST pie.dev/post hello=world') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def program(args: argparse.Namespace, env: Environment) -> ExitStatus: | ||||||
|  |     if args.action is None: | ||||||
|  |         parser.error(MSG_NAKED_INVOCATION) | ||||||
|  |  | ||||||
|  |     if args.action == 'plugins': | ||||||
|  |         plugins = PluginInstaller(env, debug=args.debug) | ||||||
|  |         return plugins.run(args.plugins_action, args) | ||||||
|  |  | ||||||
|  |     return ExitStatus.SUCCESS | ||||||
							
								
								
									
										188
									
								
								httpie/manager/plugins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								httpie/manager/plugins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,188 @@ | |||||||
|  | import argparse | ||||||
|  | import os | ||||||
|  | import subprocess | ||||||
|  | import sys | ||||||
|  | import textwrap | ||||||
|  | from collections import defaultdict | ||||||
|  | from contextlib import suppress | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Optional, List | ||||||
|  |  | ||||||
|  | import importlib_metadata | ||||||
|  |  | ||||||
|  | from httpie.manager.cli import parser, missing_subcommand | ||||||
|  | from httpie.compat import get_dist_name | ||||||
|  | from httpie.context import Environment | ||||||
|  | from httpie.status import ExitStatus | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PluginInstaller: | ||||||
|  |  | ||||||
|  |     def __init__(self, env: Environment, debug: bool = False) -> None: | ||||||
|  |         self.env = env | ||||||
|  |         self.dir = env.config.plugins_dir | ||||||
|  |         self.debug = debug | ||||||
|  |  | ||||||
|  |         self.setup_plugins_dir() | ||||||
|  |  | ||||||
|  |     def setup_plugins_dir(self) -> None: | ||||||
|  |         try: | ||||||
|  |             self.dir.mkdir( | ||||||
|  |                 exist_ok=True, | ||||||
|  |                 parents=True | ||||||
|  |             ) | ||||||
|  |         except OSError: | ||||||
|  |             self.env.stderr.write( | ||||||
|  |                 f'Couldn\'t create "{self.dir!s}"' | ||||||
|  |                 ' directory for plugin installation.' | ||||||
|  |                 ' Please re-check the permissions for that directory,' | ||||||
|  |                 ' and if needed, allow write-access.' | ||||||
|  |             ) | ||||||
|  |             raise | ||||||
|  |  | ||||||
|  |     def fail( | ||||||
|  |         self, | ||||||
|  |         command: str, | ||||||
|  |         target: Optional[str] = None, | ||||||
|  |         reason: Optional[str] = None | ||||||
|  |     ) -> ExitStatus: | ||||||
|  |         message = f'Can\'t {command}' | ||||||
|  |         if target: | ||||||
|  |             message += f' {target!r}' | ||||||
|  |         if reason: | ||||||
|  |             message += f': {reason}' | ||||||
|  |  | ||||||
|  |         self.env.stderr.write(message + '\n') | ||||||
|  |         return ExitStatus.ERROR | ||||||
|  |  | ||||||
|  |     def pip(self, *args, **kwargs) -> subprocess.CompletedProcess: | ||||||
|  |         options = { | ||||||
|  |             'check': True, | ||||||
|  |             'shell': False, | ||||||
|  |             'stdout': self.env.stdout, | ||||||
|  |             'stderr': subprocess.PIPE, | ||||||
|  |         } | ||||||
|  |         options.update(kwargs) | ||||||
|  |  | ||||||
|  |         cmd = [sys.executable, '-m', 'pip', *args] | ||||||
|  |         return subprocess.run( | ||||||
|  |             cmd, | ||||||
|  |             **options | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def install(self, targets: List[str]) -> Optional[ExitStatus]: | ||||||
|  |         self.env.stdout.write(f"Installing {', '.join(targets)}...\n") | ||||||
|  |         self.env.stdout.flush() | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             self.pip( | ||||||
|  |                 'install', | ||||||
|  |                 f'--prefix={self.dir}', | ||||||
|  |                 '--no-warn-script-location', | ||||||
|  |                 *targets, | ||||||
|  |             ) | ||||||
|  |         except subprocess.CalledProcessError as error: | ||||||
|  |             reason = None | ||||||
|  |             if error.stderr: | ||||||
|  |                 stderr = error.stderr.decode() | ||||||
|  |  | ||||||
|  |                 if self.debug: | ||||||
|  |                     self.env.stderr.write('Command failed: ') | ||||||
|  |                     self.env.stderr.write(' '.join(error.cmd) + '\n') | ||||||
|  |                     self.env.stderr.write(textwrap.indent('  ', stderr)) | ||||||
|  |  | ||||||
|  |                 last_line = stderr.strip().splitlines()[-1] | ||||||
|  |                 severity, _, message = last_line.partition(': ') | ||||||
|  |                 if severity == 'ERROR': | ||||||
|  |                     reason = message | ||||||
|  |  | ||||||
|  |             return self.fail('install', ', '.join(targets), reason) | ||||||
|  |  | ||||||
|  |     def _uninstall(self, target: str) -> Optional[ExitStatus]: | ||||||
|  |         try: | ||||||
|  |             distribution = importlib_metadata.distribution(target) | ||||||
|  |         except importlib_metadata.PackageNotFoundError: | ||||||
|  |             return self.fail('uninstall', target, 'package is not installed') | ||||||
|  |  | ||||||
|  |         base_dir = Path(distribution.locate_file('.')).resolve() | ||||||
|  |         if self.dir not in base_dir.parents: | ||||||
|  |             # If the package is installed somewhere else (e.g on the site packages | ||||||
|  |             # of the real python interpreter), than that means this package is not | ||||||
|  |             # installed through us. | ||||||
|  |             return self.fail('uninstall', target, | ||||||
|  |                              'package is not installed through httpie plugins' | ||||||
|  |                              ' interface') | ||||||
|  |  | ||||||
|  |         files = distribution.files | ||||||
|  |         if files is None: | ||||||
|  |             return self.fail('uninstall', target, 'couldn\'t locate the package') | ||||||
|  |  | ||||||
|  |         # TODO: Consider handling failures here (e.g if it fails, | ||||||
|  |         # just rever the operation and leave the site-packages | ||||||
|  |         # in a proper shape). | ||||||
|  |         for file in files: | ||||||
|  |             with suppress(FileNotFoundError): | ||||||
|  |                 os.unlink(distribution.locate_file(file)) | ||||||
|  |  | ||||||
|  |         metadata_path = getattr(distribution, '_path', None) | ||||||
|  |         if ( | ||||||
|  |             metadata_path | ||||||
|  |             and metadata_path.exists() | ||||||
|  |             and not any(metadata_path.iterdir()) | ||||||
|  |         ): | ||||||
|  |             metadata_path.rmdir() | ||||||
|  |  | ||||||
|  |         self.env.stdout.write(f'Successfully uninstalled {target}\n') | ||||||
|  |  | ||||||
|  |     def uninstall(self, targets: List[str]) -> ExitStatus: | ||||||
|  |         # Unfortunately uninstall doesn't work with custom pip schemes. See: | ||||||
|  |         # - https://github.com/pypa/pip/issues/5595 | ||||||
|  |         # - https://github.com/pypa/pip/issues/4575 | ||||||
|  |         # so we have to implement our own uninstalling logic. Which works | ||||||
|  |         # on top of the importlib_metadata. | ||||||
|  |  | ||||||
|  |         exit_code = ExitStatus.SUCCESS | ||||||
|  |         for target in targets: | ||||||
|  |             exit_code |= self._uninstall(target) or ExitStatus.SUCCESS | ||||||
|  |         return ExitStatus(exit_code) | ||||||
|  |  | ||||||
|  |     def list(self) -> None: | ||||||
|  |         from httpie.plugins.registry import plugin_manager | ||||||
|  |  | ||||||
|  |         known_plugins = defaultdict(list) | ||||||
|  |  | ||||||
|  |         for entry_point in plugin_manager.iter_entry_points(self.dir): | ||||||
|  |             ep_info = (entry_point.group, entry_point.name) | ||||||
|  |             ep_name = get_dist_name(entry_point) or entry_point.module | ||||||
|  |             known_plugins[ep_name].append(ep_info) | ||||||
|  |  | ||||||
|  |         for plugin, entry_points in known_plugins.items(): | ||||||
|  |             self.env.stdout.write(plugin) | ||||||
|  |  | ||||||
|  |             version = importlib_metadata.version(plugin) | ||||||
|  |             if version is not None: | ||||||
|  |                 self.env.stdout.write(f' ({version})') | ||||||
|  |             self.env.stdout.write('\n') | ||||||
|  |  | ||||||
|  |             for group, entry_point in sorted(entry_points): | ||||||
|  |                 self.env.stdout.write(f'  {entry_point} ({group})\n') | ||||||
|  |  | ||||||
|  |     def run( | ||||||
|  |         self, | ||||||
|  |         action: Optional[str], | ||||||
|  |         args: argparse.Namespace, | ||||||
|  |     ) -> ExitStatus: | ||||||
|  |         from httpie.plugins.manager import enable_plugins | ||||||
|  |  | ||||||
|  |         if action is None: | ||||||
|  |             parser.error(missing_subcommand('plugins')) | ||||||
|  |  | ||||||
|  |         with enable_plugins(self.dir): | ||||||
|  |             if action == 'install': | ||||||
|  |                 status = self.install(args.targets) | ||||||
|  |             elif action == 'uninstall': | ||||||
|  |                 status = self.uninstall(args.targets) | ||||||
|  |             elif action == 'list': | ||||||
|  |                 status = self.list() | ||||||
|  |  | ||||||
|  |         return status or ExitStatus.SUCCESS | ||||||
| @@ -1,24 +1,55 @@ | |||||||
|  | import sys | ||||||
|  | import os | ||||||
|  |  | ||||||
| from itertools import groupby | from itertools import groupby | ||||||
| from operator import attrgetter | from operator import attrgetter | ||||||
| from typing import Dict, List, Type | from typing import Dict, List, Type, Iterator, TypeVar, Optional, ContextManager | ||||||
|  | from pathlib import Path | ||||||
|  | from contextlib import contextmanager | ||||||
|  |  | ||||||
| from pkg_resources import iter_entry_points | from ..compat import importlib_metadata, find_entry_points, get_dist_name | ||||||
|  |  | ||||||
| from ..utils import repr_dict | from ..utils import repr_dict, as_site | ||||||
| from . import AuthPlugin, ConverterPlugin, FormatterPlugin | from . import AuthPlugin, ConverterPlugin, FormatterPlugin, TransportPlugin | ||||||
| from .base import BasePlugin, TransportPlugin | from .base import BasePlugin | ||||||
|  |  | ||||||
|  |  | ||||||
| ENTRY_POINT_NAMES = [ | ENTRY_POINT_CLASSES = { | ||||||
|     'httpie.plugins.auth.v1', |     'httpie.plugins.auth.v1': AuthPlugin, | ||||||
|     'httpie.plugins.formatter.v1', |     'httpie.plugins.converter.v1': ConverterPlugin, | ||||||
|     'httpie.plugins.converter.v1', |     'httpie.plugins.formatter.v1': FormatterPlugin, | ||||||
|     'httpie.plugins.transport.v1', |     'httpie.plugins.transport.v1': TransportPlugin | ||||||
| ] | } | ||||||
|  | ENTRY_POINT_NAMES = list(ENTRY_POINT_CLASSES.keys()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @contextmanager | ||||||
|  | def _load_directory(plugins_dir: Path) -> Iterator[None]: | ||||||
|  |     plugins_path = os.fspath(plugins_dir) | ||||||
|  |     sys.path.insert(0, plugins_path) | ||||||
|  |     try: | ||||||
|  |         yield | ||||||
|  |     finally: | ||||||
|  |         sys.path.remove(plugins_path) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | T = TypeVar("T") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @contextmanager | ||||||
|  | def nullcontext(obj: Optional[T] = None) -> Iterator[Optional[T]]: | ||||||
|  |     # A naive replacement of the nullcontext() for 3.6 | ||||||
|  |     yield obj | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def enable_plugins(plugins_dir: Optional[Path]) -> ContextManager[None]: | ||||||
|  |     if plugins_dir is None: | ||||||
|  |         return nullcontext() | ||||||
|  |     else: | ||||||
|  |         return _load_directory(as_site(plugins_dir)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PluginManager(list): | class PluginManager(list): | ||||||
|  |  | ||||||
|     def register(self, *plugins: Type[BasePlugin]): |     def register(self, *plugins: Type[BasePlugin]): | ||||||
|         for plugin in plugins: |         for plugin in plugins: | ||||||
|             self.append(plugin) |             self.append(plugin) | ||||||
| @@ -29,12 +60,18 @@ class PluginManager(list): | |||||||
|     def filter(self, by_type=Type[BasePlugin]): |     def filter(self, by_type=Type[BasePlugin]): | ||||||
|         return [plugin for plugin in self if issubclass(plugin, by_type)] |         return [plugin for plugin in self if issubclass(plugin, by_type)] | ||||||
|  |  | ||||||
|     def load_installed_plugins(self): |     def iter_entry_points(self, directory: Optional[Path] = None): | ||||||
|         for entry_point_name in ENTRY_POINT_NAMES: |         with enable_plugins(directory): | ||||||
|             for entry_point in iter_entry_points(entry_point_name): |             eps = importlib_metadata.entry_points() | ||||||
|                 plugin = entry_point.load() |  | ||||||
|                 plugin.package_name = entry_point.dist.key |             for entry_point_name in ENTRY_POINT_NAMES: | ||||||
|                 self.register(entry_point.load()) |                 yield from find_entry_points(eps, group=entry_point_name) | ||||||
|  |  | ||||||
|  |     def load_installed_plugins(self, directory: Optional[Path] = None): | ||||||
|  |         for entry_point in self.iter_entry_points(directory): | ||||||
|  |             plugin = entry_point.load() | ||||||
|  |             plugin.package_name = get_dist_name(entry_point) | ||||||
|  |             self.register(entry_point.load()) | ||||||
|  |  | ||||||
|     # Auth |     # Auth | ||||||
|     def get_auth_plugins(self) -> List[Type[AuthPlugin]]: |     def get_auth_plugins(self) -> List[Type[AuthPlugin]]: | ||||||
|   | |||||||
| @@ -3,8 +3,11 @@ import mimetypes | |||||||
| import re | import re | ||||||
| import sys | import sys | ||||||
| import time | import time | ||||||
|  | import sysconfig | ||||||
|  |  | ||||||
| from collections import OrderedDict | from collections import OrderedDict | ||||||
| from http.cookiejar import parse_ns_headers | from http.cookiejar import parse_ns_headers | ||||||
|  | from pathlib import Path | ||||||
| from pprint import pformat | from pprint import pformat | ||||||
| from typing import Any, List, Optional, Tuple | from typing import Any, List, Optional, Tuple | ||||||
|  |  | ||||||
| @@ -207,3 +210,11 @@ def parse_content_type_header(header): | |||||||
|                 value = param[index_of_equals + 1:].strip(items_to_strip) |                 value = param[index_of_equals + 1:].strip(items_to_strip) | ||||||
|             params_dict[key.lower()] = value |             params_dict[key.lower()] = value | ||||||
|     return content_type, params_dict |     return content_type, params_dict | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def as_site(path: Path) -> Path: | ||||||
|  |     site_packages_path = sysconfig.get_path( | ||||||
|  |         'purelib', | ||||||
|  |         vars={'base': str(path)} | ||||||
|  |     ) | ||||||
|  |     return Path(site_packages_path) | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								setup.py
									
									
									
									
									
								
							| @@ -35,6 +35,7 @@ install_requires = [ | |||||||
|     'requests-toolbelt>=0.9.1', |     'requests-toolbelt>=0.9.1', | ||||||
|     'multidict>=4.7.0', |     'multidict>=4.7.0', | ||||||
|     'setuptools', |     'setuptools', | ||||||
|  |     'importlib-metadata>=1.4.0', | ||||||
| ] | ] | ||||||
| install_requires_win_only = [ | install_requires_win_only = [ | ||||||
|     'colorama>=0.2.4', |     'colorama>=0.2.4', | ||||||
| @@ -80,6 +81,7 @@ setup( | |||||||
|         'console_scripts': [ |         'console_scripts': [ | ||||||
|             'http = httpie.__main__:main', |             'http = httpie.__main__:main', | ||||||
|             'https = httpie.__main__:main', |             'https = httpie.__main__:main', | ||||||
|  |             'httpie = httpie.manager.__main__:main', | ||||||
|         ], |         ], | ||||||
|     }, |     }, | ||||||
|     python_requires='>=3.6', |     python_requires='>=3.6', | ||||||
|   | |||||||
| @@ -5,6 +5,13 @@ import pytest | |||||||
| from pytest_httpbin import certs | from pytest_httpbin import certs | ||||||
|  |  | ||||||
| from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT | from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT | ||||||
|  | from .utils.plugins_cli import ( # noqa | ||||||
|  |     dummy_plugin, | ||||||
|  |     dummy_plugins, | ||||||
|  |     httpie_plugins, | ||||||
|  |     httpie_plugins_success, | ||||||
|  |     interface, | ||||||
|  | ) | ||||||
| from .utils.http_server import http_server # noqa | from .utils.http_server import http_server # noqa | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										132
									
								
								tests/test_plugins_cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								tests/test_plugins_cli.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from httpie.status import ExitStatus | ||||||
|  | from tests.utils import httpie | ||||||
|  | from tests.utils.plugins_cli import parse_listing | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_installation(httpie_plugins_success, interface, dummy_plugin): | ||||||
|  |     lines = httpie_plugins_success('install', dummy_plugin.path) | ||||||
|  |     assert lines[0].startswith( | ||||||
|  |         f'Installing {dummy_plugin.path}' | ||||||
|  |     ) | ||||||
|  |     assert f'Successfully installed {dummy_plugin.name}-{dummy_plugin.version}' in lines | ||||||
|  |     assert interface.is_installed(dummy_plugin.name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_listing(httpie_plugins_success, interface, dummy_plugin): | ||||||
|  |     httpie_plugins_success('install', dummy_plugin.path) | ||||||
|  |     data = parse_listing(httpie_plugins_success('list')) | ||||||
|  |  | ||||||
|  |     assert data == { | ||||||
|  |         dummy_plugin.name: dummy_plugin.dump() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_listing_multiple(interface, httpie_plugins_success, dummy_plugins): | ||||||
|  |     paths = [plugin.path for plugin in dummy_plugins] | ||||||
|  |     httpie_plugins_success('install', *paths) | ||||||
|  |     data = parse_listing(httpie_plugins_success('list')) | ||||||
|  |  | ||||||
|  |     assert data == { | ||||||
|  |         plugin.name: plugin.dump() | ||||||
|  |         for plugin in dummy_plugins | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_uninstall(interface, httpie_plugins_success, dummy_plugin): | ||||||
|  |     httpie_plugins_success('install', dummy_plugin.path) | ||||||
|  |     httpie_plugins_success('uninstall', dummy_plugin.name) | ||||||
|  |     assert not interface.is_installed(dummy_plugin.name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_listing_after_uninstall(interface, httpie_plugins_success, dummy_plugin): | ||||||
|  |     httpie_plugins_success('install', dummy_plugin.path) | ||||||
|  |     httpie_plugins_success('uninstall', dummy_plugin.name) | ||||||
|  |  | ||||||
|  |     data = parse_listing(httpie_plugins_success('list')) | ||||||
|  |     assert len(data) == 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_uninstall_specific(interface, httpie_plugins_success): | ||||||
|  |     new_plugin_1 = interface.make_dummy_plugin() | ||||||
|  |     new_plugin_2 = interface.make_dummy_plugin() | ||||||
|  |     target_plugin = interface.make_dummy_plugin() | ||||||
|  |  | ||||||
|  |     httpie_plugins_success('install', new_plugin_1.path, new_plugin_2.path, target_plugin.path) | ||||||
|  |     httpie_plugins_success('uninstall', target_plugin.name) | ||||||
|  |  | ||||||
|  |     assert interface.is_installed(new_plugin_1.name) | ||||||
|  |     assert interface.is_installed(new_plugin_2.name) | ||||||
|  |     assert not interface.is_installed(target_plugin.name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_installation_failed(httpie_plugins, interface): | ||||||
|  |     plugin = interface.make_dummy_plugin(build=False) | ||||||
|  |     result = httpie_plugins('install', plugin.path) | ||||||
|  |  | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert result.stderr.splitlines()[-1].strip().startswith("Can't install") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_uninstall_non_existent(httpie_plugins, interface): | ||||||
|  |     plugin = interface.make_dummy_plugin(build=False) | ||||||
|  |     result = httpie_plugins('uninstall', plugin.name) | ||||||
|  |  | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert ( | ||||||
|  |         result.stderr.splitlines()[-1].strip() | ||||||
|  |         == f"Can't uninstall '{plugin.name}': package is not installed" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_double_uninstall(httpie_plugins, httpie_plugins_success, dummy_plugin): | ||||||
|  |     httpie_plugins_success("install", dummy_plugin.path) | ||||||
|  |     httpie_plugins_success("uninstall", dummy_plugin.name) | ||||||
|  |  | ||||||
|  |     result = httpie_plugins("uninstall", dummy_plugin.name) | ||||||
|  |  | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert ( | ||||||
|  |         result.stderr.splitlines()[-1].strip() | ||||||
|  |         == f"Can't uninstall '{dummy_plugin.name}': package is not installed" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_plugins_cli_error_message_without_args(): | ||||||
|  |     # No arguments | ||||||
|  |     result = httpie(no_debug=True) | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert 'usage: ' in result.stderr | ||||||
|  |     assert 'specify one of these' in result.stderr | ||||||
|  |     assert 'please use the http/https commands:' in result.stderr | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'example', [ | ||||||
|  |         'pie.dev/get', | ||||||
|  |         'DELETE localhost:8000/delete', | ||||||
|  |         'POST pie.dev/post header:value a=b header_2:value x:=1' | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | def test_plugins_cli_error_messages_with_example(example): | ||||||
|  |     result = httpie(*example.split(), no_debug=True) | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert 'usage: ' in result.stderr | ||||||
|  |     assert f'http {example}' in result.stderr | ||||||
|  |     assert f'https {example}' in result.stderr | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'example', [ | ||||||
|  |         'plugins unknown', | ||||||
|  |         'plugins unknown.com A:B c=d', | ||||||
|  |         'unknown.com UNPARSABLE????SYNTAX', | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | def test_plugins_cli_error_messages_invalid_example(example): | ||||||
|  |     result = httpie(*example.split(), no_debug=True) | ||||||
|  |     assert result.exit_status == ExitStatus.ERROR | ||||||
|  |     assert 'usage: ' in result.stderr | ||||||
|  |     assert f'http {example}' not in result.stderr | ||||||
|  |     assert f'https {example}' not in result.stderr | ||||||
| @@ -6,7 +6,7 @@ import pytest_httpbin.certs | |||||||
| import requests.exceptions | import requests.exceptions | ||||||
| import urllib3 | import urllib3 | ||||||
|  |  | ||||||
| from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS | from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS | ||||||
| from httpie.status import ExitStatus | from httpie.status import ExitStatus | ||||||
|  |  | ||||||
| from .utils import HTTP_OK, TESTS_ROOT, http | from .utils import HTTP_OK, TESTS_ROOT, http | ||||||
|   | |||||||
| @@ -7,12 +7,14 @@ import json | |||||||
| import tempfile | import tempfile | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Optional, Union, List | from typing import Any, Optional, Union, List, Iterable | ||||||
|  |  | ||||||
|  | import httpie.core as core | ||||||
|  | import httpie.manager.__main__ as manager | ||||||
|  |  | ||||||
| from httpie.status import ExitStatus | from httpie.status import ExitStatus | ||||||
| from httpie.config import Config | from httpie.config import Config | ||||||
| from httpie.context import Environment | from httpie.context import Environment | ||||||
| from httpie.core import main |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # pytest-httpbin currently does not support chunked requests: | # pytest-httpbin currently does not support chunked requests: | ||||||
| @@ -58,10 +60,10 @@ class MockEnvironment(Environment): | |||||||
|     stdout_isatty = True |     stdout_isatty = True | ||||||
|     is_windows = False |     is_windows = False | ||||||
|  |  | ||||||
|     def __init__(self, create_temp_config_dir=True, **kwargs): |     def __init__(self, create_temp_config_dir=True, *, stdout_mode='b', **kwargs): | ||||||
|         if 'stdout' not in kwargs: |         if 'stdout' not in kwargs: | ||||||
|             kwargs['stdout'] = tempfile.TemporaryFile( |             kwargs['stdout'] = tempfile.TemporaryFile( | ||||||
|                 mode='w+b', |                 mode=f'w+{stdout_mode}', | ||||||
|                 prefix='httpie_stdout' |                 prefix='httpie_stdout' | ||||||
|             ) |             ) | ||||||
|         if 'stderr' not in kwargs: |         if 'stderr' not in kwargs: | ||||||
| @@ -177,6 +179,46 @@ class ExitStatusError(Exception): | |||||||
|     pass |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def normalize_args(args: Iterable[Any]) -> List[str]: | ||||||
|  |     return [str(arg) for arg in args] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def httpie( | ||||||
|  |     *args, | ||||||
|  |     **kwargs | ||||||
|  | ) -> StrCLIResponse: | ||||||
|  |     """ | ||||||
|  |     Run HTTPie manager command with the given | ||||||
|  |     args/kwargs, and capture stderr/out and exit | ||||||
|  |     status. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     env = kwargs.setdefault('env', MockEnvironment()) | ||||||
|  |     cli_args = ['httpie'] | ||||||
|  |     if not kwargs.pop('no_debug', False): | ||||||
|  |         cli_args.append('--debug') | ||||||
|  |     cli_args += normalize_args(args) | ||||||
|  |     exit_status = manager.main( | ||||||
|  |         args=cli_args, | ||||||
|  |         **kwargs | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     env.stdout.seek(0) | ||||||
|  |     env.stderr.seek(0) | ||||||
|  |     try: | ||||||
|  |         response = StrCLIResponse(env.stdout.read()) | ||||||
|  |         response.stderr = env.stderr.read() | ||||||
|  |         response.exit_status = exit_status | ||||||
|  |         response.args = cli_args | ||||||
|  |     finally: | ||||||
|  |         env.stdout.truncate(0) | ||||||
|  |         env.stderr.truncate(0) | ||||||
|  |         env.stdout.seek(0) | ||||||
|  |         env.stderr.seek(0) | ||||||
|  |  | ||||||
|  |     return response | ||||||
|  |  | ||||||
|  |  | ||||||
| def http( | def http( | ||||||
|     *args, |     *args, | ||||||
|     program_name='http', |     program_name='http', | ||||||
| @@ -254,7 +296,7 @@ def http( | |||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         try: |         try: | ||||||
|             exit_status = main(args=complete_args, **kwargs) |             exit_status = core.main(args=complete_args, **kwargs) | ||||||
|             if '--download' in args: |             if '--download' in args: | ||||||
|                 # Let the progress reporter thread finish. |                 # Let the progress reporter thread finish. | ||||||
|                 time.sleep(.5) |                 time.sleep(.5) | ||||||
|   | |||||||
							
								
								
									
										222
									
								
								tests/utils/plugins_cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								tests/utils/plugins_cli.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,222 @@ | |||||||
|  | import secrets | ||||||
|  | import site | ||||||
|  | import sys | ||||||
|  | import textwrap | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from collections import defaultdict | ||||||
|  | from dataclasses import dataclass, field, asdict | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Any, List, Dict, Tuple | ||||||
|  | from unittest.mock import patch | ||||||
|  |  | ||||||
|  | from httpie.context import Environment | ||||||
|  | from httpie.compat import importlib_metadata | ||||||
|  | from httpie.status import ExitStatus | ||||||
|  | from httpie.plugins.manager import ( | ||||||
|  |     enable_plugins, | ||||||
|  |     ENTRY_POINT_CLASSES as CLASSES, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def make_name() -> str: | ||||||
|  |     return 'httpie-' + secrets.token_hex(4) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class EntryPoint: | ||||||
|  |     name: str | ||||||
|  |     group: str | ||||||
|  |  | ||||||
|  |     def dump(self) -> Dict[str, str]: | ||||||
|  |         return asdict(self) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class Plugin: | ||||||
|  |     interface: 'Interface' | ||||||
|  |  | ||||||
|  |     name: str = field(default_factory=make_name) | ||||||
|  |     version: str = '1.0.0' | ||||||
|  |     entry_points: List[EntryPoint] = field(default_factory=list) | ||||||
|  |  | ||||||
|  |     def build(self) -> None: | ||||||
|  |         ''' | ||||||
|  |         Create an installable dummy plugin at the given path. | ||||||
|  |  | ||||||
|  |         It will create a setup.py with the specified entry points, | ||||||
|  |         as well as dummy classes in a python module to imitate | ||||||
|  |         real plugins. | ||||||
|  |         ''' | ||||||
|  |  | ||||||
|  |         groups = defaultdict(list) | ||||||
|  |         for entry_point in self.entry_points: | ||||||
|  |             groups[entry_point.group].append(entry_point.name) | ||||||
|  |  | ||||||
|  |         setup_eps = { | ||||||
|  |             group: [ | ||||||
|  |                 f'{name} = {self.import_name}:{name.title()}' | ||||||
|  |                 for name in names | ||||||
|  |             ] | ||||||
|  |             for group, names in groups.items() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         self.path.mkdir(parents=True, exist_ok=True) | ||||||
|  |         with open(self.path / 'setup.py', 'w') as stream: | ||||||
|  |             stream.write(textwrap.dedent(f''' | ||||||
|  |             from setuptools import setup | ||||||
|  |  | ||||||
|  |             setup( | ||||||
|  |                 name='{self.name}', | ||||||
|  |                 version='{self.version}', | ||||||
|  |                 py_modules=['{self.import_name}'], | ||||||
|  |                 entry_points={setup_eps!r}, | ||||||
|  |                 install_requires=['httpie'] | ||||||
|  |             ) | ||||||
|  |             ''')) | ||||||
|  |  | ||||||
|  |         with open(self.path / (self.import_name + '.py'), 'w') as stream: | ||||||
|  |             stream.write('from httpie.plugins import *\n') | ||||||
|  |             stream.writelines( | ||||||
|  |                 f'class {name.title()}({CLASSES[group].__name__}): ...\n' | ||||||
|  |                 for group, names in groups.items() | ||||||
|  |                 for name in names | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     def dump(self) -> Dict[str, Any]: | ||||||
|  |         return { | ||||||
|  |             'version': self.version, | ||||||
|  |             'entry_points': [ | ||||||
|  |                 entry_point.dump() | ||||||
|  |                 for entry_point in self.entry_points | ||||||
|  |             ] | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def path(self) -> Path: | ||||||
|  |         return self.interface.path / self.name | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def import_name(self) -> str: | ||||||
|  |         return self.name.replace('-', '_') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class Interface: | ||||||
|  |     path: Path | ||||||
|  |     environment: Environment | ||||||
|  |  | ||||||
|  |     def get_plugin(self, target: str) -> importlib_metadata.Distribution: | ||||||
|  |         with enable_plugins(self.environment.config.plugins_dir): | ||||||
|  |             return importlib_metadata.distribution(target) | ||||||
|  |  | ||||||
|  |     def is_installed(self, target: str) -> bool: | ||||||
|  |         try: | ||||||
|  |             self.get_plugin(target) | ||||||
|  |         except ModuleNotFoundError: | ||||||
|  |             return False | ||||||
|  |         else: | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |     def make_dummy_plugin(self, build=True, **kwargs) -> Plugin: | ||||||
|  |         kwargs.setdefault('entry_points', [EntryPoint('test', 'httpie.plugins.auth.v1')]) | ||||||
|  |  | ||||||
|  |         plugin = Plugin(self, **kwargs) | ||||||
|  |         if build: | ||||||
|  |             plugin.build() | ||||||
|  |         return plugin | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def parse_listing(lines: List[str]) -> Dict[str, Any]: | ||||||
|  |     plugins = {} | ||||||
|  |     current_plugin = None | ||||||
|  |  | ||||||
|  |     def parse_entry_point(line: str) -> Tuple[str, str]: | ||||||
|  |         entry_point, raw_group = line.strip().split() | ||||||
|  |         return entry_point, raw_group[1:-1] | ||||||
|  |  | ||||||
|  |     def parse_plugin(line: str) -> Tuple[str, str]: | ||||||
|  |         plugin, raw_version = line.strip().split() | ||||||
|  |         return plugin, raw_version[1:-1] | ||||||
|  |  | ||||||
|  |     for line in lines: | ||||||
|  |         if not line.strip(): | ||||||
|  |             continue | ||||||
|  |  | ||||||
|  |         if line[0].isspace(): | ||||||
|  |             # <indent> $entry_point ($group) | ||||||
|  |             assert current_plugin is not None | ||||||
|  |             entry_point, group = parse_entry_point(line) | ||||||
|  |             plugins[current_plugin]['entry_points'].append({ | ||||||
|  |                 'name': entry_point, | ||||||
|  |                 'group': group | ||||||
|  |             }) | ||||||
|  |         else: | ||||||
|  |             # $plugin ($version) | ||||||
|  |             current_plugin, version = parse_plugin(line) | ||||||
|  |             plugins[current_plugin] = { | ||||||
|  |                 'version': version, | ||||||
|  |                 'entry_points': [] | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |     return plugins | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture(scope='function') | ||||||
|  | def interface(tmp_path): | ||||||
|  |     from tests.utils import MockEnvironment | ||||||
|  |  | ||||||
|  |     return Interface( | ||||||
|  |         path=tmp_path / 'interface', | ||||||
|  |         environment=MockEnvironment(stdout_mode='t') | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture(scope='function') | ||||||
|  | def dummy_plugin(interface): | ||||||
|  |     return interface.make_dummy_plugin() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture(scope='function') | ||||||
|  | def dummy_plugins(interface): | ||||||
|  |     # Multiple plugins with different configurations | ||||||
|  |     return [ | ||||||
|  |         interface.make_dummy_plugin(), | ||||||
|  |         interface.make_dummy_plugin( | ||||||
|  |             version='3.2.0' | ||||||
|  |         ), | ||||||
|  |         interface.make_dummy_plugin( | ||||||
|  |             entry_points=[ | ||||||
|  |                 EntryPoint('test_1', 'httpie.plugins.converter.v1'), | ||||||
|  |                 EntryPoint('test_2', 'httpie.plugins.formatter.v1') | ||||||
|  |             ] | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def httpie_plugins(interface): | ||||||
|  |     from tests.utils import httpie | ||||||
|  |     from httpie.plugins.registry import plugin_manager | ||||||
|  |  | ||||||
|  |     def runner(*args): | ||||||
|  |         # Prevent installed plugins from showing up. | ||||||
|  |         original_plugins = plugin_manager.copy() | ||||||
|  |         clean_sys_path = set(sys.path).difference(site.getsitepackages()) | ||||||
|  |         with patch('sys.path', list(clean_sys_path)): | ||||||
|  |             response = httpie('plugins', *args, env=interface.environment) | ||||||
|  |         plugin_manager.clear() | ||||||
|  |         plugin_manager.extend(original_plugins) | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |     return runner | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def httpie_plugins_success(httpie_plugins): | ||||||
|  |     def runner(*args): | ||||||
|  |         response = httpie_plugins(*args) | ||||||
|  |         assert response.exit_status == ExitStatus.SUCCESS | ||||||
|  |         return response.splitlines() | ||||||
|  |     return runner | ||||||
		Reference in New Issue
	
	Block a user