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.
@@ -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))
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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