readability-cli 0.10.2__tar.gz → 0.10.4__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 (37) hide show
  1. readability_cli-0.10.2/README.md → readability_cli-0.10.4/PKG-INFO +38 -15
  2. readability_cli-0.10.2/PKG-INFO → readability_cli-0.10.4/README.md +23 -29
  3. {readability_cli-0.10.2 → readability_cli-0.10.4}/pyproject.toml +10 -3
  4. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/checking.py +3 -2
  5. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/cli.py +8 -1
  6. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/configs/ruff.toml +3 -2
  7. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guide.py +40 -25
  8. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/tools.py +61 -2
  9. {readability_cli-0.10.2 → readability_cli-0.10.4}/test_checking.py +496 -0
  10. {readability_cli-0.10.2 → readability_cli-0.10.4}/test_outline.py +17 -1
  11. {readability_cli-0.10.2 → readability_cli-0.10.4}/uv.lock +12 -5
  12. {readability_cli-0.10.2 → readability_cli-0.10.4}/.github/workflows/ci.yml +0 -0
  13. {readability_cli-0.10.2 → readability_cli-0.10.4}/.github/workflows/publish.yml +0 -0
  14. {readability_cli-0.10.2 → readability_cli-0.10.4}/.github/workflows/update-guides.yml +0 -0
  15. {readability_cli-0.10.2 → readability_cli-0.10.4}/.gitignore +0 -0
  16. {readability_cli-0.10.2 → readability_cli-0.10.4}/.python-version +0 -0
  17. {readability_cli-0.10.2 → readability_cli-0.10.4}/LICENSE +0 -0
  18. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/__init__.py +0 -0
  19. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/configs/biome-default.json +0 -0
  20. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/configs/pyrefly.toml +0 -0
  21. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/Rguide.md +0 -0
  22. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/cppguide.md +0 -0
  23. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/csharp-style.md +0 -0
  24. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/docguide-style.md +0 -0
  25. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/go-guide.md +0 -0
  26. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/htmlcssguide.md +0 -0
  27. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/javaguide.md +0 -0
  28. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/jsguide.md +0 -0
  29. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/jsoncstyleguide.md +0 -0
  30. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/objcguide.md +0 -0
  31. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/pyguide.md +0 -0
  32. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/shellguide.md +0 -0
  33. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/tsguide.md +0 -0
  34. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/guides/vimscriptguide.md +0 -0
  35. {readability_cli-0.10.2 → readability_cli-0.10.4}/readability/outline.py +0 -0
  36. {readability_cli-0.10.2 → readability_cli-0.10.4}/test_guide.py +0 -0
  37. {readability_cli-0.10.2 → readability_cli-0.10.4}/test_package.py +0 -0
@@ -1,3 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: readability-cli
3
+ Version: 0.10.4
4
+ Summary: A CLI to lint, format, and type-check code with Google-style defaults, and pull Google style guides in markdown format.
5
+ Project-URL: Homepage, https://github.com/owahltinez/readability
6
+ Project-URL: Repository, https://github.com/owahltinez/readability
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: click>=8.3.1
10
+ Provides-Extra: sync
11
+ Requires-Dist: beautifulsoup4>=4.14.3; extra == 'sync'
12
+ Requires-Dist: markdownify>=1.2.2; extra == 'sync'
13
+ Requires-Dist: requests>=2.33.0; extra == 'sync'
14
+ Description-Content-Type: text/markdown
15
+
1
16
  # Readability
2
17
 
3
18
  A CLI tool that keeps code aligned with Google style conventions. It runs the
@@ -9,7 +24,8 @@ quick access to style conventions without browsing HTML pages.
9
24
  ## Features
10
25
 
11
26
  - **Linting & Formatting**: A `check` command that automatically detects and
12
- runs relevant tools (Ruff, Pyrefly, Biome, and gofmt) for your project.
27
+ runs relevant tools (Ruff, Pyrefly, Biome, gofmt, goimports, shfmt,
28
+ shellcheck, and rustfmt) for your project.
13
29
  - **Predictable Ownership**: Every supported format has fixed tools, so
14
30
  configuration customizes checks without changing which formatter runs.
15
31
  - **Style Guides**: A `guide` command that fetches the latest Google style
@@ -84,7 +100,7 @@ readability check . --unsafe
84
100
  Fixes that may change a program's behavior or drop its comments are opt-in
85
101
  through `--unsafe`, which every tool applies under its own name: Ruff's
86
102
  `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
87
- such as gofmt, are unaffected by it.
103
+ such as gofmt and goimports, are unaffected by it.
88
104
 
89
105
  ### Supported Formats
90
106
 
@@ -92,7 +108,9 @@ such as gofmt, are unaffected by it.
92
108
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
93
109
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
94
110
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
95
- | `.go` | gofmt | None |
111
+ | `.go` | gofmt, goimports | None |
112
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
113
+ | `.rs` | rustfmt | None |
96
114
 
97
115
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
98
116
  An unsupported-only path reports that nothing was checked and exits
@@ -100,8 +118,8 @@ successfully.
100
118
 
101
119
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
102
120
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
103
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
104
- and must be available on `PATH`.
121
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
122
+ must be available on `PATH`.
105
123
 
106
124
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
107
125
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -119,8 +137,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
119
137
  the run rather than passing it.
120
138
 
121
139
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
122
- file they own without project setup. Gofmt runs on `.go` files even when there
123
- is no `go.mod`.
140
+ file they own without project setup. Gofmt and goimports run on `.go` files even
141
+ when there is no `go.mod`.
124
142
 
125
143
  A tool that could not be reached at all is never skipped quietly:
126
144
 
@@ -175,8 +193,9 @@ caller happened to be standing in.
175
193
  Ruff and Pyrefly defaults follow the
176
194
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
177
195
  80-column lines, Google docstrings, one import per line sorted as isort's
178
- `google` profile does, and full type checking. The Biome default applies the
179
- 80-column lines and two-space indentation of the
196
+ `google` profile does, best-practice lint rules (bugbear, pyupgrade, simplify,
197
+ comprehensions, and Ruff-specific rules), and full type checking. The Biome
198
+ default applies the 80-column lines and two-space indentation of the
180
199
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
181
200
  and enables Biome's recommended lint rules. Project `biome.json` and
182
201
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -185,9 +204,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
185
204
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
186
205
  their native project configurations in place of bundled defaults. Readability
187
206
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
188
- into it through that tool's native configuration. Gofmt has no project settings.
189
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
190
- runner's version floor.
207
+ into it through that tool's native configuration. Neither gofmt nor goimports
208
+ has project settings. The bundled Biome file requires Biome 2.5 or later,
209
+ matching the fallback runner's version floor.
191
210
 
192
211
  Configuration is found per file, in the file's own directory and then its
193
212
  ancestors, which is where each canonical tool looks. A package keeping its
@@ -354,9 +373,13 @@ Supported languages and their aliases:
354
373
  - vim [cached]
355
374
  ```
356
375
 
357
- Refresh the cache with `sync`, which takes languages or refetches everything:
376
+ Refresh the cache with `sync`, which takes languages or refetches everything.
377
+ It fetches and converts HTML, so it needs the `sync` extra; the bundled guides
378
+ are already converted, and every other command reads those:
358
379
 
359
380
  ```bash
381
+ pip install 'readability-cli[sync]'
382
+
360
383
  # Refetch one guide, or a few
361
384
  readability sync python shell
362
385
 
@@ -371,8 +394,8 @@ The tool stores local copies of the style guides in the
371
394
  the `guide` command uses these local files when they exist. The bundled copies
372
395
  are automatically synchronized weekly from the official
373
396
  [Google Style Guides](https://google.github.io/styleguide/) repository via
374
- GitHub Actions, and you can refresh your local cache at any time with the `sync`
375
- command.
397
+ GitHub Actions, so upgrading the package is usually enough. To refresh a local
398
+ cache yourself, install the `sync` extra and run the `sync` command.
376
399
 
377
400
  You can override the default guide directory by setting the
378
401
  `READABILITY_CACHE` environment variable. This is useful if you want to store
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: readability-cli
3
- Version: 0.10.2
4
- Summary: A CLI to lint, format, and type-check code with Google-style defaults, and pull Google style guides in markdown format.
5
- Project-URL: Homepage, https://github.com/owahltinez/readability
6
- Project-URL: Repository, https://github.com/owahltinez/readability
7
- License-File: LICENSE
8
- Requires-Python: >=3.12
9
- Requires-Dist: beautifulsoup4>=4.14.3
10
- Requires-Dist: click>=8.3.1
11
- Requires-Dist: markdownify>=1.2.2
12
- Requires-Dist: requests>=2.33.0
13
- Description-Content-Type: text/markdown
14
-
15
1
  # Readability
16
2
 
17
3
  A CLI tool that keeps code aligned with Google style conventions. It runs the
@@ -23,7 +9,8 @@ quick access to style conventions without browsing HTML pages.
23
9
  ## Features
24
10
 
25
11
  - **Linting & Formatting**: A `check` command that automatically detects and
26
- runs relevant tools (Ruff, Pyrefly, Biome, and gofmt) for your project.
12
+ runs relevant tools (Ruff, Pyrefly, Biome, gofmt, goimports, shfmt,
13
+ shellcheck, and rustfmt) for your project.
27
14
  - **Predictable Ownership**: Every supported format has fixed tools, so
28
15
  configuration customizes checks without changing which formatter runs.
29
16
  - **Style Guides**: A `guide` command that fetches the latest Google style
@@ -98,7 +85,7 @@ readability check . --unsafe
98
85
  Fixes that may change a program's behavior or drop its comments are opt-in
99
86
  through `--unsafe`, which every tool applies under its own name: Ruff's
100
87
  `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
101
- such as gofmt, are unaffected by it.
88
+ such as gofmt and goimports, are unaffected by it.
102
89
 
103
90
  ### Supported Formats
104
91
 
@@ -106,7 +93,9 @@ such as gofmt, are unaffected by it.
106
93
  | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------- |
107
94
  | `.py` | Ruff lint/format; Pyrefly type checking | Ruff and Pyrefly native configuration |
108
95
  | `.js`, `.jsx`, `.ts`, `.tsx`, `.json`, `.jsonc`, `.css`, `.html` | Biome | `biome.json` or `biome.jsonc` |
109
- | `.go` | gofmt | None |
96
+ | `.go` | gofmt, goimports | None |
97
+ | `.sh`, `.bash` | shfmt format; shellcheck lint | None |
98
+ | `.rs` | rustfmt | None |
110
99
 
111
100
  Markdown, YAML, SCSS, JSONL, and extensions not listed above are unsupported.
112
101
  An unsupported-only path reports that nothing was checked and exits
@@ -114,8 +103,8 @@ successfully.
114
103
 
115
104
  Biome first checks a project's `node_modules/.bin`. Ruff, Pyrefly, and Biome
116
105
  then use `PATH`, followed by `uvx` for the Python tools or `npx` for Biome. The
117
- runners cache downloads, so subsequent runs work offline. Gofmt ships with Go
118
- and must be available on `PATH`.
106
+ runners cache downloads, so subsequent runs work offline. Gofmt and goimports
107
+ must be available on `PATH`.
119
108
 
120
109
  That keeps this package at ~4 MB rather than the ~54 MB it would take to carry
121
110
  Ruff and Pyrefly itself — a cost that would fall on everyone using only `guide`.
@@ -133,8 +122,8 @@ Set `UV_OFFLINE=1` to forbid fetching. A tool that then cannot be reached fails
133
122
  the run rather than passing it.
134
123
 
135
124
  Ruff, Pyrefly, and Biome ship with bundled configurations, so they run on every
136
- file they own without project setup. Gofmt runs on `.go` files even when there
137
- is no `go.mod`.
125
+ file they own without project setup. Gofmt and goimports run on `.go` files even
126
+ when there is no `go.mod`.
138
127
 
139
128
  A tool that could not be reached at all is never skipped quietly:
140
129
 
@@ -189,8 +178,9 @@ caller happened to be standing in.
189
178
  Ruff and Pyrefly defaults follow the
190
179
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
191
180
  80-column lines, Google docstrings, one import per line sorted as isort's
192
- `google` profile does, and full type checking. The Biome default applies the
193
- 80-column lines and two-space indentation of the
181
+ `google` profile does, best-practice lint rules (bugbear, pyupgrade, simplify,
182
+ comprehensions, and Ruff-specific rules), and full type checking. The Biome
183
+ default applies the 80-column lines and two-space indentation of the
194
184
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
195
185
  and enables Biome's recommended lint rules. Project `biome.json` and
196
186
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -199,9 +189,9 @@ For Python, add `[tool.ruff]` or `[tool.pyrefly]` to `pyproject.toml`, or use
199
189
  `ruff.toml`, `.ruff.toml`, or `pyrefly.toml`. Ruff, Pyrefly, and Biome load
200
190
  their native project configurations in place of bundled defaults. Readability
201
191
  does not interpret EditorConfig itself; a canonical tool such as Biome may opt
202
- into it through that tool's native configuration. Gofmt has no project settings.
203
- The bundled Biome file requires Biome 2.5 or later, matching the fallback
204
- runner's version floor.
192
+ into it through that tool's native configuration. Neither gofmt nor goimports
193
+ has project settings. The bundled Biome file requires Biome 2.5 or later,
194
+ matching the fallback runner's version floor.
205
195
 
206
196
  Configuration is found per file, in the file's own directory and then its
207
197
  ancestors, which is where each canonical tool looks. A package keeping its
@@ -368,9 +358,13 @@ Supported languages and their aliases:
368
358
  - vim [cached]
369
359
  ```
370
360
 
371
- Refresh the cache with `sync`, which takes languages or refetches everything:
361
+ Refresh the cache with `sync`, which takes languages or refetches everything.
362
+ It fetches and converts HTML, so it needs the `sync` extra; the bundled guides
363
+ are already converted, and every other command reads those:
372
364
 
373
365
  ```bash
366
+ pip install 'readability-cli[sync]'
367
+
374
368
  # Refetch one guide, or a few
375
369
  readability sync python shell
376
370
 
@@ -385,8 +379,8 @@ The tool stores local copies of the style guides in the
385
379
  the `guide` command uses these local files when they exist. The bundled copies
386
380
  are automatically synchronized weekly from the official
387
381
  [Google Style Guides](https://google.github.io/styleguide/) repository via
388
- GitHub Actions, and you can refresh your local cache at any time with the `sync`
389
- command.
382
+ GitHub Actions, so upgrading the package is usually enough. To refresh a local
383
+ cache yourself, install the `sync` extra and run the `sync` command.
390
384
 
391
385
  You can override the default guide directory by setting the
392
386
  `READABILITY_CACHE` environment variable. This is useful if you want to store
@@ -1,12 +1,17 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.10.2"
3
+ version = "0.10.4"
4
4
  description = "A CLI to lint, format, and type-check code with Google-style defaults, and pull Google style guides in markdown format."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  dependencies = [
8
- "beautifulsoup4>=4.14.3",
9
8
  "click>=8.3.1",
9
+ ]
10
+
11
+ # Only `sync` fetches and converts guides; the rest reads bundled copies.
12
+ [project.optional-dependencies]
13
+ sync = [
14
+ "beautifulsoup4>=4.14.3",
10
15
  "markdownify>=1.2.2",
11
16
  "requests>=2.33.0",
12
17
  ]
@@ -34,6 +39,8 @@ testpaths = ["."]
34
39
  [dependency-groups]
35
40
  dev = [
36
41
  "pytest>=9.0.2",
42
+ # The suite and the weekly guide refresh both exercise `sync`.
43
+ "readability-cli[sync]",
37
44
  "ruff>=0.15.8",
38
45
  ]
39
46
 
@@ -41,7 +48,7 @@ dev = [
41
48
  line-length = 80
42
49
 
43
50
  [tool.ruff.lint]
44
- select = ["E", "W", "F", "I", "N", "D", "PL"]
51
+ select = ["E", "W", "F", "I", "N", "D", "PL", "B", "UP", "SIM", "C4", "RUF"]
45
52
  ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
46
53
 
47
54
  [tool.ruff.lint.pydocstyle]
@@ -204,9 +204,10 @@ def _run_tool(
204
204
  result = _capture_tool_command(command, cwd=tool.cwd)
205
205
  if _tool_checked_files(tool.name, result):
206
206
  report.ran.add(tool.name)
207
- # gofmt reports by naming files rather than by exit code
207
+ # gofmt and goimports name files rather than using exit codes
208
208
  if result.returncode != 0 or (
209
- tool.name == "gofmt" and result.stdout.strip()
209
+ tool.name in ("gofmt", "goimports")
210
+ and result.stdout.strip()
210
211
  ):
211
212
  report.findings = True
212
213
  click.echo(
@@ -12,6 +12,7 @@ from readability.guide import get_guide
12
12
  from readability.guide import get_guides_dir
13
13
  from readability.guide import get_local_path
14
14
  from readability.guide import LANGUAGE_MAP
15
+ from readability.guide import load_sync_deps
15
16
  from readability.guide import refresh_guide
16
17
  from readability.outline import _echo_outline
17
18
  from readability.outline import _select_section
@@ -123,6 +124,9 @@ def sync(languages: Sequence[str], verbose: bool) -> None:
123
124
  if verbose:
124
125
  logger.setLevel(logging.DEBUG)
125
126
 
127
+ # A precondition, not a per-guide failure the loop should report N times
128
+ load_sync_deps()
129
+
126
130
  filenames = _resolve_filenames(languages)
127
131
  click.echo(f"Synchronizing {len(filenames)} style guide(s)...", err=True)
128
132
 
@@ -147,6 +151,10 @@ def sync(languages: Sequence[str], verbose: bool) -> None:
147
151
  err=True,
148
152
  )
149
153
 
154
+ # A guide that did not refetch leaves a stale local copy behind
155
+ if failure_count:
156
+ sys.exit(1)
157
+
150
158
 
151
159
  def _echo_languages() -> None:
152
160
  """Print the languages that have a guide, with their aliases."""
@@ -240,7 +248,6 @@ def check(paths: Sequence[str], fix: bool, unsafe: bool, verbose: bool) -> None:
240
248
  )
241
249
 
242
250
 
243
- # Main entry point for the CLI
244
251
  def main() -> None:
245
252
  """Main entry point for the CLI."""
246
253
  # Configured here, not at import, so library use has no side effects
@@ -6,8 +6,9 @@ line-length = 80
6
6
 
7
7
  [lint]
8
8
  # Pycodestyle (E, W), Pyflakes (F), import order (I), naming (N),
9
- # docstrings (D), and pylint (PL) rules
10
- select = ["E", "W", "F", "I", "N", "D", "PL"]
9
+ # docstrings (D), pylint (PL), bugbear (B), pyupgrade (UP),
10
+ # simplify (SIM), comprehensions (C4), and ruff-specific (RUF) rules
11
+ select = ["E", "W", "F", "I", "N", "D", "PL", "B", "UP", "SIM", "C4", "RUF"]
11
12
  # Complexity counters and magic-value warnings are guidance, not errors
12
13
  ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
13
14
 
@@ -1,21 +1,46 @@
1
1
  """Fetch and cache Google style guides."""
2
2
 
3
+ import functools
3
4
  from importlib.resources import files
4
5
  import logging
5
6
  import os
7
+ import types
6
8
  import warnings
7
9
 
8
- from bs4 import BeautifulSoup
9
- from bs4 import XMLParsedAsHTMLWarning
10
10
  import click
11
- from markdownify import markdownify as md
12
- import requests
13
-
14
- warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
15
11
 
16
12
  logger = logging.getLogger("readability")
17
13
 
18
14
 
15
+ @functools.cache
16
+ def load_sync_deps() -> tuple[
17
+ types.ModuleType, types.ModuleType, types.ModuleType
18
+ ]:
19
+ """Imports the libraries only the guide refresh needs.
20
+
21
+ Guides ship pre-converted, so this stack stays out of the default
22
+ install rather than charging every user for `sync`.
23
+
24
+ Returns:
25
+ The requests, bs4, and markdownify modules.
26
+
27
+ Raises:
28
+ click.ClickException: If the sync extra is not installed.
29
+ """
30
+ try:
31
+ import bs4 # noqa: PLC0415
32
+ import markdownify # noqa: PLC0415
33
+ import requests # noqa: PLC0415
34
+ except ImportError as e:
35
+ raise click.ClickException(
36
+ "Refreshing style guides needs extra libraries. Install them "
37
+ "with: pip install 'readability-cli[sync]'"
38
+ ) from e
39
+
40
+ warnings.filterwarnings("ignore", category=bs4.XMLParsedAsHTMLWarning)
41
+ return requests, bs4, markdownify
42
+
43
+
19
44
  def get_guides_dir() -> str:
20
45
  """Get the directory where style guides are cached.
21
46
 
@@ -29,7 +54,6 @@ def get_guides_dir() -> str:
29
54
  return os.getenv("READABILITY_CACHE") or str(bundled_guides)
30
55
 
31
56
 
32
- # Mapping of languages to their Google Style Guide file paths
33
57
  LANGUAGE_MAP = {
34
58
  "python": "pyguide.md",
35
59
  "shell": "shellguide.md",
@@ -72,9 +96,9 @@ def get_guide_content(url: str) -> str:
72
96
  Raises:
73
97
  click.ClickException: If the HTTP request fails.
74
98
  """
99
+ requests, _, _ = load_sync_deps()
75
100
  logger.info("Fetching style guide from %s", url)
76
101
 
77
- # Perform the HTTP GET request with a timeout
78
102
  try:
79
103
  response = requests.get(url, timeout=10)
80
104
  response.raise_for_status()
@@ -82,7 +106,7 @@ def get_guide_content(url: str) -> str:
82
106
  logger.error("Failed to fetch content from %s: %s", url, e)
83
107
  raise click.ClickException(
84
108
  f"Failed to fetch style guide from {url}: {e}"
85
- )
109
+ ) from e
86
110
 
87
111
  return response.text
88
112
 
@@ -97,9 +121,9 @@ def convert_to_markdown(content: str, filename: str) -> str:
97
121
  Returns:
98
122
  The converted markdown content.
99
123
  """
124
+ _, bs4, markdownify = load_sync_deps()
100
125
  logger.debug("Converting content for %s", filename)
101
126
 
102
- # Handle Markdown files directly
103
127
  if filename.endswith(".md"):
104
128
  return content
105
129
 
@@ -107,11 +131,10 @@ def convert_to_markdown(content: str, filename: str) -> str:
107
131
  if content.lstrip().startswith("<?xml"):
108
132
  content = content.split("?>", 1)[-1].lstrip()
109
133
 
110
- # Handle XML files (used for Vim script guide and JSON style guide)
134
+ # The Vim script and JSON guides are only published as XML
111
135
  if filename.endswith(".xml"):
112
- soup = BeautifulSoup(content, "html.parser")
136
+ soup = bs4.BeautifulSoup(content, "html.parser")
113
137
 
114
- # Add titles as headers
115
138
  guide = soup.find("guide")
116
139
  if guide:
117
140
  title = guide.get("title")
@@ -136,11 +159,10 @@ def convert_to_markdown(content: str, filename: str) -> str:
136
159
 
137
160
  for summary in soup.find_all("summary"):
138
161
  summary.name = "p"
139
- # Wrap content in strong tags
140
162
  content_str = summary.decode_contents()
141
163
  summary.clear()
142
164
  strong = soup.new_tag("strong")
143
- strong.append(BeautifulSoup(content_str, "html.parser"))
165
+ strong.append(bs4.BeautifulSoup(content_str, "html.parser"))
144
166
  summary.append(strong)
145
167
 
146
168
  for snippet in soup.find_all(["code_snippet", "bad_code_snippet"]):
@@ -157,14 +179,11 @@ def convert_to_markdown(content: str, filename: str) -> str:
157
179
  snippet.append(p)
158
180
  snippet.append(code)
159
181
 
160
- # Convert the modified soup to string and then to markdown
161
- return md(str(soup), **MARKDOWNIFY_OPTIONS)
182
+ return markdownify.markdownify(str(soup), **MARKDOWNIFY_OPTIONS)
162
183
 
163
- # Handle HTML files by converting them to Markdown
164
184
  if filename.endswith(".html"):
165
- return md(content, **MARKDOWNIFY_OPTIONS)
185
+ return markdownify.markdownify(content, **MARKDOWNIFY_OPTIONS)
166
186
 
167
- # Fallback to returning raw content
168
187
  return content
169
188
 
170
189
 
@@ -181,9 +200,7 @@ def get_local_path(filename: str) -> str:
181
200
  Returns:
182
201
  The full local path to the cached markdown file.
183
202
  """
184
- # Flatten the filename by replacing '/' with '-'
185
203
  flattened = filename.replace("/", "-")
186
- # Use the flattened filename and change extension to .md for uniform storage
187
204
  base_name = flattened.rsplit(".", 1)[0]
188
205
  return os.path.join(get_guides_dir(), f"{base_name}.md")
189
206
 
@@ -227,7 +244,6 @@ def get_guide(language: str, remote: bool = False) -> str:
227
244
  Raises:
228
245
  click.UsageError: If the language is not supported.
229
246
  """
230
- # Look up the filename in the mapping
231
247
  filename = LANGUAGE_MAP.get(language.lower())
232
248
  if not filename:
233
249
  error_msg = f"Language '{language}' is not supported."
@@ -239,10 +255,9 @@ def get_guide(language: str, remote: bool = False) -> str:
239
255
 
240
256
  local_path = get_local_path(filename)
241
257
 
242
- # If remote is False, check for local file first
243
258
  if not remote and os.path.exists(local_path):
244
259
  logger.info("Reading style guide from local file: %s", local_path)
245
- with open(local_path, "r", encoding="utf-8") as f:
260
+ with open(local_path, encoding="utf-8") as f:
246
261
  return f.read()
247
262
 
248
263
  return refresh_guide(filename)
@@ -347,6 +347,10 @@ TOOL_EXTENSIONS = {
347
347
  ".html",
348
348
  ),
349
349
  "gofmt": (".go",),
350
+ "goimports": (".go",),
351
+ "shfmt": (".sh", ".bash"),
352
+ "shellcheck": (".sh", ".bash"),
353
+ "rustfmt": (".rs",),
350
354
  }
351
355
 
352
356
  # Headroom below common argv limits; long target lists are split before exec
@@ -354,8 +358,20 @@ MAX_COMMAND_BYTES = 16 * 1024
354
358
 
355
359
  # Named files bypass a tool's own exclusions, and Pyrefly ignores its excludes
356
360
  PRUNED_DIRECTORIES = frozenset(
357
- ".git .hg .svn .venv venv .tox .nox node_modules "
358
- "__pycache__ .mypy_cache .pytest_cache .ruff_cache".split()
361
+ [
362
+ ".git",
363
+ ".hg",
364
+ ".svn",
365
+ ".venv",
366
+ "venv",
367
+ ".tox",
368
+ ".nox",
369
+ "node_modules",
370
+ "__pycache__",
371
+ ".mypy_cache",
372
+ ".pytest_cache",
373
+ ".ruff_cache",
374
+ ]
359
375
  )
360
376
 
361
377
  # Per tool: config filenames, pyproject section (None if it has none), flag
@@ -392,6 +408,13 @@ TOOL_PHASES = {
392
408
  "format": ("format", "--write", "--no-errors-on-unmatched"),
393
409
  },
394
410
  "gofmt": {"check_format": ("-l",), "format": ("-w",)},
411
+ "goimports": {"check_format": ("-l",), "format": ("-w",)},
412
+ "shfmt": {
413
+ "check_format": ("-i", "2", "-ci", "-d"),
414
+ "format": ("-i", "2", "-ci", "-w"),
415
+ },
416
+ "shellcheck": {"check": ()},
417
+ "rustfmt": {"check_format": ("--check",), "format": ()},
395
418
  }
396
419
 
397
420
  # Per tool: how it names fixes that may change behavior or drop comments
@@ -549,6 +572,42 @@ def _get_tool_definitions(
549
572
  path,
550
573
  )
551
574
  )
575
+ plans.append(
576
+ _plan(
577
+ "goimports",
578
+ ("goimports",),
579
+ [],
580
+ _matching_paths(path, TOOL_EXTENSIONS["goimports"]),
581
+ path,
582
+ )
583
+ )
584
+ plans.append(
585
+ _plan(
586
+ "shfmt",
587
+ ("shfmt",),
588
+ [],
589
+ _matching_paths(path, TOOL_EXTENSIONS["shfmt"]),
590
+ path,
591
+ )
592
+ )
593
+ plans.append(
594
+ _plan(
595
+ "shellcheck",
596
+ ("shellcheck",),
597
+ [],
598
+ _matching_paths(path, TOOL_EXTENSIONS["shellcheck"]),
599
+ path,
600
+ )
601
+ )
602
+ plans.append(
603
+ _plan(
604
+ "rustfmt",
605
+ ("rustfmt",),
606
+ [],
607
+ _matching_paths(path, TOOL_EXTENSIONS["rustfmt"]),
608
+ path,
609
+ )
610
+ )
552
611
 
553
612
  # A directory owning its config alone lets Pyrefly select the files
554
613
  files, section = CONFIG_SOURCES["pyrefly"][:2]
@@ -15,6 +15,8 @@ from readability.cli import cli
15
15
  from readability.tools import _bundled_config
16
16
  from readability.tools import _get_tool_definitions
17
17
  from readability.tools import _has_project_config
18
+ from readability.tools import TOOL_EXTENSIONS
19
+ from readability.tools import TOOL_PHASES
18
20
  from readability.tools import TOOL_RUNNERS
19
21
 
20
22
 
@@ -1045,6 +1047,17 @@ def test_bundled_default_configs_are_valid() -> None:
1045
1047
  }
1046
1048
 
1047
1049
 
1050
+ def test_bundled_ruff_config_includes_enriched_rules() -> None:
1051
+ """The default ruff config selects best-practice and modernization rules."""
1052
+ # Enriched rules for bug prevention, modern syntax, and simplification
1053
+ ruff_config = tomllib.loads(_bundled_config("ruff").read_text())
1054
+ selected_rules = set(ruff_config["lint"]["select"])
1055
+ expected_rules = {"B", "UP", "SIM", "C4", "RUF"}
1056
+
1057
+ # All enriched rule sets must be present
1058
+ assert expected_rules.issubset(selected_rules)
1059
+
1060
+
1048
1061
  @patch("readability.checking._check_path")
1049
1062
  def test_check_paths_aggregates_str_and_path_inputs(
1050
1063
  mock_check_path: MagicMock,
@@ -1608,6 +1621,170 @@ def test_gofmt_fix_failure_is_reported(
1608
1621
  assert "gofmt formatting findings" in result.output
1609
1622
 
1610
1623
 
1624
+ def test_goimports_tool_plan_generation(tmp_path: Path) -> None:
1625
+ """Go files produce goimports plans alongside gofmt."""
1626
+ go_file = tmp_path / "main.go"
1627
+ go_file.touch()
1628
+
1629
+ # Verify tool registration in extensions and phases
1630
+ assert TOOL_EXTENSIONS["goimports"] == (".go",)
1631
+ assert TOOL_PHASES["goimports"] == {
1632
+ "check_format": ("-l",),
1633
+ "format": ("-w",),
1634
+ }
1635
+
1636
+ # Verify tool plan creation
1637
+ plans = {
1638
+ plan.name: plan for plan in _get_tool_definitions(go_file, tmp_path)
1639
+ }
1640
+ assert "goimports" in plans
1641
+ goimports = plans["goimports"]
1642
+ assert goimports.extensions == (".go",)
1643
+ assert goimports.check_format == ("goimports", "-l", str(go_file))
1644
+ assert goimports.format == ("goimports", "-w", str(go_file))
1645
+ assert goimports.check == ()
1646
+ assert goimports.fix == ()
1647
+ assert goimports.targets == (str(go_file),)
1648
+
1649
+
1650
+ @pytest.mark.parametrize("fix", (False, True))
1651
+ @patch("shutil.which")
1652
+ @patch("subprocess.run")
1653
+ def test_configless_go_uses_goimports_for_check_and_fix(
1654
+ mock_run: MagicMock,
1655
+ mock_which: MagicMock,
1656
+ tmp_path: Path,
1657
+ fix: bool,
1658
+ ) -> None:
1659
+ """Standalone Go files use symmetric goimports commands without go.mod."""
1660
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1661
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1662
+
1663
+ # Run check or format on Go file
1664
+ runner = CliRunner()
1665
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1666
+ Path("main.go").touch()
1667
+ arguments = ["check", "main.go"]
1668
+ if fix:
1669
+ arguments.append("--fix")
1670
+
1671
+ result = runner.invoke(cli, arguments)
1672
+
1673
+ # Verify goimports command invoked with correct flags
1674
+ assert result.exit_code == 0
1675
+ expected = ["goimports", "-w" if fix else "-l", "main.go"]
1676
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1677
+ expected
1678
+ ]
1679
+
1680
+
1681
+ @patch("shutil.which")
1682
+ @patch("subprocess.run")
1683
+ def test_goimports_scopes_a_directory_to_go_files(
1684
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1685
+ ) -> None:
1686
+ """Goimports receives recursive Go files instead of an invalid directory."""
1687
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1688
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1689
+
1690
+ # Populate directory structure with mixed files
1691
+ runner = CliRunner()
1692
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1693
+ Path("src/nested").mkdir(parents=True)
1694
+ Path("src/main.go").touch()
1695
+ Path("src/nested/helper.go").touch()
1696
+ Path("src/notes.txt").touch()
1697
+
1698
+ result = runner.invoke(cli, ["check", "src"])
1699
+
1700
+ # Verify only Go files are passed to goimports
1701
+ assert result.exit_code == 0
1702
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1703
+ ["goimports", "-l", "src/main.go", "src/nested/helper.go"]
1704
+ ]
1705
+
1706
+
1707
+ @patch("shutil.which")
1708
+ @patch("subprocess.run")
1709
+ def test_goimports_check_reports_diff_in_stdout_as_findings(
1710
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1711
+ ) -> None:
1712
+ """Goimports reports diffs on stdout while exiting zero."""
1713
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1714
+ mock_run.return_value = MagicMock(
1715
+ returncode=0, stdout="main.go\n", stderr=""
1716
+ )
1717
+
1718
+ # Invoke check on unformatted Go file
1719
+ runner = CliRunner()
1720
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1721
+ Path("main.go").touch()
1722
+
1723
+ result = runner.invoke(cli, ["check", "main.go"])
1724
+
1725
+ # Findings in stdout must fail the check
1726
+ assert result.exit_code == 1
1727
+ assert "--- goimports formatting findings ---" in result.output
1728
+ assert "main.go" in result.output
1729
+
1730
+
1731
+ @patch("shutil.which")
1732
+ @patch("subprocess.run")
1733
+ def test_goimports_fix_failure_is_reported(
1734
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
1735
+ ) -> None:
1736
+ """A failed goimports write cannot be reported as a clean fix."""
1737
+ mock_which.side_effect = lambda name: name if name == "goimports" else None
1738
+ mock_run.return_value = MagicMock(
1739
+ returncode=1, stdout="", stderr="invalid Go syntax"
1740
+ )
1741
+
1742
+ # Invoke check --fix on invalid Go file
1743
+ runner = CliRunner()
1744
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1745
+ Path("main.go").touch()
1746
+
1747
+ result = runner.invoke(cli, ["check", "main.go", "--fix"])
1748
+
1749
+ # Fix error must fail the check
1750
+ assert result.exit_code == 1
1751
+ assert "goimports formatting findings" in result.output
1752
+
1753
+
1754
+ @pytest.mark.parametrize("fix", (False, True))
1755
+ @patch("shutil.which")
1756
+ @patch("subprocess.run")
1757
+ def test_go_runs_both_gofmt_and_goimports(
1758
+ mock_run: MagicMock,
1759
+ mock_which: MagicMock,
1760
+ tmp_path: Path,
1761
+ fix: bool,
1762
+ ) -> None:
1763
+ """When both gofmt and goimports are installed, both run on Go files."""
1764
+ mock_which.side_effect = lambda name: (
1765
+ name if name in ("gofmt", "goimports") else None
1766
+ )
1767
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
1768
+
1769
+ # Run check or fix with both Go tools available
1770
+ runner = CliRunner()
1771
+ with runner.isolated_filesystem(temp_dir=tmp_path):
1772
+ Path("main.go").touch()
1773
+ arguments = ["check", "main.go"]
1774
+ if fix:
1775
+ arguments.append("--fix")
1776
+
1777
+ result = runner.invoke(cli, arguments)
1778
+
1779
+ # Verify both tools ran with the appropriate flag
1780
+ assert result.exit_code == 0
1781
+ flag = "-w" if fix else "-l"
1782
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
1783
+ ["gofmt", flag, "main.go"],
1784
+ ["goimports", flag, "main.go"],
1785
+ ]
1786
+
1787
+
1611
1788
  @patch("shutil.which")
1612
1789
  @patch("subprocess.run")
1613
1790
  def test_large_biome_directory_uses_bounded_commands(
@@ -1653,6 +1830,7 @@ def test_missing_gofmt_is_reported_for_configless_go(
1653
1830
 
1654
1831
  assert result.exit_code == 1
1655
1832
  assert "gofmt" in result.stderr
1833
+ assert "goimports" in result.stderr
1656
1834
  assert "nothing was verified" in result.stderr
1657
1835
 
1658
1836
 
@@ -2195,3 +2373,321 @@ def test_unsafe_is_a_no_op_for_tools_without_one(
2195
2373
  assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2196
2374
  ["gofmt", "-w", "main.go"]
2197
2375
  ]
2376
+
2377
+
2378
+ @pytest.mark.parametrize("extension", (".sh", ".bash"))
2379
+ def test_tool_definitions_for_shell(extension: str, tmp_path: Path) -> None:
2380
+ """Shell files yield shfmt and shellcheck plans with style arguments."""
2381
+ # Create the target shell file
2382
+ script = tmp_path / f"script{extension}"
2383
+ script.touch()
2384
+
2385
+ # Resolve tool plans for the file
2386
+ tools = {
2387
+ tool.name: tool for tool in _get_tool_definitions(script, tmp_path)
2388
+ }
2389
+
2390
+ # Verify shfmt plan
2391
+ shfmt = tools["shfmt"]
2392
+ assert shfmt.extensions == (".sh", ".bash")
2393
+ assert shfmt.check_format == ("shfmt", "-i", "2", "-ci", "-d", str(script))
2394
+ assert shfmt.format == ("shfmt", "-i", "2", "-ci", "-w", str(script))
2395
+ assert shfmt.check == ()
2396
+ assert shfmt.fix == ()
2397
+
2398
+ # Verify shellcheck plan
2399
+ shellcheck = tools["shellcheck"]
2400
+ assert shellcheck.extensions == (".sh", ".bash")
2401
+ assert shellcheck.check == ("shellcheck", str(script))
2402
+ assert shellcheck.check_format == ()
2403
+ assert shellcheck.format == ()
2404
+ assert shellcheck.fix == ()
2405
+
2406
+
2407
+ def test_tool_definitions_for_rust(tmp_path: Path) -> None:
2408
+ """Rust files yield rustfmt plans with check and in-place formatting."""
2409
+ # Create the target Rust file
2410
+ source = tmp_path / "main.rs"
2411
+ source.touch()
2412
+
2413
+ # Resolve tool plans for the file
2414
+ tools = {
2415
+ tool.name: tool for tool in _get_tool_definitions(source, tmp_path)
2416
+ }
2417
+
2418
+ # Verify rustfmt plan
2419
+ rustfmt = tools["rustfmt"]
2420
+ assert rustfmt.extensions == (".rs",)
2421
+ assert rustfmt.check_format == ("rustfmt", "--check", str(source))
2422
+ assert rustfmt.format == ("rustfmt", str(source))
2423
+ assert rustfmt.check == ()
2424
+ assert rustfmt.fix == ()
2425
+
2426
+
2427
+ @pytest.mark.parametrize("extension", (".sh", ".bash"))
2428
+ @pytest.mark.parametrize("fix", (False, True))
2429
+ @patch("shutil.which")
2430
+ @patch("subprocess.run")
2431
+ def test_shell_uses_shfmt_and_shellcheck_for_check_and_fix(
2432
+ mock_run: MagicMock,
2433
+ mock_which: MagicMock,
2434
+ tmp_path: Path,
2435
+ extension: str,
2436
+ fix: bool,
2437
+ ) -> None:
2438
+ """Shell files invoke shfmt and shellcheck during check and fix."""
2439
+ # Allow shfmt and shellcheck to appear installed
2440
+ mock_which.side_effect = lambda name: (
2441
+ name if name in ("shfmt", "shellcheck") else None
2442
+ )
2443
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2444
+
2445
+ # Run check command in an isolated environment
2446
+ runner = CliRunner()
2447
+ filename = f"script{extension}"
2448
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2449
+ Path(filename).touch()
2450
+ arguments = ["check", filename]
2451
+ if fix:
2452
+ arguments.append("--fix")
2453
+ result = runner.invoke(cli, arguments)
2454
+
2455
+ # Verify clean exit and expected tool invocations
2456
+ assert result.exit_code == 0
2457
+ shfmt_action = "-w" if fix else "-d"
2458
+ expected = [
2459
+ ["shfmt", "-i", "2", "-ci", shfmt_action, filename],
2460
+ ["shellcheck", filename],
2461
+ ]
2462
+ assert [
2463
+ invocation.args[0] for invocation in mock_run.call_args_list
2464
+ ] == expected
2465
+
2466
+
2467
+ @pytest.mark.parametrize("fix", (False, True))
2468
+ @patch("shutil.which")
2469
+ @patch("subprocess.run")
2470
+ def test_rust_uses_rustfmt_for_check_and_fix(
2471
+ mock_run: MagicMock,
2472
+ mock_which: MagicMock,
2473
+ tmp_path: Path,
2474
+ fix: bool,
2475
+ ) -> None:
2476
+ """Rust files invoke rustfmt with check flag or in-place format."""
2477
+ # Allow rustfmt to appear installed
2478
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2479
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2480
+
2481
+ # Run check command in an isolated environment
2482
+ runner = CliRunner()
2483
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2484
+ Path("main.rs").touch()
2485
+ arguments = ["check", "main.rs"]
2486
+ if fix:
2487
+ arguments.append("--fix")
2488
+ result = runner.invoke(cli, arguments)
2489
+
2490
+ # Verify clean exit and expected tool invocations
2491
+ assert result.exit_code == 0
2492
+ expected = (
2493
+ [["rustfmt", "main.rs"]] if fix else [["rustfmt", "--check", "main.rs"]]
2494
+ )
2495
+ assert [
2496
+ invocation.args[0] for invocation in mock_run.call_args_list
2497
+ ] == expected
2498
+
2499
+
2500
+ @patch("shutil.which")
2501
+ @patch("subprocess.run")
2502
+ def test_shell_tools_scope_a_directory_to_shell_files(
2503
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2504
+ ) -> None:
2505
+ """Shell tools only receive shell files under checked directories."""
2506
+ # Allow shfmt and shellcheck to appear installed
2507
+ mock_which.side_effect = lambda name: (
2508
+ name if name in ("shfmt", "shellcheck") else None
2509
+ )
2510
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2511
+
2512
+ # Populate directory with shell and non-shell files
2513
+ runner = CliRunner()
2514
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2515
+ Path("src/nested").mkdir(parents=True)
2516
+ Path("src/script.sh").touch()
2517
+ Path("src/nested/setup.bash").touch()
2518
+ Path("src/notes.txt").touch()
2519
+ result = runner.invoke(cli, ["check", "src"])
2520
+
2521
+ # Verify tool calls are scoped to matching files
2522
+ assert result.exit_code == 0
2523
+ expected_files = ["src/nested/setup.bash", "src/script.sh"]
2524
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2525
+ ["shfmt", "-i", "2", "-ci", "-d", *expected_files],
2526
+ ["shellcheck", *expected_files],
2527
+ ]
2528
+
2529
+
2530
+ @patch("shutil.which")
2531
+ @patch("subprocess.run")
2532
+ def test_rustfmt_scopes_a_directory_to_rust_files(
2533
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2534
+ ) -> None:
2535
+ """Rustfmt only receives Rust files under checked directories."""
2536
+ # Allow rustfmt to appear installed
2537
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2538
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2539
+
2540
+ # Populate directory with Rust and non-Rust files
2541
+ runner = CliRunner()
2542
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2543
+ Path("src/nested").mkdir(parents=True)
2544
+ Path("src/main.rs").touch()
2545
+ Path("src/nested/lib.rs").touch()
2546
+ Path("src/notes.txt").touch()
2547
+ result = runner.invoke(cli, ["check", "src"])
2548
+
2549
+ # Verify tool calls are scoped to matching files
2550
+ assert result.exit_code == 0
2551
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2552
+ ["rustfmt", "--check", "src/main.rs", "src/nested/lib.rs"]
2553
+ ]
2554
+
2555
+
2556
+ @patch("shutil.which")
2557
+ @patch("subprocess.run")
2558
+ def test_shfmt_formatting_findings_reported(
2559
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2560
+ ) -> None:
2561
+ """Non-zero exit from shfmt diff is reported as a formatting finding."""
2562
+ # Allow shfmt to appear installed
2563
+ mock_which.side_effect = lambda name: (
2564
+ name if name in ("shfmt", "shellcheck") else None
2565
+ )
2566
+ mock_run.side_effect = [
2567
+ MagicMock(
2568
+ returncode=1, stdout="--- script.sh\n+++ script.sh\n", stderr=""
2569
+ ),
2570
+ MagicMock(returncode=0, stdout="", stderr=""),
2571
+ ]
2572
+
2573
+ # Run check command
2574
+ runner = CliRunner()
2575
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2576
+ Path("script.sh").touch()
2577
+ result = runner.invoke(cli, ["check", "script.sh"])
2578
+
2579
+ # Verify exit code and finding output
2580
+ assert result.exit_code == 1
2581
+ assert "shfmt formatting findings" in result.output
2582
+
2583
+
2584
+ @patch("shutil.which")
2585
+ @patch("subprocess.run")
2586
+ def test_shellcheck_findings_reported(
2587
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2588
+ ) -> None:
2589
+ """Non-zero exit from shellcheck is reported as findings."""
2590
+ # Allow shellcheck to appear installed
2591
+ mock_which.side_effect = lambda name: (
2592
+ name if name in ("shfmt", "shellcheck") else None
2593
+ )
2594
+ mock_run.side_effect = [
2595
+ MagicMock(returncode=0, stdout="", stderr=""),
2596
+ MagicMock(
2597
+ returncode=1,
2598
+ stdout="SC2086: Double quote to prevent globbing",
2599
+ stderr="",
2600
+ ),
2601
+ ]
2602
+
2603
+ # Run check command
2604
+ runner = CliRunner()
2605
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2606
+ Path("script.sh").touch()
2607
+ result = runner.invoke(cli, ["check", "script.sh"])
2608
+
2609
+ # Verify exit code and finding output
2610
+ assert result.exit_code == 1
2611
+ assert "shellcheck findings" in result.output
2612
+
2613
+
2614
+ @patch("shutil.which")
2615
+ @patch("subprocess.run")
2616
+ def test_rustfmt_formatting_findings_reported(
2617
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2618
+ ) -> None:
2619
+ """Non-zero exit from rustfmt check is reported as a formatting finding."""
2620
+ # Allow rustfmt to appear installed
2621
+ mock_which.side_effect = lambda name: name if name == "rustfmt" else None
2622
+ mock_run.return_value = MagicMock(
2623
+ returncode=1, stdout="Diff in main.rs", stderr=""
2624
+ )
2625
+
2626
+ # Run check command
2627
+ runner = CliRunner()
2628
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2629
+ Path("main.rs").touch()
2630
+ result = runner.invoke(cli, ["check", "main.rs"])
2631
+
2632
+ # Verify exit code and finding output
2633
+ assert result.exit_code == 1
2634
+ assert "rustfmt formatting findings" in result.output
2635
+
2636
+
2637
+ @patch("shutil.which", return_value=None)
2638
+ def test_missing_shell_tools_reported(
2639
+ mock_which: MagicMock, tmp_path: Path
2640
+ ) -> None:
2641
+ """Missing shell tools leave shell files unverified."""
2642
+ # Run check command with missing tools
2643
+ runner = CliRunner()
2644
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2645
+ Path("script.sh").touch()
2646
+ result = runner.invoke(cli, ["check", "script.sh"])
2647
+
2648
+ # Verify error message and exit code
2649
+ assert result.exit_code == 1
2650
+ assert "shfmt" in result.stderr
2651
+ assert "shellcheck" in result.stderr
2652
+ assert "nothing was verified" in result.stderr
2653
+
2654
+
2655
+ @patch("shutil.which", return_value=None)
2656
+ def test_missing_rustfmt_reported(
2657
+ mock_which: MagicMock, tmp_path: Path
2658
+ ) -> None:
2659
+ """Missing rustfmt leaves Rust files unverified."""
2660
+ # Run check command with missing rustfmt
2661
+ runner = CliRunner()
2662
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2663
+ Path("main.rs").touch()
2664
+ result = runner.invoke(cli, ["check", "main.rs"])
2665
+
2666
+ # Verify error message and exit code
2667
+ assert result.exit_code == 1
2668
+ assert "rustfmt" in result.stderr
2669
+ assert "nothing was verified" in result.stderr
2670
+
2671
+
2672
+ @patch("shutil.which")
2673
+ @patch("subprocess.run")
2674
+ def test_partial_shell_tools_installed_warns_and_runs_installed(
2675
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2676
+ ) -> None:
2677
+ """A lone installed shell tool runs and warns that the other is absent."""
2678
+ # Only shfmt is installed
2679
+ mock_which.side_effect = lambda name: name if name == "shfmt" else None
2680
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2681
+
2682
+ # Run check command
2683
+ runner = CliRunner()
2684
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2685
+ Path("script.sh").touch()
2686
+ result = runner.invoke(cli, ["check", "script.sh"])
2687
+
2688
+ # Verify shfmt ran, shellcheck was skipped with warning, exit code 0
2689
+ assert result.exit_code == 0
2690
+ assert "shellcheck" in result.stderr
2691
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2692
+ ["shfmt", "-i", "2", "-ci", "-d", "script.sh"]
2693
+ ]
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from unittest.mock import MagicMock
5
5
  from unittest.mock import patch
6
6
 
7
+ import click
7
8
  from click.testing import CliRunner
8
9
 
9
10
  from readability.cli import cli
@@ -523,7 +524,7 @@ def _shipped_guides() -> list[tuple[str, str]]:
523
524
  if filename in seen or not os.path.exists(path):
524
525
  continue
525
526
  seen.add(filename)
526
- with open(path, "r", encoding="utf-8") as f:
527
+ with open(path, encoding="utf-8") as f:
527
528
  guides.append((language, f.read()))
528
529
  return guides
529
530
 
@@ -787,6 +788,21 @@ def test_sync_deduplicates_aliases(
787
788
  assert mock_get_content.call_count == 1
788
789
 
789
790
 
791
+ @patch("readability.guide.get_guide_content")
792
+ def test_sync_reports_a_failed_fetch_in_its_exit_code(
793
+ mock_get_content: MagicMock, tmp_path: Path
794
+ ) -> None:
795
+ """A stale local copy left behind is a failure, not a quiet success."""
796
+ mock_get_content.side_effect = click.ClickException("network is down")
797
+
798
+ with patch("readability.guide.get_guides_dir", return_value=str(tmp_path)):
799
+ runner = CliRunner()
800
+ result = runner.invoke(cli, ["sync", "python"])
801
+
802
+ assert result.exit_code != 0
803
+ assert "Failures: 1" in result.stderr
804
+
805
+
790
806
  def test_sync_rejects_an_unsupported_language(tmp_path: Path) -> None:
791
807
  """An unknown language is a typo, not a reason to sync everything."""
792
808
  with patch("readability.guide.get_guides_dir", return_value=str(tmp_path)):
@@ -194,11 +194,15 @@ wheels = [
194
194
 
195
195
  [[package]]
196
196
  name = "readability-cli"
197
- version = "0.10.2"
197
+ version = "0.10.4"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
- { name = "beautifulsoup4" },
201
200
  { name = "click" },
201
+ ]
202
+
203
+ [package.optional-dependencies]
204
+ sync = [
205
+ { name = "beautifulsoup4" },
202
206
  { name = "markdownify" },
203
207
  { name = "requests" },
204
208
  ]
@@ -206,20 +210,23 @@ dependencies = [
206
210
  [package.dev-dependencies]
207
211
  dev = [
208
212
  { name = "pytest" },
213
+ { name = "readability-cli", extra = ["sync"] },
209
214
  { name = "ruff" },
210
215
  ]
211
216
 
212
217
  [package.metadata]
213
218
  requires-dist = [
214
- { name = "beautifulsoup4", specifier = ">=4.14.3" },
219
+ { name = "beautifulsoup4", marker = "extra == 'sync'", specifier = ">=4.14.3" },
215
220
  { name = "click", specifier = ">=8.3.1" },
216
- { name = "markdownify", specifier = ">=1.2.2" },
217
- { name = "requests", specifier = ">=2.33.0" },
221
+ { name = "markdownify", marker = "extra == 'sync'", specifier = ">=1.2.2" },
222
+ { name = "requests", marker = "extra == 'sync'", specifier = ">=2.33.0" },
218
223
  ]
224
+ provides-extras = ["sync"]
219
225
 
220
226
  [package.metadata.requires-dev]
221
227
  dev = [
222
228
  { name = "pytest", specifier = ">=9.0.2" },
229
+ { name = "readability-cli", extras = ["sync"] },
223
230
  { name = "ruff", specifier = ">=0.15.8" },
224
231
  ]
225
232