lib.App: Refactor App / Cmd to adress multiple functionality and style issues #60
2 changed files with 140 additions and 76 deletions
|
|
@ -4,15 +4,18 @@ import asyncio
|
||||||
import cProfile
|
import cProfile
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
|
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
|
||||||
from typing import TYPE_CHECKING, Any, cast, override
|
from typing import TYPE_CHECKING, Any, NamedTuple, cast, override
|
||||||
|
|
||||||
from .AsyncRunner import AsyncRunner
|
from .AsyncRunner import AsyncRunner
|
||||||
|
from .Cmd import AbstractCmd
|
||||||
from .log import (
|
from .log import (
|
||||||
DEBUG,
|
DEBUG,
|
||||||
ERR,
|
ERR,
|
||||||
NOTICE,
|
NOTICE,
|
||||||
|
WARNING,
|
||||||
LogFlag,
|
LogFlag,
|
||||||
log,
|
log,
|
||||||
log_m,
|
log_m,
|
||||||
|
|
@ -31,25 +34,47 @@ if TYPE_CHECKING:
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
def _get_current_event_loop() -> asyncio.AbstractEventLoop | None:
|
||||||
|
"""Return the current event loop of this thread, or None if there is
|
||||||
|
none, without creating one implicitly or emitting a deprecation
|
||||||
|
warning."""
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter('error', DeprecationWarning)
|
||||||
|
try:
|
||||||
|
return asyncio.get_event_loop()
|
||||||
|
except (RuntimeError, DeprecationWarning):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _SubCommand(NamedTuple):
|
||||||
|
|
||||||
|
cmd: AbstractCmd
|
||||||
|
parser: ArgumentParser
|
||||||
|
|
||||||
class App: # export
|
class App: # export
|
||||||
|
|
||||||
def _add_arguments(self, parser: ArgumentParser) -> None:
|
def _add_arguments(self, parser: ArgumentParser) -> None:
|
||||||
self.__parser.add_argument(
|
parser.add_argument(
|
||||||
'--log-flags', help = 'Log flags', default = self.__default_log_flags
|
'--log-flags',
|
||||||
|
help = 'Log flags',
|
||||||
|
default = self.__default_log_flags,
|
||||||
|
type = parse_log_flags,
|
||||||
)
|
)
|
||||||
self.__parser.add_argument(
|
parser.add_argument(
|
||||||
'--log-level', help = 'Log level', default = self.__default_log_level
|
'--log-level',
|
||||||
|
help = 'Log level',
|
||||||
|
default = self.__default_log_level,
|
||||||
|
type = parse_log_level,
|
||||||
)
|
)
|
||||||
self.__parser.add_argument(
|
parser.add_argument(
|
||||||
'--log-file', help = 'Log file', default = self.__default_log_file
|
'--log-file', help = 'Log file', default = self.__default_log_file
|
||||||
)
|
)
|
||||||
self.__parser.add_argument(
|
parser.add_argument(
|
||||||
'--backtrace',
|
'--backtrace',
|
||||||
help = 'Show exception backtraces',
|
help = 'Show exception backtraces',
|
||||||
action = 'store_true',
|
action = 'store_true',
|
||||||
default = self.__back_trace,
|
default = self.__back_trace,
|
||||||
)
|
)
|
||||||
self.__parser.add_argument(
|
parser.add_argument(
|
||||||
'--write-profile',
|
'--write-profile',
|
||||||
help = 'Profile code and store output to file',
|
help = 'Profile code and store output to file',
|
||||||
default = None,
|
default = None,
|
||||||
|
|
@ -87,6 +112,45 @@ class App: # export
|
||||||
eloop: asyncio.AbstractEventLoop | None = None,
|
eloop: asyncio.AbstractEventLoop | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
self.__args: Namespace | None = None
|
||||||
|
self.__cmdline: str | None = None
|
||||||
|
self.__description = description
|
||||||
|
|
||||||
|
self.__default_log_flags = self._default_log_flags(
|
||||||
|
LogFlag.STDERR | LogFlag.POSITION | LogFlag.PRIO | LogFlag.COLOR
|
||||||
|
)
|
||||||
|
if (env := os.getenv(self._default_log_flags_env(), None)) is not None:
|
||||||
|
self.__default_log_flags = parse_log_flags(env)
|
||||||
|
|
||||||
|
self.__default_log_level = self._default_log_level(NOTICE)
|
||||||
|
if (env := os.getenv(self._default_log_level_env(), None)) is not None:
|
||||||
|
self.__default_log_level = parse_log_level(env)
|
||||||
|
|
||||||
|
self.__default_log_file = self._default_log_file(None)
|
||||||
|
if (env := os.getenv(self._default_log_file_env(), None)) is not None:
|
||||||
|
self.__default_log_file = env
|
||||||
|
|
||||||
|
self.__back_trace = self._default_show_backtrace(False)
|
||||||
|
if (env := os.getenv(self._default_show_backtrace_env(), None)) is not None:
|
||||||
|
self.__back_trace = env.lower() in ['1', 'true']
|
||||||
|
|
||||||
|
set_log_flags(self.__default_log_flags)
|
||||||
|
set_log_level(self.__default_log_level)
|
||||||
|
|
||||||
|
self.__async_runner: AsyncRunner | None = None
|
||||||
|
self.__eloop = eloop
|
||||||
|
self.__own_eloop = False
|
||||||
|
|
||||||
|
cmd_classes: LoadTypes[AbstractCmd] = LoadTypes(
|
||||||
|
modules if modules else ['__main__'],
|
||||||
|
type_name_filter = name_filter,
|
||||||
|
type_filter = [AbstractCmd],
|
||||||
|
)
|
||||||
|
self.__cmds: list[AbstractCmd] = [cmd_class(self) for cmd_class in cmd_classes]
|
||||||
|
self._build_parser()
|
||||||
|
|
||||||
|
def _build_parser(self, argv: list[str] | None = None) -> None:
|
||||||
|
|
||||||
def add_cmd_to_parser(cmd: AbstractCmd, parsers: Any) -> ArgumentParser:
|
def add_cmd_to_parser(cmd: AbstractCmd, parsers: Any) -> ArgumentParser:
|
||||||
parser = cast(
|
parser = cast(
|
||||||
'ArgumentParser',
|
'ArgumentParser',
|
||||||
|
|
@ -112,23 +176,25 @@ class App: # export
|
||||||
if not cmds:
|
if not cmds:
|
||||||
return
|
return
|
||||||
|
|
||||||
class SubCommand:
|
|
||||||
|
|
||||||
def __init__(self, cmd: AbstractCmd, parser: Any):
|
|
||||||
self.cmd = cmd
|
|
||||||
self.parser = parser
|
|
||||||
|
|
||||||
title = 'Available subcommands'
|
title = 'Available subcommands'
|
||||||
if isinstance(parent, AbstractCmd):
|
if isinstance(parent, AbstractCmd):
|
||||||
title += ' of ' + parent.name
|
title += ' of ' + parent.name
|
||||||
subparsers = parser.add_subparsers(
|
subparsers = parser.add_subparsers(
|
||||||
title = title, metavar = '', dest = 'command'
|
title = title, metavar = '', dest = 'command'
|
||||||
)
|
)
|
||||||
scs: dict[str, SubCommand] = {}
|
scs: dict[str, _SubCommand] = {}
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
cmd.set_parent(parent)
|
cmd.set_parent(parent)
|
||||||
scs[cmd.name] = SubCommand(cmd, add_cmd_to_parser(cmd, subparsers))
|
if cmd.name in scs:
|
||||||
|
log(WARNING, f'Duplicate subcommand name: {cmd.name}')
|
||||||
|
scs[cmd.name] = _SubCommand(cmd, add_cmd_to_parser(cmd, subparsers))
|
||||||
for alias in cmd.aliases:
|
for alias in cmd.aliases:
|
||||||
|
if alias != cmd.name and alias in scs:
|
||||||
|
log(
|
||||||
|
WARNING,
|
||||||
|
f'Subcommand alias "{alias}" of "{cmd.name}" '
|
||||||
|
'collides with an earlier subcommand',
|
||||||
|
)
|
||||||
scs[alias] = scs[cmd.name]
|
scs[alias] = scs[cmd.name]
|
||||||
if all:
|
if all:
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
|
|
@ -139,69 +205,37 @@ class App: # export
|
||||||
sc.cmd, sc.parser, sc.cmd.children, all = all
|
sc.cmd, sc.parser, sc.cmd.children, all = all
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
args, _ = self.__parser.parse_known_args()
|
# -- Re-parse the command line to find the invoked subcommand.
|
||||||
|
# This works because every level below uses dest = 'command',
|
||||||
|
# so each pass descends one level further into the command
|
||||||
|
# tree.
|
||||||
|
args, _ = self.__parser.parse_known_args(argv)
|
||||||
cmd_name = getattr(args, 'command', None)
|
cmd_name = getattr(args, 'command', None)
|
||||||
if cmd_name in scs:
|
if cmd_name in scs:
|
||||||
sc = scs[cmd_name]
|
sc = scs[cmd_name]
|
||||||
add_cmds_to_parser(sc.cmd, sc.parser, sc.cmd.children, all = all)
|
add_cmds_to_parser(sc.cmd, sc.parser, sc.cmd.children, all = all)
|
||||||
|
|
||||||
from .Cmd import AbstractCmd
|
cmdline = sys.argv if argv is None else argv
|
||||||
|
if argv is None:
|
||||||
self.__args: Namespace | None = None
|
argv = sys.argv[1:]
|
||||||
self.__cmdline: str | None = None
|
add_all_parsers = (
|
||||||
|
'-h' in argv or '--help' in argv or '_ARGCOMPLETE' in os.environ
|
||||||
self.__default_log_flags = self._default_log_flags(
|
|
||||||
LogFlag.STDERR | LogFlag.POSITION | LogFlag.PRIO | LogFlag.COLOR
|
|
||||||
)
|
)
|
||||||
if (env := os.getenv(self._default_log_flags_env(), None)) is not None:
|
|
||||||
self.__default_log_flags = parse_log_flags(env)
|
|
||||||
|
|
||||||
self.__default_log_level = self._default_log_level(NOTICE)
|
|
||||||
if (env := os.getenv(self._default_log_level_env(), None)) is not None:
|
|
||||||
self.__default_log_level = parse_log_level(env)
|
|
||||||
|
|
||||||
self.__default_log_file = self._default_log_file(None)
|
|
||||||
if (env := os.getenv(self._default_log_file_env(), None)) is not None:
|
|
||||||
self.__default_log_file = env
|
|
||||||
|
|
||||||
self.__back_trace = self._default_show_backtrace(False)
|
|
||||||
if (env := os.getenv(self._default_show_backtrace_env())) is not None:
|
|
||||||
self.__back_trace = env.lower() in ['1', 'true']
|
|
||||||
|
|
||||||
set_log_flags(self.__default_log_flags)
|
|
||||||
set_log_level(self.__default_log_level)
|
|
||||||
|
|
||||||
self.__async_runner: AsyncRunner | None = None
|
|
||||||
self.__eloop = eloop
|
|
||||||
self.__own_eloop = False
|
|
||||||
|
|
||||||
self.__parser = ArgumentParser(
|
self.__parser = ArgumentParser(
|
||||||
formatter_class = ArgumentDefaultsHelpFormatter,
|
formatter_class = ArgumentDefaultsHelpFormatter,
|
||||||
description = description,
|
description = self.__description,
|
||||||
add_help = False,
|
add_help = False,
|
||||||
)
|
)
|
||||||
self._add_arguments(self.__parser)
|
self._add_arguments(self.__parser)
|
||||||
|
|
||||||
args, _ = self.__parser.parse_known_args()
|
args, _ = self.__parser.parse_known_args(argv)
|
||||||
set_log_flags(args.log_flags)
|
set_log_flags(args.log_flags)
|
||||||
set_log_level(args.log_level)
|
set_log_level(args.log_level)
|
||||||
|
|
||||||
log(DEBUG, f'-------------- Running: >{pretty_cmd(sys.argv)}<')
|
log(DEBUG, f'-------------- Running: >{pretty_cmd(cmdline)}<')
|
||||||
|
|
||||||
cmd_classes: LoadTypes[AbstractCmd] = LoadTypes(
|
add_cmds_to_parser(self, self.__parser, self.__cmds, all = add_all_parsers)
|
||||||
modules if modules else ['__main__'],
|
|
||||||
type_name_filter = name_filter,
|
|
||||||
type_filter = [AbstractCmd],
|
|
||||||
)
|
|
||||||
add_all_parsers = (
|
|
||||||
'-h' in sys.argv or '--help' in sys.argv or '_ARGCOMPLETE' in os.environ
|
|
||||||
)
|
|
||||||
add_cmds_to_parser(
|
|
||||||
self,
|
|
||||||
self.__parser,
|
|
||||||
[cmd_class(self) for cmd_class in cmd_classes],
|
|
||||||
all = add_all_parsers,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Add help only now, wouldn't want to have parse_known_args() exit
|
# -- Add help only now, wouldn't want to have parse_known_args() exit
|
||||||
# on --help with subcommands missing
|
# on --help with subcommands missing
|
||||||
|
|
@ -210,14 +244,19 @@ class App: # export
|
||||||
)
|
)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
"""Close the application and release all resources"""
|
||||||
|
if self.__async_runner is not None:
|
||||||
|
self.__async_runner.close()
|
||||||
|
self.__async_runner = None
|
||||||
if self.__own_eloop:
|
if self.__own_eloop:
|
||||||
if self.__eloop is not None:
|
if self.__eloop is not None:
|
||||||
if not self.__eloop.is_closed():
|
if not self.__eloop.is_closed():
|
||||||
self.__eloop.close()
|
self.__eloop.close()
|
||||||
self.__eloop = None
|
self.__eloop = None
|
||||||
|
self.__own_eloop = False
|
||||||
|
|
||||||
async def __aenter__(self) -> None:
|
async def __aenter__(self) -> App:
|
||||||
pass
|
return self
|
||||||
|
|
||||||
async def __aexit__(
|
async def __aexit__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -225,7 +264,7 @@ class App: # export
|
||||||
exc: BaseException | None,
|
exc: BaseException | None,
|
||||||
tb: types.TracebackType | None,
|
tb: types.TracebackType | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
self.close()
|
||||||
|
|
||||||
async def __run(self, argv: list[str] | None = None) -> None:
|
async def __run(self, argv: list[str] | None = None) -> None:
|
||||||
|
|
||||||
|
|
@ -245,8 +284,10 @@ class App: # export
|
||||||
|
|
||||||
argcomplete.autocomplete(self.__parser, default_completer = NoopCompleter())
|
argcomplete.autocomplete(self.__parser, default_completer = NoopCompleter())
|
||||||
|
|
||||||
except Exception:
|
except ImportError:
|
||||||
pass
|
log(DEBUG, 'argcomplete is not installed, shell completion disabled')
|
||||||
|
except Exception as e:
|
||||||
|
log(DEBUG, f'Shell completion disabled: {e}')
|
||||||
|
|
||||||
self.__args = self.__parser.parse_args(args = argv)
|
self.__args = self.__parser.parse_args(args = argv)
|
||||||
|
|
||||||
|
|
@ -261,9 +302,17 @@ class App: # export
|
||||||
pr.enable()
|
pr.enable()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ret = await self._run(self.__args)
|
result = await self._run(self.__args)
|
||||||
if isinstance(ret, int) and ret >= 0 and ret <= 0xFF:
|
if isinstance(result, int):
|
||||||
exit_status = ret
|
if 0 <= result <= 0xFF:
|
||||||
|
exit_status = result
|
||||||
|
else:
|
||||||
|
log(
|
||||||
|
WARNING,
|
||||||
|
f'Command returned invalid exit status {result}, '
|
||||||
|
'using 1 instead',
|
||||||
|
)
|
||||||
|
exit_status = 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_m(ERR, f'Failed: {repr(e) if self.__back_trace else str(e)}')
|
log_m(ERR, f'Failed: {repr(e) if self.__back_trace else str(e)}')
|
||||||
exit_status = 1
|
exit_status = 1
|
||||||
|
|
@ -288,11 +337,11 @@ class App: # export
|
||||||
# want to do something else, for instance if you don't have sub-commands,
|
# want to do something else, for instance if you don't have sub-commands,
|
||||||
# or if want to do anything before and / or after the subcommands.
|
# or if want to do anything before and / or after the subcommands.
|
||||||
async def _run(self, args: Namespace) -> None | int:
|
async def _run(self, args: Namespace) -> None | int:
|
||||||
if not hasattr(self.__args, 'func'):
|
if not hasattr(args, 'func'):
|
||||||
self.__parser.print_help()
|
self.__parser.print_help()
|
||||||
return None
|
return None
|
||||||
# Run sub-command. Overwrite if you want to do anything before or after
|
# Run sub-command. Overwrite if you want to do anything before or after
|
||||||
return cast('None | int', await self.args.func(args))
|
return cast('None | int', await args.func(args))
|
||||||
|
|
||||||
def call_async(self, awaitable: Awaitable[T], timeout: float | None = None) -> T:
|
def call_async(self, awaitable: Awaitable[T], timeout: float | None = None) -> T:
|
||||||
return self.async_runner.call(awaitable, timeout)
|
return self.async_runner.call(awaitable, timeout)
|
||||||
|
|
@ -330,18 +379,25 @@ class App: # export
|
||||||
return self.__parser
|
return self.__parser
|
||||||
|
|
||||||
def run(self, argv: list[str] | None = None) -> None:
|
def run(self, argv: list[str] | None = None) -> None:
|
||||||
|
previous_eloop: asyncio.AbstractEventLoop | None = None
|
||||||
if self.__eloop is None:
|
if self.__eloop is None:
|
||||||
|
previous_eloop = _get_current_event_loop()
|
||||||
eloop = asyncio.new_event_loop()
|
eloop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(eloop)
|
asyncio.set_event_loop(eloop)
|
||||||
self.__eloop = eloop
|
self.__eloop = eloop
|
||||||
self.__own_eloop = True
|
self.__own_eloop = True
|
||||||
try:
|
try:
|
||||||
|
if argv is not None:
|
||||||
|
self._build_parser(argv)
|
||||||
ret = self.eloop.run_until_complete(self.__run(argv))
|
ret = self.eloop.run_until_complete(self.__run(argv))
|
||||||
finally:
|
finally:
|
||||||
if self.__async_runner:
|
|
||||||
self.__async_runner.close()
|
|
||||||
self.__async_runner = None
|
|
||||||
self.close()
|
self.close()
|
||||||
|
# -- Restore the event loop the thread had before run(), or
|
||||||
|
# unset the loop if there was none.
|
||||||
|
if previous_eloop is not None:
|
||||||
|
asyncio.set_event_loop(previous_eloop)
|
||||||
|
else:
|
||||||
|
asyncio.set_event_loop(None)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,15 @@ class AbstractCmd(abc.ABC):
|
||||||
self, cmds: Cmd | list[Cmd] | Types[Any] | list[Types[Any]]
|
self, cmds: Cmd | list[Cmd] | Types[Any] | list[Types[Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
if isinstance(cmds, Cmd):
|
if isinstance(cmds, Cmd):
|
||||||
raise NotImplementedError('Single Cmd should be handled elsewhere')
|
if any(child.name == cmds.name for child in self.__children):
|
||||||
|
raise Exception(
|
||||||
|
f'Can\'t register subcommand with already taken name "{cmds.name}"'
|
||||||
|
)
|
||||||
|
self.__child_classes.append(type(cmds))
|
||||||
|
cmds.set_parent(self)
|
||||||
|
self.__children.append(cmds)
|
||||||
|
assert len(self.__children) == len(self.__child_classes)
|
||||||
|
return
|
||||||
if isinstance(cmds, list):
|
if isinstance(cmds, list):
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
self.add_subcommands(cmd)
|
self.add_subcommands(cmd)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue