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
|
@@ -7,52 +7,288 @@ per-file docstring coverage on stdout::
|
|
|
7
7
|
| TOTAL | 5 | 2 | 3 | 60.0% |
|
|
8
8
|
|
|
9
9
|
The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
|
|
10
|
-
as the ``doc_coverage`` metric (0.0
|
|
10
|
+
as the ``doc_coverage`` metric (0.0-1.0).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import ast
|
|
13
14
|
import re
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
14
17
|
|
|
15
18
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
19
|
+
from py_cq.parsers.common import (
|
|
20
|
+
extract_first_issue,
|
|
21
|
+
format_issue_header,
|
|
22
|
+
format_source_context,
|
|
23
|
+
)
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
_SKIP_PARAMS = {"self", "cls"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_missing_docstring(file_str: str, line: int, code: str, message: str) -> str:
|
|
29
|
+
base = format_issue_header(file_str, line, code, message) + format_source_context(
|
|
30
|
+
file_str, line
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if code == "D100":
|
|
34
|
+
return base + "\n\nInsert a module-level docstring as the very first statement in the file."
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
source = Path(file_str).read_text(encoding="utf-8")
|
|
38
|
+
tree = ast.parse(source)
|
|
39
|
+
except (OSError, SyntaxError):
|
|
40
|
+
return base + "\n\nInsert a docstring as the first statement in the body."
|
|
41
|
+
|
|
42
|
+
for node in ast.walk(tree):
|
|
43
|
+
if isinstance(node, ast.ClassDef) and node.lineno == line:
|
|
44
|
+
insert_line = node.body[0].lineno
|
|
45
|
+
return (
|
|
46
|
+
base
|
|
47
|
+
+ f"\n\nInsert a docstring on line {insert_line} (first line of the class body)."
|
|
48
|
+
+ "\n\nA good class docstring describes the class purpose in one sentence."
|
|
49
|
+
)
|
|
50
|
+
if (
|
|
51
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
52
|
+
and node.lineno == line
|
|
53
|
+
):
|
|
54
|
+
insert_line = node.body[0].lineno
|
|
55
|
+
params = [a.arg for a in node.args.args if a.arg not in _SKIP_PARAMS]
|
|
56
|
+
returns = ast.unparse(node.returns) if node.returns else None
|
|
57
|
+
|
|
58
|
+
hint = f"\n\nInsert a docstring on line {insert_line} (first line of the function body)."
|
|
59
|
+
hint += "\n\nA good one-line docstring describes what the function does (not how)."
|
|
60
|
+
|
|
61
|
+
indent = " "
|
|
62
|
+
if returns:
|
|
63
|
+
hint += f' Return annotation is `{returns}` - start the docstring with a verb like "Return ...".'
|
|
64
|
+
hint += f'\n\nExample:\n```python\n{indent}"""Return the <value> for <reason>."""\n```'
|
|
65
|
+
else:
|
|
66
|
+
hint += f'\n\nExample:\n```python\n{indent}"""Do <action> and return <result>."""\n```'
|
|
67
|
+
|
|
68
|
+
if params:
|
|
69
|
+
hint += f"\n\nDocument non-obvious parameters: {', '.join(f'`{p}`' for p in params)}."
|
|
70
|
+
|
|
71
|
+
return base + hint
|
|
72
|
+
|
|
73
|
+
return base + "\n\nInsert a docstring as the first statement in the body."
|
|
74
|
+
|
|
75
|
+
_ROW_RE = re.compile(
|
|
76
|
+
r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|"
|
|
77
|
+
)
|
|
78
|
+
_CONTEXT_PATH_RE = re.compile(r'interrogate\s+"([^"]+)"')
|
|
79
|
+
_COVERAGE_FOR_RE = re.compile(r"Coverage for\s+(.+?)[\s=]*$")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_interrogate_cfg(context_path: str) -> dict:
|
|
83
|
+
"""Read [tool.interrogate] from the nearest pyproject.toml."""
|
|
84
|
+
p = Path(context_path).resolve()
|
|
85
|
+
for candidate in [p, *p.parents]:
|
|
86
|
+
pyproject = (candidate if candidate.is_dir() else candidate.parent) / "pyproject.toml"
|
|
87
|
+
if pyproject.exists():
|
|
88
|
+
try:
|
|
89
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
90
|
+
return data.get("tool", {}).get("interrogate", {})
|
|
91
|
+
except Exception:
|
|
92
|
+
return {}
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _skip_node(name: str, cfg: dict) -> bool:
|
|
97
|
+
"""Return True if interrogate would skip this name given cfg."""
|
|
98
|
+
is_magic = name.startswith("__") and name.endswith("__")
|
|
99
|
+
is_private = name.startswith("__") and not name.endswith("__")
|
|
100
|
+
is_semiprivate = name.startswith("_") and not name.startswith("__")
|
|
101
|
+
if name == "__init__" and cfg.get("ignore-init-method"):
|
|
102
|
+
return True
|
|
103
|
+
if is_magic and cfg.get("ignore-magic"):
|
|
104
|
+
return True
|
|
105
|
+
if is_private and cfg.get("ignore-private"):
|
|
106
|
+
return True
|
|
107
|
+
if is_semiprivate and cfg.get("ignore-semiprivate"):
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[int, str, str]]:
|
|
113
|
+
"""Return (line, kind, source_line) for each node missing a docstring.
|
|
114
|
+
|
|
115
|
+
kind is 'module', 'def', or 'class'. source_line is the verbatim text of
|
|
116
|
+
the def/class line (for searching), or the first non-empty source line for
|
|
117
|
+
the module.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
source = file_path.read_text(encoding="utf-8")
|
|
121
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
122
|
+
except (OSError, SyntaxError):
|
|
123
|
+
return []
|
|
124
|
+
src_lines = source.splitlines()
|
|
125
|
+
|
|
126
|
+
def src_line(lineno: int) -> str:
|
|
127
|
+
"""Return the stripped content of the source line at the given line number."""
|
|
128
|
+
return src_lines[lineno - 1].strip() if 0 < lineno <= len(src_lines) else ""
|
|
129
|
+
|
|
130
|
+
def first_code_line() -> str:
|
|
131
|
+
"""Return the first non-empty, non-comment line of the source."""
|
|
132
|
+
for ln in src_lines:
|
|
133
|
+
stripped = ln.strip()
|
|
134
|
+
if stripped and not stripped.startswith("#"):
|
|
135
|
+
return stripped
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
effective_cfg = cfg or {}
|
|
139
|
+
results = []
|
|
140
|
+
for node in ast.walk(tree):
|
|
141
|
+
if isinstance(node, ast.Module):
|
|
142
|
+
if not ast.get_docstring(node):
|
|
143
|
+
results.append((0, "module", first_code_line()))
|
|
144
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
145
|
+
if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
|
|
146
|
+
results.append((node.lineno, "def", src_line(node.lineno)))
|
|
147
|
+
elif isinstance(node, ast.ClassDef):
|
|
148
|
+
if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
|
|
149
|
+
results.append((node.lineno, "class", src_line(node.lineno)))
|
|
150
|
+
results.sort(key=lambda x: x[0])
|
|
151
|
+
return results
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_file_empty(path: Path | None) -> bool:
|
|
155
|
+
"""Check if the file is empty."""
|
|
156
|
+
if path is None:
|
|
157
|
+
return False
|
|
158
|
+
try:
|
|
159
|
+
return not path.read_text(encoding="utf-8").strip()
|
|
160
|
+
except OSError:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve(context_path: str, rel_file: str) -> Path | None:
|
|
165
|
+
"""Return the first existing Path for rel_file, trying context_path then cwd."""
|
|
166
|
+
via_context = Path(context_path) / rel_file
|
|
167
|
+
if via_context.exists():
|
|
168
|
+
return via_context
|
|
169
|
+
# When context_path is a file, interrogate reports only the basename.
|
|
170
|
+
# Try the parent directory so "dig.py" resolves against "D:/.../dig.py".
|
|
171
|
+
via_parent = Path(context_path).parent / rel_file
|
|
172
|
+
if via_parent.exists():
|
|
173
|
+
return via_parent
|
|
174
|
+
direct = Path(rel_file)
|
|
175
|
+
if direct.exists():
|
|
176
|
+
return direct
|
|
177
|
+
return None
|
|
18
178
|
|
|
19
179
|
|
|
20
180
|
class InterrogateParser(AbstractParser):
|
|
21
181
|
"""Parses raw output from ``interrogate -v`` into a ToolResult."""
|
|
22
182
|
|
|
23
183
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
24
|
-
|
|
25
|
-
|
|
184
|
+
cm = _CONTEXT_PATH_RE.search(raw_result.command)
|
|
185
|
+
context_path = cm.group(1) if cm else "."
|
|
186
|
+
interrogate_cfg = _load_interrogate_cfg(context_path)
|
|
187
|
+
|
|
188
|
+
# Interrogate reports paths relative to the package root it discovers,
|
|
189
|
+
# not relative to context_path. Parse the "Coverage for <dir>" header
|
|
190
|
+
# to compute the prefix needed to make paths project-relative.
|
|
191
|
+
prefix = ""
|
|
192
|
+
for line in (raw_result.stdout or "").splitlines():
|
|
193
|
+
hm = _COVERAGE_FOR_RE.search(line)
|
|
194
|
+
if hm:
|
|
195
|
+
coverage_root = hm.group(1).strip().rstrip("\\/")
|
|
196
|
+
try:
|
|
197
|
+
rel = (
|
|
198
|
+
Path(coverage_root)
|
|
199
|
+
.resolve()
|
|
200
|
+
.relative_to(Path(context_path).resolve())
|
|
201
|
+
)
|
|
202
|
+
prefix = rel.as_posix()
|
|
203
|
+
except ValueError:
|
|
204
|
+
pass
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
summaries: dict[str, dict] = {}
|
|
26
208
|
for line in (raw_result.stdout or "").splitlines():
|
|
27
209
|
m = _ROW_RE.match(line)
|
|
28
210
|
if not m:
|
|
29
211
|
continue
|
|
30
|
-
name = m.group(1).strip()
|
|
212
|
+
name = m.group(1).strip().replace("\\", "/")
|
|
31
213
|
total = int(m.group(2))
|
|
32
214
|
miss = int(m.group(3))
|
|
33
215
|
cover = float(m.group(4))
|
|
34
|
-
if name
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
files[name.replace("\\", "/")] = {
|
|
216
|
+
if name != "TOTAL" and total > 0 and ".venv" not in name:
|
|
217
|
+
file_key = f"{prefix}/{name}" if prefix else name
|
|
218
|
+
summaries[file_key] = {
|
|
38
219
|
"total": total,
|
|
39
220
|
"missing": miss,
|
|
40
221
|
"coverage": cover / 100.0,
|
|
41
222
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
223
|
+
|
|
224
|
+
if self.parser_config.get("skip_empty_init", True):
|
|
225
|
+
summaries = {
|
|
226
|
+
f: d
|
|
227
|
+
for f, d in summaries.items()
|
|
228
|
+
if not (
|
|
229
|
+
Path(f).name == "__init__.py"
|
|
230
|
+
and _is_file_empty(_resolve(context_path, f))
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
total_docs = sum(d["total"] for d in summaries.values())
|
|
235
|
+
missing_docs = sum(d["missing"] for d in summaries.values())
|
|
236
|
+
score = (total_docs - missing_docs) / total_docs if total_docs > 0 else 1.0
|
|
237
|
+
|
|
238
|
+
# Build per-issue list details (sorted worst-first) so _fingerprint_from_slice
|
|
239
|
+
# can produce a specific line+code fingerprint for is_fixed checks.
|
|
240
|
+
files: dict[str, list] = {}
|
|
241
|
+
for rel_file, summary in sorted(
|
|
242
|
+
summaries.items(), key=lambda x: x[1]["coverage"]
|
|
243
|
+
):
|
|
244
|
+
if summary["missing"] == 0:
|
|
245
|
+
continue
|
|
246
|
+
resolved = _resolve(context_path, rel_file)
|
|
247
|
+
nodes = _missing_docstrings(resolved, interrogate_cfg) if resolved else []
|
|
248
|
+
issues = []
|
|
249
|
+
for lineno, kind, source_line in nodes:
|
|
250
|
+
if kind == "module":
|
|
251
|
+
code, message = "D100", "missing module docstring"
|
|
252
|
+
lineno = 1
|
|
253
|
+
elif kind == "class":
|
|
254
|
+
nm = re.search(r"class\s+(\w+)", source_line)
|
|
255
|
+
name = nm.group(1) if nm else source_line
|
|
256
|
+
code, message = "D101", f"missing docstring in class `{name}`"
|
|
257
|
+
else:
|
|
258
|
+
nm = re.search(r"def\s+(\w+)", source_line)
|
|
259
|
+
name = nm.group(1) if nm else source_line
|
|
260
|
+
code, message = "D103", f"missing docstring in function `{name}`"
|
|
261
|
+
issues.append(
|
|
262
|
+
{
|
|
263
|
+
"line": lineno if lineno > 0 else 1,
|
|
264
|
+
"code": code,
|
|
265
|
+
"message": message,
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
if issues:
|
|
269
|
+
files[rel_file] = issues
|
|
270
|
+
|
|
271
|
+
return ToolResult(
|
|
272
|
+
raw=raw_result, metrics={"doc_coverage": score}, details=files
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def format_llm_message(
|
|
276
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
277
|
+
) -> str:
|
|
278
|
+
result = extract_first_issue(tr.details)
|
|
279
|
+
if result is None:
|
|
280
|
+
score = tr.metrics.get("doc_coverage", 0)
|
|
52
281
|
return f"**doc_coverage** score: {score:.3f}"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
282
|
+
|
|
283
|
+
m = _CONTEXT_PATH_RE.search(tr.raw.command)
|
|
284
|
+
context_path = m.group(1) if m else "."
|
|
285
|
+
|
|
286
|
+
rel_file, issue = result
|
|
287
|
+
line = issue.get("line", 1)
|
|
288
|
+
code = issue.get("code", "D100")
|
|
289
|
+
message = issue.get("message", "missing docstring")
|
|
290
|
+
|
|
291
|
+
resolved = _resolve(context_path, rel_file)
|
|
292
|
+
file_str = str(resolved) if resolved else rel_file
|
|
293
|
+
|
|
294
|
+
return _format_missing_docstring(file_str, line, code, message)
|
py_cq/parsers/linecountparser.py
CHANGED
|
@@ -12,13 +12,21 @@ class LineCountParser(AbstractParser):
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
15
|
+
"""
|
|
16
|
+
Parse the raw result to count non-empty lines and calculate the violation score.
|
|
17
|
+
"""
|
|
15
18
|
lines = [ln for ln in (raw_result.stdout or "").splitlines() if ln.strip()]
|
|
16
19
|
count = len(lines)
|
|
17
20
|
scale = self.parser_config.get("scale_factor", 15)
|
|
18
21
|
score = score_logistic_variant(count, scale_factor=scale)
|
|
19
|
-
return ToolResult(
|
|
22
|
+
return ToolResult(
|
|
23
|
+
raw=raw_result, metrics={"violations": score}, details={"lines": lines}
|
|
24
|
+
)
|
|
20
25
|
|
|
21
|
-
def format_llm_message(
|
|
26
|
+
def format_llm_message(
|
|
27
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Formats the ToolResult lines into a string for the LLM message."""
|
|
22
30
|
lines = tr.details.get("lines", [])
|
|
23
31
|
if not lines:
|
|
24
32
|
return "No violations found"
|
|
@@ -4,10 +4,8 @@ The `MaintainabilityParser` implements the `AbstractParser` interface to
|
|
|
4
4
|
convert raw JSON output from a maintainability tool into a
|
|
5
5
|
`ToolResult` structure that other components of the framework can consume."""
|
|
6
6
|
|
|
7
|
-
import json
|
|
8
|
-
|
|
9
7
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
10
|
-
from py_cq.parsers.common import score_logistic_variant
|
|
8
|
+
from py_cq.parsers.common import parse_json_dict, score_logistic_variant
|
|
11
9
|
|
|
12
10
|
|
|
13
11
|
class MaintainabilityParser(AbstractParser):
|
|
@@ -41,7 +39,10 @@ class MaintainabilityParser(AbstractParser):
|
|
|
41
39
|
* ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
|
|
42
40
|
* ``details['return_code']`` - the tool's exit code."""
|
|
43
41
|
tr = ToolResult(raw=raw_result)
|
|
44
|
-
data =
|
|
42
|
+
data = parse_json_dict(raw_result.stdout)
|
|
43
|
+
if data is None:
|
|
44
|
+
tr.metrics["maintainability"] = 0.0
|
|
45
|
+
return tr
|
|
45
46
|
num_items = 0
|
|
46
47
|
score = 0
|
|
47
48
|
for file, values in data.items():
|
|
@@ -61,3 +62,32 @@ class MaintainabilityParser(AbstractParser):
|
|
|
61
62
|
}
|
|
62
63
|
tr.metrics["maintainability"] = score / num_items if num_items > 0 else 0.0
|
|
63
64
|
return tr
|
|
65
|
+
|
|
66
|
+
def format_llm_message(
|
|
67
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Format the LLM message with details about the worst maintainability scores."""
|
|
70
|
+
worst_file = worst_rank = None
|
|
71
|
+
worst_score = 1.0
|
|
72
|
+
for file, data in tr.details.items():
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
continue
|
|
75
|
+
error = data.get("error")
|
|
76
|
+
score = data.get("mi", 1.0)
|
|
77
|
+
if score < worst_score:
|
|
78
|
+
worst_score = score
|
|
79
|
+
worst_file = file
|
|
80
|
+
worst_rank = data.get("rank", "F")
|
|
81
|
+
if error:
|
|
82
|
+
worst_rank = f"F (error: {error})"
|
|
83
|
+
if worst_file is None:
|
|
84
|
+
if tr.metrics:
|
|
85
|
+
metric_name, value = next(iter(tr.metrics.items()))
|
|
86
|
+
return f"**{metric_name}** score: {value:.3f}"
|
|
87
|
+
return "No maintainability details available"
|
|
88
|
+
return (
|
|
89
|
+
f"`{worst_file}` - maintainability rank **{worst_rank}**\n\n"
|
|
90
|
+
"The maintainability index is low. Common causes: long functions, high complexity, "
|
|
91
|
+
"deeply nested logic, or lack of comments. Refactor by extracting helpers and "
|
|
92
|
+
"simplifying control flow."
|
|
93
|
+
)
|
py_cq/parsers/pytestparser.py
CHANGED
|
@@ -8,11 +8,24 @@ process return code so downstream components can uniformly consume results
|
|
|
8
8
|
from multiple test tools. It is part of the test-collection framework and
|
|
9
9
|
enables consistent handling of pytest output across the system."""
|
|
10
10
|
|
|
11
|
+
import functools
|
|
11
12
|
import re as _re
|
|
13
|
+
from pathlib import Path as _Path
|
|
12
14
|
|
|
13
15
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
@functools.lru_cache(maxsize=64)
|
|
19
|
+
def _section_pattern(test_name: str) -> _re.Pattern:
|
|
20
|
+
return _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _target_dir(command: str) -> str:
|
|
24
|
+
"""Extract the --directory value from a uv run command, or ''."""
|
|
25
|
+
m = _re.search(r'--directory\s+"?([^"\s]+)"?', command)
|
|
26
|
+
return m.group(1) if m else ""
|
|
27
|
+
|
|
28
|
+
|
|
16
29
|
def _last_call_line_for_test(stdout: str, test_name: str) -> str:
|
|
17
30
|
"""Return the last source line before E-lines in a test's failure section.
|
|
18
31
|
|
|
@@ -20,7 +33,7 @@ def _last_call_line_for_test(stdout: str, test_name: str) -> str:
|
|
|
20
33
|
current-executing-line marker.
|
|
21
34
|
"""
|
|
22
35
|
lines = stdout.splitlines()
|
|
23
|
-
pattern =
|
|
36
|
+
pattern = _section_pattern(test_name)
|
|
24
37
|
in_section = False
|
|
25
38
|
last_src = ""
|
|
26
39
|
for line in lines:
|
|
@@ -68,7 +81,7 @@ def _extract_collection_error(stdout: str) -> dict | None:
|
|
|
68
81
|
def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
|
|
69
82
|
"""Extract the failure section for test_name from pytest stdout."""
|
|
70
83
|
lines = stdout.splitlines()
|
|
71
|
-
pattern =
|
|
84
|
+
pattern = _section_pattern(test_name)
|
|
72
85
|
start = None
|
|
73
86
|
for i, line in enumerate(lines):
|
|
74
87
|
if pattern.search(line):
|
|
@@ -76,14 +89,37 @@ def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
|
|
|
76
89
|
break
|
|
77
90
|
if start is None:
|
|
78
91
|
return ""
|
|
79
|
-
|
|
92
|
+
# Collect the full block - skip sub-section dividers ("_ _ _") but stop at
|
|
93
|
+
# the next test header ("____") or summary line ("====").
|
|
94
|
+
section = []
|
|
80
95
|
for line in lines[start:]:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
collected.append(line)
|
|
84
|
-
if len(collected) >= max_lines:
|
|
96
|
+
stripped = line.strip()
|
|
97
|
+
if _re.match(r"_{4,}", stripped) or stripped.startswith("="):
|
|
85
98
|
break
|
|
86
|
-
|
|
99
|
+
section.append(line)
|
|
100
|
+
# Prefer E-lines (the actual assertion / exception messages).
|
|
101
|
+
e_lines = [ln for ln in section if ln.startswith("E ") or ln.strip() == "E"]
|
|
102
|
+
if e_lines:
|
|
103
|
+
arrow_lines = [
|
|
104
|
+
ln.lstrip("> \t")
|
|
105
|
+
for ln in section
|
|
106
|
+
if ln.startswith(">") and ln.lstrip("> \t")
|
|
107
|
+
]
|
|
108
|
+
cleaned = [_re.sub(r"^E\s*", "", ln) for ln in e_lines]
|
|
109
|
+
# Drop "At index N diff:" lines - always redundant with the first E-line.
|
|
110
|
+
cleaned = [ln for ln in cleaned if not _re.match(r"At index \d+ diff:", ln)]
|
|
111
|
+
parts = ([f"> {arrow_lines[-1]}"] if arrow_lines else []) + cleaned
|
|
112
|
+
text = "\n".join(parts[:max_lines]).strip()
|
|
113
|
+
else:
|
|
114
|
+
# Fall back: show the traceback up to max_lines, stopping before E-lines.
|
|
115
|
+
sub = []
|
|
116
|
+
for line in section:
|
|
117
|
+
if line.startswith("E ") or line.strip() == "E":
|
|
118
|
+
break
|
|
119
|
+
sub.append(line)
|
|
120
|
+
if len(sub) >= max_lines:
|
|
121
|
+
break
|
|
122
|
+
text = "\n".join(sub).strip()
|
|
87
123
|
return f"\n```\n{text}\n```" if text else ""
|
|
88
124
|
|
|
89
125
|
|
|
@@ -132,7 +168,10 @@ class PytestParser(AbstractParser):
|
|
|
132
168
|
passed_tests = 0
|
|
133
169
|
for line in lines:
|
|
134
170
|
# tests/test_common.py::test_name[param] PASSED [ 8%]
|
|
135
|
-
tests_match = _re.search(
|
|
171
|
+
tests_match = _re.search(
|
|
172
|
+
r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)",
|
|
173
|
+
line,
|
|
174
|
+
)
|
|
136
175
|
if tests_match:
|
|
137
176
|
test_file = tests_match.group(1)
|
|
138
177
|
test_name = tests_match.group(2).strip()
|
|
@@ -144,7 +183,9 @@ class PytestParser(AbstractParser):
|
|
|
144
183
|
if num_tests == 0:
|
|
145
184
|
# No individual test lines found (e.g. non-verbose output);
|
|
146
185
|
# fall back to parsing the pytest summary line.
|
|
147
|
-
summary = _re.search(
|
|
186
|
+
summary = _re.search(
|
|
187
|
+
r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout
|
|
188
|
+
)
|
|
148
189
|
if summary:
|
|
149
190
|
passed_tests = int(summary.group(1))
|
|
150
191
|
failed_tests = int(summary.group(2)) if summary.group(2) else 0
|
|
@@ -153,29 +194,40 @@ class PytestParser(AbstractParser):
|
|
|
153
194
|
tr.details = tests_found
|
|
154
195
|
return tr
|
|
155
196
|
|
|
156
|
-
def format_llm_message(
|
|
197
|
+
def format_llm_message(
|
|
198
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
199
|
+
) -> str:
|
|
157
200
|
"""Return the first failing test with function body, failure output, and callee signature."""
|
|
158
201
|
from py_cq.parsers.common import (
|
|
159
202
|
extract_callee_name,
|
|
160
203
|
find_function_source,
|
|
161
204
|
format_callee_context,
|
|
162
205
|
)
|
|
206
|
+
|
|
163
207
|
for file, tests in tr.details.items():
|
|
164
208
|
if not isinstance(tests, dict):
|
|
165
209
|
continue
|
|
166
210
|
for test_name, status in tests.items():
|
|
167
211
|
if status != "FAILED":
|
|
168
212
|
continue
|
|
169
|
-
header = f"`{file}::{test_name}`
|
|
213
|
+
header = f"`{file}::{test_name}` - test **FAILED**"
|
|
170
214
|
bare_name = test_name.split("[")[0]
|
|
171
|
-
|
|
172
|
-
|
|
215
|
+
tdir = _target_dir(tr.raw.command)
|
|
216
|
+
resolved = (
|
|
217
|
+
(_Path(tdir) / file).as_posix()
|
|
218
|
+
if tdir and not _Path(file).is_absolute()
|
|
219
|
+
else file
|
|
220
|
+
)
|
|
221
|
+
body = find_function_source(
|
|
222
|
+
resolved, bare_name, max_lines=context_lines
|
|
223
|
+
)
|
|
224
|
+
failure = _extract_failure(tr.raw.stdout, test_name, max_lines=50)
|
|
173
225
|
callee = ""
|
|
174
226
|
call_line = _last_call_line_for_test(tr.raw.stdout, test_name)
|
|
175
227
|
if call_line:
|
|
176
228
|
func_name = extract_callee_name(call_line)
|
|
177
229
|
if func_name and func_name != bare_name:
|
|
178
|
-
callee = format_callee_context(func_name,
|
|
230
|
+
callee = format_callee_context(func_name, resolved)
|
|
179
231
|
parts = [header]
|
|
180
232
|
if body:
|
|
181
233
|
parts.append(body)
|
|
@@ -186,19 +238,24 @@ class PytestParser(AbstractParser):
|
|
|
186
238
|
return "\n".join(parts)
|
|
187
239
|
if "no tests ran" in tr.raw.stdout:
|
|
188
240
|
return (
|
|
189
|
-
"**No tests found.**
|
|
190
|
-
"
|
|
191
|
-
"and write a first test covering a core function."
|
|
241
|
+
"**No tests found.** pytest ran but collected nothing.\n\n"
|
|
242
|
+
"Create `tests/test_basic.py` and write a first test covering a core function."
|
|
192
243
|
)
|
|
193
244
|
from py_cq.parsers.common import (
|
|
194
245
|
extract_callee_name,
|
|
195
246
|
format_callee_context,
|
|
196
247
|
format_source_context,
|
|
197
248
|
)
|
|
249
|
+
|
|
198
250
|
combined = tr.raw.stdout + tr.raw.stderr
|
|
199
251
|
err = _extract_collection_error(combined)
|
|
200
252
|
if err:
|
|
201
|
-
file, line, typ, help_msg =
|
|
253
|
+
file, line, typ, help_msg = (
|
|
254
|
+
err["file"],
|
|
255
|
+
err["line"],
|
|
256
|
+
err["type"],
|
|
257
|
+
err["help"],
|
|
258
|
+
)
|
|
202
259
|
code_block = format_source_context(file, line, count=context_lines) or ""
|
|
203
260
|
callee = ""
|
|
204
261
|
# try to find callee from the offending source line via format_source_context result
|
|
@@ -211,7 +268,7 @@ class PytestParser(AbstractParser):
|
|
|
211
268
|
func_name = extract_callee_name(src_line)
|
|
212
269
|
if func_name:
|
|
213
270
|
callee = format_callee_context(func_name, file)
|
|
214
|
-
return f"`{file}:{line}`
|
|
271
|
+
return f"`{file}:{line}` - **{typ}**: {help_msg}{code_block}{callee}"
|
|
215
272
|
output = combined.strip()
|
|
216
273
|
if output:
|
|
217
274
|
tail = "\n".join(output.splitlines()[-30:])
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Parser that counts stdout lines matching a regex pattern."""
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import re
|
|
4
5
|
|
|
5
6
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
@@ -14,11 +15,17 @@ class RegexCountParser(AbstractParser):
|
|
|
14
15
|
scale_factor (int, default 15): passed to score_logistic_variant.
|
|
15
16
|
"""
|
|
16
17
|
|
|
18
|
+
@functools.cached_property
|
|
19
|
+
def _pattern(self) -> re.Pattern:
|
|
20
|
+
return re.compile(self.parser_config["pattern"])
|
|
21
|
+
|
|
17
22
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
18
|
-
|
|
23
|
+
"""
|
|
24
|
+
Parses the raw result using a regex pattern and computes a score.
|
|
25
|
+
"""
|
|
19
26
|
scale = self.parser_config.get("scale_factor", 15)
|
|
20
27
|
lines = (raw_result.stdout or "").splitlines()
|
|
21
|
-
matches = [ln for ln in lines if
|
|
28
|
+
matches = [ln for ln in lines if self._pattern.search(ln)]
|
|
22
29
|
count = len(matches)
|
|
23
30
|
score = score_logistic_variant(count, scale_factor=scale)
|
|
24
31
|
return ToolResult(
|
|
@@ -27,7 +34,10 @@ class RegexCountParser(AbstractParser):
|
|
|
27
34
|
details={"count": count, "matches": matches},
|
|
28
35
|
)
|
|
29
36
|
|
|
30
|
-
def format_llm_message(
|
|
37
|
+
def format_llm_message(
|
|
38
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Formats the LLM message with match details."""
|
|
31
41
|
matches = tr.details.get("matches", [])
|
|
32
42
|
if not matches:
|
|
33
43
|
return "No violations found"
|