diff --git a/.generator/Dockerfile b/.generator/Dockerfile index 2f64101ea8cc..2785e9d5c2ef 100644 --- a/.generator/Dockerfile +++ b/.generator/Dockerfile @@ -34,11 +34,10 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* # Set up environment variables for tool versions to make updates easier. -# TODO(): Downgrade to Python `3.11` if `3.13.5` is incompatible with googleapis. -ENV PYTHON_VERSION=3.13.5 +ENV PYTHON_VERSION=3.11.5 # Create a symbolic link for `python3` to point to our specific version. -ENV PATH /usr/local/bin/python3.13:$PATH +ENV PATH /usr/local/bin/python3.11:$PATH # Install Python from source RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz && \ @@ -64,8 +63,7 @@ WORKDIR /app # TODO(https://github.com/googleapis/librarian/issues/906): Clone googleapis and run bazelisk build. # Copy the CLI script into the container and set ownership. -# No other files or dependencies are needed for this simple script. -COPY cli.py . +COPY .generator/cli.py . # Set the entrypoint for the container to run the script. -ENTRYPOINT ["python3.13", "./cli.py"] +ENTRYPOINT ["python3.11", "./cli.py"] diff --git a/.generator/cli.py b/.generator/cli.py index 09dfbe15746f..fe87ce1a6b09 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -13,32 +13,88 @@ # limitations under the License. import argparse +import json +import logging +import os import sys -def handle_configure(dry_run=False): - # TODO(https://github.com/googleapis/librarian/issues/466): Implement configure command. - print("'configure' command executed.") +logger = logging.getLogger() -def handle_generate(dry_run=False): - # TODO(https://github.com/googleapis/librarian/issues/448): Implement generate command. - print("'generate' command executed.") +LIBRARIAN_DIR = "librarian" +GENERATE_REQUEST_FILE = "generate-request.json" -def handle_build(dry_run=False): - # TODO(https://github.com/googleapis/librarian/issues/450): Implement build command. - print("'build' command executed.") + +def _read_json_file(path): + """Helper function that reads a json file path and returns the loaded json content. + + Args: + path (str): The file path to read. + + Returns: + dict: The parsed JSON content. + + Raises: + FileNotFoundError: If the file is not found at the specified path. + json.JSONDecodeError: If the file does not contain valid JSON. + IOError: If there is an issue reading the file. + """ + with open(path, "r") as f: + return json.load(f) + + +def handle_configure(): + # TODO(https://github.com/googleapis/librarian/issues/466): Implement configure command and update docstring. + logger.info("'configure' command executed.") + + +def handle_generate(): + """The main coordinator for the code generation process. + + This function orchestrates the generation of a client library by reading a + `librarian/generate-request.json` file, determining the necessary Bazel rule for each API, and + (in future steps) executing the build. + + Raises: + ValueError: If the `generate-request.json` file is not found or read. + """ + + # Read a generate-request.json file + try: + request_data = _read_json_file(f"{LIBRARIAN_DIR}/{GENERATE_REQUEST_FILE}") + except Exception as e: + raise ValueError( + f"failed to read {LIBRARIAN_DIR}/{GENERATE_REQUEST_FILE}" + ) from e + + logger.info(json.dumps(request_data, indent=2)) + + # TODO(https://github.com/googleapis/librarian/issues/448): Implement generate command and update docstring. + logger.info("'generate' command executed.") + + +def handle_build(): + # TODO(https://github.com/googleapis/librarian/issues/450): Implement build command and update docstring. + logger.info("'build' command executed.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="A simple CLI tool.") - subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands") + subparsers = parser.add_subparsers( + dest="command", required=True, help="Available commands" + ) # Define commands + handler_map = { + "configure": handle_configure, + "generate": handle_generate, + "build": handle_build, + } + for command_name, help_text in [ ("configure", "Onboard a new library or an api path to Librarian workflow."), ("generate", "generate a python client for an API."), - ("build", "Run unit tests via nox for the generated library.") + ("build", "Run unit tests via nox for the generated library."), ]: - handler_map = {"configure": handle_configure, "generate": handle_generate, "build": handle_build} parser_cmd = subparsers.add_parser(command_name, help=help_text) parser_cmd.set_defaults(func=handler_map[command_name]) @@ -47,4 +103,4 @@ def handle_build(dry_run=False): sys.exit(1) args = parser.parse_args() - args.func(args) + args.func() diff --git a/.generator/requirements-test.in b/.generator/requirements-test.in new file mode 100644 index 000000000000..c09827ec2eeb --- /dev/null +++ b/.generator/requirements-test.in @@ -0,0 +1,16 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +pytest +pytest-mock diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 1f77c88ffa39..cbf73bdfaea3 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -12,17 +12,105 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cli import handle_generate, handle_build, handle_configure +import os +import pytest +import json +import logging +from unittest.mock import mock_open -def test_handle_configure_dry_run(): - # This is a simple test to ensure that the dry run command succeeds. - handle_configure(dry_run=True) +from cli import ( + _read_json_file, + handle_generate, + handle_build, + handle_configure, + LIBRARIAN_DIR, + GENERATE_REQUEST_FILE, +) -def test_handle_generate_dry_run(): - # This is a simple test to ensure that the dry run command succeeds. - handle_generate(dry_run=True) -def test_handle_build_dry_run(): - # This is a simple test to ensure that the dry run command succeeds. - handle_build(dry_run=True) +@pytest.fixture +def mock_generate_request_file(tmp_path, monkeypatch): + """Creates the mock request file at the correct path inside a temp dir.""" + # Create the path as expected by the script: .librarian/generate-request.json + request_path = f"{LIBRARIAN_DIR}/{GENERATE_REQUEST_FILE}" + request_dir = tmp_path / os.path.dirname(request_path) + request_dir.mkdir() + request_file = request_dir / os.path.basename(request_path) + + request_content = { + "id": "google-cloud-language", + "apis": [{"path": "google/cloud/language/v1"}], + } + request_file.write_text(json.dumps(request_content)) + + # Change the current working directory to the temp path for the test. + monkeypatch.chdir(tmp_path) + return request_file + + +def test_handle_configure_success(caplog, mock_generate_request_file): + """ + Tests the successful execution path of handle_configure. + """ + caplog.set_level(logging.INFO) + + handle_configure() + + assert "'configure' command executed." in caplog.text + + +def test_handle_generate_success(caplog, mock_generate_request_file): + """ + Tests the successful execution path of handle_generate. + """ + caplog.set_level(logging.INFO) + + handle_generate() + + assert "google-cloud-language" in caplog.text + assert "'generate' command executed." in caplog.text + + +def test_handle_generate_fail(caplog): + """ + Tests the failed to read `librarian/generate-request.json` file in handle_generates. + """ + with pytest.raises(ValueError): + handle_generate() + + +def test_handle_build_success(caplog, mock_generate_request_file): + """ + Tests the successful execution path of handle_build. + """ + caplog.set_level(logging.INFO) + + handle_build() + + assert "'build' command executed." in caplog.text + + +def test_read_valid_json(mocker): + """Tests reading a valid JSON file.""" + mock_content = '{"key": "value"}' + mocker.patch("builtins.open", mocker.mock_open(read_data=mock_content)) + result = _read_json_file("fake/path.json") + assert result == {"key": "value"} + + +def test_file_not_found(mocker): + """Tests behavior when the file does not exist.""" + mocker.patch("builtins.open", side_effect=FileNotFoundError("No such file")) + + with pytest.raises(FileNotFoundError): + _read_json_file("non/existent/path.json") + + +def test_invalid_json(mocker): + """Tests reading a file with malformed JSON.""" + mock_content = '{"key": "value",}' + mocker.patch("builtins.open", mocker.mock_open(read_data=mock_content)) + + with pytest.raises(json.JSONDecodeError): + _read_json_file("fake/path.json") diff --git a/.github/workflows/generator.yml b/.github/workflows/generator.yml index 447c24df88e0..77d5f05daffa 100644 --- a/.github/workflows/generator.yml +++ b/.github/workflows/generator.yml @@ -21,10 +21,11 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" - - name: Install pytest + python-version: "3.11" + - name: Install dependencies run: | - python -m pip install pytest + python -m pip install --upgrade pip + pip install -r .generator/requirements-test.in - name: Run generator_cli tests run: | pytest .generator/test_cli.py diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 000000000000..2a7dd688e1af --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,28 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + # This step builds the Docker image. + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - 'gcr.io/$PROJECT_ID/python-librarian-generator:latest' + - '-f' + - '.generator/Dockerfile' + - '.' + +# This section automatically create a storage bucket for storing docker build logs. +options: + default_logs_bucket_behavior: REGIONAL_USER_OWNED_BUCKET