diff --git a/conjure_python_client/_serde/__init__.py b/conjure_python_client/_serde/__init__.py index 22a14f50..c6c5865b 100644 --- a/conjure_python_client/_serde/__init__.py +++ b/conjure_python_client/_serde/__init__.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .decoder import ConjureDecoder +from .decoder import ConjureDecoder, DecodingOptions from .encoder import ConjureEncoder __all__ = [ "ConjureDecoder", + "DecodingOptions", "ConjureEncoder", ] diff --git a/conjure_python_client/_serde/decoder.py b/conjure_python_client/_serde/decoder.py index aa806acf..68ffa7b6 100644 --- a/conjure_python_client/_serde/decoder.py +++ b/conjure_python_client/_serde/decoder.py @@ -24,18 +24,47 @@ OptionalType, ) from typing import Optional, Type, Union, get_origin, get_args -from typing import Dict, Any, List +from typing import Dict, Any, List, Tuple +from dataclasses import dataclass import inspect import json NoneType = type(None) +@dataclass +class DecodingOptions(object): + """ + A set of options allowed to be inputted by the user to tweak the + behavior of the decoder - i.e. An interface for usage. This is invoked on + a per class-method instantiation of the class - meaning all configuraable + inputs must have a default value specified at compile time. These options + are application-specific, and persist throughout the recursion as the + decoder dispatches more method stacks in memory. + + Attributes: + allow_hyphen (int): This controls whether or not you would prefer to + allow users to input "hyphenated" versions of field names. + e.g. "foo-bar: "baz" turns into Foo(foobar: baz) instead of + considering it an invalid key. The default behavior is false. + """ + + allow_hyphen: bool = False + + def __post_init__(self): + if self.allow_hyphen is None: + self.allow_hyphen = False + + class ConjureDecoder(object): """Decodes json into a conjure object""" @classmethod - def decode_conjure_bean_type(cls, obj, conjure_type): + def decode_conjure_bean_type( + cls, + obj, + conjure_type, + decoding_options: DecodingOptions = DecodingOptions()): """Decodes json into a conjure bean type (a plain bean, not enum or union). @@ -43,31 +72,79 @@ def decode_conjure_bean_type(cls, obj, conjure_type): obj: the json object to decode conjure_type: a class object which is the bean type we're decoding into + decoding_options: additional decoding options - + see DecodingOptions Returns: A instance of a bean of type conjure_type. """ deserialized: Dict[str, Any] = {} for ( - python_arg_name, - field_definition, + python_arg_name, + field_definition, ) in conjure_type._fields().items(): - field_identifier = field_definition.identifier - if field_identifier not in obj or obj[field_identifier] is None: + field_candidates = [field_definition.identifier] + + if decoding_options.allow_hyphen: + field_candidates += [ + python_arg_name, + cls.convert_field_to_hyphenated(python_arg_name) + ] + + result, field, value = cls.attempt_field_extraction( + obj, field_candidates) + + if result is False or value is None: cls.check_null_field( obj, deserialized, python_arg_name, field_definition ) else: - value = obj[field_identifier] field_type = field_definition.field_type deserialized[python_arg_name] = cls.do_decode( - value, field_type + value, field_type, decoding_options ) return conjure_type(**deserialized) + @classmethod + def attempt_field_extraction( + cls, + obj, + field_candidates) -> Tuple[bool, Optional[str], Optional[Any]]: + """ + Checks to see if any given fields (candidates) exist in the given + object. Returns only the first one that matches, silently ignores + the rest. If a candidate matches, but the value is none, it will + return the candidate that matched as the field, and None as the value. + If no candidates are found, returns (False, None, None) + Args: + obj: the json object to decode + field_candidates: the candidates that we are looking for + in the obj + Returns: + Tuple( + bool, - whether a candidate was found + str, - the candidate that matched (if any) + Any - the value for the matched candidate (if any) + ) + """ + + for candidate in field_candidates: + if candidate in obj: + return True, candidate, obj[candidate] + + return False, None, None + + @staticmethod + def convert_field_from_hyphenated(field_identifier): + return field_identifier.replace("-", "_") + + @staticmethod + def convert_field_to_hyphenated(field_identifier): + return field_identifier.replace("_", "-") + @classmethod def check_null_field( - cls, obj, deserialized, python_arg_name, field_definition + cls, obj, deserialized, python_arg_name, field_definition ): type_origin = get_origin(field_definition.field_type) if isinstance(field_definition.field_type, ListType): @@ -90,13 +167,19 @@ def check_null_field( ) @classmethod - def decode_conjure_union_type(cls, obj, conjure_type): + def decode_conjure_union_type( + cls, + obj, + conjure_type, + decoding_options: DecodingOptions = DecodingOptions()): """Decodes json into a conjure union type. Args: obj: the json object to decode conjure_type: a class object which is the union type we're decoding into + decoding_options: additional decoding options - + see DecodingOptions Returns: An instance of type conjure_type. """ @@ -121,11 +204,15 @@ def decode_conjure_union_type(cls, obj, conjure_type): else: value = obj[type_of_union] field_type = conjure_field_definition.field_type - deserialized[attribute] = cls.do_decode(value, field_type) + deserialized[attribute] = cls.do_decode( + value, field_type, decoding_options) return conjure_type(**deserialized) @classmethod - def decode_conjure_enum_type(cls, obj, conjure_type): + def decode_conjure_enum_type( + cls, + obj, + conjure_type): """Decodes json into a conjure enum type. Args: @@ -150,10 +237,11 @@ def decode_conjure_enum_type(cls, obj, conjure_type): @classmethod def decode_dict( - cls, - obj: Dict[Any, Any], - key_type: Type[DecodableType], - item_type: Type[DecodableType], + cls, + obj: Dict[Any, Any], + key_type: Type[DecodableType], + item_type: Type[DecodableType], + decoding_options: DecodingOptions = DecodingOptions() ) -> Dict[Any, Any]: """Decodes json into a dictionary, handling conversion of the keys/values (the keys/values may themselves require conversion). @@ -164,6 +252,8 @@ def decode_dict( of the keys in this dict item_type: a class object which is the conjure type of the values in this dict + decoding_options: additional decoding options - + see DecodingOptions Returns: A python dictionary, where the keys are instances of type key_type and the values are of type value_type. @@ -171,19 +261,25 @@ def decode_dict( if not isinstance(obj, dict): raise Exception("expected a python dict") if ( - key_type is str - or isinstance(key_type, BinaryType) - or key_type is BinaryType - or ( + key_type is str + or isinstance(key_type, BinaryType) + or key_type is BinaryType + or ( inspect.isclass(key_type) and issubclass(key_type, ConjureEnumType) - ) + ) ): return dict( ( ( - cls.do_decode(x[0], key_type), - cls.do_decode(x[1], item_type), + cls.do_decode( + x[0], + key_type, + decoding_options), + cls.do_decode( + x[1], + item_type, + decoding_options), ) for x in obj.items() ) @@ -192,8 +288,14 @@ def decode_dict( return dict( ( ( - cls.do_decode(json.loads(x[0]), key_type), - cls.do_decode(x[1], item_type), + cls.do_decode( + json.loads(x[0]), + key_type, + decoding_options), + cls.do_decode( + x[1], + item_type, + decoding_options), ) for x in obj.items() ) @@ -201,7 +303,10 @@ def decode_dict( @classmethod def decode_list( - cls, obj: List[Any], element_type: Type[DecodableType] + cls, + obj: List[Any], + element_type: Type[DecodableType], + decoding_options: DecodingOptions = DecodingOptions() ) -> List[Any]: """Decodes json into a list, handling conversion of the elements. @@ -209,6 +314,8 @@ def decode_list( obj: the json object to decode element_type: a class object which is the conjure type of the elements in this list. + decoding_options: additional decoding options - + see DecodingOptions Returns: A python list where the elements are instances of type element_type. @@ -216,11 +323,14 @@ def decode_list( if not isinstance(obj, list): raise Exception("expected a python list") - return list(map(lambda x: cls.do_decode(x, element_type), obj)) + return list(map(lambda x: cls.do_decode( + x, element_type, decoding_options), obj)) @classmethod def decode_optional( - cls, obj: Optional[Any], object_type: Type[DecodableType] + cls, obj: Optional[Any], + object_type: Type[DecodableType], + decoding_options: DecodingOptions = DecodingOptions() ) -> Optional[Any]: """Decodes json into an element, returning None if the provided object is None. @@ -229,13 +339,17 @@ def decode_optional( obj: the json object to decode object_type: a class object which is the conjure type of the object if present. + decoding_options: additional decoding options - + see DecodingOptions Returns: The decoded obj or None if no obj is provided. """ if obj is None: return None - return cls.do_decode(obj, object_type) + return cls.do_decode(obj, + object_type, + decoding_options=decoding_options) @classmethod def decode_primitive(cls, obj, object_type): @@ -249,13 +363,14 @@ def raise_mismatch(): if object_type is float: return float(obj) elif ( - object_type is str - or object_type is BinaryType - or isinstance(object_type, BinaryType) + object_type is str + or object_type is BinaryType + or isinstance(object_type, BinaryType) ): # Python 2/3 compatible way of checking string if not ( - isinstance(obj, str) or str(type(obj)) == "" + isinstance(obj, str) or + str(type(obj)) == "" ): raise_mismatch() elif not isinstance(obj, object_type): @@ -264,27 +379,39 @@ def raise_mismatch(): return obj @classmethod - def do_decode(cls, obj: Any, obj_type: Type[DecodableType]) -> Any: + def do_decode( + cls, + obj: Any, + obj_type: Type[DecodableType], + decoding_options: DecodingOptions = DecodingOptions() + ) -> Any: """Decodes json into the specified type Args: obj: the json object to decode obj_type: a class object which is the type we're decoding into. + decoding_options: see DecodingOptions """ type_origin = get_origin(obj_type) type_args = get_args(obj_type) - if inspect.isclass(obj_type) and issubclass(obj_type, ConjureBeanType): - return cls.decode_conjure_bean_type(obj, obj_type) + if ( + inspect.isclass(obj_type) and + issubclass(obj_type, ConjureBeanType) + ): + return cls.decode_conjure_bean_type( + obj, + obj_type, + decoding_options=decoding_options) elif inspect.isclass(obj_type) and issubclass( - obj_type, ConjureUnionType + obj_type, ConjureUnionType ): return cls.decode_conjure_union_type(obj, obj_type) elif inspect.isclass(obj_type) and issubclass( - obj_type, ConjureEnumType + obj_type, ConjureEnumType ): return cls.decode_conjure_enum_type(obj, obj_type) @@ -309,11 +436,12 @@ def do_decode(cls, obj: Any, obj_type: Type[DecodableType]) -> Any: return cls.decode_primitive(obj, obj_type) - def decode(self, obj: Any, obj_type: Type[DecodableType]) -> Any: - return self.do_decode(obj, obj_type) + def decode(self, obj: Any, obj_type: Type[DecodableType], + decoding_options: DecodingOptions = DecodingOptions()) -> Any: + return self.do_decode(obj, obj_type, decoding_options) def read_from_string( - self, string_value: str, obj_type: Type[DecodableType] + self, string_value: str, obj_type: Type[DecodableType] ) -> Any: deserialized = json.loads(string_value) return self.decode(deserialized, obj_type) diff --git a/test/serde/test_decode_object.py b/test/serde/test_decode_object.py index 05c5f92c..9907753c 100644 --- a/test/serde/test_decode_object.py +++ b/test/serde/test_decode_object.py @@ -14,7 +14,7 @@ import pytest import re -from conjure_python_client import ConjureDecoder +from conjure_python_client import ConjureDecoder, DecodingOptions from test.example_service.product import CreateDatasetRequest from test.example_service.product.datasets import ListExample, MapExample @@ -75,3 +75,17 @@ def test_object_with_null_field_fails(): ConjureDecoder().read_from_string( """{"fileSystemId": null, "path": "bar"}""", CreateDatasetRequest ) + + +def test_object_with_hyphen(): + decoded = ConjureDecoder().decode( + {"file-system-id": "foo", "path": "bar"}, + CreateDatasetRequest, decoding_options=DecodingOptions(allow_hyphen=True)) + assert decoded == CreateDatasetRequest("foo", "bar") + + +def test_object_with_hyphen_alternative(): + with pytest.raises(Exception): + ConjureDecoder().decode( + {"file-system-id": "foo", "path": "bar"}, + CreateDatasetRequest, decoding_options=DecodingOptions(allow_hyphen=False))