readability-cli 0.10.0__tar.gz → 0.10.2__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.0 → readability_cli-0.10.2}/PKG-INFO +12 -3
  2. {readability_cli-0.10.0 → readability_cli-0.10.2}/README.md +11 -2
  3. {readability_cli-0.10.0 → readability_cli-0.10.2}/pyproject.toml +7 -1
  4. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/checking.py +21 -14
  5. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/cli.py +16 -11
  6. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/configs/ruff.toml +8 -0
  7. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guide.py +5 -7
  8. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/outline.py +19 -43
  9. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/tools.py +16 -5
  10. {readability_cli-0.10.0 → readability_cli-0.10.2}/test_checking.py +166 -14
  11. {readability_cli-0.10.0 → readability_cli-0.10.2}/test_guide.py +9 -11
  12. {readability_cli-0.10.0 → readability_cli-0.10.2}/test_outline.py +16 -26
  13. {readability_cli-0.10.0 → readability_cli-0.10.2}/test_package.py +4 -2
  14. {readability_cli-0.10.0 → readability_cli-0.10.2}/uv.lock +1 -1
  15. {readability_cli-0.10.0 → readability_cli-0.10.2}/.github/workflows/ci.yml +0 -0
  16. {readability_cli-0.10.0 → readability_cli-0.10.2}/.github/workflows/publish.yml +0 -0
  17. {readability_cli-0.10.0 → readability_cli-0.10.2}/.github/workflows/update-guides.yml +0 -0
  18. {readability_cli-0.10.0 → readability_cli-0.10.2}/.gitignore +0 -0
  19. {readability_cli-0.10.0 → readability_cli-0.10.2}/.python-version +0 -0
  20. {readability_cli-0.10.0 → readability_cli-0.10.2}/LICENSE +0 -0
  21. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/__init__.py +0 -0
  22. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/configs/biome-default.json +0 -0
  23. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/configs/pyrefly.toml +0 -0
  24. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/Rguide.md +0 -0
  25. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/cppguide.md +0 -0
  26. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/csharp-style.md +0 -0
  27. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/docguide-style.md +0 -0
  28. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/go-guide.md +0 -0
  29. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/htmlcssguide.md +0 -0
  30. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/javaguide.md +0 -0
  31. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/jsguide.md +0 -0
  32. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/jsoncstyleguide.md +0 -0
  33. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/objcguide.md +0 -0
  34. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/pyguide.md +0 -0
  35. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/shellguide.md +0 -0
  36. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/tsguide.md +0 -0
  37. {readability_cli-0.10.0 → readability_cli-0.10.2}/readability/guides/vimscriptguide.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readability-cli
3
- Version: 0.10.0
3
+ Version: 0.10.2
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
5
  Project-URL: Homepage, https://github.com/owahltinez/readability
6
6
  Project-URL: Repository, https://github.com/owahltinez/readability
@@ -90,8 +90,16 @@ readability check src/ tests/ main.py
90
90
 
91
91
  # Automatically fix and format files
92
92
  readability check . --fix
93
+
94
+ # Also apply fixes that may change behavior (implies --fix)
95
+ readability check . --unsafe
93
96
  ```
94
97
 
98
+ Fixes that may change a program's behavior or drop its comments are opt-in
99
+ through `--unsafe`, which every tool applies under its own name: Ruff's
100
+ `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
101
+ such as gofmt, are unaffected by it.
102
+
95
103
  ### Supported Formats
96
104
 
97
105
  | Formats | Owners | Project configuration |
@@ -180,8 +188,9 @@ caller happened to be standing in.
180
188
 
181
189
  Ruff and Pyrefly defaults follow the
182
190
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
183
- 80-column lines, Google docstrings, import ordering, and full type checking. The
184
- Biome default applies the 80-column lines and two-space indentation of the
191
+ 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
185
194
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
186
195
  and enables Biome's recommended lint rules. Project `biome.json` and
187
196
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -76,8 +76,16 @@ readability check src/ tests/ main.py
76
76
 
77
77
  # Automatically fix and format files
78
78
  readability check . --fix
79
+
80
+ # Also apply fixes that may change behavior (implies --fix)
81
+ readability check . --unsafe
79
82
  ```
80
83
 
84
+ Fixes that may change a program's behavior or drop its comments are opt-in
85
+ through `--unsafe`, which every tool applies under its own name: Ruff's
86
+ `--unsafe-fixes` and Biome's `--unsafe`. Tools drawing no such distinction,
87
+ such as gofmt, are unaffected by it.
88
+
81
89
  ### Supported Formats
82
90
 
83
91
  | Formats | Owners | Project configuration |
@@ -166,8 +174,9 @@ caller happened to be standing in.
166
174
 
167
175
  Ruff and Pyrefly defaults follow the
168
176
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
169
- 80-column lines, Google docstrings, import ordering, and full type checking. The
170
- Biome default applies the 80-column lines and two-space indentation of the
177
+ 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
171
180
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
172
181
  and enables Biome's recommended lint rules. Project `biome.json` and
173
182
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.10.0"
3
+ version = "0.10.2"
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"
@@ -47,6 +47,12 @@ ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
47
47
  [tool.ruff.lint.pydocstyle]
48
48
  convention = "google"
49
49
 
50
+ [tool.ruff.lint.isort]
51
+ force-single-line = true
52
+ force-sort-within-sections = true
53
+ single-line-exclusions = ["typing"]
54
+ order-by-type = false
55
+
50
56
  [tool.ruff.lint.per-file-ignores]
51
57
  "test_*.py" = ["D"]
52
58
  "*_test.py" = ["D"]
@@ -1,21 +1,19 @@
1
1
  """Run readability checks and report their coverage."""
2
2
 
3
+ from collections.abc import Sequence
3
4
  import dataclasses
4
5
  import logging
5
- import subprocess
6
- from collections.abc import Sequence
7
6
  from pathlib import Path
7
+ import subprocess
8
8
 
9
9
  import click
10
10
 
11
- from readability.tools import (
12
- ToolPlan,
13
- _command_batches,
14
- _get_tool_definitions,
15
- _repository_root,
16
- _should_run_tool,
17
- _tool_is_installed,
18
- )
11
+ from readability.tools import _command_batches
12
+ from readability.tools import _get_tool_definitions
13
+ from readability.tools import _repository_root
14
+ from readability.tools import _should_run_tool
15
+ from readability.tools import _tool_is_installed
16
+ from readability.tools import ToolPlan
19
17
 
20
18
  logger = logging.getLogger("readability")
21
19
  DEFAULT_TIMEOUT = 60
@@ -64,6 +62,7 @@ def check_paths(
64
62
  paths: Sequence[str | Path],
65
63
  project_root: Path | None = None,
66
64
  fix: bool = False,
65
+ unsafe: bool = False,
67
66
  ) -> CheckReport:
68
67
  """Run relevant checks for paths and aggregate what the tools did.
69
68
 
@@ -80,6 +79,8 @@ def check_paths(
80
79
  and ignore files. Defaults to the repository the process is in.
81
80
  It does not rebase paths.
82
81
  fix: Whether to apply automatic fixes.
82
+ unsafe: Whether those fixes may change behavior or drop comments.
83
+ Tools that draw no such distinction are unaffected.
83
84
 
84
85
  Returns:
85
86
  A report aggregated across all provided paths.
@@ -102,7 +103,7 @@ def check_paths(
102
103
 
103
104
  report = CheckReport()
104
105
  for path in requested_paths:
105
- path_report = _check_path(path, root, boundary, fix=fix)
106
+ path_report = _check_path(path, root, boundary, fix=fix, unsafe=unsafe)
106
107
  if not path_report.ran:
107
108
  path_report.unverified_paths.append(path)
108
109
  report.absorb(path_report)
@@ -114,6 +115,7 @@ def _check_path(
114
115
  project_root: Path,
115
116
  boundary: Path | None = None,
116
117
  fix: bool = False,
118
+ unsafe: bool = False,
117
119
  ) -> CheckReport:
118
120
  """Apply relevant tools to a single path.
119
121
 
@@ -123,6 +125,7 @@ def _check_path(
123
125
  boundary: Outermost directory config discovery may consider, or
124
126
  None to bound it by the path's own repository.
125
127
  fix: Whether to apply automatic fixes.
128
+ unsafe: Whether those fixes may change behavior or drop comments.
126
129
 
127
130
  Returns:
128
131
  What the tools applicable to this path did.
@@ -143,7 +146,7 @@ def _check_path(
143
146
  report.skipped.add(tool.name)
144
147
  continue
145
148
 
146
- report.absorb(_run_tool(tool, fix=fix))
149
+ report.absorb(_run_tool(tool, fix=fix, unsafe=unsafe))
147
150
 
148
151
  return report
149
152
 
@@ -151,12 +154,14 @@ def _check_path(
151
154
  def _run_tool(
152
155
  tool: ToolPlan,
153
156
  fix: bool = False,
157
+ unsafe: bool = False,
154
158
  ) -> CheckReport:
155
159
  """Orchestrate the execution of a specific formatting or linting tool.
156
160
 
157
161
  Args:
158
162
  tool: The executable plan to run.
159
163
  fix: Whether to apply automatic fixes.
164
+ unsafe: Whether those fixes may change behavior or drop comments.
160
165
 
161
166
  Returns:
162
167
  What the tool did: whether it reported findings, and whether it
@@ -170,9 +175,11 @@ def _run_tool(
170
175
  target_count = len(tool.targets or ())
171
176
  try:
172
177
  if fix:
178
+ # A tool with no unsafe mode still fixes what it safely can
179
+ unsafe_fix = tool.unsafe_fix if unsafe else ()
180
+ phases = (("format", tool.format), ("fix", unsafe_fix or tool.fix))
173
181
  # Leftovers exit non-zero, which is a finding rather than a failure
174
- for phase in ("format", "fix"):
175
- configured_command = getattr(tool, phase)
182
+ for phase, configured_command in phases:
176
183
  if configured_command:
177
184
  commands = _command_batches(
178
185
  configured_command, target_count
@@ -1,21 +1,20 @@
1
1
  """Command-line interface for readability."""
2
2
 
3
+ from collections.abc import Sequence
3
4
  import logging
4
5
  import os
5
6
  import sys
6
- from collections.abc import Sequence
7
7
 
8
8
  import click
9
9
 
10
10
  from readability.checking import check_paths
11
- from readability.guide import (
12
- LANGUAGE_MAP,
13
- get_guide,
14
- get_guides_dir,
15
- get_local_path,
16
- refresh_guide,
17
- )
18
- from readability.outline import _echo_outline, _select_section
11
+ from readability.guide import get_guide
12
+ from readability.guide import get_guides_dir
13
+ from readability.guide import get_local_path
14
+ from readability.guide import LANGUAGE_MAP
15
+ from readability.guide import refresh_guide
16
+ from readability.outline import _echo_outline
17
+ from readability.outline import _select_section
19
18
 
20
19
  logger = logging.getLogger("readability")
21
20
 
@@ -168,8 +167,13 @@ def _echo_languages() -> None:
168
167
  @click.option(
169
168
  "--fix", is_flag=True, help="Automatically fix issues if possible."
170
169
  )
170
+ @click.option(
171
+ "--unsafe",
172
+ is_flag=True,
173
+ help="Also apply fixes that may change behavior. Implies --fix.",
174
+ )
171
175
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
172
- def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
176
+ def check(paths: Sequence[str], fix: bool, unsafe: bool, verbose: bool) -> None:
173
177
  """Run relevant formatters and linters for given paths.
174
178
 
175
179
  Exits with a non-zero status code if any tool reports findings, so the
@@ -178,7 +182,8 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
178
182
  if verbose:
179
183
  logger.setLevel(logging.DEBUG)
180
184
 
181
- report = check_paths(paths, fix=fix)
185
+ # Unsafe fixes are still fixes, so asking for them is asking to fix
186
+ report = check_paths(paths, fix=fix or unsafe, unsafe=unsafe)
182
187
 
183
188
  # Coverage the caller does not know is missing reads as coverage
184
189
  if report.skipped:
@@ -14,6 +14,14 @@ ignore = ["PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004"]
14
14
  [lint.pydocstyle]
15
15
  convention = "google"
16
16
 
17
+ [lint.isort]
18
+ # Mirrors isort's google profile; its lexicographical and group-by-package
19
+ # options have no ruff equivalent
20
+ force-single-line = true
21
+ force-sort-within-sections = true
22
+ single-line-exclusions = ["typing"]
23
+ order-by-type = false
24
+
17
25
  [lint.per-file-ignores]
18
26
  # Docstring requirements are noise in test files
19
27
  "test_*.py" = ["D"]
@@ -1,14 +1,15 @@
1
1
  """Fetch and cache Google style guides."""
2
2
 
3
+ from importlib.resources import files
3
4
  import logging
4
5
  import os
5
6
  import warnings
6
- from importlib.resources import files
7
7
 
8
+ from bs4 import BeautifulSoup
9
+ from bs4 import XMLParsedAsHTMLWarning
8
10
  import click
9
- import requests
10
- from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
11
11
  from markdownify import markdownify as md
12
+ import requests
12
13
 
13
14
  warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
14
15
 
@@ -55,10 +56,7 @@ LANGUAGE_MAP = {
55
56
 
56
57
  BASE_URL = "https://google.github.io/styleguide/"
57
58
 
58
- # Style guides are dense with snake_case and dunder identifiers, and escaping
59
- # their underscores leaves 'from \_\_future\_\_ import' in the text a caller
60
- # greps or reads. CommonMark would render a bare '__future__' as emphasis, but
61
- # these guides are consumed as plain text, where fidelity matters more.
59
+ # Escaping underscores would leave 'from \_\_future\_\_ import' in the text
62
60
  MARKDOWNIFY_OPTIONS = {"heading_style": "ATX", "escape_underscores": False}
63
61
 
64
62
 
@@ -1,31 +1,27 @@
1
1
  """Parse, search, and render style-guide outlines."""
2
2
 
3
+ from collections.abc import Iterator
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
3
6
  import re
4
7
  import sys
5
- from collections.abc import Iterator, Sequence
6
- from dataclasses import dataclass
7
8
  from typing import Any
8
9
 
9
10
  import click
10
11
 
11
- # A fence opens or closes a code block; anything inside is sample code, not
12
- # document structure. Guides for `#`-commented languages (shell, Python) would
13
- # otherwise report hundreds of code comments as headings.
12
+ # Inside a fence is sample code, where a '#' comment is not a heading
14
13
  FENCE_PATTERN = re.compile(r"^\s{0,3}(?:```|~~~)")
15
14
 
16
- # Closing hashes are optional in ATX headings and must be space-separated, so
17
- # a title such as 'C#' keeps its trailing character.
15
+ # Closing hashes are space-separated, so a title like 'C#' keeps its own
18
16
  HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$")
19
17
 
20
- # A section number the document itself prints, e.g. '2.2' in '2.2 Imports'.
18
+ # A number the document prints itself, e.g. '2.2' in '2.2 Imports'
21
19
  DOCUMENT_NUMBER_PATTERN = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.+)$")
22
20
 
23
- # Guides put a heading's link targets on the lines above it, which would
24
- # otherwise trail the end of the preceding section.
21
+ # Link targets sit above their heading, not at the end of the one before
25
22
  ANCHOR_PATTERN = re.compile(r'^<a id="[^"]*"></a>$')
26
23
 
27
- # The separator in a scoped reference such as 'Imports > Decision'. Spaces on
28
- # both sides are required so that a heading like '`Array<T>` Type' stays whole.
24
+ # Scoped reference separator; spaces required so '`Array<T>` Type' stays whole
29
25
  PATH_SEPARATOR_PATTERN = re.compile(r"\s+>\s+")
30
26
 
31
27
 
@@ -148,8 +144,7 @@ def parse_headings(content: str) -> list[Heading]:
148
144
  if not raw:
149
145
  return []
150
146
 
151
- # Only three of the shipped guides number their headings. A dotted number
152
- # ('2.2') is the reliable signal; a bare leading digit is not.
147
+ # A dotted number is the reliable signal; a bare leading digit is not
153
148
  document_numbers = [
154
149
  match.group(1)
155
150
  for _, _, text in raw
@@ -157,8 +152,7 @@ def parse_headings(content: str) -> list[Heading]:
157
152
  ]
158
153
  numbered = any("." in number for number in document_numbers)
159
154
 
160
- # A lone top-level heading is the document title: it roots the tree rather
161
- # than being the first section, so numbering starts with its children.
155
+ # A lone top-level heading is the title, so numbering starts below it
162
156
  top_level = min(level for _, level, _ in raw)
163
157
  top_level_lines = [line for line, level, _ in raw if level == top_level]
164
158
  title_line = top_level_lines[0] if len(top_level_lines) == 1 else None
@@ -169,12 +163,7 @@ def parse_headings(content: str) -> list[Heading]:
169
163
  index = "" if line == title_line else _next_index(open_headings, level)
170
164
  number, title = _split_document_number(text, numbered)
171
165
 
172
- # A guide that numbers its own sections is the authority on what they
173
- # are called, so its numbers address them. A positional index would
174
- # drift wherever the guide skips one — pyguide has no 2.15 at all,
175
- # and calling its 2.16 by that name would cite a section that does
176
- # not exist. The tree still advances above, so headings the guide
177
- # leaves unnumbered keep a positional index to be reachable by.
166
+ # A guide's own numbers address its sections; an index would drift
178
167
  if number:
179
168
  index = number
180
169
  headings.append(
@@ -203,10 +192,7 @@ def _slugify(text: str) -> str:
203
192
  return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
204
193
 
205
194
 
206
- # Sections this long are worth warning about before one is fetched. Measured
207
- # over the shipped corpus, it marks the 4% that are expensive while leaving
208
- # the rest unannotated: a size on every line would read '0' on 59% of them,
209
- # which is noise in the one output whose job is to be scanned quickly.
195
+ # Marks the expensive 4% of the corpus; a size on every line would be noise
210
196
  LARGE_SECTION_WORDS = 1200
211
197
 
212
198
 
@@ -263,8 +249,7 @@ def format_outline(headings: Sequence[Heading], content: str = "") -> str:
263
249
  section = extract_section(content, headings, position)
264
250
  size = _format_size(len(section.split()))
265
251
 
266
- # The index already carries the guide's own number where it has one,
267
- # so printing the heading verbatim would show it twice.
252
+ # The index already carries the guide's number where it has one
268
253
  lines.append(f"{indent}{prefix}{heading.title}{size}")
269
254
 
270
255
  return "\n".join(lines)
@@ -281,8 +266,7 @@ def _matches_component(heading: Heading, component: str, exact: bool) -> bool:
281
266
  Returns:
282
267
  True if the component identifies the heading.
283
268
  """
284
- # Numbers are only ever compared in full; a substring of a number would
285
- # match unrelated sections.
269
+ # Numbers compare in full; a substring would match unrelated sections
286
270
  wanted = component.strip().rstrip(".")
287
271
  if wanted and wanted in {heading.index, heading.number}:
288
272
  return True
@@ -369,8 +353,7 @@ def find_headings(headings: Sequence[Heading], reference: str) -> list[int]:
369
353
  if not components:
370
354
  return []
371
355
 
372
- # Prefer whole matches; fall back to substrings only when nothing matches
373
- # in full, so 'Imports' does not also select 'Imports and Exports'.
356
+ # Whole matches first, so 'Imports' does not select 'Imports and Exports'
374
357
  for exact in (True, False):
375
358
  matches = [
376
359
  position
@@ -512,9 +495,7 @@ def find_mentions(
512
495
 
513
496
  lines = content.splitlines()
514
497
 
515
- # Only an indexed heading can be offered. The document title has no index
516
- # because it is the whole guide rather than a section within it, so
517
- # crediting the preamble to it would suggest reading everything.
498
+ # The title has no index, so crediting it would suggest reading everything
518
499
  sections = [
519
500
  (position, _section_bounds(lines, headings, position))
520
501
  for position in range(len(headings))
@@ -526,10 +507,7 @@ def find_mentions(
526
507
  if needle not in line.lower():
527
508
  continue
528
509
 
529
- # Bounds nest, so the last section still covering the line is the
530
- # innermost one that prints it. A section sheds the anchors of the
531
- # heading below it, and those fall to an ancestor rather than to the
532
- # section they merely trail.
510
+ # Bounds nest, so the last one covering a line is what prints it
533
511
  holders = [
534
512
  position
535
513
  for position, (start, end) in sections
@@ -577,8 +555,7 @@ def _echo_outline(content: str, language: str) -> None:
577
555
  example = _example_reference(headings)
578
556
  if example:
579
557
  count = sum(1 for heading in headings if heading.index)
580
- # stdout is block-buffered when redirected, so without this the hint
581
- # lands above the outline it is meant to follow
558
+ # Redirected stdout is block-buffered, so the hint would land first
582
559
  sys.stdout.flush()
583
560
  click.echo(
584
561
  f"# {count} sections · print one: "
@@ -656,8 +633,7 @@ def _select_section(content: str, reference: str, language: str) -> str:
656
633
  if not matches:
657
634
  _report_no_heading(content, headings, reference, language)
658
635
 
659
- # Reporting every candidate beats returning the first one silently, since
660
- # guides repeat headings ('Decision' appears under every Python rule).
636
+ # Guides repeat headings, so name every candidate rather than guess
661
637
  if len(matches) > 1:
662
638
  click.echo(
663
639
  f"Error: '{reference}' matches {len(matches)} headings in the "
@@ -1,13 +1,13 @@
1
1
  """Build executable plans for readability's canonical tools."""
2
2
 
3
- import logging
4
- import os
5
- import shutil
6
- import tomllib
7
3
  from collections.abc import Sequence
8
4
  from dataclasses import dataclass
9
5
  from importlib.resources import files
6
+ import logging
7
+ import os
10
8
  from pathlib import Path
9
+ import shutil
10
+ import tomllib
11
11
 
12
12
  logger = logging.getLogger("readability")
13
13
 
@@ -21,6 +21,7 @@ class ToolPlan:
21
21
  check: tuple[str, ...] = ()
22
22
  check_format: tuple[str, ...] = ()
23
23
  fix: tuple[str, ...] = ()
24
+ unsafe_fix: tuple[str, ...] = ()
24
25
  format: tuple[str, ...] = ()
25
26
  targets: tuple[str, ...] | None = None
26
27
  cwd: Path | None = None
@@ -300,10 +301,16 @@ def _plan(
300
301
  for exclude in excludes
301
302
  for argument in (str(flag), str(exclude))
302
303
  ]
304
+ # Every tool spells its own opt-in, so callers only ask for the intent
305
+ words_by_phase: dict[str, tuple[str, ...]] = dict(TOOL_PHASES[tool])
306
+ unsafe_flag = UNSAFE_FLAGS.get(tool)
307
+ if unsafe_flag and "fix" in words_by_phase:
308
+ words_by_phase["unsafe_fix"] = (*words_by_phase["fix"], unsafe_flag)
309
+
303
310
  # Named rather than unpacked, so the type checker still sees the fields
304
311
  phases = {
305
312
  phase: (*binary, *words, *config, *carved, *argv)
306
- for phase, words in TOOL_PHASES[tool].items()
313
+ for phase, words in words_by_phase.items()
307
314
  }
308
315
  return ToolPlan(
309
316
  name=tool,
@@ -313,6 +320,7 @@ def _plan(
313
320
  check=phases.get("check", ()),
314
321
  check_format=phases.get("check_format", ()),
315
322
  fix=phases.get("fix", ()),
323
+ unsafe_fix=phases.get("unsafe_fix", ()),
316
324
  format=phases.get("format", ()),
317
325
  )
318
326
 
@@ -386,6 +394,9 @@ TOOL_PHASES = {
386
394
  "gofmt": {"check_format": ("-l",), "format": ("-w",)},
387
395
  }
388
396
 
397
+ # Per tool: how it names fixes that may change behavior or drop comments
398
+ UNSAFE_FLAGS = {"ruff": "--unsafe-fixes", "biome": "--unsafe"}
399
+
389
400
 
390
401
  def _matching_paths(path: Path, extensions: Sequence[str]) -> list[str]:
391
402
  """Return files under a requested path owned by one tool.
@@ -1,20 +1,21 @@
1
1
  import json
2
+ from pathlib import Path
2
3
  import subprocess
3
4
  import tomllib
4
- from pathlib import Path
5
- from unittest.mock import MagicMock, call, patch
5
+ from unittest.mock import call
6
+ from unittest.mock import MagicMock
7
+ from unittest.mock import patch
6
8
 
7
- import pytest
8
9
  from click.testing import CliRunner
10
+ import pytest
9
11
 
10
- from readability.checking import CheckReport, check_paths
12
+ from readability.checking import check_paths
13
+ from readability.checking import CheckReport
11
14
  from readability.cli import cli
12
- from readability.tools import (
13
- TOOL_RUNNERS,
14
- _bundled_config,
15
- _get_tool_definitions,
16
- _has_project_config,
17
- )
15
+ from readability.tools import _bundled_config
16
+ from readability.tools import _get_tool_definitions
17
+ from readability.tools import _has_project_config
18
+ from readability.tools import TOOL_RUNNERS
18
19
 
19
20
 
20
21
  def _source_file(directory: Path, name: str) -> Path:
@@ -1035,6 +1036,14 @@ def test_bundled_default_configs_are_valid() -> None:
1035
1036
  assert ruff_config["line-length"] == 80
1036
1037
  assert ruff_config["lint"]["pydocstyle"]["convention"] == "google"
1037
1038
 
1039
+ # Import sorting mirrors isort's google profile
1040
+ assert ruff_config["lint"]["isort"] == {
1041
+ "force-single-line": True,
1042
+ "force-sort-within-sections": True,
1043
+ "single-line-exclusions": ["typing"],
1044
+ "order-by-type": False,
1045
+ }
1046
+
1038
1047
 
1039
1048
  @patch("readability.checking._check_path")
1040
1049
  def test_check_paths_aggregates_str_and_path_inputs(
@@ -1071,8 +1080,16 @@ def test_check_paths_aggregates_str_and_path_inputs(
1071
1080
  failed={"biome"},
1072
1081
  )
1073
1082
  assert mock_check_path.call_args_list == [
1074
- call(Path("src/example.py"), project_root, project_root, fix=True),
1075
- call(Path("main.go"), project_root, project_root, fix=True),
1083
+ call(
1084
+ Path("src/example.py"),
1085
+ project_root,
1086
+ project_root,
1087
+ fix=True,
1088
+ unsafe=False,
1089
+ ),
1090
+ call(
1091
+ Path("main.go"), project_root, project_root, fix=True, unsafe=False
1092
+ ),
1076
1093
  ]
1077
1094
 
1078
1095
 
@@ -1119,7 +1136,7 @@ def test_check_paths_defaults_project_root_to_cwd(
1119
1136
  check_paths([Path("example.py")])
1120
1137
 
1121
1138
  mock_check_path.assert_called_once_with(
1122
- Path("example.py"), tmp_path, None, fix=False
1139
+ Path("example.py"), tmp_path, None, fix=False, unsafe=False
1123
1140
  )
1124
1141
 
1125
1142
 
@@ -1188,7 +1205,9 @@ def test_check_command_delegates_to_public_api(
1188
1205
  Path("script.py").touch()
1189
1206
  result = runner.invoke(cli, ["check", "script.py"])
1190
1207
 
1191
- mock_check_paths.assert_called_once_with(("script.py",), fix=False)
1208
+ mock_check_paths.assert_called_once_with(
1209
+ ("script.py",), fix=False, unsafe=False
1210
+ )
1192
1211
  assert result.exit_code == 0
1193
1212
  assert result.stdout == ""
1194
1213
  assert result.stderr == "No findings in 1 path(s) (ruff).\n"
@@ -2043,3 +2062,136 @@ def test_fix_still_reports_what_it_could_not_fix(
2043
2062
  commands = [call.args[0] for call in mock_run.call_args_list]
2044
2063
  assert any("--fix" not in c and "check" in c for c in commands)
2045
2064
  assert result.exit_code != 0
2065
+
2066
+
2067
+ @patch("shutil.which")
2068
+ @patch("subprocess.run")
2069
+ def test_unsafe_translates_to_the_ruff_flag(
2070
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2071
+ ) -> None:
2072
+ """--unsafe reaches ruff under the name ruff gives it.
2073
+
2074
+ Args:
2075
+ mock_run: The mocked subprocess.run function.
2076
+ mock_which: The mocked shutil.which function.
2077
+ tmp_path: The temporary directory fixture.
2078
+ """
2079
+ mock_which.side_effect = lambda name: name if name == "ruff" else None
2080
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2081
+
2082
+ runner = CliRunner()
2083
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2084
+ Path("pyproject.toml").touch()
2085
+ Path("script.py").touch()
2086
+
2087
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "script.py"])
2088
+
2089
+ assert result.exit_code == 0
2090
+ cfg = str(_bundled_config("ruff"))
2091
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2092
+ assert [
2093
+ "ruff",
2094
+ "check",
2095
+ "--fix",
2096
+ "--force-exclude",
2097
+ "--unsafe-fixes",
2098
+ "--config",
2099
+ cfg,
2100
+ "script.py",
2101
+ ] in called_cmds
2102
+ # Formatters have no unsafe mode, so the flag must not reach them
2103
+ assert [
2104
+ "ruff",
2105
+ "format",
2106
+ "--force-exclude",
2107
+ "--config",
2108
+ cfg,
2109
+ "script.py",
2110
+ ] in called_cmds
2111
+
2112
+
2113
+ @patch("shutil.which")
2114
+ @patch("subprocess.run")
2115
+ def test_unsafe_translates_to_the_biome_flag(
2116
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2117
+ ) -> None:
2118
+ """Biome spells the same intent differently and gets its own spelling.
2119
+
2120
+ Args:
2121
+ mock_run: The mocked subprocess.run function.
2122
+ mock_which: The mocked shutil.which function.
2123
+ tmp_path: The temporary directory fixture.
2124
+ """
2125
+ mock_which.side_effect = lambda name: name if name == "biome" else None
2126
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2127
+
2128
+ runner = CliRunner()
2129
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2130
+ Path("script.ts").touch()
2131
+
2132
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "script.ts"])
2133
+
2134
+ assert result.exit_code == 0
2135
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2136
+ lint = next(c for c in called_cmds if "lint" in c)
2137
+ assert lint[:5] == [
2138
+ "biome",
2139
+ "lint",
2140
+ "--write",
2141
+ "--no-errors-on-unmatched",
2142
+ "--unsafe",
2143
+ ]
2144
+
2145
+
2146
+ @patch("shutil.which")
2147
+ @patch("subprocess.run")
2148
+ def test_unsafe_implies_fix(
2149
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2150
+ ) -> None:
2151
+ """Asking for unsafe fixes without --fix asks for nothing otherwise.
2152
+
2153
+ Args:
2154
+ mock_run: The mocked subprocess.run function.
2155
+ mock_which: The mocked shutil.which function.
2156
+ tmp_path: The temporary directory fixture.
2157
+ """
2158
+ mock_which.side_effect = lambda name: name if name == "ruff" else None
2159
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2160
+
2161
+ runner = CliRunner()
2162
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2163
+ Path("pyproject.toml").touch()
2164
+ Path("script.py").touch()
2165
+
2166
+ result = runner.invoke(cli, ["check", "--unsafe", "script.py"])
2167
+
2168
+ assert result.exit_code == 0
2169
+ called_cmds = [call.args[0] for call in mock_run.call_args_list]
2170
+ assert any("--unsafe-fixes" in command for command in called_cmds)
2171
+
2172
+
2173
+ @patch("shutil.which")
2174
+ @patch("subprocess.run")
2175
+ def test_unsafe_is_a_no_op_for_tools_without_one(
2176
+ mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path
2177
+ ) -> None:
2178
+ """A tool with no unsafe mode still fixes rather than being skipped.
2179
+
2180
+ Args:
2181
+ mock_run: The mocked subprocess.run function.
2182
+ mock_which: The mocked shutil.which function.
2183
+ tmp_path: The temporary directory fixture.
2184
+ """
2185
+ mock_which.side_effect = lambda name: name if name == "gofmt" else None
2186
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
2187
+
2188
+ runner = CliRunner()
2189
+ with runner.isolated_filesystem(temp_dir=tmp_path):
2190
+ Path("main.go").touch()
2191
+
2192
+ result = runner.invoke(cli, ["check", "--fix", "--unsafe", "main.go"])
2193
+
2194
+ assert result.exit_code == 0
2195
+ assert [invocation.args[0] for invocation in mock_run.call_args_list] == [
2196
+ ["gofmt", "-w", "main.go"]
2197
+ ]
@@ -1,20 +1,19 @@
1
1
  import os
2
2
  from pathlib import Path
3
- from unittest.mock import MagicMock, patch
3
+ from unittest.mock import MagicMock
4
+ from unittest.mock import patch
4
5
 
5
6
  import click
7
+ from click.testing import CliRunner
6
8
  import pytest
7
9
  import requests
8
- from click.testing import CliRunner
9
10
 
10
11
  from readability.cli import cli
11
- from readability.guide import (
12
- convert_to_markdown,
13
- get_guide,
14
- get_guide_content,
15
- get_guides_dir,
16
- get_local_path,
17
- )
12
+ from readability.guide import convert_to_markdown
13
+ from readability.guide import get_guide
14
+ from readability.guide import get_guide_content
15
+ from readability.guide import get_guides_dir
16
+ from readability.guide import get_local_path
18
17
 
19
18
 
20
19
  def test_get_guide_unsupported() -> None:
@@ -159,8 +158,7 @@ def test_sync_command(
159
158
  result = runner.invoke(cli, ["sync"])
160
159
 
161
160
  assert result.exit_code == 0
162
- # Progress is an outcome the caller asked for, so it is reported
163
- # regardless of log level rather than logged as narration.
161
+ # An asked-for outcome, so reported regardless of log level
164
162
  assert "Sync complete" in result.stderr
165
163
  # Check if at least one guide was "synced" (written to tmp_path)
166
164
  assert len(os.listdir(tmp_path)) > 0
@@ -1,23 +1,21 @@
1
1
  import logging
2
2
  import os
3
3
  from pathlib import Path
4
- from unittest.mock import MagicMock, patch
4
+ from unittest.mock import MagicMock
5
+ from unittest.mock import patch
5
6
 
6
7
  from click.testing import CliRunner
7
8
 
8
- from readability.cli import cli, main
9
- from readability.guide import (
10
- LANGUAGE_MAP,
11
- get_local_path,
12
- )
13
- from readability.outline import (
14
- _iter_heading_lines,
15
- _unique_reference,
16
- extract_section,
17
- find_headings,
18
- find_mentions,
19
- parse_headings,
20
- )
9
+ from readability.cli import cli
10
+ from readability.cli import main
11
+ from readability.guide import get_local_path
12
+ from readability.guide import LANGUAGE_MAP
13
+ from readability.outline import _iter_heading_lines
14
+ from readability.outline import _unique_reference
15
+ from readability.outline import extract_section
16
+ from readability.outline import find_headings
17
+ from readability.outline import find_mentions
18
+ from readability.outline import parse_headings
21
19
 
22
20
  NUMBERED_GUIDE = """# Sample Style Guide
23
21
 
@@ -456,10 +454,7 @@ def test_cli_section_heading_containing_an_angle_bracket(
456
454
  assert "Array body." in result.stdout
457
455
 
458
456
 
459
- # The heading scanner is deliberately not a Markdown parser (see the note on
460
- # _iter_heading_lines). These tests are the evidence for that decision: the
461
- # scope it covers, the constructs it rejects, and its behaviour over every
462
- # guide actually shipped. A failure here means the scope has been outgrown.
457
+ # Evidence the scanner need not be a Markdown parser; a failure means it is
463
458
 
464
459
 
465
460
  def test_heading_scanner_rejects_non_headings() -> None:
@@ -551,8 +546,7 @@ def test_every_shipped_guide_parses_into_unique_sections() -> None:
551
546
  f"{language}: duplicate positional indices"
552
547
  )
553
548
 
554
- # A heading taken from inside a fence would carry a comment marker
555
- # or a shebang, neither of which appears in a real guide heading.
549
+ # A fenced line would carry a comment marker or a shebang
556
550
  for heading in headings:
557
551
  assert not heading.title.startswith("!"), (
558
552
  f"{language}: shebang parsed as heading: {heading.title!r}"
@@ -575,9 +569,7 @@ def test_every_shipped_guide_heading_is_addressable() -> None:
575
569
  )
576
570
  assert titles[0] is headings[0], f"{language}: title is not first"
577
571
 
578
- # An index alone is not always enough: where a guide prints its own
579
- # numbers those drift from the tree, so one string can name two
580
- # headings. What must always hold is that some reference resolves.
572
+ # A guide's own numbers drift, so only 'some reference resolves' holds
581
573
  for position, heading in enumerate(headings):
582
574
  reference = _unique_reference(headings, position).strip('"')
583
575
  matches = find_headings(headings, reference)
@@ -587,9 +579,7 @@ def test_every_shipped_guide_heading_is_addressable() -> None:
587
579
  )
588
580
 
589
581
 
590
- # Words the corpus uses often enough to exercise many sections at once.
591
- # 'truefalse' appears only inside pyguide's anchors, which is the shape that
592
- # used to be credited to whichever section happened to precede them.
582
+ # Common words, plus 'truefalse' which appears only inside pyguide's anchors
593
583
  MENTION_PROBES = (
594
584
  "truefalse",
595
585
  "indent",
@@ -1,8 +1,10 @@
1
1
  from importlib.resources import files
2
2
 
3
3
  import readability
4
- from readability.checking import CheckReport, check_paths
5
- from readability.cli import cli, main
4
+ from readability.checking import check_paths
5
+ from readability.checking import CheckReport
6
+ from readability.cli import cli
7
+ from readability.cli import main
6
8
 
7
9
 
8
10
  def test_public_modules_are_importable() -> None:
@@ -194,7 +194,7 @@ wheels = [
194
194
 
195
195
  [[package]]
196
196
  name = "readability-cli"
197
- version = "0.9.0"
197
+ version = "0.10.2"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },