ExecApp is a ready-made base class for applications that operate through an ExecContext: it adds the --interactive, --verbose and --target options, exposes interactive, verbose and exec_context properties, and closes the exec context when the async context manager exits. The code for that has lived in jw.pkg.App code before, which now inherits from ExecApp. A fix along the way: __aexit__() closes the exec context and then chains to super().__aexit__(), so App.close() runs when the async context manager exits. Before the change, exiting the async context left the app unclosed; close() ran only on the run() path. Add a unit test that builds an ExecApp with a root command and asserts that close() runs on context exit and that the exec options are registered. The exec options are now registered before App's own options, which moves them up in the rendered --help output. Update the golden file of the help integration test to match. Signed-off-by: Jan Lindemann <jan@janware.com> Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from typing import TYPE_CHECKING, Any, override
|
|
|
|
from .App import App as Base
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
from argparse import ArgumentParser
|
|
|
|
from .ExecContext import ExecContext
|
|
|
|
class ExecApp(Base): # export
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
super().__init__(**kwargs)
|
|
self.__opt_interactive: bool | None = None
|
|
self.__opt_verbose: bool | None = None
|
|
self.__exec_context: ExecContext | None = None
|
|
|
|
@override
|
|
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
if self.__exec_context is not None:
|
|
await self.__exec_context.close()
|
|
self.__exec_context = None
|
|
# -- Close the app (async runner, event loop) after the exec context,
|
|
# which may still need them.
|
|
await super().__aexit__(exc_type, exc, tb)
|
|
|
|
@override
|
|
def _add_arguments(self, parser: ArgumentParser) -> None:
|
|
super()._add_arguments(parser)
|
|
parser.add_argument(
|
|
'--interactive',
|
|
choices = ['true', 'false', 'auto'],
|
|
default = 'true',
|
|
help = 'Wait for user input or try to proceed unattended',
|
|
)
|
|
parser.add_argument(
|
|
'--verbose',
|
|
action = 'store_true',
|
|
default = False,
|
|
help = "Be verbose on stderr about what's being done on the distro level",
|
|
)
|
|
parser.add_argument(
|
|
'--target', default = 'local', help = 'Run commands on this host'
|
|
)
|
|
|
|
@property
|
|
def interactive(self) -> bool:
|
|
if self.__opt_interactive is None:
|
|
match self.args.interactive:
|
|
case 'true':
|
|
self.__opt_interactive = True
|
|
case 'false':
|
|
self.__opt_interactive = False
|
|
case 'auto':
|
|
self.__opt_interactive = sys.stdin.isatty()
|
|
case _:
|
|
raise ValueError(
|
|
f'Unknown --interactive value: {self.args.interactive}'
|
|
)
|
|
# Not logically possible to fail, but this keeps pyright happy
|
|
assert self.__opt_interactive is not None
|
|
return self.__opt_interactive
|
|
|
|
@property
|
|
def verbose(self) -> bool:
|
|
if self.__opt_verbose is None:
|
|
self.__opt_verbose = self.args.verbose
|
|
# Not logically possible to fail, but this keeps pyright happy
|
|
assert self.__opt_verbose is not None
|
|
return self.__opt_verbose
|
|
|
|
@property
|
|
def exec_context(self) -> ExecContext:
|
|
if self.__exec_context is None:
|
|
from .ExecContext import ExecContext
|
|
|
|
self.__exec_context = ExecContext.create(
|
|
self.args.target,
|
|
interactive = self.interactive,
|
|
verbose_default = self.verbose,
|
|
)
|
|
return self.__exec_context
|