readability-cli 0.9.0__tar.gz → 0.10.1__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 (38) hide show
  1. {readability_cli-0.9.0 → readability_cli-0.10.1}/PKG-INFO +38 -5
  2. {readability_cli-0.9.0 → readability_cli-0.10.1}/README.md +37 -4
  3. {readability_cli-0.9.0 → readability_cli-0.10.1}/pyproject.toml +7 -1
  4. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/checking.py +42 -29
  5. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/cli.py +16 -30
  6. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/configs/ruff.toml +8 -0
  7. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guide.py +5 -7
  8. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/outline.py +19 -43
  9. readability_cli-0.10.1/readability/tools.py +560 -0
  10. {readability_cli-0.9.0 → readability_cli-0.10.1}/test_checking.py +674 -47
  11. {readability_cli-0.9.0 → readability_cli-0.10.1}/test_guide.py +9 -11
  12. {readability_cli-0.9.0 → readability_cli-0.10.1}/test_outline.py +16 -26
  13. {readability_cli-0.9.0 → readability_cli-0.10.1}/test_package.py +4 -2
  14. {readability_cli-0.9.0 → readability_cli-0.10.1}/uv.lock +1 -1
  15. readability_cli-0.9.0/readability/tools.py +0 -407
  16. {readability_cli-0.9.0 → readability_cli-0.10.1}/.github/workflows/ci.yml +0 -0
  17. {readability_cli-0.9.0 → readability_cli-0.10.1}/.github/workflows/publish.yml +0 -0
  18. {readability_cli-0.9.0 → readability_cli-0.10.1}/.github/workflows/update-guides.yml +0 -0
  19. {readability_cli-0.9.0 → readability_cli-0.10.1}/.gitignore +0 -0
  20. {readability_cli-0.9.0 → readability_cli-0.10.1}/.python-version +0 -0
  21. {readability_cli-0.9.0 → readability_cli-0.10.1}/LICENSE +0 -0
  22. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/__init__.py +0 -0
  23. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/configs/biome-default.json +0 -0
  24. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/configs/pyrefly.toml +0 -0
  25. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/Rguide.md +0 -0
  26. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/cppguide.md +0 -0
  27. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/csharp-style.md +0 -0
  28. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/docguide-style.md +0 -0
  29. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/go-guide.md +0 -0
  30. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/htmlcssguide.md +0 -0
  31. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/javaguide.md +0 -0
  32. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/jsguide.md +0 -0
  33. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/jsoncstyleguide.md +0 -0
  34. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/objcguide.md +0 -0
  35. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/pyguide.md +0 -0
  36. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/shellguide.md +0 -0
  37. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/tsguide.md +0 -0
  38. {readability_cli-0.9.0 → readability_cli-0.10.1}/readability/guides/vimscriptguide.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readability-cli
3
- Version: 0.9.0
3
+ Version: 0.10.1
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
@@ -168,15 +168,21 @@ The returned `CheckReport` records only whether findings occurred and which
168
168
  tools ran, were skipped, or failed. Detailed tool findings are still written as
169
169
  each tool runs. Every path is validated before any tool runs, and a missing one
170
170
  raises `FileNotFoundError` rather than being misreported as a finding. Relative
171
- paths remain relative to the process working directory; `project_root` controls
172
- configuration discovery only.
171
+ paths remain relative to the process working directory. `project_root` bounds
172
+ configuration discovery, locates project-local tool installs, and roots
173
+ ignore-file discovery. Because it bounds discovery, a caller that passes one
174
+ gets the same verdict for the same files wherever they sit, which is what makes
175
+ the result usable as a baseline. It defaults to the repository the process is
176
+ in, so the command agrees with the tools rather than with the directory the
177
+ caller happened to be standing in.
173
178
 
174
179
  ### Configuring Formats
175
180
 
176
181
  Ruff and Pyrefly defaults follow the
177
182
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
178
- 80-column lines, Google docstrings, import ordering, and full type checking. The
179
- Biome default applies the 80-column lines and two-space indentation of the
183
+ 80-column lines, Google docstrings, one import per line sorted as isort's
184
+ `google` profile does, and full type checking. The Biome default applies the
185
+ 80-column lines and two-space indentation of the
180
186
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
181
187
  and enables Biome's recommended lint rules. Project `biome.json` and
182
188
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -189,6 +195,33 @@ into it through that tool's native configuration. Gofmt has no project settings.
189
195
  The bundled Biome file requires Biome 2.5 or later, matching the fallback
190
196
  runner's version floor.
191
197
 
198
+ Configuration is found per file, in the file's own directory and then its
199
+ ancestors, which is where each canonical tool looks. A package keeping its
200
+ settings in a subdirectory is checked against them however it is reached:
201
+
202
+ ```bash
203
+ # Uses web/biome.json, not the bundled default
204
+ readability check web/app.ts
205
+
206
+ # Uses the repository root's [tool.ruff], not the bundled default
207
+ cd pkg && readability check module.py
208
+
209
+ # pkg/ is checked against pkg/pyproject.toml, the rest against the defaults
210
+ readability check .
211
+ ```
212
+
213
+ A tool is only told which configuration to use when the file has none, because
214
+ each one resolves its own hierarchy: a single Ruff invocation over two packages
215
+ declaring different line lengths reports each against its own. So a mixed tree
216
+ needs no choosing between configurations, and the bundled default never
217
+ overrides one the project declared.
218
+
219
+ Discovery is per tool, so configuring one leaves the others on their bundled
220
+ defaults. It is also bounded, by the repository the command runs in, or by
221
+ `project_root` when calling `check_paths` directly. Configuration outside that
222
+ boundary is not read, so the same paths get the same answer wherever the
223
+ repository sits.
224
+
192
225
  ## Style Guides
193
226
 
194
227
  The `guide` command reads a Google style guide, using the local cache when
@@ -154,15 +154,21 @@ The returned `CheckReport` records only whether findings occurred and which
154
154
  tools ran, were skipped, or failed. Detailed tool findings are still written as
155
155
  each tool runs. Every path is validated before any tool runs, and a missing one
156
156
  raises `FileNotFoundError` rather than being misreported as a finding. Relative
157
- paths remain relative to the process working directory; `project_root` controls
158
- configuration discovery only.
157
+ paths remain relative to the process working directory. `project_root` bounds
158
+ configuration discovery, locates project-local tool installs, and roots
159
+ ignore-file discovery. Because it bounds discovery, a caller that passes one
160
+ gets the same verdict for the same files wherever they sit, which is what makes
161
+ the result usable as a baseline. It defaults to the repository the process is
162
+ in, so the command agrees with the tools rather than with the directory the
163
+ caller happened to be standing in.
159
164
 
160
165
  ### Configuring Formats
161
166
 
162
167
  Ruff and Pyrefly defaults follow the
163
168
  [Google Python style guide](https://google.github.io/styleguide/pyguide.html):
164
- 80-column lines, Google docstrings, import ordering, and full type checking. The
165
- Biome default applies the 80-column lines and two-space indentation of the
169
+ 80-column lines, Google docstrings, one import per line sorted as isort's
170
+ `google` profile does, and full type checking. The Biome default applies the
171
+ 80-column lines and two-space indentation of the
166
172
  [Google JavaScript style guide](https://google.github.io/styleguide/jsguide.html)
167
173
  and enables Biome's recommended lint rules. Project `biome.json` and
168
174
  `biome.jsonc` files replace those bundled defaults for Biome-owned formats.
@@ -175,6 +181,33 @@ into it through that tool's native configuration. Gofmt has no project settings.
175
181
  The bundled Biome file requires Biome 2.5 or later, matching the fallback
176
182
  runner's version floor.
177
183
 
184
+ Configuration is found per file, in the file's own directory and then its
185
+ ancestors, which is where each canonical tool looks. A package keeping its
186
+ settings in a subdirectory is checked against them however it is reached:
187
+
188
+ ```bash
189
+ # Uses web/biome.json, not the bundled default
190
+ readability check web/app.ts
191
+
192
+ # Uses the repository root's [tool.ruff], not the bundled default
193
+ cd pkg && readability check module.py
194
+
195
+ # pkg/ is checked against pkg/pyproject.toml, the rest against the defaults
196
+ readability check .
197
+ ```
198
+
199
+ A tool is only told which configuration to use when the file has none, because
200
+ each one resolves its own hierarchy: a single Ruff invocation over two packages
201
+ declaring different line lengths reports each against its own. So a mixed tree
202
+ needs no choosing between configurations, and the bundled default never
203
+ overrides one the project declared.
204
+
205
+ Discovery is per tool, so configuring one leaves the others on their bundled
206
+ defaults. It is also bounded, by the repository the command runs in, or by
207
+ `project_root` when calling `check_paths` directly. Configuration outside that
208
+ boundary is not read, so the same paths get the same answer wherever the
209
+ repository sits.
210
+
178
211
  ## Style Guides
179
212
 
180
213
  The `guide` command reads a Google style guide, using the local cache when
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.9.0"
3
+ version = "0.10.1"
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,20 +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
- _should_run_tool,
16
- _tool_is_installed,
17
- )
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
18
17
 
19
18
  logger = logging.getLogger("readability")
20
19
  DEFAULT_TIMEOUT = 60
@@ -74,8 +73,10 @@ def check_paths(
74
73
  Args:
75
74
  paths: Files or directories to check, as strings or paths. Relative
76
75
  paths are interpreted from the current working directory.
77
- project_root: Root used only to discover tool configuration. Defaults
78
- to the current working directory; it does not rebase paths.
76
+ project_root: Bounds config discovery, so the same files get the
77
+ same verdict wherever the tree sits, and locates tool installs
78
+ and ignore files. Defaults to the repository the process is in.
79
+ It does not rebase paths.
79
80
  fix: Whether to apply automatic fixes.
80
81
 
81
82
  Returns:
@@ -85,7 +86,11 @@ def check_paths(
85
86
  FileNotFoundError: If any requested path does not exist. Every path is
86
87
  validated before any tools run.
87
88
  """
88
- root = Path.cwd() if project_root is None else project_root
89
+ here = Path.cwd()
90
+ repository = _repository_root(here)
91
+ root = project_root if project_root is not None else (repository or here)
92
+ # A named root bounds the search; otherwise each path's repository does
93
+ boundary = project_root
89
94
  requested_paths = [Path(path) for path in paths]
90
95
  missing_path = next(
91
96
  (path for path in requested_paths if not path.exists()), None
@@ -95,7 +100,7 @@ def check_paths(
95
100
 
96
101
  report = CheckReport()
97
102
  for path in requested_paths:
98
- path_report = _check_path(path, root, fix=fix)
103
+ path_report = _check_path(path, root, boundary, fix=fix)
99
104
  if not path_report.ran:
100
105
  path_report.unverified_paths.append(path)
101
106
  report.absorb(path_report)
@@ -103,13 +108,18 @@ def check_paths(
103
108
 
104
109
 
105
110
  def _check_path(
106
- path: Path, project_root: Path, fix: bool = False
111
+ path: Path,
112
+ project_root: Path,
113
+ boundary: Path | None = None,
114
+ fix: bool = False,
107
115
  ) -> CheckReport:
108
116
  """Apply relevant tools to a single path.
109
117
 
110
118
  Args:
111
119
  path: The path (file or directory) to check.
112
- project_root: The root used for native tool configuration discovery.
120
+ project_root: Locates tool installs and ignore files.
121
+ boundary: Outermost directory config discovery may consider, or
122
+ None to bound it by the path's own repository.
113
123
  fix: Whether to apply automatic fixes.
114
124
 
115
125
  Returns:
@@ -117,14 +127,16 @@ def _check_path(
117
127
  """
118
128
  logger.info("Checking path: %s", path)
119
129
 
130
+ if boundary is None:
131
+ boundary = _repository_root(path) or Path(path.resolve().anchor)
132
+
120
133
  # Iterate through all supported tool definitions
121
134
  report = CheckReport()
122
- for tool in _get_tool_definitions(path, project_root):
135
+ for tool in _get_tool_definitions(path, project_root, boundary):
123
136
  if not _should_run_tool(tool, path):
124
137
  continue
125
138
 
126
- # A tool that is wanted but absent leaves a hole in the coverage,
127
- # which is not the same as a clean result
139
+ # A wanted but absent tool is a hole in coverage, not a clean result
128
140
  if not _tool_is_installed(tool):
129
141
  report.skipped.add(tool.name)
130
142
  continue
@@ -156,10 +168,7 @@ def _run_tool(
156
168
  target_count = len(tool.targets or ())
157
169
  try:
158
170
  if fix:
159
- # Formatters rewrite files, fixers apply what they can. Both
160
- # exit non-zero when something is left over, which is a finding
161
- # rather than a failure, so the check below still gets to run
162
- # and report what they could not deal with.
171
+ # Leftovers exit non-zero, which is a finding rather than a failure
163
172
  for phase in ("format", "fix"):
164
173
  configured_command = getattr(tool, phase)
165
174
  if configured_command:
@@ -208,8 +217,7 @@ def _run_tool(
208
217
  f"{result.stdout}\n{result.stderr}"
209
218
  )
210
219
 
211
- # Failing to start, or running past the timeout, means this tool checked
212
- # nothing. Whatever it managed before that stays in ran.
220
+ # Never starting, or timing out, means this tool checked nothing
213
221
  except (subprocess.SubprocessError, OSError) as e:
214
222
  logger.warning("Could not run %s: %s", tool.name, e)
215
223
  report.failed.add(tool.name)
@@ -227,14 +235,19 @@ def _tool_checked_files(
227
235
  result: The completed subprocess.
228
236
 
229
237
  Returns:
230
- False when Biome explicitly reports that it checked zero files.
238
+ False when the tool explicitly reports that it processed no files.
231
239
  """
232
- if tool_name != "biome":
240
+ # Biome says this for an unmatched target, Ruff when excludes cover them all
241
+ zero_file_reports = {
242
+ "biome": ("Checked 0 files", "Formatted 0 files"),
243
+ "ruff": ("No Python files found",),
244
+ }
245
+ summaries = zero_file_reports.get(tool_name)
246
+ if summaries is None:
233
247
  return True
248
+
234
249
  output = f"{result.stdout or ''}\n{result.stderr or ''}"
235
- # Biome 2.x uses these summaries for an unmatched target.
236
- zero_file_summaries = ("Checked 0 files", "Formatted 0 files")
237
- return not any(summary in output for summary in zero_file_summaries)
250
+ return not any(summary in output for summary in summaries)
238
251
 
239
252
 
240
253
  def _capture_tool_command(
@@ -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
 
@@ -60,8 +59,7 @@ def guide(
60
59
  _echo_languages()
61
60
  return
62
61
 
63
- # Refusing beats picking a winner: a silent precedence rule is how the
64
- # caller ends up reading the wrong thing without being told.
62
+ # Refusing beats a silent precedence rule the caller cannot see
65
63
  if full and reference:
66
64
  raise click.UsageError("--full takes the whole guide, so REF cannot.")
67
65
 
@@ -106,8 +104,7 @@ def _resolve_filenames(languages: Sequence[str]) -> list[str]:
106
104
  f"Language '{language}' is not supported. Supported "
107
105
  f"languages: {', '.join(sorted(LANGUAGE_MAP.keys()))}"
108
106
  )
109
- # Aliases such as 'cpp' and 'c++' resolve to one guide, which is
110
- # fetched once however many of its names were given
107
+ # Aliases share a guide, fetched once however many names were given
111
108
  if filename not in filenames:
112
109
  filenames.append(filename)
113
110
 
@@ -190,8 +187,7 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
190
187
  err=True,
191
188
  )
192
189
 
193
- # A tool that could not start or outlived the timeout checked nothing,
194
- # so its silence is not a pass however far the rest of the run got
190
+ # A tool that never ran checked nothing, so its silence is not a pass
195
191
  if report.failed:
196
192
  click.echo(
197
193
  f"Error: Could not run: {', '.join(sorted(report.failed))}. "
@@ -200,14 +196,11 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
200
196
  )
201
197
  sys.exit(1)
202
198
 
203
- # Findings remain a failed check even when a tool reports that it could
204
- # not process any files (for example, a Biome configuration error).
199
+ # Findings fail the check even if the tool processed no files
205
200
  if report.findings:
206
201
  sys.exit(1)
207
202
 
208
- # Having run nothing is not a pass. Reporting it as one is how this
209
- # command became a silent no-op wherever its tools were absent, gating
210
- # nothing while every caller read the exit code as approval.
203
+ # Having run nothing is not a pass, it is a silent no-op gating nothing
211
204
  if not report.ran and report.skipped:
212
205
  click.echo(
213
206
  f"Error: Every tool for {len(paths)} path(s) is missing, so "
@@ -216,9 +209,7 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
216
209
  )
217
210
  sys.exit(1)
218
211
 
219
- # No tool applying is a fact about the project rather than a fault: there
220
- # is nothing to install and nothing to fix. It still cannot be reported
221
- # as a clean result, because nothing was inspected.
212
+ # No tool applying is a fact, not a fault, but still not a clean result
222
213
  if not report.ran:
223
214
  click.echo(
224
215
  f"No tool applies to {len(paths)} path(s); nothing was checked.",
@@ -235,8 +226,7 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
235
226
  err=True,
236
227
  )
237
228
 
238
- # Findings are the only thing this command printed, so a clean run said
239
- # nothing at all and left the caller unable to tell it from a no-op.
229
+ # Without this a clean run printed nothing and read as a no-op
240
230
  click.echo(
241
231
  f"No findings in {checked_path_count} path(s) "
242
232
  f"({', '.join(sorted(report.ran))}).",
@@ -247,11 +237,7 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
247
237
  # Main entry point for the CLI
248
238
  def main() -> None:
249
239
  """Main entry point for the CLI."""
250
- # Configure logging here rather than at import time so that importing this
251
- # module as a library (e.g. from lemming) has no side effects
252
- # WARNING, not INFO: everything logged below it narrates progress, which
253
- # is what --verbose is for. Leaving it on meant there was no quiet mode
254
- # and the flag could only add DEBUG on top.
240
+ # Configured here, not at import, so library use has no side effects
255
241
  logging.basicConfig(
256
242
  level=logging.WARNING,
257
243
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -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 "