-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature/22 implement usethis tool pre commit (#25)
* 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
1 parent
fecb2ac
commit c2956da
Showing
12 changed files
with
904 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
PRE_COMMIT_NAME = "deptry" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()] |
Oops, something went wrong.