readability-cli 0.10.1__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.1 → readability_cli-0.10.2}/PKG-INFO +9 -1
  2. {readability_cli-0.10.1 → readability_cli-0.10.2}/README.md +8 -0
  3. {readability_cli-0.10.1 → readability_cli-0.10.2}/pyproject.toml +1 -1
  4. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/checking.py +13 -4
  5. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/cli.py +8 -2
  6. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/tools.py +12 -1
  7. {readability_cli-0.10.1 → readability_cli-0.10.2}/test_checking.py +147 -4
  8. {readability_cli-0.10.1 → readability_cli-0.10.2}/uv.lock +1 -1
  9. {readability_cli-0.10.1 → readability_cli-0.10.2}/.github/workflows/ci.yml +0 -0
  10. {readability_cli-0.10.1 → readability_cli-0.10.2}/.github/workflows/publish.yml +0 -0
  11. {readability_cli-0.10.1 → readability_cli-0.10.2}/.github/workflows/update-guides.yml +0 -0
  12. {readability_cli-0.10.1 → readability_cli-0.10.2}/.gitignore +0 -0
  13. {readability_cli-0.10.1 → readability_cli-0.10.2}/.python-version +0 -0
  14. {readability_cli-0.10.1 → readability_cli-0.10.2}/LICENSE +0 -0
  15. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/__init__.py +0 -0
  16. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/configs/biome-default.json +0 -0
  17. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/configs/pyrefly.toml +0 -0
  18. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/configs/ruff.toml +0 -0
  19. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guide.py +0 -0
  20. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/Rguide.md +0 -0
  21. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/cppguide.md +0 -0
  22. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/csharp-style.md +0 -0
  23. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/docguide-style.md +0 -0
  24. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/go-guide.md +0 -0
  25. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/htmlcssguide.md +0 -0
  26. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/javaguide.md +0 -0
  27. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/jsguide.md +0 -0
  28. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/jsoncstyleguide.md +0 -0
  29. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/objcguide.md +0 -0
  30. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/pyguide.md +0 -0
  31. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/shellguide.md +0 -0
  32. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/tsguide.md +0 -0
  33. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/guides/vimscriptguide.md +0 -0
  34. {readability_cli-0.10.1 → readability_cli-0.10.2}/readability/outline.py +0 -0
  35. {readability_cli-0.10.1 → readability_cli-0.10.2}/test_guide.py +0 -0
  36. {readability_cli-0.10.1 → readability_cli-0.10.2}/test_outline.py +0 -0
  37. {readability_cli-0.10.1 → readability_cli-0.10.2}/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readability-cli
3
- Version: 0.10.1
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 |
@@ -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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "readability-cli"
3
- version = "0.10.1"
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"
@@ -62,6 +62,7 @@ def check_paths(
62
62
  paths: Sequence[str | Path],
63
63
  project_root: Path | None = None,
64
64
  fix: bool = False,
65
+ unsafe: bool = False,
65
66
  ) -> CheckReport:
66
67
  """Run relevant checks for paths and aggregate what the tools did.
67
68
 
@@ -78,6 +79,8 @@ def check_paths(
78
79
  and ignore files. Defaults to the repository the process is in.
79
80
  It does not rebase paths.
80
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.
81
84
 
82
85
  Returns:
83
86
  A report aggregated across all provided paths.
@@ -100,7 +103,7 @@ def check_paths(
100
103
 
101
104
  report = CheckReport()
102
105
  for path in requested_paths:
103
- path_report = _check_path(path, root, boundary, fix=fix)
106
+ path_report = _check_path(path, root, boundary, fix=fix, unsafe=unsafe)
104
107
  if not path_report.ran:
105
108
  path_report.unverified_paths.append(path)
106
109
  report.absorb(path_report)
@@ -112,6 +115,7 @@ def _check_path(
112
115
  project_root: Path,
113
116
  boundary: Path | None = None,
114
117
  fix: bool = False,
118
+ unsafe: bool = False,
115
119
  ) -> CheckReport:
116
120
  """Apply relevant tools to a single path.
117
121
 
@@ -121,6 +125,7 @@ def _check_path(
121
125
  boundary: Outermost directory config discovery may consider, or
122
126
  None to bound it by the path's own repository.
123
127
  fix: Whether to apply automatic fixes.
128
+ unsafe: Whether those fixes may change behavior or drop comments.
124
129
 
125
130
  Returns:
126
131
  What the tools applicable to this path did.
@@ -141,7 +146,7 @@ def _check_path(
141
146
  report.skipped.add(tool.name)
142
147
  continue
143
148
 
144
- report.absorb(_run_tool(tool, fix=fix))
149
+ report.absorb(_run_tool(tool, fix=fix, unsafe=unsafe))
145
150
 
146
151
  return report
147
152
 
@@ -149,12 +154,14 @@ def _check_path(
149
154
  def _run_tool(
150
155
  tool: ToolPlan,
151
156
  fix: bool = False,
157
+ unsafe: bool = False,
152
158
  ) -> CheckReport:
153
159
  """Orchestrate the execution of a specific formatting or linting tool.
154
160
 
155
161
  Args:
156
162
  tool: The executable plan to run.
157
163
  fix: Whether to apply automatic fixes.
164
+ unsafe: Whether those fixes may change behavior or drop comments.
158
165
 
159
166
  Returns:
160
167
  What the tool did: whether it reported findings, and whether it
@@ -168,9 +175,11 @@ def _run_tool(
168
175
  target_count = len(tool.targets or ())
169
176
  try:
170
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))
171
181
  # Leftovers exit non-zero, which is a finding rather than a failure
172
- for phase in ("format", "fix"):
173
- configured_command = getattr(tool, phase)
182
+ for phase, configured_command in phases:
174
183
  if configured_command:
175
184
  commands = _command_batches(
176
185
  configured_command, target_count
@@ -167,8 +167,13 @@ def _echo_languages() -> None:
167
167
  @click.option(
168
168
  "--fix", is_flag=True, help="Automatically fix issues if possible."
169
169
  )
170
+ @click.option(
171
+ "--unsafe",
172
+ is_flag=True,
173
+ help="Also apply fixes that may change behavior. Implies --fix.",
174
+ )
170
175
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
171
- def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
176
+ def check(paths: Sequence[str], fix: bool, unsafe: bool, verbose: bool) -> None:
172
177
  """Run relevant formatters and linters for given paths.
173
178
 
174
179
  Exits with a non-zero status code if any tool reports findings, so the
@@ -177,7 +182,8 @@ def check(paths: Sequence[str], fix: bool, verbose: bool) -> None:
177
182
  if verbose:
178
183
  logger.setLevel(logging.DEBUG)
179
184
 
180
- 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)
181
187
 
182
188
  # Coverage the caller does not know is missing reads as coverage
183
189
  if report.skipped:
@@ -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.
@@ -1080,8 +1080,16 @@ def test_check_paths_aggregates_str_and_path_inputs(
1080
1080
  failed={"biome"},
1081
1081
  )
1082
1082
  assert mock_check_path.call_args_list == [
1083
- call(Path("src/example.py"), project_root, project_root, fix=True),
1084
- 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
+ ),
1085
1093
  ]
1086
1094
 
1087
1095
 
@@ -1128,7 +1136,7 @@ def test_check_paths_defaults_project_root_to_cwd(
1128
1136
  check_paths([Path("example.py")])
1129
1137
 
1130
1138
  mock_check_path.assert_called_once_with(
1131
- Path("example.py"), tmp_path, None, fix=False
1139
+ Path("example.py"), tmp_path, None, fix=False, unsafe=False
1132
1140
  )
1133
1141
 
1134
1142
 
@@ -1197,7 +1205,9 @@ def test_check_command_delegates_to_public_api(
1197
1205
  Path("script.py").touch()
1198
1206
  result = runner.invoke(cli, ["check", "script.py"])
1199
1207
 
1200
- 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
+ )
1201
1211
  assert result.exit_code == 0
1202
1212
  assert result.stdout == ""
1203
1213
  assert result.stderr == "No findings in 1 path(s) (ruff).\n"
@@ -2052,3 +2062,136 @@ def test_fix_still_reports_what_it_could_not_fix(
2052
2062
  commands = [call.args[0] for call in mock_run.call_args_list]
2053
2063
  assert any("--fix" not in c and "check" in c for c in commands)
2054
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
+ ]
@@ -194,7 +194,7 @@ wheels = [
194
194
 
195
195
  [[package]]
196
196
  name = "readability-cli"
197
- version = "0.10.1"
197
+ version = "0.10.2"
198
198
  source = { editable = "." }
199
199
  dependencies = [
200
200
  { name = "beautifulsoup4" },