python-code-quality 0.3.1__py3-none-any.whl → 0.4.0__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.
- py_cq/cli.py +2 -2
- py_cq/config/config.toml +31 -19
- py_cq/execution_engine.py +8 -0
- py_cq/localtypes.py +0 -3
- py_cq/parsers/kingfisherparser.py +73 -0
- py_cq/parsers/uvauditparser.py +88 -0
- py_cq/parsers/vultureparser.py +18 -3
- py_cq/runners/pytest_serial.py +27 -0
- py_cq/tool_registry.py +0 -1
- {python_code_quality-0.3.1.dist-info → python_code_quality-0.4.0.dist-info}/METADATA +53 -32
- {python_code_quality-0.3.1.dist-info → python_code_quality-0.4.0.dist-info}/RECORD +13 -10
- {python_code_quality-0.3.1.dist-info → python_code_quality-0.4.0.dist-info}/WHEEL +1 -1
- {python_code_quality-0.3.1.dist-info → python_code_quality-0.4.0.dist-info}/entry_points.txt +0 -0
py_cq/cli.py
CHANGED
|
@@ -107,7 +107,7 @@ def callback(
|
|
|
107
107
|
help="Show version and dependencies",
|
|
108
108
|
),
|
|
109
109
|
) -> None:
|
|
110
|
-
"""Feed the results from
|
|
110
|
+
"""Feed the results from 13 code quality tools to an LLM. Try: cq check . -o llm"""
|
|
111
111
|
import sys
|
|
112
112
|
|
|
113
113
|
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
@@ -172,7 +172,7 @@ def check(
|
|
|
172
172
|
help="Issue selection order for -o llm/llm-json: severity (most severe first) or phase (first non-clean phase, in dependency order)",
|
|
173
173
|
),
|
|
174
174
|
):
|
|
175
|
-
"""Feed the results from
|
|
175
|
+
"""Feed the results from 13 code quality tools to an LLM. Try: cq check . -o llm""" # --help
|
|
176
176
|
path_obj = Path(path)
|
|
177
177
|
if not path_obj.exists():
|
|
178
178
|
raise typer.BadParameter(f"Path does not exist: {path}")
|
py_cq/config/config.toml
CHANGED
|
@@ -7,8 +7,8 @@ error_threshold = 0.9999
|
|
|
7
7
|
cache_invariant = "ast"
|
|
8
8
|
|
|
9
9
|
[python.ruff]
|
|
10
|
-
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
|
|
11
|
-
exclude_format = " --exclude {path}"
|
|
10
|
+
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude{exclude}"
|
|
11
|
+
exclude_format = " --extend-exclude {path}"
|
|
12
12
|
parser = "RuffParser"
|
|
13
13
|
order = 2
|
|
14
14
|
warning_threshold = 0.9999
|
|
@@ -33,11 +33,27 @@ order = 4
|
|
|
33
33
|
warning_threshold = 0.9999
|
|
34
34
|
error_threshold = 0.8
|
|
35
35
|
|
|
36
|
+
[python.kingfisher]
|
|
37
|
+
command = "kingfisher scan \"{context_path}\" --no-validate --no-update-check --git-history none --no-extract-archives --redact -q -f json --exclude .venv --exclude .git --exclude .hypothesis --exclude dist --exclude \"*_cache\" --exclude __pycache__ --exclude .claude{exclude}"
|
|
38
|
+
exclude_format = " --exclude {path}"
|
|
39
|
+
parser = "KingfisherParser"
|
|
40
|
+
order = 5
|
|
41
|
+
warning_threshold = 0.9999
|
|
42
|
+
error_threshold = 0.9999
|
|
43
|
+
|
|
44
|
+
[python.uv-audit]
|
|
45
|
+
command = "uv audit --project \"{abs_context_path}\" --frozen --output-format json"
|
|
46
|
+
parser = "UvAuditParser"
|
|
47
|
+
order = 6
|
|
48
|
+
warning_threshold = 0.9999
|
|
49
|
+
error_threshold = 0.8
|
|
50
|
+
skip_for_file = true
|
|
51
|
+
|
|
36
52
|
[python.pytest]
|
|
37
53
|
command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
|
|
38
54
|
exclude_format = " --ignore {path}"
|
|
39
55
|
parser = "PytestParser"
|
|
40
|
-
order =
|
|
56
|
+
order = 7
|
|
41
57
|
warning_threshold = 1.0
|
|
42
58
|
error_threshold = 1.0
|
|
43
59
|
run_in_target_env = true
|
|
@@ -46,10 +62,10 @@ skip_for_file = true
|
|
|
46
62
|
cache_invariant = "ast"
|
|
47
63
|
|
|
48
64
|
[python.coverage]
|
|
49
|
-
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py
|
|
65
|
+
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py,*/py_cq/runners/* \"{pytest_serial}\" \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py,*/py_cq/runners/*"
|
|
50
66
|
exclude_format = " --ignore {path}"
|
|
51
67
|
parser = "CoverageParser"
|
|
52
|
-
order =
|
|
68
|
+
order = 8
|
|
53
69
|
warning_threshold = 0.9
|
|
54
70
|
error_threshold = 0.5
|
|
55
71
|
run_in_target_env = true
|
|
@@ -59,19 +75,19 @@ skip_if = "pytest"
|
|
|
59
75
|
cache_invariant = "ast"
|
|
60
76
|
|
|
61
77
|
[python.vulture]
|
|
62
|
-
command = "{python} -m vulture \"{context_path}\" --min-confidence
|
|
78
|
+
command = "{python} -m vulture \"{context_path}\" --min-confidence 60 --ignore-decorators \"@*.command,@*.callback,@*.route,@*.get,@*.post,@*.put,@*.delete,@*.patch,@*fixture,@*.task,@*hookimpl,@*validator,@*.errorhandler\" --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
|
|
63
79
|
exclude_format = ",{path}"
|
|
64
80
|
parser = "VultureParser"
|
|
65
|
-
order =
|
|
81
|
+
order = 9
|
|
66
82
|
warning_threshold = 0.9999
|
|
67
83
|
error_threshold = 0.8
|
|
68
84
|
cache_invariant = "ast"
|
|
69
85
|
|
|
70
86
|
[python.interrogate]
|
|
71
|
-
command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
|
|
87
|
+
command = "{python} -m interrogate \"{context_path}\" -e .claude -e \"{context_path}/tests\"{exclude} -v --fail-under 0"
|
|
72
88
|
exclude_format = " -e {path}"
|
|
73
89
|
parser = "InterrogateParser"
|
|
74
|
-
order =
|
|
90
|
+
order = 10
|
|
75
91
|
warning_threshold = 0.8
|
|
76
92
|
error_threshold = 0.5
|
|
77
93
|
|
|
@@ -81,32 +97,28 @@ skip_empty_init = true
|
|
|
81
97
|
[python.radon-cc]
|
|
82
98
|
command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
|
|
83
99
|
parser = "ComplexityParser"
|
|
84
|
-
order =
|
|
85
|
-
gate_strict = false
|
|
100
|
+
order = 11
|
|
86
101
|
warning_threshold = 0.6
|
|
87
102
|
error_threshold = 0.4
|
|
88
103
|
|
|
89
104
|
[python.radon-mi]
|
|
90
105
|
command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
|
|
91
106
|
parser = "MaintainabilityParser"
|
|
92
|
-
order =
|
|
93
|
-
gate_strict = false
|
|
107
|
+
order = 12
|
|
94
108
|
warning_threshold = 0.6
|
|
95
109
|
error_threshold = 0.4
|
|
96
110
|
|
|
97
111
|
[python.radon-hal]
|
|
98
112
|
command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
|
|
99
113
|
parser = "HalsteadParser"
|
|
100
|
-
order =
|
|
101
|
-
gate_strict = false
|
|
114
|
+
order = 13
|
|
102
115
|
warning_threshold = 0.35
|
|
103
116
|
error_threshold = 0.3
|
|
104
117
|
|
|
105
118
|
[python.ruff-format]
|
|
106
|
-
command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
|
|
107
|
-
exclude_format = " --exclude {path}"
|
|
119
|
+
command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
|
|
120
|
+
exclude_format = " --extend-exclude {path}"
|
|
108
121
|
parser = "RuffFormatParser"
|
|
109
|
-
order =
|
|
110
|
-
gate_strict = false
|
|
122
|
+
order = 14
|
|
111
123
|
warning_threshold = 0.9999
|
|
112
124
|
error_threshold = 0.5
|
py_cq/execution_engine.py
CHANGED
|
@@ -38,6 +38,9 @@ _cache = Cache(
|
|
|
38
38
|
Path.home() / ".cache" / "cq", size_limit=100 * 1024 * 1024, disk=JSONDisk
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
# Script that runs pytest in one process under coverage; see its docstring.
|
|
42
|
+
_PYTEST_SERIAL = str(Path(__file__).parent / "runners" / "pytest_serial.py")
|
|
43
|
+
|
|
41
44
|
|
|
42
45
|
def _find_project_root(path: Path) -> Path | None:
|
|
43
46
|
"""Walk up from path to find the nearest directory containing pyproject.toml."""
|
|
@@ -271,6 +274,7 @@ def run_tool(
|
|
|
271
274
|
scan_targets=scan_targets,
|
|
272
275
|
python=python,
|
|
273
276
|
exclude=exclude,
|
|
277
|
+
pytest_serial=_PYTEST_SERIAL,
|
|
274
278
|
)
|
|
275
279
|
if precomputed_hash is not None:
|
|
276
280
|
context_hash = precomputed_hash
|
|
@@ -328,6 +332,10 @@ def run_tool(
|
|
|
328
332
|
timestamp=timestamp,
|
|
329
333
|
project_path=project_dir,
|
|
330
334
|
)
|
|
335
|
+
if return_code != 0 and not stdout.strip():
|
|
336
|
+
# Failed without a report: the tool is missing from PATH or offline.
|
|
337
|
+
# Caching this would hide real results until the files change.
|
|
338
|
+
return raw_result
|
|
331
339
|
_cache.set(
|
|
332
340
|
cache_key, raw_result.to_dict(), expire=5 * 24 * 60 * 60, tag=project_tag
|
|
333
341
|
)
|
py_cq/localtypes.py
CHANGED
|
@@ -60,9 +60,6 @@ class ToolConfig:
|
|
|
60
60
|
default_factory=list
|
|
61
61
|
) # Top-level dir/file names to omit from {scan_targets}
|
|
62
62
|
skip_for_file: bool = False # If True, skip when context_path is a single file
|
|
63
|
-
gate_strict: bool = (
|
|
64
|
-
True # If False, small regressions above warning_threshold are accepted
|
|
65
|
-
)
|
|
66
63
|
skip_if: str = "" # Tool name; if that tool's score is below its error_threshold, skip this tool
|
|
67
64
|
cache_invariant: str = "" # "ast": key the cache on the docstring-stripped AST, ignoring docstrings/comments/format
|
|
68
65
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Parses output from the Kingfisher secret scanner into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
Kingfisher (``uv tool install kingfisher-bin``) is invoked with ``-f json`` and
|
|
4
|
+
``--redact``, so the report on stdout never contains the secret itself, only a
|
|
5
|
+
hash. ``--no-validate`` keeps it from sending findings to provider APIs.
|
|
6
|
+
|
|
7
|
+
Each finding is listed against its file. The count becomes a logistic-variant
|
|
8
|
+
score stored under the ``secrets`` metric key.
|
|
9
|
+
|
|
10
|
+
When no report can be read (``kingfisher`` not on PATH) the tool reports no
|
|
11
|
+
metric instead of a failing one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
18
|
+
from py_cq.parsers.common import extract_first_issue, score_logistic_variant
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("cq")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class KingfisherParser(AbstractParser):
|
|
24
|
+
"""Parses raw JSON output from ``kingfisher scan -f json`` into a ToolResult."""
|
|
25
|
+
|
|
26
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
27
|
+
"""Parses the raw Kingfisher JSON report into a ToolResult."""
|
|
28
|
+
try:
|
|
29
|
+
findings = json.loads(raw_result.stdout)["findings"]
|
|
30
|
+
except (ValueError, KeyError, TypeError):
|
|
31
|
+
log.warning(
|
|
32
|
+
"kingfisher produced no report (return_code=%s), skipping. "
|
|
33
|
+
"Install it with `uv tool install kingfisher-bin`.",
|
|
34
|
+
raw_result.return_code,
|
|
35
|
+
)
|
|
36
|
+
return ToolResult(raw=raw_result)
|
|
37
|
+
|
|
38
|
+
files: dict[str, list] = {}
|
|
39
|
+
for entry in findings:
|
|
40
|
+
rule = entry.get("rule") or {}
|
|
41
|
+
finding = entry.get("finding") or {}
|
|
42
|
+
path = str(finding.get("path", "")).replace("\\", "/")
|
|
43
|
+
files.setdefault(path, []).append(
|
|
44
|
+
{
|
|
45
|
+
"line": finding.get("line", 0),
|
|
46
|
+
"code": rule.get("id", ""),
|
|
47
|
+
"confidence": finding.get("confidence", ""),
|
|
48
|
+
"message": rule.get("description", ""),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
count = sum(len(issues) for issues in files.values())
|
|
53
|
+
score = score_logistic_variant(count, scale_factor=5)
|
|
54
|
+
return ToolResult(raw=raw_result, metrics={"secrets": score}, details=files)
|
|
55
|
+
|
|
56
|
+
def format_llm_message(
|
|
57
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Formats the first secret finding into an LLM-friendly message."""
|
|
60
|
+
result = extract_first_issue(tr.details)
|
|
61
|
+
if result is None:
|
|
62
|
+
return "kingfisher reported secrets (no details available)"
|
|
63
|
+
file, issue = result
|
|
64
|
+
line = issue.get("line", "?")
|
|
65
|
+
# No source excerpt: it would put the plaintext secret into the LLM prompt.
|
|
66
|
+
return (
|
|
67
|
+
f"{file}:{line} - {issue.get('code', '')}: "
|
|
68
|
+
f"[{issue.get('confidence', '')} confidence] {issue.get('message', '')}\n\n"
|
|
69
|
+
"Move the secret out of the source (environment variable or an untracked "
|
|
70
|
+
"file) and rotate it if it was ever committed. Only if it is an intentional "
|
|
71
|
+
"test fixture or a false positive, add a `kingfisher:ignore` comment on "
|
|
72
|
+
"that line."
|
|
73
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Parses output from ``uv audit`` into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
``uv audit --output-format json`` checks the packages pinned in ``uv.lock``
|
|
4
|
+
against the OSV advisory database and prints a JSON report on stdout.
|
|
5
|
+
|
|
6
|
+
Only advisories with a fix version are counted: those are the ones an upgrade
|
|
7
|
+
resolves. The same vulnerability is often listed once per advisory database
|
|
8
|
+
(GHSA, PYSEC), so entries that alias one already seen for a package are
|
|
9
|
+
dropped. The count becomes a logistic-variant score stored under the
|
|
10
|
+
``dependencies`` metric key.
|
|
11
|
+
|
|
12
|
+
When no report can be read (``uv`` missing, no ``pyproject.toml`` or
|
|
13
|
+
``uv.lock``, no network) the tool reports no metric instead of a failing one.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
20
|
+
from py_cq.parsers.common import extract_first_issue, score_logistic_variant
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("cq")
|
|
23
|
+
|
|
24
|
+
_LOCKFILE = "uv.lock"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UvAuditParser(AbstractParser):
|
|
28
|
+
"""Parses raw JSON output from ``uv audit`` into a ToolResult."""
|
|
29
|
+
|
|
30
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
31
|
+
"""Parses the raw ``uv audit`` JSON report into a ToolResult."""
|
|
32
|
+
try:
|
|
33
|
+
vulnerabilities = json.loads(raw_result.stdout)["vulnerabilities"]
|
|
34
|
+
except (ValueError, KeyError, TypeError):
|
|
35
|
+
reason = (raw_result.stderr or "").strip().splitlines()[-1:]
|
|
36
|
+
log.warning(
|
|
37
|
+
"uv audit produced no report (return_code=%s), skipping: %s",
|
|
38
|
+
raw_result.return_code,
|
|
39
|
+
reason[0] if reason else "no output",
|
|
40
|
+
)
|
|
41
|
+
return ToolResult(raw=raw_result)
|
|
42
|
+
|
|
43
|
+
seen: set[tuple[str, str]] = set()
|
|
44
|
+
issues: list[dict] = []
|
|
45
|
+
for vuln in vulnerabilities:
|
|
46
|
+
fix_versions = [str(v) for v in vuln.get("fix_versions") or []]
|
|
47
|
+
if not fix_versions:
|
|
48
|
+
continue
|
|
49
|
+
dependency = vuln.get("dependency") or {}
|
|
50
|
+
package = dependency.get("name", "")
|
|
51
|
+
ids = {vuln.get("id", ""), *(vuln.get("aliases") or [])}
|
|
52
|
+
if any((package, i) in seen for i in ids):
|
|
53
|
+
continue
|
|
54
|
+
seen.update((package, i) for i in ids)
|
|
55
|
+
issues.append(
|
|
56
|
+
{
|
|
57
|
+
"code": vuln.get("id", ""),
|
|
58
|
+
"package": package,
|
|
59
|
+
"version": dependency.get("version", ""),
|
|
60
|
+
"fix_versions": fix_versions,
|
|
61
|
+
"message": vuln.get("summary") or "",
|
|
62
|
+
"link": vuln.get("link") or "",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
score = score_logistic_variant(len(issues), scale_factor=5)
|
|
67
|
+
details = {_LOCKFILE: issues} if issues else {}
|
|
68
|
+
return ToolResult(
|
|
69
|
+
raw=raw_result, metrics={"dependencies": score}, details=details
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def format_llm_message(
|
|
73
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Formats the first vulnerable dependency into an LLM-friendly message."""
|
|
76
|
+
result = extract_first_issue(tr.details)
|
|
77
|
+
if result is None:
|
|
78
|
+
return "uv audit reported issues (no details available)"
|
|
79
|
+
file, issue = result
|
|
80
|
+
package = issue.get("package", "")
|
|
81
|
+
fixed = ", ".join(issue.get("fix_versions", []))
|
|
82
|
+
message = (
|
|
83
|
+
f"{file} - {issue.get('code', '')}: **{package} {issue.get('version', '')}** "
|
|
84
|
+
f"has a known vulnerability: {issue.get('message', '')}\n\n"
|
|
85
|
+
f"Fixed in {fixed}. Upgrade with `uv lock --upgrade-package {package}`."
|
|
86
|
+
)
|
|
87
|
+
link = issue.get("link")
|
|
88
|
+
return f"{message}\n\n{link}" if link else message
|
py_cq/parsers/vultureparser.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""Parses output from vulture dead-code detector into a standardized ToolResult.
|
|
2
2
|
|
|
3
|
-
Vulture is invoked with ``--min-confidence
|
|
3
|
+
Vulture is invoked with ``--min-confidence 60``, producing one text line
|
|
4
4
|
per unused symbol::
|
|
5
5
|
|
|
6
|
-
src/foo.py:10: unused function 'bar' (
|
|
6
|
+
src/foo.py:10: unused function 'bar' (60% confidence)
|
|
7
|
+
|
|
8
|
+
60% is the confidence vulture gives unused functions, classes, methods and
|
|
9
|
+
variables, so anything higher only reports unused imports and unreachable
|
|
10
|
+
code. At that level vulture cannot see dynamic use, so the LLM message for a
|
|
11
|
+
finding below 80% says how to keep the symbol instead of deleting it.
|
|
7
12
|
|
|
8
13
|
The parser counts violations and converts the count into a logistic-variant
|
|
9
14
|
score stored under the ``dead_code`` metric key.
|
|
@@ -20,6 +25,13 @@ from py_cq.parsers.common import (
|
|
|
20
25
|
|
|
21
26
|
_LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
|
|
22
27
|
|
|
28
|
+
_CERTAIN = 80
|
|
29
|
+
_KEEP_HINT = (
|
|
30
|
+
"\n\nRemove it if nothing uses it. If it is public API or is used dynamically,"
|
|
31
|
+
' keep it and add `"{name}"` to `ignore_names` under `[tool.vulture]` in'
|
|
32
|
+
" `pyproject.toml`."
|
|
33
|
+
)
|
|
34
|
+
|
|
23
35
|
|
|
24
36
|
class VultureParser(AbstractParser):
|
|
25
37
|
"""Parses raw text output from ``vulture`` into a ToolResult."""
|
|
@@ -55,4 +67,7 @@ class VultureParser(AbstractParser):
|
|
|
55
67
|
kind = issue.get("type", "unused")
|
|
56
68
|
name = issue.get("name", "")
|
|
57
69
|
confidence = issue.get("confidence", "?")
|
|
58
|
-
|
|
70
|
+
message = f"{file}:{line} - **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
|
|
71
|
+
if isinstance(confidence, int) and confidence < _CERTAIN:
|
|
72
|
+
message += _KEEP_HINT.format(name=name)
|
|
73
|
+
return message
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Run pytest in a single process, for the coverage tool.
|
|
2
|
+
|
|
3
|
+
cq runs this script under ``coverage run``. Coverage only measures the process
|
|
4
|
+
it starts, so if the target project enables pytest-xdist through its own
|
|
5
|
+
``addopts``, the tests run in worker processes that coverage never sees and the
|
|
6
|
+
coverage score collapses. Passing ``-n 0`` keeps the tests in this process. It
|
|
7
|
+
is only passed when xdist is installed, because pytest rejects ``-n`` otherwise.
|
|
8
|
+
|
|
9
|
+
The script runs in the target project's environment, so it imports only the
|
|
10
|
+
standard library and pytest. It lives in its own directory because running a
|
|
11
|
+
script puts that directory first on ``sys.path``; that entry is replaced with
|
|
12
|
+
the working directory so imports behave as they do under ``python -m pytest``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from importlib.util import find_spec
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
sys.path[0] = os.getcwd()
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
args = sys.argv[1:]
|
|
25
|
+
if find_spec("xdist") is not None:
|
|
26
|
+
args += ["-n", "0"]
|
|
27
|
+
sys.exit(pytest.main(args))
|
py_cq/tool_registry.py
CHANGED
|
@@ -32,7 +32,6 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
32
32
|
exclude_format=tool_data.get("exclude_format", ""),
|
|
33
33
|
scan_exclude_names=tool_data.get("scan_exclude_names", []),
|
|
34
34
|
skip_for_file=tool_data.get("skip_for_file", False),
|
|
35
|
-
gate_strict=tool_data.get("gate_strict", True),
|
|
36
35
|
skip_if=tool_data.get("skip_if", ""),
|
|
37
36
|
cache_invariant=tool_data.get("cache_invariant", ""),
|
|
38
37
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-code-quality
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python Code Quality Analysis Tool - feed the results from
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python Code Quality Analysis Tool - feed the results from 14 CQ tools straight into an LLM. Minimal tokens.
|
|
5
5
|
Author: Chris Kilner
|
|
6
6
|
Author-email: Chris Kilner <chris@rhiza.fr>
|
|
7
7
|
License-Expression: MIT
|
|
@@ -35,7 +35,7 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
[](https://pypi.org/project/python-code-quality/)
|
|
36
36
|
[](LICENSE)
|
|
37
37
|
|
|
38
|
-
Run
|
|
38
|
+
Run 14 code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
|
|
39
39
|
|
|
40
40
|
This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
|
|
41
41
|
|
|
@@ -74,16 +74,24 @@ When running '-o llm', we run sequentially and exit early at the first error.
|
|
|
74
74
|
| 2 | ruff | Lint / style |
|
|
75
75
|
| 3 | ty | Type errors |
|
|
76
76
|
| 4 | bandit | Security vulnerabilities |
|
|
77
|
-
| 5 |
|
|
78
|
-
| 6 |
|
|
79
|
-
| 7 |
|
|
80
|
-
| 8 |
|
|
81
|
-
| 9 |
|
|
82
|
-
| 10 |
|
|
83
|
-
| 11 | radon
|
|
84
|
-
| 12 |
|
|
77
|
+
| 5 | kingfisher | Hardcoded secrets (API keys, tokens, private keys) |
|
|
78
|
+
| 6 | uv audit | Known vulnerabilities in locked dependencies |
|
|
79
|
+
| 7 | pytest | Test pass rate |
|
|
80
|
+
| 8 | coverage | Test coverage |
|
|
81
|
+
| 9 | vulture | Dead code |
|
|
82
|
+
| 10 | interrogate | Docstring coverage |
|
|
83
|
+
| 11 | radon cc | Cyclomatic complexity |
|
|
84
|
+
| 12 | radon mi | Maintainability index |
|
|
85
|
+
| 13 | radon hal | Halstead volume / bug estimate |
|
|
86
|
+
| 14 | ruff format | Formatting (fraction of files already formatted) |
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
`kingfisher` scans the working tree for hardcoded secrets with [Kingfisher](https://github.com/mongodb/kingfisher) (502 rules, Rust). It is not a Python dependency of cq: install it once with `uv tool install kingfisher-bin` (about 41 MB). When it is not on the `PATH` the tool is skipped with a warning. cq runs it offline (`--no-validate --no-update-check`) and with `--redact`, so secrets are never sent to provider APIs and never stored in the cache; the LLM message gives the file, line and rule but no source excerpt. Silence a test fixture or false positive with a `kingfisher:ignore` comment on that line.
|
|
89
|
+
|
|
90
|
+
`uv audit` checks the versions pinned in `uv.lock` against the OSV advisory database. It needs `uv` on the `PATH`, a `uv.lock` and network access; when any is missing the tool is skipped with a warning instead of failing. Only advisories that have a fix version are reported, since those are the ones an upgrade resolves. Results are cached like every other tool, so an advisory published after a run shows up when the project changes or the cache entry expires.
|
|
91
|
+
|
|
92
|
+
`vulture` runs at `--min-confidence 60`, the level at which it reports unused functions, classes, methods and variables. It cannot see dynamic use, so common framework decorators (`@app.command`, `@app.route`, `@pytest.fixture`, validators and similar) are ignored. For anything else that is used but never referenced by name, such as public API, add it to `ignore_names` under `[tool.vulture]` in your `pyproject.toml`.
|
|
93
|
+
|
|
94
|
+
Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, stored as JSON (cq never writes pickles).
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
## Usage
|
|
@@ -249,6 +257,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
|
|
|
249
257
|
│ ruff │ 0.17s │ lint │ 1.000 │ OK │
|
|
250
258
|
│ ty │ 0.33s │ type_check │ 1.000 │ OK │
|
|
251
259
|
│ bandit │ 0.56s │ security │ 1.000 │ OK │
|
|
260
|
+
│ uv-audit │ 0.41s │ dependencies │ 1.000 │ OK │
|
|
252
261
|
│ pytest │ 0.91s │ tests │ 1.000 │ OK │
|
|
253
262
|
│ coverage │ 1.26s │ coverage │ 0.910 │ OK │
|
|
254
263
|
│ vulture │ 0.32s │ dead_code │ 1.000 │ OK │
|
|
@@ -341,7 +350,7 @@ warning = 0.9
|
|
|
341
350
|
error = 0.7
|
|
342
351
|
```
|
|
343
352
|
|
|
344
|
-
Tool IDs match the keys in `src/py_cq/config/config.toml`: `compile`, `ruff`, `ty`, `bandit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`, `ruff-format`.
|
|
353
|
+
Tool IDs match the keys in `src/py_cq/config/config.toml`: `compile`, `ruff`, `ty`, `bandit`, `kingfisher`, `uv-audit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`, `ruff-format`.
|
|
345
354
|
|
|
346
355
|
|
|
347
356
|
### Default config
|
|
@@ -358,8 +367,8 @@ error_threshold = 0.9999
|
|
|
358
367
|
cache_invariant = "ast"
|
|
359
368
|
|
|
360
369
|
[python.ruff]
|
|
361
|
-
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
|
|
362
|
-
exclude_format = " --exclude {path}"
|
|
370
|
+
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude{exclude}"
|
|
371
|
+
exclude_format = " --extend-exclude {path}"
|
|
363
372
|
parser = "RuffParser"
|
|
364
373
|
order = 2
|
|
365
374
|
warning_threshold = 0.9999
|
|
@@ -384,11 +393,27 @@ order = 4
|
|
|
384
393
|
warning_threshold = 0.9999
|
|
385
394
|
error_threshold = 0.8
|
|
386
395
|
|
|
396
|
+
[python.kingfisher]
|
|
397
|
+
command = "kingfisher scan \"{context_path}\" --no-validate --no-update-check --git-history none --no-extract-archives --redact -q -f json --exclude .venv --exclude .git --exclude .hypothesis --exclude dist --exclude \"*_cache\" --exclude __pycache__ --exclude .claude{exclude}"
|
|
398
|
+
exclude_format = " --exclude {path}"
|
|
399
|
+
parser = "KingfisherParser"
|
|
400
|
+
order = 5
|
|
401
|
+
warning_threshold = 0.9999
|
|
402
|
+
error_threshold = 0.9999
|
|
403
|
+
|
|
404
|
+
[python.uv-audit]
|
|
405
|
+
command = "uv audit --project \"{abs_context_path}\" --frozen --output-format json"
|
|
406
|
+
parser = "UvAuditParser"
|
|
407
|
+
order = 6
|
|
408
|
+
warning_threshold = 0.9999
|
|
409
|
+
error_threshold = 0.8
|
|
410
|
+
skip_for_file = true
|
|
411
|
+
|
|
387
412
|
[python.pytest]
|
|
388
413
|
command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
|
|
389
414
|
exclude_format = " --ignore {path}"
|
|
390
415
|
parser = "PytestParser"
|
|
391
|
-
order =
|
|
416
|
+
order = 7
|
|
392
417
|
warning_threshold = 1.0
|
|
393
418
|
error_threshold = 1.0
|
|
394
419
|
run_in_target_env = true
|
|
@@ -397,10 +422,10 @@ skip_for_file = true
|
|
|
397
422
|
cache_invariant = "ast"
|
|
398
423
|
|
|
399
424
|
[python.coverage]
|
|
400
|
-
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py
|
|
425
|
+
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py,*/py_cq/runners/* \"{pytest_serial}\" \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py,*/py_cq/runners/*"
|
|
401
426
|
exclude_format = " --ignore {path}"
|
|
402
427
|
parser = "CoverageParser"
|
|
403
|
-
order =
|
|
428
|
+
order = 8
|
|
404
429
|
warning_threshold = 0.9
|
|
405
430
|
error_threshold = 0.5
|
|
406
431
|
run_in_target_env = true
|
|
@@ -410,19 +435,19 @@ skip_if = "pytest"
|
|
|
410
435
|
cache_invariant = "ast"
|
|
411
436
|
|
|
412
437
|
[python.vulture]
|
|
413
|
-
command = "{python} -m vulture \"{context_path}\" --min-confidence
|
|
438
|
+
command = "{python} -m vulture \"{context_path}\" --min-confidence 60 --ignore-decorators \"@*.command,@*.callback,@*.route,@*.get,@*.post,@*.put,@*.delete,@*.patch,@*fixture,@*.task,@*hookimpl,@*validator,@*.errorhandler\" --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
|
|
414
439
|
exclude_format = ",{path}"
|
|
415
440
|
parser = "VultureParser"
|
|
416
|
-
order =
|
|
441
|
+
order = 9
|
|
417
442
|
warning_threshold = 0.9999
|
|
418
443
|
error_threshold = 0.8
|
|
419
444
|
cache_invariant = "ast"
|
|
420
445
|
|
|
421
446
|
[python.interrogate]
|
|
422
|
-
command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
|
|
447
|
+
command = "{python} -m interrogate \"{context_path}\" -e .claude -e \"{context_path}/tests\"{exclude} -v --fail-under 0"
|
|
423
448
|
exclude_format = " -e {path}"
|
|
424
449
|
parser = "InterrogateParser"
|
|
425
|
-
order =
|
|
450
|
+
order = 10
|
|
426
451
|
warning_threshold = 0.8
|
|
427
452
|
error_threshold = 0.5
|
|
428
453
|
|
|
@@ -432,33 +457,29 @@ skip_empty_init = true
|
|
|
432
457
|
[python.radon-cc]
|
|
433
458
|
command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
|
|
434
459
|
parser = "ComplexityParser"
|
|
435
|
-
order =
|
|
436
|
-
gate_strict = false
|
|
460
|
+
order = 11
|
|
437
461
|
warning_threshold = 0.6
|
|
438
462
|
error_threshold = 0.4
|
|
439
463
|
|
|
440
464
|
[python.radon-mi]
|
|
441
465
|
command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
|
|
442
466
|
parser = "MaintainabilityParser"
|
|
443
|
-
order =
|
|
444
|
-
gate_strict = false
|
|
467
|
+
order = 12
|
|
445
468
|
warning_threshold = 0.6
|
|
446
469
|
error_threshold = 0.4
|
|
447
470
|
|
|
448
471
|
[python.radon-hal]
|
|
449
472
|
command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
|
|
450
473
|
parser = "HalsteadParser"
|
|
451
|
-
order =
|
|
452
|
-
gate_strict = false
|
|
474
|
+
order = 13
|
|
453
475
|
warning_threshold = 0.35
|
|
454
476
|
error_threshold = 0.3
|
|
455
477
|
|
|
456
478
|
[python.ruff-format]
|
|
457
|
-
command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
|
|
458
|
-
exclude_format = " --exclude {path}"
|
|
479
|
+
command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
|
|
480
|
+
exclude_format = " --extend-exclude {path}"
|
|
459
481
|
parser = "RuffFormatParser"
|
|
460
|
-
order =
|
|
461
|
-
gate_strict = false
|
|
482
|
+
order = 14
|
|
462
483
|
warning_threshold = 0.9999
|
|
463
484
|
error_threshold = 0.5
|
|
464
485
|
```
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
py_cq/__init__.py,sha256=-C6_QzQeydxft3ndlExTgWnvlqH8fJNPJxfySXULQmU,56
|
|
2
2
|
py_cq/api.py,sha256=u0ajk6ZYWFGjSsLYkRfUBBtL_Vw0HHZbWpa1AM79y8Y,10172
|
|
3
|
-
py_cq/cli.py,sha256=
|
|
3
|
+
py_cq/cli.py,sha256=i_-VCXVzrj2kpzbx8lEJxWmQNFyiFa37nsCMMkxqvas,13827
|
|
4
4
|
py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
|
|
5
|
-
py_cq/config/config.toml,sha256=
|
|
5
|
+
py_cq/config/config.toml,sha256=iV0kUp6ifZvHu_3fyNyC6C3Ggv7ts1zhopU0-3LH1U4,4170
|
|
6
6
|
py_cq/context_hash.py,sha256=uKMkN394v6QYMukZj9usbxnq8px3i1khyIRPKwe09DE,5614
|
|
7
|
-
py_cq/execution_engine.py,sha256=
|
|
7
|
+
py_cq/execution_engine.py,sha256=1-AM1PbIAdizj-PGf3cmePMKGmsV4pk6K2YgDHyerdA,21279
|
|
8
8
|
py_cq/language_detector.py,sha256=rh5bAA3LyCQR3RODmx_EalqRNYvSYK2SJzh2hIkxOUo,1139
|
|
9
9
|
py_cq/llm_formatter.py,sha256=nCV3nFdxJAi2CnH9FKb5tB-0w-yjLWmjywQI5GJ2cSU,8412
|
|
10
|
-
py_cq/localtypes.py,sha256
|
|
10
|
+
py_cq/localtypes.py,sha256=-XxoasKVOQ6hLzNX2KdpTxxHu3F2c_9RV3DCLPPj0D0,7794
|
|
11
11
|
py_cq/main.py,sha256=5dgr8Zk1LNK5M-0wncYVksBp0qE-jWZYqmOXfb1eOhk,400
|
|
12
12
|
py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
|
|
13
13
|
py_cq/parsers/__init__.py,sha256=uCy7R0cnvDPNvB5aWMLgM3FB9nDcu1jwUmznFMRROSY,28
|
|
@@ -19,6 +19,7 @@ py_cq/parsers/coverageparser.py,sha256=QGjZ8JJF0G-0uiZ07BzxSnblGiysHtOwq0SYvWrtO
|
|
|
19
19
|
py_cq/parsers/exitcodeparser.py,sha256=b4LWIwwklPwCX3MnqNsGQC1slQU56DKE05vJ0OdWVoA,957
|
|
20
20
|
py_cq/parsers/halsteadparser.py,sha256=789c_WWfomskM4i7SUEbKvB67-F2y5nfqt48aFXOusY,10192
|
|
21
21
|
py_cq/parsers/interrogateparser.py,sha256=KDqXTdnJ5JzWhN4ziRda90nZCQOJaKn3CCiq4Rh0IRw,11314
|
|
22
|
+
py_cq/parsers/kingfisherparser.py,sha256=-UbtZ4d_SAPoohgz9FwcVE4u33VB_8FtE1_dhAwJ1VM,3129
|
|
22
23
|
py_cq/parsers/linecountparser.py,sha256=-hqebwG4j7_QugzGLUUQRcPukGHicV2nbIp1pR8ZcAw,1310
|
|
23
24
|
py_cq/parsers/maintainabilityparser.py,sha256=3a4m93XNtUurw3IxEI1Iae9idgJY6qiEt7HMYl1wTu0,4793
|
|
24
25
|
py_cq/parsers/pytestparser.py,sha256=cIaTs2XxgeJlVragLEVTZcrbB-bzfNYYMdUrakRa-rQ,11545
|
|
@@ -26,12 +27,14 @@ py_cq/parsers/regexcountparser.py,sha256=zGzHEYDIori6aiWnVkRFixdE7fWt66npAAWjzvp
|
|
|
26
27
|
py_cq/parsers/ruffformatparser.py,sha256=UXff5Qwh9i0goiSTJ_AaaIsmMSssUjNY2O_dERXjsFw,2636
|
|
27
28
|
py_cq/parsers/ruffparser.py,sha256=uE0QEaZ4Ch7IsWY_2ZkfHXPU0TYPGsGPfv66hGe5G34,7634
|
|
28
29
|
py_cq/parsers/typarser.py,sha256=x835OrapG1aUteW2NhC5U0bhj1w1pMtbfmbvb74-1AI,8077
|
|
29
|
-
py_cq/parsers/
|
|
30
|
+
py_cq/parsers/uvauditparser.py,sha256=rnNLp4UD48utALr-ut1fCt3CKIu5Q80x75ws5s6ArqI,3652
|
|
31
|
+
py_cq/parsers/vultureparser.py,sha256=C3teaMYtROHqFrXHBvTvLMK9jbDK5Q8X4gAq5ruNTGM,2889
|
|
30
32
|
py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
|
+
py_cq/runners/pytest_serial.py,sha256=lhhkaKdwrgPzP3uKnHAfNgEE0iETjbvVh9xc23pbkXo,1026
|
|
31
34
|
py_cq/source_file.py,sha256=Uyv-CSVeliFnE6TpRB4Y15ykFQs4n0r9-amzxXXlqN0,7562
|
|
32
35
|
py_cq/table_formatter.py,sha256=-22nzVIOaDe3QOBmZ2iBPH7ewIEIPEn5WYSjCoB4Hj4,1608
|
|
33
|
-
py_cq/tool_registry.py,sha256=
|
|
34
|
-
python_code_quality-0.
|
|
35
|
-
python_code_quality-0.
|
|
36
|
-
python_code_quality-0.
|
|
37
|
-
python_code_quality-0.
|
|
36
|
+
py_cq/tool_registry.py,sha256=1LkaBzWu5NzbaEZdZp1JLGFXZpEyke4pHHe01UvcfPo,1834
|
|
37
|
+
python_code_quality-0.4.0.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
|
|
38
|
+
python_code_quality-0.4.0.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
|
|
39
|
+
python_code_quality-0.4.0.dist-info/METADATA,sha256=nHEPvS1bYCSgE-ev1QYGHOfdUehL8cEqv8CMCtVr89Y,19336
|
|
40
|
+
python_code_quality-0.4.0.dist-info/RECORD,,
|
{python_code_quality-0.3.1.dist-info → python_code_quality-0.4.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|