python-surveyor 0.3.0__tar.gz → 0.4.1__tar.gz
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.
- {python_surveyor-0.3.0/python_surveyor.egg-info → python_surveyor-0.4.1}/PKG-INFO +3 -1
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/pyproject.toml +4 -2
- python_surveyor-0.4.1/python_surveyor/checks/__init__.py +152 -0
- python_surveyor-0.4.1/python_surveyor/checks/_util.py +73 -0
- python_surveyor-0.4.1/python_surveyor/checks/broad_except.py +168 -0
- python_surveyor-0.4.1/python_surveyor/checks/fixture_naming.py +134 -0
- python_surveyor-0.4.1/python_surveyor/checks/future_annotations.py +46 -0
- python_surveyor-0.4.1/python_surveyor/checks/nontoplevel_imports.py +125 -0
- python_surveyor-0.4.1/python_surveyor/checks/optional_params.py +311 -0
- python_surveyor-0.4.1/python_surveyor/checks/suppressions.py +96 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1/python_surveyor.egg-info}/PKG-INFO +3 -1
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/SOURCES.txt +8 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/requires.txt +2 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_optional_params.py +147 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/LICENSE +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/README.md +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/__init__.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/__main__.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/cli.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/model.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/report.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/scanner.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/dependency_links.txt +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/entry_points.txt +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/top_level.txt +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/setup.cfg +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_broad_except.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_cli.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_fixture_naming.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_future_annotations.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_nontoplevel_imports.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_scanner.py +0 -0
- {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_suppressions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-surveyor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Use python introspection to survey source code for final LLM judgement
|
|
5
5
|
Author: Dave Cunningham
|
|
6
6
|
License-Expression: MIT
|
|
@@ -9,6 +9,8 @@ Requires-Python: >=3.12
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: click>=8.1.0
|
|
11
11
|
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: fastapi>=0.115.0; extra == "dev"
|
|
13
|
+
Requires-Dist: flask>=3.0.0; extra == "dev"
|
|
12
14
|
Requires-Dist: pyright==1.1.411; extra == "dev"
|
|
13
15
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
14
16
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[tool.setuptools.packages.find]
|
|
6
6
|
where = ["."]
|
|
7
|
-
include = ["python_surveyor"]
|
|
7
|
+
include = ["python_surveyor*"]
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "python-surveyor"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.4.1"
|
|
12
12
|
description = "Use python introspection to survey source code for final LLM judgement"
|
|
13
13
|
requires-python = ">=3.12"
|
|
14
14
|
license = "MIT"
|
|
@@ -24,6 +24,8 @@ dependencies = [
|
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
dev = [
|
|
27
|
+
"fastapi>=0.115.0",
|
|
28
|
+
"flask>=3.0.0",
|
|
27
29
|
"pyright==1.1.411",
|
|
28
30
|
"pytest>=8.0.0",
|
|
29
31
|
"pytest-cov",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Check registry used by dispatch and the ``list-checks`` command.
|
|
2
|
+
|
|
3
|
+
Every check is a ``CheckSpec(check_id, description, run)`` where ``run`` has
|
|
4
|
+
the signature ``run(source: SourceFile, corpus: Corpus) -> list[Finding]``.
|
|
5
|
+
Checks that ignore ``corpus`` still take it, so the internal check API itself
|
|
6
|
+
has no optional parameters.
|
|
7
|
+
|
|
8
|
+
``SourceFile`` and ``Corpus`` are imported only under ``TYPE_CHECKING`` to
|
|
9
|
+
avoid a circular import with :mod:`python_surveyor.scanner` (which imports
|
|
10
|
+
this registry at module top-level).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from python_surveyor.checks.broad_except import run as run_broad_except
|
|
18
|
+
from python_surveyor.checks.fixture_naming import run as run_fixture_naming
|
|
19
|
+
from python_surveyor.checks.future_annotations import run as run_future_annotations
|
|
20
|
+
from python_surveyor.checks.nontoplevel_imports import run as run_nontoplevel_imports
|
|
21
|
+
from python_surveyor.checks.optional_params import run as run_optional_params
|
|
22
|
+
from python_surveyor.checks.suppressions import run as run_suppressions
|
|
23
|
+
from python_surveyor.model import Finding
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CheckSpec:
|
|
31
|
+
"""A single check: id, short description, longer explanation, and runner.
|
|
32
|
+
|
|
33
|
+
``description`` is the one-liner shown by ``list-checks``.
|
|
34
|
+
``explanation`` is shown as a header note when the text report has
|
|
35
|
+
findings for this check, so the reader understands *why* the smell
|
|
36
|
+
matters before judging each hit.
|
|
37
|
+
``pylint_equivalents`` lists pylint check IDs that this check already
|
|
38
|
+
covers, so the ``suppression-comment`` check can skip ``# pylint:
|
|
39
|
+
disable=`` directives that are fully redundant with another check (the
|
|
40
|
+
list is parsed and checked against this set — no regex to maintain).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
check_id: str
|
|
44
|
+
description: str
|
|
45
|
+
explanation: str
|
|
46
|
+
pylint_equivalents: tuple[str, ...]
|
|
47
|
+
run: "Callable[[SourceFile, Corpus], list[Finding]]"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_ALL_CHECKS_DATA: tuple[
|
|
51
|
+
tuple[
|
|
52
|
+
str, str, str, tuple[str, ...], "Callable[[SourceFile, Corpus], list[Finding]]"
|
|
53
|
+
],
|
|
54
|
+
...,
|
|
55
|
+
] = (
|
|
56
|
+
(
|
|
57
|
+
"broad-except",
|
|
58
|
+
"ExceptHandler with no type, or type Exception/BaseException "
|
|
59
|
+
"(including tuples and except*).",
|
|
60
|
+
"Catching a narrower exception type is usually better — it makes "
|
|
61
|
+
"the intent explicit and avoids swallowing unexpected bugs. When a "
|
|
62
|
+
"broad except is necessary, it should typically re-raise to "
|
|
63
|
+
"preserve the stack trace for actual bugs rather than silently "
|
|
64
|
+
"discarding them.",
|
|
65
|
+
("broad-exception-caught",),
|
|
66
|
+
run_broad_except,
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"fixture-naming",
|
|
70
|
+
"@pytest.fixture function whose registered name does not match "
|
|
71
|
+
"its declared name (the redefined-outer-name collision case).",
|
|
72
|
+
"A pytest fixture function should be prefixed with `fixture_` and "
|
|
73
|
+
"register its real name via `name=` so the function parameter in "
|
|
74
|
+
"test functions doesn't shadow the fixture function name — the "
|
|
75
|
+
"`redefined-outer-name` pylint disable that AI code often adds to "
|
|
76
|
+
"silence this is a symptom, not a fix.",
|
|
77
|
+
("redefined-outer-name",),
|
|
78
|
+
run_fixture_naming,
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"future-annotations-import",
|
|
82
|
+
"`from __future__ import annotations` (we never use 3.9).",
|
|
83
|
+
"`from __future__ import annotations` makes all type annotations "
|
|
84
|
+
"strings at runtime, which breaks any code that relies on "
|
|
85
|
+
"resolving types at runtime (e.g. `typing.get_type_hints`, "
|
|
86
|
+
"Pydantic models, FastAPI dependency injection). Since the "
|
|
87
|
+
"target is always Python 3.12+, it's unnecessary and should be "
|
|
88
|
+
"removed.",
|
|
89
|
+
(),
|
|
90
|
+
run_future_annotations,
|
|
91
|
+
),
|
|
92
|
+
(
|
|
93
|
+
"non-toplevel-import",
|
|
94
|
+
"Import/ImportFrom not directly in Module.body, at any nesting depth.",
|
|
95
|
+
"Imports inside functions or blocks are usually a sign of lazy "
|
|
96
|
+
"import resolution — often masking circular dependencies that "
|
|
97
|
+
"should be fixed with proper architecture. There may be a good "
|
|
98
|
+
"reason (e.g. performance, test-harness monkeypatching), but "
|
|
99
|
+
"absent one, imports should be at module top level.",
|
|
100
|
+
("import-outside-toplevel",),
|
|
101
|
+
run_nontoplevel_imports,
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"optional-param-default",
|
|
105
|
+
"FunctionDef with >=1 defaulted positional or keyword-only parameter, "
|
|
106
|
+
"excluding FastAPI/Flask route handlers (verified via real imports).",
|
|
107
|
+
"Defaulted parameters are often added to avoid updating existing "
|
|
108
|
+
"call sites, but they make it impossible for pyright to flag "
|
|
109
|
+
"callers that should be passing an explicit value — the type "
|
|
110
|
+
"checker sees the default and moves on. On internal APIs, every "
|
|
111
|
+
"caller should pass an explicit value so pyright can catch "
|
|
112
|
+
"missing or wrong arguments. FastAPI/Flask route handlers are "
|
|
113
|
+
"excluded because their defaults are a framework user interface "
|
|
114
|
+
"(query parameters, headers) and the handler is invoked by the "
|
|
115
|
+
"framework, not by application code — the 'not called from "
|
|
116
|
+
"anywhere' signal doesn't apply.",
|
|
117
|
+
(),
|
|
118
|
+
run_optional_params,
|
|
119
|
+
),
|
|
120
|
+
(
|
|
121
|
+
"suppression-comment",
|
|
122
|
+
"# pylint: disable=... or # pyright: ignore without a "
|
|
123
|
+
"justifying comment block above.",
|
|
124
|
+
"Suppression comments disable a linter's check for a line or "
|
|
125
|
+
"region. They're sometimes necessary, but each one should have a "
|
|
126
|
+
"comment explaining *why* the suppression is warranted — without "
|
|
127
|
+
"that, the suppression is indistinguishable from silencing a real "
|
|
128
|
+
"problem.",
|
|
129
|
+
(),
|
|
130
|
+
run_suppressions,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
ALL_CHECKS: tuple[CheckSpec, ...] = tuple(
|
|
136
|
+
CheckSpec(
|
|
137
|
+
check_id=cid,
|
|
138
|
+
description=desc,
|
|
139
|
+
explanation=expl,
|
|
140
|
+
pylint_equivalents=equiv,
|
|
141
|
+
run=run,
|
|
142
|
+
)
|
|
143
|
+
for cid, desc, expl, equiv, run in _ALL_CHECKS_DATA
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
CHECKS_BY_ID: dict[str, CheckSpec] = {
|
|
147
|
+
check_spec.check_id: check_spec for check_spec in ALL_CHECKS
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
COVERED_PYLINT_IDS: frozenset[str] = frozenset(
|
|
151
|
+
eq for check in ALL_CHECKS for eq in check.pylint_equivalents
|
|
152
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared helpers for check implementations.
|
|
2
|
+
|
|
3
|
+
Every finding needs two pieces of context that are shared across all checks:
|
|
4
|
+
the source line(s) at the finding location (so the reader can see the actual
|
|
5
|
+
code) and any justifying comment block immediately above it (so the reader
|
|
6
|
+
can judge whether the smell is intentional). Excerpt-line capping is a
|
|
7
|
+
rendering concern and lives in :mod:`python_surveyor.report`.
|
|
8
|
+
|
|
9
|
+
``SourceFile`` is imported only under ``TYPE_CHECKING`` to avoid a circular
|
|
10
|
+
import with :mod:`python_surveyor.scanner`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from python_surveyor.model import SourceExcerpt
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from python_surveyor.scanner import SourceFile
|
|
20
|
+
|
|
21
|
+
CONTROL_FLOW_NODES: tuple[type[ast.AST], ...] = (
|
|
22
|
+
ast.If,
|
|
23
|
+
ast.Try,
|
|
24
|
+
ast.With,
|
|
25
|
+
ast.For,
|
|
26
|
+
ast.While,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_pure_comment_line(text: str) -> bool:
|
|
31
|
+
"""True if ``text`` is a ``#``-comment line (possibly indented, no code)."""
|
|
32
|
+
return text.strip().startswith("#")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def preceding_comment_block(
|
|
36
|
+
source: "SourceFile", line: int
|
|
37
|
+
) -> "tuple[int, int] | None":
|
|
38
|
+
"""Return ``(start, end)`` inclusive of the contiguous comment block above
|
|
39
|
+
``line``, or ``None`` if the line immediately above is not a comment.
|
|
40
|
+
|
|
41
|
+
Walks upward while each preceding line is a pure ``#``-comment line; blank
|
|
42
|
+
lines and code lines break the block.
|
|
43
|
+
"""
|
|
44
|
+
end = line - 1
|
|
45
|
+
if end < 1:
|
|
46
|
+
return None
|
|
47
|
+
if not is_pure_comment_line(source.line_text(end)):
|
|
48
|
+
return None
|
|
49
|
+
start = end
|
|
50
|
+
while start - 1 >= 1 and is_pure_comment_line(source.line_text(start - 1)):
|
|
51
|
+
start -= 1
|
|
52
|
+
return start, end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def justifying_comment(source: "SourceFile", line: int) -> tuple[SourceExcerpt, ...]:
|
|
56
|
+
"""Return a 1-tuple excerpt for the comment block above ``line``, or ``()``.
|
|
57
|
+
|
|
58
|
+
If a preceding ``#``-comment block is found, it is attached as an excerpt
|
|
59
|
+
labeled "justifying comment". If no preceding comment block is found,
|
|
60
|
+
returns ``()`` — the absence of the excerpt is the signal that no
|
|
61
|
+
justifying comment exists, so no note is needed.
|
|
62
|
+
"""
|
|
63
|
+
block = preceding_comment_block(source, line)
|
|
64
|
+
if block is None:
|
|
65
|
+
return ()
|
|
66
|
+
start, end = block
|
|
67
|
+
return (
|
|
68
|
+
SourceExcerpt(
|
|
69
|
+
path=source.path,
|
|
70
|
+
start_line=start,
|
|
71
|
+
end_line=end,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""``broad-except`` check.
|
|
2
|
+
|
|
3
|
+
Finds ``ExceptHandler`` nodes with no type, or with type
|
|
4
|
+
``Exception``/``BaseException`` (including tuples and ``except*`` via
|
|
5
|
+
``TryStar``). Two excerpts are attached: the ``try`` side (justifying
|
|
6
|
+
comment above, the ``try`` line, and any comment block immediately after
|
|
7
|
+
the ``try`` — but no code from inside the try body) and the ``except``
|
|
8
|
+
side (the ``except`` line plus handler body, capped at ``_WINDOW``
|
|
9
|
+
lines). If the two excerpts overlap, they are merged into one.
|
|
10
|
+
|
|
11
|
+
Handlers that re-raise (bare ``raise``) or call ``logger.exception(...)``
|
|
12
|
+
are not flagged — re-raising preserves the stack trace, and
|
|
13
|
+
``logger.exception`` captures it (though the error is still swallowed).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from python_surveyor.checks._util import is_pure_comment_line, justifying_comment
|
|
20
|
+
from python_surveyor.model import Finding, SourceExcerpt
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
24
|
+
|
|
25
|
+
_BROAD_NAMES = {"Exception", "BaseException"}
|
|
26
|
+
_WINDOW = 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _type_contains_broad(node: ast.expr | None) -> bool:
|
|
30
|
+
if node is None:
|
|
31
|
+
return True
|
|
32
|
+
if isinstance(node, ast.Name):
|
|
33
|
+
return node.id in _BROAD_NAMES
|
|
34
|
+
if isinstance(node, ast.Tuple):
|
|
35
|
+
return any(_type_contains_broad(elt) for elt in node.elts)
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_reraise(stmt: ast.stmt) -> bool:
|
|
40
|
+
"""True if ``stmt`` is a bare ``raise`` (re-raises the current exception)."""
|
|
41
|
+
return isinstance(stmt, ast.Raise) and stmt.exc is None and stmt.cause is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_logger_exception(stmt: ast.stmt) -> bool:
|
|
45
|
+
"""True if ``stmt`` is ``<something>.exception(...)`` (e.g. ``logger.exception``)."""
|
|
46
|
+
if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call):
|
|
47
|
+
return False
|
|
48
|
+
func = stmt.value.func
|
|
49
|
+
return isinstance(func, ast.Attribute) and func.attr == "exception"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _handler_re_raises_or_logs(handler: ast.ExceptHandler) -> bool:
|
|
53
|
+
"""True if the handler body re-raises or calls ``logger.exception``."""
|
|
54
|
+
for stmt in handler.body:
|
|
55
|
+
for sub in ast.walk(stmt):
|
|
56
|
+
if isinstance(sub, ast.stmt) and (
|
|
57
|
+
_is_reraise(sub) or _is_logger_exception(sub)
|
|
58
|
+
):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _body_end(handler: ast.ExceptHandler) -> int:
|
|
64
|
+
"""Return the last line of the handler body (or the ``except`` line)."""
|
|
65
|
+
body = handler.body
|
|
66
|
+
if not body:
|
|
67
|
+
return handler.lineno
|
|
68
|
+
last = body[-1]
|
|
69
|
+
end = getattr(last, "end_lineno", None)
|
|
70
|
+
if end is None:
|
|
71
|
+
end = last.lineno
|
|
72
|
+
return max(end, handler.lineno)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _comment_block_end(source: "SourceFile", start: int) -> int:
|
|
76
|
+
"""Return the last line of a contiguous comment block starting at ``start``.
|
|
77
|
+
|
|
78
|
+
If ``start`` is not a comment line, returns ``start - 1`` (an empty
|
|
79
|
+
block before ``start``).
|
|
80
|
+
"""
|
|
81
|
+
total = len(source.lines)
|
|
82
|
+
end = start
|
|
83
|
+
while end + 1 <= total and is_pure_comment_line(source.line_text(end + 1)):
|
|
84
|
+
end += 1
|
|
85
|
+
return end
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _try_except_excerpts(
|
|
89
|
+
source: "SourceFile",
|
|
90
|
+
jc_start: "int | None",
|
|
91
|
+
try_line: int,
|
|
92
|
+
handler_line: int,
|
|
93
|
+
body_end: int,
|
|
94
|
+
) -> tuple[SourceExcerpt, ...]:
|
|
95
|
+
"""Build excerpts for the try and except sides of a broad except.
|
|
96
|
+
|
|
97
|
+
The try-side excerpt is justifying comment (if any) + ``try`` line +
|
|
98
|
+
any comment block immediately after the ``try`` — never code from
|
|
99
|
+
inside the try body. If there are no comments before or after the
|
|
100
|
+
``try``, the try-side excerpt is omitted entirely. The except-side
|
|
101
|
+
excerpt is the ``except`` line + handler body, capped at ``_WINDOW``
|
|
102
|
+
lines. If the two overlap, they are merged into one excerpt.
|
|
103
|
+
"""
|
|
104
|
+
has_jc = jc_start is not None
|
|
105
|
+
post_end = _comment_block_end(source, try_line)
|
|
106
|
+
has_post = post_end > try_line
|
|
107
|
+
second_start = handler_line
|
|
108
|
+
second_end = min(body_end, handler_line + _WINDOW)
|
|
109
|
+
if not has_jc and not has_post:
|
|
110
|
+
return (
|
|
111
|
+
SourceExcerpt(
|
|
112
|
+
path=source.path,
|
|
113
|
+
start_line=second_start,
|
|
114
|
+
end_line=second_end,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
first_start = jc_start if jc_start is not None else try_line
|
|
118
|
+
first_end = post_end
|
|
119
|
+
if first_end >= second_start:
|
|
120
|
+
merged_end = min(max(first_end, second_end), body_end)
|
|
121
|
+
return (
|
|
122
|
+
SourceExcerpt(
|
|
123
|
+
path=source.path,
|
|
124
|
+
start_line=first_start,
|
|
125
|
+
end_line=merged_end,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
return (
|
|
129
|
+
SourceExcerpt(
|
|
130
|
+
path=source.path,
|
|
131
|
+
start_line=first_start,
|
|
132
|
+
end_line=first_end,
|
|
133
|
+
),
|
|
134
|
+
SourceExcerpt(
|
|
135
|
+
path=source.path,
|
|
136
|
+
start_line=second_start,
|
|
137
|
+
end_line=second_end,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
|
|
143
|
+
"""Find broad ``except`` handlers and attach body + justifying-comment context."""
|
|
144
|
+
findings: list[Finding] = []
|
|
145
|
+
for node in ast.walk(source.tree):
|
|
146
|
+
if not isinstance(node, (ast.Try, ast.TryStar)):
|
|
147
|
+
continue
|
|
148
|
+
jc = justifying_comment(source, node.lineno)
|
|
149
|
+
jc_start = jc[0].start_line if jc else None
|
|
150
|
+
for handler in node.handlers:
|
|
151
|
+
if not _type_contains_broad(handler.type):
|
|
152
|
+
continue
|
|
153
|
+
if _handler_re_raises_or_logs(handler):
|
|
154
|
+
continue
|
|
155
|
+
body_end = _body_end(handler)
|
|
156
|
+
excerpts = _try_except_excerpts(
|
|
157
|
+
source, jc_start, node.lineno, handler.lineno, body_end
|
|
158
|
+
)
|
|
159
|
+
findings.append(
|
|
160
|
+
Finding(
|
|
161
|
+
check_id="broad-except",
|
|
162
|
+
path=source.path,
|
|
163
|
+
line=handler.lineno,
|
|
164
|
+
column=handler.col_offset,
|
|
165
|
+
excerpts=excerpts,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return findings
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""``fixture-naming`` check.
|
|
2
|
+
|
|
3
|
+
Finds ``@pytest.fixture``/``@fixture``-decorated functions whose registered
|
|
4
|
+
fixture name does not match the convention: the function should be prefixed
|
|
5
|
+
with ``fixture_`` **and** ``name=`` should register the true fixture name.
|
|
6
|
+
The ``def`` line is attached as an excerpt (extended upward to include any
|
|
7
|
+
justifying comment block), and a note reports the effective registered name
|
|
8
|
+
and the corpus lookup of parameter usages of that name elsewhere (the actual
|
|
9
|
+
``redefined-outer-name`` collision sites).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from python_surveyor.checks._util import justifying_comment
|
|
16
|
+
from python_surveyor.model import Finding, SourceExcerpt
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _decorator_info(
|
|
23
|
+
deco: ast.expr,
|
|
24
|
+
) -> "tuple[bool, bool, str | None]":
|
|
25
|
+
"""Return ``(is_fixture, has_name_kwarg, name_value)`` for a decorator."""
|
|
26
|
+
call: ast.Call | None = None
|
|
27
|
+
expr = deco
|
|
28
|
+
if isinstance(expr, ast.Call):
|
|
29
|
+
call = expr
|
|
30
|
+
expr = expr.func
|
|
31
|
+
is_fixture = False
|
|
32
|
+
if isinstance(expr, ast.Attribute) and expr.attr == "fixture":
|
|
33
|
+
if isinstance(expr.value, ast.Name) and expr.value.id == "pytest":
|
|
34
|
+
is_fixture = True
|
|
35
|
+
elif isinstance(expr, ast.Name) and expr.id == "fixture":
|
|
36
|
+
is_fixture = True
|
|
37
|
+
if not is_fixture or call is None:
|
|
38
|
+
return is_fixture, False, None
|
|
39
|
+
name_value: str | None = None
|
|
40
|
+
has_name = False
|
|
41
|
+
for kw in call.keywords:
|
|
42
|
+
if kw.arg == "name":
|
|
43
|
+
has_name = True
|
|
44
|
+
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
45
|
+
name_value = kw.value.value
|
|
46
|
+
return True, has_name, name_value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _collision_note(registered_name: str, corpus: "Corpus") -> str:
|
|
50
|
+
sites = corpus.param_locations.get(registered_name, ())
|
|
51
|
+
count = len(sites)
|
|
52
|
+
if count == 0:
|
|
53
|
+
return f"registered as `{registered_name}`; no parameter usages elsewhere"
|
|
54
|
+
cap = corpus.max_call_sites
|
|
55
|
+
samples = sorted(sites, key=lambda pair: (str(pair[0].path), pair[0].line))[:cap]
|
|
56
|
+
rendered = ", ".join(
|
|
57
|
+
f"{pair[0].path.name}:{pair[0].line} (in `{pair[1]}`)" for pair in samples
|
|
58
|
+
)
|
|
59
|
+
remaining = count - cap
|
|
60
|
+
if remaining > 0:
|
|
61
|
+
rendered += f", and {remaining} other(s)"
|
|
62
|
+
return (
|
|
63
|
+
f"registered as `{registered_name}`; parameter used in "
|
|
64
|
+
f"{count} other function(s): {rendered}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_autouse(deco: ast.expr) -> bool:
|
|
69
|
+
"""True if the decorator call has ``autouse=True``."""
|
|
70
|
+
if not isinstance(deco, ast.Call):
|
|
71
|
+
return False
|
|
72
|
+
for kw in deco.keywords:
|
|
73
|
+
if (
|
|
74
|
+
kw.arg == "autouse"
|
|
75
|
+
and isinstance(kw.value, ast.Constant)
|
|
76
|
+
and kw.value.value is True
|
|
77
|
+
):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
|
|
83
|
+
"""Find fixture-naming issues and attach source + collision-site context."""
|
|
84
|
+
if source.path.name == "conftest.py":
|
|
85
|
+
return []
|
|
86
|
+
findings: list[Finding] = []
|
|
87
|
+
for node in ast.walk(source.tree):
|
|
88
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
89
|
+
continue
|
|
90
|
+
fixture_deco: ast.expr | None = None
|
|
91
|
+
has_name_kwarg = False
|
|
92
|
+
name_value: str | None = None
|
|
93
|
+
for deco in node.decorator_list:
|
|
94
|
+
is_fixture, has_name, value = _decorator_info(deco)
|
|
95
|
+
if is_fixture:
|
|
96
|
+
fixture_deco = deco
|
|
97
|
+
has_name_kwarg = has_name
|
|
98
|
+
name_value = value
|
|
99
|
+
break
|
|
100
|
+
if fixture_deco is None:
|
|
101
|
+
continue
|
|
102
|
+
if _is_autouse(fixture_deco):
|
|
103
|
+
continue
|
|
104
|
+
starts_with_fixture_ = node.name.startswith("fixture_")
|
|
105
|
+
if has_name_kwarg and starts_with_fixture_:
|
|
106
|
+
continue
|
|
107
|
+
registered_name = name_value if name_value is not None else node.name
|
|
108
|
+
reasons: list[str] = []
|
|
109
|
+
if not has_name_kwarg:
|
|
110
|
+
reasons.append("no `name=` kwarg")
|
|
111
|
+
if not starts_with_fixture_:
|
|
112
|
+
reasons.append(f"name `{node.name}` lacks `fixture_` prefix")
|
|
113
|
+
jc = justifying_comment(source, node.lineno)
|
|
114
|
+
start = jc[0].start_line if jc else node.lineno
|
|
115
|
+
findings.append(
|
|
116
|
+
Finding(
|
|
117
|
+
check_id="fixture-naming",
|
|
118
|
+
path=source.path,
|
|
119
|
+
line=node.lineno,
|
|
120
|
+
column=node.col_offset,
|
|
121
|
+
excerpts=(
|
|
122
|
+
SourceExcerpt(
|
|
123
|
+
path=source.path,
|
|
124
|
+
start_line=start,
|
|
125
|
+
end_line=node.lineno,
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
notes=(
|
|
129
|
+
_collision_note(registered_name, corpus),
|
|
130
|
+
f"reason: {'; '.join(reasons)}",
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return findings
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``future-annotations-import`` check.
|
|
2
|
+
|
|
3
|
+
Finds ``from __future__ import annotations``. The import line is attached
|
|
4
|
+
as an excerpt (extended upward to include any justifying comment block),
|
|
5
|
+
but the fix is always "delete the line" so there is little else to judge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from python_surveyor.checks._util import justifying_comment
|
|
12
|
+
from python_surveyor.model import Finding, SourceExcerpt
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
|
|
19
|
+
"""Find ``from __future__ import annotations`` directives."""
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
for node in source.tree.body:
|
|
22
|
+
if not isinstance(node, ast.ImportFrom):
|
|
23
|
+
continue
|
|
24
|
+
if node.module != "__future__":
|
|
25
|
+
continue
|
|
26
|
+
for alias in node.names:
|
|
27
|
+
if alias.name == "annotations":
|
|
28
|
+
jc = justifying_comment(source, node.lineno)
|
|
29
|
+
start = jc[0].start_line if jc else node.lineno
|
|
30
|
+
findings.append(
|
|
31
|
+
Finding(
|
|
32
|
+
check_id="future-annotations-import",
|
|
33
|
+
path=source.path,
|
|
34
|
+
line=node.lineno,
|
|
35
|
+
column=node.col_offset,
|
|
36
|
+
excerpts=(
|
|
37
|
+
SourceExcerpt(
|
|
38
|
+
path=source.path,
|
|
39
|
+
start_line=start,
|
|
40
|
+
end_line=node.lineno,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
notes=(),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|