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/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
+ )
@@ -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
+ )
@@ -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}"