├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── LICENSE ├── README.md ├── pre_commit_ci_config.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── pre_commit_ci_config_test.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py39", "py310", "py311", "py312"]' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v2.8.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.15.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py39-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v3.2.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.20.0 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py39-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.3.2 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.2.0 36 | hooks: 37 | - id: flake8 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.15.0 40 | hooks: 41 | - id: mypy 42 | additional_dependencies: [types-pyyaml] 43 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: check-pre-commit-ci-config 2 | name: check pre-commit.ci config 3 | description: validate pre-commit.ci configuration 4 | entry: check-pre-commit-ci-config 5 | language: python 6 | files: ^\.pre-commit-config.yaml$ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/pre-commit-ci/pre-commit-ci-config/actions/workflows/main.yml/badge.svg)](https://github.com/pre-commit-ci/pre-commit-ci-config/actions/workflows/main.yml) 2 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/pre-commit-ci/pre-commit-ci-config/main.svg)](https://results.pre-commit.ci/latest/github/pre-commit-ci/pre-commit-ci-config/main) 3 | 4 | pre-commit-ci-config 5 | ==================== 6 | 7 | validation for [pre-commit.ci](https://pre-commit.ci) configuration 8 | 9 | ## installation 10 | 11 | ```bash 12 | pip install pre-commit-ci-config 13 | ``` 14 | 15 | ## api 16 | 17 | ### `pre_commit_ci_config.SCHEMA` 18 | 19 | a [cfgv](https://github.com/asottile/cfgv) schema. 20 | 21 | the expected input to this schema is the loaded top-level pre-commit 22 | configuration. 23 | 24 | ```pycon 25 | >>> import cfgv 26 | >>> from pre_commit.clientlib import load_config 27 | >>> from pre_commit_ci_config import SCHEMA 28 | >>> cfg = load_config('.pre-commit-config.yaml') 29 | >>> cfg = cfgv.validate(cfg, SCHEMA) 30 | >>> cfg = cfgv.apply_defaults(cfg, SCHEMA) 31 | ``` 32 | 33 | ### `check-pre-commit-ci-config` 34 | 35 | a commandline tool to validate the configuration 36 | 37 | ```console 38 | $ check-pre-commit-ci-config .pre-commit-config.yaml 39 | $ 40 | ``` 41 | 42 | ## as a pre-commit hook 43 | 44 | See [pre-commit](https://github.com/pre-commit/pre-commit) for instructions 45 | 46 | Sample `.pre-commit-config.yaml`: 47 | 48 | ```yaml 49 | - repo: https://github.com/pre-commit-ci/pre-commit-ci-config 50 | rev: v1.6.1 51 | hooks: 52 | - id: check-pre-commit-ci-config 53 | ``` 54 | -------------------------------------------------------------------------------- /pre_commit_ci_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import functools 5 | from collections.abc import Sequence 6 | from typing import Any 7 | 8 | import cfgv 9 | import yaml 10 | 11 | Loader = getattr(yaml, 'CSafeLoader', yaml.SafeLoader) 12 | yaml_load = functools.partial(yaml.load, Loader=Loader) 13 | 14 | 15 | def _check_non_empty_string(val: str) -> None: 16 | if not val: 17 | raise cfgv.ValidationError('string cannot be empty') 18 | 19 | 20 | def _check_autoupdate_branch(val: str) -> None: 21 | if val == 'pre-commit-ci-update-config': 22 | raise cfgv.ValidationError(f'autoupdate branch cannot be {val!r}') 23 | 24 | 25 | class ValidateSkip: 26 | def check(self, dct: dict[str, Any]) -> None: 27 | all_ids = { 28 | hook_id 29 | for repo in dct['repos'] 30 | for hook in repo['hooks'] 31 | for hook_id in (hook['id'], hook.get('alias')) 32 | if hook_id is not None 33 | } 34 | unexpected_skip = set(dct.get('ci', {}).get('skip', ())) - all_ids 35 | if unexpected_skip: 36 | with cfgv.validate_context('At key: ci'): 37 | with cfgv.validate_context('At key: skip'): 38 | raise cfgv.ValidationError( 39 | f'unexpected hook ids: ' 40 | f'{", ".join(sorted(unexpected_skip))}', 41 | ) 42 | 43 | apply_default = cfgv.Required.apply_default 44 | remove_default = cfgv.Required.remove_default 45 | 46 | 47 | HOOK_DICT = cfgv.Map( 48 | 'Hook', 'id', 49 | 50 | cfgv.Required('id', cfgv.check_string), 51 | cfgv.OptionalNoDefault('alias', cfgv.check_string), 52 | ) 53 | 54 | CONFIG_REPO_DICT = cfgv.Map( 55 | 'Repository', 'repo', 56 | 57 | cfgv.Required('repo', cfgv.check_string), 58 | cfgv.RequiredRecurse('hooks', cfgv.Array(HOOK_DICT)), 59 | ) 60 | 61 | AUTOFIX_DEFAULT_COMMIT_MSG = '''\ 62 | [pre-commit.ci] auto fixes from pre-commit.com hooks 63 | 64 | for more information, see https://pre-commit.ci 65 | ''' 66 | 67 | CI_DICT = cfgv.Map( 68 | 'CI', None, 69 | 70 | cfgv.Optional( 71 | 'autofix_commit_msg', 72 | cfgv.check_and(cfgv.check_string, _check_non_empty_string), 73 | AUTOFIX_DEFAULT_COMMIT_MSG, 74 | ), 75 | cfgv.Optional('autofix_prs', cfgv.check_bool, True), 76 | cfgv.Optional( 77 | 'autoupdate_branch', 78 | cfgv.check_and(cfgv.check_string, _check_autoupdate_branch), 79 | '', 80 | ), 81 | cfgv.Optional( 82 | 'autoupdate_commit_msg', 83 | cfgv.check_and(cfgv.check_string, _check_non_empty_string), 84 | '[pre-commit.ci] pre-commit autoupdate', 85 | ), 86 | cfgv.Optional( 87 | 'autoupdate_schedule', 88 | cfgv.check_one_of(('weekly', 'monthly', 'quarterly')), 89 | 'weekly', 90 | ), 91 | cfgv.Optional('skip', cfgv.check_array(cfgv.check_string), []), 92 | cfgv.Optional('submodules', cfgv.check_bool, False), 93 | ) 94 | 95 | SCHEMA = cfgv.Map( 96 | 'Config', None, 97 | 98 | # to cross-validate hook values 99 | cfgv.RequiredRecurse('repos', cfgv.Array(CONFIG_REPO_DICT)), 100 | 101 | # our configuration 102 | cfgv.OptionalRecurse('ci', CI_DICT, {}), 103 | ValidateSkip(), 104 | ) 105 | 106 | 107 | def main(argv: Sequence[str] | None = None) -> int: 108 | parser = argparse.ArgumentParser() 109 | parser.add_argument('filenames', nargs='*') 110 | args = parser.parse_args(argv) 111 | 112 | retv = 0 113 | for filename in args.filenames: 114 | try: 115 | cfgv.load_from_filename( 116 | filename, 117 | schema=SCHEMA, 118 | load_strategy=yaml_load, 119 | ) 120 | except cfgv.ValidationError as e: 121 | print(str(e).strip()) 122 | retv = 1 123 | return retv 124 | 125 | 126 | if __name__ == '__main__': 127 | raise SystemExit(main()) 128 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pre_commit_ci_config 3 | version = 1.6.1 4 | description = validation for pre-commit.ci configuration 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/pre-commit-ci/pre-commit-ci-config 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: Implementation :: CPython 16 | Programming Language :: Python :: Implementation :: PyPy 17 | 18 | [options] 19 | py_modules = pre_commit_ci_config 20 | install_requires = 21 | cfgv>=2.0.0 22 | pyyaml>=5.1 23 | python_requires = >=3.9 24 | 25 | [options.entry_points] 26 | console_scripts = 27 | check-pre-commit-ci-config = pre_commit_ci_config:main 28 | 29 | [bdist_wheel] 30 | universal = True 31 | 32 | [coverage:run] 33 | plugins = covdefaults 34 | 35 | [mypy] 36 | check_untyped_defs = true 37 | disallow_any_generics = true 38 | disallow_incomplete_defs = true 39 | disallow_untyped_defs = true 40 | warn_redundant_casts = true 41 | warn_unused_ignores = true 42 | 43 | [mypy-testing.*] 44 | disallow_untyped_defs = false 45 | 46 | [mypy-tests.*] 47 | disallow_untyped_defs = false 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pre-commit-ci/pre-commit-ci-config/66a1f8084eed1f92aa57a7ab91b4f6ee663ee80c/tests/__init__.py -------------------------------------------------------------------------------- /tests/pre_commit_ci_config_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import cfgv 4 | import pytest 5 | 6 | from pre_commit_ci_config import main 7 | from pre_commit_ci_config import SCHEMA 8 | 9 | 10 | def _error_to_trace(e): 11 | ret = [] 12 | while isinstance(e.error_msg, cfgv.ValidationError): 13 | ret.append(e.ctx) 14 | e = e.error_msg 15 | ret.append(e.error_msg) 16 | return tuple(ret) 17 | 18 | 19 | def test_apply_defaults(): 20 | ret = cfgv.apply_defaults({'repos': []}, SCHEMA) 21 | assert ret == { 22 | 'ci': { 23 | 'autofix_commit_msg': ( 24 | '[pre-commit.ci] auto fixes from pre-commit.com hooks\n\n' 25 | 'for more information, see https://pre-commit.ci\n' 26 | ), 27 | 'autofix_prs': True, 28 | 'autoupdate_branch': '', 29 | 'autoupdate_commit_msg': '[pre-commit.ci] pre-commit autoupdate', 30 | 'autoupdate_schedule': 'weekly', 31 | 'skip': [], 32 | 'submodules': False, 33 | }, 34 | 'repos': [], 35 | } 36 | 37 | 38 | def test_autoupdate_branch_ok(): 39 | cfg = {'ci': {'autoupdate_branch': 'dev'}, 'repos': []} 40 | cfgv.validate(cfg, SCHEMA) 41 | 42 | 43 | def test_autoupdate_branch_does_not_allow_our_branch_name(): 44 | cfg = { 45 | 'ci': {'autoupdate_branch': 'pre-commit-ci-update-config'}, 46 | 'repos': [], 47 | } 48 | with pytest.raises(cfgv.ValidationError) as excinfo: 49 | cfgv.validate(cfg, SCHEMA) 50 | assert _error_to_trace(excinfo.value) == ( 51 | 'At Config()', 52 | 'At key: ci', 53 | 'At CI()', 54 | 'At key: autoupdate_branch', 55 | "autoupdate branch cannot be 'pre-commit-ci-update-config'", 56 | ) 57 | 58 | 59 | def test_autoupdate_commit_msg_cannot_be_empty(): 60 | cfg = {'ci': {'autoupdate_commit_msg': ''}, 'repos': []} 61 | with pytest.raises(cfgv.ValidationError) as excinfo: 62 | cfgv.validate(cfg, SCHEMA) 63 | assert _error_to_trace(excinfo.value) == ( 64 | 'At Config()', 65 | 'At key: ci', 66 | 'At CI()', 67 | 'At key: autoupdate_commit_msg', 68 | 'string cannot be empty', 69 | ) 70 | 71 | 72 | def test_autoupdate_commit_msg_ok_if_provided(): 73 | cfg = {'ci': {'autoupdate_commit_msg': 'autoupdate'}, 'repos': []} 74 | cfgv.validate(cfg, SCHEMA) 75 | 76 | 77 | def test_skip_references_hook(): 78 | cfg = { 79 | 'ci': {'skip': ['identity']}, 80 | 'repos': [{'repo': 'meta', 'hooks': [{'id': 'identity'}]}], 81 | } 82 | cfgv.validate(cfg, SCHEMA) 83 | 84 | 85 | def test_skip_referencing_missing_hook(): 86 | cfg = {'ci': {'skip': ['identity']}, 'repos': []} 87 | with pytest.raises(cfgv.ValidationError) as excinfo: 88 | cfgv.validate(cfg, SCHEMA) 89 | assert _error_to_trace(excinfo.value) == ( 90 | 'At Config()', 91 | 'At key: ci', 92 | 'At key: skip', 93 | 'unexpected hook ids: identity', 94 | ) 95 | 96 | 97 | def test_skip_references_hook_with_alias(): 98 | cfg = { 99 | 'ci': {'skip': ['alternate-identity']}, 100 | 'repos': [ 101 | { 102 | 'repo': 'meta', 103 | 'hooks': [ 104 | { 105 | 'id': 'identity', 106 | 'alias': 'alternate-identity', 107 | }, 108 | ], 109 | }, 110 | ], 111 | } 112 | cfgv.validate(cfg, SCHEMA) 113 | 114 | 115 | def test_main_ok(tmpdir): 116 | cfg_s = '''\ 117 | ci: 118 | skip: [identity] 119 | repos: 120 | - repo: meta 121 | hooks: 122 | - id: identity 123 | ''' 124 | cfg = tmpdir.join('cfg.yaml') 125 | cfg.write(cfg_s) 126 | 127 | assert not main((str(cfg),)) 128 | 129 | 130 | def test_main_failing(tmpdir, capsys): 131 | cfg_s = '''\ 132 | ci: 133 | skip: [identity] 134 | repos: [] 135 | ''' 136 | cfg = tmpdir.join('cfg.yaml') 137 | cfg.write(cfg_s) 138 | 139 | assert main((str(cfg),)) 140 | 141 | out, _ = capsys.readouterr() 142 | 143 | # TODO: cfgv produces trailing whitespace sometimes 144 | assert ' \n' in out 145 | out = out.replace(' \n', '\n') 146 | 147 | assert out == f'''\ 148 | =====> 149 | ==> File {cfg} 150 | ==> At Config() 151 | ==> At key: ci 152 | ==> At key: skip 153 | =====> unexpected hook ids: identity 154 | ''' 155 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | --------------------------------------------------------------------------------