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.
- {python_surveyor-0.1.0/python_surveyor.egg-info → python_surveyor-0.3.0}/PKG-INFO +1 -1
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/pyproject.toml +4 -1
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/cli.py +4 -15
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/model.py +2 -9
- python_surveyor-0.3.0/python_surveyor/report.py +101 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/scanner.py +14 -3
- {python_surveyor-0.1.0 → python_surveyor-0.3.0/python_surveyor.egg-info}/PKG-INFO +1 -1
- python_surveyor-0.3.0/tests/test_broad_except.py +219 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_cli.py +0 -16
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_fixture_naming.py +28 -5
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_optional_params.py +61 -17
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_scanner.py +10 -4
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_suppressions.py +20 -5
- python_surveyor-0.1.0/python_surveyor/report.py +0 -137
- python_surveyor-0.1.0/tests/test_broad_except.py +0 -93
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/LICENSE +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/README.md +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/__init__.py +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor/__main__.py +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/SOURCES.txt +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/dependency_links.txt +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/entry_points.txt +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/requires.txt +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/top_level.txt +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/setup.cfg +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_future_annotations.py +0 -0
- {python_surveyor-0.1.0 → python_surveyor-0.3.0}/tests/test_nontoplevel_imports.py +0 -0
|
@@ -8,7 +8,7 @@ include = ["python_surveyor"]
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "python-surveyor"
|
|
11
|
-
version = "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
|
|
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=
|
|
59
|
-
help="Cap call-site samples in notes (default
|
|
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
|
-
|
|
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(
|
|
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
|
)
|
|
@@ -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
|
-
|
|
14
|
-
assert "
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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],
|
|
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
|
|
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],
|
|
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[
|
|
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],
|
|
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 "
|
|
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],
|
|
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 "
|
|
88
|
-
assert "using default" in notes
|
|
99
|
+
assert "and 5 other(s)" in notes
|
|
89
100
|
|
|
90
101
|
|
|
91
|
-
def
|
|
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
|
-
|
|
97
|
-
assert "
|
|
98
|
-
assert "
|
|
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],
|
|
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],
|
|
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],
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_surveyor-0.1.0 → python_surveyor-0.3.0}/python_surveyor.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|