python-checkup 0.0.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.
- python_checkup/__init__.py +9 -0
- python_checkup/__main__.py +3 -0
- python_checkup/analysis_request.py +35 -0
- python_checkup/analyzer_catalog.py +100 -0
- python_checkup/analyzers/__init__.py +54 -0
- python_checkup/analyzers/bandit.py +158 -0
- python_checkup/analyzers/basedpyright.py +103 -0
- python_checkup/analyzers/cached.py +106 -0
- python_checkup/analyzers/dependency_vulns.py +298 -0
- python_checkup/analyzers/deptry.py +142 -0
- python_checkup/analyzers/detect_secrets.py +101 -0
- python_checkup/analyzers/mypy.py +217 -0
- python_checkup/analyzers/radon.py +150 -0
- python_checkup/analyzers/registry.py +69 -0
- python_checkup/analyzers/ruff.py +256 -0
- python_checkup/analyzers/typos.py +80 -0
- python_checkup/analyzers/vulture.py +151 -0
- python_checkup/cache.py +244 -0
- python_checkup/cli.py +763 -0
- python_checkup/config.py +87 -0
- python_checkup/dedup.py +119 -0
- python_checkup/dependencies/discovery.py +192 -0
- python_checkup/detection.py +298 -0
- python_checkup/diff.py +130 -0
- python_checkup/discovery.py +180 -0
- python_checkup/formatters/__init__.py +0 -0
- python_checkup/formatters/badge.py +38 -0
- python_checkup/formatters/json_fmt.py +22 -0
- python_checkup/formatters/terminal.py +396 -0
- python_checkup/mcp/__init__.py +3 -0
- python_checkup/mcp/installer.py +119 -0
- python_checkup/mcp/server.py +411 -0
- python_checkup/models.py +114 -0
- python_checkup/plan.py +109 -0
- python_checkup/progress.py +95 -0
- python_checkup/runner.py +438 -0
- python_checkup/scoring/__init__.py +0 -0
- python_checkup/scoring/engine.py +397 -0
- python_checkup/skills/SKILL.md +416 -0
- python_checkup/skills/__init__.py +0 -0
- python_checkup/skills/agents.py +98 -0
- python_checkup/skills/installer.py +248 -0
- python_checkup/skills/rule_db.py +806 -0
- python_checkup/web/__init__.py +0 -0
- python_checkup/web/server.py +285 -0
- python_checkup/web/static/__init__.py +0 -0
- python_checkup/web/static/index.html +959 -0
- python_checkup/web/template.py +26 -0
- python_checkup-0.0.1.dist-info/METADATA +250 -0
- python_checkup-0.0.1.dist-info/RECORD +53 -0
- python_checkup-0.0.1.dist-info/WHEEL +4 -0
- python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
- python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
python_checkup/cli.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from python_checkup import __version__
|
|
11
|
+
from python_checkup.config import CheckupConfig
|
|
12
|
+
from python_checkup.models import Category, HealthReport
|
|
13
|
+
from python_checkup.plan import (
|
|
14
|
+
PROFILE_DEFAULT,
|
|
15
|
+
PROFILE_FULL,
|
|
16
|
+
PROFILE_QUICK,
|
|
17
|
+
TYPE_BACKEND_AUTO,
|
|
18
|
+
TYPE_BACKEND_BASEDPYRIGHT,
|
|
19
|
+
TYPE_BACKEND_MYPY,
|
|
20
|
+
ScanPlan,
|
|
21
|
+
build_scan_plan,
|
|
22
|
+
parse_categories,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
err_console = Console(stderr=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SubcommandFirstGroup(click.Group):
|
|
29
|
+
"""Click group that recognises subcommands before consuming PATH.
|
|
30
|
+
|
|
31
|
+
Without this, ``python-checkup install-skill`` would feed
|
|
32
|
+
``install-skill`` to the positional PATH argument and never
|
|
33
|
+
dispatch the subcommand. We override :meth:`parse_args` to
|
|
34
|
+
peek at the first non-option token and, if it matches a known
|
|
35
|
+
command name, strip any default PATH value so Click routes
|
|
36
|
+
correctly.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
40
|
+
"""Rewrite args so subcommand names aren't swallowed by PATH."""
|
|
41
|
+
# Find the first non-option token
|
|
42
|
+
for token in args:
|
|
43
|
+
if token.startswith("-"):
|
|
44
|
+
continue
|
|
45
|
+
if token in self.commands:
|
|
46
|
+
# Insert an explicit default for PATH so the arg
|
|
47
|
+
# isn't consumed by the subcommand name.
|
|
48
|
+
args = [".", *args]
|
|
49
|
+
break
|
|
50
|
+
# First non-option is NOT a command → let Click handle normally
|
|
51
|
+
break
|
|
52
|
+
return super().parse_args(ctx, args)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.group(
|
|
56
|
+
cls=_SubcommandFirstGroup,
|
|
57
|
+
invoke_without_command=True,
|
|
58
|
+
context_settings={"allow_interspersed_args": True},
|
|
59
|
+
)
|
|
60
|
+
@click.argument(
|
|
61
|
+
"path",
|
|
62
|
+
default=".",
|
|
63
|
+
required=False,
|
|
64
|
+
type=click.Path(),
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--json",
|
|
68
|
+
"json_output",
|
|
69
|
+
is_flag=True,
|
|
70
|
+
help="Output full results as JSON.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--score",
|
|
74
|
+
"score_only",
|
|
75
|
+
is_flag=True,
|
|
76
|
+
help="Output only the numeric score.",
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
"--verbose",
|
|
80
|
+
"-v",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Show all issues with file:line.",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--diff",
|
|
86
|
+
"diff_base",
|
|
87
|
+
default=None,
|
|
88
|
+
is_eager=False,
|
|
89
|
+
flag_value="main",
|
|
90
|
+
is_flag=False,
|
|
91
|
+
help="Only analyze files changed vs BASE (default: main).",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--fail-under",
|
|
95
|
+
type=float,
|
|
96
|
+
default=None,
|
|
97
|
+
help="Exit 1 if score is below SCORE.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--profile",
|
|
101
|
+
type=click.Choice([PROFILE_QUICK, PROFILE_DEFAULT, PROFILE_FULL]),
|
|
102
|
+
default=PROFILE_DEFAULT,
|
|
103
|
+
help="Scan profile: quick, default, or full.",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--only",
|
|
107
|
+
"only_categories",
|
|
108
|
+
default=None,
|
|
109
|
+
help="Only run these categories (comma-separated).",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--skip",
|
|
113
|
+
"skip_categories",
|
|
114
|
+
default=None,
|
|
115
|
+
help="Skip these categories (comma-separated).",
|
|
116
|
+
)
|
|
117
|
+
@click.option(
|
|
118
|
+
"--analyzers",
|
|
119
|
+
"analyzer_names",
|
|
120
|
+
default=None,
|
|
121
|
+
help="Only run these analyzers (comma-separated). Overrides --only/--skip.",
|
|
122
|
+
)
|
|
123
|
+
@click.option(
|
|
124
|
+
"--quick",
|
|
125
|
+
is_flag=True,
|
|
126
|
+
help="Compatibility alias for --profile quick.",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--no-lint",
|
|
130
|
+
is_flag=True,
|
|
131
|
+
help="Skip linting (Ruff).",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--no-typecheck",
|
|
135
|
+
is_flag=True,
|
|
136
|
+
help="Skip type checking (mypy).",
|
|
137
|
+
)
|
|
138
|
+
@click.option(
|
|
139
|
+
"--no-security",
|
|
140
|
+
is_flag=True,
|
|
141
|
+
help="Skip security scanning (Bandit).",
|
|
142
|
+
)
|
|
143
|
+
@click.option(
|
|
144
|
+
"--no-complexity",
|
|
145
|
+
is_flag=True,
|
|
146
|
+
help="Skip complexity analysis (Radon).",
|
|
147
|
+
)
|
|
148
|
+
@click.option(
|
|
149
|
+
"--no-dead-code",
|
|
150
|
+
is_flag=True,
|
|
151
|
+
help="Skip dead code detection (Vulture).",
|
|
152
|
+
)
|
|
153
|
+
@click.option(
|
|
154
|
+
"--type-backend",
|
|
155
|
+
type=click.Choice(
|
|
156
|
+
[TYPE_BACKEND_AUTO, TYPE_BACKEND_MYPY, TYPE_BACKEND_BASEDPYRIGHT]
|
|
157
|
+
),
|
|
158
|
+
default=TYPE_BACKEND_AUTO,
|
|
159
|
+
help="Type checking backend: auto (default), mypy, or basedpyright.",
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--fix",
|
|
163
|
+
is_flag=True,
|
|
164
|
+
help="Apply safe Ruff fixes before rescanning.",
|
|
165
|
+
)
|
|
166
|
+
@click.option(
|
|
167
|
+
"--show-fixes",
|
|
168
|
+
is_flag=True,
|
|
169
|
+
help="Show fix suggestions inline.",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--config",
|
|
173
|
+
"config_path",
|
|
174
|
+
type=click.Path(exists=True),
|
|
175
|
+
default=None,
|
|
176
|
+
help="Path to pyproject.toml.",
|
|
177
|
+
)
|
|
178
|
+
@click.option(
|
|
179
|
+
"--no-cache",
|
|
180
|
+
"no_cache",
|
|
181
|
+
is_flag=True,
|
|
182
|
+
help="Skip the cache and force a fresh analysis.",
|
|
183
|
+
)
|
|
184
|
+
@click.option(
|
|
185
|
+
"--clear-cache",
|
|
186
|
+
"clear_cache",
|
|
187
|
+
is_flag=True,
|
|
188
|
+
help="Delete all cached results and exit.",
|
|
189
|
+
)
|
|
190
|
+
@click.option(
|
|
191
|
+
"--badge",
|
|
192
|
+
is_flag=True,
|
|
193
|
+
help="Output a shields.io badge URL for the score.",
|
|
194
|
+
)
|
|
195
|
+
@click.option(
|
|
196
|
+
"--web",
|
|
197
|
+
is_flag=True,
|
|
198
|
+
help="Serve an interactive HTML report in the browser.",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--port",
|
|
202
|
+
type=int,
|
|
203
|
+
default=8765,
|
|
204
|
+
help="Port for the web report server (default: 8765).",
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--mcp",
|
|
208
|
+
is_flag=True,
|
|
209
|
+
help="Start as MCP server (stdio transport).",
|
|
210
|
+
)
|
|
211
|
+
@click.version_option(version=__version__, prog_name="python-checkup")
|
|
212
|
+
@click.pass_context
|
|
213
|
+
def main(
|
|
214
|
+
ctx: click.Context,
|
|
215
|
+
path: str,
|
|
216
|
+
json_output: bool,
|
|
217
|
+
score_only: bool,
|
|
218
|
+
verbose: bool,
|
|
219
|
+
diff_base: str | None,
|
|
220
|
+
fail_under: float | None,
|
|
221
|
+
profile: str,
|
|
222
|
+
only_categories: str | None,
|
|
223
|
+
skip_categories: str | None,
|
|
224
|
+
analyzer_names: str | None,
|
|
225
|
+
quick: bool,
|
|
226
|
+
no_lint: bool,
|
|
227
|
+
no_typecheck: bool,
|
|
228
|
+
no_security: bool,
|
|
229
|
+
no_complexity: bool,
|
|
230
|
+
no_dead_code: bool,
|
|
231
|
+
type_backend: str,
|
|
232
|
+
fix: bool,
|
|
233
|
+
show_fixes: bool,
|
|
234
|
+
config_path: str | None,
|
|
235
|
+
no_cache: bool,
|
|
236
|
+
clear_cache: bool,
|
|
237
|
+
badge: bool,
|
|
238
|
+
web: bool,
|
|
239
|
+
port: int,
|
|
240
|
+
mcp: bool,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""python-checkup -- fast, local-first Python code health checker.
|
|
243
|
+
|
|
244
|
+
Analyzes a Python project and produces a 0-100 health score with
|
|
245
|
+
categorized diagnostics covering code quality, type safety,
|
|
246
|
+
security, complexity, and dead code.
|
|
247
|
+
|
|
248
|
+
\b
|
|
249
|
+
Examples:
|
|
250
|
+
python-checkup . # Scan current directory
|
|
251
|
+
python-checkup src/ --verbose # Scan src/ with all issues
|
|
252
|
+
python-checkup . --score # Just the number (for CI)
|
|
253
|
+
python-checkup . --diff develop # Only changed files
|
|
254
|
+
python-checkup . --fail-under 70 # Exit 1 if score < 70
|
|
255
|
+
python-checkup . --profile quick # Fast feedback
|
|
256
|
+
python-checkup . --analyzers ruff,mypy # Run specific analyzers
|
|
257
|
+
"""
|
|
258
|
+
ctx.ensure_object(dict)
|
|
259
|
+
|
|
260
|
+
# Handle early-exit modes (MCP, cache clear, subcommands)
|
|
261
|
+
if _handle_early_exits(ctx, path, mcp, clear_cache):
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Validate that PATH exists (we can't use click.Path(exists=True)
|
|
265
|
+
# because it would reject subcommand names like "mcp" and "install-skill")
|
|
266
|
+
resolved = Path(path).resolve()
|
|
267
|
+
if not resolved.exists():
|
|
268
|
+
raise click.BadParameter(
|
|
269
|
+
f"Path '{path}' does not exist.",
|
|
270
|
+
param_hint="'PATH'",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# --score wins over --json (simpler request)
|
|
274
|
+
if score_only:
|
|
275
|
+
json_output = False
|
|
276
|
+
|
|
277
|
+
only_set = parse_categories(only_categories)
|
|
278
|
+
skip_set = _build_skip_categories(
|
|
279
|
+
skip_categories,
|
|
280
|
+
no_lint,
|
|
281
|
+
no_typecheck,
|
|
282
|
+
no_security,
|
|
283
|
+
no_complexity,
|
|
284
|
+
no_dead_code,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
plan, skip_analyzers = _resolve_analyzers(
|
|
288
|
+
analyzer_names=analyzer_names,
|
|
289
|
+
profile=profile,
|
|
290
|
+
only_set=only_set,
|
|
291
|
+
skip_set=skip_set,
|
|
292
|
+
quick=quick,
|
|
293
|
+
fix=fix,
|
|
294
|
+
show_fixes=show_fixes,
|
|
295
|
+
diff_base=diff_base,
|
|
296
|
+
type_backend=type_backend,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Load config
|
|
300
|
+
from python_checkup.config import load_config
|
|
301
|
+
|
|
302
|
+
project_root = Path(path).resolve()
|
|
303
|
+
config = load_config(Path(config_path).parent if config_path else project_root)
|
|
304
|
+
|
|
305
|
+
if fix:
|
|
306
|
+
_apply_safe_ruff_fixes(project_root)
|
|
307
|
+
|
|
308
|
+
# Discover files
|
|
309
|
+
files, total_project_files = _discover_files(
|
|
310
|
+
project_root,
|
|
311
|
+
config,
|
|
312
|
+
diff_base,
|
|
313
|
+
skip_analyzers,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Run analysis and output results
|
|
317
|
+
report = _run_analysis(
|
|
318
|
+
project_root=project_root,
|
|
319
|
+
config=config,
|
|
320
|
+
files=files,
|
|
321
|
+
skip_analyzers=skip_analyzers,
|
|
322
|
+
score_only=score_only,
|
|
323
|
+
json_output=json_output,
|
|
324
|
+
badge=badge,
|
|
325
|
+
no_cache=no_cache,
|
|
326
|
+
plan=plan,
|
|
327
|
+
diff_base=diff_base,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
_output_report(
|
|
331
|
+
report,
|
|
332
|
+
score_only=score_only,
|
|
333
|
+
badge=badge,
|
|
334
|
+
json_output=json_output,
|
|
335
|
+
web=web,
|
|
336
|
+
verbose=verbose,
|
|
337
|
+
show_fixes=show_fixes,
|
|
338
|
+
diff_base=diff_base,
|
|
339
|
+
files=files,
|
|
340
|
+
total_project_files=total_project_files,
|
|
341
|
+
project_root=project_root,
|
|
342
|
+
config=config,
|
|
343
|
+
plan=plan,
|
|
344
|
+
no_cache=no_cache,
|
|
345
|
+
port=port,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Exit code
|
|
349
|
+
if fail_under is not None and report.score < fail_under:
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _handle_early_exits(
|
|
354
|
+
ctx: click.Context,
|
|
355
|
+
path: str,
|
|
356
|
+
mcp: bool,
|
|
357
|
+
clear_cache: bool,
|
|
358
|
+
) -> bool:
|
|
359
|
+
"""Handle MCP, cache-clear, and subcommand early-exit modes.
|
|
360
|
+
|
|
361
|
+
Returns ``True`` if the caller should return immediately.
|
|
362
|
+
"""
|
|
363
|
+
if mcp:
|
|
364
|
+
from python_checkup.mcp.server import start_mcp_server
|
|
365
|
+
|
|
366
|
+
start_mcp_server()
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
if clear_cache:
|
|
370
|
+
from python_checkup.cache import AnalysisCache
|
|
371
|
+
|
|
372
|
+
resolved = Path(path).resolve()
|
|
373
|
+
cache = AnalysisCache(resolved, enabled=True)
|
|
374
|
+
count = cache.clear()
|
|
375
|
+
err_console.print(f"[green]Cleared {count} cached entries.[/green]")
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
return ctx.invoked_subcommand is not None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _build_skip_categories(
|
|
382
|
+
skip_categories: str | None,
|
|
383
|
+
no_lint: bool,
|
|
384
|
+
no_typecheck: bool,
|
|
385
|
+
no_security: bool,
|
|
386
|
+
no_complexity: bool,
|
|
387
|
+
no_dead_code: bool,
|
|
388
|
+
) -> set[Category]:
|
|
389
|
+
"""Build the set of categories to skip from CLI flags."""
|
|
390
|
+
skip_set = set(parse_categories(skip_categories) or set())
|
|
391
|
+
|
|
392
|
+
flag_to_category = [
|
|
393
|
+
(no_lint, Category.QUALITY),
|
|
394
|
+
(no_typecheck, Category.TYPE_SAFETY),
|
|
395
|
+
(no_security, Category.SECURITY),
|
|
396
|
+
(no_complexity, Category.COMPLEXITY),
|
|
397
|
+
(no_dead_code, Category.DEAD_CODE),
|
|
398
|
+
]
|
|
399
|
+
for flag, category in flag_to_category:
|
|
400
|
+
if flag:
|
|
401
|
+
skip_set.add(category)
|
|
402
|
+
|
|
403
|
+
return skip_set
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _resolve_analyzers(
|
|
407
|
+
*,
|
|
408
|
+
analyzer_names: str | None,
|
|
409
|
+
profile: str,
|
|
410
|
+
only_set: frozenset[Category] | None,
|
|
411
|
+
skip_set: set[Category],
|
|
412
|
+
quick: bool,
|
|
413
|
+
fix: bool,
|
|
414
|
+
show_fixes: bool,
|
|
415
|
+
diff_base: str | None,
|
|
416
|
+
type_backend: str,
|
|
417
|
+
) -> tuple[ScanPlan, set[str]]:
|
|
418
|
+
"""Resolve the scan plan and the set of analyzers to skip.
|
|
419
|
+
|
|
420
|
+
Returns ``(plan, skip_analyzers)``.
|
|
421
|
+
"""
|
|
422
|
+
if analyzer_names:
|
|
423
|
+
return _resolve_explicit_analyzers(
|
|
424
|
+
analyzer_names,
|
|
425
|
+
profile,
|
|
426
|
+
quick,
|
|
427
|
+
fix,
|
|
428
|
+
show_fixes,
|
|
429
|
+
diff_base,
|
|
430
|
+
type_backend,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
plan = build_scan_plan(
|
|
434
|
+
profile=profile,
|
|
435
|
+
only_categories=only_set,
|
|
436
|
+
skip_categories=frozenset(skip_set),
|
|
437
|
+
quick=quick,
|
|
438
|
+
include_optional=profile == PROFILE_FULL,
|
|
439
|
+
apply_fixes=fix,
|
|
440
|
+
show_fix_suggestions=show_fixes,
|
|
441
|
+
diff_mode=diff_base is not None,
|
|
442
|
+
type_backend=type_backend,
|
|
443
|
+
)
|
|
444
|
+
skip_analyzers = _derive_skip_analyzers_from_plan(plan)
|
|
445
|
+
return plan, skip_analyzers
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _resolve_explicit_analyzers(
|
|
449
|
+
analyzer_names: str,
|
|
450
|
+
profile: str,
|
|
451
|
+
quick: bool,
|
|
452
|
+
fix: bool,
|
|
453
|
+
show_fixes: bool,
|
|
454
|
+
diff_base: str | None,
|
|
455
|
+
type_backend: str,
|
|
456
|
+
) -> tuple[ScanPlan, set[str]]:
|
|
457
|
+
"""Handle --analyzers: validate names and build a plan from them."""
|
|
458
|
+
from python_checkup.analyzer_catalog import ANALYZER_CATALOG
|
|
459
|
+
|
|
460
|
+
requested = {a.strip() for a in analyzer_names.split(",") if a.strip()}
|
|
461
|
+
unknown = requested - set(ANALYZER_CATALOG.keys())
|
|
462
|
+
if unknown:
|
|
463
|
+
raise click.BadParameter(
|
|
464
|
+
f"Unknown analyzer(s): {', '.join(sorted(unknown))}. "
|
|
465
|
+
f"Available: {', '.join(sorted(ANALYZER_CATALOG.keys()))}",
|
|
466
|
+
param_hint="'--analyzers'",
|
|
467
|
+
)
|
|
468
|
+
skip_analyzers = set(ANALYZER_CATALOG.keys()) - requested
|
|
469
|
+
|
|
470
|
+
selected_categories: set[Category] = set()
|
|
471
|
+
for name in requested:
|
|
472
|
+
selected_categories.update(ANALYZER_CATALOG[name].categories)
|
|
473
|
+
|
|
474
|
+
plan = build_scan_plan(
|
|
475
|
+
profile=profile,
|
|
476
|
+
only_categories=frozenset(selected_categories),
|
|
477
|
+
skip_categories=frozenset(),
|
|
478
|
+
quick=quick,
|
|
479
|
+
include_optional=True,
|
|
480
|
+
apply_fixes=fix,
|
|
481
|
+
show_fix_suggestions=show_fixes,
|
|
482
|
+
diff_mode=diff_base is not None,
|
|
483
|
+
type_backend=type_backend,
|
|
484
|
+
)
|
|
485
|
+
return plan, skip_analyzers
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _derive_skip_analyzers_from_plan(plan: ScanPlan) -> set[str]:
|
|
489
|
+
"""Derive the analyzer skip-set from a scan plan's categories/profile."""
|
|
490
|
+
skip: set[str] = set()
|
|
491
|
+
|
|
492
|
+
# Map absent categories to the analyzers they control
|
|
493
|
+
category_analyzers: dict[Category, set[str]] = {
|
|
494
|
+
Category.QUALITY: {"typos"},
|
|
495
|
+
Category.TYPE_SAFETY: {"mypy", "basedpyright"},
|
|
496
|
+
Category.SECURITY: {"bandit", "detect-secrets"},
|
|
497
|
+
Category.COMPLEXITY: {"radon"},
|
|
498
|
+
Category.DEAD_CODE: {"vulture"},
|
|
499
|
+
Category.DEPENDENCIES: {"deptry", "dependency-vulns", "pip-audit"},
|
|
500
|
+
}
|
|
501
|
+
for category, analyzers in category_analyzers.items():
|
|
502
|
+
if category not in plan.categories:
|
|
503
|
+
skip.update(analyzers)
|
|
504
|
+
|
|
505
|
+
# Enforce type backend mutual exclusion when TYPE_SAFETY is active
|
|
506
|
+
if Category.TYPE_SAFETY in plan.categories:
|
|
507
|
+
if plan.type_backend == TYPE_BACKEND_MYPY:
|
|
508
|
+
skip.add("basedpyright")
|
|
509
|
+
elif plan.type_backend == TYPE_BACKEND_BASEDPYRIGHT:
|
|
510
|
+
skip.add("mypy")
|
|
511
|
+
|
|
512
|
+
# Ruff is only needed if at least one of these categories is active
|
|
513
|
+
ruff_categories = {
|
|
514
|
+
Category.QUALITY,
|
|
515
|
+
Category.SECURITY,
|
|
516
|
+
Category.COMPLEXITY,
|
|
517
|
+
Category.DEAD_CODE,
|
|
518
|
+
}
|
|
519
|
+
if not (plan.categories & ruff_categories):
|
|
520
|
+
skip.add("ruff")
|
|
521
|
+
|
|
522
|
+
if plan.profile == PROFILE_QUICK:
|
|
523
|
+
skip.update({"mypy", "bandit", "radon", "vulture"})
|
|
524
|
+
|
|
525
|
+
return skip
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _discover_files(
|
|
529
|
+
project_root: Path,
|
|
530
|
+
config: CheckupConfig,
|
|
531
|
+
diff_base: str | None,
|
|
532
|
+
skip_analyzers: set[str],
|
|
533
|
+
) -> tuple[list[Path], int]:
|
|
534
|
+
"""Discover Python files to analyze.
|
|
535
|
+
|
|
536
|
+
Returns ``(files, total_project_files)``.
|
|
537
|
+
"""
|
|
538
|
+
from python_checkup.discovery import discover_python_files
|
|
539
|
+
|
|
540
|
+
if diff_base is not None:
|
|
541
|
+
from python_checkup.diff import get_changed_files
|
|
542
|
+
|
|
543
|
+
files = get_changed_files(project_root, base=diff_base)
|
|
544
|
+
total_project_files = len(
|
|
545
|
+
discover_python_files(project_root, config.ignore_files)
|
|
546
|
+
)
|
|
547
|
+
# Skip Vulture in diff mode -- dead code detection
|
|
548
|
+
# on a subset is misleading
|
|
549
|
+
skip_analyzers.add("vulture")
|
|
550
|
+
else:
|
|
551
|
+
files = discover_python_files(project_root, config.ignore_files)
|
|
552
|
+
total_project_files = len(files)
|
|
553
|
+
|
|
554
|
+
return files, total_project_files
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _run_analysis(
|
|
558
|
+
*,
|
|
559
|
+
project_root: Path,
|
|
560
|
+
config: CheckupConfig,
|
|
561
|
+
files: list[Path],
|
|
562
|
+
skip_analyzers: set[str],
|
|
563
|
+
score_only: bool,
|
|
564
|
+
json_output: bool,
|
|
565
|
+
badge: bool,
|
|
566
|
+
no_cache: bool,
|
|
567
|
+
plan: ScanPlan,
|
|
568
|
+
diff_base: str | None,
|
|
569
|
+
) -> HealthReport:
|
|
570
|
+
"""Run the async analysis pipeline and return the report."""
|
|
571
|
+
from python_checkup.runner import run_analysis
|
|
572
|
+
|
|
573
|
+
quiet = score_only or json_output or badge
|
|
574
|
+
return asyncio.run(
|
|
575
|
+
run_analysis(
|
|
576
|
+
project_root=project_root,
|
|
577
|
+
config=config,
|
|
578
|
+
files=files,
|
|
579
|
+
skip_analyzers=skip_analyzers,
|
|
580
|
+
quiet=quiet,
|
|
581
|
+
no_cache=no_cache,
|
|
582
|
+
plan=plan,
|
|
583
|
+
diff_base=diff_base,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _output_report(
|
|
589
|
+
report: HealthReport,
|
|
590
|
+
*,
|
|
591
|
+
score_only: bool,
|
|
592
|
+
badge: bool,
|
|
593
|
+
json_output: bool,
|
|
594
|
+
web: bool,
|
|
595
|
+
verbose: bool,
|
|
596
|
+
show_fixes: bool,
|
|
597
|
+
diff_base: str | None,
|
|
598
|
+
files: list[Path],
|
|
599
|
+
total_project_files: int,
|
|
600
|
+
project_root: Path,
|
|
601
|
+
config: CheckupConfig,
|
|
602
|
+
plan: ScanPlan,
|
|
603
|
+
no_cache: bool,
|
|
604
|
+
port: int,
|
|
605
|
+
) -> None:
|
|
606
|
+
"""Render the analysis report in the requested format."""
|
|
607
|
+
if score_only:
|
|
608
|
+
click.echo(report.score)
|
|
609
|
+
elif badge:
|
|
610
|
+
from python_checkup.formatters.badge import generate_badge_url
|
|
611
|
+
|
|
612
|
+
click.echo(generate_badge_url(report.score))
|
|
613
|
+
elif json_output:
|
|
614
|
+
from python_checkup.formatters.json_fmt import format_json
|
|
615
|
+
|
|
616
|
+
click.echo(format_json(report))
|
|
617
|
+
elif web:
|
|
618
|
+
from python_checkup.web.server import RunContext, serve_report
|
|
619
|
+
|
|
620
|
+
run_context = RunContext(
|
|
621
|
+
project_root=project_root,
|
|
622
|
+
config=config,
|
|
623
|
+
files=files,
|
|
624
|
+
plan=plan,
|
|
625
|
+
no_cache=no_cache,
|
|
626
|
+
diff_base=diff_base,
|
|
627
|
+
)
|
|
628
|
+
serve_report(report, port=port, run_context=run_context)
|
|
629
|
+
else:
|
|
630
|
+
from python_checkup.formatters.terminal import print_report
|
|
631
|
+
|
|
632
|
+
print_report(
|
|
633
|
+
report,
|
|
634
|
+
verbose=verbose,
|
|
635
|
+
show_fix=show_fixes,
|
|
636
|
+
diff_mode=diff_base is not None,
|
|
637
|
+
changed_file_count=len(files) if diff_base else None,
|
|
638
|
+
total_file_count=total_project_files,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _apply_safe_ruff_fixes(project_root: Path) -> None:
|
|
643
|
+
import subprocess
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
ruff_bin = "ruff"
|
|
647
|
+
subprocess.run( # noqa: S603
|
|
648
|
+
[ruff_bin, "check", "--fix", "--no-unsafe-fixes", str(project_root)],
|
|
649
|
+
check=False,
|
|
650
|
+
cwd=project_root,
|
|
651
|
+
capture_output=True,
|
|
652
|
+
text=True,
|
|
653
|
+
)
|
|
654
|
+
except FileNotFoundError:
|
|
655
|
+
err_console.print(
|
|
656
|
+
"[yellow]Ruff is not installed, so no fixes were applied.[/yellow]"
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@main.group("mcp")
|
|
661
|
+
def mcp_cmd() -> None:
|
|
662
|
+
"""MCP server management commands."""
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@mcp_cmd.command("install")
|
|
666
|
+
@click.option(
|
|
667
|
+
"--editor",
|
|
668
|
+
type=click.Choice(["auto", "claude-code", "cursor", "vscode"]),
|
|
669
|
+
default="auto",
|
|
670
|
+
help="Target editor (default: auto-detect).",
|
|
671
|
+
)
|
|
672
|
+
def mcp_install(editor: str) -> None:
|
|
673
|
+
"""Install python-checkup as an MCP server for your AI coding agent."""
|
|
674
|
+
from python_checkup.mcp.installer import install_mcp_config
|
|
675
|
+
|
|
676
|
+
err_console.print("[bold]Installing python-checkup MCP server...[/bold]")
|
|
677
|
+
installed = install_mcp_config()
|
|
678
|
+
if installed:
|
|
679
|
+
err_console.print(
|
|
680
|
+
f"\n[green]Installed to {len(installed)} location(s).[/green]"
|
|
681
|
+
)
|
|
682
|
+
err_console.print("[dim]Restart your editor to activate.[/dim]")
|
|
683
|
+
else:
|
|
684
|
+
err_console.print(
|
|
685
|
+
"[yellow]No editor configs found. "
|
|
686
|
+
"Created .mcp.json for Claude Code.[/yellow]"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@main.command("install-skill")
|
|
691
|
+
@click.option(
|
|
692
|
+
"--agent",
|
|
693
|
+
"-a",
|
|
694
|
+
multiple=True,
|
|
695
|
+
help="Specific agent(s) to install for (e.g., claude-code, cursor).",
|
|
696
|
+
)
|
|
697
|
+
@click.option(
|
|
698
|
+
"--project",
|
|
699
|
+
"-p",
|
|
700
|
+
is_flag=True,
|
|
701
|
+
help="Also install to .agents/python-checkup/ in current directory.",
|
|
702
|
+
)
|
|
703
|
+
@click.option(
|
|
704
|
+
"--force",
|
|
705
|
+
"-f",
|
|
706
|
+
is_flag=True,
|
|
707
|
+
help="Overwrite existing skill files.",
|
|
708
|
+
)
|
|
709
|
+
@click.option(
|
|
710
|
+
"--list-agents",
|
|
711
|
+
is_flag=True,
|
|
712
|
+
help="List all supported agents and their directories.",
|
|
713
|
+
)
|
|
714
|
+
def install_skill_cmd(
|
|
715
|
+
agent: tuple[str, ...],
|
|
716
|
+
project: bool,
|
|
717
|
+
force: bool,
|
|
718
|
+
list_agents: bool,
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Install python-checkup skill files for AI coding agents."""
|
|
721
|
+
from rich.table import Table
|
|
722
|
+
|
|
723
|
+
from python_checkup.skills.agents import detect_installed_agents, get_agent_targets
|
|
724
|
+
|
|
725
|
+
if list_agents:
|
|
726
|
+
table = Table(title="Supported AI Coding Agents", show_header=True)
|
|
727
|
+
table.add_column("Agent", style="cyan")
|
|
728
|
+
table.add_column("Directory", style="dim")
|
|
729
|
+
table.add_column("Detected", justify="center")
|
|
730
|
+
|
|
731
|
+
all_targets = get_agent_targets()
|
|
732
|
+
detected = {t.name for t in detect_installed_agents()}
|
|
733
|
+
|
|
734
|
+
for target in all_targets:
|
|
735
|
+
is_detected = "\u2713" if target.name in detected else ""
|
|
736
|
+
if target.is_append and target.global_rules_file:
|
|
737
|
+
dir_str = f"{target.global_rules_file} (append)"
|
|
738
|
+
else:
|
|
739
|
+
dir_str = str(target.skill_dir)
|
|
740
|
+
table.add_row(target.display_name, dir_str, is_detected)
|
|
741
|
+
|
|
742
|
+
err_console.print(table)
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
from python_checkup.skills.installer import install_skill
|
|
746
|
+
|
|
747
|
+
agents_list = list(agent) if agent else None
|
|
748
|
+
install_skill(agents=agents_list, project_level=project, force=force)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@main.command("uninstall-skill")
|
|
752
|
+
@click.option(
|
|
753
|
+
"--agent",
|
|
754
|
+
"-a",
|
|
755
|
+
multiple=True,
|
|
756
|
+
help="Specific agent(s) to uninstall from.",
|
|
757
|
+
)
|
|
758
|
+
def uninstall_skill_cmd(agent: tuple[str, ...]) -> None:
|
|
759
|
+
"""Remove installed python-checkup skill files."""
|
|
760
|
+
from python_checkup.skills.installer import uninstall_skill
|
|
761
|
+
|
|
762
|
+
agents_list = list(agent) if agent else None
|
|
763
|
+
uninstall_skill(agents=agents_list)
|