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.
@@ -1,88 +1,202 @@
1
- """Parses raw coverage tool output into a standardized `ToolResult` for consistent analysis across different coverage utilities.
2
- The module defines `CoverageParser`, a concrete implementation of `AbstractParser`, which extracts overall and per-file coverage metrics from a `RawResult` object and normalises the data format for downstream processing."""
1
+ """Parses raw coverage tool output into structured ToolResult instances with per-function granularity."""
3
2
 
3
+ import ast
4
4
  import logging
5
+ from pathlib import Path
5
6
 
6
7
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
8
+ from py_cq.parsers.common import find_function_source, resolve_path
7
9
 
8
10
  log = logging.getLogger("cq")
9
11
 
10
12
 
13
+ def _parse_line_ranges(s: str) -> set[int]:
14
+ """Parse a comma-separated string of line ranges and individual lines.
15
+
16
+ Example: "1, 3-5, 10" -> {1, 3, 4, 5, 10}
17
+ """
18
+ result: set[int] = set()
19
+ for part in s.split(","):
20
+ part = part.strip()
21
+ if "-" in part:
22
+ lo, hi = part.split("-", 1)
23
+ try:
24
+ result.update(range(int(lo), int(hi) + 1))
25
+ except ValueError:
26
+ pass
27
+ elif part.isdigit():
28
+ result.add(int(part))
29
+ return result
30
+
31
+
32
+ def _get_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
33
+ """Return the signature of the function definition as a string."""
34
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
35
+ args = ast.unparse(node.args)
36
+ returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
37
+ return f"{prefix} {node.name}({args}){returns}"
38
+
39
+
40
+ def _extract_functions(file: str, missing_lines_str: str) -> list[tuple[str, int, str]]:
41
+ """Return (name, lineno, signature) for functions whose bodies overlap with the missing line ranges."""
42
+ try:
43
+ source = Path(file).read_text(encoding="utf-8", errors="replace")
44
+ tree = ast.parse(source)
45
+ except (OSError, SyntaxError, ValueError):
46
+ return []
47
+ missing = _parse_line_ranges(missing_lines_str)
48
+ seen: set[str] = set()
49
+ result: list[tuple[str, int, str]] = []
50
+ for node in sorted(ast.walk(tree), key=lambda n: getattr(n, "lineno", 0)):
51
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
52
+ end = getattr(node, "end_lineno", node.lineno)
53
+ if missing & set(range(node.lineno, end + 1)) and node.name not in seen:
54
+ seen.add(node.name)
55
+ result.append((node.name, node.lineno, _get_signature(node)))
56
+ return result
57
+
58
+
59
+ def _find_test_file(source_file: str) -> str | None:
60
+ """Return the test file path for source_file if a tests/ directory exists nearby."""
61
+ candidate_name = f"test_{Path(source_file).stem}.py"
62
+ try:
63
+ for ancestor in Path(source_file).parents:
64
+ try:
65
+ tests_dir = ancestor / "tests"
66
+ if tests_dir.is_dir():
67
+ return str(tests_dir / candidate_name).replace("\\", "/")
68
+ except (OSError, ValueError):
69
+ pass
70
+ except (OSError, ValueError):
71
+ pass
72
+ return None
73
+
74
+
11
75
  class CoverageParser(AbstractParser):
12
- """Parses raw coverage output into structured ToolResult instances.
13
- Extends AbstractParser, extracting overall coverage percentages, per-file coverage values, normalising file paths, and preserving the tool's return code."""
76
+ """Parser for coverage results."""
14
77
 
15
78
  def parse(self, raw_result: RawResult) -> ToolResult:
16
- """Parse raw coverage output into a :class:`ToolResult`.
17
-
18
- Given a :class:`RawResult` containing the stdout of a coverage tool, the
19
- method extracts every line that ends with a percent sign. Each such line
20
- is expected to follow the format::
21
-
22
- <file> <total_lines> <covered_lines> <coverage>%
23
-
24
- The coverage percentage is converted to a fraction (e.g. 90\u202f% → 0.9) and
25
- stored in ``metrics['coverage']`` for the overall ``TOTAL`` line, while
26
- the per-file values are placed in ``details`` with the file path
27
- normalised to use forward slashes. The tool's return code is added to
28
- ``details`` under the key ``'return_code'``.
29
-
30
- Args:
31
- raw_result (RawResult): The raw output from a coverage tool.
32
-
33
- Returns:
34
- ToolResult: A structured result containing the overall coverage
35
- metric, per-file coverage percentages, and the tool's return code.
36
-
37
- Example:
38
- >>> parser = CoverageParser()
39
- >>> raw = RawResult(
40
- ... stdout='src/main.py 100 90 90%\\\\nTOTAL 200 180 90%',
41
- ... return_code=0)
42
- >>> result = parser.parse(raw)
43
- >>> result.metrics['coverage']
44
- 0.9
45
- >>> result.details['src/main.py']
46
- 0.9"""
47
- tr = ToolResult(raw=raw_result)
79
+ """Parse the coverage result."""
80
+ tr = ToolResult(raw=raw_result, project_path=raw_result.project_path)
48
81
  lines = raw_result.stdout.splitlines()
49
- coverage_lines = [line for line in lines if line.endswith("%")]
50
- details = {}
51
- for line in coverage_lines:
82
+ base_dir = raw_result.project_path
83
+
84
+ file_data: dict[str, dict] = {}
85
+ for line in lines:
86
+ if "[" in line:
87
+ continue
52
88
  parts = line.split()
53
- if len(parts) >= 2:
54
- file_name = parts[0]
89
+ if len(parts) < 4 or not parts[3].endswith("%"):
90
+ continue
91
+ file_name = parts[0]
92
+ try:
93
+ coverage_pct = float(parts[3].rstrip("%")) / 100.0
94
+ except ValueError:
95
+ log.warning("Error parsing coverage percentage from line: %s", line)
96
+ continue
97
+ if file_name == "TOTAL":
98
+ tr.metrics["coverage"] = coverage_pct
99
+ else:
55
100
  try:
56
- coverage_percentage = float(parts[-1].rstrip('%')) / 100.0
57
- except ValueError:
58
- log.warning("Error parsing coverage percentage from line: %s", line)
101
+ missing = int(parts[2])
102
+ except (ValueError, IndexError):
103
+ missing = None
104
+ missing_lines = " ".join(parts[4:]) if len(parts) > 4 else None
105
+ file_data[file_name.replace("\\", "/")] = {
106
+ "coverage": coverage_pct,
107
+ "missing": missing,
108
+ "missing_lines": missing_lines,
109
+ }
110
+
111
+ # Build list-based details sorted worst-coverage-first so _single_issue_slices
112
+ # picks the most urgent file and function first.
113
+ details: dict[str, list] = {}
114
+ for file_name, data in sorted(
115
+ file_data.items(), key=lambda x: x[1].get("coverage", 1.0)
116
+ ):
117
+ if data.get("missing") == 0:
118
+ continue
119
+ missing_lines_str = data.get("missing_lines")
120
+ coverage_pct = data["coverage"]
121
+ missing_count = data["missing"]
122
+ resolved = resolve_path(base_dir, file_name)
123
+ if missing_lines_str:
124
+ funcs = _extract_functions(resolved, missing_lines_str)
125
+ if funcs:
126
+ details[file_name] = [
127
+ {
128
+ "code": name,
129
+ "line": lineno,
130
+ "signature": sig,
131
+ "file_coverage": coverage_pct,
132
+ "missing": missing_count,
133
+ }
134
+ for name, lineno, sig in funcs
135
+ ]
59
136
  continue
60
- if file_name == "TOTAL":
61
- tr.metrics["coverage"] = coverage_percentage
62
- else:
63
- try:
64
- missing = int(parts[2]) if len(parts) >= 4 else None
65
- except (ValueError, IndexError):
66
- missing = None
67
- details[file_name.replace("\\", "/")] = {
68
- "coverage": coverage_percentage,
69
- "missing": missing,
70
- }
137
+ # Fallback when --show-missing wasn't used or AST parsing failed
138
+ details[file_name] = [
139
+ {
140
+ "code": None,
141
+ "line": None,
142
+ "missing": missing_count,
143
+ "missing_lines": missing_lines_str,
144
+ "file_coverage": coverage_pct,
145
+ }
146
+ ]
147
+
71
148
  tr.details = details
72
149
  return tr
73
150
 
74
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
75
- """Return the files with lowest coverage as a defect description."""
76
- score = tr.metrics.get("coverage", 0)
77
- uncovered = sorted(
78
- [(f, d) for f, d in tr.details.items() if isinstance(d, dict) and d.get("missing")],
79
- key=lambda x: x[1]["coverage"],
80
- )[:5]
81
- if not uncovered:
82
- return f"**coverage** score: {score:.3f}"
83
- lines = [f"**coverage** score: {score:.3f} — files with lowest coverage:"]
84
- for path, data in uncovered:
85
- pct = data["coverage"]
86
- miss = data["missing"]
87
- lines.append(f"- `{path}`: {pct:.0%} ({miss} uncovered statements)")
88
- return "\n".join(lines)
151
+ def format_llm_message(
152
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
153
+ ) -> str:
154
+ for file, issues in tr.details.items():
155
+ if not isinstance(issues, list) or not issues:
156
+ continue
157
+ issue = issues[0]
158
+ if not isinstance(issue, dict):
159
+ continue
160
+ code = issue.get("code")
161
+ line = issue.get("line")
162
+ missing = issue.get("missing")
163
+ file_coverage = issue.get("file_coverage", 0.0)
164
+ missing_lines = issue.get("missing_lines")
165
+ try:
166
+ resolved_file = resolve_path(tr.project_path, file)
167
+ except (OSError, ValueError):
168
+ resolved_file = file
169
+
170
+ parts: list[str] = []
171
+ if code and line:
172
+ parts.append(f"{file}:{line} - {code} is missing tests")
173
+ fn_src = find_function_source(resolved_file, code)
174
+ if fn_src:
175
+ parts.append(fn_src)
176
+ else:
177
+ pct = (
178
+ f"{file_coverage:.0%} "
179
+ if isinstance(file_coverage, float) and file_coverage
180
+ else ""
181
+ )
182
+ miss_info = f"{missing} uncovered lines" if missing else "uncovered"
183
+ parts.append(f"{file} - {pct}coverage ({miss_info})")
184
+ if missing_lines:
185
+ parts.append(f" missing lines: {missing_lines}")
186
+
187
+ test_file = _find_test_file(file)
188
+ if test_file:
189
+ try:
190
+ resolved_test = resolve_path(tr.project_path, test_file)
191
+ last_line = len(
192
+ Path(resolved_test).read_text(encoding="utf-8").splitlines()
193
+ )
194
+ except (OSError, ValueError):
195
+ last_line = None
196
+ location = (
197
+ f"{test_file} after line {last_line}" if last_line else test_file
198
+ )
199
+ parts.append(f"\nAdd tests to: {location}")
200
+
201
+ return "\n".join(parts)
202
+ return ""
@@ -7,10 +7,19 @@ class ExitCodeParser(AbstractParser):
7
7
  """Score 1.0 if the tool exited with code 0, else 0.0."""
8
8
 
9
9
  def parse(self, raw_result: RawResult) -> ToolResult:
10
+ """Parse the tool result and return a score based on the exit code."""
10
11
  score = 1.0 if raw_result.return_code == 0 else 0.0
11
12
  return ToolResult(raw=raw_result, metrics={"exit_code": score})
12
13
 
13
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
14
+ def format_llm_message(
15
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
16
+ ) -> str:
17
+ """Format the tool result as a string message for the LLM."""
18
+
14
19
  output = tr.raw.stdout.strip() or tr.raw.stderr.strip()
15
20
  lines = output.splitlines()[:context_lines]
16
- return "\n".join(lines) if lines else "Tool exited with non-zero status (no output)"
21
+ return (
22
+ "\n".join(lines)
23
+ if lines
24
+ else "Tool exited with non-zero status (no output)"
25
+ )
@@ -5,10 +5,8 @@ converts the JSON output from Halstead metric tools into a
5
5
  ``ToolResult``. It extracts bug estimates and program volume, applies
6
6
  maximum thresholds, and aggregates file- and function-level metrics."""
7
7
 
8
- import json
9
-
10
8
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
11
- from py_cq.parsers.common import score_logistic_variant
9
+ from py_cq.parsers.common import _relative_path, parse_json_dict, score_logistic_variant
12
10
 
13
11
 
14
12
  class HalsteadParser(AbstractParser):
@@ -27,7 +25,7 @@ class HalsteadParser(AbstractParser):
27
25
  def parse(self, raw_result: RawResult) -> ToolResult:
28
26
  """Parses Halstead tool JSON output and returns a ToolResult.
29
27
 
30
- The method reads ``raw_result.stdout``—a JSON string containing
28
+ The method reads ``raw_result.stdout``-a JSON string containing
31
29
  per-file and per-function Halstead metrics. For each file it
32
30
  populates a ``ToolResult`` detail entry with bug-free and
33
31
  smallness scores. If a file contains an ``error`` key, the
@@ -59,15 +57,23 @@ class HalsteadParser(AbstractParser):
59
57
  # {"total": {"h1": 6, "h2": 18, "N1": 13, "N2": 22, "vocabulary": 24, "length": 35, "calculated_length": 90.56842503028855, "volume": 160.4736875252405, "difficulty": 3.6666666666666665, "effort": 588.4035209258818, "time": 32.68908449588233, "bugs": 0.05349122917508017},
60
58
  # "functions": {"calc_dist": {"h1": 3, "h2": 9, "N1": 5, "N2": 10, "vocabulary": 12, "length": 15, "calculated_length": 33.28421251514428, "volume": 53.77443751081735, "difficulty": 1.6666666666666667, "effort": 89.62406251802892, "time": 4.9791145843349405, "bugs": 0.017924812503605784}, "find_nearest_city": {"h1": 1, "h2": 2, "N1": 1, "N2": 2, "vocabulary": 3, "length": 3, "calculated_length": 2.0, "volume": 4.754887502163469, "difficulty": 0.5, "effort": 2.3774437510817346, "time": 0.1320802083934297, "bugs": 0.0015849625007211565}, "generate_tour": {"h1": 2, "h2": 5, "N1": 6, "N2": 8, "vocabulary": 7, "length": 14, "calculated_length": 13.60964047443681, "volume": 39.302968908806456, "difficulty": 1.6, "effort": 62.884750254090335, "time": 3.493597236338352, "bugs": 0.01310098963626882}, "main": {"h1": 0, "h2": 0, "N1": 0, "N2": 0, "vocabulary": 0, "length": 0, "calculated_length": 0, "volume": 0, "difficulty": 0, "effort": 0, "time": 0.0, "bugs": 0.0}}}
61
59
  tr = ToolResult(raw=raw_result)
60
+ data = parse_json_dict(raw_result.stdout)
61
+ if data is None:
62
+ tr.metrics = {
63
+ "file_bug_free": 1.0,
64
+ "file_smallness": 1.0,
65
+ "functions_bug_free": 1.0,
66
+ "functions_smallness": 1.0,
67
+ }
68
+ return tr
62
69
  MAX_FILE_BUGS = 1
63
- MAX_FILE_VOLUME = 2000
70
+ MAX_FILE_VOLUME = 3000
64
71
  MAX_FUNCTION_BUGS = 0.2
65
72
  MAX_FUNCTION_VOLUME = 600
66
73
  min_file_nb = 1.0
67
74
  min_file_sm = 1.0
68
75
  min_function_nb = 1.0
69
76
  min_function_sm = 1.0
70
- data = json.loads(raw_result.stdout)
71
77
  for file, values in data.items():
72
78
  file_name = file.replace("\\", "/")
73
79
  if file_name not in tr.details:
@@ -104,6 +110,7 @@ class HalsteadParser(AbstractParser):
104
110
  "smallness": sm,
105
111
  "bugs": function_values.get("bugs", 0),
106
112
  "volume": function_values.get("volume", 0),
113
+ "difficulty": function_values.get("difficulty", 0),
107
114
  }
108
115
  tr.metrics = {
109
116
  "file_bug_free": min_file_nb,
@@ -113,7 +120,9 @@ class HalsteadParser(AbstractParser):
113
120
  }
114
121
  return tr
115
122
 
116
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
123
+ def format_llm_message(
124
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
125
+ ) -> str:
117
126
  """Return the worst Halstead offender as an actionable defect description."""
118
127
  if not tr.metrics:
119
128
  return "No Halstead details available"
@@ -127,6 +136,7 @@ class HalsteadParser(AbstractParser):
127
136
  worst_score = 1.0
128
137
  worst_bugs = None
129
138
  worst_volume = None
139
+ worst_difficulty = None
130
140
 
131
141
  for file_name, file_data in tr.details.items():
132
142
  if is_function_metric:
@@ -138,6 +148,7 @@ class HalsteadParser(AbstractParser):
138
148
  worst_function = func_name
139
149
  worst_bugs = func_data.get("bugs")
140
150
  worst_volume = func_data.get("volume")
151
+ worst_difficulty = func_data.get("difficulty")
141
152
  else:
142
153
  s = file_data.get("bug_free" if is_bug_metric else "smallness", 1.0)
143
154
  if s < worst_score:
@@ -149,15 +160,32 @@ class HalsteadParser(AbstractParser):
149
160
  if worst_file is None:
150
161
  return f"**{metric_name}** score: {score:.3f}"
151
162
 
152
- location = f"`{worst_file}` — function `{worst_function}`" if worst_function else f"`{worst_file}`"
163
+ path = _relative_path(worst_file)
164
+ location = f"{path} - function `{worst_function}`" if worst_function else path
153
165
  if is_bug_metric:
154
- detail = f" (Halstead bug estimate: {worst_bugs:.3f})" if worst_bugs is not None else ""
155
- return (
156
- f"{location} has high estimated bug density{detail}\n\n"
157
- f"Reduce complexity by extracting helper functions or simplifying logic."
158
- )
166
+ parts = []
167
+ if worst_bugs is not None:
168
+ parts.append(f"bugs: {worst_bugs:.3f}")
169
+ if worst_volume is not None:
170
+ parts.append(f"volume: {worst_volume:.0f}")
171
+ if worst_difficulty is not None:
172
+ parts.append(f"difficulty: {worst_difficulty:.1f}")
173
+ detail = f" ({', '.join(parts)})" if parts else ""
174
+ if (
175
+ worst_difficulty is not None
176
+ and worst_volume is not None
177
+ and worst_difficulty > worst_volume / 50
178
+ ):
179
+ advice = "Simplify branching logic, reduce nesting, or consolidate repeated operator patterns."
180
+ else:
181
+ advice = (
182
+ "Extract helper functions to reduce the function's size and scope."
183
+ )
184
+ return f"{location} has high estimated bug density{detail}\n\n{advice}"
159
185
  else:
160
- detail = f" (volume: {worst_volume:.0f})" if worst_volume is not None else ""
186
+ detail = (
187
+ f" (volume: {worst_volume:.0f})" if worst_volume is not None else ""
188
+ )
161
189
  return (
162
190
  f"{location} is too large{detail}\n\n"
163
191
  f"Split into smaller functions or modules."