python-code-quality 0.2.2__py3-none-any.whl → 0.3.0__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/api.py +26 -5
- py_cq/cli.py +21 -11
- py_cq/config/config.toml +30 -22
- py_cq/context_hash.py +61 -15
- py_cq/execution_engine.py +175 -38
- py_cq/localtypes.py +10 -7
- py_cq/parsers/common.py +17 -18
- py_cq/parsers/complexityparser.py +2 -0
- py_cq/parsers/coverageparser.py +11 -18
- py_cq/parsers/halsteadparser.py +4 -3
- py_cq/parsers/interrogateparser.py +20 -21
- py_cq/parsers/pytestparser.py +1 -1
- py_cq/parsers/ruffparser.py +1 -1
- py_cq/source_file.py +199 -0
- py_cq/table_formatter.py +1 -1
- py_cq/tool_registry.py +3 -0
- {python_code_quality-0.2.2.dist-info → python_code_quality-0.3.0.dist-info}/METADATA +116 -103
- python_code_quality-0.3.0.dist-info/RECORD +36 -0
- {python_code_quality-0.2.2.dist-info → python_code_quality-0.3.0.dist-info}/WHEEL +1 -1
- python_code_quality-0.2.2.dist-info/RECORD +0 -35
- {python_code_quality-0.2.2.dist-info → python_code_quality-0.3.0.dist-info}/entry_points.txt +0 -0
py_cq/localtypes.py
CHANGED
|
@@ -43,7 +43,7 @@ class ToolConfig:
|
|
|
43
43
|
|
|
44
44
|
name: str # e.g., "pytest", "coverage", "pydocstyle"
|
|
45
45
|
command: str # The command to execute (can include placeholders)
|
|
46
|
-
parser_class: Callable #
|
|
46
|
+
parser_class: Callable # The parser class itself (resolved from its name in config)
|
|
47
47
|
context_path: str = "" # Path to project or file
|
|
48
48
|
order: int = 5 # 1=first (compilation), 11=last (style)
|
|
49
49
|
warning_threshold: float = 0.7 # Yellow warning if below this
|
|
@@ -60,6 +60,11 @@ class ToolConfig:
|
|
|
60
60
|
default_factory=list
|
|
61
61
|
) # Top-level dir/file names to omit from {scan_targets}
|
|
62
62
|
skip_for_file: bool = False # If True, skip when context_path is a single file
|
|
63
|
+
gate_strict: bool = True # If False, small regressions above warning_threshold are accepted
|
|
64
|
+
skip_if: str = "" # Tool name; if that tool's score is below its error_threshold, skip this tool
|
|
65
|
+
cache_invariant: str = (
|
|
66
|
+
"" # "ast": key the cache on the docstring-stripped AST, ignoring docstrings/comments/format
|
|
67
|
+
)
|
|
63
68
|
|
|
64
69
|
|
|
65
70
|
@dataclass
|
|
@@ -123,10 +128,12 @@ class ToolResult:
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
|
|
126
|
-
@dataclass
|
|
127
131
|
class CombinedToolResults:
|
|
128
132
|
"""Aggregates results from multiple tools, stores the associated path, and calculates an overall score by averaging the mean metric values of each ``ToolResult``. If a ``ToolResult`` has no metrics, it contributes zero, and the score defaults to ``0.0`` when the list is empty."""
|
|
129
133
|
|
|
134
|
+
score: float
|
|
135
|
+
path: str
|
|
136
|
+
|
|
130
137
|
def __init__(self, path: str, tool_results: list[ToolResult]):
|
|
131
138
|
"""Initializes a CombinedToolResults instance.
|
|
132
139
|
|
|
@@ -148,9 +155,6 @@ class CombinedToolResults:
|
|
|
148
155
|
else 0.0
|
|
149
156
|
)
|
|
150
157
|
|
|
151
|
-
score: float = 0.0
|
|
152
|
-
path: str = ""
|
|
153
|
-
|
|
154
158
|
def to_dict(self) -> dict:
|
|
155
159
|
"""Returns a dictionary containing the path, overall score, and each ToolResult serialized."""
|
|
156
160
|
return {
|
|
@@ -163,7 +167,7 @@ class CombinedToolResults:
|
|
|
163
167
|
class AbstractParser(ABC):
|
|
164
168
|
"""Base class for parsers that transform raw tool output into structured `ToolResult` objects.
|
|
165
169
|
|
|
166
|
-
Subclasses must implement `parse` to convert a `RawResult` into a `ToolResult`.
|
|
170
|
+
Subclasses must implement `parse` to convert a `RawResult` into a `ToolResult`. The `format_llm_message` method can be overridden to supply a richer single-defect description for a parsed result."""
|
|
167
171
|
|
|
168
172
|
def __init__(self, parser_config: dict | None = None):
|
|
169
173
|
self.parser_config = parser_config or {}
|
|
@@ -171,7 +175,6 @@ class AbstractParser(ABC):
|
|
|
171
175
|
@abstractmethod
|
|
172
176
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
173
177
|
"""Converts raw tool output into a structured ToolResult."""
|
|
174
|
-
pass
|
|
175
178
|
|
|
176
179
|
def format_llm_message(
|
|
177
180
|
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
py_cq/parsers/common.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
This module
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
"""Shared helpers used across parsers and the LLM formatter.
|
|
2
|
+
|
|
3
|
+
This module groups small utilities for several concerns:
|
|
4
|
+
|
|
5
|
+
* Scoring - :func:`inv_normalize` and :func:`score_logistic_variant` map error
|
|
6
|
+
magnitudes to bounded ``[0, 1]`` scores.
|
|
7
|
+
* Source context - :func:`read_source_lines`, :func:`format_source_context`,
|
|
8
|
+
:func:`find_function_source`, :func:`find_enclosing_function`, and
|
|
9
|
+
:func:`enclosing_function_range` extract fenced code snippets for LLM prompts.
|
|
10
|
+
* Project navigation - :func:`find_in_project`, :func:`format_callee_context`,
|
|
11
|
+
:func:`extract_callee_name`, :func:`resolve_path`, and :func:`_relative_path`
|
|
12
|
+
locate and reference definitions across a project tree.
|
|
13
|
+
* Output formatting - :func:`format_issue_header` builds issue headers.
|
|
14
|
+
* Parsing - :func:`parse_json_dict` and :func:`extract_first_issue` read tool
|
|
15
|
+
output into Python structures."""
|
|
14
16
|
|
|
15
17
|
import json
|
|
16
18
|
import re
|
|
@@ -46,9 +48,7 @@ def format_source_context(
|
|
|
46
48
|
collected = []
|
|
47
49
|
for i, rline in enumerate(raw_lines):
|
|
48
50
|
if i > error_offset and (
|
|
49
|
-
rline.startswith("def ")
|
|
50
|
-
or rline.startswith("async def ")
|
|
51
|
-
or rline.startswith("class ")
|
|
51
|
+
rline.startswith(("def ", "async def ", "class "))
|
|
52
52
|
):
|
|
53
53
|
break
|
|
54
54
|
collected.append(f"{context_start + i}: {rline}")
|
|
@@ -380,8 +380,7 @@ def score_logistic_variant(
|
|
|
380
380
|
1.0
|
|
381
381
|
>>> score_logistic_variant(10, scale_factor=0)
|
|
382
382
|
0.0"""
|
|
383
|
-
|
|
384
|
-
errors = 0
|
|
383
|
+
errors = max(errors, 0)
|
|
385
384
|
if scale_factor == 0:
|
|
386
385
|
return 1.0 if errors == 0 else 0.0
|
|
387
386
|
try:
|
|
@@ -74,6 +74,8 @@ class ComplexityParser(AbstractParser):
|
|
|
74
74
|
num_items = 0
|
|
75
75
|
max_complexity = 30
|
|
76
76
|
for file, functions in data.items():
|
|
77
|
+
if not isinstance(functions, list):
|
|
78
|
+
continue
|
|
77
79
|
file_name = file.replace("\\", "/")
|
|
78
80
|
if file_name not in tr.details:
|
|
79
81
|
tr.details[file_name] = {}
|
py_cq/parsers/coverageparser.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Parses raw coverage tool output into structured ToolResult instances with per-function granularity."""
|
|
2
2
|
|
|
3
|
-
import ast
|
|
4
3
|
import logging
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
7
6
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
8
7
|
from py_cq.parsers.common import find_function_source, resolve_path
|
|
8
|
+
from py_cq.source_file import get_source
|
|
9
9
|
|
|
10
10
|
log = logging.getLogger("cq")
|
|
11
11
|
|
|
@@ -29,30 +29,19 @@ def _parse_line_ranges(s: str) -> set[int]:
|
|
|
29
29
|
return result
|
|
30
30
|
|
|
31
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
32
|
def _extract_functions(file: str, missing_lines_str: str) -> list[tuple[str, int, str]]:
|
|
41
33
|
"""Return (name, lineno, signature) for functions whose bodies overlap with the missing line ranges."""
|
|
42
34
|
try:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
except (OSError, SyntaxError, ValueError):
|
|
35
|
+
funcs = get_source(file).functions
|
|
36
|
+
except OSError:
|
|
46
37
|
return []
|
|
47
38
|
missing = _parse_line_ranges(missing_lines_str)
|
|
48
39
|
seen: set[str] = set()
|
|
49
40
|
result: list[tuple[str, int, str]] = []
|
|
50
|
-
for
|
|
51
|
-
if
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
seen.add(node.name)
|
|
55
|
-
result.append((node.name, node.lineno, _get_signature(node)))
|
|
41
|
+
for fn in funcs:
|
|
42
|
+
if missing & set(range(fn.lineno, fn.end_lineno + 1)) and fn.name not in seen:
|
|
43
|
+
seen.add(fn.name)
|
|
44
|
+
result.append((fn.name, fn.lineno, fn.signature))
|
|
56
45
|
return result
|
|
57
46
|
|
|
58
47
|
|
|
@@ -146,6 +135,8 @@ class CoverageParser(AbstractParser):
|
|
|
146
135
|
]
|
|
147
136
|
|
|
148
137
|
tr.details = details
|
|
138
|
+
if "coverage" not in tr.metrics:
|
|
139
|
+
tr.metrics["coverage"] = 0.0
|
|
149
140
|
return tr
|
|
150
141
|
|
|
151
142
|
def format_llm_message(
|
|
@@ -199,4 +190,6 @@ class CoverageParser(AbstractParser):
|
|
|
199
190
|
parts.append(f"\nAdd tests to: {location}")
|
|
200
191
|
|
|
201
192
|
return "\n".join(parts)
|
|
193
|
+
if tr.metrics.get("coverage", 1.0) == 0.0:
|
|
194
|
+
return "**Coverage: 0%** - pytest failed or collected no tests, so no coverage data is available."
|
|
202
195
|
return ""
|
py_cq/parsers/halsteadparser.py
CHANGED
|
@@ -83,11 +83,12 @@ class HalsteadParser(AbstractParser):
|
|
|
83
83
|
"functions": {},
|
|
84
84
|
}
|
|
85
85
|
if "error" in values:
|
|
86
|
+
tr.details[file_name]["error"] = values["error"]
|
|
86
87
|
min_file_nb = 0.0
|
|
87
88
|
min_file_sm = 0.0
|
|
88
89
|
min_function_nb = 0.0
|
|
89
90
|
min_function_sm = 0.0
|
|
90
|
-
|
|
91
|
+
continue
|
|
91
92
|
if "total" in values:
|
|
92
93
|
nb, sm = self.extract_bugs_and_volume(
|
|
93
94
|
values.get("total", {}), MAX_FILE_BUGS, MAX_FILE_VOLUME
|
|
@@ -106,7 +107,7 @@ class HalsteadParser(AbstractParser):
|
|
|
106
107
|
min_function_nb = min(nb, min_function_nb)
|
|
107
108
|
min_function_sm = min(sm, min_function_sm)
|
|
108
109
|
tr.details[file_name]["functions"][function] = {
|
|
109
|
-
"
|
|
110
|
+
"bug_free": nb,
|
|
110
111
|
"smallness": sm,
|
|
111
112
|
"bugs": function_values.get("bugs", 0),
|
|
112
113
|
"volume": function_values.get("volume", 0),
|
|
@@ -141,7 +142,7 @@ class HalsteadParser(AbstractParser):
|
|
|
141
142
|
for file_name, file_data in tr.details.items():
|
|
142
143
|
if is_function_metric:
|
|
143
144
|
for func_name, func_data in file_data.get("functions", {}).items():
|
|
144
|
-
s = func_data.get("
|
|
145
|
+
s = func_data.get("bug_free" if is_bug_metric else "smallness", 1.0)
|
|
145
146
|
if s < worst_score:
|
|
146
147
|
worst_score = s
|
|
147
148
|
worst_file = file_name
|
|
@@ -21,6 +21,7 @@ from py_cq.parsers.common import (
|
|
|
21
21
|
format_issue_header,
|
|
22
22
|
format_source_context,
|
|
23
23
|
)
|
|
24
|
+
from py_cq.source_file import get_source
|
|
24
25
|
|
|
25
26
|
_SKIP_PARAMS = {"self", "cls"}
|
|
26
27
|
|
|
@@ -34,9 +35,10 @@ def _format_missing_docstring(file_str: str, line: int, code: str, message: str)
|
|
|
34
35
|
return base + "\n\nInsert a module-level docstring as the very first statement in the file."
|
|
35
36
|
|
|
36
37
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
tree = get_source(file_str).tree
|
|
39
|
+
except OSError:
|
|
40
|
+
tree = None
|
|
41
|
+
if tree is None:
|
|
40
42
|
return base + "\n\nInsert a docstring as the first statement in the body."
|
|
41
43
|
|
|
42
44
|
for node in ast.walk(tree):
|
|
@@ -88,7 +90,7 @@ def _load_interrogate_cfg(context_path: str) -> dict:
|
|
|
88
90
|
try:
|
|
89
91
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
90
92
|
return data.get("tool", {}).get("interrogate", {})
|
|
91
|
-
except
|
|
93
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
|
92
94
|
return {}
|
|
93
95
|
return {}
|
|
94
96
|
|
|
@@ -104,9 +106,7 @@ def _skip_node(name: str, cfg: dict) -> bool:
|
|
|
104
106
|
return True
|
|
105
107
|
if is_private and cfg.get("ignore-private"):
|
|
106
108
|
return True
|
|
107
|
-
|
|
108
|
-
return True
|
|
109
|
-
return False
|
|
109
|
+
return bool(is_semiprivate and cfg.get("ignore-semiprivate"))
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[int, str, str]]:
|
|
@@ -117,11 +117,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
|
|
|
117
117
|
the module.
|
|
118
118
|
"""
|
|
119
119
|
try:
|
|
120
|
-
|
|
121
|
-
tree =
|
|
122
|
-
except
|
|
120
|
+
sf = get_source(str(file_path))
|
|
121
|
+
tree = sf.tree
|
|
122
|
+
except OSError:
|
|
123
123
|
return []
|
|
124
|
-
|
|
124
|
+
if tree is None:
|
|
125
|
+
return []
|
|
126
|
+
src_lines = sf.text.splitlines()
|
|
125
127
|
|
|
126
128
|
def src_line(lineno: int) -> str:
|
|
127
129
|
"""Return the stripped content of the source line at the given line number."""
|
|
@@ -137,16 +139,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
|
|
|
137
139
|
|
|
138
140
|
effective_cfg = cfg or {}
|
|
139
141
|
results = []
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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)))
|
|
142
|
+
if not sf.module_has_docstring:
|
|
143
|
+
results.append((0, "module", first_code_line()))
|
|
144
|
+
for d in sf.definitions:
|
|
145
|
+
if d.has_docstring or _skip_node(d.name, effective_cfg):
|
|
146
|
+
continue
|
|
147
|
+
kind = "def" if d.kind == "function" else "class"
|
|
148
|
+
results.append((d.lineno, kind, src_line(d.lineno)))
|
|
150
149
|
results.sort(key=lambda x: x[0])
|
|
151
150
|
return results
|
|
152
151
|
|
py_cq/parsers/pytestparser.py
CHANGED
py_cq/parsers/ruffparser.py
CHANGED
|
@@ -123,7 +123,7 @@ def _format_F821(file: str, line: int, message: str) -> str:
|
|
|
123
123
|
base += f"\n\nOther references to `{name}` in this file:\n{ref_lines}"
|
|
124
124
|
else:
|
|
125
125
|
base += f"\n\n`{name}` is not imported or defined anywhere else in this file."
|
|
126
|
-
if enclosing:
|
|
126
|
+
if enclosing and enclosing not in base:
|
|
127
127
|
base += f"\n\nEnclosing function:{enclosing}"
|
|
128
128
|
return base
|
|
129
129
|
|
py_cq/source_file.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Process-scoped cache of parsed source files, shared across one ``cq check``.
|
|
2
|
+
|
|
3
|
+
Within a single run the same ``.py`` file is otherwise read and parsed several
|
|
4
|
+
times: by the normalized context hash, the coverage parser, the interrogate
|
|
5
|
+
parser, and the LLM formatters. :class:`SourceFile` parses each file at most
|
|
6
|
+
once and exposes lazy projections (functions, classes, docstring presence) that
|
|
7
|
+
downstream consumers walk instead of re-parsing.
|
|
8
|
+
|
|
9
|
+
The cache is keyed on ``(path, size, mtime)`` so a content change (which bumps
|
|
10
|
+
``mtime``) misses and forces a fresh parse; correctness never depends on a hit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FuncInfo:
|
|
22
|
+
"""A function or class definition discovered in a source file.
|
|
23
|
+
|
|
24
|
+
``signature`` is the rendered ``def`` line for functions and ``""`` for
|
|
25
|
+
classes (no consumer needs a class signature). ``kind`` is ``"function"``
|
|
26
|
+
or ``"class"``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
kind: str
|
|
31
|
+
lineno: int
|
|
32
|
+
end_lineno: int
|
|
33
|
+
has_docstring: bool
|
|
34
|
+
signature: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _docstring_invariant_digest(tree: ast.AST) -> str:
|
|
38
|
+
"""Return an md5 of ``tree`` with docstrings removed, leaving ``tree`` intact.
|
|
39
|
+
|
|
40
|
+
Docstring statements are temporarily detached, the tree is dumped and
|
|
41
|
+
hashed, then they are reattached - so the digest is docstring-invariant
|
|
42
|
+
without parsing a second time or mutating the tree that other consumers
|
|
43
|
+
share. Safe because the hash pass runs single-threaded before the tool pool.
|
|
44
|
+
"""
|
|
45
|
+
detached = []
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if isinstance(
|
|
48
|
+
node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
49
|
+
):
|
|
50
|
+
body = node.body
|
|
51
|
+
if (
|
|
52
|
+
body
|
|
53
|
+
and isinstance(body[0], ast.Expr)
|
|
54
|
+
and isinstance(body[0].value, ast.Constant)
|
|
55
|
+
and isinstance(body[0].value.value, str)
|
|
56
|
+
):
|
|
57
|
+
detached.append((node, body[0]))
|
|
58
|
+
node.body = body[1:]
|
|
59
|
+
digest = hashlib.md5(ast.dump(tree).encode()).hexdigest() # nosec
|
|
60
|
+
for node, doc in detached:
|
|
61
|
+
node.body = [doc, *node.body]
|
|
62
|
+
return digest
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
66
|
+
"""Return the signature of the function definition as a string."""
|
|
67
|
+
prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
68
|
+
args = ast.unparse(node.args)
|
|
69
|
+
returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
|
|
70
|
+
return f"{prefix} {node.name}({args}){returns}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SourceFile:
|
|
74
|
+
"""Lazily-realized view of one source file: text, AST, and projections.
|
|
75
|
+
|
|
76
|
+
Fields are computed on first access and cached. ``.tree`` stays pristine
|
|
77
|
+
(docstrings intact) so multiple consumers can share it; ``.norm_digest``
|
|
78
|
+
derives its docstring-stripped digest from a throwaway reparse rather than
|
|
79
|
+
mutating ``.tree``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, path: str) -> None:
|
|
83
|
+
self.path = path
|
|
84
|
+
self._raw: bytes | None = None
|
|
85
|
+
self._text: str | None = None
|
|
86
|
+
self._tree: ast.AST | None = None
|
|
87
|
+
self._parsed = False
|
|
88
|
+
self._norm_digest: str | None = None
|
|
89
|
+
self._definitions: list[FuncInfo] | None = None
|
|
90
|
+
|
|
91
|
+
def _read_bytes(self) -> bytes:
|
|
92
|
+
if self._raw is None:
|
|
93
|
+
self._raw = Path(self.path).read_bytes()
|
|
94
|
+
return self._raw
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def text(self) -> str:
|
|
98
|
+
"""Return the file contents decoded as UTF-8 (undecodable bytes replaced)."""
|
|
99
|
+
if self._text is None:
|
|
100
|
+
self._text = self._read_bytes().decode("utf-8", "replace")
|
|
101
|
+
return self._text
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def tree(self) -> ast.AST | None:
|
|
105
|
+
"""Return the parsed AST, or ``None`` if the file can't be parsed."""
|
|
106
|
+
if not self._parsed:
|
|
107
|
+
self._parsed = True
|
|
108
|
+
try:
|
|
109
|
+
self._tree = ast.parse(self.text)
|
|
110
|
+
except (SyntaxError, ValueError):
|
|
111
|
+
self._tree = None
|
|
112
|
+
return self._tree
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def norm_digest(self) -> str:
|
|
116
|
+
"""Return a docstring/comment/format-invariant digest of the file.
|
|
117
|
+
|
|
118
|
+
Falls back to a byte digest when the file can't be parsed (e.g. a
|
|
119
|
+
mid-edit syntax error), so callers always get a usable digest.
|
|
120
|
+
"""
|
|
121
|
+
if self._norm_digest is None:
|
|
122
|
+
tree = self.tree
|
|
123
|
+
if tree is None:
|
|
124
|
+
self._norm_digest = hashlib.md5(self._read_bytes()).hexdigest() # nosec
|
|
125
|
+
else:
|
|
126
|
+
self._norm_digest = _docstring_invariant_digest(tree)
|
|
127
|
+
return self._norm_digest
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def definitions(self) -> list[FuncInfo]:
|
|
131
|
+
"""Return every function and class definition, in source order."""
|
|
132
|
+
if self._definitions is None:
|
|
133
|
+
defs: list[FuncInfo] = []
|
|
134
|
+
tree = self.tree
|
|
135
|
+
if tree is not None:
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
138
|
+
defs.append(
|
|
139
|
+
FuncInfo(
|
|
140
|
+
node.name,
|
|
141
|
+
"function",
|
|
142
|
+
node.lineno,
|
|
143
|
+
getattr(node, "end_lineno", node.lineno),
|
|
144
|
+
ast.get_docstring(node) is not None,
|
|
145
|
+
_signature(node),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
elif isinstance(node, ast.ClassDef):
|
|
149
|
+
defs.append(
|
|
150
|
+
FuncInfo(
|
|
151
|
+
node.name,
|
|
152
|
+
"class",
|
|
153
|
+
node.lineno,
|
|
154
|
+
getattr(node, "end_lineno", node.lineno),
|
|
155
|
+
ast.get_docstring(node) is not None,
|
|
156
|
+
"",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
defs.sort(key=lambda d: d.lineno)
|
|
160
|
+
self._definitions = defs
|
|
161
|
+
return self._definitions
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def functions(self) -> list[FuncInfo]:
|
|
165
|
+
"""Return the function definitions only (no classes), in source order."""
|
|
166
|
+
return [d for d in self.definitions if d.kind == "function"]
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def module_has_docstring(self) -> bool:
|
|
170
|
+
"""Return True if the module has a docstring."""
|
|
171
|
+
tree = self.tree
|
|
172
|
+
return isinstance(tree, ast.Module) and ast.get_docstring(tree) is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Process-scoped memo: (path, size, mtime) -> SourceFile. A stale mtime only
|
|
176
|
+
# forces a re-parse that reproduces the same projections, so correctness never
|
|
177
|
+
# depends on this cache (matches the lock-free design: a duplicate parse is
|
|
178
|
+
# harmless and GIL-serialized).
|
|
179
|
+
_source_cache: dict[tuple[str, int, float], SourceFile] = {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_source(path: str) -> SourceFile:
|
|
183
|
+
"""Return the cached :class:`SourceFile` for ``path``, creating it on miss.
|
|
184
|
+
|
|
185
|
+
Keyed on ``(canonical_path, size, mtime)``; a content change misses and
|
|
186
|
+
re-parses. The path is canonicalized (absolute + OS case folding) so the
|
|
187
|
+
hash pass and the parsers - which spell the same file differently (relative
|
|
188
|
+
vs. resolved/posix) - share one cache entry.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
OSError: If ``path`` cannot be stat'd.
|
|
192
|
+
"""
|
|
193
|
+
s = os.stat(path)
|
|
194
|
+
key = (os.path.normcase(os.path.abspath(path)), s.st_size, s.st_mtime)
|
|
195
|
+
sf = _source_cache.get(key)
|
|
196
|
+
if sf is None:
|
|
197
|
+
sf = SourceFile(path)
|
|
198
|
+
_source_cache[key] = sf
|
|
199
|
+
return sf
|
py_cq/table_formatter.py
CHANGED
|
@@ -21,7 +21,7 @@ def format_as_table(
|
|
|
21
21
|
table.add_column("Status")
|
|
22
22
|
for tr in data.tool_results:
|
|
23
23
|
tool_name = tr.raw.tool_name
|
|
24
|
-
config = next(
|
|
24
|
+
config = next(t for t in registry.values() if t.name == tool_name)
|
|
25
25
|
for i, (name, value) in enumerate(tr.metrics.items()):
|
|
26
26
|
if value < config.error_threshold:
|
|
27
27
|
status = "[bold red]Error[/]"
|
py_cq/tool_registry.py
CHANGED
|
@@ -32,6 +32,9 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
32
32
|
exclude_format=tool_data.get("exclude_format", ""),
|
|
33
33
|
scan_exclude_names=tool_data.get("scan_exclude_names", []),
|
|
34
34
|
skip_for_file=tool_data.get("skip_for_file", False),
|
|
35
|
+
gate_strict=tool_data.get("gate_strict", True),
|
|
36
|
+
skip_if=tool_data.get("skip_if", ""),
|
|
37
|
+
cache_invariant=tool_data.get("cache_invariant", ""),
|
|
35
38
|
)
|
|
36
39
|
return registry
|
|
37
40
|
|