python-code-quality 0.1.15__py3-none-any.whl → 0.2.1__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
@@ -1,6 +1,5 @@
1
- """Provides a simple greeting function that returns a friendly message.
1
+ """Py cq"""
2
2
 
3
- The module defines a single function, `hello`, which returns the string
4
- `'Hello from py_cq!'`. It can serve as a minimal example, placeholder, or
5
- testing stub in larger applications."""
3
+ from py_cq.api import CQ
6
4
 
5
+ __all__ = ["CQ"]
py_cq/api.py ADDED
@@ -0,0 +1,248 @@
1
+ """Library API for py-cq. Instantiate CQ with a project root, then call methods."""
2
+
3
+ import copy
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+
7
+ from py_cq.config import load_user_config
8
+ from py_cq.execution_engine import _cache, run_tools
9
+ from py_cq.llm_formatter import format_for_llm_json
10
+ from py_cq.localtypes import CombinedToolResults, Fingerprint, ToolConfig, ToolResult
11
+ from py_cq.metric_aggregator import aggregate_metrics
12
+ from py_cq.tool_registry import tool_registry
13
+
14
+ _KNOWN_PARSER_CLASSES = frozenset(
15
+ {
16
+ "CompileParser",
17
+ "RuffParser",
18
+ "TyParser",
19
+ "BanditParser",
20
+ "PytestParser",
21
+ "CoverageParser",
22
+ "ComplexityParser",
23
+ "MaintainabilityParser",
24
+ "HalsteadParser",
25
+ "VultureParser",
26
+ "InterrogateParser",
27
+ "ExitCodeParser",
28
+ "LineCountParser",
29
+ "RegexCountParser",
30
+ }
31
+ )
32
+
33
+
34
+ def _apply_user_config(
35
+ base: dict[str, ToolConfig], user_cfg: dict
36
+ ) -> dict[str, ToolConfig]:
37
+ """Return a modified copy of base with user overrides applied.
38
+
39
+ Raises ValueError on invalid config (caller wraps for CLI context).
40
+ """
41
+ registry = {k: copy.copy(v) for k, v in base.items()}
42
+ for tool_id in user_cfg.get("disable", []):
43
+ registry.pop(tool_id, None)
44
+ for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
45
+ if tool_id in registry:
46
+ if "warning" in thresholds:
47
+ registry[tool_id].warning_threshold = float(thresholds["warning"])
48
+ if "error" in thresholds:
49
+ registry[tool_id].error_threshold = float(thresholds["error"])
50
+ for tool_id, tool_data in user_cfg.get("tools", {}).items():
51
+ if tool_id in base:
52
+ available = ", ".join(sorted(base))
53
+ raise ValueError(
54
+ f"[tool.cq.tools.{tool_id}] is a built-in tool and cannot be redefined via pyproject.toml. "
55
+ f"Use [tool.cq.thresholds.{tool_id}] to adjust thresholds instead. "
56
+ f"Available: {available}"
57
+ )
58
+ try:
59
+ parser_name = tool_data["parser"]
60
+ except KeyError:
61
+ raise ValueError(
62
+ f"[tool.cq.tools.{tool_id}] missing required field 'parser'"
63
+ )
64
+ if parser_name not in _KNOWN_PARSER_CLASSES:
65
+ allowed = ", ".join(sorted(_KNOWN_PARSER_CLASSES))
66
+ raise ValueError(
67
+ f"[tool.cq.tools.{tool_id}] unknown parser {parser_name!r}. "
68
+ f"Allowed parsers: {allowed}"
69
+ )
70
+ try:
71
+ module = import_module(f"py_cq.parsers.{parser_name.lower()}")
72
+ parser_class = getattr(module, parser_name)
73
+ registry[tool_id] = ToolConfig(
74
+ name=tool_id,
75
+ command=tool_data["command"],
76
+ parser_class=parser_class,
77
+ order=tool_data["order"],
78
+ warning_threshold=tool_data["warning_threshold"],
79
+ error_threshold=tool_data["error_threshold"],
80
+ run_in_target_env=tool_data.get("run_in_target_env", False),
81
+ extra_deps=tool_data.get("extra_deps", []),
82
+ parser_config=tool_data.get("parser_config", {}),
83
+ exclude_format=tool_data.get("exclude_format", ""),
84
+ )
85
+ except (KeyError, ImportError, AttributeError) as e:
86
+ raise ValueError(f"[tool.cq.tools.{tool_id}] {e}")
87
+ return registry
88
+
89
+
90
+ class CQ:
91
+ """Run code quality checks against a project root.
92
+
93
+ Config is loaded once at construction from pyproject.toml [tool.cq].
94
+ All methods return data objects; formatting is left to the caller.
95
+
96
+ Example::
97
+
98
+ cq = CQ(".")
99
+ issue = cq.check_llm_json() # {"id": ..., "message": ..., "file": ..., "project": ...}
100
+ fixed = cq.is_fixed(issue["id"])
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ path: str | Path,
106
+ *,
107
+ skip: list[str] | None = None,
108
+ only: list[str] | None = None,
109
+ exclude: list[str] | None = None,
110
+ workers: int = 0,
111
+ clear_cache: bool = False,
112
+ ) -> None:
113
+ self.path = Path(path)
114
+ self._workers = workers
115
+
116
+ user_cfg = load_user_config(self.path)
117
+ self._context_lines: int = int(user_cfg.get("context_lines", 15))
118
+ self._registry = _apply_user_config(tool_registry, user_cfg)
119
+
120
+ if self.path.is_file():
121
+ self._registry = {
122
+ k: v for k, v in self._registry.items() if not v.skip_for_file
123
+ }
124
+
125
+ if only:
126
+ keep = set(only)
127
+ unknown = keep - set(self._registry)
128
+ if unknown:
129
+ raise ValueError(
130
+ f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
131
+ )
132
+ self._registry = {k: v for k, v in self._registry.items() if k in keep}
133
+ if skip:
134
+ drop = set(skip)
135
+ unknown = drop - set(self._registry)
136
+ if unknown:
137
+ raise ValueError(
138
+ f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
139
+ )
140
+ self._registry = {k: v for k, v in self._registry.items() if k not in drop}
141
+
142
+ config_excludes: list[str] = user_cfg.get("exclude", [])
143
+ self._excludes = list(dict.fromkeys(config_excludes + (exclude or [])))
144
+ self._project_root = (
145
+ self.path.resolve() if self.path.is_dir() else self.path.resolve().parent
146
+ )
147
+
148
+ if clear_cache:
149
+ _cache.clear()
150
+
151
+ def raw(self, *, early_exit: bool = False) -> list[ToolResult]:
152
+ """Run all tools and return parsed results before aggregation."""
153
+ return run_tools(
154
+ self._registry.values(),
155
+ str(self.path),
156
+ self._workers,
157
+ early_exit=early_exit,
158
+ excludes=self._excludes,
159
+ )
160
+
161
+ def check(self) -> CombinedToolResults:
162
+ """Run all tools and return aggregated results."""
163
+ return aggregate_metrics(str(self.path), self.raw())
164
+
165
+ def check_llm_json(
166
+ self,
167
+ *,
168
+ limit: int = 1,
169
+ silence: list[str] | None = None,
170
+ hint: bool = False,
171
+ ) -> dict:
172
+ """Return the top defect as a dict with keys: id, file, project, message.
173
+
174
+ Stops running tools after the first error (early_exit) for speed.
175
+ """
176
+ results = self.raw(early_exit=True)
177
+ combined = aggregate_metrics(str(self.path), results)
178
+ return format_for_llm_json(
179
+ self._registry,
180
+ combined,
181
+ context_lines=self._context_lines,
182
+ hint=hint,
183
+ limit=limit,
184
+ silence=silence or [],
185
+ project_root=self._project_root,
186
+ )
187
+
188
+ def is_fixed(self, fingerprint: str) -> bool:
189
+ """Return True if the fingerprinted issue is no longer present.
190
+
191
+ Fingerprint format: ``tool::project::path[::line[::code]]`` (as returned by check_llm_json["id"])
192
+ """
193
+ fp = Fingerprint.from_string(fingerprint)
194
+ if not fp.tool:
195
+ raise ValueError(
196
+ f"Expected tool::project::path[::line[::code]], got: {fingerprint!r}"
197
+ )
198
+ if fp.tool not in tool_registry:
199
+ raise ValueError(f"Unknown tool: {fp.tool!r}")
200
+
201
+ if fp.code and fp.path:
202
+ # Specific issue: target only the affected file for a fast re-check
203
+ file_path = Path(fp.path)
204
+ if not file_path.is_absolute():
205
+ base = Path(fp.project) if fp.project else self._project_root
206
+ file_path = (base / file_path) if base else file_path
207
+ target = str(file_path)
208
+ elif fp.project and Path(fp.project).is_dir():
209
+ target = fp.project
210
+ elif fp.path:
211
+ file_path = Path(fp.path)
212
+ if not file_path.is_absolute():
213
+ file_path = (
214
+ (self._project_root / file_path)
215
+ if self._project_root
216
+ else file_path
217
+ )
218
+ target = str(file_path)
219
+ else:
220
+ target = str(self.path)
221
+
222
+ only_registry = {fp.tool: tool_registry[fp.tool]}
223
+ tool_results = run_tools(
224
+ only_registry.values(), target, max_workers=1, early_exit=False, excludes=[],
225
+ project_root=str(self.path),
226
+ )
227
+
228
+ if not tool_results:
229
+ return False
230
+
231
+ tr = tool_results[0]
232
+ tc = tool_registry[fp.tool]
233
+
234
+ if not fp.code:
235
+ return bool(tr.metrics and min(tr.metrics.values()) >= tc.warning_threshold)
236
+
237
+ def _file_matches(detail_file: str) -> bool:
238
+ p = Path(detail_file).as_posix()
239
+ t = Path(fp.path).as_posix()
240
+ return p == t or p.endswith(f"/{t}") or t.endswith(f"/{p}")
241
+
242
+ still_present = any(
243
+ str(i.get("line", "")) == fp.line and i.get("code") == fp.code
244
+ for f, issues in tr.details.items()
245
+ if _file_matches(f) and isinstance(issues, list)
246
+ for i in issues
247
+ )
248
+ return not still_present