readability-cli 0.4.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.
- configs/pyrefly.toml +11 -0
- configs/ruff.toml +20 -0
- guides/Rguide.md +109 -0
- guides/cppguide.md +5713 -0
- guides/csharp-style.md +478 -0
- guides/docguide-style.md +845 -0
- guides/go-guide.md +483 -0
- guides/htmlcssguide.md +1022 -0
- guides/javaguide.md +1189 -0
- guides/jsguide.md +3775 -0
- guides/jsoncstyleguide.md +1376 -0
- guides/objcguide.md +2386 -0
- guides/pyguide.md +3709 -0
- guides/shellguide.md +1343 -0
- guides/tsguide.md +3662 -0
- guides/vimscriptguide.md +356 -0
- readability.py +755 -0
- readability_cli-0.4.0.dist-info/METADATA +192 -0
- readability_cli-0.4.0.dist-info/RECORD +22 -0
- readability_cli-0.4.0.dist-info/WHEEL +4 -0
- readability_cli-0.4.0.dist-info/entry_points.txt +2 -0
- readability_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
readability.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""CLI for fetching Google style guides and running code quality tools."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
import warnings
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import requests
|
|
16
|
+
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
|
17
|
+
from markdownify import markdownify as md
|
|
18
|
+
|
|
19
|
+
# Suppress BeautifulSoup warning when parsing XML as HTML
|
|
20
|
+
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("readability")
|
|
23
|
+
|
|
24
|
+
# Default timeout for subprocess calls in seconds
|
|
25
|
+
DEFAULT_TIMEOUT = 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_guides_dir() -> str:
|
|
29
|
+
"""Get the directory where style guides are cached.
|
|
30
|
+
|
|
31
|
+
Defaults to 'guides/' in the same directory as this script, but can be
|
|
32
|
+
overridden by the READABILITY_CACHE environment variable.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The path to the guides directory.
|
|
36
|
+
"""
|
|
37
|
+
return os.getenv("READABILITY_CACHE") or os.path.join(
|
|
38
|
+
os.path.dirname(os.path.abspath(__file__)), "guides"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Mapping of languages to their Google Style Guide file paths
|
|
43
|
+
LANGUAGE_MAP = {
|
|
44
|
+
"python": "pyguide.md",
|
|
45
|
+
"shell": "shellguide.md",
|
|
46
|
+
"objc": "objcguide.md",
|
|
47
|
+
"objective-c": "objcguide.md",
|
|
48
|
+
"r": "Rguide.md",
|
|
49
|
+
"csharp": "csharp-style.md",
|
|
50
|
+
"c#": "csharp-style.md",
|
|
51
|
+
"docguide": "docguide/style.md",
|
|
52
|
+
"markdown": "docguide/style.md",
|
|
53
|
+
"go": "go/guide.md",
|
|
54
|
+
"cpp": "cppguide.html",
|
|
55
|
+
"c++": "cppguide.html",
|
|
56
|
+
"java": "javaguide.html",
|
|
57
|
+
"js": "jsguide.html",
|
|
58
|
+
"javascript": "jsguide.html",
|
|
59
|
+
"ts": "tsguide.html",
|
|
60
|
+
"typescript": "tsguide.html",
|
|
61
|
+
"html": "htmlcssguide.html",
|
|
62
|
+
"css": "htmlcssguide.html",
|
|
63
|
+
"json": "jsoncstyleguide.xml",
|
|
64
|
+
"vim": "vimscriptguide.xml",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
BASE_URL = "https://google.github.io/styleguide/"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_guide_content(url: str) -> str:
|
|
71
|
+
"""Fetch raw content from the specified URL.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
url: The URL to fetch content from.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The raw text content from the URL.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
click.ClickException: If the HTTP request fails.
|
|
81
|
+
"""
|
|
82
|
+
logger.info("Fetching style guide from %s", url)
|
|
83
|
+
|
|
84
|
+
# Perform the HTTP GET request with a timeout
|
|
85
|
+
try:
|
|
86
|
+
response = requests.get(url, timeout=10)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
except requests.exceptions.RequestException as e:
|
|
89
|
+
logger.error("Failed to fetch content from %s: %s", url, e)
|
|
90
|
+
raise click.ClickException(
|
|
91
|
+
f"Failed to fetch style guide from {url}: {e}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return response.text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def convert_to_markdown(content: str, filename: str) -> str:
|
|
98
|
+
"""Convert the raw content to markdown based on file extension.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
content: The raw text content to convert.
|
|
102
|
+
filename: The original filename to determine conversion logic.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The converted markdown content.
|
|
106
|
+
"""
|
|
107
|
+
logger.debug("Converting content for %s", filename)
|
|
108
|
+
|
|
109
|
+
# Handle Markdown files directly
|
|
110
|
+
if filename.endswith(".md"):
|
|
111
|
+
return content
|
|
112
|
+
|
|
113
|
+
# Strip XML prologue if present to avoid it leaking into the output
|
|
114
|
+
if content.lstrip().startswith("<?xml"):
|
|
115
|
+
content = content.split("?>", 1)[-1].lstrip()
|
|
116
|
+
|
|
117
|
+
# Handle XML files (used for Vim script guide and JSON style guide)
|
|
118
|
+
if filename.endswith(".xml"):
|
|
119
|
+
soup = BeautifulSoup(content, "html.parser")
|
|
120
|
+
|
|
121
|
+
# Add titles as headers
|
|
122
|
+
guide = soup.find("guide")
|
|
123
|
+
if guide:
|
|
124
|
+
title = guide.get("title")
|
|
125
|
+
if isinstance(title, str) and title:
|
|
126
|
+
h1 = soup.new_tag("h1")
|
|
127
|
+
h1.string = title
|
|
128
|
+
guide.insert(0, h1)
|
|
129
|
+
|
|
130
|
+
for category in soup.find_all("category"):
|
|
131
|
+
title = category.get("title")
|
|
132
|
+
if isinstance(title, str) and title:
|
|
133
|
+
h2 = soup.new_tag("h2")
|
|
134
|
+
h2.string = title
|
|
135
|
+
category.insert(0, h2)
|
|
136
|
+
|
|
137
|
+
for sp in soup.find_all("stylepoint"):
|
|
138
|
+
title = sp.get("title")
|
|
139
|
+
if isinstance(title, str) and title:
|
|
140
|
+
h3 = soup.new_tag("h3")
|
|
141
|
+
h3.string = title
|
|
142
|
+
sp.insert(0, h3)
|
|
143
|
+
|
|
144
|
+
for summary in soup.find_all("summary"):
|
|
145
|
+
summary.name = "p"
|
|
146
|
+
# Wrap content in strong tags
|
|
147
|
+
content_str = summary.decode_contents()
|
|
148
|
+
summary.clear()
|
|
149
|
+
strong = soup.new_tag("strong")
|
|
150
|
+
strong.append(BeautifulSoup(content_str, "html.parser"))
|
|
151
|
+
summary.append(strong)
|
|
152
|
+
|
|
153
|
+
for snippet in soup.find_all(["code_snippet", "bad_code_snippet"]):
|
|
154
|
+
is_bad = snippet.name == "bad_code_snippet"
|
|
155
|
+
snippet.name = "pre"
|
|
156
|
+
code = soup.new_tag("code")
|
|
157
|
+
code.string = snippet.get_text()
|
|
158
|
+
snippet.clear()
|
|
159
|
+
if is_bad:
|
|
160
|
+
p = soup.new_tag("p")
|
|
161
|
+
strong = soup.new_tag("strong")
|
|
162
|
+
strong.string = "BAD:"
|
|
163
|
+
p.append(strong)
|
|
164
|
+
snippet.append(p)
|
|
165
|
+
snippet.append(code)
|
|
166
|
+
|
|
167
|
+
# Convert the modified soup to string and then to markdown
|
|
168
|
+
return md(str(soup), heading_style="ATX")
|
|
169
|
+
|
|
170
|
+
# Handle HTML files by converting them to Markdown
|
|
171
|
+
if filename.endswith(".html"):
|
|
172
|
+
return md(content, heading_style="ATX")
|
|
173
|
+
|
|
174
|
+
# Fallback to returning raw content
|
|
175
|
+
return content
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_local_path(filename: str) -> str:
|
|
179
|
+
"""Get the local path for a given style guide filename.
|
|
180
|
+
|
|
181
|
+
Flattens the filename by replacing path separators with dashes and ensures
|
|
182
|
+
the file has a .md extension for uniform storage.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
filename: The original filename or relative path from the style guide
|
|
186
|
+
repository (e.g., 'pyguide.md' or 'go/guide.md').
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The full local path to the cached markdown file.
|
|
190
|
+
"""
|
|
191
|
+
# Flatten the filename by replacing '/' with '-'
|
|
192
|
+
flattened = filename.replace("/", "-")
|
|
193
|
+
# Use the flattened filename and change extension to .md for uniform storage
|
|
194
|
+
base_name = flattened.rsplit(".", 1)[0]
|
|
195
|
+
return os.path.join(get_guides_dir(), f"{base_name}.md")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_guide(language: str, remote: bool = False) -> str:
|
|
199
|
+
"""Orchestrate fetching and converting the style guide for a given language.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
language: The language to fetch the guide for.
|
|
203
|
+
remote: Whether to force fetching from the web instead of local cache.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The markdown content of the style guide.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
click.UsageError: If the language is not supported.
|
|
210
|
+
"""
|
|
211
|
+
# Look up the filename in the mapping
|
|
212
|
+
filename = LANGUAGE_MAP.get(language.lower())
|
|
213
|
+
if not filename:
|
|
214
|
+
error_msg = f"Language '{language}' is not supported."
|
|
215
|
+
logger.warning(error_msg)
|
|
216
|
+
raise click.UsageError(
|
|
217
|
+
f"{error_msg} Supported languages: "
|
|
218
|
+
f"{', '.join(sorted(LANGUAGE_MAP.keys()))}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
local_path = get_local_path(filename)
|
|
222
|
+
|
|
223
|
+
# If remote is False, check for local file first
|
|
224
|
+
if not remote and os.path.exists(local_path):
|
|
225
|
+
logger.info("Reading style guide from local file: %s", local_path)
|
|
226
|
+
with open(local_path, "r", encoding="utf-8") as f:
|
|
227
|
+
return f.read()
|
|
228
|
+
|
|
229
|
+
# Build the full URL and fetch the raw content
|
|
230
|
+
url = f"{BASE_URL}{filename}"
|
|
231
|
+
content = get_guide_content(url)
|
|
232
|
+
|
|
233
|
+
# Convert the content to Markdown format
|
|
234
|
+
markdown_content = convert_to_markdown(content, filename)
|
|
235
|
+
|
|
236
|
+
# Save to local cache for future use
|
|
237
|
+
if not os.path.exists(get_guides_dir()):
|
|
238
|
+
os.makedirs(get_guides_dir(), exist_ok=True)
|
|
239
|
+
|
|
240
|
+
with open(local_path, "w", encoding="utf-8") as f:
|
|
241
|
+
f.write(markdown_content)
|
|
242
|
+
logger.debug("Cached style guide locally: %s", local_path)
|
|
243
|
+
|
|
244
|
+
return markdown_content
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@click.group(invoke_without_command=True)
|
|
248
|
+
@click.pass_context
|
|
249
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
|
|
250
|
+
def cli(ctx: click.Context, verbose: bool) -> None:
|
|
251
|
+
"""Pulls the latest Google style guide in markdown format."""
|
|
252
|
+
if verbose:
|
|
253
|
+
logger.setLevel(logging.DEBUG)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@cli.command()
|
|
257
|
+
@click.argument("language")
|
|
258
|
+
@click.option(
|
|
259
|
+
"--output",
|
|
260
|
+
"-o",
|
|
261
|
+
type=click.Path(),
|
|
262
|
+
help="Path to save the style guide markdown.",
|
|
263
|
+
)
|
|
264
|
+
@click.option(
|
|
265
|
+
"--remote", "-r", is_flag=True, help="Force fetching from the web."
|
|
266
|
+
)
|
|
267
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
|
|
268
|
+
def guide(
|
|
269
|
+
language: str, output: Optional[str], remote: bool, verbose: bool
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Fetch the style guide for a specific LANGUAGE."""
|
|
272
|
+
if verbose:
|
|
273
|
+
logger.setLevel(logging.DEBUG)
|
|
274
|
+
|
|
275
|
+
logger.info("Processing style guide for: %s", language)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
# Fetch and process the style guide
|
|
279
|
+
markdown_content = get_guide(language, remote=remote)
|
|
280
|
+
|
|
281
|
+
# Handle output: either save to file or print to stdout
|
|
282
|
+
if output:
|
|
283
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
284
|
+
f.write(markdown_content)
|
|
285
|
+
logger.info("Style guide saved to %s", output)
|
|
286
|
+
else:
|
|
287
|
+
click.echo(markdown_content)
|
|
288
|
+
|
|
289
|
+
except (click.ClickException, click.UsageError) as e:
|
|
290
|
+
logger.error("Execution failed: %s", e)
|
|
291
|
+
click.echo(f"Error: {e}", err=True)
|
|
292
|
+
sys.exit(1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@cli.command()
|
|
296
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
|
|
297
|
+
def sync(verbose: bool) -> None:
|
|
298
|
+
"""Synchronize all supported style guides from the web to local storage."""
|
|
299
|
+
if verbose:
|
|
300
|
+
logger.setLevel(logging.DEBUG)
|
|
301
|
+
|
|
302
|
+
logger.info("Synchronizing all style guides...")
|
|
303
|
+
|
|
304
|
+
if not os.path.exists(get_guides_dir()):
|
|
305
|
+
os.makedirs(get_guides_dir(), exist_ok=True)
|
|
306
|
+
|
|
307
|
+
# Get unique filenames to avoid redundant downloads
|
|
308
|
+
filenames = set(LANGUAGE_MAP.values())
|
|
309
|
+
|
|
310
|
+
success_count = 0
|
|
311
|
+
failure_count = 0
|
|
312
|
+
|
|
313
|
+
for filename in sorted(filenames):
|
|
314
|
+
logger.info("Syncing %s...", filename)
|
|
315
|
+
try:
|
|
316
|
+
url = f"{BASE_URL}{filename}"
|
|
317
|
+
content = get_guide_content(url)
|
|
318
|
+
markdown_content = convert_to_markdown(content, filename)
|
|
319
|
+
local_path = get_local_path(filename)
|
|
320
|
+
|
|
321
|
+
with open(local_path, "w", encoding="utf-8") as f:
|
|
322
|
+
f.write(markdown_content)
|
|
323
|
+
|
|
324
|
+
logger.info("Successfully synced %s to %s", filename, local_path)
|
|
325
|
+
success_count += 1
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.error("Failed to sync %s: %s", filename, e)
|
|
328
|
+
failure_count += 1
|
|
329
|
+
|
|
330
|
+
logger.info(
|
|
331
|
+
"Sync complete. Successes: %d, Failures: %d",
|
|
332
|
+
success_count,
|
|
333
|
+
failure_count,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@cli.command()
|
|
338
|
+
def languages() -> None:
|
|
339
|
+
"""List all supported languages and their aliases."""
|
|
340
|
+
# Group languages by their target guide
|
|
341
|
+
guides = {}
|
|
342
|
+
for lang, filename in LANGUAGE_MAP.items():
|
|
343
|
+
if filename not in guides:
|
|
344
|
+
guides[filename] = []
|
|
345
|
+
guides[filename].append(lang)
|
|
346
|
+
|
|
347
|
+
click.echo("Supported languages and their aliases:")
|
|
348
|
+
for filename in sorted(guides.keys()):
|
|
349
|
+
aliases = sorted(guides[filename])
|
|
350
|
+
|
|
351
|
+
# Check if the guide is cached
|
|
352
|
+
local_path = get_local_path(filename)
|
|
353
|
+
cached_label = " [cached]" if os.path.exists(local_path) else ""
|
|
354
|
+
|
|
355
|
+
click.echo(f" - {', '.join(aliases)}{cached_label}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@cli.command()
|
|
359
|
+
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
|
|
360
|
+
@click.option(
|
|
361
|
+
"--fix", is_flag=True, help="Automatically fix issues if possible."
|
|
362
|
+
)
|
|
363
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
|
|
364
|
+
def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
|
|
365
|
+
"""Run relevant formatters and linters for given paths."""
|
|
366
|
+
if verbose:
|
|
367
|
+
logger.setLevel(logging.DEBUG)
|
|
368
|
+
|
|
369
|
+
# Resolve project root once for trigger file checking
|
|
370
|
+
project_root = Path.cwd()
|
|
371
|
+
|
|
372
|
+
# Process each provided path independently
|
|
373
|
+
for path_str in paths:
|
|
374
|
+
_check_path(Path(path_str), project_root, fix=fix)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _check_path(path: Path, project_root: Path, fix: bool = False) -> None:
|
|
378
|
+
"""Apply relevant tools to a single path.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
path: The path (file or directory) to check.
|
|
382
|
+
project_root: The root of the project for trigger file discovery.
|
|
383
|
+
fix: Whether to apply automatic fixes.
|
|
384
|
+
"""
|
|
385
|
+
logger.info("Checking path: %s", path)
|
|
386
|
+
|
|
387
|
+
# Iterate through all supported tool definitions
|
|
388
|
+
for tool in _get_tool_definitions(path, project_root):
|
|
389
|
+
if _should_run_tool(tool, path, project_root):
|
|
390
|
+
_run_tool(tool["name"], tool, fix=fix)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _should_run_tool(
|
|
394
|
+
tool: dict[str, Any], path: Path, project_root: Path
|
|
395
|
+
) -> bool:
|
|
396
|
+
"""Determine if a tool should run based on triggers and extensions.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
tool: The tool configuration dictionary.
|
|
400
|
+
path: The path being checked.
|
|
401
|
+
project_root: The project root directory.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True if the tool should run, False otherwise.
|
|
405
|
+
"""
|
|
406
|
+
# Check if any trigger files (like pyproject.toml) exist in the project root
|
|
407
|
+
has_trigger = any((project_root / t).exists() for t in tool["trigger"])
|
|
408
|
+
|
|
409
|
+
# For files, also check if the extension matches one of the supported ones
|
|
410
|
+
if path.is_file():
|
|
411
|
+
return has_trigger and path.suffix in tool["extensions"]
|
|
412
|
+
|
|
413
|
+
# For directories, the existence of a trigger file is sufficient
|
|
414
|
+
return has_trigger
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _bundled_config(tool_name: str) -> Path:
|
|
418
|
+
"""Get the path to the bundled default configuration for a tool.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
tool_name: The name of the tool (e.g. "ruff", "pyrefly").
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
The path to the bundled default config file.
|
|
425
|
+
"""
|
|
426
|
+
return Path(__file__).parent / "configs" / f"{tool_name}.toml"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _has_project_config(
|
|
430
|
+
project_root: Path, config_files: Sequence[str], tool_name: str
|
|
431
|
+
) -> bool:
|
|
432
|
+
"""Determine whether the project defines its own configuration for a tool.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
project_root: The project root directory.
|
|
436
|
+
config_files: Dedicated config filenames to look for (e.g. ruff.toml).
|
|
437
|
+
tool_name: The pyproject.toml [tool.<name>] section to look for.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
True if the project has its own configuration, False otherwise.
|
|
441
|
+
"""
|
|
442
|
+
# Dedicated config files take precedence over pyproject.toml sections
|
|
443
|
+
if any((project_root / f).exists() for f in config_files):
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
# Otherwise look for a [tool.<name>] section in pyproject.toml
|
|
447
|
+
pyproject = project_root / "pyproject.toml"
|
|
448
|
+
if not pyproject.exists():
|
|
449
|
+
return False
|
|
450
|
+
try:
|
|
451
|
+
data = tomllib.loads(pyproject.read_text())
|
|
452
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
453
|
+
logger.warning("Failed to parse %s: %s", pyproject, e)
|
|
454
|
+
return False
|
|
455
|
+
return tool_name in data.get("tool", {})
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _default_config_args(
|
|
459
|
+
project_root: Path, config_files: Sequence[str], tool_name: str
|
|
460
|
+
) -> list[str]:
|
|
461
|
+
"""Build --config arguments pointing at the bundled defaults for a tool.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
project_root: The project root directory.
|
|
465
|
+
config_files: Dedicated config filenames the project may define.
|
|
466
|
+
tool_name: The name of the tool, matching a bundled config file.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
--config arguments for the bundled defaults, or an empty list when the
|
|
470
|
+
project defines its own configuration (which must take precedence).
|
|
471
|
+
"""
|
|
472
|
+
if _has_project_config(project_root, config_files, tool_name):
|
|
473
|
+
return []
|
|
474
|
+
return ["--config", str(_bundled_config(tool_name))]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _get_tool_definitions(
|
|
478
|
+
path: Path, project_root: Path
|
|
479
|
+
) -> list[dict[str, Any]]:
|
|
480
|
+
"""Define supported tools with their triggers, extensions, and commands.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
path: The path being checked.
|
|
484
|
+
project_root: The project root, used to resolve default configurations.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
A list of tool configuration dictionaries.
|
|
488
|
+
"""
|
|
489
|
+
path_str = str(path)
|
|
490
|
+
|
|
491
|
+
# Fall back to the bundled default configs unless the project has its own
|
|
492
|
+
ruff_config = _default_config_args(
|
|
493
|
+
project_root, ["ruff.toml", ".ruff.toml"], "ruff"
|
|
494
|
+
)
|
|
495
|
+
pyrefly_config = _default_config_args(
|
|
496
|
+
project_root, ["pyrefly.toml"], "pyrefly"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return [
|
|
500
|
+
{
|
|
501
|
+
"name": "ruff",
|
|
502
|
+
"check": [
|
|
503
|
+
"ruff",
|
|
504
|
+
"check",
|
|
505
|
+
"--force-exclude",
|
|
506
|
+
*ruff_config,
|
|
507
|
+
path_str,
|
|
508
|
+
],
|
|
509
|
+
"check_format": [
|
|
510
|
+
"ruff",
|
|
511
|
+
"format",
|
|
512
|
+
"--check",
|
|
513
|
+
"--force-exclude",
|
|
514
|
+
*ruff_config,
|
|
515
|
+
path_str,
|
|
516
|
+
],
|
|
517
|
+
"fix": [
|
|
518
|
+
"ruff",
|
|
519
|
+
"check",
|
|
520
|
+
"--fix",
|
|
521
|
+
"--force-exclude",
|
|
522
|
+
*ruff_config,
|
|
523
|
+
path_str,
|
|
524
|
+
],
|
|
525
|
+
"format": [
|
|
526
|
+
"ruff",
|
|
527
|
+
"format",
|
|
528
|
+
"--force-exclude",
|
|
529
|
+
*ruff_config,
|
|
530
|
+
path_str,
|
|
531
|
+
],
|
|
532
|
+
"trigger": ["pyproject.toml", "ruff.toml", ".ruff.toml"],
|
|
533
|
+
"extensions": [".py"],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
# Type checker only: it reports findings but cannot fix or format
|
|
537
|
+
"name": "pyrefly",
|
|
538
|
+
"check": ["pyrefly", "check", *pyrefly_config, path_str],
|
|
539
|
+
"trigger": ["pyproject.toml", "pyrefly.toml"],
|
|
540
|
+
"extensions": [".py"],
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
"name": "biome",
|
|
544
|
+
"check": [
|
|
545
|
+
"npx",
|
|
546
|
+
"-y",
|
|
547
|
+
"biome",
|
|
548
|
+
"lint",
|
|
549
|
+
"--no-errors-on-unmatched",
|
|
550
|
+
path_str,
|
|
551
|
+
],
|
|
552
|
+
"check_format": [
|
|
553
|
+
"npx",
|
|
554
|
+
"-y",
|
|
555
|
+
"biome",
|
|
556
|
+
"format",
|
|
557
|
+
"--no-errors-on-unmatched",
|
|
558
|
+
path_str,
|
|
559
|
+
],
|
|
560
|
+
"fix": [
|
|
561
|
+
"npx",
|
|
562
|
+
"-y",
|
|
563
|
+
"biome",
|
|
564
|
+
"lint",
|
|
565
|
+
"--write",
|
|
566
|
+
"--no-errors-on-unmatched",
|
|
567
|
+
path_str,
|
|
568
|
+
],
|
|
569
|
+
"format": [
|
|
570
|
+
"npx",
|
|
571
|
+
"-y",
|
|
572
|
+
"biome",
|
|
573
|
+
"format",
|
|
574
|
+
"--write",
|
|
575
|
+
"--no-errors-on-unmatched",
|
|
576
|
+
path_str,
|
|
577
|
+
],
|
|
578
|
+
"trigger": ["biome.json", "biome.jsonc"],
|
|
579
|
+
"extensions": [
|
|
580
|
+
".js",
|
|
581
|
+
".ts",
|
|
582
|
+
".jsx",
|
|
583
|
+
".tsx",
|
|
584
|
+
".json",
|
|
585
|
+
".jsonc",
|
|
586
|
+
".css",
|
|
587
|
+
".html",
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
"name": "prettier",
|
|
592
|
+
"check_format": [
|
|
593
|
+
"npx",
|
|
594
|
+
"-y",
|
|
595
|
+
"prettier",
|
|
596
|
+
"--check",
|
|
597
|
+
"--no-error-on-unmatched-pattern",
|
|
598
|
+
path_str,
|
|
599
|
+
],
|
|
600
|
+
"format": [
|
|
601
|
+
"npx",
|
|
602
|
+
"-y",
|
|
603
|
+
"prettier",
|
|
604
|
+
"--write",
|
|
605
|
+
"--no-error-on-unmatched-pattern",
|
|
606
|
+
path_str,
|
|
607
|
+
],
|
|
608
|
+
"trigger": [
|
|
609
|
+
".prettierrc",
|
|
610
|
+
".prettierrc.json",
|
|
611
|
+
".prettierrc.yml",
|
|
612
|
+
".prettierrc.yaml",
|
|
613
|
+
".prettierrc.js",
|
|
614
|
+
"prettier.config.js",
|
|
615
|
+
"prettier.config.cjs",
|
|
616
|
+
],
|
|
617
|
+
"extensions": [
|
|
618
|
+
".js",
|
|
619
|
+
".ts",
|
|
620
|
+
".jsx",
|
|
621
|
+
".tsx",
|
|
622
|
+
".json",
|
|
623
|
+
".css",
|
|
624
|
+
".scss",
|
|
625
|
+
".html",
|
|
626
|
+
".md",
|
|
627
|
+
".yml",
|
|
628
|
+
".yaml",
|
|
629
|
+
],
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
"name": "go fmt",
|
|
633
|
+
"check_format": ["gofmt", "-l", path_str],
|
|
634
|
+
"format": ["go", "fmt", path_str],
|
|
635
|
+
"trigger": ["go.mod"],
|
|
636
|
+
"extensions": [".go"],
|
|
637
|
+
},
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _run_tool(
|
|
642
|
+
tool_name: str,
|
|
643
|
+
tool_config: dict[str, Any],
|
|
644
|
+
fix: bool = False,
|
|
645
|
+
) -> None:
|
|
646
|
+
"""Orchestrate the execution of a specific formatting or linting tool.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
tool_name: The name of the tool to run.
|
|
650
|
+
tool_config: The tool configuration dictionary.
|
|
651
|
+
fix: Whether to apply automatic fixes.
|
|
652
|
+
"""
|
|
653
|
+
# Identify the primary command to check for executable availability
|
|
654
|
+
cmd = (
|
|
655
|
+
tool_config.get("format")
|
|
656
|
+
or tool_config.get("check")
|
|
657
|
+
or tool_config.get("fix")
|
|
658
|
+
or tool_config.get("check_format")
|
|
659
|
+
)
|
|
660
|
+
if not cmd:
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
executable = str(cmd[0])
|
|
664
|
+
if not shutil.which(executable):
|
|
665
|
+
logger.debug(
|
|
666
|
+
"Tool %s (%s) not found in PATH, skipping.", tool_name, executable
|
|
667
|
+
)
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
logger.info("Running %s...", tool_name)
|
|
671
|
+
try:
|
|
672
|
+
if fix:
|
|
673
|
+
# 1. Run formatters (if available) - these are expected to
|
|
674
|
+
# modify files
|
|
675
|
+
if "format" in tool_config:
|
|
676
|
+
_execute_tool_command(tool_config["format"])
|
|
677
|
+
|
|
678
|
+
# 2. Run fixers (if available) - these apply automatic linting fixes
|
|
679
|
+
if "fix" in tool_config:
|
|
680
|
+
_execute_tool_command(tool_config["fix"])
|
|
681
|
+
# 1. Run check_format (if available) - check-only
|
|
682
|
+
elif "check_format" in tool_config:
|
|
683
|
+
logger.debug("Executing: %s", " ".join(tool_config["check_format"]))
|
|
684
|
+
result = subprocess.run(
|
|
685
|
+
tool_config["check_format"],
|
|
686
|
+
capture_output=True,
|
|
687
|
+
text=True,
|
|
688
|
+
check=False,
|
|
689
|
+
timeout=DEFAULT_TIMEOUT,
|
|
690
|
+
)
|
|
691
|
+
if result.returncode != 0 or (
|
|
692
|
+
tool_name == "go fmt" and result.stdout.strip()
|
|
693
|
+
):
|
|
694
|
+
click.echo(
|
|
695
|
+
f"--- {tool_name} formatting findings ---\n"
|
|
696
|
+
f"{result.stdout}\n{result.stderr}"
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# 3. Run checks and report findings - these provide feedback to the user
|
|
700
|
+
if "check" in tool_config:
|
|
701
|
+
logger.debug("Executing: %s", " ".join(tool_config["check"]))
|
|
702
|
+
result = subprocess.run(
|
|
703
|
+
tool_config["check"],
|
|
704
|
+
capture_output=True,
|
|
705
|
+
text=True,
|
|
706
|
+
check=False,
|
|
707
|
+
timeout=DEFAULT_TIMEOUT,
|
|
708
|
+
)
|
|
709
|
+
if result.returncode != 0:
|
|
710
|
+
click.echo(
|
|
711
|
+
f"--- {tool_name} findings ---\n"
|
|
712
|
+
f"{result.stdout}\n{result.stderr}"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
except subprocess.CalledProcessError as e:
|
|
716
|
+
logger.warning("%s failed with exit code %d", tool_name, e.returncode)
|
|
717
|
+
if e.stdout:
|
|
718
|
+
logger.debug("STDOUT: %s", e.stdout)
|
|
719
|
+
if e.stderr:
|
|
720
|
+
logger.debug("STDERR: %s", e.stderr)
|
|
721
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
722
|
+
logger.warning("Unexpected error while running %s: %s", tool_name, e)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _execute_tool_command(cmd: list[str]) -> None:
|
|
726
|
+
"""Execute a tool command, raising if it exits with a non-zero code.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
cmd: The command list to execute.
|
|
730
|
+
|
|
731
|
+
Raises:
|
|
732
|
+
subprocess.CalledProcessError: If the command returns a non-zero
|
|
733
|
+
exit code.
|
|
734
|
+
"""
|
|
735
|
+
logger.debug("Executing: %s", " ".join(cmd))
|
|
736
|
+
subprocess.run(
|
|
737
|
+
cmd, capture_output=True, check=True, timeout=DEFAULT_TIMEOUT
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# Main entry point for the CLI
|
|
742
|
+
def main() -> None:
|
|
743
|
+
"""Main entry point for the CLI."""
|
|
744
|
+
# Configure logging here rather than at import time so that importing this
|
|
745
|
+
# module as a library (e.g. from lemming) has no side effects
|
|
746
|
+
logging.basicConfig(
|
|
747
|
+
level=logging.INFO,
|
|
748
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
749
|
+
handlers=[logging.StreamHandler(sys.stderr)],
|
|
750
|
+
)
|
|
751
|
+
cli()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
if __name__ == "__main__":
|
|
755
|
+
main()
|