python-code-quality 0.1.15__tar.gz → 0.1.16__tar.gz

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.
Files changed (33) hide show
  1. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/PKG-INFO +1 -1
  2. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/pyproject.toml +10 -1
  3. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/cli.py +5 -42
  4. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/main.py +1 -1
  5. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/banditparser.py +6 -0
  6. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/common.py +3 -10
  7. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/compileparser.py +12 -5
  8. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/complexityparser.py +8 -1
  9. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/coverageparser.py +3 -3
  10. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/halsteadparser.py +8 -1
  11. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/interrogateparser.py +4 -4
  12. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/maintainabilityparser.py +8 -1
  13. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/ruffparser.py +4 -0
  14. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/typarser.py +4 -0
  15. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/vultureparser.py +4 -0
  16. python_code_quality-0.1.16/src/py_cq/table_formatter.py +29 -0
  17. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/README.md +0 -0
  18. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/__init__.py +0 -0
  19. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/config/__init__.py +0 -0
  20. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/config/config.yaml +0 -0
  21. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/context_hash.py +0 -0
  22. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/execution_engine.py +0 -0
  23. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/language_detector.py +0 -0
  24. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/llm_formatter.py +0 -0
  25. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/localtypes.py +0 -0
  26. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/metric_aggregator.py +0 -0
  27. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/__init__.py +0 -0
  28. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/exitcodeparser.py +0 -0
  29. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/linecountparser.py +0 -0
  30. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/pytestparser.py +0 -0
  31. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/parsers/regexcountparser.py +0 -0
  32. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/py.typed +0 -0
  33. {python_code_quality-0.1.15 → python_code_quality-0.1.16}/src/py_cq/tool_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.15
3
+ Version: 0.1.16
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-code-quality"
3
- version = "0.1.15"
3
+ version = "0.1.16"
4
4
  description = "Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -47,5 +47,14 @@ cq = "py_cq.main:main"
47
47
  testpaths = ["tests"]
48
48
  norecursedirs = [".venv", "dist"]
49
49
 
50
+ [[tool.ty.overrides]]
51
+ include = ["demo/**"]
52
+ rules = {unresolved-import = "ignore"}
53
+
50
54
  [tool.cq]
51
55
  exclude = ["demo"]
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "hypothesis>=6.151.10",
60
+ ]
@@ -29,8 +29,9 @@ from py_cq.config import load_user_config
29
29
  from py_cq.execution_engine import _cache as tool_cache
30
30
  from py_cq.execution_engine import run_tools
31
31
  from py_cq.language_detector import detect_language
32
- from py_cq.localtypes import CombinedToolResults, ToolConfig
32
+ from py_cq.localtypes import ToolConfig
33
33
  from py_cq.metric_aggregator import aggregate_metrics
34
+ from py_cq.table_formatter import format_as_table
34
35
  from py_cq.tool_registry import tool_registry
35
36
 
36
37
  logging.basicConfig(
@@ -106,7 +107,7 @@ def _version_callback(value: bool) -> None:
106
107
  return
107
108
  import re
108
109
  import sys
109
- if isinstance(sys.stdout, io.TextIOWrapper):
110
+ if isinstance(sys.stdout, io.TextIOWrapper): # pragma: no branch
110
111
  sys.stdout.reconfigure(encoding="utf-8")
111
112
  pkg = "python-code-quality"
112
113
  pkg_version = version(pkg)
@@ -185,7 +186,7 @@ def check(
185
186
  if path_obj.is_file():
186
187
  if path_obj.suffix != ".py":
187
188
  raise typer.BadParameter(f"File must be a Python file (.py): {path}")
188
- elif path_obj.is_dir():
189
+ elif path_obj.is_dir(): # pragma: no branch
189
190
  if not (path_obj / "pyproject.toml").exists():
190
191
  raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
191
192
  log.setLevel(log_level)
@@ -216,7 +217,7 @@ def check(
216
217
  elif output == OutputMode.LLM:
217
218
  # log.setLevel("CRITICAL")
218
219
  from py_cq.llm_formatter import format_for_llm
219
- console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
220
+ print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
220
221
  else:
221
222
  console.print(f"[bold green]{path_obj.resolve()}[/]")
222
223
  console.print(format_as_table(combined_metrics, effective_registry))
@@ -284,41 +285,3 @@ def config(
284
285
  console.print(table)
285
286
 
286
287
 
287
- def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
288
- """Format combined tool results into a Rich Table.
289
-
290
- Args:
291
- data (CombinedToolResults): Aggregated tool results, including the path,
292
- individual tool results, and the overall score.
293
-
294
- Returns:
295
- rich.table.Table: A Rich table with columns ``Tool``, ``Metric``, ``Score`` and
296
- ``Status``. Each metric row displays a status icon based on thresholds from
297
- the tool's configuration. The table is titled with the data path and ends
298
- with a row showing the overall score.
299
-
300
- Example:
301
- >>> table = format_as_table(combined_results)
302
- >>> console.print(table)
303
- """
304
- table = Table(width=80)
305
- table.add_column("Tool", justify="left", no_wrap=True)
306
- table.add_column("Time", justify="right", style="dim")
307
- table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
308
- table.add_column("Score", style="magenta")
309
- table.add_column("Status")
310
- for tr in data.tool_results:
311
- tool_name = tr.raw.tool_name
312
- config = next((t for t in registry.values() if t.name == tool_name))
313
- for i, (name, value) in enumerate(tr.metrics.items()):
314
- status = ""
315
- if value < config.error_threshold:
316
- status = "[bold red]Error[/]"
317
- elif value < config.warning_threshold:
318
- status = "[yellow]Warning[/]"
319
- else:
320
- status = "[green]OK[/]"
321
- time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
322
- table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
323
- table.add_row("", "", "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", "")
324
- return table
@@ -8,5 +8,5 @@ def main():
8
8
  app()
9
9
 
10
10
 
11
- if __name__ == "__main__":
11
+ if __name__ == "__main__": # pragma: no cover
12
12
  main()
@@ -22,6 +22,8 @@ class BanditParser(AbstractParser):
22
22
  data = json.loads(raw_result.stdout)
23
23
  except (json.JSONDecodeError, ValueError):
24
24
  return ToolResult(raw=raw_result, metrics={"security": 1.0})
25
+ if not isinstance(data, dict):
26
+ return ToolResult(raw=raw_result, metrics={"security": 1.0})
25
27
 
26
28
  files: dict[str, list] = {}
27
29
  weighted = 0
@@ -46,7 +48,11 @@ class BanditParser(AbstractParser):
46
48
  if not tr.details:
47
49
  return "bandit reported issues (no details available)"
48
50
  file, issues = next(iter(tr.details.items()))
51
+ if not isinstance(issues, list) or not issues:
52
+ return "bandit reported issues (no details available)"
49
53
  issue = issues[0]
54
+ if not isinstance(issue, dict):
55
+ return "bandit reported issues (no details available)"
50
56
  line = issue.get("line", "?")
51
57
  code = issue.get("code", "")
52
58
  severity = issue.get("severity", "")
@@ -12,17 +12,17 @@ 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
+ import re
15
16
  from pathlib import Path
16
17
 
17
18
 
18
19
  def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
19
20
  """Return up to `count` source lines starting at the given 1-based line number."""
20
- from pathlib import Path
21
21
  try:
22
22
  all_lines = Path(file_path).read_text(encoding="utf-8").splitlines()
23
23
  start = max(0, line - 1)
24
24
  return "\n".join(all_lines[start : start + count])
25
- except OSError:
25
+ except (OSError, ValueError):
26
26
  return ""
27
27
 
28
28
 
@@ -66,7 +66,6 @@ def extract_callee_name(source_line: str) -> str | None:
66
66
  ``func`` rather than the variable on the left. Python keywords and
67
67
  built-ins listed in ``_PYTHON_KEYWORDS`` are excluded.
68
68
  """
69
- import re
70
69
  stripped = source_line.strip()
71
70
  rhs = stripped
72
71
  if "=" in stripped and not stripped.startswith(("assert", "return")):
@@ -78,7 +77,6 @@ def extract_callee_name(source_line: str) -> str | None:
78
77
 
79
78
 
80
79
  def _find_project_root(hint_file: str) -> Path:
81
- from pathlib import Path
82
80
  root = Path(hint_file).resolve().parent
83
81
  current = root
84
82
  for _ in range(8):
@@ -96,7 +94,6 @@ def find_in_project(func_name: str, hint_file: str, max_lines: int = 10) -> tupl
96
94
 
97
95
  Returns ``(file_path, code_block)`` for the first match, or ``("", "")`` if not found.
98
96
  """
99
- from pathlib import Path
100
97
  result = find_function_source(hint_file, func_name, max_lines=max_lines)
101
98
  if result:
102
99
  return hint_file, result
@@ -112,7 +109,6 @@ def find_in_project(func_name: str, hint_file: str, max_lines: int = 10) -> tupl
112
109
 
113
110
  def _relative_path(path: str) -> str:
114
111
  """Return path relative to cwd, normalised to forward slashes."""
115
- from pathlib import Path
116
112
  try:
117
113
  return str(Path(path).relative_to(Path.cwd())).replace("\\", "/")
118
114
  except ValueError:
@@ -130,7 +126,6 @@ def format_callee_context(func_name: str, hint_file: str, max_lines: int = 10) -
130
126
  ...
131
127
  ```
132
128
  """
133
- import re
134
129
  callee_file, code_block = find_in_project(func_name, hint_file, max_lines=max_lines)
135
130
  if not code_block:
136
131
  return ""
@@ -141,12 +136,10 @@ def format_callee_context(func_name: str, hint_file: str, max_lines: int = 10) -
141
136
 
142
137
  def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
143
138
  """Return a fenced python block for the body of func_name, or '' if unavailable."""
144
- from pathlib import Path
145
139
  try:
146
140
  all_lines = Path(file).read_text(encoding="utf-8").splitlines()
147
- except OSError:
141
+ except (OSError, ValueError):
148
142
  return ""
149
- import re
150
143
  pattern = re.compile(rf"^(\s*)(?:async\s+)?def\s+{re.escape(func_name)}\s*\(")
151
144
  match_result: tuple[int, int] | None = None
152
145
  for i, line in enumerate(all_lines):
@@ -64,7 +64,10 @@ class CompileParser(AbstractParser):
64
64
  compilations += 1
65
65
  elif line.startswith("*** File "):
66
66
  # This indicates a compilation error
67
- file_path = line.split('"')[1]
67
+ parts = line.split('"')
68
+ if len(parts) < 2:
69
+ continue
70
+ file_path = parts[1]
68
71
  current_error = {"file": file_path, "error": line}
69
72
  elif current_error and line.strip():
70
73
  # Append additional error context
@@ -80,17 +83,21 @@ class CompileParser(AbstractParser):
80
83
  error_info = {}
81
84
  # Extract line number if present
82
85
  if "line " in error_lines[0]:
83
- error_info["line"] = int(
84
- error_lines[0].split("line ")[1].split(",")[0]
85
- )
86
+ try:
87
+ error_info["line"] = int(
88
+ error_lines[0].split("line ")[1].split(",")[0]
89
+ )
90
+ except ValueError:
91
+ pass
86
92
  # Get source code context if available
87
93
  if len(error_lines) > 1:
88
94
  error_info["src"] = error_lines[1].strip()
89
95
  if len(error_lines) > 3:
90
96
  if "Error:" in error_lines[3]:
91
97
  error_parts = error_lines[3].split(":")
98
+ type_tokens = error_parts[0].strip().split()
92
99
  error_info["type"] = (
93
- error_parts[0].strip().split()[-1]
100
+ type_tokens[-1] if type_tokens else "Unknown"
94
101
  ) # Gets "SyntaxError"
95
102
  error_info["help"] = ",".join(
96
103
  error_parts[1:]
@@ -64,7 +64,14 @@ class ComplexityParser(AbstractParser):
64
64
  >>> result.metrics["simplicity"]
65
65
  0.4"""
66
66
  tr = ToolResult(raw=raw_result)
67
- data = json.loads(raw_result.stdout)
67
+ try:
68
+ data = json.loads(raw_result.stdout)
69
+ except (json.JSONDecodeError, ValueError):
70
+ tr.metrics["simplicity"] = 0.0
71
+ return tr
72
+ if not isinstance(data, dict):
73
+ tr.metrics["simplicity"] = 0.0
74
+ return tr
68
75
  score = 0
69
76
  num_items = 0
70
77
  max_complexity = 30
@@ -76,13 +76,13 @@ class CoverageParser(AbstractParser):
76
76
  score = tr.metrics.get("coverage", 0)
77
77
  uncovered = sorted(
78
78
  [(f, d) for f, d in tr.details.items() if isinstance(d, dict) and d.get("missing")],
79
- key=lambda x: x[1]["coverage"],
79
+ key=lambda x: x[1].get("coverage", 0.0),
80
80
  )[:5]
81
81
  if not uncovered:
82
82
  return f"**coverage** score: {score:.3f}"
83
83
  lines = [f"**coverage** score: {score:.3f} — files with lowest coverage:"]
84
84
  for path, data in uncovered:
85
- pct = data["coverage"]
86
- miss = data["missing"]
85
+ pct = data.get("coverage", 0.0)
86
+ miss = data.get("missing", 0)
87
87
  lines.append(f"- `{path}`: {pct:.0%} ({miss} uncovered statements)")
88
88
  return "\n".join(lines)
@@ -59,6 +59,14 @@ class HalsteadParser(AbstractParser):
59
59
  # {"total": {"h1": 6, "h2": 18, "N1": 13, "N2": 22, "vocabulary": 24, "length": 35, "calculated_length": 90.56842503028855, "volume": 160.4736875252405, "difficulty": 3.6666666666666665, "effort": 588.4035209258818, "time": 32.68908449588233, "bugs": 0.05349122917508017},
60
60
  # "functions": {"calc_dist": {"h1": 3, "h2": 9, "N1": 5, "N2": 10, "vocabulary": 12, "length": 15, "calculated_length": 33.28421251514428, "volume": 53.77443751081735, "difficulty": 1.6666666666666667, "effort": 89.62406251802892, "time": 4.9791145843349405, "bugs": 0.017924812503605784}, "find_nearest_city": {"h1": 1, "h2": 2, "N1": 1, "N2": 2, "vocabulary": 3, "length": 3, "calculated_length": 2.0, "volume": 4.754887502163469, "difficulty": 0.5, "effort": 2.3774437510817346, "time": 0.1320802083934297, "bugs": 0.0015849625007211565}, "generate_tour": {"h1": 2, "h2": 5, "N1": 6, "N2": 8, "vocabulary": 7, "length": 14, "calculated_length": 13.60964047443681, "volume": 39.302968908806456, "difficulty": 1.6, "effort": 62.884750254090335, "time": 3.493597236338352, "bugs": 0.01310098963626882}, "main": {"h1": 0, "h2": 0, "N1": 0, "N2": 0, "vocabulary": 0, "length": 0, "calculated_length": 0, "volume": 0, "difficulty": 0, "effort": 0, "time": 0.0, "bugs": 0.0}}}
61
61
  tr = ToolResult(raw=raw_result)
62
+ try:
63
+ data = json.loads(raw_result.stdout)
64
+ except (json.JSONDecodeError, ValueError):
65
+ tr.metrics = {"file_bug_free": 1.0, "file_smallness": 1.0, "functions_bug_free": 1.0, "functions_smallness": 1.0}
66
+ return tr
67
+ if not isinstance(data, dict):
68
+ tr.metrics = {"file_bug_free": 1.0, "file_smallness": 1.0, "functions_bug_free": 1.0, "functions_smallness": 1.0}
69
+ return tr
62
70
  MAX_FILE_BUGS = 1
63
71
  MAX_FILE_VOLUME = 2000
64
72
  MAX_FUNCTION_BUGS = 0.2
@@ -67,7 +75,6 @@ class HalsteadParser(AbstractParser):
67
75
  min_file_sm = 1.0
68
76
  min_function_nb = 1.0
69
77
  min_function_sm = 1.0
70
- data = json.loads(raw_result.stdout)
71
78
  for file, values in data.items():
72
79
  file_name = file.replace("\\", "/")
73
80
  if file_name not in tr.details:
@@ -45,14 +45,14 @@ class InterrogateParser(AbstractParser):
45
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
- [(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
49
- key=lambda x: x[1]["coverage"],
48
+ [(f, d) for f, d in tr.details.items() if isinstance(d, dict) and d.get("missing", 0) > 0],
49
+ key=lambda x: x[1].get("coverage", 0.0),
50
50
  )[:5]
51
51
  if not uncovered:
52
52
  return f"**doc_coverage** score: {score:.3f}"
53
53
  lines = [f"**doc coverage** {score:.1%} — files with most missing docstrings:"]
54
54
  for path, data in uncovered:
55
- miss = data["missing"]
56
- pct = data["coverage"]
55
+ miss = data.get("missing", 0)
56
+ pct = data.get("coverage", 0.0)
57
57
  lines.append(f"- `{path}`: {pct:.0%} ({miss} undocumented)")
58
58
  return "\n".join(lines)
@@ -41,7 +41,14 @@ class MaintainabilityParser(AbstractParser):
41
41
  * ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
42
42
  * ``details['return_code']`` - the tool's exit code."""
43
43
  tr = ToolResult(raw=raw_result)
44
- data = json.loads(raw_result.stdout)
44
+ try:
45
+ data = json.loads(raw_result.stdout)
46
+ except (json.JSONDecodeError, ValueError):
47
+ tr.metrics["maintainability"] = 0.0
48
+ return tr
49
+ if not isinstance(data, dict):
50
+ tr.metrics["maintainability"] = 0.0
51
+ return tr
45
52
  num_items = 0
46
53
  score = 0
47
54
  for file, values in data.items():
@@ -50,7 +50,11 @@ class RuffParser(AbstractParser):
50
50
  if not tr.details:
51
51
  return "ruff reported issues (no details available)"
52
52
  file, issues = next(iter(tr.details.items()))
53
+ if not isinstance(issues, list) or not issues:
54
+ return "ruff reported issues (no details available)"
53
55
  issue = issues[0]
56
+ if not isinstance(issue, dict):
57
+ return "ruff reported issues (no details available)"
54
58
  line = issue.get("line", "?")
55
59
  code = issue.get("code", "")
56
60
  message = issue.get("message", "")
@@ -64,7 +64,11 @@ class TyParser(AbstractParser):
64
64
  if not tr.details:
65
65
  return "ty reported issues (no details available)"
66
66
  file, issues = next(iter(tr.details.items()))
67
+ if not isinstance(issues, list) or not issues:
68
+ return "ty reported issues (no details available)"
67
69
  issue = issues[0]
70
+ if not isinstance(issue, dict):
71
+ return "ty reported issues (no details available)"
68
72
  line = issue.get("line", "?")
69
73
  code = issue.get("code", "")
70
74
  message = issue.get("message", "")
@@ -40,7 +40,11 @@ class VultureParser(AbstractParser):
40
40
  if not tr.details:
41
41
  return "vulture reported issues (no details available)"
42
42
  file, issues = next(iter(tr.details.items()))
43
+ if not isinstance(issues, list) or not issues:
44
+ return "vulture reported issues (no details available)"
43
45
  issue = issues[0]
46
+ if not isinstance(issue, dict):
47
+ return "vulture reported issues (no details available)"
44
48
  line = issue.get("line", "?")
45
49
  kind = issue.get("type", "unused")
46
50
  name = issue.get("name", "")
@@ -0,0 +1,29 @@
1
+ """Rich table formatter for combined tool results."""
2
+
3
+ from rich.table import Table
4
+
5
+ from py_cq.localtypes import CombinedToolResults, ToolConfig
6
+
7
+
8
+ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]) -> Table:
9
+ """Format combined tool results into a Rich Table."""
10
+ table = Table(width=80)
11
+ table.add_column("Tool", justify="left", no_wrap=True)
12
+ table.add_column("Time", justify="right", style="dim")
13
+ table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
14
+ table.add_column("Score", style="magenta")
15
+ table.add_column("Status")
16
+ for tr in data.tool_results:
17
+ tool_name = tr.raw.tool_name
18
+ config = next((t for t in registry.values() if t.name == tool_name))
19
+ for i, (name, value) in enumerate(tr.metrics.items()):
20
+ if value < config.error_threshold:
21
+ status = "[bold red]Error[/]"
22
+ elif value < config.warning_threshold:
23
+ status = "[yellow]Warning[/]"
24
+ else:
25
+ status = "[green]OK[/]"
26
+ time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
27
+ table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
28
+ table.add_row("", "", "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", "")
29
+ return table