mirror of
https://github.com/PiBrewing/craftbeerpi4.git
synced 2024-11-30 02:34:18 +01:00
582 lines
18 KiB
Python
582 lines
18 KiB
Python
|
import asyncio
|
||
|
import logging
|
||
|
import socket
|
||
|
import sys
|
||
|
from argparse import ArgumentParser
|
||
|
from collections.abc import Iterable
|
||
|
from importlib import import_module
|
||
|
from typing import (
|
||
|
Any as Any,
|
||
|
Awaitable as Awaitable,
|
||
|
Callable as Callable,
|
||
|
Iterable as TypingIterable,
|
||
|
List as List,
|
||
|
Optional as Optional,
|
||
|
Set as Set,
|
||
|
Type as Type,
|
||
|
Union as Union,
|
||
|
cast as cast,
|
||
|
)
|
||
|
|
||
|
from .abc import AbstractAccessLogger
|
||
|
from .helpers import all_tasks
|
||
|
from .log import access_logger
|
||
|
from .web_app import Application as Application, CleanupError as CleanupError
|
||
|
from .web_exceptions import (
|
||
|
HTTPAccepted as HTTPAccepted,
|
||
|
HTTPBadGateway as HTTPBadGateway,
|
||
|
HTTPBadRequest as HTTPBadRequest,
|
||
|
HTTPClientError as HTTPClientError,
|
||
|
HTTPConflict as HTTPConflict,
|
||
|
HTTPCreated as HTTPCreated,
|
||
|
HTTPError as HTTPError,
|
||
|
HTTPException as HTTPException,
|
||
|
HTTPExpectationFailed as HTTPExpectationFailed,
|
||
|
HTTPFailedDependency as HTTPFailedDependency,
|
||
|
HTTPForbidden as HTTPForbidden,
|
||
|
HTTPFound as HTTPFound,
|
||
|
HTTPGatewayTimeout as HTTPGatewayTimeout,
|
||
|
HTTPGone as HTTPGone,
|
||
|
HTTPInsufficientStorage as HTTPInsufficientStorage,
|
||
|
HTTPInternalServerError as HTTPInternalServerError,
|
||
|
HTTPLengthRequired as HTTPLengthRequired,
|
||
|
HTTPMethodNotAllowed as HTTPMethodNotAllowed,
|
||
|
HTTPMisdirectedRequest as HTTPMisdirectedRequest,
|
||
|
HTTPMovedPermanently as HTTPMovedPermanently,
|
||
|
HTTPMultipleChoices as HTTPMultipleChoices,
|
||
|
HTTPNetworkAuthenticationRequired as HTTPNetworkAuthenticationRequired,
|
||
|
HTTPNoContent as HTTPNoContent,
|
||
|
HTTPNonAuthoritativeInformation as HTTPNonAuthoritativeInformation,
|
||
|
HTTPNotAcceptable as HTTPNotAcceptable,
|
||
|
HTTPNotExtended as HTTPNotExtended,
|
||
|
HTTPNotFound as HTTPNotFound,
|
||
|
HTTPNotImplemented as HTTPNotImplemented,
|
||
|
HTTPNotModified as HTTPNotModified,
|
||
|
HTTPOk as HTTPOk,
|
||
|
HTTPPartialContent as HTTPPartialContent,
|
||
|
HTTPPaymentRequired as HTTPPaymentRequired,
|
||
|
HTTPPermanentRedirect as HTTPPermanentRedirect,
|
||
|
HTTPPreconditionFailed as HTTPPreconditionFailed,
|
||
|
HTTPPreconditionRequired as HTTPPreconditionRequired,
|
||
|
HTTPProxyAuthenticationRequired as HTTPProxyAuthenticationRequired,
|
||
|
HTTPRedirection as HTTPRedirection,
|
||
|
HTTPRequestEntityTooLarge as HTTPRequestEntityTooLarge,
|
||
|
HTTPRequestHeaderFieldsTooLarge as HTTPRequestHeaderFieldsTooLarge,
|
||
|
HTTPRequestRangeNotSatisfiable as HTTPRequestRangeNotSatisfiable,
|
||
|
HTTPRequestTimeout as HTTPRequestTimeout,
|
||
|
HTTPRequestURITooLong as HTTPRequestURITooLong,
|
||
|
HTTPResetContent as HTTPResetContent,
|
||
|
HTTPSeeOther as HTTPSeeOther,
|
||
|
HTTPServerError as HTTPServerError,
|
||
|
HTTPServiceUnavailable as HTTPServiceUnavailable,
|
||
|
HTTPSuccessful as HTTPSuccessful,
|
||
|
HTTPTemporaryRedirect as HTTPTemporaryRedirect,
|
||
|
HTTPTooManyRequests as HTTPTooManyRequests,
|
||
|
HTTPUnauthorized as HTTPUnauthorized,
|
||
|
HTTPUnavailableForLegalReasons as HTTPUnavailableForLegalReasons,
|
||
|
HTTPUnprocessableEntity as HTTPUnprocessableEntity,
|
||
|
HTTPUnsupportedMediaType as HTTPUnsupportedMediaType,
|
||
|
HTTPUpgradeRequired as HTTPUpgradeRequired,
|
||
|
HTTPUseProxy as HTTPUseProxy,
|
||
|
HTTPVariantAlsoNegotiates as HTTPVariantAlsoNegotiates,
|
||
|
HTTPVersionNotSupported as HTTPVersionNotSupported,
|
||
|
)
|
||
|
from .web_fileresponse import FileResponse as FileResponse
|
||
|
from .web_log import AccessLogger
|
||
|
from .web_middlewares import (
|
||
|
middleware as middleware,
|
||
|
normalize_path_middleware as normalize_path_middleware,
|
||
|
)
|
||
|
from .web_protocol import (
|
||
|
PayloadAccessError as PayloadAccessError,
|
||
|
RequestHandler as RequestHandler,
|
||
|
RequestPayloadError as RequestPayloadError,
|
||
|
)
|
||
|
from .web_request import (
|
||
|
BaseRequest as BaseRequest,
|
||
|
FileField as FileField,
|
||
|
Request as Request,
|
||
|
)
|
||
|
from .web_response import (
|
||
|
ContentCoding as ContentCoding,
|
||
|
Response as Response,
|
||
|
StreamResponse as StreamResponse,
|
||
|
json_response as json_response,
|
||
|
)
|
||
|
from .web_routedef import (
|
||
|
AbstractRouteDef as AbstractRouteDef,
|
||
|
RouteDef as RouteDef,
|
||
|
RouteTableDef as RouteTableDef,
|
||
|
StaticDef as StaticDef,
|
||
|
delete as delete,
|
||
|
get as get,
|
||
|
head as head,
|
||
|
options as options,
|
||
|
patch as patch,
|
||
|
post as post,
|
||
|
put as put,
|
||
|
route as route,
|
||
|
static as static,
|
||
|
view as view,
|
||
|
)
|
||
|
from .web_runner import (
|
||
|
AppRunner as AppRunner,
|
||
|
BaseRunner as BaseRunner,
|
||
|
BaseSite as BaseSite,
|
||
|
GracefulExit as GracefulExit,
|
||
|
NamedPipeSite as NamedPipeSite,
|
||
|
ServerRunner as ServerRunner,
|
||
|
SockSite as SockSite,
|
||
|
TCPSite as TCPSite,
|
||
|
UnixSite as UnixSite,
|
||
|
)
|
||
|
from .web_server import Server as Server
|
||
|
from .web_urldispatcher import (
|
||
|
AbstractResource as AbstractResource,
|
||
|
AbstractRoute as AbstractRoute,
|
||
|
DynamicResource as DynamicResource,
|
||
|
PlainResource as PlainResource,
|
||
|
Resource as Resource,
|
||
|
ResourceRoute as ResourceRoute,
|
||
|
StaticResource as StaticResource,
|
||
|
UrlDispatcher as UrlDispatcher,
|
||
|
UrlMappingMatchInfo as UrlMappingMatchInfo,
|
||
|
View as View,
|
||
|
)
|
||
|
from .web_ws import (
|
||
|
WebSocketReady as WebSocketReady,
|
||
|
WebSocketResponse as WebSocketResponse,
|
||
|
WSMsgType as WSMsgType,
|
||
|
)
|
||
|
|
||
|
__all__ = (
|
||
|
# web_app
|
||
|
"Application",
|
||
|
"CleanupError",
|
||
|
# web_exceptions
|
||
|
"HTTPAccepted",
|
||
|
"HTTPBadGateway",
|
||
|
"HTTPBadRequest",
|
||
|
"HTTPClientError",
|
||
|
"HTTPConflict",
|
||
|
"HTTPCreated",
|
||
|
"HTTPError",
|
||
|
"HTTPException",
|
||
|
"HTTPExpectationFailed",
|
||
|
"HTTPFailedDependency",
|
||
|
"HTTPForbidden",
|
||
|
"HTTPFound",
|
||
|
"HTTPGatewayTimeout",
|
||
|
"HTTPGone",
|
||
|
"HTTPInsufficientStorage",
|
||
|
"HTTPInternalServerError",
|
||
|
"HTTPLengthRequired",
|
||
|
"HTTPMethodNotAllowed",
|
||
|
"HTTPMisdirectedRequest",
|
||
|
"HTTPMovedPermanently",
|
||
|
"HTTPMultipleChoices",
|
||
|
"HTTPNetworkAuthenticationRequired",
|
||
|
"HTTPNoContent",
|
||
|
"HTTPNonAuthoritativeInformation",
|
||
|
"HTTPNotAcceptable",
|
||
|
"HTTPNotExtended",
|
||
|
"HTTPNotFound",
|
||
|
"HTTPNotImplemented",
|
||
|
"HTTPNotModified",
|
||
|
"HTTPOk",
|
||
|
"HTTPPartialContent",
|
||
|
"HTTPPaymentRequired",
|
||
|
"HTTPPermanentRedirect",
|
||
|
"HTTPPreconditionFailed",
|
||
|
"HTTPPreconditionRequired",
|
||
|
"HTTPProxyAuthenticationRequired",
|
||
|
"HTTPRedirection",
|
||
|
"HTTPRequestEntityTooLarge",
|
||
|
"HTTPRequestHeaderFieldsTooLarge",
|
||
|
"HTTPRequestRangeNotSatisfiable",
|
||
|
"HTTPRequestTimeout",
|
||
|
"HTTPRequestURITooLong",
|
||
|
"HTTPResetContent",
|
||
|
"HTTPSeeOther",
|
||
|
"HTTPServerError",
|
||
|
"HTTPServiceUnavailable",
|
||
|
"HTTPSuccessful",
|
||
|
"HTTPTemporaryRedirect",
|
||
|
"HTTPTooManyRequests",
|
||
|
"HTTPUnauthorized",
|
||
|
"HTTPUnavailableForLegalReasons",
|
||
|
"HTTPUnprocessableEntity",
|
||
|
"HTTPUnsupportedMediaType",
|
||
|
"HTTPUpgradeRequired",
|
||
|
"HTTPUseProxy",
|
||
|
"HTTPVariantAlsoNegotiates",
|
||
|
"HTTPVersionNotSupported",
|
||
|
# web_fileresponse
|
||
|
"FileResponse",
|
||
|
# web_middlewares
|
||
|
"middleware",
|
||
|
"normalize_path_middleware",
|
||
|
# web_protocol
|
||
|
"PayloadAccessError",
|
||
|
"RequestHandler",
|
||
|
"RequestPayloadError",
|
||
|
# web_request
|
||
|
"BaseRequest",
|
||
|
"FileField",
|
||
|
"Request",
|
||
|
# web_response
|
||
|
"ContentCoding",
|
||
|
"Response",
|
||
|
"StreamResponse",
|
||
|
"json_response",
|
||
|
# web_routedef
|
||
|
"AbstractRouteDef",
|
||
|
"RouteDef",
|
||
|
"RouteTableDef",
|
||
|
"StaticDef",
|
||
|
"delete",
|
||
|
"get",
|
||
|
"head",
|
||
|
"options",
|
||
|
"patch",
|
||
|
"post",
|
||
|
"put",
|
||
|
"route",
|
||
|
"static",
|
||
|
"view",
|
||
|
# web_runner
|
||
|
"AppRunner",
|
||
|
"BaseRunner",
|
||
|
"BaseSite",
|
||
|
"GracefulExit",
|
||
|
"ServerRunner",
|
||
|
"SockSite",
|
||
|
"TCPSite",
|
||
|
"UnixSite",
|
||
|
"NamedPipeSite",
|
||
|
# web_server
|
||
|
"Server",
|
||
|
# web_urldispatcher
|
||
|
"AbstractResource",
|
||
|
"AbstractRoute",
|
||
|
"DynamicResource",
|
||
|
"PlainResource",
|
||
|
"Resource",
|
||
|
"ResourceRoute",
|
||
|
"StaticResource",
|
||
|
"UrlDispatcher",
|
||
|
"UrlMappingMatchInfo",
|
||
|
"View",
|
||
|
# web_ws
|
||
|
"WebSocketReady",
|
||
|
"WebSocketResponse",
|
||
|
"WSMsgType",
|
||
|
# web
|
||
|
"run_app",
|
||
|
)
|
||
|
|
||
|
|
||
|
try:
|
||
|
from ssl import SSLContext
|
||
|
except ImportError: # pragma: no cover
|
||
|
SSLContext = Any # type: ignore
|
||
|
|
||
|
HostSequence = TypingIterable[str]
|
||
|
|
||
|
|
||
|
async def _run_app(
|
||
|
app: Union[Application, Awaitable[Application]],
|
||
|
*,
|
||
|
host: Optional[Union[str, HostSequence]] = None,
|
||
|
port: Optional[int] = None,
|
||
|
path: Optional[str] = None,
|
||
|
sock: Optional[socket.socket] = None,
|
||
|
shutdown_timeout: float = 60.0,
|
||
|
ssl_context: Optional[SSLContext] = None,
|
||
|
print: Callable[..., None] = print,
|
||
|
backlog: int = 128,
|
||
|
access_log_class: Type[AbstractAccessLogger] = AccessLogger,
|
||
|
access_log_format: str = AccessLogger.LOG_FORMAT,
|
||
|
access_log: Optional[logging.Logger] = access_logger,
|
||
|
handle_signals: bool = True,
|
||
|
reuse_address: Optional[bool] = None,
|
||
|
reuse_port: Optional[bool] = None,
|
||
|
) -> None:
|
||
|
# A internal functio to actually do all dirty job for application running
|
||
|
if asyncio.iscoroutine(app):
|
||
|
app = await app # type: ignore
|
||
|
|
||
|
app = cast(Application, app)
|
||
|
|
||
|
runner = AppRunner(
|
||
|
app,
|
||
|
handle_signals=handle_signals,
|
||
|
access_log_class=access_log_class,
|
||
|
access_log_format=access_log_format,
|
||
|
access_log=access_log,
|
||
|
)
|
||
|
|
||
|
await runner.setup()
|
||
|
|
||
|
sites = [] # type: List[BaseSite]
|
||
|
|
||
|
try:
|
||
|
if host is not None:
|
||
|
if isinstance(host, (str, bytes, bytearray, memoryview)):
|
||
|
sites.append(
|
||
|
TCPSite(
|
||
|
runner,
|
||
|
host,
|
||
|
port,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
reuse_address=reuse_address,
|
||
|
reuse_port=reuse_port,
|
||
|
)
|
||
|
)
|
||
|
else:
|
||
|
for h in host:
|
||
|
sites.append(
|
||
|
TCPSite(
|
||
|
runner,
|
||
|
h,
|
||
|
port,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
reuse_address=reuse_address,
|
||
|
reuse_port=reuse_port,
|
||
|
)
|
||
|
)
|
||
|
elif path is None and sock is None or port is not None:
|
||
|
sites.append(
|
||
|
TCPSite(
|
||
|
runner,
|
||
|
port=port,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
reuse_address=reuse_address,
|
||
|
reuse_port=reuse_port,
|
||
|
)
|
||
|
)
|
||
|
|
||
|
if path is not None:
|
||
|
if isinstance(path, (str, bytes, bytearray, memoryview)):
|
||
|
sites.append(
|
||
|
UnixSite(
|
||
|
runner,
|
||
|
path,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
)
|
||
|
)
|
||
|
else:
|
||
|
for p in path:
|
||
|
sites.append(
|
||
|
UnixSite(
|
||
|
runner,
|
||
|
p,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
)
|
||
|
)
|
||
|
|
||
|
if sock is not None:
|
||
|
if not isinstance(sock, Iterable):
|
||
|
sites.append(
|
||
|
SockSite(
|
||
|
runner,
|
||
|
sock,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
)
|
||
|
)
|
||
|
else:
|
||
|
for s in sock:
|
||
|
sites.append(
|
||
|
SockSite(
|
||
|
runner,
|
||
|
s,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
backlog=backlog,
|
||
|
)
|
||
|
)
|
||
|
for site in sites:
|
||
|
await site.start()
|
||
|
|
||
|
if print: # pragma: no branch
|
||
|
names = sorted(str(s.name) for s in runner.sites)
|
||
|
print(
|
||
|
"======== Running on {} ========\n"
|
||
|
"(Press CTRL+C to quit)".format(", ".join(names))
|
||
|
)
|
||
|
|
||
|
# sleep forever by 1 hour intervals,
|
||
|
# on Windows before Python 3.8 wake up every 1 second to handle
|
||
|
# Ctrl+C smoothly
|
||
|
if sys.platform == "win32" and sys.version_info < (3, 8):
|
||
|
delay = 1
|
||
|
else:
|
||
|
delay = 3600
|
||
|
|
||
|
while True:
|
||
|
await asyncio.sleep(delay)
|
||
|
finally:
|
||
|
await runner.cleanup()
|
||
|
|
||
|
|
||
|
def _cancel_tasks(
|
||
|
to_cancel: Set["asyncio.Task[Any]"], loop: asyncio.AbstractEventLoop
|
||
|
) -> None:
|
||
|
if not to_cancel:
|
||
|
return
|
||
|
|
||
|
for task in to_cancel:
|
||
|
task.cancel()
|
||
|
|
||
|
loop.run_until_complete(
|
||
|
asyncio.gather(*to_cancel, loop=loop, return_exceptions=True)
|
||
|
)
|
||
|
|
||
|
for task in to_cancel:
|
||
|
if task.cancelled():
|
||
|
continue
|
||
|
if task.exception() is not None:
|
||
|
loop.call_exception_handler(
|
||
|
{
|
||
|
"message": "unhandled exception during asyncio.run() shutdown",
|
||
|
"exception": task.exception(),
|
||
|
"task": task,
|
||
|
}
|
||
|
)
|
||
|
|
||
|
|
||
|
def run_app(
|
||
|
app: Union[Application, Awaitable[Application]],
|
||
|
*,
|
||
|
host: Optional[Union[str, HostSequence]] = None,
|
||
|
port: Optional[int] = None,
|
||
|
path: Optional[str] = None,
|
||
|
sock: Optional[socket.socket] = None,
|
||
|
shutdown_timeout: float = 60.0,
|
||
|
ssl_context: Optional[SSLContext] = None,
|
||
|
print: Callable[..., None] = print,
|
||
|
backlog: int = 128,
|
||
|
access_log_class: Type[AbstractAccessLogger] = AccessLogger,
|
||
|
access_log_format: str = AccessLogger.LOG_FORMAT,
|
||
|
access_log: Optional[logging.Logger] = access_logger,
|
||
|
handle_signals: bool = True,
|
||
|
reuse_address: Optional[bool] = None,
|
||
|
reuse_port: Optional[bool] = None,
|
||
|
) -> None:
|
||
|
"""Run an app locally"""
|
||
|
loop = asyncio.get_event_loop()
|
||
|
|
||
|
# Configure if and only if in debugging mode and using the default logger
|
||
|
if loop.get_debug() and access_log and access_log.name == "aiohttp.access":
|
||
|
if access_log.level == logging.NOTSET:
|
||
|
access_log.setLevel(logging.DEBUG)
|
||
|
if not access_log.hasHandlers():
|
||
|
access_log.addHandler(logging.StreamHandler())
|
||
|
|
||
|
try:
|
||
|
main_task = loop.create_task(
|
||
|
_run_app(
|
||
|
app,
|
||
|
host=host,
|
||
|
port=port,
|
||
|
path=path,
|
||
|
sock=sock,
|
||
|
shutdown_timeout=shutdown_timeout,
|
||
|
ssl_context=ssl_context,
|
||
|
print=print,
|
||
|
backlog=backlog,
|
||
|
access_log_class=access_log_class,
|
||
|
access_log_format=access_log_format,
|
||
|
access_log=access_log,
|
||
|
handle_signals=handle_signals,
|
||
|
reuse_address=reuse_address,
|
||
|
reuse_port=reuse_port,
|
||
|
)
|
||
|
)
|
||
|
loop.run_until_complete(main_task)
|
||
|
except (GracefulExit, KeyboardInterrupt): # pragma: no cover
|
||
|
pass
|
||
|
finally:
|
||
|
_cancel_tasks({main_task}, loop)
|
||
|
_cancel_tasks(all_tasks(loop), loop)
|
||
|
if sys.version_info >= (3, 6): # don't use PY_36 to pass mypy
|
||
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||
|
loop.close()
|
||
|
|
||
|
|
||
|
def main(argv: List[str]) -> None:
|
||
|
arg_parser = ArgumentParser(
|
||
|
description="aiohttp.web Application server", prog="aiohttp.web"
|
||
|
)
|
||
|
arg_parser.add_argument(
|
||
|
"entry_func",
|
||
|
help=(
|
||
|
"Callable returning the `aiohttp.web.Application` instance to "
|
||
|
"run. Should be specified in the 'module:function' syntax."
|
||
|
),
|
||
|
metavar="entry-func",
|
||
|
)
|
||
|
arg_parser.add_argument(
|
||
|
"-H",
|
||
|
"--hostname",
|
||
|
help="TCP/IP hostname to serve on (default: %(default)r)",
|
||
|
default="localhost",
|
||
|
)
|
||
|
arg_parser.add_argument(
|
||
|
"-P",
|
||
|
"--port",
|
||
|
help="TCP/IP port to serve on (default: %(default)r)",
|
||
|
type=int,
|
||
|
default="8080",
|
||
|
)
|
||
|
arg_parser.add_argument(
|
||
|
"-U",
|
||
|
"--path",
|
||
|
help="Unix file system path to serve on. Specifying a path will cause "
|
||
|
"hostname and port arguments to be ignored.",
|
||
|
)
|
||
|
args, extra_argv = arg_parser.parse_known_args(argv)
|
||
|
|
||
|
# Import logic
|
||
|
mod_str, _, func_str = args.entry_func.partition(":")
|
||
|
if not func_str or not mod_str:
|
||
|
arg_parser.error("'entry-func' not in 'module:function' syntax")
|
||
|
if mod_str.startswith("."):
|
||
|
arg_parser.error("relative module names not supported")
|
||
|
try:
|
||
|
module = import_module(mod_str)
|
||
|
except ImportError as ex:
|
||
|
arg_parser.error(f"unable to import {mod_str}: {ex}")
|
||
|
try:
|
||
|
func = getattr(module, func_str)
|
||
|
except AttributeError:
|
||
|
arg_parser.error(f"module {mod_str!r} has no attribute {func_str!r}")
|
||
|
|
||
|
# Compatibility logic
|
||
|
if args.path is not None and not hasattr(socket, "AF_UNIX"):
|
||
|
arg_parser.error(
|
||
|
"file system paths not supported by your operating" " environment"
|
||
|
)
|
||
|
|
||
|
logging.basicConfig(level=logging.DEBUG)
|
||
|
|
||
|
app = func(extra_argv)
|
||
|
run_app(app, host=args.hostname, port=args.port, path=args.path)
|
||
|
arg_parser.exit(message="Stopped\n")
|
||
|
|
||
|
|
||
|
if __name__ == "__main__": # pragma: no branch
|
||
|
main(sys.argv[1:]) # pragma: no cover
|