diff --git a/.generator/cli.py b/.generator/cli.py index 30831934937a..48ed4efb369d 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -45,6 +45,7 @@ BUILD_REQUEST_FILE = "build-request.json" GENERATE_REQUEST_FILE = "generate-request.json" +CONFIGURE_REQUEST_FILE = "configure-request.json" RELEASE_INIT_REQUEST_FILE = "release-init-request.json" STATE_YAML_FILE = "state.yaml" @@ -114,6 +115,103 @@ def _write_json_file(path: str, updated_content: Dict): json.dump(updated_content, f, indent=2) f.write("\n") +def _add_new_library_source_roots(library_config: Dict, library_id: str) -> None: + """Adds the default source_roots to the library configuration if not present. + + Args: + library_config(Dict): The library configuration. + library_id(str): The id of the library. + """ + if library_config["source_roots"] is None: + library_config["source_roots"] = [f"packages/{library_id}"] + + +def _add_new_library_preserve_regex(library_config: Dict, library_id: str) -> None: + """Adds the default preserve_regex to the library configuration if not present. + + Args: + library_config(Dict): The library configuration. + library_id(str): The id of the library. + """ + if library_config["preserve_regex"] is None: + library_config["preserve_regex"] = [ + f"packages/{library_id}/CHANGELOG.md", + "docs/CHANGELOG.md", + "docs/README.rst", + "samples/README.txt", + "tar.gz", + "gapic_version.py", + "scripts/client-post-processing", + "samples/snippets/README.rst", + "tests/system", + ] + + +def _add_new_library_remove_regex(library_config: Dict, library_id: str) -> None: + """Adds the default remove_regex to the library configuration if not present. + + Args: + library_config(Dict): The library configuration. + library_id(str): The id of the library. + """ + if library_config["remove_regex"] is None: + library_config["remove_regex"] = [f"packages/{library_id}"] + + +def _add_new_library_tag_format(library_config: Dict) -> None: + """Adds the default tag_format to the library configuration if not present. + + Args: + library_config(Dict): The library configuration. + """ + if "tag_format" not in library_config: + library_config["tag_format"] = "{{id}}-v{{version}}" + + +def _get_new_library_config(request_data: Dict) -> Dict: + """Finds and returns the configuration for a new library. + + Args: + request_data(Dict): The request data from which to extract the new + library config. + + Returns: + Dict: The unmodified configuration of a new library, or an empty + dictionary if not found. + """ + for library_config in request_data.get("libraries", []): + all_apis = library_config.get("apis", []) + for api in all_apis: + if api.get("status") == "new": + return library_config + return {} + + +def _prepare_new_library_config(library_config: Dict) -> Dict: + """ + Prepares the new library's configuration by removing temporary keys and + adding default values. + + Args: + library_config (Dict): The raw library configuration. + + Returns: + Dict: The prepared library configuration. + """ + # remove status key from new library config. + all_apis = library_config.get("apis", []) + for api in all_apis: + if "status" in api: + del api["status"] + + library_id = _get_library_id(library_config) + _add_new_library_source_roots(library_config, library_id) + _add_new_library_preserve_regex(library_config, library_id) + _add_new_library_remove_regex(library_config, library_id) + _add_new_library_tag_format(library_config) + + return library_config + def handle_configure( librarian: str = LIBRARIAN_DIR, @@ -144,7 +242,17 @@ def handle_configure( Raises: ValueError: If configuring a new library fails. """ - # TODO(https://github.com/googleapis/librarian/issues/466): Implement configure command and update docstring. + try: + # configure-request.json contains the library definitions. + request_data = _read_json_file(f"{librarian}/{CONFIGURE_REQUEST_FILE}") + new_library_config = _get_new_library_config(request_data) + prepared_config = _prepare_new_library_config(new_library_config) + + # Write the new library configuration to configure-response.json. + _write_json_file(f"{librarian}/configure-response.json", prepared_config) + + except Exception as e: + raise ValueError("Configuring a new library failed.") from e logger.info("'configure' command executed.") diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 61a4930c3251..3904d8e72879 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -28,6 +28,7 @@ from cli import ( GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, + CONFIGURE_REQUEST_FILE, RELEASE_INIT_REQUEST_FILE, SOURCE_DIR, STATE_YAML_FILE, @@ -43,7 +44,9 @@ _get_library_dist_name, _get_library_id, _get_libraries_to_prepare_for_release, + _get_new_library_config, _get_previous_version, + _prepare_new_library_config, _process_changelog, _process_version_file, _read_bazel_build_py_rule, @@ -160,6 +163,35 @@ def mock_build_request_file(tmp_path, monkeypatch): return request_file +@pytest.fixture +def mock_configure_request_data(): + """Returns mock data for configure-request.json.""" + return { + "libraries": [ + { + "id": "google-cloud-language", + "apis": [{"path": "google/cloud/language/v1", "status": "new"}], + } + ] + } + + +@pytest.fixture +def mock_configure_request_file(tmp_path, monkeypatch, mock_configure_request_data): + """Creates the mock request file at the correct path inside a temp dir.""" + # Create the path as expected by the script: .librarian/configure-request.json + request_path = f"{LIBRARIAN_DIR}/{CONFIGURE_REQUEST_FILE}" + request_dir = tmp_path / os.path.dirname(request_path) + request_dir.mkdir(parents=True, exist_ok=True) + request_file = request_dir / os.path.basename(request_path) + + request_file.write_text(json.dumps(mock_configure_request_data)) + + # Change the current working directory to the temp path for the test. + monkeypatch.chdir(tmp_path) + return request_file + + @pytest.fixture def mock_build_bazel_file(tmp_path, monkeypatch): """Creates the mock BUILD.bazel file at the correct path inside a temp dir.""" @@ -236,6 +268,98 @@ def mock_state_file(tmp_path, monkeypatch): return request_file +def test_handle_configure_success(mock_configure_request_file, mocker): + """Tests the successful execution path of handle_configure.""" + mock_write_json = mocker.patch("cli._write_json_file") + mock_prepare_config = mocker.patch( + "cli._prepare_new_library_config", return_value={"id": "prepared"} + ) + + handle_configure() + + mock_prepare_config.assert_called_once() + mock_write_json.assert_called_once_with( + f"{LIBRARIAN_DIR}/configure-response.json", {"id": "prepared"} + ) + + +def test_handle_configure_no_new_library(mocker): + """Tests that handle_configure fails if no new library is found.""" + mocker.patch("cli._read_json_file", return_value={"libraries": []}) + # The call to _prepare_new_library_config with an empty dict will raise a ValueError + # because _get_library_id will fail. + with pytest.raises(ValueError, match="Configuring a new library failed."): + handle_configure() + + +def test_get_new_library_config_found(mock_configure_request_data): + """Tests that the new library configuration is returned when found.""" + config = _get_new_library_config(mock_configure_request_data) + assert config["id"] == "google-cloud-language" + # Assert that the config is NOT modified + assert "status" in config["apis"][0] + + +def test_get_new_library_config_not_found(): + """Tests that an empty dictionary is returned when no new library is found.""" + request_data = { + "libraries": [ + {"id": "existing-library", "apis": [{"path": "path/v1", "status": "existing"}]}, + ] + } + config = _get_new_library_config(request_data) + assert config == {} + + +def test_get_new_library_config_empty_input(): + """Tests that an empty dictionary is returned for empty input.""" + config = _get_new_library_config({}) + assert config == {} + + +def test_prepare_new_library_config(): + """Tests the preparation of a new library's configuration.""" + raw_config = { + "id": "google-cloud-language", + "apis": [{"path": "google/cloud/language/v1", "status": "new"}], + "source_roots": None, + "preserve_regex": None, + "remove_regex": None, + } + + prepared_config = _prepare_new_library_config(raw_config) + + # Check that status is removed + assert "status" not in prepared_config["apis"][0] + # Check that defaults are added + assert prepared_config["source_roots"] == ["packages/google-cloud-language"] + assert "packages/google-cloud-language/CHANGELOG.md" in prepared_config["preserve_regex"] + assert prepared_config["remove_regex"] == ["packages/google-cloud-language"] + assert prepared_config["tag_format"] == "{{id}}-v{{version}}" + + +def test_prepare_new_library_config_preserves_existing_values(): + """Tests that existing values in the config are not overwritten.""" + raw_config = { + "id": "google-cloud-language", + "apis": [{"path": "google/cloud/language/v1", "status": "new"}], + "source_roots": ["packages/google-cloud-language-custom"], + "preserve_regex": ["custom/regex"], + "remove_regex": ["custom/remove"], + "tag_format": "custom-format-{{version}}", + } + + prepared_config = _prepare_new_library_config(raw_config) + + # Check that status is removed + assert "status" not in prepared_config["apis"][0] + # Check that existing values are preserved + assert prepared_config["source_roots"] == ["packages/google-cloud-language-custom"] + assert prepared_config["preserve_regex"] == ["custom/regex"] + assert prepared_config["remove_regex"] == ["custom/remove"] + assert prepared_config["tag_format"] == "custom-format-{{version}}" + + def test_get_library_id_success(): """Tests that _get_library_id returns the correct ID when present.""" request_data = {"id": "test-library", "name": "Test Library"} @@ -261,17 +385,6 @@ def test_get_library_id_empty_id(): _get_library_id(request_data) -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_run_post_processor_success(mocker, caplog): """ Tests that the post-processor helper calls the correct command.