diff --git a/.generator/cli.py b/.generator/cli.py index 2dad6fb03349..48d6776594f0 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -57,6 +57,11 @@ SOURCE_DIR = "source" _GITHUB_BASE = "https://github.com" +_GENERATOR_INPUT_HEADER_TEXT = ( + "# DO NOT EDIT THIS FILE OUTSIDE OF `.librarian/generator-input`\n" + "# The source of truth for this file is `.librarian/generator-input`\n" +) + def _read_text_file(path: str) -> str: """Helper function that reads a text file path and returns the content. @@ -349,6 +354,41 @@ def _run_post_processor(output: str, library_id: str, is_mono_repo: bool): logger.info("Python post-processor ran successfully.") +def _add_header_to_files(directory: str) -> None: + """Adds a 'DO NOT EDIT' header to files in the specified directory. + + Skips JSON and YAML files. Attempts to insert the header after any existing + license headers (blocks of comments starting with '#'). + + Args: + directory (str): The directory containing files to update. + """ + + # Files with these extensions should be ignored. + skipped_extensions = {".json", ".yaml"} + + for root, _, files in os.walk(directory): + for file_name in files: + file_path = Path(root) / file_name + + if file_path.suffix in skipped_extensions: + continue + + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + line_index = 0 + # Skip the license header (contiguous block of comments starting with '#'). + while line_index < len(lines) and lines[line_index].strip().startswith("#"): + line_index += 1 + + header_prefix = "\n" if line_index > 0 else "" + lines.insert(line_index, f"{header_prefix}{_GENERATOR_INPUT_HEADER_TEXT}\n") + + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + def _copy_files_needed_for_post_processing( output: str, input: str, library_id: str, is_mono_repo: bool ): @@ -367,13 +407,16 @@ def _copy_files_needed_for_post_processing( path_to_library = f"packages/{library_id}" if is_mono_repo else "." source_dir = f"{input}/{path_to_library}" + destination_dir = f"{output}/{path_to_library}" if Path(source_dir).exists(): shutil.copytree( source_dir, - f"{output}/{path_to_library}", + destination_dir, dirs_exist_ok=True, ) + # Apply headers only to the generator-input files copied above. + _add_header_to_files(destination_dir) # We need to create these directories so that we can copy files necessary for post-processing. os.makedirs( diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 0bfc3c66bf3b..4a168cbc2759 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -26,6 +26,7 @@ import pytest from cli import ( + _GENERATOR_INPUT_HEADER_TEXT, GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, CONFIGURE_REQUEST_FILE, @@ -34,6 +35,7 @@ STATE_YAML_FILE, LIBRARIAN_DIR, REPO_DIR, + _add_header_to_files, _clean_up_files_after_post_processing, _copy_files_needed_for_post_processing, _create_main_version_header, @@ -142,6 +144,16 @@ )""" +@pytest.fixture +def setup_dirs(tmp_path): + """Creates input and output directories.""" + input_dir = tmp_path / "input" + output_dir = tmp_path / "output" + input_dir.mkdir() + output_dir.mkdir() + return input_dir, output_dir + + @pytest.fixture(autouse=True) def _clear_lru_cache(): """Automatically clears the cache of all LRU-cached functions after each test.""" @@ -906,6 +918,93 @@ def test_copy_files_needed_for_post_processing_copies_files_from_generator_input mock_makedirs.assert_called() +def test_copy_files_needed_for_post_processing_copies_files_from_generator_input_skips_json_files( + setup_dirs, +): + """Test that .json files are copied but NOT modified.""" + input_dir, output_dir = setup_dirs + + json_content = '{"key": "value"}' + (input_dir / ".repo-metadata.json").write_text(json_content) + + _copy_files_needed_for_post_processing( + output=str(output_dir), + input=str(input_dir), + library_id="google-cloud-foo", + is_mono_repo=False, + ) + + dest_file = output_dir / ".repo-metadata.json" + assert dest_file.exists() + # Content should be exactly the same, no # comments added + assert dest_file.read_text() == json_content + + +def test_add_header_with_existing_license(tmp_path): + """ + Test that the header is inserted AFTER the existing license block. + """ + # Setup: Create a file with a license header + file_path = tmp_path / "example.py" + original_content = ( + "# Copyright 2025 Google LLC\n" "# Licensed under Apache 2.0\n" "\n" "import os" + ) + file_path.write_text(original_content, encoding="utf-8") + + # Execute + _add_header_to_files(str(tmp_path)) + + # Verify + new_content = file_path.read_text(encoding="utf-8") + expected_content = ( + "# Copyright 2025 Google LLC\n" + "# Licensed under Apache 2.0\n" + "\n" + f"{_GENERATOR_INPUT_HEADER_TEXT}\n" + "\n" + "import os" + ) + assert new_content == expected_content + + +def test_add_header_to_files_add_header_no_license(tmp_path): + """ + Test that the header is inserted at the top if no license block exists. + """ + # Setup: Create a file starting directly with code + file_path = tmp_path / "script.sh" + original_content = "echo 'Hello World'" + file_path.write_text(original_content, encoding="utf-8") + + # Execute + _add_header_to_files(str(tmp_path)) + + # Verify + new_content = file_path.read_text(encoding="utf-8") + expected_content = f"{_GENERATOR_INPUT_HEADER_TEXT}\n" "echo 'Hello World'" + assert new_content == expected_content + + +def test_add_header_to_files_skips_excluded_extensions(tmp_path): + """ + Test that .json and .yaml files are ignored. + """ + # Setup: Create files that should be ignored + json_file = tmp_path / "data.json" + yaml_file = tmp_path / "config.yaml" + + content = "key: value" + json_file.write_text('{"key": "value"}', encoding="utf-8") + yaml_file.write_text(content, encoding="utf-8") + + # Execute + _add_header_to_files(str(tmp_path)) + + # Verify contents remain exactly the same + assert json_file.read_text(encoding="utf-8") == '{"key": "value"}' + assert yaml_file.read_text(encoding="utf-8") == content + + @pytest.mark.parametrize("is_mono_repo", [False, True]) def test_clean_up_files_after_post_processing_success(mocker, is_mono_repo): mock_shutil_rmtree = mocker.patch("shutil.rmtree")