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