python-code-quality 0.1.15__py3-none-any.whl → 0.2.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.
- py_cq/__init__.py +3 -4
- py_cq/api.py +248 -0
- py_cq/cli.py +218 -129
- py_cq/config/config.toml +95 -0
- py_cq/context_hash.py +18 -8
- py_cq/execution_engine.py +182 -26
- py_cq/language_detector.py +4 -1
- py_cq/llm_formatter.py +200 -18
- py_cq/localtypes.py +53 -7
- py_cq/main.py +1 -1
- py_cq/parsers/__init__.py +1 -1
- py_cq/parsers/banditparser.py +43 -14
- py_cq/parsers/common.py +187 -25
- py_cq/parsers/compileparser.py +21 -9
- py_cq/parsers/complexityparser.py +40 -4
- py_cq/parsers/coverageparser.py +184 -70
- py_cq/parsers/exitcodeparser.py +11 -2
- py_cq/parsers/halsteadparser.py +42 -14
- py_cq/parsers/interrogateparser.py +261 -25
- py_cq/parsers/linecountparser.py +10 -2
- py_cq/parsers/maintainabilityparser.py +34 -4
- py_cq/parsers/pytestparser.py +77 -20
- py_cq/parsers/regexcountparser.py +13 -3
- py_cq/parsers/ruffparser.py +160 -12
- py_cq/parsers/typarser.py +175 -39
- py_cq/parsers/vultureparser.py +22 -12
- py_cq/table_formatter.py +43 -0
- py_cq/tool_registry.py +7 -6
- {python_code_quality-0.1.15.dist-info → python_code_quality-0.2.1.dist-info}/METADATA +88 -3
- python_code_quality-0.2.1.dist-info/RECORD +35 -0
- {python_code_quality-0.1.15.dist-info → python_code_quality-0.2.1.dist-info}/WHEEL +1 -1
- py_cq/config/config.yaml +0 -94
- python_code_quality-0.1.15.dist-info/RECORD +0 -33
- {python_code_quality-0.1.15.dist-info → python_code_quality-0.2.1.dist-info}/entry_points.txt +0 -0
py_cq/cli.py
CHANGED
|
@@ -10,27 +10,26 @@ analysis.
|
|
|
10
10
|
Helper functions such as `format_as_table` convert the aggregated tool
|
|
11
11
|
results into a Rich Table for convenient console display.
|
|
12
12
|
"""
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import io
|
|
15
15
|
import json
|
|
16
16
|
import logging
|
|
17
|
+
import time
|
|
17
18
|
import tomllib
|
|
18
19
|
from enum import Enum
|
|
19
|
-
from importlib import import_module
|
|
20
20
|
from importlib.metadata import requires, version
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
+
import tomlkit
|
|
23
24
|
import typer
|
|
24
25
|
from rich.console import Console
|
|
25
26
|
from rich.logging import RichHandler
|
|
26
27
|
from rich.table import Table
|
|
27
28
|
|
|
28
|
-
from py_cq.
|
|
29
|
-
from py_cq.execution_engine import _cache as tool_cache
|
|
30
|
-
from py_cq.execution_engine import run_tools
|
|
29
|
+
from py_cq.api import CQ, _apply_user_config
|
|
31
30
|
from py_cq.language_detector import detect_language
|
|
32
|
-
from py_cq.localtypes import CombinedToolResults, ToolConfig
|
|
33
31
|
from py_cq.metric_aggregator import aggregate_metrics
|
|
32
|
+
from py_cq.table_formatter import format_as_table
|
|
34
33
|
from py_cq.tool_registry import tool_registry
|
|
35
34
|
|
|
36
35
|
logging.basicConfig(
|
|
@@ -44,60 +43,27 @@ app = typer.Typer(
|
|
|
44
43
|
epilog=(
|
|
45
44
|
"Examples:\n\n"
|
|
46
45
|
" cq check . # full table with all metrics (default)\n\n"
|
|
47
|
-
" cq check . -o llm
|
|
48
|
-
" cq check . -o
|
|
49
|
-
" cq check . -o
|
|
50
|
-
" cq check . -o
|
|
51
|
-
" cq
|
|
46
|
+
" cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
|
|
47
|
+
" cq check . -o llm-json # top defect as JSON with fingerprint for automation\n\n"
|
|
48
|
+
" cq check . -o score # numeric score only\n\n"
|
|
49
|
+
" cq check . -o json # parsed metrics as json\n\n"
|
|
50
|
+
" cq check . -o raw # unprocessed tool output as json\n\n"
|
|
51
|
+
" cq config # show effective tool configuration\n"
|
|
52
|
+
" cq config --path . # show configuration for current project\n\n"
|
|
53
|
+
" cq config set radon-hal --warning 0.45 --error 0.25 # set thresholds\n\n"
|
|
54
|
+
" cq config set radon-hal --error 0.25 --path . # set with path"
|
|
52
55
|
),
|
|
53
56
|
)
|
|
54
57
|
|
|
55
58
|
|
|
56
|
-
def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str, ToolConfig]:
|
|
57
|
-
"""Return a modified copy of base with user overrides applied.
|
|
58
|
-
|
|
59
|
-
Supports:
|
|
60
|
-
- ``disable``: list of tool IDs to remove
|
|
61
|
-
- ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
|
|
62
|
-
- ``tools.<tool_id>``: declare new tools (or override built-ins)
|
|
63
|
-
"""
|
|
64
|
-
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
65
|
-
for tool_id in user_cfg.get("disable", []):
|
|
66
|
-
registry.pop(tool_id, None)
|
|
67
|
-
for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
|
|
68
|
-
if tool_id in registry:
|
|
69
|
-
if "warning" in thresholds:
|
|
70
|
-
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
71
|
-
if "error" in thresholds:
|
|
72
|
-
registry[tool_id].error_threshold = float(thresholds["error"])
|
|
73
|
-
for tool_id, tool_data in user_cfg.get("tools", {}).items():
|
|
74
|
-
try:
|
|
75
|
-
parser_name = tool_data["parser"]
|
|
76
|
-
module = import_module(f"py_cq.parsers.{parser_name.lower()}")
|
|
77
|
-
parser_class = getattr(module, parser_name)
|
|
78
|
-
registry[tool_id] = ToolConfig(
|
|
79
|
-
name=tool_id,
|
|
80
|
-
command=tool_data["command"],
|
|
81
|
-
parser_class=parser_class,
|
|
82
|
-
order=tool_data["order"],
|
|
83
|
-
warning_threshold=tool_data["warning_threshold"],
|
|
84
|
-
error_threshold=tool_data["error_threshold"],
|
|
85
|
-
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
86
|
-
extra_deps=tool_data.get("extra_deps", []),
|
|
87
|
-
parser_config=tool_data.get("parser_config", {}),
|
|
88
|
-
exclude_format=tool_data.get("exclude_format", ""),
|
|
89
|
-
)
|
|
90
|
-
except KeyError as e:
|
|
91
|
-
raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
|
|
92
|
-
return registry
|
|
93
|
-
|
|
94
|
-
|
|
95
59
|
class OutputMode(str, Enum):
|
|
96
60
|
"""Enum of output types."""
|
|
61
|
+
|
|
97
62
|
TABLE = "table"
|
|
98
63
|
SCORE = "score"
|
|
99
64
|
JSON = "json"
|
|
100
65
|
LLM = "llm"
|
|
66
|
+
LLM_JSON = "llm-json"
|
|
101
67
|
RAW = "raw"
|
|
102
68
|
|
|
103
69
|
|
|
@@ -105,13 +71,11 @@ def _version_callback(value: bool) -> None:
|
|
|
105
71
|
if not value:
|
|
106
72
|
return
|
|
107
73
|
import re
|
|
108
|
-
|
|
109
|
-
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
110
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
74
|
+
|
|
111
75
|
pkg = "python-code-quality"
|
|
112
76
|
pkg_version = version(pkg)
|
|
113
77
|
dep_versions: list[tuple[str, str]] = []
|
|
114
|
-
for req in
|
|
78
|
+
for req in requires(pkg) or []:
|
|
115
79
|
if "; extra ==" in req:
|
|
116
80
|
continue
|
|
117
81
|
dep_name = re.split(r"[>=<!;\s\[]", req)[0]
|
|
@@ -121,17 +85,28 @@ def _version_callback(value: bool) -> None:
|
|
|
121
85
|
pass
|
|
122
86
|
typer.echo(f"{pkg} v{pkg_version}")
|
|
123
87
|
for dep_name, dep_ver in sorted(dep_versions):
|
|
124
|
-
typer.echo(f"
|
|
88
|
+
typer.echo(f"+-- {dep_name} v{dep_ver}")
|
|
125
89
|
raise typer.Exit()
|
|
126
90
|
|
|
127
91
|
|
|
128
92
|
@app.callback()
|
|
129
93
|
def callback(
|
|
130
94
|
_: bool = typer.Option(
|
|
131
|
-
False,
|
|
95
|
+
False,
|
|
96
|
+
"--version",
|
|
97
|
+
"-V",
|
|
98
|
+
callback=_version_callback,
|
|
99
|
+
is_eager=True,
|
|
100
|
+
help="Show version and dependencies",
|
|
132
101
|
),
|
|
133
102
|
) -> None:
|
|
134
103
|
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
107
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
135
110
|
console = Console()
|
|
136
111
|
|
|
137
112
|
|
|
@@ -139,7 +114,10 @@ console = Console()
|
|
|
139
114
|
def check(
|
|
140
115
|
path: str = typer.Argument(".", help="Path to Python file or project directory"),
|
|
141
116
|
output: OutputMode = typer.Option(
|
|
142
|
-
OutputMode.TABLE,
|
|
117
|
+
OutputMode.TABLE,
|
|
118
|
+
"--output",
|
|
119
|
+
"-o",
|
|
120
|
+
help="Output mode: table (default), score, json, llm",
|
|
143
121
|
),
|
|
144
122
|
log_level: str = typer.Option(
|
|
145
123
|
"CRITICAL",
|
|
@@ -150,10 +128,15 @@ def check(
|
|
|
150
128
|
False, "--clear-cache", help="Clear cached tool results before running"
|
|
151
129
|
),
|
|
152
130
|
workers: int = typer.Option(
|
|
153
|
-
0,
|
|
131
|
+
0,
|
|
132
|
+
"--workers",
|
|
133
|
+
help="Max parallel workers (default: one per tool, use 1 for sequential)",
|
|
154
134
|
),
|
|
155
135
|
language: str | None = typer.Option(
|
|
156
|
-
None,
|
|
136
|
+
None,
|
|
137
|
+
"--language",
|
|
138
|
+
"-l",
|
|
139
|
+
help="Override language detection (e.g. python, typescript, rust)",
|
|
157
140
|
),
|
|
158
141
|
only: str | None = typer.Option(
|
|
159
142
|
None, "--only", help="Comma-separated tool IDs to run (e.g. ruff,ty,pytest)"
|
|
@@ -164,8 +147,20 @@ def check(
|
|
|
164
147
|
exclude: str | None = typer.Option(
|
|
165
148
|
None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
|
|
166
149
|
),
|
|
150
|
+
hint: bool = typer.Option(
|
|
151
|
+
False, "--hint", help="Append 'run cq again to verify' to -o llm output"
|
|
152
|
+
),
|
|
153
|
+
limit: int = typer.Option(
|
|
154
|
+
1, "--limit", help="Number of issues to show with -o llm (default: 1)"
|
|
155
|
+
),
|
|
156
|
+
silence: list[str] = typer.Option(
|
|
157
|
+
[],
|
|
158
|
+
"--silence",
|
|
159
|
+
"-s",
|
|
160
|
+
help="Silence issues from -o llm output (e.g. -s src/foo.py or -s src/foo.py:42:E501)",
|
|
161
|
+
),
|
|
167
162
|
):
|
|
168
|
-
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
|
|
163
|
+
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
|
|
169
164
|
path_obj = Path(path)
|
|
170
165
|
if not path_obj.exists():
|
|
171
166
|
raise typer.BadParameter(f"Path does not exist: {path}")
|
|
@@ -179,49 +174,76 @@ def check(
|
|
|
179
174
|
)
|
|
180
175
|
raise typer.Exit(0)
|
|
181
176
|
|
|
182
|
-
# Python path (or unknown — fall through to existing validation).
|
|
183
|
-
# Note: --language python still requires pyproject.toml; the flag selects
|
|
184
|
-
# the tool set, not the input validation rules.
|
|
185
177
|
if path_obj.is_file():
|
|
186
178
|
if path_obj.suffix != ".py":
|
|
187
179
|
raise typer.BadParameter(f"File must be a Python file (.py): {path}")
|
|
188
|
-
elif path_obj.is_dir():
|
|
180
|
+
elif path_obj.is_dir(): # pragma: no branch
|
|
189
181
|
if not (path_obj / "pyproject.toml").exists():
|
|
190
182
|
raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
|
|
191
183
|
log.setLevel(log_level)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
184
|
+
|
|
185
|
+
only_list = [t.strip() for t in only.split(",")] if only else None
|
|
186
|
+
skip_list = [t.strip() for t in skip.split(",")] if skip else None
|
|
187
|
+
exclude_list = [e.strip() for e in exclude.split(",")] if exclude else None
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
cq = CQ(
|
|
191
|
+
path_obj,
|
|
192
|
+
only=only_list,
|
|
193
|
+
skip=skip_list,
|
|
194
|
+
exclude=exclude_list,
|
|
195
|
+
workers=workers,
|
|
196
|
+
clear_cache=clear_cache,
|
|
197
|
+
)
|
|
198
|
+
except ValueError as e:
|
|
199
|
+
raise typer.BadParameter(str(e))
|
|
200
|
+
|
|
201
|
+
is_llm = output in (OutputMode.LLM, OutputMode.LLM_JSON)
|
|
202
|
+
t0 = time.perf_counter()
|
|
203
|
+
tool_results = cq.raw(early_exit=is_llm)
|
|
204
|
+
total_s = time.perf_counter() - t0
|
|
205
|
+
combined = aggregate_metrics(path, tool_results)
|
|
206
|
+
|
|
210
207
|
if output == OutputMode.SCORE:
|
|
211
|
-
console.print(
|
|
208
|
+
console.print(combined.score)
|
|
212
209
|
elif output == OutputMode.JSON:
|
|
213
210
|
print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
|
|
214
211
|
elif output == OutputMode.RAW:
|
|
215
212
|
print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
|
|
216
213
|
elif output == OutputMode.LLM:
|
|
217
|
-
# log.setLevel("CRITICAL")
|
|
218
214
|
from py_cq.llm_formatter import format_for_llm
|
|
219
|
-
|
|
215
|
+
|
|
216
|
+
print(
|
|
217
|
+
format_for_llm(
|
|
218
|
+
cq._registry,
|
|
219
|
+
combined,
|
|
220
|
+
context_lines=cq._context_lines,
|
|
221
|
+
hint=hint,
|
|
222
|
+
limit=limit,
|
|
223
|
+
silence=silence,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
elif output == OutputMode.LLM_JSON:
|
|
227
|
+
from py_cq.llm_formatter import format_for_llm_json
|
|
228
|
+
|
|
229
|
+
print(
|
|
230
|
+
json.dumps(
|
|
231
|
+
format_for_llm_json(
|
|
232
|
+
cq._registry,
|
|
233
|
+
combined,
|
|
234
|
+
context_lines=cq._context_lines,
|
|
235
|
+
hint=hint,
|
|
236
|
+
limit=limit,
|
|
237
|
+
silence=silence,
|
|
238
|
+
project_root=cq._project_root,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
)
|
|
220
242
|
else:
|
|
221
243
|
console.print(f"[bold green]{path_obj.resolve()}[/]")
|
|
222
|
-
console.print(format_as_table(
|
|
244
|
+
console.print(format_as_table(combined, cq._registry, total_s=total_s))
|
|
223
245
|
|
|
224
|
-
tool_by_name = {tc.name: tc for tc in
|
|
246
|
+
tool_by_name = {tc.name: tc for tc in cq._registry.values()}
|
|
225
247
|
if any(
|
|
226
248
|
min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
|
|
227
249
|
for tr in tool_results
|
|
@@ -230,11 +252,20 @@ def check(
|
|
|
230
252
|
raise typer.Exit(code=1)
|
|
231
253
|
|
|
232
254
|
|
|
233
|
-
|
|
255
|
+
config_app = typer.Typer(help="Show or modify tool configuration")
|
|
256
|
+
app.add_typer(config_app, name="config")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@config_app.callback(invoke_without_command=True)
|
|
234
260
|
def config(
|
|
235
|
-
|
|
261
|
+
ctx: typer.Context,
|
|
262
|
+
path: str = typer.Option(
|
|
263
|
+
".", "--path", "-p", help="Path to Python file or project directory"
|
|
264
|
+
),
|
|
236
265
|
) -> None:
|
|
237
266
|
"""Show the effective tool configuration for a project."""
|
|
267
|
+
if ctx.invoked_subcommand is not None:
|
|
268
|
+
return
|
|
238
269
|
path_obj = Path(path).resolve()
|
|
239
270
|
toml_path = (
|
|
240
271
|
path_obj.parent / "pyproject.toml"
|
|
@@ -258,7 +289,10 @@ def config(
|
|
|
258
289
|
|
|
259
290
|
console.print(f"Config: [bold]{toml_path}[/bold] ({status_text})\n")
|
|
260
291
|
|
|
261
|
-
|
|
292
|
+
try:
|
|
293
|
+
effective_registry = _apply_user_config(tool_registry, user_cfg)
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
raise typer.BadParameter(str(e))
|
|
262
296
|
disabled_ids = set(tool_registry.keys()) - set(effective_registry.keys())
|
|
263
297
|
|
|
264
298
|
table = Table()
|
|
@@ -269,7 +303,10 @@ def config(
|
|
|
269
303
|
table.add_column("Status", justify="center")
|
|
270
304
|
|
|
271
305
|
all_tool_ids = set(tool_registry) | set(effective_registry)
|
|
272
|
-
for tool_id in sorted(
|
|
306
|
+
for tool_id in sorted(
|
|
307
|
+
all_tool_ids,
|
|
308
|
+
key=lambda t: (effective_registry.get(t) or tool_registry[t]).order,
|
|
309
|
+
):
|
|
273
310
|
tc = effective_registry.get(tool_id) or tool_registry[tool_id]
|
|
274
311
|
is_disabled = tool_id in disabled_ids
|
|
275
312
|
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
|
@@ -284,41 +321,93 @@ def config(
|
|
|
284
321
|
console.print(table)
|
|
285
322
|
|
|
286
323
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
324
|
+
@config_app.command("set")
|
|
325
|
+
def config_set(
|
|
326
|
+
tool_id: str = typer.Argument(..., help="Tool ID (e.g. radon-hal, ruff)"),
|
|
327
|
+
warning: float | None = typer.Option(
|
|
328
|
+
None, "--warning", "-w", help="Warning threshold (0-1)"
|
|
329
|
+
),
|
|
330
|
+
error: float | None = typer.Option(
|
|
331
|
+
None, "--error", "-e", help="Error threshold (0-1)"
|
|
332
|
+
),
|
|
333
|
+
path: str = typer.Option(".", "--path", "-p", help="Path to project directory"),
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Set warning/error thresholds for a tool in pyproject.toml."""
|
|
336
|
+
if warning is None and error is None:
|
|
337
|
+
raise typer.BadParameter("At least one of --warning or --error is required")
|
|
338
|
+
|
|
339
|
+
if tool_id not in tool_registry:
|
|
340
|
+
available = ", ".join(sorted(tool_registry))
|
|
341
|
+
raise typer.BadParameter(
|
|
342
|
+
f"Unknown tool: {tool_id!r}. Available tools: {available}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
path_obj = Path(path).resolve()
|
|
346
|
+
if not path_obj.is_dir():
|
|
347
|
+
raise typer.BadParameter(f"Path must be a directory: {path}")
|
|
348
|
+
|
|
349
|
+
toml_path = path_obj / "pyproject.toml"
|
|
350
|
+
if not toml_path.exists():
|
|
351
|
+
raise typer.BadParameter(f"No pyproject.toml found at {toml_path}")
|
|
352
|
+
|
|
353
|
+
with toml_path.open("r", encoding="utf-8") as f:
|
|
354
|
+
doc = tomlkit.parse(f.read())
|
|
355
|
+
|
|
356
|
+
if "tool" not in doc:
|
|
357
|
+
doc["tool"] = tomlkit.table()
|
|
358
|
+
tool_tbl = doc["tool"]
|
|
359
|
+
if "cq" not in tool_tbl:
|
|
360
|
+
tool_tbl["cq"] = tomlkit.table()
|
|
361
|
+
cq_tbl = tool_tbl["cq"]
|
|
362
|
+
if "thresholds" not in cq_tbl:
|
|
363
|
+
cq_tbl["thresholds"] = tomlkit.table()
|
|
364
|
+
thresholds = cq_tbl["thresholds"]
|
|
365
|
+
|
|
366
|
+
if tool_id in thresholds:
|
|
367
|
+
entry = thresholds[tool_id]
|
|
368
|
+
else:
|
|
369
|
+
entry = tomlkit.inline_table()
|
|
370
|
+
|
|
371
|
+
if warning is not None:
|
|
372
|
+
entry["warning"] = warning
|
|
373
|
+
if error is not None:
|
|
374
|
+
entry["error"] = error
|
|
375
|
+
thresholds[tool_id] = entry
|
|
376
|
+
|
|
377
|
+
with toml_path.open("w", encoding="utf-8") as f:
|
|
378
|
+
f.write(tomlkit.dumps(doc))
|
|
379
|
+
|
|
380
|
+
parts = []
|
|
381
|
+
if warning is not None:
|
|
382
|
+
parts.append(f"warning={warning}")
|
|
383
|
+
if error is not None:
|
|
384
|
+
parts.append(f"error={error}")
|
|
385
|
+
console.print(
|
|
386
|
+
f"[green]Set {tool_id} thresholds ({', '.join(parts)}) in {toml_path}[/green]"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
from py_cq.execution_engine import _cache
|
|
390
|
+
|
|
391
|
+
_cache.clear()
|
|
392
|
+
console.print("[dim]Tool cache cleared[/dim]")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.command()
|
|
396
|
+
def is_fixed(
|
|
397
|
+
fingerprint: str = typer.Argument(
|
|
398
|
+
...,
|
|
399
|
+
help="Fingerprint from -o llm-json output (tool::project::path[::line[::code]])",
|
|
400
|
+
),
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Return True if the fingerprinted issue is no longer present."""
|
|
403
|
+
try:
|
|
404
|
+
cq = CQ(".")
|
|
405
|
+
fixed = cq.is_fixed(fingerprint)
|
|
406
|
+
except ValueError as e:
|
|
407
|
+
raise typer.BadParameter(str(e))
|
|
408
|
+
|
|
409
|
+
if fixed:
|
|
410
|
+
typer.echo("FIXED")
|
|
411
|
+
else:
|
|
412
|
+
typer.echo(f"FAILED: {fingerprint}")
|
|
413
|
+
raise typer.Exit(1)
|
py_cq/config/config.toml
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[python.compile]
|
|
2
|
+
command = "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv|\\.claude"
|
|
3
|
+
parser = "CompileParser"
|
|
4
|
+
order = 1
|
|
5
|
+
warning_threshold = 0.9999
|
|
6
|
+
error_threshold = 0.9999
|
|
7
|
+
|
|
8
|
+
[python.ruff]
|
|
9
|
+
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
|
|
10
|
+
exclude_format = " --exclude {path}"
|
|
11
|
+
parser = "RuffParser"
|
|
12
|
+
order = 2
|
|
13
|
+
warning_threshold = 0.9999
|
|
14
|
+
error_threshold = 0.9
|
|
15
|
+
|
|
16
|
+
[python.ty]
|
|
17
|
+
command = "{python} -m ty check --output-format concise --color never \"{context_path}\" --exclude .claude{exclude}"
|
|
18
|
+
exclude_format = " --exclude {path}"
|
|
19
|
+
parser = "TyParser"
|
|
20
|
+
order = 3
|
|
21
|
+
warning_threshold = 0.9999
|
|
22
|
+
error_threshold = 0.8
|
|
23
|
+
run_in_target_env = true
|
|
24
|
+
extra_deps = ["ty"]
|
|
25
|
+
|
|
26
|
+
[python.bandit]
|
|
27
|
+
command = "{python} -m bandit -r {scan_targets} -f json -q -s B101 --severity-level medium{exclude}"
|
|
28
|
+
scan_exclude_names = [".venv", ".claude", "tests"]
|
|
29
|
+
exclude_format = " --exclude {abs_native_path}"
|
|
30
|
+
parser = "BanditParser"
|
|
31
|
+
order = 4
|
|
32
|
+
warning_threshold = 0.9999
|
|
33
|
+
error_threshold = 0.8
|
|
34
|
+
|
|
35
|
+
[python.pytest]
|
|
36
|
+
command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
|
|
37
|
+
exclude_format = " --ignore {path}"
|
|
38
|
+
parser = "PytestParser"
|
|
39
|
+
order = 5
|
|
40
|
+
warning_threshold = 1.0
|
|
41
|
+
error_threshold = 1.0
|
|
42
|
+
run_in_target_env = true
|
|
43
|
+
extra_deps = ["pytest"]
|
|
44
|
+
skip_for_file = true
|
|
45
|
+
|
|
46
|
+
[python.coverage]
|
|
47
|
+
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
|
|
48
|
+
exclude_format = " --ignore {path}"
|
|
49
|
+
parser = "CoverageParser"
|
|
50
|
+
order = 6
|
|
51
|
+
warning_threshold = 0.9
|
|
52
|
+
error_threshold = 0.5
|
|
53
|
+
run_in_target_env = true
|
|
54
|
+
extra_deps = ["coverage", "pytest"]
|
|
55
|
+
skip_for_file = true
|
|
56
|
+
|
|
57
|
+
[python.radon-cc]
|
|
58
|
+
command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
|
|
59
|
+
parser = "ComplexityParser"
|
|
60
|
+
order = 7
|
|
61
|
+
warning_threshold = 0.6
|
|
62
|
+
error_threshold = 0.4
|
|
63
|
+
|
|
64
|
+
[python.radon-mi]
|
|
65
|
+
command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
|
|
66
|
+
parser = "MaintainabilityParser"
|
|
67
|
+
order = 8
|
|
68
|
+
warning_threshold = 0.6
|
|
69
|
+
error_threshold = 0.4
|
|
70
|
+
|
|
71
|
+
[python.radon-hal]
|
|
72
|
+
command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
|
|
73
|
+
parser = "HalsteadParser"
|
|
74
|
+
order = 9
|
|
75
|
+
warning_threshold = 0.5
|
|
76
|
+
error_threshold = 0.3
|
|
77
|
+
|
|
78
|
+
[python.vulture]
|
|
79
|
+
command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
|
|
80
|
+
exclude_format = ",{path}"
|
|
81
|
+
parser = "VultureParser"
|
|
82
|
+
order = 10
|
|
83
|
+
warning_threshold = 0.9999
|
|
84
|
+
error_threshold = 0.8
|
|
85
|
+
|
|
86
|
+
[python.interrogate]
|
|
87
|
+
command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
|
|
88
|
+
exclude_format = " -e {path}"
|
|
89
|
+
parser = "InterrogateParser"
|
|
90
|
+
order = 11
|
|
91
|
+
warning_threshold = 0.8
|
|
92
|
+
error_threshold = 0.3
|
|
93
|
+
|
|
94
|
+
[python.interrogate.parser_config]
|
|
95
|
+
skip_empty_init = true
|
py_cq/context_hash.py
CHANGED
|
@@ -44,15 +44,22 @@ def get_sigs(path: str):
|
|
|
44
44
|
items = []
|
|
45
45
|
with os.scandir(path) as entries:
|
|
46
46
|
for entry in entries:
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
# Use follow_symlinks=False to prevent cache poisoning from
|
|
48
|
+
# symlinks pointing outside the project tree (M-2)
|
|
49
|
+
if entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
|
|
50
|
+
stat_info = entry.stat(follow_symlinks=False)
|
|
49
51
|
items.append(f"{entry.path}:{stat_info.st_size}:{stat_info.st_mtime}")
|
|
50
|
-
if entry.is_dir() and entry.name not in [
|
|
52
|
+
if entry.is_dir(follow_symlinks=False) and entry.name not in [
|
|
53
|
+
".venv",
|
|
54
|
+
"venv",
|
|
55
|
+
"__pycache__",
|
|
56
|
+
".git",
|
|
57
|
+
]:
|
|
51
58
|
items.extend(get_sigs(entry.path))
|
|
52
59
|
return items
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
def get_context_hash(path: str):
|
|
62
|
+
def get_context_hash(path: str) -> str:
|
|
56
63
|
"""Compute an MD5 hash that uniquely identifies a file or directory.
|
|
57
64
|
|
|
58
65
|
The hash is derived from a signature string. For a file, the signature consists of
|
|
@@ -72,10 +79,13 @@ def get_context_hash(path: str):
|
|
|
72
79
|
>>> get_context_hash('/tmp/example.txt')
|
|
73
80
|
'5d41402abc4b2a76b9719d911017c592'
|
|
74
81
|
"""
|
|
75
|
-
|
|
82
|
+
h = hashlib.md5() # nosec
|
|
76
83
|
if os.path.isfile(path):
|
|
77
84
|
s = os.stat(path)
|
|
78
|
-
|
|
85
|
+
h.update(f"{path}:{s.st_size}:{s.st_mtime}".encode())
|
|
79
86
|
elif os.path.isdir(path):
|
|
80
|
-
sig
|
|
81
|
-
|
|
87
|
+
for sig in sorted(get_sigs(path)):
|
|
88
|
+
h.update(sig.encode())
|
|
89
|
+
else:
|
|
90
|
+
h.update(b"empty")
|
|
91
|
+
return h.hexdigest()
|