Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
Size: Mime:
"""Base classes for implementing applications."""

from exceptiongroup import catch
from functools import partial
from importlib import import_module
from logging import getLogger, Logger
from trio import CancelScope, Nursery, open_nursery
from typing import Awaitable, Callable, List, Optional, Union, final

from .configurator import AppConfigurator, Configuration
from .errors import ApplicationExit

__all__ = ("AsyncApp", "SyncApp")


class BaseApp:
    """Base class for all application subclasses in this framework.

    This base class holds all the code that is common for AsyncApp_ and
    SyncApp_.
    """

    config: Configuration
    """Dictionary holding the configuration options of the application"""

    debug: bool
    """Whether the app is in debug mode"""

    log: Logger
    """The logger of the application"""

    _app_name: str
    _app_full_name: Optional[str]
    _configurator: Optional[AppConfigurator]
    _package_name: str
    _prepared: bool = False
    _version: Optional[str]

    _shutting_down: bool = False
    """Stores whether the application is currently being shut down."""

    def __init__(
        self,
        name: str,
        package_name: str,
        *,
        full_name: Optional[str] = None,
        log: Optional[Union[str, Logger]] = None,
    ):
        """Constructor.

        Parameters:
            name: short, lowercase, human-readable name of the application,
                without spaces. Used to derive the name of the root logger, the
                default configuration file and the environment variable that
                holds the configuration filename override.
            package_name: name of the Python package that holds the code of the
                application. The default configuration of the app is assumed to
                be in a Python module named `config` within this package.
                Extensions corresponding to the application are looked up in the
                `ext` subpackage of this package.
            full_name: longer, human-readable name of the application, which
                may also contain spaces. Falls back to the short app name if
                not specified.
            log: name of the logger to use by the app; defaults to the
                application name. You may also pass a Logger instance here
        """
        if " " in name:
            raise ValueError("App name may not contain spaces")

        self._app_name = name
        self._app_full_name = full_name
        self._configurator = None
        self._package_name = package_name
        self._prepared = False
        self._version = None

        self.config = {}
        self.debug = False

        logger = log or self._app_name
        if hasattr(logger, "info"):
            self.log = logger  # type: ignore
        elif isinstance(logger, str):
            self.log = getLogger(logger)
        else:
            raise TypeError("Invalid logger type, expected str or Logger")

        self._create_basic_components()
        self._create_components()

    def _create_basic_components(self) -> None:
        """Creates the most basic components of the application.

        This function is called by the constructor once at construction time.
        You should not need to call it later.

        Typically, you should not override this function; override
        `_create_components()` instead. If you do override this function, make
        sure to call the superclass implementation.

        The configuration of the app is not loaded yet when this function is
        executed. Avoid querying the configuration of the app here because
        the settings will not be up-to-date yet. Use `prepare()` for any
        preparations that depend on the configuration.
        """
        pass

    def _setup_app_configurator(self, configurator: AppConfigurator) -> None:
        """Hook function that is called after the app constructs the
        AppConfigurator_ instance responsible for parsing configuration files,
        and that allows one to update some properties of the AppConfigurator_
        to tweak its behaviour.

        The default implementation does nothing; you can safely override this
        function in subclasses.
        """
        pass

    def prepare(
        self, config: Optional[str] = None, debug: bool = False
    ) -> Optional[int]:
        """Hook function that contains preparation steps that should be
        performed by the app before it starts running.

        Parameters:
            config: name of the configuration file to load
            debug: whether to force the app into debug mode

        Returns:
            error code to terminate the app with if the preparation was not
            successful; ``None`` if the preparation was successful
        """
        configurator = AppConfigurator(
            self.config,
            environment_variable=(self._app_name.upper() + "_SETTINGS"),
            default_filename=(
                self._app_name + ".cfg",
                self._app_name + ".toml",
                self._app_name + ".jsonc",
                self._app_name + ".json",
            ),
            log=self.log,
            package_name=self._package_name,
        )
        self._setup_app_configurator(configurator)
        self._configurator = configurator
        if not configurator.configure(config):
            return 1

        if debug or self.config.get("DEBUG"):
            self.debug = True

        result = self._process_configuration(self.config)
        self._prepared = True

        return result

    @property
    def shutting_down(self) -> bool:
        """Returns whether the application is currently shutting down."""
        return self._shutting_down

    def _create_components(self) -> None:
        """Creates the components of the application.

        This function is called by the constructor once at construction time.
        You should not need to call it later.

        The default implementation of this function does nothing; you can safely
        override it in derived classes without calling the superclass implementation.

        The configuration of the app is not loaded yet when this function is
        executed. Avoid querying the configuration of the app here because
        the settings will not be up-to-date yet. Use `_process_configuration()`
        for any preparations that depend on the configuration.
        """
        pass

    def _process_configuration(self, config: Configuration) -> Optional[int]:
        """Processes the configuration of the application after it was
        configured.

        The default implementation of this function does nothing; you can safely
        override it in derived classes without calling the superclass implementation.

        Returns:
            error code to terminate the app with if there was an error while
            processing the configuration, or ``None`` if the processing was
            successful
        """
        pass

    @property
    def app_name(self) -> str:
        """Short, human-readable name of the application, without spaces."""
        return self._app_name

    @property
    def app_full_name(self) -> str:
        """Longer, human-readable name of the application, may contain spaces.
        Defaults to the short name of the application. Use this property in
        contexts where you want to present the full name of the application and
        it does not matter if it contains spaces.
        """
        return self._app_full_name or self._app_name

    @property
    def configurator(self) -> AppConfigurator:
        """The configurator object of the application that was responsible for
        loading the configuration from various sources. Must be called only after
        the app was prepared with `prepare()`.

        Raises:
            RuntimeError: when called before the app was prepared
        """
        if self._configurator is None:
            raise RuntimeError("Application was not prepared yet")
        return self._configurator

    @property
    def version(self) -> str:
        """Returns the version number of the application.

        The version number is imported from the `.version` module of the
        application package. Returns `0.0.0` if there is no such module.
        """
        if self._version is None:
            try:
                version_module = import_module(".version", self._package_name)
            except ImportError:
                version_module = None
            self._version = str(getattr(version_module, "__version__", "0.0.0"))
        return self._version


class AsyncApp(BaseApp):
    """Base class for apps that revolve around running several asynchronous
    tasks concurrently.

    Basically almost all of our apps except the most simple ones are based
    either on this base class or on SyncApp_.
    """

    _nursery: Optional[Nursery]
    _pending_tasks: List[Callable[[], Awaitable[None]]]

    def __init__(
        self,
        name: str,
        package_name: str,
        *,
        full_name: Optional[str] = None,
        log: Optional[Union[str, Logger]] = None,
    ):
        """Constructor.

        Parameters:
            name: short, lowercase, human-readable name of the asynchronous
                application, without spaces. Used to derive the name of the
                root logger, the default configuration file and the environment
                variable that holds the configuration filename override.
            package_name: name of the Python package that holds the code of the
                async app. The default configuration of the app is assumed to
                be in a Python module named `config` within this package.
                Extensions corresponding to the async app are looked up in the
                `ext` subpackage of this package.
            log: name of the logger to use by the app; defaults to the
                application name. You may also pass a Logger instance here
            full_name: longer, human-readable name of the application, which
                may also contain spaces. Falls back to the short app name if
                not specified.
        """
        super().__init__(name, package_name, full_name=full_name, log=log)

        # Placeholder for a nursery that parents all tasks in the app.
        # This will be set to a real nursery when the app starts.
        self._nursery = None
        self._pending_tasks = []

    def request_shutdown(self) -> None:
        """Requests tha application to shut down in a clean way.

        Has no effect if the main nursery of the app is not running.
        """
        if self._nursery:
            self._nursery.cancel_scope.cancel()

    def run_in_background(
        self, func, *args, cancellable: bool = False, protected: bool = False
    ) -> Optional[CancelScope]:
        """Runs the given function as a background task in the application.

        Parameters:
            cancellable: whether the task is cancellable
            protected: whether the task should be executed in protected mode,
                i.e. in a way that prevents the main nursery of the application
                from being cancelled if the task dies. When this is `True`,
                unexpected exceptions from the task will be logged and then
                swallowed

        Returns:
            an optional cancel scope that can be used to cancel the background
            task if it is cancellable
        """
        scope = CancelScope() if cancellable or hasattr(func, "_cancellable") else None
        if scope is not None:
            func = partial(func, cancel_scope=scope)

        if protected:
            func = partial(self._run_protected, func)

        if self._nursery:
            self._nursery.start_soon(func, *args)
        else:
            self._pending_tasks.append(partial(func, *args))

        return scope

    @final
    async def run(self) -> int:
        """Runs the application."""

        if not self._prepared:
            exit_code = self.prepare()
            if exit_code:
                return exit_code

        exit_code_holder: List[int] = [0]

        # Helper function to ignore KeyboardInterrupt exceptions even if
        # they are wrapped in an exception group
        def ignore_keyboard_interrupt(exc) -> None:
            pass

        # Helper function to handle application exit requests
        def handle_application_exit_request(exc_group) -> None:
            for exc in exc_group.exceptions:
                # We process the first ApplicationExit request only
                self.log.error(str(exc) or "Received request to stop application.")
                exit_code_holder[0] = exc.exit_code
                break

        try:
            with catch(
                {
                    KeyboardInterrupt: ignore_keyboard_interrupt,
                    ApplicationExit: handle_application_exit_request,
                }
            ):
                async with open_nursery() as nursery:
                    self._nursery = nursery

                    await self._on_nursery_created(nursery)

                    tasks = self._pending_tasks[:]
                    del self._pending_tasks[:]

                    for task in tasks:
                        nursery.start_soon(task)

                    await self.ready()

            return exit_code_holder[0]

        finally:
            self._nursery = None
            self._shutting_down = True
            await self.teardown()

    async def ready(self) -> None:
        """Called when the application is ready and all the background tasks
        have been scheduled to start.

        Make sure to call the superclass implementation if you override this
        method.
        """
        pass

    async def teardown(self) -> None:
        """Called when the application is about to shut down.

        Make sure to call the superclass implementation if you override this
        method.
        """
        pass

    async def _on_nursery_created(self, nursery: Nursery) -> None:
        """Function that is called by the `run()` method when the task nursery
        was created and that allows us to launch certain important tasks in the
        nursery before we launch the rest.
        """
        pass

    async def _run_protected(self, func, *args) -> None:
        """Runs the given function in a "protected" mode that prevents exceptions
        emitted from it to crash the nursery that the function is being executed
        in.
        """
        try:
            return await func(*args)
        except Exception:
            self.log.exception(
                f"Unexpected exception caught from background task {func.__name__}"
            )


class SyncApp(BaseApp):
    """Base class for apps with a main synchronous function.

    Basically almost all of our apps except the most simple ones are based
    either on this base class or on AsyncApp_.
    """

    @final
    def run(self) -> int:
        """Runs the application.

        Do not override this method; override the `prepare(), `ready()`,
        `run_main()` and `teardown()` hooks instead.
        """

        if not self._prepared:
            exit_code = self.prepare()
            if exit_code:
                return exit_code

        try:
            self.ready()
            self.run_main()
            return 0

        except ApplicationExit as ex:
            self.log.error(str(ex) or "Received request to stop application.")
            return ex.exit_code

        except KeyboardInterrupt:
            print()
            return 0

        finally:
            self._shutting_down = True
            self.teardown()

    def run_main(self) -> None:
        """The main task of the application. You should override this in
        subclasses.

        The default implementation of this function does nothing; you can safely
        override it in derived classes without calling the superclass
        implementation.
        """
        pass

    def ready(self) -> None:
        """Called when the application is ready and is about to start the main
        task.

        Make sure to call the superclass implementation if you override this
        method.
        """
        pass

    def teardown(self) -> None:
        """Called when the application is about to shut down.

        Make sure to call the superclass implementation if you override this
        method.
        """
        pass