From c8f577dc11d82fc393275b16ec5e947945c65232 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 11:04:44 +0800 Subject: [PATCH 01/12] fix: mac autocompletion error --- fast_dev_cli/cli.py | 4 ++-- tests/test_lint.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index 39108c6..36077a8 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -798,7 +798,7 @@ def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> @cli.command(name="lint") def make_style( - files: Optional[list[Path]] = typer.Argument(default=None), # noqa:B008 + files: Optional[list[str]] = typer.Argument(default=None), # noqa:B008 check_only: bool = Option(False, "--check-only", "-c"), bandit: bool = Option(False, "--bandit", help="Run `bandit -r `"), skip_mypy: bool = Option(False, "--skip-mypy"), @@ -809,7 +809,7 @@ def make_style( ) -> None: """Run: ruff check/format to reformat code and then mypy to check""" if getattr(files, "default", files) is None: - files = [Path(".")] + files = ["."] elif isinstance(files, str): files = [files] skip = _ensure_bool(skip_mypy) diff --git a/tests/test_lint.py b/tests/test_lint.py index 4156793..84774b5 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -164,7 +164,7 @@ def test_dmypy_run(monkeypatch): def test_lint_with_prefix(mocker): mocker.patch("fast_dev_cli.cli.is_venv", return_value=False) with capture_stdout() as stream: - make_style([Path(".")], check_only=False, dry=True) + make_style(["."], check_only=False, dry=True) assert "pdm run" in stream.getvalue() @@ -174,13 +174,13 @@ def test_make_style(mock_skip_mypy_0, mocker, mock_no_dmypy): make_style(check_only=False, dry=True) assert LINT_CMD in stream.getvalue() with capture_stdout() as stream: - make_style([Path(".")], check_only=False, dry=True) + make_style(["."], check_only=False, dry=True) assert LINT_CMD in stream.getvalue() with capture_stdout() as stream: make_style(".", check_only=False, dry=True) # type:ignore[arg-type] assert LINT_CMD in stream.getvalue() with capture_stdout() as stream: - make_style([Path(".")], check_only=True, dry=True) + make_style(["."], check_only=True, dry=True) assert CHECK_CMD in stream.getvalue() with capture_stdout() as stream: only_check(dry=True) From 95a196784c75d6260d86d52527426703c5e66d83 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 11:23:53 +0800 Subject: [PATCH 02/12] refactor: make it easy to test --- fast_dev_cli/cli.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index 36077a8..5151801 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -209,9 +209,9 @@ def __init__( super().__init__(dry=dry) @staticmethod - def get_last_commit_message() -> str: + def get_last_commit_message(raises=False) -> str: cmd = 'git show --pretty=format:"%s" -s HEAD' - return capture_cmd_output(cmd) + return capture_cmd_output(cmd, raises=raises) @classmethod def should_add_emoji(cls) -> bool: @@ -219,9 +219,12 @@ def should_add_emoji(cls) -> bool: If last commit message is startswith emoji, add a ⬆️ flag at the prefix of bump up commit message. """ - if out := cls.get_last_commit_message(): - return emoji.is_emoji(out[0]) - return False + try: + first_char = cls.get_last_commit_message(raises=True)[0] + except (IndexError, ShellCommandError): + return False + else: + return emoji.is_emoji(first_char) @staticmethod def parse_filename() -> str: From d711d8ff6897270025c8c77e8ea30a19f0843ce9 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 11:46:04 +0800 Subject: [PATCH 03/12] tests: improve coverage --- tests/test_bump.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_bump.py b/tests/test_bump.py index 043341e..6aebe99 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -1,4 +1,5 @@ import os +import shutil import subprocess from contextlib import redirect_stdout from io import StringIO @@ -200,3 +201,25 @@ def test_bump_with_uv(tmp_path): Path(TOML_FILE).write_text("[project]" + os.linesep + 'version = "0.1.0"') command = BumpUp(part="patch", commit=True).gen() assert TOML_FILE in command + + +def test_parse_filename(tmp_path): + pyproject = """ +[tool.poetry] +version = "0" + """ + project_dir = tmp_path / "helloworld" + project_dir.mkdir() + with chdir(project_dir): + toml_file = project_dir.joinpath(TOML_FILE) + toml_file.write_text(pyproject) + src_dir = project_dir / project_dir.name + src_dir.mkdir() + init_file = src_dir / "__init__.py" + init_file.write_text('__version__ = "0.1.0"') + another_dir = project_dir / "hello" + another_dir.mkdir() + shutil.copy(init_file, another_dir / init_file.name) + assert BumpUp.parse_filename() == "helloworld/__init__.py" + toml_file.write_text(pyproject.strip() + '\npackages=[{include="hello"}]') + assert BumpUp.parse_filename() == "hello/__init__.py" From b1017db7cdefc921730612dbfe886cded71710ed Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 12:24:33 +0800 Subject: [PATCH 04/12] feat: support --tool for upgrade and lint --- fast_dev_cli/cli.py | 48 ++++++++++++++++++++++++++++++++++++--------- tests/test_bump.py | 7 +++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index 5151801..0549afa 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -41,6 +41,9 @@ class StrEnum(str, Enum): DryOption = Option(False, "--dry", help="Only print, not really run shell command") TOML_FILE = "pyproject.toml" ToolName = Literal["poetry", "pdm", "uv"] +ToolOption = Option( + "auto", "--tool", help="Explicit declare manage tool (default to auto detect)" +) class ShellCommandError(Exception): ... @@ -164,6 +167,12 @@ def _ensure_bool(value: bool | OptionInfo) -> bool: return value +def _ensure_str(value: str | OptionInfo) -> str: + if not isinstance(value, str): + value = getattr(value, "default", "") + return value + + def exit_if_run_failed( cmd: str, env=None, _exit=False, dry=False, **kw ) -> subprocess.CompletedProcess: @@ -616,15 +625,21 @@ def gen(self: Self) -> str: @cli.command() def upgrade( + tool: str = ToolOption, dry: bool = DryOption, ) -> None: """Upgrade dependencies in pyproject.toml to latest versions""" - if (tool := Project.get_manage_tool()) == "uv": + if not (tool := _ensure_str(tool)) or tool == "auto": + tool = Project.get_manage_tool() or "uv" + if tool == "uv": exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry) elif tool == "pdm": exit_if_run_failed("pdm update && pdm install", dry=dry) - else: + elif tool == "poetry": UpgradeDependencies(dry=dry).run() + else: + secho("Unknown tool {tool!r}", fg=typer.colors.YELLOW) + raise typer.Exit(1) class GitTag(DryRun): @@ -686,12 +701,14 @@ def __init__( bandit=False, skip_mypy=False, dmypy=False, + tool: str = "auto", ) -> None: self.args = args self.check_only = check_only self._bandit = bandit self._skip_mypy = skip_mypy self._use_dmypy = dmypy + self._tool = tool super().__init__(_exit, dry) @staticmethod @@ -723,6 +740,7 @@ def to_cmd( bandit: bool = False, skip_mypy: bool = False, use_dmypy: bool = False, + tool: str = "auto", ) -> str: if paths != "." and all(i.endswith(".html") for i in paths.split()): return f"prettier -w {paths}" @@ -751,8 +769,11 @@ def to_cmd( secho(f"{tip}\n\n {command}\n", fg="yellow") else: should_run_by_tool = True - if should_run_by_tool and (manage_tool := Project.get_manage_tool()): - prefix = manage_tool + " run " + if should_run_by_tool: + if tool == "auto": + tool = Project.get_manage_tool() or "" + if tool: + prefix = tool + " run " if cls.prefer_dmypy(paths, tools, use_dmypy=use_dmypy): tools[-1] = "dmypy run" cmd += lint_them.format(prefix, paths, *tools) @@ -779,15 +800,21 @@ def parse_files(args: list[str] | tuple[str, ...]) -> list[str]: return [i for i in args if not i.startswith("-")] -def lint(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None: +def lint( + files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto" +) -> None: if files is None: files = parse_files(sys.argv[1:]) if files and files[0] == "lint": files = files[1:] - LintCode(files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy).run() + LintCode( + files, dry=dry, skip_mypy=skip_mypy, bandit=bandit, dmypy=dmypy, tool=tool + ).run() -def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> None: +def check( + files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto" +) -> None: LintCode( files, check_only=True, @@ -796,6 +823,7 @@ def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> bandit=bandit, skip_mypy=skip_mypy, dmypy=dmypy, + tool=tool, ).run() @@ -808,6 +836,7 @@ def make_style( use_dmypy: bool = Option( False, "--dmypy", help="Use `dmypy run` instead of `mypy`" ), + tool: str = ToolOption, dry: bool = DryOption, ) -> None: """Run: ruff check/format to reformat code and then mypy to check""" @@ -818,10 +847,11 @@ def make_style( skip = _ensure_bool(skip_mypy) dmypy = _ensure_bool(use_dmypy) bandit = _ensure_bool(bandit) + tool = _ensure_str(tool) if _ensure_bool(check_only): - check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit) + check(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool) else: - lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit) + lint(files, dry=dry, skip_mypy=skip, dmypy=dmypy, bandit=bandit, tool=tool) @cli.command(name="check") diff --git a/tests/test_bump.py b/tests/test_bump.py index 6aebe99..8bf6fc4 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -223,3 +223,10 @@ def test_parse_filename(tmp_path): assert BumpUp.parse_filename() == "helloworld/__init__.py" toml_file.write_text(pyproject.strip() + '\npackages=[{include="hello"}]') assert BumpUp.parse_filename() == "hello/__init__.py" + toml_file.write_text( + pyproject.strip() + '\npackages=[{include="hello",from="py"}]' + ) + from_dir = project_dir / "py" + from_dir.mkdir() + shutil.move(another_dir, from_dir) + assert BumpUp.parse_filename() == "py/hello/__init__.py" From 313f32935f700e7ca1a75877c16b482ed1969efa Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 16:27:08 +0800 Subject: [PATCH 05/12] feat: upgrade with --verbose --- fast_dev_cli/__init__.py | 2 +- fast_dev_cli/cli.py | 33 ++++++++++++++++++++++----------- tests/test_lint.py | 25 +++++++++++++++++++++++++ tests/test_upgrade.py | 20 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/fast_dev_cli/__init__.py b/fast_dev_cli/__init__.py index 745162e..9da2f8f 100644 --- a/fast_dev_cli/__init__.py +++ b/fast_dev_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.14.2" +__version__ = "0.15.0" diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index 0549afa..0a467be 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -629,16 +629,16 @@ def upgrade( dry: bool = DryOption, ) -> None: """Upgrade dependencies in pyproject.toml to latest versions""" - if not (tool := _ensure_str(tool)) or tool == "auto": + if not (tool := _ensure_str(tool)) or tool == ToolOption.default: tool = Project.get_manage_tool() or "uv" if tool == "uv": - exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry) + exit_if_run_failed("uv lock --upgrade --verbose && uv sync --frozen", dry=dry) elif tool == "pdm": - exit_if_run_failed("pdm update && pdm install", dry=dry) + exit_if_run_failed("pdm update --verbose && pdm install", dry=dry) elif tool == "poetry": UpgradeDependencies(dry=dry).run() else: - secho("Unknown tool {tool!r}", fg=typer.colors.YELLOW) + secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW) raise typer.Exit(1) @@ -701,7 +701,7 @@ def __init__( bandit=False, skip_mypy=False, dmypy=False, - tool: str = "auto", + tool: str = ToolOption.default, ) -> None: self.args = args self.check_only = check_only @@ -726,7 +726,8 @@ def prefer_dmypy(paths: str, tools: list[str], use_dmypy=False) -> bool: @staticmethod def get_package_name() -> str: root = Project.get_work_dir(allow_cwd=True) - package_maybe = (root.name.replace("-", "_"), "src") + module_name = root.name.replace("-", "_").replace(" ", "_") + package_maybe = (module_name, "src") for name in package_maybe: if root.joinpath(name).is_dir(): return name @@ -740,7 +741,7 @@ def to_cmd( bandit: bool = False, skip_mypy: bool = False, use_dmypy: bool = False, - tool: str = "auto", + tool: str = ToolOption.default, ) -> str: if paths != "." and all(i.endswith(".html") for i in paths.split()): return f"prettier -w {paths}" @@ -769,8 +770,8 @@ def to_cmd( secho(f"{tip}\n\n {command}\n", fg="yellow") else: should_run_by_tool = True - if should_run_by_tool: - if tool == "auto": + if should_run_by_tool and tool: + if tool == ToolOption.default: tool = Project.get_manage_tool() or "" if tool: prefix = tool + " run " @@ -801,7 +802,12 @@ def parse_files(args: list[str] | tuple[str, ...]) -> list[str]: def lint( - files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto" + files=None, + dry=False, + bandit=False, + skip_mypy=False, + dmypy=False, + tool=ToolOption.default, ) -> None: if files is None: files = parse_files(sys.argv[1:]) @@ -813,7 +819,12 @@ def lint( def check( - files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False, tool="auto" + files=None, + dry=False, + bandit=False, + skip_mypy=False, + dmypy=False, + tool=ToolOption.default, ) -> None: LintCode( files, diff --git a/tests/test_lint.py b/tests/test_lint.py index 84774b5..99a2878 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -156,9 +156,13 @@ def test_with_dmypy(): def test_dmypy_run(monkeypatch): + command = capture_cmd_output("python -m fast_dev_cli lint --dry .") + assert "dmypy run ." not in command monkeypatch.setenv("FASTDEVCLI_DMYPY", "1") command = capture_cmd_output("python -m fast_dev_cli lint --dry .") assert "dmypy run ." in command + command = capture_cmd_output("python -m fast_dev_cli lint --skip-mypy --dry .") + assert "dmypy run ." not in command def test_lint_with_prefix(mocker): @@ -297,3 +301,24 @@ def test_get_manage_tool(tmp_path): assert Project.get_manage_tool() == "pdm" Path(TOML_FILE).write_text("[tool.uv]") assert Project.get_manage_tool() == "uv" + + +class TestGetPackageName: + project = "hello-world" + + def test_get_package_name(self, tmp_path): + project_dir = tmp_path / self.project + project_dir.mkdir() + module_name = project_dir.name.replace("-", "_").replace(" ", "_") + with chdir(project_dir): + Path(TOML_FILE).touch() + Path(module_name).mkdir() + assert LintCode.get_package_name() == module_name + Path("src").mkdir() + assert LintCode.get_package_name() == module_name + shutil.rmtree(module_name) + assert LintCode.get_package_name() == "src" + + +class TestGetPackageNameWithSpace(TestGetPackageName): + project = "hello world" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 2e6f9f2..74395fc 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -9,6 +9,7 @@ from fast_dev_cli.cli import ( TOML_FILE, UpgradeDependencies, + capture_cmd_output, run_and_echo, upgrade, ) @@ -283,3 +284,22 @@ def test_parse_complex_segment(): [], "--dev", ) + + +def test_upgrade_uv_project(): + cmd = "fast upgrade --tool=uv --dry" + expected = "uv lock --upgrade --verbose && uv sync --frozen" + assert expected in capture_cmd_output(cmd) + + +def test_upgrade_pdm_project(): + cmd = "fast upgrade --tool=pdm --dry" + expected = "pdm update --verbose && pdm install" + assert expected in capture_cmd_output(cmd) + + +def test_upgrade_unknown_tool(): + cmd = "fast upgrade --tool=hatch --dry" + expected = "Unknown tool 'hatch'" + assert expected in capture_cmd_output(cmd) + assert run_and_echo(cmd, verbose=False) == 1 From 305670a4f48fbfb5ab713981d3eacb75e6323ccd Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 16:46:49 +0800 Subject: [PATCH 06/12] tests: improve coverage --- tests/test_lint.py | 11 +++++++++-- tests/test_upgrade.py | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_lint.py b/tests/test_lint.py index 99a2878..8d96430 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -140,6 +140,12 @@ def test_lint_html(): assert "prettier -w index.html" in command command = capture_cmd_output(f"{lint_cmd} index.html flv.html --dry") assert "prettier -w index.html flv.html" in command + cmd = "fast lint index.html --dry" + assert "prettier -w index.html" in capture_cmd_output(cmd) + assert "prettier -w index.html" in capture_cmd_output("pdm run " + cmd) + cmd = "fast lint index.html flv.html --dry" + assert "prettier -w index.html flv.html" in capture_cmd_output(cmd) + assert "prettier -w index.html flv.html" in capture_cmd_output("pdm run " + cmd) def test_lint_by_global_fast(): @@ -151,8 +157,9 @@ def test_lint_by_global_fast(): def test_with_dmypy(): - command = capture_cmd_output("fast lint --dmypy --dry .") - assert "dmypy run ." in command + cmd = "fast lint --dmypy --dry ." + assert "dmypy run ." in capture_cmd_output(cmd) + assert "dmypy run ." in capture_cmd_output("pdm run " + cmd) def test_dmypy_run(monkeypatch): diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 74395fc..eba9e2f 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -290,16 +290,20 @@ def test_upgrade_uv_project(): cmd = "fast upgrade --tool=uv --dry" expected = "uv lock --upgrade --verbose && uv sync --frozen" assert expected in capture_cmd_output(cmd) + assert expected in capture_cmd_output("pdm run " + cmd) def test_upgrade_pdm_project(): cmd = "fast upgrade --tool=pdm --dry" expected = "pdm update --verbose && pdm install" assert expected in capture_cmd_output(cmd) + assert expected in capture_cmd_output("pdm run " + cmd) def test_upgrade_unknown_tool(): cmd = "fast upgrade --tool=hatch --dry" expected = "Unknown tool 'hatch'" assert expected in capture_cmd_output(cmd) + assert expected in capture_cmd_output("pdm run " + cmd) assert run_and_echo(cmd, verbose=False) == 1 + assert run_and_echo("pdm run " + cmd, verbose=False) == 1 From c59c8e0937dab91a848a458e80f016aea1964624 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 17:43:10 +0800 Subject: [PATCH 07/12] tests: improve coverage --- tests/test_lint.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_lint.py b/tests/test_lint.py index 8d96430..69706fb 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -82,11 +82,21 @@ def test_check_bandit(tmp_path): src_dir = package_path / "src" if not src_dir.exists(): # For poetry<2.1 src_dir = src_dir.parent / package_path.name + with chdir(package_path): + package_name = src_dir.name + assert f"bandit -r {package_name}" in LintCode.to_cmd(bandit=True) + toml_file = Path(TOML_FILE) + content = toml_file.read_text() + toml_file.write_text(content + '\n[tool.bandit]\nexclude_dirs = ["tests"]') + assert f"bandit -c {TOML_FILE} -r ." in LintCode.to_cmd(bandit=True) shutil.rmtree(src_dir) with chdir(package_path): assert LintCode.get_package_name() == "." command = capture_cmd_output("fast check --bandit --dry") - assert "bandit -r ." in command + assert f"bandit -c {TOML_FILE} -r ." in command + toml_file.write_text(content) + command = capture_cmd_output("fast check --bandit --dry") + assert "bandit -r ." in command def test_check_skip_mypy(mock_skip_mypy_0, mocker, capsys): @@ -160,6 +170,10 @@ def test_with_dmypy(): cmd = "fast lint --dmypy --dry ." assert "dmypy run ." in capture_cmd_output(cmd) assert "dmypy run ." in capture_cmd_output("pdm run " + cmd) + command = LintCode.to_cmd(use_dmypy=True, tool="pdm") + assert "dmypy run ." in command + command = LintCode.to_cmd(use_dmypy=False, tool="pdm") + assert "dmypy run ." not in command def test_dmypy_run(monkeypatch): From 6032b5be1a9006e598c4df3e940de4969eff0933 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 17:52:25 +0800 Subject: [PATCH 08/12] refactor: simplify upgrade --- fast_dev_cli/cli.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index 0a467be..bf8bbb4 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -8,7 +8,7 @@ import sys from functools import cached_property from pathlib import Path -from typing import Literal, Optional, get_args # Optional is required by typers +from typing import Literal, Optional, cast, get_args # Optional is required by typers import emoji import typer @@ -459,6 +459,10 @@ class ParseError(Exception): class UpgradeDependencies(Project, DryRun): + def __init__(self: Self, _exit=False, dry=False, tool: ToolName = "poetry") -> None: + super().__init__(_exit, dry) + self._tool = tool + class DevFlag(StrEnum): new = "[tool.poetry.group.dev.dependencies]" old = "[tool.poetry.dev-dependencies]" @@ -620,6 +624,10 @@ def to_cmd( return _upgrade def gen(self: Self) -> str: + if self._tool == "uv": + return "uv lock --upgrade --verbose && uv sync --frozen" + elif self._tool == "pdm": + return "pdm update --verbose && pdm install" return self.gen_cmd() + " && poetry lock && poetry update" @@ -631,12 +639,8 @@ def upgrade( """Upgrade dependencies in pyproject.toml to latest versions""" if not (tool := _ensure_str(tool)) or tool == ToolOption.default: tool = Project.get_manage_tool() or "uv" - if tool == "uv": - exit_if_run_failed("uv lock --upgrade --verbose && uv sync --frozen", dry=dry) - elif tool == "pdm": - exit_if_run_failed("pdm update --verbose && pdm install", dry=dry) - elif tool == "poetry": - UpgradeDependencies(dry=dry).run() + if tool in get_args(ToolName): + UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run() else: secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW) raise typer.Exit(1) From 116b7e435424d5b22ae9b2f571bbdf4e3f3900c6 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Thu, 17 Apr 2025 18:05:09 +0800 Subject: [PATCH 09/12] tests: improve coverage --- tests/test_lint.py | 2 ++ tests/test_upgrade.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_lint.py b/tests/test_lint.py index 69706fb..4b42fff 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -156,6 +156,8 @@ def test_lint_html(): cmd = "fast lint index.html flv.html --dry" assert "prettier -w index.html flv.html" in capture_cmd_output(cmd) assert "prettier -w index.html flv.html" in capture_cmd_output("pdm run " + cmd) + assert LintCode.to_cmd("index.html") == "prettier -w index.html" + assert LintCode.to_cmd("index.html flv.html") == "prettier -w index.html flv.html" def test_lint_by_global_fast(): diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index eba9e2f..a09b769 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -6,6 +6,10 @@ from io import StringIO from pathlib import Path +import pytest +import typer + +import fast_dev_cli from fast_dev_cli.cli import ( TOML_FILE, UpgradeDependencies, @@ -300,10 +304,16 @@ def test_upgrade_pdm_project(): assert expected in capture_cmd_output("pdm run " + cmd) -def test_upgrade_unknown_tool(): +def test_upgrade_unknown_tool(mocker): cmd = "fast upgrade --tool=hatch --dry" expected = "Unknown tool 'hatch'" assert expected in capture_cmd_output(cmd) assert expected in capture_cmd_output("pdm run " + cmd) assert run_and_echo(cmd, verbose=False) == 1 assert run_and_echo("pdm run " + cmd, verbose=False) == 1 + mocker.patch("fast_dev_cli.cli.secho") + with pytest.raises(typer.Exit): + upgrade(tool="pipenv") + fast_dev_cli.cli.secho.assert_called_once_with( # type:ignore + "Unknown tool 'pipenv'", fg=typer.colors.YELLOW + ) From 08f814216ec3d36155f1f5970ae7b21f61ee0eef Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 18 Apr 2025 10:09:14 +0800 Subject: [PATCH 10/12] tests: full coverage --- tests/test_upgrade.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index a09b769..b7d9024 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -295,6 +295,7 @@ def test_upgrade_uv_project(): expected = "uv lock --upgrade --verbose && uv sync --frozen" assert expected in capture_cmd_output(cmd) assert expected in capture_cmd_output("pdm run " + cmd) + assert UpgradeDependencies(tool="uv").gen() == expected def test_upgrade_pdm_project(): @@ -302,6 +303,7 @@ def test_upgrade_pdm_project(): expected = "pdm update --verbose && pdm install" assert expected in capture_cmd_output(cmd) assert expected in capture_cmd_output("pdm run " + cmd) + assert UpgradeDependencies(tool="pdm").gen() == expected def test_upgrade_unknown_tool(mocker): From b2a50288ef8f115a6fc9d51a75280c7cc965f8dc Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 18 Apr 2025 10:33:26 +0800 Subject: [PATCH 11/12] chore: strict type hints --- fast_dev_cli/cli.py | 123 +++++++++++++++++++++++++--------------- pyproject.toml | 1 + tests/test_functions.py | 6 +- tests/utils.py | 27 ++++----- 4 files changed, 93 insertions(+), 64 deletions(-) diff --git a/fast_dev_cli/cli.py b/fast_dev_cli/cli.py index bf8bbb4..28aecda 100644 --- a/fast_dev_cli/cli.py +++ b/fast_dev_cli/cli.py @@ -8,7 +8,14 @@ import sys from functools import cached_property from pathlib import Path -from typing import Literal, Optional, cast, get_args # Optional is required by typers +from typing import ( + Any, + Literal, + # Optional is required by typers + Optional, + cast, + get_args, +) import emoji import typer @@ -56,7 +63,7 @@ def poetry_module_name(name: str) -> str: return canonicalize_name(name).replace("-", "_").replace(" ", "_") -def load_bool(name: str, default=False) -> bool: +def load_bool(name: str, default: bool = False) -> bool: if not (v := os.getenv(name)): return default if (lower := v.lower()) in ("0", "false", "f", "off", "no", "n"): @@ -74,13 +81,15 @@ def is_venv() -> bool: ) -def _run_shell(cmd: list[str] | str, **kw) -> subprocess.CompletedProcess: +def _run_shell(cmd: list[str] | str, **kw: Any) -> subprocess.CompletedProcess[str]: if isinstance(cmd, str): kw.setdefault("shell", True) return subprocess.run(cmd, **kw) # nosec:B603 -def run_and_echo(cmd: str, *, dry=False, verbose=True, **kw) -> int: +def run_and_echo( + cmd: str, *, dry: bool = False, verbose: bool = True, **kw: Any +) -> int: """Run shell command with subprocess and print it""" if verbose: echo(f"--> {cmd}") @@ -94,7 +103,9 @@ def check_call(cmd: str) -> bool: return r.returncode == 0 -def capture_cmd_output(command: list[str] | str, *, raises=False, **kw) -> str: +def capture_cmd_output( + command: list[str] | str, *, raises: bool = False, **kw: Any +) -> str: if isinstance(command, str) and not kw.get("shell"): command = shlex.split(command) r = _run_shell(command, capture_output=True, encoding="utf-8", **kw) @@ -103,12 +114,12 @@ def capture_cmd_output(command: list[str] | str, *, raises=False, **kw) -> str: return r.stdout.strip() or r.stderr -def _parse_version(line: str, pattern: re.Pattern) -> str: +def _parse_version(line: str, pattern: re.Pattern[str]) -> str: return pattern.sub("", line).split("#")[0].strip(" '\"") def read_version_from_file( - package_name: str, work_dir=None, toml_text: str | None = None + package_name: str, work_dir: Path | None = None, toml_text: str | None = None ) -> str: if toml_text is None: toml_text = Project.load_toml_text() @@ -139,7 +150,9 @@ def read_version_from_file( def get_current_version( - verbose=False, is_poetry: bool | None = None, package_name: str | None = None + verbose: bool = False, + is_poetry: bool | None = None, + package_name: str | None = None, ) -> str: if is_poetry is None: is_poetry = Project.manage_by_poetry() @@ -174,8 +187,12 @@ def _ensure_str(value: str | OptionInfo) -> str: def exit_if_run_failed( - cmd: str, env=None, _exit=False, dry=False, **kw -) -> subprocess.CompletedProcess: + cmd: str, + env: dict[str, str] | None = None, + _exit: bool = False, + dry: bool = False, + **kw: Any, +) -> subprocess.CompletedProcess[str]: run_and_echo(cmd, dry=True) if _ensure_bool(dry): return subprocess.CompletedProcess("", 0) @@ -190,7 +207,7 @@ def exit_if_run_failed( class DryRun: - def __init__(self: Self, _exit=False, dry=False) -> None: + def __init__(self: Self, _exit: bool = False, dry: bool = False) -> None: self.dry = dry self._exit = _exit @@ -208,7 +225,11 @@ class PartChoices(StrEnum): major = "major" def __init__( - self: Self, commit: bool, part: str, filename: str | None = None, dry=False + self: Self, + commit: bool, + part: str, + filename: str | None = None, + dry: bool = False, ) -> None: self.commit = commit self.part = part @@ -218,7 +239,7 @@ def __init__( super().__init__(dry=dry) @staticmethod - def get_last_commit_message(raises=False) -> str: + def get_last_commit_message(raises: bool = False) -> str: cmd = 'git show --pretty=format:"%s" -s HEAD' return capture_cmd_output(cmd, raises=raises) @@ -391,7 +412,9 @@ def is_poetry_v2(text: str) -> bool: return 'build-backend = "poetry' in text @staticmethod - def work_dir(name: str, parent: Path, depth: int, be_file=False) -> Path | None: + def work_dir( + name: str, parent: Path, depth: int, be_file: bool = False + ) -> Path | None: for _ in range(depth): if (f := parent.joinpath(name)).exists(): if be_file: @@ -403,10 +426,10 @@ def work_dir(name: str, parent: Path, depth: int, be_file=False) -> Path | None: @classmethod def get_work_dir( cls: type[Self], - name=TOML_FILE, + name: str = TOML_FILE, cwd: Path | None = None, - allow_cwd=False, - be_file=False, + allow_cwd: bool = False, + be_file: bool = False, ) -> Path: cwd = cwd or Path.cwd() if d := cls.work_dir(name, cwd, cls.path_depth, be_file): @@ -416,7 +439,7 @@ def get_work_dir( raise EnvError(f"{name} not found! Make sure this is a poetry project.") @classmethod - def load_toml_text(cls: type[Self], name=TOML_FILE) -> str: + def load_toml_text(cls: type[Self], name: str = TOML_FILE) -> str: toml_file = cls.get_work_dir(name, be_file=True) return toml_file.read_text("utf8") @@ -433,7 +456,7 @@ def get_manage_tool(cls: type[Self]) -> ToolName | None: else: for name in get_args(ToolName): if f"[tool.{name}]" in text: - return name + return cast(ToolName, name) # Poetry 2.0 default to not include the '[tool.poetry]' section if cls.is_poetry_v2(text): return "poetry" @@ -459,7 +482,9 @@ class ParseError(Exception): class UpgradeDependencies(Project, DryRun): - def __init__(self: Self, _exit=False, dry=False, tool: ToolName = "poetry") -> None: + def __init__( + self: Self, _exit: bool = False, dry: bool = False, tool: ToolName = "poetry" + ) -> None: super().__init__(_exit, dry) self._tool = tool @@ -554,7 +579,7 @@ def should_with_dev(cls: type[Self]) -> bool: return cls.DevFlag.new in text or cls.DevFlag.old in text @staticmethod - def parse_item(toml_str) -> list[str]: + def parse_item(toml_str: str) -> list[str]: lines: list[str] = [] for line in toml_str.splitlines(): if (line := line.strip()).startswith("["): @@ -698,13 +723,13 @@ def tag( class LintCode(DryRun): def __init__( self: Self, - args, - check_only=False, - _exit=False, - dry=False, - bandit=False, - skip_mypy=False, - dmypy=False, + args: list[str] | str | None, + check_only: bool = False, + _exit: bool = False, + dry: bool = False, + bandit: bool = False, + skip_mypy: bool = False, + dmypy: bool = False, tool: str = ToolOption.default, ) -> None: self.args = args @@ -720,7 +745,7 @@ def check_lint_tool_installed() -> bool: return check_call("ruff --version") @staticmethod - def prefer_dmypy(paths: str, tools: list[str], use_dmypy=False) -> bool: + def prefer_dmypy(paths: str, tools: list[str], use_dmypy: bool = False) -> bool: return ( paths == "." and any(t.startswith("mypy") for t in tools) @@ -795,7 +820,9 @@ def to_cmd( return cmd def gen(self: Self) -> str: - paths = " ".join(map(str, self.args)) if self.args else "." + if isinstance(args := self.args, str): + args = args.split() + paths = " ".join(map(str, args)) if args else "." return self.to_cmd( paths, self.check_only, self._bandit, self._skip_mypy, self._use_dmypy ) @@ -806,12 +833,12 @@ def parse_files(args: list[str] | tuple[str, ...]) -> list[str]: def lint( - files=None, - dry=False, - bandit=False, - skip_mypy=False, - dmypy=False, - tool=ToolOption.default, + files: list[str] | str | None = None, + dry: bool = False, + bandit: bool = False, + skip_mypy: bool = False, + dmypy: bool = False, + tool: str = ToolOption.default, ) -> None: if files is None: files = parse_files(sys.argv[1:]) @@ -823,12 +850,12 @@ def lint( def check( - files=None, - dry=False, - bandit=False, - skip_mypy=False, - dmypy=False, - tool=ToolOption.default, + files: list[str] | str | None = None, + dry: bool = False, + bandit: bool = False, + skip_mypy: bool = False, + dmypy: bool = False, + tool: str = ToolOption.default, ) -> None: LintCode( files, @@ -880,7 +907,9 @@ def only_check( class Sync(DryRun): - def __init__(self: Self, filename: str, extras: str, save: bool, dry=False) -> None: + def __init__( + self: Self, filename: str, extras: str, save: bool, dry: bool = False + ) -> None: self.filename = filename self.extras = extras self._save = save @@ -919,7 +948,7 @@ def gen(self) -> str: @cli.command() def sync( - filename="dev_requirements.txt", + filename: str = "dev_requirements.txt", extras: str = Option("", "--extras", "-E"), save: bool = Option( False, "--save", "-s", help="Whether save the requirement file" @@ -937,7 +966,7 @@ def _should_run_test_script(path: Path = Path("scripts")) -> Path | None: return None -def test(dry: bool, ignore_script=False) -> None: +def test(dry: bool, ignore_script: bool = False) -> None: cwd = Path.cwd() root = Project.get_work_dir(cwd=cwd, allow_cwd=True) script_dir = root / "scripts" @@ -992,13 +1021,13 @@ def dev( port: int | None | OptionInfo, host: str | None | OptionInfo, file: str | None | ArgumentInfo = None, - dry=False, + dry: bool = False, ) -> None: cmd = "fastapi dev" no_port_yet = True if file is not None: try: - port = int(str(file)) # type:ignore[arg-type] + port = int(str(file)) except ValueError: cmd += f" {file}" else: diff --git a/pyproject.toml b/pyproject.toml index dc074c8..fce4607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ pretty = true python_version = "3.9" ignore_missing_imports = true check_untyped_defs = true +warn_unused_ignores = true exclude = [ "^fabfile\\.py$", # TOML's double-quoted strings require escaping backslashes 'two\.pyi$', # but TOML's single-quoted strings do not diff --git a/tests/test_functions.py b/tests/test_functions.py index d733eb2..9995a9e 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -72,8 +72,10 @@ def test_run_shell(): value = "foo" cmd = 'python -c "import os;print(list(os.environ))"' with redirect_stdout(StringIO()): - r = exit_if_run_failed(cmd, env={name: value}, capture_output=True) - assert name in r.stdout.decode() + r = exit_if_run_failed( + cmd, env={name: value}, capture_output=True, encoding="utf-8" + ) + assert name in r.stdout assert run_and_echo("echo foo", capture_output=True) == 0 diff --git a/tests/utils.py b/tests/utils.py index 42296d9..40267de 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,28 +1,25 @@ import os import subprocess import sys -from contextlib import contextmanager, redirect_stdout +from contextlib import AbstractContextManager, contextmanager, redirect_stdout from io import StringIO from pathlib import Path -if sys.version_info >= (3, 11): - from contextlib import chdir # type:ignore[attr-defined] -else: - from contextlib import AbstractContextManager - class chdir(AbstractContextManager): # type:ignore[no-redef] - """Non thread-safe context manager to change the current working directory.""" +# TODO: use `from contextlib import chdir` instead when drop support for Python3.10 +class chdir(AbstractContextManager): # Copied from source code of Python3.13 + """Non thread-safe context manager to change the current working directory.""" - def __init__(self, path): - self.path = path - self._old_cwd = [] + def __init__(self, path): + self.path = path + self._old_cwd = [] - def __enter__(self): - self._old_cwd.append(os.getcwd()) - os.chdir(self.path) + def __enter__(self): + self._old_cwd.append(os.getcwd()) + os.chdir(self.path) - def __exit__(self, *excinfo): - os.chdir(self._old_cwd.pop()) + def __exit__(self, *excinfo): + os.chdir(self._old_cwd.pop()) __all__ = ( From 9610312252df8397060f79dc7432cff74ac1cc3c Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 18 Apr 2025 10:46:18 +0800 Subject: [PATCH 12/12] docs: update changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 773ba1c..e039de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,25 @@ # ChangeLog +## 0.15 + +### [0.15.0](../../releases/tag/v0.15.0) - 2025-04-18 + +#### Added +- Support `fast lint --tool=uv` + +#### Changed +- Strict type hints for `fast_dev_cli/cli.py` + +#### Fixed +- Fix macOS filename auto completion error + ## 0.14 +### [0.14.2](../../releases/tag/v0.14.2) - 2025-04-15 + +#### Added +- Support `fast lint xxx.html` + ### [0.14.1](../../releases/tag/v0.14.1) - 2025-04-06 #### Added