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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pytest --eo: a strict style gate that aggregates ready-made tools."""
|
pytest_eo/angry_tests.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Rules of Angry Tests, which flake8 runs over the test files of pytest."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from pytest_eo.extension import Extension
|
|
12
|
+
from pytest_eo.final_assertion import FinalAssertion
|
|
13
|
+
from pytest_eo.module_constants import ModuleConstants
|
|
14
|
+
from pytest_eo.negative_messages import NegativeMessages
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class AngryTests(Extension):
|
|
20
|
+
"""Tests that end with their only assertion and share no constants.
|
|
21
|
+
|
|
22
|
+
An assertion is a plain ``assert`` or a Hamcrest ``assert_that``; a test
|
|
23
|
+
that ends with ``pytest.raises`` makes its assertion there. Every
|
|
24
|
+
assertion carries a negative message, such as "x doesnt y".
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
tree: ast.Module # noqa: PEO301 - flake8 passes it by this very name
|
|
28
|
+
filename: str # noqa: PEO301 - flake8 passes it by this very name
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def run(self) -> Iterator[tuple[int, int, str, type[object]]]:
|
|
32
|
+
"""Report the tests of a pytest test file that break a rule."""
|
|
33
|
+
if not any(
|
|
34
|
+
PurePath(self.filename).match(pattern)
|
|
35
|
+
for pattern in ("test_*.py", "*_test.py")
|
|
36
|
+
):
|
|
37
|
+
return
|
|
38
|
+
for rule in (
|
|
39
|
+
FinalAssertion(self.tree),
|
|
40
|
+
ModuleConstants(self.tree),
|
|
41
|
+
NegativeMessages(self.tree),
|
|
42
|
+
):
|
|
43
|
+
for line, column, text in rule.problems():
|
|
44
|
+
yield line, column, text, AngryTests
|
pytest_eo/assertion.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Whether a node asserts."""
|
|
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 Assertion(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""Whether a node asserts, by a plain ``assert`` or by ``assert_that``."""
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def generic_visit(self, node: ast.AST) -> bool:
|
|
19
|
+
"""Find no assertion in any other node."""
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def visit_Assert(self, node: ast.Assert) -> bool:
|
|
24
|
+
"""Find an assertion in a plain assert."""
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def visit_Call(self, node: ast.Call) -> bool:
|
|
29
|
+
"""Find an assertion in a call of ``assert_that``, qualified or not."""
|
|
30
|
+
return str(Callee().visit(node)).rsplit(".", 1)[-1] == "assert_that"
|
pytest_eo/assigned.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The attributes that one statement of a constructor sets."""
|
|
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.owned import Owned
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Assigned(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""Names of the attributes of self that a statement assigns."""
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def generic_visit(self, node: ast.AST) -> tuple[str, ...]:
|
|
19
|
+
"""Find no attribute in a statement that assigns nothing."""
|
|
20
|
+
return ()
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def visit_Assign(self, node: ast.Assign) -> tuple[str, ...]:
|
|
24
|
+
"""Name the attributes of self among the targets of an assignment."""
|
|
25
|
+
return tuple(
|
|
26
|
+
name for target in node.targets for name in Owned().visit(target)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> tuple[str, ...]:
|
|
31
|
+
"""Name the attribute of self that an annotated assignment sets."""
|
|
32
|
+
return tuple(Owned().visit(node.target))
|
pytest_eo/blank_lines.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""BOD100: a function body holds no blank lines."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import tokenize
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from pytest_eo.margin import Margin
|
|
12
|
+
from pytest_eo.rule import Rule
|
|
13
|
+
from pytest_eo.span import Span
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class BlankLines(Rule):
|
|
19
|
+
"""Blank lines inside the functions of a module.
|
|
20
|
+
|
|
21
|
+
A blank line inside a string is a part of the string, and a blank line
|
|
22
|
+
next to a nested definition is what ``ruff format`` puts there, so
|
|
23
|
+
neither counts.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_tree: ast.Module
|
|
27
|
+
_tokens: Sequence[tokenize.TokenInfo]
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def problems(self) -> Iterator[tuple[int, int, str]]:
|
|
31
|
+
"""Report every blank line from the first to the last row of a body."""
|
|
32
|
+
nodes = tuple(ast.walk(self._tree))
|
|
33
|
+
inside = {row for node in nodes for row in Span().visit(node)} - {
|
|
34
|
+
row for node in nodes for row in Margin().visit(node)
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
(*token.start, "BOD100 function body must not hold blank lines")
|
|
38
|
+
for token in self._tokens
|
|
39
|
+
if token.type == tokenize.NL
|
|
40
|
+
and not token.line.strip()
|
|
41
|
+
and token.start[0] in inside
|
|
42
|
+
)
|
pytest_eo/bodies.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Function bodies, which flake8 checks in every file of the project."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import tokenize
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from pytest_eo.blank_lines import BlankLines
|
|
12
|
+
from pytest_eo.extension import Extension
|
|
13
|
+
from pytest_eo.inline_comments import InlineComments
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Bodies(Extension):
|
|
19
|
+
"""Function bodies packed tight, as Qulice asks of Java methods.
|
|
20
|
+
|
|
21
|
+
A blank line or a comment inside a body splits it into parts, and a
|
|
22
|
+
method of several parts does too much.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
tree: ast.Module # noqa: PEO301 - flake8 passes it by this very name
|
|
26
|
+
file_tokens: Sequence[tokenize.TokenInfo] # noqa: PEO301 - flake8 passes it by this very name
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def run(self) -> Iterator[tuple[int, int, str, type[object]]]:
|
|
30
|
+
"""Report every function body that breaks a rule."""
|
|
31
|
+
for rule in (
|
|
32
|
+
BlankLines(self.tree, self.file_tokens),
|
|
33
|
+
InlineComments(self.tree, self.file_tokens),
|
|
34
|
+
):
|
|
35
|
+
for line, column, text in rule.problems():
|
|
36
|
+
yield line, column, text, Bodies
|
pytest_eo/bundled.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A configuration file shipped inside this package."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Bundled:
|
|
13
|
+
"""The path of a locked configuration that the gate hands to a tool."""
|
|
14
|
+
|
|
15
|
+
_name: str
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
"""Show the path of the file inside the installed package."""
|
|
20
|
+
return str(resources.files("pytest_eo") / "config" / self._name)
|
pytest_eo/callee.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The function a call goes to."""
|
|
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 Callee(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
13
|
+
"""The source of the function a call goes to; nothing for other nodes."""
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
def generic_visit(self, node: ast.AST) -> str:
|
|
17
|
+
"""Name nothing for a node that calls nothing."""
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def visit_Call(self, node: ast.Call) -> str:
|
|
22
|
+
"""Name the function of a call as its source spells it."""
|
|
23
|
+
return ast.unparse(node.func)
|
pytest_eo/capacity.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""ENC100: a class encapsulates one to four attributes."""
|
|
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.fields import Fields
|
|
10
|
+
|
|
11
|
+
_ALLOWED = range(1, 5)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Capacity(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
17
|
+
"""A class with no attributes or more than four, and nothing for others.
|
|
18
|
+
|
|
19
|
+
A protocol is an interface, which holds no state, so it is left alone.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def generic_visit(self, node: ast.AST) -> tuple[tuple[int, int, str], ...]:
|
|
24
|
+
"""Find nothing to count in a node that isnt a class."""
|
|
25
|
+
return ()
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def visit_ClassDef(
|
|
29
|
+
self,
|
|
30
|
+
node: ast.ClassDef,
|
|
31
|
+
) -> tuple[tuple[int, int, str], ...]:
|
|
32
|
+
"""Count the attributes of a class and report a wrong number."""
|
|
33
|
+
count = len(
|
|
34
|
+
{name for item in node.body for name in Fields().visit(item)},
|
|
35
|
+
)
|
|
36
|
+
if count in _ALLOWED or any(
|
|
37
|
+
ast.unparse(base).split("[")[0].endswith("Protocol")
|
|
38
|
+
for base in node.bases
|
|
39
|
+
):
|
|
40
|
+
return ()
|
|
41
|
+
return (
|
|
42
|
+
(
|
|
43
|
+
node.lineno,
|
|
44
|
+
node.col_offset,
|
|
45
|
+
(
|
|
46
|
+
"ENC100 class must encapsulate one to four attributes, "
|
|
47
|
+
f"not {count}"
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
)
|
pytest_eo/check.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""One aggregated tool: a named source of violations."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pytest_eo.violation import Violation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Check(Protocol):
|
|
9
|
+
"""A named source of violations."""
|
|
10
|
+
|
|
11
|
+
def name(self) -> str:
|
|
12
|
+
"""Short name shown in the pytest report."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def violations(self) -> tuple[Violation, ...]:
|
|
16
|
+
"""Run the tool and return everything it complains about."""
|
|
17
|
+
...
|
pytest_eo/command.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Something that runs and leaves an output behind."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pytest_eo.printout import Printout
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(Protocol):
|
|
9
|
+
"""Something that can be run and leaves a :class:`Printout`."""
|
|
10
|
+
|
|
11
|
+
def run(self) -> Printout:
|
|
12
|
+
"""Run it and return what it printed."""
|
|
13
|
+
...
|
pytest_eo/comment.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""One comment of a source file."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tokenize
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import final
|
|
9
|
+
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from pytest_eo.place import Place
|
|
13
|
+
from pytest_eo.remark import Remark
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Comment(Remark):
|
|
19
|
+
"""A comment of a file, read as the tokenizer gives it."""
|
|
20
|
+
|
|
21
|
+
_file: Path
|
|
22
|
+
_token: tokenize.TokenInfo
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
def matches(
|
|
26
|
+
self,
|
|
27
|
+
pattern: re.Pattern[str],
|
|
28
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
29
|
+
"""Yield every match of the pattern, with its line and column."""
|
|
30
|
+
line, column = self._token.start
|
|
31
|
+
return (
|
|
32
|
+
(Place(str(self._file), line, column + match.start() + 1), match)
|
|
33
|
+
for match in pattern.finditer(self._token.string)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def heading(
|
|
38
|
+
self,
|
|
39
|
+
pattern: re.Pattern[str],
|
|
40
|
+
) -> Iterator[tuple[Place, re.Match[str]]]:
|
|
41
|
+
"""Yield nothing, since a plain comment stands among the code."""
|
|
42
|
+
return iter(())
|
pytest_eo/comments.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The comments of a source file."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import tokenize
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import final
|
|
9
|
+
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from pytest_eo.comment import Comment
|
|
13
|
+
from pytest_eo.heading import Heading
|
|
14
|
+
from pytest_eo.remark import Remark
|
|
15
|
+
|
|
16
|
+
_SILENT = (tokenize.COMMENT, tokenize.NL, tokenize.NEWLINE, tokenize.ENCODING)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Comments(Iterable[Remark]):
|
|
22
|
+
"""Every comment of a file, or none when the file cannot be tokenized."""
|
|
23
|
+
|
|
24
|
+
_file: Path
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
def __iter__(self) -> Iterator[Remark]:
|
|
28
|
+
"""Read the comments in order, telling the header from the rest."""
|
|
29
|
+
text = self._file.read_text(encoding="utf-8")
|
|
30
|
+
try:
|
|
31
|
+
tokens = tuple(
|
|
32
|
+
tokenize.generate_tokens(io.StringIO(text).readline),
|
|
33
|
+
)
|
|
34
|
+
except (tokenize.TokenError, SyntaxError):
|
|
35
|
+
return
|
|
36
|
+
code = next(
|
|
37
|
+
token.start for token in tokens if token.type not in _SILENT
|
|
38
|
+
)
|
|
39
|
+
for token in tokens:
|
|
40
|
+
if token.type == tokenize.COMMENT:
|
|
41
|
+
comment = Comment(self._file, token)
|
|
42
|
+
yield Heading(comment) if token.start < code else comment
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Locked configuration: Elegant Objects rules from pyeo (eo-styleguide).
|
|
2
|
+
# FIN100 class must be final
|
|
3
|
+
# PEO101 __init__ contains only assignments
|
|
4
|
+
# PEO102 @classmethod contains only a cls() call
|
|
5
|
+
# PEO200 class must be frozen
|
|
6
|
+
# PEO300 no class names ending in "er"
|
|
7
|
+
# PEO301 no public attributes
|
|
8
|
+
# PEO400 no static methods
|
|
9
|
+
# PEO500 no @property, a getter in disguise
|
|
10
|
+
# PEO601 no methods starting with "get"
|
|
11
|
+
# PEO602 no getter methods
|
|
12
|
+
# PEO700 no type introspection, casting, or reflection
|
|
13
|
+
# OVR100 every public method is an @override of an interface method
|
|
14
|
+
# FOC100 one public class per module (flake8-one-class)
|
|
15
|
+
# NPM100 no private functions or methods (flake8-no-private-methods)
|
|
16
|
+
# ENC100 a class encapsulates one to four attributes (pytest-eo itself)
|
|
17
|
+
# BOD100 a function body holds no blank lines (pytest-eo itself)
|
|
18
|
+
# BOD200 a function body holds no comments, except suppressions (pytest-eo itself)
|
|
19
|
+
# TST100 a test ends with its only assertion (pytest-eo itself)
|
|
20
|
+
# TST200 a test module shares no constants between its tests (pytest-eo itself)
|
|
21
|
+
# TST300 every assertion carries a negative message (pytest-eo itself)
|
|
22
|
+
[flake8]
|
|
23
|
+
select = FIN100,PEO101,PEO102,PEO200,PEO300,PEO301,PEO400,PEO500,PEO601,PEO602,PEO700,OVR100,FOC100,NPM100,ENC100,BOD100,BOD200,TST100,TST200,TST300
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Locked configuration: projects cannot override it (only paths/excludes via [tool.eo]).
|
|
2
|
+
target-version = "py311"
|
|
3
|
+
line-length = 80
|
|
4
|
+
|
|
5
|
+
[lint]
|
|
6
|
+
preview = true
|
|
7
|
+
select = ["ALL"]
|
|
8
|
+
ignore = [
|
|
9
|
+
"incorrect-blank-line-before-class", # conflicts with blank-line-before-class
|
|
10
|
+
"multi-line-summary-second-line", # conflicts with multi-line-summary-first-line
|
|
11
|
+
"missing-trailing-comma", # conflicts with the formatter
|
|
12
|
+
"single-line-implicit-string-concatenation", # conflicts with the formatter
|
|
13
|
+
"missing-copyright-notice", # needs an author and a license: enable once the project picks them
|
|
14
|
+
"DOC", # docstrings explain purpose, not returns and raises
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Codes checked by flake8: lets ruff accept them in "# noqa" comments.
|
|
18
|
+
external = ["FIN", "PEO", "OVR", "FOC", "TST", "ENC", "BOD"]
|
|
19
|
+
|
|
20
|
+
[lint.per-file-ignores]
|
|
21
|
+
"tests/**" = [
|
|
22
|
+
"magic-value-comparison", # literals in expectations are fine
|
|
23
|
+
"D", # test names are the documentation
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[lint.flake8-tidy-imports.banned-api]
|
|
27
|
+
"unittest.mock".msg = "No mocks: write a Fake that implements the same interface."
|
|
28
|
+
"mock".msg = "No mocks: write a Fake that implements the same interface."
|
|
29
|
+
"pytest_mock".msg = "No mocks: write a Fake that implements the same interface."
|
|
30
|
+
|
|
31
|
+
[lint.pylint]
|
|
32
|
+
max-args = 4
|
|
33
|
+
max-statements = 10
|
|
34
|
+
max-branches = 4
|
|
35
|
+
max-returns = 2
|
|
36
|
+
max-nested-blocks = 2
|
|
37
|
+
max-public-methods = 5
|
|
38
|
+
|
|
39
|
+
[lint.mccabe]
|
|
40
|
+
max-complexity = 5
|
|
41
|
+
|
|
42
|
+
[lint.flake8-tidy-imports]
|
|
43
|
+
ban-relative-imports = "all"
|
pytest_eo/edges.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The two rows that border a definition."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Edges(Iterable[int]):
|
|
14
|
+
"""The row above a definition and its decorators, and the row below it."""
|
|
15
|
+
|
|
16
|
+
_node: ast.stmt
|
|
17
|
+
_decorators: Sequence[ast.expr]
|
|
18
|
+
|
|
19
|
+
@override
|
|
20
|
+
def __iter__(self) -> Iterator[int]:
|
|
21
|
+
"""Yield the row above the first decorator and the row past the end."""
|
|
22
|
+
yield (
|
|
23
|
+
min(
|
|
24
|
+
(self._node.lineno, *(item.lineno for item in self._decorators))
|
|
25
|
+
)
|
|
26
|
+
- 1
|
|
27
|
+
)
|
|
28
|
+
yield (self._node.end_lineno or self._node.lineno) + 1
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Encapsulation, which flake8 runs over every file of the project."""
|
|
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.capacity import Capacity
|
|
11
|
+
from pytest_eo.extension import Extension
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Encapsulation(Extension):
|
|
17
|
+
"""Classes that encapsulate as little as possible, but something.
|
|
18
|
+
|
|
19
|
+
Elegant Objects asks for one to four attributes per class: fewer is a
|
|
20
|
+
bag of procedures, more is a class that does too much.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
tree: ast.Module # noqa: PEO301 - flake8 passes it by this very name
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def run(self) -> Iterator[tuple[int, int, str, type[object]]]:
|
|
27
|
+
"""Report every class that holds no attributes or more than four."""
|
|
28
|
+
for node in ast.walk(self.tree):
|
|
29
|
+
for line, column, text in Capacity().visit(node):
|
|
30
|
+
yield line, column, text, Encapsulation
|
pytest_eo/ending.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Whether a statement ends a test with its assertion."""
|
|
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
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Ending(ast.NodeVisitor): # noqa: ENC100 - a visitor holds no state, ast dispatches on the node
|
|
15
|
+
"""Whether a statement is an assertion that may close a test."""
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def generic_visit(self, node: ast.AST) -> bool:
|
|
19
|
+
"""Refuse any other statement as the end of a test."""
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def visit_Assert(self, node: ast.Assert) -> bool:
|
|
24
|
+
"""Accept a plain assert as the end of a test."""
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def visit_Expr(self, node: ast.Expr) -> bool:
|
|
29
|
+
"""Accept an expression that asserts, such as a matcher."""
|
|
30
|
+
return bool(Assertion().visit(node.value))
|
pytest_eo/eo_item.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""A test item that passes when its tool reports nothing."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import final
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from pytest_eo.check import Check
|
|
10
|
+
from pytest_eo.style_violations_error import StyleViolationsError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
class EoItem(pytest.Item): # noqa: PEO200 - pytest.Item is mutable by design
|
|
15
|
+
"""A test that passes when its tool reports nothing."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, check: Check, **kwargs: object) -> None:
|
|
18
|
+
"""Bind the item to one check; pytest requires the super() call."""
|
|
19
|
+
super().__init__(**kwargs) # type: ignore[arg-type] # noqa: PEO101 - pytest needs it
|
|
20
|
+
self._check = check # noqa: PEO101 - flagged only because of super()
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def runtest(self) -> None:
|
|
24
|
+
"""Run the tool; fail with its findings."""
|
|
25
|
+
found = self._check.violations()
|
|
26
|
+
if found:
|
|
27
|
+
raise StyleViolationsError(*found)
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def repr_failure(
|
|
31
|
+
self,
|
|
32
|
+
excinfo: pytest.ExceptionInfo[BaseException],
|
|
33
|
+
style: object = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Let the error show itself instead of a Python traceback."""
|
|
36
|
+
return str(excinfo.value)
|
|
37
|
+
|
|
38
|
+
@override
|
|
39
|
+
def reportinfo(self) -> tuple[Path, int, str]:
|
|
40
|
+
"""Show ``eo::<tool>`` as the item location."""
|
|
41
|
+
return self.path, 0, f"eo::{self._check.name()}"
|
pytest_eo/eo_table.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The ``[tool.eo]`` table of a ``pyproject.toml``."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from pytest_eo.table import Table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class EoTable(Table):
|
|
16
|
+
"""Settings read from ``[tool.eo]``; a missing file gives none."""
|
|
17
|
+
|
|
18
|
+
_file: Path
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
def strings(self, key: str) -> tuple[str, ...]:
|
|
22
|
+
"""Read the list under the key, empty unless the TOML has one."""
|
|
23
|
+
if not self._file.is_file():
|
|
24
|
+
return ()
|
|
25
|
+
tool = tomllib.loads(self._file.read_text(encoding="utf-8")).get("tool")
|
|
26
|
+
eo = tool.get("eo") if isinstance(tool, dict) else {} # noqa: PEO700 - TOML gives untyped data
|
|
27
|
+
listed = eo.get(key) if isinstance(eo, dict) else () # noqa: PEO700 - TOML gives untyped data
|
|
28
|
+
entries = listed if isinstance(listed, list) else () # noqa: PEO700 - TOML gives untyped data
|
|
29
|
+
return tuple(str(item) for item in entries)
|
pytest_eo/excludes.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The paths a project leaves out of the check."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Container
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Excludes(Container[str]):
|
|
14
|
+
"""Glob patterns and directories that a project leaves unchecked."""
|
|
15
|
+
|
|
16
|
+
_patterns: tuple[str, ...]
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
def __contains__(self, path: object) -> bool:
|
|
20
|
+
"""Tell whether a POSIX path, relative to the root, is left out."""
|
|
21
|
+
return any(
|
|
22
|
+
fnmatch(str(path), pattern)
|
|
23
|
+
or str(path).startswith(pattern.rstrip("/") + "/")
|
|
24
|
+
for pattern in self._patterns
|
|
25
|
+
)
|