diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6aa6fb6..8caa9cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,10 @@ repos: hooks: - id: validate-pyproject additional_dependencies: ["validate-pyproject-schema-store[all]"] + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "v2.5.0" + hooks: + - id: pyproject-fmt - repo: local hooks: - id: ruff-format diff --git a/pyproject.toml b/pyproject.toml index 2bf579b..2cd98d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ dev = [ "deptry>=0.20.0", "import-linter>=2.1", "pre-commit>=4.0.1", - "pyproject-fmt>=2.4.3", "pyright>=1.1.387", "ruff>=0.7.1", ] @@ -74,7 +73,10 @@ line-length = 88 src = [ "src" ] lint.select = [ "C4", "E4", "E7", "E9", "F", "FURB", "I", "PLE", "PLR", "PT", "RUF", "SIM", "UP" ] -lint.ignore = ["PT004", "PT005"] +lint.ignore = [ "PT004", "PT005" ] + +[tool.pyproject-fmt] +keep_full_version = true [tool.pytest.ini_options] testpaths = [ "tests" ] diff --git a/src/usethis/_config.py b/src/usethis/_config.py index adb7aa1..43d449b 100644 --- a/src/usethis/_config.py +++ b/src/usethis/_config.py @@ -4,12 +4,6 @@ import typer from pydantic import BaseModel -_OFFLINE_DEFAULT = False -_QUIET_DEFAULT = False - -offline_opt = typer.Option(_OFFLINE_DEFAULT, "--offline", help="Disable network access") -quiet_opt = typer.Option(_QUIET_DEFAULT, "--quiet", help="Suppress output") - class UsethisConfig(BaseModel): """Global-state for command options which affect low level behaviour.""" @@ -18,13 +12,29 @@ class UsethisConfig(BaseModel): quiet: bool @contextmanager - def set(self, *, offline: bool, quiet: bool) -> Generator[None, None, None]: - """Temporarily set the console to quiet mode.""" + def set( + self, *, offline: bool | None = None, quiet: bool | None = None + ) -> Generator[None, None, None]: + """Temporarily change command options.""" + old_offline = self.offline + old_quiet = self.quiet + + if offline is None: + offline = old_offline + if quiet is None: + quiet = old_quiet + self.offline = offline self.quiet = quiet yield - self.offline = _OFFLINE_DEFAULT - self.quiet = _QUIET_DEFAULT + self.offline = old_offline + self.quiet = old_quiet +_OFFLINE_DEFAULT = False +_QUIET_DEFAULT = False + usethis_config = UsethisConfig(offline=_OFFLINE_DEFAULT, quiet=_QUIET_DEFAULT) + +offline_opt = typer.Option(_OFFLINE_DEFAULT, "--offline", help="Disable network access") +quiet_opt = typer.Option(_QUIET_DEFAULT, "--quiet", help="Suppress output") diff --git a/src/usethis/_integrations/pre_commit/config.py b/src/usethis/_integrations/pre_commit/config.py index f48d079..533af19 100644 --- a/src/usethis/_integrations/pre_commit/config.py +++ b/src/usethis/_integrations/pre_commit/config.py @@ -5,7 +5,7 @@ class HookConfig(BaseModel): id: str - name: str + name: str | None = None entry: str | None = None language: Literal["system", "python"] | None = None always_run: bool | None = None diff --git a/src/usethis/_integrations/pre_commit/hooks.py b/src/usethis/_integrations/pre_commit/hooks.py index c3bcc65..d5e710d 100644 --- a/src/usethis/_integrations/pre_commit/hooks.py +++ b/src/usethis/_integrations/pre_commit/hooks.py @@ -8,6 +8,7 @@ _HOOK_ORDER = [ "validate-pyproject", + "pyproject-fmt", "ruff-format", "ruff-check", "deptry", diff --git a/src/usethis/_interface/tool.py b/src/usethis/_interface/tool.py index 1e0b142..6e03194 100644 --- a/src/usethis/_interface/tool.py +++ b/src/usethis/_interface/tool.py @@ -13,11 +13,47 @@ from usethis._integrations.pytest.core import add_pytest_dir, remove_pytest_dir from usethis._integrations.ruff.rules import deselect_ruff_rules, select_ruff_rules from usethis._integrations.uv.deps import add_deps_to_group, remove_deps_from_group -from usethis._tool import ALL_TOOLS, DeptryTool, PreCommitTool, PytestTool, RuffTool +from usethis._tool import ( + ALL_TOOLS, + DeptryTool, + PreCommitTool, + PyprojectFmtTool, + PytestTool, + RuffTool, +) app = typer.Typer(help="Add and configure development tools, e.g. linters.") +@app.command( + help="Use the deptry linter: avoid missing or superfluous dependency declarations." +) +def deptry( + remove: bool = typer.Option( + False, "--remove", help="Remove deptry instead of adding it." + ), + offline: bool = offline_opt, + quiet: bool = quiet_opt, +) -> None: + with usethis_config.set(offline=offline, quiet=quiet): + _deptry(remove=remove) + + +def _deptry(*, remove: bool = False) -> None: + tool = DeptryTool() + + if not remove: + add_deps_to_group(tool.dev_deps, "dev") + if PreCommitTool().is_used(): + tool.add_pre_commit_repo_config() + + box_print("Call the 'deptry src' command to run deptry.") + else: + if PreCommitTool().is_used(): + tool.remove_pre_commit_repo_config() + remove_deps_from_group(tool.dev_deps, "dev") + + @app.command( help="Use the pre-commit framework to manage and maintain pre-commit hooks." ) @@ -54,72 +90,51 @@ def _pre_commit(*, remove: bool = False) -> None: remove_pre_commit_config() remove_deps_from_group(tool.dev_deps, "dev") + # Need to add a new way of running some hooks manually if they are not dev + # dependencies yet + if PyprojectFmtTool().is_used(): + _pyproject_fmt() + @app.command( - help="Use the deptry linter: avoid missing or superfluous dependency declarations." + help="Use the pyproject-fmt linter: opinionated formatting of 'pyproject.toml' files." ) -def deptry( +def pyproject_fmt( remove: bool = typer.Option( - False, "--remove", help="Remove deptry instead of adding it." + False, "--remove", help="Remove pyproject-fmt instead of adding it." ), offline: bool = offline_opt, quiet: bool = quiet_opt, ) -> None: with usethis_config.set(offline=offline, quiet=quiet): - _deptry(remove=remove) + _pyproject_fmt(remove=remove) -def _deptry(*, remove: bool = False) -> None: - tool = DeptryTool() +def _pyproject_fmt(*, remove: bool = False) -> None: + tool = PyprojectFmtTool() if not remove: - add_deps_to_group(tool.dev_deps, "dev") - if PreCommitTool().is_used(): - tool.add_pre_commit_repo_config() - - box_print("Call the 'deptry src' command to run deptry.") - else: - if PreCommitTool().is_used(): - tool.remove_pre_commit_repo_config() - remove_deps_from_group(tool.dev_deps, "dev") - - -@app.command(help="Use ruff: an extremely fast Python linter and code formatter.") -def ruff( - remove: bool = typer.Option( - False, "--remove", help="Remove ruff instead of adding it." - ), - offline: bool = offline_opt, - quiet: bool = quiet_opt, -) -> None: - with usethis_config.set(offline=offline, quiet=quiet): - _ruff(remove=remove) - + is_precommit = PreCommitTool().is_used() -def _ruff(*, remove: bool = False) -> None: - tool = RuffTool() - - rules = [] - for _tool in ALL_TOOLS: - if _tool.is_used() or _tool.name == "ruff": - with contextlib.suppress(NotImplementedError): - rules += _tool.get_associated_ruff_rules() + if not is_precommit: + add_deps_to_group(tool.dev_deps, "dev") + else: + tool.add_pre_commit_repo_config() - if not remove: - add_deps_to_group(tool.dev_deps, "dev") tool.add_pyproject_configs() - select_ruff_rules(rules) - if PreCommitTool().is_used(): - tool.add_pre_commit_repo_config() - box_print( - "Call the 'ruff check --fix' command to run the ruff linter with autofixes." - ) - box_print("Call the 'ruff format' command to run the ruff formatter.") + if not is_precommit: + box_print( + "Call the 'pyproject-fmt pyproject.toml' command to run pyproject-fmt." + ) + else: + box_print( + "Call the 'pre-commit run pyproject-fmt --all-files' command to run pyproject-fmt." + ) else: + tool.remove_pyproject_configs() if PreCommitTool().is_used(): tool.remove_pre_commit_repo_config() - tool.remove_pyproject_configs() # N.B. this will remove the selected ruff rules remove_deps_from_group(tool.dev_deps, "dev") @@ -158,3 +173,42 @@ def _pytest(*, remove: bool = False) -> None: tool.remove_pyproject_configs() remove_deps_from_group(tool.dev_deps, "test") remove_pytest_dir() # Last, since this is a manual step + + +@app.command(help="Use ruff: an extremely fast Python linter and code formatter.") +def ruff( + remove: bool = typer.Option( + False, "--remove", help="Remove ruff instead of adding it." + ), + offline: bool = offline_opt, + quiet: bool = quiet_opt, +) -> None: + with usethis_config.set(offline=offline, quiet=quiet): + _ruff(remove=remove) + + +def _ruff(*, remove: bool = False) -> None: + tool = RuffTool() + + rules = [] + for _tool in ALL_TOOLS: + if _tool.is_used() or _tool.name == "ruff": + with contextlib.suppress(NotImplementedError): + rules += _tool.get_associated_ruff_rules() + + if not remove: + add_deps_to_group(tool.dev_deps, "dev") + tool.add_pyproject_configs() + select_ruff_rules(rules) + if PreCommitTool().is_used(): + tool.add_pre_commit_repo_config() + + box_print( + "Call the 'ruff check --fix' command to run the ruff linter with autofixes." + ) + box_print("Call the 'ruff format' command to run the ruff formatter.") + else: + if PreCommitTool().is_used(): + tool.remove_pre_commit_repo_config() + tool.remove_pyproject_configs() # N.B. this will remove the selected ruff rules + remove_deps_from_group(tool.dev_deps, "dev") diff --git a/src/usethis/_tool.py b/src/usethis/_tool.py index ec2935c..0210418 100644 --- a/src/usethis/_tool.py +++ b/src/usethis/_tool.py @@ -147,31 +147,6 @@ def remove_pyproject_configs(self) -> None: first_removal = False -class PreCommitTool(Tool): - @property - def name(self) -> str: - return "pre-commit" - - @property - def dev_deps(self) -> list[str]: - return ["pre-commit"] - - def get_pre_commit_repo_config(self) -> PreCommitRepoConfig: - raise NotImplementedError - - def get_pyproject_configs(self) -> list[PyProjectConfig]: - raise NotImplementedError - - def get_associated_ruff_rules(self) -> list[str]: - raise NotImplementedError - - def is_used(self) -> bool: - return ( - any(is_dep_in_any_group(dep) for dep in self.dev_deps) - or (Path.cwd() / ".pre-commit-config.yaml").exists() - ) - - class DeptryTool(Tool): @property def name(self) -> str: @@ -203,65 +178,57 @@ def get_associated_ruff_rules(self) -> list[str]: raise NotImplementedError -class RuffTool(Tool): +class PreCommitTool(Tool): @property def name(self) -> str: - return "ruff" + return "pre-commit" @property def dev_deps(self) -> list[str]: - return ["ruff"] + return ["pre-commit"] + + def get_pre_commit_repo_config(self) -> PreCommitRepoConfig: + raise NotImplementedError + + def get_pyproject_configs(self) -> list[PyProjectConfig]: + raise NotImplementedError + + def get_associated_ruff_rules(self) -> list[str]: + raise NotImplementedError + + def is_used(self) -> bool: + return ( + any(is_dep_in_any_group(dep) for dep in self.dev_deps) + or (Path.cwd() / ".pre-commit-config.yaml").exists() + ) + + +class PyprojectFmtTool(Tool): + @property + def name(self) -> str: + return "pyproject-fmt" + + @property + def dev_deps(self) -> list[str]: + return ["pyproject-fmt"] def get_pre_commit_repo_config(self) -> PreCommitRepoConfig: return PreCommitRepoConfig( - repo="local", - hooks=[ - HookConfig( - id="ruff-format", - name="ruff-format", - entry="uv run --frozen ruff format", - language="system", - always_run=True, - pass_filenames=False, - ), - HookConfig( - id="ruff-check", - name="ruff-check", - entry="uv run --frozen ruff check --fix", - language="system", - always_run=True, - pass_filenames=False, - ), - ], + repo="https://github.com/tox-dev/pyproject-fmt", + rev="v2.5.0", # Manually bump this version when necessary + hooks=[HookConfig(id="pyproject-fmt")], ) def get_pyproject_configs(self) -> list[PyProjectConfig]: return [ PyProjectConfig( - id_keys=["tool", "ruff"], - main_contents={ - "src": ["src"], - "line-length": 88, - "lint": {"select": []}, - }, + id_keys=["tool", "pyproject-fmt"], + main_contents={"keep_full_version": True}, ) ] def get_associated_ruff_rules(self) -> list[str]: - return [ - "C4", - "E4", - "E7", - "E9", - "F", - "FURB", - "I", - "PLE", - "PLR", - "RUF", - "SIM", - "UP", - ] + return [] def is_used(self) -> bool: pyproject = read_pyproject_toml() @@ -270,20 +237,15 @@ def is_used(self) -> bool: tool = pyproject["tool"] TypeAdapter(dict).validate_python(tool) assert isinstance(tool, dict) - tool["ruff"] + tool["pyproject-fmt"] except KeyError: is_pyproject_config = False else: is_pyproject_config = True - is_ruff_toml_config = (Path.cwd() / "ruff.toml").exists() or ( - Path.cwd() / ".ruff.toml" - ).exists() - return ( any(is_dep_in_any_group(dep) for dep in self.dev_deps) or is_pyproject_config - or is_ruff_toml_config ) @@ -346,4 +308,94 @@ def is_used(self) -> bool: ) -ALL_TOOLS: list[Tool] = [PreCommitTool(), DeptryTool(), RuffTool(), PytestTool()] +class RuffTool(Tool): + @property + def name(self) -> str: + return "ruff" + + @property + def dev_deps(self) -> list[str]: + return ["ruff"] + + def get_pre_commit_repo_config(self) -> PreCommitRepoConfig: + return PreCommitRepoConfig( + repo="local", + hooks=[ + HookConfig( + id="ruff-format", + name="ruff-format", + entry="uv run --frozen ruff format", + language="system", + always_run=True, + pass_filenames=False, + ), + HookConfig( + id="ruff-check", + name="ruff-check", + entry="uv run --frozen ruff check --fix", + language="system", + always_run=True, + pass_filenames=False, + ), + ], + ) + + def get_pyproject_configs(self) -> list[PyProjectConfig]: + return [ + PyProjectConfig( + id_keys=["tool", "ruff"], + main_contents={ + "src": ["src"], + "line-length": 88, + "lint": {"select": []}, + }, + ) + ] + + def get_associated_ruff_rules(self) -> list[str]: + return [ + "C4", + "E4", + "E7", + "E9", + "F", + "FURB", + "I", + "PLE", + "PLR", + "RUF", + "SIM", + "UP", + ] + + def is_used(self) -> bool: + pyproject = read_pyproject_toml() + + try: + tool = pyproject["tool"] + TypeAdapter(dict).validate_python(tool) + assert isinstance(tool, dict) + tool["ruff"] + except KeyError: + is_pyproject_config = False + else: + is_pyproject_config = True + + is_ruff_toml_config = (Path.cwd() / "ruff.toml").exists() or ( + Path.cwd() / ".ruff.toml" + ).exists() + + return ( + any(is_dep_in_any_group(dep) for dep in self.dev_deps) + or is_pyproject_config + or is_ruff_toml_config + ) + + +ALL_TOOLS: list[Tool] = [ + DeptryTool(), + PreCommitTool(), + PyprojectFmtTool(), + PytestTool(), + RuffTool(), +] diff --git a/tests/usethis/_interface/test_tool.py b/tests/usethis/_interface/test_tool.py index 72a3c88..3908844 100644 --- a/tests/usethis/_interface/test_tool.py +++ b/tests/usethis/_interface/test_tool.py @@ -3,6 +3,7 @@ import pytest +from usethis._config import usethis_config from usethis._integrations.pre_commit.core import _VALIDATEPYPROJECT_VERSION from usethis._integrations.pre_commit.hooks import ( _HOOK_ORDER, @@ -12,7 +13,7 @@ add_deps_to_group, get_deps_from_group, ) -from usethis._interface.tool import _deptry, _pre_commit, _pytest, _ruff +from usethis._interface.tool import _deptry, _pre_commit, _pyproject_fmt, _pytest, _ruff from usethis._tool import ALL_TOOLS from usethis._utils._test import change_cwd, is_offline @@ -30,169 +31,6 @@ def test_subset_hook_names(self): assert hook_name in _HOOK_ORDER -class TestPreCommit: - class TestAdd: - def test_dependency_added(self, uv_init_dir: Path): - # Act - with change_cwd(uv_init_dir): - _pre_commit() - - # Assert - (dev_dep,) = get_deps_from_group("dev") - assert dev_dep == "pre-commit" - - def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): - # Act - with change_cwd(uv_init_dir): - _pre_commit() - - # Assert - out, _ = capfd.readouterr() - assert out == ( - "✔ Adding 'pre-commit' to the 'dev' dependency group.\n" - "✔ Writing '.pre-commit-config.yaml'.\n" - "✔ Ensuring pre-commit hooks are installed.\n" - "☐ Call the 'pre-commit run --all-files' command to run the hooks manually.\n" - ) - - def test_config_file_exists(self, uv_init_dir: Path): - # Act - with change_cwd(uv_init_dir): - _pre_commit() - - # Assert - assert (uv_init_dir / ".pre-commit-config.yaml").exists() - - def test_config_file_contents(self, uv_init_dir: Path): - # Act - with change_cwd(uv_init_dir): - _pre_commit() - - # Assert - contents = (uv_init_dir / ".pre-commit-config.yaml").read_text() - assert contents == ( - f"""\ -repos: - - repo: https://github.com/abravalheri/validate-pyproject - rev: "{_VALIDATEPYPROJECT_VERSION}" - hooks: - - id: validate-pyproject - additional_dependencies: ["validate-pyproject-schema-store[all]"] -""" - ) - - def test_already_exists(self, uv_init_repo_dir: Path): - # Arrange - (uv_init_repo_dir / ".pre-commit-config.yaml").write_text( - """\ -repos: -- repo: foo - hooks: - - id: bar -""" - ) - - # Act - with change_cwd(uv_init_repo_dir): - _pre_commit() - - # Assert - contents = (uv_init_repo_dir / ".pre-commit-config.yaml").read_text() - assert contents == ( - """\ -repos: -- repo: foo - hooks: - - id: bar -""" - ) - - def test_bad_commit(self, uv_init_repo_dir: Path): - # Act - with change_cwd(uv_init_repo_dir): - _pre_commit() - subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) - subprocess.run( - ["git", "commit", "-m", "Good commit"], cwd=uv_init_repo_dir, check=True - ) - - # Assert - (uv_init_repo_dir / "pyproject.toml").write_text("[") - subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) - with pytest.raises(subprocess.CalledProcessError): - subprocess.run( - ["git", "commit", "-m", "Bad commit"], - cwd=uv_init_repo_dir, - check=True, - ) - - def test_cli_pass(self, uv_init_repo_dir: Path): - if not is_offline(): - subprocess.run( - ["usethis", "tool", "pre-commit"], cwd=uv_init_repo_dir, check=True - ) - else: - subprocess.run( - ["usethis", "tool", "pre-commit", "--offline"], - cwd=uv_init_repo_dir, - check=True, - ) - - subprocess.run( - ["uv", "run", "pre-commit", "run", "--all-files"], - cwd=uv_init_repo_dir, - check=True, - ) - - def test_cli_fail(self, uv_init_repo_dir: Path): - if not is_offline(): - subprocess.run( - ["usethis", "tool", "pre-commit"], cwd=uv_init_repo_dir, check=True - ) - else: - subprocess.run( - ["usethis", "tool", "pre-commit", "--offline"], - cwd=uv_init_repo_dir, - check=True, - ) - - # Pass invalid TOML to fail the pre-commit for validate-pyproject - (uv_init_repo_dir / "pyproject.toml").write_text("[") - try: - subprocess.run( - ["uv", "run", "pre-commit", "run", "--all-files"], - cwd=uv_init_repo_dir, - check=True, - ) - except subprocess.CalledProcessError: - pass - else: - pytest.fail("Expected subprocess.CalledProcessError") - - class TestRemove: - def test_config_file(self, uv_init_dir: Path): - # Arrange - (uv_init_dir / ".pre-commit-config.yaml").touch() - - # Act - with change_cwd(uv_init_dir): - _pre_commit(remove=True) - - # Assert - assert not (uv_init_dir / ".pre-commit-config.yaml").exists() - - def test_dep(self, uv_init_dir: Path): - with change_cwd(uv_init_dir): - # Arrange - add_deps_to_group(["pre-commit"], "dev") - - # Act - _pre_commit(remove=True) - - # Assert - assert not get_deps_from_group("dev") - - class TestDeptry: def test_dependency_added(self, uv_init_dir: Path): # Act @@ -341,104 +179,211 @@ def test_pre_commit_first( ) -class TestRuff: +class TestPreCommit: class TestAdd: def test_dependency_added(self, uv_init_dir: Path): # Act with change_cwd(uv_init_dir): - _ruff() + _pre_commit() # Assert (dev_dep,) = get_deps_from_group("dev") - assert dev_dep == "ruff" + assert dev_dep == "pre-commit" def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): # Act with change_cwd(uv_init_dir): - _ruff() + _pre_commit() # Assert out, _ = capfd.readouterr() assert out == ( - "✔ Adding 'ruff' to the 'dev' dependency group.\n" - "✔ Adding ruff config to 'pyproject.toml'.\n" - "✔ Enabling ruff rules 'C4', 'E4', 'E7', 'E9', 'F', 'FURB', 'I', 'PLE', 'PLR', \n'RUF', 'SIM', 'UP' in 'pyproject.toml'.\n" - "☐ Call the 'ruff check --fix' command to run the ruff linter with autofixes.\n" - "☐ Call the 'ruff format' command to run the ruff formatter.\n" + "✔ Adding 'pre-commit' to the 'dev' dependency group.\n" + "✔ Writing '.pre-commit-config.yaml'.\n" + "✔ Ensuring pre-commit hooks are installed.\n" + "☐ Call the 'pre-commit run --all-files' command to run the hooks manually.\n" ) - def test_cli(self, uv_init_dir: Path): - if not is_offline(): - subprocess.run(["usethis", "tool", "ruff"], cwd=uv_init_dir, check=True) - else: - subprocess.run( - ["usethis", "tool", "ruff", "--offline"], - cwd=uv_init_dir, - check=True, - ) - - def test_pre_commit_first( - self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str] - ): + def test_config_file_exists(self, uv_init_dir: Path): # Act with change_cwd(uv_init_dir): - _ruff() _pre_commit() - # Assert - hook_names = get_hook_names() + # Assert + assert (uv_init_dir / ".pre-commit-config.yaml").exists() - assert "ruff-format" in hook_names - assert "ruff-check" in hook_names + def test_config_file_contents(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _pre_commit() - class TestRemove: - def test_config_file(self, uv_init_dir: Path): + # Assert + contents = (uv_init_dir / ".pre-commit-config.yaml").read_text() + assert contents == ( + f"""\ +repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: "{_VALIDATEPYPROJECT_VERSION}" + hooks: + - id: validate-pyproject + additional_dependencies: ["validate-pyproject-schema-store[all]"] +""" + ) + + def test_already_exists(self, uv_init_repo_dir: Path): # Arrange - (uv_init_dir / "pyproject.toml").write_text( + (uv_init_repo_dir / ".pre-commit-config.yaml").write_text( """\ -[tool.ruff.lint] -select = ["A", "B", "C"] +repos: +- repo: foo + hooks: + - id: bar """ ) # Act - with change_cwd(uv_init_dir): - _ruff(remove=True) + with change_cwd(uv_init_repo_dir): + _pre_commit() # Assert - assert (uv_init_dir / "pyproject.toml").read_text() == "" - - def test_blank_slate(self, uv_init_dir: Path): - # Arrange - contents = (uv_init_dir / "pyproject.toml").read_text() + contents = (uv_init_repo_dir / ".pre-commit-config.yaml").read_text() + assert contents == ( + """\ +repos: +- repo: foo + hooks: + - id: bar +""" + ) + def test_bad_commit(self, uv_init_repo_dir: Path): # Act - with change_cwd(uv_init_dir): - _ruff(remove=True) + with change_cwd(uv_init_repo_dir): + _pre_commit() + subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) + subprocess.run( + ["git", "commit", "-m", "Good commit"], cwd=uv_init_repo_dir, check=True + ) # Assert - assert (uv_init_dir / "pyproject.toml").read_text() == contents + (uv_init_repo_dir / "pyproject.toml").write_text("[") + subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) + with pytest.raises(subprocess.CalledProcessError): + subprocess.run( + ["git", "commit", "-m", "Bad commit"], + cwd=uv_init_repo_dir, + check=True, + ) - def test_roundtrip(self, uv_init_dir: Path): + def test_cli_pass(self, uv_init_repo_dir: Path): + if not is_offline(): + subprocess.run( + ["usethis", "tool", "pre-commit"], cwd=uv_init_repo_dir, check=True + ) + else: + subprocess.run( + ["usethis", "tool", "pre-commit", "--offline"], + cwd=uv_init_repo_dir, + check=True, + ) + + subprocess.run( + ["uv", "run", "pre-commit", "run", "--all-files"], + cwd=uv_init_repo_dir, + check=True, + ) + + def test_cli_fail(self, uv_init_repo_dir: Path): + if not is_offline(): + subprocess.run( + ["usethis", "tool", "pre-commit"], cwd=uv_init_repo_dir, check=True + ) + else: + subprocess.run( + ["usethis", "tool", "pre-commit", "--offline"], + cwd=uv_init_repo_dir, + check=True, + ) + + # Pass invalid TOML to fail the pre-commit for validate-pyproject + (uv_init_repo_dir / "pyproject.toml").write_text("[") + try: + subprocess.run( + ["uv", "run", "pre-commit", "run", "--all-files"], + cwd=uv_init_repo_dir, + check=True, + ) + except subprocess.CalledProcessError: + pass + else: + pytest.fail("Expected subprocess.CalledProcessError") + + class TestRemove: + def test_config_file(self, uv_init_dir: Path): # Arrange - contents = (uv_init_dir / "pyproject.toml").read_text() + (uv_init_dir / ".pre-commit-config.yaml").touch() # Act with change_cwd(uv_init_dir): - _ruff() - _ruff(remove=True) + _pre_commit(remove=True) # Assert - assert ( - (uv_init_dir / "pyproject.toml").read_text() - == contents - + """\ + assert not (uv_init_dir / ".pre-commit-config.yaml").exists() -[dependency-groups] -dev = [] + def test_dep(self, uv_init_dir: Path): + with change_cwd(uv_init_dir): + # Arrange + add_deps_to_group(["pre-commit"], "dev") + + # Act + _pre_commit(remove=True) + + # Assert + assert not get_deps_from_group("dev") + + +class TestPyprojectFormat: + class TestAdd: + class TestPyproject: + def test_added(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): + # Arrange + with change_cwd(uv_init_dir), usethis_config.set(quiet=True): + add_deps_to_group(["pyproject-fmt"], "dev") + content = (uv_init_dir / "pyproject.toml").read_text() + + # Act + with change_cwd(uv_init_dir): + _pyproject_fmt() + # Assert + assert ( + uv_init_dir / "pyproject.toml" + ).read_text() == content + "\n" + ( + """\ +[tool.pyproject-fmt] +keep_full_version = true """ - ) + ) + out, _ = capfd.readouterr() + assert out == ( + "✔ Adding pyproject-fmt config to 'pyproject.toml'.\n" + "☐ Call the 'pyproject-fmt pyproject.toml' command to run pyproject-fmt.\n" + ) + + class TestDeps: + def test_added(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): + with change_cwd(uv_init_dir): + # Act + _pyproject_fmt() + + # Assert + assert get_deps_from_group("dev") == ["pyproject-fmt"] + out, _ = capfd.readouterr() + assert out == ( + "✔ Adding 'pyproject-fmt' to the 'dev' dependency group.\n" + "✔ Adding pyproject-fmt config to 'pyproject.toml'.\n" + "☐ Call the 'pyproject-fmt pyproject.toml' command to run pyproject-fmt.\n" + ) class TestPytest: @@ -545,3 +490,103 @@ def test_removed(self, uv_init_dir: Path): # Assert assert not get_deps_from_group("test") + + +class TestRuff: + class TestAdd: + def test_dependency_added(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _ruff() + + # Assert + (dev_dep,) = get_deps_from_group("dev") + assert dev_dep == "ruff" + + def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): + # Act + with change_cwd(uv_init_dir): + _ruff() + + # Assert + out, _ = capfd.readouterr() + assert out == ( + "✔ Adding 'ruff' to the 'dev' dependency group.\n" + "✔ Adding ruff config to 'pyproject.toml'.\n" + "✔ Enabling ruff rules 'C4', 'E4', 'E7', 'E9', 'F', 'FURB', 'I', 'PLE', 'PLR', \n'RUF', 'SIM', 'UP' in 'pyproject.toml'.\n" + "☐ Call the 'ruff check --fix' command to run the ruff linter with autofixes.\n" + "☐ Call the 'ruff format' command to run the ruff formatter.\n" + ) + + def test_cli(self, uv_init_dir: Path): + if not is_offline(): + subprocess.run(["usethis", "tool", "ruff"], cwd=uv_init_dir, check=True) + else: + subprocess.run( + ["usethis", "tool", "ruff", "--offline"], + cwd=uv_init_dir, + check=True, + ) + + def test_pre_commit_first( + self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str] + ): + # Act + with change_cwd(uv_init_dir): + _ruff() + _pre_commit() + + # Assert + hook_names = get_hook_names() + + assert "ruff-format" in hook_names + assert "ruff-check" in hook_names + + class TestRemove: + def test_config_file(self, uv_init_dir: Path): + # Arrange + (uv_init_dir / "pyproject.toml").write_text( + """\ +[tool.ruff.lint] +select = ["A", "B", "C"] +""" + ) + + # Act + with change_cwd(uv_init_dir): + _ruff(remove=True) + + # Assert + assert (uv_init_dir / "pyproject.toml").read_text() == "" + + def test_blank_slate(self, uv_init_dir: Path): + # Arrange + contents = (uv_init_dir / "pyproject.toml").read_text() + + # Act + with change_cwd(uv_init_dir): + _ruff(remove=True) + + # Assert + assert (uv_init_dir / "pyproject.toml").read_text() == contents + + def test_roundtrip(self, uv_init_dir: Path): + # Arrange + contents = (uv_init_dir / "pyproject.toml").read_text() + + # Act + with change_cwd(uv_init_dir): + _ruff() + _ruff(remove=True) + + # Assert + assert ( + (uv_init_dir / "pyproject.toml").read_text() + == contents + + """\ + +[dependency-groups] +dev = [] + +""" + ) diff --git a/uv.lock b/uv.lock index 124032e..38a9632 100644 --- a/uv.lock +++ b/uv.lock @@ -410,26 +410,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] -[[package]] -name = "pyproject-fmt" -version = "2.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/7d/28ee3ac4eaa610f8300b16900867f53100ab15dda84ea586524280ec4ca9/pyproject_fmt-2.4.3.tar.gz", hash = "sha256:68c9dc7e360cd169ba323238bab8d0659cbf0ce4380bf787973c3a042829de70", size = 44238 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/07/d3da7a86a8e170fb8394ce703aa7ce045da4f19c893447b38d891100944c/pyproject_fmt-2.4.3-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f84a3bf5e296c899591da3179befe08f38ce50dbbeb9bfdd90322c65ca0c2477", size = 1539013 }, - { url = "https://files.pythonhosted.org/packages/96/d4/1e4b7b9f1ad1290eaa671d890d56a1009ef7f1b1a678166cf1f23100053b/pyproject_fmt-2.4.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:7fb2350e7243daa5a4c25c5d9d0230aa7365f880e80e0d31363b3782d613df7b", size = 1476472 }, - { url = "https://files.pythonhosted.org/packages/17/c8/b99892a2c9ec2de8760f2d511e73fc378129553bbf456a20fe2ff156bd6c/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86ceb5beb3c2a45940dde50abe93f34db75fa1782870c00cba91cf621bedd88b", size = 1654919 }, - { url = "https://files.pythonhosted.org/packages/da/27/3068a1f83af5f75b9d931db7989e447d9aa2f95197a93244254abdfa7d66/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd23a5dd33ccd2af626580d3089b71347e78ddef0531ca2c1eb91e5c1b2afb8", size = 1608263 }, - { url = "https://files.pythonhosted.org/packages/26/cf/c91339ee37bb61443609293213d73b9701397d8da0aa5eec1908d87982cb/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9008ce82ba59bd8125285120da89cfe0c02d3ea809b179176510647a4639de1", size = 1746595 }, - { url = "https://files.pythonhosted.org/packages/e5/3e/8af30215bbc8c714bf4f04299a1ed7c8fbeb22178b365ad750ccf7d77ca1/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:adf22afbf4ca3ebe28ebc75186787f9138c8009db107d3df61921c2bd9589519", size = 1841317 }, - { url = "https://files.pythonhosted.org/packages/40/56/9b5fc20fc9e98ea1f44a1399de796da15c7b6b0e9ec81a49d51788a1f8c8/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e52fb2bbfbc1f4c2e37e5abba913c3b99655a31d0df38fea8fb1707287a2d5a", size = 1695313 }, - { url = "https://files.pythonhosted.org/packages/bc/62/1f297dda59fda72475653aa3d78ac90b757cef1113bfe93a25cff7091944/pyproject_fmt-2.4.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:35979fa3371ed5866f6e84f4e3cb9d53e8616970633cbc40c19d82e7c19f4e9f", size = 1693040 }, - { url = "https://files.pythonhosted.org/packages/c1/31/37452414afb2d0a2419fa8428afa558b9077cdf6dfbd8b89aa95537cbd91/pyproject_fmt-2.4.3-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:163f37763dd8e87e38c8cba63fa156038f052a0448dcf651772131a150b28749", size = 1819670 }, - { url = "https://files.pythonhosted.org/packages/bf/07/a8c092db25c5820ec29e009cf641ad64fa085db6f287cd3688f306b8abce/pyproject_fmt-2.4.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:558351ce471dccc4a1b0a275515580a2d3012ecdf57be7a87f5f6251d24bb8f6", size = 1866608 }, - { url = "https://files.pythonhosted.org/packages/c3/23/04dd4c612195382a1bb57704ce959dabc0e276db5f21c36dcbf30856e4ff/pyproject_fmt-2.4.3-cp38-abi3-win32.whl", hash = "sha256:aa1de547dcb08921bcbba6d62755ae33f4723c8c6b2fe2ba63148bcbfbe87191", size = 1261484 }, - { url = "https://files.pythonhosted.org/packages/d2/2a/f8df838111c2ea65f2146ff411177ea9d8bcec7a8eb8ecd093e41fab0c4a/pyproject_fmt-2.4.3-cp38-abi3-win_amd64.whl", hash = "sha256:31c23b2abe33ab5899f8ef9ed64349589824a0a9a2b2b8ada3e97880c7de7db0", size = 1374630 }, -] - [[package]] name = "pyright" version = "1.1.387" @@ -664,7 +644,7 @@ wheels = [ [[package]] name = "usethis" -version = "0.2.1.dev0+g54c3798.d20241030" +version = "0.2.1.dev1+gc1d09e6.d20241031" source = { editable = "." } dependencies = [ { name = "mergedeep" }, @@ -682,7 +662,6 @@ dev = [ { name = "deptry" }, { name = "import-linter" }, { name = "pre-commit" }, - { name = "pyproject-fmt" }, { name = "pyright" }, { name = "ruff" }, ] @@ -712,7 +691,6 @@ dev = [ { name = "deptry", specifier = ">=0.20.0" }, { name = "import-linter", specifier = ">=2.1" }, { name = "pre-commit", specifier = ">=4.0.1" }, - { name = "pyproject-fmt", specifier = ">=2.4.3" }, { name = "pyright", specifier = ">=1.1.387" }, { name = "ruff", specifier = ">=0.7.1" }, ]