python-surveyor 0.4.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.4.0/python_surveyor.egg-info → python_surveyor-0.4.1}/PKG-INFO +1 -1
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/pyproject.toml +2 -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.4.0 → python_surveyor-0.4.1/python_surveyor.egg-info}/PKG-INFO +1 -1
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/SOURCES.txt +8 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/LICENSE +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/README.md +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/__init__.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/__main__.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/cli.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/model.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/report.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/scanner.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/dependency_links.txt +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/entry_points.txt +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/requires.txt +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/top_level.txt +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/setup.cfg +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_broad_except.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_cli.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_fixture_naming.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_future_annotations.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_nontoplevel_imports.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_optional_params.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_scanner.py +0 -0
- {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_suppressions.py +0 -0
|
@@ -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.4.
|
|
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"
|
|
@@ -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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""``non-toplevel-import`` check.
|
|
2
|
+
|
|
3
|
+
Finds ``Import``/``ImportFrom`` nodes that are not direct children of
|
|
4
|
+
``Module.body`` (i.e. nested inside a function, class, or control-flow block
|
|
5
|
+
at any depth). The import line is attached as an excerpt (extended upward to
|
|
6
|
+
include any justifying comment block), and a note names the nearest
|
|
7
|
+
enclosing scope so the agent can judge whether the late import is justified
|
|
8
|
+
(e.g. test-harness monkeypatching) or just laziness.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from python_surveyor.checks._util import (
|
|
15
|
+
CONTROL_FLOW_NODES,
|
|
16
|
+
justifying_comment,
|
|
17
|
+
)
|
|
18
|
+
from python_surveyor.model import Finding, SourceExcerpt
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
22
|
+
|
|
23
|
+
_SCOPE_LABELS = {
|
|
24
|
+
ast.FunctionDef: "function",
|
|
25
|
+
ast.AsyncFunctionDef: "function",
|
|
26
|
+
ast.ClassDef: "class",
|
|
27
|
+
ast.If: "if block",
|
|
28
|
+
ast.Try: "try block",
|
|
29
|
+
ast.With: "with block",
|
|
30
|
+
ast.For: "for block",
|
|
31
|
+
ast.While: "while block",
|
|
32
|
+
ast.ExceptHandler: "except block",
|
|
33
|
+
ast.TryStar: "try* block",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_type_checking_test(test: ast.expr) -> bool:
|
|
38
|
+
"""True for ``if TYPE_CHECKING:`` / ``if typing.TYPE_CHECKING:`` guards."""
|
|
39
|
+
if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
|
|
40
|
+
return True
|
|
41
|
+
return (
|
|
42
|
+
isinstance(test, ast.Attribute)
|
|
43
|
+
and test.attr == "TYPE_CHECKING"
|
|
44
|
+
and isinstance(test.value, ast.Name)
|
|
45
|
+
and test.value.id == "typing"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scope_label(node: ast.AST) -> str:
|
|
50
|
+
kind = _SCOPE_LABELS.get(type(node))
|
|
51
|
+
if kind is None:
|
|
52
|
+
return "nested block"
|
|
53
|
+
name = getattr(node, "name", None)
|
|
54
|
+
if name:
|
|
55
|
+
return f"{kind} `{name}`"
|
|
56
|
+
return kind
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _walk(
|
|
60
|
+
body: list[ast.stmt],
|
|
61
|
+
scope_stack: list[ast.AST],
|
|
62
|
+
source: "SourceFile",
|
|
63
|
+
findings: list[Finding],
|
|
64
|
+
) -> None:
|
|
65
|
+
for stmt in body:
|
|
66
|
+
new_stack = scope_stack
|
|
67
|
+
if isinstance(
|
|
68
|
+
stmt,
|
|
69
|
+
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
|
|
70
|
+
):
|
|
71
|
+
new_stack = scope_stack + [stmt]
|
|
72
|
+
elif isinstance(stmt, CONTROL_FLOW_NODES):
|
|
73
|
+
if isinstance(stmt, ast.If) and _is_type_checking_test(stmt.test):
|
|
74
|
+
new_stack = scope_stack
|
|
75
|
+
else:
|
|
76
|
+
new_stack = scope_stack + [stmt]
|
|
77
|
+
if isinstance(stmt, (ast.Import, ast.ImportFrom)) and scope_stack:
|
|
78
|
+
_record(stmt, source, scope_stack, findings)
|
|
79
|
+
for child_field in ("body", "orelse", "finalbody"):
|
|
80
|
+
child_body = getattr(stmt, child_field, None)
|
|
81
|
+
if isinstance(child_body, list):
|
|
82
|
+
_walk(child_body, new_stack, source, findings)
|
|
83
|
+
if isinstance(stmt, ast.Try):
|
|
84
|
+
for handler in stmt.handlers:
|
|
85
|
+
_walk(handler.body, new_stack + [handler], source, findings)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _record(
|
|
89
|
+
stmt: "ast.Import | ast.ImportFrom",
|
|
90
|
+
source: "SourceFile",
|
|
91
|
+
scope_stack: list[ast.AST],
|
|
92
|
+
findings: list[Finding],
|
|
93
|
+
) -> None:
|
|
94
|
+
line = stmt.lineno
|
|
95
|
+
jc = justifying_comment(source, line)
|
|
96
|
+
start = jc[0].start_line if jc else line
|
|
97
|
+
nearest = scope_stack[-1] if scope_stack else None
|
|
98
|
+
note = (
|
|
99
|
+
f"inside {_scope_label(nearest)}"
|
|
100
|
+
if nearest is not None
|
|
101
|
+
else "at module top-level"
|
|
102
|
+
)
|
|
103
|
+
findings.append(
|
|
104
|
+
Finding(
|
|
105
|
+
check_id="non-toplevel-import",
|
|
106
|
+
path=source.path,
|
|
107
|
+
line=line,
|
|
108
|
+
column=stmt.col_offset,
|
|
109
|
+
excerpts=(
|
|
110
|
+
SourceExcerpt(
|
|
111
|
+
path=source.path,
|
|
112
|
+
start_line=start,
|
|
113
|
+
end_line=line,
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
notes=(note,),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
|
|
122
|
+
"""Find non-top-level imports and attach source + enclosing-scope context."""
|
|
123
|
+
findings: list[Finding] = []
|
|
124
|
+
_walk(source.tree.body, [], source, findings)
|
|
125
|
+
return findings
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""``optional-param-default`` check.
|
|
2
|
+
|
|
3
|
+
Finds any ``FunctionDef``/``AsyncFunctionDef`` with at least one defaulted
|
|
4
|
+
positional or keyword-only parameter (``*args``/``**kwargs`` names are not
|
|
5
|
+
"parameters" for this purpose). The signature (justifying comment + ``def``
|
|
6
|
+
line, with non-defaulted leading params elided) is attached as an excerpt,
|
|
7
|
+
and notes report the defaulted parameter names plus call sites that rely on
|
|
8
|
+
at least one default value (capped at ``corpus.max_call_sites``, with
|
|
9
|
+
"and N other(s)" when truncated) — call sites that override every default
|
|
10
|
+
are not listed, since they don't exercise the default path.
|
|
11
|
+
|
|
12
|
+
**Framework route handlers are excluded.** FastAPI/Flask route handlers use
|
|
13
|
+
defaulted params as a user interface (query parameters, headers, etc.) and
|
|
14
|
+
are invoked by the framework rather than by application code, so the
|
|
15
|
+
"missing call sites" signal this check looks for doesn't apply. A handler is
|
|
16
|
+
recognised only when its decorator (e.g. ``@app.get(...)``, ``@router.post(
|
|
17
|
+
...)``, ``@app.route(...)``) is a call on a name that is statically traced,
|
|
18
|
+
via imports in the same file, to an actual ``fastapi.FastAPI`` /
|
|
19
|
+
``fastapi.APIRouter`` / ``flask.Flask`` / ``flask.Blueprint`` instance. Only
|
|
20
|
+
direct ``name = Ctor()`` assignments are tracked — factory functions that
|
|
21
|
+
build and return an app/router are not (this matches the heuristic, not
|
|
22
|
+
exhaustive, philosophy of the other checks).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from python_surveyor.checks._util import justifying_comment
|
|
29
|
+
from python_surveyor.model import CallSite, Finding, SourceExcerpt
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Framework classes whose instances expose route-decorator methods. Mapped
|
|
36
|
+
# from fully-qualified ``module.ClassName`` to the set of attribute names
|
|
37
|
+
# that route handlers are registered with.
|
|
38
|
+
_FRAMEWORK_CLASSES: dict[str, frozenset[str]] = {
|
|
39
|
+
"fastapi.FastAPI": frozenset(
|
|
40
|
+
{
|
|
41
|
+
"get",
|
|
42
|
+
"post",
|
|
43
|
+
"put",
|
|
44
|
+
"delete",
|
|
45
|
+
"patch",
|
|
46
|
+
"options",
|
|
47
|
+
"head",
|
|
48
|
+
"trace",
|
|
49
|
+
"websocket",
|
|
50
|
+
"api_route",
|
|
51
|
+
"route",
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
"fastapi.APIRouter": frozenset(
|
|
55
|
+
{
|
|
56
|
+
"get",
|
|
57
|
+
"post",
|
|
58
|
+
"put",
|
|
59
|
+
"delete",
|
|
60
|
+
"patch",
|
|
61
|
+
"options",
|
|
62
|
+
"head",
|
|
63
|
+
"trace",
|
|
64
|
+
"websocket",
|
|
65
|
+
"api_route",
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
"flask.Flask": frozenset(
|
|
69
|
+
{"get", "post", "put", "delete", "patch", "options", "head", "route"}
|
|
70
|
+
),
|
|
71
|
+
"flask.Blueprint": frozenset(
|
|
72
|
+
{"get", "post", "put", "delete", "patch", "options", "head", "route"}
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _framework_import_bindings(
|
|
78
|
+
tree: ast.Module,
|
|
79
|
+
) -> dict[str, str]:
|
|
80
|
+
"""Map locally-bound names to fully-qualified ``module.Name`` for framework imports.
|
|
81
|
+
|
|
82
|
+
Handles ``from fastapi import FastAPI``, ``from fastapi import APIRouter as
|
|
83
|
+
AR``, and ``import fastapi`` / ``import fastapi as fa``. Only names that
|
|
84
|
+
resolve to a known framework class (``fastapi.FastAPI`` etc.) are kept.
|
|
85
|
+
"""
|
|
86
|
+
bindings: dict[str, str] = {}
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
if isinstance(node, ast.ImportFrom):
|
|
89
|
+
if node.module not in ("fastapi", "flask"):
|
|
90
|
+
continue
|
|
91
|
+
for alias in node.names:
|
|
92
|
+
fq = f"{node.module}.{alias.name}"
|
|
93
|
+
if fq in _FRAMEWORK_CLASSES:
|
|
94
|
+
local = alias.asname if alias.asname else alias.name
|
|
95
|
+
bindings[local] = fq
|
|
96
|
+
elif isinstance(node, ast.Import):
|
|
97
|
+
for alias in node.names:
|
|
98
|
+
if alias.name in ("fastapi", "flask"):
|
|
99
|
+
local = alias.asname if alias.asname else alias.name
|
|
100
|
+
bindings[local] = alias.name
|
|
101
|
+
return bindings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_call_qualname(func: ast.expr, bindings: dict[str, str]) -> str | None:
|
|
105
|
+
"""Resolve the called function to a fully-qualified name, or ``None``.
|
|
106
|
+
|
|
107
|
+
Handles bare ``FastAPI()`` (``Name`` resolved via bindings) and dotted
|
|
108
|
+
``fastapi.FastAPI()`` / ``fa.FastAPI()`` (``Attribute`` whose value is a
|
|
109
|
+
module-alias name in bindings).
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(func, ast.Name):
|
|
112
|
+
return bindings.get(func.id)
|
|
113
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
114
|
+
module_alias = bindings.get(func.value.id)
|
|
115
|
+
if module_alias is not None:
|
|
116
|
+
return f"{module_alias}.{func.attr}"
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _framework_object_names(tree: ast.Module) -> set[str]:
|
|
121
|
+
"""Return names of variables bound to real FastAPI/Flask app/router instances.
|
|
122
|
+
|
|
123
|
+
Only direct ``name = Ctor(...)`` assignments at any depth are tracked.
|
|
124
|
+
Factory functions that build and return an app/router are not recognised
|
|
125
|
+
— see the module docstring for the rationale.
|
|
126
|
+
"""
|
|
127
|
+
bindings = _framework_import_bindings(tree)
|
|
128
|
+
if not bindings:
|
|
129
|
+
return set()
|
|
130
|
+
names: set[str] = set()
|
|
131
|
+
for node in ast.walk(tree):
|
|
132
|
+
target: ast.expr | None = None
|
|
133
|
+
value: ast.expr | None = None
|
|
134
|
+
if isinstance(node, ast.Assign):
|
|
135
|
+
if len(node.targets) != 1:
|
|
136
|
+
continue
|
|
137
|
+
target = node.targets[0]
|
|
138
|
+
value = node.value
|
|
139
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
140
|
+
target = node.target
|
|
141
|
+
value = node.value
|
|
142
|
+
if not isinstance(target, ast.Name) or value is None:
|
|
143
|
+
continue
|
|
144
|
+
if not isinstance(value, ast.Call):
|
|
145
|
+
continue
|
|
146
|
+
fq = _resolve_call_qualname(value.func, bindings)
|
|
147
|
+
if fq is not None and fq in _FRAMEWORK_CLASSES:
|
|
148
|
+
names.add(target.id)
|
|
149
|
+
return names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_route_decorator(deco: ast.expr, router_names: set[str]) -> bool:
|
|
153
|
+
"""True if ``deco`` is a ``@<router>.<verb>(...)`` call on a known router.
|
|
154
|
+
|
|
155
|
+
``<router>`` must be a name in ``router_names`` (a verified framework
|
|
156
|
+
app/router instance) and ``<verb>`` must be a route-registration method
|
|
157
|
+
for that object's class (e.g. ``get``, ``post``, ``route``).
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(deco, ast.Call):
|
|
160
|
+
return False
|
|
161
|
+
func = deco.func
|
|
162
|
+
if not isinstance(func, ast.Attribute):
|
|
163
|
+
return False
|
|
164
|
+
if not isinstance(func.value, ast.Name):
|
|
165
|
+
return False
|
|
166
|
+
if func.value.id not in router_names:
|
|
167
|
+
return False
|
|
168
|
+
# ``router_names`` only contains verified framework instances, so any
|
|
169
|
+
# attribute on them that matches a known route verb is a route decorator.
|
|
170
|
+
for verbs in _FRAMEWORK_CLASSES.values():
|
|
171
|
+
if func.attr in verbs:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _defaulted_param_names(
|
|
177
|
+
func: "ast.FunctionDef | ast.AsyncFunctionDef",
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""Return the names of positional and keyword-only params that have defaults."""
|
|
180
|
+
args = func.args
|
|
181
|
+
names: list[str] = []
|
|
182
|
+
positional_defaults = len(args.defaults)
|
|
183
|
+
for i, arg in enumerate(args.args):
|
|
184
|
+
if i >= len(args.args) - positional_defaults:
|
|
185
|
+
names.append(arg.arg)
|
|
186
|
+
for idx, default in enumerate(args.kw_defaults):
|
|
187
|
+
if default is not None:
|
|
188
|
+
names.append(args.kwonlyargs[idx].arg)
|
|
189
|
+
return names
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _first_defaulted_line(
|
|
193
|
+
func: "ast.FunctionDef | ast.AsyncFunctionDef",
|
|
194
|
+
) -> int:
|
|
195
|
+
"""Return the source line of the first defaulted parameter.
|
|
196
|
+
|
|
197
|
+
Non-defaulted positional params before the first default are elided
|
|
198
|
+
from the excerpt, so the reader sees only the params that matter.
|
|
199
|
+
"""
|
|
200
|
+
args = func.args
|
|
201
|
+
positional_defaults = len(args.defaults)
|
|
202
|
+
first_defaulted_idx = len(args.args) - positional_defaults
|
|
203
|
+
if positional_defaults > 0:
|
|
204
|
+
return args.args[first_defaulted_idx].lineno
|
|
205
|
+
for idx, default in enumerate(args.kw_defaults):
|
|
206
|
+
if default is not None:
|
|
207
|
+
return args.kwonlyargs[idx].lineno
|
|
208
|
+
return func.lineno
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _param_uses_default(
|
|
212
|
+
param_name: str,
|
|
213
|
+
param_index: int,
|
|
214
|
+
call: CallSite,
|
|
215
|
+
) -> bool:
|
|
216
|
+
"""True if the call site does not pass this param (uses the default)."""
|
|
217
|
+
if param_name in call.keywords:
|
|
218
|
+
return False
|
|
219
|
+
if param_index < call.positional_count:
|
|
220
|
+
return False
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _call_site_notes(
|
|
225
|
+
name: str,
|
|
226
|
+
defaulted_params: list[str],
|
|
227
|
+
corpus: "Corpus",
|
|
228
|
+
) -> tuple[str, ...]:
|
|
229
|
+
"""Return notes describing call sites that use at least one default value.
|
|
230
|
+
|
|
231
|
+
Call sites that override every defaulted param are not listed — they
|
|
232
|
+
don't exercise the default path and only add noise. Call sites that
|
|
233
|
+
use at least one default are listed (capped at ``corpus.max_call_sites``)
|
|
234
|
+
with "and N other(s)" when truncated.
|
|
235
|
+
"""
|
|
236
|
+
sites = corpus.call_sites.get(name, ())
|
|
237
|
+
total = len(sites)
|
|
238
|
+
if total == 0:
|
|
239
|
+
return (f"`{name}` not called from anywhere scanned",)
|
|
240
|
+
using_default: list[CallSite] = []
|
|
241
|
+
for site in sites:
|
|
242
|
+
for idx, param_name in enumerate(defaulted_params):
|
|
243
|
+
if _param_uses_default(param_name, idx, site):
|
|
244
|
+
using_default.append(site)
|
|
245
|
+
break
|
|
246
|
+
using_default_sorted = sorted(
|
|
247
|
+
using_default, key=lambda s: (str(s.location.path), s.location.line)
|
|
248
|
+
)
|
|
249
|
+
cap = corpus.max_call_sites
|
|
250
|
+
shown = using_default_sorted[:cap]
|
|
251
|
+
if not shown:
|
|
252
|
+
return (f"All {total} call site(s) override every defaulted parameter",)
|
|
253
|
+
parts = [f"{s.location.path.name}:{s.location.line}" for s in shown]
|
|
254
|
+
remaining = len(using_default) - len(shown)
|
|
255
|
+
if remaining > 0:
|
|
256
|
+
parts.append(f"and {remaining} other(s)")
|
|
257
|
+
sites_str = ", ".join(parts)
|
|
258
|
+
return (
|
|
259
|
+
f"Call sites using defaults ({len(using_default)} of {total}): {sites_str}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _signature_end_line(
|
|
264
|
+
node: "ast.FunctionDef | ast.AsyncFunctionDef",
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Return the last line of the ``def`` signature (the ``:`` line)."""
|
|
267
|
+
if not node.body:
|
|
268
|
+
return node.lineno
|
|
269
|
+
first_body = node.body[0]
|
|
270
|
+
return max(node.lineno, first_body.lineno - 1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
|
|
274
|
+
"""Find functions with defaulted params and attach signature + call-site context."""
|
|
275
|
+
router_names = _framework_object_names(source.tree)
|
|
276
|
+
findings: list[Finding] = []
|
|
277
|
+
for node in ast.walk(source.tree):
|
|
278
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
279
|
+
continue
|
|
280
|
+
if router_names and any(
|
|
281
|
+
_is_route_decorator(deco, router_names) for deco in node.decorator_list
|
|
282
|
+
):
|
|
283
|
+
continue
|
|
284
|
+
defaulted = _defaulted_param_names(node)
|
|
285
|
+
if not defaulted:
|
|
286
|
+
continue
|
|
287
|
+
jc = justifying_comment(source, node.lineno)
|
|
288
|
+
jc_start = jc[0].start_line if jc else None
|
|
289
|
+
sig_start = _first_defaulted_line(node)
|
|
290
|
+
start = jc_start if jc_start is not None else sig_start
|
|
291
|
+
end = _signature_end_line(node)
|
|
292
|
+
findings.append(
|
|
293
|
+
Finding(
|
|
294
|
+
check_id="optional-param-default",
|
|
295
|
+
path=source.path,
|
|
296
|
+
line=node.lineno,
|
|
297
|
+
column=node.col_offset,
|
|
298
|
+
excerpts=(
|
|
299
|
+
SourceExcerpt(
|
|
300
|
+
path=source.path,
|
|
301
|
+
start_line=start,
|
|
302
|
+
end_line=end,
|
|
303
|
+
),
|
|
304
|
+
),
|
|
305
|
+
notes=(
|
|
306
|
+
f"Defaulted params: {', '.join(defaulted)}",
|
|
307
|
+
*_call_site_notes(node.name, defaulted, corpus),
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
return findings
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""``suppression-comment`` check.
|
|
2
|
+
|
|
3
|
+
Finds ``# pylint: disable=...`` / ``# pyright: ignore`` directives via
|
|
4
|
+
``tokenize`` (not regex) so they aren't matched by strings or multi-line
|
|
5
|
+
constructs. The source line is attached as an excerpt (extended upward to
|
|
6
|
+
include any justifying comment block immediately above) so the reader can see
|
|
7
|
+
both the suppression and its justification in one contiguous block.
|
|
8
|
+
|
|
9
|
+
When a ``# pylint: disable=...`` is a standalone comment (not a trailing
|
|
10
|
+
comment on a code line), it applies to all lines below until a matching
|
|
11
|
+
``# pylint: enable=...`` or the end of the file. In that case the excerpt is
|
|
12
|
+
extended downward to show what code is being suppressed, capped at
|
|
13
|
+
``_MAX_SUPPRESSED_LINES`` lines.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import tokenize
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from python_surveyor.checks._util import justifying_comment
|
|
21
|
+
from python_surveyor.model import Finding, SourceExcerpt
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from python_surveyor.scanner import Corpus, SourceFile
|
|
25
|
+
|
|
26
|
+
_SUPPRESSION_RE = re.compile(r"pylint:\s*disable[-\w]*\s*=|pyright:\s*ignore")
|
|
27
|
+
_PYLINT_DISABLE_RE = re.compile(r"pylint:\s*disable[-\w]*\s*=\s*(.+)")
|
|
28
|
+
_ENABLE_RE = re.compile(r"pylint:\s*enable[-\w]*\s*=")
|
|
29
|
+
_MAX_SUPPRESSED_LINES = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _all_pylint_ids_covered(comment: str, covered: frozenset[str]) -> bool:
|
|
33
|
+
m = _PYLINT_DISABLE_RE.search(comment)
|
|
34
|
+
if m is None:
|
|
35
|
+
return False
|
|
36
|
+
ids = {part.strip() for part in m.group(1).split(",") if part.strip()}
|
|
37
|
+
return ids <= covered
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_standalone_comment(source: "SourceFile", row: int) -> bool:
|
|
41
|
+
"""True if the suppression comment is a whole-line comment, not trailing."""
|
|
42
|
+
line = source.line_text(row)
|
|
43
|
+
return line.strip().startswith("#")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_enable_or_end(source: "SourceFile", row: int) -> int:
|
|
47
|
+
"""Return the last line covered by a standalone ``disable`` at ``row``.
|
|
48
|
+
|
|
49
|
+
Walks downward from ``row + 1`` looking for ``# pylint: enable=...``.
|
|
50
|
+
If found, returns the line before it. If not found, returns either
|
|
51
|
+
``row + _MAX_SUPPRESSED_LINES`` or the last line of the file,
|
|
52
|
+
whichever comes first.
|
|
53
|
+
"""
|
|
54
|
+
total = len(source.lines)
|
|
55
|
+
limit = min(row + _MAX_SUPPRESSED_LINES, total)
|
|
56
|
+
for candidate in range(row + 1, limit + 1):
|
|
57
|
+
text = source.line_text(candidate)
|
|
58
|
+
if _ENABLE_RE.search(text):
|
|
59
|
+
return candidate - 1
|
|
60
|
+
return limit
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
|
|
64
|
+
"""Find suppression comments and attach source + justifying-comment context."""
|
|
65
|
+
findings: list[Finding] = []
|
|
66
|
+
for token in source.tokens:
|
|
67
|
+
if token.type != tokenize.COMMENT:
|
|
68
|
+
continue
|
|
69
|
+
if not _SUPPRESSION_RE.search(token.string):
|
|
70
|
+
continue
|
|
71
|
+
if _all_pylint_ids_covered(token.string, corpus.covered_pylint_ids):
|
|
72
|
+
continue
|
|
73
|
+
row = token.start[0]
|
|
74
|
+
jc = justifying_comment(source, row)
|
|
75
|
+
start = jc[0].start_line if jc else row
|
|
76
|
+
if _is_standalone_comment(source, row):
|
|
77
|
+
end = _find_enable_or_end(source, row)
|
|
78
|
+
else:
|
|
79
|
+
end = row
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(
|
|
82
|
+
check_id="suppression-comment",
|
|
83
|
+
path=source.path,
|
|
84
|
+
line=row,
|
|
85
|
+
column=token.start[1],
|
|
86
|
+
excerpts=(
|
|
87
|
+
SourceExcerpt(
|
|
88
|
+
path=source.path,
|
|
89
|
+
start_line=start,
|
|
90
|
+
end_line=end,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
notes=(),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return findings
|
|
@@ -13,6 +13,14 @@ python_surveyor.egg-info/dependency_links.txt
|
|
|
13
13
|
python_surveyor.egg-info/entry_points.txt
|
|
14
14
|
python_surveyor.egg-info/requires.txt
|
|
15
15
|
python_surveyor.egg-info/top_level.txt
|
|
16
|
+
python_surveyor/checks/__init__.py
|
|
17
|
+
python_surveyor/checks/_util.py
|
|
18
|
+
python_surveyor/checks/broad_except.py
|
|
19
|
+
python_surveyor/checks/fixture_naming.py
|
|
20
|
+
python_surveyor/checks/future_annotations.py
|
|
21
|
+
python_surveyor/checks/nontoplevel_imports.py
|
|
22
|
+
python_surveyor/checks/optional_params.py
|
|
23
|
+
python_surveyor/checks/suppressions.py
|
|
16
24
|
tests/test_broad_except.py
|
|
17
25
|
tests/test_cli.py
|
|
18
26
|
tests/test_fixture_naming.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|