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.
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ """Run pyclichecker with ``python -m pyclichecker``."""
2
+
3
+ from pyclichecker.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """Package version lookup."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("pyclichecker")
7
+ except PackageNotFoundError:
8
+ __version__ = "0+unknown"
9
+
10
+ VERSION = __version__
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
+ )