__init__() builds the parser and the lazy subcommand registration inside it decides which subcommands to register by re-parsing sys.argv. run() then parses a different argv, so if the caller passes an argv that is deeper than the one in sys.argv, the required subparsers have not been registered and the invocation fails with an "unrecognized arguments" error. run_sub_commands() passes argv to run(), so the mismatch is reachable from the public API. Move the parser construction from __init__() into _build_parser() and call it from run() when an argv is given, so that registration and parsing are driven by the same command line. The top-level command instances are created once in __init__() and reused when the parser is rebuilt. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann <jan@janware.com>
392 lines
13 KiB
Python
392 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import cProfile
|
|
import os
|
|
import sys
|
|
import warnings
|
|
|
|
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
|
|
from typing import TYPE_CHECKING, Any, cast, override
|
|
|
|
from .AsyncRunner import AsyncRunner
|
|
from .log import (
|
|
DEBUG,
|
|
ERR,
|
|
NOTICE,
|
|
LogFlag,
|
|
log,
|
|
log_m,
|
|
parse_log_flags,
|
|
parse_log_level,
|
|
set_log_flags,
|
|
set_log_level
|
|
)
|
|
from .Types import LoadTypes
|
|
from .util import pretty_cmd
|
|
|
|
if TYPE_CHECKING:
|
|
import types
|
|
|
|
from collections.abc import Awaitable, Collection
|
|
from typing import TypeVar
|
|
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 App: # export
|
|
|
|
def _add_arguments(self, parser: ArgumentParser) -> None:
|
|
self.__parser.add_argument(
|
|
'--log-flags',
|
|
help = 'Log flags',
|
|
default = self.__default_log_flags,
|
|
type = parse_log_flags,
|
|
)
|
|
self.__parser.add_argument(
|
|
'--log-level',
|
|
help = 'Log level',
|
|
default = self.__default_log_level,
|
|
type = parse_log_level,
|
|
)
|
|
self.__parser.add_argument(
|
|
'--log-file', help = 'Log file', default = self.__default_log_file
|
|
)
|
|
self.__parser.add_argument(
|
|
'--backtrace',
|
|
help = 'Show exception backtraces',
|
|
action = 'store_true',
|
|
default = self.__back_trace,
|
|
)
|
|
self.__parser.add_argument(
|
|
'--write-profile',
|
|
help = 'Profile code and store output to file',
|
|
default = None,
|
|
)
|
|
|
|
def _default_log_flags_env(self) -> str:
|
|
return 'JW_DEFAULT_LOG_FLAGS'
|
|
|
|
def _default_log_flags(self, default: LogFlag) -> LogFlag:
|
|
return default
|
|
|
|
def _default_log_level_env(self) -> str:
|
|
return 'JW_DEFAULT_LOG_LEVEL'
|
|
|
|
def _default_log_level(self, default: int) -> int:
|
|
return default
|
|
|
|
def _default_log_file_env(self) -> str:
|
|
return 'JW_DEFAULT_LOG_FILE'
|
|
|
|
def _default_log_file(self, default: str | None) -> str | None:
|
|
return default
|
|
|
|
def _default_show_backtrace_env(self) -> str:
|
|
return 'JW_DEFAULT_SHOW_BACKTRACE'
|
|
|
|
def _default_show_backtrace(self, default: bool) -> bool:
|
|
return default
|
|
|
|
def __init__(
|
|
self,
|
|
description: str = '',
|
|
name_filter: str = '^Cmd.*',
|
|
modules: list[str] | None = None,
|
|
eloop: asyncio.AbstractEventLoop | None = None,
|
|
) -> None:
|
|
|
|
from .Cmd import AbstractCmd
|
|
|
|
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())) 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:
|
|
parser = cast(
|
|
'ArgumentParser',
|
|
parsers.add_parser(
|
|
cmd.name,
|
|
help = cmd.help,
|
|
description = cmd.description,
|
|
aliases = cmd.aliases,
|
|
formatter_class = ArgumentDefaultsHelpFormatter,
|
|
)
|
|
)
|
|
parser.set_defaults(func = cmd.run)
|
|
cmd.add_arguments(parser)
|
|
cmd.set_parser(parser)
|
|
return parser
|
|
|
|
def add_cmds_to_parser(
|
|
parent: AbstractCmd | App,
|
|
parser: ArgumentParser,
|
|
cmds: Collection[AbstractCmd],
|
|
all: bool = False
|
|
) -> None:
|
|
if not cmds:
|
|
return
|
|
|
|
class SubCommand:
|
|
|
|
def __init__(self, cmd: AbstractCmd, parser: Any):
|
|
self.cmd = cmd
|
|
self.parser = parser
|
|
|
|
title = 'Available subcommands'
|
|
if isinstance(parent, AbstractCmd):
|
|
title += ' of ' + parent.name
|
|
subparsers = parser.add_subparsers(
|
|
title = title, metavar = '', dest = 'command'
|
|
)
|
|
scs: dict[str, SubCommand] = {}
|
|
for cmd in cmds:
|
|
cmd.set_parent(parent)
|
|
scs[cmd.name] = SubCommand(cmd, add_cmd_to_parser(cmd, subparsers))
|
|
for alias in cmd.aliases:
|
|
scs[alias] = scs[cmd.name]
|
|
if all:
|
|
seen: set[int] = set()
|
|
for sc in scs.values():
|
|
if id(sc) not in seen:
|
|
seen.add(id(sc))
|
|
add_cmds_to_parser(
|
|
sc.cmd, sc.parser, sc.cmd.children, all = all
|
|
)
|
|
return
|
|
args, _ = self.__parser.parse_known_args(argv)
|
|
cmd_name = getattr(args, 'command', None)
|
|
if cmd_name in scs:
|
|
sc = scs[cmd_name]
|
|
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:
|
|
argv = sys.argv[1:]
|
|
add_all_parsers = (
|
|
'-h' in argv or '--help' in argv or '_ARGCOMPLETE' in os.environ
|
|
)
|
|
|
|
self.__parser = ArgumentParser(
|
|
formatter_class = ArgumentDefaultsHelpFormatter,
|
|
description = self.__description,
|
|
add_help = False,
|
|
)
|
|
self._add_arguments(self.__parser)
|
|
|
|
args, _ = self.__parser.parse_known_args(argv)
|
|
set_log_flags(args.log_flags)
|
|
set_log_level(args.log_level)
|
|
|
|
log(DEBUG, f'-------------- Running: >{pretty_cmd(cmdline)}<')
|
|
|
|
add_cmds_to_parser(self, self.__parser, self.__cmds, all = add_all_parsers)
|
|
|
|
# -- Add help only now, wouldn't want to have parse_known_args() exit
|
|
# on --help with subcommands missing
|
|
self.__parser.add_argument(
|
|
'-h', '--help', action = 'help', help = 'Show this help message and exit'
|
|
)
|
|
|
|
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.__eloop is not None:
|
|
if not self.__eloop.is_closed():
|
|
self.__eloop.close()
|
|
self.__eloop = None
|
|
self.__own_eloop = False
|
|
|
|
async def __aenter__(self) -> App:
|
|
return self
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
tb: types.TracebackType | None,
|
|
) -> None:
|
|
self.close()
|
|
|
|
async def __run(self, argv: list[str] | None = None) -> None:
|
|
|
|
try:
|
|
# Import argcomplete only here to not require it to be compatible
|
|
# with minimal environments
|
|
from argcomplete.completers import BaseCompleter # type: ignore[import-not-found, unused-ignore] # isort: skip
|
|
|
|
class NoopCompleter(BaseCompleter): # type: ignore[misc, unused-ignore]
|
|
|
|
@override
|
|
def __call__( # pyright: ignore[reportGeneralTypeIssues]
|
|
self, *args: Any, **kwargs: Any) -> None:
|
|
return None
|
|
|
|
import argcomplete # type: ignore[import-not-found, unused-ignore]
|
|
|
|
argcomplete.autocomplete(self.__parser, default_completer = NoopCompleter())
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
self.__args = self.__parser.parse_args(args = argv)
|
|
|
|
set_log_flags(self.__args.log_flags)
|
|
set_log_level(self.__args.log_level)
|
|
self.__back_trace = self.__args.backtrace
|
|
|
|
exit_status = 0
|
|
|
|
pr = None if self.__args.write_profile is None else cProfile.Profile()
|
|
if pr is not None:
|
|
pr.enable()
|
|
|
|
try:
|
|
ret = await self._run(self.__args)
|
|
if isinstance(ret, int) and ret >= 0 and ret <= 0xFF:
|
|
exit_status = ret
|
|
except Exception as e:
|
|
log_m(ERR, f'Failed: {repr(e) if self.__back_trace else str(e)}')
|
|
exit_status = 1
|
|
# AssertionErrors are programming errors, hence a programmer should
|
|
# get a chance to figure it out
|
|
if self.__back_trace or isinstance(e, AssertionError):
|
|
raise
|
|
finally:
|
|
if pr is not None:
|
|
pr.disable()
|
|
log(
|
|
NOTICE,
|
|
f'Writing profile statistics to {self.__args.write_profile}'
|
|
)
|
|
assert self.__args.write_profile is not None, 'args.write_profile'
|
|
pr.dump_stats(self.__args.write_profile)
|
|
|
|
if exit_status:
|
|
sys.exit(exit_status)
|
|
|
|
# Do the main work. Tries to run sub-commands by default. Overwrite if you
|
|
# 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.
|
|
async def _run(self, args: Namespace) -> None | int:
|
|
if not hasattr(self.__args, 'func'):
|
|
self.__parser.print_help()
|
|
return None
|
|
# Run sub-command. Overwrite if you want to do anything before or after
|
|
return cast('None | int', await self.args.func(args))
|
|
|
|
def call_async(self, awaitable: Awaitable[T], timeout: float | None = None) -> T:
|
|
return self.async_runner.call(awaitable, timeout)
|
|
|
|
@property
|
|
def eloop(self) -> asyncio.AbstractEventLoop:
|
|
if self.__eloop is None:
|
|
raise Exception('Tried to get inexistent event loop from application')
|
|
return self.__eloop
|
|
|
|
@property
|
|
def async_runner(self) -> AsyncRunner:
|
|
if self.__async_runner is None:
|
|
self.__async_runner = AsyncRunner()
|
|
return self.__async_runner
|
|
|
|
@property
|
|
def cmdline(self) -> str:
|
|
if self.__cmdline is None:
|
|
import shlex
|
|
|
|
with open('/proc/self/cmdline', 'rb') as f:
|
|
raw = f.read().split(b'\0')[:-1]
|
|
self.__cmdline = ' '.join(shlex.quote(arg.decode()) for arg in raw)
|
|
return self.__cmdline
|
|
|
|
@property
|
|
def args(self) -> Namespace:
|
|
if self.__args is None:
|
|
raise Exception('Tried to get inexistent argument list from application')
|
|
return self.__args
|
|
|
|
@property
|
|
def parser(self) -> ArgumentParser:
|
|
return self.__parser
|
|
|
|
def run(self, argv: list[str] | None = None) -> None:
|
|
previous_eloop: asyncio.AbstractEventLoop | None = None
|
|
if self.__eloop is None:
|
|
previous_eloop = _get_current_event_loop()
|
|
eloop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(eloop)
|
|
self.__eloop = eloop
|
|
self.__own_eloop = True
|
|
try:
|
|
if argv is not None:
|
|
self._build_parser(argv)
|
|
ret = self.eloop.run_until_complete(self.__run(argv))
|
|
finally:
|
|
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
|
|
|
|
|
|
def run_sub_commands( # export
|
|
description: str = '',
|
|
name_filter: str = '^Cmd.*',
|
|
modules: list[str] | None = None,
|
|
argv: list[str] | None = None,
|
|
) -> None:
|
|
app = App(description, name_filter, modules)
|
|
app.run(argv = argv)
|