#!/usr/bin/env python
#
# pre_commit.py
"""
Configuration for `pre-commit <https://pre-commit.com>`_.
"""
#
# Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
# stdlib
import functools
import pathlib
import posixpath
import re
from io import StringIO
from textwrap import indent
from typing import Iterable, List, MutableMapping, Union
# 3rd party
import attr
from apeye.url import URL
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import StringList
from ruamel.yaml import YAML
from typing_extensions import Literal, TypedDict
# this package
from repo_helper.files import management
from repo_helper.templates import Environment
__all__ = ["GITHUB_COM", "make_github_url", "Hook", "Repo", "make_pre_commit"]
#: Instance of :class:`apeye.url.URL` that points to the GitHub website.
GITHUB_COM: URL = URL("https://github.com")
yaml_safe_loader = YAML(typ="safe", pure=True)
[docs]@functools.lru_cache()
def make_github_url(username: str, repository: str) -> URL:
"""
Construct a URL to a GitHub repository from a username and repository name.
:param username: The username of the GitHub account that owns the repository.
:param repository: The name of the repository.
"""
return GITHUB_COM / username / repository
class _BaseHook(TypedDict):
#: Which hook from the repository to use.
id: str # noqa: A003 # pylint: disable=redefined-builtin
[docs]class Hook(_BaseHook, total=False):
"""
Represents a pre-commit hook.
"""
#: Allows the hook to be referenced using an additional id when using pre-commit run <hookid>.
alias: str
#: Override the name of the hook - shown during hook execution.
name: str
#: Override the language version for the hook. See https://pre-commit.com/#overriding-language-version
language_version: str
#: Override the default pattern for files to run on.
files: str
#: File exclude pattern.
exclude: str
#: Override the default file types to run on. See https://pre-commit.com/#filtering-files-with-types.
types: List[str]
#: File types to exclude.
exclude_types: List[str]
#: List of additional parameters to pass to the hook.
args: List[str]
stages: List[Literal["commit", "merge-commit", "push", "prepare-commit-msg", "commit-msg", "manual"]]
"""
Confines the hook to the commit, merge-commit, push, prepare-commit-msg, commit-msg,
post-checkout, or manual stage.
See https://pre-commit.com/#confining-hooks-to-run-at-certain-stages.
"""
additional_dependencies: List[str]
"""
A list of dependencies that will be installed in the environment where this hook gets run.
One useful application is to install plugins for hooks such as eslint."""
#: If :py:obj:`True`, this hook will run even if there are no matching files.
always_run: bool
#: If :py:obj:`True`, forces the output of the hook to be printed even when the hook passes.
verbose: bool
#: If present, the hook output will additionally be written to a file.
log_file: str
def _hook_converter(hooks: Iterable[Union[str, Hook]]) -> List[Hook]:
return [hook if isinstance(hook, dict) else {"id": hook} for hook in hooks]
[docs]@attr.s
class Repo:
"""
Represents a repository providing a pre-commit hooks.
"""
#: The repository url to git clone from.
repo: URL = attr.ib(converter=URL)
#: The revision or tag to clone at.
rev: str = attr.ib(converter=str)
hooks: List[Hook] = attr.ib(converter=_hook_converter)
[docs] def to_dict(self) -> MutableMapping[str, Union[str, List[Hook]]]:
"""
Returns a dictionary representation of the :class:`~.Repo`.
"""
return {
"repo": str(self.repo),
"rev": self.rev,
"hooks": self.hooks,
}
[docs] def replace_hooks(self: "Repo", hooks: Iterable[Union[str, Hook]]) -> "Repo":
"""
Return a new instance of this repo with the new hooks provided.
:param hooks:
"""
return Repo(self.repo, self.rev, hooks)
pre_commit_hooks = Repo(
repo=make_github_url("pre-commit", "pre-commit-hooks"),
rev="v3.4.0",
hooks=[
"check-added-large-files",
"check-ast",
"fix-byte-order-marker",
"check-byte-order-marker",
"check-case-conflict",
"check-executables-have-shebangs",
"check-json",
"check-toml",
"check-yaml",
"check-merge-conflict",
"check-symlinks",
"check-vcs-permalinks",
"detect-private-key",
"trailing-whitespace",
"mixed-line-ending",
"end-of-file-fixer",
]
)
pygrep_hooks = Repo(
repo=make_github_url("pre-commit", "pygrep-hooks"),
rev="v1.10.0",
hooks=[
"python-no-eval",
"rst-backticks",
"rst-directive-colons",
"rst-inline-touching-normal", # TODO: "python-check-blanket-type-ignore",
],
)
pyupgrade = Repo(
repo=make_github_url("asottile", "pyupgrade"),
rev="v2.12.0",
hooks=[{"id": "pyupgrade", "args": ["--py36-plus", "--keep-runtime-typing"]}]
)
lucas_c_hooks = Repo(
repo=make_github_url("Lucas-C", "pre-commit-hooks"),
rev="v1.5.1",
hooks=["remove-crlf", "forbid-crlf"],
)
flake2lint = Repo(
repo=make_github_url("domdfcoding", "flake2lint"),
rev="v0.4.3",
hooks=["flake2lint"],
)
pyproject_parser = Repo(
repo=make_github_url("repo-helper", "pyproject-parser"),
rev="v0.11.1",
hooks=["reformat-pyproject"], # TODO: add check-pyproject
)
snippet_fmt = Repo(
repo=make_github_url("python-formate", "snippet-fmt"),
rev="v0.1.5",
hooks=["snippet-fmt"],
)
domdfcoding_hooks = Repo(
repo=make_github_url("domdfcoding", "pre-commit-hooks"),
rev="v0.4.0",
hooks=[
{"id": "requirements-txt-sorter", "args": ["--allow-git"]},
{"id": "check-docstring-first", "exclude": fr"^(doc-source/conf|__pkginfo__|setup|tests/.*)\.py$"},
"bind-requirements",
]
)
formate = Repo(
repo=make_github_url("python-formate", "formate"),
rev="v0.7.0",
hooks=[{"id": "formate", "exclude": r"^(doc-source/conf|__pkginfo__|setup)\.(_)?py$"}],
)
[docs]@management.register("pre-commit", ["enable_pre_commit"])
def make_pre_commit(repo_path: pathlib.Path, templates: Environment) -> List[str]:
"""
Add configuration for ``pre-commit``.
https://github.com/pre-commit/pre-commit
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
:param repo_path: Path to the repository root.
:param templates:
"""
docs_dir = templates.globals["docs_dir"]
import_name = templates.globals["import_name"]
stubs_package = templates.globals["stubs_package"]
non_source_files = [posixpath.join(docs_dir, "conf"), "__pkginfo__", "setup"]
domdfcoding_hooks_custom = domdfcoding_hooks.replace_hooks(
hooks=[
{"id": "requirements-txt-sorter", "args": ["--allow-git"]},
{
"id": "check-docstring-first",
"exclude": fr"^({'|'.join(non_source_files)}|{templates.globals['tests_dir']}/.*)\.py$"
},
"bind-requirements",
]
)
flake8_dunder_all = Repo(
repo=make_github_url("python-formate", "flake8-dunder-all"),
rev="v0.4.1",
hooks=[{
"id": "ensure-dunder-all",
"files": fr"^{import_name}{'-stubs' if stubs_package else ''}/.*\.py$"
}]
)
formate_excludes = fr"^({'|'.join([*templates.globals['yapf_exclude'], *non_source_files])})\.(_)?py$"
formate_custom = formate.replace_hooks(hooks=[{"id": "formate", "exclude": formate_excludes}])
dep_checker_args = [templates.globals["import_name"].replace('.', '/')]
if templates.globals["source_dir"]:
dep_checker_args.extend((
"--work-dir",
templates.globals["source_dir"],
"--req-file",
"../requirements.txt",
))
dep_checker = Repo(
repo=make_github_url("python-coincidence", "dep_checker"),
rev="v0.8.0",
hooks=[{"id": "dep_checker", "args": dep_checker_args}]
)
pre_commit_file = PathPlus(repo_path / ".pre-commit-config.yaml")
if not pre_commit_file.is_file():
pre_commit_file.touch()
dumper = YAML()
dumper.indent(mapping=2, sequence=3, offset=1)
output = StringList([
f"# {templates.globals['managed_message']}",
"---",
'',
f"exclude: {templates.globals['pre_commit_exclude']}",
'',
"ci:",
" autoupdate_schedule: quarterly",
'',
"repos:",
])
indent_re = re.compile("^ {3}")
managed_hooks = [
pyproject_parser,
pre_commit_hooks,
domdfcoding_hooks_custom,
flake8_dunder_all,
flake2lint,
pygrep_hooks,
pyupgrade,
lucas_c_hooks,
snippet_fmt,
formate_custom,
]
if not templates.globals["stubs_package"]:
managed_hooks.append(dep_checker)
managed_hooks_urls = [str(hook.repo) for hook in managed_hooks]
custom_hooks_comment = "# Custom hooks can be added below this comment"
for hook in managed_hooks:
buf = StringIO()
dumper.dump(hook.to_dict(), buf)
output.append(indent_re.sub(" - ", indent(buf.getvalue(), " ")))
output.blankline(ensure_single=True)
output.append(custom_hooks_comment)
output.blankline(ensure_single=True)
raw_yaml = pre_commit_file.read_text()
if custom_hooks_comment in raw_yaml:
custom_hooks_yaml = pre_commit_file.read_text().split(custom_hooks_comment)[1]
custom_hooks = []
local_hooks = []
for repo in yaml_safe_loader.load(custom_hooks_yaml) or []:
if repo["repo"] == "local":
local_hooks.append(repo)
elif repo["repo"] not in managed_hooks_urls:
custom_hooks.append(Repo(**repo))
for hook in custom_hooks:
buf = StringIO()
dumper.dump(hook.to_dict(), buf)
output.append(indent_re.sub(" - ", indent(buf.getvalue(), " ")))
output.blankline(ensure_single=True)
for hook in local_hooks:
buf = StringIO()
dumper.dump(hook, buf)
output.append(indent_re.sub(" - ", indent(buf.getvalue(), " ")))
output.blankline(ensure_single=True)
pre_commit_file.write_lines(output)
return [pre_commit_file.name]