From d54ed6dde3eda17f027a3f1f5f9508bdb0352b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Prchl=C3=ADk?= Date: Wed, 29 Jan 2025 10:48:44 +0100 Subject: [PATCH] Move hints to their own place, with API and unicorns I wanted to change a couple of things about "hints" tmt shows to users when when a package is missing and functionality is limited: * Static, stored in a single function. That made them detached from their origin, and 3rd party plugins would have no chance to add their own hints. * A bit confined text of hints did not cover all possible venues. PyPI installation was ignored by some, other hints spoke just about using `pip install`. * Hints are interesting and useful, but visible only when error strikes. The patch turns the function into a "registry", a simple dictionary storing them. Plugins and tmt code in general can "register" their hints, and a nice tools are available for showing them. Hints wow have IDs, and there are dedicated IDs for step-specific (``report``, ...) and plugin-specific (``report/foo``) hints, allowing tmt core to print them when step or plugins crashes on import. Plugin-specific hints are now rendered both in their CLI and HTML documentation. In the future, I would like to provide hints not just as "a package foo is missing, install it" guide, but also explaining various errors and issues tmt would report, e.g. pairing them with exceptions, "To learn more, run `tmt about E1234`". --- docs/_static/tmt-custom.css | 8 + docs/scripts/generate-plugins.py | 2 + docs/templates/plugins.rst.j2 | 6 + tests/unit/provision/testcloud/test_hw.py | 2 +- tmt/base.py | 3 +- tmt/options.py | 63 ++----- tmt/plugins/__init__.py | 19 ++- tmt/steps/__init__.py | 53 ++++-- tmt/steps/provision/__init__.py | 6 +- tmt/steps/provision/local.py | 5 +- tmt/steps/provision/podman.py | 21 ++- tmt/steps/provision/testcloud.py | 24 ++- tmt/steps/report/junit.py | 38 ++--- tmt/utils/hints.py | 192 ++++++++++++++++++++++ tmt/utils/jira.py | 15 +- 15 files changed, 353 insertions(+), 104 deletions(-) create mode 100644 tmt/utils/hints.py diff --git a/docs/_static/tmt-custom.css b/docs/_static/tmt-custom.css index 7055a8cad0..f9f758f9d2 100644 --- a/docs/_static/tmt-custom.css +++ b/docs/_static/tmt-custom.css @@ -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; +} diff --git a/docs/scripts/generate-plugins.py b/docs/scripts/generate-plugins.py index 553629acfa..d68e23090b 100755 --- a/docs/scripts/generate-plugins.py +++ b/docs/scripts/generate-plugins.py @@ -17,6 +17,7 @@ import tmt.steps.provision import tmt.steps.report import tmt.utils +import tmt.utils.hints from tmt.utils import ContainerClass, Path from tmt.utils.templates import render_template_file @@ -195,6 +196,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.utils.container_fields, container_field=tmt.utils.container_field, diff --git a/docs/templates/plugins.rst.j2 b/docs/templates/plugins.rst.j2 index 88c6e28c5f..de8b1bdf2d 100644 --- a/docs/templates/plugins.rst.j2 +++ b/docs/templates/plugins.rst.j2 @@ -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 %} diff --git a/tests/unit/provision/testcloud/test_hw.py b/tests/unit/provision/testcloud/test_hw.py index 1f2234b8f7..06f4b3979e 100644 --- a/tests/unit/provision/testcloud/test_hw.py +++ b/tests/unit/provision/testcloud/test_hw.py @@ -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, \ diff --git a/tmt/base.py b/tmt/base.py index c6cd9df468..7e695dbff2 100644 --- a/tmt/base.py +++ b/tmt/base.py @@ -3993,8 +3993,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(self, self.is_dry_run): # type: ignore[union-attr] + if not method.class_.clean_images(self, self.is_dry_run): # type: ignore[attr-defined] successful = False return successful diff --git a/tmt/options.py b/tmt/options.py index 6eafe0971a..2fdea5d62f 100644 --- a/tmt/options.py +++ b/tmt/options.py @@ -427,56 +427,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 @@ -599,10 +549,21 @@ 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}'.") diff --git a/tmt/plugins/__init__.py b/tmt/plugins/__init__.py index 4e6c314e31..de306e963f 100644 --- a/tmt/plugins/__init__.py +++ b/tmt/plugins/__init__.py @@ -231,7 +231,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] """ Import a module, or raise an exception. @@ -247,7 +248,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 @@ -385,10 +394,10 @@ def __init__( self, module: str, exc_class: type[Exception], - exc_message: str) -> None: + 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 @@ -397,7 +406,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) assert self._module # narrow type diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index 82b3050e9a..0d5a290cea 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -37,7 +37,7 @@ import tmt.queue import tmt.utils import tmt.utils.rest -from tmt.options import option, show_step_method_hints +from tmt.options import option from tmt.utils import ( DEFAULT_NAME, Environment, @@ -69,6 +69,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 @@ -1190,9 +1192,10 @@ class Method: def __init__( self, name: str, - class_: Optional[PluginClass] = None, + class_: PluginClass, doc: Optional[str] = None, - order: int = PHASE_ORDER_DEFAULT + order: int = PHASE_ORDER_DEFAULT, + installation_hint: Optional[str] = None ) -> None: """ Store method data """ @@ -1204,6 +1207,10 @@ 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 = tmt.utils.rest.render_rst(doc, tmt.log.Logger.get_bootstrap_logger()) \ @@ -1236,7 +1243,8 @@ def usage(self) -> str: def provides_method( name: str, doc: Optional[str] = None, - order: int = PHASE_ORDER_DEFAULT) -> Callable[[PluginClass], PluginClass]: + 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. @@ -1256,18 +1264,29 @@ 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( - plugin_id=name, - plugin=plugin_method, - logger=tmt.log.Logger.get_bootstrap_logger()) + 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 @@ -1309,6 +1328,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) @@ -1523,7 +1550,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.") diff --git a/tmt/steps/provision/__init__.py b/tmt/steps/provision/__init__.py index bc73e8d088..ea47bc235a 100644 --- a/tmt/steps/provision/__init__.py +++ b/tmt/steps/provision/__init__.py @@ -43,7 +43,7 @@ import tmt.steps.provision import tmt.utils 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 @@ -1942,7 +1942,9 @@ def _run_ansible( log=log) 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 diff --git a/tmt/steps/provision/local.py b/tmt/steps/provision/local.py index 9ec25d116a..123e057916 100644 --- a/tmt/steps/provision/local.py +++ b/tmt/steps/provision/local.py @@ -7,7 +7,6 @@ import tmt.steps import tmt.steps.provision import tmt.utils -from tmt.options import show_step_method_hints from tmt.utils import Command, OnProcessStartCallback, Path, ShellScript @@ -72,7 +71,9 @@ def _run_ansible( silent=silent) 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(self, diff --git a/tmt/steps/provision/podman.py b/tmt/steps/provision/podman.py index ac1b2661ba..92514d4ed4 100644 --- a/tmt/steps/provision/podman.py +++ b/tmt/steps/provision/podman.py @@ -9,7 +9,6 @@ import tmt.steps import tmt.steps.provision import tmt.utils -from tmt.options import show_step_method_hints from tmt.steps.provision import GuestCapability from tmt.utils import Command, OnProcessStartCallback, Path, ShellScript, field, retry @@ -286,7 +285,9 @@ def _run_ansible( silent=silent) 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( @@ -438,7 +439,21 @@ 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``. diff --git a/tmt/steps/provision/testcloud.py b/tmt/steps/provision/testcloud.py index 4120b6e6aa..672939232a 100644 --- a/tmt/steps/provision/testcloud.py +++ b/tmt/steps/provision/testcloud.py @@ -56,7 +56,7 @@ TPMConfiguration: Any -def import_testcloud() -> None: +def import_testcloud(logger: tmt.log.Logger) -> None: """ Import testcloud module only when needed """ global testcloud global libvirt @@ -90,8 +90,11 @@ def import_testcloud() -> None: ) from testcloud.workarounds import Workarounds except ImportError as error: - raise ProvisionError( - "Install 'tmt+provision-virtual' to provision using this method.") from error + from tmt.utils.hints import print_hint + + print_hint(id_='provision/virtual.testcloud', logger=logger) + + raise ProvisionError('Could not import testcloud package.') from error # Version-aware TPM configuration is added in # https://pagure.io/testcloud/c/89f1c024ca829543de7f74f89329158c6dee3d83 @@ -774,7 +777,7 @@ def prepare_ssh_key(self, key_type: Optional[str] = None) -> str: def prepare_config(self) -> None: """ Prepare common configuration """ - import_testcloud() + import_testcloud(self._logger) # Get configuration assert testcloud is not None @@ -1089,7 +1092,18 @@ def reboot(self, return self.reconnect(timeout=timeout) -@tmt.steps.provides_method('virtual.testcloud') +@tmt.steps.provides_method( + 'virtual.testcloud', + installation_hint=""" + Make sure ``testcloud`` and ``libvirt`` packages are installed and configured, they are + required for VM-backed guests provided by ``provision/virtual.testcloud`` plugin. + + * Users who installed tmt from system repositories should install ``tmt+provision-virtual`` + package. + * Users who installed tmt from PyPI should also install ``tmt+provision-virtual`` package, + as it will install required system dependencies. After doing so, they should install + ``tmt[provision-virtual]`` extra. + """) class ProvisionTestcloud(tmt.steps.provision.ProvisionPlugin[ProvisionTestcloudData]): """ Local virtual machine using ``testcloud`` library. diff --git a/tmt/steps/report/junit.py b/tmt/steps/report/junit.py index ca15a7f863..4bad60d8f1 100644 --- a/tmt/steps/report/junit.py +++ b/tmt/steps/report/junit.py @@ -15,13 +15,11 @@ import tmt.steps import tmt.steps.report import tmt.utils -from tmt.plugins import ModuleImporter from tmt.result import ResultOutcome from tmt.utils import Path, field from tmt.utils.templates import default_template_environment, render_template_file if TYPE_CHECKING: - import lxml from tmt._compat.typing import TypeAlias @@ -35,21 +33,6 @@ # Relative path to tmt junit template directory. DEFAULT_TEMPLATE_DIR = Path('steps/report/junit/templates/') -# ignore[unused-ignore]: Pyright would report that "module cannot be -# used as a type", and it would be correct. On the other hand, it works, -# and both mypy and pyright are able to propagate the essence of a given -# module through `ModuleImporter` that, eventually, the module object -# returned by the importer does have all expected members. -# -# The error message does not have its own code, but simple `type: ignore` -# is enough to suppress it. And then mypy complains about an unused -# ignore, hence `unused-ignore` code, leading to apparently confusing -# directive. -import_lxml: ModuleImporter['lxml'] = ModuleImporter( # type: ignore[valid-type] - 'lxml', - tmt.utils.ReportError, - "Missing 'lxml', fixable by 'pip install tmt[report-junit]'.") - @overload def _duration_to_seconds_filter(duration: str) -> int: pass @@ -303,10 +286,12 @@ def _read_log_filter(log: Path) -> str: # output. try: from lxml import etree + except ImportError: - phase.warn( - "Install 'tmt[report-junit]' to support neater JUnit XML output and the XML schema " - "validation against the XSD.") + from tmt.utils.hints import print_hint + + print_hint(id_='report/junit', logger=phase._logger) + return xml_data xml_parser_kwargs: dict[str, Any] = { @@ -417,7 +402,18 @@ class ReportJUnitData(tmt.steps.report.ReportStepData): help='Include full standard output in resulting xml file.') -@tmt.steps.provides_method('junit') +@tmt.steps.provides_method( + 'junit', + installation_hint=""" + For neater JUnit XML and XML validation against the XSD, ``lxml`` package is required + by the ``report/junit`` plugin. + + To quickly test ``lxml`` presence, you can try running ``python -c 'import lxml'``. + + * Users who installed tmt from system repositories should install ``tmt+report-junit`` + package. + * Users who installed tmt from PyPI should install ``tmt[report-junit]`` extra. + """) class ReportJUnit(tmt.steps.report.ReportPlugin[ReportJUnitData]): """ Save test results in chosen JUnit flavor format. diff --git a/tmt/utils/hints.py b/tmt/utils/hints.py new file mode 100644 index 0000000000..99d8c72103 --- /dev/null +++ b/tmt/utils/hints.py @@ -0,0 +1,192 @@ +""" +Hints for users when facing installation-related issues. + +Plugins, steps, and tmt code in general can register hints to be shown +to user when an important (or optional, but interesting) package is not +available. + +Hints are shown when importing plugins fails, and rendered as part of +both their CLI help and HTML documentation. +""" + +# NOTE (happz): in my plan, this module would be an unfinished, staging +# area for hints; eventually, I would like them to be managed under the +# umbrella of `tmt about` subcommand. `print_hint()` would still exist, +# but `tmt about` would be responsible for handling hints, therefore the +# code below may change, the concept should not. And hints would cover +# wider area, e.g. describing common errors and issues, not just when +# a package is missing. They would be coupled with exceptions tmt +# raises, providing more info on command-line and in HTML docs. + +import textwrap +from collections.abc import Iterator +from typing import Optional + +import tmt.log +import tmt.utils +import tmt.utils.rest + +HINTS: dict[str, str] = { + # Hints must be dedented & stripped of leading/trailing white space. + # For hints registered by plugins, this is done by `register_hint()`. + _hint_id: textwrap.dedent(_hint).strip() + for _hint_id, _hint in { + 'provision': + """ + You can use the ``local`` method to execute tests directly on your localhost. + + See ``tmt run provision --help`` for all available ``provision`` options. + """, + + "report": + """ + You can use the ``display`` method to show test results on the terminal. + + See ``tmt run report --help`` for all available report options. + """, + + 'ansible-not-available': + """ + Make sure ``ansible-playbook`` is installed, it is required for preparing guests using + Ansible playbooks. + + To quickly test ``ansible-playbook`` presence, you can try running + ``ansible-playbook --help``. + + * Users who installed tmt from system repositories should install ``ansible-core`` + package. + * Users who installed tmt from PyPI should install ``tmt[ansible]`` extra. + """, + + # TODO: once `minute` plugin provides its own hints, we can drop + # this hint and move it to the plugin. + 'provision/minute': + """ + Make sure ``tmt-redhat-provision-minute`` package is installed, it is required for + guests backed by 1minutetip OpenStack as provided by ``provision/minute`` plugin. The + package is available from the internal COPR repository only. + """ + }.items() + } + + +def register_hint(id_: str, hint: str) -> None: + """ + Register a hint for users. + + :param id_: step name for step-specific hints, + ``/< plugin name>`` for plugin-specific hints, + or an arbitrary string. + :param hint: a hint to register. + """ + + if id_ in HINTS: + raise tmt.utils.GeneralError( + "Registering hint '{id_}' collides with an already registered hint.") + + HINTS[id_] = textwrap.dedent(hint).strip() + + +def render_hint( + *, + id_: str, + ignore_missing: bool = False, + logger: tmt.log.Logger) -> Optional[str]: + """ + Render a given hint to be printable. + + :param id_: id of the hint to render. + :param ignore_missing: if not set, non-existent hints will + raise an exception. + :param logger: to use for logging. + :returns: a printable representation of the hint. If the hint ID + does not exist and ``ignore_missing`` is set, ``None`` is + returned. + """ + + def _render_single_hint(hint: str) -> str: + if tmt.utils.rest.REST_RENDERING_ALLOWED: + return tmt.utils.rest.render_rst(hint, logger) + + return hint + + if ignore_missing: + hint = HINTS.get(id_) + + if hint is None: + return None + + return _render_single_hint(hint) + + hint = HINTS.get(id_) + + if hint is None: + raise tmt.utils.GeneralError("Could not find hint '{id_}'.") + + return _render_single_hint(hint) + + +def render_hints( + *ids: str, + ignore_missing: bool = False, + logger: tmt.log.Logger) -> str: + """ + Render multiple hints into a single screen of text. + + :param ids: ids of hints to render. + :param ignore_missing: if not set, non-existent hints will + raise an exception. Otherwise, non-existent hints will + be skipped. + :param logger: to use for logging. + :returns: a printable representation of hints. + """ + + def _render_single_hint(hint: str) -> str: + if tmt.utils.rest.REST_RENDERING_ALLOWED: + return tmt.utils.rest.render_rst(hint, logger) + + return hint + + if ignore_missing: + def _render_optional_hints() -> Iterator[str]: + for id_ in ids: + hint = HINTS.get(id_) + + if hint is None: + continue + + yield _render_single_hint(hint) + + return '\n'.join(_render_optional_hints()) + + def _render_mandatory_hints() -> Iterator[str]: + for id_ in ids: + hint = HINTS.get(id_) + + if hint is None: + raise tmt.utils.GeneralError("Could not find hint '{id_}'.") + + yield _render_single_hint(hint) + + return '\n'.join(_render_mandatory_hints()) + + +def print_hint(*, id_: str, ignore_missing: bool = False, logger: tmt.log.Logger) -> None: + """ + Display a given hint by printing it as a warning. + + :param id_: id of the hint to render. + :param ignore_missing: if not set, non-existent hints will + raise an exception. + :param logger: to use for logging. + """ + + hint = render_hint(id_=id_, ignore_missing=ignore_missing, logger=logger) + + if hint is None: + return + + logger.info( + 'hint', + render_hint(id_=id_, ignore_missing=ignore_missing, logger=logger), + color='blue') diff --git a/tmt/utils/jira.py b/tmt/utils/jira.py index f18f0eb05e..76d9988bc2 100644 --- a/tmt/utils/jira.py +++ b/tmt/utils/jira.py @@ -8,6 +8,7 @@ import tmt.config import tmt.log import tmt.utils +import tmt.utils.hints from tmt.config.models.link import IssueTracker, IssueTrackerType from tmt.plugins import ModuleImporter @@ -21,7 +22,19 @@ import_jira: ModuleImporter['jira'] = ModuleImporter( # type: ignore[valid-type] 'jira', tmt.utils.ReportError, - "Install 'tmt+link-jira' to use the Jira linking.") + 'jira') + + +tmt.utils.hints.register_hint( + 'jira', + """ + For linking tests, plans and stories to Jira, ``jira`` package is required by tmt. + + To quickly test ``jira`` presence, you can try running ``python -c 'import jira'``. + + * Users who installed tmt from system repositories should install ``tmt+link-jira`` package. + * Users who installed tmt from PyPI should install ``tmt[link-jira]`` extra. + """) def prepare_url_params(tmt_object: 'tmt.base.Core') -> dict[str, str]: