python-code-quality 0.2.2__py3-none-any.whl → 0.3.0__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/localtypes.py CHANGED
@@ -43,7 +43,7 @@ class ToolConfig:
43
43
 
44
44
  name: str # e.g., "pytest", "coverage", "pydocstyle"
45
45
  command: str # The command to execute (can include placeholders)
46
- parser_class: Callable # Name of the parser class to use
46
+ parser_class: Callable # The parser class itself (resolved from its name in config)
47
47
  context_path: str = "" # Path to project or file
48
48
  order: int = 5 # 1=first (compilation), 11=last (style)
49
49
  warning_threshold: float = 0.7 # Yellow warning if below this
@@ -60,6 +60,11 @@ class ToolConfig:
60
60
  default_factory=list
61
61
  ) # Top-level dir/file names to omit from {scan_targets}
62
62
  skip_for_file: bool = False # If True, skip when context_path is a single file
63
+ gate_strict: bool = True # If False, small regressions above warning_threshold are accepted
64
+ skip_if: str = "" # Tool name; if that tool's score is below its error_threshold, skip this tool
65
+ cache_invariant: str = (
66
+ "" # "ast": key the cache on the docstring-stripped AST, ignoring docstrings/comments/format
67
+ )
63
68
 
64
69
 
65
70
  @dataclass
@@ -123,10 +128,12 @@ class ToolResult:
123
128
  }
124
129
 
125
130
 
126
- @dataclass
127
131
  class CombinedToolResults:
128
132
  """Aggregates results from multiple tools, stores the associated path, and calculates an overall score by averaging the mean metric values of each ``ToolResult``. If a ``ToolResult`` has no metrics, it contributes zero, and the score defaults to ``0.0`` when the list is empty."""
129
133
 
134
+ score: float
135
+ path: str
136
+
130
137
  def __init__(self, path: str, tool_results: list[ToolResult]):
131
138
  """Initializes a CombinedToolResults instance.
132
139
 
@@ -148,9 +155,6 @@ class CombinedToolResults:
148
155
  else 0.0
149
156
  )
150
157
 
151
- score: float = 0.0
152
- path: str = ""
153
-
154
158
  def to_dict(self) -> dict:
155
159
  """Returns a dictionary containing the path, overall score, and each ToolResult serialized."""
156
160
  return {
@@ -163,7 +167,7 @@ class CombinedToolResults:
163
167
  class AbstractParser(ABC):
164
168
  """Base class for parsers that transform raw tool output into structured `ToolResult` objects.
165
169
 
166
- 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."""
170
+ Subclasses must implement `parse` to convert a `RawResult` into a `ToolResult`. The `format_llm_message` method can be overridden to supply a richer single-defect description for a parsed result."""
167
171
 
168
172
  def __init__(self, parser_config: dict | None = None):
169
173
  self.parser_config = parser_config or {}
@@ -171,7 +175,6 @@ class AbstractParser(ABC):
171
175
  @abstractmethod
172
176
  def parse(self, raw_result: RawResult) -> ToolResult:
173
177
  """Converts raw tool output into a structured ToolResult."""
174
- pass
175
178
 
176
179
  def format_llm_message(
177
180
  self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
py_cq/parsers/common.py CHANGED
@@ -1,16 +1,18 @@
1
- """Utility functions for normalising numeric values and scoring error magnitudes.
2
-
3
- This module provides two small helpers that are often used when working with
4
- performance metrics or error scores:
5
-
6
- * :func:`inv_normalize` - Inversely normalises a value relative to a maximum
7
- reference, yielding a float in the interval [0,\u202f1].
8
- * :func:`score_logistic_variant` - Maps an error magnitude to a bounded score
9
- using a logistic-style curve, with optional parameters controlling the scale
10
- and steepness of the transition.
11
-
12
- Both functions return a float and can be used directly in downstream analytics,
13
- visualisation or decision-making pipelines."""
1
+ """Shared helpers used across parsers and the LLM formatter.
2
+
3
+ This module groups small utilities for several concerns:
4
+
5
+ * Scoring - :func:`inv_normalize` and :func:`score_logistic_variant` map error
6
+ magnitudes to bounded ``[0, 1]`` scores.
7
+ * Source context - :func:`read_source_lines`, :func:`format_source_context`,
8
+ :func:`find_function_source`, :func:`find_enclosing_function`, and
9
+ :func:`enclosing_function_range` extract fenced code snippets for LLM prompts.
10
+ * Project navigation - :func:`find_in_project`, :func:`format_callee_context`,
11
+ :func:`extract_callee_name`, :func:`resolve_path`, and :func:`_relative_path`
12
+ locate and reference definitions across a project tree.
13
+ * Output formatting - :func:`format_issue_header` builds issue headers.
14
+ * Parsing - :func:`parse_json_dict` and :func:`extract_first_issue` read tool
15
+ output into Python structures."""
14
16
 
15
17
  import json
16
18
  import re
@@ -46,9 +48,7 @@ def format_source_context(
46
48
  collected = []
47
49
  for i, rline in enumerate(raw_lines):
48
50
  if i > error_offset and (
49
- rline.startswith("def ")
50
- or rline.startswith("async def ")
51
- or rline.startswith("class ")
51
+ rline.startswith(("def ", "async def ", "class "))
52
52
  ):
53
53
  break
54
54
  collected.append(f"{context_start + i}: {rline}")
@@ -380,8 +380,7 @@ def score_logistic_variant(
380
380
  1.0
381
381
  >>> score_logistic_variant(10, scale_factor=0)
382
382
  0.0"""
383
- if errors < 0:
384
- errors = 0
383
+ errors = max(errors, 0)
385
384
  if scale_factor == 0:
386
385
  return 1.0 if errors == 0 else 0.0
387
386
  try:
@@ -74,6 +74,8 @@ class ComplexityParser(AbstractParser):
74
74
  num_items = 0
75
75
  max_complexity = 30
76
76
  for file, functions in data.items():
77
+ if not isinstance(functions, list):
78
+ continue
77
79
  file_name = file.replace("\\", "/")
78
80
  if file_name not in tr.details:
79
81
  tr.details[file_name] = {}
@@ -1,11 +1,11 @@
1
1
  """Parses raw coverage tool output into structured ToolResult instances with per-function granularity."""
2
2
 
3
- import ast
4
3
  import logging
5
4
  from pathlib import Path
6
5
 
7
6
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
8
7
  from py_cq.parsers.common import find_function_source, resolve_path
8
+ from py_cq.source_file import get_source
9
9
 
10
10
  log = logging.getLogger("cq")
11
11
 
@@ -29,30 +29,19 @@ def _parse_line_ranges(s: str) -> set[int]:
29
29
  return result
30
30
 
31
31
 
32
- def _get_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
33
- """Return the signature of the function definition as a string."""
34
- prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
35
- args = ast.unparse(node.args)
36
- returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
37
- return f"{prefix} {node.name}({args}){returns}"
38
-
39
-
40
32
  def _extract_functions(file: str, missing_lines_str: str) -> list[tuple[str, int, str]]:
41
33
  """Return (name, lineno, signature) for functions whose bodies overlap with the missing line ranges."""
42
34
  try:
43
- source = Path(file).read_text(encoding="utf-8", errors="replace")
44
- tree = ast.parse(source)
45
- except (OSError, SyntaxError, ValueError):
35
+ funcs = get_source(file).functions
36
+ except OSError:
46
37
  return []
47
38
  missing = _parse_line_ranges(missing_lines_str)
48
39
  seen: set[str] = set()
49
40
  result: list[tuple[str, int, str]] = []
50
- for node in sorted(ast.walk(tree), key=lambda n: getattr(n, "lineno", 0)):
51
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
52
- end = getattr(node, "end_lineno", node.lineno)
53
- if missing & set(range(node.lineno, end + 1)) and node.name not in seen:
54
- seen.add(node.name)
55
- result.append((node.name, node.lineno, _get_signature(node)))
41
+ for fn in funcs:
42
+ if missing & set(range(fn.lineno, fn.end_lineno + 1)) and fn.name not in seen:
43
+ seen.add(fn.name)
44
+ result.append((fn.name, fn.lineno, fn.signature))
56
45
  return result
57
46
 
58
47
 
@@ -146,6 +135,8 @@ class CoverageParser(AbstractParser):
146
135
  ]
147
136
 
148
137
  tr.details = details
138
+ if "coverage" not in tr.metrics:
139
+ tr.metrics["coverage"] = 0.0
149
140
  return tr
150
141
 
151
142
  def format_llm_message(
@@ -199,4 +190,6 @@ class CoverageParser(AbstractParser):
199
190
  parts.append(f"\nAdd tests to: {location}")
200
191
 
201
192
  return "\n".join(parts)
193
+ if tr.metrics.get("coverage", 1.0) == 0.0:
194
+ return "**Coverage: 0%** - pytest failed or collected no tests, so no coverage data is available."
202
195
  return ""
@@ -83,11 +83,12 @@ class HalsteadParser(AbstractParser):
83
83
  "functions": {},
84
84
  }
85
85
  if "error" in values:
86
+ tr.details[file_name]["error"] = values["error"]
86
87
  min_file_nb = 0.0
87
88
  min_file_sm = 0.0
88
89
  min_function_nb = 0.0
89
90
  min_function_sm = 0.0
90
- tr.details[file_name]["error"] = values["error"]
91
+ continue
91
92
  if "total" in values:
92
93
  nb, sm = self.extract_bugs_and_volume(
93
94
  values.get("total", {}), MAX_FILE_BUGS, MAX_FILE_VOLUME
@@ -106,7 +107,7 @@ class HalsteadParser(AbstractParser):
106
107
  min_function_nb = min(nb, min_function_nb)
107
108
  min_function_sm = min(sm, min_function_sm)
108
109
  tr.details[file_name]["functions"][function] = {
109
- "no_bugs": nb,
110
+ "bug_free": nb,
110
111
  "smallness": sm,
111
112
  "bugs": function_values.get("bugs", 0),
112
113
  "volume": function_values.get("volume", 0),
@@ -141,7 +142,7 @@ class HalsteadParser(AbstractParser):
141
142
  for file_name, file_data in tr.details.items():
142
143
  if is_function_metric:
143
144
  for func_name, func_data in file_data.get("functions", {}).items():
144
- s = func_data.get("no_bugs" if is_bug_metric else "smallness", 1.0)
145
+ s = func_data.get("bug_free" if is_bug_metric else "smallness", 1.0)
145
146
  if s < worst_score:
146
147
  worst_score = s
147
148
  worst_file = file_name
@@ -21,6 +21,7 @@ from py_cq.parsers.common import (
21
21
  format_issue_header,
22
22
  format_source_context,
23
23
  )
24
+ from py_cq.source_file import get_source
24
25
 
25
26
  _SKIP_PARAMS = {"self", "cls"}
26
27
 
@@ -34,9 +35,10 @@ def _format_missing_docstring(file_str: str, line: int, code: str, message: str)
34
35
  return base + "\n\nInsert a module-level docstring as the very first statement in the file."
35
36
 
36
37
  try:
37
- source = Path(file_str).read_text(encoding="utf-8")
38
- tree = ast.parse(source)
39
- except (OSError, SyntaxError):
38
+ tree = get_source(file_str).tree
39
+ except OSError:
40
+ tree = None
41
+ if tree is None:
40
42
  return base + "\n\nInsert a docstring as the first statement in the body."
41
43
 
42
44
  for node in ast.walk(tree):
@@ -88,7 +90,7 @@ def _load_interrogate_cfg(context_path: str) -> dict:
88
90
  try:
89
91
  data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
90
92
  return data.get("tool", {}).get("interrogate", {})
91
- except Exception:
93
+ except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
92
94
  return {}
93
95
  return {}
94
96
 
@@ -104,9 +106,7 @@ def _skip_node(name: str, cfg: dict) -> bool:
104
106
  return True
105
107
  if is_private and cfg.get("ignore-private"):
106
108
  return True
107
- if is_semiprivate and cfg.get("ignore-semiprivate"):
108
- return True
109
- return False
109
+ return bool(is_semiprivate and cfg.get("ignore-semiprivate"))
110
110
 
111
111
 
112
112
  def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[int, str, str]]:
@@ -117,11 +117,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
117
117
  the module.
118
118
  """
119
119
  try:
120
- source = file_path.read_text(encoding="utf-8")
121
- tree = ast.parse(source, filename=str(file_path))
122
- except (OSError, SyntaxError):
120
+ sf = get_source(str(file_path))
121
+ tree = sf.tree
122
+ except OSError:
123
123
  return []
124
- src_lines = source.splitlines()
124
+ if tree is None:
125
+ return []
126
+ src_lines = sf.text.splitlines()
125
127
 
126
128
  def src_line(lineno: int) -> str:
127
129
  """Return the stripped content of the source line at the given line number."""
@@ -137,16 +139,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
137
139
 
138
140
  effective_cfg = cfg or {}
139
141
  results = []
140
- for node in ast.walk(tree):
141
- if isinstance(node, ast.Module):
142
- if not ast.get_docstring(node):
143
- results.append((0, "module", first_code_line()))
144
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
145
- if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
146
- results.append((node.lineno, "def", src_line(node.lineno)))
147
- elif isinstance(node, ast.ClassDef):
148
- if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
149
- results.append((node.lineno, "class", src_line(node.lineno)))
142
+ if not sf.module_has_docstring:
143
+ results.append((0, "module", first_code_line()))
144
+ for d in sf.definitions:
145
+ if d.has_docstring or _skip_node(d.name, effective_cfg):
146
+ continue
147
+ kind = "def" if d.kind == "function" else "class"
148
+ results.append((d.lineno, kind, src_line(d.lineno)))
150
149
  results.sort(key=lambda x: x[0])
151
150
  return results
152
151
 
@@ -163,7 +163,7 @@ class PytestParser(AbstractParser):
163
163
  if "no tests ran" in raw_result.stdout:
164
164
  tr.metrics["tests"] = 0.0
165
165
  else:
166
- tests_found = dict()
166
+ tests_found = {}
167
167
  num_tests = 0
168
168
  passed_tests = 0
169
169
  for line in lines:
@@ -123,7 +123,7 @@ def _format_F821(file: str, line: int, message: str) -> str:
123
123
  base += f"\n\nOther references to `{name}` in this file:\n{ref_lines}"
124
124
  else:
125
125
  base += f"\n\n`{name}` is not imported or defined anywhere else in this file."
126
- if enclosing:
126
+ if enclosing and enclosing not in base:
127
127
  base += f"\n\nEnclosing function:{enclosing}"
128
128
  return base
129
129
 
py_cq/source_file.py ADDED
@@ -0,0 +1,199 @@
1
+ """Process-scoped cache of parsed source files, shared across one ``cq check``.
2
+
3
+ Within a single run the same ``.py`` file is otherwise read and parsed several
4
+ times: by the normalized context hash, the coverage parser, the interrogate
5
+ parser, and the LLM formatters. :class:`SourceFile` parses each file at most
6
+ once and exposes lazy projections (functions, classes, docstring presence) that
7
+ downstream consumers walk instead of re-parsing.
8
+
9
+ The cache is keyed on ``(path, size, mtime)`` so a content change (which bumps
10
+ ``mtime``) misses and forces a fresh parse; correctness never depends on a hit.
11
+ """
12
+
13
+ import ast
14
+ import hashlib
15
+ import os
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class FuncInfo:
22
+ """A function or class definition discovered in a source file.
23
+
24
+ ``signature`` is the rendered ``def`` line for functions and ``""`` for
25
+ classes (no consumer needs a class signature). ``kind`` is ``"function"``
26
+ or ``"class"``.
27
+ """
28
+
29
+ name: str
30
+ kind: str
31
+ lineno: int
32
+ end_lineno: int
33
+ has_docstring: bool
34
+ signature: str
35
+
36
+
37
+ def _docstring_invariant_digest(tree: ast.AST) -> str:
38
+ """Return an md5 of ``tree`` with docstrings removed, leaving ``tree`` intact.
39
+
40
+ Docstring statements are temporarily detached, the tree is dumped and
41
+ hashed, then they are reattached - so the digest is docstring-invariant
42
+ without parsing a second time or mutating the tree that other consumers
43
+ share. Safe because the hash pass runs single-threaded before the tool pool.
44
+ """
45
+ detached = []
46
+ for node in ast.walk(tree):
47
+ if isinstance(
48
+ node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
49
+ ):
50
+ body = node.body
51
+ if (
52
+ body
53
+ and isinstance(body[0], ast.Expr)
54
+ and isinstance(body[0].value, ast.Constant)
55
+ and isinstance(body[0].value.value, str)
56
+ ):
57
+ detached.append((node, body[0]))
58
+ node.body = body[1:]
59
+ digest = hashlib.md5(ast.dump(tree).encode()).hexdigest() # nosec
60
+ for node, doc in detached:
61
+ node.body = [doc, *node.body]
62
+ return digest
63
+
64
+
65
+ def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
66
+ """Return the signature of the function definition as a string."""
67
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
68
+ args = ast.unparse(node.args)
69
+ returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
70
+ return f"{prefix} {node.name}({args}){returns}"
71
+
72
+
73
+ class SourceFile:
74
+ """Lazily-realized view of one source file: text, AST, and projections.
75
+
76
+ Fields are computed on first access and cached. ``.tree`` stays pristine
77
+ (docstrings intact) so multiple consumers can share it; ``.norm_digest``
78
+ derives its docstring-stripped digest from a throwaway reparse rather than
79
+ mutating ``.tree``.
80
+ """
81
+
82
+ def __init__(self, path: str) -> None:
83
+ self.path = path
84
+ self._raw: bytes | None = None
85
+ self._text: str | None = None
86
+ self._tree: ast.AST | None = None
87
+ self._parsed = False
88
+ self._norm_digest: str | None = None
89
+ self._definitions: list[FuncInfo] | None = None
90
+
91
+ def _read_bytes(self) -> bytes:
92
+ if self._raw is None:
93
+ self._raw = Path(self.path).read_bytes()
94
+ return self._raw
95
+
96
+ @property
97
+ def text(self) -> str:
98
+ """Return the file contents decoded as UTF-8 (undecodable bytes replaced)."""
99
+ if self._text is None:
100
+ self._text = self._read_bytes().decode("utf-8", "replace")
101
+ return self._text
102
+
103
+ @property
104
+ def tree(self) -> ast.AST | None:
105
+ """Return the parsed AST, or ``None`` if the file can't be parsed."""
106
+ if not self._parsed:
107
+ self._parsed = True
108
+ try:
109
+ self._tree = ast.parse(self.text)
110
+ except (SyntaxError, ValueError):
111
+ self._tree = None
112
+ return self._tree
113
+
114
+ @property
115
+ def norm_digest(self) -> str:
116
+ """Return a docstring/comment/format-invariant digest of the file.
117
+
118
+ Falls back to a byte digest when the file can't be parsed (e.g. a
119
+ mid-edit syntax error), so callers always get a usable digest.
120
+ """
121
+ if self._norm_digest is None:
122
+ tree = self.tree
123
+ if tree is None:
124
+ self._norm_digest = hashlib.md5(self._read_bytes()).hexdigest() # nosec
125
+ else:
126
+ self._norm_digest = _docstring_invariant_digest(tree)
127
+ return self._norm_digest
128
+
129
+ @property
130
+ def definitions(self) -> list[FuncInfo]:
131
+ """Return every function and class definition, in source order."""
132
+ if self._definitions is None:
133
+ defs: list[FuncInfo] = []
134
+ tree = self.tree
135
+ if tree is not None:
136
+ for node in ast.walk(tree):
137
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
138
+ defs.append(
139
+ FuncInfo(
140
+ node.name,
141
+ "function",
142
+ node.lineno,
143
+ getattr(node, "end_lineno", node.lineno),
144
+ ast.get_docstring(node) is not None,
145
+ _signature(node),
146
+ )
147
+ )
148
+ elif isinstance(node, ast.ClassDef):
149
+ defs.append(
150
+ FuncInfo(
151
+ node.name,
152
+ "class",
153
+ node.lineno,
154
+ getattr(node, "end_lineno", node.lineno),
155
+ ast.get_docstring(node) is not None,
156
+ "",
157
+ )
158
+ )
159
+ defs.sort(key=lambda d: d.lineno)
160
+ self._definitions = defs
161
+ return self._definitions
162
+
163
+ @property
164
+ def functions(self) -> list[FuncInfo]:
165
+ """Return the function definitions only (no classes), in source order."""
166
+ return [d for d in self.definitions if d.kind == "function"]
167
+
168
+ @property
169
+ def module_has_docstring(self) -> bool:
170
+ """Return True if the module has a docstring."""
171
+ tree = self.tree
172
+ return isinstance(tree, ast.Module) and ast.get_docstring(tree) is not None
173
+
174
+
175
+ # Process-scoped memo: (path, size, mtime) -> SourceFile. A stale mtime only
176
+ # forces a re-parse that reproduces the same projections, so correctness never
177
+ # depends on this cache (matches the lock-free design: a duplicate parse is
178
+ # harmless and GIL-serialized).
179
+ _source_cache: dict[tuple[str, int, float], SourceFile] = {}
180
+
181
+
182
+ def get_source(path: str) -> SourceFile:
183
+ """Return the cached :class:`SourceFile` for ``path``, creating it on miss.
184
+
185
+ Keyed on ``(canonical_path, size, mtime)``; a content change misses and
186
+ re-parses. The path is canonicalized (absolute + OS case folding) so the
187
+ hash pass and the parsers - which spell the same file differently (relative
188
+ vs. resolved/posix) - share one cache entry.
189
+
190
+ Raises:
191
+ OSError: If ``path`` cannot be stat'd.
192
+ """
193
+ s = os.stat(path)
194
+ key = (os.path.normcase(os.path.abspath(path)), s.st_size, s.st_mtime)
195
+ sf = _source_cache.get(key)
196
+ if sf is None:
197
+ sf = SourceFile(path)
198
+ _source_cache[key] = sf
199
+ return sf
py_cq/table_formatter.py CHANGED
@@ -21,7 +21,7 @@ def format_as_table(
21
21
  table.add_column("Status")
22
22
  for tr in data.tool_results:
23
23
  tool_name = tr.raw.tool_name
24
- config = next((t for t in registry.values() if t.name == tool_name))
24
+ config = next(t for t in registry.values() if t.name == tool_name)
25
25
  for i, (name, value) in enumerate(tr.metrics.items()):
26
26
  if value < config.error_threshold:
27
27
  status = "[bold red]Error[/]"
py_cq/tool_registry.py CHANGED
@@ -32,6 +32,9 @@ def load_tool_configs() -> dict[str, ToolConfig]:
32
32
  exclude_format=tool_data.get("exclude_format", ""),
33
33
  scan_exclude_names=tool_data.get("scan_exclude_names", []),
34
34
  skip_for_file=tool_data.get("skip_for_file", False),
35
+ gate_strict=tool_data.get("gate_strict", True),
36
+ skip_if=tool_data.get("skip_if", ""),
37
+ cache_invariant=tool_data.get("cache_invariant", ""),
35
38
  )
36
39
  return registry
37
40