python-code-quality 0.1.4__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.
@@ -0,0 +1,174 @@
1
+ """Utilities for parsing Halstead-based metrics.
2
+
3
+ This module implements :class:`HalsteadParser`, an ``AbstractParser`` that
4
+ converts the JSON output from Halstead metric tools into a
5
+ ``ToolResult``. It extracts bug estimates and program volume, applies
6
+ maximum thresholds, and aggregates file- and function-level metrics."""
7
+
8
+ import json
9
+
10
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
11
+ from py_cq.parsers.common import score_logistic_variant
12
+
13
+
14
+ class HalsteadParser(AbstractParser):
15
+ """Parses Halstead metric output and converts it into a `ToolResult`.
16
+
17
+ Implements the `AbstractParser` interface for tools that emit
18
+ Halstead-based JSON. The `parse` method consumes a `RawResult`,
19
+ extracts per-file and per-function metrics, applies the configured
20
+ maximum bug and volume thresholds, and aggregates the results.
21
+ The helper `extract_bugs_and_volume` performs the core calculation of
22
+ bug-free and smallness scores.
23
+
24
+ The parser also records the tool's return code and any errors in the
25
+ result details."""
26
+
27
+ def parse(self, raw_result: RawResult) -> ToolResult:
28
+ """Parses Halstead tool JSON output and returns a ToolResult.
29
+
30
+ The method reads ``raw_result.stdout``—a JSON string containing
31
+ per-file and per-function Halstead metrics. For each file it
32
+ populates a ``ToolResult`` detail entry with bug-free and
33
+ smallness scores. If a file contains an ``error`` key, the
34
+ associated metrics are set to zero and the error message is
35
+ stored. File-level and function-level thresholds are applied
36
+ when computing metrics via :meth:`extract_bugs_and_volume`.
37
+
38
+ After processing all entries, aggregate metrics
39
+ (``file_bug_free``, ``file_smallness``,
40
+ ``functions_bug_free``, ``functions_smallness``) are calculated
41
+ from the minimum values observed. The tool's return code is also
42
+ recorded in the ``ToolResult`` details.
43
+
44
+ Args:
45
+ raw_result (RawResult): The raw output from a Halstead
46
+ analysis tool. Its ``stdout`` attribute must contain a
47
+ JSON string with the expected structure.
48
+
49
+ Returns:
50
+ ToolResult: A populated ``ToolResult`` instance with
51
+ detailed per-file and per-function metrics, aggregate
52
+ metrics, and the tool's return code.
53
+
54
+ Raises:
55
+ json.JSONDecodeError: If the ``stdout`` cannot be parsed as
56
+ JSON."""
57
+ # radon hal -f --json .\data\problems\travelling_salesman\ts_bad.py
58
+ # {".\\data\\problems\\travelling_salesman\\ts_bad.py":
59
+ # {"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
+ # "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
+ tr = ToolResult(raw=raw_result)
62
+ MAX_FILE_BUGS = 1
63
+ MAX_FILE_VOLUME = 2000
64
+ MAX_FUNCTION_BUGS = 0.2
65
+ MAX_FUNCTION_VOLUME = 300
66
+ min_file_nb = 1.0
67
+ min_file_sm = 1.0
68
+ min_function_nb = 1.0
69
+ min_function_sm = 1.0
70
+ data = json.loads(raw_result.stdout)
71
+ for file, values in data.items():
72
+ file_name = file.replace("\\", "/")
73
+ if file_name not in tr.details:
74
+ tr.details[file_name] = {
75
+ "bug_free": 0.0,
76
+ "smallness": 0.0,
77
+ "functions": {},
78
+ }
79
+ if "error" in values:
80
+ min_file_nb = 0.0
81
+ min_file_sm = 0.0
82
+ min_function_nb = 0.0
83
+ min_function_sm = 0.0
84
+ tr.details[file_name]["error"] = values["error"]
85
+ if "total" in values:
86
+ nb, sm = self.extract_bugs_and_volume(
87
+ values.get("total", {}), MAX_FILE_BUGS, MAX_FILE_VOLUME
88
+ )
89
+ min_file_nb = min(nb, min_file_nb)
90
+ min_file_sm = min(sm, min_file_sm)
91
+ tr.details[file_name]["bug_free"] = nb
92
+ tr.details[file_name]["smallness"] = sm
93
+ tr.details[file_name]["bugs"] = values["total"].get("bugs", 0)
94
+ tr.details[file_name]["volume"] = values["total"].get("volume", 0)
95
+ if "functions" in values:
96
+ for function, function_values in values["functions"].items():
97
+ nb, sm = self.extract_bugs_and_volume(
98
+ function_values, MAX_FUNCTION_BUGS, MAX_FUNCTION_VOLUME
99
+ )
100
+ min_function_nb = min(nb, min_function_nb)
101
+ min_function_sm = min(sm, min_function_sm)
102
+ tr.details[file_name]["functions"][function] = {
103
+ "no_bugs": nb,
104
+ "smallness": sm,
105
+ "bugs": function_values.get("bugs", 0),
106
+ "volume": function_values.get("volume", 0),
107
+ }
108
+ tr.metrics = {
109
+ "file_bug_free": min_file_nb,
110
+ "file_smallness": min_file_sm,
111
+ "functions_bug_free": min_function_nb,
112
+ "functions_smallness": min_function_sm,
113
+ }
114
+ return tr
115
+
116
+ def format_llm_message(self, tr: ToolResult) -> str:
117
+ """Return the worst Halstead offender as an actionable defect description."""
118
+ if not tr.metrics:
119
+ return "No Halstead details available"
120
+
121
+ metric_name, score = min(tr.metrics.items(), key=lambda x: x[1])
122
+ is_function_metric = metric_name.startswith("functions_")
123
+ is_bug_metric = "bug_free" in metric_name
124
+
125
+ worst_file = None
126
+ worst_function = None
127
+ worst_score = 1.0
128
+ worst_bugs = None
129
+ worst_volume = None
130
+
131
+ for file_name, file_data in tr.details.items():
132
+ if is_function_metric:
133
+ for func_name, func_data in file_data.get("functions", {}).items():
134
+ s = func_data.get("no_bugs" if is_bug_metric else "smallness", 1.0)
135
+ if s < worst_score:
136
+ worst_score = s
137
+ worst_file = file_name
138
+ worst_function = func_name
139
+ worst_bugs = func_data.get("bugs")
140
+ worst_volume = func_data.get("volume")
141
+ else:
142
+ s = file_data.get("bug_free" if is_bug_metric else "smallness", 1.0)
143
+ if s < worst_score:
144
+ worst_score = s
145
+ worst_file = file_name
146
+ worst_bugs = file_data.get("bugs")
147
+ worst_volume = file_data.get("volume")
148
+
149
+ if worst_file is None:
150
+ return f"**{metric_name}** score: {score:.3f}"
151
+
152
+ location = f"`{worst_file}` — function `{worst_function}`" if worst_function else f"`{worst_file}`"
153
+ 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
+ )
159
+ else:
160
+ detail = f" (volume: {worst_volume:.0f})" if worst_volume is not None else ""
161
+ return (
162
+ f"{location} is too large{detail}\n\n"
163
+ f"Split into smaller functions or modules."
164
+ )
165
+
166
+ def extract_bugs_and_volume(
167
+ self, values: dict, max_bugs: float, max_volume: float
168
+ ) -> tuple[float, float]:
169
+ """Calculates bug-free and smallness scores from Halstead metrics using logistic scoring."""
170
+ no_bugs_score = score_logistic_variant(values.get("bugs", max_bugs), max_bugs)
171
+ smallness_score = score_logistic_variant(
172
+ values.get("volume", max_volume), max_volume
173
+ )
174
+ return (no_bugs_score, smallness_score)
@@ -0,0 +1,58 @@
1
+ """Parses output from interrogate into a standardized ToolResult.
2
+
3
+ Interrogate is invoked with ``-v --fail-under 0``, producing a table of
4
+ per-file docstring coverage on stdout::
5
+
6
+ | src/foo.py | 5 | 2 | 60% |
7
+ | TOTAL | 5 | 2 | 60% |
8
+
9
+ The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
10
+ as the ``doc_coverage`` metric (0.0–1.0).
11
+ """
12
+
13
+ import re
14
+
15
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
16
+
17
+ _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)%\s+\|")
18
+
19
+
20
+ class InterrogateParser(AbstractParser):
21
+ """Parses raw output from ``interrogate -v`` into a ToolResult."""
22
+
23
+ def parse(self, raw_result: RawResult) -> ToolResult:
24
+ files: dict[str, dict] = {}
25
+ total_coverage: float | None = None
26
+ for line in (raw_result.stdout or "").splitlines():
27
+ m = _ROW_RE.match(line)
28
+ if not m:
29
+ continue
30
+ name = m.group(1).strip()
31
+ total = int(m.group(2))
32
+ miss = int(m.group(3))
33
+ cover = int(m.group(4))
34
+ if name == "TOTAL":
35
+ total_coverage = cover / 100.0
36
+ elif total > 0:
37
+ files[name.replace("\\", "/")] = {
38
+ "total": total,
39
+ "missing": miss,
40
+ "coverage": cover / 100.0,
41
+ }
42
+ score = total_coverage if total_coverage is not None else 1.0
43
+ return ToolResult(raw=raw_result, metrics={"doc_coverage": score}, details=files)
44
+
45
+ def format_llm_message(self, tr: ToolResult) -> str:
46
+ score = tr.metrics.get("doc_coverage", 0)
47
+ uncovered = sorted(
48
+ [(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
49
+ key=lambda x: x[1]["coverage"],
50
+ )[:5]
51
+ if not uncovered:
52
+ return f"**doc_coverage** score: {score:.3f}"
53
+ lines = [f"**doc coverage** {score:.1%} — files with most missing docstrings:"]
54
+ for path, data in uncovered:
55
+ miss = data["missing"]
56
+ pct = data["coverage"]
57
+ lines.append(f"- `{path}`: {pct:.0%} ({miss} undocumented)")
58
+ return "\n".join(lines)
@@ -0,0 +1,63 @@
1
+ """Module providing a concrete parser for maintainability analysis tools.
2
+
3
+ The `MaintainabilityParser` implements the `AbstractParser` interface to
4
+ convert raw JSON output from a maintainability tool into a
5
+ `ToolResult` structure that other components of the framework can consume."""
6
+
7
+ import json
8
+
9
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
10
+ from py_cq.parsers.common import score_logistic_variant
11
+
12
+
13
+ class MaintainabilityParser(AbstractParser):
14
+ """Parses maintainability metrics from raw tool output into :class:`ToolResult` objects.
15
+
16
+ This parser consumes the JSON produced by a maintainability analysis tool on
17
+ ``stdout``. For each file it extracts the maintainability index (`mi`) and
18
+ rank, converts the index into a normalized score using
19
+ :func:`score_logistic_variant`, aggregates per-file scores, and returns a
20
+ :class:`ToolResult` containing an overall maintainability metric and
21
+ detailed per-file information.
22
+
23
+ It implements the :meth:`parse` method required by :class:`AbstractParser`
24
+ and is intended for use by the framework to transform raw results into a
25
+ structured format."""
26
+
27
+ def parse(self, raw_result: RawResult) -> ToolResult:
28
+ """Parses maintainability metrics from a raw tool result.
29
+
30
+ The parser expects the tool to output JSON on ``stdout`` where each key is a file name mapping to a dictionary that contains at least an ``mi`` value (maintainability index) and a ``rank`` string. If a file entry contains an ``error`` key, that file is recorded with a zero score and the error message.
31
+
32
+ For each file with a valid ``mi`` value the method converts the raw maintainability index into a score using :func:`score_logistic_variant`, aggregates the scores, and stores per-file details. The overall maintainability metric for the tool is then calculated as the average score across all processed files.
33
+
34
+ Args:
35
+ raw_result (RawResult): The raw result object produced by the underlying maintainability tool.
36
+ It must provide a ``stdout`` attribute containing a JSON string and a ``return_code`` attribute.
37
+
38
+ Returns:
39
+ ToolResult: A structured result object containing:
40
+ * ``metrics['maintainability']`` - the average score of all processed files (or ``0.0`` if none were processed).
41
+ * ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
42
+ * ``details['return_code']`` - the tool's exit code."""
43
+ tr = ToolResult(raw=raw_result)
44
+ data = json.loads(raw_result.stdout)
45
+ num_items = 0
46
+ score = 0
47
+ for file, values in data.items():
48
+ if "error" in values:
49
+ tr.details[file.replace("\\", "/")] = {
50
+ "mi": 0.0,
51
+ "rank": "F",
52
+ "error": values["error"],
53
+ }
54
+ elif "mi" in values:
55
+ file_score = score_logistic_variant(100 - values["mi"], 85)
56
+ score += file_score
57
+ num_items += 1
58
+ tr.details[file.replace("\\", "/")] = {
59
+ "mi": file_score,
60
+ "rank": values["rank"],
61
+ }
62
+ tr.metrics["maintainability"] = score / num_items if num_items > 0 else 0.0
63
+ return tr
@@ -0,0 +1,81 @@
1
+ """Parses pytest test run output into a standardized :class:`ToolResult`.
2
+
3
+ This module provides :class:`PytestParser`, a concrete implementation of
4
+ :class:`AbstractParser` that consumes a :class:`RawResult` produced by a pytest
5
+ invocation and returns a :class:`ToolResult` instance. The parser extracts
6
+ per-test statuses, calculates the overall pass rate, and attaches the
7
+ process return code so downstream components can uniformly consume results
8
+ from multiple test tools. It is part of the test-collection framework and
9
+ enables consistent handling of pytest output across the system."""
10
+
11
+ import re
12
+
13
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
14
+
15
+
16
+ class PytestParser(AbstractParser):
17
+ """Parses raw pytest output into a structured `ToolResult`.
18
+
19
+ Transforms the low-level output from a pytest run into a `ToolResult` that includes pass-rate metrics and detailed per-file test information, enabling downstream components to consume test metrics in a consistent, typed format. Inherits from `AbstractParser` to integrate with the existing parsing framework."""
20
+
21
+ def parse(self, raw_result: RawResult) -> ToolResult:
22
+ """Parse pytest raw output into a structured ToolResult.
23
+
24
+ This method transforms the textual output of a pytest run into a
25
+ :class:`ToolResult` object containing two pieces of information:
26
+
27
+ * ``metrics['tests']`` - the fraction of tests that passed (``0`` when no
28
+ tests were executed).
29
+ * ``details`` - a mapping from each test file to the names of the
30
+ tests defined in that file and their status (`PASSED`, `FAILED`, etc.).
31
+ The dictionary also includes the process ``return_code`` for
32
+ downstream consumers.
33
+
34
+ The method splits ``raw_result.stdout`` into lines, checks for the
35
+ ``'no tests ran'`` sentinel, then uses a regular expression to locate
36
+ ``<file>::<test_name> <status>`` patterns. It counts the total number of
37
+ tests and the number of passed tests to compute the pass-rate metric.
38
+
39
+ Args:
40
+ raw_result (RawResult): Raw output produced by a pytest run,
41
+ containing ``stdout`` and ``return_code``.
42
+
43
+ Returns:
44
+ ToolResult: A structured result that includes a ``tests`` metric
45
+ and a ``details`` dictionary as described above.
46
+
47
+ Raises:
48
+ None. The method never raises an exception; it gracefully handles
49
+ malformed input by treating it as no tests were run."""
50
+ # Simplified parsing - replace with actual logic
51
+ lines = raw_result.stdout.splitlines()
52
+ tr = ToolResult(raw=raw_result)
53
+ if "no tests ran" in raw_result.stdout:
54
+ pass
55
+ else:
56
+ tests_found = dict()
57
+ num_tests = 0
58
+ passed_tests = 0
59
+ for line in lines:
60
+ # tests/test_common.py::test_name[param] PASSED [ 8%]
61
+ tests_match = re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
62
+ if tests_match:
63
+ test_file = tests_match.group(1)
64
+ test_name = tests_match.group(2).strip()
65
+ test_status = tests_match.group(3)
66
+ tests_found.setdefault(test_file, {})[test_name] = test_status
67
+ num_tests += 1
68
+ if test_status == "PASSED":
69
+ passed_tests += 1
70
+ tr.metrics["tests"] = passed_tests / num_tests if num_tests else 0
71
+ tr.details = tests_found
72
+ return tr
73
+
74
+ def format_llm_message(self, tr: ToolResult) -> str:
75
+ """Return the first failing test as a defect description."""
76
+ for file, tests in tr.details.items():
77
+ if isinstance(tests, dict):
78
+ for test_name, status in tests.items():
79
+ if status == "FAILED":
80
+ return f"`{file}::{test_name}` — test **FAILED**"
81
+ return "pytest reported failures (no details available)"
@@ -0,0 +1,61 @@
1
+ """Parses output from ruff check into a standardized ToolResult.
2
+
3
+ This module defines :class:`RuffParser`, an implementation of
4
+ :class:`~.AbstractParser` that converts the raw stdout produced by
5
+ ``ruff check --output-format concise`` into a :class:`~.ToolResult`.
6
+
7
+ The concise output format is one violation per line::
8
+
9
+ <file>:<line>:<col>: <CODE> <message>
10
+
11
+ followed by a summary line ``Found N error.`` or ``All checks passed!``."""
12
+
13
+ import re
14
+
15
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
16
+ from py_cq.parsers.common import read_source_lines, score_logistic_variant
17
+
18
+ _DIAG_RE = re.compile(r"^(.+):(\d+):(\d+): ([A-Z]\d+) (.+)$")
19
+
20
+
21
+ class RuffParser(AbstractParser):
22
+ """Parses raw output from ``ruff check`` into a structured ToolResult."""
23
+
24
+ def parse(self, raw_result: RawResult) -> ToolResult:
25
+ """Parse concise ruff output and return a ToolResult.
26
+
27
+ Args:
28
+ raw_result: Raw output from ``ruff check --output-format concise``.
29
+
30
+ Returns:
31
+ ToolResult with a ``lint`` metric in [0, 1] and per-file violations in details.
32
+ """
33
+ files: dict[str, list] = {}
34
+ for line in (raw_result.stdout or "").splitlines():
35
+ m = _DIAG_RE.match(line)
36
+ if m:
37
+ path = m.group(1).replace("\\", "/")
38
+ files.setdefault(path, []).append({
39
+ "line": int(m.group(2)),
40
+ "code": m.group(4),
41
+ "message": m.group(5),
42
+ })
43
+ score = score_logistic_variant(
44
+ sum(len(v) for v in files.values()), scale_factor=20
45
+ )
46
+ return ToolResult(raw=raw_result, metrics={"lint": score}, details=files)
47
+
48
+ def format_llm_message(self, tr: ToolResult) -> str:
49
+ """Return the first lint violation as a defect description."""
50
+ if not tr.details:
51
+ return "ruff reported issues (no details available)"
52
+ file, issues = next(iter(tr.details.items()))
53
+ issue = issues[0]
54
+ line = issue.get("line", "?")
55
+ code = issue.get("code", "")
56
+ message = issue.get("message", "")
57
+ context_start = max(1, line - 3) if isinstance(line, int) else line
58
+ raw_lines = read_source_lines(file, context_start, count=8).splitlines() if isinstance(line, int) else []
59
+ src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines)) if raw_lines else ""
60
+ code_block = f"\n```python\n{src}\n```" if src else ""
61
+ return f"`{file}:{line}` — **{code}**: {message}{code_block}"
@@ -0,0 +1,65 @@
1
+ """Parses output from the ty type checker into a standardized ToolResult.
2
+
3
+ This module defines :class:`TyParser`, an implementation of
4
+ :class:`~.AbstractParser` that converts the raw stdout produced by
5
+ ``ty check --output-format concise`` into a :class:`~.ToolResult`.
6
+
7
+ The concise output format is one diagnostic per line::
8
+
9
+ <file>:<line>:<col>: <severity>[<code>] <message>
10
+
11
+ followed by a summary line ``Found N diagnostic`` or ``All checks passed!``.
12
+ Errors count more heavily than warnings toward the score."""
13
+
14
+ import re
15
+
16
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
17
+ from py_cq.parsers.common import read_source_lines, score_logistic_variant
18
+
19
+ _DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
20
+
21
+
22
+ class TyParser(AbstractParser):
23
+ """Parses raw output from ``ty check`` into a structured ToolResult."""
24
+
25
+ def parse(self, raw_result: RawResult) -> ToolResult:
26
+ """Parse concise ty output and return a ToolResult.
27
+
28
+ Args:
29
+ raw_result: Raw output from ``ty check --output-format concise``.
30
+
31
+ Returns:
32
+ ToolResult with a ``type_check`` metric in [0, 1] and per-file diagnostics in details.
33
+ """
34
+ files: dict[str, list] = {}
35
+ weighted = 0
36
+ for line in (raw_result.stdout or "").splitlines():
37
+ m = _DIAG_RE.match(line)
38
+ if m:
39
+ path = m.group(1).replace("\\", "/")
40
+ severity = m.group(3)
41
+ files.setdefault(path, []).append({
42
+ "line": int(m.group(2)),
43
+ "code": m.group(4),
44
+ "severity": severity,
45
+ "message": m.group(5),
46
+ })
47
+ weighted += 3 if severity == "error" else 1
48
+
49
+ score = score_logistic_variant(weighted, scale_factor=10)
50
+ return ToolResult(raw=raw_result, metrics={"type_check": score}, details=files)
51
+
52
+ def format_llm_message(self, tr: ToolResult) -> str:
53
+ """Return the first type-check diagnostic as a defect description."""
54
+ if not tr.details:
55
+ return "ty reported issues (no details available)"
56
+ file, issues = next(iter(tr.details.items()))
57
+ issue = issues[0]
58
+ line = issue.get("line", "?")
59
+ code = issue.get("code", "")
60
+ message = issue.get("message", "")
61
+ context_start = max(1, line - 3) if isinstance(line, int) else line
62
+ raw_lines = read_source_lines(file, context_start, count=8).splitlines() if isinstance(line, int) else []
63
+ src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines)) if raw_lines else ""
64
+ code_block = f"\n```python\n{src}\n```" if src else ""
65
+ return f"`{file}:{line}` — **{code}**: {message}{code_block}"
@@ -0,0 +1,48 @@
1
+ """Parses output from vulture dead-code detector into a standardized ToolResult.
2
+
3
+ Vulture is invoked with ``--min-confidence 80``, producing one text line
4
+ per unused symbol::
5
+
6
+ src/foo.py:10: unused function 'bar' (80% confidence)
7
+
8
+ The parser counts violations and converts the count into a logistic-variant
9
+ score stored under the ``dead_code`` metric key.
10
+ """
11
+
12
+ import re
13
+
14
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
15
+ from py_cq.parsers.common import score_logistic_variant
16
+
17
+ _LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
18
+
19
+
20
+ class VultureParser(AbstractParser):
21
+ """Parses raw text output from ``vulture`` into a ToolResult."""
22
+
23
+ def parse(self, raw_result: RawResult) -> ToolResult:
24
+ files: dict[str, list] = {}
25
+ for line in (raw_result.stdout or "").splitlines():
26
+ m = _LINE_RE.match(line)
27
+ if m:
28
+ path = m.group(1).replace("\\", "/")
29
+ files.setdefault(path, []).append({
30
+ "line": int(m.group(2)),
31
+ "type": m.group(3),
32
+ "name": m.group(4),
33
+ "confidence": int(m.group(5)),
34
+ })
35
+ count = sum(len(v) for v in files.values())
36
+ score = score_logistic_variant(count, scale_factor=15)
37
+ return ToolResult(raw=raw_result, metrics={"dead_code": score}, details=files)
38
+
39
+ def format_llm_message(self, tr: ToolResult) -> str:
40
+ if not tr.details:
41
+ return "vulture reported issues (no details available)"
42
+ file, issues = next(iter(tr.details.items()))
43
+ issue = issues[0]
44
+ line = issue.get("line", "?")
45
+ kind = issue.get("type", "unused")
46
+ name = issue.get("name", "")
47
+ confidence = issue.get("confidence", "?")
48
+ return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence)"
py_cq/py.typed ADDED
File without changes
py_cq/storage.py ADDED
@@ -0,0 +1,27 @@
1
+ """Utilities for persisting combined tool results.
2
+
3
+ This module provides a single helper function, :func:`save_result`, which
4
+ serializes a :class:`CombinedToolResults` instance to a JSON file. The
5
+ function ensures that the target directory exists, writes a readable
6
+ representation of the results, and returns the absolute path of the created
7
+ file.
8
+
9
+ Example
10
+ -------
11
+ >>> from your_package import CombinedToolResults, save_result
12
+ >>> results = CombinedToolResults(...)
13
+ >>> output_path = save_result(results, "output.json")
14
+ >>> print(output_path)"""
15
+
16
+ import json
17
+
18
+ from py_cq.localtypes import CombinedToolResults
19
+
20
+
21
+ def save_result(combined_tool_results: CombinedToolResults, file_name: str):
22
+ """Saves combined tool results to a JSON file named by `file_name`."""
23
+ if not file_name:
24
+ return
25
+ data = combined_tool_results.to_dict()
26
+ with open(file_name, "w") as f:
27
+ json.dump(data, f, indent=4)
py_cq/tool_registry.py ADDED
@@ -0,0 +1,36 @@
1
+ """Loads tool configurations from a YAML file and builds a registry that maps tool names to their configuration objects, enabling efficient lookup and instantiation of tools throughout the application. The module relies on PyYAML for parsing the configuration file."""
2
+
3
+ from importlib import import_module
4
+ from importlib.resources import files
5
+
6
+ import yaml
7
+
8
+ from py_cq.localtypes import ToolConfig
9
+
10
+
11
+ def load_tool_configs() -> dict[str, ToolConfig]:
12
+ """Load tool configurations from the bundled tools.yaml and return a registry.
13
+
14
+ Returns:
15
+ dict[str, ToolConfig]: A mapping from tool ID to its configuration instance."""
16
+ yaml_text = files("py_cq.config").joinpath("tools.yaml").read_text(encoding="utf-8")
17
+ config = yaml.safe_load(yaml_text)
18
+ registry = {}
19
+ for tool_id, tool_data in config["tools"].items():
20
+ # Dynamically import parser class
21
+ module = import_module(f"py_cq.parsers.{tool_data['parser'].lower()}")
22
+ parser_class = getattr(module, tool_data["parser"])
23
+ registry[tool_id] = ToolConfig(
24
+ name=tool_data["name"],
25
+ command=tool_data["command"],
26
+ parser_class=parser_class,
27
+ priority=tool_data["priority"],
28
+ warning_threshold=tool_data["warning_threshold"],
29
+ error_threshold=tool_data["error_threshold"],
30
+ run_in_target_env=tool_data.get("run_in_target_env", False),
31
+ extra_deps=tool_data.get("extra_deps", []),
32
+ )
33
+ return registry
34
+
35
+
36
+ tool_registry = load_tool_configs()