pytest-tidy 0.1.0__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_tidy/__init__.py +44 -0
- pytest_tidy/__main__.py +4 -0
- pytest_tidy/astutils.py +259 -0
- pytest_tidy/autofix.py +100 -0
- pytest_tidy/cli.py +383 -0
- pytest_tidy/config.py +186 -0
- pytest_tidy/context.py +97 -0
- pytest_tidy/discovery.py +102 -0
- pytest_tidy/engine.py +135 -0
- pytest_tidy/models.py +79 -0
- pytest_tidy/plugin.py +137 -0
- pytest_tidy/py.typed +0 -0
- pytest_tidy/reporting.py +178 -0
- pytest_tidy/rules/__init__.py +45 -0
- pytest_tidy/rules/assertions.py +548 -0
- pytest_tidy/rules/base.py +92 -0
- pytest_tidy/rules/exceptions.py +178 -0
- pytest_tidy/rules/fixtures.py +275 -0
- pytest_tidy/rules/markers.py +168 -0
- pytest_tidy/rules/mocking.py +104 -0
- pytest_tidy/rules/structure.py +163 -0
- pytest_tidy/rules/timing.py +170 -0
- pytest_tidy-0.1.0.dist-info/METADATA +316 -0
- pytest_tidy-0.1.0.dist-info/RECORD +28 -0
- pytest_tidy-0.1.0.dist-info/WHEEL +5 -0
- pytest_tidy-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_tidy-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytest_tidy-0.1.0.dist-info/top_level.txt +1 -0
pytest_tidy/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""pytest-tidy: a static, AST-based test-smell linter for pytest suites.
|
|
2
|
+
|
|
3
|
+
The public surface is intentionally small and stable:
|
|
4
|
+
|
|
5
|
+
from pytest_tidy import lint_source, lint_paths, __version__
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .models import Diagnostic, Edit, Fix, Severity
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"Diagnostic",
|
|
17
|
+
"Edit",
|
|
18
|
+
"Fix",
|
|
19
|
+
"Severity",
|
|
20
|
+
"lint_source",
|
|
21
|
+
"lint_paths",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lint_source(source: str, filename: str = "<string>", config=None):
|
|
26
|
+
"""Lint a single source string and return a list of :class:`Diagnostic`.
|
|
27
|
+
|
|
28
|
+
This is a convenience wrapper around the engine so that the package can be
|
|
29
|
+
used as a library without touching the CLI.
|
|
30
|
+
"""
|
|
31
|
+
from .config import Config
|
|
32
|
+
from .engine import Engine
|
|
33
|
+
|
|
34
|
+
engine = Engine(config or Config())
|
|
35
|
+
return engine.check_source(filename, source)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def lint_paths(paths, config=None):
|
|
39
|
+
"""Lint files/directories and return a list of :class:`Diagnostic`."""
|
|
40
|
+
from .config import Config
|
|
41
|
+
from .engine import Engine
|
|
42
|
+
|
|
43
|
+
engine = Engine(config or Config())
|
|
44
|
+
return engine.check_paths(list(paths))
|
pytest_tidy/__main__.py
ADDED
pytest_tidy/astutils.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Low-level AST helpers shared by every rule.
|
|
2
|
+
|
|
3
|
+
The most important helper is :func:`qualified_name`, which resolves an
|
|
4
|
+
expression like ``pt.raises`` (given ``import pytest as pt``) or ``raises``
|
|
5
|
+
(given ``from pytest import raises``) to its canonical dotted path
|
|
6
|
+
``"pytest.raises"``. Rules match against those canonical names so they work
|
|
7
|
+
regardless of how a symbol was imported.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Dict, Iterator, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
# Scopes ordered from narrowest to widest. A fixture may only depend on fixtures
|
|
17
|
+
# whose scope is >= its own; the reverse is a pytest ScopeMismatch error.
|
|
18
|
+
SCOPE_ORDER: Dict[str, int] = {
|
|
19
|
+
"function": 0,
|
|
20
|
+
"class": 1,
|
|
21
|
+
"module": 2,
|
|
22
|
+
"package": 3,
|
|
23
|
+
"session": 4,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
FIXTURE_DECORATORS = frozenset({"pytest.fixture", "pytest.yield_fixture"})
|
|
27
|
+
|
|
28
|
+
NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
29
|
+
FUNCTION_DEFS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Imports:
|
|
34
|
+
"""Resolved import table for a module.
|
|
35
|
+
|
|
36
|
+
``modules`` maps a bound alias to the real module name, e.g.
|
|
37
|
+
``{"pytest": "pytest", "pt": "pytest", "np": "numpy"}``.
|
|
38
|
+
|
|
39
|
+
``names`` maps a bound name to ``(module, original_name)`` for
|
|
40
|
+
``from x import y as z`` style imports, e.g.
|
|
41
|
+
``{"raises": ("pytest", "raises"), "sleep": ("time", "sleep")}``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
modules: Dict[str, str] = field(default_factory=dict)
|
|
45
|
+
names: Dict[str, Tuple[str, str]] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_import_table(tree: ast.Module) -> Imports:
|
|
49
|
+
imports = Imports()
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if isinstance(node, ast.Import):
|
|
52
|
+
for alias in node.names:
|
|
53
|
+
bound = alias.asname or alias.name.split(".")[0]
|
|
54
|
+
# For "import a.b.c" without asname, the bound name is "a" and
|
|
55
|
+
# refers to module "a". For "import a.b as c" it is c -> a.b.
|
|
56
|
+
target = alias.name if alias.asname else alias.name.split(".")[0]
|
|
57
|
+
imports.modules[bound] = target
|
|
58
|
+
elif isinstance(node, ast.ImportFrom):
|
|
59
|
+
if node.module is None or node.level:
|
|
60
|
+
# Relative imports: we can't resolve the absolute module, but the
|
|
61
|
+
# bound name is still useful for local resolution. Record it as a
|
|
62
|
+
# local name pointing at a best-effort module string.
|
|
63
|
+
module = node.module or ""
|
|
64
|
+
else:
|
|
65
|
+
module = node.module
|
|
66
|
+
for alias in node.names:
|
|
67
|
+
if alias.name == "*":
|
|
68
|
+
continue
|
|
69
|
+
bound = alias.asname or alias.name
|
|
70
|
+
imports.names[bound] = (module, alias.name)
|
|
71
|
+
return imports
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def qualified_name(node: ast.AST, imports: Imports) -> Optional[str]:
|
|
75
|
+
"""Return the canonical dotted name for an attribute/name expression.
|
|
76
|
+
|
|
77
|
+
Returns ``None`` if ``node`` is not a plain attribute/name chain (e.g. it is
|
|
78
|
+
a call, subscript, or literal). Import aliases are resolved so callers can
|
|
79
|
+
match on stable names like ``"pytest.mark.parametrize"``.
|
|
80
|
+
"""
|
|
81
|
+
parts: List[str] = []
|
|
82
|
+
cur = node
|
|
83
|
+
while isinstance(cur, ast.Attribute):
|
|
84
|
+
parts.append(cur.attr)
|
|
85
|
+
cur = cur.value
|
|
86
|
+
if not isinstance(cur, ast.Name):
|
|
87
|
+
return None
|
|
88
|
+
base = cur.id
|
|
89
|
+
parts.append(base)
|
|
90
|
+
parts.reverse() # [base, attr, attr, ...]
|
|
91
|
+
|
|
92
|
+
if base in imports.modules:
|
|
93
|
+
parts[0] = imports.modules[base]
|
|
94
|
+
return ".".join(parts)
|
|
95
|
+
if base in imports.names:
|
|
96
|
+
module, original = imports.names[base]
|
|
97
|
+
resolved = ([module] if module else []) + [original] + parts[1:]
|
|
98
|
+
return ".".join(p for p in resolved if p)
|
|
99
|
+
return ".".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def call_qualified_name(node: ast.Call, imports: Imports) -> Optional[str]:
|
|
103
|
+
"""Canonical dotted name of a call's *callee* (``node.func``)."""
|
|
104
|
+
return qualified_name(node.func, imports)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def decorator_names(func: ast.AST, imports: Imports) -> List[Tuple[str, ast.AST]]:
|
|
108
|
+
"""Return ``(canonical_name, decorator_node)`` for each decorator.
|
|
109
|
+
|
|
110
|
+
For ``@x.y(...)`` the decorator node is the ``Call`` and the name resolves
|
|
111
|
+
``x.y``; for ``@x.y`` the node is the attribute/name itself.
|
|
112
|
+
"""
|
|
113
|
+
out: List[Tuple[str, ast.AST]] = []
|
|
114
|
+
for dec in getattr(func, "decorator_list", []) or []:
|
|
115
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
116
|
+
name = qualified_name(target, imports)
|
|
117
|
+
if name is not None:
|
|
118
|
+
out.append((name, dec))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fixture_decorator(
|
|
123
|
+
func: ast.AST, imports: Imports
|
|
124
|
+
) -> Optional[Tuple[str, ast.AST]]:
|
|
125
|
+
"""Return ``(canonical_name, decorator_node)`` if ``func`` is a fixture."""
|
|
126
|
+
for name, dec in decorator_names(func, imports):
|
|
127
|
+
if name in FIXTURE_DECORATORS:
|
|
128
|
+
return name, dec
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fixture_scope(decorator: ast.AST) -> Tuple[str, Optional[ast.AST]]:
|
|
133
|
+
"""Extract a fixture's declared scope from its decorator.
|
|
134
|
+
|
|
135
|
+
Returns ``(scope, scope_value_node)``. ``scope_value_node`` is the AST node
|
|
136
|
+
carrying the scope literal (a keyword value or positional arg), or ``None``
|
|
137
|
+
when the scope is implicit (defaults to ``"function"``).
|
|
138
|
+
"""
|
|
139
|
+
if not isinstance(decorator, ast.Call):
|
|
140
|
+
return "function", None
|
|
141
|
+
for kw in decorator.keywords:
|
|
142
|
+
if kw.arg == "scope" and isinstance(kw.value, ast.Constant):
|
|
143
|
+
if isinstance(kw.value.value, str):
|
|
144
|
+
return kw.value.value, kw.value
|
|
145
|
+
# Deprecated positional scope: pytest.fixture("module").
|
|
146
|
+
if decorator.args:
|
|
147
|
+
first = decorator.args[0]
|
|
148
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
149
|
+
if first.value in SCOPE_ORDER:
|
|
150
|
+
return first.value, first
|
|
151
|
+
return "function", None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_test_function(func: ast.AST, prefix: str = "test") -> bool:
|
|
155
|
+
name = getattr(func, "name", "")
|
|
156
|
+
return isinstance(func, FUNCTION_DEFS) and name.startswith(prefix)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def function_arg_names(func: ast.AST) -> List[str]:
|
|
160
|
+
"""Positional/keyword parameter names, excluding ``self``/``cls``."""
|
|
161
|
+
args = func.args
|
|
162
|
+
names: List[str] = []
|
|
163
|
+
for a in list(args.posonlyargs) + list(args.args):
|
|
164
|
+
names.append(a.arg)
|
|
165
|
+
if args.vararg:
|
|
166
|
+
names.append(args.vararg.arg)
|
|
167
|
+
for a in args.kwonlyargs:
|
|
168
|
+
names.append(a.arg)
|
|
169
|
+
if args.kwarg:
|
|
170
|
+
names.append(args.kwarg.arg)
|
|
171
|
+
return [n for n in names if n not in ("self", "cls")]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def is_docstring(stmt: ast.stmt) -> bool:
|
|
175
|
+
return (
|
|
176
|
+
isinstance(stmt, ast.Expr)
|
|
177
|
+
and isinstance(stmt.value, ast.Constant)
|
|
178
|
+
and isinstance(stmt.value.value, str)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_trivial_stmt(stmt: ast.stmt) -> bool:
|
|
183
|
+
"""A statement that carries no real behaviour: ``pass``, ``...``, docstring."""
|
|
184
|
+
if isinstance(stmt, ast.Pass):
|
|
185
|
+
return True
|
|
186
|
+
if is_docstring(stmt):
|
|
187
|
+
return True
|
|
188
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
189
|
+
return stmt.value.value is Ellipsis
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def significant_body(body: List[ast.stmt]) -> List[ast.stmt]:
|
|
194
|
+
return [s for s in body if not is_trivial_stmt(s)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def iter_body_excluding_nested(func: ast.AST) -> Iterator[ast.AST]:
|
|
198
|
+
"""Yield every node under ``func.body`` without descending into nested
|
|
199
|
+
function/lambda/class scopes. The nested scope node itself is yielded, but
|
|
200
|
+
not its contents."""
|
|
201
|
+
for stmt in getattr(func, "body", []):
|
|
202
|
+
yield from _walk_no_nested(stmt)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _walk_no_nested(node: ast.AST) -> Iterator[ast.AST]:
|
|
206
|
+
yield node
|
|
207
|
+
if isinstance(node, NESTED_SCOPES):
|
|
208
|
+
# Yield the nested scope itself but do not descend into its body, so a
|
|
209
|
+
# `return`/`yield` inside a nested helper isn't attributed to the outer
|
|
210
|
+
# function.
|
|
211
|
+
return
|
|
212
|
+
for child in ast.iter_child_nodes(node):
|
|
213
|
+
yield from _walk_no_nested(child)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def constant_is_truthy(node: ast.AST) -> Optional[bool]:
|
|
217
|
+
"""If ``node`` is a literal whose truthiness is statically known, return it.
|
|
218
|
+
|
|
219
|
+
Handles constants and non-empty/empty literal containers. Returns ``None``
|
|
220
|
+
when truthiness cannot be determined statically.
|
|
221
|
+
"""
|
|
222
|
+
if isinstance(node, ast.Constant):
|
|
223
|
+
try:
|
|
224
|
+
return bool(node.value)
|
|
225
|
+
except Exception: # pragma: no cover - defensive
|
|
226
|
+
return None
|
|
227
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
228
|
+
return len(node.elts) > 0
|
|
229
|
+
if isinstance(node, ast.Dict):
|
|
230
|
+
return len(node.keys) > 0
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def unparse(node: ast.AST) -> str:
|
|
235
|
+
try:
|
|
236
|
+
return ast.unparse(node)
|
|
237
|
+
except Exception: # pragma: no cover - defensive
|
|
238
|
+
return "<expr>"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def source_segment(source: str, node: ast.AST) -> Optional[str]:
|
|
242
|
+
try:
|
|
243
|
+
return ast.get_source_segment(source, node)
|
|
244
|
+
except Exception: # pragma: no cover - defensive
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def is_simple_expr(node: ast.AST) -> bool:
|
|
249
|
+
"""True for expressions safe to wrap/inline without precedence surprises."""
|
|
250
|
+
return isinstance(
|
|
251
|
+
node,
|
|
252
|
+
(
|
|
253
|
+
ast.Name,
|
|
254
|
+
ast.Attribute,
|
|
255
|
+
ast.Call,
|
|
256
|
+
ast.Subscript,
|
|
257
|
+
ast.Constant,
|
|
258
|
+
),
|
|
259
|
+
)
|
pytest_tidy/autofix.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Apply autofixes to source text as byte-precise range replacements.
|
|
2
|
+
|
|
3
|
+
Python's AST reports ``col_offset`` as a UTF-8 *byte* offset, so we do all edit
|
|
4
|
+
arithmetic in bytes and decode once at the end. Edits are applied from the end
|
|
5
|
+
of the file toward the start, which keeps earlier offsets valid; any pair of
|
|
6
|
+
overlapping edits is resolved by keeping the first and deferring the rest to a
|
|
7
|
+
later pass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
from .models import Diagnostic, Edit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Span:
|
|
18
|
+
__slots__ = ("start", "end", "text")
|
|
19
|
+
|
|
20
|
+
def __init__(self, start: int, end: int, text: bytes) -> None:
|
|
21
|
+
self.start = start
|
|
22
|
+
self.end = end
|
|
23
|
+
self.text = text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _line_byte_offsets(source: str) -> List[int]:
|
|
27
|
+
offsets = [0]
|
|
28
|
+
total = 0
|
|
29
|
+
for line in source.splitlines(keepends=True):
|
|
30
|
+
total += len(line.encode("utf-8"))
|
|
31
|
+
offsets.append(total)
|
|
32
|
+
return offsets
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _edit_to_span(offsets: List[int], edit: Edit) -> _Span:
|
|
36
|
+
start = offsets[edit.start_line - 1] + edit.start_col
|
|
37
|
+
end = offsets[edit.end_line - 1] + edit.end_col
|
|
38
|
+
return _Span(start, end, edit.replacement.encode("utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _overlaps(start: int, end: int, occupied: Sequence[Tuple[int, int]]) -> bool:
|
|
42
|
+
for os, oe in occupied:
|
|
43
|
+
if not (end <= os or start >= oe):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def apply_fixes(
|
|
49
|
+
source: str, diagnostics: Sequence[Diagnostic]
|
|
50
|
+
) -> Tuple[str, List[Diagnostic]]:
|
|
51
|
+
"""Apply every non-conflicting fix once.
|
|
52
|
+
|
|
53
|
+
Returns ``(new_source, fixed_diagnostics)``. Diagnostics whose edits
|
|
54
|
+
conflicted with an already-applied fix are simply left unfixed; callers that
|
|
55
|
+
want to converge can call :func:`fix_source` which loops.
|
|
56
|
+
"""
|
|
57
|
+
offsets = _line_byte_offsets(source)
|
|
58
|
+
items: List[Tuple[Diagnostic, List[_Span]]] = []
|
|
59
|
+
for diag in diagnostics:
|
|
60
|
+
if not diag.fix:
|
|
61
|
+
continue
|
|
62
|
+
spans = [_edit_to_span(offsets, e) for e in diag.fix.edits]
|
|
63
|
+
if not spans:
|
|
64
|
+
continue
|
|
65
|
+
items.append((diag, spans))
|
|
66
|
+
|
|
67
|
+
# Highest start offset first so applied edits never invalidate lower ones.
|
|
68
|
+
items.sort(key=lambda it: min(s.start for s in it[1]), reverse=True)
|
|
69
|
+
|
|
70
|
+
data = source.encode("utf-8")
|
|
71
|
+
occupied: List[Tuple[int, int]] = []
|
|
72
|
+
fixed: List[Diagnostic] = []
|
|
73
|
+
for diag, spans in items:
|
|
74
|
+
if any(_overlaps(s.start, s.end, occupied) for s in spans):
|
|
75
|
+
continue
|
|
76
|
+
for span in sorted(spans, key=lambda s: s.start, reverse=True):
|
|
77
|
+
data = data[: span.start] + span.text + data[span.end :]
|
|
78
|
+
occupied.append((span.start, span.end))
|
|
79
|
+
fixed.append(diag)
|
|
80
|
+
return data.decode("utf-8"), fixed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fix_source(
|
|
84
|
+
source: str, engine, filename: str, max_passes: int = 10
|
|
85
|
+
) -> Tuple[str, List[Diagnostic]]:
|
|
86
|
+
"""Repeatedly lint + apply fixes until stable.
|
|
87
|
+
|
|
88
|
+
Returns ``(fixed_source, applied_diagnostics)``. Re-linting between passes
|
|
89
|
+
lets a fix that becomes applicable only after an earlier edit still land.
|
|
90
|
+
"""
|
|
91
|
+
applied: List[Diagnostic] = []
|
|
92
|
+
current = source
|
|
93
|
+
for _ in range(max_passes):
|
|
94
|
+
diagnostics = engine.check_source(filename, current)
|
|
95
|
+
new_source, fixed = apply_fixes(current, diagnostics)
|
|
96
|
+
if not fixed or new_source == current:
|
|
97
|
+
break
|
|
98
|
+
applied.extend(fixed)
|
|
99
|
+
current = new_source
|
|
100
|
+
return current, applied
|