Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement --remove flag. #36

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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