diff --git a/src/usethis/__init__.py b/src/usethis/__init__.py index a9463af..f3392e6 100644 --- a/src/usethis/__init__.py +++ b/src/usethis/__init__.py @@ -1,3 +1,6 @@ +import typer from rich.console import Console console = Console() + +offline_opt = typer.Option(False, "--offline", help="Disable network access") diff --git a/src/usethis/_errors.py b/src/usethis/_errors.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/usethis/_github/tags.py b/src/usethis/_github/tags.py index d56a0fe..e69f86f 100644 --- a/src/usethis/_github/tags.py +++ b/src/usethis/_github/tags.py @@ -31,7 +31,7 @@ def get_github_latest_tag(owner: str, repo: str) -> str: try: response = requests.get(api_url, timeout=1) response.raise_for_status() # Raise an error for HTTP issues - except requests.exceptions.HTTPError as err: + except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError) as err: raise GitHubTagError(f"Failed to fetch tags from GitHub API: {err}") tags = response.json() diff --git a/src/usethis/_pyproject/core.py b/src/usethis/_pyproject/core.py index e9c787f..81d12c0 100644 --- a/src/usethis/_pyproject/core.py +++ b/src/usethis/_pyproject/core.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, assert_never +from typing import Any import mergedeep @@ -97,8 +97,6 @@ def remove_config_value(id_keys: list[str], *, missing_ok: bool = False) -> None def append_config_list( id_keys: list[str], values: list[Any], - *, - order: Literal["sorted", "preserved"] = "sorted", ) -> list[str]: """Append values to a list in the pyproject.toml configuration file.""" pyproject = read_pyproject_toml() @@ -115,15 +113,7 @@ def append_config_list( contents = {key: contents} pyproject = mergedeep.merge(pyproject, contents) else: - # Append to the existing configuration. - if order == "sorted": - new_values = sorted(p + values) - elif order == "preserved": - new_values = p + values - else: - assert_never(order) - - p_parent[id_keys[-1]] = new_values + p_parent[id_keys[-1]] = p + values write_pyproject_toml(pyproject) diff --git a/src/usethis/_pytest/core.py b/src/usethis/_pytest/core.py index 66f37a5..13b27a8 100644 --- a/src/usethis/_pytest/core.py +++ b/src/usethis/_pytest/core.py @@ -1,9 +1,10 @@ +import shutil from pathlib import Path from usethis import console -def add_pytest_dir(): +def add_pytest_dir() -> None: tests_dir = Path.cwd() / "tests" if not tests_dir.exists(): @@ -20,14 +21,19 @@ def add_pytest_dir(): ) -def remove_pytest_dir(): +def remove_pytest_dir() -> None: tests_dir = Path.cwd() / "tests" if not tests_dir.exists(): # Early exit; tests directory does not exist return - console.print( - "☐ Reconfigure the /tests directory to run without pytest", style="blue" - ) - # Note we don't actually remove the directory, just explain what needs to be done. + if set(tests_dir.iterdir()) <= {tests_dir / "conftest.py"}: + # The only file in the directory is conftest.py + console.print("✔ Removing '/tests'.", style="green") + shutil.rmtree(tests_dir) + else: + console.print( + "☐ Reconfigure the /tests directory to run without pytest", style="blue" + ) + # Note we don't actually remove the directory, just explain what needs to be done. diff --git a/src/usethis/_ruff/rules.py b/src/usethis/_ruff/rules.py index 887e7ca..872e999 100644 --- a/src/usethis/_ruff/rules.py +++ b/src/usethis/_ruff/rules.py @@ -8,7 +8,6 @@ def select_ruff_rules(rules: list[str]) -> None: """Add ruff rules to the project.""" - rules = sorted(set(rules) - set(get_ruff_rules())) if not rules: diff --git a/src/usethis/_test.py b/src/usethis/_test.py index 33cf450..9e1d77d 100644 --- a/src/usethis/_test.py +++ b/src/usethis/_test.py @@ -1,4 +1,5 @@ import os +import socket from collections.abc import Generator from contextlib import contextmanager from pathlib import Path @@ -13,3 +14,13 @@ def change_cwd(new_dir: Path) -> Generator[None, None, None]: yield finally: os.chdir(old_dir) + + +def is_offline() -> bool: + try: + # Connect to Google's DNS server + socket.create_connection(("8.8.8.8", 53), timeout=3) + except OSError: + return True + else: + return False diff --git a/src/usethis/_tool.py b/src/usethis/_tool.py index 83db387..6705dba 100644 --- a/src/usethis/_tool.py +++ b/src/usethis/_tool.py @@ -1,5 +1,3 @@ -import re -import subprocess from abc import abstractmethod from pathlib import Path from typing import Protocol @@ -20,7 +18,7 @@ set_config_value, ) from usethis._pyproject.io import read_pyproject_toml -from usethis._uv.deps import get_dev_deps +from usethis._uv.deps import is_dep_used class Tool(Protocol): @@ -63,7 +61,7 @@ def get_associated_ruff_rules(self) -> list[str]: def is_used(self) -> bool: """Whether the tool is being used in the current project.""" - return any(_strip_extras(dep) in get_dev_deps() for dep in self.dev_deps) + return any(is_dep_used(dep) for dep in self.dev_deps) def add_pre_commit_repo_config(self) -> None: """Add the tool's pre-commit configuration.""" @@ -154,42 +152,6 @@ def remove_pyproject_configs(self) -> None: ) first_removal = False - def add_dev_deps(self) -> None: - """Add the tool's development dependencies, if not already added.""" - existing_dev_deps = get_dev_deps() - - for dep in self.dev_deps: - if _strip_extras(dep) in existing_dev_deps: - # Early exit; the tool is already a dev dependency. - continue - - console.print( - f"✔ Adding '{dep}' as a development dependency.", style="green" - ) - subprocess.run(["uv", "add", "--dev", "--quiet", dep], check=True) - - def remove_dev_deps(self) -> None: - """Remove the tool's development dependencies, if present.""" - existing_dev_deps = get_dev_deps() - - for dep in self.dev_deps: - if _strip_extras(dep) not in existing_dev_deps: - # Early exit; the tool is already not a dev dependency. - continue - - console.print( - f"✔ Removing '{dep}' as a development dependency.", - style="green", - ) - subprocess.run( - ["uv", "remove", "--dev", "--quiet", _strip_extras(dep)], check=True - ) - - -def _strip_extras(dep: str) -> str: - """Remove extras from a dependency string.""" - return re.sub(r"\[.*\]", "", dep) - class PreCommitTool(Tool): @property diff --git a/src/usethis/_uv/deps.py b/src/usethis/_uv/deps.py index 565991a..b63c6fa 100644 --- a/src/usethis/_uv/deps.py +++ b/src/usethis/_uv/deps.py @@ -1,6 +1,10 @@ +import re +import subprocess + from packaging.requirements import Requirement from pydantic import TypeAdapter +from usethis import console from usethis._pyproject.io import read_pyproject_toml @@ -14,3 +18,58 @@ def get_dev_deps() -> list[str]: req_strs = TypeAdapter(list[str]).validate_python(dev_deps_section) reqs = [Requirement(req_str) for req_str in req_strs] return [req.name for req in reqs] + + +def add_dev_deps(pypi_names: list[str], *, offline: bool) -> None: + """Add a package as a development dependency, if not already added.""" + existing_dev_deps = get_dev_deps() + + for dep in pypi_names: + if _strip_extras(dep) in existing_dev_deps: + # Early exit; the tool is already a dev dependency. + continue + + console.print(f"✔ Adding '{dep}' as a development dependency.", style="green") + if not offline: + subprocess.run( + ["uv", "add", "--dev", "--quiet", dep], + check=True, + ) + else: + subprocess.run( + ["uv", "add", "--dev", "--quiet", "--offline", dep], + check=True, + ) + + +def remove_dev_deps(pypi_names: list[str], *, offline: bool) -> None: + """Remove the tool's development dependencies, if present.""" + existing_dev_deps = get_dev_deps() + + for dep in pypi_names: + if _strip_extras(dep) not in existing_dev_deps: + # Early exit; the tool is already not a dev dependency. + continue + + console.print( + f"✔ Removing '{dep}' as a development dependency.", + style="green", + ) + if not offline: + subprocess.run( + ["uv", "remove", "--dev", "--quiet", _strip_extras(dep)], check=True + ) + else: + subprocess.run( + ["uv", "remove", "--dev", "--quiet", "--offline", _strip_extras(dep)], + check=True, + ) + + +def is_dep_used(dep: str) -> bool: + return _strip_extras(dep) in get_dev_deps() + + +def _strip_extras(dep: str) -> str: + """Remove extras from a dependency string.""" + return re.sub(r"\[.*\]", "", dep) diff --git a/src/usethis/ci.py b/src/usethis/ci.py index e96496e..451287e 100644 --- a/src/usethis/ci.py +++ b/src/usethis/ci.py @@ -2,6 +2,7 @@ import typer +from usethis import console, offline_opt from usethis._bitbucket.config import ( add_bitbucket_pipeline_config, remove_bitbucket_pipeline_config, @@ -18,11 +19,14 @@ def bitbucket( remove: bool = typer.Option( False, "--remove", help="Remove Bitbucket pipelines CI instead of adding it." ), + offline: bool = offline_opt, ) -> None: - _bitbucket(remove=remove) + _bitbucket(remove=remove, offline=offline) -def _bitbucket(*, remove: bool = False) -> None: +def _bitbucket(*, remove: bool = False, offline: bool = False) -> None: + _ = offline # Already offline + config_yaml_path = Path.cwd() / "bitbucket-pipelines.yml" if config_yaml_path.exists(): @@ -70,4 +74,8 @@ def _bitbucket(*, remove: bool = False) -> None: ) ) + console.print("☐ Populate the placeholder step in 'bitbucket-pipelines.yml'.") + add_steps(steps, is_parallel=True) + + console.print("☐ Run your first pipeline on the Bitbucket website.") diff --git a/src/usethis/tool.py b/src/usethis/tool.py index ecd5368..4832ef8 100644 --- a/src/usethis/tool.py +++ b/src/usethis/tool.py @@ -2,7 +2,7 @@ import typer -from usethis import console +from usethis import console, offline_opt from usethis._pre_commit.core import ( add_pre_commit_config, install_pre_commit, @@ -12,6 +12,7 @@ from usethis._pytest.core import add_pytest_dir, remove_pytest_dir from usethis._ruff.rules import deselect_ruff_rules, select_ruff_rules from usethis._tool import ALL_TOOLS, DeptryTool, PreCommitTool, PytestTool, RuffTool +from usethis._uv.deps import add_dev_deps, remove_dev_deps app = typer.Typer(help="Add and configure development tools, e.g. linters.") @@ -23,29 +24,32 @@ def pre_commit( remove: bool = typer.Option( False, "--remove", help="Remove pre-commit instead of adding it." ), + offline: bool = offline_opt, ) -> None: - _pre_commit(remove=remove) + _pre_commit(remove=remove, offline=offline) -def _pre_commit(*, remove: bool = False) -> None: +def _pre_commit(*, remove: bool = False, offline: bool = False) -> None: tool = PreCommitTool() if not remove: - tool.add_dev_deps() + add_dev_deps(tool.dev_deps, offline=offline) add_pre_commit_config() - for tool in ALL_TOOLS: - if tool.is_used(): - tool.add_pre_commit_repo_config() + for _tool in ALL_TOOLS: + if _tool.is_used(): + _tool.add_pre_commit_repo_config() install_pre_commit() console.print( "☐ Call the 'pre-commit run --all-files' command to run the hooks manually.", ) else: - tool.add_dev_deps() # Need pre-commit to be installed so we can uninstall hooks + add_dev_deps( # Need pre-commit to be installed so we can uninstall hooks + tool.dev_deps, offline=offline + ) uninstall_pre_commit() remove_pre_commit_config() - tool.remove_dev_deps() + remove_dev_deps(tool.dev_deps, offline=offline) @app.command( @@ -55,15 +59,16 @@ def deptry( remove: bool = typer.Option( False, "--remove", help="Remove deptry instead of adding it." ), + offline: bool = offline_opt, ) -> None: - _deptry(remove=remove) + _deptry(remove=remove, offline=offline) -def _deptry(*, remove: bool = False) -> None: +def _deptry(*, remove: bool = False, offline: bool = False) -> None: tool = DeptryTool() if not remove: - tool.add_dev_deps() + add_dev_deps(tool.dev_deps, offline=offline) if PreCommitTool().is_used(): tool.add_pre_commit_repo_config() @@ -73,7 +78,7 @@ def _deptry(*, remove: bool = False) -> None: else: if PreCommitTool().is_used(): tool.remove_pre_commit_repo_config() - tool.remove_dev_deps() + remove_dev_deps(tool.dev_deps, offline=offline) @app.command(help="Use ruff: an extremely fast Python linter and code formatter.") @@ -81,11 +86,12 @@ def ruff( remove: bool = typer.Option( False, "--remove", help="Remove ruff instead of adding it." ), + offline: bool = offline_opt, ) -> None: - _ruff(remove=remove) + _ruff(remove=remove, offline=offline) -def _ruff(*, remove: bool = False) -> None: +def _ruff(*, remove: bool = False, offline: bool = False) -> None: tool = RuffTool() rules = [] @@ -94,7 +100,7 @@ def _ruff(*, remove: bool = False) -> None: rules += _tool.get_associated_ruff_rules() if not remove: - tool.add_dev_deps() + add_dev_deps(tool.dev_deps, offline=offline) tool.add_pyproject_configs() select_ruff_rules(rules) if PreCommitTool().is_used(): @@ -108,7 +114,7 @@ def _ruff(*, remove: bool = False) -> None: if PreCommitTool().is_used(): tool.remove_pre_commit_repo_config() tool.remove_pyproject_configs() # N.B. this will remove the selected ruff rules - tool.remove_dev_deps() + remove_dev_deps(tool.dev_deps, offline=offline) @app.command(help="Use the pytest testing framework.") @@ -116,15 +122,16 @@ def pytest( remove: bool = typer.Option( False, "--remove", help="Remove pytest instead of adding it." ), + offline: bool = offline_opt, ) -> None: - _pytest(remove=remove) + _pytest(remove=remove, offline=offline) -def _pytest(*, remove: bool = False) -> None: +def _pytest(*, remove: bool = False, offline: bool = False) -> None: tool = PytestTool() if not remove: - tool.add_dev_deps() + add_dev_deps(tool.dev_deps, offline=offline) tool.add_pyproject_configs() if RuffTool().is_used(): select_ruff_rules(tool.get_associated_ruff_rules()) @@ -141,5 +148,5 @@ def _pytest(*, remove: bool = False) -> None: if RuffTool().is_used(): deselect_ruff_rules(tool.get_associated_ruff_rules()) tool.remove_pyproject_configs() - tool.remove_dev_deps() + remove_dev_deps(tool.dev_deps, offline=offline) remove_pytest_dir() # Last, since this is a manual step diff --git a/tests/usethis/_pyproject/test_requires_python.py b/tests/usethis/_pyproject/test_requires_python.py index 42fb1d4..90e2507 100644 --- a/tests/usethis/_pyproject/test_requires_python.py +++ b/tests/usethis/_pyproject/test_requires_python.py @@ -17,9 +17,14 @@ class TestMaxMajorPy3: def test_max_major_py3(self): - endoflife_info: list[dict[str, Any] | dict[Literal["cycle"], str]] = ( - requests.get(r"https://endoflife.date/api/python.json", timeout=5).json() - ) + try: + endoflife_info: list[dict[str, Any] | dict[Literal["cycle"], str]] = ( + requests.get( + r"https://endoflife.date/api/python.json", timeout=5 + ).json() + ) + except requests.exceptions.ConnectionError: + pytest.skip(reason="Failed to connect to https://endoflife.date/") assert ( max(int(x["cycle"].split(".")[1]) for x in endoflife_info) == MAX_MAJOR_PY3 diff --git a/tests/usethis/_pytest/test_core.py b/tests/usethis/_pytest/test_core.py index cd6200d..27af335 100644 --- a/tests/usethis/_pytest/test_core.py +++ b/tests/usethis/_pytest/test_core.py @@ -2,7 +2,7 @@ import pytest -from usethis._pytest.core import add_pytest_dir +from usethis._pytest.core import add_pytest_dir, remove_pytest_dir from usethis._test import change_cwd @@ -38,3 +38,48 @@ def test_conftest_exists( assert (uv_init_dir / "tests" / "conftest.py").exists() out, _ = capfd.readouterr() assert out == "✔ Writing '/tests/conftest.py'.\n" + + +class TestRemovePytestDir: + def test_blank_slate(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + remove_pytest_dir() + + # Assert + assert not (uv_init_dir / "tests").exists() + + def test_dir(self, uv_init_dir: Path): + # Arrange + (uv_init_dir / "tests").mkdir() + + # Act + with change_cwd(uv_init_dir): + remove_pytest_dir() + + # Assert + assert not (uv_init_dir / "tests").exists() + + def test_protect_file(self, uv_init_dir: Path): + # Arrange + (uv_init_dir / "tests").mkdir() + (uv_init_dir / "tests" / "test_something.py").touch() + + # Act + with change_cwd(uv_init_dir): + remove_pytest_dir() + + # Assert + assert (uv_init_dir / "tests").exists() + assert (uv_init_dir / "tests" / "test_something.py").exists() + + def test_roundtrip(self, uv_init_dir: Path): + with change_cwd(uv_init_dir): + # Arrange + add_pytest_dir() + + # Act + remove_pytest_dir() + + # Assert + assert not (uv_init_dir / "tests").exists() diff --git a/tests/usethis/_ruff/test_rules.py b/tests/usethis/_ruff/test_rules.py index 4c8c607..e36ed6f 100644 --- a/tests/usethis/_ruff/test_rules.py +++ b/tests/usethis/_ruff/test_rules.py @@ -3,7 +3,7 @@ import pytest from usethis._pyproject.io import PyProjectTOMLNotFoundError -from usethis._ruff.rules import get_ruff_rules, select_ruff_rules +from usethis._ruff.rules import deselect_ruff_rules, get_ruff_rules, select_ruff_rules from usethis._test import change_cwd @@ -42,3 +42,69 @@ def test_mixing(self, tmp_path: Path): # Assert rules = get_ruff_rules() assert rules == ["A", "B", "C", "D"] + + def test_respects_order(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + """ +[tool.ruff.lint] +select = ["D", "B", "A"] +""" + ) + + # Act + with change_cwd(tmp_path): + select_ruff_rules(["E", "C", "A"]) + + # Assert + assert get_ruff_rules() == ["D", "B", "A", "C", "E"] + + +class TestDeselectRuffRules: + def test_no_pyproject_toml(self, tmp_path: Path): + # Act + with change_cwd(tmp_path), pytest.raises(PyProjectTOMLNotFoundError): + deselect_ruff_rules(["A", "B", "C"]) + + def test_blank_slate(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text("") + + # Act + with change_cwd(tmp_path): + deselect_ruff_rules(["A", "B", "C"]) + + # Assert + assert get_ruff_rules() == [] + + def test_single_rule(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + """ +[tool.ruff.lint] +select = ["A"] +""" + ) + + # Act + with change_cwd(tmp_path): + deselect_ruff_rules(["A"]) + + # Assert + assert get_ruff_rules() == [] + + def test_mix(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + """ +[tool.ruff.lint] +select = ["A", "B", "C"] +""" + ) + + # Act + with change_cwd(tmp_path): + deselect_ruff_rules(["A", "C"]) + + # Assert + assert get_ruff_rules() == ["B"] diff --git a/tests/usethis/test_tool.py b/tests/usethis/test_tool.py index 4004de5..ecb62e6 100644 --- a/tests/usethis/test_tool.py +++ b/tests/usethis/test_tool.py @@ -8,52 +8,54 @@ _HOOK_ORDER, get_hook_names, ) -from usethis._test import change_cwd -from usethis._tool import ALL_TOOLS, get_dev_deps +from usethis._test import change_cwd, is_offline +from usethis._tool import ALL_TOOLS +from usethis._uv.deps import add_dev_deps, get_dev_deps from usethis.tool import _deptry, _pre_commit, _ruff class TestToolPreCommit: - def test_dependency_added(self, uv_init_dir: Path): - # Act - with change_cwd(uv_init_dir): - _pre_commit() + class TestAdd: + def test_dependency_added(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _pre_commit(offline=is_offline()) - # Assert - (dev_dep,) = get_dev_deps() - assert dev_dep == "pre-commit" + # Assert + (dev_dep,) = get_dev_deps() + 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() + def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): + # Act + with change_cwd(uv_init_dir): + _pre_commit(offline=is_offline()) - # Assert - out, _ = capfd.readouterr() - assert out == ( - "✔ Adding 'pre-commit' as a development dependency.\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" - ) + # Assert + out, _ = capfd.readouterr() + assert out == ( + "✔ Adding 'pre-commit' as a development dependency.\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() + def test_config_file_exists(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _pre_commit(offline=is_offline()) - # Assert - assert (uv_init_dir / ".pre-commit-config.yaml").exists() + # 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() + def test_config_file_contents(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _pre_commit(offline=is_offline()) - # Assert - contents = (uv_init_dir / ".pre-commit-config.yaml").read_text() - assert contents == ( - f"""\ + # 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}" @@ -61,79 +63,118 @@ def test_config_file_contents(self, uv_init_dir: Path): - 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( - """ + def test_already_exists(self, uv_init_repo_dir: Path): + # Arrange + (uv_init_repo_dir / ".pre-commit-config.yaml").write_text( + """\ repos: - - repo: foo +- repo: foo hooks: - - id: bar + - id: bar """ - ) + ) - # Act - with change_cwd(uv_init_repo_dir): - _pre_commit() + # Act + with change_cwd(uv_init_repo_dir): + _pre_commit(offline=is_offline()) - # Assert - contents = (uv_init_repo_dir / ".pre-commit-config.yaml").read_text() - assert contents == ( - """ + # Assert + contents = (uv_init_repo_dir / ".pre-commit-config.yaml").read_text() + assert contents == ( + """\ repos: - - repo: foo +- repo: foo hooks: - - id: bar + - 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 - with pytest.raises(subprocess.CalledProcessError): - (uv_init_repo_dir / "pyproject.toml").write_text("[") + def test_bad_commit(self, uv_init_repo_dir: Path): + # Act + with change_cwd(uv_init_repo_dir): + _pre_commit(offline=is_offline()) subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) subprocess.run( - ["git", "commit", "-m", "Bad commit"], cwd=uv_init_repo_dir, check=True + ["git", "commit", "-m", "Good commit"], cwd=uv_init_repo_dir, check=True ) - def test_cli_pass(self, uv_init_repo_dir: Path): - subprocess.run( - ["usethis", "tool", "pre-commit"], 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): - subprocess.run( - ["usethis", "tool", "pre-commit"], cwd=uv_init_repo_dir, check=True - ) + # Assert + with pytest.raises(subprocess.CalledProcessError): + (uv_init_repo_dir / "pyproject.toml").write_text("[") + subprocess.run(["git", "add", "."], cwd=uv_init_repo_dir, check=True) + 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, + ) - # 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") + + 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, offline=is_offline()) + + # 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_dev_deps(["pre-commit"], offline=is_offline()) + + # Act + _pre_commit(remove=True, offline=is_offline()) + + # Assert + assert not get_dev_deps() class TestDeptry: @@ -191,8 +232,8 @@ def test_pre_commit_after( ): # Act with change_cwd(uv_init_dir): - _deptry() - _pre_commit() + _deptry(offline=is_offline()) + _pre_commit(offline=is_offline()) # Assert hook_names = get_hook_names() @@ -240,8 +281,8 @@ def test_pre_commit_first( ): # Act with change_cwd(uv_init_dir): - _pre_commit() - _deptry() + _pre_commit(offline=is_offline()) + _deptry(offline=is_offline()) # Assert hook_names = get_hook_names() @@ -285,46 +326,71 @@ def test_pre_commit_first( class TestRuff: - def test_dependency_added(self, uv_init_dir: Path): - # Act - with change_cwd(uv_init_dir): - _ruff() + class TestAdd: + def test_dependency_added(self, uv_init_dir: Path): + # Act + with change_cwd(uv_init_dir): + _ruff(offline=is_offline()) - # Assert - (dev_dep,) = get_dev_deps() - assert dev_dep == "ruff" + # Assert + (dev_dep,) = get_dev_deps() + assert dev_dep == "ruff" - def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): - # Act - with change_cwd(uv_init_dir): - _ruff() + def test_stdout(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): + # Act + with change_cwd(uv_init_dir): + _ruff(offline=is_offline()) - # Assert - out, _ = capfd.readouterr() - assert out == ( - "✔ Adding 'ruff' as a development dependency.\n" - "✔ Adding ruff config to 'pyproject.toml'.\n" - "✔ Enabling ruff rules 'C4', 'E4', 'E7', 'E9', 'F', 'FURB', 'I', 'PLE', 'PLR', \n'PT', 'RUF', 'SIM', 'UP' in 'pyproject.toml'.\n" - "☐ Call the 'ruff check' command to run the ruff linter.\n" - "☐ Call the 'ruff format' command to run the ruff formatter.\n" - ) + # Assert + out, _ = capfd.readouterr() + assert out == ( + "✔ Adding 'ruff' as a development dependency.\n" + "✔ Adding ruff config to 'pyproject.toml'.\n" + "✔ Enabling ruff rules 'C4', 'E4', 'E7', 'E9', 'F', 'FURB', 'I', 'PLE', 'PLR', \n'PT', 'RUF', 'SIM', 'UP' in 'pyproject.toml'.\n" + "☐ Call the 'ruff check' command to run the ruff linter.\n" + "☐ Call the 'ruff format' command to run the ruff formatter.\n" + ) - def test_cli(self, uv_init_dir: Path): - subprocess.run(["usethis", "tool", "ruff"], cwd=uv_init_dir, check=True) + 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(offline=is_offline()) + _pre_commit(offline=is_offline()) + + # 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"] +""" + ) - def test_pre_commit_first( - self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str] - ): - # Act - with change_cwd(uv_init_dir): - _ruff() - _pre_commit() + # Act + with change_cwd(uv_init_dir): + _ruff(remove=True, offline=is_offline()) # Assert - hook_names = get_hook_names() - - assert "ruff-format" in hook_names - assert "ruff-check" in hook_names + assert (uv_init_dir / "pyproject.toml").read_text() == "" class TestAllHooksList: