diff --git a/tmt/base/plan.py b/tmt/base/plan.py index fbce351c31..f32db350c2 100644 --- a/tmt/base/plan.py +++ b/tmt/base/plan.py @@ -1028,7 +1028,7 @@ def lint_execute_unknown_method(self) -> LinterReturn: yield from self._lint_step_methods( 'execute', - tmt.steps.execute.ExecutePlugin, # type: ignore[type-abstract] + tmt.steps.execute.ExecutePlugin, ) def lint_discover_unknown_method(self) -> LinterReturn: @@ -1038,7 +1038,7 @@ def lint_discover_unknown_method(self) -> LinterReturn: yield from self._lint_step_methods( 'discover', - tmt.steps.discover.DiscoverPlugin, # type: ignore[type-abstract] + tmt.steps.discover.DiscoverPlugin, ) def lint_fmf_remote_ids_valid(self) -> LinterReturn: diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index 2f544d86ed..28fb1118ab 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -17,6 +17,7 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Generic, Literal, Optional, @@ -53,7 +54,7 @@ option_to_key, simple_field, ) -from tmt.options import option +from tmt.options import ClickOptionDecoratorType, option from tmt.result import ResultOutcome from tmt.utils import ( DEFAULT_NAME, @@ -1366,7 +1367,7 @@ def prune(self, logger: tmt.log.Logger) -> None: # Do not prune plugin workdirs, each plugin decides what should # be pruned from the workdir and what should be kept there - plugins = self.phases(classes=BasePlugin) # type: ignore[type-abstract] + plugins = self.phases(classes=BasePlugin) for plugin in plugins: if plugin.workdir is not None: preserved_members = {*preserved_members, plugin.workdir.name} @@ -1537,6 +1538,11 @@ class BasePlugin( # subclasses. _supported_methods: 'tmt.plugins.PluginRegistry[Method]' + #: A sequence of :py:func:`click.option`-like decorators that should + #: be added to the step base command created by :py:meth:`base_command`. + #: Decorators are applied in the same order they are in this sequence. + _base_command_options: tuple[ClickOptionDecoratorType, ...] = (PHASE_OPTIONS,) + _data_class: type[StepDataT] @classmethod @@ -1552,6 +1558,21 @@ def get_data_class(cls) -> type[StepDataT]: data: StepDataT + #: Point back to the :py:class:`Step` subclass implementing the step + #: which owns this family of plugins. + #: + #: .. note:: + #: + #: This "backlink" is initialized at the end of Python modules + #: holding the respective step implementations. Each ``Step`` + #: subclass points at the base class of its plugin family, via + #: simple class-level attribute, and it would be impossible to + #: establish the same kind of link in the opposite direction. + #: When the plugin base class is defined, the step class does not + #: even exist yet. Therefore this link is set after both classes, + #: step and its plugin base, are finalized. + _step_class: ClassVar[type[Step]] + @classmethod def get_step_name(cls) -> str: match = _PLUGIN_CLASS_NAME_TO_STEP_PATTERN.match(cls.__module__) @@ -1633,17 +1654,61 @@ def safe_name(self) -> str: return self.pathless_safe_name @classmethod - @abc.abstractmethod def base_command( cls, usage: str, method_class: Optional[type[click.Command]] = None, ) -> click.Command: """ - Create base click command (common for all step plugins) - """ - - raise NotImplementedError + Create base :py:mod:`click` command for plugins of the step. + """ + + step_name = cls._step_class.__name__.lower() + + # Prepare general usage message for the step + if method_class: + usage = cls._step_class.usage(method_overview=usage) + + # Instead of the well-known way `@option(...)` decorators are + # used, we get rid of the syntax sugar they add, and apply them + # in a way they are actually applied. We want to include the + # extra decorators, and we can't simply `@cls._base_command_extra_options`. + # Note that the order is opposite to what one would expect when + # looking at decorators, which is correct, they are indeed applied + # from bottom to top. + + # First, the actual command code. + def base_command(context: 'tmt.cli.Context', **kwargs: Any) -> None: + context.obj.steps.add(step_name) + cls._step_class.store_cli_invocation(context) + + # Then apply the custom options by invoking them as if they were + # decorators. + for options_decorator in cls._base_command_options: + base_command = options_decorator(base_command) + + # And then the rest, `@option(...)` for `--how`, context, and + # finally `@click.command(...)`. + base_command = option( + '-h', + '--how', + # Cannot use `choices=...` because we want to allow values + # that are not on the list *as long as they are clearly + # matching values on the list*. For example, `virtual` is + # absolutely acceptable as long as some `virtual.*` plugin + # is available. + metavar='|'.join( + sorted([method.name for method in cls._supported_methods.iter_plugins()]) + ), + help=f'Use specified method for {step_name} phase.', + )(base_command) + + # ignore[arg-type]: `pass_context` annotations add `Context` + # parameter, but we already have that one, because we use it + # in the command code. This is probably much less visible when + # `pass_context` is used as a decorator. + base_command = click.pass_context(base_command) # type: ignore[arg-type] + return click.command(cls=method_class, help=usage, name=step_name)(base_command) @classmethod def options(cls, how: Optional[str] = None) -> list[tmt.options.ClickOptionDecoratorType]: diff --git a/tmt/steps/cleanup/__init__.py b/tmt/steps/cleanup/__init__.py index 566f3231a7..342075b5a2 100644 --- a/tmt/steps/cleanup/__init__.py +++ b/tmt/steps/cleanup/__init__.py @@ -1,7 +1,6 @@ import copy -from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast +from typing import Optional, TypeVar, cast -import click import fmf.utils import tmt.log @@ -9,7 +8,6 @@ import tmt.utils from tmt.container import container from tmt.guest import Guest -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.result import PhaseResult, ResultGuestData, ResultOutcome from tmt.steps import ( @@ -19,9 +17,6 @@ PluginTask, ) -if TYPE_CHECKING: - import tmt.cli - @container class CleanupStepData(tmt.steps.StepData): @@ -46,31 +41,6 @@ class CleanupPlugin(tmt.steps.Plugin[CleanupStepDataT, PluginOutcome]): # Internal cleanup plugin is the default implementation how = 'tmt' - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all cleanup plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Cleanup.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option('-h', '--how', metavar='METHOD', help='Use specified method for cleanup tasks.') - @tmt.steps.PHASE_OPTIONS - def cleanup(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('cleanup') - Cleanup.store_cli_invocation(context) - - return cleanup - def go( self, *, @@ -263,3 +233,7 @@ def _record_exception( # Update status and save self.status('done') self.save() + + +# Establish the "plugin class -> step class" link. +CleanupPlugin._step_class = Cleanup diff --git a/tmt/steps/discover/__init__.py b/tmt/steps/discover/__init__.py index d698da12c9..b446e3adb7 100644 --- a/tmt/steps/discover/__init__.py +++ b/tmt/steps/discover/__init__.py @@ -24,7 +24,6 @@ import tmt.utils.filesystem import tmt.utils.git import tmt.utils.url -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.steps import Action from tmt.utils import Command, Environment, EnvVarValue, GeneralError, Path @@ -192,31 +191,6 @@ def test_dir(self) -> Path: def source_dir(self) -> Path: return self.phase_workdir / 'source' - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all discover plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Discover.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option('-h', '--how', metavar='METHOD', help='Use specified method to discover tests.') - @tmt.steps.PHASE_OPTIONS - def discover(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('discover') - Discover.store_cli_invocation(context) - - return discover - def go(self, *, path: Optional[Path] = None, logger: Optional[tmt.log.Logger] = None) -> None: """ Perform actions shared among plugins when beginning their tasks @@ -1020,3 +994,7 @@ def _iter_tests() -> Iterator['TestOrigin']: return [ test_origin for test_origin in _iter_tests() if test_origin.test.enabled is enabled ] + + +# Establish the "plugin class -> step class" link. +DiscoverPlugin._step_class = Discover diff --git a/tmt/steps/execute/__init__.py b/tmt/steps/execute/__init__.py index ffd3f66156..d21d072652 100644 --- a/tmt/steps/execute/__init__.py +++ b/tmt/steps/execute/__init__.py @@ -8,7 +8,6 @@ from collections.abc import Iterator, Sequence from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast -import click import fmf.utils import tmt @@ -21,7 +20,6 @@ from tmt.checks import Check, CheckEvent, CheckPlugin from tmt.container import container, field, simple_field from tmt.guest import Guest -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.result import ( CheckResult, @@ -54,7 +52,6 @@ if TYPE_CHECKING: import tmt.base.plan - import tmt.cli import tmt.result import tmt.steps.discover @@ -639,6 +636,10 @@ class ExecutePlugin(tmt.steps.Plugin[ExecuteStepDataT, None]): # Methods ("how: ..." implementations) registered for the same step. _supported_methods: PluginRegistry[tmt.steps.Method] = PluginRegistry('step.execute') + # No additional options, `execute` does not support modifications of + # phases. + _base_command_options = () + # Internal executor is the default implementation how = 'tmt' @@ -660,30 +661,6 @@ def __init__( if tmt.steps.Login._opt('test'): self._login_after_test = tmt.steps.Login(logger=logger, step=self.step, order=90) - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all execute plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Execute.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option('-h', '--how', metavar='METHOD', help='Use specified method for test execution.') - def execute(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('execute') - Execute.store_cli_invocation(context) - - return execute - def go( self, *, @@ -1366,3 +1343,7 @@ def _assert_required_tests_executed(self) -> None: raise tmt.utils.ExecuteError( f"Required test '{result.name}' on guest '{result.guest.name}' was skipped." ) + + +# Establish the "plugin class -> step class" link. +ExecutePlugin._step_class = Execute diff --git a/tmt/steps/finish/__init__.py b/tmt/steps/finish/__init__.py index 2a293e27dd..68a274e53e 100644 --- a/tmt/steps/finish/__init__.py +++ b/tmt/steps/finish/__init__.py @@ -1,14 +1,12 @@ import copy -from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast +from typing import Optional, TypeVar, cast -import click import fmf import tmt import tmt.steps from tmt.container import container from tmt.guest import Guest -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.result import PhaseResult, ResultGuestData, ResultOutcome from tmt.steps import ( @@ -21,9 +19,6 @@ sync_with_guests, ) -if TYPE_CHECKING: - import tmt.cli - @container class FinishStepData(tmt.steps.WhereableStepData, tmt.steps.StepData): @@ -45,31 +40,6 @@ class FinishPlugin(tmt.steps.Plugin[FinishStepDataT, PluginOutcome]): # Methods ("how: ..." implementations) registered for the same step. _supported_methods: PluginRegistry[Method] = PluginRegistry('step.finish') - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all finish plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Finish.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option('-h', '--how', metavar='METHOD', help='Use specified method for finishing tasks.') - @tmt.steps.PHASE_OPTIONS - def finish(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('finish') - Finish.store_cli_invocation(context) - - return finish - def go( self, *, @@ -277,3 +247,7 @@ def _is_failed() -> bool: # Update status and save self.status('done') self.save() + + +# Establish the "plugin class -> step class" link. +FinishPlugin._step_class = Finish diff --git a/tmt/steps/prepare/__init__.py b/tmt/steps/prepare/__init__.py index f6f12e35f0..23c5cbc616 100644 --- a/tmt/steps/prepare/__init__.py +++ b/tmt/steps/prepare/__init__.py @@ -1,7 +1,6 @@ import copy -from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Literal, Optional, TypeVar, cast -import click import fmf.utils import tmt @@ -10,7 +9,6 @@ import tmt.utils from tmt.container import container, simple_field from tmt.guest import Guest -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.result import PhaseResult, ResultGuestData, ResultOutcome from tmt.steps import ( @@ -26,7 +24,6 @@ if TYPE_CHECKING: import tmt.base.core - import tmt.cli from tmt.base.plan import Plan @@ -54,36 +51,6 @@ class PreparePlugin(tmt.steps.Plugin[PrepareStepDataT, PluginOutcome]): # Methods ("how: ..." implementations) registered for the same step. _supported_methods: PluginRegistry[tmt.steps.Method] = PluginRegistry('step.prepare') - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all prepare plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Prepare.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option( - '-h', - '--how', - metavar='METHOD', - help='Use specified method for environment preparation.', - ) - @tmt.steps.PHASE_OPTIONS - def prepare(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('prepare') - Prepare.store_cli_invocation(context) - - return prepare - def go( self, *, @@ -538,3 +505,7 @@ def _is_failed() -> bool: self.summary() self.status('done') self.save() + + +# Establish the "plugin class -> step class" link. +PreparePlugin._step_class = Prepare diff --git a/tmt/steps/provision/__init__.py b/tmt/steps/provision/__init__.py index 9e0d6d5dfa..b78406a8c0 100644 --- a/tmt/steps/provision/__init__.py +++ b/tmt/steps/provision/__init__.py @@ -9,7 +9,6 @@ cast, ) -import click import fmf.utils from click import echo @@ -28,14 +27,12 @@ field, ) from tmt.log import Logger -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.steps import Action, ActionTask, PhaseQueue, PushTask, sync_with_guests from tmt.utils import Path if TYPE_CHECKING: import tmt.base.core - import tmt.cli @container @@ -89,31 +86,6 @@ def _preserved_workdir_members(self) -> set[str]: return {*super()._preserved_workdir_members, "logs"} - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all provision plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Provision.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option('-h', '--how', metavar='METHOD', help='Use specified method for provisioning.') - @tmt.steps.PHASE_OPTIONS - def provision(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('provision') - Provision.store_cli_invocation(context) - - return provision - def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None: """ Perform actions shared among plugins when beginning their tasks @@ -677,3 +649,7 @@ def _run_action_phases(phases: list[Action]) -> tuple[list[ActionTask], list[Act self.summary() self.status('done') self.save() + + +# Establish the "plugin class -> step class" link. +ProvisionPlugin._step_class = Provision diff --git a/tmt/steps/report/__init__.py b/tmt/steps/report/__init__.py index 3a8b0b8d10..8eab133312 100644 --- a/tmt/steps/report/__init__.py +++ b/tmt/steps/report/__init__.py @@ -1,16 +1,10 @@ -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast - -import click +from typing import Optional, TypeVar, Union, cast import tmt.steps from tmt.container import container -from tmt.options import option from tmt.plugins import PluginRegistry from tmt.steps import Action -if TYPE_CHECKING: - import tmt.cli - @container class ReportStepData(tmt.steps.StepData): @@ -35,36 +29,6 @@ class ReportPlugin(tmt.steps.GuestlessPlugin[ReportStepDataT, None]): # Methods ("how: ..." implementations) registered for the same step. _supported_methods: PluginRegistry[tmt.steps.Method] = PluginRegistry('step.report') - @classmethod - def base_command( - cls, - usage: str, - method_class: Optional[type[click.Command]] = None, - ) -> click.Command: - """ - Create base click command (common for all report plugins) - """ - - # Prepare general usage message for the step - if method_class: - usage = Report.usage(method_overview=usage) - - # Create the command - @click.command(cls=method_class, help=usage) - @click.pass_context - @option( - '-h', - '--how', - metavar='METHOD', - help='Use specified method for results reporting.', - ) - @tmt.steps.PHASE_OPTIONS - def report(context: 'tmt.cli.Context', **kwargs: Any) -> None: - context.obj.steps.add('report') - Report.store_cli_invocation(context) - - return report - def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None: """ Perform actions shared among plugins when beginning their tasks @@ -145,3 +109,7 @@ def go(self, force: bool = False) -> None: self.summary() self.status('done') self.save() + + +# Establish the "plugin class -> step class" link. +ReportPlugin._step_class = Report