Skip to content

Commit 268bf2e

Browse files
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.
1 parent c14f727 commit 268bf2e

File tree

12 files changed

+904
-65
lines changed

12 files changed

+904
-65
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ jobs:
1313
- name: Checkout code
1414
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
1515

16+
- name: Setup git user config
17+
run: |
18+
git config --global user.name github-actions[bot]
19+
git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
20+
1621
- name: Setup uv and handle its cache
1722
uses: hynek/setup-cached-uv@49a39f911c85c6ec0c9aadd5a426ae2761afaba2 # v2.0.0
1823

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
8+
"gitpython>=3.1.43",
9+
"packaging>=24.1",
10+
"pydantic>=2.9.2",
11+
"requests>=2.32.3",
812
"rich>=13.8.1",
13+
"ruamel-yaml>=0.18.6",
14+
"tomlkit>=0.13.2",
915
"typer>=0.12.5",
1016
]
1117

@@ -21,7 +27,5 @@ dev-dependencies = [
2127
"pytest>=8.3.2",
2228
"pytest-md>=0.2.0",
2329
"pytest-emoji>=0.2.0",
24-
"pydantic>=2.9.1",
25-
"tomlkit>=0.13.2",
2630
"deptry>=0.20.0",
2731
]

src/usethis/_deptry/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PRE_COMMIT_NAME = "deptry"

src/usethis/_git.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import requests
2+
3+
4+
class _GitHubTagError(Exception):
5+
"""Custom exception for GitHub tag-related errors."""
6+
7+
8+
class _NoTagsFoundError(_GitHubTagError):
9+
"""Custom exception raised when no tags are found."""
10+
11+
12+
def _get_github_latest_tag(owner: str, repo: str) -> str:
13+
"""Get the name of the most recent tag on the default branch of a GitHub repository.
14+
15+
Args:
16+
owner: GitHub repository owner (username or organization).
17+
repo: GitHub repository name.
18+
19+
Returns:
20+
The name of most recent tag of the repository.
21+
22+
Raises:
23+
GitHubTagError: If there's an issue fetching the tags from the GitHub API.
24+
NoTagsFoundError: If the repository has no tags.
25+
"""
26+
27+
# GitHub API URL for repository tags
28+
api_url = f"https://api.github.com/repos/{owner}/{repo}/tags"
29+
30+
# Fetch the tags using the GitHub API
31+
try:
32+
response = requests.get(api_url, timeout=1)
33+
response.raise_for_status() # Raise an error for HTTP issues
34+
except requests.exceptions.HTTPError as err:
35+
raise _GitHubTagError(f"Failed to fetch tags from GitHub API: {err}")
36+
37+
tags = response.json()
38+
39+
if not tags:
40+
raise _NoTagsFoundError(f"No tags found for repository '{owner}/{repo}'")
41+
42+
# Most recent tag's name
43+
return tags[0]["name"]

src/usethis/_pre_commit/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from typing import Literal
2+
3+
from pydantic import BaseModel
4+
5+
6+
class HookConfig(BaseModel):
7+
id: str
8+
name: str
9+
entry: str | None = None
10+
language: Literal["system", "python"] | None = None
11+
always_run: bool | None = None
12+
pass_filenames: bool | None = None
13+
additional_dependencies: list[str] | None = None
14+
15+
16+
class PreCommitRepoConfig(BaseModel):
17+
repo: str
18+
rev: str | None = None
19+
hooks: list[HookConfig]

src/usethis/_pre_commit/core.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import subprocess
2+
from collections import Counter
3+
from pathlib import Path
4+
5+
import ruamel.yaml
6+
from ruamel.yaml.util import load_yaml_guess_indent
7+
8+
from usethis import console
9+
from usethis._deptry.core import PRE_COMMIT_NAME as DEPTRY_PRE_COMMIT_NAME
10+
from usethis._git import _get_github_latest_tag, _GitHubTagError
11+
from usethis._pre_commit.config import PreCommitRepoConfig
12+
13+
_YAML_CONTENTS_TEMPLATE = """
14+
repos:
15+
- repo: https://github.com/abravalheri/validate-pyproject
16+
rev: "{pkg_version}"
17+
hooks:
18+
- id: validate-pyproject
19+
additional_dependencies: ["validate-pyproject-schema-store[all]"]
20+
"""
21+
# Manually bump this version when necessary
22+
_VALIDATEPYPROJECT_VERSION = "v0.21"
23+
24+
_HOOK_ORDER = [
25+
"validate-pyproject",
26+
DEPTRY_PRE_COMMIT_NAME,
27+
]
28+
29+
30+
def make_pre_commit_config() -> None:
31+
console.print("✔ Creating .pre-commit-config.yaml file", style="green")
32+
try:
33+
pkg_version = _get_github_latest_tag("abravalheri", "validate-pyproject")
34+
except _GitHubTagError:
35+
# Fallback to last known working version
36+
pkg_version = _VALIDATEPYPROJECT_VERSION
37+
yaml_contents = _YAML_CONTENTS_TEMPLATE.format(pkg_version=pkg_version)
38+
39+
(Path.cwd() / ".pre-commit-config.yaml").write_text(yaml_contents)
40+
41+
42+
def delete_hook(name: str) -> None:
43+
path = Path.cwd() / ".pre-commit-config.yaml"
44+
45+
with path.open(mode="r") as f:
46+
content, sequence_ind, offset_ind = load_yaml_guess_indent(f)
47+
48+
yaml = ruamel.yaml.YAML(typ="rt")
49+
yaml.indent(mapping=sequence_ind, sequence=sequence_ind, offset=offset_ind)
50+
51+
# search across the repos for any hooks with ID equal to name
52+
for repo in content["repos"]:
53+
for hook in repo["hooks"]:
54+
if hook["id"] == name:
55+
repo["hooks"].remove(hook)
56+
57+
# if repo has no hooks, remove it
58+
if not repo["hooks"]:
59+
content["repos"].remove(repo)
60+
61+
yaml.dump(content, path)
62+
63+
64+
def add_single_hook(config: PreCommitRepoConfig) -> None:
65+
# 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.
66+
67+
path = Path.cwd() / ".pre-commit-config.yaml"
68+
69+
with path.open(mode="r") as f:
70+
content, sequence_ind, offset_ind = load_yaml_guess_indent(f)
71+
72+
yaml = ruamel.yaml.YAML(typ="rt")
73+
yaml.indent(mapping=sequence_ind, sequence=sequence_ind, offset=offset_ind)
74+
75+
(hook_config,) = config.hooks
76+
hook_name = hook_config.id
77+
78+
# Get an ordered list of the hooks already in the file
79+
existing_hooks = get_hook_names(path.parent)
80+
81+
# Get the precendents, i.e. hooks occuring before the new hook
82+
hook_idx = _HOOK_ORDER.index(hook_name)
83+
if hook_idx == -1:
84+
raise ValueError(f"Hook {hook_name} not recognized")
85+
precedents = _HOOK_ORDER[:hook_idx]
86+
87+
# Find the last of the precedents in the existing hooks
88+
existings_precedents = [hook for hook in existing_hooks if hook in precedents]
89+
if existings_precedents:
90+
last_precedent = existings_precedents[-1]
91+
else:
92+
# Use the last existing hook
93+
last_precedent = existing_hooks[-1]
94+
95+
# Insert the new hook after the last precedent repo
96+
# Do this by iterating over the repos and hooks, and inserting the new hook after
97+
# the last precedent
98+
new_repos = []
99+
for repo in content["repos"]:
100+
new_repos.append(repo)
101+
for hook in repo["hooks"]:
102+
if hook["id"] == last_precedent:
103+
new_repos.append(config.model_dump(exclude_none=True))
104+
content["repos"] = new_repos
105+
106+
# Dump the new content
107+
yaml.dump(content, path)
108+
109+
110+
def get_hook_names(path: Path) -> list[str]:
111+
yaml = ruamel.yaml.YAML()
112+
with (path / ".pre-commit-config.yaml").open(mode="r") as f:
113+
content = yaml.load(f)
114+
115+
hook_names = []
116+
for repo in content["repos"]:
117+
for hook in repo["hooks"]:
118+
hook_names.append(hook["id"])
119+
120+
# Need to validate there are no duplciates
121+
for name, count in Counter(hook_names).items():
122+
if count > 1:
123+
raise DuplicatedHookNameError(f"Hook name '{name}' is duplicated")
124+
125+
return hook_names
126+
127+
128+
class DuplicatedHookNameError(ValueError):
129+
"""Raised when a hook name is duplicated in a pre-commit configuration file."""
130+
131+
132+
def install_pre_commit() -> None:
133+
console.print("✔ Installing pre-commit hooks", style="green")
134+
subprocess.run(
135+
["uv", "run", "pre-commit", "install"],
136+
check=True,
137+
stdout=subprocess.DEVNULL,
138+
)

src/usethis/_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import os
2+
from contextlib import contextmanager
3+
from pathlib import Path
4+
from typing import Generator
5+
6+
7+
@contextmanager
8+
def change_cwd(new_dir: Path) -> Generator[None, None, None]:
9+
"""Change the working directory temporarily."""
10+
old_dir = Path.cwd()
11+
os.chdir(new_dir)
12+
try:
13+
yield
14+
finally:
15+
os.chdir(old_dir)

src/usethis/_tool.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import subprocess
2+
from abc import abstractmethod
3+
from pathlib import Path
4+
from typing import Protocol
5+
6+
import tomlkit
7+
from packaging.requirements import Requirement
8+
from pydantic import TypeAdapter
9+
10+
from usethis import console
11+
from usethis._deptry.core import PRE_COMMIT_NAME as DEPTRY_PRE_COMMIT_NAME
12+
from usethis._pre_commit.config import HookConfig, PreCommitRepoConfig
13+
from usethis._pre_commit.core import (
14+
add_single_hook,
15+
get_hook_names,
16+
make_pre_commit_config,
17+
)
18+
19+
20+
class Tool(Protocol):
21+
@property
22+
@abstractmethod
23+
def pypi_name(self) -> str:
24+
"""The name of the tool on PyPI."""
25+
26+
@property
27+
@abstractmethod
28+
def pre_commit_name(self) -> str:
29+
"""The name of the hook to be used in the pre-commit configuration.
30+
31+
Raises:
32+
NotImplementedError: If the tool does not have a pre-commit configuration.
33+
"""
34+
35+
raise NotImplementedError
36+
37+
@abstractmethod
38+
def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
39+
"""Get the pre-commit repository configuration for the tool.
40+
41+
Returns:
42+
The pre-commit repository configuration.
43+
44+
Raises:
45+
NotImplementedError: If the tool does not have a pre-commit configuration.
46+
"""
47+
raise NotImplementedError
48+
49+
def is_used(self) -> bool:
50+
"""Whether the tool is being used in the current project."""
51+
return self.pypi_name in _get_dev_deps(Path.cwd())
52+
53+
def add_pre_commit_repo_config(self) -> None:
54+
"""Add the tool's pre-commit configuration."""
55+
# Create a new pre-commit config file if there isn't already one.
56+
if not (Path.cwd() / ".pre-commit-config.yaml").exists():
57+
make_pre_commit_config()
58+
59+
try:
60+
pre_commit_name = self.pre_commit_name
61+
repo_config = self.get_pre_commit_repo_config()
62+
except NotImplementedError:
63+
return
64+
65+
# Add the config for this specific tool.
66+
if pre_commit_name not in get_hook_names(Path.cwd()):
67+
console.print(
68+
f"✔ Adding {pre_commit_name} config to .pre-commit-config.yaml",
69+
style="green",
70+
)
71+
add_single_hook(repo_config)
72+
73+
def ensure_dev_dep(self) -> None:
74+
"""Add the tool as a development dependency, if it is not already."""
75+
console.print(
76+
f"✔ Ensuring {self.pypi_name} is a development dependency", style="green"
77+
)
78+
subprocess.run(["uv", "add", "--dev", "--quiet", self.pypi_name], check=True)
79+
80+
81+
def _get_dev_deps(proj_dir: Path) -> list[str]:
82+
pyproject = tomlkit.parse((proj_dir / "pyproject.toml").read_text())
83+
req_strs = TypeAdapter(list[str]).validate_python(
84+
pyproject["tool"]["uv"]["dev-dependencies"]
85+
)
86+
reqs = [Requirement(req_str) for req_str in req_strs]
87+
return [req.name for req in reqs]
88+
89+
90+
class PreCommitTool(Tool):
91+
@property
92+
def pypi_name(self) -> str:
93+
return "pre-commit"
94+
95+
@property
96+
def pre_commit_name(self) -> str:
97+
raise NotImplementedError
98+
99+
def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
100+
raise NotImplementedError
101+
102+
103+
class DeptryTool(Tool):
104+
@property
105+
def pypi_name(self) -> str:
106+
return "deptry"
107+
108+
@property
109+
def pre_commit_name(self) -> str:
110+
return DEPTRY_PRE_COMMIT_NAME
111+
112+
def get_pre_commit_repo_config(self) -> PreCommitRepoConfig:
113+
return PreCommitRepoConfig(
114+
repo="local",
115+
hooks=[
116+
HookConfig(
117+
id=self.pre_commit_name,
118+
name=self.pre_commit_name,
119+
entry="uv run --frozen deptry src",
120+
language="system",
121+
always_run=True,
122+
pass_filenames=False,
123+
)
124+
],
125+
)
126+
127+
128+
ALL_TOOLS: list[Tool] = [PreCommitTool(), DeptryTool()]

0 commit comments

Comments
 (0)