From 3ebdd057526865ee65e6381cf2a0f4565bee6444 Mon Sep 17 00:00:00 2001 From: Hayao Suzuki Date: Tue, 1 Sep 2026 17:51:51 +0900 Subject: [PATCH] Upgrade Click and ufmt Require Click 8.3.3 or newer to address PYSEC-2026-2132, and update ufmt to 2.9.1. Adapt the CLI tests to Click's separate stdout and stderr streams. Preserve Fixit's no-argument help behavior after Click 8.2 changed the default exit status and stream. --- pyproject.toml | 4 +-- src/fixit/cli.py | 13 ++++++++- src/fixit/tests/config.py | 14 +++++----- src/fixit/tests/smoke.py | 55 +++++++++++++++++++++++++-------------- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5ef89d67..7d5dd45f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ - "click >= 8.0, < 8.2", + "click >= 8.3.3, < 9", "libcst >= 0.3.18", "moreorless >= 0.4.0", "packaging >= 21", @@ -48,7 +48,7 @@ dev = [ "build > 1", "flake8 == 7.3.0", "flake8-bugbear == 24.12.12", - "ufmt == 2.8.0", + "ufmt == 2.9.1", "usort == 1.0.8.post1", "pyrefly == 0.63.1", ] diff --git a/src/fixit/cli.py b/src/fixit/cli.py index 84bd344a..b434d6f6 100644 --- a/src/fixit/cli.py +++ b/src/fixit/cli.py @@ -47,7 +47,18 @@ def f(v: int) -> str: click.secho(f"🧼 {len(visited)} {f(len(visited))} clean 🧼", err=True) -@click.group() +class _FixitGroup(click.Group): + """Keep the pre-Click 8.2 behavior for an empty command line.""" + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + click.echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + return super().parse_args(ctx, args) + + +@click.group(cls=_FixitGroup) @click.pass_context @click.version_option(__version__, "--version", "-V", prog_name="fixit") @click.option( diff --git a/src/fixit/tests/config.py b/src/fixit/tests/config.py index 04729734..6fd9318c 100644 --- a/src/fixit/tests/config.py +++ b/src/fixit/tests/config.py @@ -612,7 +612,7 @@ def test_format_output(self) -> None: ) ) - runner = CliRunner(mix_stderr=False) + runner = CliRunner() content = "name = '{name}'.format(name='Jane Doe')" filepath = self.tdp / "f_string.py" filepath.write_text(content) @@ -622,13 +622,13 @@ def test_format_output(self) -> None: result = runner.invoke( main, ["lint", filepath.as_posix()], catch_exceptions=False ) - self.assertRegex(result.output, output_format_regex) + self.assertRegex(result.stdout, output_format_regex) with self.subTest("fixing vscode"): result = runner.invoke( main, ["fix", filepath.as_posix()], catch_exceptions=False ) - self.assertRegex(result.output, output_format_regex) + self.assertRegex(result.stdout, output_format_regex) custom_output_format_regex = r".*f_string\.py|\d+|\d+ UseFstring: .+" custom_output_format = ( @@ -648,13 +648,13 @@ def test_format_output(self) -> None: result = runner.invoke( main, ["lint", filepath.as_posix()], catch_exceptions=False ) - self.assertRegex(result.output, custom_output_format_regex) + self.assertRegex(result.stdout, custom_output_format_regex) with self.subTest("fixing custom"): result = runner.invoke( main, ["fix", filepath.as_posix()], catch_exceptions=False ) - self.assertRegex(result.output, custom_output_format_regex) + self.assertRegex(result.stdout, custom_output_format_regex) with self.subTest("override output-format"): result = runner.invoke( @@ -662,7 +662,7 @@ def test_format_output(self) -> None: ["--output-format", "vscode", "lint", filepath.as_posix()], catch_exceptions=True, ) - self.assertRegex(result.output, output_format_regex) + self.assertRegex(result.stdout, output_format_regex) with self.subTest("override output-template"): result = runner.invoke( @@ -676,7 +676,7 @@ def test_format_output(self) -> None: catch_exceptions=True, ) self.assertRegex( - result.output, r"file .*f_string\.py line \d+ rule UseFstring" + result.stdout, r"file .*f_string\.py line \d+ rule UseFstring" ) def test_validate_config(self) -> None: diff --git a/src/fixit/tests/smoke.py b/src/fixit/tests/smoke.py index 2c4afa34..c4d00e83 100644 --- a/src/fixit/tests/smoke.py +++ b/src/fixit/tests/smoke.py @@ -20,7 +20,22 @@ class SmokeTest(TestCase): def setUp(self) -> None: - self.runner = CliRunner(mix_stderr=False) + self.runner = CliRunner() + + def test_cli_without_args_shows_help(self) -> None: + result = self.runner.invoke(main, []) + + self.assertEqual(result.exit_code, 0) + self.assertRegex(result.stdout, r"^Usage: ") + self.assertIn("Commands:", result.stdout) + self.assertEqual(result.stderr, "") + + def test_cli_options_without_command_fail(self) -> None: + result = self.runner.invoke(main, ["--debug"]) + + self.assertEqual(result.exit_code, 2) + self.assertEqual(result.stdout, "") + self.assertIn("Error: Missing command.", result.stderr) def test_cli_version(self) -> None: result = self.runner.invoke(main, ["--version"]) @@ -66,10 +81,10 @@ def func(): main, ["lint", path.as_posix()], catch_exceptions=False ) - self.assertNotEqual(result.output, "") + self.assertNotEqual(result.stdout, "") self.assertNotEqual(result.exit_code, 0) self.assertRegex( - result.output, + result.stdout, r"file\.py@\d+:\d+ NoRedundantFString: .+ \(has autofix\)", ) self.assertEqual(content, path.read_text(), "file unexpectedly changed") @@ -82,10 +97,10 @@ def func(): catch_exceptions=False, ) - self.assertNotEqual(result.output, "") + self.assertNotEqual(result.stdout, "") self.assertEqual(result.exit_code, 0) self.assertRegex( - result.output, + result.stdout, r"file\.py@\d+:\d+ NoRedundantFString: .+ \(has autofix\)", ) self.assertEqual( @@ -102,10 +117,10 @@ def func(): catch_exceptions=False, ) - self.assertNotEqual(result.output, "") + self.assertNotEqual(result.stdout, "") self.assertEqual(result.exit_code, 0) self.assertRegex( - result.output, + result.stdout, r"file\.py@\d+:\d+ NoRedundantFString: .+ \(has autofix\)", ) self.assertEqual( @@ -120,10 +135,10 @@ def func(): catch_exceptions=False, ) - self.assertNotEqual(result.output, "") + self.assertNotEqual(result.stdout, "") self.assertNotEqual(result.exit_code, 0) self.assertRegex( - result.output, + result.stdout, r"file\.py@\d+:\d+ NoRedundantFString: .+ \(has autofix\)", ) @@ -136,7 +151,7 @@ def func(): ) self.assertEqual(result.exit_code, 0) - self.assertEqual(expected_format, result.output, "unexpected stdout") + self.assertEqual(expected_format, result.stdout, "unexpected stdout") with self.subTest("LSP"): path.write_text(content) @@ -161,20 +176,20 @@ def payload(content: str) -> str: self.assertEqual(result.exit_code, 0) self.assertRegex( - result.output, + result.stdout, r"file\.py\".+\"range\".+\"start\".+\"end\".+\"severity\": 2, \"code\": \"NoRedundantFString\", \"source\": \"fixit\"", ) def test_this_file_is_clean(self) -> None: path = Path(__file__).resolve().as_posix() result = self.runner.invoke(main, ["lint", path], catch_exceptions=False) - self.assertEqual(result.output, "") + self.assertEqual(result.stdout, "") self.assertEqual(result.exit_code, 0) def test_this_project_is_clean(self) -> None: project_dir = Path(__file__).resolve().parent.parent.as_posix() result = self.runner.invoke(main, ["lint", project_dir], catch_exceptions=False) - self.assertEqual(result.output, "") + self.assertEqual(result.stdout, "") self.assertEqual(result.exit_code, 0) def test_directory_with_violations(self) -> None: @@ -184,7 +199,7 @@ def test_directory_with_violations(self) -> None: (tdp / "dirty.py").write_text("name = 'Kirby'\nprint('hello %s' % name)\n") result = self.runner.invoke(main, ["lint", td]) - self.assertIn("dirty.py@2:6 UseFstring:", result.output) + self.assertIn("dirty.py@2:6 UseFstring:", result.stdout) self.assertEqual(result.exit_code, 1) def test_directory_with_errors(self) -> None: @@ -194,7 +209,7 @@ def test_directory_with_errors(self) -> None: (tdp / "broken.py").write_text("print)\n") result = self.runner.invoke(main, ["lint", td]) - self.assertIn("broken.py: EXCEPTION: Syntax Error @ 1:", result.output) + self.assertIn("broken.py: EXCEPTION: Syntax Error @ 1:", result.stdout) self.assertEqual(result.exit_code, 2) def test_directory_with_violations_and_errors(self) -> None: @@ -205,8 +220,8 @@ def test_directory_with_violations_and_errors(self) -> None: (tdp / "broken.py").write_text("print)\n") result = self.runner.invoke(main, ["lint", td]) - self.assertIn("dirty.py@2:6 UseFstring:", result.output) - self.assertIn("broken.py: EXCEPTION: Syntax Error @ 1:", result.output) + self.assertIn("dirty.py@2:6 UseFstring:", result.stdout) + self.assertIn("broken.py: EXCEPTION: Syntax Error @ 1:", result.stdout) self.assertEqual(result.exit_code, 3) def test_directory_with_autofixes(self) -> None: @@ -256,7 +271,7 @@ def foo(): result = self.runner.invoke(main, ["fix", "--automatic", td]) errors = defaultdict(list) - for line in result.output.splitlines(): + for line in result.stdout.splitlines(): fn, _, error = line.partition("@") short, _, _ = error.partition(": ") errors[Path(fn)].append(short) @@ -311,7 +326,7 @@ def func(): catch_exceptions=False, ) - self.assertEqual(result.output, "") + self.assertEqual(result.stdout, "") self.assertEqual(result.exit_code, 0) with self.subTest("fix"): @@ -330,5 +345,5 @@ def func(): catch_exceptions=False, ) - self.assertEqual(result.output, "") + self.assertEqual(result.stdout, "") self.assertEqual(result.exit_code, 0)