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