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.
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()