Skip to content

Commit

Permalink
Implement --remove flag. (#36)
Browse files Browse the repository at this point in the history
* Implement --remove flag.

* Consistency in whether to include quotes in pre-commit conifg YAML rev

* Use example email in github actions

* Consistency in use of double quotes in YAML

* Fix test in case of empty pre-commit repo list
  • Loading branch information
nathanjmcdougall committed Oct 28, 2024
1 parent d4364b8 commit 9c4ba37
Show file tree
Hide file tree
Showing 10 changed files with 481 additions and 198 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:

- name: Setup git user config
run: |
git config --global user.name github-actions[bot]
git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
git config --global user.name placeholder
git config --global user.email placeholder@example.com
- name: "Set up uv"
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3.1.7
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.21
rev: "v0.21"
hooks:
- id: validate-pyproject
additional_dependencies: ["validate-pyproject-schema-store[all]"]
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@ dev-dependencies = [
"pytest-md>=0.2.0",
"pytest-emoji>=0.2.0",
"deptry>=0.20.0",
"pre-commit>=4.0.1",
"ruff>=0.7.0",
"pytest-cov>=5.0.0",
"gitpython>=3.1.43",
"pre-commit>=4.0.1",
]

[tool.coverage.run]
source = ["src"]
omit = ["*/pytest-of-*/*"]


[tool.ruff]
src = ["src"]
line-length = 88
Expand Down
77 changes: 49 additions & 28 deletions src/usethis/_pre_commit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
]


def make_pre_commit_config() -> None:
def ensure_pre_commit_config() -> None:
if (Path.cwd() / ".pre-commit-config.yaml").exists():
# Early exit; the file already exists
return

console.print("✔ Creating .pre-commit-config.yaml file", style="green")
try:
pkg_version = get_github_latest_tag("abravalheri", "validate-pyproject")
Expand All @@ -40,34 +44,16 @@ def make_pre_commit_config() -> None:
(Path.cwd() / ".pre-commit-config.yaml").write_text(yaml_contents)


def ensure_pre_commit_config() -> None:
def remove_pre_commit_config() -> None:
if not (Path.cwd() / ".pre-commit-config.yaml").exists():
make_pre_commit_config()


def delete_hook(name: str) -> None:
path = Path.cwd() / ".pre-commit-config.yaml"

with path.open(mode="r") as f:
content, sequence_ind, offset_ind = load_yaml_guess_indent(f)

yaml = ruamel.yaml.YAML(typ="rt")
yaml.indent(mapping=sequence_ind, sequence=sequence_ind, offset=offset_ind)

# search across the repos for any hooks with ID equal to name
for repo in content["repos"]:
for hook in repo["hooks"]:
if hook["id"] == name:
repo["hooks"].remove(hook)
# Early exit; the file already doesn't exist
return

# if repo has no hooks, remove it
if not repo["hooks"]:
content["repos"].remove(repo)

yaml.dump(content, path)
console.print("✔ Removing .pre-commit-config.yaml file", style="green")
(Path.cwd() / ".pre-commit-config.yaml").unlink()


def add_single_hook(config: PreCommitRepoConfig) -> None:
def add_hook(config: PreCommitRepoConfig) -> None:
path = Path.cwd() / ".pre-commit-config.yaml"

with path.open(mode="r") as f:
Expand All @@ -82,10 +68,14 @@ def add_single_hook(config: PreCommitRepoConfig) -> None:
# Get an ordered list of the hooks already in the file
existing_hooks = get_hook_names(path.parent)

if not existing_hooks:
raise NotImplementedError

# Get the precendents, i.e. hooks occuring before the new hook
hook_idx = _HOOK_ORDER.index(hook_name)
if hook_idx == -1:
raise ValueError(f"Hook {hook_name} not recognized")
try:
hook_idx = _HOOK_ORDER.index(hook_name)
except ValueError:
raise NotImplementedError(f"Hook '{hook_name}' not recognized")
precedents = _HOOK_ORDER[:hook_idx]

# Find the last of the precedents in the existing hooks
Expand All @@ -111,6 +101,28 @@ def add_single_hook(config: PreCommitRepoConfig) -> None:
yaml.dump(content, path)


def remove_hook(name: str) -> None:
path = Path.cwd() / ".pre-commit-config.yaml"

with path.open(mode="r") as f:
content, sequence_ind, offset_ind = load_yaml_guess_indent(f)

yaml = ruamel.yaml.YAML(typ="rt")
yaml.indent(mapping=sequence_ind, sequence=sequence_ind, offset=offset_ind)

# search across the repos for any hooks with ID equal to name
for repo in content["repos"]:
for hook in repo["hooks"]:
if hook["id"] == name:
repo["hooks"].remove(hook)

# if repo has no hooks, remove it
if not repo["hooks"]:
content["repos"].remove(repo)

yaml.dump(content, path)


def get_hook_names(path: Path) -> list[str]:
yaml = ruamel.yaml.YAML()
with (path / ".pre-commit-config.yaml").open(mode="r") as f:
Expand Down Expand Up @@ -140,3 +152,12 @@ def install_pre_commit() -> None:
check=True,
stdout=subprocess.DEVNULL,
)


def uninstall_pre_commit() -> None:
console.print("✔ Uninstalling pre-commit hooks", style="green")
subprocess.run(
["uv", "run", "pre-commit", "uninstall"],
check=True,
stdout=subprocess.DEVNULL,
)
79 changes: 74 additions & 5 deletions src/usethis/_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from usethis import console
from usethis._pre_commit.config import HookConfig, PreCommitRepoConfig
from usethis._pre_commit.core import (
add_single_hook,
add_hook,
ensure_pre_commit_config,
get_hook_names,
remove_hook,
)
from usethis._pyproject.config import PyProjectConfig
from usethis._uv.deps import get_dev_deps
Expand Down Expand Up @@ -71,12 +72,31 @@ def add_pre_commit_repo_config(self) -> None:
)
first_time_adding = False

add_single_hook(
add_hook(
PreCommitRepoConfig(
repo=repo_config.repo, rev=repo_config.rev, hooks=[hook]
)
)

def remove_pre_commit_repo_config(self) -> None:
"""Remove the tool's pre-commit configuration."""
try:
repo_config = self.get_pre_commit_repo_config()
except NotImplementedError:
return

# Remove the config for this specific tool.
first_removal = True
for hook in repo_config.hooks:
if hook.id in get_hook_names(Path.cwd()):
if first_removal:
console.print(
f"✔ Removing {self.name} config from .pre-commit-config.yaml",
style="green",
)
first_removal = False
remove_hook(hook.id)

def add_pyproject_config(self) -> None:
"""Add the tool's pyproject.toml configuration."""

Expand All @@ -98,9 +118,7 @@ def add_pyproject_config(self) -> None:
# The configuration is already present.
return

console.print(
f"✔ Adding {self.pypi_name} configuration to pyproject.toml", style="green"
)
console.print(f"✔ Adding {self.name} config to pyproject.toml", style="green")

# The old configuration should be kept for all ID keys except the final/deepest
# one which shouldn't exist anyway since we checked as much, above. For example,
Expand All @@ -110,13 +128,64 @@ def add_pyproject_config(self) -> None:

(Path.cwd() / "pyproject.toml").write_text(tomlkit.dumps(pyproject))

def remove_pyproject_config(self) -> None:
"""Remove the tool's pyproject.toml configuration."""
try:
config = self.get_pyproject_config()
except NotImplementedError:
return

pyproject = tomlkit.parse((Path.cwd() / "pyproject.toml").read_text())

# Exit early if the configuration is not present.
try:
p = pyproject
for key in config.id_keys:
p = p[key]
except KeyError:
# The configuration is not present.
return

console.print(
f"✔ Removing {self.name} config from pyproject.toml",
style="green",
)

# Remove the configuration.
p = pyproject
for key in config.id_keys[:-1]:
p = p[key]
del p[config.id_keys[-1]]

# Cleanup: any empty sections should be removed.
for idx in range(len(config.id_keys) - 1):
p = pyproject
for key in config.id_keys[: idx + 1]:
p = p[key]
if not p:
del p

(Path.cwd() / "pyproject.toml").write_text(tomlkit.dumps(pyproject))

def ensure_dev_dep(self) -> None:
"""Add the tool as a development dependency, if it is not already."""
console.print(
f"✔ Ensuring {self.pypi_name} is a development dependency", style="green"
)
subprocess.run(["uv", "add", "--dev", "--quiet", self.pypi_name], check=True)

def remove_dev_dep(self) -> None:
"""Remove the tool as a development dependency, if it is present."""
if self.pypi_name not in get_dev_deps(Path.cwd()):
# Early exit; the tool is already not a dev dependency.
return

console.print(
f"✔ Removing {self.pypi_name} as a development dependency",
style="green",
)
subprocess.run(["uv", "remove", "--dev", "--quiet", self.pypi_name], check=True)


class PreCommitTool(Tool):
@property
Expand Down
82 changes: 65 additions & 17 deletions src/usethis/tool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import typer

from usethis._pre_commit.core import ensure_pre_commit_config, install_pre_commit
from usethis._pre_commit.core import (
ensure_pre_commit_config,
install_pre_commit,
remove_pre_commit_config,
uninstall_pre_commit,
)
from usethis._tool import ALL_TOOLS, DeptryTool, PreCommitTool, RuffTool

app = typer.Typer(help="Add and configure development tools, e.g. linters")
Expand All @@ -9,30 +14,73 @@
@app.command(
help="Use the pre-commit framework to manage and maintain pre-commit hooks."
)
def pre_commit() -> None:
def pre_commit(
remove: bool = typer.Option(
False, "--remove", help="Remove pre-commit instead of adding it."
),
) -> None:
_pre_commit(remove=remove)


def _pre_commit(*, remove: bool = False) -> None:
tool = PreCommitTool()
tool.ensure_dev_dep()
ensure_pre_commit_config()
for tool in ALL_TOOLS:
if tool.is_used():
tool.add_pre_commit_repo_config()
install_pre_commit()

if not remove:
tool.ensure_dev_dep()
ensure_pre_commit_config()
for tool in ALL_TOOLS:
if tool.is_used():
tool.add_pre_commit_repo_config()
install_pre_commit()
else:
uninstall_pre_commit()
remove_pre_commit_config()
tool.remove_dev_dep()


@app.command(
help="Use the deptry linter: avoid missing or superfluous dependency declarations."
)
def deptry() -> None:
def deptry(
remove: bool = typer.Option(
False, "--remove", help="Remove deptry instead of adding it."
),
) -> None:
_deptry(remove=remove)


def _deptry(*, remove: bool = False) -> None:
tool = DeptryTool()
tool.ensure_dev_dep()
if PreCommitTool().is_used():
tool.add_pre_commit_repo_config()

if not remove:
tool.ensure_dev_dep()
if PreCommitTool().is_used():
tool.add_pre_commit_repo_config()
else:
if PreCommitTool().is_used():
tool.remove_pre_commit_repo_config()
tool.remove_dev_dep()


@app.command(help="Use ruff: an extremely fast Python linter and code formatter.")
def ruff() -> None:
def ruff(
remove: bool = typer.Option(
False, "--remove", help="Remove ruff instead of adding it."
),
) -> None:
_ruff(remove=remove)


def _ruff(*, remove: bool = False) -> None:
tool = RuffTool()
tool.ensure_dev_dep()
tool.add_pyproject_config()
if PreCommitTool().is_used():
tool.add_pre_commit_repo_config()

if not remove:
tool.ensure_dev_dep()
tool.add_pyproject_config()
if PreCommitTool().is_used():
tool.add_pre_commit_repo_config()
else:
if PreCommitTool().is_used():
tool.remove_pre_commit_repo_config()
tool.remove_pyproject_config()
tool.remove_dev_dep()
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import subprocess
from pathlib import Path

import pytest
from git import Repo


@pytest.fixture(scope="function")
def uv_init_dir(tmp_path: Path) -> Path:
subprocess.run(["uv", "init", "--lib"], cwd=tmp_path, check=True)
return tmp_path


@pytest.fixture(scope="function")
def uv_init_repo_dir(tmp_path: Path) -> Path:
subprocess.run(["uv", "init", "--lib"], cwd=tmp_path, check=True)
Repo.init(tmp_path)
return tmp_path
Loading

0 comments on commit 9c4ba37

Please sign in to comment.