├── tests ├── __init__.py ├── resources │ ├── init_without_license.py │ ├── module_without_license.py │ ├── module_without_license.txt │ ├── module_without_license.jinja │ ├── module_without_license.php │ ├── main_without_license.cpp │ ├── module_without_license_skip.py │ ├── module_without_license.css │ ├── init_with_license.py │ ├── module_without_license_skip.jinja │ ├── main_iso8859_with_license.cpp │ ├── module_without_license_and_few_words.css │ ├── LICENSE_2_without_trailing_newline.txt │ ├── LICENSE_without_trailing_newline.txt │ ├── main_iso8859_without_license.cpp │ ├── module_without_license_skip.css │ ├── LICENSE_with_trailing_newline.txt │ ├── module_without_license_and_shebang.py │ ├── init_with_license_and_newline.py │ ├── LICENSE_with_year_range_and_trailing_newline.txt │ ├── module_without_license_and_shebang_skip.py │ ├── module_with_license.py │ ├── module_with_license_2.py │ ├── module_with_license_noeol.py │ ├── module_with_license_noprefix.txt │ ├── module_with_license_nospace.py │ ├── LICENSE_with_multiple_year_ranges.txt │ ├── module_with_stale_year_in_license.py │ ├── module_with_year_range_in_license.py │ ├── module_with_license_and_numbers.py │ ├── module_with_stale_year_range_in_license.py │ ├── module_with_spaced_year_range_in_license.py │ ├── module_with_license.jinja │ ├── module_with_fuzzy_matched_license.py │ ├── module_with_badly_formatted_stale_year_range_in_license.py │ ├── module_with_license.php │ ├── module_without_license.groovy │ ├── main_with_license.cpp │ ├── module_with_fuzzy_matched_license.jinja │ ├── module_with_license.css │ ├── module_with_multiple_years_in_license.py │ ├── module_with_multiple_stale_years_in_license.py │ ├── module_without_license_skip.groovy │ ├── module_with_license_and_shebang.py │ ├── module_with_fuzzy_matched_license.css │ ├── module_with_license_and_few_words.css │ ├── module_with_fuzzy_matched_license_and_shebang.py │ ├── module_with_license.groovy │ ├── module_with_year_range_license.groovy │ ├── module_with_fuzzy_matched_license.groovy │ ├── module_with_license_todo.py │ ├── module_with_license_todo.jinja │ ├── module_with_license_todo.css │ ├── module_with_license_and_shebang_todo.py │ └── module_with_license_todo.groovy ├── utils.py ├── chmod_test.py ├── remove_crlf_test.py ├── remove_tabs_test.py └── insert_license_test.py ├── pre_commit_hooks ├── __init__.py ├── forbid_tabs.py ├── forbid_crlf.py ├── remove_crlf.py ├── remove_tabs.py ├── chmod.py └── insert_license.py ├── .gitattributes ├── pytest.ini ├── .gitignore ├── requirements-dev.txt ├── .pylintrc ├── LICENSE ├── setup.py ├── .pre-commit-hooks.yaml ├── .github └── workflows │ └── ci.yml ├── .pre-commit-config.yaml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pre_commit_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /tests/resources/init_without_license.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov=pre_commit_hooks --cov-report term-missing 3 | -------------------------------------------------------------------------------- /tests/resources/module_without_license.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.stdout.write("FOO") 3 | -------------------------------------------------------------------------------- /tests/resources/module_without_license.txt: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.stdout.write("FOO") 3 | -------------------------------------------------------------------------------- /tests/resources/module_without_license.jinja: -------------------------------------------------------------------------------- 1 | 2 | {value} 3 | 4 | -------------------------------------------------------------------------------- /tests/resources/module_without_license.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | {value} 6 | 7 | -------------------------------------------------------------------------------- /tests/resources/main_iso8859_with_license.cpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucas-C/pre-commit-hooks/HEAD/tests/resources/main_iso8859_with_license.cpp -------------------------------------------------------------------------------- /tests/resources/module_without_license_and_few_words.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Goto class (Apache License) 3 | */ 4 | .dumb { 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /tests/resources/LICENSE_2_without_trailing_newline.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2023 John Smith 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); -------------------------------------------------------------------------------- /tests/resources/LICENSE_without_trailing_newline.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 Teela O'Malley 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); -------------------------------------------------------------------------------- /tests/resources/main_iso8859_without_license.cpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucas-C/pre-commit-hooks/HEAD/tests/resources/main_iso8859_without_license.cpp -------------------------------------------------------------------------------- /tests/resources/module_without_license_skip.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Goto class 3 | * SKIP LICENSE INSERTION 4 | */ 5 | .dumb { 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /tests/resources/LICENSE_with_trailing_newline.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 Teela O'Malley 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | -------------------------------------------------------------------------------- /tests/resources/module_without_license_and_shebang.py: -------------------------------------------------------------------------------- 1 | #!/bin/usr/env python 2 | 3 | # -*- coding: utf-8 -*- 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/init_with_license_and_newline.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Install dependencies from setup.py 2 | -e . 3 | # Install development specific dependencies 4 | pre-commit 5 | pylint 6 | pytest 7 | pytest-cov 8 | coverage 9 | -------------------------------------------------------------------------------- /tests/resources/LICENSE_with_year_range_and_trailing_newline.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2016-2017 Teela O'Malley 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | -------------------------------------------------------------------------------- /tests/resources/module_without_license_and_shebang_skip.py: -------------------------------------------------------------------------------- 1 | #!/bin/usr/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # SKIP LICENSE INSERTION 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_2.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 John Smith 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_noeol.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | import sys 5 | sys.stdout.write("FOO") 6 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_noprefix.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 Teela O'Malley 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_nospace.py: -------------------------------------------------------------------------------- 1 | #Copyright (C) 2017 Teela O'Malley 2 | # 3 | #Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/LICENSE_with_multiple_year_ranges.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2016-2017 Teela O'Malley 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | @copyright 2012-2015 Teela O'Malley -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = duplicate-code, fixme, missing-docstring, multiple-imports, too-many-arguments, too-many-locals, too-many-positional-arguments 3 | 4 | [FORMAT] 5 | max-line-length = 150 6 | -------------------------------------------------------------------------------- /tests/resources/module_with_stale_year_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_year_range_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015-2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_and_numbers.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2016-2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("1985") # 1985 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_stale_year_range_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015-2016 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_spaced_year_range_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 - 2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license.jinja: -------------------------------------------------------------------------------- 1 | {# 2 | Copyright (C) 2017 Teela O'Malley 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | #} 6 | 7 | 8 | {value} 9 | 10 | -------------------------------------------------------------------------------- /tests/resources/module_with_fuzzy_matched_license.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Copyright (C) 2017 Teela O'Malley 4 | # 5 | # Licensed under the Apache License, 6 | # Version 2.0 (the "License"); 7 | 8 | import sys 9 | sys.stdout.write("FOO") 10 | -------------------------------------------------------------------------------- /tests/resources/module_with_badly_formatted_stale_year_range_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 -- 16 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import sys 6 | sys.stdout.write("FOO") 7 | -------------------------------------------------------------------------------- /tests/resources/module_with_license.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | {value} 9 | 10 | -------------------------------------------------------------------------------- /tests/resources/module_with_license.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 Teela O'Malley 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | */ 6 | 7 | /* 8 | * Goto class 9 | */ 10 | .dumb { 11 | text-align: center; 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/module_with_multiple_years_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012-2017 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # 5 | # @copyright 2012-2017 Teela O'Malley 6 | 7 | import sys 8 | sys.stdout.write("FOO") 9 | -------------------------------------------------------------------------------- /tests/resources/module_with_multiple_stale_years_in_license.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012-2015 Teela O'Malley 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # 5 | # @copyright 2012-2015 Teela O'Malley 6 | 7 | import sys 8 | sys.stdout.write("FOO") 9 | -------------------------------------------------------------------------------- /tests/resources/module_without_license_skip.groovy: -------------------------------------------------------------------------------- 1 | // SKIP LICENSE INSERTION 2 | import static groovy.json.JsonOutput.* 3 | import groovy.json.JsonSlurperClassic 4 | import com.cloudbees.groovy.cps.NonCPS 5 | 6 | def toto(Map args) { 7 | echo prettyPrint(toJson(args)) 8 | } 9 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_and_shebang.py: -------------------------------------------------------------------------------- 1 | #!/bin/usr/env python 2 | 3 | # -*- coding: utf-8 -*- 4 | 5 | # Copyright (C) 2017 Teela O'Malley 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | 9 | import sys 10 | sys.stdout.write("FOO") 11 | -------------------------------------------------------------------------------- /tests/resources/module_with_fuzzy_matched_license.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 Teela O'Malley 3 | * 4 | * Licensed 5 | * under the Apache License, wih Version 2.1 (the "License"); 6 | */ 7 | 8 | /* 9 | * Goto class 10 | */ 11 | .dumb { 12 | text-align: center; 13 | } 14 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_and_few_words.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2017 Teela O'Malley 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | */ 6 | 7 | /* 8 | * Goto class (Apache License) 9 | */ 10 | .dumb { 11 | text-align: center; 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/module_with_fuzzy_matched_license_and_shebang.py: -------------------------------------------------------------------------------- 1 | #!/bin/usr/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (C) 2017 Teela O'Malley 5 | # 6 | # Licensed under the Apache License, 7 | # Version 2.1 (the "License"); 8 | 9 | import sys 10 | sys.stdout.write("FOO") 11 | -------------------------------------------------------------------------------- /tests/resources/module_with_license.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Teela O'Malley 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import static groovy.json.JsonOutput.* 6 | import groovy.json.JsonSlurperClassic 7 | import com.cloudbees.groovy.cps.NonCPS 8 | 9 | def toto(Map args) { 10 | echo prettyPrint(toJson(args)) 11 | } 12 | -------------------------------------------------------------------------------- /tests/resources/module_with_year_range_license.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Teela O'Malley 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | 5 | import static groovy.json.JsonOutput.* 6 | import groovy.json.JsonSlurperClassic 7 | import com.cloudbees.groovy.cps.NonCPS 8 | 9 | def toto(Map args) { 10 | echo prettyPrint(toJson(args)) 11 | } 12 | -------------------------------------------------------------------------------- /tests/resources/module_with_fuzzy_matched_license.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 2 | // Teela O'Malley 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the 'License'); 5 | 6 | import static groovy.json.JsonOutput.* 7 | import groovy.json.JsonSlurperClassic 8 | import com.cloudbees.groovy.cps.NonCPS 9 | 10 | def toto(Map args) { 11 | echo prettyPrint(toJson(args)) 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_todo.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # TODO: This license is not consistent with the license used in the project. 4 | # Delete the inconsistent license and above line and rerun pre-commit to insert a good license. 5 | # Copyright (C) 2017 Teela O'Malley 6 | # 7 | # Licensed under the Apache License, 8 | # Version 2.0 (the "License"); 9 | 10 | import sys 11 | sys.stdout.write("FOO") 12 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_todo.jinja: -------------------------------------------------------------------------------- 1 | {# 2 | TODO: This license is not consistent with the license used in the project. 3 | Delete the inconsistent license and above line and rerun pre-commit to insert a good license. 4 | Copyright (C) 2017 Teela O'Malley 5 | 6 | Licensed under the Apache License, Version 2.1 (the 'License'); 7 | #} 8 | 9 | 10 | {value} 11 | 12 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_todo.css: -------------------------------------------------------------------------------- 1 | /* 2 | * TODO: This license is not consistent with the license used in the project. 3 | * Delete the inconsistent license and above line and rerun pre-commit to insert a good license. 4 | * Copyright (C) 2017 Teela O'Malley 5 | * 6 | * Licensed 7 | * under the Apache License, wih Version 2.1 (the "License"); 8 | */ 9 | 10 | /* 11 | * Goto class 12 | */ 13 | .dumb { 14 | text-align: center; 15 | } 16 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_and_shebang_todo.py: -------------------------------------------------------------------------------- 1 | #!/bin/usr/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # TODO: This license is not consistent with the license used in the project. 5 | # Delete the inconsistent license and above line and rerun pre-commit to insert a good license. 6 | # Copyright (C) 2017 Teela O'Malley 7 | # 8 | # Licensed under the Apache License, 9 | # Version 2.1 (the "License"); 10 | 11 | import sys 12 | sys.stdout.write("FOO") 13 | -------------------------------------------------------------------------------- /tests/resources/module_with_license_todo.groovy: -------------------------------------------------------------------------------- 1 | // TODO: This license is not consistent with the license used in the project. 2 | // Delete the inconsistent license and above line and rerun pre-commit to insert a good license. 3 | // Copyright (C) 2017 4 | // Teela O'Malley 5 | // 6 | // Licensed under the Apache License, Version 2.0 (the 'License'); 7 | 8 | import static groovy.json.JsonOutput.* 9 | import groovy.json.JsonSlurperClassic 10 | import com.cloudbees.groovy.cps.NonCPS 11 | 12 | def toto(Map args) { 13 | echo prettyPrint(toJson(args)) 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import io 3 | import os 4 | import sys 5 | 6 | 7 | @contextmanager 8 | def chdir_to_test_resources(): 9 | prev_dir = os.getcwd() 10 | try: 11 | res_dir = os.path.dirname(os.path.realpath(__file__)) + "/resources" 12 | os.chdir(res_dir) 13 | yield 14 | finally: 15 | os.chdir(prev_dir) 16 | 17 | 18 | @contextmanager 19 | def capture_stdout(): 20 | try: 21 | captured = io.StringIO() 22 | sys.stdout = captured 23 | yield captured 24 | finally: 25 | sys.stdout = sys.__stdout__ 26 | -------------------------------------------------------------------------------- /pre_commit_hooks/forbid_tabs.py: -------------------------------------------------------------------------------- 1 | import argparse, sys 2 | 3 | 4 | def contains_tabs(filename): 5 | with open(filename, mode="rb") as file_checked: 6 | return b"\t" in file_checked.read() 7 | 8 | 9 | def main(argv=None): 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument("filenames", nargs="*", help="filenames to check") 12 | args = parser.parse_args(argv) 13 | files_with_tabs = [f for f in args.filenames if contains_tabs(f)] 14 | return_code = 0 15 | for file_with_tabs in files_with_tabs: 16 | print(f"Tabs detected in file: {file_with_tabs}") 17 | return_code = 1 18 | return return_code 19 | 20 | 21 | if __name__ == "__main__": 22 | sys.exit(main(sys.argv[1:])) # pragma: no cover 23 | -------------------------------------------------------------------------------- /tests/chmod_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pre_commit_hooks.chmod import main as chmod 3 | 4 | from .utils import chdir_to_test_resources, capture_stdout 5 | 6 | 7 | def test_chmod_ok(): 8 | with chdir_to_test_resources(): 9 | if sys.platform == "win32": 10 | with capture_stdout() as stdout: 11 | assert chmod(["755", "module_with_license.py"]) == 0 12 | assert ( 13 | "This hook does nothing when executed on Windows" in stdout.getvalue() 14 | ) 15 | else: 16 | assert chmod(["755", "module_with_license.py"]) == 1 17 | assert chmod(["644", "module_with_license.py"]) == 1 18 | assert chmod(["644", "module_with_license.py"]) == 0 19 | 20 | 21 | def test_invalid_perms(): 22 | assert chmod(["668", __file__]) == 2 23 | -------------------------------------------------------------------------------- /pre_commit_hooks/forbid_crlf.py: -------------------------------------------------------------------------------- 1 | import argparse, sys 2 | 3 | 4 | def contains_crlf(filename): 5 | with open(filename, mode="rb") as file_checked: 6 | for line in file_checked.readlines(): 7 | if line.endswith(b"\r\n"): 8 | return True 9 | return False 10 | 11 | 12 | def main(argv=None): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("filenames", nargs="*", help="filenames to check") 15 | args = parser.parse_args(argv) 16 | files_with_crlf = [f for f in args.filenames if contains_crlf(f)] 17 | return_code = 0 18 | for file_with_crlf in files_with_crlf: 19 | print(f"CRLF end-lines detected in file: {file_with_crlf}") 20 | return_code = 1 21 | return return_code 22 | 23 | 24 | if __name__ == "__main__": 25 | sys.exit(main(sys.argv[1:])) # pragma: no cover 26 | -------------------------------------------------------------------------------- /tests/remove_crlf_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from pre_commit_hooks.remove_crlf import main as remove_crlf 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("input_s", "expected"), 10 | ( 11 | ("foo\r\nbar", "foo\nbar"), 12 | ("bar\nbaz\r\n", "bar\nbaz\n"), 13 | ), 14 | ) 15 | def test_remove_crlf(input_s, expected, tmpdir): 16 | input_file = Path(tmpdir.join("file.txt")) 17 | input_file.write_bytes(bytes(input_s, "UTF-8")) 18 | assert remove_crlf([str(input_file)]) == 1 19 | assert input_file.read_bytes() == bytes(expected, "UTF-8") 20 | 21 | 22 | @pytest.mark.parametrize(("arg"), ("", "a.b", "a/b")) 23 | def test_badopt(arg): 24 | with pytest.raises( 25 | ( 26 | FileNotFoundError, 27 | NotADirectoryError, 28 | ) 29 | ): 30 | remove_crlf([arg]) 31 | 32 | 33 | def test_nothing_to_fix(): 34 | assert remove_crlf([__file__]) == 0 35 | assert remove_crlf(["--"]) == 0 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lucas Cimon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | setup( 5 | name="pre-commit-hooks", 6 | description="Some out-of-the-box hooks for pre-commit", 7 | url="https://github.com/Lucas-C/pre-commit-hooks", 8 | version="1.5.5", 9 | author="Lucas Cimon", 10 | author_email="lucas.cimon@gmail.com", 11 | platforms="linux", 12 | classifiers=[ 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3.7", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: Implementation :: CPython", 20 | "Programming Language :: Python :: Implementation :: PyPy", 21 | ], 22 | packages=find_packages("."), 23 | install_requires=[ 24 | "rapidfuzz", 25 | ], 26 | entry_points={ 27 | "console_scripts": [ 28 | "pre_commit_chmod = pre_commit_hooks.chmod:main", 29 | "forbid_crlf = pre_commit_hooks.forbid_crlf:main", 30 | "forbid_tabs = pre_commit_hooks.forbid_tabs:main", 31 | "insert_license = pre_commit_hooks.insert_license:main", 32 | "remove_crlf = pre_commit_hooks.remove_crlf:main", 33 | "remove_tabs = pre_commit_hooks.remove_tabs:main", 34 | ], 35 | }, 36 | ) 37 | -------------------------------------------------------------------------------- /pre_commit_hooks/remove_crlf.py: -------------------------------------------------------------------------------- 1 | import argparse, sys 2 | 3 | 4 | def contains_crlf(filename): 5 | with open(filename, mode="rb") as file_checked: 6 | for line in file_checked.readlines(): 7 | if line.endswith(b"\r\n"): 8 | return True 9 | return False 10 | 11 | 12 | def removes_crlf_in_file(filename): 13 | with open(filename, mode="rb") as file_processed: 14 | lines = file_processed.readlines() 15 | lines = [line.replace(b"\r\n", b"\n") for line in lines] 16 | with open(filename, mode="wb") as file_processed: 17 | for line in lines: 18 | file_processed.write(line) 19 | 20 | 21 | def main(argv=None): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument("filenames", nargs="*", help="filenames to check") 24 | args = parser.parse_args(argv) 25 | files_with_crlf = [f for f in args.filenames if contains_crlf(f)] 26 | for file_with_crlf in files_with_crlf: 27 | print(f"Removing CRLF end-lines in: {file_with_crlf}") 28 | removes_crlf_in_file(file_with_crlf) 29 | if files_with_crlf: 30 | print("") 31 | print("CRLF end-lines have been successfully removed. Now aborting the commit.") 32 | print( 33 | 'You can check the changes made. Then simply "git add --update ." and re-commit' 34 | ) 35 | return 1 36 | return 0 37 | 38 | 39 | if __name__ == "__main__": 40 | sys.exit(main(sys.argv[1:])) # pragma: no cover 41 | -------------------------------------------------------------------------------- /tests/remove_tabs_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pre_commit_hooks.remove_tabs import main as remove_tabs 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("input_s", "expected"), 8 | ( 9 | ("\tfoo", " foo"), 10 | ("foo\t", "foo "), 11 | ("foo \t", "foo "), 12 | ("foo \t \t\t bar", "foo bar"), 13 | ( 14 | "No leading\ttab\n\tleading\ttab\n \tSpace then\tTab\n", 15 | "No leading tab\n leading tab\n Space then Tab\n", 16 | ), 17 | ( 18 | "Tabs\tbetween\tevery\tword\tin\tthe\tline.\n", 19 | "Tabs between every word in the line.\n", 20 | ), 21 | ( 22 | "Space \tthen \ttab \tbetween \tevery \tword \tin \tthe \tline.", 23 | "Space then tab between every word in the line.", 24 | ), 25 | ), 26 | ) 27 | def test_remove_tabs(input_s, expected, tmpdir): 28 | path = tmpdir.join("file.txt") 29 | path.write(input_s) 30 | assert remove_tabs(("--whitespaces-count=4", path.strpath)) == 1 31 | assert path.read() == expected 32 | 33 | 34 | @pytest.mark.parametrize(("arg"), ("", "--", "a.b", "a/b")) 35 | def test_badopt(arg): 36 | with pytest.raises(SystemExit) as excinfo: 37 | remove_tabs(["--whitespaces-count", arg]) 38 | assert excinfo.value.code == 2 39 | 40 | 41 | def test_nothing_to_fix(): 42 | assert remove_tabs(["--whitespaces-count=4", __file__]) == 0 43 | -------------------------------------------------------------------------------- /pre_commit_hooks/remove_tabs.py: -------------------------------------------------------------------------------- 1 | import argparse, sys 2 | 3 | 4 | def contains_tabs(filename): 5 | with open(filename, mode="rb") as file_checked: 6 | return b"\t" in file_checked.read() 7 | 8 | 9 | def removes_tabs_in_file(filename, whitespaces_count): 10 | with open(filename, mode="rb") as file_processed: 11 | lines = file_processed.readlines() 12 | lines = [line.expandtabs(whitespaces_count) for line in lines] 13 | with open(filename, mode="wb") as file_processed: 14 | for line in lines: 15 | file_processed.write(line) 16 | 17 | 18 | def main(argv=None): 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument( 21 | "--whitespaces-count", 22 | type=int, 23 | required=True, 24 | help="number of whitespaces to substitute tabs with", 25 | ) 26 | parser.add_argument("filenames", nargs="*", help="filenames to check") 27 | args = parser.parse_args(argv) 28 | files_with_tabs = [f for f in args.filenames if contains_tabs(f)] 29 | for file_with_tabs in files_with_tabs: 30 | print( 31 | f"Substituting tabs in: {file_with_tabs} by {args.whitespaces_count} whitespaces" 32 | ) 33 | removes_tabs_in_file(file_with_tabs, args.whitespaces_count) 34 | if files_with_tabs: 35 | print("") 36 | print("Tabs have been successfully removed. Now aborting the commit.") 37 | print( 38 | 'You can check the changes made. Then simply "git add --update ." and re-commit' 39 | ) 40 | return 1 41 | return 0 42 | 43 | 44 | if __name__ == "__main__": 45 | sys.exit(main(sys.argv[1:])) # pragma: no cover 46 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: forbid-crlf 2 | name: CRLF end-lines checker 3 | description: "Forbid files containing CRLF end-lines to be committed" 4 | entry: forbid_crlf 5 | language: python 6 | types: [text] 7 | stages: [manual, pre-commit, pre-push, pre-merge-commit] 8 | minimum_pre_commit_version: "3.2.0" 9 | - id: remove-crlf 10 | name: CRLF end-lines remover 11 | description: "Replace CRLF end-lines by LF ones before committing" 12 | entry: remove_crlf 13 | language: python 14 | types: [text] 15 | stages: [manual, pre-commit, pre-push, pre-merge-commit] 16 | minimum_pre_commit_version: "3.2.0" 17 | - id: forbid-tabs 18 | name: No-tabs checker 19 | description: "Forbid files containing tabs to be committed" 20 | entry: forbid_tabs 21 | language: python 22 | types: [text] 23 | exclude: (Makefile|debian/rules|.gitmodules)(\.in)?$ 24 | stages: [manual, pre-commit, pre-push, pre-merge-commit] 25 | minimum_pre_commit_version: "3.2.0" 26 | - id: remove-tabs 27 | name: Tabs remover 28 | description: "Replace tabs by whitespaces before committing" 29 | entry: remove_tabs 30 | language: python 31 | args: [ --whitespaces-count, '4' ] 32 | types: [text] 33 | exclude: (Makefile|debian/rules|.gitmodules)(\.in)?$ 34 | stages: [manual, pre-commit, pre-push, pre-merge-commit] 35 | minimum_pre_commit_version: "3.2.0" 36 | - id: chmod 37 | name: Set file permissions 38 | entry: pre_commit_chmod 39 | language: python 40 | - id: insert-license 41 | name: Insert license in comments 42 | description: "Insert a short license disclaimer as a header comment in source files" 43 | entry: insert_license 44 | language: python 45 | types: [text] 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: # cf. https://github.community/t/how-to-trigger-an-action-on-push-or-pull-request-but-not-both/16662 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | check: 14 | name: Run check 15 | strategy: 16 | fail-fast: false 17 | max-parallel: 5 18 | matrix: 19 | python-version: [3.8, 3.9, '3.10', '3.11'] 20 | platform: [ubuntu-latest, windows-latest] 21 | runs-on: ${{ matrix.platform }} 22 | steps: 23 | - name: Checkout code 🛎️ 24 | uses: actions/checkout@v3 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 🔧 27 | uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Cache pip 🏗️ 32 | uses: actions/cache@v3 33 | with: 34 | path: | 35 | ~/.cache/pip 36 | ~/.cache/pre-commit 37 | key: ${{ runner.os }}-python-${{ matrix.python-version }}-pip-${{ hashFiles('requirements.txt') 38 | }}-git-${{ github.sha }} 39 | restore-keys: | 40 | ${{ runner.os }}-python-${{ matrix.python-version }}-pip-${{ hashFiles('requirements.txt') }} 41 | ${{ runner.os }}-python-${{ matrix.python-version }}- 42 | 43 | - name: Install ⚙️ 44 | run: | 45 | python -m pip install --upgrade pip setuptools wheel 46 | pip install --upgrade -r requirements-dev.txt 47 | pip install --upgrade . 48 | 49 | - name: Running checks ☑ 50 | if: matrix.python-version != '3.11' # Pylint currently triggers erroneous no-member errors 51 | run: pre-commit run --all-files --verbose 52 | -------------------------------------------------------------------------------- /pre_commit_hooks/chmod.py: -------------------------------------------------------------------------------- 1 | import argparse, os, sys 2 | 3 | # pylint: disable=unused-wildcard-import, wildcard-import 4 | from stat import * 5 | 6 | SUPPORTED_BITS = ( 7 | S_ISUID # set UID bit 8 | | S_ISGID # set GID bit 9 | | S_ISVTX # sticky bit 10 | | S_IREAD # Unix V7 synonym for S_IRUSR 11 | | S_IWRITE # Unix V7 synonym for S_IWUSR 12 | | S_IEXEC # Unix V7 synonym for S_IXUSR 13 | | S_IRWXU # mask for owner permissions 14 | | S_IRUSR # read by owner 15 | | S_IWUSR # write by owner 16 | | S_IXUSR # execute by owner 17 | | S_IRWXG # mask for group permissions 18 | | S_IRGRP # read by group 19 | | S_IWGRP # write by group 20 | | S_IXGRP # execute by group 21 | | S_IRWXO # mask for others (not in group) permissions 22 | | S_IROTH # read by others 23 | | S_IWOTH # write by others 24 | | S_IXOTH # execute by others 25 | ) 26 | 27 | 28 | def main(argv=None): 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument( 31 | "perms", type=str, help="Octal permissions to set on target files" 32 | ) 33 | parser.add_argument("filenames", nargs="*", help="filenames to check") 34 | args = parser.parse_args(argv) 35 | try: 36 | # TODO: add support for +rwx syntax 37 | new_mode = int(args.perms, 8) 38 | except ValueError as error: 39 | print(f"Incorrect octal permissions provided in configuration: {error}") 40 | return 2 41 | result = 0 42 | if sys.platform == "win32": 43 | print("This hook does nothing when executed on Windows") 44 | else: 45 | for filename in args.filenames: 46 | current_mode = os.stat(filename).st_mode 47 | # We ignore S_IFREG and other similar unsupported bits: 48 | current_mode &= SUPPORTED_BITS 49 | if current_mode != new_mode: 50 | print( 51 | f"Fixing file permissions on {filename}:" 52 | f" 0o{current_mode:o} -> 0o{new_mode:o}" 53 | ) 54 | os.chmod(filename, new_mode) 55 | result = 1 56 | return result 57 | 58 | 59 | if __name__ == "__main__": 60 | sys.exit(main(sys.argv[1:])) # pragma: no cover 61 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude: ^(\.[^/]*cache(__)?/.*|(.*/)?\.coverage)$ 3 | repos: 4 | # Perform Markdown formatting before other hooks "fixing" line endings: 5 | # PROBLEM: it alters the .md file permissions from 644 to 600 :( 6 | - repo: https://github.com/executablebooks/mdformat 7 | rev: 0.7.17 8 | hooks: 9 | - id: mdformat 10 | name: Format Markdown 11 | entry: mdformat # Executable to run, with fixed options 12 | language: python 13 | types: [markdown] 14 | args: [--wrap, '75', --number] 15 | additional_dependencies: 16 | - mdformat-toc 17 | - repo: https://github.com/Lucas-C/pre-commit-hooks 18 | rev: v1.5.5 19 | hooks: 20 | - id: forbid-crlf 21 | - id: remove-crlf 22 | - id: forbid-tabs 23 | exclude: tests/resources/main.*_with_license.cpp 24 | - id: remove-tabs 25 | exclude: tests/resources/main.*_with_license.cpp 26 | - id: chmod 27 | args: ['644'] 28 | exclude: (\.md$|^tests/resources/.*shebang) 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v4.5.0 31 | hooks: 32 | - id: trailing-whitespace 33 | files: '' 34 | exclude: tests/resources/main.*_with_license.cpp 35 | - id: check-yaml 36 | - id: check-merge-conflict 37 | - id: check-executables-have-shebangs 38 | - id: check-shebang-scripts-are-executable 39 | - repo: https://github.com/asottile/pyupgrade 40 | rev: v3.15.0 41 | hooks: 42 | - id: pyupgrade 43 | args: 44 | - --py37-plus 45 | exclude: ^tests/resources/.* 46 | - repo: https://github.com/psf/black 47 | rev: 23.12.1 48 | hooks: 49 | - id: black 50 | exclude: ^tests/resources/ 51 | - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit 52 | rev: v1.0.6 53 | hooks: 54 | - id: python-bandit-vulnerability-check 55 | args: [--skip, 'B101', --recursive, .] 56 | - repo: https://github.com/pre-commit/mirrors-mypy 57 | rev: v1.8.0 58 | hooks: 59 | - id: mypy 60 | args: 61 | - --ignore-missing-imports 62 | - --install-types 63 | - --non-interactive 64 | - --check-untyped-defs 65 | - --show-error-codes 66 | - --show-error-context 67 | - repo: local 68 | hooks: 69 | - id: pylint 70 | name: pylint 71 | # 3x faster than the official pylint hook, and has no issue with imports 72 | # (tested with: time pre-commit run pylint --all-files) 73 | language: system 74 | entry: pylint 75 | files: \.py$ 76 | exclude: ^tests/resources/.*(init_with_license|todo) 77 | - id: pytest 78 | name: pytest 79 | language: python 80 | additional_dependencies: [pytest, pytest-cov, coverage, rapidfuzz] 81 | entry: pytest -vv 82 | require_serial: true 83 | pass_filenames: false 84 | files: \.py$ 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/Lucas-C/pre-commit-hooks/workflows/CI/badge.svg)](https://github.com/Lucas-C/pre-commit-hooks/actions?query=branch%3Amaster) 2 | 3 | A few useful git hooks to integrate with 4 | [pre-commit](http://pre-commit.com). 5 | 6 | ⚠️ ⚠️ **This hook, since v1.5.2, requires `pre-commit` 3.2.0 or superior.** 7 | If you get an error like `Expected one of ... but got: 'pre-commit'`, check 8 | this issue: [#83](https://github.com/Lucas-C/pre-commit-hooks/issues/83) 9 | 10 | ⚠️ **The last version of this hook to support Python 2.7 & 3.6 is v1.1.15** 11 | 12 | 13 | 14 | - [Usage](#usage) 15 | - [insert-license](#insert-license) 16 | - [Comment styles](#comment-styles) 17 | - [How to specify in how many lines to search for the license header in each file](#how-to-specify-in-how-many-lines-to-search-for-the-license-header-in-each-file) 18 | - [Removing old license and replacing it with a new one](#removing-old-license-and-replacing-it-with-a-new-one) 19 | - [Handling years flexibly](#handling-years-flexibly) 20 | - [No extra EOL](#no-extra-eol) 21 | - [Fuzzy license matching](#fuzzy-license-matching) 22 | - [Multiple license files](#multiple-license-files) 23 | - [Handy shell functions](#handy-shell-functions) 24 | - [Useful local hooks](#useful-local-hooks) 25 | - [Forbid / remove some unicode characters](#forbid--remove-some-unicode-characters) 26 | - [Bash syntax validation](#bash-syntax-validation) 27 | - [For Groovy-like Jenkins pipelines](#for-groovy-like-jenkins-pipelines) 28 | - [Forbid some Javascript keywords for browser retrocompatibility issues](#forbid-some-javascript-keywords-for-browser-retrocompatibility-issues) 29 | - [CSS](#css) 30 | - [Some Angular 1.5 checks](#some-angular-15-checks) 31 | - [Development](#development) 32 | - [Releasing a new version](#releasing-a-new-version) 33 | 34 | 35 | 36 | Hooks specific to a language, or with more dependencies have been extracted 37 | into separate repos: 38 | 39 | - https://github.com/Lucas-C/pre-commit-hooks-bandit 40 | - https://github.com/Lucas-C/pre-commit-hooks-go 41 | - https://github.com/Lucas-C/pre-commit-hooks-java 42 | - https://github.com/Lucas-C/pre-commit-hooks-lxml 43 | - https://github.com/Lucas-C/pre-commit-hooks-markup 44 | - https://github.com/Lucas-C/pre-commit-hooks-nodejs 45 | - https://github.com/Lucas-C/pre-commit-hooks-safety 46 | 47 | ## Usage 48 | 49 | See [hook definitions](./.pre-commit-hooks.yaml) for additional hook 50 | documentation. 51 | 52 | All hooks work on text files, except for the `chmod` hook that applies to 53 | all files. 54 | 55 | ```yaml 56 | - repo: https://github.com/Lucas-C/pre-commit-hooks 57 | rev: v1.5.5 58 | hooks: 59 | - id: forbid-crlf # Forbid files containing CRLF end-lines to be committed 60 | - id: remove-crlf # Replace CRLF end-lines by LF ones before committing 61 | - id: forbid-tabs # Forbid files containing tabs to be committed 62 | - id: remove-tabs # Replace tabs by whitespaces before committing 63 | args: [--whitespaces-count, '2'] # defaults to: 4 64 | - id: chmod # Set file permissions 65 | args: ['644'] 66 | files: \.md$ 67 | - id: insert-license # Insert a short license disclaimer as a header comment in source files 68 | files: \.groovy$ 69 | args: 70 | - --license-filepath 71 | - src/license_header.txt # defaults to: LICENSE.txt 72 | - --comment-style 73 | - // # defaults to: # 74 | - --use-current-year 75 | - --no-extra-eol # see below 76 | ``` 77 | 78 | ### insert-license 79 | 80 | #### Comment styles 81 | 82 | The following styles can be used for example: 83 | 84 | - For Java / Javascript / CSS/ C / C++ (multi-line comments) set 85 | `/*| *| */` ; 86 | - For Java / Javascript / C / C++ (single line comments) set `//` ; 87 | - For HTML files: `` ; 88 | - For Python: `#` ; 89 | - For Jinja templates: `'{#||#}'` . 90 | 91 | #### How to specify in how many lines to search for the license header in each file 92 | 93 | You can add `--detect-license-in-X-top-lines=` to search for the license 94 | in top X lines (default 5). 95 | 96 | #### Removing old license and replacing it with a new one 97 | 98 | In case you want to remove the comment headers introduced by 99 | `insert-license` hook, e.g. because you want to change the wording of your 100 | `LICENSE.txt` and update the comments in your source files: 101 | 102 | 1. Temporarily add the `--remove-header` arg in your 103 | `.pre-commit-config.yaml` ; 104 | 2. Run the hook on all your files: 105 | `pre-commit run insert-license --all-files` ; 106 | 3. Remove the `--remove-header` arg and update your `LICENSE.txt` ; 107 | 4. Re-run the hook on all your files. 108 | 109 | #### Handling years flexibly 110 | 111 | You can add `--use-current-year` to change how the hook treats years in the 112 | headers: 113 | 114 | - When inserting a header, the current year will always be inserted 115 | regardless of the year listed in the license file. 116 | - When modifying a file that already has a header, the hook will ensure the 117 | current year is listed in the header by using a range. For instance, 118 | `2015` or `2015-2018` would get updated to `2015-2023` in the year 2023. 119 | - When removing headers, the licenses will be removed regardless of the 120 | years they contain -- as if they used the year currently present in the 121 | license file. 122 | 123 | You can also use `--allow-past-years` to allow stale years to be unchanged. 124 | Using both `--allow-past-years` and `--use-current-year` issues a year 125 | range as described above. 126 | 127 | #### No extra EOL 128 | 129 | The `--no-extra-eol` argument prevents the insertion of an additional 130 | End-of-Line (EOL) character at the end of the license header; see 131 | [issue #70](https://github.com/Lucas-C/pre-commit-hooks/issues/70). 132 | 133 | #### Fuzzy license matching 134 | 135 | In some cases your license files can contain several slightly different 136 | variants of the license - either containing slight modifications or 137 | differently broken lines of the license text.\ 138 | By default the plugin does 139 | exact matching when searching for the license and in such case it will add 140 | second license on top - leaving the non-perfectly matched one in the source 141 | code.\ 142 | You can prevent that and add `--fuzzy-match-generates-todo` flag in 143 | which case fuzzy matching is performed based on Levenshtein distance of set 144 | of tokens in expected and actual license text (partial match in two sets is 145 | used).\ 146 | The license is detected if the ratio is > than 147 | `--fuzzy-ratio-cut-off` parameter (default 85) - ration corresponds roughly 148 | to how well the expected and actual license match (scale 0 - 100). 149 | Additionally `--fuzzy-match-extra-lines-to-check` lines in this case are 150 | checked for the license in case it has lines broken differently and takes 151 | more lines (default 3). 152 | 153 | If a fuzzy match is found (and no exact match), a TODO comment is inserted 154 | at the beginning of the match found. The comment inserted can be overridden 155 | by `--fuzzy-match-todo-comment=` flag.\ 156 | By default the inserted 157 | comment is 158 | `TODO: This license is not consistent with license used in the project` 159 | Additionally instructions on what to do are inserted in this case.\ 160 | By 161 | default instructions 162 | are:\ 163 | `Delete the inconsistent license and above line and rerun pre-commit to insert a good license.`.\ 164 | You 165 | can change it via `--fuzzy-match-todo-instructions` argument of the hook. 166 | 167 | When the TODO comment is committed, pre-commit will fail with appropriate 168 | message. The check will fails systematically if the 169 | `--fuzzy-match-generates-todo` flag is set or not.\ 170 | You will need to remove 171 | the TODO comment and license so that it gets re-added in order to get rid 172 | of the error. 173 | 174 | License insertion can be skipped altogether if the file contains the 175 | `SKIP LICENSE INSERTION` in the first X top lines. This can also be 176 | overridden by `--skip-license-insertion-comment=` flag. 177 | 178 | #### Multiple license files 179 | 180 | If more than one `--license-filepath` argument is specified, the checks are 181 | performed as follows: 182 | 183 | 1. First, an exact match is pursued, checking the 1st license file, then 184 | the 2nd, and so on. If a match is found, the normal behavior is 185 | followed, as if the matched license file was the only license file 186 | specified. 187 | 188 | 2. If no exact match is found, then the software resorts to fuzzy matching. 189 | Again, as soon as a match is found, the normal behavior is followed, as 190 | if the fuzzy-matched license file was the only license file specified. 191 | 192 | 3. Finally, if neither exact nor fuzzy matches are found, the content of 193 | the first license file is inserted. 194 | 195 | ## Handy shell functions 196 | 197 | ```shell 198 | pre_commit_all_cache_repos () { # Requires sqlite3 199 | sqlite3 -header -column ~/.cache/pre-commit/db.db < <(echo -e ".width 50\nSELECT repo, ref, path FROM repos ORDER BY repo;") 200 | } 201 | 202 | pre_commit_local_cache_repos () { # Requires PyYaml & sqlite3 203 | < $(git rev-parse --show-toplevel)/.pre-commit-config.yaml \ 204 | python -c "from __future__ import print_function; import sys, yaml; print('\n'.join(h['repo']+' '+h['sha'] for h in yaml.load(sys.stdin) if h['repo'] != 'local'))" \ 205 | | while read repo sha; do 206 | echo $repo 207 | sqlite3 ~/.cache/pre-commit/db.db "SELECT ref, path FROM repos WHERE repo = '$repo' AND ref = '$sha';" 208 | echo 209 | done 210 | } 211 | 212 | pre_commit_db_rm_repo () { # Requires sqlite3 213 | local repo=${1?'Missing parameter'} 214 | local repo_path=$(sqlite3 ~/.cache/pre-commit/db.db "SELECT path FROM repos WHERE repo LIKE '%${repo}%';") 215 | if [ -z "$repo_path" ]; then 216 | echo "No repository known for repo $repo" 217 | return 1 218 | fi 219 | rm -rf "$repo_path" 220 | sqlite3 ~/.cache/pre-commit/db.db "DELETE FROM repos WHERE repo LIKE '%${repo}%';"; 221 | } 222 | ``` 223 | 224 | ## Useful local hooks 225 | 226 | ### Forbid / remove some unicode characters 227 | 228 | ```yaml 229 | - repo: local 230 | hooks: 231 | - id: forbid-unicode-non-breaking-spaces 232 | name: Detect unicode non-breaking space character U+00A0 aka M-BM- 233 | language: system 234 | entry: perl -ne 'print if $m = /\xc2\xa0/; $t ||= $m; END{{exit $t}}' 235 | files: '' 236 | - id: remove-unicode-non-breaking-spaces 237 | name: Remove unicode non-breaking space character U+00A0 aka M-BM- 238 | language: system 239 | entry: perl -pi* -e 's/\xc2\xa0/ /g && ($t = 1) && print STDERR $_; END{{exit 240 | $t}}' 241 | files: '' 242 | - id: forbid-en-dashes 243 | name: Detect the EXTREMELY confusing unicode character U+2013 244 | language: system 245 | entry: perl -ne 'print if $m = /\xe2\x80\x93/; $t ||= $m; END{{exit $t}}' 246 | files: '' 247 | - id: remove-en-dashes 248 | name: Remove the EXTREMELY confusing unicode character U+2013 249 | language: system 250 | entry: perl -pi* -e 's/\xe2\x80\x93/-/g && ($t = 1) && print STDERR $_; END{{exit 251 | $t}}' 252 | files: '' 253 | ``` 254 | 255 | ### Bash syntax validation 256 | 257 | ```yaml 258 | - repo: local 259 | hooks: 260 | - id: check-bash-syntax 261 | name: Check Shell scripts syntax correctness 262 | language: system 263 | entry: bash -n 264 | files: \.sh$ 265 | ``` 266 | 267 | ### For Groovy-like Jenkins pipelines 268 | 269 | ```yaml 270 | - repo: local 271 | hooks: 272 | - id: forbid-abstract-classes-and-traits 273 | name: Ensure neither abstract classes nor traits are used 274 | language: pygrep 275 | entry: "^(abstract|trait) " 276 | files: ^src/.*\.groovy$ 277 | ``` 278 | 279 | **Rationale:** `abstract` classes & `traits` do not work in Jenkins 280 | pipelines : cf. https://issues.jenkins-ci.org/browse/JENKINS-39329 & 281 | https://issues.jenkins-ci.org/browse/JENKINS-46145 . 282 | 283 | ```yaml 284 | - repo: local 285 | hooks: 286 | - id: force-JsonSlurperClassic 287 | name: Ensure JsonSlurperClassic is used instead of non-serializable JsonSlurper 288 | language: pygrep 289 | entry: JsonSlurper[^C] 290 | files: \.groovy$ 291 | ``` 292 | 293 | **Rationale:** cf. http://stackoverflow.com/a/38439681/636849 294 | 295 | ```yaml 296 | - repo: local 297 | hooks: 298 | - id: Jenkinsfile-linter 299 | name: Check Jenkinsfile following the scripted-pipeline syntax using Jenkins 300 | API 301 | files: Jenkinsfile 302 | language: system 303 | entry: sh -c '! curl --silent $JENKINS_URL/job/MyPipelineName/job/master/1/replay/checkScriptCompile 304 | --user $JENKINS_USER:$JENKINS_TOKEN --data-urlencode value@Jenkinsfile | 305 | grep -F "\"status\":\"fail\""' 306 | ``` 307 | 308 | Note: the `$JENKINS_TOKEN` can be retrieved from 309 | `$JENKINS_URL/user/$USER_NAME/configure` 310 | 311 | Beware, in 1 case on 6 I faced this unsolved bug with explictly-loaded 312 | libraries: https://issues.jenkins-ci.org/browse/JENKINS-42730 . 313 | 314 | Also, there is also a linter for the declarative syntax: 315 | https://jenkins.io/doc/book/pipeline/development/#linter . 316 | 317 | ### Forbid some Javascript keywords for browser retrocompatibility issues 318 | 319 | ```yaml 320 | - repo: local 321 | hooks: 322 | - id: js-forbid-const 323 | name: The const keyword is not supported by IE10 324 | language: pygrep 325 | entry: 'const ' 326 | files: \.js$ 327 | - id: js-forbid-let 328 | name: The let keyword is not supported by IE10 329 | language: pygrep 330 | entry: 'let ' 331 | files: \.js$ 332 | ``` 333 | 334 | ### CSS 335 | 336 | ```yaml 337 | - repo: local 338 | hooks: 339 | - id: css-forbid-px 340 | name: In CSS files, use rem or % over px 341 | language: pygrep 342 | entry: px 343 | files: \.css$ 344 | - id: ot-sanitize-fonts 345 | name: Calling ot-sanitise on otf/ttf/woff/woff2 font files 346 | language: system 347 | entry: sh -c 'type ot-sanitise >/dev/null 348 | && for font in "$@"; 349 | do echo "$font"; 350 | ot-sanitise "$font"; done 351 | || echo "WARNING Command ot-sanitise not found - skipping check"' 352 | files: \.(otf|ttf|woff|woff2)$ 353 | ``` 354 | 355 | ### Some Angular 1.5 checks 356 | 357 | ```yaml 358 | - repo: local 359 | hooks: 360 | - id: angular-forbid-apply 361 | name: In AngularJS, use $digest over $apply 362 | language: pygrep 363 | entry: \$apply 364 | files: \.js$ 365 | - id: angular-forbid-ngrepeat-without-trackby 366 | name: In AngularJS, ALWAYS use 'track by' with ng-repeat 367 | language: pygrep 368 | entry: ng-repeat(?!.*track by) 369 | files: \.html$ 370 | - id: angular-forbid-ngmodel-with-no-dot 371 | name: In AngularJS, whenever you have ng-model there's gotta be a dot in 372 | there somewhere 373 | language: pygrep 374 | entry: ng-model="?[^.]+[" ] 375 | files: \.html$ 376 | ``` 377 | 378 | ## Development 379 | 380 | The [GitHub releases](https://github.com/Lucas-C/pre-commit-hooks/releases) 381 | form the historical ChangeLog. 382 | 383 | ### Releasing a new version 384 | 385 | 1. Bump version in `README.md`, `setup.py` & `.pre-commit-config.yaml` 386 | 2. `git commit -nam "New release $version" && git tag $version && git push && git push --tags` 387 | 3. Publish a GitHub release. 388 | -------------------------------------------------------------------------------- /pre_commit_hooks/insert_license.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import argparse 3 | import collections 4 | import re 5 | import sys 6 | from datetime import datetime 7 | from typing import Any, Sequence, Final 8 | 9 | from rapidfuzz import fuzz 10 | 11 | DEFAULT_LICENSE_FILEPATH: Final[str] = "LICENSE.txt" 12 | 13 | FUZZY_MATCH_TODO_COMMENT = ( 14 | " TODO: This license is not consistent with the license used in the project." 15 | ) 16 | FUZZY_MATCH_TODO_INSTRUCTIONS = ( 17 | " Delete the inconsistent license and above line" 18 | " and rerun pre-commit to insert a good license." 19 | ) 20 | FUZZY_MATCH_EXTRA_LINES_TO_CHECK = 3 21 | 22 | SKIP_LICENSE_INSERTION_COMMENT = "SKIP LICENSE INSERTION" 23 | 24 | DEBUG_LEVENSHTEIN_DISTANCE_CALCULATION = False 25 | 26 | LicenseInfo = collections.namedtuple( 27 | "LicenseInfo", 28 | [ 29 | "prefixed_license", 30 | "plain_license", 31 | "eol", 32 | "comment_start", 33 | "comment_prefix", 34 | "comment_end", 35 | "num_extra_lines", 36 | ], 37 | ) 38 | 39 | 40 | class LicenseUpdateError(Exception): 41 | def __init__(self, message): 42 | super().__init__(message) 43 | self.message = message 44 | 45 | 46 | def main(argv=None) -> int: 47 | parser = argparse.ArgumentParser() 48 | parser.add_argument("filenames", nargs="*", help="filenames to check") 49 | parser.add_argument( 50 | "--license-filepath", 51 | action="extend", 52 | nargs=1, 53 | help=f"list of file names to consider. When omitted, it defaults to '{DEFAULT_LICENSE_FILEPATH}'", 54 | ) 55 | parser.add_argument( 56 | "--comment-style", 57 | default="#", 58 | help="Can be a single prefix or a triplet: " 59 | "||" 60 | "E.g.: /*| *| */", 61 | ) 62 | parser.add_argument( 63 | "--no-space-in-comment-prefix", 64 | action="store_true", 65 | help="Do not add extra space beyond the comment-style spec", 66 | ) 67 | parser.add_argument( 68 | "--no-extra-eol", 69 | action="store_true", 70 | help="Do not add extra End of Line after license comment", 71 | ) 72 | parser.add_argument("--detect-license-in-X-top-lines", type=int, default=5) 73 | parser.add_argument("--fuzzy-match-generates-todo", action="store_true") 74 | parser.add_argument("--fuzzy-ratio-cut-off", type=int, default=85) 75 | parser.add_argument("--fuzzy-match-todo-comment", default=FUZZY_MATCH_TODO_COMMENT) 76 | parser.add_argument( 77 | "--fuzzy-match-todo-instructions", default=FUZZY_MATCH_TODO_INSTRUCTIONS 78 | ) 79 | parser.add_argument( 80 | "--fuzzy-match-extra-lines-to-check", 81 | type=int, 82 | default=FUZZY_MATCH_EXTRA_LINES_TO_CHECK, 83 | ) 84 | parser.add_argument( 85 | "--skip-license-insertion-comment", default=SKIP_LICENSE_INSERTION_COMMENT 86 | ) 87 | parser.add_argument( 88 | "--insert-license-after-regex", 89 | default="", 90 | help="Insert license after line matching regex (ex: '^<\\?php$')", 91 | ) 92 | parser.add_argument("--remove-header", action="store_true") 93 | parser.add_argument( 94 | "--use-current-year", 95 | action="store_true", 96 | help=( 97 | "Use the current year in inserted and updated licenses, implies --allow-past-years" 98 | ), 99 | ) 100 | parser.add_argument( 101 | "--allow-past-years", 102 | action="store_true", 103 | help=( 104 | "Allow past years in headers. License comments are not updated if they contain past years." 105 | ), 106 | ) 107 | args = parser.parse_args(argv) 108 | if args.use_current_year: 109 | args.allow_past_years = True 110 | if not args.license_filepath: 111 | args.license_filepath = [DEFAULT_LICENSE_FILEPATH] 112 | 113 | license_info_list = get_license_info_list(args) 114 | 115 | changed_files: list[str] = [] 116 | todo_files: list[str] = [] 117 | 118 | check_failed = process_files(args, changed_files, todo_files, license_info_list) 119 | 120 | if check_failed: 121 | print("") 122 | if changed_files: 123 | print(f"Some sources were modified by the hook {changed_files}") 124 | if todo_files: 125 | print( 126 | f"Some sources contain TODO about inconsistent licenses: {todo_files}" 127 | ) 128 | print("Now aborting the commit.") 129 | print( 130 | 'You should check the changes made. Then simply "git add --update ." and re-commit' 131 | ) 132 | print("") 133 | return 1 134 | return 0 135 | 136 | 137 | def _replace_year_in_license_with_current(plain_license: list[str], filepath: str): 138 | current_year = datetime.now().year 139 | for i, line in enumerate(plain_license): 140 | updated = try_update_year(line, filepath, current_year, introduce_range=False) 141 | if updated: 142 | plain_license[i] = updated 143 | break 144 | return plain_license 145 | 146 | 147 | def get_license_info_list(args) -> list[LicenseInfo]: 148 | comment_start, comment_end = None, None 149 | comment_prefix = args.comment_style.replace("\\t", "\t") 150 | extra_space = ( 151 | " " if not args.no_space_in_comment_prefix and comment_prefix != "" else "" 152 | ) 153 | if "|" in comment_prefix: 154 | comment_start, comment_prefix, comment_end = comment_prefix.split("|") 155 | 156 | license_info_list = [] 157 | for filepath in args.license_filepath: 158 | with open(filepath, encoding="utf8", newline="") as license_file: 159 | plain_license = license_file.readlines() 160 | 161 | if args.use_current_year: 162 | plain_license = _replace_year_in_license_with_current( 163 | plain_license, args.license_filepath 164 | ) 165 | 166 | prefixed_license = [ 167 | f'{comment_prefix}{extra_space if line.strip() else ""}{line}' 168 | for line in plain_license 169 | ] 170 | eol = "\r\n" if prefixed_license[0][-2:] == "\r\n" else "\n" 171 | num_extra_lines = 0 172 | 173 | if not prefixed_license[-1].endswith(eol): 174 | prefixed_license[-1] += eol 175 | num_extra_lines += 1 176 | if comment_start: 177 | prefixed_license = [comment_start + eol] + prefixed_license 178 | num_extra_lines += 1 179 | if comment_end: 180 | prefixed_license = prefixed_license + [comment_end + eol] 181 | num_extra_lines += 1 182 | 183 | license_info = LicenseInfo( 184 | prefixed_license=prefixed_license, 185 | plain_license=plain_license, 186 | eol="" if args.no_extra_eol else eol, 187 | comment_start=comment_start, 188 | comment_prefix=comment_prefix, 189 | comment_end=comment_end, 190 | num_extra_lines=num_extra_lines, 191 | ) 192 | 193 | license_info_list.append(license_info) 194 | return license_info_list 195 | 196 | 197 | def process_files( # pylint: disable=too-many-branches 198 | args, 199 | changed_files: list[str], 200 | todo_files: list[str], 201 | license_info_list: list[LicenseInfo], 202 | ) -> list[str] | bool: 203 | """ 204 | Processes all license files 205 | :param args: arguments of the hook 206 | :param changed_files: list of changed files 207 | :param todo_files: list of files where t.o.d.o. is detected 208 | :param license_info_list: list of license info named tuples 209 | :return: True if some files were changed, t.o.d.o is detected or an error occurred while updating the year 210 | """ 211 | license_update_failed = False 212 | after_regex = args.insert_license_after_regex 213 | for src_filepath in args.filenames: 214 | src_file_content, encoding = _read_file_content(src_filepath) 215 | if skip_license_insert_found( 216 | src_file_content=src_file_content, 217 | skip_license_insertion_comment=args.skip_license_insertion_comment, 218 | top_lines_count=args.detect_license_in_X_top_lines, 219 | ): 220 | continue 221 | if fail_license_todo_found( 222 | src_file_content=src_file_content, 223 | fuzzy_match_todo_comment=args.fuzzy_match_todo_comment, 224 | top_lines_count=args.detect_license_in_X_top_lines, 225 | ): 226 | todo_files.append(src_filepath) 227 | continue 228 | 229 | license_header_index = None 230 | license_info = None 231 | for license_info in license_info_list: 232 | license_header_index = find_license_header_index( 233 | src_file_content=src_file_content, 234 | license_info=license_info, 235 | top_lines_count=args.detect_license_in_X_top_lines, 236 | match_years_strictly=not args.allow_past_years, 237 | ) 238 | if license_header_index is not None: 239 | break 240 | fuzzy_match_header_index = None 241 | if args.fuzzy_match_generates_todo and license_header_index is None: 242 | for license_info in license_info_list: 243 | fuzzy_match_header_index = fuzzy_find_license_header_index( 244 | src_file_content=src_file_content, 245 | license_info=license_info, 246 | top_lines_count=args.detect_license_in_X_top_lines, 247 | fuzzy_match_extra_lines_to_check=args.fuzzy_match_extra_lines_to_check, 248 | fuzzy_ratio_cut_off=args.fuzzy_ratio_cut_off, 249 | ) 250 | if fuzzy_match_header_index is not None: 251 | break 252 | if license_header_index is not None: 253 | try: 254 | if license_found( 255 | remove_header=args.remove_header, 256 | update_year_range=args.use_current_year, 257 | license_header_index=license_header_index, 258 | license_info=license_info, 259 | src_file_content=src_file_content, 260 | src_filepath=src_filepath, 261 | encoding=encoding, 262 | ): 263 | changed_files.append(src_filepath) 264 | except LicenseUpdateError as error: 265 | print(error) 266 | license_update_failed = True 267 | else: 268 | if fuzzy_match_header_index is not None: 269 | if fuzzy_license_found( 270 | license_info=license_info, 271 | fuzzy_match_header_index=fuzzy_match_header_index, 272 | fuzzy_match_todo_comment=args.fuzzy_match_todo_comment, 273 | fuzzy_match_todo_instructions=args.fuzzy_match_todo_instructions, 274 | src_file_content=src_file_content, 275 | src_filepath=src_filepath, 276 | encoding=encoding, 277 | ): 278 | todo_files.append(src_filepath) 279 | else: 280 | if license_not_found( 281 | remove_header=args.remove_header, 282 | license_info=license_info_list[0], 283 | src_file_content=src_file_content, 284 | src_filepath=src_filepath, 285 | encoding=encoding, 286 | after_regex=after_regex, 287 | ): 288 | changed_files.append(src_filepath) 289 | return changed_files or todo_files or license_update_failed 290 | 291 | 292 | def _read_file_content(src_filepath): 293 | last_error = None 294 | for encoding in ( 295 | "utf8", 296 | "ISO-8859-1", 297 | ): # we could use the chardet library to support more encodings 298 | try: 299 | with open(src_filepath, encoding=encoding, newline="") as src_file: 300 | return src_file.readlines(), encoding 301 | except UnicodeDecodeError as error: 302 | last_error = error 303 | print( 304 | f"Error while processing: {src_filepath} - file encoding is probably not supported" 305 | ) 306 | if last_error is not None: # Avoid mypy message 307 | raise last_error 308 | raise RuntimeError("Unexpected branch taken (_read_file_content)") 309 | 310 | 311 | def license_not_found( # pylint: disable=too-many-arguments 312 | remove_header: bool, 313 | license_info: LicenseInfo, 314 | src_file_content: list[str], 315 | src_filepath: str, 316 | encoding: str, 317 | after_regex: str, 318 | ) -> bool: 319 | """ 320 | Executed when license is not found. 321 | It either adds license if remove_header is False, 322 | does nothing if remove_header is True. 323 | :param remove_header: whether header should be removed if found 324 | :param license_info: license info named tuple 325 | :param src_file_content: content of the src_file 326 | :param src_filepath: path of the src_file 327 | :return: True if change was made, False otherwise 328 | """ 329 | if not remove_header: 330 | index = 0 331 | for index, line in enumerate(src_file_content): 332 | stripped_line = line.strip() 333 | # Special treatment for user provided regex, 334 | # or shebang, file encoding directive, 335 | # and empty lines when at the beginning of the file. 336 | # (adds license only after those) 337 | if after_regex is not None and after_regex != "": 338 | if re.match(after_regex, stripped_line): 339 | index += 1 # Skip matched line 340 | break # And insert after that line. 341 | elif ( 342 | not stripped_line.startswith("#!") 343 | and not stripped_line.startswith("# -*- coding") 344 | and not stripped_line == "" 345 | ): 346 | break 347 | else: 348 | # We got all the way to the end without hitting `break`, reset it to line 0 349 | index = 0 350 | src_file_content = ( 351 | src_file_content[:index] 352 | + license_info.prefixed_license 353 | + [license_info.eol] 354 | + src_file_content[index:] 355 | ) 356 | with open(src_filepath, "w", encoding=encoding, newline="") as src_file: 357 | src_file.write("".join(src_file_content)) 358 | return True 359 | return False 360 | 361 | 362 | # a year, then optionally a dash (with optional spaces before and after), and another year, surrounded by word boundaries 363 | _YEAR_RANGE_PATTERN = re.compile(r"\b\d{4}(?: *- *\d{2,4})?\b") 364 | 365 | 366 | def try_update_year( 367 | line: str, filepath: str, current_year: int, introduce_range: bool 368 | ) -> str | None: 369 | """ 370 | Update the last match in self.line. 371 | :param line: the line to update 372 | :param filepath: the file the line is from 373 | :param introduce_range: 374 | Decides what to do when a single year is found and not a range of years. 375 | If True, create a range ending in the current year. If False, just replace the year. 376 | If a range is already present, it will be updated regardless of this parameter. 377 | :return: The updated line if there was an update. None otherwise. 378 | """ 379 | matches = _YEAR_RANGE_PATTERN.findall(line) 380 | if matches: 381 | match = matches[-1] 382 | start_year = int(match[:4]) 383 | end_year = match[5:].lstrip(" -,") 384 | if end_year and int(end_year) < current_year: # range detected 385 | return _try_update_year_range_in_matched_line( 386 | line, match, start_year, current_year, filepath 387 | ) 388 | if not end_year and start_year < current_year: 389 | if introduce_range: 390 | return _try_update_year_range_in_matched_line( 391 | line, match, start_year, current_year, filepath 392 | ) 393 | return line.replace(match, str(current_year)) 394 | return None 395 | 396 | 397 | def _try_update_year_range_in_matched_line( 398 | line: str, match: Any, start_year: int, current_year: int, filepath: str 399 | ): 400 | """match: a match object for the _YEAR_RANGE_PATTERN regex""" 401 | updated = line.replace(match, str(start_year) + "-" + str(current_year)) 402 | # verify the current list of years ends in the current one 403 | if _YEARS_PATTERN.findall(updated)[-1][-4:] != str(current_year): 404 | raise LicenseUpdateError( 405 | f"Year range detected in license header, but we were unable to update it.\n" 406 | f"File: {filepath}\nInput line: {line.rstrip()}\nDiscarded result: {updated.rstrip()}" 407 | ) 408 | return updated 409 | 410 | 411 | def try_update_year_range( 412 | src_file_content: list[str], 413 | src_filepath: str, 414 | license_header_index: int, 415 | license_length: int, 416 | ) -> tuple[Sequence[str], bool]: 417 | """ 418 | Updates the years in a copyright header in src_file_content by 419 | ensuring it contains a range ending in the current year. 420 | Does nothing if the current year is already present as the end of 421 | the range. 422 | The change will affect only the first line containing years. 423 | :param src_file_content: the lines in the source file 424 | :param license_header_index: line where the license starts 425 | :return: source file contents and a flag indicating update 426 | """ 427 | current_year = datetime.now().year 428 | changed = False 429 | for i in range(license_header_index, license_header_index + license_length): 430 | updated = try_update_year( 431 | src_file_content[i], src_filepath, current_year, introduce_range=True 432 | ) 433 | if updated: 434 | src_file_content[i] = updated 435 | changed = True 436 | return src_file_content, changed 437 | 438 | 439 | def license_found( 440 | remove_header, 441 | update_year_range, 442 | license_header_index, 443 | license_info, 444 | src_file_content, 445 | src_filepath, 446 | encoding, 447 | ): # pylint: disable=too-many-arguments 448 | """ 449 | Executed when license is found. It does nothing if remove_header is False, 450 | removes the license if remove_header is True. 451 | :param remove_header: whether header should be removed if found 452 | :param update_year_range: whether to update license with the current year 453 | :param license_header_index: index where license found 454 | :param license_info: license_info tuple 455 | :param src_file_content: content of the src_file 456 | :param src_filepath: path of the src_file 457 | :return: True if change was made, False otherwise 458 | """ 459 | updated = False 460 | if remove_header: 461 | last_license_line_index = license_header_index + len( 462 | license_info.prefixed_license 463 | ) 464 | if ( 465 | last_license_line_index < len(src_file_content) 466 | and src_file_content[last_license_line_index].strip() 467 | ): 468 | src_file_content = ( 469 | src_file_content[:license_header_index] 470 | + src_file_content[ 471 | license_header_index + len(license_info.prefixed_license) : 472 | ] 473 | ) 474 | else: 475 | src_file_content = ( 476 | src_file_content[:license_header_index] 477 | + src_file_content[ 478 | license_header_index + len(license_info.prefixed_license) + 1 : 479 | ] 480 | ) 481 | updated = True 482 | elif update_year_range: 483 | src_file_content, updated = try_update_year_range( 484 | src_file_content, 485 | src_filepath, 486 | license_header_index, 487 | len(license_info.prefixed_license), 488 | ) 489 | 490 | if updated: 491 | with open(src_filepath, "w", encoding=encoding, newline="") as src_file: 492 | src_file.write("".join(src_file_content)) 493 | 494 | return updated 495 | 496 | 497 | def fuzzy_license_found( 498 | license_info, # pylint: disable=too-many-arguments 499 | fuzzy_match_header_index, 500 | fuzzy_match_todo_comment, 501 | fuzzy_match_todo_instructions, 502 | src_file_content, 503 | src_filepath, 504 | encoding, 505 | ): 506 | """ 507 | Executed when fuzzy license is found. It inserts comment indicating that the license should be 508 | corrected. 509 | :param license_info: license info tuple 510 | :param fuzzy_match_header_index: index where 511 | :param fuzzy_match_todo_comment: comment to add when fuzzy match found 512 | :param fuzzy_match_todo_instructions: instructions for fuzzy_match removal 513 | :param src_file_content: content of the src_file 514 | :param src_filepath: path of the src_file 515 | :return: True if change was made, False otherwise 516 | """ 517 | src_file_content = ( 518 | src_file_content[:fuzzy_match_header_index] 519 | + [license_info.comment_prefix + fuzzy_match_todo_comment + license_info.eol] 520 | + [ 521 | license_info.comment_prefix 522 | + fuzzy_match_todo_instructions 523 | + license_info.eol 524 | ] 525 | + src_file_content[fuzzy_match_header_index:] 526 | ) 527 | with open(src_filepath, "w", encoding=encoding, newline="") as src_file: 528 | src_file.write("".join(src_file_content)) 529 | return True 530 | 531 | 532 | # More flexible than _YEAR_RANGE_PATTERN. For detecting all years in a line, not just a range. 533 | _YEARS_PATTERN = re.compile(r"\b\d{4}([ ,-]+\d{2,4})*\b") 534 | 535 | 536 | def _strip_years(line): 537 | return _YEARS_PATTERN.sub("", line) 538 | 539 | 540 | def _license_line_matches(license_line, src_file_line, match_years_strictly): 541 | license_line = license_line.strip() 542 | src_file_line = src_file_line.strip() 543 | 544 | if match_years_strictly: 545 | return license_line == src_file_line 546 | 547 | return _strip_years(license_line) == _strip_years(src_file_line) 548 | 549 | 550 | def find_license_header_index( 551 | src_file_content, license_info: LicenseInfo, top_lines_count, match_years_strictly 552 | ) -> int | None: 553 | """ 554 | Returns the line number, starting from 0 and lower than `top_lines_count`, 555 | where the license header comment starts in this file, or else None. 556 | """ 557 | for i in range(top_lines_count): 558 | license_match = True 559 | for j, license_line in enumerate(license_info.prefixed_license): 560 | if i + j >= len(src_file_content) or not _license_line_matches( 561 | license_line, src_file_content[i + j], match_years_strictly 562 | ): 563 | license_match = False 564 | break 565 | if license_match: 566 | return i 567 | return None 568 | 569 | 570 | def skip_license_insert_found( 571 | src_file_content, skip_license_insertion_comment, top_lines_count 572 | ): 573 | """ 574 | Returns True if skip license insert comment is found in top X lines 575 | """ 576 | for i in range(top_lines_count): 577 | if ( 578 | i < len(src_file_content) 579 | and skip_license_insertion_comment in src_file_content[i] 580 | ): 581 | return True 582 | return False 583 | 584 | 585 | def fail_license_todo_found( 586 | src_file_content, fuzzy_match_todo_comment, top_lines_count 587 | ): 588 | """ 589 | Returns True if "T.O.D.O" comment is found in top X lines 590 | """ 591 | for i in range(top_lines_count): 592 | if ( 593 | i < len(src_file_content) 594 | and fuzzy_match_todo_comment in src_file_content[i] 595 | ): 596 | return True 597 | return False 598 | 599 | 600 | def fuzzy_find_license_header_index( 601 | src_file_content, # pylint: disable=too-many-locals 602 | license_info, 603 | top_lines_count, 604 | fuzzy_match_extra_lines_to_check, 605 | fuzzy_ratio_cut_off, 606 | ) -> int | None: 607 | """ 608 | Returns the line number, starting from 0 and lower than `top_lines_count`, 609 | where the fuzzy matching found best match with ratio higher than the cutoff ratio. 610 | """ 611 | best_line_number_match = None 612 | best_ratio = 0 613 | best_num_token_diff = 0 614 | license_string = ( 615 | " ".join(license_info.plain_license).replace("\n", "").replace("\r", "").strip() 616 | ) 617 | expected_num_tokens = len(license_string.split(" ")) 618 | for i in range(top_lines_count): 619 | candidate_array = src_file_content[ 620 | i : i 621 | + len(license_info.plain_license) 622 | + license_info.num_extra_lines 623 | + fuzzy_match_extra_lines_to_check 624 | ] 625 | license_string_candidate, candidate_offset = get_license_candidate_string( 626 | candidate_array, license_info 627 | ) 628 | ratio = fuzz.token_set_ratio(license_string, license_string_candidate) 629 | num_tokens = len(license_string_candidate.split(" ")) 630 | num_tokens_diff = abs(num_tokens - expected_num_tokens) 631 | if DEBUG_LEVENSHTEIN_DISTANCE_CALCULATION: # pragma: no cover 632 | print(f"License_string: {license_string}") 633 | print(f"License_string_candidate: {license_string_candidate}") 634 | print(f"Candidate offset: {candidate_offset}") 635 | print(f"Ratio: {ratio}") 636 | print(f"Number of tokens: {num_tokens}") 637 | print(f"Expected number of tokens: {expected_num_tokens}") 638 | print(f"Num tokens diff: {num_tokens_diff}") 639 | if ratio >= fuzzy_ratio_cut_off: 640 | if ratio > best_ratio or ( 641 | ratio == best_ratio and num_tokens_diff < best_num_token_diff 642 | ): 643 | best_ratio = ratio 644 | best_line_number_match = i + candidate_offset 645 | best_num_token_diff = num_tokens_diff 646 | if DEBUG_LEVENSHTEIN_DISTANCE_CALCULATION: # pragma: no cover 647 | print( 648 | f"Setting best line number match: {best_line_number_match}, ratio {best_ratio}, num tokens diff {best_num_token_diff}" 649 | ) 650 | if DEBUG_LEVENSHTEIN_DISTANCE_CALCULATION: # pragma: no cover 651 | print(f"Best offset match {best_line_number_match}") 652 | return best_line_number_match 653 | 654 | 655 | def get_license_candidate_string(candidate_array, license_info): 656 | """ 657 | Return license candidate string from the array of strings retrieved 658 | :param candidate_array: array of lines of the candidate strings 659 | :param license_info: LicenseInfo named tuple containing information about the license 660 | :return: Tuple of string version of the license candidate and offset in lines where it starts. 661 | """ 662 | license_string_candidate = "" 663 | stripped_comment_start = ( 664 | license_info.comment_start.strip() if license_info.comment_start else "" 665 | ) 666 | stripped_comment_prefix = ( 667 | license_info.comment_prefix.strip() if license_info.comment_prefix else "" 668 | ) 669 | stripped_comment_end = ( 670 | license_info.comment_end.strip() if license_info.comment_end else "" 671 | ) 672 | in_license = False 673 | current_offset = 0 674 | found_license_offset = 0 675 | for license_line in candidate_array: 676 | stripped_line = license_line.strip() 677 | if not in_license: 678 | if stripped_comment_start: 679 | if stripped_line.startswith(stripped_comment_start): 680 | in_license = True 681 | found_license_offset = ( 682 | current_offset + 1 683 | ) # License starts in the next line 684 | continue 685 | else: 686 | if stripped_comment_prefix: 687 | if stripped_line.startswith(stripped_comment_prefix): 688 | in_license = True 689 | found_license_offset = ( 690 | current_offset # License starts in this line 691 | ) 692 | else: 693 | in_license = True 694 | # We have no data :(. We start license immediately 695 | found_license_offset = current_offset 696 | else: 697 | if stripped_comment_end and stripped_line.startswith(stripped_comment_end): 698 | break 699 | if in_license and ( 700 | not stripped_comment_prefix 701 | or stripped_line.startswith(stripped_comment_prefix) 702 | ): 703 | license_string_candidate += ( 704 | stripped_line[len(stripped_comment_prefix) :] + " " 705 | ) 706 | current_offset += 1 707 | return license_string_candidate.strip(), found_license_offset 708 | 709 | 710 | if __name__ == "__main__": 711 | sys.exit(main(sys.argv[1:])) # pragma: no cover 712 | -------------------------------------------------------------------------------- /tests/insert_license_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from itertools import chain, product 3 | import shutil 4 | import pytest 5 | 6 | from pre_commit_hooks.insert_license import main as insert_license, LicenseInfo 7 | from pre_commit_hooks.insert_license import find_license_header_index 8 | 9 | from .utils import chdir_to_test_resources, capture_stdout 10 | 11 | 12 | # pylint: disable=too-many-arguments 13 | 14 | 15 | def _convert_line_ending(file_path, new_line_endings): 16 | for encoding in ( 17 | "utf8", 18 | "ISO-8859-1", 19 | ): # we could use the chardet library to support more encodings 20 | last_error = None 21 | try: 22 | with open(file_path, encoding=encoding, newline="") as f_in: 23 | content = f_in.read() 24 | 25 | with open( 26 | file_path, "w", encoding=encoding, newline=new_line_endings 27 | ) as f_out: 28 | f_out.write(content) 29 | 30 | return 31 | except UnicodeDecodeError as error: 32 | last_error = error 33 | print( 34 | f"Error while processing: {file_path} - file encoding is probably not supported" 35 | ) 36 | if last_error is not None: # Avoid mypy message 37 | raise last_error 38 | raise RuntimeError("Unexpected branch taken (_convert_line_ending)") 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ( 43 | "license_file_path", 44 | "line_ending", 45 | "src_file_path", 46 | "comment_prefix", 47 | "new_src_file_expected", 48 | "message_expected", 49 | "fail_check", 50 | "extra_args", 51 | ), 52 | map( 53 | lambda a: a[:2] + a[2], 54 | chain( 55 | product( # combine license files with other args 56 | ( 57 | "LICENSE_with_trailing_newline.txt", 58 | "LICENSE_without_trailing_newline.txt", 59 | ), 60 | ("\n", "\r\n"), 61 | ( 62 | ( 63 | "module_without_license.py", 64 | "#", 65 | "module_with_license.py", 66 | "", 67 | True, 68 | None, 69 | ), 70 | ("module_without_license_skip.py", "#", None, "", False, None), 71 | ("module_with_license.py", "#", None, "", False, None), 72 | ("module_with_license_todo.py", "#", None, "", True, None), 73 | ( 74 | "module_without_license.jinja", 75 | "{#||#}", 76 | "module_with_license.jinja", 77 | "", 78 | True, 79 | None, 80 | ), 81 | ( 82 | "module_without_license_skip.jinja", 83 | "{#||#}", 84 | None, 85 | "", 86 | False, 87 | None, 88 | ), 89 | ("module_with_license.jinja", "{#||#}", None, "", False, None), 90 | ("module_with_license_todo.jinja", "{#||#}", None, "", True, None), 91 | ( 92 | "module_without_license_and_shebang.py", 93 | "#", 94 | "module_with_license_and_shebang.py", 95 | "", 96 | True, 97 | None, 98 | ), 99 | ( 100 | "module_without_license_and_shebang_skip.py", 101 | "#", 102 | None, 103 | "", 104 | False, 105 | None, 106 | ), 107 | ("module_with_license_and_shebang.py", "#", None, "", False, None), 108 | ( 109 | "module_with_license_and_shebang_todo.py", 110 | "#", 111 | None, 112 | "", 113 | True, 114 | None, 115 | ), 116 | ( 117 | "module_without_license.groovy", 118 | "//", 119 | "module_with_license.groovy", 120 | "", 121 | True, 122 | None, 123 | ), 124 | ("module_without_license_skip.groovy", "//", None, "", False, None), 125 | ("module_with_license.groovy", "//", None, "", False, None), 126 | ("module_with_license_todo.groovy", "//", None, "", True, None), 127 | ( 128 | "module_without_license.css", 129 | "/*| *| */", 130 | "module_with_license.css", 131 | "", 132 | True, 133 | None, 134 | ), 135 | ( 136 | "module_without_license_and_few_words.css", 137 | "/*| *| */", 138 | "module_with_license_and_few_words.css", 139 | "", 140 | True, 141 | None, 142 | ), # Test fuzzy match does not match greedily 143 | ( 144 | "module_without_license_skip.css", 145 | "/*| *| */", 146 | None, 147 | "", 148 | False, 149 | None, 150 | ), 151 | ("module_with_license.css", "/*| *| */", None, "", False, None), 152 | ("module_with_license_todo.css", "/*| *| */", None, "", True, None), 153 | ( 154 | "main_without_license.cpp", 155 | "/*|\t| */", 156 | "main_with_license.cpp", 157 | "", 158 | True, 159 | None, 160 | ), 161 | ( 162 | "main_iso8859_without_license.cpp", 163 | "/*|\t| */", 164 | "main_iso8859_with_license.cpp", 165 | "", 166 | True, 167 | None, 168 | ), 169 | ( 170 | "module_without_license.txt", 171 | "", 172 | "module_with_license_noprefix.txt", 173 | "", 174 | True, 175 | None, 176 | ), 177 | ( 178 | "module_without_license.py", 179 | "#", 180 | "module_with_license_nospace.py", 181 | "", 182 | True, 183 | ["--no-space-in-comment-prefix"], 184 | ), 185 | ( 186 | "module_without_license.php", 187 | "/*| *| */", 188 | "module_with_license.php", 189 | "", 190 | True, 191 | ["--insert-license-after-regex", "^<\\?php$"], 192 | ), 193 | ( 194 | "module_without_license.py", 195 | "#", 196 | "module_with_license.py", 197 | "", 198 | True, 199 | # Test that when the regex is not found, the license is put at the first line 200 | ["--insert-license-after-regex", "^<\\?php$"], 201 | ), 202 | ( 203 | "module_without_license.py", 204 | "#", 205 | "module_with_license_noeol.py", 206 | "", 207 | True, 208 | ["--no-extra-eol"], 209 | ), 210 | ( 211 | "module_without_license.groovy", 212 | "//", 213 | "module_with_license.groovy", 214 | "", 215 | True, 216 | ["--use-current-year"], 217 | ), 218 | ( 219 | "module_with_stale_year_in_license.py", 220 | "#", 221 | "module_with_year_range_in_license.py", 222 | "", 223 | True, 224 | ["--use-current-year"], 225 | ), 226 | ( 227 | "module_with_stale_year_range_in_license.py", 228 | "#", 229 | "module_with_year_range_in_license.py", 230 | "", 231 | True, 232 | ["--use-current-year"], 233 | ), 234 | ( 235 | "module_with_stale_year_range_in_license.py", 236 | "#", 237 | "module_with_stale_year_range_in_license.py", 238 | "", 239 | False, 240 | ["--allow-past-years"], 241 | ), 242 | ( 243 | "module_with_badly_formatted_stale_year_range_in_license.py", 244 | "#", 245 | "module_with_badly_formatted_stale_year_range_in_license.py", 246 | "module_with_badly_formatted_stale_year_range_in_license.py", 247 | True, 248 | ["--use-current-year"], 249 | ), 250 | ( 251 | "module_without_license.py", 252 | "#", 253 | "module_with_license.py", 254 | "", 255 | True, 256 | None, 257 | ), 258 | ("module_without_license_skip.py", "#", None, "", False, None), 259 | ("module_with_license.py", "#", None, "", False, None), 260 | ("module_with_license_todo.py", "#", None, "", True, None), 261 | ( 262 | "module_without_license.jinja", 263 | "{#||#}", 264 | "module_with_license.jinja", 265 | "", 266 | True, 267 | None, 268 | ), 269 | ( 270 | "module_without_license_skip.jinja", 271 | "{#||#}", 272 | None, 273 | "", 274 | False, 275 | None, 276 | ), 277 | ("module_with_license.jinja", "{#||#}", None, "", False, None), 278 | ("module_with_license_todo.jinja", "{#||#}", None, "", True, None), 279 | ( 280 | "module_without_license_and_shebang.py", 281 | "#", 282 | "module_with_license_and_shebang.py", 283 | "", 284 | True, 285 | None, 286 | ), 287 | ( 288 | "module_without_license_and_shebang_skip.py", 289 | "#", 290 | None, 291 | "", 292 | False, 293 | None, 294 | ), 295 | ("module_with_license_and_shebang.py", "#", None, "", False, None), 296 | ( 297 | "module_with_license_and_shebang_todo.py", 298 | "#", 299 | None, 300 | "", 301 | True, 302 | None, 303 | ), 304 | ( 305 | "module_without_license.groovy", 306 | "//", 307 | "module_with_license.groovy", 308 | "", 309 | True, 310 | None, 311 | ), 312 | ("module_without_license_skip.groovy", "//", None, "", False, None), 313 | ("module_with_license.groovy", "//", None, "", False, None), 314 | ("module_with_license_todo.groovy", "//", None, "", True, None), 315 | ( 316 | "module_without_license.css", 317 | "/*| *| */", 318 | "module_with_license.css", 319 | "", 320 | True, 321 | None, 322 | ), 323 | ( 324 | "module_without_license_and_few_words.css", 325 | "/*| *| */", 326 | "module_with_license_and_few_words.css", 327 | "", 328 | True, 329 | None, 330 | ), # Test fuzzy match does not match greedily 331 | ( 332 | "module_without_license_skip.css", 333 | "/*| *| */", 334 | None, 335 | "", 336 | False, 337 | None, 338 | ), 339 | ("module_with_license.css", "/*| *| */", None, "", False, None), 340 | ("module_with_license_todo.css", "/*| *| */", None, "", True, None), 341 | ( 342 | "main_without_license.cpp", 343 | "/*|\t| */", 344 | "main_with_license.cpp", 345 | "", 346 | True, 347 | None, 348 | ), 349 | ( 350 | "main_iso8859_without_license.cpp", 351 | "/*|\t| */", 352 | "main_iso8859_with_license.cpp", 353 | "", 354 | True, 355 | None, 356 | ), 357 | ( 358 | "module_without_license.txt", 359 | "", 360 | "module_with_license_noprefix.txt", 361 | "", 362 | True, 363 | None, 364 | ), 365 | ( 366 | "module_without_license.py", 367 | "#", 368 | "module_with_license_nospace.py", 369 | "", 370 | True, 371 | ["--no-space-in-comment-prefix"], 372 | ), 373 | ( 374 | "module_without_license.php", 375 | "/*| *| */", 376 | "module_with_license.php", 377 | "", 378 | True, 379 | ["--insert-license-after-regex", "^<\\?php$"], 380 | ), 381 | ( 382 | "module_without_license.py", 383 | "#", 384 | "module_with_license_noeol.py", 385 | "", 386 | True, 387 | ["--no-extra-eol"], 388 | ), 389 | ( 390 | "module_without_license.groovy", 391 | "//", 392 | "module_with_license.groovy", 393 | "", 394 | True, 395 | ["--use-current-year"], 396 | ), 397 | ( 398 | "module_with_stale_year_in_license.py", 399 | "#", 400 | "module_with_year_range_in_license.py", 401 | "", 402 | True, 403 | ["--use-current-year"], 404 | ), 405 | ( 406 | "module_with_stale_year_range_in_license.py", 407 | "#", 408 | "module_with_year_range_in_license.py", 409 | "", 410 | True, 411 | ["--use-current-year"], 412 | ), 413 | ( 414 | "module_with_badly_formatted_stale_year_range_in_license.py", 415 | "#", 416 | "module_with_badly_formatted_stale_year_range_in_license.py", 417 | "module_with_badly_formatted_stale_year_range_in_license.py", 418 | True, 419 | ["--use-current-year"], 420 | ), 421 | ( 422 | "module_without_license.py", 423 | "#", 424 | "module_with_license.py", 425 | "", 426 | True, 427 | [ 428 | "--license-filepath", 429 | "LICENSE_2_without_trailing_newline.txt", 430 | ], 431 | ), 432 | ( 433 | "module_with_license_2.py", 434 | "#", 435 | None, 436 | "", 437 | False, 438 | [ 439 | "--license-filepath", 440 | "LICENSE_2_without_trailing_newline.txt", 441 | ], 442 | ), 443 | ( 444 | "module_with_license.py", 445 | "#", 446 | None, 447 | "", 448 | False, 449 | [ 450 | "--license-filepath", 451 | "LICENSE_2_without_trailing_newline.txt", 452 | ], 453 | ), 454 | ( 455 | "module_with_license_todo.py", 456 | "#", 457 | None, 458 | "", 459 | True, 460 | [ 461 | "--license-filepath", 462 | "LICENSE_2_without_trailing_newline.txt", 463 | ], 464 | ), 465 | ), 466 | ), 467 | product( 468 | ("LICENSE_with_year_range_and_trailing_newline.txt",), 469 | ("\n", "\r\n"), 470 | ( 471 | ( 472 | "module_without_license.groovy", 473 | "//", 474 | "module_with_year_range_license.groovy", 475 | "", 476 | True, 477 | ["--use-current-year"], 478 | ), 479 | ), 480 | ), 481 | product( 482 | ("LICENSE_with_multiple_year_ranges.txt",), 483 | ("\n",), 484 | ( 485 | ( 486 | "module_with_multiple_stale_years_in_license.py", 487 | "#", 488 | "module_with_multiple_years_in_license.py", 489 | "", 490 | True, 491 | ["--use-current-year"], 492 | ), 493 | ), 494 | ), 495 | ), 496 | ), 497 | ) 498 | def test_insert_license( 499 | license_file_path, 500 | line_ending, 501 | src_file_path, 502 | comment_prefix, 503 | new_src_file_expected, 504 | message_expected, 505 | fail_check, 506 | extra_args, 507 | tmpdir, 508 | ): 509 | encoding = "ISO-8859-1" if "iso8859" in src_file_path else "utf-8" 510 | with chdir_to_test_resources(): 511 | path = tmpdir.join(src_file_path) 512 | shutil.copy(src_file_path, path.strpath) 513 | _convert_line_ending(path.strpath, line_ending) 514 | args = [ 515 | "--license-filepath", 516 | license_file_path, 517 | "--comment-style", 518 | comment_prefix, 519 | path.strpath, 520 | ] 521 | if extra_args is not None: 522 | args.extend(extra_args) 523 | 524 | with capture_stdout() as stdout: 525 | assert insert_license(args) == (1 if fail_check else 0) 526 | assert message_expected in stdout.getvalue() 527 | 528 | if new_src_file_expected: 529 | with open( 530 | new_src_file_expected, encoding=encoding, newline=line_ending 531 | ) as expected_content_file: 532 | expected_content = expected_content_file.read() 533 | if "--use-current-year" in args: 534 | expected_content = expected_content.replace( 535 | "2017", str(datetime.now().year) 536 | ) 537 | new_file_content = path.open(encoding=encoding).read() 538 | assert new_file_content == expected_content 539 | 540 | 541 | @pytest.mark.parametrize( 542 | ("license_file_path", "src_file_path", "comment_prefix"), 543 | map( 544 | lambda a: a[:1] + a[1], 545 | product( # combine license files with other args 546 | ( 547 | "LICENSE_with_trailing_newline.txt", 548 | "LICENSE_without_trailing_newline.txt", 549 | "LICENSE_with_year_range_and_trailing_newline.txt", 550 | ), 551 | ( 552 | ("module_with_license.groovy", "//"), 553 | ("module_with_license_and_numbers.py", "#"), 554 | ("module_with_year_range_in_license.py", "#"), 555 | ("module_with_spaced_year_range_in_license.py", "#"), 556 | ), 557 | ), 558 | ), 559 | ) 560 | def test_insert_license_current_year_already_there( 561 | license_file_path, src_file_path, comment_prefix, tmpdir 562 | ): 563 | with chdir_to_test_resources(): 564 | with open(src_file_path, encoding="utf-8") as src_file: 565 | input_contents = src_file.read().replace("2017", str(datetime.now().year)) 566 | path = tmpdir.join("src_file_path") 567 | with open(path.strpath, "w", encoding="utf-8") as input_file: 568 | input_file.write(input_contents) 569 | 570 | args = [ 571 | "--license-filepath", 572 | license_file_path, 573 | "--comment-style", 574 | comment_prefix, 575 | "--use-current-year", 576 | path.strpath, 577 | ] 578 | assert insert_license(args) == 0 579 | # ensure file was not modified 580 | with open(path.strpath, encoding="utf-8") as output_file: 581 | output_contents = output_file.read() 582 | assert output_contents == input_contents 583 | 584 | 585 | @pytest.mark.parametrize( 586 | ( 587 | "license_file_path", 588 | "line_ending", 589 | "src_file_path", 590 | "comment_style", 591 | "new_src_file_expected", 592 | "fail_check", 593 | "extra_args", 594 | ), 595 | map( 596 | lambda a: a[:2] + a[2] + a[3], 597 | chain( 598 | product( # combine license files with other args 599 | ( 600 | "LICENSE_with_trailing_newline.txt", 601 | "LICENSE_without_trailing_newline.txt", 602 | ), 603 | ("\n", "\r\n"), 604 | ( 605 | ( 606 | "module_without_license.jinja", 607 | "{#||#}", 608 | "module_with_license.jinja", 609 | True, 610 | ), 611 | ("module_with_license.jinja", "{#||#}", None, False), 612 | ( 613 | "module_with_fuzzy_matched_license.jinja", 614 | "{#||#}", 615 | "module_with_license_todo.jinja", 616 | True, 617 | ), 618 | ("module_with_license_todo.jinja", "{#||#}", None, True), 619 | ("module_without_license.py", "#", "module_with_license.py", True), 620 | ("module_with_license.py", "#", None, False), 621 | ( 622 | "module_with_fuzzy_matched_license.py", 623 | "#", 624 | "module_with_license_todo.py", 625 | True, 626 | ), 627 | ("module_with_license_todo.py", "#", None, True), 628 | ("module_with_license_and_shebang.py", "#", None, False), 629 | ( 630 | "module_with_fuzzy_matched_license_and_shebang.py", 631 | "#", 632 | "module_with_license_and_shebang_todo.py", 633 | True, 634 | ), 635 | ("module_with_license_and_shebang_todo.py", "#", None, True), 636 | ( 637 | "module_without_license.groovy", 638 | "//", 639 | "module_with_license.groovy", 640 | True, 641 | ), 642 | ("module_with_license.groovy", "//", None, False), 643 | ( 644 | "module_with_fuzzy_matched_license.groovy", 645 | "//", 646 | "module_with_license_todo.groovy", 647 | True, 648 | ), 649 | ("module_with_license_todo.groovy", "//", None, True), 650 | ( 651 | "module_without_license.css", 652 | "/*| *| */", 653 | "module_with_license.css", 654 | True, 655 | ), 656 | ("module_with_license.css", "/*| *| */", None, False), 657 | ( 658 | "module_with_fuzzy_matched_license.css", 659 | "/*| *| */", 660 | "module_with_license_todo.css", 661 | True, 662 | ), 663 | ("module_with_license_todo.css", "/*| *| */", None, True), 664 | ), 665 | ( 666 | (tuple(),), 667 | ( 668 | ( 669 | "--license-filepath", 670 | "LICENSE_2_without_trailing_newline.txt", 671 | ), 672 | ), 673 | ), 674 | ), 675 | product( 676 | ("LICENSE_2_without_trailing_newline.txt",), 677 | ("\n", "\r\n"), 678 | ( 679 | ( 680 | "module_without_license.py", 681 | "#", 682 | "module_with_license_2.py", 683 | True, 684 | ), 685 | ("module_with_license.py", "#", None, False), 686 | ("module_with_license_todo.py", "#", None, True), 687 | ("module_with_license_and_shebang.py", "#", None, False), 688 | ("module_with_license_and_shebang_todo.py", "#", None, True), 689 | ), 690 | ( 691 | ( 692 | ( 693 | "--license-filepath", 694 | "LICENSE_with_trailing_newline.txt", 695 | ), 696 | ), 697 | ( 698 | ( 699 | "--license-filepath", 700 | "LICENSE_without_trailing_newline.txt", 701 | ), 702 | ), 703 | ), 704 | ), 705 | ), 706 | ), 707 | ) 708 | def test_fuzzy_match_license( 709 | license_file_path, 710 | line_ending, 711 | src_file_path, 712 | comment_style, 713 | new_src_file_expected, 714 | fail_check, 715 | extra_args, 716 | tmpdir, 717 | ): 718 | with chdir_to_test_resources(): 719 | path = tmpdir.join("src_file_path") 720 | shutil.copy(src_file_path, path.strpath) 721 | _convert_line_ending(path.strpath, line_ending) 722 | args = [ 723 | "--license-filepath", 724 | license_file_path, 725 | "--comment-style", 726 | comment_style, 727 | "--fuzzy-match-generates-todo", 728 | path.strpath, 729 | ] 730 | if extra_args is not None: 731 | args.extend(extra_args) 732 | assert insert_license(args) == (1 if fail_check else 0) 733 | if new_src_file_expected: 734 | with open(new_src_file_expected, encoding="utf-8") as expected_content_file: 735 | expected_content = expected_content_file.read() 736 | new_file_content = path.open(encoding="utf-8").read() 737 | assert new_file_content == expected_content 738 | 739 | 740 | @pytest.mark.parametrize( 741 | ("src_file_content", "expected_index", "match_years_strictly"), 742 | ( 743 | (["foo\n", "bar\n"], None, True), 744 | (["# License line 1\n", "# Copyright 2017\n", "\n", "foo\n", "bar\n"], 0, True), 745 | (["\n", "# License line 1\n", "# Copyright 2017\n", "foo\n", "bar\n"], 1, True), 746 | ( 747 | ["\n", "# License line 1\n", "# Copyright 2017\n", "foo\n", "bar\n"], 748 | 1, 749 | False, 750 | ), 751 | ( 752 | ["# License line 1\n", "# Copyright 1984\n", "\n", "foo\n", "bar\n"], 753 | None, 754 | True, 755 | ), 756 | ( 757 | ["# License line 1\n", "# Copyright 1984\n", "\n", "foo\n", "bar\n"], 758 | 0, 759 | False, 760 | ), 761 | ( 762 | [ 763 | "\n", 764 | "# License line 1\n", 765 | "# Copyright 2013,2015-2016\n", 766 | "foo\n", 767 | "bar\n", 768 | ], 769 | 1, 770 | False, 771 | ), 772 | ), 773 | ) 774 | def test_is_license_present(src_file_content, expected_index, match_years_strictly): 775 | license_info = LicenseInfo( 776 | plain_license="", 777 | eol="\n", 778 | comment_start="", 779 | comment_prefix="#", 780 | comment_end="", 781 | num_extra_lines=0, 782 | prefixed_license=["# License line 1\n", "# Copyright 2017\n"], 783 | ) 784 | assert expected_index == find_license_header_index( 785 | src_file_content, license_info, 5, match_years_strictly=match_years_strictly 786 | ) 787 | 788 | 789 | @pytest.mark.parametrize( 790 | ( 791 | "license_file_path", 792 | "line_ending", 793 | "src_file_path", 794 | "comment_style", 795 | "fuzzy_match", 796 | "new_src_file_expected", 797 | "fail_check", 798 | "use_current_year", 799 | ), 800 | map( 801 | lambda a: a[:2] + a[2], 802 | product( # combine license files with other args 803 | ( 804 | "LICENSE_with_trailing_newline.txt", 805 | "LICENSE_without_trailing_newline.txt", 806 | ), 807 | ("\n", "\r\n"), 808 | ( 809 | ( 810 | "module_with_license.css", 811 | "/*| *| */", 812 | False, 813 | "module_without_license.css", 814 | True, 815 | False, 816 | ), 817 | ( 818 | "module_with_license_and_few_words.css", 819 | "/*| *| */", 820 | False, 821 | "module_without_license_and_few_words.css", 822 | True, 823 | False, 824 | ), 825 | ("module_with_license_todo.css", "/*| *| */", False, None, True, False), 826 | ( 827 | "module_with_fuzzy_matched_license.css", 828 | "/*| *| */", 829 | False, 830 | None, 831 | False, 832 | False, 833 | ), 834 | ("module_without_license.css", "/*| *| */", False, None, False, False), 835 | ( 836 | "module_with_license.py", 837 | "#", 838 | False, 839 | "module_without_license.py", 840 | True, 841 | False, 842 | ), 843 | ( 844 | "module_with_license_and_shebang.py", 845 | "#", 846 | False, 847 | "module_without_license_and_shebang.py", 848 | True, 849 | False, 850 | ), 851 | ( 852 | "init_with_license.py", 853 | "#", 854 | False, 855 | "init_without_license.py", 856 | True, 857 | False, 858 | ), 859 | ( 860 | "init_with_license_and_newline.py", 861 | "#", 862 | False, 863 | "init_without_license.py", 864 | True, 865 | False, 866 | ), 867 | # Fuzzy match 868 | ( 869 | "module_with_license.css", 870 | "/*| *| */", 871 | True, 872 | "module_without_license.css", 873 | True, 874 | False, 875 | ), 876 | ("module_with_license_todo.css", "/*| *| */", True, None, True, False), 877 | ( 878 | "module_with_fuzzy_matched_license.css", 879 | "/*| *| */", 880 | True, 881 | "module_with_license_todo.css", 882 | True, 883 | False, 884 | ), 885 | ("module_without_license.css", "/*| *| */", True, None, False, False), 886 | ( 887 | "module_with_license_and_shebang.py", 888 | "#", 889 | True, 890 | "module_without_license_and_shebang.py", 891 | True, 892 | False, 893 | ), 894 | # Strict and flexible years 895 | ( 896 | "module_with_stale_year_in_license.py", 897 | "#", 898 | False, 899 | None, 900 | False, 901 | False, 902 | ), 903 | ( 904 | "module_with_stale_year_range_in_license.py", 905 | "#", 906 | False, 907 | None, 908 | False, 909 | False, 910 | ), 911 | ( 912 | "module_with_license.py", 913 | "#", 914 | False, 915 | "module_without_license.py", 916 | True, 917 | True, 918 | ), 919 | ( 920 | "module_with_stale_year_in_license.py", 921 | "#", 922 | False, 923 | "module_without_license.py", 924 | True, 925 | True, 926 | ), 927 | ( 928 | "module_with_stale_year_range_in_license.py", 929 | "#", 930 | False, 931 | "module_without_license.py", 932 | True, 933 | True, 934 | ), 935 | ( 936 | "module_with_badly_formatted_stale_year_range_in_license.py", 937 | "#", 938 | False, 939 | "module_without_license.py", 940 | True, 941 | True, 942 | ), 943 | ), 944 | ), 945 | ), 946 | ) 947 | def test_remove_license( 948 | license_file_path, 949 | line_ending, 950 | src_file_path, 951 | comment_style, 952 | fuzzy_match, 953 | new_src_file_expected, 954 | fail_check, 955 | use_current_year, 956 | tmpdir, 957 | ): 958 | with chdir_to_test_resources(): 959 | path = tmpdir.join("src_file_path") 960 | shutil.copy(src_file_path, path.strpath) 961 | _convert_line_ending(path.strpath, line_ending) 962 | argv = [ 963 | "--license-filepath", 964 | license_file_path, 965 | "--remove-header", 966 | path.strpath, 967 | "--comment-style", 968 | comment_style, 969 | ] 970 | if fuzzy_match: 971 | argv = ["--fuzzy-match-generates-todo"] + argv 972 | if use_current_year: 973 | argv = ["--use-current-year"] + argv 974 | assert insert_license(argv) == (1 if fail_check else 0) 975 | if new_src_file_expected: 976 | with open(new_src_file_expected, encoding="utf-8") as expected_content_file: 977 | expected_content = expected_content_file.read() 978 | new_file_content = path.open(encoding="utf-8").read() 979 | assert new_file_content == expected_content 980 | --------------------------------------------------------------------------------