diff --git a/.generator/cli.py b/.generator/cli.py index 3b10d6ec4306..26509443ffad 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -381,7 +381,6 @@ def _clean_up_files_after_post_processing(output: str, library_id: str): # Safely remove specific files if they exist using pathlib. Path(f"{output}/{path_to_library}/CHANGELOG.md").unlink(missing_ok=True) Path(f"{output}/{path_to_library}/docs/CHANGELOG.md").unlink(missing_ok=True) - Path(f"{output}/{path_to_library}/docs/README.rst").unlink(missing_ok=True) # The glob loops are already safe, as they do nothing if no files match. for post_processing_file in glob.glob( @@ -501,6 +500,47 @@ def _generate_repo_metadata_file( _write_json_file(output_repo_metadata, metadata_content) +def _copy_readme_to_docs(output: str, library_id: str): + """Copies the README.rst file for a generated library to docs/README.rst. + + This function is robust against various symlink configurations that could + cause `shutil.copy` to fail with a `SameFileError`. It reads the content + from the source and writes it to the destination, ensuring the final + destination is a real file. + + Args: + output(str): Path to the directory in the container where code + should be generated. + library_id(str): The library id. + """ + path_to_library = f"packages/{library_id}" + source_path = f"{output}/{path_to_library}/README.rst" + docs_path = f"{output}/{path_to_library}/docs" + destination_path = f"{docs_path}/README.rst" + + # If the source file doesn't exist (not even as a broken symlink), + # there's nothing to copy. + if not os.path.lexists(source_path): + return + + # Read the content from the source, which will resolve any symlinks. + with open(source_path, "r") as f: + content = f.read() + + # Remove any symlinks at the destination to prevent errors. + if os.path.islink(destination_path): + os.remove(destination_path) + elif os.path.islink(docs_path): + os.remove(docs_path) + + # Ensure the destination directory exists as a real directory. + os.makedirs(docs_path, exist_ok=True) + + # Write the content to the destination, creating a new physical file. + with open(destination_path, "w") as f: + f.write(content) + + def handle_generate( librarian: str = LIBRARIAN_DIR, source: str = SOURCE_DIR, @@ -542,6 +582,7 @@ def handle_generate( _copy_files_needed_for_post_processing(output, input, library_id) _generate_repo_metadata_file(output, library_id, source, apis_to_generate) _run_post_processor(output, library_id) + _copy_readme_to_docs(output, library_id) _clean_up_files_after_post_processing(output, library_id) except Exception as e: raise ValueError("Generation failed.") from e diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 5751c60b71a2..a401229a6723 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -70,6 +70,7 @@ _verify_library_namespace, _write_json_file, _write_text_file, + _copy_readme_to_docs, handle_build, handle_configure, handle_generate, @@ -1514,3 +1515,100 @@ def test_stage_gapic_library(mocker): mock_shutil_copytree.assert_called_once_with( tmp_dir, staging_dir, dirs_exist_ok=True ) + + +def test_copy_readme_to_docs(mocker): + """Tests that the README.rst is copied to the docs directory, handling symlinks.""" + mock_makedirs = mocker.patch("os.makedirs") + mock_shutil_copy = mocker.patch("shutil.copy") + mock_os_islink = mocker.patch("os.path.islink", return_value=False) + mock_os_remove = mocker.patch("os.remove") + mock_os_lexists = mocker.patch("os.path.lexists", return_value=True) + mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="dummy content")) + + output = "output" + library_id = "google-cloud-language" + _copy_readme_to_docs(output, library_id) + + expected_source = "output/packages/google-cloud-language/README.rst" + expected_docs_path = "output/packages/google-cloud-language/docs" + expected_destination = "output/packages/google-cloud-language/docs/README.rst" + + mock_os_lexists.assert_called_once_with(expected_source) + mock_open.assert_any_call(expected_source, "r") + mock_os_islink.assert_any_call(expected_destination) + mock_os_islink.assert_any_call(expected_docs_path) + mock_os_remove.assert_not_called() + mock_makedirs.assert_called_once_with(expected_docs_path, exist_ok=True) + mock_open.assert_any_call(expected_destination, "w") + mock_open().write.assert_called_once_with("dummy content") + + +def test_copy_readme_to_docs_handles_symlink(mocker): + """Tests that the README.rst is copied to the docs directory, handling symlinks.""" + mock_makedirs = mocker.patch("os.makedirs") + mock_shutil_copy = mocker.patch("shutil.copy") + mock_os_islink = mocker.patch("os.path.islink") + mock_os_remove = mocker.patch("os.remove") + mock_os_lexists = mocker.patch("os.path.lexists", return_value=True) + mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="dummy content")) + + # Simulate docs_path being a symlink + mock_os_islink.side_effect = [False, True] # First call for destination_path, second for docs_path + + output = "output" + library_id = "google-cloud-language" + _copy_readme_to_docs(output, library_id) + + expected_source = "output/packages/google-cloud-language/README.rst" + expected_docs_path = "output/packages/google-cloud-language/docs" + expected_destination = "output/packages/google-cloud-language/docs/README.rst" + + mock_os_lexists.assert_called_once_with(expected_source) + mock_open.assert_any_call(expected_source, "r") + mock_os_islink.assert_any_call(expected_destination) + mock_os_islink.assert_any_call(expected_docs_path) + mock_os_remove.assert_called_once_with(expected_docs_path) + mock_makedirs.assert_called_once_with(expected_docs_path, exist_ok=True) + mock_open.assert_any_call(expected_destination, "w") + mock_open().write.assert_called_once_with("dummy content") + + +def test_copy_readme_to_docs_destination_path_is_symlink(mocker): + """Tests that the README.rst is copied to the docs directory, handling destination_path being a symlink.""" + mock_makedirs = mocker.patch("os.makedirs") + mock_shutil_copy = mocker.patch("shutil.copy") + mock_os_islink = mocker.patch("os.path.islink", return_value=True) + mock_os_remove = mocker.patch("os.remove") + mock_os_lexists = mocker.patch("os.path.lexists", return_value=True) + mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="dummy content")) + + output = "output" + library_id = "google-cloud-language" + _copy_readme_to_docs(output, library_id) + + expected_destination = "output/packages/google-cloud-language/docs/README.rst" + mock_os_remove.assert_called_once_with(expected_destination) + + +def test_copy_readme_to_docs_source_not_exists(mocker): + """Tests that the function returns early if the source README.rst does not exist.""" + mock_makedirs = mocker.patch("os.makedirs") + mock_shutil_copy = mocker.patch("shutil.copy") + mock_os_islink = mocker.patch("os.path.islink") + mock_os_remove = mocker.patch("os.remove") + mock_os_lexists = mocker.patch("os.path.lexists", return_value=False) + mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="dummy content")) + + output = "output" + library_id = "google-cloud-language" + _copy_readme_to_docs(output, library_id) + + expected_source = "output/packages/google-cloud-language/README.rst" + + mock_os_lexists.assert_called_once_with(expected_source) + mock_open.assert_not_called() + mock_os_islink.assert_not_called() + mock_os_remove.assert_not_called() + mock_makedirs.assert_not_called() + mock_shutil_copy.assert_not_called()