# Copyright 2018 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 # # https://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. import glob import os import xml.etree.ElementTree as ET import re import requests import yaml import synthtool as s import synthtool.gcp as gcp from synthtool import cache, shell from synthtool.gcp import common, partials, pregenerated, samples, snippets from synthtool.log import logger from pathlib import Path from typing import Any, Optional, Dict, Iterable, List JAR_DOWNLOAD_URL = "https://github.com/google/google-java-format/releases/download/google-java-format-{version}/google-java-format-{version}-all-deps.jar" DEFAULT_FORMAT_VERSION = "1.7" GOOD_LICENSE = """/* * Copyright 2020 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 * * https://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. */ """ PROTOBUF_HEADER = "// Generated by the protocol buffer compiler. DO NOT EDIT!" BAD_LICENSE = """/\\* \\* Copyright \\d{4} 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. \\*/ """ DEFAULT_MIN_SUPPORTED_JAVA_VERSION = 8 def format_code( path: str, version: str = DEFAULT_FORMAT_VERSION, times: int = 2 ) -> None: """ Runs the google-java-format jar against all .java files found within the provided path. """ jar_name = f"google-java-format-{version}.jar" jar = cache.get_cache_dir() / jar_name if not jar.exists(): _download_formatter(version, jar) # Find all .java files in path and run the formatter on them files = list(glob.iglob(os.path.join(path, "**/*.java"), recursive=True)) # Run the formatter as a jar file logger.info("Running java formatter on {} files".format(len(files))) for _ in range(times): shell.run(["java", "-jar", str(jar), "--replace"] + files) def _download_formatter(version: str, dest: Path) -> None: logger.info("Downloading java formatter") url = JAR_DOWNLOAD_URL.format(version=version) response = requests.get(url) response.raise_for_status() with open(dest, "wb") as fh: fh.write(response.content) HEADER_REGEX = re.compile("\\* Copyright \\d{4} Google LLC") def _file_has_header(path: Path) -> bool: """Return true if the file already contains a license header.""" with open(path, "rt") as fp: for line in fp: if HEADER_REGEX.search(line): return True return False def _filter_no_header(paths: Iterable[Path]) -> Iterable[Path]: """Return a subset of files that do not already have a header.""" for path in paths: anchor = Path(path.anchor) remainder = str(path.relative_to(path.anchor)) for file in anchor.glob(remainder): if not _file_has_header(file): yield file def fix_proto_headers(proto_root: Path) -> None: """Helper to ensure that generated proto classes have appropriate license headers. If the file does not already contain a license header, inject one at the top of the file. Some resource name classes may contain malformed license headers. In those cases, replace those with our standard license header. """ s.replace( _filter_no_header([proto_root / "src/**/*.java"]), PROTOBUF_HEADER, f"{GOOD_LICENSE}{PROTOBUF_HEADER}", ) # https://github.com/googleapis/gapic-generator/issues/3074 s.replace( [proto_root / "src/**/*Name.java", proto_root / "src/**/*Names.java"], BAD_LICENSE, GOOD_LICENSE, ) def fix_grpc_headers(grpc_root: Path, package_name: str = "unused") -> None: """Helper to ensure that generated grpc stub classes have appropriate license headers. If the file does not already contain a license header, inject one at the top of the file. """ s.replace( _filter_no_header([grpc_root / "src/**/*.java"]), "^package (.*);", f"{GOOD_LICENSE}package \\1;", ) def latest_maven_version(group_id: str, artifact_id: str) -> Optional[str]: """Helper function to find the latest released version of a Maven artifact. Fetches metadata from Maven Central and parses out the latest released version. Args: group_id (str): The groupId of the Maven artifact artifact_id (str): The artifactId of the Maven artifact Returns: The latest version of the artifact as a string or None """ group_path = "/".join(group_id.split(".")) url = ( f"https://repo1.maven.org/maven2/{group_path}/{artifact_id}/maven-metadata.xml" ) response = requests.get(url) if response.status_code >= 400: return "0.0.0" return version_from_maven_metadata(response.text) def version_from_maven_metadata(metadata: str) -> Optional[str]: """Helper function to parse the latest released version from the Maven metadata XML file. Args: metadata (str): The XML contents of the Maven metadata file Returns: The latest version of the artifact as a string or None """ root = ET.fromstring(metadata) latest = root.find("./versioning/latest") if latest is not None: return latest.text return None def _common_generation( service: str, version: str, library: Path, package_pattern: str, suffix: str = "", destination_name: str = None, cloud_api: bool = True, diregapic: bool = False, preserve_gapic: bool = False, ): """Helper function to execution the common generation cleanup actions. Fixes headers for protobuf classes and generated gRPC stub services. Copies code and samples to their final destinations by convention. Runs the code formatter on the generated code. Args: service (str): Name of the service. version (str): Service API version. library (Path): Path to the temp directory with the generated library. package_pattern (str): Package name template for fixing file headers. suffix (str, optional): Suffix that the generated library folder. The artman output differs from bazel's output directory. Defaults to "". destination_name (str, optional): Override the service name for the destination of the output code. Defaults to the service name. preserve_gapic (bool, optional): Whether to preserve the gapic directory prefix. Default False. """ if destination_name is None: destination_name = service cloud_prefix = "cloud-" if cloud_api else "" package_name = package_pattern.format(service=service, version=version) fix_proto_headers( library / f"proto-google-{cloud_prefix}{service}-{version}{suffix}" ) fix_grpc_headers( library / f"grpc-google-{cloud_prefix}{service}-{version}{suffix}", package_name ) if preserve_gapic: s.copy( [library / f"gapic-google-{cloud_prefix}{service}-{version}{suffix}/src"], f"gapic-google-{cloud_prefix}{destination_name}-{version}/src", required=True, ) else: s.copy( [library / f"gapic-google-{cloud_prefix}{service}-{version}{suffix}/src"], f"google-{cloud_prefix}{destination_name}/src", required=True, ) s.copy( [library / f"grpc-google-{cloud_prefix}{service}-{version}{suffix}/src"], f"grpc-google-{cloud_prefix}{destination_name}-{version}/src", # For REST-only clients, like java-compute, gRPC artifact does not exist required=(not diregapic), ) s.copy( [library / f"proto-google-{cloud_prefix}{service}-{version}{suffix}/src"], f"proto-google-{cloud_prefix}{destination_name}-{version}/src", required=True, ) if preserve_gapic: format_code(f"gapic-google-{cloud_prefix}{destination_name}-{version}/src") else: format_code(f"google-{cloud_prefix}{destination_name}/src") format_code(f"grpc-google-{cloud_prefix}{destination_name}-{version}/src") format_code(f"proto-google-{cloud_prefix}{destination_name}-{version}/src") def gapic_library( service: str, version: str, config_pattern: str = "/google/cloud/{service}/artman_{service}_{version}.yaml", package_pattern: str = "com.google.cloud.{service}.{version}", gapic: gcp.GAPICGenerator = None, destination_name: str = None, diregapic: bool = False, preserve_gapic: bool = False, **kwargs, ) -> Path: """Generate a Java library using the gapic-generator via artman via Docker. Generates code into a temp directory, fixes missing header fields, and copies into the expected locations. Args: service (str): Name of the service. version (str): Service API version. config_pattern (str, optional): Path template to artman config YAML file. Defaults to "/google/cloud/{service}/artman_{service}_{version}.yaml" package_pattern (str, optional): Package name template for fixing file headers. Defaults to "com.google.cloud.{service}.{version}". gapic (GAPICGenerator, optional): Generator instance. destination_name (str, optional): Override the service name for the destination of the output code. Defaults to the service name. preserve_gapic (bool, optional): Whether to preserve the gapic directory prefix. Default False. **kwargs: Additional options for gapic.java_library() Returns: The path to the temp directory containing the generated client. """ if gapic is None: gapic = gcp.GAPICGenerator() library = gapic.java_library( service=service, version=version, config_path=config_pattern.format(service=service, version=version), artman_output_name="", include_samples=True, diregapic=diregapic, **kwargs, ) _common_generation( service=service, version=version, library=library, package_pattern=package_pattern, destination_name=destination_name, diregapic=diregapic, preserve_gapic=preserve_gapic, ) return library def bazel_library( service: str, version: str, package_pattern: str = "com.google.cloud.{service}.{version}", gapic: gcp.GAPICBazel = None, destination_name: str = None, cloud_api: bool = True, diregapic: bool = False, preserve_gapic: bool = False, **kwargs, ) -> Path: """Generate a Java library using the gapic-generator via bazel. Generates code into a temp directory, fixes missing header fields, and copies into the expected locations. Args: service (str): Name of the service. version (str): Service API version. package_pattern (str, optional): Package name template for fixing file headers. Defaults to "com.google.cloud.{service}.{version}". gapic (GAPICBazel, optional): Generator instance. destination_name (str, optional): Override the service name for the destination of the output code. Defaults to the service name. preserve_gapic (bool, optional): Whether to preserve the gapic directory prefix. Default False. **kwargs: Additional options for gapic.java_library() Returns: The path to the temp directory containing the generated client. """ if gapic is None: gapic = gcp.GAPICBazel() library = gapic.java_library( service=service, version=version, diregapic=diregapic, **kwargs ) _common_generation( service=service, version=version, library=library / f"google-cloud-{service}-{version}-java", package_pattern=package_pattern, suffix="-java", destination_name=destination_name, cloud_api=cloud_api, diregapic=diregapic, preserve_gapic=preserve_gapic, ) return library def pregenerated_library( path: str, service: str, version: str, destination_name: str = None, cloud_api: bool = True, ) -> Path: """Generate a Java library using the gapic-generator via bazel. Generates code into a temp directory, fixes missing header fields, and copies into the expected locations. Args: path (str): Path in googleapis-gen to un-versioned generated code. service (str): Name of the service. version (str): Service API version. destination_name (str, optional): Override the service name for the destination of the output code. Defaults to the service name. cloud_api (bool, optional): Whether or not this is a cloud API (for naming) Returns: The path to the temp directory containing the generated client. """ generator = pregenerated.Pregenerated() library = generator.generate(path) cloud_prefix = "cloud-" if cloud_api else "" _common_generation( service=service, version=version, library=library / f"google-{cloud_prefix}{service}-{version}-java", package_pattern="unused", suffix="-java", destination_name=destination_name, cloud_api=cloud_api, ) return library def _merge_release_please(destination_text: str): config = yaml.safe_load(destination_text) if "handleGHRelease" in config: return destination_text config["handleGHRelease"] = True if "branches" in config: for branch in config["branches"]: branch["handleGHRelease"] = True return yaml.dump(config) def _merge_common_templates( source_text: str, destination_text: str, file_path: Path ) -> str: # keep any existing pom.xml if file_path.match("pom.xml") or file_path.match("sync-repo-settings.yaml"): logger.debug(f"existing pom file found ({file_path}) - keeping the existing") return destination_text if file_path.match("release-please.yml"): return _merge_release_please(destination_text) # by default return the newly generated content return source_text def _common_template_metadata() -> Dict[str, Any]: metadata = {} # type: Dict[str, Any] repo_metadata = common._load_repo_metadata() if repo_metadata: metadata["repo"] = repo_metadata group_id, artifact_id = repo_metadata["distribution_name"].split(":") metadata["latest_version"] = latest_maven_version( group_id=group_id, artifact_id=artifact_id ) metadata["latest_bom_version"] = latest_maven_version( group_id="com.google.cloud", artifact_id="libraries-bom", ) metadata["samples"] = samples.all_samples(["samples/**/src/main/java/**/*.java"]) metadata["snippets"] = snippets.all_snippets( ["samples/**/src/main/java/**/*.java", "samples/**/pom.xml"] ) if repo_metadata and "min_java_version" in repo_metadata: metadata["min_java_version"] = repo_metadata["min_java_version"] else: metadata["min_java_version"] = DEFAULT_MIN_SUPPORTED_JAVA_VERSION return metadata def common_templates( excludes: List[str] = [], template_path: Optional[Path] = None, **kwargs ) -> None: """Generate common templates for a Java Library Fetches information about the repository from the .repo-metadata.json file, information about the latest artifact versions and copies the files into their expected location. Args: excludes (List[str], optional): List of template paths to ignore **kwargs: Additional options for CommonTemplates.java_library() """ metadata = _common_template_metadata() kwargs["metadata"] = metadata # Generate flat to tell this repository is a split repo that have migrated # to monorepo. The owlbot.py in the monorepo sets monorepo=True. monorepo = kwargs.get("monorepo", False) split_repo = not monorepo repo_metadata = metadata["repo"] repo_short = repo_metadata["repo_short"] # Special libraries that are not GAPIC_AUTO but in the monorepo special_libs_in_monorepo = [ "java-translate", "java-dns", "java-notification", "java-resourcemanager", ] kwargs["migrated_split_repo"] = split_repo and ( repo_metadata["library_type"] == "GAPIC_AUTO" or (repo_short and repo_short in special_libs_in_monorepo) ) logger.info( "monorepo: {}, split_repo: {}, library_type: {}," " repo_short: {}, migrated_split_repo: {}".format( monorepo, split_repo, repo_metadata["library_type"], repo_short, kwargs["migrated_split_repo"], ) ) templates = gcp.CommonTemplates(template_path=template_path).java_library(**kwargs) # skip README generation on Kokoro (autosynth) if os.environ.get("KOKORO_ROOT") is not None: # README.md is now synthesized separately. This prevents synthtool from deleting the # README as it's no longer generated here. excludes.append("README.md") s.copy([templates], excludes=excludes, merge=_merge_common_templates) def custom_templates(files: List[str], **kwargs) -> None: """Generate custom template files Fetches information about the repository from the .repo-metadata.json file, information about the latest artifact versions and copies the files into their expected location. Args: files (List[str], optional): List of template paths to include **kwargs: Additional options for CommonTemplates.render() """ kwargs["metadata"] = _common_template_metadata() kwargs["metadata"]["partials"] = partials.load_partials() for file in files: template = gcp.CommonTemplates().render(file, **kwargs) s.copy([template]) def remove_method(filename: str, signature: str): """Helper to remove an entire method. Goes line-by-line to detect the start of the block. Determines the end of the block by a closing brace at the same indentation level. This requires the file to be correctly formatted. Example: consider the following class: class Example { public void main(String[] args) { System.out.println("Hello World"); } public String foo() { return "bar"; } } To remove the `main` method above, use: remove_method('path/to/file', 'public void main(String[] args)') Args: filename (str): Path to source file signature (str): Full signature of the method to remove. Example: `public void main(String[] args)`. """ lines = [] leading_regex = None with open(filename, "r") as fp: line = fp.readline() while line: # for each line, try to find the matching regex = re.compile("(\\s*)" + re.escape(signature) + ".*") match = regex.match(line) if match: leading_regex = re.compile(match.group(1) + "}") line = fp.readline() continue # not in a ignore block - preserve the line if not leading_regex: lines.append(line) line = fp.readline() continue # detect the closing tag based on the leading spaces match = leading_regex.match(line) if match: # block is closed, resume capturing content leading_regex = None line = fp.readline() with open(filename, "w") as fp: for line in lines: # print(line) fp.write(line) def copy_and_rename_method(filename: str, signature: str, before: str, after: str): """Helper to make a copy an entire method and rename it. Goes line-by-line to detect the start of the block. Determines the end of the block by a closing brace at the same indentation level. This requires the file to be correctly formatted. The method is copied over and renamed in the method signature. The calls to both methods are separate and unaffected. Example: consider the following class: class Example { public void main(String[] args) { System.out.println("Hello World"); } public String foo() { return "bar"; } } To copy and rename the `main` method above, use: copy_and_rename_method('path/to/file', 'public void main(String[] args)', 'main', 'foo1') Args: filename (str): Path to source file signature (str): Full signature of the method to remove. Example: `public void main(String[] args)`. before (str): name of the method to be copied after (str): new name of the copied method """ lines = [] method = [] leading_regex = None with open(filename, "r") as fp: line = fp.readline() while line: # for each line, try to find the matching regex = re.compile("(\\s*)" + re.escape(signature) + ".*") match = regex.match(line) if match: leading_regex = re.compile(match.group(1) + "}") lines.append(line) method.append(line.replace(before, after)) line = fp.readline() continue lines.append(line) # not in a ignore block - preserve the line if leading_regex: method.append(line) else: line = fp.readline() continue # detect the closing tag based on the leading spaces match = leading_regex.match(line) if match: # block is closed, resume capturing content leading_regex = None lines.append("\n") lines.extend(method) line = fp.readline() with open(filename, "w") as fp: for line in lines: # print(line) fp.write(line) def add_javadoc(filename: str, signature: str, javadoc_type: str, content: List[str]): """Helper to add a javadoc annoatation to a method. Goes line-by-line to detect the start of the block. Then finds the existing method comment (if it exists). If the comment already exists, it will append the javadoc annotation to the javadoc block. Otherwise, it will create a new javadoc comment block. Example: consider the following class: class Example { public void main(String[] args) { System.out.println("Hello World"); } public String foo() { return "bar"; } } To add a javadoc annotation the `main` method above, use: add_javadoc('path/to/file', 'public void main(String[] args)', 'deprecated', 'Please use foo instead.') Args: filename (str): Path to source file signature (str): Full signature of the method to remove. Example: `public void main(String[] args)`. javadoc_type (str): The type of javadoc annotation. Example: `deprecated`. content (List[str]): The javadoc lines """ lines: List[str] = [] annotations: List[str] = [] with open(filename, "r") as fp: line = fp.readline() while line: # for each line, try to find the matching regex = re.compile("(\\s*)" + re.escape(signature) + ".*") match = regex.match(line) if match: leading_spaces = len(line) - len(line.lstrip()) indent = leading_spaces * " " last_line = lines.pop() while last_line.lstrip() and last_line.lstrip()[0] == "@": annotations.append(last_line) last_line = lines.pop() if last_line.strip() == "*/": first = True for content_line in content: if first: lines.append( indent + " * @" + javadoc_type + " " + content_line + "\n" ) first = False else: lines.append(indent + " * " + content_line + "\n") lines.append(last_line) else: lines.append(last_line) lines.append(indent + "/**\n") first = True for content_line in content: if first: lines.append( indent + " * @" + javadoc_type + " " + content_line + "\n" ) first = False else: lines.append(indent + " * " + content_line + "\n") lines.append(indent + " */\n") lines.extend(annotations[::-1]) lines.append(line) line = fp.readline() with open(filename, "w") as fp: for line in lines: # print(line) fp.write(line) def annotate_method(filename: str, signature: str, annotation: str): """Helper to add an annotation to a method. Goes line-by-line to detect the start of the block. Then adds the annotation above the found method signature. Example: consider the following class: class Example { public void main(String[] args) { System.out.println("Hello World"); } public String foo() { return "bar"; } } To add an annotation the `main` method above, use: annotate_method('path/to/file', 'public void main(String[] args)', '@Generated()') Args: filename (str): Path to source file signature (str): Full signature of the method to remove. Example: `public void main(String[] args)`. annotation (str): Full annotation. Example: `@Deprecated` """ lines: List[str] = [] with open(filename, "r") as fp: line = fp.readline() while line: # for each line, try to find the matching regex = re.compile("(\\s*)" + re.escape(signature) + ".*") match = regex.match(line) if match: leading_spaces = len(line) - len(line.lstrip()) indent = leading_spaces * " " lines.append(indent + annotation + "\n") lines.append(line) line = fp.readline() with open(filename, "w") as fp: for line in lines: # print(line) fp.write(line) def deprecate_method(filename: str, signature: str, alternative: str): """Helper to deprecate a method. Goes line-by-line to detect the start of the block. Then adds the deprecation comment before the method signature. The @Deprecation annotation is also added. Example: consider the following class: class Example { public void main(String[] args) { System.out.println("Hello World"); } public String foo() { return "bar"; } } To deprecate the `main` method above, use: deprecate_method('path/to/file', 'public void main(String[] args)', DEPRECATION_WARNING.format(new_method="foo")) Args: filename (str): Path to source file signature (str): Full signature of the method to remove. Example: `public void main(String[] args)`. alternative: DEPRECATION WARNING: multiline javadoc comment with user specified leading open/close comment tags """ add_javadoc(filename, signature, "deprecated", alternative.splitlines()) annotate_method(filename, signature, "@Deprecated")