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/extension.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""A flake8 plugin, as this package sees one."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Extension(Protocol):
|
|
8
|
+
"""A flake8 plugin: the problems of one parsed file."""
|
|
9
|
+
|
|
10
|
+
def run(self) -> Iterator[tuple[int, int, str, type[object]]]:
|
|
11
|
+
"""Yield the line, column, message and plugin of every problem."""
|
|
12
|
+
...
|
pytest_eo/fields.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The attributes that one statement of a class body declares."""
|
|
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.assigned import Assigned
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Fields(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""Names of the instance attributes a statement of a class body declares.
|
|
16
|
+
|
|
17
|
+
An annotated name other than a ``ClassVar`` is a field, as dataclasses
|
|
18
|
+
read it, and so is every attribute of self that ``__init__`` sets.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def generic_visit(self, node: ast.AST) -> tuple[str, ...]:
|
|
23
|
+
"""Find no field in any other statement."""
|
|
24
|
+
return ()
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> tuple[str, ...]:
|
|
28
|
+
"""Name an annotated field, unless it belongs to the class itself."""
|
|
29
|
+
if ast.unparse(node.annotation).split("[")[0].endswith("ClassVar"):
|
|
30
|
+
return ()
|
|
31
|
+
return (ast.unparse(node.target),)
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> tuple[str, ...]:
|
|
35
|
+
"""Name the attributes that the constructor sets, at any depth."""
|
|
36
|
+
if node.name != "__init__":
|
|
37
|
+
return ()
|
|
38
|
+
return tuple(
|
|
39
|
+
name for inner in ast.walk(node) for name in Assigned().visit(inner)
|
|
40
|
+
)
|
pytest_eo/files.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Python files that the gate reads itself."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Files(Protocol):
|
|
8
|
+
"""Python files of a project that the gate reads itself."""
|
|
9
|
+
|
|
10
|
+
def files(self) -> tuple[Path, ...]:
|
|
11
|
+
"""Every file to read, in a stable order."""
|
|
12
|
+
...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""TST100: a test ends with its only assertion."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from pytest_eo.assertion import Assertion
|
|
11
|
+
from pytest_eo.ending import Ending
|
|
12
|
+
from pytest_eo.raising import Raising
|
|
13
|
+
from pytest_eo.rule import Rule
|
|
14
|
+
from pytest_eo.trial import Trial
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class FinalAssertion(Rule):
|
|
20
|
+
"""Tests that end with their only assertion, or with ``pytest.raises``.
|
|
21
|
+
|
|
22
|
+
A test that ends with ``pytest.raises`` makes its assertion there, so it
|
|
23
|
+
may hold no other one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_tree: ast.Module
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
30
|
+
"""Report every test that doesnt end with its only assertion."""
|
|
31
|
+
for test in (
|
|
32
|
+
found
|
|
33
|
+
for node in ast.walk(self._tree)
|
|
34
|
+
for found in Trial().visit(node)
|
|
35
|
+
):
|
|
36
|
+
count = sum(Assertion().visit(inner) for inner in ast.walk(test))
|
|
37
|
+
last = test.body[-1]
|
|
38
|
+
if not (
|
|
39
|
+
(count == 1 and Ending().visit(last))
|
|
40
|
+
or (count == 0 and Raising().visit(last))
|
|
41
|
+
):
|
|
42
|
+
yield (
|
|
43
|
+
test.lineno,
|
|
44
|
+
test.col_offset,
|
|
45
|
+
"TST100 test must end with its only assertion",
|
|
46
|
+
)
|
pytest_eo/flake_check.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``flake8`` with pyeo 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 FlakeCheck(Check):
|
|
19
|
+
"""``flake8`` with pyeo: rules of Elegant Objects."""
|
|
20
|
+
|
|
21
|
+
_command: Command
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
"""Short name shown in the pytest report."""
|
|
26
|
+
return "flake8"
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
30
|
+
"""Parse the default ``path:row:col: CODE text`` report of flake8."""
|
|
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>[A-Z]+\d+) (?P<msg>.+)$"
|
|
35
|
+
)
|
|
36
|
+
found = tuple(
|
|
37
|
+
Violation(
|
|
38
|
+
self.name(),
|
|
39
|
+
match["code"],
|
|
40
|
+
Place(match["path"], int(match["line"]), int(match["col"])),
|
|
41
|
+
match["msg"],
|
|
42
|
+
)
|
|
43
|
+
for match in (pattern.match(line) for line in output)
|
|
44
|
+
if match
|
|
45
|
+
)
|
|
46
|
+
if not found and output.exited((1,)):
|
|
47
|
+
reason = "flake8 failed without a report"
|
|
48
|
+
raise output.failure(reason)
|
|
49
|
+
return found
|
pytest_eo/heading.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A comment that stands above the first line of code."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from pytest_eo.place import Place
|
|
11
|
+
from pytest_eo.remark import Remark
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Heading(Remark):
|
|
17
|
+
"""A comment above all code, where some forms reach the whole file."""
|
|
18
|
+
|
|
19
|
+
_origin: Remark
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def matches(
|
|
23
|
+
self,
|
|
24
|
+
pattern: re.Pattern[str],
|
|
25
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
26
|
+
"""Yield the matches of the comment itself."""
|
|
27
|
+
return self._origin.matches(pattern)
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def heading(
|
|
31
|
+
self,
|
|
32
|
+
pattern: re.Pattern[str],
|
|
33
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
34
|
+
"""Yield the same matches, since this comment heads the file."""
|
|
35
|
+
return self._origin.matches(pattern)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""BOD200: a function body holds no comments."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import tokenize
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import final
|
|
9
|
+
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from pytest_eo.rule import Rule
|
|
13
|
+
from pytest_eo.span import Span
|
|
14
|
+
|
|
15
|
+
_SUPPRESSION = re.compile(
|
|
16
|
+
r"#\s*(?:noqa\b|type:\s*ignore\b|ruff:|flake8:|mypy:)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@final
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class InlineComments(Rule):
|
|
23
|
+
"""Comments inside the functions of a module.
|
|
24
|
+
|
|
25
|
+
A suppression is a comment too, but ``eo::suppressions`` governs it, so
|
|
26
|
+
it doesnt count.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_tree: ast.Module
|
|
30
|
+
_tokens: Sequence[tokenize.TokenInfo]
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
34
|
+
"""Report every comment from the first to the last row of a body."""
|
|
35
|
+
inside = {
|
|
36
|
+
row for node in ast.walk(self._tree) for row in Span().visit(node)
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
(*token.start, "BOD200 function body must not hold comments")
|
|
40
|
+
for token in self._tokens
|
|
41
|
+
if token.type == tokenize.COMMENT
|
|
42
|
+
and token.start[0] in inside
|
|
43
|
+
and not _SUPPRESSION.match(token.string)
|
|
44
|
+
)
|
pytest_eo/layout.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Which directories of a project the gate checks."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Layout(Protocol):
|
|
7
|
+
"""Which directories of a project to check and which to skip."""
|
|
8
|
+
|
|
9
|
+
def paths(self) -> tuple[str, ...]:
|
|
10
|
+
"""Directories to check, relative to the project root."""
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def excludes(self) -> tuple[str, ...]:
|
|
14
|
+
"""Glob patterns to skip."""
|
|
15
|
+
...
|
pytest_eo/margin.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The rows right around a definition, where ruff format wants blank lines."""
|
|
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.edges import Edges
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Margin(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""The rows that border a ``def`` or a ``class``, and none for others.
|
|
16
|
+
|
|
17
|
+
``ruff format`` puts a blank line on both sides of a nested ``def`` or
|
|
18
|
+
``class``, so a body may hold blank lines there and nowhere else.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def generic_visit(self, node: ast.AST) -> tuple[int, ...]:
|
|
23
|
+
"""Find no margins around a node that defines nothing."""
|
|
24
|
+
return ()
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> tuple[int, ...]:
|
|
28
|
+
"""Find the rows around a function."""
|
|
29
|
+
return tuple(Edges(node, node.decorator_list))
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def visit_AsyncFunctionDef(
|
|
33
|
+
self,
|
|
34
|
+
node: ast.AsyncFunctionDef,
|
|
35
|
+
) -> tuple[int, ...]:
|
|
36
|
+
"""Find the rows around a coroutine."""
|
|
37
|
+
return tuple(Edges(node, node.decorator_list))
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> tuple[int, ...]:
|
|
41
|
+
"""Find the rows around a class."""
|
|
42
|
+
return tuple(Edges(node, node.decorator_list))
|
pytest_eo/message.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""What an assertion says when it fails."""
|
|
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.assertion import Assertion
|
|
10
|
+
from pytest_eo.text import Text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Message(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
16
|
+
"""The place and words of a failure message, and nothing for other nodes.
|
|
17
|
+
|
|
18
|
+
A plain ``assert`` speaks through its message; ``assert_that`` speaks
|
|
19
|
+
through its ``reason`` keyword or else its last argument.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def generic_visit(self, node: ast.AST) -> tuple[tuple[int, int, str], ...]:
|
|
24
|
+
"""Find no message in a node that asserts nothing."""
|
|
25
|
+
return ()
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def visit_Assert(
|
|
29
|
+
self, node: ast.Assert
|
|
30
|
+
) -> tuple[tuple[int, int, str], ...]:
|
|
31
|
+
"""Read the message of a plain assert, empty when it has none."""
|
|
32
|
+
return (
|
|
33
|
+
(
|
|
34
|
+
node.lineno,
|
|
35
|
+
node.col_offset,
|
|
36
|
+
Text().visit(node.msg or ast.Constant("")),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def visit_Call(self, node: ast.Call) -> tuple[tuple[int, int, str], ...]:
|
|
42
|
+
"""Read the reason of a matcher, given by keyword or last."""
|
|
43
|
+
if not (node.args and Assertion().visit(node)):
|
|
44
|
+
return ()
|
|
45
|
+
return (
|
|
46
|
+
(
|
|
47
|
+
node.lineno,
|
|
48
|
+
node.col_offset,
|
|
49
|
+
Text().visit(
|
|
50
|
+
next(
|
|
51
|
+
(
|
|
52
|
+
word.value
|
|
53
|
+
for word in node.keywords
|
|
54
|
+
if word.arg == "reason"
|
|
55
|
+
),
|
|
56
|
+
node.args[-1],
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""TST200: a test module shares no constants between its tests."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from pytest_eo.rule import Rule
|
|
11
|
+
from pytest_eo.shared import Shared
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ModuleConstants(Rule):
|
|
17
|
+
"""Values a test module assigns for its tests to share."""
|
|
18
|
+
|
|
19
|
+
_tree: ast.Module
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
23
|
+
"""Report every module statement that shares a value."""
|
|
24
|
+
return (
|
|
25
|
+
(
|
|
26
|
+
statement.lineno,
|
|
27
|
+
statement.col_offset,
|
|
28
|
+
"TST200 test module must not share constants between its tests",
|
|
29
|
+
)
|
|
30
|
+
for statement in self._tree.body
|
|
31
|
+
if Shared().visit(statement)
|
|
32
|
+
)
|
pytest_eo/mypy_check.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``mypy --strict`` 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.verified import Verified
|
|
13
|
+
from pytest_eo.violation import Violation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class MypyCheck(Check):
|
|
19
|
+
"""``mypy --strict`` with JSON output."""
|
|
20
|
+
|
|
21
|
+
_command: Command
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
"""Short name shown in the pytest report."""
|
|
26
|
+
return "mypy"
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
30
|
+
"""Parse one JSON object per line, skipping notes."""
|
|
31
|
+
output = Verified(self._command, self.name(), (0, 1)).run()
|
|
32
|
+
lines = (line for line in output if line.startswith("{"))
|
|
33
|
+
items = (json.loads(line) for line in lines)
|
|
34
|
+
return tuple(
|
|
35
|
+
Violation(
|
|
36
|
+
self.name(),
|
|
37
|
+
item["code"] or "error",
|
|
38
|
+
Place(item["file"], item["line"], item["column"]),
|
|
39
|
+
item["message"],
|
|
40
|
+
)
|
|
41
|
+
for item in items
|
|
42
|
+
if item["severity"] == "error"
|
|
43
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The suppressions of one line, and what is wrong with them."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable, Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from pytest_eo.remark import Remark
|
|
11
|
+
from pytest_eo.violation import Violation
|
|
12
|
+
|
|
13
|
+
_FORMS = (
|
|
14
|
+
(
|
|
15
|
+
re.compile(
|
|
16
|
+
r"#\s*noqa:\s*(?P<codes>[A-Z]+\d*(?:\s*,\s*[A-Z]+\d*)*)"
|
|
17
|
+
r"(?P<rest>[^#]*)",
|
|
18
|
+
),
|
|
19
|
+
"# noqa: {}",
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
re.compile(
|
|
23
|
+
r"#\s*ruff:\s*ignore\[(?P<codes>[\w-]+(?:\s*,\s*[\w-]+)*)\]"
|
|
24
|
+
r"(?P<rest>[^#]*)",
|
|
25
|
+
),
|
|
26
|
+
"# ruff: ignore[{}]",
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
_REASON = re.compile(r"^\s+-\s+\S")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class NarrowSuppressions(Iterable[Violation]):
|
|
35
|
+
"""Suppressions of one line that name a forbidden rule or give no reason.
|
|
36
|
+
|
|
37
|
+
It reads ``# noqa: CODE`` and ``# ruff: ignore[CODE-OR-NAME]``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_tool: str
|
|
41
|
+
_comment: Remark
|
|
42
|
+
_forbidden: tuple[str, ...]
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def __iter__(self) -> Iterator[Violation]:
|
|
46
|
+
"""Report every suppression of the comment that breaks a rule."""
|
|
47
|
+
for template, place, match in (
|
|
48
|
+
(template, place, match)
|
|
49
|
+
for form, template in _FORMS
|
|
50
|
+
for place, match in self._comment.matches(form)
|
|
51
|
+
):
|
|
52
|
+
codes = tuple(code.strip() for code in match["codes"].split(","))
|
|
53
|
+
listed = template.format(", ".join(codes))
|
|
54
|
+
banned = tuple(
|
|
55
|
+
code for code in codes if code.startswith(self._forbidden)
|
|
56
|
+
)
|
|
57
|
+
if banned:
|
|
58
|
+
yield Violation(
|
|
59
|
+
self._tool,
|
|
60
|
+
"SUP002",
|
|
61
|
+
place,
|
|
62
|
+
f"{', '.join(banned)} cannot be suppressed: "
|
|
63
|
+
"fix the code instead",
|
|
64
|
+
)
|
|
65
|
+
if not _REASON.match(match["rest"]):
|
|
66
|
+
yield Violation(
|
|
67
|
+
self._tool,
|
|
68
|
+
"SUP001",
|
|
69
|
+
place,
|
|
70
|
+
f"`{listed}` needs a reason: "
|
|
71
|
+
f"write `{listed} - why the rule cannot hold here`",
|
|
72
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""TST300: every assertion explains its failure with a negation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from pytest_eo.message import Message
|
|
12
|
+
from pytest_eo.rule import Rule
|
|
13
|
+
|
|
14
|
+
_NEGATION = re.compile(
|
|
15
|
+
r"\b(?:not|never|no|nothing|cannot|dont|doesnt|didnt|isnt|arent|wasnt"
|
|
16
|
+
r"|werent|wont|cant)\b",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class NegativeMessages(Rule):
|
|
24
|
+
"""Assertions whose message says what went wrong, such as "x doesnt y"."""
|
|
25
|
+
|
|
26
|
+
_tree: ast.Module
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
30
|
+
"""Report every assertion whose message holds no negation."""
|
|
31
|
+
return (
|
|
32
|
+
(
|
|
33
|
+
line,
|
|
34
|
+
column,
|
|
35
|
+
"TST300 assertion must explain its failure with a negation",
|
|
36
|
+
)
|
|
37
|
+
for node in ast.walk(self._tree)
|
|
38
|
+
for line, column, text in Message().visit(node)
|
|
39
|
+
if not _NEGATION.search(text)
|
|
40
|
+
)
|
pytest_eo/output.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""What a finished tool printed and how it exited."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.printout import Printout
|
|
10
|
+
from pytest_eo.tool_failed_error import ToolFailedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Output(Printout):
|
|
16
|
+
"""The streams and the exit status of a finished process."""
|
|
17
|
+
|
|
18
|
+
_stdout: str
|
|
19
|
+
_stderr: str
|
|
20
|
+
_status: int
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def __iter__(self) -> Iterator[str]:
|
|
24
|
+
"""Read the lines of stdout, where tools print their reports."""
|
|
25
|
+
return iter(self._stdout.splitlines())
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def exited(self, statuses: tuple[int, ...]) -> bool:
|
|
29
|
+
"""Tell whether the process exited with one of the statuses."""
|
|
30
|
+
return self._status in statuses
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def failure(self, reason: str) -> ToolFailedError:
|
|
34
|
+
"""Build the error with the exit status and what went to stderr."""
|
|
35
|
+
return ToolFailedError(
|
|
36
|
+
f"{reason} (status {self._status}): {self._stderr.strip()}",
|
|
37
|
+
)
|
pytest_eo/owned.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The attributes of an object that an assignment sets on itself."""
|
|
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 Owned(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
13
|
+
"""Names of ``self.x`` in an assignment target, and nothing for others."""
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
def generic_visit(self, node: ast.AST) -> tuple[str, ...]:
|
|
17
|
+
"""Find no attribute of self in any other target."""
|
|
18
|
+
return ()
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def visit_Attribute(self, node: ast.Attribute) -> tuple[str, ...]:
|
|
22
|
+
"""Name the attribute when it belongs to ``self``."""
|
|
23
|
+
return (node.attr,) if ast.unparse(node.value) == "self" else ()
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def visit_Tuple(self, node: ast.Tuple) -> tuple[str, ...]:
|
|
27
|
+
"""Name the attributes of self among the targets of an unpacking."""
|
|
28
|
+
return tuple(name for item in node.elts for name in self.visit(item))
|
pytest_eo/place.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Where in the code a tool found something."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import final
|
|
5
|
+
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@final
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Place:
|
|
12
|
+
"""A file, a line and a column, as tools and editors point at code."""
|
|
13
|
+
|
|
14
|
+
_path: str
|
|
15
|
+
_line: int
|
|
16
|
+
_column: int
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
"""Render as ``path:line:col``."""
|
|
21
|
+
return f"{self._path}:{self._line}:{self._column}"
|