├── 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 | [](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 |
--------------------------------------------------------------------------------