eptm_dashboard/.venv/lib/python3.12/site-packages/granian/cli.py

606 lines
20 KiB
Python

import json
import pathlib
import re
from collections.abc import Callable
from enum import Enum
from typing import Any, TypeVar
import click
from .constants import HTTPModes, Interfaces, Loops, RuntimeModes, SSLProtocols, TaskImpl
from .errors import FatalError
from .http import HTTP1Settings, HTTP2Settings
from .log import LogLevels
from .server import Server
_AnyCallable = Callable[..., Any]
FC = TypeVar('FC', bound=_AnyCallable | click.Command)
class Duration(click.IntRange):
"""Custom parameter type for duration strings like '24h', '6m', '2s', '1h30m', etc.
If the value is a plain number, it will be treated as seconds.
"""
name = 'duration'
_multipliers = {'s': 1, 'm': 60, 'h': 60 * 60, 'd': 60 * 60 * 24}
_pattern = re.compile(r'^(?:(?P<d>\d+)d)?(?:(?P<h>\d+)h)?(?:(?P<m>\d+)m)?(?:(?P<s>\d+)s)?$')
def __init__(self, min: int | None = None, max: int | None = None) -> None:
super().__init__(min, max, min_open=False, max_open=False, clamp=False)
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any:
if value is None:
return value
if isinstance(value, int):
seconds = value
elif isinstance(value, str):
if value.isnumeric():
seconds = int(value)
elif (match := self._pattern.fullmatch(value)) is not None:
seconds = (
int(match.group('d') or 0) * self._multipliers['d']
+ int(match.group('h') or 0) * self._multipliers['h']
+ int(match.group('m') or 0) * self._multipliers['m']
+ int(match.group('s') or 0) * self._multipliers['s']
)
else:
self.fail(f'{value!r} is not a valid duration', param, ctx)
else:
self.fail(f'{value!r} is not a valid duration', param, ctx)
if self.min is not None and seconds < self.min:
self.fail(f'{value!r} is less than the minimum allowed value of {self.min} seconds', param, ctx)
if self.max is not None and seconds > self.max:
self.fail(f'{value!r} is greater than the maximum allowed value of {self.max} seconds', param, ctx)
return seconds
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: click.Parameter | None, ctx: click.Context | None) -> Enum:
if value is None or isinstance(value, Enum):
return value
converted_str = super().convert(value, param, ctx)
return self.__enum(converted_str)
class OctalIntType(click.ParamType):
name = 'Octal integer'
def convert(self, value, param, ctx):
if value is None or isinstance(value, int):
return value
if not isinstance(value, str):
self.fail(f'{value!r} is not a valid integer')
base = 8 if len(value) > 0 and value[0] == '0' else 10
try:
return int(value, base)
except ValueError as e:
self.fail(str(e))
def _pretty_print_default(value: bool | None) -> str | None:
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: type[click.Option] | None = 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(
'--uds', type=click.Path(exists=False, writable=True, path_type=pathlib.Path), help='Unix Domain Socket to bind to.'
)
@option('--uds-permissions', type=OctalIntType(), default=None, help='Unix Domain Socket file permissions')
@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(
'--blocking-threads',
type=click.IntRange(1),
help='Number of blocking threads (per worker)',
)
@option(
'--blocking-threads-idle-timeout',
type=Duration(5, 600),
default=30,
help='The maximum amount of time in seconds (or a human-readable duration) an idle blocking thread will be kept alive',
)
@option('--runtime-threads', type=click.IntRange(1), default=1, help='Number of runtime threads (per worker)')
@option(
'--runtime-blocking-threads',
type=click.IntRange(1),
help='Number of runtime I/O blocking threads (per worker)',
)
@option(
'--runtime-mode',
type=EnumType(RuntimeModes),
default=RuntimeModes.auto,
help='Runtime mode to use (single/multi threaded)',
)
@option('--loop', type=EnumType(Loops), default=Loops.auto, help='Event loop implementation')
@option(
'--task-impl',
type=EnumType(TaskImpl),
default=TaskImpl.asyncio,
help='Async task implementation to use',
)
@option(
'--backlog',
type=click.IntRange(128),
default=1024,
help='Maximum number of connections to hold in backlog (globally)',
)
@option(
'--backpressure',
type=click.IntRange(1),
show_default='backlog/workers',
help='Maximum number of requests to process concurrently (per worker)',
)
@option(
'--http1-buffer-size',
type=click.IntRange(8192),
default=HTTP1Settings.max_buffer_size,
help='Sets the maximum buffer size for HTTP/1 connections',
)
@option(
'--http1-header-read-timeout',
type=click.IntRange(1, 60_000),
default=HTTP1Settings.header_read_timeout,
help='Sets a timeout (in milliseconds) to read headers',
)
@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=click.IntRange(1024),
default=HTTP2Settings.initial_connection_window_size,
help='Sets the max connection-level flow control for HTTP2',
)
@option(
'--http2-initial-stream-window-size',
type=click.IntRange(1024),
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=click.IntRange(1, 60_000),
default=HTTP2Settings.keep_alive_interval,
help='Sets an interval (in milliseconds) for HTTP2 Ping frames should be sent to keep a connection alive',
)
@option(
'--http2-keep-alive-timeout',
type=Duration(1),
default=HTTP2Settings.keep_alive_timeout,
help='Sets a timeout (in seconds or a human-readable duration) for receiving an acknowledgement of the HTTP2 keep-alive ping',
)
@option(
'--http2-max-concurrent-streams',
type=click.IntRange(10),
default=HTTP2Settings.max_concurrent_streams,
help='Sets the SETTINGS_MAX_CONCURRENT_STREAMS option for HTTP2 connections',
)
@option(
'--http2-max-frame-size',
type=click.IntRange(1024),
default=HTTP2Settings.max_frame_size,
help='Sets the maximum frame size to use for HTTP2',
)
@option(
'--http2-max-headers-size',
type=click.IntRange(1),
default=HTTP2Settings.max_headers_size,
help='Sets the max size of received header frames',
)
@option(
'--http2-max-send-buffer-size',
type=click.IntRange(1024),
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('--access-log/--no-access-log', 'log_access_enabled', default=False, help='Enable access log')
@option('--access-log-fmt', 'log_access_fmt', help='Access log format')
@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(
'--ssl-keyfile',
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path),
help='SSL key file (PKCS#8 format only)',
)
@option('--ssl-keyfile-password', help='SSL key password')
@option(
'--ssl-protocol-min',
type=EnumType(SSLProtocols),
default=SSLProtocols.tls13,
help='Set the minimum supported protocol for SSL connections.',
)
@option(
'--ssl-ca',
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path),
help='Root SSL cerificate file for client verification',
)
@option(
'--ssl-crl',
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path),
help='SSL CRL file(s)',
multiple=True,
)
@option(
'--ssl-client-verify/--no-ssl-client-verify',
default=False,
help='Verify clients SSL certificates',
)
@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(
'--respawn-interval',
default=3.5,
help='The number of seconds to sleep between workers respawn',
)
@option(
'--rss-sample-interval',
type=Duration(1, 300),
default=30,
help='The sample rate in seconds (or a human-readable duration) for the resource monitor',
)
@option(
'--rss-samples',
type=click.IntRange(1),
default=1,
help='The number of consecutive samples to consider a worker over resource limit',
)
@option(
'--workers-lifetime',
type=Duration(60),
help='The maximum amount of time in seconds (or a human-readable duration) a worker will be kept alive before respawn',
)
@option(
'--workers-max-rss',
type=click.IntRange(1),
help='The maximum amount of memory (in MiB) a worker can consume before respawn',
)
@option(
'--workers-kill-timeout',
type=Duration(1, 1800),
help='The amount of time in seconds (or a human-readable duration) to wait for killing workers that refused to gracefully stop',
show_default='disabled',
)
@option(
'--factory/--no-factory',
default=False,
help='Treat target as a factory function, that should be invoked to build the actual target',
)
@option(
'--working-dir',
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, path_type=pathlib.Path),
help='Set the working directory',
)
@option(
'--env-files',
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=pathlib.Path),
help='Environment file(s) to load (requires granian[dotenv] extra)',
multiple=True,
)
@option(
'--static-path-route',
multiple=True,
show_default='/static',
help='Route(s) for static file serving',
)
@option(
'--static-path-mount',
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, path_type=pathlib.Path),
multiple=True,
help='Path(s) to mount for static file serving',
)
@option(
'--static-path-dir-to-file',
default=None,
help='Serve the specified file as the index for directory listings',
)
@option(
'--static-path-expires',
type=Duration(0),
default=86400,
help='Cache headers expiration (in seconds or a human-readable duration) for static file serving. 0 to disable.',
)
@option('--metrics/--no-metrics', 'metrics_enabled', default=False, help='Enable the prometheus metrics exporter.')
@option(
'--metrics-scrape-interval', default=15, type=Duration(1, 60), help='Configure the interval for metrics collection.'
)
@option(
'--metrics-address',
default='127.0.0.1',
help='Metrics exporter host address to bind to',
)
@option('--metrics-port', type=int, default=9090, help='Metrics exporter port to bind to.')
@option(
'--reload/--no-reload',
default=False,
help="Enable auto reload on application's files changes (requires granian[reload] extra)",
)
@option(
'--reload-paths',
type=click.Path(exists=True, file_okay=True, dir_okay=True, readable=True, path_type=pathlib.Path),
help='Paths to watch for changes',
show_default='Working directory',
multiple=True,
)
@option(
'--reload-ignore-dirs',
help=(
'Names of directories to ignore changes for. '
"Extends the default list of directories to ignore in watchfiles' default filter"
),
multiple=True,
)
@option(
'--reload-ignore-patterns',
help=(
'File/directory name patterns (regex) to ignore changes for. '
"Extends the default list of patterns to ignore in watchfiles' default filter"
),
multiple=True,
)
@option(
'--reload-ignore-paths',
type=click.Path(exists=False, path_type=pathlib.Path),
help='Absolute paths to ignore changes for',
multiple=True,
)
@option(
'--reload-tick',
type=click.IntRange(50, 5000),
help='The tick frequency (in milliseconds) the reloader watch for changes',
default=50,
)
@option(
'--reload-ignore-worker-failure/--no-reload-ignore-worker-failure',
default=False,
help='Ignore worker failures when auto reload is enabled',
)
@option(
'--process-name',
help='Set a custom name for processes (requires granian[pname] extra)',
)
@option(
'--pid-file',
type=click.Path(exists=False, file_okay=True, dir_okay=False, writable=True, path_type=pathlib.Path),
help='A path to write the PID file to',
)
@click.version_option(message='%(prog)s %(version)s')
def cli(
app: str,
host: str,
port: int,
uds: pathlib.Path | None,
uds_permissions: int | None,
interface: Interfaces,
http: HTTPModes,
websockets: bool,
workers: int,
blocking_threads: int | None,
blocking_threads_idle_timeout: int,
runtime_threads: int,
runtime_blocking_threads: int | None,
runtime_mode: RuntimeModes,
loop: Loops,
task_impl: TaskImpl,
backlog: int,
backpressure: int | None,
http1_buffer_size: int,
http1_header_read_timeout: 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: int | None,
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_access_enabled: bool,
log_access_fmt: str | None,
log_level: LogLevels,
log_config: pathlib.Path | None,
ssl_certificate: pathlib.Path | None,
ssl_keyfile: pathlib.Path | None,
ssl_keyfile_password: str | None,
ssl_protocol_min: SSLProtocols,
ssl_ca: pathlib.Path | None,
ssl_crl: list[pathlib.Path] | None,
ssl_client_verify: bool,
url_path_prefix: str | None,
respawn_failed_workers: bool,
respawn_interval: float,
rss_sample_interval: int,
rss_samples: int,
workers_lifetime: int | None,
workers_max_rss: int | None,
workers_kill_timeout: int | None,
factory: bool,
working_dir: pathlib.Path | None,
env_files: list[pathlib.Path] | None,
static_path_route: list[str],
static_path_mount: list[pathlib.Path],
static_path_dir_to_file: str | None,
static_path_expires: int,
metrics_enabled: bool,
metrics_scrape_interval: int,
metrics_address: str,
metrics_port: int,
reload: bool,
reload_paths: list[pathlib.Path] | None,
reload_ignore_dirs: list[str] | None,
reload_ignore_patterns: list[str] | None,
reload_ignore_paths: list[pathlib.Path] | None,
reload_tick: int,
reload_ignore_worker_failure: bool,
process_name: str | None,
pid_file: pathlib.Path | None,
) -> None:
log_dictconfig = None
if log_config:
with log_config.open() as log_config_file:
try:
log_dictconfig = json.loads(log_config_file.read())
except Exception:
print('Unable to parse provided logging config.')
raise click.exceptions.Exit(1)
from ._internal import patch_pypath
patch_pypath(working_dir)
server = Server(
app,
address=host,
port=port,
uds=uds,
uds_permissions=uds_permissions,
interface=interface,
workers=workers,
blocking_threads=blocking_threads,
blocking_threads_idle_timeout=blocking_threads_idle_timeout,
runtime_threads=runtime_threads,
runtime_blocking_threads=runtime_blocking_threads,
runtime_mode=runtime_mode,
loop=loop,
task_impl=task_impl,
http=http,
websockets=websockets,
backlog=backlog,
backpressure=backpressure,
http1_settings=HTTP1Settings(
header_read_timeout=http1_header_read_timeout,
keep_alive=http1_keep_alive,
max_buffer_size=http1_buffer_size,
pipeline_flush=http1_pipeline_flush,
),
http2_settings=HTTP2Settings(
adaptive_window=http2_adaptive_window,
initial_connection_window_size=http2_initial_connection_window_size,
initial_stream_window_size=http2_initial_stream_window_size,
keep_alive_interval=http2_keep_alive_interval,
keep_alive_timeout=http2_keep_alive_timeout,
max_concurrent_streams=http2_max_concurrent_streams,
max_frame_size=http2_max_frame_size,
max_headers_size=http2_max_headers_size,
max_send_buffer_size=http2_max_send_buffer_size,
),
log_enabled=log_enabled,
log_level=log_level,
log_dictconfig=log_dictconfig,
log_access=log_access_enabled,
log_access_format=log_access_fmt,
ssl_cert=ssl_certificate,
ssl_key=ssl_keyfile,
ssl_key_password=ssl_keyfile_password,
ssl_protocol_min=ssl_protocol_min,
ssl_ca=ssl_ca,
ssl_crl=ssl_crl,
ssl_client_verify=ssl_client_verify,
url_path_prefix=url_path_prefix,
respawn_failed_workers=respawn_failed_workers,
respawn_interval=respawn_interval,
rss_sample_interval=rss_sample_interval,
rss_samples=rss_samples,
workers_lifetime=workers_lifetime,
workers_max_rss=workers_max_rss,
workers_kill_timeout=workers_kill_timeout,
factory=factory,
working_dir=working_dir,
env_files=env_files,
static_path_route=static_path_route,
static_path_mount=static_path_mount,
static_path_dir_to_file=static_path_dir_to_file,
static_path_expires=static_path_expires,
metrics_enabled=metrics_enabled,
metrics_scrape_interval=metrics_scrape_interval,
metrics_address=metrics_address,
metrics_port=metrics_port,
reload=reload,
reload_paths=reload_paths,
reload_ignore_paths=reload_ignore_paths,
reload_ignore_dirs=reload_ignore_dirs,
reload_ignore_patterns=reload_ignore_patterns,
reload_tick=reload_tick,
reload_ignore_worker_failure=reload_ignore_worker_failure,
process_name=process_name,
pid_file=pid_file,
)
try:
server.serve()
except FatalError:
raise click.exceptions.Exit(1)
def entrypoint():
cli(auto_envvar_prefix='GRANIAN')