python-code-quality 0.1.8__py3-none-any.whl → 0.1.10__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/cli.py +54 -5
- py_cq/config/{tools.yaml → config.yaml} +28 -37
- py_cq/execution_engine.py +2 -2
- py_cq/language_detector.py +29 -0
- py_cq/llm_formatter.py +2 -1
- py_cq/localtypes.py +5 -1
- py_cq/parsers/banditparser.py +2 -2
- py_cq/parsers/common.py +31 -0
- py_cq/parsers/compileparser.py +2 -2
- py_cq/parsers/coverageparser.py +1 -1
- py_cq/parsers/exitcodeparser.py +16 -0
- py_cq/parsers/halsteadparser.py +1 -1
- py_cq/parsers/interrogateparser.py +1 -1
- py_cq/parsers/linecountparser.py +26 -0
- py_cq/parsers/pytestparser.py +53 -10
- py_cq/parsers/regexcountparser.py +35 -0
- py_cq/parsers/ruffparser.py +2 -2
- py_cq/parsers/typarser.py +2 -2
- py_cq/parsers/vultureparser.py +2 -2
- py_cq/tool_registry.py +5 -4
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.10.dist-info}/METADATA +81 -51
- python_code_quality-0.1.10.dist-info/RECORD +33 -0
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.10.dist-info}/WHEEL +1 -1
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.10.dist-info}/entry_points.txt +1 -0
- python_code_quality-0.1.8.dist-info/RECORD +0 -30
- python_code_quality-0.1.8.dist-info/licenses/LICENSE +0 -21
py_cq/cli.py
CHANGED
|
@@ -16,6 +16,7 @@ import json
|
|
|
16
16
|
import logging
|
|
17
17
|
import tomllib
|
|
18
18
|
from enum import Enum
|
|
19
|
+
from importlib import import_module
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
22
|
import typer
|
|
@@ -26,6 +27,7 @@ from rich.table import Table
|
|
|
26
27
|
from py_cq.config import load_user_config
|
|
27
28
|
from py_cq.execution_engine import _cache as tool_cache
|
|
28
29
|
from py_cq.execution_engine import run_tools
|
|
30
|
+
from py_cq.language_detector import detect_language
|
|
29
31
|
from py_cq.localtypes import CombinedToolResults, ToolConfig
|
|
30
32
|
from py_cq.metric_aggregator import aggregate_metrics
|
|
31
33
|
from py_cq.tool_registry import tool_registry
|
|
@@ -56,6 +58,7 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
|
|
|
56
58
|
Supports:
|
|
57
59
|
- ``disable``: list of tool IDs to remove
|
|
58
60
|
- ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
|
|
61
|
+
- ``tools.<tool_id>``: declare new tools (or override built-ins)
|
|
59
62
|
"""
|
|
60
63
|
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
61
64
|
for tool_id in user_cfg.get("disable", []):
|
|
@@ -66,6 +69,24 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
|
|
|
66
69
|
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
67
70
|
if "error" in thresholds:
|
|
68
71
|
registry[tool_id].error_threshold = float(thresholds["error"])
|
|
72
|
+
for tool_id, tool_data in user_cfg.get("tools", {}).items():
|
|
73
|
+
try:
|
|
74
|
+
parser_name = tool_data["parser"]
|
|
75
|
+
module = import_module(f"py_cq.parsers.{parser_name.lower()}")
|
|
76
|
+
parser_class = getattr(module, parser_name)
|
|
77
|
+
registry[tool_id] = ToolConfig(
|
|
78
|
+
name=tool_id,
|
|
79
|
+
command=tool_data["command"],
|
|
80
|
+
parser_class=parser_class,
|
|
81
|
+
order=tool_data["order"],
|
|
82
|
+
warning_threshold=tool_data["warning_threshold"],
|
|
83
|
+
error_threshold=tool_data["error_threshold"],
|
|
84
|
+
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
85
|
+
extra_deps=tool_data.get("extra_deps", []),
|
|
86
|
+
parser_config=tool_data.get("parser_config", {}),
|
|
87
|
+
)
|
|
88
|
+
except KeyError as e:
|
|
89
|
+
raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
|
|
69
90
|
return registry
|
|
70
91
|
|
|
71
92
|
|
|
@@ -101,11 +122,27 @@ def check(
|
|
|
101
122
|
workers: int = typer.Option(
|
|
102
123
|
0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
|
|
103
124
|
),
|
|
125
|
+
language: str | None = typer.Option(
|
|
126
|
+
None, "--language", "-l", help="Override language detection (e.g. python, typescript, rust)"
|
|
127
|
+
),
|
|
104
128
|
):
|
|
105
129
|
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
|
|
106
130
|
path_obj = Path(path)
|
|
107
131
|
if not path_obj.exists():
|
|
108
132
|
raise typer.BadParameter(f"Path does not exist: {path}")
|
|
133
|
+
|
|
134
|
+
resolved_language = language or detect_language(path_obj)
|
|
135
|
+
|
|
136
|
+
if resolved_language is not None and resolved_language != "python":
|
|
137
|
+
console.print(
|
|
138
|
+
f"[yellow]{resolved_language.capitalize()} project detected. "
|
|
139
|
+
"Non-Python language support is not yet available.[/yellow]"
|
|
140
|
+
)
|
|
141
|
+
raise typer.Exit(0)
|
|
142
|
+
|
|
143
|
+
# Python path (or unknown — fall through to existing validation).
|
|
144
|
+
# Note: --language python still requires pyproject.toml; the flag selects
|
|
145
|
+
# the tool set, not the input validation rules.
|
|
109
146
|
if path_obj.is_file():
|
|
110
147
|
if path_obj.suffix != ".py":
|
|
111
148
|
raise typer.BadParameter(f"File must be a Python file (.py): {path}")
|
|
@@ -113,7 +150,9 @@ def check(
|
|
|
113
150
|
if not (path_obj / "pyproject.toml").exists():
|
|
114
151
|
raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
|
|
115
152
|
log.setLevel(log_level)
|
|
116
|
-
|
|
153
|
+
user_cfg = load_user_config(path_obj)
|
|
154
|
+
context_lines: int = int(user_cfg.get("context_lines", 15))
|
|
155
|
+
effective_registry = _apply_user_config(tool_registry, user_cfg)
|
|
117
156
|
if clear_cache:
|
|
118
157
|
tool_cache.clear()
|
|
119
158
|
tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
|
|
@@ -129,10 +168,19 @@ def check(
|
|
|
129
168
|
elif output == OutputMode.LLM:
|
|
130
169
|
# log.setLevel("CRITICAL")
|
|
131
170
|
from py_cq.llm_formatter import format_for_llm
|
|
132
|
-
console.print(format_for_llm(effective_registry, combined_metrics))
|
|
171
|
+
console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
|
|
133
172
|
else:
|
|
173
|
+
console.print(f"[bold green]{path_obj.resolve()}[/]")
|
|
134
174
|
console.print(format_as_table(combined_metrics, effective_registry))
|
|
135
175
|
|
|
176
|
+
tool_by_name = {tc.name: tc for tc in effective_registry.values()}
|
|
177
|
+
if any(
|
|
178
|
+
min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
|
|
179
|
+
for tr in tool_results
|
|
180
|
+
if tr.metrics and tr.raw.tool_name in tool_by_name
|
|
181
|
+
):
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
|
|
136
184
|
|
|
137
185
|
@app.command()
|
|
138
186
|
def config(
|
|
@@ -172,8 +220,9 @@ def config(
|
|
|
172
220
|
table.add_column("Error", justify="right")
|
|
173
221
|
table.add_column("Status", justify="center")
|
|
174
222
|
|
|
175
|
-
|
|
176
|
-
|
|
223
|
+
all_tool_ids = set(tool_registry) | set(effective_registry)
|
|
224
|
+
for tool_id in sorted(all_tool_ids, key=lambda t: (effective_registry.get(t) or tool_registry[t]).order):
|
|
225
|
+
tc = effective_registry.get(tool_id) or tool_registry[tool_id]
|
|
177
226
|
is_disabled = tool_id in disabled_ids
|
|
178
227
|
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
|
179
228
|
table.add_row(
|
|
@@ -204,7 +253,7 @@ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
|
|
|
204
253
|
>>> table = format_as_table(combined_results)
|
|
205
254
|
>>> console.print(table)
|
|
206
255
|
"""
|
|
207
|
-
table = Table(
|
|
256
|
+
table = Table(width=80)
|
|
208
257
|
table.add_column("Tool", justify="left", no_wrap=True)
|
|
209
258
|
table.add_column("Time", justify="right", style="dim")
|
|
210
259
|
table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
|
|
@@ -1,52 +1,48 @@
|
|
|
1
|
-
|
|
1
|
+
python:
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
|
|
3
|
+
compile:
|
|
4
|
+
command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
|
|
6
5
|
parser: "CompileParser"
|
|
7
6
|
order: 1
|
|
8
7
|
warning_threshold: 0.9999
|
|
9
8
|
error_threshold: 0.9999
|
|
10
9
|
|
|
11
|
-
bandit:
|
|
12
|
-
name: "bandit"
|
|
13
|
-
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
14
|
-
parser: "BanditParser"
|
|
15
|
-
order: 2
|
|
16
|
-
warning_threshold: 0.9999
|
|
17
|
-
error_threshold: 0.8
|
|
18
|
-
|
|
19
10
|
ruff:
|
|
20
|
-
|
|
21
|
-
command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
|
|
11
|
+
command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\""
|
|
22
12
|
parser: "RuffParser"
|
|
23
|
-
order:
|
|
13
|
+
order: 2
|
|
24
14
|
warning_threshold: 0.9999
|
|
25
15
|
error_threshold: 0.9
|
|
26
16
|
|
|
27
17
|
ty:
|
|
28
|
-
|
|
29
|
-
command: "{python} -m ty check --output-format concise --color never {context_path}"
|
|
18
|
+
command: "{python} -m ty check --output-format concise --color never \"{context_path}\""
|
|
30
19
|
parser: "TyParser"
|
|
31
|
-
order:
|
|
20
|
+
order: 3
|
|
32
21
|
warning_threshold: 0.9999
|
|
33
22
|
error_threshold: 0.8
|
|
34
23
|
run_in_target_env: true
|
|
35
24
|
extra_deps:
|
|
36
25
|
- ty
|
|
37
26
|
|
|
27
|
+
bandit:
|
|
28
|
+
command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests\""
|
|
29
|
+
parser: "BanditParser"
|
|
30
|
+
order: 4
|
|
31
|
+
warning_threshold: 0.9999
|
|
32
|
+
error_threshold: 0.8
|
|
33
|
+
|
|
38
34
|
pytest:
|
|
39
|
-
|
|
40
|
-
command: "{python} -m pytest -v {context_path}"
|
|
35
|
+
command: "{python} -m pytest -v \"{context_path}\""
|
|
41
36
|
parser: "PytestParser"
|
|
42
37
|
order: 5
|
|
43
|
-
warning_threshold: 0
|
|
44
|
-
error_threshold: 0
|
|
38
|
+
warning_threshold: 1.0
|
|
39
|
+
error_threshold: 1.0
|
|
45
40
|
run_in_target_env: true
|
|
41
|
+
extra_deps:
|
|
42
|
+
- pytest
|
|
46
43
|
|
|
47
44
|
coverage:
|
|
48
|
-
|
|
49
|
-
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
45
|
+
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
50
46
|
parser: "CoverageParser"
|
|
51
47
|
order: 6
|
|
52
48
|
warning_threshold: 0.9
|
|
@@ -56,41 +52,36 @@ tools:
|
|
|
56
52
|
- coverage
|
|
57
53
|
- pytest
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
command: "{python} -m radon cc --json {context_path}"
|
|
55
|
+
radon-cc:
|
|
56
|
+
command: "{python} -m radon cc --json \"{context_path}\""
|
|
62
57
|
parser: "ComplexityParser"
|
|
63
58
|
order: 7
|
|
64
59
|
warning_threshold: 0.6
|
|
65
60
|
error_threshold: 0.4
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
command: "{python} -m radon mi -s --json {context_path}"
|
|
62
|
+
radon-mi:
|
|
63
|
+
command: "{python} -m radon mi -s --json \"{context_path}\""
|
|
70
64
|
parser: "MaintainabilityParser"
|
|
71
65
|
order: 8
|
|
72
66
|
warning_threshold: 0.6
|
|
73
67
|
error_threshold: 0.4
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
command: "{python} -m radon hal -f --json {context_path}"
|
|
69
|
+
radon-hal:
|
|
70
|
+
command: "{python} -m radon hal -f --json \"{context_path}\""
|
|
78
71
|
parser: "HalsteadParser"
|
|
79
72
|
order: 9
|
|
80
73
|
warning_threshold: 0.5
|
|
81
74
|
error_threshold: 0.3
|
|
82
75
|
|
|
83
76
|
vulture:
|
|
84
|
-
|
|
85
|
-
command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
77
|
+
command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
86
78
|
parser: "VultureParser"
|
|
87
79
|
order: 10
|
|
88
80
|
warning_threshold: 0.9999
|
|
89
81
|
error_threshold: 0.8
|
|
90
82
|
|
|
91
83
|
interrogate:
|
|
92
|
-
|
|
93
|
-
command: "{python} -m interrogate {context_path} -v --fail-under 0"
|
|
84
|
+
command: "{python} -m interrogate \"{context_path}\" -v --fail-under 0"
|
|
94
85
|
parser: "InterrogateParser"
|
|
95
86
|
order: 11
|
|
96
87
|
warning_threshold: 0.8
|
py_cq/execution_engine.py
CHANGED
|
@@ -82,7 +82,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
|
|
|
82
82
|
log.info(f"Cache hit: {command}")
|
|
83
83
|
return RawResult(**cast(dict[str, Any], _cache[cache_key]))
|
|
84
84
|
log.info(f"Running: {command}")
|
|
85
|
-
result = subprocess.run(command, capture_output=True, text=True, shell=True) # nosec
|
|
85
|
+
result = subprocess.run(command, capture_output=True, text=True, shell=True, encoding="utf-8", errors="replace") # nosec
|
|
86
86
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
87
87
|
raw_result = RawResult(
|
|
88
88
|
tool_name=tool_config.name,
|
|
@@ -139,7 +139,7 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
|
|
|
139
139
|
def _run_and_parse(tool_config: ToolConfig) -> tuple[int, ToolResult]:
|
|
140
140
|
t0 = time.perf_counter()
|
|
141
141
|
raw_result = run_tool(tool_config, path)
|
|
142
|
-
tr = tool_config.parser_class().parse(raw_result)
|
|
142
|
+
tr = tool_config.parser_class(tool_config.parser_config).parse(raw_result)
|
|
143
143
|
tr.duration_s = time.perf_counter() - t0
|
|
144
144
|
return tool_config.order, tr
|
|
145
145
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Detect the primary language of a project from its file markers."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Ordered: first match wins. Python is listed first so it takes priority.
|
|
6
|
+
_MARKERS: list[tuple[str, list[str]]] = [
|
|
7
|
+
("python", ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]),
|
|
8
|
+
("typescript", ["tsconfig.json", "package.json"]),
|
|
9
|
+
("rust", ["Cargo.toml"]),
|
|
10
|
+
("go", ["go.mod"]),
|
|
11
|
+
("ruby", ["Gemfile"]),
|
|
12
|
+
("java", ["pom.xml", "build.gradle"]),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
_DOTNET_SUFFIXES = {".csproj", ".sln"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_language(path: Path) -> str | None:
|
|
19
|
+
"""Return the detected language for a project path, or None if unrecognised.
|
|
20
|
+
|
|
21
|
+
If path is a file, the parent directory is checked.
|
|
22
|
+
Dotnet is checked last as it uses suffix matching rather than fixed filenames."""
|
|
23
|
+
directory = path if path.is_dir() else path.parent
|
|
24
|
+
for language, markers in _MARKERS:
|
|
25
|
+
if any((directory / marker).exists() for marker in markers):
|
|
26
|
+
return language
|
|
27
|
+
if any(f.suffix in _DOTNET_SUFFIXES for f in directory.iterdir() if f.is_file()):
|
|
28
|
+
return "dotnet"
|
|
29
|
+
return None
|
py_cq/llm_formatter.py
CHANGED
|
@@ -18,6 +18,7 @@ def format_for_llm(
|
|
|
18
18
|
tool_configs: dict,
|
|
19
19
|
combined: CombinedToolResults,
|
|
20
20
|
cq_invocation: str | None = None,
|
|
21
|
+
context_lines: int = 15,
|
|
21
22
|
) -> str:
|
|
22
23
|
"""Return a markdown prompt describing the single most important defect."""
|
|
23
24
|
by_name = {tc.name: tc for tc in tool_configs.values()}
|
|
@@ -38,7 +39,7 @@ def format_for_llm(
|
|
|
38
39
|
|
|
39
40
|
worst = failing[0]
|
|
40
41
|
config = by_name[worst.raw.tool_name]
|
|
41
|
-
defect_md = config.parser_class().format_llm_message(worst)
|
|
42
|
+
defect_md = config.parser_class().format_llm_message(worst, context_lines=context_lines)
|
|
42
43
|
if cq_invocation is None:
|
|
43
44
|
cq_invocation = "cq " + " ".join(sys.argv[1:])
|
|
44
45
|
return (
|
py_cq/localtypes.py
CHANGED
|
@@ -21,6 +21,7 @@ class ToolConfig:
|
|
|
21
21
|
error_threshold: float = 0.5 # Red error if below this
|
|
22
22
|
run_in_target_env: bool = False # If True, run in target project's env via uv
|
|
23
23
|
extra_deps: list[str] = field(default_factory=list) # Extra deps to inject via uv --with
|
|
24
|
+
parser_config: dict[str, Any] = field(default_factory=dict)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
@dataclass
|
|
@@ -119,12 +120,15 @@ class AbstractParser(ABC):
|
|
|
119
120
|
|
|
120
121
|
Subclasses must implement `parse` to convert a `RawResult` into a `ToolResult`. An optional `provide_help` can be overridden to supply contextual guidance for a parsed result."""
|
|
121
122
|
|
|
123
|
+
def __init__(self, parser_config: dict | None = None):
|
|
124
|
+
self.parser_config = parser_config or {}
|
|
125
|
+
|
|
122
126
|
@abstractmethod
|
|
123
127
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
124
128
|
"""Converts raw tool output into a structured ToolResult."""
|
|
125
129
|
pass
|
|
126
130
|
|
|
127
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
131
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
128
132
|
"""Return a single-defect description for LLM consumption.
|
|
129
133
|
|
|
130
134
|
Default implementation reports the worst metric by name and score.
|
py_cq/parsers/banditparser.py
CHANGED
|
@@ -42,7 +42,7 @@ class BanditParser(AbstractParser):
|
|
|
42
42
|
score = score_logistic_variant(weighted, scale_factor=10)
|
|
43
43
|
return ToolResult(raw=raw_result, metrics={"security": score}, details=files)
|
|
44
44
|
|
|
45
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
45
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
46
46
|
if not tr.details:
|
|
47
47
|
return "bandit reported issues (no details available)"
|
|
48
48
|
file, issues = next(iter(tr.details.items()))
|
|
@@ -51,4 +51,4 @@ class BanditParser(AbstractParser):
|
|
|
51
51
|
code = issue.get("code", "")
|
|
52
52
|
severity = issue.get("severity", "")
|
|
53
53
|
message = issue.get("message", "")
|
|
54
|
-
return f"`{file}:{line}` — **{code}** [{severity}]: {message}{format_source_context(file, line)}"
|
|
54
|
+
return f"`{file}:{line}` — **{code}** [{severity}]: {message}{format_source_context(file, line, count=context_lines)}"
|
py_cq/parsers/common.py
CHANGED
|
@@ -37,6 +37,37 @@ def format_source_context(file: str, line: int | str, context: int = 3, count: i
|
|
|
37
37
|
return f"\n```python\n{src}\n```"
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
|
|
41
|
+
"""Return a fenced python block for the body of func_name, or '' if unavailable."""
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
try:
|
|
44
|
+
all_lines = Path(file).read_text(encoding="utf-8").splitlines()
|
|
45
|
+
except OSError:
|
|
46
|
+
return ""
|
|
47
|
+
import re
|
|
48
|
+
pattern = re.compile(rf"^(\s*)(?:async\s+)?def\s+{re.escape(func_name)}\s*\(")
|
|
49
|
+
match_result: tuple[int, int] | None = None
|
|
50
|
+
for i, line in enumerate(all_lines):
|
|
51
|
+
m = pattern.match(line)
|
|
52
|
+
if m:
|
|
53
|
+
match_result = (i, len(m.group(1)))
|
|
54
|
+
break
|
|
55
|
+
if match_result is None:
|
|
56
|
+
return ""
|
|
57
|
+
start_idx, baseline_indent = match_result
|
|
58
|
+
collected = [all_lines[start_idx]]
|
|
59
|
+
for line in all_lines[start_idx + 1 :]:
|
|
60
|
+
stripped = line.lstrip()
|
|
61
|
+
indent = len(line) - len(stripped)
|
|
62
|
+
if stripped and indent <= baseline_indent:
|
|
63
|
+
break
|
|
64
|
+
collected.append(line)
|
|
65
|
+
if len(collected) >= max_lines:
|
|
66
|
+
break
|
|
67
|
+
numbered = "\n".join(f"{start_idx + 1 + i}: {ln}" for i, ln in enumerate(collected))
|
|
68
|
+
return f"\n```python\n{numbered}\n```"
|
|
69
|
+
|
|
70
|
+
|
|
40
71
|
def inv_normalize(value: float, max_value: float) -> float:
|
|
41
72
|
"""Returns the inverse normalized value of `value` relative to `max_value`."""
|
|
42
73
|
return (max_value - min(value, max_value)) / max_value
|
py_cq/parsers/compileparser.py
CHANGED
|
@@ -115,7 +115,7 @@ class CompileParser(AbstractParser):
|
|
|
115
115
|
tr.details["failed_files"] = failed_files
|
|
116
116
|
return tr
|
|
117
117
|
|
|
118
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
118
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
119
119
|
"""Return the first compilation failure as a defect description."""
|
|
120
120
|
failed = tr.details.get("failed_files", {})
|
|
121
121
|
if not failed:
|
|
@@ -124,5 +124,5 @@ class CompileParser(AbstractParser):
|
|
|
124
124
|
line = info.get("line", "?")
|
|
125
125
|
typ = info.get("type", "Error")
|
|
126
126
|
help_msg = info.get("help", "")
|
|
127
|
-
code_block = format_source_context(file, line) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
|
|
127
|
+
code_block = format_source_context(file, line, count=context_lines) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
|
|
128
128
|
return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
|
py_cq/parsers/coverageparser.py
CHANGED
|
@@ -71,7 +71,7 @@ class CoverageParser(AbstractParser):
|
|
|
71
71
|
tr.details = details
|
|
72
72
|
return tr
|
|
73
73
|
|
|
74
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
74
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
75
75
|
"""Return the files with lowest coverage as a defect description."""
|
|
76
76
|
score = tr.metrics.get("coverage", 0)
|
|
77
77
|
uncovered = sorted(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Parser that scores a tool pass/fail based solely on its exit code."""
|
|
2
|
+
|
|
3
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCodeParser(AbstractParser):
|
|
7
|
+
"""Score 1.0 if the tool exited with code 0, else 0.0."""
|
|
8
|
+
|
|
9
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
10
|
+
score = 1.0 if raw_result.return_code == 0 else 0.0
|
|
11
|
+
return ToolResult(raw=raw_result, metrics={"exit_code": score})
|
|
12
|
+
|
|
13
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
14
|
+
output = tr.raw.stdout.strip() or tr.raw.stderr.strip()
|
|
15
|
+
lines = output.splitlines()[:context_lines]
|
|
16
|
+
return "\n".join(lines) if lines else "Tool exited with non-zero status (no output)"
|
py_cq/parsers/halsteadparser.py
CHANGED
|
@@ -113,7 +113,7 @@ class HalsteadParser(AbstractParser):
|
|
|
113
113
|
}
|
|
114
114
|
return tr
|
|
115
115
|
|
|
116
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
116
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
117
117
|
"""Return the worst Halstead offender as an actionable defect description."""
|
|
118
118
|
if not tr.metrics:
|
|
119
119
|
return "No Halstead details available"
|
|
@@ -42,7 +42,7 @@ class InterrogateParser(AbstractParser):
|
|
|
42
42
|
score = total_coverage if total_coverage is not None else 1.0
|
|
43
43
|
return ToolResult(raw=raw_result, metrics={"doc_coverage": score}, details=files)
|
|
44
44
|
|
|
45
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
45
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
46
46
|
score = tr.metrics.get("doc_coverage", 0)
|
|
47
47
|
uncovered = sorted(
|
|
48
48
|
[(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Parser that scores a tool by counting non-empty output lines as violations."""
|
|
2
|
+
|
|
3
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
4
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LineCountParser(AbstractParser):
|
|
8
|
+
"""Score based on number of non-empty stdout lines.
|
|
9
|
+
|
|
10
|
+
parser_config keys:
|
|
11
|
+
scale_factor (int, default 15): passed to score_logistic_variant.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
15
|
+
lines = [ln for ln in (raw_result.stdout or "").splitlines() if ln.strip()]
|
|
16
|
+
count = len(lines)
|
|
17
|
+
scale = self.parser_config.get("scale_factor", 15)
|
|
18
|
+
score = score_logistic_variant(count, scale_factor=scale)
|
|
19
|
+
return ToolResult(raw=raw_result, metrics={"violations": score}, details={"lines": lines})
|
|
20
|
+
|
|
21
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
22
|
+
lines = tr.details.get("lines", [])
|
|
23
|
+
if not lines:
|
|
24
|
+
return "No violations found"
|
|
25
|
+
shown = lines[:context_lines]
|
|
26
|
+
return "\n".join(shown)
|
py_cq/parsers/pytestparser.py
CHANGED
|
@@ -8,11 +8,33 @@ 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 re
|
|
11
|
+
import re as _re
|
|
12
12
|
|
|
13
13
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
|
|
17
|
+
"""Extract the failure section for test_name from pytest stdout."""
|
|
18
|
+
lines = stdout.splitlines()
|
|
19
|
+
pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
|
|
20
|
+
start = None
|
|
21
|
+
for i, line in enumerate(lines):
|
|
22
|
+
if pattern.search(line):
|
|
23
|
+
start = i + 1
|
|
24
|
+
break
|
|
25
|
+
if start is None:
|
|
26
|
+
return ""
|
|
27
|
+
collected = []
|
|
28
|
+
for line in lines[start:]:
|
|
29
|
+
if line.strip().startswith("_") or line.strip().startswith("="):
|
|
30
|
+
break
|
|
31
|
+
collected.append(line)
|
|
32
|
+
if len(collected) >= max_lines:
|
|
33
|
+
break
|
|
34
|
+
text = "\n".join(collected).strip()
|
|
35
|
+
return f"\n```\n{text}\n```" if text else ""
|
|
36
|
+
|
|
37
|
+
|
|
16
38
|
class PytestParser(AbstractParser):
|
|
17
39
|
"""Parses raw pytest output into a structured `ToolResult`.
|
|
18
40
|
|
|
@@ -51,14 +73,14 @@ class PytestParser(AbstractParser):
|
|
|
51
73
|
lines = raw_result.stdout.splitlines()
|
|
52
74
|
tr = ToolResult(raw=raw_result)
|
|
53
75
|
if "no tests ran" in raw_result.stdout:
|
|
54
|
-
|
|
76
|
+
tr.metrics["tests"] = 0.0
|
|
55
77
|
else:
|
|
56
78
|
tests_found = dict()
|
|
57
79
|
num_tests = 0
|
|
58
80
|
passed_tests = 0
|
|
59
81
|
for line in lines:
|
|
60
82
|
# tests/test_common.py::test_name[param] PASSED [ 8%]
|
|
61
|
-
tests_match =
|
|
83
|
+
tests_match = _re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
|
|
62
84
|
if tests_match:
|
|
63
85
|
test_file = tests_match.group(1)
|
|
64
86
|
test_name = tests_match.group(2).strip()
|
|
@@ -70,7 +92,7 @@ class PytestParser(AbstractParser):
|
|
|
70
92
|
if num_tests == 0:
|
|
71
93
|
# No individual test lines found (e.g. non-verbose output);
|
|
72
94
|
# fall back to parsing the pytest summary line.
|
|
73
|
-
summary =
|
|
95
|
+
summary = _re.search(r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout)
|
|
74
96
|
if summary:
|
|
75
97
|
passed_tests = int(summary.group(1))
|
|
76
98
|
failed_tests = int(summary.group(2)) if summary.group(2) else 0
|
|
@@ -79,11 +101,32 @@ class PytestParser(AbstractParser):
|
|
|
79
101
|
tr.details = tests_found
|
|
80
102
|
return tr
|
|
81
103
|
|
|
82
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
83
|
-
"""Return the first failing test
|
|
104
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
105
|
+
"""Return the first failing test with function body and failure output."""
|
|
106
|
+
from py_cq.parsers.common import find_function_source
|
|
84
107
|
for file, tests in tr.details.items():
|
|
85
|
-
if isinstance(tests, dict):
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
if not isinstance(tests, dict):
|
|
109
|
+
continue
|
|
110
|
+
for test_name, status in tests.items():
|
|
111
|
+
if status != "FAILED":
|
|
112
|
+
continue
|
|
113
|
+
header = f"`{file}::{test_name}` — test **FAILED**"
|
|
114
|
+
body = find_function_source(file, test_name, max_lines=context_lines)
|
|
115
|
+
failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
|
|
116
|
+
parts = [header]
|
|
117
|
+
if body:
|
|
118
|
+
parts.append(body)
|
|
119
|
+
if failure:
|
|
120
|
+
parts.append(failure)
|
|
121
|
+
return "\n".join(parts)
|
|
122
|
+
if "no tests ran" in tr.raw.stdout:
|
|
123
|
+
return (
|
|
124
|
+
"**No tests found.** This project has no pytest test suite.\n\n"
|
|
125
|
+
"Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
|
|
126
|
+
"and write a first test covering a core function."
|
|
127
|
+
)
|
|
128
|
+
output = (tr.raw.stdout + tr.raw.stderr).strip()
|
|
129
|
+
if output:
|
|
130
|
+
tail = "\n".join(output.splitlines()[-30:])
|
|
131
|
+
return f"pytest reported failures:\n\n```\n{tail}\n```"
|
|
89
132
|
return "pytest reported failures (no details available)"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Parser that counts stdout lines matching a regex pattern."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
6
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegexCountParser(AbstractParser):
|
|
10
|
+
"""Score based on the number of stdout lines matching a regex.
|
|
11
|
+
|
|
12
|
+
parser_config keys:
|
|
13
|
+
pattern (str, required): regex pattern to match against each line.
|
|
14
|
+
scale_factor (int, default 15): passed to score_logistic_variant.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
18
|
+
pattern = re.compile(self.parser_config["pattern"])
|
|
19
|
+
scale = self.parser_config.get("scale_factor", 15)
|
|
20
|
+
lines = (raw_result.stdout or "").splitlines()
|
|
21
|
+
matches = [ln for ln in lines if pattern.search(ln)]
|
|
22
|
+
count = len(matches)
|
|
23
|
+
score = score_logistic_variant(count, scale_factor=scale)
|
|
24
|
+
return ToolResult(
|
|
25
|
+
raw=raw_result,
|
|
26
|
+
metrics={"violations": score},
|
|
27
|
+
details={"count": count, "matches": matches},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
31
|
+
matches = tr.details.get("matches", [])
|
|
32
|
+
if not matches:
|
|
33
|
+
return "No violations found"
|
|
34
|
+
shown = matches[:context_lines]
|
|
35
|
+
return "\n".join(shown)
|
py_cq/parsers/ruffparser.py
CHANGED
|
@@ -45,7 +45,7 @@ class RuffParser(AbstractParser):
|
|
|
45
45
|
)
|
|
46
46
|
return ToolResult(raw=raw_result, metrics={"lint": score}, details=files)
|
|
47
47
|
|
|
48
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
48
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
49
49
|
"""Return the first lint violation as a defect description."""
|
|
50
50
|
if not tr.details:
|
|
51
51
|
return "ruff reported issues (no details available)"
|
|
@@ -54,4 +54,4 @@ class RuffParser(AbstractParser):
|
|
|
54
54
|
line = issue.get("line", "?")
|
|
55
55
|
code = issue.get("code", "")
|
|
56
56
|
message = issue.get("message", "")
|
|
57
|
-
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line)}"
|
|
57
|
+
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
|
py_cq/parsers/typarser.py
CHANGED
|
@@ -49,7 +49,7 @@ class TyParser(AbstractParser):
|
|
|
49
49
|
score = score_logistic_variant(weighted, scale_factor=10)
|
|
50
50
|
return ToolResult(raw=raw_result, metrics={"type_check": score}, details=files)
|
|
51
51
|
|
|
52
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
52
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
53
53
|
"""Return the first type-check diagnostic as a defect description."""
|
|
54
54
|
if not tr.details:
|
|
55
55
|
return "ty reported issues (no details available)"
|
|
@@ -58,4 +58,4 @@ class TyParser(AbstractParser):
|
|
|
58
58
|
line = issue.get("line", "?")
|
|
59
59
|
code = issue.get("code", "")
|
|
60
60
|
message = issue.get("message", "")
|
|
61
|
-
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line)}"
|
|
61
|
+
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
|
py_cq/parsers/vultureparser.py
CHANGED
|
@@ -36,7 +36,7 @@ class VultureParser(AbstractParser):
|
|
|
36
36
|
score = score_logistic_variant(count, scale_factor=15)
|
|
37
37
|
return ToolResult(raw=raw_result, metrics={"dead_code": score}, details=files)
|
|
38
38
|
|
|
39
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
39
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
40
40
|
if not tr.details:
|
|
41
41
|
return "vulture reported issues (no details available)"
|
|
42
42
|
file, issues = next(iter(tr.details.items()))
|
|
@@ -45,4 +45,4 @@ class VultureParser(AbstractParser):
|
|
|
45
45
|
kind = issue.get("type", "unused")
|
|
46
46
|
name = issue.get("name", "")
|
|
47
47
|
confidence = issue.get("confidence", "?")
|
|
48
|
-
return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line)}"
|
|
48
|
+
return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
|
py_cq/tool_registry.py
CHANGED
|
@@ -9,19 +9,19 @@ from py_cq.localtypes import ToolConfig
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def load_tool_configs() -> dict[str, ToolConfig]:
|
|
12
|
-
"""Load tool configurations from the bundled
|
|
12
|
+
"""Load tool configurations from the bundled config.yaml and return a registry.
|
|
13
13
|
|
|
14
14
|
Returns:
|
|
15
15
|
dict[str, ToolConfig]: A mapping from tool ID to its configuration instance."""
|
|
16
|
-
yaml_text = files("py_cq.config").joinpath("
|
|
16
|
+
yaml_text = files("py_cq.config").joinpath("config.yaml").read_text(encoding="utf-8")
|
|
17
17
|
config = yaml.safe_load(yaml_text)
|
|
18
18
|
registry = {}
|
|
19
|
-
for tool_id, tool_data in config["
|
|
19
|
+
for tool_id, tool_data in config["python"].items():
|
|
20
20
|
# Dynamically import parser class
|
|
21
21
|
module = import_module(f"py_cq.parsers.{tool_data['parser'].lower()}")
|
|
22
22
|
parser_class = getattr(module, tool_data["parser"])
|
|
23
23
|
registry[tool_id] = ToolConfig(
|
|
24
|
-
name=
|
|
24
|
+
name=tool_id,
|
|
25
25
|
command=tool_data["command"],
|
|
26
26
|
parser_class=parser_class,
|
|
27
27
|
order=tool_data["order"],
|
|
@@ -29,6 +29,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
29
29
|
error_threshold=tool_data["error_threshold"],
|
|
30
30
|
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
31
31
|
extra_deps=tool_data.get("extra_deps", []),
|
|
32
|
+
parser_config=tool_data.get("parser_config", {}),
|
|
32
33
|
)
|
|
33
34
|
return registry
|
|
34
35
|
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-code-quality
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
|
|
5
|
-
|
|
6
|
-
Project-URL: Repository, https://github.com/rhiza-fr/py-cq
|
|
5
|
+
Author: Chris Kilner
|
|
7
6
|
Author-email: Chris Kilner <chris@rhiza.fr>
|
|
8
7
|
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Python: >=3.12
|
|
11
8
|
Requires-Dist: bandit>=1.8.0
|
|
12
9
|
Requires-Dist: coverage>=7.8.2
|
|
13
10
|
Requires-Dist: diskcache>=5.6.3
|
|
14
11
|
Requires-Dist: interrogate>=1.7.0
|
|
12
|
+
Requires-Dist: pytest>=8.4.0
|
|
15
13
|
Requires-Dist: pytest-cov>=6.1.1
|
|
16
14
|
Requires-Dist: pytest-json-report>=1.5.0
|
|
17
|
-
Requires-Dist: pytest>=8.4.0
|
|
18
15
|
Requires-Dist: pyyaml>=6.0.2
|
|
19
16
|
Requires-Dist: radon>=6.0.1
|
|
20
17
|
Requires-Dist: rich>=14.0.0
|
|
@@ -22,26 +19,25 @@ Requires-Dist: ruff>=0.14.1
|
|
|
22
19
|
Requires-Dist: ty>=0.0.17
|
|
23
20
|
Requires-Dist: typer>=0.16.0
|
|
24
21
|
Requires-Dist: vulture>=2.14
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/rhiza-fr/py-cq
|
|
24
|
+
Project-URL: Repository, https://github.com/rhiza-fr/py-cq
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
27
|
# CQ - Python Code Quality Analysis Tool
|
|
28
28
|
|
|
29
29
|
Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
|
|
30
30
|
|
|
31
|
+
Why? It removes the mental burden of understanding all these tools and parsing their results.
|
|
32
|
+
|
|
31
33
|
The primary workflow is:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
36
|
# get the single most critical defect as markdown
|
|
35
37
|
cq check . -o llm
|
|
36
38
|
```
|
|
37
|
-
Selects the single most critical defect using this priority order:
|
|
38
|
-
|
|
39
|
-
1. **Severity** — tools with score below `error_threshold` come before those only below `warning_threshold`
|
|
40
|
-
2. **Order** — among tools at the same severity, lower-order tools win (compile before lint before style)
|
|
41
|
-
3. **Score** — among ties, the lower score wins
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
```md
|
|
40
|
+
```python
|
|
45
41
|
`data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
|
|
46
42
|
|
|
47
43
|
18: min_dist = float("inf")
|
|
@@ -60,9 +56,10 @@ Feed to an LLM with edit tools and repeat until there are no issues, e.g.
|
|
|
60
56
|
```python
|
|
61
57
|
cq check . -o llm | claude -p "fix this"
|
|
62
58
|
# or
|
|
63
|
-
cq check . -o llm | ollama gpt-oss:20b "Explain how to fix this"
|
|
59
|
+
cq check . -o llm | ollama run gpt-oss:20b "Explain how to fix this"
|
|
64
60
|
```
|
|
65
61
|
|
|
62
|
+
|
|
66
63
|
## Install
|
|
67
64
|
|
|
68
65
|
```bash
|
|
@@ -70,21 +67,22 @@ cq check . -o llm | ollama gpt-oss:20b "Explain how to fix this"
|
|
|
70
67
|
uv tool install python-code-quality
|
|
71
68
|
|
|
72
69
|
# or, clone it then install
|
|
73
|
-
git
|
|
70
|
+
git clone https://github.com/rhiza-fr/py-cq.git
|
|
74
71
|
cd py-cq
|
|
75
72
|
uv tool install .
|
|
76
73
|
```
|
|
77
74
|
|
|
78
75
|
## Tools
|
|
79
76
|
|
|
80
|
-
These tools are run in **parallel** except
|
|
77
|
+
These tools are run in **parallel** except:
|
|
78
|
+
When running '-o llm', we run sequentially and exit early at the first error.
|
|
81
79
|
|
|
82
80
|
| Order | Tool | Measures |
|
|
83
81
|
|----------|------|----------|
|
|
84
82
|
| 1 | compileall | Syntax errors |
|
|
85
|
-
| 2 |
|
|
86
|
-
| 3 |
|
|
87
|
-
| 4 |
|
|
83
|
+
| 2 | ruff | Lint / style |
|
|
84
|
+
| 3 | ty | Type errors |
|
|
85
|
+
| 4 | bandit | Security vulnerabilities |
|
|
88
86
|
| 5 | pytest | Test pass rate |
|
|
89
87
|
| 6 | coverage | Test coverage |
|
|
90
88
|
| 7 | radon cc | Cyclomatic complexity |
|
|
@@ -93,14 +91,14 @@ These tools are run in **parallel** except when looking for the first error in -
|
|
|
93
91
|
| 10 | vulture | Dead code |
|
|
94
92
|
| 11 | interrogate | Docstring coverage |
|
|
95
93
|
|
|
96
|
-
Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle
|
|
94
|
+
Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle risk.
|
|
97
95
|
|
|
98
96
|
|
|
99
97
|
## Usage
|
|
100
98
|
|
|
101
99
|
```bash
|
|
102
100
|
cq check . # Table overview of scores for humans
|
|
103
|
-
cq check -o llm
|
|
101
|
+
cq check . -o llm # Top defect as markdown for LLMs
|
|
104
102
|
cq check . -o score # Numeric score only for CI
|
|
105
103
|
cq check . -o json # Detailed parsed JSON output for jq
|
|
106
104
|
cq check . -o raw # Raw tool output for debug
|
|
@@ -110,6 +108,46 @@ cq check . --clear-cache # Clear cached results before running (rarely needed)
|
|
|
110
108
|
cq config path/to/project/ # Show effective tool configuration
|
|
111
109
|
```
|
|
112
110
|
|
|
111
|
+
**Exit codes:** `cq check` exits with code `1` if any tool metric falls below its `error_threshold`, making it suitable as a CI gate:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cq check . && deploy # block deploy on errors
|
|
115
|
+
cq check . -o score # print score, exit 1 on errors
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Claude Code Integration
|
|
119
|
+
|
|
120
|
+
Add a stop hook to your project's `.claude/settings.json` so Claude automatically checks quality after each session and loops until clean:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"hooks": {
|
|
125
|
+
"Stop": [{
|
|
126
|
+
"matcher": "",
|
|
127
|
+
"hooks": [{"type": "command", "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm"}]
|
|
128
|
+
}]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
When the score passes, Claude sees `CQ: all clear` (~5 tokens). When it fails, Claude receives the targeted fix prompt and continues working. This automates the `cq check . -o llm | claude -p "fix this"` loop.
|
|
134
|
+
|
|
135
|
+
> **Note:** Use project-level `.claude/settings.json`, not global settings — this hook only makes sense in Python projects.
|
|
136
|
+
|
|
137
|
+
### As a slash command (skill)
|
|
138
|
+
|
|
139
|
+
For manual invocation, create `.claude/commands/cq-fix.md`:
|
|
140
|
+
|
|
141
|
+
```markdown
|
|
142
|
+
$(cq check . -o llm)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq` output directly into the prompt before Claude starts, so it sees the issue immediately without an extra tool call.
|
|
146
|
+
|
|
147
|
+
**Hook vs skill:**
|
|
148
|
+
- **Stop hook** — automatic, runs after every session, best for unattended loops
|
|
149
|
+
- **Skill** — manual `/cq-fix`, gives you explicit control over when to check
|
|
150
|
+
|
|
113
151
|
## Table output
|
|
114
152
|
|
|
115
153
|
```bash
|
|
@@ -121,9 +159,9 @@ cq config path/to/project/ # Show effective tool configuration
|
|
|
121
159
|
┃ Tool ┃ Time ┃ Metric ┃ Score ┃ Status ┃
|
|
122
160
|
┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━┩
|
|
123
161
|
│ compile │ 0.42s │ compile │ 1.000 │ OK │
|
|
124
|
-
│ bandit │ 0.56s │ security │ 1.000 │ OK │
|
|
125
162
|
│ ruff │ 0.17s │ lint │ 1.000 │ OK │
|
|
126
163
|
│ ty │ 0.33s │ type_check │ 1.000 │ OK │
|
|
164
|
+
│ bandit │ 0.56s │ security │ 1.000 │ OK │
|
|
127
165
|
│ pytest │ 0.91s │ tests │ 1.000 │ OK │
|
|
128
166
|
│ coverage │ 1.26s │ coverage │ 0.910 │ OK │
|
|
129
167
|
│ radon cc │ 0.32s │ simplicity │ 0.982 │ OK │
|
|
@@ -167,7 +205,7 @@ cq config path/to/project/ # Show effective tool configuration
|
|
|
167
205
|
|
|
168
206
|
## Raw output
|
|
169
207
|
```bash
|
|
170
|
-
> cq check -o raw
|
|
208
|
+
> cq check . -o raw
|
|
171
209
|
```
|
|
172
210
|
|
|
173
211
|
```json
|
|
@@ -193,66 +231,63 @@ Add a `[tool.cq]` section to your project's `pyproject.toml`:
|
|
|
193
231
|
# Skip tools that are slow or not relevant to your project
|
|
194
232
|
disable = ["coverage", "interrogate"]
|
|
195
233
|
|
|
234
|
+
# Lines of source context shown around each defect in LLM output (default: 15)
|
|
235
|
+
context_lines = 15
|
|
236
|
+
|
|
196
237
|
# Override warning/error thresholds per tool
|
|
197
238
|
[tool.cq.thresholds.coverage]
|
|
198
239
|
warning = 0.9
|
|
199
240
|
error = 0.7
|
|
200
241
|
```
|
|
201
242
|
|
|
202
|
-
Tool IDs match the keys in `config/
|
|
243
|
+
Tool IDs match the keys in `config/config.yaml`: `compile`, `ruff`, `ty`, `bandit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`.
|
|
203
244
|
|
|
204
245
|
|
|
205
246
|
### Default config
|
|
206
247
|
|
|
207
248
|
```yaml
|
|
208
|
-
|
|
249
|
+
python:
|
|
209
250
|
|
|
210
|
-
|
|
211
|
-
name: "compile"
|
|
251
|
+
compile:
|
|
212
252
|
command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
|
|
213
253
|
parser: "CompileParser"
|
|
214
254
|
order: 1
|
|
215
255
|
warning_threshold: 0.9999
|
|
216
256
|
error_threshold: 0.9999
|
|
217
257
|
|
|
218
|
-
bandit:
|
|
219
|
-
name: "bandit"
|
|
220
|
-
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
221
|
-
parser: "BanditParser"
|
|
222
|
-
order: 2
|
|
223
|
-
warning_threshold: 0.9999
|
|
224
|
-
error_threshold: 0.8
|
|
225
|
-
|
|
226
258
|
ruff:
|
|
227
|
-
name: "ruff"
|
|
228
259
|
command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
|
|
229
260
|
parser: "RuffParser"
|
|
230
|
-
order:
|
|
261
|
+
order: 2
|
|
231
262
|
warning_threshold: 0.9999
|
|
232
263
|
error_threshold: 0.9
|
|
233
264
|
|
|
234
265
|
ty:
|
|
235
|
-
name: "ty"
|
|
236
266
|
command: "{python} -m ty check --output-format concise --color never {context_path}"
|
|
237
267
|
parser: "TyParser"
|
|
238
|
-
order:
|
|
268
|
+
order: 3
|
|
239
269
|
warning_threshold: 0.9999
|
|
240
270
|
error_threshold: 0.8
|
|
241
271
|
run_in_target_env: true
|
|
242
272
|
extra_deps:
|
|
243
273
|
- ty
|
|
244
274
|
|
|
275
|
+
bandit:
|
|
276
|
+
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
277
|
+
parser: "BanditParser"
|
|
278
|
+
order: 4
|
|
279
|
+
warning_threshold: 0.9999
|
|
280
|
+
error_threshold: 0.8
|
|
281
|
+
|
|
245
282
|
pytest:
|
|
246
|
-
name: "pytest"
|
|
247
283
|
command: "{python} -m pytest -v {context_path}"
|
|
248
284
|
parser: "PytestParser"
|
|
249
285
|
order: 5
|
|
250
|
-
warning_threshold: 0
|
|
251
|
-
error_threshold: 0
|
|
286
|
+
warning_threshold: 1.0
|
|
287
|
+
error_threshold: 1.0
|
|
252
288
|
run_in_target_env: true
|
|
253
289
|
|
|
254
290
|
coverage:
|
|
255
|
-
name: "coverage"
|
|
256
291
|
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
257
292
|
parser: "CoverageParser"
|
|
258
293
|
order: 6
|
|
@@ -263,24 +298,21 @@ tools:
|
|
|
263
298
|
- coverage
|
|
264
299
|
- pytest
|
|
265
300
|
|
|
266
|
-
|
|
267
|
-
name: "radon cc"
|
|
301
|
+
radon-cc:
|
|
268
302
|
command: "{python} -m radon cc --json {context_path}"
|
|
269
303
|
parser: "ComplexityParser"
|
|
270
304
|
order: 7
|
|
271
305
|
warning_threshold: 0.6
|
|
272
306
|
error_threshold: 0.4
|
|
273
307
|
|
|
274
|
-
|
|
275
|
-
name: "radon mi"
|
|
308
|
+
radon-mi:
|
|
276
309
|
command: "{python} -m radon mi -s --json {context_path}"
|
|
277
310
|
parser: "MaintainabilityParser"
|
|
278
311
|
order: 8
|
|
279
312
|
warning_threshold: 0.6
|
|
280
313
|
error_threshold: 0.4
|
|
281
314
|
|
|
282
|
-
|
|
283
|
-
name: "radon hal"
|
|
315
|
+
radon-hal:
|
|
284
316
|
command: "{python} -m radon hal -f --json {context_path}"
|
|
285
317
|
parser: "HalsteadParser"
|
|
286
318
|
order: 9
|
|
@@ -288,7 +320,6 @@ tools:
|
|
|
288
320
|
error_threshold: 0.3
|
|
289
321
|
|
|
290
322
|
vulture:
|
|
291
|
-
name: "vulture"
|
|
292
323
|
command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
293
324
|
parser: "VultureParser"
|
|
294
325
|
order: 10
|
|
@@ -296,7 +327,6 @@ tools:
|
|
|
296
327
|
error_threshold: 0.8
|
|
297
328
|
|
|
298
329
|
interrogate:
|
|
299
|
-
name: "interrogate"
|
|
300
330
|
command: "{python} -m interrogate {context_path} -v --fail-under 0"
|
|
301
331
|
parser: "InterrogateParser"
|
|
302
332
|
order: 11
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
|
|
2
|
+
py_cq/cli.py,sha256=wu1GlxSDxS835i9-mO4-xmyBLfr6puU-ES-26T7Mty0,11007
|
|
3
|
+
py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
|
|
4
|
+
py_cq/config/config.yaml,sha256=R8CjZH8erBQSbJFKgHt-CCwD1Mgj6ZCGQ3OtWH0Ebig,2402
|
|
5
|
+
py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
|
|
6
|
+
py_cq/execution_engine.py,sha256=tgNGFOO3h-EyetCEzC_RS2K-b9OkOFpOwGwrEAHIpZA,7477
|
|
7
|
+
py_cq/language_detector.py,sha256=6av5HaimcZ54RkN69xQmGgC0mxtTvGzPV3SL8NGG8Uc,1116
|
|
8
|
+
py_cq/llm_formatter.py,sha256=l74O5iqNWMR16789Xncoi93xOqgQJhW5IQK9_OyA9mE,1632
|
|
9
|
+
py_cq/localtypes.py,sha256=jCX1ZuwTixQooEk2N1Gi1XRuDcdBc2NlOdQBKysEybk,6128
|
|
10
|
+
py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
|
|
11
|
+
py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
|
|
12
|
+
py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
py_cq/parsers/banditparser.py,sha256=Ju_CkuXtkVn1Th9aQ6mv6fTUTpb9pc1YD8Nzt_nMgFQ,2326
|
|
14
|
+
py_cq/parsers/common.py,sha256=thbziNjqaf-SY3Y0W-KmPwzlcC-YCV98Z8jubUJM7_4,5203
|
|
15
|
+
py_cq/parsers/compileparser.py,sha256=YsT7ePUDRjsUHTLqgRv4x6jJBCTt6cbm1mgxplzxETg,6256
|
|
16
|
+
py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
|
|
17
|
+
py_cq/parsers/coverageparser.py,sha256=n4h_RCxKvJoYgp356PA4laAmFcngjQ41b_GXZvKkVgc,4041
|
|
18
|
+
py_cq/parsers/exitcodeparser.py,sha256=ZFL3EbPhGvFjm2qZhfLD1_5dNjR9ULYNQpKgy0Z_dGo,729
|
|
19
|
+
py_cq/parsers/halsteadparser.py,sha256=ZCN7LP1iUZ91tf3tlspKAPQrTEa78XVdE9Fjn9yPvYk,9008
|
|
20
|
+
py_cq/parsers/interrogateparser.py,sha256=G07beMaGR0Mq-pGVozAbwK0xOx9MdMaNVGq2CGrUb7w,2225
|
|
21
|
+
py_cq/parsers/linecountparser.py,sha256=nxWvFntuR8T6FDGpafrlevvt-jR3rwkbVo_t2JX4TF0,1067
|
|
22
|
+
py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
|
|
23
|
+
py_cq/parsers/pytestparser.py,sha256=3N1X4wKhJ2h-2U1GuM3gHWHpHxU9f0LPGcPaojra9W4,6377
|
|
24
|
+
py_cq/parsers/regexcountparser.py,sha256=KSoNh2spucXU06pxxr2QW0LrPLfJkFMAsmSgjooaFv0,1316
|
|
25
|
+
py_cq/parsers/ruffparser.py,sha256=ZdIya4sct2PrsOyfKfMGmXHLh3Qu7HtFqXNY9IuNFog,2275
|
|
26
|
+
py_cq/parsers/typarser.py,sha256=zrI0KS65MUGPxYPP74B6BRyTbjcPhKyQPjH2KZIyxN0,2495
|
|
27
|
+
py_cq/parsers/vultureparser.py,sha256=U6zC7P0ATA_N4SB90BahKF5QHMITf_z-NsOgrh_Q5rA,1995
|
|
28
|
+
py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
py_cq/tool_registry.py,sha256=oMEkFHkU3gg5UpeGD4zHtynOYmWieRgDN5kTwZ5KsE8,1584
|
|
30
|
+
python_code_quality-0.1.10.dist-info/WHEEL,sha256=Y4JtJkdCWKLnDS7bvHXqjUWSsYTnJMN9TTubfHSxAyo,80
|
|
31
|
+
python_code_quality-0.1.10.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
|
|
32
|
+
python_code_quality-0.1.10.dist-info/METADATA,sha256=YSf-Gi84I-D8SWRe4tA5oS9AuH2nKLu6W88pVBUjO3Q,11358
|
|
33
|
+
python_code_quality-0.1.10.dist-info/RECORD,,
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
|
|
2
|
-
py_cq/cli.py,sha256=9rNdLdU2mcZVz3qdkczO4Rs_PU0-brZMmj5wJfv-Jcs,8688
|
|
3
|
-
py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
|
|
4
|
-
py_cq/execution_engine.py,sha256=Q7Z8iibkE_E9VfkbUdnI_g5wA8GdwDbnJI4Mex-V8mE,7416
|
|
5
|
-
py_cq/llm_formatter.py,sha256=EdUMhvsnPLplSSUKDknMHiaLdKsd9B6aH-tTdpxukdY,1574
|
|
6
|
-
py_cq/localtypes.py,sha256=_PAx-F0cj03r_3YR1cyR9ilYYmYxUC14TkRtgLjH-Rc,5927
|
|
7
|
-
py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
|
|
8
|
-
py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
|
|
9
|
-
py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
py_cq/tool_registry.py,sha256=Ov5kQIRc5C5vkAq5Nc2Otp5kYiQzJuK5_nA-ZkY_-NQ,1529
|
|
11
|
-
py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
|
|
12
|
-
py_cq/config/tools.yaml,sha256=cNs4h4sIJA-j28QhEFG0uWL9ELaPy_z8BNo4l5F3is4,2553
|
|
13
|
-
py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
py_cq/parsers/banditparser.py,sha256=vj23tTbipaeVkhS_ldWI7GrpHGwlIkOuaEgszkrjzh0,2277
|
|
15
|
-
py_cq/parsers/common.py,sha256=lc9Chtr3H5l3vTk-vRVhptQVJfOeLax0UWTRjhA9IOU,4044
|
|
16
|
-
py_cq/parsers/compileparser.py,sha256=mVY8qh1oZQ8n9GJLW2ruF9j89G5GuxWb7fb6JeogTJ4,6207
|
|
17
|
-
py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
|
|
18
|
-
py_cq/parsers/coverageparser.py,sha256=xDNlLNEsA0U3z4GV02iEq97IL90-UJAQrhMlFzpIdy0,4013
|
|
19
|
-
py_cq/parsers/halsteadparser.py,sha256=9z_abpPPuclUQBgq4P6u2vIfIB7ZShX2NbFnubTatqI,8980
|
|
20
|
-
py_cq/parsers/interrogateparser.py,sha256=eMROINtyZE2eHrRxVU0jA-nYTdvr0PZ8iERVn7kPH5o,2197
|
|
21
|
-
py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
|
|
22
|
-
py_cq/parsers/pytestparser.py,sha256=ERgS1aTTi7aB-Xk_Y9Xpo9h5jT9n0q3_vAfHCZwBTFE,4539
|
|
23
|
-
py_cq/parsers/ruffparser.py,sha256=Wgch9rDkR-6tJlfhDfG1CXXEZu0Gfk0qRpsI3KzjCZU,2226
|
|
24
|
-
py_cq/parsers/typarser.py,sha256=u7ktoH0TzmhB2saCJF1iY1LkfGui5ami2leLlYSCcns,2446
|
|
25
|
-
py_cq/parsers/vultureparser.py,sha256=X2fen6yQu-r_zoRt1qKfIGlGZKORkHCiZE4BFlxtzdE,1946
|
|
26
|
-
python_code_quality-0.1.8.dist-info/METADATA,sha256=2-E2SuWhsQMwYtv4NERKmE9CajsglC81tZY5DPPefrc,10266
|
|
27
|
-
python_code_quality-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
28
|
-
python_code_quality-0.1.8.dist-info/entry_points.txt,sha256=j5Q_gGr0b7389lddt1JlZ7gcL4Z7RHxuDhij_G-IhBY,39
|
|
29
|
-
python_code_quality-0.1.8.dist-info/licenses/LICENSE,sha256=Bpuh8tbf37so8M5NtRGTLmT5ue7diJ17223L9f9nsT0,1086
|
|
30
|
-
python_code_quality-0.1.8.dist-info/RECORD,,
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Chris Kilner <chris@rhiza.fr>
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|