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/__init__.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Py cq"""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
`'Hello from py_cq!'`. It can serve as a minimal example, placeholder, or
|
|
5
|
-
testing stub in larger applications."""
|
|
3
|
+
from py_cq.api import CQ
|
|
6
4
|
|
|
5
|
+
__all__ = ["CQ"]
|
py_cq/api.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Library API for py-cq. Instantiate CQ with a project root, then call methods."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from py_cq.config import load_user_config
|
|
8
|
+
from py_cq.execution_engine import _cache, run_tools
|
|
9
|
+
from py_cq.llm_formatter import format_for_llm_json
|
|
10
|
+
from py_cq.localtypes import CombinedToolResults, Fingerprint, ToolConfig, ToolResult
|
|
11
|
+
from py_cq.metric_aggregator import aggregate_metrics
|
|
12
|
+
from py_cq.tool_registry import tool_registry
|
|
13
|
+
|
|
14
|
+
_KNOWN_PARSER_CLASSES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"CompileParser",
|
|
17
|
+
"RuffParser",
|
|
18
|
+
"TyParser",
|
|
19
|
+
"BanditParser",
|
|
20
|
+
"PytestParser",
|
|
21
|
+
"CoverageParser",
|
|
22
|
+
"ComplexityParser",
|
|
23
|
+
"MaintainabilityParser",
|
|
24
|
+
"HalsteadParser",
|
|
25
|
+
"VultureParser",
|
|
26
|
+
"InterrogateParser",
|
|
27
|
+
"ExitCodeParser",
|
|
28
|
+
"LineCountParser",
|
|
29
|
+
"RegexCountParser",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _apply_user_config(
|
|
35
|
+
base: dict[str, ToolConfig], user_cfg: dict
|
|
36
|
+
) -> dict[str, ToolConfig]:
|
|
37
|
+
"""Return a modified copy of base with user overrides applied.
|
|
38
|
+
|
|
39
|
+
Raises ValueError on invalid config (caller wraps for CLI context).
|
|
40
|
+
"""
|
|
41
|
+
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
42
|
+
for tool_id in user_cfg.get("disable", []):
|
|
43
|
+
registry.pop(tool_id, None)
|
|
44
|
+
for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
|
|
45
|
+
if tool_id in registry:
|
|
46
|
+
if "warning" in thresholds:
|
|
47
|
+
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
48
|
+
if "error" in thresholds:
|
|
49
|
+
registry[tool_id].error_threshold = float(thresholds["error"])
|
|
50
|
+
for tool_id, tool_data in user_cfg.get("tools", {}).items():
|
|
51
|
+
if tool_id in base:
|
|
52
|
+
available = ", ".join(sorted(base))
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"[tool.cq.tools.{tool_id}] is a built-in tool and cannot be redefined via pyproject.toml. "
|
|
55
|
+
f"Use [tool.cq.thresholds.{tool_id}] to adjust thresholds instead. "
|
|
56
|
+
f"Available: {available}"
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
parser_name = tool_data["parser"]
|
|
60
|
+
except KeyError:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"[tool.cq.tools.{tool_id}] missing required field 'parser'"
|
|
63
|
+
)
|
|
64
|
+
if parser_name not in _KNOWN_PARSER_CLASSES:
|
|
65
|
+
allowed = ", ".join(sorted(_KNOWN_PARSER_CLASSES))
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"[tool.cq.tools.{tool_id}] unknown parser {parser_name!r}. "
|
|
68
|
+
f"Allowed parsers: {allowed}"
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
module = import_module(f"py_cq.parsers.{parser_name.lower()}")
|
|
72
|
+
parser_class = getattr(module, parser_name)
|
|
73
|
+
registry[tool_id] = ToolConfig(
|
|
74
|
+
name=tool_id,
|
|
75
|
+
command=tool_data["command"],
|
|
76
|
+
parser_class=parser_class,
|
|
77
|
+
order=tool_data["order"],
|
|
78
|
+
warning_threshold=tool_data["warning_threshold"],
|
|
79
|
+
error_threshold=tool_data["error_threshold"],
|
|
80
|
+
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
81
|
+
extra_deps=tool_data.get("extra_deps", []),
|
|
82
|
+
parser_config=tool_data.get("parser_config", {}),
|
|
83
|
+
exclude_format=tool_data.get("exclude_format", ""),
|
|
84
|
+
)
|
|
85
|
+
except (KeyError, ImportError, AttributeError) as e:
|
|
86
|
+
raise ValueError(f"[tool.cq.tools.{tool_id}] {e}")
|
|
87
|
+
return registry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CQ:
|
|
91
|
+
"""Run code quality checks against a project root.
|
|
92
|
+
|
|
93
|
+
Config is loaded once at construction from pyproject.toml [tool.cq].
|
|
94
|
+
All methods return data objects; formatting is left to the caller.
|
|
95
|
+
|
|
96
|
+
Example::
|
|
97
|
+
|
|
98
|
+
cq = CQ(".")
|
|
99
|
+
issue = cq.check_llm_json() # {"id": ..., "message": ..., "file": ..., "project": ...}
|
|
100
|
+
fixed = cq.is_fixed(issue["id"])
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
path: str | Path,
|
|
106
|
+
*,
|
|
107
|
+
skip: list[str] | None = None,
|
|
108
|
+
only: list[str] | None = None,
|
|
109
|
+
exclude: list[str] | None = None,
|
|
110
|
+
workers: int = 0,
|
|
111
|
+
clear_cache: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.path = Path(path)
|
|
114
|
+
self._workers = workers
|
|
115
|
+
|
|
116
|
+
user_cfg = load_user_config(self.path)
|
|
117
|
+
self._context_lines: int = int(user_cfg.get("context_lines", 15))
|
|
118
|
+
self._registry = _apply_user_config(tool_registry, user_cfg)
|
|
119
|
+
|
|
120
|
+
if self.path.is_file():
|
|
121
|
+
self._registry = {
|
|
122
|
+
k: v for k, v in self._registry.items() if not v.skip_for_file
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if only:
|
|
126
|
+
keep = set(only)
|
|
127
|
+
unknown = keep - set(self._registry)
|
|
128
|
+
if unknown:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
|
|
131
|
+
)
|
|
132
|
+
self._registry = {k: v for k, v in self._registry.items() if k in keep}
|
|
133
|
+
if skip:
|
|
134
|
+
drop = set(skip)
|
|
135
|
+
unknown = drop - set(self._registry)
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
|
|
139
|
+
)
|
|
140
|
+
self._registry = {k: v for k, v in self._registry.items() if k not in drop}
|
|
141
|
+
|
|
142
|
+
config_excludes: list[str] = user_cfg.get("exclude", [])
|
|
143
|
+
self._excludes = list(dict.fromkeys(config_excludes + (exclude or [])))
|
|
144
|
+
self._project_root = (
|
|
145
|
+
self.path.resolve() if self.path.is_dir() else self.path.resolve().parent
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if clear_cache:
|
|
149
|
+
_cache.clear()
|
|
150
|
+
|
|
151
|
+
def raw(self, *, early_exit: bool = False) -> list[ToolResult]:
|
|
152
|
+
"""Run all tools and return parsed results before aggregation."""
|
|
153
|
+
return run_tools(
|
|
154
|
+
self._registry.values(),
|
|
155
|
+
str(self.path),
|
|
156
|
+
self._workers,
|
|
157
|
+
early_exit=early_exit,
|
|
158
|
+
excludes=self._excludes,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def check(self) -> CombinedToolResults:
|
|
162
|
+
"""Run all tools and return aggregated results."""
|
|
163
|
+
return aggregate_metrics(str(self.path), self.raw())
|
|
164
|
+
|
|
165
|
+
def check_llm_json(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
limit: int = 1,
|
|
169
|
+
silence: list[str] | None = None,
|
|
170
|
+
hint: bool = False,
|
|
171
|
+
) -> dict:
|
|
172
|
+
"""Return the top defect as a dict with keys: id, file, project, message.
|
|
173
|
+
|
|
174
|
+
Stops running tools after the first error (early_exit) for speed.
|
|
175
|
+
"""
|
|
176
|
+
results = self.raw(early_exit=True)
|
|
177
|
+
combined = aggregate_metrics(str(self.path), results)
|
|
178
|
+
return format_for_llm_json(
|
|
179
|
+
self._registry,
|
|
180
|
+
combined,
|
|
181
|
+
context_lines=self._context_lines,
|
|
182
|
+
hint=hint,
|
|
183
|
+
limit=limit,
|
|
184
|
+
silence=silence or [],
|
|
185
|
+
project_root=self._project_root,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def is_fixed(self, fingerprint: str) -> bool:
|
|
189
|
+
"""Return True if the fingerprinted issue is no longer present.
|
|
190
|
+
|
|
191
|
+
Fingerprint format: ``tool::project::path[::line[::code]]`` (as returned by check_llm_json["id"])
|
|
192
|
+
"""
|
|
193
|
+
fp = Fingerprint.from_string(fingerprint)
|
|
194
|
+
if not fp.tool:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Expected tool::project::path[::line[::code]], got: {fingerprint!r}"
|
|
197
|
+
)
|
|
198
|
+
if fp.tool not in tool_registry:
|
|
199
|
+
raise ValueError(f"Unknown tool: {fp.tool!r}")
|
|
200
|
+
|
|
201
|
+
if fp.code and fp.path:
|
|
202
|
+
# Specific issue: target only the affected file for a fast re-check
|
|
203
|
+
file_path = Path(fp.path)
|
|
204
|
+
if not file_path.is_absolute():
|
|
205
|
+
base = Path(fp.project) if fp.project else self._project_root
|
|
206
|
+
file_path = (base / file_path) if base else file_path
|
|
207
|
+
target = str(file_path)
|
|
208
|
+
elif fp.project and Path(fp.project).is_dir():
|
|
209
|
+
target = fp.project
|
|
210
|
+
elif fp.path:
|
|
211
|
+
file_path = Path(fp.path)
|
|
212
|
+
if not file_path.is_absolute():
|
|
213
|
+
file_path = (
|
|
214
|
+
(self._project_root / file_path)
|
|
215
|
+
if self._project_root
|
|
216
|
+
else file_path
|
|
217
|
+
)
|
|
218
|
+
target = str(file_path)
|
|
219
|
+
else:
|
|
220
|
+
target = str(self.path)
|
|
221
|
+
|
|
222
|
+
only_registry = {fp.tool: tool_registry[fp.tool]}
|
|
223
|
+
tool_results = run_tools(
|
|
224
|
+
only_registry.values(), target, max_workers=1, early_exit=False, excludes=[],
|
|
225
|
+
project_root=str(self.path),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if not tool_results:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
tr = tool_results[0]
|
|
232
|
+
tc = tool_registry[fp.tool]
|
|
233
|
+
|
|
234
|
+
if not fp.code:
|
|
235
|
+
return bool(tr.metrics and min(tr.metrics.values()) >= tc.warning_threshold)
|
|
236
|
+
|
|
237
|
+
def _file_matches(detail_file: str) -> bool:
|
|
238
|
+
p = Path(detail_file).as_posix()
|
|
239
|
+
t = Path(fp.path).as_posix()
|
|
240
|
+
return p == t or p.endswith(f"/{t}") or t.endswith(f"/{p}")
|
|
241
|
+
|
|
242
|
+
still_present = any(
|
|
243
|
+
str(i.get("line", "")) == fp.line and i.get("code") == fp.code
|
|
244
|
+
for f, issues in tr.details.items()
|
|
245
|
+
if _file_matches(f) and isinstance(issues, list)
|
|
246
|
+
for i in issues
|
|
247
|
+
)
|
|
248
|
+
return not still_present
|