pytest-eo 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_eo/__init__.py +1 -0
- pytest_eo/angry_tests.py +44 -0
- pytest_eo/assertion.py +30 -0
- pytest_eo/assigned.py +32 -0
- pytest_eo/blank_lines.py +42 -0
- pytest_eo/bodies.py +36 -0
- pytest_eo/bundled.py +20 -0
- pytest_eo/callee.py +23 -0
- pytest_eo/capacity.py +50 -0
- pytest_eo/check.py +17 -0
- pytest_eo/command.py +13 -0
- pytest_eo/comment.py +42 -0
- pytest_eo/comments.py +42 -0
- pytest_eo/config/flake8.ini +23 -0
- pytest_eo/config/mypy.ini +5 -0
- pytest_eo/config/ruff.toml +43 -0
- pytest_eo/edges.py +28 -0
- pytest_eo/encapsulation.py +30 -0
- pytest_eo/ending.py +30 -0
- pytest_eo/eo_item.py +41 -0
- pytest_eo/eo_table.py +29 -0
- pytest_eo/excludes.py +25 -0
- pytest_eo/extension.py +12 -0
- pytest_eo/fields.py +40 -0
- pytest_eo/files.py +12 -0
- pytest_eo/final_assertion.py +46 -0
- pytest_eo/flake_check.py +49 -0
- pytest_eo/heading.py +35 -0
- pytest_eo/inline_comments.py +44 -0
- pytest_eo/layout.py +15 -0
- pytest_eo/margin.py +42 -0
- pytest_eo/message.py +60 -0
- pytest_eo/module_constants.py +32 -0
- pytest_eo/mypy_check.py +43 -0
- pytest_eo/narrow_suppressions.py +72 -0
- pytest_eo/negative_messages.py +40 -0
- pytest_eo/output.py +37 -0
- pytest_eo/owned.py +28 -0
- pytest_eo/place.py +21 -0
- pytest_eo/plugin.py +45 -0
- pytest_eo/printout.py +18 -0
- pytest_eo/profile.py +13 -0
- pytest_eo/project.py +37 -0
- pytest_eo/project_files.py +39 -0
- pytest_eo/python_module.py +36 -0
- pytest_eo/raising.py +27 -0
- pytest_eo/remark.py +25 -0
- pytest_eo/ruff_check.py +50 -0
- pytest_eo/ruff_format_check.py +46 -0
- pytest_eo/rule.py +12 -0
- pytest_eo/shared.py +35 -0
- pytest_eo/span.py +28 -0
- pytest_eo/strict.py +112 -0
- pytest_eo/style_violations_error.py +15 -0
- pytest_eo/suppression_check.py +44 -0
- pytest_eo/table.py +11 -0
- pytest_eo/text.py +28 -0
- pytest_eo/tool_failed_error.py +15 -0
- pytest_eo/trial.py +37 -0
- pytest_eo/verified.py +28 -0
- pytest_eo/violation.py +24 -0
- pytest_eo/wide_suppressions.py +45 -0
- pytest_eo-0.5.1.dist-info/METADATA +177 -0
- pytest_eo-0.5.1.dist-info/RECORD +67 -0
- pytest_eo-0.5.1.dist-info/WHEEL +4 -0
- pytest_eo-0.5.1.dist-info/entry_points.txt +7 -0
- pytest_eo-0.5.1.dist-info/licenses/LICENSE.txt +21 -0
pytest_eo/plugin.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The pytest plugin: ``pytest --eo`` adds one test item per aggregated tool."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from pytest_eo.eo_item import EoItem
|
|
8
|
+
from pytest_eo.eo_table import EoTable
|
|
9
|
+
from pytest_eo.project import Project
|
|
10
|
+
from pytest_eo.strict import Strict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
14
|
+
"""Register ``--eo``."""
|
|
15
|
+
parser.getgroup("eo").addoption(
|
|
16
|
+
"--eo",
|
|
17
|
+
action="store_true",
|
|
18
|
+
default=False,
|
|
19
|
+
help=(
|
|
20
|
+
"also run the strict style gate (ruff, ruff format, mypy --strict)"
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pytest_collection_modifyitems(
|
|
26
|
+
session: pytest.Session,
|
|
27
|
+
config: pytest.Config,
|
|
28
|
+
items: list[pytest.Item],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Append one item per tool when ``--eo`` is given."""
|
|
31
|
+
if not config.getoption("--eo"):
|
|
32
|
+
return
|
|
33
|
+
root = Path(config.rootpath)
|
|
34
|
+
items.extend(
|
|
35
|
+
EoItem.from_parent(
|
|
36
|
+
session,
|
|
37
|
+
name=check.name(),
|
|
38
|
+
nodeid=f"eo::{check.name()}",
|
|
39
|
+
check=check,
|
|
40
|
+
)
|
|
41
|
+
for check in Strict(
|
|
42
|
+
Project(EoTable(root / "pyproject.toml"), root),
|
|
43
|
+
root,
|
|
44
|
+
).checks()
|
|
45
|
+
)
|
pytest_eo/printout.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""What a finished tool left behind, as the checks see it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from pytest_eo.tool_failed_error import ToolFailedError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Printout(Iterable[str], Protocol):
|
|
10
|
+
"""The lines a finished tool printed, and how it exited."""
|
|
11
|
+
|
|
12
|
+
def exited(self, statuses: tuple[int, ...]) -> bool:
|
|
13
|
+
"""Tell whether the tool exited with one of the statuses."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def failure(self, reason: str) -> ToolFailedError:
|
|
17
|
+
"""Build the error of a tool that failed, with what it complained."""
|
|
18
|
+
...
|
pytest_eo/profile.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""A set of checks to run over a project."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pytest_eo.check import Check
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Profile(Protocol):
|
|
9
|
+
"""A set of checks to run over a project."""
|
|
10
|
+
|
|
11
|
+
def checks(self) -> tuple[Check, ...]:
|
|
12
|
+
"""Build the checks; nothing runs until ``violations()`` is called."""
|
|
13
|
+
...
|
pytest_eo/project.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The project under check and its settings."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.layout import Layout
|
|
10
|
+
from pytest_eo.table import Table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Project(Layout):
|
|
16
|
+
"""A directory checked as its settings say, or as its layout suggests."""
|
|
17
|
+
|
|
18
|
+
_table: Table
|
|
19
|
+
_root: Path
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def paths(self) -> tuple[str, ...]:
|
|
23
|
+
"""Directories to check: the configured ones or existing src/tests."""
|
|
24
|
+
return (
|
|
25
|
+
self._table.strings("paths")
|
|
26
|
+
or tuple(
|
|
27
|
+
name
|
|
28
|
+
for name in ("src", "tests")
|
|
29
|
+
if (self._root / name).is_dir()
|
|
30
|
+
)
|
|
31
|
+
or (".",)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def excludes(self) -> tuple[str, ...]:
|
|
36
|
+
"""Glob patterns to skip, as configured."""
|
|
37
|
+
return self._table.strings("exclude")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Python files under the checked paths of a project."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.excludes import Excludes
|
|
10
|
+
from pytest_eo.files import Files
|
|
11
|
+
from pytest_eo.layout import Layout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ProjectFiles(Files):
|
|
17
|
+
"""Python files under the checked paths of a layout, minus its excludes."""
|
|
18
|
+
|
|
19
|
+
_layout: Layout
|
|
20
|
+
_root: Path
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def files(self) -> tuple[Path, ...]:
|
|
24
|
+
"""Sorted ``*.py`` files under the checked paths, minus excludes."""
|
|
25
|
+
found = (
|
|
26
|
+
file
|
|
27
|
+
for path in self._layout.paths()
|
|
28
|
+
for file in (self._root / path).rglob("*.py")
|
|
29
|
+
)
|
|
30
|
+
excludes = Excludes(self._layout.excludes())
|
|
31
|
+
return tuple(
|
|
32
|
+
sorted(
|
|
33
|
+
{
|
|
34
|
+
file
|
|
35
|
+
for file in found
|
|
36
|
+
if file.relative_to(self._root).as_posix() not in excludes
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Running a Python module: the only place that touches subprocesses."""
|
|
2
|
+
|
|
3
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] - the only module that runs external tools
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from pytest_eo.command import Command
|
|
12
|
+
from pytest_eo.output import Output
|
|
13
|
+
from pytest_eo.printout import Printout
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class PythonModule(Command):
|
|
19
|
+
"""``python -m <module> <args>`` in the interpreter that runs pytest."""
|
|
20
|
+
|
|
21
|
+
_module: str
|
|
22
|
+
_args: tuple[str, ...]
|
|
23
|
+
_cwd: Path
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def run(self) -> Printout:
|
|
27
|
+
"""Run the module and capture its output."""
|
|
28
|
+
argv = (sys.executable, "-m", self._module, *self._args)
|
|
29
|
+
done = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - argv is built from fixed tool names
|
|
30
|
+
argv,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
check=False,
|
|
34
|
+
cwd=self._cwd,
|
|
35
|
+
)
|
|
36
|
+
return Output(done.stdout, done.stderr, done.returncode)
|
pytest_eo/raising.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Whether a statement expects an exception."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.callee import Callee
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Raising(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""Whether a statement is a block under ``pytest.raises`` alone."""
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def generic_visit(self, node: ast.AST) -> bool:
|
|
19
|
+
"""Expect no exception from any other statement."""
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def visit_With(self, node: ast.With) -> bool:
|
|
24
|
+
"""Expect an exception from a block under ``pytest.raises`` alone."""
|
|
25
|
+
return tuple(
|
|
26
|
+
Callee().visit(item.context_expr) for item in node.items
|
|
27
|
+
) == ("pytest.raises",)
|
pytest_eo/remark.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""A comment of a source file, as the suppression checks see one."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from pytest_eo.place import Place
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Remark(Protocol):
|
|
11
|
+
"""A comment that finds patterns in its own text."""
|
|
12
|
+
|
|
13
|
+
def matches(
|
|
14
|
+
self,
|
|
15
|
+
pattern: re.Pattern[str],
|
|
16
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
17
|
+
"""Yield every match of the pattern, with where it stands."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def heading(
|
|
21
|
+
self,
|
|
22
|
+
pattern: re.Pattern[str],
|
|
23
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
24
|
+
"""Yield the matches only when the comment stands above all code."""
|
|
25
|
+
...
|
pytest_eo/ruff_check.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``ruff check`` as a source of violations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.check import Check
|
|
10
|
+
from pytest_eo.command import Command
|
|
11
|
+
from pytest_eo.place import Place
|
|
12
|
+
from pytest_eo.tool_failed_error import ToolFailedError
|
|
13
|
+
from pytest_eo.verified import Verified
|
|
14
|
+
from pytest_eo.violation import Violation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class RuffCheck(Check):
|
|
20
|
+
"""``ruff check`` with the locked configuration."""
|
|
21
|
+
|
|
22
|
+
_command: Command
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
"""Short name shown in the pytest report."""
|
|
27
|
+
return "ruff"
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
31
|
+
"""Parse the JSON report of ``ruff check``."""
|
|
32
|
+
output = Verified(self._command, self.name(), (0, 1)).run()
|
|
33
|
+
try:
|
|
34
|
+
items = json.loads("\n".join(output) or "[]")
|
|
35
|
+
except json.JSONDecodeError as error:
|
|
36
|
+
message = f"ruff printed invalid JSON: {error}"
|
|
37
|
+
raise ToolFailedError(message) from error
|
|
38
|
+
return tuple(
|
|
39
|
+
Violation(
|
|
40
|
+
self.name(),
|
|
41
|
+
item["code"] or "syntax",
|
|
42
|
+
Place(
|
|
43
|
+
item["filename"],
|
|
44
|
+
item["location"]["row"],
|
|
45
|
+
item["location"]["column"],
|
|
46
|
+
),
|
|
47
|
+
item["message"],
|
|
48
|
+
)
|
|
49
|
+
for item in items
|
|
50
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``ruff format --check`` as a source of violations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.check import Check
|
|
10
|
+
from pytest_eo.command import Command
|
|
11
|
+
from pytest_eo.place import Place
|
|
12
|
+
from pytest_eo.verified import Verified
|
|
13
|
+
from pytest_eo.violation import Violation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class RuffFormatCheck(Check):
|
|
19
|
+
"""``ruff format --check``: files that the formatter would change."""
|
|
20
|
+
|
|
21
|
+
_command: Command
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
"""Short name shown in the pytest report."""
|
|
26
|
+
return "ruff-format"
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
30
|
+
"""Parse the concise report of ``ruff format --check``."""
|
|
31
|
+
output = Verified(self._command, self.name(), (0, 1)).run()
|
|
32
|
+
pattern = re.compile(
|
|
33
|
+
r"^(?P<path>.+?):(?P<line>\d+):(?P<col>\d+): "
|
|
34
|
+
r"(?P<code>\w+): (?P<msg>.+)$"
|
|
35
|
+
)
|
|
36
|
+
found = (pattern.match(line) for line in output)
|
|
37
|
+
return tuple(
|
|
38
|
+
Violation(
|
|
39
|
+
self.name(),
|
|
40
|
+
match["code"],
|
|
41
|
+
Place(match["path"], int(match["line"]), int(match["col"])),
|
|
42
|
+
match["msg"],
|
|
43
|
+
)
|
|
44
|
+
for match in found
|
|
45
|
+
if match
|
|
46
|
+
)
|
pytest_eo/rule.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A rule over one parsed module, as this package sees one."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Rule(Protocol):
|
|
8
|
+
"""One rule, such as one of Angry Tests, over a parsed module."""
|
|
9
|
+
|
|
10
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
11
|
+
"""Yield the line, column and message of every break of the rule."""
|
|
12
|
+
...
|
pytest_eo/shared.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Whether a statement of a module shares a value between its tests."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Shared(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
13
|
+
"""Whether a statement assigns a module value besides ``pytest_plugins``."""
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
def generic_visit(self, node: ast.AST) -> bool:
|
|
17
|
+
"""Share nothing through a statement that assigns nothing."""
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def visit_Assign(self, node: ast.Assign) -> bool:
|
|
22
|
+
"""Share the value unless it is the list of plugins."""
|
|
23
|
+
return tuple(ast.unparse(target) for target in node.targets) != (
|
|
24
|
+
"pytest_plugins",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> bool:
|
|
29
|
+
"""Share the annotated value unless it is the list of plugins."""
|
|
30
|
+
return ast.unparse(node.target) != "pytest_plugins"
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> bool:
|
|
34
|
+
"""Share the value that a module changes in place."""
|
|
35
|
+
return True
|
pytest_eo/span.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The rows that a function takes in its file."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Span(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
13
|
+
"""The rows of a function, from ``def`` to its last line, and none else."""
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
def generic_visit(self, node: ast.AST) -> range:
|
|
17
|
+
"""Span no rows of a node that isnt a function."""
|
|
18
|
+
return range(0)
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> range:
|
|
22
|
+
"""Span the rows of a function."""
|
|
23
|
+
return range(node.lineno, (node.end_lineno or node.lineno) + 1)
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> range:
|
|
27
|
+
"""Span the rows of a coroutine."""
|
|
28
|
+
return range(node.lineno, (node.end_lineno or node.lineno) + 1)
|
pytest_eo/strict.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The locked profile: which tools run, with which bundled configuration."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.bundled import Bundled
|
|
10
|
+
from pytest_eo.check import Check
|
|
11
|
+
from pytest_eo.flake_check import FlakeCheck
|
|
12
|
+
from pytest_eo.layout import Layout
|
|
13
|
+
from pytest_eo.mypy_check import MypyCheck
|
|
14
|
+
from pytest_eo.profile import Profile
|
|
15
|
+
from pytest_eo.project_files import ProjectFiles
|
|
16
|
+
from pytest_eo.python_module import PythonModule
|
|
17
|
+
from pytest_eo.ruff_check import RuffCheck
|
|
18
|
+
from pytest_eo.ruff_format_check import RuffFormatCheck
|
|
19
|
+
from pytest_eo.suppression_check import SuppressionCheck
|
|
20
|
+
|
|
21
|
+
# The rule a suppression may never name, by code or by name: the ban on mocks.
|
|
22
|
+
UNSUPPRESSIBLE = ("TID251", "banned-api")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Strict(Profile):
|
|
28
|
+
"""ruff, ruff format, ``mypy --strict``, pyeo and suppressions."""
|
|
29
|
+
|
|
30
|
+
_layout: Layout
|
|
31
|
+
_root: Path
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def checks(self) -> tuple[Check, ...]:
|
|
35
|
+
"""Build the checks; nothing runs until ``violations()`` is called."""
|
|
36
|
+
root = self._root
|
|
37
|
+
paths = self._layout.paths()
|
|
38
|
+
skipped = tuple(
|
|
39
|
+
arg
|
|
40
|
+
for pattern in self._layout.excludes()
|
|
41
|
+
for arg in ("--extend-exclude", pattern)
|
|
42
|
+
)
|
|
43
|
+
ruff_config = ("--config", str(Bundled("ruff.toml")), *skipped)
|
|
44
|
+
flake_skipped = (
|
|
45
|
+
("--extend-exclude", ",".join(self._layout.excludes()))
|
|
46
|
+
if self._layout.excludes()
|
|
47
|
+
else ()
|
|
48
|
+
)
|
|
49
|
+
mypy_skipped = tuple(
|
|
50
|
+
arg
|
|
51
|
+
for pattern in self._layout.excludes()
|
|
52
|
+
for arg in ("--exclude", pattern)
|
|
53
|
+
)
|
|
54
|
+
return (
|
|
55
|
+
RuffCheck(
|
|
56
|
+
PythonModule(
|
|
57
|
+
"ruff",
|
|
58
|
+
(
|
|
59
|
+
"check",
|
|
60
|
+
"--output-format",
|
|
61
|
+
"json",
|
|
62
|
+
"--no-cache",
|
|
63
|
+
*ruff_config,
|
|
64
|
+
*paths,
|
|
65
|
+
),
|
|
66
|
+
root,
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
RuffFormatCheck(
|
|
70
|
+
PythonModule(
|
|
71
|
+
"ruff",
|
|
72
|
+
(
|
|
73
|
+
"format",
|
|
74
|
+
"--check",
|
|
75
|
+
"--output-format",
|
|
76
|
+
"concise",
|
|
77
|
+
"--no-cache",
|
|
78
|
+
*ruff_config,
|
|
79
|
+
*paths,
|
|
80
|
+
),
|
|
81
|
+
root,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
MypyCheck(
|
|
85
|
+
PythonModule(
|
|
86
|
+
"mypy",
|
|
87
|
+
(
|
|
88
|
+
"--strict",
|
|
89
|
+
"--config-file",
|
|
90
|
+
str(Bundled("mypy.ini")),
|
|
91
|
+
"--output",
|
|
92
|
+
"json",
|
|
93
|
+
*mypy_skipped,
|
|
94
|
+
*paths,
|
|
95
|
+
),
|
|
96
|
+
root,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
FlakeCheck(
|
|
100
|
+
PythonModule(
|
|
101
|
+
"flake8",
|
|
102
|
+
(
|
|
103
|
+
"--config",
|
|
104
|
+
str(Bundled("flake8.ini")),
|
|
105
|
+
*flake_skipped,
|
|
106
|
+
*paths,
|
|
107
|
+
),
|
|
108
|
+
root,
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
SuppressionCheck(ProjectFiles(self._layout, root), UNSUPPRESSIBLE),
|
|
112
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The error an item raises when its tool found violations."""
|
|
2
|
+
|
|
3
|
+
from typing import final
|
|
4
|
+
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@final
|
|
9
|
+
class StyleViolationsError(Exception): # noqa: ENC100 - an exception keeps its message in args
|
|
10
|
+
"""Raised by an item when its tool found violations; its args are them."""
|
|
11
|
+
|
|
12
|
+
@override
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
"""Show one violation per line."""
|
|
15
|
+
return "\n".join(str(item) for item in self.args)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Suppression comments: each one gives a reason, and some codes allow none."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import final
|
|
5
|
+
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from pytest_eo.check import Check
|
|
9
|
+
from pytest_eo.comments import Comments
|
|
10
|
+
from pytest_eo.files import Files
|
|
11
|
+
from pytest_eo.narrow_suppressions import NarrowSuppressions
|
|
12
|
+
from pytest_eo.violation import Violation
|
|
13
|
+
from pytest_eo.wide_suppressions import WideSuppressions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class SuppressionCheck(Check):
|
|
19
|
+
"""Suppressions without a reason, of a forbidden rule, or wider than a line.
|
|
20
|
+
|
|
21
|
+
It reads ``# noqa: CODE`` and ``# ruff: ignore[CODE-OR-NAME]``, and
|
|
22
|
+
refuses the file-wide and range forms of ruff, flake8 and mypy outright.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_sources: Files
|
|
26
|
+
_forbidden: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""Short name shown in the pytest report."""
|
|
31
|
+
return "suppressions"
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
35
|
+
"""Read the comments of every file, never running a tool."""
|
|
36
|
+
return tuple(
|
|
37
|
+
found
|
|
38
|
+
for file in self._sources.files()
|
|
39
|
+
for comment in Comments(file)
|
|
40
|
+
for found in (
|
|
41
|
+
*NarrowSuppressions(self.name(), comment, self._forbidden),
|
|
42
|
+
*WideSuppressions(self.name(), comment),
|
|
43
|
+
)
|
|
44
|
+
)
|
pytest_eo/table.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Settings of the gate, as this package sees them."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Table(Protocol):
|
|
7
|
+
"""Settings a project gives the gate, by key."""
|
|
8
|
+
|
|
9
|
+
def strings(self, key: str) -> tuple[str, ...]:
|
|
10
|
+
"""Return the strings listed under the key, or none without them."""
|
|
11
|
+
...
|
pytest_eo/text.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The words of a message as the source spells them."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Text(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
13
|
+
"""The literal words of an expression, and nothing for computed ones."""
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
def generic_visit(self, node: ast.AST) -> str:
|
|
17
|
+
"""Read nothing from a value only known at run time."""
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def visit_Constant(self, node: ast.Constant) -> str:
|
|
22
|
+
"""Read a literal as it is written."""
|
|
23
|
+
return str(node.value)
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def visit_JoinedStr(self, node: ast.JoinedStr) -> str:
|
|
27
|
+
"""Read the literal parts of a formatted string."""
|
|
28
|
+
return " ".join(self.visit(part) for part in node.values)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The error of a tool that crashed or printed nonsense."""
|
|
2
|
+
|
|
3
|
+
from typing import final
|
|
4
|
+
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@final
|
|
9
|
+
class ToolFailedError(RuntimeError): # noqa: ENC100 - an exception keeps its message in args
|
|
10
|
+
"""A tool crashed or printed something we cannot understand."""
|
|
11
|
+
|
|
12
|
+
@override
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
"""Show the message as a failure of the tool, not of the code."""
|
|
15
|
+
return f"tool failure: {super().__str__()}"
|