python-code-quality 0.1.14__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/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
@@ -100,8 +101,36 @@ class OutputMode(str, Enum):
100
101
  RAW = "raw"
101
102
 
102
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
+
103
128
  @app.callback()
104
- 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:
105
134
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
106
135
  console = Console()
107
136
 
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}"
@@ -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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.14
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>
@@ -37,6 +37,10 @@ Description-Content-Type: text/markdown
37
37
 
38
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
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
41
+
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
+
40
44
  ```bash
41
45
  cq check . -o llm # top defect as markdown, pipe to an LLM
42
46
  cq check . # table overview of all scores
@@ -175,7 +179,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
175
179
  ```bash
176
180
  > cq check . -o score
177
181
  ```
178
- ```python
182
+ ```
179
183
  0.9662730667181059 # this is designed to approach but not reach 1.0
180
184
  ```
181
185
 
@@ -1,5 +1,5 @@
1
1
  py_cq/__init__.py,sha256=U2ysDtSFdv2mlXZz4w1Q42pfgfi6YY_3Ln24bkZq14I,260
2
- py_cq/cli.py,sha256=RRQVPVOwG-EMYndMUhBfqBqm1S1kcaObhKOdfxrrYl0,11963
2
+ py_cq/cli.py,sha256=HWTMtA9Gfn3YeO7B94kter6HNNSzPjyX1HLJQ1eNBvU,12936
3
3
  py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
4
4
  py_cq/config/config.yaml,sha256=TPZJogpWbyf0Ml2mHrHzTNyTik3k07KPVGsA1wT9GEc,2696
5
5
  py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
@@ -11,8 +11,8 @@ py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
11
11
  py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
12
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
@@ -20,14 +20,14 @@ py_cq/parsers/halsteadparser.py,sha256=ZCN7LP1iUZ91tf3tlspKAPQrTEa78XVdE9Fjn9yPv
20
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
29
  py_cq/tool_registry.py,sha256=UfmwJH8rtmTY9kX02QaE4OJIWyboTMcXUavgb9R4fxc,1648
30
- python_code_quality-0.1.14.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
31
- python_code_quality-0.1.14.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
32
- python_code_quality-0.1.14.dist-info/METADATA,sha256=HR_8B9uca6SBAn7f74rMAnTOJ6LzODc5AF0w0Uiic28,12496
33
- python_code_quality-0.1.14.dist-info/RECORD,,
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,,