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.
Files changed (33) hide show
  1. {python_surveyor-0.3.0/python_surveyor.egg-info → python_surveyor-0.4.1}/PKG-INFO +3 -1
  2. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/pyproject.toml +4 -2
  3. python_surveyor-0.4.1/python_surveyor/checks/__init__.py +152 -0
  4. python_surveyor-0.4.1/python_surveyor/checks/_util.py +73 -0
  5. python_surveyor-0.4.1/python_surveyor/checks/broad_except.py +168 -0
  6. python_surveyor-0.4.1/python_surveyor/checks/fixture_naming.py +134 -0
  7. python_surveyor-0.4.1/python_surveyor/checks/future_annotations.py +46 -0
  8. python_surveyor-0.4.1/python_surveyor/checks/nontoplevel_imports.py +125 -0
  9. python_surveyor-0.4.1/python_surveyor/checks/optional_params.py +311 -0
  10. python_surveyor-0.4.1/python_surveyor/checks/suppressions.py +96 -0
  11. {python_surveyor-0.3.0 → python_surveyor-0.4.1/python_surveyor.egg-info}/PKG-INFO +3 -1
  12. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/SOURCES.txt +8 -0
  13. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/requires.txt +2 -0
  14. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_optional_params.py +147 -0
  15. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/LICENSE +0 -0
  16. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/README.md +0 -0
  17. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/__init__.py +0 -0
  18. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/__main__.py +0 -0
  19. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/cli.py +0 -0
  20. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/model.py +0 -0
  21. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/report.py +0 -0
  22. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor/scanner.py +0 -0
  23. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/dependency_links.txt +0 -0
  24. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/entry_points.txt +0 -0
  25. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/top_level.txt +0 -0
  26. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/setup.cfg +0 -0
  27. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_broad_except.py +0 -0
  28. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_cli.py +0 -0
  29. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_fixture_naming.py +0 -0
  30. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_future_annotations.py +0 -0
  31. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_nontoplevel_imports.py +0 -0
  32. {python_surveyor-0.3.0 → python_surveyor-0.4.1}/tests/test_scanner.py +0 -0
  33. {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.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.3.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