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 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 39108c6..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, 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 @@ -41,6 +48,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): ... @@ -53,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"): @@ -71,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}") @@ -91,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) @@ -100,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() @@ -136,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() @@ -164,9 +180,19 @@ 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: + 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) @@ -181,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 @@ -199,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 @@ -209,9 +239,9 @@ def __init__( super().__init__(dry=dry) @staticmethod - def get_last_commit_message() -> str: + def get_last_commit_message(raises: bool = 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 +249,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: @@ -379,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: @@ -391,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): @@ -404,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") @@ -421,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" @@ -447,6 +482,12 @@ class ParseError(Exception): class UpgradeDependencies(Project, DryRun): + def __init__( + self: Self, _exit: bool = False, dry: bool = 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]" @@ -538,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("["): @@ -608,20 +649,26 @@ 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" @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": - exit_if_run_failed("uv lock --upgrade && uv sync", dry=dry) - elif tool == "pdm": - exit_if_run_failed("pdm update && pdm install", dry=dry) + if not (tool := _ensure_str(tool)) or tool == ToolOption.default: + tool = Project.get_manage_tool() or "uv" + if tool in get_args(ToolName): + UpgradeDependencies(dry=dry, tool=cast(ToolName, tool)).run() else: - UpgradeDependencies(dry=dry).run() + secho(f"Unknown tool {tool!r}", fg=typer.colors.YELLOW) + raise typer.Exit(1) class GitTag(DryRun): @@ -676,19 +723,21 @@ 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 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 @@ -696,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) @@ -706,7 +755,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 @@ -720,6 +770,7 @@ def to_cmd( bandit: bool = False, skip_mypy: bool = False, use_dmypy: bool = False, + tool: str = ToolOption.default, ) -> str: if paths != "." and all(i.endswith(".html") for i in paths.split()): return f"prettier -w {paths}" @@ -748,8 +799,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 and tool: + if tool == ToolOption.default: + 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) @@ -766,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 ) @@ -776,15 +832,31 @@ 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: 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:]) 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: 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, check_only=True, @@ -793,32 +865,35 @@ def check(files=None, dry=False, bandit=False, skip_mypy=False, dmypy=False) -> bandit=bandit, skip_mypy=skip_mypy, dmypy=dmypy, + tool=tool, ).run() @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"), 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""" if getattr(files, "default", files) is None: - files = [Path(".")] + files = ["."] elif isinstance(files, str): files = [files] 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") @@ -832,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 @@ -871,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" @@ -889,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" @@ -944,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_bump.py b/tests/test_bump.py index 043341e..8bf6fc4 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,32 @@ 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" + 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" 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/test_lint.py b/tests/test_lint.py index 4156793..4b42fff 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): @@ -140,6 +150,14 @@ 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) + 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(): @@ -151,20 +169,29 @@ def test_lint_by_global_fast(): def test_with_dmypy(): - command = capture_cmd_output("fast lint --dmypy --dry .") + 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): + 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): 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 +201,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) @@ -297,3 +324,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..b7d9024 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -6,9 +6,14 @@ 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, + capture_cmd_output, run_and_echo, upgrade, ) @@ -283,3 +288,34 @@ 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) + assert expected in capture_cmd_output("pdm run " + cmd) + assert UpgradeDependencies(tool="uv").gen() == expected + + +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) + assert UpgradeDependencies(tool="pdm").gen() == expected + + +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 + ) 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__ = (