|
| 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 | + ) |
0 commit comments