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.
Files changed (67) hide show
  1. pytest_eo/__init__.py +1 -0
  2. pytest_eo/angry_tests.py +44 -0
  3. pytest_eo/assertion.py +30 -0
  4. pytest_eo/assigned.py +32 -0
  5. pytest_eo/blank_lines.py +42 -0
  6. pytest_eo/bodies.py +36 -0
  7. pytest_eo/bundled.py +20 -0
  8. pytest_eo/callee.py +23 -0
  9. pytest_eo/capacity.py +50 -0
  10. pytest_eo/check.py +17 -0
  11. pytest_eo/command.py +13 -0
  12. pytest_eo/comment.py +42 -0
  13. pytest_eo/comments.py +42 -0
  14. pytest_eo/config/flake8.ini +23 -0
  15. pytest_eo/config/mypy.ini +5 -0
  16. pytest_eo/config/ruff.toml +43 -0
  17. pytest_eo/edges.py +28 -0
  18. pytest_eo/encapsulation.py +30 -0
  19. pytest_eo/ending.py +30 -0
  20. pytest_eo/eo_item.py +41 -0
  21. pytest_eo/eo_table.py +29 -0
  22. pytest_eo/excludes.py +25 -0
  23. pytest_eo/extension.py +12 -0
  24. pytest_eo/fields.py +40 -0
  25. pytest_eo/files.py +12 -0
  26. pytest_eo/final_assertion.py +46 -0
  27. pytest_eo/flake_check.py +49 -0
  28. pytest_eo/heading.py +35 -0
  29. pytest_eo/inline_comments.py +44 -0
  30. pytest_eo/layout.py +15 -0
  31. pytest_eo/margin.py +42 -0
  32. pytest_eo/message.py +60 -0
  33. pytest_eo/module_constants.py +32 -0
  34. pytest_eo/mypy_check.py +43 -0
  35. pytest_eo/narrow_suppressions.py +72 -0
  36. pytest_eo/negative_messages.py +40 -0
  37. pytest_eo/output.py +37 -0
  38. pytest_eo/owned.py +28 -0
  39. pytest_eo/place.py +21 -0
  40. pytest_eo/plugin.py +45 -0
  41. pytest_eo/printout.py +18 -0
  42. pytest_eo/profile.py +13 -0
  43. pytest_eo/project.py +37 -0
  44. pytest_eo/project_files.py +39 -0
  45. pytest_eo/python_module.py +36 -0
  46. pytest_eo/raising.py +27 -0
  47. pytest_eo/remark.py +25 -0
  48. pytest_eo/ruff_check.py +50 -0
  49. pytest_eo/ruff_format_check.py +46 -0
  50. pytest_eo/rule.py +12 -0
  51. pytest_eo/shared.py +35 -0
  52. pytest_eo/span.py +28 -0
  53. pytest_eo/strict.py +112 -0
  54. pytest_eo/style_violations_error.py +15 -0
  55. pytest_eo/suppression_check.py +44 -0
  56. pytest_eo/table.py +11 -0
  57. pytest_eo/text.py +28 -0
  58. pytest_eo/tool_failed_error.py +15 -0
  59. pytest_eo/trial.py +37 -0
  60. pytest_eo/verified.py +28 -0
  61. pytest_eo/violation.py +24 -0
  62. pytest_eo/wide_suppressions.py +45 -0
  63. pytest_eo-0.5.1.dist-info/METADATA +177 -0
  64. pytest_eo-0.5.1.dist-info/RECORD +67 -0
  65. pytest_eo-0.5.1.dist-info/WHEEL +4 -0
  66. pytest_eo-0.5.1.dist-info/entry_points.txt +7 -0
  67. 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."""
@@ -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))
@@ -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,5 @@
1
+ [mypy]
2
+ strict = True
3
+ warn_unreachable = True
4
+ enable_error_code = explicit-override
5
+ pretty = False
@@ -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
+ )