From abbf90767d2fd229bfb06b007a67c9e544e1813c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Prchl=C3=ADk?= Date: Fri, 10 Jan 2025 14:24:59 +0100 Subject: [PATCH] Extract linter commands into their own module under tmt.cli (#3433) Part of the ongoing effort to shorten tmt.cli code. --- tmt/__main__.py | 1 + tmt/cli/_root.py | 330 +-------------------------------------------- tmt/cli/lint.py | 345 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 329 deletions(-) create mode 100644 tmt/cli/lint.py diff --git a/tmt/__main__.py b/tmt/__main__.py index 5bf38bfeb8..68fc7a8062 100644 --- a/tmt/__main__.py +++ b/tmt/__main__.py @@ -7,6 +7,7 @@ def import_cli_commands() -> None: # TODO: some kind of `import tmt.cli.*` would be nice import tmt.cli._root # type: ignore[reportUnusedImport,unused-ignore] import tmt.cli.init # noqa: F401,I001,RUF100 + import tmt.cli.lint # noqa: F401,I001,RUF100 import tmt.cli.status # noqa: F401,I001,RUF100 import tmt.cli.trying # noqa: F401,I001,RUF100 diff --git a/tmt/cli/_root.py b/tmt/cli/_root.py index a037770c04..60dedef6de 100644 --- a/tmt/cli/_root.py +++ b/tmt/cli/_root.py @@ -9,7 +9,7 @@ command remains. """ -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Optional import click import fmf @@ -23,7 +23,6 @@ import tmt.convert import tmt.export import tmt.identifier -import tmt.lint import tmt.log import tmt.options import tmt.plugins @@ -333,168 +332,6 @@ def finito( click_context.obj.run.go() -def _apply_linters(lintable: Union[tmt.lint.Lintable[tmt.base.Test], - tmt.lint.Lintable[tmt.base.Plan], - tmt.lint.Lintable[tmt.base.Story], - tmt.lint.Lintable[tmt.base.LintableCollection]], - linters: list[tmt.lint.Linter], - failed_only: bool, - enforce_checks: list[str], - outcomes: list[tmt.lint.LinterOutcome]) -> tuple[bool, - Optional[list[tmt.lint.LinterRuling]]]: - """Apply linters on a lintable and filter out disallowed outcomes.""" - - valid, rulings = lintable.lint( - linters=linters, - enforce_checks=enforce_checks or None) - - # If the object pass the checks, and we're asked to show only the failed - # ones, display nothing. - if valid and failed_only: - return valid, None - - # Find out what rulings were allowed by user. By default, it's all, but - # user might be interested in "warn" only, for example. Reduce the list - # of rulings, and if we end up with an empty list *and* user constrained - # us to just a subset of rulings, display nothing. - allowed_rulings = list(tmt.lint.filter_allowed_checks(rulings, outcomes=outcomes)) - - if not allowed_rulings and outcomes: - return valid, None - - return valid, allowed_rulings - - -def _lint_class( - context: Context, - klass: Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]], - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcomes: list[tmt.lint.LinterOutcome], - **kwargs: Any) -> int: - """ Lint a single class of objects """ - - # FIXME: Workaround https://github.com/pallets/click/pull/1840 for click 7 - context.params.update(**kwargs) - klass.store_cli_invocation(context) - - exit_code = 0 - - linters = klass.resolve_enabled_linters( - enable_checks=enable_checks or None, - disable_checks=disable_checks or None) - - for lintable in klass.from_tree(context.obj.tree): - valid, allowed_rulings = _apply_linters( - lintable, linters, failed_only, enforce_checks, outcomes) - if allowed_rulings is None: - continue - - lintable.ls() - - echo('\n'.join(tmt.lint.format_rulings(allowed_rulings))) - - if not valid: - exit_code = 1 - - echo() - - return exit_code - - -def _lint_collection( - context: Context, - klasses: list[Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]]], - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcomes: list[tmt.lint.LinterOutcome], - **kwargs: Any) -> int: - """ Lint a collection of objects """ - - # FIXME: Workaround https://github.com/pallets/click/pull/1840 for click 7 - context.params.update(**kwargs) - - exit_code = 0 - - linters = tmt.base.LintableCollection.resolve_enabled_linters( - enable_checks=enable_checks or None, - disable_checks=disable_checks or None) - - objs: list[tmt.base.Core] = [ - obj for cls in klasses - for obj in cls.from_tree(context.obj.tree)] - lintable = tmt.base.LintableCollection(objs) - - valid, allowed_rulings = _apply_linters( - lintable, - linters, - failed_only, - enforce_checks, - outcomes) - if allowed_rulings is None: - return exit_code - - lintable.print_header() - - echo('\n'.join(tmt.lint.format_rulings(allowed_rulings))) - - if not valid: - exit_code = 1 - - echo() - - return exit_code - - -def do_lint( - context: Context, - klasses: list[Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]]], - list_checks: bool, - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcomes: list[tmt.lint.LinterOutcome], - **kwargs: Any) -> int: - """ Core of all ``lint`` commands """ - - if list_checks: - for klass in klasses: - klass_label = 'stories' if klass is tmt.base.Story else f'{klass.__name__.lower()}s' - echo(f'Linters available for {klass_label}') - echo(klass.format_linters()) - echo() - - return 0 - - res_single = max(_lint_class( - context, - klass, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - outcomes, - **kwargs) - for klass in klasses) - - res_collection = _lint_collection( - context, - klasses, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - outcomes, - **kwargs) - - return max(res_single, res_collection) - - # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Test # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -554,45 +391,6 @@ def tests_show(context: Context, **kwargs: Any) -> None: echo() -# ignore[arg-type]: click code expects click.Context, but we use our own type for better type -# inference. See Context and ContextObjects above. -@tests.command(name='lint') # type: ignore[arg-type] -@pass_context -@filtering_options -@fmf_source_options -@lint_options -@fix_options -@verbosity_options -def tests_lint( - context: Context, - list_checks: bool, - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcome_only: tuple[str, ...], - **kwargs: Any) -> None: - """ - Check tests against the L1 metadata specification. - - Regular expression can be used to filter tests for linting. - Use '.' to select tests under the current working directory. - """ - - exit_code = do_lint( - context, - [tmt.base.Test], - list_checks, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], - **kwargs) - - raise SystemExit(exit_code) - - _script_templates = fmf.utils.listed( tmt.templates.MANAGER.templates['script'], join='or') @@ -993,45 +791,6 @@ def plans_show(context: Context, **kwargs: Any) -> None: echo() -# ignore[arg-type]: click code expects click.Context, but we use our own type for better type -# inference. See Context and ContextObjects above. -@plans.command(name='lint') # type: ignore[arg-type] -@pass_context -@filtering_options -@fmf_source_options -@lint_options -@fix_options -@verbosity_options -def plans_lint( - context: Context, - list_checks: bool, - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcome_only: tuple[str, ...], - **kwargs: Any) -> None: - """ - Check plans against the L2 metadata specification. - - Regular expression can be used to filter plans by name. - Use '.' to select plans under the current working directory. - """ - - exit_code = do_lint( - context, - [tmt.base.Plan], - list_checks, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], - **kwargs) - - raise SystemExit(exit_code) - - _plan_templates = fmf.utils.listed(tmt.templates.MANAGER.templates['plan'], join='or') @@ -1413,45 +1172,6 @@ def stories_export( template=Path(template) if template else None)) -# ignore[arg-type]: click code expects click.Context, but we use our own type for better type -# inference. See Context and ContextObjects above. -@stories.command(name='lint') # type: ignore[arg-type] -@pass_context -@filtering_options -@fmf_source_options -@lint_options -@fix_options -@verbosity_options -def stories_lint( - context: Context, - list_checks: bool, - failed_only: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - outcome_only: tuple[str, ...], - **kwargs: Any) -> None: - """ - Check stories against the L3 metadata specification. - - Regular expression can be used to filter stories by name. - Use '.' to select stories under the current working directory. - """ - - exit_code = do_lint( - context, - [tmt.base.Story], - list_checks, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], - **kwargs) - - raise SystemExit(exit_code) - - # ignore[arg-type]: click code expects click.Context, but we use our own type for better type # inference. See Context and ContextObjects above. @stories.command(name="id") # type: ignore[arg-type] @@ -1722,54 +1442,6 @@ def clean_images(context: Context, **kwargs: Any) -> None: cli_invocation=CliInvocation.from_context(context)) context.obj.clean_partials["images"].append(clean_obj.images) -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# Lint -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - -# ignore[arg-type]: click code expects click.Context, but we use our own type for better type -# inference. See Context and ContextObjects above. -@main.command(name='lint') # type: ignore[arg-type] -@pass_context -@filtering_options -@fmf_source_options -@lint_options -@fix_options -@verbosity_options -def lint( - context: Context, - list_checks: bool, - enable_checks: list[str], - disable_checks: list[str], - enforce_checks: list[str], - failed_only: bool, - outcome_only: tuple[str, ...], - **kwargs: Any) -> None: - """ - Check all the present metadata against the specification. - - Combines all the partial linting (tests, plans and stories) - into one command. Options are applied to all parts of the lint. - - Regular expression can be used to filter metadata by name. - Use '.' to select tests, plans and stories under the current - working directory. - """ - - exit_code = do_lint( - context, - [tmt.base.Test, tmt.base.Plan, tmt.base.Story], - list_checks, - failed_only, - enable_checks, - disable_checks, - enforce_checks, - [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], - **kwargs - ) - - raise SystemExit(exit_code) - # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Setup # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tmt/cli/lint.py b/tmt/cli/lint.py new file mode 100644 index 0000000000..3991601654 --- /dev/null +++ b/tmt/cli/lint.py @@ -0,0 +1,345 @@ +""" ``tmt lint`` and ``tmt * lint`` implementation """ + +from typing import Any, Optional, Union + +from click import echo + +import tmt.base +import tmt.lint +import tmt.utils +from tmt.cli import Context, pass_context +from tmt.cli._root import ( + filtering_options, + fix_options, + fmf_source_options, + lint_options, + main, + plans, + stories, + tests, + verbosity_options, + ) + + +def _apply_linters( + lintable: Union[tmt.lint.Lintable[tmt.base.Test], + tmt.lint.Lintable[tmt.base.Plan], + tmt.lint.Lintable[tmt.base.Story], + tmt.lint.Lintable[tmt.base.LintableCollection]], + linters: list[tmt.lint.Linter], + failed_only: bool, + enforce_checks: list[str], + outcomes: list[tmt.lint.LinterOutcome]) -> tuple[ + bool, Optional[list[tmt.lint.LinterRuling]]]: + """Apply linters on a lintable and filter out disallowed outcomes.""" + + valid, rulings = lintable.lint( + linters=linters, + enforce_checks=enforce_checks or None) + + # If the object pass the checks, and we're asked to show only the failed + # ones, display nothing. + if valid and failed_only: + return valid, None + + # Find out what rulings were allowed by user. By default, it's all, but + # user might be interested in "warn" only, for example. Reduce the list + # of rulings, and if we end up with an empty list *and* user constrained + # us to just a subset of rulings, display nothing. + allowed_rulings = list(tmt.lint.filter_allowed_checks(rulings, outcomes=outcomes)) + + if not allowed_rulings and outcomes: + return valid, None + + return valid, allowed_rulings + + +def _lint_class( + context: Context, + klass: Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]], + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcomes: list[tmt.lint.LinterOutcome], + **kwargs: Any) -> int: + """ Lint a single class of objects """ + + # FIXME: Workaround https://github.com/pallets/click/pull/1840 for click 7 + context.params.update(**kwargs) + klass.store_cli_invocation(context) + + exit_code = 0 + + linters = klass.resolve_enabled_linters( + enable_checks=enable_checks or None, + disable_checks=disable_checks or None) + + for lintable in klass.from_tree(context.obj.tree): + valid, allowed_rulings = _apply_linters( + lintable, linters, failed_only, enforce_checks, outcomes) + if allowed_rulings is None: + continue + + lintable.ls() + + echo('\n'.join(tmt.lint.format_rulings(allowed_rulings))) + + if not valid: + exit_code = 1 + + echo() + + return exit_code + + +def _lint_collection( + context: Context, + klasses: list[Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]]], + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcomes: list[tmt.lint.LinterOutcome], + **kwargs: Any) -> int: + """ Lint a collection of objects """ + + # FIXME: Workaround https://github.com/pallets/click/pull/1840 for click 7 + context.params.update(**kwargs) + + exit_code = 0 + + linters = tmt.base.LintableCollection.resolve_enabled_linters( + enable_checks=enable_checks or None, + disable_checks=disable_checks or None) + + objs: list[tmt.base.Core] = [ + obj for cls in klasses + for obj in cls.from_tree(context.obj.tree)] + lintable = tmt.base.LintableCollection(objs) + + valid, allowed_rulings = _apply_linters( + lintable, + linters, + failed_only, + enforce_checks, + outcomes) + if allowed_rulings is None: + return exit_code + + lintable.print_header() + + echo('\n'.join(tmt.lint.format_rulings(allowed_rulings))) + + if not valid: + exit_code = 1 + + echo() + + return exit_code + + +def do_lint( + context: Context, + klasses: list[Union[type[tmt.base.Test], type[tmt.base.Plan], type[tmt.base.Story]]], + list_checks: bool, + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcomes: list[tmt.lint.LinterOutcome], + **kwargs: Any) -> int: + """ Core of all ``lint`` commands """ + + if list_checks: + for klass in klasses: + klass_label = 'stories' if klass is tmt.base.Story else f'{klass.__name__.lower()}s' + echo(f'Linters available for {klass_label}') + echo(klass.format_linters()) + echo() + + return 0 + + res_single = max(_lint_class( + context, + klass, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + outcomes, + **kwargs) + for klass in klasses) + + res_collection = _lint_collection( + context, + klasses, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + outcomes, + **kwargs) + + return max(res_single, res_collection) + + +# ignore[arg-type]: click code expects click.Context, but we use our own type for better type +# inference. See Context and ContextObjects above. +@tests.command(name='lint') # type: ignore[arg-type] +@pass_context +@filtering_options +@fmf_source_options +@lint_options +@fix_options +@verbosity_options +def tests_lint( + context: Context, + list_checks: bool, + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcome_only: tuple[str, ...], + **kwargs: Any) -> None: + """ + Check tests against the L1 metadata specification. + + Regular expression can be used to filter tests for linting. + Use '.' to select tests under the current working directory. + """ + + exit_code = do_lint( + context, + [tmt.base.Test], + list_checks, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], + **kwargs) + + raise SystemExit(exit_code) + + +# ignore[arg-type]: click code expects click.Context, but we use our own type for better type +# inference. See Context and ContextObjects above. +@plans.command(name='lint') # type: ignore[arg-type] +@pass_context +@filtering_options +@fmf_source_options +@lint_options +@fix_options +@verbosity_options +def plans_lint( + context: Context, + list_checks: bool, + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcome_only: tuple[str, ...], + **kwargs: Any) -> None: + """ + Check plans against the L2 metadata specification. + + Regular expression can be used to filter plans by name. + Use '.' to select plans under the current working directory. + """ + + exit_code = do_lint( + context, + [tmt.base.Plan], + list_checks, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], + **kwargs) + + raise SystemExit(exit_code) + + +# ignore[arg-type]: click code expects click.Context, but we use our own type for better type +# inference. See Context and ContextObjects above. +@stories.command(name='lint') # type: ignore[arg-type] +@pass_context +@filtering_options +@fmf_source_options +@lint_options +@fix_options +@verbosity_options +def stories_lint( + context: Context, + list_checks: bool, + failed_only: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + outcome_only: tuple[str, ...], + **kwargs: Any) -> None: + """ + Check stories against the L3 metadata specification. + + Regular expression can be used to filter stories by name. + Use '.' to select stories under the current working directory. + """ + + exit_code = do_lint( + context, + [tmt.base.Story], + list_checks, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], + **kwargs) + + raise SystemExit(exit_code) + + +# ignore[arg-type]: click code expects click.Context, but we use our own type for better type +# inference. See Context and ContextObjects above. +@main.command(name='lint') # type: ignore[arg-type] +@pass_context +@filtering_options +@fmf_source_options +@lint_options +@fix_options +@verbosity_options +def lint( + context: Context, + list_checks: bool, + enable_checks: list[str], + disable_checks: list[str], + enforce_checks: list[str], + failed_only: bool, + outcome_only: tuple[str, ...], + **kwargs: Any) -> None: + """ + Check all the present metadata against the specification. + + Combines all the partial linting (tests, plans and stories) + into one command. Options are applied to all parts of the lint. + + Regular expression can be used to filter metadata by name. + Use '.' to select tests, plans and stories under the current + working directory. + """ + + exit_code = do_lint( + context, + [tmt.base.Test, tmt.base.Plan, tmt.base.Story], + list_checks, + failed_only, + enable_checks, + disable_checks, + enforce_checks, + [tmt.lint.LinterOutcome(outcome) for outcome in outcome_only], + **kwargs + ) + + raise SystemExit(exit_code)