Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move hints to their own place, with API and unicorns #3495

Merged
merged 2 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/_static/tmt-custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@
.logo {
padding: 10px 50px !important;
}

.rst-content .note .admonition-title {
display: block !important;
}

.rst-content .warning .admonition-title {
display: block !important;
}
2 changes: 2 additions & 0 deletions docs/scripts/generate-plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import tmt.steps.provision
import tmt.steps.report
import tmt.utils
import tmt.utils.hints
from tmt.container import ContainerClass
from tmt.utils import Path
from tmt.utils.templates import render_template_file
Expand Down Expand Up @@ -222,6 +223,7 @@ def main() -> None:
STEP=step_name,
PLUGINS=plugin_generator,
REVIEWED_PLUGINS=REVIEWED_PLUGINS,
HINTS=tmt.utils.hints.HINTS,
is_enum=is_enum,
container_fields=tmt.container.container_fields,
container_field=tmt.container.container_field,
Expand Down
6 changes: 6 additions & 0 deletions docs/templates/plugins.rst.j2
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ The following keys are accepted by all plugins of the ``{{ STEP }}`` step.
{{ PLUGIN.__doc__ | dedent | trim }}
{% endif %}

{% if plugin_full_id in HINTS %}
.. note::

{{ HINTS[plugin_full_id] | indent(3, first=true) }}
{% endif %}

{% set intrinsic_fields = container_intrinsic_fields(PLUGIN_DATA_CLASS) | sort %}

{% if intrinsic_fields %}
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/provision/testcloud/test_hw.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import_testcloud,
)

import_testcloud()
import_testcloud(Logger.get_bootstrap_logger())

# These must be imported *after* importing testcloud
from tmt.steps.provision.testcloud import TPM_CONFIG_ALLOWS_VERSIONS, \
Expand Down
3 changes: 1 addition & 2 deletions tmt/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4431,8 +4431,7 @@ def images(self) -> bool:
self.info('images', color='blue')
successful = True
for method in tmt.steps.provision.ProvisionPlugin.methods():
# FIXME: ignore[union-attr]: https://github.com/teemtee/tmt/issues/1599
if not method.class_.clean_images( # type: ignore[union-attr]
if not method.class_.clean_images( # type: ignore[attr-defined]
self, self.is_dry_run, self.workdir_root
):
successful = False
Expand Down
78 changes: 8 additions & 70 deletions tmt/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,75 +535,6 @@ def common_decorator(fn: FC) -> FC:
return common_decorator


def show_step_method_hints(
step_name: str,
how: str,
logger: tmt.log.Logger,
) -> None:
"""
Show hints about available step methods' installation

The logger will be used to output the hints to the terminal, hence
it must be an instance of a subclass of tmt.utils.Common (info method
must be available).
"""

if how == 'ansible':
logger.info(
'hint',
"Install 'ansible-core' to prepare guests using ansible playbooks.",
color='blue',
)
elif step_name == 'provision':
if how == 'virtual':
logger.info(
'hint',
"Install 'tmt+provision-virtual' to run tests in a virtual machine.",
color='blue',
)
if how == 'container':
logger.info(
'hint',
"Install 'tmt+provision-container' to run tests in a container.",
color='blue',
)
if how == 'minute':
logger.info(
'hint',
"Install 'tmt-redhat-provision-minute' "
"to run tests in 1minutetip OpenStack backend. "
"(Available only from the internal COPR repository.)",
color='blue',
)
logger.info(
'hint',
"Use the 'local' method to execute tests directly on your localhost.",
color='blue',
)
logger.info(
'hint',
"See 'tmt run provision --help' for all available provision options.",
color='blue',
)
elif step_name == 'report':
if how == 'junit':
logger.info(
'hint',
"Install 'tmt+report-junit' to write results in JUnit format.",
color='blue',
)
logger.info(
'hint',
"Use the 'display' method to show test results on the terminal.",
color='blue',
)
logger.info(
'hint',
"See 'tmt run report --help' for all available report options.",
color='blue',
)


def create_method_class(methods: MethodDictType) -> type[click.Command]:
"""
Create special class to handle different options for each method
Expand Down Expand Up @@ -732,10 +663,17 @@ def _find_how(args: list[str]) -> Optional[str]:
break

if how and self._method is None:
from tmt.utils.hints import print_hint

# Use run for logging, steps may not be initialized yet
assert context.obj.run is not None # narrow type
assert self.name is not None # narrow type
show_step_method_hints(self.name, how, context.obj.run._logger)

print_hint(
id_=f'{self.name}/{how}', ignore_missing=True, logger=context.obj.run._logger
)
print_hint(id_=self.name, ignore_missing=True, logger=context.obj.run._logger)

raise tmt.utils.SpecificationError(f"Unsupported {self.name} method '{how}'.")

def parse_args( # type: ignore[override]
Expand Down
24 changes: 14 additions & 10 deletions tmt/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ def _import_or_raise(
*,
module: str,
exc_class: type[BaseException],
exc_message: str,
exc_message: Optional[str] = None,
hint_id: Optional[str] = None,
logger: Logger,
) -> ModuleT: # type: ignore[type-var,misc]
"""
Expand All @@ -269,7 +270,15 @@ def _import_or_raise(
return _import(module=module, logger=logger)

except tmt.utils.GeneralError as exc:
raise exc_class(exc_message) from exc
if hint_id is not None:
from tmt.utils.hints import print_hint

print_hint(id_=hint_id, logger=logger)

if exc_message is not None:
raise exc_class(exc_message) from exc

raise exc_class(f"Failed to import the '{module}' module.") from exc


# ignore[type-var,misc]: the actual type is provided by caller - the
Expand Down Expand Up @@ -410,15 +419,10 @@ class ModuleImporter(Generic[ModuleT]):
taken from :py:attr:`sys.modules`.
"""

def __init__(
self,
module: str,
exc_class: type[Exception],
exc_message: str,
) -> None:
def __init__(self, module: str, exc_class: type[Exception], hint_id: str) -> None:
self._module_name = module
self._exc_class = exc_class
self._exc_message = exc_message
self._hint_id = hint_id

self._module: Optional[ModuleT] = None

Expand All @@ -427,7 +431,7 @@ def __call__(self, logger: Logger) -> ModuleT:
self._module = _import_or_raise(
module=self._module_name,
exc_class=self._exc_class,
exc_message=self._exc_message,
hint_id=self._hint_id,
logger=logger,
)

Expand Down
42 changes: 37 additions & 5 deletions tmt/steps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
key_to_option,
option_to_key,
)
from tmt.options import option, show_step_method_hints
from tmt.options import option
from tmt.utils import (
DEFAULT_NAME,
Environment,
Expand All @@ -71,6 +71,8 @@

DEFAULT_ALLOWED_HOW_PATTERN: Pattern[str] = re.compile(r'.*')

_PLUGIN_CLASS_NAME_TO_STEP_PATTERN = re.compile(r'tmt.steps.([a-z]+)')

#
# Following are default and predefined order values for various special phases
# recognized by tmt. When adding new special phase, add its order below, and
Expand Down Expand Up @@ -1265,9 +1267,10 @@ class Method:
def __init__(
self,
name: str,
class_: Optional[PluginClass] = None,
class_: PluginClass,
doc: Optional[str] = None,
order: int = PHASE_ORDER_DEFAULT,
installation_hint: Optional[str] = None,
) -> None:
"""
Store method data
Expand All @@ -1281,6 +1284,13 @@ def __init__(

raise tmt.utils.GeneralError(f"Plugin method '{name}' provides no docstring.")

if installation_hint is not None:
doc = (
doc
+ '\n\n.. note::\n\n'
+ textwrap.indent(textwrap.dedent(installation_hint), ' ')
)

self.name = name
self.class_ = class_
self.doc = (
Expand Down Expand Up @@ -1323,6 +1333,7 @@ def provides_method(
name: str,
doc: Optional[str] = None,
order: int = PHASE_ORDER_DEFAULT,
installation_hint: Optional[str] = None,
) -> Callable[[PluginClass], PluginClass]:
"""
A plugin class decorator to register plugin's method with tmt steps.
Expand All @@ -1343,17 +1354,26 @@ class SomePlugin(tmt.steps.discover.DicoverPlugin):
"""

def _method(cls: PluginClass) -> PluginClass:
plugin_method = Method(name, class_=cls, doc=doc, order=order)
plugin_method = Method(
name, cls, doc=doc, order=order, installation_hint=installation_hint
)

# FIXME: make sure cls.__bases__[0] is really BasePlugin class
# TODO: BasePlugin[Any]: it's tempting to use StepDataT, but I was
# unable to introduce the type var into annotations. Apparently, `cls`
# is a more complete type, e.g. `type[ReportJUnit]`, which does not show
# space for type var. But it's still something to fix later.
cast('BasePlugin[Any, Any]', cls.__bases__[0])._supported_methods.register_plugin(
base_class = cast('BasePlugin[Any, Any]', cls.__bases__[0])

base_class._supported_methods.register_plugin(
plugin_id=name, plugin=plugin_method, logger=tmt.log.Logger.get_bootstrap_logger()
)

if installation_hint is not None:
from tmt.utils.hints import register_hint

register_hint(f'{base_class.get_step_name()}/{name}', installation_hint)

return cls

return _method
Expand Down Expand Up @@ -1396,6 +1416,14 @@ def get_data_class(cls) -> type[StepDataT]:

data: StepDataT

@classmethod
def get_step_name(cls) -> str:
match = _PLUGIN_CLASS_NAME_TO_STEP_PATTERN.match(cls.__module__)

assert match is not None # must be

return match.group(1)

# TODO: do we need this list? Can whatever code is using it use _data_class directly?
# List of supported keys
# (used for import/export to/from attributes during load and save)
Expand Down Expand Up @@ -1631,7 +1659,11 @@ def delegate(
assert isinstance(plugin, BasePlugin)
return plugin

show_step_method_hints(step.name, how, step._logger)
from tmt.utils.hints import print_hint

print_hint(id_=f'{step.name}/{how}', ignore_missing=True, logger=step._logger)
print_hint(id_=step.name, ignore_missing=True, logger=step._logger)

# Report invalid method
if step.plan is None:
raise tmt.utils.GeneralError(f"Plan for {step.name} is not set.")
Expand Down
6 changes: 4 additions & 2 deletions tmt/steps/provision/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
import tmt.utils
from tmt.container import SerializableContainer, container, field, key_to_option
from tmt.log import Logger
from tmt.options import option, show_step_method_hints
from tmt.options import option
from tmt.package_managers import FileSystemPath, Package, PackageManagerClass
from tmt.plugins import PluginRegistry
from tmt.steps import Action, ActionTask, PhaseQueue
Expand Down Expand Up @@ -2111,7 +2111,9 @@ def _run_ansible(
)
except tmt.utils.RunError as exc:
if "File 'ansible-playbook' not found." in exc.message:
show_step_method_hints('plugin', 'ansible', self._logger)
from tmt.utils.hints import print_hint

print_hint(id_='ansible-not-available', logger=self._logger)
raise exc

@property
Expand Down
5 changes: 3 additions & 2 deletions tmt/steps/provision/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import tmt.steps.provision
import tmt.utils
from tmt.container import container
from tmt.options import show_step_method_hints
from tmt.utils import Command, OnProcessStartCallback, Path, ShellScript


Expand Down Expand Up @@ -82,7 +81,9 @@ def _run_ansible(
# fmt: on
except tmt.utils.RunError as exc:
if exc.stderr and 'ansible-playbook: command not found' in exc.stderr:
show_step_method_hints('plugin', 'ansible', self._logger)
from tmt.utils.hints import print_hint

print_hint(id_='ansible-not-available', logger=self._logger)
raise exc

def execute(
Expand Down
22 changes: 19 additions & 3 deletions tmt/steps/provision/podman.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import tmt.steps.provision
import tmt.utils
from tmt.container import container, field
from tmt.options import show_step_method_hints
from tmt.steps.provision import GuestCapability
from tmt.utils import Command, OnProcessStartCallback, Path, ShellScript, retry

Expand Down Expand Up @@ -361,7 +360,9 @@ def _run_ansible(
)
except tmt.utils.RunError as exc:
if "File 'ansible-playbook' not found." in exc.message:
show_step_method_hints('plugin', 'ansible', self._logger)
from tmt.utils.hints import print_hint

print_hint(id_='ansible-not-available', logger=self._logger)
raise exc

def podman(
Expand Down Expand Up @@ -544,7 +545,22 @@ def remove(self) -> None:
raise err


@tmt.steps.provides_method('container')
@tmt.steps.provides_method(
'container',
installation_hint="""
Make sure ``podman`` is installed and configured, it is required for container-backed
guests provided by ``provision/container`` plugin.

To quickly test ``podman`` functionality, you can try running ``podman images`` or
``podman run --rm -it fedora:latest``.

* Users who installed tmt from system repositories should install
``tmt+provision-container`` package.
* Users who installed tmt from PyPI should also install ``tmt+provision-container``
package, as it will install required system dependencies. After doing so, they should
install ``tmt[provision-container]`` extra.
""",
)
class ProvisionPodman(tmt.steps.provision.ProvisionPlugin[ProvisionPodmanData]):
"""
Create a new container using ``podman``.
Expand Down
Loading