forked from TrueCloudLab/allure-validator
Compare commits
3 commits
dba27032e4
...
cc5b0180e7
Author | SHA1 | Date | |
---|---|---|---|
cc5b0180e7 | |||
65fab7f1c5 | |||
156c32a0cb |
7 changed files with 222 additions and 39 deletions
7
.pre-commit-hooks.yaml
Normal file
7
.pre-commit-hooks.yaml
Normal file
|
@ -0,0 +1,7 @@
|
|||
- id: allure-validator
|
||||
name: allure-validator
|
||||
entry: allure-validator
|
||||
language: python
|
||||
minimum_pre_commit_version: 2.20.0
|
||||
require_serial: true
|
||||
types_or: [python, pyi]
|
31
README.md
31
README.md
|
@ -13,22 +13,19 @@ 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 <folder>
|
||||
allure-validator <folder> [OPTIONS]
|
||||
```
|
||||
|
||||
Additionally, `allure-validator` can be added to pre-commit hooks:
|
||||
```yaml
|
||||
# .pre-commit-config.yaml
|
||||
repos:
|
||||
- repo: local
|
||||
- repo: https://git.frostfs.info/TrueCloudLab/allure-validator
|
||||
rev: latest
|
||||
hooks:
|
||||
- id: allure-validator
|
||||
name: allure-validator
|
||||
entry: allure-validator
|
||||
language: system
|
||||
args: ["pytest_tests/"] # folder with tests
|
||||
args: ["pytest_tests/"]
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
```
|
||||
|
||||
### Ignore mechanism
|
||||
|
@ -52,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` - 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):
|
||||
|
@ -96,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.
|
||||
```
|
|
@ -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 <folder path>",
|
||||
"%(prog)s <folder path> [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()
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
@ -133,21 +180,25 @@ def fixtures(ast_fixtures: list[ast.FunctionDef], ast_hooks: list[ast.FunctionDe
|
|||
|
||||
for ast_hook in ast_hooks:
|
||||
path = getattr(ast_hook, ATTR_PATH)
|
||||
for node in ast_hook.body:
|
||||
if isinstance(node, ast.Expr):
|
||||
if parse.decorator(node.value) == PYTEST_METAFUNC_PARAMETRIZE:
|
||||
fixture_names = parse.param_names(node.value.args[0])
|
||||
for name in fixture_names:
|
||||
# Dynamic fake fixture
|
||||
fixture = Fixture(
|
||||
name,
|
||||
path,
|
||||
node.lineno,
|
||||
node.col_offset,
|
||||
args=[],
|
||||
params=["parametrized"],
|
||||
)
|
||||
fixtures[fixture] = fixture
|
||||
for node in ast.walk(ast_hook):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
|
||||
if parse.decorator(node.value) != PYTEST_METAFUNC_PARAMETRIZE:
|
||||
continue
|
||||
|
||||
fixture_names = parse.param_names(node.value.args[0])
|
||||
for name in fixture_names:
|
||||
# Dynamic fake fixture
|
||||
fixture = Fixture(
|
||||
name,
|
||||
path,
|
||||
node.lineno,
|
||||
node.col_offset,
|
||||
args=[],
|
||||
params=["parametrized"],
|
||||
)
|
||||
fixtures[fixture] = fixture
|
||||
|
||||
for fixture in fixtures.values():
|
||||
for arg in fixture.args:
|
||||
|
@ -171,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.
|
||||
|
||||
|
@ -209,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
|
||||
|
@ -223,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."""
|
||||
|
||||
|
@ -236,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()
|
||||
|
@ -262,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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue