Source code for cmdkit.app

# SPDX-FileCopyrightText: 2022 CmdKit Developers
# SPDX-License-Identifier: Apache-2.0

"""Application class implementation."""


# type annotations
from __future__ import annotations
from typing import List, Dict, Callable, NamedTuple, Type, TypeVar

# standard libs
import abc
import logging

# internal libs
from cmdkit.cli import Interface, HelpOption, VersionOption, ArgumentError
from cmdkit.namespace import Namespace

# public interface
__all__ = ['exit_status', 'Application', 'ApplicationGroup', 'CompletedCommand']


TApp = TypeVar('TApp', bound='Application')
TAppGrp = TypeVar('TAppGrp', bound='ApplicationGroup')


log = logging.getLogger(__name__)


# NOTE: In next major release this will be made into an Enum
class ExitStatus(NamedTuple):
    """Collection of exit status values."""
    success:            int = 0
    usage:              int = 1
    bad_argument:       int = 2
    bad_config:         int = 3
    keyboard_interrupt: int = 4
    runtime_error:      int = 5
    uncaught_exception: int = 6


# global shared instance
exit_status = ExitStatus()


[docs] class Application(abc.ABC): """ Abstract base class for all application interfaces. An application is typically initialized with one of the factory methods :func:`~from_namespace` or :func:`~from_cmdline`. These parse command-line arguments using the member :class:`~Interface`. Direct initialization takes named parameters and are simply assigned to the instance. These should be existing class-level attributes with annotations. """ interface: Interface = None ALLOW_NOARGS: bool = False shared: Namespace = None exceptions: Dict[Type[Exception], Callable[[Exception], int]] = dict() log_critical: Callable[[str], None] = log.critical log_exception: Callable[[str], None] = log.exception
[docs] @classmethod def handle_help(cls, message: str) -> None: print(message)
[docs] @classmethod def handle_version(cls, *args) -> None: print(*args)
[docs] @classmethod def handle_usage(cls, message: str) -> None: print(message)
def __init__(self, **parameters) -> None: """Direct initialization sets `parameters`.""" for name, value in parameters.items(): setattr(self, name, value)
[docs] @classmethod def from_cmdline(cls: Type[TApp], cmdline: List[str] = None) -> TApp: """Initialize via command-line arguments (e.g., `sys.argv`).""" return cls.from_namespace(cls.interface.parse_args(cmdline))
[docs] @classmethod def from_namespace(cls: Type[TApp], namespace: Namespace) -> TApp: """Initialize via existing namespace/namedtuple.""" return cls(**vars(namespace))
[docs] @classmethod def main(cls, cmdline: List[str] = None, shared: Namespace = None) -> int: """ Entry-point for application. This is a try-except block that handles standard scenarios. See Also: :data:`~Application.exceptions` """ try: if not cmdline: if hasattr(cls, 'ALLOW_NOARGS') and cls.ALLOW_NOARGS is True: pass else: cls.handle_usage(cls.interface.usage_text) return exit_status.usage with cls.from_cmdline(cmdline) as app: if shared is not None: app.shared = Namespace(shared) if not app.shared else Namespace({**shared, **app.shared}) app.run() return exit_status.success except HelpOption as help_opt: cls.handle_help(*help_opt.args) return exit_status.success except VersionOption as version: cls.handle_version(*version.args) return exit_status.success except ArgumentError as error: cls.log_critical(error) return exit_status.bad_argument except KeyboardInterrupt: cls.log_critical('keyboard-interrupt: going down now!') return exit_status.keyboard_interrupt except Exception as error: for exc_type, exc_handler in cls.exceptions.items(): if isinstance(error, exc_type): return exc_handler(error) cls.log_exception('uncaught exception occurred!') raise
[docs] @abc.abstractmethod def run(self) -> None: """Business-logic of the application.""" raise NotImplementedError()
[docs] def __enter__(self) -> Application: """Place-holder for context manager.""" return self
[docs] def __exit__(self, *exc) -> None: """Release resources.""" pass
class CompletedCommand(Exception): """Contains the exit status of a member application's main method."""
[docs] class ApplicationGroup(Application): """A group entry-point delegates to a member `Application`.""" interface: Interface = None commands: Dict[str, Type[Application]] = None command: str = None ALLOW_PARSE: bool = False cmdline: List[str] = None exceptions = { CompletedCommand: (lambda cmd: int(cmd.args[0])) } @classmethod def from_cmdline(cls: Type[TAppGrp], cmdline: List[str] = None) -> TAppGrp: """Initialize via command-line arguments (e.g., `sys.argv`).""" if not cmdline: return super(ApplicationGroup, cls).from_cmdline(cmdline) else: if cls.ALLOW_PARSE is True and not any(arg in cmdline for arg in {'-h', '--help'}): known, remainder = cls.interface.parse_known_intermixed_args(cmdline) self = super(ApplicationGroup, cls).from_namespace(known) self.cmdline = remainder self.shared = Namespace(vars(known)) self.shared.pop('command') else: first, *remainder = cmdline self = super(ApplicationGroup, cls).from_cmdline([first, ]) self.cmdline = list(remainder) return self def run(self) -> None: """Delegate to member application.""" if self.command in self.commands: app = self.commands[self.command] status = app.main(self.cmdline, shared=self.shared) raise CompletedCommand(status) else: raise ArgumentError(f'unrecognized command: {self.command}')