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 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
- effective_registry = _apply_user_config(tool_registry, load_user_config(path_obj))
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
- for tool_id in sorted(tool_registry, key=lambda t: tool_registry[t].order):
176
- tc = effective_registry.get(tool_id, tool_registry[tool_id])
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(title=f"[bold green]{data.path}[/]", width=80)
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
- tools:
1
+ python:
2
2
 
3
- compilation:
4
- name: "compile"
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
- name: "ruff"
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: 3
13
+ order: 2
24
14
  warning_threshold: 0.9999
25
15
  error_threshold: 0.9
26
16
 
27
17
  ty:
28
- name: "ty"
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: 4
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
- name: "pytest"
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.7
44
- error_threshold: 0.5
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
- name: "coverage"
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
- complexity:
60
- name: "radon cc"
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
- maintainability:
68
- name: "radon mi"
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
- halstead:
76
- name: "radon hal"
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
- name: "vulture"
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
- name: "interrogate"
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.
@@ -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
@@ -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}"
@@ -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)"
@@ -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)
@@ -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
- pass
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 = re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
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 = re.search(r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout)
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 as a defect description."""
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
- for test_name, status in tests.items():
87
- if status == "FAILED":
88
- return f"`{file}::{test_name}` test **FAILED**"
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)
@@ -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)}"
@@ -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 tools.yaml and return a registry.
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("tools.yaml").read_text(encoding="utf-8")
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["tools"].items():
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=tool_data["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.8
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
- Project-URL: Homepage, https://github.com/rhiza-fr/py-cq
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
- The code context is expanded if available.
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 pull https://github.com/rhiza-fr/py-cq.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 when looking for the first error in -o llm mode:
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 | bandit | Security vulnerabilities |
86
- | 3 | ruff | Lint / style |
87
- | 4 | ty | Type errors |
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 # Top defect as markdown for LLMs
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/tools.yaml`: `compilation`, `bandit`, `ruff`, `ty`, `pytest`, `coverage`, `complexity`, `maintainability`, `halstead`, `vulture`, `interrogate`.
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
- tools:
249
+ python:
209
250
 
210
- compilation:
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: 3
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: 4
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.7
251
- error_threshold: 0.5
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
- complexity:
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
- maintainability:
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
- halstead:
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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.28.0
2
+ Generator: uv 0.11.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  cq = py_cq.main:main
3
+
@@ -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.