diff --git a/.gitignore b/.gitignore index e335335fe8..ae77d62a42 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,13 @@ /tmp/ docs/code/autodocs/*.rst +docs/plugins/discover.rst +docs/plugins/execute.rst +docs/plugins/finish.rst +docs/plugins/prepare.rst +docs/plugins/provision.rst +docs/plugins/report.rst +docs/plugins/test-checks.rst docs/_build docs/spec docs/stories diff --git a/docs/Makefile b/docs/Makefile index 0358671cac..00196b0df8 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,7 +16,7 @@ ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help generate-stories generate-autodocs clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext +.PHONY: help generate-plugins plugins/*.rst generate-stories generate-autodocs clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext clean: rm -rf $(BUILDDIR) stories spec docs/autodocs/*.rst @@ -52,7 +52,7 @@ REPODIR = .. TMTDIR = $(REPODIR)/tmt SCRIPTSDIR = scripts -generate: spec stories generate-lint-checks generate-test-checks generate-stories generate-autodocs ## Refresh all generated documentation sources +generate: spec stories generate-lint-checks generate-plugins generate-stories generate-autodocs ## Refresh all generated documentation sources spec: mkdir -p spec @@ -63,7 +63,25 @@ stories: spec/lint.rst: $(SCRIPTSDIR)/generate-lint-checks.py lint-checks.rst.j2 $(TMTDIR)/base.py $(SCRIPTSDIR)/generate-lint-checks.py lint-checks.rst.j2 $@ -spec/test-checks.rst: $(SCRIPTSDIR)/generate-test-checks.py test-checks.rst.j2 $(TMTDIR)/checks/*.py +plugins/discover.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/discover/*.py + $(SCRIPTSDIR)/generate-plugins.py discover plugins.rst.j2 $@ + +plugins/execute.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/execute/*.py + $(SCRIPTSDIR)/generate-plugins.py execute plugins.rst.j2 $@ + +plugins/finish.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/finish/*.py + $(SCRIPTSDIR)/generate-plugins.py finish plugins.rst.j2 $@ + +plugins/prepare.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/prepare/*.py + $(SCRIPTSDIR)/generate-plugins.py prepare plugins.rst.j2 $@ + +plugins/provision.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/provision/*.py + $(SCRIPTSDIR)/generate-plugins.py provision plugins.rst.j2 $@ + +plugins/report.rst: $(SCRIPTSDIR)/generate-plugins.py plugins.rst.j2 $(TMTDIR)/steps/report/*.py + $(SCRIPTSDIR)/generate-plugins.py report plugins.rst.j2 $@ + +plugins/test-checks.rst: $(SCRIPTSDIR)/generate-test-checks.py test-checks.rst.j2 $(TMTDIR)/checks/*.py $(SCRIPTSDIR)/generate-test-checks.py test-checks.rst.j2 $@ generate-lint-checks: spec spec/lint.rst ## Generate documentation sources for lint checks @@ -71,7 +89,7 @@ generate-lint-checks: spec spec/lint.rst ## Generate documentation sources for generate-stories: stories ## Generate documentation sources for stories $(SCRIPTSDIR)/generate-stories.py story.rst.j2 -generate-test-checks: spec spec/test-checks.rst ## Generate documentation sources for test checks +generate-plugins: plugins/discover.rst plugins/execute.rst plugins/finish.rst plugins/prepare.rst plugins/provision.rst plugins/report.rst plugins/test-checks.rst ## Generate documentation sources for plugins generate-autodocs: ## Generate autodocs from source docstrings cd ../ && sphinx-apidoc --force --implicit-namespaces --no-toc -o docs/code/autodocs tmt diff --git a/docs/code/index.rst b/docs/code/index.rst index 38300dab66..54f042103e 100644 --- a/docs/code/index.rst +++ b/docs/code/index.rst @@ -5,8 +5,8 @@ In order to get a quick start with the ``tmt`` source code you might want look through the :ref:`classes` first to learn about -the overall structure of the code. The :ref:`plugins` can help if -you are planning to write a new plugin. To find detailed +the overall structure of the code. The :ref:`plugin_introduction` +can help if you are planning to write a new plugin. To find detailed information about individual classes, modules and packages inspect the documentation generated from sources linked below. @@ -14,7 +14,7 @@ the documentation generated from sources linked below. :maxdepth: 2 Class Overview - Plugin Introduction + Plugin Introduction tmt .. toctree:: diff --git a/docs/code/plugins.rst b/docs/code/plugin-introduction.rst similarity index 98% rename from docs/code/plugins.rst rename to docs/code/plugin-introduction.rst index 9ba3c49253..de2dd190d9 100644 --- a/docs/code/plugins.rst +++ b/docs/code/plugin-introduction.rst @@ -1,4 +1,4 @@ -.. _plugins: +.. _plugin_introduction: =========================== Plugin Introduction diff --git a/docs/index.rst b/docs/index.rst index 0b6db6b813..1f80de42b3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,7 @@ Table of Contents Overview Guide Specification + Plugins Examples Stories Questions diff --git a/docs/plugins.rst.j2 b/docs/plugins.rst.j2 new file mode 100644 index 0000000000..9adb0deb25 --- /dev/null +++ b/docs/plugins.rst.j2 @@ -0,0 +1,43 @@ +:tocdepth: 0 + +.. _/plugins/{{ STEP }}: + +{{ STEP | capitalize }} Plugins +{{ '=' * (8 + (STEP | length)) }} + +{% for PLUGIN_ID in REGISTRY.iter_plugin_ids() %} + {% set method = REGISTRY.get_plugin(PLUGIN_ID) %} + {% set PLUGIN = method.class_ %} + +.. _plugins/{{ STEP }}/{{ PLUGIN_ID | strip }}: + +{{ PLUGIN_ID }} +{{ '^' * (PLUGIN_ID | length)}} + +{% if PLUGIN.__doc__ %} +{{ PLUGIN.__doc__ | dedent | strip }} +{% endif %} + +**Configuration** + +{% for field in container_fields(PLUGIN._data_class) %} + {% if field.name in ('how', 'name', 'where') and field.internal != true %} + {% set _, option, _, metadata = container_field(PLUGIN._data_class, field.name) %} + + {% if metadata.metavar %} +{{ option }}: ``{{ metadata.metavar }}`` + {% elif metadata.default is boolean %} +{{ option }}: ``true|false`` + {% else %} +{{ option }}: + {% endif %} + {% if metadata.help %} +{{ metadata.help | strip | indent(4, first=true) }} + {% endif %} + {% endif %} +{% endfor %} + +{% if not loop.last %} +---- +{% endif %} +{% endfor %} diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst new file mode 100644 index 0000000000..7cbff0953c --- /dev/null +++ b/docs/plugins/index.rst @@ -0,0 +1,17 @@ +.. _plugins: + +Plugins +======= + +Here you will find documentation for plugins shipped with tmt. + +.. toctree:: + :maxdepth: 2 + + Discover + Provision + Prepare + Execute + Finish + Report + Test checks diff --git a/docs/scripts/generate-plugins.py b/docs/scripts/generate-plugins.py new file mode 100755 index 0000000000..b68ad254db --- /dev/null +++ b/docs/scripts/generate-plugins.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import sys +import textwrap + +import tmt.log +import tmt.plugins +import tmt.steps.discover +import tmt.steps.execute +import tmt.steps.finish +import tmt.steps.prepare +import tmt.steps.provision +import tmt.steps.report +import tmt.utils +from tmt.utils import Path, render_template_file + +HELP = textwrap.dedent(""" +Usage: generate-plugins.py + +Generate pages for step plugins sources. +""").strip() + + +def main() -> None: + if len(sys.argv) != 4: + print(HELP) + + sys.exit(1) + + step_name = sys.argv[1] + template_filepath = Path(sys.argv[2]) + output_filepath = Path(sys.argv[3]) + + # We will need a logger... + logger = tmt.log.Logger.create() + logger.add_console_handler() + + # ... explore available plugins... + tmt.plugins.explore(logger) + + if step_name == 'discover': + registry = tmt.steps.discover.DiscoverPlugin._supported_methods + + elif step_name == 'execute': + registry = tmt.steps.execute.ExecutePlugin._supported_methods + + elif step_name == 'finish': + registry = tmt.steps.finish.FinishPlugin._supported_methods + + elif step_name == 'prepare': + registry = tmt.steps.prepare.PreparePlugin._supported_methods + + elif step_name == 'provision': + registry = tmt.steps.provision.ProvisionPlugin._supported_methods + + elif step_name == 'report': + registry = tmt.steps.report.ReportPlugin._supported_methods + + else: + raise tmt.utils.GeneralError(f"Unhandled step name '{step_name}'.") + + # ... and render the template. + output_filepath.write_text(render_template_file( + template_filepath, + STEP=step_name, + REGISTRY=registry, + container_fields=tmt.utils.container_fields, + container_field=tmt.utils.container_field)) + + +if __name__ == '__main__': + main() diff --git a/docs/spec.rst b/docs/spec.rst index 14d8a7e17c..7150dd289e 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -27,9 +27,7 @@ Level 1: Tests Metadata closely related to individual :ref:`/spec/tests` such as the :ref:`/spec/tests/test` script, directory :ref:`/spec/tests/path` or maximum :ref:`/spec/tests/duration` - which are stored directly with the test code. See - :ref:`/spec/test-checks` for the list of available test - :ref:`checks`. + which are stored directly with the test code. Level 2: Plans :ref:`/spec/plans` are used to group relevant tests and enable @@ -56,5 +54,4 @@ Level 3: Stories spec/stories spec/context spec/hardware - spec/test-checks spec/lint diff --git a/docs/test-checks.rst.j2 b/docs/test-checks.rst.j2 index ee6a312726..6a18dea42f 100644 --- a/docs/test-checks.rst.j2 +++ b/docs/test-checks.rst.j2 @@ -1,6 +1,6 @@ :tocdepth: 0 -.. _/spec/test-checks: +.. _/plugins/test-checks: Tests Checks ============ @@ -19,7 +19,7 @@ tmt. {% for PLUGIN_ID in REGISTRY.iter_plugin_ids() %} {% set PLUGIN = REGISTRY.get_plugin(PLUGIN_ID) %} -.. _spec/test-checks/{{ PLUGIN_ID | strip }}: +.. _plugins/test-checks/{{ PLUGIN_ID | strip }}: {{ PLUGIN_ID }} {{ '^' * (PLUGIN_ID | length)}} @@ -37,7 +37,7 @@ tmt. {% if metadata.metavar %} {{ option }}: ``{{ metadata.metavar }}`` {% elif metadata.default is boolean %} -{{ option }}: ``true | false`` +{{ option }}: ``true|false`` {% else %} {{ option }}: ... {% endif %} diff --git a/spec/tests/check.fmf b/spec/tests/check.fmf index 55b84b5dbe..102eb4771c 100644 --- a/spec/tests/check.fmf +++ b/spec/tests/check.fmf @@ -14,7 +14,7 @@ description: | panic detection, core dump collection or collection of system logs. - See :ref:`/spec/test-checks` for the list of available checks. + See :ref:`/plugins/test-checks` for the list of available checks. example: - | diff --git a/tmt/checks/__init__.py b/tmt/checks/__init__.py index b3017ad8cf..37709cce75 100644 --- a/tmt/checks/__init__.py +++ b/tmt/checks/__init__.py @@ -97,6 +97,7 @@ class Check( how: str enabled: bool = field( default=True, + is_flag=True, help='Whether the check is enabled or not.') @cached_property diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index 63fdde619c..25ecc20ed4 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -240,10 +240,14 @@ class StepData( # TODO: we can easily add lists of keys for various verbosity levels... _KEYS_SHOW_ORDER = ['name', 'how'] - name: str - how: str - order: int = tmt.utils.DEFAULT_PLUGIN_ORDER - summary: Optional[str] = None + name: str = field(help='The name of the step phase.') + how: str = field() + order: int = field( + default=tmt.utils.DEFAULT_PLUGIN_ORDER, + help='Order in which the phase should be handled.') + summary: Optional[str] = field( + default=None, + help='Concise summary describing purpose of the phase.') def to_spec(self) -> _RawStepData: """ Convert to a form suitable for saving in a specification file """ diff --git a/tmt/utils.py b/tmt/utils.py index db74728d26..78843f6934 100644 --- a/tmt/utils.py +++ b/tmt/utils.py @@ -2522,20 +2522,44 @@ class FieldMetadata(Generic[T]): #: Help text documenting the field. help: Optional[str] = None + #: If field accepts a value, this string would represent it in documentation. - metavar: Optional[str] = None - #: Field default value. + #: This stores the metavar provided when field was created - it may be unset. + #: py:attr:`metavar` provides the actual metavar to be used. + _metavar: Optional[str] = None + + #: The default value for the field. default: Optional[Any] = None - #: Field default value factory. + + #: A zero-argument callable that will be called when a default value is + #: needed for the field. default_factory: Optional[Callable[[], Any]] = None - #: CLI option parameters, for lazy option creation. - option_args: Optional['FieldCLIOption'] = None - option_kwargs: Optional[dict[str, Any]] = None - option_choices: Union[None, Sequence[str], Callable[[], Sequence[str]]] = None + #: Marks the fields as a flag. + is_flag: bool = False - #: A :py:func:`click.option` decorator defining a corresponding CLI option. - _option: Optional['tmt.options.ClickOptionDecoratorType'] = None + #: Marks the field as accepting multiple values. When used on command line, + #: the option could be used multiple times, accumulating values. + multiple: bool = False + + #: If set, show the default value in command line help. + show_default: bool = False + + #: Either a list of allowed values the field can take, or a zero-argument + #: callable that would return such a list. + _choices: Union[None, Sequence[str], Callable[[], Sequence[str]]] = None + + #: Environment variable providing value for the field. + envvar: Optional[str] = None + + #: Mark the option as deprecated. Instance of :py:class:`Deprecated` + #: describes the version in which the field was deprecated plus an optional + #: hint with the recommended alternative. Documentation and help texts would + #: contain this info. + deprecated: Optional['tmt.options.Deprecated'] = None + + #: One or more command-line option names. + cli_option: Optional[FieldCLIOption] = None #: A normalization callback to call when loading the value from key source #: (performed by :py:class:`NormalizeKeysMixin`). @@ -2550,20 +2574,62 @@ class FieldMetadata(Generic[T]): #: :py:class:`tmt.export.Exportable`). export_callback: Optional['FieldExporter[T]'] = None + #: CLI option parameters, for lazy option creation. + _option_args: Optional['FieldCLIOption'] = None + _option_kwargs: dict[str, Any] = dataclasses.field(default_factory=dict) + + #: A :py:func:`click.option` decorator defining a corresponding CLI option. + _option: Optional['tmt.options.ClickOptionDecoratorType'] = None + + @cached_property + def choices(self) -> Optional[Sequence[str]]: + """ A list of allowed values the field can take """ + + if isinstance(self._choices, (list, tuple)): + return list(self._choices) + + if callable(self._choices): + return self._choices() + + return None + + @cached_property + def metavar(self) -> Optional[str]: + """ Placeholder for field's value in documentation and help """ + + if self._metavar: + return self._metavar + + if self.choices: + return '|'.join(self.choices) + + return None + @property def option(self) -> Optional['tmt.options.ClickOptionDecoratorType']: - if self._option is None and self.option_args and self.option_kwargs: + if self._option is None and self.cli_option: from tmt.options import option - if isinstance(self.option_choices, (list, tuple)): - self.option_kwargs['choices'] = self.option_choices + self._option_args = (self.cli_option,) if isinstance(self.cli_option, str) \ + else self.cli_option + + self._option_kwargs.update({ + 'is_flag': self.is_flag, + 'multiple': self.multiple, + 'envvar': self.envvar, + 'metavar': self.metavar, + 'choices': self.choices, + 'show_default': self.show_default, + 'help': self.help, + 'deprecated': self.deprecated + }) - elif callable(self.option_choices): - self.option_kwargs['choices'] = self.option_choices() + if self.default is not dataclasses.MISSING and not self.is_flag: + self._option_kwargs['default'] = self.default self._option = option( - *self.option_args, - **self.option_kwargs + *self._option_args, + **self._option_kwargs ) return self._option @@ -6024,6 +6090,31 @@ def field( pass +@overload +def field( + *, + # Options + option: Optional[FieldCLIOption] = None, + is_flag: bool = False, + choices: Union[None, Sequence[str], Callable[[], Sequence[str]]] = None, + multiple: bool = False, + metavar: Optional[str] = None, + envvar: Optional[str] = None, + deprecated: Optional['tmt.options.Deprecated'] = None, + help: Optional[str] = None, + show_default: bool = False, + internal: bool = False, + # Input data normalization + normalize: Optional[NormalizeCallback[T]] = None, + # Custom serialization + serialize: Optional[SerializeCallback[T]] = None, + unserialize: Optional[UnserializeCallback[T]] = None, + # Custom exporter + exporter: Optional[FieldExporter[T]] = None + ) -> T: + pass + + def field( *, default: Any = dataclasses.MISSING, @@ -6095,40 +6186,28 @@ def field( Consumed by :py:class:`tmt.export.Exportable`. """ - if default is dataclasses.MISSING and default_factory is dataclasses.MISSING: - raise GeneralError("Container field must define one of 'default' or 'default_factory'.") + if is_flag is False and isinstance(default, bool): + raise GeneralError("Container field must be a flag to have boolean default value.") + + if is_flag is True and not isinstance(default, bool): + raise GeneralError("Container field must have a boolean default value when it is a flag.") metadata: FieldMetadata[T] = FieldMetadata( internal=internal, help=textwrap.dedent(help).strip() if help else None, - metavar=metavar, + _metavar=metavar, default=default, - default_factory=default_factory) - - if option: - assert is_flag is False or isinstance(default, bool) - - metadata.option_args = (option,) if isinstance(option, str) else option - metadata.option_kwargs = { - 'is_flag': is_flag, - 'multiple': multiple, - 'metavar': metavar, - 'envvar': envvar, - 'help': help, - 'show_default': show_default, - 'deprecated': deprecated - } - metadata.option_choices = choices - - if default is not dataclasses.MISSING and not is_flag: - metadata.option_kwargs['default'] = default - - if normalize: - metadata.normalize_callback = normalize - - metadata.serialize_callback = serialize - metadata.unserialize_callback = unserialize - metadata.export_callback = exporter + default_factory=default_factory, + is_flag=is_flag, + multiple=multiple, + _choices=choices, + envvar=envvar, + deprecated=deprecated, + cli_option=option, + normalize_callback=normalize, + serialize_callback=serialize, + unserialize_callback=unserialize, + export_callback=exporter) # ignore[call-overload]: returning "wrong" type on purpose. field() must be annotated # as if returning the value of type matching the field declaration, and the original