python-code-quality 0.1.6__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -23,12 +23,11 @@ from rich.console import Console
23
23
  from rich.logging import RichHandler
24
24
  from rich.table import Table
25
25
 
26
- from py_cq.config import DEFAULT_STORAGE_FILE, load_user_config
26
+ from py_cq.config import load_user_config
27
27
  from py_cq.execution_engine import _cache as tool_cache
28
28
  from py_cq.execution_engine import run_tools
29
29
  from py_cq.localtypes import CombinedToolResults, ToolConfig
30
30
  from py_cq.metric_aggregator import aggregate_metrics
31
- from py_cq.storage import save_result
32
31
  from py_cq.tool_registry import tool_registry
33
32
 
34
33
  logging.basicConfig(
@@ -44,6 +43,8 @@ app = typer.Typer(
44
43
  " cq check . # full table with all metrics (default)\n\n"
45
44
  " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
46
45
  " cq check . -o score # numeric score only\n\n"
46
+ " cq check . -o json # parsed metrics as json\n\n"
47
+ " cq check . -o raw # unprocessed tool output as json\n\n"
47
48
  " cq config . # show effective tool configuration"
48
49
  ),
49
50
  )
@@ -74,11 +75,12 @@ class OutputMode(str, Enum):
74
75
  SCORE = "score"
75
76
  JSON = "json"
76
77
  LLM = "llm"
78
+ RAW = "raw"
77
79
 
78
80
 
79
81
  @app.callback()
80
82
  def callback():
81
- """CQ - Code Quality Analysis Tool."""
83
+ """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
82
84
  console = Console()
83
85
 
84
86
 
@@ -93,11 +95,6 @@ def check(
93
95
  "--log-level",
94
96
  help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
95
97
  ),
96
- out_file: str = typer.Option(
97
- DEFAULT_STORAGE_FILE,
98
- "--out-file",
99
- help="File path to save results in table mode",
100
- ),
101
98
  clear_cache: bool = typer.Option(
102
99
  False, "--clear-cache", help="Clear cached tool results before running"
103
100
  ),
@@ -105,7 +102,7 @@ def check(
105
102
  0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
106
103
  ),
107
104
  ):
108
- """Run static analysis on a Python file or project directory."""
105
+ """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
109
106
  path_obj = Path(path)
110
107
  if not path_obj.exists():
111
108
  raise typer.BadParameter(f"Path does not exist: {path}")
@@ -119,20 +116,21 @@ def check(
119
116
  effective_registry = _apply_user_config(tool_registry, load_user_config(path_obj))
120
117
  if clear_cache:
121
118
  tool_cache.clear()
122
- tool_results = run_tools(effective_registry.values(), path, workers)
123
- for tr in tool_results:
124
- log.debug(json.dumps(tr.to_dict(), indent=2))
119
+ tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
120
+ # for tr in tool_results:
121
+ # log.debug(json.dumps(tr.to_dict(), indent=2))
125
122
  combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
126
123
  if output == OutputMode.SCORE:
127
124
  console.print(combined_metrics.score)
128
125
  elif output == OutputMode.JSON:
129
- console.print(json.dumps(combined_metrics.to_dict(), indent=2))
126
+ console.print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
127
+ elif output == OutputMode.RAW:
128
+ console.print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
130
129
  elif output == OutputMode.LLM:
131
- log.setLevel("CRITICAL")
130
+ # log.setLevel("CRITICAL")
132
131
  from py_cq.llm_formatter import format_for_llm
133
132
  console.print(format_for_llm(effective_registry, combined_metrics))
134
133
  else:
135
- save_result(combined_tool_results=combined_metrics, file_name=out_file)
136
134
  console.print(format_as_table(combined_metrics, effective_registry))
137
135
 
138
136
 
@@ -169,18 +167,18 @@ def config(
169
167
 
170
168
  table = Table()
171
169
  table.add_column("Tool", style="cyan")
172
- table.add_column("Priority", justify="right")
170
+ table.add_column("Order", justify="right")
173
171
  table.add_column("Warning", justify="right")
174
172
  table.add_column("Error", justify="right")
175
173
  table.add_column("Status", justify="center")
176
174
 
177
- for tool_id in sorted(tool_registry, key=lambda t: tool_registry[t].priority):
175
+ for tool_id in sorted(tool_registry, key=lambda t: tool_registry[t].order):
178
176
  tc = effective_registry.get(tool_id, tool_registry[tool_id])
179
177
  is_disabled = tool_id in disabled_ids
180
178
  status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
181
179
  table.add_row(
182
180
  tc.name,
183
- str(tc.priority),
181
+ str(tc.order),
184
182
  f"{tc.warning_threshold:.2f}",
185
183
  f"{tc.error_threshold:.2f}",
186
184
  status,
py_cq/config/__init__.py CHANGED
@@ -1,10 +1,8 @@
1
- """Default storage path and user config loader."""
1
+ """User config loader."""
2
2
 
3
3
  import tomllib
4
4
  from pathlib import Path
5
5
 
6
- DEFAULT_STORAGE_FILE = ".cq.json"
7
-
8
6
 
9
7
  def load_user_config(project_path: Path) -> dict:
10
8
  """Read [tool.cq] from pyproject.toml at project_path, if present.
py_cq/config/tools.yaml CHANGED
@@ -4,15 +4,15 @@ tools:
4
4
  name: "compile"
5
5
  command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
6
6
  parser: "CompileParser"
7
- priority: 1
7
+ order: 1
8
8
  warning_threshold: 0.9999
9
9
  error_threshold: 0.9999
10
10
 
11
11
  bandit:
12
12
  name: "bandit"
13
- command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {context_dir}/.venv,{context_dir}/tests"
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
14
  parser: "BanditParser"
15
- priority: 2
15
+ order: 2
16
16
  warning_threshold: 0.9999
17
17
  error_threshold: 0.8
18
18
 
@@ -20,7 +20,7 @@ tools:
20
20
  name: "ruff"
21
21
  command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
22
22
  parser: "RuffParser"
23
- priority: 3
23
+ order: 3
24
24
  warning_threshold: 0.9999
25
25
  error_threshold: 0.9
26
26
 
@@ -28,7 +28,7 @@ tools:
28
28
  name: "ty"
29
29
  command: "{python} -m ty check --output-format concise --color never {context_path}"
30
30
  parser: "TyParser"
31
- priority: 4
31
+ order: 4
32
32
  warning_threshold: 0.9999
33
33
  error_threshold: 0.8
34
34
  run_in_target_env: true
@@ -39,16 +39,16 @@ tools:
39
39
  name: "pytest"
40
40
  command: "{python} -m pytest -v {context_path}"
41
41
  parser: "PytestParser"
42
- priority: 5
42
+ order: 5
43
43
  warning_threshold: 0.7
44
44
  error_threshold: 0.5
45
45
  run_in_target_env: true
46
46
 
47
47
  coverage:
48
48
  name: "coverage"
49
- command: "{python} -m coverage run -m pytest {context_path} && {python} -m coverage report"
49
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
50
50
  parser: "CoverageParser"
51
- priority: 6
51
+ order: 6
52
52
  warning_threshold: 0.9
53
53
  error_threshold: 0.5
54
54
  run_in_target_env: true
@@ -60,7 +60,7 @@ tools:
60
60
  name: "radon cc"
61
61
  command: "{python} -m radon cc --json {context_path}"
62
62
  parser: "ComplexityParser"
63
- priority: 7
63
+ order: 7
64
64
  warning_threshold: 0.6
65
65
  error_threshold: 0.4
66
66
 
@@ -68,7 +68,7 @@ tools:
68
68
  name: "radon mi"
69
69
  command: "{python} -m radon mi -s --json {context_path}"
70
70
  parser: "MaintainabilityParser"
71
- priority: 8
71
+ order: 8
72
72
  warning_threshold: 0.6
73
73
  error_threshold: 0.4
74
74
 
@@ -76,7 +76,7 @@ tools:
76
76
  name: "radon hal"
77
77
  command: "{python} -m radon hal -f --json {context_path}"
78
78
  parser: "HalsteadParser"
79
- priority: 9
79
+ order: 9
80
80
  warning_threshold: 0.5
81
81
  error_threshold: 0.3
82
82
 
@@ -84,7 +84,7 @@ tools:
84
84
  name: "vulture"
85
85
  command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
86
86
  parser: "VultureParser"
87
- priority: 10
87
+ order: 10
88
88
  warning_threshold: 0.9999
89
89
  error_threshold: 0.8
90
90
 
@@ -92,6 +92,6 @@ tools:
92
92
  name: "interrogate"
93
93
  command: "{python} -m interrogate {context_path} -v --fail-under 0"
94
94
  parser: "InterrogateParser"
95
- priority: 11
95
+ order: 11
96
96
  warning_threshold: 0.8
97
97
  error_threshold: 0.3
py_cq/execution_engine.py CHANGED
@@ -20,16 +20,16 @@ import time
20
20
  from collections.abc import Collection
21
21
  from concurrent.futures import ThreadPoolExecutor, as_completed
22
22
  from pathlib import Path
23
- from typing import cast
23
+ from typing import Any, cast
24
24
 
25
- import diskcache
25
+ from diskcache import Cache, JSONDisk
26
26
 
27
27
  from py_cq.context_hash import get_context_hash
28
28
  from py_cq.localtypes import RawResult, ToolConfig, ToolResult
29
29
 
30
30
  log = logging.getLogger("cq")
31
31
 
32
- _cache = diskcache.Cache(Path.home() / ".cache" / "cq")
32
+ _cache = Cache(Path.home() / ".cache" / "cq", size_limit=100 * 1024 * 1024, disk=JSONDisk)
33
33
 
34
34
 
35
35
  def _find_project_root(path: Path) -> Path | None:
@@ -75,12 +75,12 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
75
75
  with_flags = " ".join(f"--with {dep}" for dep in tool_config.extra_deps)
76
76
  python = f'"{uv}" run --directory "{abs_dir}" {with_flags}'.rstrip()
77
77
  abs_context_path = str(Path(context_path).resolve())
78
- context_dir = Path(context_path).as_posix().rstrip("/")
79
- command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, context_dir=context_dir, python=python)
78
+ input_path_posix = Path(context_path).as_posix().rstrip("/")
79
+ command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python)
80
80
  cache_key = f"{command}:{get_context_hash(context_path)}"
81
81
  if cache_key in _cache:
82
82
  log.info(f"Cache hit: {command}")
83
- return cast(RawResult, _cache[cache_key])
83
+ return RawResult(**cast(dict[str, Any], _cache[cache_key]))
84
84
  log.info(f"Running: {command}")
85
85
  result = subprocess.run(command, capture_output=True, text=True, shell=True) # nosec
86
86
  timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
@@ -92,11 +92,11 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
92
92
  return_code=result.returncode,
93
93
  timestamp=timestamp,
94
94
  )
95
- _cache[cache_key] = raw_result
95
+ _cache.set(cache_key, raw_result.to_dict(), expire=5 * 24 * 60 * 60)
96
96
  return raw_result
97
97
 
98
98
 
99
- def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0) -> list[ToolResult]:
99
+ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False) -> list[ToolResult]:
100
100
  """Run multiple tools and return their parsed results.
101
101
 
102
102
  Runs each tool specified in *tool_configs* on the file or directory at
@@ -141,12 +141,24 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
141
141
  raw_result = run_tool(tool_config, path)
142
142
  tr = tool_config.parser_class().parse(raw_result)
143
143
  tr.duration_s = time.perf_counter() - t0
144
- return tool_config.priority, tr
144
+ return tool_config.order, tr
145
145
 
146
146
  if not tool_configs:
147
147
  return []
148
148
  t_start = time.perf_counter()
149
149
  prioritized: list[tuple[int, ToolResult]] = []
150
+ if early_exit:
151
+ for tool_config in sorted(tool_configs, key=lambda tc: tc.order):
152
+ try:
153
+ prioritized.append(_run_and_parse(tool_config))
154
+ except Exception as exc:
155
+ log.error(f"{tool_config.name} generated an exception: {exc}")
156
+ break
157
+ _, tr = prioritized[-1]
158
+ if tr.metrics and min(tr.metrics.values()) < tool_config.error_threshold:
159
+ break
160
+ log.info(f"run_tools elapsed: {time.perf_counter() - t_start:.2f}s")
161
+ return [tr for _, tr in sorted(prioritized)]
150
162
  with ThreadPoolExecutor(max_workers=max_workers or len(tool_configs)) as executor:
151
163
  future_to_tool = {
152
164
  executor.submit(_run_and_parse, tool_config): tool_config
py_cq/llm_formatter.py CHANGED
@@ -29,7 +29,7 @@ def format_for_llm(
29
29
  ],
30
30
  key=lambda tr: (
31
31
  _severity(min(tr.metrics.values()), by_name[tr.raw.tool_name]),
32
- by_name[tr.raw.tool_name].priority,
32
+ by_name[tr.raw.tool_name].order,
33
33
  min(tr.metrics.values()),
34
34
  ),
35
35
  )
py_cq/localtypes.py CHANGED
@@ -10,13 +10,13 @@ from typing import Any
10
10
 
11
11
  @dataclass
12
12
  class ToolConfig:
13
- """Represents the configuration for an analysis tool, including its name, command, parser class, context path, priority, and thresholds for warnings and errors."""
13
+ """Represents the configuration for an analysis tool, including its name, command, parser class, context path, order, and thresholds for warnings and errors."""
14
14
 
15
15
  name: str # e.g., "pytest", "coverage", "pydocstyle"
16
16
  command: str # The command to execute (can include placeholders)
17
17
  parser_class: Callable # Name of the parser class to use
18
18
  context_path: str = "" # Path to project or file
19
- priority: int = 5 # 1=critical (compilation), 5=low (style)
19
+ order: int = 5 # 1=first (compilation), 11=last (style)
20
20
  warning_threshold: float = 0.7 # Yellow warning if below this
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
@@ -73,11 +73,11 @@ class ToolResult:
73
73
  self.metrics = {}
74
74
 
75
75
  def to_dict(self) -> dict:
76
- """Returns a dictionary containing the metrics, details, and the raw data serialized via its own `to_dict` method."""
76
+ """Returns a dictionary containing the tool name, metrics, details, and duration."""
77
77
  return {
78
+ "tool_name": self.raw.tool_name,
78
79
  "metrics": self.metrics,
79
80
  "details": self.details,
80
- "raw": self.raw.to_dict(),
81
81
  "duration_s": self.duration_s,
82
82
  }
83
83
 
@@ -9,7 +9,7 @@ logistic-variant score stored under the ``security`` metric key.
9
9
  import json
10
10
 
11
11
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
12
- from py_cq.parsers.common import score_logistic_variant
12
+ from py_cq.parsers.common import format_source_context, score_logistic_variant
13
13
 
14
14
  _SEVERITY_WEIGHT = {"HIGH": 5, "MEDIUM": 2, "LOW": 1}
15
15
 
@@ -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}"
54
+ return f"`{file}:{line}` — **{code}** [{severity}]: {message}{format_source_context(file, line)}"
py_cq/parsers/common.py CHANGED
@@ -25,6 +25,18 @@ def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
25
25
  return ""
26
26
 
27
27
 
28
+ def format_source_context(file: str, line: int | str, context: int = 3, count: int = 8) -> str:
29
+ """Return a fenced python code block for the source around `line`, or '' if unavailable."""
30
+ if not isinstance(line, int):
31
+ return ""
32
+ context_start = max(1, line - context)
33
+ raw_lines = read_source_lines(file, context_start, count=count).splitlines()
34
+ if not raw_lines:
35
+ return ""
36
+ src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines))
37
+ return f"\n```python\n{src}\n```"
38
+
39
+
28
40
  def inv_normalize(value: float, max_value: float) -> float:
29
41
  """Returns the inverse normalized value of `value` relative to `max_value`."""
30
42
  return (max_value - min(value, max_value)) / max_value
@@ -6,7 +6,7 @@ compile score, and providing concise help messages for any failures."""
6
6
  import logging
7
7
 
8
8
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
9
- from py_cq.parsers.common import read_source_lines, score_logistic_variant
9
+ from py_cq.parsers.common import format_source_context, score_logistic_variant
10
10
 
11
11
  log = logging.getLogger("cq")
12
12
 
@@ -124,11 +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
- if isinstance(line, int):
128
- context_start = max(1, line - 3)
129
- raw_lines = read_source_lines(file, context_start, count=8).splitlines()
130
- src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines)) if raw_lines else info.get("src", "")
131
- else:
132
- src = info.get("src", "")
133
- code_block = f"\n```python\n{src}\n```" if src else ""
127
+ code_block = format_source_context(file, line) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
134
128
  return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
@@ -62,7 +62,7 @@ class HalsteadParser(AbstractParser):
62
62
  MAX_FILE_BUGS = 1
63
63
  MAX_FILE_VOLUME = 2000
64
64
  MAX_FUNCTION_BUGS = 0.2
65
- MAX_FUNCTION_VOLUME = 300
65
+ MAX_FUNCTION_VOLUME = 600
66
66
  min_file_nb = 1.0
67
67
  min_file_sm = 1.0
68
68
  min_function_nb = 1.0
@@ -67,6 +67,14 @@ class PytestParser(AbstractParser):
67
67
  num_tests += 1
68
68
  if test_status == "PASSED":
69
69
  passed_tests += 1
70
+ if num_tests == 0:
71
+ # No individual test lines found (e.g. non-verbose output);
72
+ # fall back to parsing the pytest summary line.
73
+ summary = re.search(r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout)
74
+ if summary:
75
+ passed_tests = int(summary.group(1))
76
+ failed_tests = int(summary.group(2)) if summary.group(2) else 0
77
+ num_tests = passed_tests + failed_tests
70
78
  tr.metrics["tests"] = passed_tests / num_tests if num_tests else 0
71
79
  tr.details = tests_found
72
80
  return tr
@@ -13,7 +13,7 @@ followed by a summary line ``Found N error.`` or ``All checks passed!``."""
13
13
  import re
14
14
 
15
15
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
16
- from py_cq.parsers.common import read_source_lines, score_logistic_variant
16
+ from py_cq.parsers.common import format_source_context, score_logistic_variant
17
17
 
18
18
  _DIAG_RE = re.compile(r"^(.+):(\d+):(\d+): ([A-Z]\d+) (.+)$")
19
19
 
@@ -54,8 +54,4 @@ class RuffParser(AbstractParser):
54
54
  line = issue.get("line", "?")
55
55
  code = issue.get("code", "")
56
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}"
57
+ return f"`{file}:{line}` **{code}**: {message}{format_source_context(file, line)}"
py_cq/parsers/typarser.py CHANGED
@@ -14,7 +14,7 @@ Errors count more heavily than warnings toward the score."""
14
14
  import re
15
15
 
16
16
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
17
- from py_cq.parsers.common import read_source_lines, score_logistic_variant
17
+ from py_cq.parsers.common import format_source_context, score_logistic_variant
18
18
 
19
19
  _DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
20
20
 
@@ -58,8 +58,4 @@ class TyParser(AbstractParser):
58
58
  line = issue.get("line", "?")
59
59
  code = issue.get("code", "")
60
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}"
61
+ return f"`{file}:{line}` **{code}**: {message}{format_source_context(file, line)}"
@@ -12,7 +12,7 @@ score stored under the ``dead_code`` metric key.
12
12
  import re
13
13
 
14
14
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
15
- from py_cq.parsers.common import score_logistic_variant
15
+ from py_cq.parsers.common import format_source_context, score_logistic_variant
16
16
 
17
17
  _LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
18
18
 
@@ -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)"
48
+ return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line)}"
py_cq/tool_registry.py CHANGED
@@ -24,7 +24,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
24
24
  name=tool_data["name"],
25
25
  command=tool_data["command"],
26
26
  parser_class=parser_class,
27
- priority=tool_data["priority"],
27
+ order=tool_data["order"],
28
28
  warning_threshold=tool_data["warning_threshold"],
29
29
  error_threshold=tool_data["error_threshold"],
30
30
  run_in_target_env=tool_data.get("run_in_target_env", False),
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.6
4
- Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ CQ straight into an LLM.
3
+ Version: 0.1.8
4
+ Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
5
5
  Project-URL: Homepage, https://github.com/rhiza-fr/py-cq
6
6
  Project-URL: Repository, https://github.com/rhiza-fr/py-cq
7
7
  Author-email: Chris Kilner <chris@rhiza.fr>
@@ -26,20 +26,50 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # CQ - Python Code Quality Analysis Tool
28
28
 
29
- Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. The primary workflow is:
29
+ Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
30
+
31
+ The primary workflow is:
30
32
 
31
33
  ```bash
32
- cq check -o llm # get the single most critical defect as markdown
34
+ # get the single most critical defect as markdown
35
+ cq check . -o llm
33
36
  ```
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
34
42
 
35
- Feed that output to an LLM, apply the fix, repeat until the score is clean.
43
+ The code context is expanded if available.
44
+ ```md
45
+ `data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
46
+
47
+ 18: min_dist = float("inf")
48
+ 19: nearest_city = None
49
+ 20: for city in cities:
50
+ 21: unused_variable = 67
51
+ 22: dist = calc_dist(current_city, city)
52
+ 23: if dist < min_dist:
53
+ 24: min_dist = dist
54
+ 25: nearest_city = city
55
+
56
+ Please fix only this issue. After fixing, run `cq check . -o llm` to verify.
57
+ ```
58
+ Feed to an LLM with edit tools and repeat until there are no issues, e.g.
59
+
60
+ ```python
61
+ cq check . -o llm | claude -p "fix this"
62
+ # or
63
+ cq check . -o llm | ollama gpt-oss:20b "Explain how to fix this"
64
+ ```
36
65
 
37
66
  ## Install
38
67
 
39
68
  ```bash
69
+ # install the `cq` command line tool from PyPi
40
70
  uv tool install python-code-quality
41
71
 
42
- # or
72
+ # or, clone it then install
43
73
  git pull https://github.com/rhiza-fr/py-cq.git
44
74
  cd py-cq
45
75
  uv tool install .
@@ -47,9 +77,9 @@ uv tool install .
47
77
 
48
78
  ## Tools
49
79
 
50
- CQ runs these tools in *parallel*:
80
+ These tools are run in **parallel** except when looking for the first error in -o llm mode:
51
81
 
52
- | Priority | Tool | Measures |
82
+ | Order | Tool | Measures |
53
83
  |----------|------|----------|
54
84
  | 1 | compileall | Syntax errors |
55
85
  | 2 | bandit | Security vulnerabilities |
@@ -63,40 +93,24 @@ CQ runs these tools in *parallel*:
63
93
  | 10 | vulture | Dead code |
64
94
  | 11 | interrogate | Docstring coverage |
65
95
 
66
- ## Usage
67
-
68
- ```bash
69
- # LLM workflow: get the top defect as markdown (primary use case)
70
- cq check -o llm
71
-
72
- # Rich table with all metrics (default, also saves .cq.json)
73
- cq check
96
+ Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle
74
97
 
75
- # Numeric score only — useful in CI or scripts
76
- cq check -o score
77
98
 
78
- # Full JSON output
79
- cq check -o json
80
-
81
- # Explicit path (defaults to current directory)
82
- cq check path/to/project/
83
- cq check path/to/file.py
84
-
85
- # Run sequentially (1 worker) instead of in parallel
86
- cq check --workers 1
87
-
88
- # Clear cached results before running
89
- cq check --clear-cache
90
-
91
- # Save table output to a custom file
92
- cq check --out-file custom_results.json
99
+ ## Usage
93
100
 
94
- # Show effective tool configuration (thresholds, enabled/disabled status)
95
- cq config
96
- cq config path/to/project/
101
+ ```bash
102
+ cq check . # Table overview of scores for humans
103
+ cq check -o llm # Top defect as markdown for LLMs
104
+ cq check . -o score # Numeric score only for CI
105
+ cq check . -o json # Detailed parsed JSON output for jq
106
+ cq check . -o raw # Raw tool output for debug
107
+ cq check path/to/file.py # Just one file (skips pytest and coverage)
108
+ cq check . --workers 1 # Run sequentially if you like things slow
109
+ cq check . --clear-cache # Clear cached results before running (rarely needed)
110
+ cq config path/to/project/ # Show effective tool configuration
97
111
  ```
98
112
 
99
- ## Output
113
+ ## Table output
100
114
 
101
115
  ```bash
102
116
  > cq check .
@@ -123,6 +137,8 @@ cq config path/to/project/
123
137
  │ │ │ Score │ 0.965 │ │
124
138
  └──────────────────┴──────────┴───────────────────────────┴─────────┴──────────┘
125
139
  ```
140
+
141
+ ## Single score output
126
142
  ```bash
127
143
  > cq check . -o score
128
144
  ```
@@ -130,23 +146,42 @@ cq config path/to/project/
130
146
  0.9662730667181059 # this is designed to approach but not reach 1.0
131
147
  ```
132
148
 
149
+ ## Json output
133
150
  ```bash
134
- > cq check . -o llm
151
+ > cq check . -o json
135
152
  ```
136
153
 
137
- ```md
138
- `data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
154
+ ```json
155
+ [
156
+ {
157
+ "tool_name": "compile",
158
+ "metrics": {
159
+ "compile": 1.0
160
+ },
161
+ "details": {},
162
+ "duration_s": 0.05611889995634556
163
+ }
164
+ ...
165
+ ]
166
+ ```
139
167
 
140
- 18: min_dist = float("inf")
141
- 19: nearest_city = None
142
- 20: for city in cities:
143
- 21: unused_variable = 67
144
- 22: dist = calc_dist(current_city, city)
145
- 23: if dist < min_dist:
146
- 24: min_dist = dist
147
- 25: nearest_city = city
168
+ ## Raw output
169
+ ```bash
170
+ > cq check -o raw
171
+ ```
148
172
 
149
- Please fix only this issue. After fixing, run `cq check . -o llm` to verify.
173
+ ```json
174
+ [
175
+ {
176
+ "tool_name": "compile",
177
+ "command": "D:\\ai\\py-cq\\.venv\\Scripts\\python.exe -m compileall -r 10 -j 8 . -x .*venv",
178
+ "stdout": "",
179
+ "stderr": "",
180
+ "return_code": 0,
181
+ "timestamp": "2026-02-20 10:01:22"
182
+ }
183
+ ...
184
+ ]
150
185
  ```
151
186
 
152
187
  ## Configuration
@@ -166,14 +201,111 @@ error = 0.7
166
201
 
167
202
  Tool IDs match the keys in `config/tools.yaml`: `compilation`, `bandit`, `ruff`, `ty`, `pytest`, `coverage`, `complexity`, `maintainability`, `halstead`, `vulture`, `interrogate`.
168
203
 
169
- ## LLM workflow
170
204
 
171
- `-o llm` selects the single worst-scoring tool and formats its top defect as
172
- concise markdown. The LLM fixes it, you re-run `cq check -o llm`, and repeat
173
- until all tools are green. Priority order ensures the most critical category
174
- (security, type errors, failing tests) is fixed before cosmetic ones.
205
+ ### Default config
206
+
207
+ ```yaml
208
+ tools:
209
+
210
+ compilation:
211
+ name: "compile"
212
+ command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
213
+ parser: "CompileParser"
214
+ order: 1
215
+ warning_threshold: 0.9999
216
+ error_threshold: 0.9999
217
+
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
+ ruff:
227
+ name: "ruff"
228
+ command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
229
+ parser: "RuffParser"
230
+ order: 3
231
+ warning_threshold: 0.9999
232
+ error_threshold: 0.9
233
+
234
+ ty:
235
+ name: "ty"
236
+ command: "{python} -m ty check --output-format concise --color never {context_path}"
237
+ parser: "TyParser"
238
+ order: 4
239
+ warning_threshold: 0.9999
240
+ error_threshold: 0.8
241
+ run_in_target_env: true
242
+ extra_deps:
243
+ - ty
244
+
245
+ pytest:
246
+ name: "pytest"
247
+ command: "{python} -m pytest -v {context_path}"
248
+ parser: "PytestParser"
249
+ order: 5
250
+ warning_threshold: 0.7
251
+ error_threshold: 0.5
252
+ run_in_target_env: true
253
+
254
+ coverage:
255
+ name: "coverage"
256
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
257
+ parser: "CoverageParser"
258
+ order: 6
259
+ warning_threshold: 0.9
260
+ error_threshold: 0.5
261
+ run_in_target_env: true
262
+ extra_deps:
263
+ - coverage
264
+ - pytest
265
+
266
+ complexity:
267
+ name: "radon cc"
268
+ command: "{python} -m radon cc --json {context_path}"
269
+ parser: "ComplexityParser"
270
+ order: 7
271
+ warning_threshold: 0.6
272
+ error_threshold: 0.4
273
+
274
+ maintainability:
275
+ name: "radon mi"
276
+ command: "{python} -m radon mi -s --json {context_path}"
277
+ parser: "MaintainabilityParser"
278
+ order: 8
279
+ warning_threshold: 0.6
280
+ error_threshold: 0.4
281
+
282
+ halstead:
283
+ name: "radon hal"
284
+ command: "{python} -m radon hal -f --json {context_path}"
285
+ parser: "HalsteadParser"
286
+ order: 9
287
+ warning_threshold: 0.5
288
+ error_threshold: 0.3
289
+
290
+ vulture:
291
+ name: "vulture"
292
+ command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
293
+ parser: "VultureParser"
294
+ order: 10
295
+ warning_threshold: 0.9999
296
+ error_threshold: 0.8
297
+
298
+ interrogate:
299
+ name: "interrogate"
300
+ command: "{python} -m interrogate {context_path} -v --fail-under 0"
301
+ parser: "InterrogateParser"
302
+ order: 11
303
+ warning_threshold: 0.8
304
+ error_threshold: 0.3
175
305
 
176
- ## Tools
306
+ ```
307
+
308
+ ## Respect
177
309
 
178
310
  Many thanks to all the wonderful maintainers of :
179
311
 
@@ -186,3 +318,5 @@ Many thanks to all the wonderful maintainers of :
186
318
  - [radon](https://github.com/rubik/radon)
187
319
  - [vulture](https://github.com/jendrikseipp/vulture)
188
320
  - [interrogate](https://github.com/econchick/interrogate)
321
+ - [diskcache](https://github.com/grantjenks/python-diskcache)
322
+ - [typer](https://github.com/fastapi/typer)
@@ -0,0 +1,30 @@
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,,
py_cq/storage.py DELETED
@@ -1,27 +0,0 @@
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)
@@ -1,31 +0,0 @@
1
- py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
2
- py_cq/cli.py,sha256=M0t5noGEt-a1TmTaDvsGKfApcpOZnXjXHiUo_tSdcjk,8586
3
- py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
4
- py_cq/execution_engine.py,sha256=xNZHLVbkNf97FwDI29R1_njyy-xrlHD_RDUX86PLlQU,6675
5
- py_cq/llm_formatter.py,sha256=tOjoWIFEq3ZRGKLcwAnLswQ00B79sM3yJT3K3bh857A,1577
6
- py_cq/localtypes.py,sha256=Ix871IDcZh8_EldhOrmvR4TPxvWfDi0YhZVSGpI6pl0,5961
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/storage.py,sha256=J8360OUnGFyQAl_jJ6Eoy5hn7DDpShQvqaU6dFtfrkc,916
11
- py_cq/tool_registry.py,sha256=9QFqeXxrK7NF-qMd9ZxP5zfqc0nsHVQAbrbAqinAA4g,1535
12
- py_cq/config/__init__.py,sha256=JZgIe38Lq0XYd-zwOVzi6SxobYBc4lofhidmU4T9_oU,730
13
- py_cq/config/tools.yaml,sha256=ajZANuj2vBsXF70dVzFW1zMvNjnJ9ns3kkIr2P6Cexg,2518
14
- py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- py_cq/parsers/banditparser.py,sha256=G4wg7Jry6GsrHm5LOfTAvBuwoA6PvGqz8OKdNf0Ifs4,2219
16
- py_cq/parsers/common.py,sha256=FWkQIm03IKoCB7pvW4MRbQKjC_quINIKkGq5xKk86qM,3506
17
- py_cq/parsers/compileparser.py,sha256=GcIcKGVSL8W3zTwfA7WzD045-gNdSeSkljOrPA0qG0M,6496
18
- py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
19
- py_cq/parsers/coverageparser.py,sha256=xDNlLNEsA0U3z4GV02iEq97IL90-UJAQrhMlFzpIdy0,4013
20
- py_cq/parsers/halsteadparser.py,sha256=Rj690m_C4KRdlfrTC_bXtT5_-oJPKT38I7V-v1CDoJY,8980
21
- py_cq/parsers/interrogateparser.py,sha256=eMROINtyZE2eHrRxVU0jA-nYTdvr0PZ8iERVn7kPH5o,2197
22
- py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
23
- py_cq/parsers/pytestparser.py,sha256=FrAaNJfDHZmEr_vRYQpp04qRj4Cq3jaDhpnAFxLv5dQ,4047
24
- py_cq/parsers/ruffparser.py,sha256=WMJhsYA5wwBcIhqGSfjBxy78RGv_iCJJ1Qdmt1dqTOw,2567
25
- py_cq/parsers/typarser.py,sha256=bC6-P-NGBMJBLyr1DkX1yMtnTS8afuRO0Keb_XyBeg0,2787
26
- py_cq/parsers/vultureparser.py,sha256=fe-LPagivKGtmf6Kyu_gT0OrWJjowAsJ2OhXQiv_zQY,1888
27
- python_code_quality-0.1.6.dist-info/METADATA,sha256=90Ylk0f3eOWGyq2Nw1OmoZ0897OEzTQay0lSfLY-EtY,6745
28
- python_code_quality-0.1.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
- python_code_quality-0.1.6.dist-info/entry_points.txt,sha256=j5Q_gGr0b7389lddt1JlZ7gcL4Z7RHxuDhij_G-IhBY,39
30
- python_code_quality-0.1.6.dist-info/licenses/LICENSE,sha256=Bpuh8tbf37so8M5NtRGTLmT5ue7diJ17223L9f9nsT0,1086
31
- python_code_quality-0.1.6.dist-info/RECORD,,