Skip to content

Instantly share code, notes, and snippets.

@beccasaurus
Last active October 31, 2018 13:56
Show Gist options
  • Save beccasaurus/c8210e23fa428be70fc44f915cb120cd to your computer and use it in GitHub Desktop.
Save beccasaurus/c8210e23fa428be70fc44f915cb120cd to your computer and use it in GitHub Desktop.
Vision Partial GAPICs & you! πŸ¦‡

πŸ–‹ GAPIC :: Library Partials

So you want to write a partial helper, eh?

Hand-written code adds all kinds of functionality on top of generated GAPIC client libraries!

Let's look at the most common type of hand-written extension:

>> Helper Methods added to service object which wraps call to 1 rpc method <<



"for demonstration purposes only"

πŸ‘ Cloud Vision API

The Google Cloud Vision API is a great candidate for this because, while the API supports performing various types of image analysis, there is actually only 1 rpc method available. To perform certain types of analysis, the rpc request contains an array of enum values, each representing 1 type of analysis. 1 or many types may be passed at once but, often, folks may want to easily invoke just 1 type of analysis!

Here is an example of a Request to detect all of the Landmarks in a given image (How-to Guide):

ImageAnnotatorService.BatchAnnotateImages(
  BatchAnnotateImagesRequest {
    requests = [
        AnnotateImageRequest {
          image = Image {
            source = ImageSource { image_uri = "gs://bucket/path/to/an/image.png" }
          }
          features = [
            Feature { type = Feature.Type.LANDMARK_DETECTION }
          ]
        }
    ]
  }
)

And here is what the code looks like in Python:

uri_for_image_stored_in_cloud_storage = "gs://cloud-samples-tests/vision/landmark.jpg"

from google.cloud import vision

client = vision.ImageAnnotatorClient()

response = client.batch_annotate_images(
    requests = [
        vision.types.AnnotateImageRequest(
            image = vision.types.Image(
                source = vision.types.ImageSource(
                    gcs_image_uri = uri_for_image_stored_in_cloud_storage    
                )
            ),
            features = [
                vision.types.Feature(
                    type = vision.enums.Feature.Type.LANDMARK_DETECTION
                )
            ]
        )
    ]
)

print(response)

Skip to the FUN PART below 😎


Wouldn't it be great if we could simply call client.landmark_detection(myImage)?

Well, you can! It already exists! Check out the current Landmark Detection Python Sample and you'll see it looks like this:

image = vision.types.Image()
image.source.image_uri = uri
response = client.landmark_detection(image=image)

If you're interested in how that helper method is added today, you can checkout the vision_helpers/ folder in the Python client library for Google Cloud Vision.

tl;dr

  1. add_single_feature_methods loops over the available Feature enum values
  2. For each Feature, it dynamically creates a function via _create_single_feature_method and dynamically adds that function to the VisionHelpers class
  3. Each dynamically created function for each enum value calls the helper method annotate_image, which invokes BatchAnnotateImage with a single request (rather than an array of requests)
  4. The VisionHelpers class is inherited by the ImageAnnotatorClient class using multiple inheritance & @add_single_feature_methods is invoked as an decorator.
@add_single_feature_methods
class ImageAnnotatorClient(VisionHelpers, iac.ImageAnnotatorClient):

^-- some hand-wavy guessing, I'm not actually familiar with Python's inheritance model or how Python decorators work :P

But that's not what we're here to do. We're here to generate helper methods across all languages.

So let's do so by misusing the sample generation features... 😈


Misusing Samplegen! 😎

We all know why we're here. (πŸ‘‹πŸ¦‡)

Let's repurpose our snippets as helper methods, shall we?

Round 1

I'm not going to actually add the function to VisionHelpers or anything, I'm just going to generate a top-level method called def detect_landmarks which only accepts a GCS URI and returns the API response.

We won't accept any other kwargs** to pass along or anything, like we should look into really doing (the dynamically generated helper represents the request as a Dictionary with splatted kwargs**: here)

I replaced the Python sample above with the following (and my goal was to make it work):

from generated_helper import detect_landmarks

response = detect_landmarks("gs://cloud-samples-tests/vision/landmark.jpg")

print(response)

... and so I did.

Script

Below is a hacky script that does all the hacky things necessary to make it go!

  1. clone a fresh gapic-generator (because I hack the .snip)
  2. patch gapic-generator with snip.diff to hack the existing standalone snippet template
    • remove region tags
    • remove print output
    • remove commented out defaults
    • provide a name for the function in the YAML (abusing unused title config field)
    • return response
  3. compile gapic-generator
  4. clone a fresh googleapis (because I represent the helper method as a sample)
  5. patch googleapis with detect_landmarks.diff (adds a "sample" representing the helper method to the Vision V1 GAPIC config)
  6. pull artman docker image
  7. generate Python "sample" (ie. def detect_landmarks()) and save it in the local directory as generated_helpers.py

Then, you can run it.

  1. pip install google-cloud-vision
  2. run the above python example which calls detect_landmarks()

It should give you the exact same output as the full length sample which calls batch_annotate_images() directly


πŸ‘©πŸΌβ€πŸ’» the 🌎

gapic-generator/
googleapis/
#! /bin/bash
# dependencies: git, java, docker
# repos will be cloned here:
working_directory="`pwd`"
# the .diff patches may only apply cleanly to the current HEAD
# which the patches were generated from... we'll checkout those SHAs
gapic_generator_latest=a4d600a1c3bb526ce47feca2a9f832a25694f3bd
googleapis_latest=caa431d9ddb71a29b14ff6bfa6ccd7c044cf9697
gapic_generator_root="$working_directory/gapic-generator"
googleapis_root="$working_directory/googleapis"
set -ex
# nuke the existing gapic-generator / googleapis clones in the working dir
#
# rm -rf <------
#
rm -rf "$gapic_generator_root"
rm -rf "$googleapis_root"
git clone https://github.com/googleapis/gapic-generator.git
git clone https://github.com/googleapis/googleapis.git
# Update GAPIC generator .snip (hack) to render function && compile
cd "$gapic_generator_root"
git checkout $gapic_generator_latest
git status
git apply < "$working_directory/snip.diff"
git status
./gradlew fatJar
# Add the "function" to googleapis
cd "$googleapis_root"
git checkout $googleapis_latest
git status
git apply < "$working_directory/detect_landmarks.diff"
git status
# Sweet. Let's go back 🏑
cd "$working_directory"
# Let's pull down the latest docker image and run it!
docker pull googleapis/artman
docker run --rm \
-e RUNNING_IN_ARTMAN_DOCKER=True \
-v "$gapic_generator_root:/toolkit" \
-v "$googleapis_root:/googleapis" \
-w /googleapis \
googleapis/artman \
/bin/bash -c \
"artman --local --config /googleapis/google/cloud/vision/artman_vision_v1.yaml generate python_gapic"
# If all went as planned, there is a new Python file with the function we want:
cp "$googleapis_root/artman-genfiles/python/vision-v1/samples/google/cloud/vision_v1/gapic/batch_annotate_images/batch_annotate_images_request_landmark_detection_sample.py" generated_helper.py
echo "[ generated_helper.pyh ]"
cat generated_helper.py
echo "Would you like to try to run the code now?"
echo "This requires having GOOGLE_APPLICATION_CREDENTIALS set"
echo "And I'm assuming you have virtualenv and Python 3"
echo
echo "Press any key to continue (Ctrl-C to exit)"
virtualenv --python python3 env
source env/bin/activate
pip install -r requirements.txt
echo "OK, let's run this..."
echo
echo "Hold onto your butts πŸ¦–"
python example_using_helper.py
diff --git a/google/cloud/vision/v1/vision_gapic.yaml b/google/cloud/vision/v1/vision_gapic.yaml
index d95e1a82b..b72eeba14 100644
--- a/google/cloud/vision/v1/vision_gapic.yaml
+++ b/google/cloud/vision/v1/vision_gapic.yaml
@@ -48,6 +48,21 @@ interfaces:
total_timeout_millis: 600000
methods:
- name: BatchAnnotateImages
+ samples:
+ standalone:
+ - calling_forms: ".*"
+ value_sets: ["landmark_detection_sample"]
+ sample_value_sets:
+ - id: landmark_detection_sample
+ title: detect_landmarks
+ description: "Performs Landmark Detection on provided GCS image URI"
+ parameters:
+ defaults:
+ - requests[0].image.source.gcs_image_uri="gs://cloud-samples-tests/vision/landmark.jpg"
+ - requests[0].features[0].type=LANDMARK_DETECTION
+ attributes:
+ - parameter: requests[0].image.source.gcs_image_uri
+ sample_argument_name: cloud_storage_uri
flattening:
groups:
- parameters:
uri_for_image_stored_in_cloud_storage = "gs://cloud-samples-tests/vision/landmark.jpg"
from google.cloud import vision
client = vision.ImageAnnotatorClient()
response = client.batch_annotate_images(
requests = [
vision.types.AnnotateImageRequest(
image = vision.types.Image(
source = vision.types.ImageSource(
gcs_image_uri = uri_for_image_stored_in_cloud_storage
)
),
features = [
vision.types.Feature(
type = vision.enums.Feature.Type.LANDMARK_DETECTION
)
]
)
]
)
print(response)
from generated_helper import detect_landmarks
response = detect_landmarks("gs://cloud-samples-tests/vision/landmark.jpg")
print(response)
# -*- coding: utf-8 -*-
#
# 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.
# DO NOT EDIT! This is a generated sample ("Request", "landmark_detection_sample")
# To install the latest published package dependency, execute the following:
# pip install google-cloud-vision
import sys
from google.cloud import vision_v1
from google.cloud.vision_v1 import enums
import six
def detect_landmarks(gcs_image_uri):
"""Performs Landmark Detection on provided GCS image URI"""
client = vision_v1.ImageAnnotatorClient()
source = {'gcs_image_uri': gcs_image_uri}
image = {'source': source}
type_ = enums.Feature.Type.LANDMARK_DETECTION
features_element = {'type': type_}
features = [features_element]
requests_element = {'image': image, 'features': features}
requests = [requests_element]
response = client.batch_annotate_images(requests)
return response
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
'--gcs_image_uri',
type=str,
default='gs://cloud-samples-tests/vision/landmark.jpg')
args = parser.parse_args()
sample_batch_annotate_images(args.gcs_image_uri)
if __name__ == '__main__':
main()
google-cloud-vision==0.34.0
diff --git a/src/main/resources/com/google/api/codegen/py/standalone_sample.snip b/src/main/resources/com/google/api/codegen/py/standalone_sample.snip
index 92ebfbc0..fd200007 100644
--- a/src/main/resources/com/google/api/codegen/py/standalone_sample.snip
+++ b/src/main/resources/com/google/api/codegen/py/standalone_sample.snip
@@ -22,8 +22,6 @@
@snippet standaloneSample(apiMethod, sample)
import sys
- @# [START {@sample.regionTag}]
-
{@importList(sample.initCode.importSection.appImports)}
@if sample.outputImports.size
{@importList(sample.outputImports)}
@@ -34,22 +32,11 @@
@end
- def {@sample.sampleFunctionName}({@formalArgs(sample.initCode.argDefaultParams)}):
+ def {@sample.valueSet.title}({@formalArgs(sample.initCode.argDefaultParams)}):
"""{@sample.valueSet.description}"""
- @# [START {@sample.regionTag}_core]
-
client = {@apiMethod.apiModuleName}.{@apiMethod.apiClassName}()
- @if sample.initCode.argDefaultLines
- @join line : util.pretty(initCode(sample.initCode.argDefaultLines))
- @# {@line}
- @end
-
-
- # FIXME(hzyi): handle ListInitType, MapInitType and StructInitType correctly
- {@convertTextCode}
- @end
@if sample.initCode.lines
{@initCode(sample.initCode.lines)}
@@ -73,8 +60,7 @@
$unhandledCallingForm: {@sample.callingForm} in sample "{@apiMethod.getClass.getSimpleName}"$
@end
- @# [END {@sample.regionTag}_core]
- @# [END {@sample.regionTag}]
+ return response
@end
def main():
@@ -209,11 +195,11 @@
@end
@private processResponse(sample)
- @if sample.outputs.size == 0
- print(response)
- @else
- {@processOutputViews(sample.outputs)}
- @end
+ # @if sample.outputs.size == 0
+ # print(response)
+ # @else
+ # {@processOutputViews(sample.outputs)}
+ # @end
@end
@private processOutputViews(outputViews)
@vchudnov-g
Copy link

Very impressive!

This is indeed something we can hack the samplegen tech for, but I do want to come up with a less hacky and easier way to do this. But that's mostly cosmetic. As we've discussed and as you've shown here, the guts are already in place.

(The only non-minor part would be switching to a pseudo-language, but that would likely happen for samples anyway, so it's independent of adapting the sample technology for partials.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment