diff --git a/granian/__main__.py b/granian/__main__.py index f1f1f30..0cf3a43 100644 --- a/granian/__main__.py +++ b/granian/__main__.py @@ -1,4 +1,4 @@ -from granian.cli import cli +from granian.cli import entrypoint -cli() +entrypoint() diff --git a/granian/cli.py b/granian/cli.py index db5bbad..b11a907 100644 --- a/granian/cli.py +++ b/granian/cli.py @@ -1,127 +1,227 @@ import json -from pathlib import Path -from typing import Optional +import pathlib +from enum import Enum +from typing import Any, Callable, Optional, Type, TypeVar, Union -import typer +import click -from . import __version__ from .constants import HTTPModes, Interfaces, Loops, ThreadModes from .http import HTTP1Settings, HTTP2Settings from .log import LogLevels from .server import Granian -cli = typer.Typer(name='granian', context_settings={'auto_envvar_prefix': 'GRANIAN', 'ignore_unknown_options': True}) - - -def version_callback(value: bool): - if value: - typer.echo(f'{cli.info.name} {__version__}') - raise typer.Exit() - - -@cli.command() -def main( - app: str = typer.Argument(..., help='Application target to serve'), - host: str = typer.Option('127.0.0.1', help='Host address to bind to'), - port: int = typer.Option(8000, help='Port to bind to.'), - interface: Interfaces = typer.Option(Interfaces.RSGI.value, help='Application interface type'), - http: HTTPModes = typer.Option(HTTPModes.auto.value, help='HTTP version'), - websockets: bool = typer.Option(True, '--ws/--no-ws', help='Enable websockets handling', show_default='enabled'), - workers: int = typer.Option(1, min=1, help='Number of worker processes'), - threads: int = typer.Option(1, min=1, help='Number of threads'), - blocking_threads: int = typer.Option(1, min=1, help='Number of blocking threads'), - threading_mode: ThreadModes = typer.Option(ThreadModes.workers.value, help='Threading mode to use'), - loop: Loops = typer.Option(Loops.auto.value, help='Event loop implementation'), - loop_opt: bool = typer.Option(False, '--opt/--no-opt', help='Enable loop optimizations', show_default='disabled'), - backlog: int = typer.Option(1024, min=128, help='Maximum number of connections to hold in backlog'), - http1_buffer_size: int = typer.Option( - HTTP1Settings.max_buffer_size, min=8192, help='Set the maximum buffer size for HTTP/1 connections' - ), - http1_keep_alive: bool = typer.Option( - HTTP1Settings.keep_alive, - '--http1-keep-alive/--no-http1-keep-alive', - show_default='enabled', - help='Enables or disables HTTP/1 keep-alive', - ), - http1_pipeline_flush: bool = typer.Option( - HTTP1Settings.pipeline_flush, - '--http1-pipeline-flush/--no-http1-pipeline-flush', - show_default='disabled', - help='Aggregates HTTP/1 flushes to better support pipelined responses (experimental)', - ), - http2_adaptive_window: bool = typer.Option( - HTTP2Settings.adaptive_window, - '--http2-adaptive-window/--no-http2-adaptive-window', - show_default='disabled', - help='Sets whether to use an adaptive flow control for HTTP2', - ), - http2_initial_connection_window_size: int = typer.Option( - HTTP2Settings.initial_connection_window_size, help='Sets the max connection-level flow control for HTTP2' - ), - http2_initial_stream_window_size: int = typer.Option( - HTTP2Settings.initial_stream_window_size, - help='Sets the `SETTINGS_INITIAL_WINDOW_SIZE` option for HTTP2 stream-level flow control', - ), - http2_keep_alive_interval: Optional[int] = typer.Option( - HTTP2Settings.keep_alive_interval, - help='Sets an interval for HTTP2 Ping frames should be sent to keep a connection alive', - show_default='disabled', - ), - http2_keep_alive_timeout: int = typer.Option( - HTTP2Settings.keep_alive_timeout, - help='Sets a timeout for receiving an acknowledgement of the HTTP2 keep-alive ping', - ), - http2_max_concurrent_streams: int = typer.Option( - HTTP2Settings.max_concurrent_streams, - help='Sets the SETTINGS_MAX_CONCURRENT_STREAMS option for HTTP2 connections', - ), - http2_max_frame_size: int = typer.Option( - HTTP2Settings.max_frame_size, help='Sets the maximum frame size to use for HTTP2' - ), - http2_max_headers_size: int = typer.Option( - HTTP2Settings.max_headers_size, help='Sets the max size of received header frames' - ), - http2_max_send_buffer_size: int = typer.Option( - HTTP2Settings.max_send_buffer_size, help='Set the maximum write buffer size for each HTTP/2 stream' - ), - log_enabled: bool = typer.Option(True, '--log/--no-log', help='Enable logging', show_default='enabled'), - log_level: LogLevels = typer.Option(LogLevels.info.value, help='Log level', case_sensitive=False), - log_config: Optional[Path] = typer.Option( - None, help='Logging configuration file (json)', exists=True, file_okay=True, dir_okay=False, readable=True - ), - ssl_keyfile: Optional[Path] = typer.Option( - None, help='SSL key file', exists=True, file_okay=True, dir_okay=False, readable=True - ), - ssl_certificate: Optional[Path] = typer.Option( - None, help='SSL certificate file', exists=True, file_okay=True, dir_okay=False, readable=True - ), - url_path_prefix: Optional[str] = typer.Option(None, help='URL path prefix the app is mounted on'), - respawn_failed_workers: bool = typer.Option( - False, - '--respawn-failed-workers/--no-respawn-failed-workers', - help='Enable workers respawn on unexpected exit', - show_default='disabled', - ), - reload: bool = typer.Option( - False, - '--reload/--no-reload', - help="Enable auto reload on application's files changes (requires granian[reload] extra)", - show_default='disabled', - ), - process_name: Optional[str] = typer.Option( - None, - help='Set a custom name for processes (requires granian[pname] extra)', - ), - _: Optional[bool] = typer.Option( - None, - '--version', - callback=version_callback, - is_eager=True, - help='Shows the version and exit', - allow_from_autoenv=False, - ), -): +_AnyCallable = Callable[..., Any] +FC = TypeVar('FC', bound=Union[_AnyCallable, click.Command]) + + +class EnumType(click.Choice): + def __init__(self, enum: Enum, case_sensitive=False) -> None: + self.__enum = enum + super().__init__(choices=[item.value for item in enum], case_sensitive=case_sensitive) + + def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> Enum: + if value is None or isinstance(value, Enum): + return value + + converted_str = super().convert(value, param, ctx) + return self.__enum(converted_str) + + +def _pretty_print_default(value: Optional[bool]) -> Optional[str]: + if isinstance(value, bool): + return 'enabled' if value else 'disabled' + if isinstance(value, Enum): + return value.value + return value + + +def option(*param_decls: str, cls: Optional[Type[click.Option]] = None, **attrs: Any) -> Callable[[FC], FC]: + attrs['show_envvar'] = True + if 'default' in attrs: + attrs['show_default'] = _pretty_print_default(attrs['default']) + return click.option(*param_decls, cls=cls, **attrs) + + +@click.command( + context_settings={'show_default': True}, + help='APP Application target to serve. [required]', +) +@click.argument('app', required=True) +@option( + '--host', + default='127.0.0.1', + help='Host address to bind to', +) +@option('--port', type=int, default=8000, help='Port to bind to.') +@option( + '--interface', + type=EnumType(Interfaces), + default=Interfaces.RSGI, + help='Application interface type', +) +@option('--http', type=EnumType(HTTPModes), default=HTTPModes.auto, help='HTTP version') +@option('--ws/--no-ws', 'websockets', default=True, help='Enable websockets handling') +@option('--workers', type=click.IntRange(1), default=1, help='Number of worker processes') +@option('--threads', type=click.IntRange(1), default=1, help='Number of threads') +@option( + '--blocking-threads', + type=click.IntRange(1), + default=1, + help='Number of blocking threads', +) +@option( + '--threading-mode', + type=EnumType(ThreadModes), + default=ThreadModes.workers, + help='Threading mode to use', +) +@option('--loop', type=EnumType(Loops), default=Loops.auto, help='Event loop implementation') +@option('--opt/--no-opt', 'loop_opt', default=False, help='Enable loop optimizations') +@option( + '--backlog', + type=click.IntRange(128), + default=1024, + help='Maximum number of connections to hold in backlog', +) +@option( + '--http1-buffer-size', + type=click.IntRange(8192), + default=HTTP1Settings.max_buffer_size, + help='Set the maximum buffer size for HTTP/1 connections', +) +@option( + '--http1-keep-alive/--no-http1-keep-alive', + default=HTTP1Settings.keep_alive, + help='Enables or disables HTTP/1 keep-alive', +) +@option( + '--http1-pipeline-flush/--no-http1-pipeline-flush', + default=HTTP1Settings.pipeline_flush, + help='Aggregates HTTP/1 flushes to better support pipelined responses (experimental)', +) +@option( + '--http2-adaptive-window/--no-http2-adaptive-window', + default=HTTP2Settings.adaptive_window, + help='Sets whether to use an adaptive flow control for HTTP2', +) +@option( + '--http2-initial-connection-window-size', + type=int, + default=HTTP2Settings.initial_connection_window_size, + help='Sets the max connection-level flow control for HTTP2', +) +@option( + '--http2-initial-stream-window-size', + type=int, + default=HTTP2Settings.initial_stream_window_size, + help='Sets the `SETTINGS_INITIAL_WINDOW_SIZE` option for HTTP2 stream-level flow control', +) +@option( + '--http2-keep-alive-interval', + type=int, + default=HTTP2Settings.keep_alive_interval, + help='Sets an interval for HTTP2 Ping frames should be sent to keep a connection alive', +) +@option( + '--http2-keep-alive-timeout', + type=int, + default=HTTP2Settings.keep_alive_timeout, + help='Sets a timeout for receiving an acknowledgement of the HTTP2 keep-alive ping', +) +@option( + '--http2-max-concurrent-streams', + type=int, + default=HTTP2Settings.max_concurrent_streams, + help='Sets the SETTINGS_MAX_CONCURRENT_STREAMS option for HTTP2 connections', +) +@option( + '--http2-max-frame-size', + type=int, + default=HTTP2Settings.max_frame_size, + help='Sets the maximum frame size to use for HTTP2', +) +@option( + '--http2-max-headers-size', + type=int, + default=HTTP2Settings.max_headers_size, + help='Sets the max size of received header frames', +) +@option( + '--http2-max-send-buffer-size', + type=int, + default=HTTP2Settings.max_send_buffer_size, + help='Set the maximum write buffer size for each HTTP/2 stream', +) +@option('--log/--no-log', 'log_enabled', default=True, help='Enable logging') +@option('--log-level', type=EnumType(LogLevels), default=LogLevels.info, help='Log level') +@option( + '--log-config', + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path), + help='Logging configuration file (json)', +) +@option( + '--ssl-keyfile', + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path), + help='SSL key file', +) +@option( + '--ssl-certificate', + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path), + help='SSL certificate file', +) +@option('--url-path-prefix', help='URL path prefix the app is mounted on') +@option( + '--respawn-failed-workers/--no-respawn-failed-workers', + default=False, + help='Enable workers respawn on unexpected exit', +) +@option( + '--reload/--no-reload', + default=False, + help="Enable auto reload on application's files changes (requires granian[reload] extra)", +) +@option( + '--process-name', + help='Set a custom name for processes (requires granian[pname] extra)', +) +@click.version_option(message='%(prog)s %(version)s') +def cli( + app: str, + host: str, + port: int, + interface: Interfaces, + http: HTTPModes, + websockets: bool, + workers: int, + threads: int, + blocking_threads: int, + threading_mode: ThreadModes, + loop: Loops, + loop_opt: bool, + backlog: int, + http1_buffer_size: int, + http1_keep_alive: bool, + http1_pipeline_flush: bool, + http2_adaptive_window: bool, + http2_initial_connection_window_size: int, + http2_initial_stream_window_size: int, + http2_keep_alive_interval: Optional[int], + http2_keep_alive_timeout: int, + http2_max_concurrent_streams: int, + http2_max_frame_size: int, + http2_max_headers_size: int, + http2_max_send_buffer_size: int, + log_enabled: bool, + log_level: LogLevels, + log_config: Optional[pathlib.Path], + ssl_keyfile: Optional[pathlib.Path], + ssl_certificate: Optional[pathlib.Path], + url_path_prefix: Optional[str], + respawn_failed_workers: bool, + reload: bool, + process_name: Optional[str], +) -> None: log_dictconfig = None if log_config: with log_config.open() as log_config_file: @@ -129,7 +229,7 @@ def main( log_dictconfig = json.loads(log_config_file.read()) except Exception: print('Unable to parse provided logging config.') - raise typer.Exit(1) + raise click.exceptions.Exit(1) Granian( app, @@ -169,3 +269,7 @@ def main( reload=reload, process_name=process_name, ).serve() + + +def entrypoint(): + cli(auto_envvar_prefix='GRANIAN') diff --git a/pyproject.toml b/pyproject.toml index 70260d1..7666918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dynamic = [ requires-python = '>=3.8' dependencies = [ - 'typer>=0.4.2,<0.12.0', + 'click>=8.0.0', 'uvloop~=0.18.0; sys_platform != "win32" and platform_python_implementation == "CPython"', ] @@ -62,7 +62,7 @@ Funding = 'https://github.com/sponsors/gi0baro' Source = 'https://github.com/emmett-framework/granian' [project.scripts] -granian = 'granian:cli.cli' +granian = 'granian:cli.entrypoint' [build-system] requires = ['maturin>=1.1.0,<1.5.0']