pyclichecker 2.4.1__py3-none-any.whl
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.
- pyclichecker/__init__.py +29 -0
- pyclichecker/__main__.py +5 -0
- pyclichecker/_version.py +10 -0
- pyclichecker/cli.py +260 -0
- pyclichecker/config.py +23 -0
- pyclichecker/diagnostics.py +149 -0
- pyclichecker/discovery.py +150 -0
- pyclichecker/rules.py +2286 -0
- pyclichecker-2.4.1.dist-info/METADATA +219 -0
- pyclichecker-2.4.1.dist-info/RECORD +13 -0
- pyclichecker-2.4.1.dist-info/WHEEL +4 -0
- pyclichecker-2.4.1.dist-info/entry_points.txt +3 -0
- pyclichecker-2.4.1.dist-info/licenses/LICENSE +21 -0
pyclichecker/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public API for pyclichecker."""
|
|
2
|
+
|
|
3
|
+
from pyclichecker._version import VERSION, __version__
|
|
4
|
+
from pyclichecker.cli import (
|
|
5
|
+
EXIT_CLEAN,
|
|
6
|
+
EXIT_FINDINGS,
|
|
7
|
+
EXIT_OPERATIONAL_ERROR,
|
|
8
|
+
main,
|
|
9
|
+
)
|
|
10
|
+
from pyclichecker.config import LintConfig
|
|
11
|
+
from pyclichecker.diagnostics import RULES, Finding, Rule
|
|
12
|
+
from pyclichecker.discovery import discover_python_files, lint_files
|
|
13
|
+
from pyclichecker.rules import lint_source
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RULES",
|
|
17
|
+
"VERSION",
|
|
18
|
+
"EXIT_CLEAN",
|
|
19
|
+
"EXIT_FINDINGS",
|
|
20
|
+
"EXIT_OPERATIONAL_ERROR",
|
|
21
|
+
"Finding",
|
|
22
|
+
"LintConfig",
|
|
23
|
+
"Rule",
|
|
24
|
+
"__version__",
|
|
25
|
+
"discover_python_files",
|
|
26
|
+
"lint_files",
|
|
27
|
+
"lint_source",
|
|
28
|
+
"main",
|
|
29
|
+
]
|
pyclichecker/__main__.py
ADDED
pyclichecker/_version.py
ADDED
pyclichecker/cli.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Command-line interface for pyclichecker."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from pyclichecker._version import VERSION
|
|
9
|
+
from pyclichecker.config import LintConfig, parse_rule_codes
|
|
10
|
+
from pyclichecker.diagnostics import RULES, Finding
|
|
11
|
+
from pyclichecker.discovery import discover_python_files, lint_files
|
|
12
|
+
|
|
13
|
+
EXIT_CLEAN = 0
|
|
14
|
+
EXIT_FINDINGS = 1
|
|
15
|
+
EXIT_OPERATIONAL_ERROR = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _expand_codes(value: str, parser: argparse.ArgumentParser) -> set[str]:
|
|
19
|
+
requested = parse_rule_codes(value)
|
|
20
|
+
if not requested or "ALL" in requested:
|
|
21
|
+
return set(RULES)
|
|
22
|
+
|
|
23
|
+
expanded: set[str] = set()
|
|
24
|
+
unknown: list[str] = []
|
|
25
|
+
for item in sorted(requested):
|
|
26
|
+
matches = {code for code in RULES if code == item or code.startswith(item)}
|
|
27
|
+
if matches:
|
|
28
|
+
expanded.update(matches)
|
|
29
|
+
else:
|
|
30
|
+
unknown.append(item)
|
|
31
|
+
if unknown:
|
|
32
|
+
parser.error(f"unknown rule code or prefix: {', '.join(unknown)}")
|
|
33
|
+
return expanded
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _positive_int(value: str) -> int:
|
|
37
|
+
parsed = int(value)
|
|
38
|
+
if parsed < 0:
|
|
39
|
+
raise argparse.ArgumentTypeError("must be zero or greater")
|
|
40
|
+
return parsed
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _add_threshold_arguments(parser: argparse.ArgumentParser) -> None:
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--max-function-lines",
|
|
46
|
+
type=_positive_int,
|
|
47
|
+
default=80,
|
|
48
|
+
metavar="N",
|
|
49
|
+
help="SLP008 threshold; zero disables the rule (default: 80)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--narrating-comments",
|
|
53
|
+
type=_positive_int,
|
|
54
|
+
default=3,
|
|
55
|
+
metavar="N",
|
|
56
|
+
help="SLP007 threshold; zero disables the rule (default: 3)",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--duplicate-min-statements",
|
|
60
|
+
type=_positive_int,
|
|
61
|
+
default=4,
|
|
62
|
+
metavar="N",
|
|
63
|
+
help="minimum statements for SLP005 (default: 4)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--duplicate-min-lines",
|
|
67
|
+
type=_positive_int,
|
|
68
|
+
default=6,
|
|
69
|
+
metavar="N",
|
|
70
|
+
help="minimum function lines for SLP005 (default: 6)",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
75
|
+
"""Build the command-line parser."""
|
|
76
|
+
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
prog="pyclichecker",
|
|
79
|
+
description="Read-only linter for high-signal AI-generated Python code smells.",
|
|
80
|
+
epilog=(
|
|
81
|
+
"Examples:\n"
|
|
82
|
+
" pyclichecker .\n"
|
|
83
|
+
" pyclichecker src tests --ignore SLP004,SLP008\n"
|
|
84
|
+
" pyclichecker app.py --format json\n"
|
|
85
|
+
"Inline suppression: # noqa: SLP003"
|
|
86
|
+
),
|
|
87
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"paths",
|
|
91
|
+
nargs="*",
|
|
92
|
+
help="Python files or directories (default: .)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--select",
|
|
96
|
+
default="ALL",
|
|
97
|
+
help="comma-separated rule codes or prefixes (default: ALL)",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--ignore",
|
|
101
|
+
default="",
|
|
102
|
+
help="comma-separated rule codes or prefixes to ignore",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--exclude",
|
|
106
|
+
action="append",
|
|
107
|
+
default=[],
|
|
108
|
+
metavar="GLOB",
|
|
109
|
+
help="exclude a relative path glob; may be repeated",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--format",
|
|
113
|
+
choices=("text", "json", "github"),
|
|
114
|
+
default="text",
|
|
115
|
+
help="diagnostic output format",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--fail-on",
|
|
119
|
+
choices=("warning", "error", "never"),
|
|
120
|
+
default="warning",
|
|
121
|
+
help="minimum severity that produces exit 1",
|
|
122
|
+
)
|
|
123
|
+
_add_threshold_arguments(parser)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--list-rules",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="list rules and exit",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
|
130
|
+
return parser
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print_rules() -> None:
|
|
134
|
+
for rule in RULES.values():
|
|
135
|
+
print(f"{rule.code} {rule.severity:<7} {rule.title}: {rule.description}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _render_text(
|
|
139
|
+
findings: Sequence[Finding],
|
|
140
|
+
*,
|
|
141
|
+
files_checked: int,
|
|
142
|
+
errors: Sequence[str],
|
|
143
|
+
) -> None:
|
|
144
|
+
for finding in findings:
|
|
145
|
+
print(
|
|
146
|
+
f"{finding.path}:{finding.line}:{finding.column}: "
|
|
147
|
+
f"{finding.code} {finding.message}"
|
|
148
|
+
)
|
|
149
|
+
for error in errors:
|
|
150
|
+
print(f"pyclichecker: {error}", file=sys.stderr)
|
|
151
|
+
|
|
152
|
+
if findings:
|
|
153
|
+
print(f"Found {len(findings)} issue(s) in {files_checked} file(s).")
|
|
154
|
+
elif not errors:
|
|
155
|
+
print(f"No AI-slop findings in {files_checked} file(s).")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _render_json(
|
|
159
|
+
findings: Sequence[Finding],
|
|
160
|
+
*,
|
|
161
|
+
files_checked: int,
|
|
162
|
+
errors: Sequence[str],
|
|
163
|
+
) -> None:
|
|
164
|
+
print(
|
|
165
|
+
json.dumps(
|
|
166
|
+
{
|
|
167
|
+
"version": VERSION,
|
|
168
|
+
"files_checked": files_checked,
|
|
169
|
+
"findings": [finding.as_dict() for finding in findings],
|
|
170
|
+
"errors": list(errors),
|
|
171
|
+
},
|
|
172
|
+
indent=2,
|
|
173
|
+
sort_keys=True,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _github_property(value: str) -> str:
|
|
179
|
+
return (
|
|
180
|
+
value.replace("%", "%25")
|
|
181
|
+
.replace("\r", "%0D")
|
|
182
|
+
.replace("\n", "%0A")
|
|
183
|
+
.replace(":", "%3A")
|
|
184
|
+
.replace(",", "%2C")
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _github_message(value: str) -> str:
|
|
189
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _render_github(
|
|
193
|
+
findings: Sequence[Finding],
|
|
194
|
+
*,
|
|
195
|
+
errors: Sequence[str],
|
|
196
|
+
) -> None:
|
|
197
|
+
for finding in findings:
|
|
198
|
+
message = _github_message(f"{finding.code} {finding.message}")
|
|
199
|
+
path = _github_property(finding.path)
|
|
200
|
+
print(
|
|
201
|
+
f"::{finding.severity} file={path},line={finding.line},"
|
|
202
|
+
f"col={finding.column}::{message}"
|
|
203
|
+
)
|
|
204
|
+
for error in errors:
|
|
205
|
+
print(f"::error title=pyclichecker::{_github_message(error)}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _findings_fail(findings: Sequence[Finding], fail_on: str) -> bool:
|
|
209
|
+
if fail_on == "never":
|
|
210
|
+
return False
|
|
211
|
+
if fail_on == "warning":
|
|
212
|
+
return bool(findings)
|
|
213
|
+
return any(finding.severity == "error" for finding in findings)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
217
|
+
"""Run the command-line interface and return its process exit code."""
|
|
218
|
+
|
|
219
|
+
parser = build_parser()
|
|
220
|
+
arguments = parser.parse_args(argv)
|
|
221
|
+
|
|
222
|
+
if arguments.list_rules:
|
|
223
|
+
_print_rules()
|
|
224
|
+
return EXIT_CLEAN
|
|
225
|
+
|
|
226
|
+
selected = _expand_codes(arguments.select, parser)
|
|
227
|
+
ignored = _expand_codes(arguments.ignore, parser) if arguments.ignore else set()
|
|
228
|
+
config = LintConfig(
|
|
229
|
+
enabled_codes=frozenset(selected - ignored),
|
|
230
|
+
max_function_lines=arguments.max_function_lines,
|
|
231
|
+
narrating_comment_threshold=arguments.narrating_comments,
|
|
232
|
+
duplicate_min_statements=arguments.duplicate_min_statements,
|
|
233
|
+
duplicate_min_lines=arguments.duplicate_min_lines,
|
|
234
|
+
)
|
|
235
|
+
files, use_stdin, discovery_errors = discover_python_files(
|
|
236
|
+
arguments.paths,
|
|
237
|
+
exclude_patterns=arguments.exclude,
|
|
238
|
+
)
|
|
239
|
+
if not files and not use_stdin and not discovery_errors:
|
|
240
|
+
discovery_errors.append("no Python files found in the requested paths")
|
|
241
|
+
|
|
242
|
+
findings, files_checked, read_errors = lint_files(
|
|
243
|
+
files,
|
|
244
|
+
use_stdin=use_stdin,
|
|
245
|
+
config=config,
|
|
246
|
+
)
|
|
247
|
+
errors = [*discovery_errors, *read_errors]
|
|
248
|
+
|
|
249
|
+
if arguments.format == "json":
|
|
250
|
+
_render_json(findings, files_checked=files_checked, errors=errors)
|
|
251
|
+
elif arguments.format == "github":
|
|
252
|
+
_render_github(findings, errors=errors)
|
|
253
|
+
else:
|
|
254
|
+
_render_text(findings, files_checked=files_checked, errors=errors)
|
|
255
|
+
|
|
256
|
+
if errors:
|
|
257
|
+
return EXIT_OPERATIONAL_ERROR
|
|
258
|
+
if _findings_fail(findings, arguments.fail_on):
|
|
259
|
+
return EXIT_FINDINGS
|
|
260
|
+
return EXIT_CLEAN
|
pyclichecker/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for lint rules."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pyclichecker.diagnostics import RULES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class LintConfig:
|
|
11
|
+
"""Resolved configuration for one lint run."""
|
|
12
|
+
|
|
13
|
+
enabled_codes: frozenset[str] = frozenset(RULES)
|
|
14
|
+
max_function_lines: int = 80
|
|
15
|
+
narrating_comment_threshold: int = 3
|
|
16
|
+
duplicate_min_statements: int = 4
|
|
17
|
+
duplicate_min_lines: int = 6
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_rule_codes(value: str) -> set[str]:
|
|
21
|
+
"""Parse a comma- or whitespace-separated list of rule codes."""
|
|
22
|
+
|
|
23
|
+
return {item.strip().upper() for item in re.split(r"[,\s]+", value) if item.strip()}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Rule metadata and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class Rule:
|
|
8
|
+
"""Metadata for one lint rule."""
|
|
9
|
+
|
|
10
|
+
code: str
|
|
11
|
+
severity: str
|
|
12
|
+
title: str
|
|
13
|
+
description: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RULES = {
|
|
17
|
+
rule.code: rule
|
|
18
|
+
for rule in (
|
|
19
|
+
Rule("SLP000", "error", "invalid-python", "Python source cannot be parsed."),
|
|
20
|
+
Rule(
|
|
21
|
+
"SLP001",
|
|
22
|
+
"error",
|
|
23
|
+
"placeholder-implementation",
|
|
24
|
+
"Concrete function contains only pass, ellipsis, or NotImplementedError.",
|
|
25
|
+
),
|
|
26
|
+
Rule(
|
|
27
|
+
"SLP002",
|
|
28
|
+
"error",
|
|
29
|
+
"swallowed-exception",
|
|
30
|
+
"Exception handler silently discards an exception.",
|
|
31
|
+
),
|
|
32
|
+
Rule(
|
|
33
|
+
"SLP003",
|
|
34
|
+
"warning",
|
|
35
|
+
"broad-exception-fallback",
|
|
36
|
+
"Broad exception handler converts unexpected failures into fallback behavior.",
|
|
37
|
+
),
|
|
38
|
+
Rule(
|
|
39
|
+
"SLP004",
|
|
40
|
+
"warning",
|
|
41
|
+
"fake-async",
|
|
42
|
+
"Async function contains no await, async iteration, async context, or yield.",
|
|
43
|
+
),
|
|
44
|
+
Rule(
|
|
45
|
+
"SLP005",
|
|
46
|
+
"warning",
|
|
47
|
+
"duplicate-implementation",
|
|
48
|
+
"Function body duplicates another implementation in the same file.",
|
|
49
|
+
),
|
|
50
|
+
Rule(
|
|
51
|
+
"SLP006",
|
|
52
|
+
"error",
|
|
53
|
+
"placeholder-configuration",
|
|
54
|
+
"Configuration-like variable contains an obvious placeholder value.",
|
|
55
|
+
),
|
|
56
|
+
Rule(
|
|
57
|
+
"SLP007",
|
|
58
|
+
"warning",
|
|
59
|
+
"narrating-comments",
|
|
60
|
+
"Function contains a cluster of comments that merely narrate operations.",
|
|
61
|
+
),
|
|
62
|
+
Rule(
|
|
63
|
+
"SLP008",
|
|
64
|
+
"warning",
|
|
65
|
+
"oversized-function",
|
|
66
|
+
"Function is large enough to warrant decomposition or focused review.",
|
|
67
|
+
),
|
|
68
|
+
Rule(
|
|
69
|
+
"SLP009",
|
|
70
|
+
"warning",
|
|
71
|
+
"unchecked-subprocess",
|
|
72
|
+
"subprocess.run can fail without its outcome being observed.",
|
|
73
|
+
),
|
|
74
|
+
Rule(
|
|
75
|
+
"SLP010",
|
|
76
|
+
"warning",
|
|
77
|
+
"missing-network-timeout",
|
|
78
|
+
"Synchronous network call omits its timeout or sets it to None.",
|
|
79
|
+
),
|
|
80
|
+
Rule(
|
|
81
|
+
"SLP011",
|
|
82
|
+
"warning",
|
|
83
|
+
"unchecked-http-response",
|
|
84
|
+
"HTTP response is consumed without checking whether the request succeeded.",
|
|
85
|
+
),
|
|
86
|
+
Rule(
|
|
87
|
+
"SLP012",
|
|
88
|
+
"warning",
|
|
89
|
+
"environment-specific-path",
|
|
90
|
+
"Source contains an absolute path tied to one user's home directory.",
|
|
91
|
+
),
|
|
92
|
+
Rule(
|
|
93
|
+
"SLP013",
|
|
94
|
+
"warning",
|
|
95
|
+
"blocking-in-async",
|
|
96
|
+
"Async function directly calls a known blocking API.",
|
|
97
|
+
),
|
|
98
|
+
Rule(
|
|
99
|
+
"SLP014",
|
|
100
|
+
"warning",
|
|
101
|
+
"assertion-free-test",
|
|
102
|
+
"Test function has no explicit result or expected-failure oracle.",
|
|
103
|
+
),
|
|
104
|
+
Rule(
|
|
105
|
+
"SLP015",
|
|
106
|
+
"warning",
|
|
107
|
+
"overridable-init-call",
|
|
108
|
+
"Constructor dispatches to an overridable same-class method before "
|
|
109
|
+
"instance state initialization is complete.",
|
|
110
|
+
),
|
|
111
|
+
Rule(
|
|
112
|
+
"SLP016",
|
|
113
|
+
"warning",
|
|
114
|
+
"conditional-instance-state",
|
|
115
|
+
"Instance attribute is not initialized on every successful constructor path.",
|
|
116
|
+
),
|
|
117
|
+
Rule(
|
|
118
|
+
"SLP017",
|
|
119
|
+
"warning",
|
|
120
|
+
"shared-mutable-class-state",
|
|
121
|
+
"Instance method mutates mutable state inherited from the class.",
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class Finding:
|
|
129
|
+
"""One source-level lint diagnostic."""
|
|
130
|
+
|
|
131
|
+
path: str
|
|
132
|
+
line: int
|
|
133
|
+
column: int
|
|
134
|
+
code: str
|
|
135
|
+
message: str
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def severity(self) -> str:
|
|
139
|
+
return RULES[self.code].severity
|
|
140
|
+
|
|
141
|
+
def as_dict(self) -> dict[str, object]:
|
|
142
|
+
return {
|
|
143
|
+
"path": self.path,
|
|
144
|
+
"line": self.line,
|
|
145
|
+
"column": self.column,
|
|
146
|
+
"code": self.code,
|
|
147
|
+
"severity": self.severity,
|
|
148
|
+
"message": self.message,
|
|
149
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""File discovery and source loading."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tokenize
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pyclichecker.config import LintConfig
|
|
11
|
+
from pyclichecker.diagnostics import Finding
|
|
12
|
+
from pyclichecker.rules import lint_source
|
|
13
|
+
|
|
14
|
+
DEFAULT_EXCLUDED_DIRECTORIES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
".git",
|
|
17
|
+
".hg",
|
|
18
|
+
".mypy_cache",
|
|
19
|
+
".pytest_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
".svn",
|
|
22
|
+
".tox",
|
|
23
|
+
".venv",
|
|
24
|
+
"__pycache__",
|
|
25
|
+
"build",
|
|
26
|
+
"dist",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"site-packages",
|
|
29
|
+
"venv",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _matches_exclude(relative_path: Path, patterns: Sequence[str]) -> bool:
|
|
35
|
+
value = relative_path.as_posix()
|
|
36
|
+
return any(
|
|
37
|
+
fnmatch.fnmatch(value, pattern) or fnmatch.fnmatch(relative_path.name, pattern)
|
|
38
|
+
for pattern in patterns
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _python_files_in_directory(
|
|
43
|
+
root: Path,
|
|
44
|
+
*,
|
|
45
|
+
exclude_patterns: Sequence[str],
|
|
46
|
+
) -> Iterable[Path]:
|
|
47
|
+
for current, directories, files in os.walk(root):
|
|
48
|
+
current_path = Path(current)
|
|
49
|
+
relative_current = current_path.relative_to(root)
|
|
50
|
+
directories[:] = sorted(
|
|
51
|
+
directory
|
|
52
|
+
for directory in directories
|
|
53
|
+
if directory not in DEFAULT_EXCLUDED_DIRECTORIES
|
|
54
|
+
and not _matches_exclude(relative_current / directory, exclude_patterns)
|
|
55
|
+
)
|
|
56
|
+
for filename in sorted(files):
|
|
57
|
+
if not filename.endswith(".py"):
|
|
58
|
+
continue
|
|
59
|
+
path = current_path / filename
|
|
60
|
+
relative_path = path.relative_to(root)
|
|
61
|
+
if not _matches_exclude(relative_path, exclude_patterns):
|
|
62
|
+
yield path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def discover_python_files(
|
|
66
|
+
inputs: Sequence[str],
|
|
67
|
+
*,
|
|
68
|
+
exclude_patterns: Sequence[str] = (),
|
|
69
|
+
) -> tuple[list[Path], bool, list[str]]:
|
|
70
|
+
"""Resolve CLI inputs into unique Python files, stdin, and errors."""
|
|
71
|
+
|
|
72
|
+
files: list[Path] = []
|
|
73
|
+
use_stdin = False
|
|
74
|
+
errors: list[str] = []
|
|
75
|
+
seen: set[Path] = set()
|
|
76
|
+
|
|
77
|
+
for raw_input in inputs or (".",):
|
|
78
|
+
if raw_input == "-":
|
|
79
|
+
use_stdin = True
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
path = Path(raw_input).expanduser()
|
|
83
|
+
if not path.exists():
|
|
84
|
+
errors.append(f"path does not exist: {raw_input}")
|
|
85
|
+
continue
|
|
86
|
+
if path.is_file():
|
|
87
|
+
if path.suffix != ".py":
|
|
88
|
+
errors.append(f"not a Python file: {raw_input}")
|
|
89
|
+
continue
|
|
90
|
+
candidates = (path,)
|
|
91
|
+
elif path.is_dir():
|
|
92
|
+
candidates = _python_files_in_directory(
|
|
93
|
+
path,
|
|
94
|
+
exclude_patterns=exclude_patterns,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
errors.append(f"unsupported path type: {raw_input}")
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
for candidate in candidates:
|
|
101
|
+
identity = candidate.resolve()
|
|
102
|
+
if identity not in seen:
|
|
103
|
+
seen.add(identity)
|
|
104
|
+
files.append(candidate)
|
|
105
|
+
|
|
106
|
+
return sorted(files, key=lambda item: item.as_posix()), use_stdin, errors
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _display_path(path: Path) -> str:
|
|
110
|
+
try:
|
|
111
|
+
return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
|
|
112
|
+
except ValueError:
|
|
113
|
+
return str(path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def lint_files(
|
|
117
|
+
files: Sequence[Path],
|
|
118
|
+
*,
|
|
119
|
+
use_stdin: bool,
|
|
120
|
+
config: LintConfig,
|
|
121
|
+
) -> tuple[list[Finding], int, list[str]]:
|
|
122
|
+
"""Load and lint discovered files and optional standard input."""
|
|
123
|
+
|
|
124
|
+
findings: list[Finding] = []
|
|
125
|
+
errors: list[str] = []
|
|
126
|
+
files_checked = 0
|
|
127
|
+
|
|
128
|
+
if use_stdin:
|
|
129
|
+
findings.extend(lint_source(sys.stdin.read(), path="<stdin>", config=config))
|
|
130
|
+
files_checked += 1
|
|
131
|
+
|
|
132
|
+
for path in files:
|
|
133
|
+
display_path = _display_path(path)
|
|
134
|
+
try:
|
|
135
|
+
with tokenize.open(path) as source_file:
|
|
136
|
+
source = source_file.read()
|
|
137
|
+
except (OSError, SyntaxError, UnicodeError) as error:
|
|
138
|
+
errors.append(f"{display_path}: {error}")
|
|
139
|
+
continue
|
|
140
|
+
findings.extend(lint_source(source, path=display_path, config=config))
|
|
141
|
+
files_checked += 1
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
sorted(
|
|
145
|
+
findings,
|
|
146
|
+
key=lambda item: (item.path, item.line, item.column, item.code),
|
|
147
|
+
),
|
|
148
|
+
files_checked,
|
|
149
|
+
errors,
|
|
150
|
+
)
|