python-surveyor 0.1.0__tar.gz → 0.2.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 (26) hide show
  1. {python_surveyor-0.1.0/python_surveyor.egg-info → python_surveyor-0.2.0}/PKG-INFO +1 -1
  2. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/pyproject.toml +4 -1
  3. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/cli.py +4 -15
  4. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/model.py +0 -2
  5. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/report.py +22 -62
  6. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/scanner.py +9 -3
  7. {python_surveyor-0.1.0 → python_surveyor-0.2.0/python_surveyor.egg-info}/PKG-INFO +1 -1
  8. python_surveyor-0.2.0/tests/test_broad_except.py +178 -0
  9. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_cli.py +0 -16
  10. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_fixture_naming.py +28 -5
  11. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_optional_params.py +50 -17
  12. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_scanner.py +10 -4
  13. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_suppressions.py +20 -5
  14. python_surveyor-0.1.0/tests/test_broad_except.py +0 -93
  15. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/LICENSE +0 -0
  16. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/README.md +0 -0
  17. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/__init__.py +0 -0
  18. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor/__main__.py +0 -0
  19. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor.egg-info/SOURCES.txt +0 -0
  20. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor.egg-info/dependency_links.txt +0 -0
  21. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor.egg-info/entry_points.txt +0 -0
  22. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor.egg-info/requires.txt +0 -0
  23. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/python_surveyor.egg-info/top_level.txt +0 -0
  24. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/setup.cfg +0 -0
  25. {python_surveyor-0.1.0 → python_surveyor-0.2.0}/tests/test_future_annotations.py +0 -0
  26. {python_surveyor-0.1.0 → python_surveyor-0.2.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.2.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.2.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()
@@ -37,7 +37,6 @@ class CallSite:
37
37
  class SourceExcerpt:
38
38
  """A literal slice of source worth showing verbatim."""
39
39
 
40
- label: str
41
40
  path: Path
42
41
  start_line: int
43
42
  end_line: int
@@ -51,7 +50,6 @@ class Finding:
51
50
  path: Path
52
51
  line: int
53
52
  column: int
54
- message: str
55
53
  excerpts: tuple[SourceExcerpt, ...]
56
54
  notes: tuple[str, ...]
57
55
 
@@ -1,16 +1,11 @@
1
- """Render a ``ScanResult`` as text or JSON.
1
+ """Render a ``ScanResult`` as text.
2
2
 
3
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).
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.
11
7
  """
12
8
 
13
- import json
14
9
  from pathlib import Path
15
10
  from typing import TextIO
16
11
 
@@ -65,6 +60,8 @@ def render_text(
65
60
  if spec is not None:
66
61
  stream.write(f"{spec.explanation}\n\n")
67
62
  counter += 1
63
+ prev_path: Path | None = None
64
+ prev_end: int | None = None
68
65
  for excerpt in finding.excerpts:
69
66
  lines, truncation = _read_excerpt_lines(
70
67
  excerpt.path,
@@ -72,66 +69,29 @@ def render_text(
72
69
  excerpt.end_line,
73
70
  max_excerpt_lines,
74
71
  )
75
- stream.write(
76
- f"({counter}) {excerpt.path}:"
77
- f"{excerpt.start_line}-{excerpt.end_line}\n"
72
+ is_continuation = (
73
+ prev_path == excerpt.path
74
+ and prev_end is not None
75
+ and excerpt.start_line > prev_end + 1
78
76
  )
77
+ if is_continuation:
78
+ stream.write(
79
+ f"... (redacted until lines "
80
+ f"{excerpt.start_line}-{excerpt.end_line}) ...\n"
81
+ )
82
+ else:
83
+ stream.write(
84
+ f"({counter}) {excerpt.path}:"
85
+ f"{excerpt.start_line}-{excerpt.end_line}\n"
86
+ )
79
87
  for line in lines:
80
88
  stream.write(f"{line}\n")
81
89
  if truncation:
82
90
  stream.write(f"... {truncation}\n")
91
+ prev_path = excerpt.path
92
+ prev_end = excerpt.end_line
83
93
  for note in finding.notes:
84
94
  stream.write(f" note: {note}\n")
85
95
  stream.write("\n")
86
96
  if not result.findings:
87
97
  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")
@@ -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)
@@ -186,7 +187,11 @@ def _collect_params(
186
187
  )
187
188
 
188
189
 
189
- def _build_corpus(source_files: list[SourceFile], max_call_sites: int) -> Corpus:
190
+ def _build_corpus(
191
+ source_files: list[SourceFile],
192
+ max_call_sites: int,
193
+ covered_pylint_ids: frozenset[str],
194
+ ) -> Corpus:
190
195
  call_sites: dict[str, list[CallSite]] = {}
191
196
  param_locations: dict[str, list[tuple[Location, str]]] = {}
192
197
  for source_file in source_files:
@@ -209,6 +214,7 @@ def _build_corpus(source_files: list[SourceFile], max_call_sites: int) -> Corpus
209
214
  call_sites={k: tuple(v) for k, v in call_sites.items()},
210
215
  param_locations={k: tuple(v) for k, v in param_locations.items()},
211
216
  max_call_sites=max_call_sites,
217
+ covered_pylint_ids=covered_pylint_ids,
212
218
  )
213
219
 
214
220
 
@@ -238,7 +244,7 @@ def scan(
238
244
  continue
239
245
  assert parsed is not None
240
246
  source_files.append(parsed)
241
- corpus = _build_corpus(source_files, max_call_sites)
247
+ corpus = _build_corpus(source_files, max_call_sites, COVERED_PYLINT_IDS)
242
248
  findings: list[Finding] = []
243
249
  for source_file in source_files:
244
250
  for check_spec in enabled_checks:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-surveyor
3
- Version: 0.1.0
3
+ Version: 0.2.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,178 @@
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_except_star_exception(make_source_file, empty_corpus):
67
+ source = (
68
+ "def f():\n"
69
+ " try:\n"
70
+ " pass\n"
71
+ " except* Exception:\n"
72
+ " pass\n"
73
+ )
74
+ source_file = make_source_file("a.py", source)
75
+ findings = run(source_file, empty_corpus)
76
+ assert len(findings) == 1
77
+ assert findings[0].check_id == "broad-except"
78
+
79
+
80
+ def test_except_body_excerpt(make_source_file, empty_corpus):
81
+ source = (
82
+ "def f():\n"
83
+ " try:\n"
84
+ " pass\n"
85
+ " except Exception:\n"
86
+ " log_error()\n"
87
+ " cleanup()\n"
88
+ )
89
+ source_file = make_source_file("a.py", source)
90
+ findings = run(source_file, empty_corpus)
91
+ assert len(findings) == 1
92
+ excerpts = findings[0].excerpts
93
+ assert len(excerpts) == 1
94
+ assert excerpts[0].start_line == 4
95
+ assert excerpts[0].end_line == 6
96
+
97
+
98
+ def test_except_long_try_body_split_excerpts(make_source_file, empty_corpus):
99
+ source = (
100
+ "def f():\n"
101
+ " try:\n"
102
+ + " pass\n" * 20
103
+ + " except Exception:\n"
104
+ + " pass\n"
105
+ )
106
+ source_file = make_source_file("a.py", source)
107
+ findings = run(source_file, empty_corpus)
108
+ assert len(findings) == 1
109
+ excerpts = findings[0].excerpts
110
+ assert len(excerpts) == 1
111
+ handler_line = 2 + 20 + 1
112
+ assert excerpts[0].start_line == handler_line
113
+ assert excerpts[0].end_line == handler_line + 1
114
+
115
+
116
+ def test_except_with_justifying_comment(make_source_file, empty_corpus):
117
+ source = (
118
+ "def f():\n"
119
+ "# We catch Exception here because the subprocess\n"
120
+ "# may already be dead during shutdown.\n"
121
+ " try:\n"
122
+ " pass\n"
123
+ " except Exception:\n"
124
+ " pass\n"
125
+ )
126
+ source_file = make_source_file("a.py", source)
127
+ findings = run(source_file, empty_corpus)
128
+ assert len(findings) == 1
129
+ excerpts = findings[0].excerpts
130
+ assert len(excerpts) == 2
131
+ assert excerpts[0].start_line == 2
132
+ assert excerpts[0].end_line == 4
133
+ assert excerpts[1].start_line == 6
134
+ assert excerpts[1].end_line == 7
135
+
136
+
137
+ def test_try_with_comments_after(make_source_file, empty_corpus):
138
+ source = (
139
+ "def f():\n"
140
+ " try:\n"
141
+ " # This is a comment about the try\n"
142
+ " # And another line\n"
143
+ " actual_code()\n"
144
+ " more_code()\n"
145
+ " except Exception:\n"
146
+ " pass\n"
147
+ )
148
+ source_file = make_source_file("a.py", source)
149
+ findings = run(source_file, empty_corpus)
150
+ assert len(findings) == 1
151
+ excerpts = findings[0].excerpts
152
+ assert len(excerpts) == 2
153
+ first = excerpts[0]
154
+ second = excerpts[1]
155
+ assert first.start_line == 2
156
+ assert first.end_line == 4
157
+ assert second.start_line == 7
158
+ assert second.end_line == 8
159
+
160
+
161
+ def test_try_with_comments_after_merged(make_source_file, empty_corpus):
162
+ source = (
163
+ "def f():\n"
164
+ " try:\n"
165
+ " # comment about try\n"
166
+ " pass\n"
167
+ " except Exception:\n"
168
+ " pass\n"
169
+ )
170
+ source_file = make_source_file("a.py", source)
171
+ findings = run(source_file, empty_corpus)
172
+ assert len(findings) == 1
173
+ excerpts = findings[0].excerpts
174
+ assert len(excerpts) == 2
175
+ assert excerpts[0].start_line == 2
176
+ assert excerpts[0].end_line == 3
177
+ assert excerpts[1].start_line == 5
178
+ 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,51 @@ 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_no_api_note(make_source_file, empty_corpus):
126
+ source = "def _helper(a=1):\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
+ notes = "\n".join(findings[0].notes)
131
+ assert "API" not in notes
@@ -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,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