python-code-quality 0.1.13__py3-none-any.whl → 0.1.15__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/__init__.py CHANGED
@@ -4,7 +4,3 @@ The module defines a single function, `hello`, which returns the string
4
4
  `'Hello from py_cq!'`. It can serve as a minimal example, placeholder, or
5
5
  testing stub in larger applications."""
6
6
 
7
-
8
- def hello() -> str:
9
- """Returns the greeting string `'Hello from py_cq!'`."""
10
- return "Hello from py_cq!"
py_cq/cli.py CHANGED
@@ -10,13 +10,14 @@ analysis.
10
10
  Helper functions such as `format_as_table` convert the aggregated tool
11
11
  results into a Rich Table for convenient console display.
12
12
  """
13
-
14
13
  import copy
14
+ import io
15
15
  import json
16
16
  import logging
17
17
  import tomllib
18
18
  from enum import Enum
19
19
  from importlib import import_module
20
+ from importlib.metadata import requires, version
20
21
  from pathlib import Path
21
22
 
22
23
  import typer
@@ -84,6 +85,7 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
84
85
  run_in_target_env=tool_data.get("run_in_target_env", False),
85
86
  extra_deps=tool_data.get("extra_deps", []),
86
87
  parser_config=tool_data.get("parser_config", {}),
88
+ exclude_format=tool_data.get("exclude_format", ""),
87
89
  )
88
90
  except KeyError as e:
89
91
  raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
@@ -99,8 +101,36 @@ class OutputMode(str, Enum):
99
101
  RAW = "raw"
100
102
 
101
103
 
104
+ def _version_callback(value: bool) -> None:
105
+ if not value:
106
+ return
107
+ import re
108
+ import sys
109
+ if isinstance(sys.stdout, io.TextIOWrapper):
110
+ sys.stdout.reconfigure(encoding="utf-8")
111
+ pkg = "python-code-quality"
112
+ pkg_version = version(pkg)
113
+ dep_versions: list[tuple[str, str]] = []
114
+ for req in (requires(pkg) or []):
115
+ if "; extra ==" in req:
116
+ continue
117
+ dep_name = re.split(r"[>=<!;\s\[]", req)[0]
118
+ try:
119
+ dep_versions.append((dep_name, version(dep_name)))
120
+ except Exception:
121
+ pass
122
+ typer.echo(f"{pkg} v{pkg_version}")
123
+ for dep_name, dep_ver in sorted(dep_versions):
124
+ typer.echo(f"\u251c\u2500\u2500 {dep_name} v{dep_ver}")
125
+ raise typer.Exit()
126
+
127
+
102
128
  @app.callback()
103
- def callback():
129
+ def callback(
130
+ _: bool = typer.Option(
131
+ False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and dependencies"
132
+ ),
133
+ ) -> None:
104
134
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
105
135
  console = Console()
106
136
 
@@ -131,6 +161,9 @@ def check(
131
161
  skip: str | None = typer.Option(
132
162
  None, "--skip", help="Comma-separated tool IDs to skip (e.g. bandit,vulture)"
133
163
  ),
164
+ exclude: str | None = typer.Option(
165
+ None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
166
+ ),
134
167
  ):
135
168
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
136
169
  path_obj = Path(path)
@@ -165,9 +198,12 @@ def check(
165
198
  if skip:
166
199
  drop = set(skip.split(","))
167
200
  effective_registry = {k: v for k, v in effective_registry.items() if k not in drop}
201
+ config_excludes: list[str] = user_cfg.get("exclude", [])
202
+ cli_excludes: list[str] = [e.strip() for e in exclude.split(",")] if exclude else []
203
+ excludes = list(dict.fromkeys(config_excludes + cli_excludes))
168
204
  if clear_cache:
169
205
  tool_cache.clear()
170
- tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
206
+ tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM), excludes=excludes)
171
207
  # for tr in tool_results:
172
208
  # log.debug(json.dumps(tr.to_dict(), indent=2))
173
209
  combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
py_cq/config/config.yaml CHANGED
@@ -8,14 +8,16 @@ python:
8
8
  error_threshold: 0.9999
9
9
 
10
10
  ruff:
11
- 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}\"{exclude}"
12
+ exclude_format: " --exclude {path}"
12
13
  parser: "RuffParser"
13
14
  order: 2
14
15
  warning_threshold: 0.9999
15
16
  error_threshold: 0.9
16
17
 
17
18
  ty:
18
- command: "{python} -m ty check --output-format concise --color never \"{context_path}\""
19
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\"{exclude}"
20
+ exclude_format: " --exclude {path}"
19
21
  parser: "TyParser"
20
22
  order: 3
21
23
  warning_threshold: 0.9999
@@ -25,14 +27,16 @@ python:
25
27
  - ty
26
28
 
27
29
  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\""
30
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests{exclude}\""
31
+ exclude_format: ",{input_path_posix}/{path}"
29
32
  parser: "BanditParser"
30
33
  order: 4
31
34
  warning_threshold: 0.9999
32
35
  error_threshold: 0.8
33
36
 
34
37
  pytest:
35
- command: "{python} -m pytest -v \"{context_path}\""
38
+ command: "{python} -m pytest -v \"{context_path}\"{exclude}"
39
+ exclude_format: " --ignore {path}"
36
40
  parser: "PytestParser"
37
41
  order: 5
38
42
  warning_threshold: 1.0
@@ -74,14 +78,16 @@ python:
74
78
  error_threshold: 0.3
75
79
 
76
80
  vulture:
77
- command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
81
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git{exclude}"
82
+ exclude_format: ",{path}"
78
83
  parser: "VultureParser"
79
84
  order: 10
80
85
  warning_threshold: 0.9999
81
86
  error_threshold: 0.8
82
87
 
83
88
  interrogate:
84
- command: "{python} -m interrogate \"{context_path}\" -v --fail-under 0"
89
+ command: "{python} -m interrogate \"{context_path}\" -e tests{exclude} -v --fail-under 0"
90
+ exclude_format: " -e {path}"
85
91
  parser: "InterrogateParser"
86
92
  order: 11
87
93
  warning_threshold: 0.8
py_cq/execution_engine.py CHANGED
@@ -53,7 +53,17 @@ def _dep_in_venv(dep: str, project_root: Path) -> bool:
53
53
  return False
54
54
 
55
55
 
56
- def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
56
+ def _build_exclude_str(exclude_format: str, excludes: list[str], **extra_vars: str) -> str:
57
+ if not exclude_format or not excludes:
58
+ return ""
59
+ parts = []
60
+ for exc in excludes:
61
+ abs_posix_path = Path(exc).resolve().as_posix()
62
+ parts.append(exclude_format.format(path=exc, abs_posix_path=abs_posix_path, **extra_vars))
63
+ return "".join(parts)
64
+
65
+
66
+ def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | None = None) -> RawResult:
57
67
  """Runs a tool defined by its configuration and returns the execution result.
58
68
 
59
69
  Args:
@@ -72,7 +82,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
72
82
  >>> result.return_code
73
83
  0"""
74
84
  python = sys.executable
75
- path = context_path
85
+ path = str(Path(context_path))
76
86
  if tool_config.run_in_target_env:
77
87
  uv = shutil.which("uv")
78
88
  if uv:
@@ -87,10 +97,12 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
87
97
  project_root_path = Path(abs_dir)
88
98
  missing_deps = [d for d in tool_config.extra_deps if not _dep_in_venv(d, project_root_path)]
89
99
  with_flags = " ".join(f"--with {dep}" for dep in missing_deps)
90
- python = f'"{uv}" run --directory "{abs_dir}" {with_flags}'.rstrip()
100
+ no_sync = "--no-sync" if sys.executable.startswith(abs_dir) else ""
101
+ python = f'"{uv}" run {no_sync} --directory "{abs_dir}" {with_flags}'.strip()
91
102
  abs_context_path = str(Path(context_path).resolve())
92
103
  input_path_posix = Path(context_path).as_posix().rstrip("/")
93
- command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python)
104
+ exclude = _build_exclude_str(tool_config.exclude_format, excludes or [], input_path_posix=input_path_posix)
105
+ command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python, exclude=exclude)
94
106
  cache_key = f"{command}:{get_context_hash(context_path)}"
95
107
  if cache_key in _cache:
96
108
  log.info(f"Cache hit: {command}")
@@ -110,7 +122,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
110
122
  return raw_result
111
123
 
112
124
 
113
- def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False) -> list[ToolResult]:
125
+ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False, excludes: list[str] | None = None) -> list[ToolResult]:
114
126
  """Run multiple tools and return their parsed results.
115
127
 
116
128
  Runs each tool specified in *tool_configs* on the file or directory at
@@ -152,7 +164,7 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
152
164
  >>> results = run_tools(configs, '/path/to/project', parallel=True)"""
153
165
  def _run_and_parse(tool_config: ToolConfig) -> tuple[int, ToolResult]:
154
166
  t0 = time.perf_counter()
155
- raw_result = run_tool(tool_config, path)
167
+ raw_result = run_tool(tool_config, path, excludes)
156
168
  tr = tool_config.parser_class(tool_config.parser_config).parse(raw_result)
157
169
  tr.duration_s = time.perf_counter() - t0
158
170
  return tool_config.order, tr
py_cq/localtypes.py CHANGED
@@ -22,6 +22,7 @@ class ToolConfig:
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
24
  parser_config: dict[str, Any] = field(default_factory=dict)
25
+ exclude_format: str = "" # Per-path template for --exclude injection, e.g. " --exclude {path}"
25
26
 
26
27
 
27
28
  @dataclass
py_cq/parsers/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ """Tool Response parsers"""
py_cq/parsers/common.py CHANGED
@@ -12,6 +12,7 @@ performance metrics or error scores:
12
12
  Both functions return a float and can be used directly in downstream analytics,
13
13
  visualisation or decision-making pipelines."""
14
14
 
15
+ from pathlib import Path
15
16
 
16
17
 
17
18
  def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
@@ -26,17 +27,118 @@ def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
26
27
 
27
28
 
28
29
  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
+ """Return a fenced python code block for the source around `line`, or '' if unavailable.
31
+
32
+ Stops before spilling into the next top-level ``def`` or ``class`` definition.
33
+ """
30
34
  if not isinstance(line, int):
31
35
  return ""
32
36
  context_start = max(1, line - context)
33
37
  raw_lines = read_source_lines(file, context_start, count=count).splitlines()
34
38
  if not raw_lines:
35
39
  return ""
36
- src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines))
40
+ error_offset = line - context_start # 0-based index of the error line in raw_lines
41
+ collected = []
42
+ for i, rline in enumerate(raw_lines):
43
+ if i > error_offset and (
44
+ rline.startswith("def ")
45
+ or rline.startswith("async def ")
46
+ or rline.startswith("class ")
47
+ ):
48
+ break
49
+ collected.append(f"{context_start + i}: {rline}")
50
+ src = "\n".join(collected)
37
51
  return f"\n```python\n{src}\n```"
38
52
 
39
53
 
54
+ _PYTHON_KEYWORDS = frozenset([
55
+ "if", "elif", "else", "for", "while", "with", "assert", "return",
56
+ "raise", "import", "from", "class", "def", "lambda", "yield",
57
+ "del", "pass", "break", "continue", "not", "and", "or", "in", "is",
58
+ "print", "super", "type", "len", "range",
59
+ ])
60
+
61
+
62
+ def extract_callee_name(source_line: str) -> str | None:
63
+ """Extract the primary callee function name from a source line, or None.
64
+
65
+ Prefers the RHS of an assignment so that ``result = func(...)`` returns
66
+ ``func`` rather than the variable on the left. Python keywords and
67
+ built-ins listed in ``_PYTHON_KEYWORDS`` are excluded.
68
+ """
69
+ import re
70
+ stripped = source_line.strip()
71
+ rhs = stripped
72
+ if "=" in stripped and not stripped.startswith(("assert", "return")):
73
+ rhs = stripped.split("=", 1)[1].strip()
74
+ m = re.search(r"\b([a-zA-Z_]\w*)\s*\(", rhs)
75
+ if m and m.group(1) not in _PYTHON_KEYWORDS:
76
+ return m.group(1)
77
+ return None
78
+
79
+
80
+ def _find_project_root(hint_file: str) -> Path:
81
+ from pathlib import Path
82
+ root = Path(hint_file).resolve().parent
83
+ current = root
84
+ for _ in range(8):
85
+ if (current / "pyproject.toml").exists() or (current / "setup.py").exists():
86
+ return current
87
+ parent = current.parent
88
+ if parent == current:
89
+ break
90
+ current = parent
91
+ return root
92
+
93
+
94
+ def find_in_project(func_name: str, hint_file: str, max_lines: int = 10) -> tuple[str, str]:
95
+ """Find func_name definition in project files; same file first, then project-wide.
96
+
97
+ Returns ``(file_path, code_block)`` for the first match, or ``("", "")`` if not found.
98
+ """
99
+ from pathlib import Path
100
+ result = find_function_source(hint_file, func_name, max_lines=max_lines)
101
+ if result:
102
+ return hint_file, result
103
+ root = _find_project_root(hint_file)
104
+ for py_file in sorted(root.rglob("*.py")):
105
+ if py_file.resolve() == Path(hint_file).resolve():
106
+ continue
107
+ r = find_function_source(str(py_file), func_name, max_lines=max_lines)
108
+ if r:
109
+ return str(py_file), r
110
+ return "", ""
111
+
112
+
113
+ def _relative_path(path: str) -> str:
114
+ """Return path relative to cwd, normalised to forward slashes."""
115
+ from pathlib import Path
116
+ try:
117
+ return str(Path(path).relative_to(Path.cwd())).replace("\\", "/")
118
+ except ValueError:
119
+ return path.replace("\\", "/")
120
+
121
+
122
+ def format_callee_context(func_name: str, hint_file: str, max_lines: int = 10) -> str:
123
+ """Return a labelled callee definition block, or '' if not found in project.
124
+
125
+ Output format::
126
+
127
+ Callee `func_name` — `relative/path/to/file.py`
128
+ ```python
129
+ N: def func_name(...):
130
+ ...
131
+ ```
132
+ """
133
+ import re
134
+ callee_file, code_block = find_in_project(func_name, hint_file, max_lines=max_lines)
135
+ if not code_block:
136
+ return ""
137
+ m = re.search(r"```python\n(\d+):", code_block)
138
+ line_ref = f":{m.group(1)}" if m else ""
139
+ return f"\n`{func_name}` is defined at: `{_relative_path(callee_file)}{line_ref}`{code_block}"
140
+
141
+
40
142
  def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
41
143
  """Return a fenced python block for the body of func_name, or '' if unavailable."""
42
144
  from pathlib import Path
@@ -64,6 +166,8 @@ def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
64
166
  collected.append(line)
65
167
  if len(collected) >= max_lines:
66
168
  break
169
+ while collected and not collected[-1].strip():
170
+ collected.pop()
67
171
  numbered = "\n".join(f"{start_idx + 1 + i}: {ln}" for i, ln in enumerate(collected))
68
172
  return f"\n```python\n{numbered}\n```"
69
173
 
@@ -125,4 +125,11 @@ class CompileParser(AbstractParser):
125
125
  typ = info.get("type", "Error")
126
126
  help_msg = info.get("help", "")
127
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
- return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
128
+ callee = ""
129
+ src_line = info.get("src", "")
130
+ if src_line:
131
+ from py_cq.parsers.common import extract_callee_name, format_callee_context
132
+ func_name = extract_callee_name(src_line)
133
+ if func_name:
134
+ callee = format_callee_context(func_name, file)
135
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
@@ -3,8 +3,8 @@
3
3
  Interrogate is invoked with ``-v --fail-under 0``, producing a table of
4
4
  per-file docstring coverage on stdout::
5
5
 
6
- | src/foo.py | 5 | 2 | 60% |
7
- | TOTAL | 5 | 2 | 60% |
6
+ | src/foo.py | 5 | 2 | 3 | 60% |
7
+ | TOTAL | 5 | 2 | 3 | 60.0% |
8
8
 
9
9
  The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
10
10
  as the ``doc_coverage`` metric (0.0–1.0).
@@ -14,7 +14,7 @@ import re
14
14
 
15
15
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
16
16
 
17
- _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)%\s+\|")
17
+ _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|")
18
18
 
19
19
 
20
20
  class InterrogateParser(AbstractParser):
@@ -30,7 +30,7 @@ class InterrogateParser(AbstractParser):
30
30
  name = m.group(1).strip()
31
31
  total = int(m.group(2))
32
32
  miss = int(m.group(3))
33
- cover = int(m.group(4))
33
+ cover = float(m.group(4))
34
34
  if name == "TOTAL":
35
35
  total_coverage = cover / 100.0
36
36
  elif total > 0:
@@ -13,6 +13,58 @@ import re as _re
13
13
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
14
14
 
15
15
 
16
+ def _last_call_line_for_test(stdout: str, test_name: str) -> str:
17
+ """Return the last source line before E-lines in a test's failure section.
18
+
19
+ Captures both indented context lines and pytest's ``>``-prefixed
20
+ current-executing-line marker.
21
+ """
22
+ lines = stdout.splitlines()
23
+ pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
24
+ in_section = False
25
+ last_src = ""
26
+ for line in lines:
27
+ if not in_section:
28
+ if pattern.search(line):
29
+ in_section = True
30
+ else:
31
+ stripped = line.strip()
32
+ if stripped.startswith(("_", "=")):
33
+ break
34
+ if stripped.startswith("E ") or stripped == "E":
35
+ break
36
+ if line.startswith((" ", "\t", ">")):
37
+ src = line.lstrip("> \t")
38
+ if src:
39
+ last_src = src
40
+ return last_src
41
+
42
+
43
+ _COLLECTION_FILE_RE = _re.compile(r'E\s+File "([^"]+)", line (\d+)')
44
+ _COLLECTION_ERROR_RE = _re.compile(r"E\s+(\w+(?:Error|Warning|Exception)):\s*(.*)")
45
+
46
+
47
+ def _extract_collection_error(stdout: str) -> dict | None:
48
+ """Return {file, line, type, help} if pytest stdout contains a collection error."""
49
+ file_match = None
50
+ error_match = None
51
+ for line in stdout.splitlines():
52
+ m = _COLLECTION_FILE_RE.search(line)
53
+ if m:
54
+ file_match = m
55
+ m = _COLLECTION_ERROR_RE.search(line)
56
+ if m:
57
+ error_match = m
58
+ if file_match and error_match:
59
+ return {
60
+ "file": file_match.group(1).replace("\\", "/"),
61
+ "line": int(file_match.group(2)),
62
+ "type": error_match.group(1),
63
+ "help": error_match.group(2).strip(),
64
+ }
65
+ return None
66
+
67
+
16
68
  def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
17
69
  """Extract the failure section for test_name from pytest stdout."""
18
70
  lines = stdout.splitlines()
@@ -102,8 +154,12 @@ class PytestParser(AbstractParser):
102
154
  return tr
103
155
 
104
156
  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
157
+ """Return the first failing test with function body, failure output, and callee signature."""
158
+ from py_cq.parsers.common import (
159
+ extract_callee_name,
160
+ find_function_source,
161
+ format_callee_context,
162
+ )
107
163
  for file, tests in tr.details.items():
108
164
  if not isinstance(tests, dict):
109
165
  continue
@@ -111,13 +167,22 @@ class PytestParser(AbstractParser):
111
167
  if status != "FAILED":
112
168
  continue
113
169
  header = f"`{file}::{test_name}` — test **FAILED**"
114
- body = find_function_source(file, test_name, max_lines=context_lines)
170
+ bare_name = test_name.split("[")[0]
171
+ body = find_function_source(file, bare_name, max_lines=context_lines)
115
172
  failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
173
+ callee = ""
174
+ call_line = _last_call_line_for_test(tr.raw.stdout, test_name)
175
+ if call_line:
176
+ func_name = extract_callee_name(call_line)
177
+ if func_name and func_name != bare_name:
178
+ callee = format_callee_context(func_name, file)
116
179
  parts = [header]
117
180
  if body:
118
181
  parts.append(body)
119
182
  if failure:
120
183
  parts.append(failure)
184
+ if callee:
185
+ parts.append(callee)
121
186
  return "\n".join(parts)
122
187
  if "no tests ran" in tr.raw.stdout:
123
188
  return (
@@ -125,7 +190,29 @@ class PytestParser(AbstractParser):
125
190
  "Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
126
191
  "and write a first test covering a core function."
127
192
  )
128
- output = (tr.raw.stdout + tr.raw.stderr).strip()
193
+ from py_cq.parsers.common import (
194
+ extract_callee_name,
195
+ format_callee_context,
196
+ format_source_context,
197
+ )
198
+ combined = tr.raw.stdout + tr.raw.stderr
199
+ err = _extract_collection_error(combined)
200
+ if err:
201
+ file, line, typ, help_msg = err["file"], err["line"], err["type"], err["help"]
202
+ code_block = format_source_context(file, line, count=context_lines) or ""
203
+ callee = ""
204
+ # try to find callee from the offending source line via format_source_context result
205
+ src_line = ""
206
+ for ln in (tr.raw.stdout + tr.raw.stderr).splitlines():
207
+ m = _re.match(r"E\s{6,}(\S.*)", ln)
208
+ if m:
209
+ src_line = m.group(1)
210
+ if src_line:
211
+ func_name = extract_callee_name(src_line)
212
+ if func_name:
213
+ callee = format_callee_context(func_name, file)
214
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
215
+ output = combined.strip()
129
216
  if output:
130
217
  tail = "\n".join(output.splitlines()[-30:])
131
218
  return f"pytest reported failures:\n\n```\n{tail}\n```"
py_cq/parsers/typarser.py CHANGED
@@ -18,6 +18,16 @@ 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
 
21
+ _CALL_CODES = frozenset([
22
+ "call-non-callable",
23
+ "missing-argument",
24
+ "unexpected-keyword",
25
+ "argument-type",
26
+ "too-many-positional-arguments",
27
+ "invalid-argument-type",
28
+ "no-matching-overload",
29
+ ])
30
+
21
31
 
22
32
  class TyParser(AbstractParser):
23
33
  """Parses raw output from ``ty check`` into a structured ToolResult."""
@@ -58,4 +68,12 @@ class TyParser(AbstractParser):
58
68
  line = issue.get("line", "?")
59
69
  code = issue.get("code", "")
60
70
  message = issue.get("message", "")
61
- return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
71
+ src_ctx = format_source_context(file, line, count=context_lines)
72
+ callee = ""
73
+ if code in _CALL_CODES and isinstance(line, int):
74
+ from py_cq.parsers.common import extract_callee_name, format_callee_context, read_source_lines
75
+ src_line = read_source_lines(file, line, count=1)
76
+ func_name = extract_callee_name(src_line)
77
+ if func_name:
78
+ callee = format_callee_context(func_name, file)
79
+ return f"`{file}:{line}` — **{code}**: {message}{src_ctx}{callee}"
py_cq/tool_registry.py CHANGED
@@ -30,6 +30,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
30
30
  run_in_target_env=tool_data.get("run_in_target_env", False),
31
31
  extra_deps=tool_data.get("extra_deps", []),
32
32
  parser_config=tool_data.get("parser_config", {}),
33
+ exclude_format=tool_data.get("exclude_format", ""),
33
34
  )
34
35
  return registry
35
36
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.13
3
+ Version: 0.1.15
4
4
  Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
5
5
  Author: Chris Kilner
6
6
  Author-email: Chris Kilner <chris@rhiza.fr>
@@ -35,39 +35,19 @@ Description-Content-Type: text/markdown
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/python-code-quality?)](https://pypi.org/project/python-code-quality/)
36
36
  [![License](https://img.shields.io/github/license/rhiza-fr/py-cq)](LICENSE)
37
37
 
38
- Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
38
+ Run 11+ code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
39
39
 
40
- Why? It removes the mental burden of understanding all these tools and parsing their results.
40
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
41
41
 
42
- The primary workflow is:
42
+ Note: It never edits your files. This is a job for you or an LLM. You may wish to run `ruff check --fix` and `ruff format` first.
43
43
 
44
44
  ```bash
45
- # get the single most critical defect as markdown
46
- cq check . -o llm
47
- ```
48
-
49
- ```python
50
- `data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
51
-
52
- 18: min_dist = float("inf")
53
- 19: nearest_city = None
54
- 20: for city in cities:
55
- 21: unused_variable = 67
56
- 22: dist = calc_dist(current_city, city)
57
- 23: if dist < min_dist:
58
- 24: min_dist = dist
59
- 25: nearest_city = city
60
-
61
- Please fix only this issue. After fixing, run `cq check . -o llm` to verify.
62
- ```
63
- Feed to an LLM with edit tools and repeat until there are no issues, e.g.
64
-
65
- ```python
66
- cq check . -o llm | claude -p "fix this"
67
- # or
68
- cq check . -o llm | ollama run gpt-oss:20b "Explain how to fix this"
45
+ cq check . -o llm # top defect as markdown, pipe to an LLM
46
+ cq check . # table overview of all scores
47
+ cq check . -o score # numeric score only, exits 1 on errors (CI gate)
69
48
  ```
70
49
 
50
+ ![cq demo](demo/output/demo.gif)
71
51
 
72
52
  ## Install
73
53
 
@@ -114,6 +94,7 @@ cq check . -o raw # Raw tool output for debug
114
94
  cq check path/to/file.py # Just one file (skips pytest and coverage)
115
95
  cq check . --only ruff,ty # Run only specific tools
116
96
  cq check . --skip bandit # Skip specific tools
97
+ cq check . --exclude demo # Exclude paths from all tools
117
98
  cq check . --workers 1 # Run sequentially if you like things slow
118
99
  cq check . --clear-cache # Clear cached results before running (rarely needed)
119
100
  cq config path/to/project/ # Show effective tool configuration
@@ -133,10 +114,17 @@ Add a stop hook to your project's `.claude/settings.json` so Claude automaticall
133
114
  ```json
134
115
  {
135
116
  "hooks": {
136
- "Stop": [{
137
- "matcher": "",
138
- "hooks": [{"type": "command", "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm"}]
139
- }]
117
+ "Stop": [
118
+ {
119
+ "matcher": "",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "cq check . -o score && echo 'CQ: all clear' || cq check . -o llm; true"
124
+ }
125
+ ]
126
+ }
127
+ ]
140
128
  }
141
129
  }
142
130
  ```
@@ -191,7 +179,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
191
179
  ```bash
192
180
  > cq check . -o score
193
181
  ```
194
- ```python
182
+ ```
195
183
  0.9662730667181059 # this is designed to approach but not reach 1.0
196
184
  ```
197
185
 
@@ -252,6 +240,9 @@ Add a `[tool.cq]` section to your project's `pyproject.toml`:
252
240
  # Skip tools that are slow or not relevant to your project
253
241
  disable = ["coverage", "interrogate"]
254
242
 
243
+ # Exclude paths from all tools (merged with --exclude CLI flag)
244
+ exclude = ["demo", "docs"]
245
+
255
246
  # Lines of source context shown around each defect in LLM output (default: 15)
256
247
  context_lines = 15
257
248
 
@@ -270,21 +261,23 @@ Tool IDs match the keys in `config/config.yaml`: `compile`, `ruff`, `ty`, `bandi
270
261
  python:
271
262
 
272
263
  compile:
273
- command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
264
+ command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
274
265
  parser: "CompileParser"
275
266
  order: 1
276
267
  warning_threshold: 0.9999
277
268
  error_threshold: 0.9999
278
269
 
279
270
  ruff:
280
- command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
271
+ command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\"{exclude}"
272
+ exclude_format: " --exclude {path}"
281
273
  parser: "RuffParser"
282
274
  order: 2
283
275
  warning_threshold: 0.9999
284
276
  error_threshold: 0.9
285
277
 
286
278
  ty:
287
- command: "{python} -m ty check --output-format concise --color never {context_path}"
279
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\"{exclude}"
280
+ exclude_format: " --exclude {path}"
288
281
  parser: "TyParser"
289
282
  order: 3
290
283
  warning_threshold: 0.9999
@@ -294,22 +287,26 @@ python:
294
287
  - ty
295
288
 
296
289
  bandit:
297
- command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
290
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests{exclude}\""
291
+ exclude_format: ",{input_path_posix}/{path}"
298
292
  parser: "BanditParser"
299
293
  order: 4
300
294
  warning_threshold: 0.9999
301
295
  error_threshold: 0.8
302
296
 
303
297
  pytest:
304
- command: "{python} -m pytest -v {context_path}"
298
+ command: "{python} -m pytest -v \"{context_path}\"{exclude}"
299
+ exclude_format: " --ignore {path}"
305
300
  parser: "PytestParser"
306
301
  order: 5
307
302
  warning_threshold: 1.0
308
303
  error_threshold: 1.0
309
304
  run_in_target_env: true
305
+ extra_deps:
306
+ - pytest
310
307
 
311
308
  coverage:
312
- command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
309
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
313
310
  parser: "CoverageParser"
314
311
  order: 6
315
312
  warning_threshold: 0.9
@@ -320,40 +317,41 @@ python:
320
317
  - pytest
321
318
 
322
319
  radon-cc:
323
- command: "{python} -m radon cc --json {context_path}"
320
+ command: "{python} -m radon cc --json \"{context_path}\""
324
321
  parser: "ComplexityParser"
325
322
  order: 7
326
323
  warning_threshold: 0.6
327
324
  error_threshold: 0.4
328
325
 
329
326
  radon-mi:
330
- command: "{python} -m radon mi -s --json {context_path}"
327
+ command: "{python} -m radon mi -s --json \"{context_path}\""
331
328
  parser: "MaintainabilityParser"
332
329
  order: 8
333
330
  warning_threshold: 0.6
334
331
  error_threshold: 0.4
335
332
 
336
333
  radon-hal:
337
- command: "{python} -m radon hal -f --json {context_path}"
334
+ command: "{python} -m radon hal -f --json \"{context_path}\""
338
335
  parser: "HalsteadParser"
339
336
  order: 9
340
337
  warning_threshold: 0.5
341
338
  error_threshold: 0.3
342
339
 
343
340
  vulture:
344
- command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
341
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git{exclude}"
342
+ exclude_format: ",{path}"
345
343
  parser: "VultureParser"
346
344
  order: 10
347
345
  warning_threshold: 0.9999
348
346
  error_threshold: 0.8
349
347
 
350
348
  interrogate:
351
- command: "{python} -m interrogate {context_path} -v --fail-under 0"
349
+ command: "{python} -m interrogate \"{context_path}\" -e tests{exclude} -v --fail-under 0"
350
+ exclude_format: " -e {path}"
352
351
  parser: "InterrogateParser"
353
352
  order: 11
354
353
  warning_threshold: 0.8
355
354
  error_threshold: 0.3
356
-
357
355
  ```
358
356
 
359
357
  ## Respect
@@ -1,33 +1,33 @@
1
- py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
2
- py_cq/cli.py,sha256=x4BZTZVFTbSCqcdvF17Ed_WEJ5n3aQtODL7mLHzF_3s,11528
1
+ py_cq/__init__.py,sha256=U2ysDtSFdv2mlXZz4w1Q42pfgfi6YY_3Ln24bkZq14I,260
2
+ py_cq/cli.py,sha256=HWTMtA9Gfn3YeO7B94kter6HNNSzPjyX1HLJQ1eNBvU,12936
3
3
  py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
4
- py_cq/config/config.yaml,sha256=R8CjZH8erBQSbJFKgHt-CCwD1Mgj6ZCGQ3OtWH0Ebig,2402
4
+ py_cq/config/config.yaml,sha256=TPZJogpWbyf0Ml2mHrHzTNyTik3k07KPVGsA1wT9GEc,2696
5
5
  py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
6
- py_cq/execution_engine.py,sha256=lA9HAX5iIxxIaOjTNxSak1qZUuETGKlNdsOMAdY2sQo,8012
6
+ py_cq/execution_engine.py,sha256=nAsCvldUfDagZnfOwXf4A37VQLxCGiv12T8ymutflC4,8697
7
7
  py_cq/language_detector.py,sha256=6av5HaimcZ54RkN69xQmGgC0mxtTvGzPV3SL8NGG8Uc,1116
8
8
  py_cq/llm_formatter.py,sha256=l74O5iqNWMR16789Xncoi93xOqgQJhW5IQK9_OyA9mE,1632
9
- py_cq/localtypes.py,sha256=jCX1ZuwTixQooEk2N1Gi1XRuDcdBc2NlOdQBKysEybk,6128
9
+ py_cq/localtypes.py,sha256=UGI2kl1xB2TedKGByth3URiqJKY56YZMNds1hnLEzvU,6228
10
10
  py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
11
11
  py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
12
- py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ py_cq/parsers/__init__.py,sha256=YS3wPS0cMNU80zkdSZBEZOkqDKE6Jk--0Xd_bX7VMcA,27
13
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
14
+ py_cq/parsers/common.py,sha256=h3-eLyi0YNkk-2ZiNUKxZhIFZNHUf01kyAqCtFbxNIY,8803
15
+ py_cq/parsers/compileparser.py,sha256=EBoqZyPDkkfc9FssGkKrIATU6TDHSSB7xWeqrnOiMEc,6576
16
16
  py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
17
17
  py_cq/parsers/coverageparser.py,sha256=n4h_RCxKvJoYgp356PA4laAmFcngjQ41b_GXZvKkVgc,4041
18
18
  py_cq/parsers/exitcodeparser.py,sha256=ZFL3EbPhGvFjm2qZhfLD1_5dNjR9ULYNQpKgy0Z_dGo,729
19
19
  py_cq/parsers/halsteadparser.py,sha256=ZCN7LP1iUZ91tf3tlspKAPQrTEa78XVdE9Fjn9yPvYk,9008
20
- py_cq/parsers/interrogateparser.py,sha256=G07beMaGR0Mq-pGVozAbwK0xOx9MdMaNVGq2CGrUb7w,2225
20
+ py_cq/parsers/interrogateparser.py,sha256=gk9pJ7yFXMzLjk6PP0X8fxW_gAE2DTIoWMsPqF2xIXs,2260
21
21
  py_cq/parsers/linecountparser.py,sha256=nxWvFntuR8T6FDGpafrlevvt-jR3rwkbVo_t2JX4TF0,1067
22
22
  py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
23
- py_cq/parsers/pytestparser.py,sha256=3N1X4wKhJ2h-2U1GuM3gHWHpHxU9f0LPGcPaojra9W4,6377
23
+ py_cq/parsers/pytestparser.py,sha256=_seSfvAD_88A-yDyWYpLuIrLL2Wjp3rbcqG3fNCNEnA,9705
24
24
  py_cq/parsers/regexcountparser.py,sha256=KSoNh2spucXU06pxxr2QW0LrPLfJkFMAsmSgjooaFv0,1316
25
25
  py_cq/parsers/ruffparser.py,sha256=ZdIya4sct2PrsOyfKfMGmXHLh3Qu7HtFqXNY9IuNFog,2275
26
- py_cq/parsers/typarser.py,sha256=zrI0KS65MUGPxYPP74B6BRyTbjcPhKyQPjH2KZIyxN0,2495
26
+ py_cq/parsers/typarser.py,sha256=oJWudNyRZw-r_-gr2nIzDfS7YJGpt5K3otk9E4G5Ldo,3140
27
27
  py_cq/parsers/vultureparser.py,sha256=U6zC7P0ATA_N4SB90BahKF5QHMITf_z-NsOgrh_Q5rA,1995
28
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.13.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
31
- python_code_quality-0.1.13.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
32
- python_code_quality-0.1.13.dist-info/METADATA,sha256=jxjtkNG-hBU_o6lXwddPF_ZIXrMFEwd9FrLc4l0Cwz8,12463
33
- python_code_quality-0.1.13.dist-info/RECORD,,
29
+ py_cq/tool_registry.py,sha256=UfmwJH8rtmTY9kX02QaE4OJIWyboTMcXUavgb9R4fxc,1648
30
+ python_code_quality-0.1.15.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
31
+ python_code_quality-0.1.15.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
32
+ python_code_quality-0.1.15.dist-info/METADATA,sha256=bh2Ex3HJZiNMiJ_LaVtOE_MHf497VN3s0xH8ISWHmvs,12749
33
+ python_code_quality-0.1.15.dist-info/RECORD,,