Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Add microgenerator support to synthtool. #252

Merged
merged 7 commits into from
Jul 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions synthtool/gcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@
# limitations under the License.

from . import gapic_generator
from . import gapic_microgenerator
from . import discogapic_generator
from . import common

DiscoGAPICGenerator = discogapic_generator.DiscoGAPICGenerator
GAPICGenerator = gapic_generator.GAPICGenerator
GAPICMicrogenerator = gapic_microgenerator.GAPICMicrogenerator
CommonTemplates = common.CommonTemplates


__all__ = (
"CommonTemplates",
"DiscoGAPICGenerator",
"GAPICGenerator",
"GAPICMicrogenerator",
)
213 changes: 213 additions & 0 deletions synthtool/gcp/gapic_microgenerator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# 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.

from pathlib import Path
from typing import Mapping, Optional, Union
import os
import platform
import tempfile

from synthtool import _tracked_paths
from synthtool import log
from synthtool import metadata
from synthtool import shell
from synthtool.sources import git

GOOGLEAPIS_URL: str = git.make_repo_clone_url("googleapis/googleapis")
GOOGLEAPIS_PRIVATE_URL: str = git.make_repo_clone_url("googleapis/googleapis-private")
LOCAL_GOOGLEAPIS: Optional[str] = os.environ.get("SYNTHTOOL_GOOGLEAPIS")


class GAPICMicrogenerator:
"""A synthtool component that can produce libraries using microgenerators.

A microgenerator is any code generator that follows the code
generation specification defined at https://aip.dev/client-libraries
"""

def __init__(self):
# Docker on mac by default cannot use the default temp file location
# instead use the more standard *nix /tmp location.
if platform.system() == "Darwin":
tempfile.tempdir = "/tmp"
self._ensure_dependencies_installed()
self._googleapis = None
self._googleapis_private = None

def py_library(self, service: str, version: str, **kwargs) -> Path:
"""
Generates the Python Library files using artman/GAPIC
returns a `Path` object
library: path to library. 'google/cloud/speech'
version: version of lib. 'v1'
"""
return self._generate_code(service, version, "python", **kwargs)

def go_library(self, service: str, version: str, **kwargs) -> Path:
return self._generate_code(service, version, "go", **kwargs)

def kotlin_library(self, service: str, version: str, **kwargs) -> Path:
return self._generate_code(service, version, "kotlin", **kwargs)

def _generate_code(
self,
service: str,
version: str,
language: str,
*,
private: bool = False,
proto_path: Union[str, Path] = None,
output_dir: Union[str, Path] = None,
generator_version: str = "latest",
generator_args: Mapping[str, str] = None,
):
# Determine which googleapis repo to use
if not private:
googleapis = self._clone_googleapis()
else:
googleapis = self._clone_googleapis_private()

# Sanity check: We should have a googleapis repo; if we do not,
# something went wrong, and we should abort.
if googleapis is None:
raise RuntimeError(
f"Unable to generate {service}, the googleapis repository"
"is unavailable."
)

# Pull the code generator for the requested language.
# If a code generator version was specified, honor that.
log.debug(
"Pulling Docker image: gapic-generator-{language}:{generator_version}"
)
shell.run(
[
"docker",
"pull",
f"gcr.io/gapic-images/gapic-generator-{language}:{generator_version}",
],
hide_output=False,
)

# Determine where the protos we are generating actually live.
# We can sometimes (but not always) determine this from the service
# and version; in other cases, the user must provide it outright.
if proto_path:
proto_path = Path(proto_path)
if proto_path.is_absolute():
proto_path = proto_path.relative_to("/")
else:
proto_path = Path("google/cloud") / service / version

# Sanity check: Do we have protos where we think we should?
if not (googleapis / proto_path).exists():
raise FileNotFoundError(
f"Unable to find directory for protos: {(googleapis / proto_path)}."
)
if not tuple((googleapis / proto_path).glob("*.proto")):
raise FileNotFoundError(
f"Directory {(googleapis / proto_path)} exists, but no protos found."
)

# Ensure the desired output directory exists.
# If none was provided, create a temporary directory.
if not output_dir:
output_dir = tempfile.mkdtemp()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crwilcox I think it is pretty likely that this is the wrong thing to do, but it is not obvious to me what a better option is.

Here are a couple potential possibilities:

  • Return a TemporaryDirectory object instead of a Path.
    • Pros: Easy for synth.py files; they just use the context manager interface to copy out of the temporary directory and cleanup magically happens.
    • Cons: Violates / changes the interface observed by GAPICGenerator.
  • Require an output directory.
    • Pros: Unambiguous. If you create a temporary directory to handle this, you clean it up.
    • Cons: Slightly more code than the option above (~1-2 more lines in each synth.py file). Slightly violates the interface by requiring output_dir.
  • What is here now.
    • Pros: Holds to the existing interface.
    • Cons: Very easy to leave temporary directories lying around.
  • Try to return a frankenstein object that subclasses both Path and TemporaryDirectory. (In this case, I would remove output_dir entirely and only write to temp directories.)
    • Pros: Holds to the existing interface.
    • Cons: Seems dirty. Edge cases intuitively seem likely.

Copy link
Contributor

@busunkim96 busunkim96 May 31, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am OK with the approach here. Synthtool already leaves behind generated libraries in ~/.cache/synthtool/googleapis/artman-genfiles/.

In fact, we have a note in the README that recommends looking at that directory when you're doing a generation.

SynthTool will run Artman which will create generated code that can be found at ~/.cache/synthtool/googleapis<-private>/artman_genfiles. This is useful for figuring out what it is you need to copy for your specific library.

Would it be reasonable to have the microgenerator default to a similar location for predictability?

For reference:
Artman's output_dir is root_dir / "artman-genfiles"

def run(self, image, root_dir, config, *args):
"""Executes artman command in the artman container.
Args:
root_dir: The input directory that will be mounted to artman docker
container as local googleapis directory.
Returns:
The output directory with artman-generated files.
"""
container_name = "artman-docker"
output_dir = root_dir / "artman-genfiles"

root_dir is the path for googleapis.

output_root = artman.Artman().run(
f"googleapis/artman:{artman.ARTMAN_VERSION}",
googleapis,
config_path,
gapic_language_arg,

And googleapis is cloned to a cache dir, by default.

def clone(
url: str,
dest: pathlib.Path = None,
committish: str = "master",
force: bool = False,
depth: int = None,
) -> pathlib.Path:
if dest is None:
dest = cache.get_cache_dir()

cache_dir = pathlib.Path.home() / ".cache" / "synthtool"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @busunkim96 for looking at this!

The new generators are protoc plugins, which require a specified directory to output to (that is the fundamental protoc plugin interface), so I can not have the generator itself default to any location.

However, I can have synthtool pick a default location (e.g. python-gapic-genfiles) and behave this way if that is your preference. That seems odd to me, but you are the end user, and I defer to your preference. :)

Should I make that change? Happy to do so.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukesneeringer Yes please! I think it would be the least surprising for the microgenerator to also put files in the synthtool cache dir. Thank-you. 😄

output_dir = Path(output_dir).resolve()

# The time has come, the walrus said, to talk of actually running
# the code generator.
log.debug(f"Generating code for: {proto_path}.")
sep = os.path.sep
shell.run(
[
"docker",
"run",
"--mount",
f"type=bind,source={googleapis / proto_path}{sep},destination={Path('/in') / proto_path}{sep},readonly",
"--mount",
f"type=bind,source={output_dir}{sep},destination={Path('/out')}{sep}",
"--rm",
"--user",
str(os.getuid()),
f"gcr.io/gapic-images/gapic-generator-{language}",
]
)

# Sanity check: Does the output location have code in it?
# If not, complain.
if not tuple(output_dir.iterdir()):
raise RuntimeError(
f"Code generation seemed to succeed, but {output_dir} is empty."
)

# Huzzah, it worked.
log.success(f"Generated code into {output_dir}.")

# Record this in the synthtool metadata.
metadata.add_client_destination(
source="googleapis" if not private else "googleapis-private",
api_name=service,
api_version=version,
language=language,
generator=f"gapic-generator-{language}",
)

_tracked_paths.add(output_dir)
return output_dir

def _clone_googleapis(self):
if self._googleapis is not None:
return self._googleapis

if LOCAL_GOOGLEAPIS:
self._googleapis = Path(LOCAL_GOOGLEAPIS).expanduser()
log.debug(f"Using local googleapis at {self._googleapis}")

else:
log.debug("Cloning googleapis.")
self._googleapis = git.clone(GOOGLEAPIS_URL, depth=1)

return self._googleapis

def _clone_googleapis_private(self):
if self._googleapis_private is not None:
return self._googleapis_private

if LOCAL_GOOGLEAPIS:
self._googleapis_private = Path(LOCAL_GOOGLEAPIS).expanduser()
log.debug(
f"Using local googleapis at {self._googleapis_private} for googleapis-private"
)

else:
log.debug("Cloning googleapis-private.")
self._googleapis_private = git.clone(GOOGLEAPIS_PRIVATE_URL, depth=1)

return self._googleapis_private

def _ensure_dependencies_installed(self):
log.debug("Ensuring dependencies.")

dependencies = ["docker", "git"]
failed_dependencies = []
for dependency in dependencies:
return_code = shell.run(["which", dependency], check=False).returncode
if return_code:
failed_dependencies.append(dependency)

if failed_dependencies:
raise EnvironmentError(
f"Dependencies missing: {', '.join(failed_dependencies)}"
)