diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml index 050b7e6..0c11ffd 100644 --- a/.github/workflows/quality_check.yml +++ b/.github/workflows/quality_check.yml @@ -20,4 +20,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --statistics - name: Pytest run: | - pytest main/githooks.py + pytest main/githooks.py test/test_licence_headers.py diff --git a/.github/workflows/status_check.yml b/.github/workflows/status_check.yml index a86b48f..86a0853 100644 --- a/.github/workflows/status_check.yml +++ b/.github/workflows/status_check.yml @@ -26,4 +26,4 @@ jobs: - uses: ./ with: commitMessage: ${{ env.commit_message }} - licenseCheck: true + licenceCheck: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae90a5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ diff --git a/README.md b/README.md index cc73bad..13a9432 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The commit will be flagged if it includes certain text files with: * Tabs * Missing terminating newline for certain files * Certain C++ #include patterns and std::exception -* Missing or non-compliant CCDC copyright and license headers (when using the GitHub Action or local copywrite integration) +* Missing or non-compliant CCDC copyright and licence headers (when header validation is enabled) The commit will also be flagged if the commit message does not include a Jira ID (unless marked with NO_JIRA or a Copilot Autofix co-author line), or if the @@ -52,42 +52,44 @@ headers and file compliance rules in CI. - uses: ccdc-opensource/commit-hooks@v8 with: commitMessage: ${{ env.commit_message }} - # Optional: enable CCDC license header validation on PR changed files - licenseCheck: true # default: false (opt-in) + # Optional: enable CCDC licence header validation on PR changed files + licenceCheck: true # default: false (opt-in) ``` A complete workflow template for CI is available in [templates/compliance.yml](templates/compliance.yml). # Native Git Hooks -To enable CCDC commit checks (Jira ID, CRLF, line endings, DO NOT COMMIT, file size, and automatic copyright headers) globally for all repositories on your machine: +To enable CCDC commit checks (Jira ID, CRLF, line endings, DO NOT COMMIT, file size, and automatic copyright headers): 1. Clone this repository. -2. Run: - ```bash - git config --global core.hooksPath /main - ``` -3. (Optional) Install `copywrite` to automatically add and format CCDC copyright headers on commit: - * **Windows:** `choco install copywrite` - * **macOS:** `brew install hashicorp/tap/copywrite` - * **Linux:** `go install github.com/hashicorp/copywrite@latest` - -> **Note:** If `copywrite` is not installed on your machine, native hooks will continue to run all other standard checks and display a gentle warning without failing your commit. - -## Configuring Copywrite Behavior - -Developers can customise the copywrite hook using Git configuration: - -* **Enable / Disable Copywrite:** +2. Configure `core.hooksPath`: + * **Globally (for all repositories on your machine):** + ```bash + git config --global core.hooksPath /main + ``` + * **Locally (for a single repository only):** + Run inside the target repository: + ```bash + git config core.hooksPath /main + ``` + *(Only use `--global` if you want the hooks applied across all repositories.)* +3. (Optional) Enable automatic CCDC copyright and licence header formatting as described below. + +## Configuring Licence Header Behavior + +Developers can customise the licence header hook using Git configuration (use `--global` for all repos, or omit it within a specific repo): + +* **Enable / Disable Header Formatting:** ```bash - git config --global hooks.copywrite true # opt-in: enable copywrite integration - git config --global hooks.copywrite false # default: disabled + git config --global hooks.licenceCheck true # opt-in: enable licence header formatting + git config --global hooks.licenceCheck false # default: disabled ``` * **Set Mode (`fix` vs `check`):** ```bash - git config --global hooks.copywriteMode fix # default: automatically inserts/updates headers on commit - git config --global hooks.copywriteMode check # read-only check (warns/fails if headers are missing) + git config --global hooks.licenceCheckMode fix # default: automatically inserts/updates headers on commit + git config --global hooks.licenceCheckMode check # read-only check (warns/fails if headers are missing) ``` ## Recommended settings diff --git a/action.yml b/action.yml index 095af22..de92503 100644 --- a/action.yml +++ b/action.yml @@ -20,26 +20,23 @@ inputs: commitMessage: description: 'The commit message' required: true + licenceCheck: + description: 'Validate CCDC copyright and licence headers on changed files (true/false)' + required: false + default: 'false' licenseCheck: - description: 'Validate CCDC copyright and license headers on changed files (true/false)' + description: 'Alias for licenceCheck (true/false)' required: false default: 'false' runs: using: composite steps: - - name: Install copywrite - if: ${{ inputs.licenseCheck == 'true' }} - uses: hashicorp/setup-copywrite@v1.1.3 - - name: Validate Header Compliance - if: ${{ inputs.licenseCheck == 'true' }} + if: ${{ inputs.licenceCheck == 'true' || inputs.licenseCheck == 'true' }} shell: bash env: - COPYWRITE_HOOK_ROOT: ${{ github.action_path }} GITHUB_EVENT_BEFORE: ${{ github.event.before }} run: | - copywrite --version - CHANGED_FILES=() if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_BASE_REF:-}" ]; then DIFF_REF="origin/${GITHUB_BASE_REF}" @@ -82,11 +79,7 @@ runs: if [ ${#CHANGED_FILES[@]} -gt 0 ]; then echo "Checking ${#CHANGED_FILES[@]} changed file(s) for header compliance..." - copywrite headers \ - --config "$GITHUB_ACTION_PATH/main/copywrite/.copywrite.hcl" \ - --plan \ - -- \ - "${CHANGED_FILES[@]}" + python3 "$GITHUB_ACTION_PATH/main/licence_headers.py" check -- "${CHANGED_FILES[@]}" else echo "No added or modified files found to check for header compliance." fi diff --git a/main/commit-msg b/main/commit-msg index ebdc690..50f7c8a 100755 --- a/main/commit-msg +++ b/main/commit-msg @@ -35,4 +35,4 @@ case "$OSTYPE" in ;; esac -"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]%.*}.py" "$@" +"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]}.py" "$@" diff --git a/main/copywrite/.copywrite.hcl b/main/copywrite/.copywrite.hcl deleted file mode 100644 index c76c316..0000000 --- a/main/copywrite/.copywrite.hcl +++ /dev/null @@ -1,37 +0,0 @@ -schema_version = 1 - -project { - copyright_holder = "The Cambridge Crystallographic Data Centre (CCDC)" - - header_ignore = [ - ".git/**", - ".github/**", - "test/**", - "tests/**", - "templates/**", - "**/bin/**", - "**/obj/**", - "**/packages/**", - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/.venv/**", - "**/venv/**", - "**/__pycache__/**", - "**/*.Designer.cs", - "**/*.g.cs", - "**/*.generated.*", - "**/*.min.js", - "**/*.lock", - ] -} - -rule { - paths = ["**/*.py", "**/*.sh", "**/*.bash", "**/*.yaml", "**/*.yml"] - license_header = "${COPYWRITE_HOOK_ROOT}/main/copywrite/headers/ccdc_hash.tmpl" -} - -rule { - paths = ["**/*.js", "**/*.ts", "**/*.cs", "**/*.cpp", "**/*.cxx", "**/*.cc", "**/*.h", "**/*.hpp"] - license_header = "${COPYWRITE_HOOK_ROOT}/main/copywrite/headers/ccdc_slash.tmpl" -} \ No newline at end of file diff --git a/main/githooks.py b/main/githooks.py index 839e24f..05bc266 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -27,11 +27,15 @@ import os import platform import re -import shutil import subprocess import unittest import sys +try: + import licence_headers +except ImportError: + licence_headers = None + # Absolute file size limit (in MB) - it's 100MB on github.com HARD_SIZE_THRESHOLD = 99.0 @@ -216,14 +220,26 @@ def get_commit_files(): else: commands += ['HEAD~..', '--'] else: - commands = ['git', 'diff-index', '--ignore-submodules', 'HEAD', '--cached'] + commands = ['git', 'diff', '--cached', '--ignore-submodules', '--name-status', '--'] output = _get_output(commands) result = defaultdict(list) for line in output.splitlines(): - parts = line.split() - if parts[-2] in ['M', 'A']: - result[parts[-2]].append(parts[-1]) + if not line: + continue + if '\t' in line: + parts = line.split('\t') + status = parts[0].strip() + path = parts[-1] + else: + parts = line.split(None, 1) + if len(parts) != 2: + continue + status, path = parts[0].strip(), parts[1] + if status in ['M', 'A']: + result[status].append(path) + elif status.startswith(('R', 'C')): + result['A'].append(path) return result @@ -289,7 +305,7 @@ def get_changed_lines(modified_file): else: commands += ['HEAD~', '--', modified_file] else: - commands = [f'git', 'diff-index', 'HEAD', '--unified=0', '--', f'{modified_file}'] + commands = [f'git', 'diff', '--cached', '--unified=0', '--', f'{modified_file}'] output = _get_output(commands) lines = [] @@ -337,6 +353,22 @@ def test_push_without_before_uses_previous_commit(self): ['git', 'diff', '--unified=0', 'HEAD~', '--', 'example.py'] ) + def test_files_with_spaces_and_renames(self): + with patch.dict(os.environ, {}, clear=True), patch('githooks._get_output') as get_output: + get_output.return_value = ( + 'M\tpath/with spaces/file.py\n' + 'A\tanother file.js\n' + 'R100\told/name.py\tnew/renamed file.py\n' + 'C100\tsrc.h\tdst/copied file.h\n' + 'A\tbad/end/space.txt \n' + ) + files = get_commit_files() + self.assertEqual(['path/with spaces/file.py'], files['M']) + self.assertEqual( + ['another file.js', 'new/renamed file.py', 'dst/copied file.h', 'bad/end/space.txt '], + files['A'] + ) + def yield_changed_lines(changed_lines): '''Yield individual line numbers from list returned by get_changed_lines''' @@ -1033,54 +1065,35 @@ def _test(input, is_good=True): _test('Close but no cigar abc-1234', False) -def run_copywrite(files): - '''Run copywrite to automatically check or fix license headers. +def run_licence_check(files): + '''Check or fix complete CCDC licence headers. Configurable via git config: - - `git config --global hooks.copywrite true|false` (default: false, opt-in) - - `git config --global hooks.copywriteMode fix|check` (default: fix) + - `git config --global hooks.licenceCheck true|false` (default: false, opt-in) + - `git config --global hooks.licenceCheckMode fix|check` (default: fix) - fix: automatically adds/updates headers and restages files - check: checks header compliance and warns/fails without modifying - - If copywrite is not installed and the hook is enabled, print a soft warning and return 0 (do not block commit). ''' if not files: return 0 # Opt-in: only run if explicitly enabled in git config - enabled_setting = get_config_setting('hooks.copywrite') + enabled_setting = get_config_setting('hooks.licenceCheck') if enabled_setting is None or enabled_setting.lower() not in ['true', '1', 'yes', 'on']: return 0 - copywrite_exe = shutil.which('copywrite') - if not copywrite_exe: - print(' WARNING: "copywrite" not found on PATH. Skipping copyright header check.') - print(' To enable automatic copyright formatting, install copywrite:') - print(' - Windows: choco install copywrite') - print(' - macOS: brew install hashicorp/tap/copywrite') - print(' - Linux: go install github.com/hashicorp/copywrite@latest') - return 0 - - hook_root = Path(__file__).resolve().parent.parent - config_path = hook_root / 'main' / 'copywrite' / '.copywrite.hcl' - if not config_path.is_file(): - _fail(f'Copywrite configuration not found: {config_path}') - return 1 - - env = os.environ.copy() - env['COPYWRITE_HOOK_ROOT'] = str(hook_root) - - mode = (get_config_setting('hooks.copywriteMode') or 'fix').lower() + mode = (get_config_setting('hooks.licenceCheckMode') or 'fix').lower() if mode not in ['fix', 'check', 'plan', 'verify']: - _fail(f'Unsupported hooks.copywriteMode value: {mode}') + _fail(f'Unsupported hooks.licenceCheckMode value: {mode}') return 1 is_check_mode = mode in ['check', 'plan', 'verify'] - cmd = [copywrite_exe, 'headers', f'--config={config_path}'] - if is_check_mode: - cmd.append('--plan') - cmd.append('--') - cmd.extend(files) + if licence_headers is None: + _fail( + 'Licence header checking is enabled but the ' + '"licence_headers" module is not installed.' + ) + return 1 try: if not is_check_mode: @@ -1091,24 +1104,17 @@ def run_copywrite(files): text=True ) if unstaged.returncode == 1: - _fail('Copywrite fix mode cannot run with unstaged changes in staged files. ' - 'Stage or stash those changes, or use hooks.copywriteMode check.') + _fail('Licence check fix mode cannot run with unstaged changes in staged files. ' + 'Stage or stash those changes, or use hooks.licenceCheckMode check.') return 1 if unstaged.returncode != 0: _fail(f'Unable to inspect unstaged changes:\n{unstaged.stderr.strip()}') return 1 - proc = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env - ) - if proc.returncode != 0: - details = '\n'.join(output.strip() for output in [proc.stdout, proc.stderr] if output.strip()) - _fail(f'Copyright header update failed:\n{details}') + if licence_headers.process_files(files, fix=not is_check_mode) != 0: + _fail('Copyright and licence header check failed.') return 1 + if not is_check_mode: restage = subprocess.run( ['git', 'add', '--'] + files, @@ -1117,77 +1123,81 @@ def run_copywrite(files): text=True ) if restage.returncode != 0: - _fail(f'Unable to restage files updated by Copywrite:\n{restage.stderr.strip()}') + _fail(f'Unable to restage files updated by the licence header fixer:\n{restage.stderr.strip()}') return 1 - except (OSError, subprocess.SubprocessError) as error: - _fail(f'Failed to run Copywrite: {error}') + except (OSError, subprocess.SubprocessError, UnicodeError) as error: + _fail(f'Failed to process licence headers: {error}') return 1 return 0 -class TestRunCopywrite(unittest.TestCase): +class TestRunLicenceCheck(unittest.TestCase): @patch('githooks.get_config_setting', return_value=None) def test_disabled(self, _config): - self.assertEqual(0, run_copywrite(['example.py'])) + self.assertEqual(0, run_licence_check(['example.py'])) - @patch('githooks.shutil.which', return_value=None) - @patch('githooks.get_config_setting', return_value='true') - def test_missing_executable_is_soft_failure(self, _config, _which): - self.assertEqual(0, run_copywrite(['example.py'])) + @patch('githooks.get_config_setting', side_effect=['true', 'check']) + @patch('githooks.licence_headers') + def test_check_failure_blocks_commit(self, mock_licence, _config): + mock_licence.process_files.return_value = 1 + self.assertEqual(1, run_licence_check(['example.py'])) + mock_licence.process_files.assert_called_once_with(['example.py'], fix=False) - @patch('githooks.Path.is_file', return_value=True) - @patch('githooks.shutil.which', return_value='copywrite') @patch('githooks.get_config_setting', side_effect=['true', 'check']) - @patch('githooks.subprocess.run') - def test_check_failure_blocks_commit(self, run, _config, _which, _is_file): - run.return_value = subprocess.CompletedProcess([], 1, 'stdout', 'stderr') - self.assertEqual(1, run_copywrite(['example.py'])) + @patch('githooks.licence_headers') + def test_check_mode_allows_unstaged_changes(self, mock_licence, _config): + mock_licence.process_files.return_value = 0 + self.assertEqual(0, run_licence_check(['example.py'])) + mock_licence.process_files.assert_called_once_with(['example.py'], fix=False) - @patch('githooks.Path.is_file', return_value=True) - @patch('githooks.shutil.which', return_value='copywrite') @patch('githooks.get_config_setting', side_effect=['true', 'fix']) @patch('githooks.subprocess.run') - def test_fix_restages_files(self, run, _config, _which, _is_file): + @patch('githooks.licence_headers') + def test_fix_restages_files(self, mock_licence, run, _config): + mock_licence.process_files.return_value = 0 run.side_effect = [ subprocess.CompletedProcess([], 0, '', ''), subprocess.CompletedProcess([], 0, '', ''), - subprocess.CompletedProcess([], 0, '', ''), ] - self.assertEqual(0, run_copywrite(['example.py'])) - self.assertEqual( - ['copywrite', 'headers', unittest.mock.ANY, '--', 'example.py'], - run.call_args_list[1].args[0] - ) + self.assertEqual(0, run_licence_check(['example.py'])) + mock_licence.process_files.assert_called_once_with(['example.py'], fix=True) self.assertEqual(['git', 'add', '--', 'example.py'], run.call_args_list[-1].args[0]) - @patch('githooks.Path.is_file', return_value=True) - @patch('githooks.shutil.which', return_value='copywrite') @patch('githooks.get_config_setting', side_effect=['true', 'fix']) @patch('githooks.subprocess.run') - def test_restage_failure_blocks_commit(self, run, _config, _which, _is_file): + @patch('githooks.licence_headers') + def test_restage_failure_blocks_commit(self, mock_licence, run, _config): + mock_licence.process_files.return_value = 0 run.side_effect = [ - subprocess.CompletedProcess([], 0, '', ''), subprocess.CompletedProcess([], 0, '', ''), subprocess.CompletedProcess([], 1, '', 'cannot add'), ] - self.assertEqual(1, run_copywrite(['example.py'])) + self.assertEqual(1, run_licence_check(['example.py'])) - @patch('githooks.Path.is_file', return_value=True) - @patch('githooks.shutil.which', return_value='copywrite') @patch('githooks.get_config_setting', side_effect=['true', 'fix']) @patch('githooks.subprocess.run') - def test_fix_rejects_partially_staged_files(self, run, _config, _which, _is_file): + @patch('githooks.licence_headers') + def test_fix_rejects_partially_staged_files(self, _mock_licence, run, _config): run.return_value = subprocess.CompletedProcess([], 1, '', '') - self.assertEqual(1, run_copywrite(['example.py'])) + self.assertEqual(1, run_licence_check(['example.py'])) run.assert_called_once() - @patch('githooks.Path.is_file', return_value=True) - @patch('githooks.shutil.which', return_value='copywrite') @patch('githooks.get_config_setting', side_effect=['true', 'check']) - @patch('githooks.subprocess.run', side_effect=OSError('cannot execute')) - def test_subprocess_error_blocks_commit(self, _run, _config, _which, _is_file): - self.assertEqual(1, run_copywrite(['example.py'])) + @patch('githooks.licence_headers') + def test_processing_error_blocks_commit(self, mock_licence, _config): + mock_licence.process_files.side_effect = OSError('cannot read') + self.assertEqual(1, run_licence_check(['example.py'])) + + @patch('githooks.get_config_setting', return_value=None) + @patch('githooks.licence_headers', None) + def test_missing_module_allowed_when_disabled(self, _config): + self.assertEqual(0, run_licence_check(['example.py'])) + + @patch('githooks.get_config_setting', side_effect=['true', 'check']) + @patch('githooks.licence_headers', None) + def test_missing_module_fails_when_enabled(self, _config): + self.assertEqual(1, run_licence_check(['example.py'])) def commit_hook(merge=False): @@ -1205,7 +1215,7 @@ def commit_hook(merge=False): staged_files = files['M'] + files['A'] print(' Check and update copyright headers ...') - retval += run_copywrite(staged_files) + retval += run_licence_check(staged_files) print(' Check filenames ...') retval += check_filenames(staged_files) diff --git a/main/copywrite/headers/ccdc_hash.tmpl b/main/headers/ccdc_hash.tmpl similarity index 100% rename from main/copywrite/headers/ccdc_hash.tmpl rename to main/headers/ccdc_hash.tmpl diff --git a/main/copywrite/headers/ccdc_slash.tmpl b/main/headers/ccdc_slash.tmpl similarity index 100% rename from main/copywrite/headers/ccdc_slash.tmpl rename to main/headers/ccdc_slash.tmpl diff --git a/main/licence_headers.py b/main/licence_headers.py new file mode 100644 index 0000000..bc0b053 --- /dev/null +++ b/main/licence_headers.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# +'''Check and fix full CCDC copyright and licence headers.''' + +import argparse +import codecs +from datetime import datetime +from io import BytesIO +from pathlib import Path, PurePosixPath +import re +import sys +import tokenize + + +HASH_EXTENSIONS = {'.py', '.sh', '.bash', '.yaml', '.yml'} +SLASH_EXTENSIONS = {'.js', '.ts', '.cs', '.cpp', '.cxx', '.cc', '.h', '.hpp'} +IGNORED_DIRECTORIES = { + '.git', '.github', 'test', 'tests', 'templates', 'bin', 'obj', 'packages', + 'node_modules', 'dist', 'build', '.venv', 'venv', '__pycache__' +} +IGNORED_SUFFIXES = ('.designer.cs', '.g.cs', '.min.js', '.lock') +TEMPLATE_DIRECTORY = Path(__file__).resolve().parent / 'headers' +PYTHON_ENCODING_PATTERN = re.compile(r'^[ \t\f]*#.*?coding[:=][ \t]*[-\w.]+') +LEGACY_HEADER_PATTERN = re.compile( + r'^(#|//) Copyright The Cambridge Crystallographic Data Centre ' + r'\(CCDC\) \d{4}(?:, \d{4})?\r?\n?$' +) + + +def _comment_style(filename): + path = PurePosixPath(str(filename).replace('\\', '/')) + lower_name = path.name.lower() + if any(part.lower() in IGNORED_DIRECTORIES for part in path.parts): + return None + if lower_name.endswith(IGNORED_SUFFIXES) or '.generated.' in lower_name: + return None + if path.suffix.lower() in HASH_EXTENSIONS: + return 'hash' + if path.suffix.lower() in SLASH_EXTENSIONS: + return 'slash' + return None + + +def _render_header(style, newline='\n', year=None): + template_path = TEMPLATE_DIRECTORY / f'ccdc_{style}.tmpl' + template = template_path.read_text(encoding='utf-8') + rendered = template.replace('{{ .Year }}', str(year or datetime.now().year)) + return rendered.replace('\n', newline) + + +def _decode_content(filename, data): + if not isinstance(data, bytes): + return data, None + if data.startswith(codecs.BOM_UTF8): + encoding = 'utf-8-sig' + elif str(filename).lower().endswith('.py'): + encoding, _ = tokenize.detect_encoding(BytesIO(data).readline) + else: + encoding = 'utf-8' + return data.decode(encoding), encoding + + +def _header_offset(filename, text, style): + lines = text.splitlines(keepends=True) + has_shebang = bool(lines and lines[0].startswith('#!')) + if not str(filename).lower().endswith('.py'): + return len(lines[0]) if has_shebang else 0 + + candidate_indexes = [1] if has_shebang else [0] + if ( + not has_shebang + and len(lines) > 1 + and ( + lines[0].strip() == '' + or lines[0].lstrip().startswith('#') + ) + ): + candidate_indexes.append(1) + + for index in candidate_indexes: + if index < len(lines) and PYTHON_ENCODING_PATTERN.match(lines[index]): + return sum(len(line) for line in lines[:index + 1]) + return len(lines[0]) if has_shebang else 0 + + +def _header_line_matches(actual, expected): + if 'This code is Copyright (C)' in expected: + marker = expected.split(' ', 1)[0] + pattern = ( + rf'{re.escape(marker)} This code is Copyright \(C\) ' + r'\d{4}(?:, \d{4})? ' + r'The Cambridge Crystallographic Data Centre \(CCDC\)' + ) + return re.fullmatch(pattern, actual) is not None + return actual == expected + + +def _known_header_prefix_end(text, offset, expected): + actual_lines = [] + position = offset + for line in text[offset:].splitlines(keepends=True): + actual_lines.append((position, position + len(line), line.strip())) + position += len(line) + + expected_lines = [line.strip() for line in expected.splitlines()] + if not actual_lines or not expected_lines: + return offset + + if actual_lines[0][2] == expected_lines[0] and len(actual_lines) > 1: + if not _header_line_matches(actual_lines[1][2], expected_lines[1]): + return offset + header_end = actual_lines[1][1] + start_idx = 2 + exp_start_idx = 2 + elif _header_line_matches(actual_lines[0][2], expected_lines[1]): + header_end = actual_lines[0][1] + start_idx = 1 + exp_start_idx = 2 + else: + return offset + + for actual_idx, exp_idx in enumerate(range(exp_start_idx, len(expected_lines)), start=start_idx): + if actual_idx >= len(actual_lines): + break + if not _header_line_matches(actual_lines[actual_idx][2], expected_lines[exp_idx]): + break + header_end = actual_lines[actual_idx][1] + return header_end + + +def _damaged_full_header_end(text, offset, style): + marker = '#' if style == 'hash' else '//' + lines = text[offset:].splitlines(keepends=True) + if not lines: + return offset + + first_line = lines[0].strip() + if first_line == marker and len(lines) > 1: + identity_line = lines[1].strip() + start_index = 2 + else: + identity_line = first_line + start_index = 1 + + if ( + not identity_line.startswith(f'{marker} This code is ') + or 'Crystallographic Data Centre (CCDC)' not in identity_line + ): + return offset + + position = offset + line_positions = [] + for line in lines: + line_positions.append((position, position + len(line), line.strip())) + position += len(line) + + for index in range(start_index, len(line_positions)): + _, line_end, stripped = line_positions[index] + if stripped and not stripped.startswith(marker): + break + if (stripped.startswith(marker) and stripped.endswith('law.')) or 'void or unenforceable under governing' in stripped: + if stripped.endswith('law.'): + if index + 1 < len(line_positions) and line_positions[index + 1][2] == marker: + return line_positions[index + 1][1] + return line_end + elif index + 1 < len(line_positions) and line_positions[index + 1][2].startswith(marker) and line_positions[index + 1][2].endswith('law.'): + next_index = index + 1 + if next_index + 1 < len(line_positions) and line_positions[next_index + 1][2] == marker: + return line_positions[next_index + 1][1] + return line_positions[next_index][1] + return offset + + +def _existing_header_end(text, offset, style, expected): + lines = text[offset:].splitlines(keepends=True) + if lines and LEGACY_HEADER_PATTERN.match(lines[0]): + if len(lines) > 1 and lines[1].strip() == '': + return offset + len(lines[0]) + len(lines[1]) + return offset + len(lines[0]) + damaged_header_end = _damaged_full_header_end(text, offset, style) + if damaged_header_end != offset: + return damaged_header_end + return _known_header_prefix_end(text, offset, expected) + + +def _has_valid_header(text, offset, expected): + '''Check if text starting at offset contains a valid compliant header (accepting any valid year).''' + if text.startswith(expected, offset): + return True + actual_lines = [line.strip() for line in text[offset:].splitlines()] + expected_lines = [line.strip() for line in expected.splitlines()] + if len(actual_lines) < len(expected_lines): + return False + for actual, exp in zip(actual_lines[:len(expected_lines)], expected_lines): + if not _header_line_matches(actual, exp): + return False + return True + + +def check_content(filename, data, year=None): + '''Return an error message when a supported file lacks the exact header.''' + style = _comment_style(filename) + if style is None: + return None + try: + text, _ = _decode_content(filename, data) + except (LookupError, SyntaxError, UnicodeDecodeError): + return 'file encoding could not be decoded' + + newline = '\r\n' if '\r\n' in text else '\n' + offset = _header_offset(filename, text, style) + expected = _render_header(style, newline, year) + if _has_valid_header(text, offset, expected): + return None + return 'missing or non-compliant CCDC copyright and licence header' + + +def fix_content(filename, data, year=None): + '''Return content with the full rendered header for supported files.''' + style = _comment_style(filename) + if style is None: + return data + + text, encoding = _decode_content(filename, data) + newline = '\r\n' if '\r\n' in text else '\n' + offset = _header_offset(filename, text, style) + expected = _render_header(style, newline, year) + if _has_valid_header(text, offset, expected): + return data + + header_end = _existing_header_end(text, offset, style, expected) + prefix = text[:offset] + if prefix and not prefix.endswith(('\n', '\r')): + prefix += newline + fixed = prefix + expected + text[header_end:] + return fixed.encode(encoding) if encoding else fixed + + +def process_files(files, fix=False, year=None): + failures = 0 + for filename in files: + path = Path(filename) + if path.is_symlink() or not path.is_file() or _comment_style(filename) is None: + continue + data = path.read_bytes() + issue = check_content(filename, data, year) + if issue is None: + continue + if fix: + fixed = fix_content(filename, data, year) + if fixed != data: + path.write_bytes(fixed) + print(f'Updated CCDC licence header: {filename}') + else: + print(f'HEADER FAIL: {filename}: {issue}') + failures += 1 + return failures + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('mode', choices=['check', 'fix']) + parser.add_argument('files', nargs='*') + arguments = parser.parse_args(argv) + return process_files(arguments.files, fix=arguments.mode == 'fix') + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/main/pre-commit b/main/pre-commit index ebdc690..50f7c8a 100755 --- a/main/pre-commit +++ b/main/pre-commit @@ -35,4 +35,4 @@ case "$OSTYPE" in ;; esac -"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]%.*}.py" "$@" +"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]}.py" "$@" diff --git a/main/pre-merge-commit b/main/pre-merge-commit index ebdc690..50f7c8a 100755 --- a/main/pre-merge-commit +++ b/main/pre-merge-commit @@ -35,4 +35,4 @@ case "$OSTYPE" in ;; esac -"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]%.*}.py" "$@" +"${PYTHON_EXECUTABLE[@]}" "${BASH_SOURCE[0]}.py" "$@" diff --git a/templates/compliance.yml b/templates/compliance.yml index fc618cd..3e48b65 100644 --- a/templates/compliance.yml +++ b/templates/compliance.yml @@ -36,4 +36,4 @@ jobs: uses: ccdc-opensource/commit-hooks@v8 with: commitMessage: ${{ env.commit_message }} - licenseCheck: true + licenceCheck: true diff --git a/test/test_licence_headers.py b/test/test_licence_headers.py new file mode 100644 index 0000000..7b96484 --- /dev/null +++ b/test/test_licence_headers.py @@ -0,0 +1,328 @@ +from pathlib import Path +import sys + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'main')) +import licence_headers + + +def test_complete_hash_header_passes(): + header = licence_headers._render_header('hash', year=2026) + assert licence_headers.check_content('example.py', header + 'print("ok")\n', 2026) is None + + +def test_existing_file_with_earlier_year_passes_and_is_not_modified(): + # An existing file with 2020 should pass and NOT be bumped to 2026 + header_2020 = licence_headers._render_header('hash', year=2020) + source = header_2020 + 'print("ok")\n' + assert licence_headers.check_content('example.py', source, year=2026) is None + fixed = licence_headers.fix_content('example.py', source, year=2026) + assert fixed == source + assert 'Copyright (C) 2020' in fixed + + +def test_literal_year_token_fails(): + header = licence_headers._render_header('hash', year=2026).replace('2026', '{{ .Year }}') + assert licence_headers.check_content('example.py', header, 2026) is not None + + +def test_removed_licence_line_fails_and_is_repaired(): + header = licence_headers._render_header('hash', year=2026) + broken = header.replace('# copied, except in accordance with a valid licence agreement with CCDC and\n', '') + content = broken + 'print("ok")\n' + assert licence_headers.check_content('example.py', content, 2026) is not None + fixed = licence_headers.fix_content('example.py', content, 2026) + assert fixed == header + 'print("ok")\n' + + +def test_missing_header_is_added_after_shebang(): + fixed = licence_headers.fix_content('script.py', '#!/usr/bin/env python3\nprint("ok")\n', 2026) + assert fixed.startswith('#!/usr/bin/env python3\n#\n# This code is Copyright (C) 2026') + assert fixed.endswith('print("ok")\n') + + +def test_missing_slash_header_is_added_after_shebang(): + for filename in ['cli.js', 'cli.ts']: + source = '#!/usr/bin/env node\nconsole.log("ok");\n' + fixed = licence_headers.fix_content(filename, source, 2026) + assert fixed.startswith('#!/usr/bin/env node\n//\n// This code is Copyright (C) 2026') + assert fixed.endswith('console.log("ok");\n') + assert licence_headers.check_content(filename, fixed, 2026) is None + + +def test_python_encoding_declaration_is_preserved_before_header(): + fixed = licence_headers.fix_content('script.py', '# -*- coding: latin-1 -*-\nprint("ok")\n', 2026) + assert fixed.startswith('# -*- coding: latin-1 -*-\n#\n# This code is Copyright (C) 2026') + + +def test_second_line_python_encoding_declaration_preserves_comment_prefix(): + source = '# generated source\n# coding=latin-1\nprint("ok")\n' + fixed = licence_headers.fix_content('script.py', source, 2026) + prefix = '# generated source\n# coding=latin-1\n#\n# This code is Copyright (C) 2026' + assert fixed.startswith(prefix) + assert licence_headers.check_content('script.py', fixed, 2026) is None + + +def test_second_line_encoding_after_code_is_not_treated_as_prefix(): + source = 'print("x")\n# coding=utf-8\n' + fixed = licence_headers.fix_content('script.py', source, 2026) + header = licence_headers._render_header('hash', year=2026) + assert fixed.startswith(header) + assert fixed.endswith(source) + + +def test_encoding_declaration_after_shebang_is_preserved(): + source = '#!/usr/bin/env python3\n# -*- coding: latin-1 -*-\nprint("ok")\n' + fixed = licence_headers.fix_content('script.py', source, 2026) + prefix = '#!/usr/bin/env python3\n# -*- coding: latin-1 -*-\n#\n# This code is Copyright (C) 2026' + assert fixed.startswith(prefix) + assert licence_headers.check_content('script.py', fixed, 2026) is None + + +def test_non_utf8_python_file_preserves_declared_encoding(): + for enc in ['latin-1', 'iso-8859-15', 'cp1252', 'utf-8-sig']: + source = f'# coding={enc}\nname = "café"\n'.encode(enc) + fixed = licence_headers.fix_content('script.py', source, 2026) + assert isinstance(fixed, bytes) + assert f'# coding={enc}'.encode('ascii') in fixed + assert licence_headers.check_content('script.py', fixed, 2026) is None + # Verify decoding with original encoding roundtrips cleanly + decoded_text, detected_enc = licence_headers._decode_content('script.py', fixed) + assert 'This code is Copyright' in decoded_text + assert 'café' in decoded_text + + +def test_empty_python_file_gets_header(): + fixed = licence_headers.fix_content('empty.py', b'', 2026) + assert fixed.startswith(b'#\n# This code is Copyright (C) 2026') + assert licence_headers.check_content('empty.py', fixed, 2026) is None + + +def test_utf8_bom_remains_at_byte_zero_for_supported_non_python_files(): + for filename in ['example.yml', 'example.js', 'example.cpp']: + fixed = licence_headers.fix_content(filename, b'\xef\xbb\xbfvalue\n', 2026) + assert fixed.startswith(b'\xef\xbb\xbf') + assert fixed.count(b'\xef\xbb\xbf') == 1 + assert licence_headers.check_content(filename, fixed, 2026) is None + + +def test_unterminated_shebang_is_separated_from_header(): + for filename, shebang in [('script.py', b'#!/usr/bin/env python3'), ('script.sh', b'#!/bin/sh')]: + fixed = licence_headers.fix_content(filename, shebang, 2026) + assert fixed.startswith(shebang + b'\n#\n# This code is Copyright (C) 2026') + for filename in ['script.js', 'script.ts']: + shebang = b'#!/usr/bin/env node' + fixed = licence_headers.fix_content(filename, shebang, 2026) + assert fixed.startswith(shebang + b'\n//\n// This code is Copyright (C) 2026') + + +def test_unterminated_encoding_declaration_is_separated_from_header(): + declaration = b'# coding=latin-1' + fixed = licence_headers.fix_content('script.py', declaration, 2026) + assert fixed.startswith(declaration + b'\n#\n# This code is Copyright (C) 2026') + + +def test_legacy_one_line_header_is_replaced(): + old_header = '# Copyright The Cambridge Crystallographic Data Centre (CCDC) 2021, 2026\n\n' + source_comment = '# keep this source comment\n' + fixed = licence_headers.fix_content('example.py', old_header + source_comment + 'print("ok")\n', 2026) + assert fixed.startswith('#\n# This code is Copyright (C) 2026') + assert old_header not in fixed + assert fixed.endswith(source_comment + 'print("ok")\n') + + +def test_truncated_full_header_is_replaced_without_losing_source_comment(): + header = licence_headers._render_header('hash', year=2026) + old_header = licence_headers._render_header('hash', year=2025) + truncated = old_header.split('# law.\n', 1)[0] + source = '# keep this source comment\nprint("ok")\n' + fixed = licence_headers.fix_content('example.py', truncated + source, 2026) + assert fixed == header + source + assert fixed.count('This code is Copyright (C)') == 1 + + +def test_heavily_damaged_full_header_is_replaced_without_duplication(): + damaged = '''# +# This code is CopyrightCrystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced,sassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributhole or in +# part, toust contain this copyright +# notice. + +# No representations, warranties, or liabilities are expressed or implied in +# the supply servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# + +''' + source = '# keep this source comment\nhello\n' + header = licence_headers._render_header('hash', year=2026) + fixed = licence_headers.fix_content('example.py', damaged + source, 2026) + assert fixed == header + '\n' + source + assert fixed.count('This code is Copyright') == 1 + + +def test_unpadded_header_without_leading_and_trailing_marker_is_repaired(): + unpadded = '''# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. + +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +''' + source = 'print("hello world")\n' + fixed = licence_headers.fix_content('example.py', unpadded + '\n' + source, 2026) + expected_header = licence_headers._render_header('hash', year=2026) + assert fixed == expected_header + '\n' + source + assert fixed.count('This code is Copyright') == 1 + + +def test_damaged_copyright_identity_line_is_repaired_without_duplication(): + damaged = '''# +# This code is Cop Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not beteded, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and + +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this cod servants or agents, except where such +# exclusion or limitation is prohibited, vrceable under governing +# law. +# + +''' + source = '# keep this source comment\nhello\n' + header = licence_headers._render_header('hash', year=2026) + fixed = licence_headers.fix_content('example.py', damaged + source, 2026) + assert fixed == header + '\n' + source + assert fixed.count('This code is Copyright') == 1 + + +def test_truncated_header_does_not_consume_source_code_before_law_comment(): + truncated = '''# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +''' + source = 'def calculate():\n return 42\n# according to the law.\n' + fixed = licence_headers.fix_content('example.py', truncated + source, 2026) + expected_header = licence_headers._render_header('hash', year=2026) + assert fixed == expected_header + source + assert 'def calculate():' in fixed + + +def test_truncated_header_preserves_source_comment_matching_later_header_line(): + truncated = '''# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +''' + source = '# notice.\nprint("ok")\n' + fixed = licence_headers.fix_content('example.py', truncated + source, 2026) + expected_header = licence_headers._render_header('hash', year=2026) + assert fixed == expected_header + source + assert '# notice.\n' in fixed + + +def test_slash_header_is_added(): + fixed = licence_headers.fix_content('example.cpp', 'int main() {}\n', 2026) + assert fixed.startswith('//\n// This code is Copyright (C) 2026') + assert licence_headers.check_content('example.cpp', fixed, 2026) is None + + +def test_ignored_and_unsupported_files_are_skipped(): + assert licence_headers.check_content('.github/workflows/check.yml', 'name: check\n', 2026) is None + assert licence_headers.check_content('templates/check.yml', 'name: check\n', 2026) is None + assert licence_headers.check_content('README.md', '# Read me\n', 2026) is None + assert licence_headers.check_content('notes.txt', 'some notes\n', 2026) is None + assert licence_headers.check_content('package.json', '{"name": "app"}\n', 2026) is None + assert licence_headers.check_content('node_modules/pkg/index.js', 'console.log();\n', 2026) is None + assert licence_headers.check_content('dist/bundle.js', 'console.log();\n', 2026) is None + assert licence_headers.check_content('.venv/lib/module.py', 'print("ok")\n', 2026) is None + + +def test_generated_files_are_excluded(): + generated_files = [ + 'Form.Designer.cs', + 'Model.g.cs', + 'bundle.min.js', + 'packages.lock', + 'service.generated.ts', + 'codegen.generated.cpp', + ] + for filename in generated_files: + assert licence_headers.check_content(filename, 'var x = 1;\n', 2026) is None + assert licence_headers.fix_content(filename, 'var x = 1;\n', 2026) == 'var x = 1;\n' + + +def test_crlf_line_endings_are_preserved(): + source = 'int main() {\r\n return 0;\r\n}\r\n' + fixed = licence_headers.fix_content('main.cpp', source, 2026) + assert '\r\n' in fixed + assert '\n' not in fixed.replace('\r\n', '') + assert licence_headers.check_content('main.cpp', fixed, 2026) is None + + +def test_legacy_slash_header_is_replaced(): + old_header = '// Copyright The Cambridge Crystallographic Data Centre (CCDC) 2020\r\n\r\n' + source = 'const x = 42;\r\n' + fixed = licence_headers.fix_content('app.js', old_header + source, 2026) + assert fixed.startswith('//\r\n// This code is Copyright (C) 2026') + assert old_header not in fixed + assert fixed.endswith(source) + + +def test_damaged_slash_header_is_repaired(): + damaged = ( + '//\n' + '// This code is Copyright (C) 2024 The Cambridge Crystallographic Data Centre (CCDC)\n' + '// of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This\n' + '// broken line...\n' + '// law.\n' + '//\n' + ) + source = 'const app = 1;\n' + fixed = licence_headers.fix_content('app.ts', damaged + source, 2026) + expected_header = licence_headers._render_header('slash', year=2026) + assert fixed == expected_header + source + + +def test_process_files_avoids_write_when_already_compliant(tmp_path): + test_file = tmp_path / 'compliant.py' + header = licence_headers._render_header('hash', year=2026) + content = (header + 'print("hello")\n').encode('utf-8') + test_file.write_bytes(content) + + initial_mtime = test_file.stat().st_mtime_ns + assert licence_headers.process_files([str(test_file)], fix=True, year=2026) == 0 + assert test_file.stat().st_mtime_ns == initial_mtime + + +def test_process_files_skips_symlinks(tmp_path): + target = tmp_path / 'target.py' + target_content = 'print("target")\n' + target.write_text(target_content, encoding='utf-8') + + symlink = tmp_path / 'link.py' + try: + symlink.symlink_to(target) + except OSError: + import pytest + pytest.skip('Symlinks not supported in current environment or permissions') + + # Check mode should skip symlink and report 0 failures + assert licence_headers.process_files([str(symlink)], fix=False, year=2026) == 0 + # Fix mode should skip symlink without modifying target file + assert licence_headers.process_files([str(symlink)], fix=True, year=2026) == 0 + assert target.read_text(encoding='utf-8') == target_content