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/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 6a69d7b16c..3dd4d256ba 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 c35f53a8b4..2499382bfc 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]: