xref: /aosp_15_r20/external/google-cloud-java/owl-bot-postprocessor/synthtool/gcp/gapic_generator.py (revision 55e87721aa1bc457b326496a7ca40f3ea1a63287)
1# Copyright 2018 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import requests
17import shutil
18import subprocess
19import yaml
20
21from pathlib import Path
22from typing import Optional
23
24from synthtool import _tracked_paths, metadata, shell
25from synthtool.gcp import artman
26from synthtool.log import logger
27from synthtool.sources import git
28
29GOOGLEAPIS_URL: str = git.make_repo_clone_url("googleapis/googleapis")
30GOOGLEAPIS_PRIVATE_URL: str = git.make_repo_clone_url("googleapis/googleapis-private")
31LOCAL_GOOGLEAPIS: Optional[str] = os.environ.get("SYNTHTOOL_GOOGLEAPIS")
32LOCAL_GENERATOR: Optional[str] = os.environ.get("SYNTHTOOL_GENERATOR")
33
34
35class GAPICGenerator:
36    def __init__(self):
37        self._googleapis = None
38        self._googleapis_private = None
39        self._artman = artman.Artman()
40
41    def py_library(self, service: str, version: str, **kwargs) -> Path:
42        """
43        Generates the Python Library files using artman/GAPIC
44        returns a `Path` object
45        library: path to library. 'google/cloud/speech'
46        version: version of lib. 'v1'
47        """
48        return self._generate_code(service, version, "python", **kwargs)
49
50    def node_library(self, service: str, version: str, **kwargs) -> Path:
51        return self._generate_code(service, version, "nodejs", **kwargs)
52
53    nodejs_library = node_library
54
55    def ruby_library(self, service: str, version: str, **kwargs) -> Path:
56        return self._generate_code(service, version, "ruby", **kwargs)
57
58    def php_library(self, service: str, version: str, **kwargs) -> Path:
59        return self._generate_code(service, version, "php", **kwargs)
60
61    def java_library(self, service: str, version: str, **kwargs) -> Path:
62        return self._generate_code(service, version, "java", **kwargs)
63
64    def _generate_code(
65        self,
66        service,
67        version,
68        language,
69        config_path=None,
70        artman_output_name=None,
71        private=False,
72        include_protos=False,
73        include_samples=False,
74        generator_args=None,
75    ):
76        # map the language to the artman argument and subdir of genfiles
77        GENERATE_FLAG_LANGUAGE = {
78            "python": ("python_gapic", "python"),
79            "nodejs": ("nodejs_gapic", "js"),
80            "ruby": ("ruby_gapic", "ruby"),
81            "php": ("php_gapic", "php"),
82            "java": ("java_gapic", "java"),
83        }
84
85        if language not in GENERATE_FLAG_LANGUAGE:
86            raise ValueError("provided language unsupported")
87
88        gapic_language_arg, gen_language = GENERATE_FLAG_LANGUAGE[language]
89
90        # Determine which googleapis repo to use
91        if not private:
92            googleapis = self._clone_googleapis()
93        else:
94            googleapis = self._clone_googleapis_private()
95
96        if googleapis is None:
97            raise RuntimeError(
98                f"Unable to generate {config_path}, the googleapis repository"
99                "is unavailable."
100            )
101
102        generator_dir = LOCAL_GENERATOR
103        if generator_dir is not None:
104            logger.debug(f"Using local generator at {generator_dir}")
105
106        # Run the code generator.
107        # $ artman --config path/to/artman_api.yaml generate python_gapic
108        if config_path is None:
109            config_path = (
110                Path("google/cloud") / service / f"artman_{service}_{version}.yaml"
111            )
112        elif Path(config_path).is_absolute():
113            config_path = Path(config_path).relative_to("/")
114        else:
115            config_path = Path("google/cloud") / service / Path(config_path)
116
117        if not (googleapis / config_path).exists():
118            raise FileNotFoundError(
119                f"Unable to find configuration yaml file: {(googleapis / config_path)}."
120            )
121
122        logger.debug(f"Running generator for {config_path}.")
123
124        if include_samples:
125            if generator_args is None:
126                generator_args = []
127            # Add feature flag for generating code samples with code generator.
128            generator_args.append("--dev_samples")
129
130        output_root = self._artman.run(
131            f"googleapis/artman:{artman.ARTMAN_VERSION}",
132            googleapis,
133            config_path,
134            gapic_language_arg,
135            generator_dir=generator_dir,
136            generator_args=generator_args,
137        )
138
139        # Expect the output to be in the artman-genfiles directory.
140        # example: /artman-genfiles/python/speech-v1
141        if artman_output_name is None:
142            artman_output_name = f"{service}-{version}"
143        genfiles = output_root / gen_language / artman_output_name
144
145        if not genfiles.exists():
146            raise FileNotFoundError(
147                f"Unable to find generated output of artman: {genfiles}."
148            )
149
150        logger.success(f"Generated code into {genfiles}.")
151
152        # Get the *.protos files and put them in a protos dir in the output
153        if include_protos:
154            source_dir = googleapis / config_path.parent / version
155            proto_files = source_dir.glob("**/*.proto")
156            # By default, put the protos at the root in a folder named 'protos'.
157            # Specific languages can be cased here to put them in a more language
158            # appropriate place.
159            proto_output_path = genfiles / "protos"
160            if language == "python":
161                # place protos alongsize the *_pb2.py files
162                proto_output_path = genfiles / f"google/cloud/{service}_{version}/proto"
163            os.makedirs(proto_output_path, exist_ok=True)
164
165            for i in proto_files:
166                logger.debug(f"Copy: {i} to {proto_output_path / i.name}")
167                shutil.copyfile(i, proto_output_path / i.name)
168            logger.success(f"Placed proto files into {proto_output_path}.")
169
170        if include_samples:
171            samples_root_dir = None
172            samples_resource_dir = None
173            if language == "java":
174                samples_root_dir = (
175                    genfiles
176                    / f"gapic-google-cloud-{service}-{version}/samples/src/main/java/com/google/cloud/examples/{service}"
177                )
178                samples_resource_dir = (
179                    genfiles
180                    / f"gapic-google-cloud-{service}-{version}/samples/resources"
181                )
182            googleapis_service_dir = googleapis / config_path.parent
183            self._include_samples(
184                language=language,
185                version=version,
186                genfiles=genfiles,
187                googleapis_service_dir=googleapis_service_dir,
188                samples_root_dir=samples_root_dir,
189                samples_resources_dir=samples_resource_dir,
190            )
191
192        metadata.add_client_destination(
193            source="googleapis" if not private else "googleapis-private",
194            api_name=service,
195            api_version=version,
196            language=language,
197            generator="gapic",
198            config=str(config_path),
199        )
200
201        _tracked_paths.add(genfiles)
202        return genfiles
203
204    def _clone_googleapis(self):
205        if self._googleapis is not None:
206            return self._googleapis
207
208        if LOCAL_GOOGLEAPIS:
209            self._googleapis = Path(LOCAL_GOOGLEAPIS).expanduser()
210            logger.debug(f"Using local googleapis at {self._googleapis}")
211
212        else:
213            logger.debug("Cloning googleapis.")
214            self._googleapis = git.clone(GOOGLEAPIS_URL)
215
216        return self._googleapis
217
218    def _clone_googleapis_private(self):
219        if self._googleapis_private is not None:
220            return self._googleapis_private
221
222        if LOCAL_GOOGLEAPIS:
223            self._googleapis_private = Path(LOCAL_GOOGLEAPIS).expanduser()
224            logger.debug(
225                f"Using local googleapis at {self._googleapis_private} for googleapis-private"
226            )
227
228        else:
229            logger.debug("Cloning googleapis-private.")
230            self._googleapis_private = git.clone(GOOGLEAPIS_PRIVATE_URL)
231
232        return self._googleapis_private
233
234    def _include_samples(
235        self,
236        language: str,
237        version: str,
238        genfiles: Path,
239        googleapis_service_dir: Path,
240        samples_root_dir: Path = None,
241        samples_resources_dir: Path = None,
242    ):
243        """Include code samples and supporting resources in generated output.
244
245        Resulting directory structure in generated output:
246            samples/
247            ├── resources
248            │   ├── example_text_file.txt
249            │   └── example_data.csv
250            └── v1/
251                ├── sample_one.py
252                ├── sample_two.py
253                └── test/
254                    ├── samples.manifest.yaml
255                    ├── sample_one.test.yaml
256                    └── sample_two.test.yaml
257
258        Samples are included in the genfiles output of the generator.
259
260        Sample tests are defined in googleapis:
261            {service}/{version}/samples/test/*.test.yaml
262
263        Sample resources are declared in {service}/sample_resources.yaml
264        which includes a list of files with public gs:// URIs for download.
265
266        Sample resources are files needed to run code samples or system tests.
267        Synth keeps resources in sync by always pulling down the latest version.
268        It is recommended to store resources in the `cloud-samples-data` bucket.
269
270        Sample manifest is a generated file which defines invocation commands
271        for each code sample (used by sample-tester to invoke samples).
272        """
273
274        if samples_root_dir is None:
275            samples_root_dir = genfiles / "samples"
276
277        if samples_resources_dir is None:
278            samples_resources_dir = samples_root_dir / "resources"
279
280        samples_version_dir = samples_root_dir / version
281
282        # Some languages capitalize their `V` prefix for version numbers
283        if not samples_version_dir.is_dir():
284            samples_version_dir = samples_root_dir / version.capitalize()
285
286        # Do not proceed if genfiles does not include samples/{version} dir.
287        if not samples_version_dir.is_dir():
288            return None
289
290        samples_test_dir = samples_version_dir / "test"
291        samples_manifest_yaml = samples_test_dir / "samples.manifest.yaml"
292
293        googleapis_samples_dir = googleapis_service_dir / version / "samples"
294        googleapis_resources_yaml = googleapis_service_dir / "sample_resources.yaml"
295
296        # Copy sample tests from googleapis {service}/{version}/samples/*.test.yaml
297        # into generated output as samples/{version}/test/*.test.yaml
298        test_files = googleapis_samples_dir.glob("**/*.test.yaml")
299        os.makedirs(samples_test_dir, exist_ok=True)
300        for i in test_files:
301            logger.debug(f"Copy: {i} to {samples_test_dir / i.name}")
302            shutil.copyfile(i, samples_test_dir / i.name)
303
304        # Download sample resources from sample_resources.yaml storage URIs.
305        #
306        #  sample_resources:
307        #  - uri: gs://bucket/the/file/path.csv
308        #    description: Description of this resource
309        #
310        # Code follows happy path. An error is desirable if YAML is invalid.
311        if googleapis_resources_yaml.is_file():
312            with open(googleapis_resources_yaml, "r") as f:
313                resources_data = yaml.load(f, Loader=yaml.SafeLoader)
314            resource_list = resources_data.get("sample_resources")
315            for resource in resource_list:
316                uri = resource.get("uri")
317                if uri.startswith("gs://"):
318                    uri = uri.replace("gs://", "https://storage.googleapis.com/")
319                response = requests.get(uri, allow_redirects=True)
320                download_path = samples_resources_dir / os.path.basename(uri)
321                os.makedirs(samples_resources_dir, exist_ok=True)
322                logger.debug(f"Download {uri} to {download_path}")
323                with open(download_path, "wb") as output:  # type: ignore
324                    output.write(response.content)
325
326        # Generate manifest file at samples/{version}/test/samples.manifest.yaml
327        # Includes a reference to every sample (via its "region tag" identifier)
328        # along with structured instructions on how to invoke that code sample.
329        relative_manifest_path = str(
330            samples_manifest_yaml.relative_to(samples_root_dir)
331        )
332
333        LANGUAGE_EXECUTABLES = {
334            "nodejs": "node",
335            "php": "php",
336            "python": "python3",
337            "ruby": "bundle exec ruby",
338        }
339        if language not in LANGUAGE_EXECUTABLES:
340            logger.info("skipping manifest gen")
341            return None
342
343        manifest_arguments = [
344            "gen-manifest",
345            f"--env={language}",
346            f"--bin={LANGUAGE_EXECUTABLES[language]}",
347            f"--output={relative_manifest_path}",
348            "--chdir={@manifest_dir}/../..",
349        ]
350
351        for code_sample in samples_version_dir.glob("*"):
352            sample_path = str(code_sample.relative_to(samples_root_dir))
353            if os.path.isfile(code_sample):
354                manifest_arguments.append(sample_path)
355        try:
356            logger.debug(f"Writing samples manifest {manifest_arguments}")
357            shell.run(manifest_arguments, cwd=samples_root_dir)
358        except (subprocess.CalledProcessError, FileNotFoundError):
359            logger.warning("gen-manifest failed (sample-tester may not be installed)")
360