python-surveyor 0.1.0__tar.gz → 0.3.0__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 (27) hide show
  1. {python_surveyor-0.1.0/python_surveyor.egg-info → python_surveyor-0.3.0}/PKG-INFO +1 -1
  2. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/pyproject.toml +4 -1
  3. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/cli.py +4 -15
  4. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/model.py +2 -9
  5. python_surveyor-0.3.0/python_surveyor/report.py +101 -0
  6. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/scanner.py +14 -3
  7. {python_surveyor-0.1.0 → python_surveyor-0.3.0/python_surveyor.egg-info}/PKG-INFO +1 -1
  8. python_surveyor-0.3.0/tests/test_broad_except.py +219 -0
  9. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_cli.py +0 -16
  10. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_fixture_naming.py +28 -5
  11. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_optional_params.py +61 -17
  12. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_scanner.py +10 -4
  13. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_suppressions.py +20 -5
  14. python_surveyor-0.1.0/python_surveyor/report.py +0 -137
  15. python_surveyor-0.1.0/tests/test_broad_except.py +0 -93
  16. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/LICENSE +0 -0
  17. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/README.md +0 -0
  18. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/__init__.py +0 -0
  19. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/__main__.py +0 -0
  20. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/SOURCES.txt +0 -0
  21. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/dependency_links.txt +0 -0
  22. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/entry_points.txt +0 -0
  23. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/requires.txt +0 -0
  24. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/top_level.txt +0 -0
  25. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/setup.cfg +0 -0
  26. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_future_annotations.py +0 -0
  27. {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_nontoplevel_imports.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-surveyor
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Use python introspection to survey source code for final LLM judgement
5
5
  Author: Dave Cunningham
6
6
  License-Expression: MIT
@@ -8,7 +8,7 @@ include = ["python_surveyor"]
8
8
 
9
9
  [project]
10
10
  name = "python-surveyor"
11
- version = "0.1.0"
11
+ version = "0.3.0"
12
12
  description = "Use python introspection to survey source code for final LLM judgement"
13
13
  requires-python = ">=3.12"
14
14
  license = "MIT"
@@ -82,3 +82,6 @@ disable = [
82
82
  "duplicate-code",
83
83
  "fixme",
84
84
  ]
85
+ enable = [
86
+ "useless-suppression",
87
+ ]
@@ -7,7 +7,7 @@ from typing import TextIO
7
7
  import click
8
8
 
9
9
  from python_surveyor.checks import ALL_CHECKS
10
- from python_surveyor.report import render_json, render_text
10
+ from python_surveyor.report import render_text
11
11
  from python_surveyor.scanner import scan
12
12
 
13
13
 
@@ -30,13 +30,6 @@ def main() -> None:
30
30
  multiple=True,
31
31
  help="fnmatch pattern to exclude (repeatable, additive to defaults).",
32
32
  )
33
- @click.option(
34
- "--format",
35
- "fmt",
36
- type=click.Choice(["text", "json"]),
37
- default="text",
38
- help="Output format. Default: text.",
39
- )
40
33
  @click.option(
41
34
  "--output",
42
35
  "output",
@@ -55,14 +48,13 @@ def main() -> None:
55
48
  "--max-call-sites",
56
49
  "max_call_sites",
57
50
  type=int,
58
- default=5,
59
- help="Cap call-site samples in notes (default 5).",
51
+ default=10,
52
+ help="Cap call-site samples in notes (default 10).",
60
53
  )
61
54
  def scan_cmd(
62
55
  paths: tuple[str, ...],
63
56
  check_ids: tuple[str, ...],
64
57
  excludes: tuple[str, ...],
65
- fmt: str,
66
58
  output: "Path | None",
67
59
  max_excerpt_lines: int,
68
60
  max_call_sites: int,
@@ -81,10 +73,7 @@ def scan_cmd(
81
73
  else:
82
74
  stream = sys.stdout
83
75
  try:
84
- if fmt == "json":
85
- render_json(result, max_excerpt_lines, stream)
86
- else:
87
- render_text(result, max_excerpt_lines, stream)
76
+ render_text(result, max_excerpt_lines, stream)
88
77
  finally:
89
78
  if output is not None:
90
79
  stream.close()
@@ -1,9 +1,4 @@
1
- """Frozen dataclasses shared by scanner, checks, and report renderers.
2
-
3
- All construction uses required positional fields; there are no
4
- default-valued fields on ``Finding``/``SourceExcerpt``, consistent with the
5
- "no optional params on internal APIs" rule this tool itself checks for.
6
- """
1
+ """Frozen dataclasses shared by scanner, checks, and report renderers."""
7
2
 
8
3
  from dataclasses import dataclass
9
4
  from pathlib import Path
@@ -37,7 +32,6 @@ class CallSite:
37
32
  class SourceExcerpt:
38
33
  """A literal slice of source worth showing verbatim."""
39
34
 
40
- label: str
41
35
  path: Path
42
36
  start_line: int
43
37
  end_line: int
@@ -51,9 +45,8 @@ class Finding:
51
45
  path: Path
52
46
  line: int
53
47
  column: int
54
- message: str
55
48
  excerpts: tuple[SourceExcerpt, ...]
56
- notes: tuple[str, ...]
49
+ notes: tuple[str, ...] = ()
57
50
 
58
51
 
59
52
  @dataclass(frozen=True)
@@ -0,0 +1,101 @@
1
+ """Render a ``ScanResult`` as text.
2
+
3
+ Text output groups findings by ``check_id``; each finding is shown as
4
+ ``path:line:col`` followed by excerpts (with the line range in the header)
5
+ and note lines. Parse errors get their own leading section. Excerpts are
6
+ capped to ``--max-excerpt-lines`` with a truncation note.
7
+ """
8
+
9
+ from typing import TextIO
10
+
11
+ from python_surveyor.checks import CHECKS_BY_ID
12
+ from python_surveyor.scanner import ScanResult
13
+
14
+
15
+ def _read_excerpt_lines(
16
+ source_lines: "dict[str, tuple[str, ...]]",
17
+ path_str: str,
18
+ start: int,
19
+ end: int,
20
+ max_lines: int,
21
+ ) -> "tuple[list[str], str | None]":
22
+ """Read ``[start, end]`` from ``source_lines``, capping to ``max_lines``.
23
+
24
+ Returns ``(lines, truncation_note)``.
25
+ """
26
+ all_lines = source_lines.get(path_str)
27
+ if all_lines is None:
28
+ return ([], None)
29
+ available_end = min(end, len(all_lines))
30
+ if available_end < start:
31
+ return ([], None)
32
+ span = available_end - start + 1
33
+ if span <= max_lines:
34
+ return (list(all_lines[start - 1 : available_end]), None)
35
+ capped_end = start + max_lines - 1
36
+ return (
37
+ list(all_lines[start - 1 : capped_end]),
38
+ f"excerpt truncated at {max_lines} lines",
39
+ )
40
+
41
+
42
+ def render_text(
43
+ result: ScanResult,
44
+ max_excerpt_lines: int,
45
+ stream: TextIO,
46
+ ) -> None:
47
+ """Render ``result`` as human-readable text to ``stream``."""
48
+ stream.write(f"# scan: {result.files_scanned} file(s) scanned\n\n")
49
+ if result.parse_errors:
50
+ stream.write("## parse errors\n\n")
51
+ for err in result.parse_errors:
52
+ stream.write(f"{err.path}:{err.line} -- {err.message}\n")
53
+ stream.write("\n")
54
+ current_check: str | None = None
55
+ counter = 0
56
+ for finding in result.findings:
57
+ if finding.check_id != current_check:
58
+ current_check = finding.check_id
59
+ counter = 0
60
+ stream.write(f"## {current_check}\n\n")
61
+ spec = CHECKS_BY_ID.get(current_check)
62
+ if spec is not None:
63
+ stream.write(f"{spec.explanation}\n\n")
64
+ counter += 1
65
+ prev_path_str: str | None = None
66
+ prev_end: int | None = None
67
+ for excerpt in finding.excerpts:
68
+ path_str = str(excerpt.path)
69
+ lines, truncation = _read_excerpt_lines(
70
+ result.source_lines,
71
+ path_str,
72
+ excerpt.start_line,
73
+ excerpt.end_line,
74
+ max_excerpt_lines,
75
+ )
76
+ is_continuation = (
77
+ prev_path_str == path_str
78
+ and prev_end is not None
79
+ and excerpt.start_line > prev_end + 1
80
+ )
81
+ if is_continuation:
82
+ stream.write(
83
+ f"... (redacted until lines "
84
+ f"{excerpt.start_line}-{excerpt.end_line}) ...\n"
85
+ )
86
+ else:
87
+ stream.write(
88
+ f"({counter}) {excerpt.path}:"
89
+ f"{excerpt.start_line}-{excerpt.end_line}\n"
90
+ )
91
+ for line in lines:
92
+ stream.write(f"{line}\n")
93
+ if truncation:
94
+ stream.write(f"... {truncation}\n")
95
+ prev_path_str = path_str
96
+ prev_end = excerpt.end_line
97
+ for note in finding.notes:
98
+ stream.write(f" note: {note}\n")
99
+ stream.write("\n")
100
+ if not result.findings:
101
+ stream.write("no findings.\n")
@@ -24,7 +24,7 @@ from collections.abc import Iterator
24
24
  from dataclasses import dataclass
25
25
  from pathlib import Path
26
26
 
27
- from python_surveyor.checks import ALL_CHECKS, CheckSpec
27
+ from python_surveyor.checks import ALL_CHECKS, COVERED_PYLINT_IDS, CheckSpec
28
28
  from python_surveyor.model import CallSite, Finding, Location, ParseError
29
29
 
30
30
  DEFAULT_PRUNED_DIRS: tuple[str, ...] = (
@@ -77,6 +77,7 @@ class Corpus:
77
77
  call_sites: dict[str, tuple[CallSite, ...]]
78
78
  param_locations: dict[str, tuple[tuple[Location, str], ...]]
79
79
  max_call_sites: int
80
+ covered_pylint_ids: frozenset[str]
80
81
 
81
82
 
82
83
  @dataclass(frozen=True)
@@ -86,6 +87,7 @@ class ScanResult:
86
87
  findings: tuple[Finding, ...]
87
88
  parse_errors: tuple[ParseError, ...]
88
89
  files_scanned: int
90
+ source_lines: "dict[str, tuple[str, ...]]"
89
91
 
90
92
 
91
93
  def _discover(root_paths: tuple[str, ...], excludes: tuple[str, ...]) -> Iterator[Path]:
@@ -175,6 +177,9 @@ def _collect_params(
175
177
  args = func_node.args
176
178
  skip_names = {"self", "cls"}
177
179
  names: list[str] = []
180
+ for arg in args.posonlyargs:
181
+ if arg.arg not in skip_names:
182
+ names.append(arg.arg)
178
183
  for arg in args.args:
179
184
  if arg.arg not in skip_names:
180
185
  names.append(arg.arg)
@@ -186,7 +191,11 @@ def _collect_params(
186
191
  )
187
192
 
188
193
 
189
- def _build_corpus(source_files: list[SourceFile], max_call_sites: int) -> Corpus:
194
+ def _build_corpus(
195
+ source_files: list[SourceFile],
196
+ max_call_sites: int,
197
+ covered_pylint_ids: frozenset[str],
198
+ ) -> Corpus:
190
199
  call_sites: dict[str, list[CallSite]] = {}
191
200
  param_locations: dict[str, list[tuple[Location, str]]] = {}
192
201
  for source_file in source_files:
@@ -209,6 +218,7 @@ def _build_corpus(source_files: list[SourceFile], max_call_sites: int) -> Corpus
209
218
  call_sites={k: tuple(v) for k, v in call_sites.items()},
210
219
  param_locations={k: tuple(v) for k, v in param_locations.items()},
211
220
  max_call_sites=max_call_sites,
221
+ covered_pylint_ids=covered_pylint_ids,
212
222
  )
213
223
 
214
224
 
@@ -238,7 +248,7 @@ def scan(
238
248
  continue
239
249
  assert parsed is not None
240
250
  source_files.append(parsed)
241
- corpus = _build_corpus(source_files, max_call_sites)
251
+ corpus = _build_corpus(source_files, max_call_sites, COVERED_PYLINT_IDS)
242
252
  findings: list[Finding] = []
243
253
  for source_file in source_files:
244
254
  for check_spec in enabled_checks:
@@ -248,4 +258,5 @@ def scan(
248
258
  findings=tuple(findings),
249
259
  parse_errors=tuple(parse_errors),
250
260
  files_scanned=len(source_files),
261
+ source_lines={str(f.path): f.lines for f in source_files},
251
262
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-surveyor
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Use python introspection to survey source code for final LLM judgement
5
5
  Author: Dave Cunningham
6
6
  License-Expression: MIT
@@ -0,0 +1,219 @@
1
+ """Tests for the ``broad-except`` check."""
2
+
3
+ from python_surveyor.checks.broad_except import run
4
+
5
+
6
+ def test_except_exception(make_source_file, empty_corpus):
7
+ source = (
8
+ "def f():\n"
9
+ " try:\n"
10
+ " pass\n"
11
+ " except Exception:\n"
12
+ " pass\n"
13
+ )
14
+ source_file = make_source_file("a.py", source)
15
+ findings = run(source_file, empty_corpus)
16
+ assert len(findings) == 1
17
+ assert findings[0].check_id == "broad-except"
18
+
19
+
20
+ def test_except_bare(make_source_file, empty_corpus):
21
+ source = "def f():\n" " try:\n" " pass\n" " except:\n" " pass\n"
22
+ source_file = make_source_file("a.py", source)
23
+ findings = run(source_file, empty_corpus)
24
+ assert len(findings) == 1
25
+
26
+
27
+ def test_except_baseexception(make_source_file, empty_corpus):
28
+ source = (
29
+ "def f():\n"
30
+ " try:\n"
31
+ " pass\n"
32
+ " except BaseException:\n"
33
+ " pass\n"
34
+ )
35
+ source_file = make_source_file("a.py", source)
36
+ findings = run(source_file, empty_corpus)
37
+ assert len(findings) == 1
38
+
39
+
40
+ def test_except_tuple_with_exception(make_source_file, empty_corpus):
41
+ source = (
42
+ "def f():\n"
43
+ " try:\n"
44
+ " pass\n"
45
+ " except (Exception, ValueError):\n"
46
+ " pass\n"
47
+ )
48
+ source_file = make_source_file("a.py", source)
49
+ findings = run(source_file, empty_corpus)
50
+ assert len(findings) == 1
51
+
52
+
53
+ def test_except_specific_not_flagged(make_source_file, empty_corpus):
54
+ source = (
55
+ "def f():\n"
56
+ " try:\n"
57
+ " pass\n"
58
+ " except ValueError:\n"
59
+ " pass\n"
60
+ )
61
+ source_file = make_source_file("a.py", source)
62
+ findings = run(source_file, empty_corpus)
63
+ assert findings == []
64
+
65
+
66
+ def test_bare_reraise_not_flagged(make_source_file, empty_corpus):
67
+ source = (
68
+ "def f():\n"
69
+ " try:\n"
70
+ " pass\n"
71
+ " except Exception:\n"
72
+ " raise\n"
73
+ )
74
+ source_file = make_source_file("a.py", source)
75
+ findings = run(source_file, empty_corpus)
76
+ assert findings == []
77
+
78
+
79
+ def test_logger_exception_not_flagged(make_source_file, empty_corpus):
80
+ source = (
81
+ "import logging\n"
82
+ "logger = logging.getLogger(__name__)\n"
83
+ "def f():\n"
84
+ " try:\n"
85
+ " pass\n"
86
+ " except Exception:\n"
87
+ " logger.exception('failed')\n"
88
+ )
89
+ source_file = make_source_file("a.py", source)
90
+ findings = run(source_file, empty_corpus)
91
+ assert findings == []
92
+
93
+
94
+ def test_reraise_with_value_still_flagged(make_source_file, empty_corpus):
95
+ source = (
96
+ "def f():\n"
97
+ " try:\n"
98
+ " pass\n"
99
+ " except Exception as e:\n"
100
+ " raise RuntimeError('bad') from e\n"
101
+ )
102
+ source_file = make_source_file("a.py", source)
103
+ findings = run(source_file, empty_corpus)
104
+ assert len(findings) == 1
105
+
106
+
107
+ def test_except_star_exception(make_source_file, empty_corpus):
108
+ source = (
109
+ "def f():\n"
110
+ " try:\n"
111
+ " pass\n"
112
+ " except* Exception:\n"
113
+ " pass\n"
114
+ )
115
+ source_file = make_source_file("a.py", source)
116
+ findings = run(source_file, empty_corpus)
117
+ assert len(findings) == 1
118
+ assert findings[0].check_id == "broad-except"
119
+
120
+
121
+ def test_except_body_excerpt(make_source_file, empty_corpus):
122
+ source = (
123
+ "def f():\n"
124
+ " try:\n"
125
+ " pass\n"
126
+ " except Exception:\n"
127
+ " log_error()\n"
128
+ " cleanup()\n"
129
+ )
130
+ source_file = make_source_file("a.py", source)
131
+ findings = run(source_file, empty_corpus)
132
+ assert len(findings) == 1
133
+ excerpts = findings[0].excerpts
134
+ assert len(excerpts) == 1
135
+ assert excerpts[0].start_line == 4
136
+ assert excerpts[0].end_line == 6
137
+
138
+
139
+ def test_except_long_try_body_split_excerpts(make_source_file, empty_corpus):
140
+ source = (
141
+ "def f():\n"
142
+ " try:\n"
143
+ + " pass\n" * 20
144
+ + " except Exception:\n"
145
+ + " pass\n"
146
+ )
147
+ source_file = make_source_file("a.py", source)
148
+ findings = run(source_file, empty_corpus)
149
+ assert len(findings) == 1
150
+ excerpts = findings[0].excerpts
151
+ assert len(excerpts) == 1
152
+ handler_line = 2 + 20 + 1
153
+ assert excerpts[0].start_line == handler_line
154
+ assert excerpts[0].end_line == handler_line + 1
155
+
156
+
157
+ def test_except_with_justifying_comment(make_source_file, empty_corpus):
158
+ source = (
159
+ "def f():\n"
160
+ "# We catch Exception here because the subprocess\n"
161
+ "# may already be dead during shutdown.\n"
162
+ " try:\n"
163
+ " pass\n"
164
+ " except Exception:\n"
165
+ " pass\n"
166
+ )
167
+ source_file = make_source_file("a.py", source)
168
+ findings = run(source_file, empty_corpus)
169
+ assert len(findings) == 1
170
+ excerpts = findings[0].excerpts
171
+ assert len(excerpts) == 2
172
+ assert excerpts[0].start_line == 2
173
+ assert excerpts[0].end_line == 4
174
+ assert excerpts[1].start_line == 6
175
+ assert excerpts[1].end_line == 7
176
+
177
+
178
+ def test_try_with_comments_after(make_source_file, empty_corpus):
179
+ source = (
180
+ "def f():\n"
181
+ " try:\n"
182
+ " # This is a comment about the try\n"
183
+ " # And another line\n"
184
+ " actual_code()\n"
185
+ " more_code()\n"
186
+ " except Exception:\n"
187
+ " pass\n"
188
+ )
189
+ source_file = make_source_file("a.py", source)
190
+ findings = run(source_file, empty_corpus)
191
+ assert len(findings) == 1
192
+ excerpts = findings[0].excerpts
193
+ assert len(excerpts) == 2
194
+ first = excerpts[0]
195
+ second = excerpts[1]
196
+ assert first.start_line == 2
197
+ assert first.end_line == 4
198
+ assert second.start_line == 7
199
+ assert second.end_line == 8
200
+
201
+
202
+ def test_try_with_comments_after_merged(make_source_file, empty_corpus):
203
+ source = (
204
+ "def f():\n"
205
+ " try:\n"
206
+ " # comment about try\n"
207
+ " pass\n"
208
+ " except Exception:\n"
209
+ " pass\n"
210
+ )
211
+ source_file = make_source_file("a.py", source)
212
+ findings = run(source_file, empty_corpus)
213
+ assert len(findings) == 1
214
+ excerpts = findings[0].excerpts
215
+ assert len(excerpts) == 2
216
+ assert excerpts[0].start_line == 2
217
+ assert excerpts[0].end_line == 3
218
+ assert excerpts[1].start_line == 5
219
+ assert excerpts[1].end_line == 6
@@ -1,7 +1,5 @@
1
1
  """End-to-end CLI tests using click's ``CliRunner``."""
2
2
 
3
- import json
4
-
5
3
  from click.testing import CliRunner
6
4
 
7
5
  from python_surveyor.cli import main
@@ -27,20 +25,6 @@ def test_scan_text(tmp_path):
27
25
  assert "file(s) scanned" in result.output
28
26
 
29
27
 
30
- def test_scan_json(tmp_path):
31
- (tmp_path / "a.py").write_text(
32
- "from __future__ import annotations\n", encoding="utf-8"
33
- )
34
- runner = CliRunner()
35
- result = runner.invoke(main, ["scan", str(tmp_path), "--format", "json"])
36
- assert result.exit_code == 0
37
- payload = json.loads(result.output)
38
- assert payload["files_scanned"] == 1
39
- assert any(
40
- f["check_id"] == "future-annotations-import" for f in payload["findings"]
41
- )
42
-
43
-
44
28
  def test_scan_no_findings(tmp_path):
45
29
  (tmp_path / "clean.py").write_text("CONST = 1\n", encoding="utf-8")
46
30
  runner = CliRunner()
@@ -1,5 +1,6 @@
1
1
  """Tests for the ``fixture-naming`` check."""
2
2
 
3
+ from python_surveyor.checks import COVERED_PYLINT_IDS
3
4
  from python_surveyor.checks.fixture_naming import run
4
5
  from python_surveyor.scanner import _build_corpus
5
6
 
@@ -10,8 +11,9 @@ def test_plain_fixture_flagged(make_source_file, empty_corpus):
10
11
  findings = run(source_file, empty_corpus)
11
12
  assert len(findings) == 1
12
13
  assert findings[0].check_id == "fixture-naming"
13
- assert "no `name=` kwarg" in findings[0].message
14
- assert "lacks `fixture_` prefix" in findings[0].message
14
+ notes = "\n".join(findings[0].notes)
15
+ assert "no `name=` kwarg" in notes
16
+ assert "lacks `fixture_` prefix" in notes
15
17
 
16
18
 
17
19
  def test_fixture_with_name_and_prefix_not_flagged(make_source_file, empty_corpus):
@@ -31,7 +33,8 @@ def test_fixture_prefix_without_name_flagged(make_source_file, empty_corpus):
31
33
  source_file = make_source_file("a.py", source)
32
34
  findings = run(source_file, empty_corpus)
33
35
  assert len(findings) == 1
34
- assert "no `name=` kwarg" in findings[0].message
36
+ notes = "\n".join(findings[0].notes)
37
+ assert "no `name=` kwarg" in notes
35
38
 
36
39
 
37
40
  def test_name_without_prefix_flagged(make_source_file, empty_corpus):
@@ -39,7 +42,8 @@ def test_name_without_prefix_flagged(make_source_file, empty_corpus):
39
42
  source_file = make_source_file("a.py", source)
40
43
  findings = run(source_file, empty_corpus)
41
44
  assert len(findings) == 1
42
- assert "lacks `fixture_` prefix" in findings[0].message
45
+ notes = "\n".join(findings[0].notes)
46
+ assert "lacks `fixture_` prefix" in notes
43
47
 
44
48
 
45
49
  def test_bare_fixture_decorator(make_source_file, empty_corpus):
@@ -66,9 +70,28 @@ def test_collision_note(make_source_file):
66
70
  " pass\n"
67
71
  )
68
72
  source_file = make_source_file("a.py", source)
69
- corpus = _build_corpus([source_file], max_call_sites=5)
73
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
70
74
  findings = run(source_file, corpus)
71
75
  assert findings
72
76
  note = findings[0].notes[0]
73
77
  assert "registered as `db`" in note
74
78
  assert "1 other function" in note
79
+
80
+
81
+ def test_autouse_fixture_not_flagged(make_source_file, empty_corpus):
82
+ source = (
83
+ "import pytest\n"
84
+ "@pytest.fixture(autouse=True)\n"
85
+ "def short_permission_ttl():\n"
86
+ " pass\n"
87
+ )
88
+ source_file = make_source_file("a.py", source)
89
+ findings = run(source_file, empty_corpus)
90
+ assert findings == []
91
+
92
+
93
+ def test_conftest_not_flagged(make_source_file, empty_corpus):
94
+ source = "import pytest\n" "@pytest.fixture\n" "def db():\n" " pass\n"
95
+ source_file = make_source_file("conftest.py", source)
96
+ findings = run(source_file, empty_corpus)
97
+ assert findings == []
@@ -1,5 +1,6 @@
1
1
  """Tests for the ``optional-param-default`` check."""
2
2
 
3
+ from python_surveyor.checks import COVERED_PYLINT_IDS
3
4
  from python_surveyor.checks.optional_params import run
4
5
  from python_surveyor.scanner import _build_corpus
5
6
 
@@ -10,7 +11,8 @@ def test_defaulted_positional_param(make_source_file, empty_corpus):
10
11
  findings = run(source_file, empty_corpus)
11
12
  assert len(findings) == 1
12
13
  assert findings[0].check_id == "optional-param-default"
13
- assert "`f`" in findings[0].message
14
+ notes = "\n".join(findings[0].notes)
15
+ assert "`f`" in notes
14
16
 
15
17
 
16
18
  def test_mixed_defaulted_positional(make_source_file, empty_corpus):
@@ -44,31 +46,29 @@ def test_args_kwargs_not_flagged(make_source_file, empty_corpus):
44
46
  def test_call_site_note_with_corpus(make_source_file):
45
47
  source = "def helper(a=1):\n" " pass\n" "def caller():\n" " helper()\n"
46
48
  source_file = make_source_file("a.py", source)
47
- corpus = _build_corpus([source_file], max_call_sites=5)
49
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
48
50
  findings = run(source_file, corpus)
49
51
  assert findings
50
52
  notes = "\n".join(findings[0].notes)
51
- assert "using default" in notes
52
- assert "explicit value" in notes
53
+ assert "using defaults" in notes
53
54
 
54
55
 
55
56
  def test_call_site_note_not_called(make_source_file):
56
- source = "def helper(a=1):\n pass\n"
57
+ source = "def helper(a=1):\n" " pass\n"
57
58
  source_file = make_source_file("a.py", source)
58
- corpus = _build_corpus([source_file], max_call_sites=5)
59
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
59
60
  findings = run(source_file, corpus)
60
61
  assert findings
61
- assert "not called from anywhere" in findings[0].notes[0]
62
+ assert "not called from anywhere" in findings[0].notes[1]
62
63
 
63
64
 
64
65
  def test_call_site_note_override(make_source_file):
65
66
  source = "def helper(a=1):\n" " pass\n" "def caller():\n" " helper(2)\n"
66
67
  source_file = make_source_file("a.py", source)
67
- corpus = _build_corpus([source_file], max_call_sites=5)
68
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
68
69
  findings = run(source_file, corpus)
69
70
  notes = "\n".join(findings[0].notes)
70
- assert "explicit value" in notes
71
- assert "using default" in notes
71
+ assert "override every defaulted parameter" in notes
72
72
 
73
73
 
74
74
  def test_call_site_note_mixed(make_source_file):
@@ -81,18 +81,62 @@ def test_call_site_note_mixed(make_source_file):
81
81
  " helper(2)\n"
82
82
  )
83
83
  source_file = make_source_file("a.py", source)
84
- corpus = _build_corpus([source_file], max_call_sites=5)
84
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
85
+ findings = run(source_file, corpus)
86
+ notes = "\n".join(findings[0].notes)
87
+ assert "using defaults" in notes
88
+ assert "1 of 2" in notes
89
+
90
+
91
+ def test_call_site_note_truncation(make_source_file):
92
+ source = "def helper(a=1):\n pass\n"
93
+ for i in range(15):
94
+ source += f"def c{i}():\n helper()\n"
95
+ source_file = make_source_file("a.py", source)
96
+ corpus = _build_corpus([source_file], 10, COVERED_PYLINT_IDS)
85
97
  findings = run(source_file, corpus)
86
98
  notes = "\n".join(findings[0].notes)
87
- assert "explicit value" in notes
88
- assert "using default" in notes
99
+ assert "and 5 other(s)" in notes
89
100
 
90
101
 
91
- def test_message_names_defaulted_params(make_source_file, empty_corpus):
102
+ def test_defaulted_param_names_in_notes(make_source_file, empty_corpus):
92
103
  source = "def f(a, b=2, *, c=3):\n pass\n"
93
104
  source_file = make_source_file("a.py", source)
94
105
  findings = run(source_file, empty_corpus)
95
106
  assert len(findings) == 1
96
- assert "b" in findings[0].message
97
- assert "c" in findings[0].message
98
- assert "a" not in findings[0].message.split(":")[-1]
107
+ notes = "\n".join(findings[0].notes)
108
+ assert "b" in notes
109
+ assert "c" in notes
110
+ # "a" should not appear in the defaulted-params note (it has no default)
111
+ defaulted_note = [n for n in findings[0].notes if "Defaulted params" in n][0]
112
+ assert "a" not in defaulted_note.split(":")[-1]
113
+
114
+
115
+ def test_signature_excerpt_not_full_body(make_source_file, empty_corpus):
116
+ source = "def f(a=1):\n" + " x = 1\n" * 30 + " return x\n"
117
+ source_file = make_source_file("a.py", source)
118
+ findings = run(source_file, empty_corpus)
119
+ assert len(findings) == 1
120
+ excerpt = findings[0].excerpts[0]
121
+ assert excerpt.start_line == 1
122
+ assert excerpt.end_line == 1
123
+
124
+
125
+ def test_signature_elides_non_defaulted_params(make_source_file, empty_corpus):
126
+ source = "def f(a, b, c=3):\n pass\n"
127
+ source_file = make_source_file("a.py", source)
128
+ findings = run(source_file, empty_corpus)
129
+ assert len(findings) == 1
130
+ excerpt = findings[0].excerpts[0]
131
+ assert excerpt.start_line == 1
132
+ assert excerpt.end_line == 1
133
+
134
+
135
+ def test_signature_elides_with_multiline_def(make_source_file, empty_corpus):
136
+ source = "def f(\n a,\n b,\n c=3,\n):\n pass\n"
137
+ source_file = make_source_file("a.py", source)
138
+ findings = run(source_file, empty_corpus)
139
+ assert len(findings) == 1
140
+ excerpt = findings[0].excerpts[0]
141
+ assert excerpt.start_line == 4
142
+ assert excerpt.end_line == 5
@@ -5,6 +5,7 @@ import dataclasses
5
5
 
6
6
  import pytest
7
7
 
8
+ from python_surveyor.checks import COVERED_PYLINT_IDS
8
9
  from python_surveyor.scanner import (
9
10
  Corpus,
10
11
  ScanResult,
@@ -122,7 +123,7 @@ def test_build_corpus_call_sites(tmp_path):
122
123
  tree=tree,
123
124
  tokens=(),
124
125
  )
125
- corpus = _build_corpus([source_file], max_call_sites=5)
126
+ corpus = _build_corpus([source_file], 5, COVERED_PYLINT_IDS)
126
127
  assert "helper" in corpus.call_sites
127
128
  assert "method" in corpus.call_sites
128
129
  assert corpus.call_sites["helper"][0].positional_count == 0
@@ -142,7 +143,7 @@ def test_build_corpus_call_args(tmp_path):
142
143
  tree=tree,
143
144
  tokens=(),
144
145
  )
145
- corpus = _build_corpus([source_file], max_call_sites=5)
146
+ corpus = _build_corpus([source_file], 5, COVERED_PYLINT_IDS)
146
147
  sites = corpus.call_sites["f"]
147
148
  assert len(sites) == 3
148
149
  assert sites[0].positional_count == 1
@@ -165,7 +166,7 @@ def test_build_corpus_param_locations(tmp_path):
165
166
  tree=tree,
166
167
  tokens=(),
167
168
  )
168
- corpus = _build_corpus([source_file], max_call_sites=5)
169
+ corpus = _build_corpus([source_file], 5, COVERED_PYLINT_IDS)
169
170
  assert "db" in corpus.param_locations
170
171
  assert len(corpus.param_locations["db"]) == 2
171
172
 
@@ -188,7 +189,12 @@ def test_scan_result_is_sorted(tmp_path):
188
189
 
189
190
 
190
191
  def test_corpus_is_immutable():
191
- corpus = Corpus(call_sites={}, param_locations={}, max_call_sites=5)
192
+ corpus = Corpus(
193
+ call_sites={},
194
+ param_locations={},
195
+ max_call_sites=5,
196
+ covered_pylint_ids=COVERED_PYLINT_IDS,
197
+ )
192
198
  with pytest.raises(dataclasses.FrozenInstanceError):
193
199
  corpus.max_call_sites = 10 # type: ignore[misc]
194
200
 
@@ -11,7 +11,8 @@ def test_pylint_disable_without_preceding_comment(make_source_file, empty_corpus
11
11
  finding = findings[0]
12
12
  assert finding.check_id == "suppression-comment"
13
13
  assert finding.notes == ()
14
- assert not any(e.label == "justifying comment" for e in finding.excerpts)
14
+ assert len(finding.excerpts) == 1
15
+ assert finding.excerpts[0].start_line == 1
15
16
 
16
17
 
17
18
  def test_pyright_ignore_without_preceding_comment(make_source_file, empty_corpus):
@@ -20,7 +21,8 @@ def test_pyright_ignore_without_preceding_comment(make_source_file, empty_corpus
20
21
  findings = run(source_file, empty_corpus)
21
22
  assert len(findings) == 1
22
23
  assert findings[0].notes == ()
23
- assert not any(e.label == "justifying comment" for e in findings[0].excerpts)
24
+ assert len(findings[0].excerpts) == 1
25
+ assert findings[0].excerpts[0].start_line == 1
24
26
 
25
27
 
26
28
  def test_pylint_disable_next_line(make_source_file, empty_corpus):
@@ -42,7 +44,6 @@ def test_suppression_with_preceding_comment(make_source_file, empty_corpus):
42
44
  assert len(findings) == 1
43
45
  assert findings[0].notes == ()
44
46
  excerpt = findings[0].excerpts[0]
45
- assert excerpt.label == "source"
46
47
  assert excerpt.start_line == 1
47
48
  assert excerpt.end_line == 3
48
49
 
@@ -113,10 +114,17 @@ def test_broad_exception_caught_not_flagged(make_source_file, empty_corpus):
113
114
  assert findings == []
114
115
 
115
116
 
116
- def test_broad_exception_caught_in_list_not_flagged(make_source_file, empty_corpus):
117
+ def test_broad_exception_caught_mixed_list_flagged(make_source_file, empty_corpus):
117
118
  source = "x = 1 # pylint: disable=broad-exception-caught,missing-docstring\n"
118
119
  source_file = make_source_file("a.py", source)
119
120
  findings = run(source_file, empty_corpus)
121
+ assert len(findings) == 1
122
+
123
+
124
+ def test_all_covered_in_list_not_flagged(make_source_file, empty_corpus):
125
+ source = "x = 1 # pylint: disable=broad-exception-caught,import-outside-toplevel\n"
126
+ source_file = make_source_file("a.py", source)
127
+ findings = run(source_file, empty_corpus)
120
128
  assert findings == []
121
129
 
122
130
 
@@ -127,8 +135,15 @@ def test_redefined_outer_name_not_flagged(make_source_file, empty_corpus):
127
135
  assert findings == []
128
136
 
129
137
 
130
- def test_redefined_outer_name_in_list_not_flagged(make_source_file, empty_corpus):
138
+ def test_redefined_outer_name_mixed_list_flagged(make_source_file, empty_corpus):
131
139
  source = "x = 1 # pylint: disable=redefined-outer-name,missing-docstring\n"
132
140
  source_file = make_source_file("a.py", source)
133
141
  findings = run(source_file, empty_corpus)
142
+ assert len(findings) == 1
143
+
144
+
145
+ def test_import_outside_toplevel_not_flagged(make_source_file, empty_corpus):
146
+ source = "x = 1 # pylint: disable=import-outside-toplevel\n"
147
+ source_file = make_source_file("a.py", source)
148
+ findings = run(source_file, empty_corpus)
134
149
  assert findings == []
@@ -1,137 +0,0 @@
1
- """Render a ``ScanResult`` as text or JSON.
2
-
3
- Text output groups findings by ``check_id``; each finding is shown as
4
- ``path:line:col -- message`` followed by labeled excerpts (with the line
5
- range in the header) and note lines. Parse errors get their own leading
6
- section. Excerpts are capped to ``--max-excerpt-lines`` with a truncation
7
- note.
8
-
9
- JSON output is a flat list of the same fields for programmatic use (excerpt
10
- text is not included — only the line range, so consumers can lazy-load).
11
- """
12
-
13
- import json
14
- from pathlib import Path
15
- from typing import TextIO
16
-
17
- from python_surveyor.checks import CHECKS_BY_ID
18
- from python_surveyor.scanner import ScanResult
19
-
20
-
21
- def _read_excerpt_lines(
22
- path: Path, start: int, end: int, max_lines: int
23
- ) -> "tuple[list[str], str | None]":
24
- """Read ``[start, end]`` from ``path``, capping to ``max_lines``.
25
-
26
- Returns ``(lines, truncation_note)``.
27
- """
28
- try:
29
- all_lines = path.read_text(encoding="utf-8").splitlines()
30
- except OSError:
31
- return ([], None)
32
- available_end = min(end, len(all_lines))
33
- if available_end < start:
34
- return ([], None)
35
- span = available_end - start + 1
36
- if span <= max_lines:
37
- return (all_lines[start - 1 : available_end], None)
38
- capped_end = start + max_lines - 1
39
- return (
40
- all_lines[start - 1 : capped_end],
41
- f"excerpt truncated at {max_lines} lines",
42
- )
43
-
44
-
45
- def render_text(
46
- result: ScanResult,
47
- max_excerpt_lines: int,
48
- stream: TextIO,
49
- ) -> None:
50
- """Render ``result`` as human-readable text to ``stream``."""
51
- stream.write(f"# scan: {result.files_scanned} file(s) scanned\n\n")
52
- if result.parse_errors:
53
- stream.write("## parse errors\n\n")
54
- for err in result.parse_errors:
55
- stream.write(f"{err.path}:{err.line} -- {err.message}\n")
56
- stream.write("\n")
57
- current_check: str | None = None
58
- counter = 0
59
- for finding in result.findings:
60
- if finding.check_id != current_check:
61
- current_check = finding.check_id
62
- counter = 0
63
- stream.write(f"## {current_check}\n\n")
64
- spec = CHECKS_BY_ID.get(current_check)
65
- if spec is not None:
66
- stream.write(f"{spec.explanation}\n\n")
67
- counter += 1
68
- for excerpt in finding.excerpts:
69
- lines, truncation = _read_excerpt_lines(
70
- excerpt.path,
71
- excerpt.start_line,
72
- excerpt.end_line,
73
- max_excerpt_lines,
74
- )
75
- stream.write(
76
- f"({counter}) {excerpt.path}:"
77
- f"{excerpt.start_line}-{excerpt.end_line}\n"
78
- )
79
- for line in lines:
80
- stream.write(f"{line}\n")
81
- if truncation:
82
- stream.write(f"... {truncation}\n")
83
- for note in finding.notes:
84
- stream.write(f" note: {note}\n")
85
- stream.write("\n")
86
- if not result.findings:
87
- stream.write("no findings.\n")
88
-
89
-
90
- def render_json(
91
- result: ScanResult,
92
- max_excerpt_lines: int,
93
- stream: TextIO,
94
- ) -> None:
95
- """Render ``result`` as JSON to ``stream``.
96
-
97
- ``max_excerpt_lines`` is accepted for API symmetry with
98
- :func:`render_text`; JSON emits ranges only, not source text.
99
- """
100
- del max_excerpt_lines
101
- findings_payload = []
102
- for finding in result.findings:
103
- excerpts_payload = [
104
- {
105
- "label": excerpt.label,
106
- "path": str(excerpt.path),
107
- "start_line": excerpt.start_line,
108
- "end_line": excerpt.end_line,
109
- }
110
- for excerpt in finding.excerpts
111
- ]
112
- findings_payload.append(
113
- {
114
- "check_id": finding.check_id,
115
- "path": str(finding.path),
116
- "line": finding.line,
117
- "column": finding.column,
118
- "message": finding.message,
119
- "excerpts": excerpts_payload,
120
- "notes": list(finding.notes),
121
- }
122
- )
123
- parse_errors_payload = [
124
- {
125
- "path": str(err.path),
126
- "line": err.line,
127
- "message": err.message,
128
- }
129
- for err in result.parse_errors
130
- ]
131
- payload = {
132
- "files_scanned": result.files_scanned,
133
- "findings": findings_payload,
134
- "parse_errors": parse_errors_payload,
135
- }
136
- json.dump(payload, stream, indent=2)
137
- stream.write("\n")
@@ -1,93 +0,0 @@
1
- """Tests for the ``broad-except`` check."""
2
-
3
- from python_surveyor.checks.broad_except import run
4
-
5
-
6
- def test_except_exception(make_source_file, empty_corpus):
7
- source = (
8
- "def f():\n"
9
- " try:\n"
10
- " pass\n"
11
- " except Exception:\n"
12
- " pass\n"
13
- )
14
- source_file = make_source_file("a.py", source)
15
- findings = run(source_file, empty_corpus)
16
- assert len(findings) == 1
17
- assert findings[0].check_id == "broad-except"
18
- assert "Exception" in findings[0].message
19
-
20
-
21
- def test_except_bare(make_source_file, empty_corpus):
22
- source = "def f():\n" " try:\n" " pass\n" " except:\n" " pass\n"
23
- source_file = make_source_file("a.py", source)
24
- findings = run(source_file, empty_corpus)
25
- assert len(findings) == 1
26
- assert "bare" in findings[0].message
27
-
28
-
29
- def test_except_baseexception(make_source_file, empty_corpus):
30
- source = (
31
- "def f():\n"
32
- " try:\n"
33
- " pass\n"
34
- " except BaseException:\n"
35
- " pass\n"
36
- )
37
- source_file = make_source_file("a.py", source)
38
- findings = run(source_file, empty_corpus)
39
- assert len(findings) == 1
40
-
41
-
42
- def test_except_tuple_with_exception(make_source_file, empty_corpus):
43
- source = (
44
- "def f():\n"
45
- " try:\n"
46
- " pass\n"
47
- " except (Exception, ValueError):\n"
48
- " pass\n"
49
- )
50
- source_file = make_source_file("a.py", source)
51
- findings = run(source_file, empty_corpus)
52
- assert len(findings) == 1
53
-
54
-
55
- def test_except_specific_not_flagged(make_source_file, empty_corpus):
56
- source = (
57
- "def f():\n"
58
- " try:\n"
59
- " pass\n"
60
- " except ValueError:\n"
61
- " pass\n"
62
- )
63
- source_file = make_source_file("a.py", source)
64
- findings = run(source_file, empty_corpus)
65
- assert findings == []
66
-
67
-
68
- def test_except_star_exception(make_source_file, empty_corpus):
69
- source = (
70
- "def f():\n"
71
- " try:\n"
72
- " pass\n"
73
- " except* Exception:\n"
74
- " pass\n"
75
- )
76
- source_file = make_source_file("a.py", source)
77
- findings = run(source_file, empty_corpus)
78
- assert len(findings) == 1
79
- assert "except*" in findings[0].message
80
-
81
-
82
- def test_except_body_excerpt(make_source_file, empty_corpus):
83
- source = (
84
- "def f():\n"
85
- " try:\n"
86
- " pass\n"
87
- " except Exception:\n"
88
- " log_error()\n"
89
- " cleanup()\n"
90
- )
91
- source_file = make_source_file("a.py", source)
92
- findings = run(source_file, empty_corpus)
93
- assert findings[0].excerpts[0].label == "source"
File without changes