Skip to content

Commit c3667fe

Browse files
authored
feat: implement build command to validate generated library (#14129)
Fixes googleapis/librarian#450
1 parent 4e034a5 commit c3667fe

File tree

4 files changed

+246
-23
lines changed

4 files changed

+246
-23
lines changed

.generator/Dockerfile

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,37 @@ RUN apt-get update && \
3636
rm -rf /var/lib/apt/lists/*
3737

3838
# Set up environment variables for tool versions to make updates easier.
39-
ENV PYTHON_VERSION=3.11.5
4039
ENV BAZELISK_VERSION=v1.26.0
4140

42-
# Create a symbolic link for `python3` to point to our specific version.
43-
ENV PATH /usr/local/bin/python3.11:$PATH
44-
45-
# Install Python from source
46-
RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz && \
41+
RUN for PYTHON_VERSION in 3.9.23 3.10.18 3.11.13 3.12.11 3.13.5; do \
42+
# Install Python from source
43+
wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz && \
4744
tar -xvf Python-${PYTHON_VERSION}.tgz && \
4845
cd Python-${PYTHON_VERSION} && \
4946
./configure --enable-optimizations && \
5047
make altinstall && \
5148
cd / && \
52-
rm -rf Python-${PYTHON_VERSION}*
49+
rm -rf Python-${PYTHON_VERSION}* \
50+
; done
51+
52+
# Get the pip installation script using the instructions below
53+
# https://pip.pypa.io/en/stable/installation/#get-pip-py
54+
# Attempts to use `ensurepip` instead of `get-pip.py` resulted in errors like `python3.13.5 not found`
55+
RUN wget --no-check-certificate -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py'
56+
57+
# Install pip for each python version
58+
RUN for PYTHON_VERSION in 3.9 3.10 3.11 3.12 3.13; do \
59+
python${PYTHON_VERSION} /tmp/get-pip.py \
60+
; done
61+
62+
# Remove the pip installation script
63+
RUN rm /tmp/get-pip.py
64+
65+
# Test Pip
66+
RUN for PYTHON_VERSION in 3.9 3.10 3.11 3.12 3.13; do \
67+
python${PYTHON_VERSION} -m pip \
68+
; done
69+
5370

5471
# TODO(https://github.com/googleapis/librarian/issues/904): Install protoc for gencode.
5572

.generator/cli.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,21 @@
1616
import json
1717
import logging
1818
import os
19+
import subprocess
1920
import sys
2021
import subprocess
22+
from typing import Dict, List
2123

2224
logger = logging.getLogger()
2325

2426
LIBRARIAN_DIR = "librarian"
2527
GENERATE_REQUEST_FILE = "generate-request.json"
2628
SOURCE_DIR = "source"
2729
OUTPUT_DIR = "output"
30+
REPO_DIR = "repo"
2831

2932

30-
def _read_json_file(path):
33+
def _read_json_file(path: str) -> Dict:
3134
"""Helper function that reads a json file path and returns the loaded json content.
3235
3336
Args:
@@ -50,7 +53,7 @@ def handle_configure():
5053
logger.info("'configure' command executed.")
5154

5255

53-
def _determine_bazel_rule(api_path):
56+
def _determine_bazel_rule(api_path: str) -> str:
5457
"""Executes a `bazelisk query` to find a Bazel rule.
5558
5659
Args:
@@ -83,7 +86,25 @@ def _determine_bazel_rule(api_path):
8386
raise ValueError(f"Bazelisk query `{query}` failed") from e
8487

8588

86-
def _build_bazel_target(bazel_rule):
89+
def _get_library_id(request_data: Dict) -> str:
90+
"""Retrieve the library id from the given request dictionary
91+
92+
Args:
93+
request_data(Dict): The contents `generate-request.json`.
94+
95+
Raises:
96+
ValueError: If the key `id` does not exist in `request_data`.
97+
98+
Returns:
99+
str: The id of the library in `generate-request.json`
100+
"""
101+
library_id = request_data.get("id")
102+
if not library_id:
103+
raise ValueError("Request file is missing required 'id' field.")
104+
return library_id
105+
106+
107+
def _build_bazel_target(bazel_rule: str):
87108
"""Executes `bazelisk build` on a given Bazel rule.
88109
89110
Args:
@@ -167,9 +188,7 @@ def handle_generate():
167188
# Read a generate-request.json file
168189
try:
169190
request_data = _read_json_file(f"{LIBRARIAN_DIR}/{GENERATE_REQUEST_FILE}")
170-
library_id = request_data.get("id")
171-
if not library_id:
172-
raise ValueError("Request file is missing required 'id' field.")
191+
library_id = _get_library_id(request_data)
173192

174193
for api in request_data.get("apis", []):
175194
api_path = api.get("path")
@@ -186,8 +205,59 @@ def handle_generate():
186205
logger.info("'generate' command executed.")
187206

188207

208+
def _run_nox_sessions(sessions: List[str]):
209+
"""Calls nox for all specified sessions.
210+
211+
Args:
212+
path(List[str]): The list of nox sessions to run.
213+
"""
214+
# Read a generate-request.json file
215+
current_session = None
216+
try:
217+
request_data = _read_json_file(f"{LIBRARIAN_DIR}/{GENERATE_REQUEST_FILE}")
218+
library_id = _get_library_id(request_data)
219+
for nox_session in sessions:
220+
_run_individual_session(nox_session, library_id)
221+
except Exception as e:
222+
raise ValueError(f"Failed to run the nox session: {current_session}") from e
223+
224+
225+
def _run_individual_session(nox_session: str, library_id: str):
226+
"""
227+
Calls nox with the specified sessions.
228+
229+
Args:
230+
nox_session(str): The nox session to run
231+
library_id(str): The library id under test
232+
"""
233+
command = [
234+
"nox",
235+
"-s",
236+
nox_session,
237+
"-f",
238+
f"{REPO_DIR}/packages/{library_id}",
239+
]
240+
result = subprocess.run(command, text=True, check=True)
241+
logger.info(result)
242+
243+
189244
def handle_build():
190-
# TODO(https://github.com/googleapis/librarian/issues/450): Implement build command and update docstring.
245+
"""The main coordinator for validating client library generation."""
246+
sessions = [
247+
"unit-3.9",
248+
"unit-3.10",
249+
"unit-3.11",
250+
"unit-3.12",
251+
"unit-3.13",
252+
"docs",
253+
"system",
254+
"lint",
255+
"lint_setup_py",
256+
"mypy",
257+
"check_lower_bounds",
258+
]
259+
_run_nox_sessions(sessions)
260+
191261
logger.info("'build' command executed.")
192262

193263

.generator/test_cli.py

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,28 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import os
16-
import pytest
1715
import json
1816
import logging
17+
import os
1918
import subprocess
19+
from unittest.mock import MagicMock, mock_open
2020

21-
from unittest.mock import mock_open, MagicMock
21+
import pytest
2222

2323
from cli import (
24-
_read_json_file,
25-
_determine_bazel_rule,
24+
GENERATE_REQUEST_FILE,
25+
LIBRARIAN_DIR,
26+
REPO_DIR,
2627
_build_bazel_target,
28+
_determine_bazel_rule,
29+
_get_library_id,
2730
_locate_and_extract_artifact,
28-
handle_generate,
31+
_read_json_file,
32+
_run_individual_session,
33+
_run_nox_sessions,
2934
handle_build,
3035
handle_configure,
31-
LIBRARIAN_DIR,
32-
GENERATE_REQUEST_FILE,
36+
handle_generate,
3337
)
3438

3539

@@ -53,6 +57,42 @@ def mock_generate_request_file(tmp_path, monkeypatch):
5357
return request_file
5458

5559

60+
@pytest.fixture
61+
def mock_generate_request_data_for_nox():
62+
"""Returns mock data for generate-request.json for nox tests."""
63+
return {
64+
"id": "mock-library",
65+
"apis": [
66+
{"path": "google/mock/v1"},
67+
],
68+
}
69+
70+
71+
def test_get_library_id_success():
72+
"""Tests that _get_library_id returns the correct ID when present."""
73+
request_data = {"id": "test-library", "name": "Test Library"}
74+
library_id = _get_library_id(request_data)
75+
assert library_id == "test-library"
76+
77+
78+
def test_get_library_id_missing_id():
79+
"""Tests that _get_library_id raises ValueError when 'id' is missing."""
80+
request_data = {"name": "Test Library"}
81+
with pytest.raises(
82+
ValueError, match="Request file is missing required 'id' field."
83+
):
84+
_get_library_id(request_data)
85+
86+
87+
def test_get_library_id_empty_id():
88+
"""Tests that _get_library_id raises ValueError when 'id' is an empty string."""
89+
request_data = {"id": "", "name": "Test Library"}
90+
with pytest.raises(
91+
ValueError, match="Request file is missing required 'id' field."
92+
):
93+
_get_library_id(request_data)
94+
95+
5696
def test_handle_configure_success(caplog, mock_generate_request_file):
5797
"""
5898
Tests the successful execution path of handle_configure.
@@ -191,12 +231,105 @@ def test_handle_generate_fail(caplog):
191231
handle_generate()
192232

193233

194-
def test_handle_build_success(caplog, mock_generate_request_file):
234+
def test_run_individual_session_success(mocker, caplog):
235+
"""Tests that _run_individual_session calls nox with correct arguments and logs success."""
236+
caplog.set_level(logging.INFO)
237+
238+
mock_subprocess_run = mocker.patch(
239+
"cli.subprocess.run", return_value=MagicMock(returncode=0)
240+
)
241+
242+
test_session = "unit-3.9"
243+
test_library_id = "test-library"
244+
_run_individual_session(test_session, test_library_id)
245+
246+
expected_command = [
247+
"nox",
248+
"-s",
249+
test_session,
250+
"-f",
251+
f"{REPO_DIR}/packages/{test_library_id}",
252+
]
253+
mock_subprocess_run.assert_called_once_with(expected_command, text=True, check=True)
254+
255+
256+
def test_run_individual_session_failure(mocker):
257+
"""Tests that _run_individual_session raises CalledProcessError if nox command fails."""
258+
mocker.patch(
259+
"cli.subprocess.run",
260+
side_effect=subprocess.CalledProcessError(
261+
1, "nox", stderr="Nox session failed"
262+
),
263+
)
264+
265+
with pytest.raises(subprocess.CalledProcessError):
266+
_run_individual_session("lint", "another-library")
267+
268+
269+
def test_run_nox_sessions_success(mocker, mock_generate_request_data_for_nox):
270+
"""Tests that _run_nox_sessions successfully runs all specified sessions."""
271+
mocker.patch("cli._read_json_file", return_value=mock_generate_request_data_for_nox)
272+
mocker.patch("cli._get_library_id", return_value="mock-library")
273+
mock_run_individual_session = mocker.patch("cli._run_individual_session")
274+
275+
sessions_to_run = ["unit-3.9", "lint"]
276+
_run_nox_sessions(sessions_to_run)
277+
278+
assert mock_run_individual_session.call_count == len(sessions_to_run)
279+
mock_run_individual_session.assert_has_calls(
280+
[
281+
mocker.call("unit-3.9", "mock-library"),
282+
mocker.call("lint", "mock-library"),
283+
]
284+
)
285+
286+
287+
def test_run_nox_sessions_read_file_failure(mocker):
288+
"""Tests that _run_nox_sessions raises ValueError if _read_json_file fails."""
289+
mocker.patch("cli._read_json_file", side_effect=FileNotFoundError("file not found"))
290+
291+
with pytest.raises(ValueError, match="Failed to run the nox session"):
292+
_run_nox_sessions(["unit-3.9"])
293+
294+
295+
def test_run_nox_sessions_get_library_id_failure(mocker):
296+
"""Tests that _run_nox_sessions raises ValueError if _get_library_id fails."""
297+
mocker.patch("cli._read_json_file", return_value={"apis": []}) # Missing 'id'
298+
mocker.patch(
299+
"cli._get_library_id",
300+
side_effect=ValueError("Request file is missing required 'id' field."),
301+
)
302+
303+
with pytest.raises(ValueError, match="Failed to run the nox session"):
304+
_run_nox_sessions(["unit-3.9"])
305+
306+
307+
def test_run_nox_sessions_individual_session_failure(
308+
mocker, mock_generate_request_data_for_nox
309+
):
310+
"""Tests that _run_nox_sessions raises ValueError if _run_individual_session fails."""
311+
mocker.patch("cli._read_json_file", return_value=mock_generate_request_data_for_nox)
312+
mocker.patch("cli._get_library_id", return_value="mock-library")
313+
mock_run_individual_session = mocker.patch(
314+
"cli._run_individual_session",
315+
side_effect=[None, subprocess.CalledProcessError(1, "nox", "session failed")],
316+
)
317+
318+
sessions_to_run = ["unit-3.9", "lint"]
319+
with pytest.raises(ValueError, match="Failed to run the nox session"):
320+
_run_nox_sessions(sessions_to_run)
321+
322+
# Check that _run_individual_session was called at least once
323+
assert mock_run_individual_session.call_count > 0
324+
325+
326+
def test_handle_build_success(caplog, mocker):
195327
"""
196328
Tests the successful execution path of handle_build.
197329
"""
198330
caplog.set_level(logging.INFO)
199331

332+
mocker.patch("cli._run_nox_sessions")
200333
handle_build()
201334

202335
assert "'build' command executed." in caplog.text

cloudbuild.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
# TODO(https://github.com/googleapis/google-cloud-python/issues/14142):
16+
# Reduce this timeout by moving the installation of Python runtimes to a separate base image
17+
timeout: 7200s # 2 hours
1518
steps:
1619
# This step builds the Docker image.
1720
- name: 'gcr.io/cloud-builders/docker'

0 commit comments

Comments
 (0)