# This file is part of craft-application.
#
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Base command for craft-application commands."""
from __future__ import annotations
import abc
import warnings
from typing import TYPE_CHECKING, Any, Protocol, final
from craft_cli import BaseCommand, emit
from typing_extensions import Self
from craft_application import application, util
if TYPE_CHECKING:
import argparse
from craft_application.models.project import Project
from craft_application.services import service_factory
class ParserCallback(Protocol):
"""A protocol that expresses the type for a parser callback."""
@staticmethod
def __call__(cmd: ExtensibleCommand, parser: argparse.ArgumentParser) -> None:
"""Call the parser callback function. Works the same as fill_parser."""
class RunCallback(Protocol):
"""A protocol that expresses the type for pre-run and post-run callbacks."""
@staticmethod
def __call__(
cmd: ExtensibleCommand,
parsed_args: argparse.Namespace,
**kwargs: Any,
) -> int | None:
"""Call the prologue or epilogue. Takes the same parameters as run."""
[docs]
class AppCommand(BaseCommand):
"""Command for use with craft-application."""
always_load_project: bool = False
"""Whether to configure and load the project before starting the command.
:deprecated: override :meth:`needs_project` instead.
"""
def __init__(self, config: dict[str, Any] | None) -> None:
if config is None:
warnings.warn(
"Creating an AppCommand without a config dict is pending deprecation.",
PendingDeprecationWarning,
stacklevel=3,
)
emit.trace("Not completing command configuration")
return
super().__init__(config)
self._app: application.AppMetadata = config["app"]
self._services: service_factory.ServiceFactory = config["services"]
[docs]
def needs_project(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (unused argument is for subclasses)
) -> bool:
"""Property to determine if the command needs a project loaded.
Defaults to :attr:`always_load_project`. Subclasses can override this method.
:param parsed_args: Parsed arguments for the command.
:returns: ``True`` if the command needs a project loaded, ``False`` otherwise.
"""
return self.always_load_project
[docs]
def run_managed(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (the unused argument is for subclasses)
) -> bool:
"""Whether this command should run in managed mode.
Returns ``False`` by default. Subclasses can override this method to change this,
including by inspecting the arguments in ``parsed_args``.
"""
return False
[docs]
def provider_name(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (the unused argument is for subclasses)
) -> str | None:
"""Name of the provider where the command should be run inside of.
Returns ``None`` by default. Subclasses can override this method to change this,
including by inspecting the arguments in ``parsed_args``.
"""
return None
def get_managed_cmd(
self,
parsed_args: argparse.Namespace, # - Used by subclasses
) -> list[str]:
"""Get the command to run in managed mode.
:param parsed_args: The parsed arguments used.
:returns: A list of strings ready to be passed into a craft-providers executor.
:raises: ``RuntimeError`` if this command is not supposed to run managed.
Commands that have additional parameters to pass in managed mode should
override this method to include those parameters.
:deprecated: and unused.
"""
if not self.run_managed(parsed_args):
raise RuntimeError("Unmanaged commands should not be run in managed mode.")
cmd_name = self._app.name
verbosity = emit.get_mode().name.lower()
return [cmd_name, f"--verbosity={verbosity}", self.name]
@property
def _project(self) -> Project:
"""Get the current project.
:raises: Any exception related to rendering the project if the project has
not yet been created.
"""
return self._services.get("project").get()
[docs]
class ExtensibleCommand(AppCommand):
"""Extensible application command.
An ``ExtensibleCommand`` is a special type of :py:class:`AppCommand` that can be
extended with the use of callback functions. It has all of the same attributes and
methods of the ``AppCommand``, except that ``fill_parser`` and ``run`` are marked
as final. When implementing or inheriting from an ``ExtensibleCommand``, the
equivalent protected methods are available.
"""
_parse_callback: ParserCallback | None
_prologue: RunCallback | None
_epilogue: RunCallback | None
@property
def services(self) -> service_factory.ServiceFactory:
"""Services available to this command."""
return self._services # pragma: no cover
[docs]
@classmethod
def register_parser_filler(cls, callback: ParserCallback) -> None:
"""Register a function that modifies the argument parser.
Only one parser filler callback can be registered to a particular command class.
However, fillers registered to parent classes will still be run, from the
top class in the inheritance tree on down.
"""
cls._parse_callback = callback
[docs]
@classmethod
def register_prologue(cls, callback: RunCallback) -> None:
"""Register a function that runs before the main run.
Each command class may only have a single prologue. Prologues on the inheritance
tree are run in reverse method resolution order. (That is, a child command's
prologue is run after the parent command's.)
"""
cls._prologue = callback
[docs]
@classmethod
def register_epilogue(cls, callback: RunCallback) -> None:
"""Register a function that runs after the main run.
Each command class may only have a single epilogue. Epilogues on the inheritance
tree are run in reverse method resolution order. (That is, a child command's
epilogue is run after the parent command's.)
"""
cls._epilogue = callback
[docs]
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
"""Real parser filler for an ExtensibleCommand."""
@final
def fill_parser(self, parser: argparse.ArgumentParser) -> None:
"""Set the arguments for the parser.
First, the real filler in ``_fill_parser`` is run, filling the base parser.
After that, the parse callbacks are run in reverse method resolution order.
(That is, starting with the top ancestor.)
"""
self._fill_parser(parser)
callbacks = util.get_unique_callbacks(self.__class__, "_parse_callback")
for callback in callbacks:
callback(self, parser)
[docs]
@abc.abstractmethod
def _run(self: Self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None:
"""Run the real run method for an ExtensibleCommand."""
@final
def run(self: Self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None:
"""Run any prologue callbacks, the main command, and any epilogue callbacks."""
result = None
for prologue in util.get_unique_callbacks(self.__class__, "_prologue"):
result = prologue(self, parsed_args, **kwargs) or result
result = self._run(parsed_args, **kwargs) or result
for epilogue in util.get_unique_callbacks(self.__class__, "_epilogue"):
result = (
epilogue(self, parsed_args, current_result=result, **kwargs) or result
)
return result