Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .generator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we create a follow up issue to track adding docs/README.rst to the output of gapic-generator?

"""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()
Comment on lines +526 to +528
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use the _read_text_file function

def _read_text_file(path: str) -> str:
"""Helper function that reads a text file path and returns the content.
Args:
path(str): The file path to read.
Returns:
str: The contents of the file.
"""
with open(path, "r") as f:
return 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,
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions .generator/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
_verify_library_namespace,
_write_json_file,
_write_text_file,
_copy_readme_to_docs,
handle_build,
handle_configure,
handle_generate,
Expand Down Expand Up @@ -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()
Loading