Skip to content

Commit

Permalink
Feature/22 implement usethis tool pre commit (#25)
Browse files Browse the repository at this point in the history
* Add deptry function which installs deptry via uv as a dev dependency.

* Add progress message for usethis tool deptry.

* Add tests for running deptry after calling `usethis tool deptry`.

* Configure the package as a CLI app using typer.

* Add pre_commit function to add pre-commit as a dev dep

* Create a .pre-commit-config.yaml file automatically.

* Add complete logic for adding pre-commit.

* Update lockfile

* Setup git user in CI

* Use global git user config.
  • Loading branch information
nathanjmcdougall authored Oct 15, 2024
1 parent fecb2ac commit c2956da
Show file tree
Hide file tree
Showing 12 changed files with 904 additions and 65 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7

- 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
- name: Setup uv and handle its cache
uses: hynek/setup-cached-uv@49a39f911c85c6ec0c9aadd5a426ae2761afaba2 # v2.0.0

Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"gitpython>=3.1.43",
"packaging>=24.1",
"pydantic>=2.9.2",
"requests>=2.32.3",
"rich>=13.8.1",
"ruamel-yaml>=0.18.6",
"tomlkit>=0.13.2",
"typer>=0.12.5",
]

Expand All @@ -21,7 +27,5 @@ dev-dependencies = [
"pytest>=8.3.2",
"pytest-md>=0.2.0",
"pytest-emoji>=0.2.0",
"pydantic>=2.9.1",
"tomlkit>=0.13.2",
"deptry>=0.20.0",
]
1 change: 1 addition & 0 deletions src/usethis/_deptry/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PRE_COMMIT_NAME = "deptry"
43 changes: 43 additions & 0 deletions src/usethis/_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import requests


class _GitHubTagError(Exception):
"""Custom exception for GitHub tag-related errors."""


class _NoTagsFoundError(_GitHubTagError):
"""Custom exception raised when no tags are found."""


def _get_github_latest_tag(owner: str, repo: str) -> str:
"""Get the name of the most recent tag on the default branch of a GitHub repository.
Args:
owner: GitHub repository owner (username or organization).
repo: GitHub repository name.
Returns:
The name of most recent tag of the repository.
Raises:
GitHubTagError: If there's an issue fetching the tags from the GitHub API.
NoTagsFoundError: If the repository has no tags.
"""

# GitHub API URL for repository tags
api_url = f"https://api.github.com/repos/{owner}/{repo}/tags"

# Fetch the tags using the GitHub API
try:
response = requests.get(api_url, timeout=1)
response.raise_for_status() # Raise an error for HTTP issues
except requests.exceptions.HTTPError as err:
raise _GitHubTagError(f"Failed to fetch tags from GitHub API: {err}")

tags = response.json()

if not tags:
raise _NoTagsFoundError(f"No tags found for repository '{owner}/{repo}'")

# Most recent tag's name
return tags[0]["name"]
19 changes: 19 additions & 0 deletions src/usethis/_pre_commit/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Literal

from pydantic import BaseModel


class HookConfig(BaseModel):
id: str
name: str
entry: str | None = None
language: Literal["system", "python"] | None = None
always_run: bool | None = None
pass_filenames: bool | None = None
additional_dependencies: list[str] | None = None


class PreCommitRepoConfig(BaseModel):
repo: str
rev: str | None = None
hooks: list[HookConfig]
138 changes: 138 additions & 0 deletions src/usethis/_pre_commit/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import subprocess
from collections import Counter
from pathlib import Path

import ruamel.yaml
from ruamel.yaml.util import load_yaml_guess_indent

from usethis import console
from usethis._deptry.core import PRE_COMMIT_NAME as DEPTRY_PRE_COMMIT_NAME
from usethis._git import _get_github_latest_tag, _GitHubTagError
from usethis._pre_commit.config import PreCommitRepoConfig

_YAML_CONTENTS_TEMPLATE = """
repos:
- repo: https://github.com/abravalheri/validate-pyproject
rev: "{pkg_version}"
hooks:
- id: validate-pyproject
additional_dependencies: ["validate-pyproject-schema-store[all]"]
"""
# Manually bump this version when necessary
_VALIDATEPYPROJECT_VERSION = "v0.21"

_HOOK_ORDER = [
"validate-pyproject",
DEPTRY_PRE_COMMIT_NAME,
]


def make_pre_commit_config() -> None:
console.print("✔ Creating .pre-commit-config.yaml file", style="green")
try:
pkg_version = _get_github_latest_tag("abravalheri", "validate-pyproject")
except _GitHubTagError:
# Fallback to last known working version
pkg_version = _VALIDATEPYPROJECT_VERSION
yaml_contents = _YAML_CONTENTS_TEMPLATE.format(pkg_version=pkg_version)

(Path.cwd() / ".pre-commit-config.yaml").write_text(yaml_contents)


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)

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

yaml.dump(content, path)


def add_single_hook(config: PreCommitRepoConfig) -> None:
# We should have a canonical sort order for all usethis-supported hooks to decide where to place the section. The main objective with the sort order is to ensure dependency relationships are satisfied. For example, valdiate-pyproject will check if the pyproject.toml is valid - if it isn't then some later tools might fail. It would be better to catch this earlier. A general principle is to move from the simpler hooks to the more complicated. Of course, this order might already be violated, or the config might include unrecognized repos - in any case, we aim to ensure the new tool is configured correctly, so it should be placed after the last of its precedents. This logic can be encoded in the adding function.

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)

(hook_config,) = config.hooks
hook_name = hook_config.id

# Get an ordered list of the hooks already in the file
existing_hooks = get_hook_names(path.parent)

# 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")
precedents = _HOOK_ORDER[:hook_idx]

# Find the last of the precedents in the existing hooks
existings_precedents = [hook for hook in existing_hooks if hook in precedents]
if existings_precedents:
last_precedent = existings_precedents[-1]
else:
# Use the last existing hook
last_precedent = existing_hooks[-1]

# Insert the new hook after the last precedent repo
# Do this by iterating over the repos and hooks, and inserting the new hook after
# the last precedent
new_repos = []
for repo in content["repos"]:
new_repos.append(repo)
for hook in repo["hooks"]:
if hook["id"] == last_precedent:
new_repos.append(config.model_dump(exclude_none=True))
content["repos"] = new_repos

# Dump the new content
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:
content = yaml.load(f)

hook_names = []
for repo in content["repos"]:
for hook in repo["hooks"]:
hook_names.append(hook["id"])

# Need to validate there are no duplciates
for name, count in Counter(hook_names).items():
if count > 1:
raise DuplicatedHookNameError(f"Hook name '{name}' is duplicated")

return hook_names


class DuplicatedHookNameError(ValueError):
"""Raised when a hook name is duplicated in a pre-commit configuration file."""


def install_pre_commit() -> None:
console.print("✔ Installing pre-commit hooks", style="green")
subprocess.run(
["uv", "run", "pre-commit", "install"],
check=True,
stdout=subprocess.DEVNULL,
)
15 changes: 15 additions & 0 deletions src/usethis/_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Generator


@contextmanager
def change_cwd(new_dir: Path) -> Generator[None, None, None]:
"""Change the working directory temporarily."""
old_dir = Path.cwd()
os.chdir(new_dir)
try:
yield
finally:
os.chdir(old_dir)
128 changes: 128 additions & 0 deletions src/usethis/_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import subprocess
from abc import abstractmethod
from pathlib import Path
from typing import Protocol

import tomlkit
from packaging.requirements import Requirement
from pydantic import TypeAdapter

from usethis import console
from usethis._deptry.core import PRE_COMMIT_NAME as DEPTRY_PRE_COMMIT_NAME
from usethis._pre_commit.config import HookConfig, PreCommitRepoConfig
from usethis._pre_commit.core import (
add_single_hook,
get_hook_names,
make_pre_commit_config,
)


class Tool(Protocol):
@property
@abstractmethod
def pypi_name(self) -> str:
"""The name of the tool on PyPI."""

@property
@abstractmethod
def pre_commit_name(self) -> str:
"""The name of the hook to be used in the pre-commit configuration.
Raises:
NotImplementedError: If the tool does not have a pre-commit configuration.
"""

raise NotImplementedError

@abstractmethod
def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
"""Get the pre-commit repository configuration for the tool.
Returns:
The pre-commit repository configuration.
Raises:
NotImplementedError: If the tool does not have a pre-commit configuration.
"""
raise NotImplementedError

def is_used(self) -> bool:
"""Whether the tool is being used in the current project."""
return self.pypi_name in _get_dev_deps(Path.cwd())

def add_pre_commit_repo_config(self) -> None:
"""Add the tool's pre-commit configuration."""
# Create a new pre-commit config file if there isn't already one.
if not (Path.cwd() / ".pre-commit-config.yaml").exists():
make_pre_commit_config()

try:
pre_commit_name = self.pre_commit_name
repo_config = self.get_pre_commit_repo_config()
except NotImplementedError:
return

# Add the config for this specific tool.
if pre_commit_name not in get_hook_names(Path.cwd()):
console.print(
f"✔ Adding {pre_commit_name} config to .pre-commit-config.yaml",
style="green",
)
add_single_hook(repo_config)

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 _get_dev_deps(proj_dir: Path) -> list[str]:
pyproject = tomlkit.parse((proj_dir / "pyproject.toml").read_text())
req_strs = TypeAdapter(list[str]).validate_python(
pyproject["tool"]["uv"]["dev-dependencies"]
)
reqs = [Requirement(req_str) for req_str in req_strs]
return [req.name for req in reqs]


class PreCommitTool(Tool):
@property
def pypi_name(self) -> str:
return "pre-commit"

@property
def pre_commit_name(self) -> str:
raise NotImplementedError

def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
raise NotImplementedError


class DeptryTool(Tool):
@property
def pypi_name(self) -> str:
return "deptry"

@property
def pre_commit_name(self) -> str:
return DEPTRY_PRE_COMMIT_NAME

def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
return PreCommitRepoConfig(
repo="local",
hooks=[
HookConfig(
id=self.pre_commit_name,
name=self.pre_commit_name,
entry="uv run --frozen deptry src",
language="system",
always_run=True,
pass_filenames=False,
)
],
)


ALL_TOOLS: list[Tool] = [PreCommitTool(), DeptryTool()]
Loading

0 comments on commit c2956da

Please sign in to comment.