diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 290aabe045..bb1666f917 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,3 +9,6 @@ # The python-samples-owners team is the default owner for samples /samples/**/*.py @dizcology @googleapis/python-samples-owners + +# The enhanced client library tests are owned by @telpirion +/tests/unit/enhanced_library/*.py @telpirion \ No newline at end of file diff --git a/google/cloud/aiplatform/helpers/__init__.py b/google/cloud/aiplatform/helpers/__init__.py new file mode 100644 index 0000000000..3f031f2bb4 --- /dev/null +++ b/google/cloud/aiplatform/helpers/__init__.py @@ -0,0 +1,3 @@ +from google.cloud.aiplatform.helpers import value_converter + +__all__ = (value_converter,) diff --git a/google/cloud/aiplatform/helpers/_decorators.py b/google/cloud/aiplatform/helpers/_decorators.py new file mode 100644 index 0000000000..5d9aa28bea --- /dev/null +++ b/google/cloud/aiplatform/helpers/_decorators.py @@ -0,0 +1,70 @@ +# 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 +# +# 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. +from __future__ import absolute_import +from google.cloud.aiplatform.helpers import value_converter + +from proto.marshal import Marshal +from proto.marshal.rules.struct import ValueRule +from google.protobuf.struct_pb2 import Value + + +class ConversionValueRule(ValueRule): + def to_python(self, value, *, absent: bool = None): + return super().to_python(value, absent=absent) + + def to_proto(self, value): + + # Need to check whether value is an instance + # of an enhanced type + if callable(getattr(value, "to_value", None)): + return value.to_value() + else: + return super().to_proto(value) + + +def _add_methods_to_classes_in_package(pkg): + classes = dict( + [(name, cls) for name, cls in pkg.__dict__.items() if isinstance(cls, type)] + ) + + for class_name, cls in classes.items(): + # Add to_value() method to class with docstring + setattr(cls, "to_value", value_converter.to_value) + cls.to_value.__doc__ = value_converter.to_value.__doc__ + + # Add from_value() method to class with docstring + setattr(cls, "from_value", _add_from_value_to_class(cls)) + cls.from_value.__doc__ = value_converter.from_value.__doc__ + + # Add from_map() method to class with docstring + setattr(cls, "from_map", _add_from_map_to_class(cls)) + cls.from_map.__doc__ = value_converter.from_map.__doc__ + + +def _add_from_value_to_class(cls): + def _from_value(value): + return value_converter.from_value(cls, value) + + return _from_value + + +def _add_from_map_to_class(cls): + def _from_map(map_): + return value_converter.from_map(cls, map_) + + return _from_map + + +marshal = Marshal(name="google.cloud.aiplatform.v1beta1") +marshal.register(Value, ConversionValueRule(marshal=marshal)) diff --git a/google/cloud/aiplatform/helpers/value_converter.py b/google/cloud/aiplatform/helpers/value_converter.py new file mode 100644 index 0000000000..99d56d8b6c --- /dev/null +++ b/google/cloud/aiplatform/helpers/value_converter.py @@ -0,0 +1,60 @@ +# 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. +from __future__ import absolute_import +from google.protobuf.struct_pb2 import Value +from google.protobuf import json_format +from proto.marshal.collections.maps import MapComposite +from proto.marshal import Marshal +from proto import Message +from proto.message import MessageMeta + + +def to_value(self: Message) -> Value: + """Converts a message type to a :class:`~google.protobuf.struct_pb2.Value` object. + + Args: + message: the message to convert + + Returns: + the message as a :class:`~google.protobuf.struct_pb2.Value` object + """ + tmp_dict = json_format.MessageToDict(self._pb) + return json_format.ParseDict(tmp_dict, Value()) + + +def from_value(cls: MessageMeta, value: Value) -> Message: + """Creates instance of class from a :class:`~google.protobuf.struct_pb2.Value` object. + + Args: + value: a :class:`~google.protobuf.struct_pb2.Value` object + + Returns: + Instance of class + """ + value_dict = json_format.MessageToDict(value) + return json_format.ParseDict(value_dict, cls()._pb) + + +def from_map(cls: MessageMeta, map_: MapComposite) -> Message: + """Creates instance of class from a :class:`~proto.marshal.collections.maps.MapComposite` object. + + Args: + map_: a :class:`~proto.marshal.collections.maps.MapComposite` object + + Returns: + Instance of class + """ + marshal = Marshal(name="marshal") + pb = marshal.to_proto(Value, map_) + return from_value(cls, pb) diff --git a/google/cloud/aiplatform/v1beta1/schema/predict/instance/__init__.py b/google/cloud/aiplatform/v1beta1/schema/predict/instance/__init__.py index 2f514ac4ed..095807df0c 100644 --- a/google/cloud/aiplatform/v1beta1/schema/predict/instance/__init__.py +++ b/google/cloud/aiplatform/v1beta1/schema/predict/instance/__init__.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from google.cloud.aiplatform.helpers import _decorators +import google.cloud.aiplatform.v1beta1.schema.predict.instance_v1beta1.types as pkg from google.cloud.aiplatform.v1beta1.schema.predict.instance_v1beta1.types.image_classification import ( ImageClassificationPredictionInstance, @@ -54,3 +56,4 @@ "VideoClassificationPredictionInstance", "VideoObjectTrackingPredictionInstance", ) +_decorators._add_methods_to_classes_in_package(pkg) diff --git a/google/cloud/aiplatform/v1beta1/schema/predict/params/__init__.py b/google/cloud/aiplatform/v1beta1/schema/predict/params/__init__.py index dc7cd58e9a..30a25cc3c8 100644 --- a/google/cloud/aiplatform/v1beta1/schema/predict/params/__init__.py +++ b/google/cloud/aiplatform/v1beta1/schema/predict/params/__init__.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from google.cloud.aiplatform.helpers import _decorators +import google.cloud.aiplatform.v1beta1.schema.predict.params_v1beta1.types as pkg from google.cloud.aiplatform.v1beta1.schema.predict.params_v1beta1.types.image_classification import ( ImageClassificationPredictionParams, @@ -42,3 +44,4 @@ "VideoClassificationPredictionParams", "VideoObjectTrackingPredictionParams", ) +_decorators._add_methods_to_classes_in_package(pkg) diff --git a/google/cloud/aiplatform/v1beta1/schema/predict/prediction/__init__.py b/google/cloud/aiplatform/v1beta1/schema/predict/prediction/__init__.py index 4447d3770a..50966a087f 100644 --- a/google/cloud/aiplatform/v1beta1/schema/predict/prediction/__init__.py +++ b/google/cloud/aiplatform/v1beta1/schema/predict/prediction/__init__.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from google.cloud.aiplatform.helpers import _decorators +import google.cloud.aiplatform.v1beta1.schema.predict.prediction_v1beta1.types as pkg from google.cloud.aiplatform.v1beta1.schema.predict.prediction_v1beta1.types.classification import ( ClassificationPredictionResult, @@ -62,3 +64,4 @@ "VideoClassificationPredictionResult", "VideoObjectTrackingPredictionResult", ) +_decorators._add_methods_to_classes_in_package(pkg) diff --git a/google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py b/google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py index 192e50419d..39ef21bf21 100644 --- a/google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py +++ b/google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py @@ -17,8 +17,10 @@ import proto # type: ignore - -from google.cloud.aiplatform.v1beta1.schema.predict.instance import text_sentiment_pb2 as gcaspi_text_sentiment # type: ignore +# DO NOT OVERWRITE FOLLOWING LINE: it was manually edited. +from google.cloud.aiplatform.v1beta1.schema.predict.instance import ( + TextSentimentPredictionInstance, +) __protobuf__ = proto.module( @@ -57,9 +59,7 @@ class Prediction(proto.Message): sentiment = proto.Field(proto.INT32, number=1) instance = proto.Field( - proto.MESSAGE, - number=1, - message=gcaspi_text_sentiment.TextSentimentPredictionInstance, + proto.MESSAGE, number=1, message=TextSentimentPredictionInstance, ) prediction = proto.Field(proto.MESSAGE, number=2, message=Prediction,) diff --git a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition/__init__.py b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition/__init__.py index abd693172a..9ebfc71841 100644 --- a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition/__init__.py +++ b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition/__init__.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from google.cloud.aiplatform.helpers import _decorators +import google.cloud.aiplatform.v1beta1.schema.trainingjob.definition_v1beta1.types as pkg from google.cloud.aiplatform.v1beta1.schema.trainingjob.definition_v1beta1.types.automl_forecasting import ( AutoMlForecasting, @@ -130,3 +132,4 @@ "AutoMlVideoObjectTrackingInputs", "ExportEvaluatedDataItemsConfig", ) +_decorators._add_methods_to_classes_in_package(pkg) diff --git a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_forecasting.py b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_forecasting.py index 40c549dc5f..710793c9a7 100644 --- a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_forecasting.py +++ b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_forecasting.py @@ -78,14 +78,14 @@ class AutoMlForecastingInputs(proto.Message): function over the validation set. The supported optimization objectives: - "minimize-rmse" (default) - Minimize root- + "minimize-rmse" (default) - Minimize root- mean-squared error (RMSE). "minimize-mae" - Minimize mean-absolute error (MAE). "minimize- rmsle" - Minimize root-mean-squared log error (RMSLE). "minimize-rmspe" - Minimize root- mean-squared percentage error (RMSPE). "minimize-wape-mae" - Minimize the combination - of weighted absolute percentage error (WAPE) + of weighted absolute percentage error (WAPE) and mean-absolute-error (MAE). train_budget_milli_node_hours (int): Required. The train budget of creating this @@ -418,11 +418,11 @@ class Period(proto.Message): unit (str): The time granularity unit of this time period. The supported unit are: - "hour" - "day" - "week" - "month" - "year". + "hour" + "day" + "week" + "month" + "year". quantity (int): The number of units per period, e.g. 3 weeks or 2 months. diff --git a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_tables.py b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_tables.py index 55d620b32e..f924979bd6 100644 --- a/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_tables.py +++ b/google/cloud/aiplatform/v1beta1/schema/trainingjob/definition_v1beta1/types/automl_tables.py @@ -61,7 +61,7 @@ class AutoMlTablesInputs(proto.Message): produce. "classification" - Predict one out of multiple target values is picked for each row. - "regression" - Predict a value based on its + "regression" - Predict a value based on its relation to other values. This type is available only to columns that contain semantically numeric values, i.e. integers or @@ -87,11 +87,11 @@ class AutoMlTablesInputs(proto.Message): the prediction type. If the field is not set, a default objective function is used. classification (binary): - "maximize-au-roc" (default) - Maximize the + "maximize-au-roc" (default) - Maximize the area under the receiver operating characteristic (ROC) curve. "minimize-log-loss" - Minimize log loss. - "maximize-au-prc" - Maximize the area under + "maximize-au-prc" - Maximize the area under the precision-recall curve. "maximize- precision-at-recall" - Maximize precision for a specified @@ -99,10 +99,10 @@ class AutoMlTablesInputs(proto.Message): Maximize recall for a specified precision value. classification (multi-class): - "minimize-log-loss" (default) - Minimize log + "minimize-log-loss" (default) - Minimize log loss. regression: - "minimize-rmse" (default) - Minimize root- + "minimize-rmse" (default) - Minimize root- mean-squared error (RMSE). "minimize-mae" - Minimize mean-absolute error (MAE). "minimize- rmsle" - Minimize root-mean-squared log error diff --git a/samples/snippets/create_training_pipeline_image_classification_sample.py b/samples/snippets/create_training_pipeline_image_classification_sample.py index 28b407927e..d97ccbca84 100644 --- a/samples/snippets/create_training_pipeline_image_classification_sample.py +++ b/samples/snippets/create_training_pipeline_image_classification_sample.py @@ -14,8 +14,8 @@ # [START aiplatform_create_training_pipeline_image_classification_sample] from google.cloud import aiplatform -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Value +from google.cloud.aiplatform.v1beta1.schema.trainingjob import definition +ModelType = definition.AutoMlImageClassificationInputs().ModelType def create_training_pipeline_image_classification_sample( @@ -31,13 +31,14 @@ def create_training_pipeline_image_classification_sample( # Initialize client that will be used to create and send requests. # This client only needs to be created once, and can be reused for multiple requests. client = aiplatform.gapic.PipelineServiceClient(client_options=client_options) - training_task_inputs_dict = { - "multiLabel": True, - "modelType": "CLOUD", - "budgetMilliNodeHours": 8000, - "disableEarlyStopping": False, - } - training_task_inputs = json_format.ParseDict(training_task_inputs_dict, Value()) + + icn_training_inputs = definition.AutoMlImageClassificationInputs( + multi_label=True, + model_type=ModelType.CLOUD, + budget_milli_node_hours=8000, + disable_early_stopping=False + ) + training_task_inputs = icn_training_inputs.to_value() training_pipeline = { "display_name": display_name, diff --git a/samples/snippets/predict_image_classification_sample.py b/samples/snippets/predict_image_classification_sample.py index b07a7b9669..f0e31ff1dc 100644 --- a/samples/snippets/predict_image_classification_sample.py +++ b/samples/snippets/predict_image_classification_sample.py @@ -16,8 +16,9 @@ import base64 from google.cloud import aiplatform -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Value +from google.cloud.aiplatform.v1beta1.schema.predict import instance +from google.cloud.aiplatform.v1beta1.schema.predict import params +from google.cloud.aiplatform.v1beta1.schema.predict import prediction def predict_image_classification_sample( @@ -37,25 +38,29 @@ def predict_image_classification_sample( # The format of each instance should conform to the deployed model's prediction input schema. encoded_content = base64.b64encode(file_content).decode("utf-8") - instance_dict = {"content": encoded_content} - instance = json_format.ParseDict(instance_dict, Value()) - instances = [instance] - # See gs://google-cloud-aiplatform/schema/predict/params/image_classification_1.0.0.yaml for the format of the parameters. - parameters_dict = {"confidence_threshold": 0.5, "max_predictions": 5} - parameters = json_format.ParseDict(parameters_dict, Value()) + instance_obj = instance.ImageClassificationPredictionInstance( + content=encoded_content) + + instance_val = instance_obj.to_value() + instances = [instance_val] + + params_obj = params.ImageClassificationPredictionParams( + confidence_threshold=0.5, max_predictions=5) + endpoint = client.endpoint_path( project=project, location=location, endpoint=endpoint_id ) response = client.predict( - endpoint=endpoint, instances=instances, parameters=parameters + endpoint=endpoint, instances=instances, parameters=params_obj ) print("response") - print(" deployed_model_id:", response.deployed_model_id) + print("\tdeployed_model_id:", response.deployed_model_id) # See gs://google-cloud-aiplatform/schema/predict/prediction/classification.yaml for the format of the predictions. predictions = response.predictions - for prediction in predictions: - print(" prediction:", dict(prediction)) + for prediction_ in predictions: + prediction_obj = prediction.ClassificationPredictionResult.from_map(prediction_) + print(prediction_obj) # [END aiplatform_predict_image_classification_sample] diff --git a/samples/snippets/predict_image_classification_sample_test.py b/samples/snippets/predict_image_classification_sample_test.py index 10e72bb386..f771af99a4 100644 --- a/samples/snippets/predict_image_classification_sample_test.py +++ b/samples/snippets/predict_image_classification_sample_test.py @@ -31,4 +31,4 @@ def test_ucaip_generated_predict_image_classification_sample(capsys): ) out, _ = capsys.readouterr() - assert 'string_value: "daisy"' in out + assert 'deployed_model_id:' in out diff --git a/synth.py b/synth.py index 107235edac..ee86460430 100644 --- a/synth.py +++ b/synth.py @@ -84,6 +84,21 @@ "request.traffic_split = traffic_split", ) + +# Generator adds a bad import statement to enhanced type; +# need to fix in post-processing steps. +s.replace( + "google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py", + "text_sentiment_pb2 as gcaspi_text_sentiment # type: ignore", + "TextSentimentPredictionInstance") + +s.replace( + "google/cloud/aiplatform/v1beta1/schema/predict/prediction_v1beta1/types/text_sentiment.py", + "message=gcaspi_text_sentiment.TextSentimentPredictionInstance,", + "message=TextSentimentPredictionInstance,") + + + # post processing to fix the generated reference doc from synthtool import transforms as st import re diff --git a/tests/unit/enhanced_library/test_enhanced_types.py b/tests/unit/enhanced_library/test_enhanced_types.py new file mode 100644 index 0000000000..e0a3120909 --- /dev/null +++ b/tests/unit/enhanced_library/test_enhanced_types.py @@ -0,0 +1,36 @@ +# 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. +from __future__ import absolute_import + +from google.cloud.aiplatform.v1beta1.schema.trainingjob import definition + +ModelType = definition.AutoMlImageClassificationInputs().ModelType +test_training_input = definition.AutoMlImageClassificationInputs( + multi_label=True, + model_type=ModelType.CLOUD, + budget_milli_node_hours=8000, + disable_early_stopping=False, +) + + +def test_exposes_to_value_method(): + assert hasattr(test_training_input, "to_value") + + +def test_exposes_from_value_method(): + assert hasattr(test_training_input, "from_value") + + +def test_exposes_from_map_method(): + assert hasattr(test_training_input, "from_map") diff --git a/tests/unit/enhanced_library/test_value_converter.py b/tests/unit/enhanced_library/test_value_converter.py new file mode 100644 index 0000000000..b39512611b --- /dev/null +++ b/tests/unit/enhanced_library/test_value_converter.py @@ -0,0 +1,87 @@ +# 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. +from __future__ import absolute_import + +from google.cloud.aiplatform.helpers import value_converter +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Value +import proto + + +class SomeMessage(proto.Message): + test_str = proto.Field(proto.STRING, number=1) + test_int64 = proto.Field(proto.INT64, number=2) + test_bool = proto.Field(proto.BOOL, number=3) + + +class SomeInType(proto.Message): + test_map = proto.MapField(proto.STRING, proto.INT32, number=1) + + +class SomeOutType(proto.Message): + test_int = proto.Field(proto.INT32, number=1) + + +input_dict = { + "test_str": "Omnia Gallia est divisa", + "test_int64": 3, + "test_bool": True, +} +input_value = json_format.ParseDict(input_dict, Value()) +input_message = SomeMessage(input_dict) + + +def test_convert_message_to_value(): + actual_to_value_output = value_converter.to_value(input_message) + expected_type = Value() + assert isinstance(expected_type, type(actual_to_value_output)) + + actual_inner_fields = actual_to_value_output.struct_value.fields + + actual_bool_type = actual_inner_fields["test_bool"] + assert hasattr(actual_bool_type, "bool_value") + + actual_int64_type = actual_inner_fields["test_int64"] + assert hasattr(actual_int64_type, "number_value") + + actual_string_type = actual_inner_fields["test_str"] + assert hasattr(actual_string_type, "string_value") + + +def test_convert_value_to_message(): + actual_from_value_output = value_converter.from_value(SomeMessage, input_value) + expected_type = SomeMessage(input_dict) + + # TODO: compare instance of SomeMessage against + # actual_from_value_output. + # See https://github.com/googleapis/python-aiplatform/issues/136 + + # Check property-level ("duck-typing") equivalency + assert actual_from_value_output.test_str == expected_type.test_str + assert actual_from_value_output.test_bool == expected_type.test_bool + assert actual_from_value_output.test_int64 == expected_type.test_int64 + + +def test_convert_map_to_message(): + message_with_map = SomeInType() + message_with_map.test_map["test_int"] = 42 + map_composite = message_with_map.test_map + actual_output = value_converter.from_map(SomeOutType, map_composite) + + # TODO: compare instance of SomeMessage against + # actual_from_value_output. + # See https://github.com/googleapis/python-aiplatform/issues/136 + + # Check property-to-key/value equivalency + assert actual_output.test_int == map_composite["test_int"]