Add plugin support

The following flags have been added:
- `--plugins` - patterns that plugins must match.
- `--files` - paths to files with fixtures.

Signed-off-by: Kirill Sosnovskikh <k.sosnovskikh@yadro.com>
This commit is contained in:
k.sosnovskikh 2024-09-01 13:23:06 +03:00
parent 65fab7f1c5
commit cd41eb5d03
6 changed files with 193 additions and 18 deletions

View file

@ -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: To start, simply specify the folder with the test database that needs to be checked:
```shell ```shell
allure-validator <folder> allure-validator <folder> [OPTIONS]
``` ```
Additionally, `allure-validator` can be added to pre-commit hooks: 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. > 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 ## Work example
Let's run the linter for some [sample tests](https://git.frostfs.info/Kiriruso/allure-validator/src/branch/master/tests): 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 ```python
def pytest_generate_tests(metafunc): def pytest_generate_tests(metafunc):
... ...
metafunc.parametrize("fixture1, fixture2", [(1, 2), (3, 4)]) # OK 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. ```

View file

@ -1,10 +1,41 @@
import argparse 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( parser = argparse.ArgumentParser(
"pyfactoring", "pyfactoring",
"%(prog)s <folder path>", "%(prog)s <folder path> [OPTIONS]",
"Linter for allure.title validation", "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() args = parser.parse_args()

View file

@ -35,6 +35,53 @@ def paths(folder: str) -> list[Path]:
return paths 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: def ast_pytest_items(paths: list[Path]) -> ASTPytestItems:
""" """
Collects AST objects of pytest tests, fixtures and hooks. Collects AST objects of pytest tests, fixtures and hooks.
@ -97,7 +144,7 @@ def ast_pytest_items(paths: list[Path]) -> ASTPytestItems:
return items 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. 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" is_conftest = current_path.name == "conftest.py"
current_path = current_path.parent.parent / "conftest.py" if is_conftest else current_path.parent / "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 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. 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, 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} depends = {depend for depend in depends if depend.name not in params.optional}
# Test parameters take precedence over fixtures # Test parameters take precedence over fixtures
@ -227,6 +281,50 @@ def tests(ast_tests: list[ast.FunctionDef], fixtures: dict[tuple, Fixture]) -> l
return tests 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]: def _all_depends(fixture: Fixture) -> set[Fixture]:
"""Get all dependencies of the passed fixture.""" """Get all dependencies of the passed fixture."""
@ -240,7 +338,7 @@ def _all_depends(fixture: Fixture) -> set[Fixture]:
return depends 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.""" """Get only parameterized dependencies for the given test."""
depends = set() depends = set()
@ -266,6 +364,13 @@ def _only_parametirzed_depends(test: Test, fixtures: dict[tuple, Fixture]) -> se
is_conftest = current_path.name == "conftest.py" is_conftest = current_path.name == "conftest.py"
current_path = current_path.parent.parent / "conftest.py" if is_conftest else current_path.parent / "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 return depends

View file

@ -12,5 +12,7 @@ ATTR_CLASS_NAME = "__class_name"
VALIDATE_DONE = 0 VALIDATE_DONE = 0
VALIDATE_ERROR = 1 VALIDATE_ERROR = 1
DIRECTORY_ERROR = 2 DIRECTORY_ERROR = 2
PLUGIN_ERROR = 3
FILE_ERROR = 4
ALLURE_VALIDATOR_IGNORE = "noqa: allure-validator" ALLURE_VALIDATOR_IGNORE = "noqa: allure-validator"

View file

@ -2,21 +2,33 @@ import sys
from allure_validator import collect, validate from allure_validator import collect, validate
from allure_validator.arguments import args 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(): def main():
tests_folder = args.folder
try: 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: except NotADirectoryError as e:
print(e) print(e)
sys.exit(DIRECTORY_ERROR) 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) ast_items = collect.ast_pytest_items(paths)
fixtures = collect.fixtures(ast_items.fixtures, ast_items.hooks) files = collect.fixtures(ast_items.fixtures, ast_items.hooks, plugins)
tests = collect.tests(ast_items.tests, fixtures) tests = collect.tests(ast_items.tests, files, plugins)
validate_result = validate.titles(tests) validate_result = validate.titles(tests)
if validate_result.errors == 0: if validate_result.errors == 0:

View file

@ -1,10 +1,21 @@
import ast import ast
import re 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.common import ALLURE_TITLE, ALLURE_VALIDATOR_IGNORE, PYTEST_FIXTURE, PYTEST_PARAMETRIZE, REPORTER_TITLE
from allure_validator.pytest_items import TestParams 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: def decorator(deco: ast.Call | ast.Attribute) -> str:
""" """
Retrieves the decorator name as a string. Retrieves the decorator name as a string.