From e5256caf03fb2026a76835dae2a394efd5f300d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Prchl=C3=ADk?= Date: Tue, 5 Dec 2023 16:52:48 +0100 Subject: [PATCH] Generate plugin documentation from their sources This required a small refactoring of how field properties were stored. Instead of treating them as something owned by CLI options, they have their use outside of CLI area, and must exist in field metadata to be consumable in general. Now we have a template, a script, a section in docs. Fixing the content would be the next step... --- .gitignore | 7 + docs/Makefile | 28 ++- docs/code/index.rst | 6 +- .../{plugins.rst => plugin-introduction.rst} | 2 +- docs/index.rst | 1 + docs/plugins/index.rst | 24 +++ docs/scripts/generate-plugins.py | 72 ++++++++ docs/spec.rst | 5 +- docs/templates/plugins.rst.j2 | 62 +++++++ docs/templates/test-checks.rst.j2 | 6 +- spec/tests/check.fmf | 2 +- tmt/checks/__init__.py | 1 + tmt/steps/__init__.py | 12 +- tmt/utils.py | 169 +++++++++++++----- 14 files changed, 332 insertions(+), 65 deletions(-) rename docs/code/{plugins.rst => plugin-introduction.rst} (98%) create mode 100644 docs/plugins/index.rst create mode 100755 docs/scripts/generate-plugins.py create mode 100644 docs/templates/plugins.rst.j2 diff --git a/.gitignore b/.gitignore index 86a7596b2c..0a45047285 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 50187015e5..be73a30808 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 code/autodocs/*.rst @@ -53,7 +53,9 @@ TMTDIR = $(REPODIR)/tmt SCRIPTSDIR = scripts TEMPLATESDIR = templates -generate: spec stories generate-lint-checks generate-test-checks generate-stories generate-autodocs ## Refresh all generated documentation sources +PLUGINS_TEMPLATE := $(TEMPLATESDIR)/plugins.rst.j2 + +generate: spec stories generate-lint-checks generate-plugins generate-stories generate-autodocs ## Refresh all generated documentation sources spec: mkdir -p spec @@ -64,7 +66,25 @@ stories: spec/lint.rst: $(SCRIPTSDIR)/generate-lint-checks.py $(TEMPLATESDIR)/lint-checks.rst.j2 $(TMTDIR)/base.py $(SCRIPTSDIR)/generate-lint-checks.py $(TEMPLATESDIR)/lint-checks.rst.j2 $@ -spec/test-checks.rst: $(SCRIPTSDIR)/generate-test-checks.py $(TEMPLATESDIR)/test-checks.rst.j2 $(TMTDIR)/checks/*.py +plugins/discover.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/discover/*.py + $(SCRIPTSDIR)/generate-plugins.py discover $(PLUGINS_TEMPLATE) $@ + +plugins/execute.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/execute/*.py + $(SCRIPTSDIR)/generate-plugins.py execute $(PLUGINS_TEMPLATE) $@ + +plugins/finish.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/finish/*.py + $(SCRIPTSDIR)/generate-plugins.py finish $(PLUGINS_TEMPLATE) $@ + +plugins/prepare.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/prepare/*.py + $(SCRIPTSDIR)/generate-plugins.py prepare $(PLUGINS_TEMPLATE) $@ + +plugins/provision.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/provision/*.py + $(SCRIPTSDIR)/generate-plugins.py provision $(PLUGINS_TEMPLATE) $@ + +plugins/report.rst: $(SCRIPTSDIR)/generate-plugins.py $(PLUGINS_TEMPLATE) $(TMTDIR)/steps/report/*.py + $(SCRIPTSDIR)/generate-plugins.py report $(PLUGINS_TEMPLATE) $@ + +plugins/test-checks.rst: $(SCRIPTSDIR)/generate-test-checks.py $(TEMPLATESDIR)/test-checks.rst.j2 $(TMTDIR)/checks/*.py $(SCRIPTSDIR)/generate-test-checks.py $(TEMPLATESDIR)/test-checks.rst.j2 $@ generate-lint-checks: spec spec/lint.rst ## Generate documentation sources for lint checks @@ -72,7 +92,7 @@ generate-lint-checks: spec spec/lint.rst ## Generate documentation sources for generate-stories: stories $(TEMPLATESDIR)/story.rst.j2 ## Generate documentation sources for stories $(SCRIPTSDIR)/generate-stories.py $(TEMPLATESDIR)/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/index.rst b/docs/plugins/index.rst new file mode 100644 index 0000000000..ec9e89a835 --- /dev/null +++ b/docs/plugins/index.rst @@ -0,0 +1,24 @@ +.. _plugins: + +Plugins +======= + +Here you will find documentation for plugins shipped with tmt. + +.. warning:: + + Please, be aware that the documentation below is a work in progress. We are + working on fixing it, adding missing bits and generally making it better. + Also, it was originaly used for command line help only, therefore the + formatting is often suboptional. + +.. 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/templates/plugins.rst.j2 b/docs/templates/plugins.rst.j2 new file mode 100644 index 0000000000..b8ebf80217 --- /dev/null +++ b/docs/templates/plugins.rst.j2 @@ -0,0 +1,62 @@ +: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)}} + +{# + TODO: once we start getting reviewed and polished plugins, drop the warning + for those that would be done and ready. Probably with some temporary list + to which we would add their names. +#} +.. warning:: + + Please, be aware that the documentation below is a work in progress. We are + working on fixing it, adding missing bits and generally making it better. + Also, it was originaly used for command line help only, therefore the + formatting is often suboptional. + +{% if PLUGIN.__doc__ %} +{{ PLUGIN.__doc__ | dedent | strip }} +{% endif %} + +**Configuration** + +{% for field in container_fields(PLUGIN._data_class) %} + {% if ( + field.name not in ('how', 'name', 'where', '_OPTIONLESS_FIELDS') + and field.internal != true + and ( + not PLUGIN._data_class._OPTIONLESS_FIELDS + or field.name not in PLUGIN._data_class._OPTIONLESS_FIELDS + ) + ) %} + {% 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/templates/test-checks.rst.j2 b/docs/templates/test-checks.rst.j2 index ee6a312726..6a18dea42f 100644 --- a/docs/templates/test-checks.rst.j2 +++ b/docs/templates/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 b6f137530c..07b2eb23f8 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -251,10 +251,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 a1bef57a11..d34d8bab76 100644 --- a/tmt/utils.py +++ b/tmt/utils.py @@ -2531,20 +2531,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`). @@ -2559,20 +2583,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 @@ -6138,6 +6204,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, @@ -6209,40 +6300,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