| # 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 json |
| import os |
| import re |
| import shutil |
| import fnmatch |
| from copy import deepcopy |
| from pathlib import Path |
| from typing import Dict, List, Optional |
| import jinja2 |
| from datetime import date |
| |
| from synthtool import shell, _tracked_paths |
| from synthtool.gcp import partials |
| from synthtool.languages import node, node_mono_repo |
| from synthtool.log import logger |
| from synthtool.sources import git, templates |
| |
| PathOrStr = templates.PathOrStr |
| TEMPLATES_URL: str = git.make_repo_clone_url("googleapis/synthtool") |
| DEFAULT_TEMPLATES_PATH = "synthtool/gcp/templates" |
| LOCAL_TEMPLATES: Optional[str] = os.environ.get("SYNTHTOOL_TEMPLATES") |
| |
| |
| class CommonTemplates: |
| def __init__(self, template_path: Optional[Path] = None): |
| if template_path: |
| self._template_root = template_path |
| elif LOCAL_TEMPLATES: |
| logger.debug(f"Using local templates at {LOCAL_TEMPLATES}") |
| self._template_root = Path(LOCAL_TEMPLATES) |
| else: |
| templates_git = git.clone(TEMPLATES_URL) |
| self._template_root = templates_git / DEFAULT_TEMPLATES_PATH |
| |
| self._templates = templates.Templates(self._template_root) |
| self.excludes = [] # type: List[str] |
| |
| def _generic_library(self, directory: str, relative_dir=None, **kwargs) -> Path: |
| # load common repo meta information (metadata that's not language specific). |
| if "metadata" in kwargs: |
| self._load_generic_metadata(kwargs["metadata"], relative_dir=relative_dir) |
| # if no samples were found, don't attempt to render a |
| # samples/README.md. |
| if "samples" not in kwargs["metadata"] or not kwargs["metadata"]["samples"]: |
| self.excludes.append("samples/README.md") |
| |
| t = templates.TemplateGroup(self._template_root / directory, self.excludes) |
| |
| if "repository" in kwargs["metadata"] and "repo" in kwargs["metadata"]: |
| kwargs["metadata"]["repo"]["default_branch"] = _get_default_branch_name( |
| kwargs["metadata"]["repository"] |
| ) |
| |
| # TODO: migrate to python.py once old sample gen is deprecated |
| if directory == "python_samples": |
| t.env.globals["get_help"] = lambda filename: shell.run( |
| ["python", filename, "--help"] |
| ).stdout |
| |
| result = t.render(**kwargs) |
| _tracked_paths.add(result) |
| |
| return result |
| |
| def py_samples(self, **kwargs) -> List[Path]: |
| """ |
| Handles generation of README.md templates for Python samples |
| - Determines whether generation is being done in a client library or in a samples |
| folder automatically |
| - Otherwise accepts manually set sample_project_dir through kwargs metadata |
| - Delegates generation of additional sample documents alternate/overridden folders |
| through py_samples_override() |
| """ |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| |
| # load common repo meta information (metadata that's not language specific). |
| self._load_generic_metadata(kwargs["metadata"]) |
| |
| # temporary exclusion prior to old templates being migrated out |
| self.excludes.extend( |
| [ |
| "README.rst", |
| "auth_api_key.tmpl.rst", |
| "auth.tmpl.rst", |
| "install_deps.tmpl.rst", |
| "install_portaudio.tmpl.rst", |
| "noxfile.py.j2", |
| ] |
| ) |
| |
| # ensure samples will generate |
| kwargs["metadata"]["samples"] = True |
| |
| # determine if in client lib and set custom root sample dir if specified, else None |
| in_client_library = Path("samples").exists() |
| sample_project_dir = kwargs["metadata"]["repo"].get("sample_project_dir") |
| |
| if sample_project_dir is None: # Not found in metadata |
| if in_client_library: |
| sample_project_dir = "samples" |
| else: |
| sample_project_dir = "." |
| elif not Path(sample_project_dir).exists(): |
| raise Exception(f"'{sample_project_dir}' does not exist") |
| |
| override_paths_to_samples: Dict[ |
| str, List[str] |
| ] = {} # Dict of format { override_path : sample(s) } |
| samples_dict = deepcopy(kwargs["metadata"]["repo"].get("samples")) |
| default_samples_dict = [] # Dict which will generate in sample_project_dir |
| |
| # Iterate through samples to store override_paths_to_samples for all existing |
| # override paths |
| for sample_idx, sample in enumerate(samples_dict): |
| override_path = samples_dict[sample_idx].get("override_path") |
| |
| if override_path is not None: |
| # add absolute path to metadata so `python foo.py --help` succeeds |
| if sample.get("file") is not None: |
| path = os.path.join( |
| sample_project_dir, override_path, sample.get("file") |
| ) |
| sample["abs_path"] = Path(path).resolve() |
| |
| cur_override_sample = override_paths_to_samples.get(override_path) |
| # Base case: No samples are yet planned to gen in this override dir |
| if cur_override_sample is None: |
| override_paths_to_samples[override_path] = [sample] |
| # Else: Sample docs will be generated in README merged with other |
| # sample doc(s) already planned to generate in this dir |
| else: |
| cur_override_sample.append(sample) |
| override_paths_to_samples[override_path] = cur_override_sample |
| # If override path none, will be generated in the default |
| # folder: sample_project_dir |
| else: |
| if sample.get("file") is not None: |
| path = os.path.join(sample_project_dir, sample.get("file")) |
| sample["abs_path"] = Path(path).resolve() |
| default_samples_dict.append(sample) |
| |
| # List of paths to tempdirs which will be copied into sample folders |
| result = [] |
| |
| # deep copy is req. here to avoid kwargs being affected |
| overridden_samples_kwargs = deepcopy(kwargs) |
| for override_path in override_paths_to_samples: |
| # Generate override sample docs |
| result.append( |
| self.py_samples_override( |
| root=sample_project_dir, |
| override_path=override_path, |
| override_samples=override_paths_to_samples[override_path], |
| **overridden_samples_kwargs, |
| ) |
| ) |
| kwargs["metadata"]["repo"]["samples"] = default_samples_dict |
| |
| logger.debug( |
| f"Generating templates for samples directory '{sample_project_dir}'" |
| ) |
| kwargs["subdir"] = sample_project_dir |
| # Generate default sample docs |
| result.append(self._generic_library("python_samples", **kwargs)) |
| |
| for path in result: |
| # .add() records the root of the paths and needs to be applied to each |
| _tracked_paths.add(path) |
| |
| return result |
| |
| def py_samples_override( |
| self, root, override_path, override_samples, **overridden_samples_kwargs |
| ) -> Path: |
| """ |
| Handles additional generation of READMEs where "override_path"s |
| are set in one or more samples' metadata |
| """ |
| overridden_samples_kwargs["metadata"]["repo"][ |
| "sample_project_dir" |
| ] = override_path |
| # Set samples metadata to ONLY samples intended to generate |
| # under this directory (override_path) |
| overridden_samples_kwargs["metadata"]["repo"]["samples"] = override_samples |
| if root != ".": |
| override_path = Path(root) / override_path |
| |
| logger.debug(f"Generating templates for override path '{override_path}'") |
| |
| overridden_samples_kwargs["subdir"] = override_path |
| return self._generic_library("python_samples", **overridden_samples_kwargs) |
| |
| def python_notebooks(self, **kwargs) -> Path: |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| return self._generic_library("python_notebooks", **kwargs) |
| |
| def py_mono_repo_library(self, relative_dir, **kwargs) -> Path: |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| |
| # load common repo meta information (metadata that's not language specific). |
| self._load_generic_metadata(kwargs["metadata"], relative_dir) |
| |
| # initialize default_version if it doesn't exist in kwargs["metadata"]['repo'] |
| if "default_version" not in kwargs["metadata"]["repo"]: |
| kwargs["metadata"]["repo"]["default_version"] = "" |
| |
| # Don't add `docs/index.rst` if `versions` is not provided or `default_version` is empty |
| if ( |
| "versions" not in kwargs |
| or not kwargs["metadata"]["repo"]["default_version"] |
| or kwargs["metadata"]["repo"]["default_version"] == "apiVersion" |
| ): |
| self.excludes += ["docs/index.rst"] |
| |
| # If the directory `google/cloud` exists, add kwargs to signal that the client library is for a Cloud API |
| if Path("google/cloud").exists(): |
| kwargs["is_google_cloud_api"] = True |
| |
| return self._generic_library("python_mono_repo_library", relative_dir, **kwargs) |
| |
| def py_library(self, **kwargs) -> Path: |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| |
| # load common repo meta information (metadata that's not language specific). |
| self._load_generic_metadata(kwargs["metadata"]) |
| |
| # initialize default_version if it doesn't exist in kwargs["metadata"]['repo'] |
| if "default_version" not in kwargs["metadata"]["repo"]: |
| kwargs["metadata"]["repo"]["default_version"] = "" |
| |
| # rename variable to accommodate existing owlbot.py files |
| if "system_test_dependencies" in kwargs: |
| kwargs["system_test_local_dependencies"] = kwargs[ |
| "system_test_dependencies" |
| ] |
| logger.warning( |
| "Template argument 'system_test_dependencies' is deprecated." |
| "Use 'system_test_local_dependencies' or 'system_test_external_dependencies'" |
| "instead." |
| ) |
| |
| # Set default Python versions for noxfile.py |
| if "default_python_version" not in kwargs: |
| kwargs["default_python_version"] = "3.8" |
| if "unit_test_python_versions" not in kwargs: |
| kwargs["unit_test_python_versions"] = ["3.7", "3.8", "3.9", "3.10"] |
| |
| if "system_test_python_versions" not in kwargs: |
| kwargs["system_test_python_versions"] = ["3.8"] |
| |
| # If cov_level is not given, set it to None. |
| if "cov_level" not in kwargs: |
| kwargs["cov_level"] = None |
| |
| # Don't add samples templates if there are no samples |
| if "samples" not in kwargs: |
| self.excludes += ["samples/AUTHORING_GUIDE.md", "samples/CONTRIBUTING.md"] |
| |
| # Don't add `docs/index.rst` if `versions` is not provided or `default_version` is empty |
| if ( |
| "versions" not in kwargs |
| or not kwargs["metadata"]["repo"]["default_version"] |
| ): |
| self.excludes += ["docs/index.rst"] |
| |
| # Add kwargs to signal that UPGRADING.md should be included in docs/index.rst if it exists |
| if Path("docs/UPGRADING.md").exists() or Path("docs/UPGRADING.rst").exists(): |
| kwargs["include_uprading_doc"] = True |
| |
| # If the directory `google/cloud` exists, add kwargs to signal that the client library is for a Cloud API |
| if Path("google/cloud").exists(): |
| kwargs["is_google_cloud_api"] = True |
| |
| # If Dockerfile exists in .kokoro/docker/samples, add kwargs to |
| # signal that a custom docker image should be used when testing samples. |
| kwargs["custom_samples_dockerfile"] = Path( |
| ".kokoro/docker/samples/Dockerfile" |
| ).exists() |
| |
| ret = self._generic_library("python_library", **kwargs) |
| |
| # If split_system_tests is set to True, we disable the system |
| # test in the main presubmit build and create individual build |
| # configs for each python versions. |
| if kwargs.get("split_system_tests", False): |
| template_root = self._template_root / "py_library_split_systests" |
| # copy the main presubmit config |
| shutil.copy2( |
| template_root / ".kokoro/presubmit/presubmit.cfg", |
| ret / ".kokoro/presubmit/presubmit.cfg", |
| ) |
| env = jinja2.Environment(loader=jinja2.FileSystemLoader(str(template_root))) |
| tmpl = env.get_template(".kokoro/presubmit/system.cfg") |
| for v in kwargs["system_test_python_versions"]: |
| nox_session = f"system-{v}" |
| dest = ret / f".kokoro/presubmit/system-{v}.cfg" |
| content = tmpl.render(nox_session=nox_session) |
| with open(dest, "w") as f: |
| f.write(content) |
| return ret |
| |
| def java_library(self, **kwargs) -> Path: |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| return self._generic_library("java_library", **kwargs) |
| |
| def node_library(self, **kwargs) -> Path: |
| # TODO: once we've migrated all Node.js repos to either having |
| # .repo-metadata.json, or excluding README.md, we can remove this. |
| if not os.path.exists("./.repo-metadata.json"): |
| self.excludes.append("README.md") |
| if "samples/README.md" not in self.excludes: |
| self.excludes.append("samples/README.md") |
| |
| kwargs["metadata"] = node.template_metadata() |
| kwargs["publish_token"] = node.get_publish_token(kwargs["metadata"]["name"]) |
| |
| ignore_src_index = [ |
| "yes" for f in self.excludes if fnmatch.fnmatch("src/index.ts", f) |
| ] |
| # generate root-level `src/index.ts` to export multiple versions and its default clients |
| if ( |
| "versions" in kwargs |
| and "default_version" in kwargs |
| and not ignore_src_index |
| ): |
| node.generate_index_ts( |
| versions=kwargs["versions"], default_version=kwargs["default_version"] |
| ) |
| |
| return self._generic_library("node_library", **kwargs) |
| |
| def node_mono_repo_library(self, relative_dir, **kwargs) -> Path: |
| # TODO: once we've migrated all Node.js repos to either having |
| # .repo-metadata.json, or excluding README.md, we can remove this. |
| if not os.path.exists(Path(relative_dir, ".repo-metadata.json").resolve()): |
| self.excludes.append("README.md") |
| if "samples/README.md" not in self.excludes: |
| self.excludes.append("samples/README.md") |
| |
| kwargs["metadata"] = node_mono_repo.template_metadata(relative_dir) |
| |
| ignore_src_index = [ |
| "yes" for f in self.excludes if fnmatch.fnmatch("src/index.ts", f) |
| ] |
| # generate root-level `src/index.ts` to export multiple versions and its default clients |
| if ( |
| "versions" in kwargs |
| and "default_version" in kwargs |
| and not ignore_src_index |
| ): |
| node_mono_repo.generate_index_ts( |
| versions=kwargs["versions"], |
| default_version=kwargs["default_version"], |
| relative_dir=relative_dir, |
| year=str(date.today().year), |
| ) |
| |
| return self._generic_library( |
| "node_mono_repo_library", relative_dir=relative_dir, **kwargs |
| ) |
| |
| def php_library(self, **kwargs) -> Path: |
| return self._generic_library("php_library", **kwargs) |
| |
| def ruby_library(self, **kwargs) -> Path: |
| # kwargs["metadata"] is required to load values from .repo-metadata.json |
| if "metadata" not in kwargs: |
| kwargs["metadata"] = {} |
| return self._generic_library("ruby_library", **kwargs) |
| |
| def render(self, template_name: str, **kwargs) -> Path: |
| template = self._templates.render(template_name, **kwargs) |
| _tracked_paths.add(template) |
| return template |
| |
| def _load_generic_metadata(self, metadata: Dict, relative_dir=None): |
| """ |
| loads additional meta information from .repo-metadata.json. |
| """ |
| metadata["partials"] = partials.load_partials() |
| |
| # Loads repo metadata information from the default location if it |
| # hasn't already been set. Some callers may have already loaded repo |
| # metadata, so we don't need to do it again or overwrite it. Also, only |
| # set the "repo" key. |
| if "repo" not in metadata: |
| metadata["repo"] = _load_repo_metadata(relative_dir=relative_dir) |
| |
| |
| def detect_versions( |
| path: str = "./src", |
| default_version: Optional[str] = None, |
| default_first: Optional[bool] = None, |
| ) -> List[str]: |
| """ |
| Detects the versions a library has, based on distinct folders |
| within path. This is based on the fact that our GAPIC libraries are |
| structured as follows: |
| |
| src/v1 |
| src/v1beta |
| src/v1alpha |
| |
| With folder names mapping directly to versions. |
| |
| Returns: a list of the sorted subdirectories; for the example above: |
| ['v1', 'v1alpha', 'v1beta'] |
| If the `default_version` argument is not provided, the `default_version` |
| will be read from `.repo-metadata.json`, if it exists. |
| If `default_version` is available, the `default_version` is moved to |
| at the front or the end of the sorted list depending on the value of `default_first`. |
| The `default_version` will be first in the list when `default_first` is `True`. |
| """ |
| |
| versions = [] |
| |
| if not default_version: |
| try: |
| # Get the `default_version` from ``.repo-metadata.json`. |
| default_version = json.load(open(".repo-metadata.json", "rt")).get( |
| "default_version" |
| ) |
| except FileNotFoundError: |
| pass |
| |
| # Detect versions up to a depth of 4 in directory hierarchy |
| for level in ("*v[1-9]*", "*/*v[1-9]*", "*/*/*v[1-9]*", "*/*/*/*v[1-9]*"): |
| # Sort the sub directories alphabetically. |
| sub_dirs = sorted([p.name for p in Path(path).glob(level) if p.is_dir()]) |
| # Don't proceed to the next level if we've detected versions in this depth level |
| if sub_dirs: |
| break |
| |
| if sub_dirs: |
| # if `default_version` is not specified, return the sorted directories. |
| if not default_version: |
| versions = sub_dirs |
| else: |
| # The subdirectory with the same suffix as the default_version |
| # will be the default client. |
| default_client = next( |
| iter([d for d in sub_dirs if d.endswith(default_version)]), None |
| ) |
| |
| # start with all the versions except for the default client |
| versions = [d for d in sub_dirs if not d.endswith(default_version)] |
| |
| if default_client: |
| # If `default_first` is true, the default_client will be first |
| # in the list. |
| if default_first: |
| versions = [default_client] + versions |
| else: |
| versions += [default_client] |
| return versions |
| |
| |
| def decamelize(value: str): |
| """Parser to convert fooBar.js to Foo Bar.""" |
| if not value: |
| return "" |
| str_decamelize = re.sub("^.", value[0].upper(), value) # apple -> Apple. |
| str_decamelize = re.sub( |
| "([A-Z]+)([A-Z])([a-z0-9])", r"\1 \2\3", str_decamelize |
| ) # ACLBatman -> ACL Batman. |
| return re.sub("([a-z0-9])([A-Z])", r"\1 \2", str_decamelize) # FooBar -> Foo Bar. |
| |
| |
| def _load_repo_metadata( |
| relative_dir=None, metadata_file: str = "./.repo-metadata.json" |
| ) -> Dict: |
| """Parse a metadata JSON file into a Dict. |
| |
| Currently, the defined fields are: |
| * `name` - The service's API name |
| * `name_pretty` - The service's API title. This will be used for generating titles on READMEs |
| * `product_documentation` - The product documentation on cloud.google.com |
| * `client_documentation` - The client library reference documentation |
| * `issue_tracker` - The public issue tracker for the product |
| * `release_level` - The release level of the client library. One of: alpha, beta, |
| ga, deprecated, preview, stable |
| * `language` - The repo language. One of dotnet, go, java, nodejs, php, python, ruby |
| * `repo` - The GitHub repo in the format {owner}/{repo} |
| * `distribution_name` - The language-idiomatic package/distribution name |
| * `api_id` - The API ID associated with the service. Fully qualified identifier use to |
| enable a service in the cloud platform (e.g. monitoring.googleapis.com) |
| * `requires_billing` - Whether or not the API requires billing to be configured on the |
| customer's acocunt |
| |
| Args: |
| metadata_file (str, optional): Path to the metadata json file |
| |
| Returns: |
| A dictionary of metadata. This may not necessarily include all the defined fields above. |
| """ |
| if relative_dir is not None: |
| if os.path.exists(Path(relative_dir, metadata_file).resolve()): |
| with open(Path(relative_dir, metadata_file).resolve()) as f: |
| return json.load(f) |
| elif os.path.exists(metadata_file): |
| with open(metadata_file) as f: |
| return json.load(f) |
| return {} |
| |
| |
| def _get_default_branch_name(repository_name: str) -> str: |
| """Read the default branch name from the environment. |
| |
| First checks environment variable DEFAULT_BRANCH_PATH. If found, it |
| reads the contents of the file at DEFAULT_BRANCH_PATH and returns it. |
| |
| Then checks environment varabile DEFAULT_BRANCH, and returns it if found. |
| """ |
| default_branch_path = os.getenv("DEFAULT_BRANCH_PATH") |
| if default_branch_path: |
| return Path(default_branch_path).read_text().strip() |
| |
| # This default should be switched to "main" once we've migrated |
| # the majority of our repositories: |
| return os.getenv("DEFAULT_BRANCH", "master") |