From babdb4998a72d848901350a074c024e693b09a0f Mon Sep 17 00:00:00 2001 From: Kirill Sosnovskikh Date: Sun, 1 Sep 2024 13:23:06 +0300 Subject: [PATCH] Add plugin support The following flags have been added: - `--plugins` - patterns that plugins must match (paths are compiled according to the template: `plugin / src / plugin`). - `--files` - paths to files with fixtures. Signed-off-by: Kirill Sosnovskikh --- README.md | 22 +++++- src/allure_validator/arguments.py | 35 ++++++++- src/allure_validator/collect.py | 117 ++++++++++++++++++++++++++++-- src/allure_validator/common.py | 2 + src/allure_validator/main.py | 24 ++++-- src/allure_validator/parse.py | 11 +++ 6 files changed, 193 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c98cb59..7c865f9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ pip install git+https://git.frostfs.info/TrueCloudLab/allure-validator.git@maste To start, simply specify the folder with the test database that needs to be checked: ```shell -allure-validator +allure-validator [OPTIONS] ``` Additionally, `allure-validator` can be added to pre-commit hooks: @@ -49,6 +49,17 @@ class TestIngoredClass: # noqa: allure-validator > Note: this may also miss fixtures, so use with caution. +### Options + +The linter supports the following flags: +- `--plugins` - regex patterns that plugins must match (paths are compiled according to the template: `plugin / src / plugin`). +- `--files` - paths to files with fixtures. + +Configuration options can also be provided using special command line arguments, for example: +```shell +allure-validator pytest_tests/ --plugins some[-_]plugin --files etc/workspace/some_plugin/src/some_plugin/fixtures.py +``` + ## Work example Let's run the linter for some [sample tests](https://git.frostfs.info/Kiriruso/allure-validator/src/branch/master/tests): @@ -93,9 +104,12 @@ Dynamic fixtures are only supported if they are declared in `pytest_generate_tes ```python def pytest_generate_tests(metafunc): ... + metafunc.parametrize("fixture1, fixture2", [(1, 2), (3, 4)]) # OK + ... + + if some_condition: + metafunc.parametrize("fixture3", *some_value_generator) # OK -``` - -Plugins are not supported (**temporarily**), so fixtures and tests from there cannot be found. \ No newline at end of file +``` \ No newline at end of file diff --git a/src/allure_validator/arguments.py b/src/allure_validator/arguments.py index cd5b866..2ed403b 100644 --- a/src/allure_validator/arguments.py +++ b/src/allure_validator/arguments.py @@ -1,10 +1,41 @@ import argparse + +class _SubcommandHelpFormatter(argparse.RawDescriptionHelpFormatter): + def __init__(self, prog): + super().__init__(prog, max_help_position=80) + + def _format_action(self, action): + parts = super(argparse.RawDescriptionHelpFormatter, self)._format_action(action) + if action.nargs == argparse.PARSER: + parts = "\n".join(parts.split("\n")[1:]) + return parts + + parser = argparse.ArgumentParser( "pyfactoring", - "%(prog)s ", + "%(prog)s [OPTIONS]", "Linter for allure.title validation", + formatter_class=_SubcommandHelpFormatter, +) +parser.add_argument( + "folder", + type=str, + help="folder with tests", +) +parser.add_argument( + "--plugins", + metavar="plugin,", + nargs="*", + default=[], + help="list plugins for analysis", +) +parser.add_argument( + "--files", + metavar="file,", + nargs="*", + default=[], + help="list paths to files with fixtures", ) -parser.add_argument("folder", type=str, help="folder with tests") args = parser.parse_args() diff --git a/src/allure_validator/collect.py b/src/allure_validator/collect.py index f077432..a71d11a 100644 --- a/src/allure_validator/collect.py +++ b/src/allure_validator/collect.py @@ -35,6 +35,53 @@ def paths(folder: str) -> list[Path]: return paths +def files(filepaths: list[str]) -> list[Path]: + """ + Converts the given paths to `pathlib.Path` objects and checks that the file exists. + + Args: + filepaths: Paths to files with fixtures. + + Returns: + list[Path]: List of paths to .py files. + """ + + paths = [] + + for path in filepaths: + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"File '{path}' not found") + paths.append(path) + + return paths + + +def plugins(plugin_pattern: str, folder: str) -> list[Path]: + """ + Collects paths to files with fixtures in plugin directories that match a given pattern. + + Args: + plugin_pattern: Pattern that plugins must follow. + folder: Source folder with tests. + + Returns: + list[Path]: List of paths to .py files. + """ + + pytest_tests = Path(folder).absolute() + workspace = pytest_tests.parent.parent if pytest_tests.name == "pytest_tests" else pytest_tests.parent + plugins = _matching_paths(plugin_pattern, workspace) + paths = [] + + for plugin in plugins: + if plugin is None or not plugin.exists(): + raise ModuleNotFoundError(f"Plugin with '{plugin_pattern}' pattern not found in '{workspace}'") + paths.extend(_plugin_paths(plugin)) + + return paths + + def ast_pytest_items(paths: list[Path]) -> ASTPytestItems: """ Collects AST objects of pytest tests, fixtures and hooks. @@ -97,7 +144,7 @@ def ast_pytest_items(paths: list[Path]) -> ASTPytestItems: return items -def fixtures(ast_fixtures: list[ast.FunctionDef], ast_hooks: list[ast.FunctionDef]) -> dict[tuple, Fixture]: +def fixtures(ast_fixtures: list[ast.FunctionDef], ast_hooks: list[ast.FunctionDef], plugin_paths: list[Path]) -> dict[tuple, Fixture]: """ From AST objects it forms fixtures and connections between these fixtures. @@ -175,12 +222,19 @@ def fixtures(ast_fixtures: list[ast.FunctionDef], ast_hooks: list[ast.FunctionDe is_conftest = current_path.name == "conftest.py" current_path = current_path.parent.parent / "conftest.py" if is_conftest else current_path.parent / "conftest.py" - # TODO: Finding fixture from plugins + # Finding fixture from plugins + for plugin_path in plugin_paths: + possible_fixture = (arg, plugin_path, None) + + if possible_fixture in fixtures.keys(): + depend = fixtures[possible_fixture] + fixture.depends.add(depend) + break return fixtures -def tests(ast_tests: list[ast.FunctionDef], fixtures: dict[tuple, Fixture]) -> list[Test]: +def tests(ast_tests: list[ast.FunctionDef], fixtures: dict[tuple, Fixture], plugin_paths: list[Path]) -> list[Test]: """ From AST objects it forms fixtures and connections between these fixtures. @@ -213,7 +267,7 @@ def tests(ast_tests: list[ast.FunctionDef], fixtures: dict[tuple, Fixture]) -> l in_class=in_class, ) - depends = _only_parametirzed_depends(test, fixtures) + depends = _only_parametirzed_depends(test, fixtures, plugin_paths) depends = {depend for depend in depends if depend.name not in params.optional} # Test parameters take precedence over fixtures @@ -227,6 +281,50 @@ def tests(ast_tests: list[ast.FunctionDef], fixtures: dict[tuple, Fixture]) -> l return tests +def _matching_paths(plugin_pattern: str, workspace: Path) -> list[Path]: + """ + Collects paths that match a pattern. + Paths are collected according to the following pattern: `pattern / src / pattern`. + """ + + plugins = [] + + for outer_folder in workspace.glob(plugin_pattern): + if not outer_folder.exists(): + continue + + outer_folder /= "src" + for inner_folder in outer_folder.glob(plugin_pattern): + if not inner_folder.exists(): + continue + + plugins.append(inner_folder) + + return plugins + + +def _plugin_paths(plugin: Path) -> list[Path]: + """Collects paths to files with fixtures from `__init__.py` file.""" + paths = [] + + if plugin.name.endswith(".egg-info"): + return [] + + init_path = plugin / "__init__.py" + if not init_path.exists(): + print(f"Plugin '{plugin.name}' was skipped: '__init__.py' not found") + return [] + + with open(init_path, "r", encoding="utf-8") as init: + import_lines = [line.strip() for line in init.readlines() if line != "\n" and line.startswith("from")] + + for line in import_lines: + if path := parse.import_path(line): + paths.append(plugin / path) + + return paths + + def _all_depends(fixture: Fixture) -> set[Fixture]: """Get all dependencies of the passed fixture.""" @@ -240,7 +338,7 @@ def _all_depends(fixture: Fixture) -> set[Fixture]: return depends -def _only_parametirzed_depends(test: Test, fixtures: dict[tuple, Fixture]) -> set[Fixture]: +def _only_parametirzed_depends(test: Test, fixtures: dict[tuple, Fixture], plugin_paths: list[Path]) -> set[Fixture]: """Get only parameterized dependencies for the given test.""" depends = set() @@ -266,6 +364,13 @@ def _only_parametirzed_depends(test: Test, fixtures: dict[tuple, Fixture]) -> se is_conftest = current_path.name == "conftest.py" current_path = current_path.parent.parent / "conftest.py" if is_conftest else current_path.parent / "conftest.py" - # TODO: Finding fixture from plugins + # Finding fixture from plugins + for plugin_path in plugin_paths: + possible_fixture = (arg, plugin_path, None) + + if possible_fixture in fixtures.keys(): + fixture = fixtures[possible_fixture] + depends.update(depend for depend in _all_depends(fixture) if depend.parametrized) + break return depends diff --git a/src/allure_validator/common.py b/src/allure_validator/common.py index edc1952..71512d5 100644 --- a/src/allure_validator/common.py +++ b/src/allure_validator/common.py @@ -12,5 +12,7 @@ ATTR_CLASS_NAME = "__class_name" VALIDATE_DONE = 0 VALIDATE_ERROR = 1 DIRECTORY_ERROR = 2 +PLUGIN_ERROR = 3 +FILE_ERROR = 4 ALLURE_VALIDATOR_IGNORE = "noqa: allure-validator" diff --git a/src/allure_validator/main.py b/src/allure_validator/main.py index b86fce8..9f364c2 100644 --- a/src/allure_validator/main.py +++ b/src/allure_validator/main.py @@ -2,21 +2,33 @@ import sys from allure_validator import collect, validate from allure_validator.arguments import args -from allure_validator.common import DIRECTORY_ERROR, VALIDATE_DONE, VALIDATE_ERROR +from allure_validator.common import DIRECTORY_ERROR, FILE_ERROR, PLUGIN_ERROR, VALIDATE_DONE, VALIDATE_ERROR def main(): - tests_folder = args.folder - try: - paths = collect.paths(tests_folder) + paths = collect.paths(args.folder) + files = collect.files(args.files) + + plugins = [] + for pattern in args.plugins: + plugins.extend(collect.plugins(pattern, args.folder)) + + paths.extend(plugins) + paths.extend(files) except NotADirectoryError as e: print(e) sys.exit(DIRECTORY_ERROR) + except ModuleNotFoundError as e: + print(e) + sys.exit(PLUGIN_ERROR) + except FileNotFoundError as e: + print(e) + sys.exit(FILE_ERROR) ast_items = collect.ast_pytest_items(paths) - fixtures = collect.fixtures(ast_items.fixtures, ast_items.hooks) - tests = collect.tests(ast_items.tests, fixtures) + files = collect.fixtures(ast_items.fixtures, ast_items.hooks, plugins) + tests = collect.tests(ast_items.tests, files, plugins) validate_result = validate.titles(tests) if validate_result.errors == 0: diff --git a/src/allure_validator/parse.py b/src/allure_validator/parse.py index b3ed3d2..6b2a6ec 100644 --- a/src/allure_validator/parse.py +++ b/src/allure_validator/parse.py @@ -1,10 +1,21 @@ import ast import re +from pathlib import Path from allure_validator.common import ALLURE_TITLE, ALLURE_VALIDATOR_IGNORE, PYTEST_FIXTURE, PYTEST_PARAMETRIZE, REPORTER_TITLE from allure_validator.pytest_items import TestParams +def import_path(import_line: str) -> Path | None: + pattern = r"(?<=from )(?:\.[A-Za-z]+)+(?= )" + + if path := re.search(pattern, import_line): + path = path.group().lstrip(".").replace(".", "/") + return Path(f"{path}.py") + + return None + + def decorator(deco: ast.Call | ast.Attribute) -> str: """ Retrieves the decorator name as a string.