diff --git a/CHANGELOG.md b/CHANGELOG.md index b476396e..14c09b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - handle GET EntitySet payload without the member results - Jakub Filak +- both Literal and JSON DateTimes has Timezone set to UTC - Jakub Filak ### Fixed - removed superfluous debug print when parsing FunctionImports from metadata - Jakub Filak - property 'Nullable' attributes are correctly parsed and respected - Vasilii Khomutov +- use correct type of deserialization of Literal (URL) structure values - Jakub Filak +- null values are correctly handled - Jakub Filak ## [1.4.0] diff --git a/pyodata/v2/model.py b/pyodata/v2/model.py index 297c9ad4..1b46eff2 100644 --- a/pyodata/v2/model.py +++ b/pyodata/v2/model.py @@ -26,6 +26,17 @@ TypeInfo = collections.namedtuple('TypeInfo', 'namespace name is_collection') +def current_timezone(): + """Default Timezone for Python datetime instances when parsed from + Edm.DateTime values and vice versa. + + OData V2 does not mention Timezones in the documentation of + Edm.DateTime and UTC was chosen because it is universal. + """ + + return datetime.timezone.utc + + def modlog(): return logging.getLogger(LOGGER_NAME) @@ -276,7 +287,7 @@ def to_literal(edm_type, value): result = {} for type_prop in edm_type.proprties(): if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name]) + result[type_prop.name] = type_prop.to_literal(value[type_prop.name]) return result @@ -290,7 +301,7 @@ def from_json(edm_type, value): result = {} for type_prop in edm_type.proprties(): if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name]) + result[type_prop.name] = type_prop.from_json(value[type_prop.name]) return result @@ -304,7 +315,7 @@ def from_literal(edm_type, value): result = {} for type_prop in edm_type.proprties(): if type_prop.name in value: - result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name]) + result[type_prop.name] = type_prop.from_literal(value[type_prop.name]) return result @@ -387,7 +398,7 @@ def to_json(self, value): # Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification # https://www.odata.org/documentation/odata-version-2-0/json-format/ - return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/' + return f'/Date({int(value.replace(tzinfo=current_timezone()).timestamp()) * 1000})/' def from_json(self, value): @@ -402,7 +413,7 @@ def from_json(self, value): try: # https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function - value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(milliseconds=int(value)) + value = datetime.datetime(1970, 1, 1, tzinfo=current_timezone()) + datetime.timedelta(milliseconds=int(value)) except ValueError: raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) @@ -426,7 +437,7 @@ def from_literal(self, value): except ValueError: raise PyODataModelError('Cannot decode datetime from value {}.'.format(value)) - return value + return value.replace(tzinfo=current_timezone()) class EdmStringTypTraits(TypTraits): @@ -551,7 +562,7 @@ def from_json(self, value): return EdmStructTypeSerializer.from_json(self._edm_type, value) def from_literal(self, value): - return EdmStructTypeSerializer.from_json(self._edm_type, value) + return EdmStructTypeSerializer.from_literal(self._edm_type, value) class EnumTypTrait(TypTraits): @@ -700,6 +711,42 @@ def precision(self): def scale(self): return self._scale + def from_literal(self, value): + if value is None: + if not self.nullable: + raise PyODataException(f'Cannot convert null URL literal to value of {str(self)}') + + return None + + return self.typ.traits.from_literal(value) + + def to_literal(self, value): + if value is None: + if not self.nullable: + raise PyODataException(f'Cannot convert None to URL literal of {str(self)}') + + return None + + return self.typ.traits.to_literal(value) + + def from_json(self, value): + if value is None: + if not self.nullable: + raise PyODataException(f'Cannot convert null JSON to value of {str(self)}') + + return None + + return self.typ.traits.from_json(value) + + def to_json(self, value): + if value is None: + if not self.nullable: + raise PyODataException(f'Cannot convert None to JSON of {str(self)}') + + return None + + return self.typ.traits.to_json(value) + def _check_scale_value(self): if self._scale > self._precision: raise PyODataModelError('Scale value ({}) must be less than or equal to precision value ({})' diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 83141bca..950173e8 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -204,7 +204,7 @@ def to_key_string_without_parentheses(self): if self._type == EntityKey.TYPE_SINGLE: # first property is the key property key_prop = self._key[0] - return key_prop.typ.traits.to_literal(self._proprties[key_prop.name]) + return key_prop.to_literal(self._proprties[key_prop.name]) key_pairs = [] for key_prop in self._key: @@ -212,7 +212,7 @@ def to_key_string_without_parentheses(self): # raise RuntimeError('Entity key is not complete, missing value of property: {0}'.format(key_prop.name)) key_pairs.append( - '{0}={1}'.format(key_prop.name, key_prop.typ.traits.to_literal(self._proprties[key_prop.name]))) + '{0}={1}'.format(key_prop.name, key_prop.to_literal(self._proprties[key_prop.name]))) return ','.join(key_pairs) @@ -456,7 +456,7 @@ def _build_values(entity_type, entity): values = {} for key, val in entity.items(): try: - val = entity_type.proprty(key).typ.traits.to_json(val) + val = entity_type.proprty(key).to_json(val) except KeyError: try: nav_prop = entity_type.nav_proprty(key) @@ -543,7 +543,7 @@ def set(self, **kwargs): for key, val in kwargs.items(): try: - val = self._entity_type.proprty(key).typ.traits.to_json(val) + val = self._entity_type.proprty(key).to_json(val) except KeyError: raise PyODataException( 'Property {} is not declared in {} entity type'.format(key, self._entity_type.name)) @@ -679,7 +679,7 @@ def parameter(self, name, value): param = self._function_import.get_parameter(name) # add parameter as custom query argument - self.custom(param.name, param.typ.traits.to_literal(value)) + self.custom(param.name, param.to_literal(value)) except KeyError: raise PyODataException('Function import {0} does not have pararmeter {1}' .format(self._function_import.name, name)) @@ -721,11 +721,10 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key= for type_proprty in self._entity_type.proprties(): if type_proprty.name in proprties: if proprties[type_proprty.name] is not None: - self._cache[type_proprty.name] = type_proprty.typ.traits.from_json(proprties[type_proprty.name]) + self._cache[type_proprty.name] = type_proprty.from_json(proprties[type_proprty.name]) else: # null value is in literal form for now, convert it to python representation - self._cache[type_proprty.name] = type_proprty.typ.traits.from_literal( - type_proprty.typ.null_value) + self._cache[type_proprty.name] = type_proprty.from_literal(type_proprty.typ.null_value) # then, assign all navigation properties for prop in self._entity_type.nav_proprties: @@ -843,7 +842,7 @@ def proprty_get_handler(key, proprty, response): .format(proprty.name, key, response.status_code), response) data = response.json()['d'] - return proprty.typ.traits.from_json(data[proprty.name]) + return proprty.from_json(data[proprty.name]) path = urljoin(self.get_path(), name) return self._service.http_get_odata( @@ -942,7 +941,7 @@ def or_(*operands): def format_filter(proprty, operator, value): """Creates a filter expression """ - return '{} {} {}'.format(proprty.name, operator, proprty.typ.traits.to_literal(value)) + return '{} {} {}'.format(proprty.name, operator, proprty.to_literal(value)) def __eq__(self, value): return GetEntitySetFilter.format_filter(self._proprty, 'eq', value) diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index 52aded75..421aca26 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -6,7 +6,7 @@ import pytest from pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \ Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \ - PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation + PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation, current_timezone from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError from tests.conftest import assert_logging_policy @@ -458,7 +458,7 @@ def test_traits_datetime(): # 1. direction Python -> OData - testdate = datetime(2005, 1, 28, 18, 30, 44, 123456, tzinfo=timezone.utc) + testdate = datetime(2005, 1, 28, 18, 30, 44, 123456, tzinfo=current_timezone()) assert typ.traits.to_literal(testdate) == "datetime'2005-01-28T18:30:44.123456'" # without miliseconds part @@ -481,12 +481,14 @@ def test_traits_datetime(): assert testdate.minute == 33 assert testdate.second == 6 assert testdate.microsecond == 654321 + assert testdate.tzinfo == current_timezone() # parsing without miliseconds testdate = typ.traits.from_literal("datetime'1976-11-23T03:33:06'") assert testdate.year == 1976 assert testdate.second == 6 assert testdate.microsecond == 0 + assert testdate.tzinfo == current_timezone() # parsing without seconds and miliseconds testdate = typ.traits.from_literal("datetime'1976-11-23T03:33'") @@ -494,6 +496,7 @@ def test_traits_datetime(): assert testdate.minute == 33 assert testdate.second == 0 assert testdate.microsecond == 0 + assert testdate.tzinfo == current_timezone() # parsing invalid value with pytest.raises(PyODataModelError) as e_info: @@ -515,12 +518,14 @@ def test_traits_datetime(): assert testdate.minute == 33 assert testdate.second == 6 assert testdate.microsecond == 10000 + assert testdate.tzinfo == current_timezone() # parsing without miliseconds testdate = typ.traits.from_json("/Date(217567986000)/") assert testdate.year == 1976 assert testdate.second == 6 assert testdate.microsecond == 0 + assert testdate.tzinfo == current_timezone() # parsing without seconds and miliseconds testdate = typ.traits.from_json("/Date(217567980000)/") @@ -528,6 +533,7 @@ def test_traits_datetime(): assert testdate.minute == 33 assert testdate.second == 0 assert testdate.microsecond == 0 + assert testdate.tzinfo == current_timezone() # parsing the lowest value with pytest.raises(OverflowError): @@ -541,6 +547,7 @@ def test_traits_datetime(): assert testdate.minute == 0 assert testdate.second == 0 assert testdate.microsecond == 0 + assert testdate.tzinfo == current_timezone() # parsing the highest value with pytest.raises(OverflowError): @@ -554,6 +561,7 @@ def test_traits_datetime(): assert testdate.minute == 59 assert testdate.second == 59 assert testdate.microsecond == 999000 + assert testdate.tzinfo == current_timezone() # parsing invalid value with pytest.raises(PyODataModelError) as e_info: diff --git a/tests/test_model_v2_EdmStructTypeSerializer.py b/tests/test_model_v2_EdmStructTypeSerializer.py new file mode 100644 index 00000000..31dcbf69 --- /dev/null +++ b/tests/test_model_v2_EdmStructTypeSerializer.py @@ -0,0 +1,99 @@ +"""Tests of OData Model: class VariableDeclaration""" + +import pytest +import datetime +from pyodata.v2.model import EdmStructTypeSerializer, Types, StructType, StructTypeProperty +from pyodata.exceptions import PyODataException + + +@pytest.fixture +def complex_type_property_declarations(): + return { + 'TestString': (Types.parse_type_name('Edm.String'), "'FooBar'", "'FooBar'", 'FooBar'), + 'TestBoolean': (Types.parse_type_name('Edm.Boolean'), False, 'false', False), + 'TestInt64': (Types.parse_type_name('Edm.Int64'), '123L', '123L', 123), + 'TestDateTime': (Types.parse_type_name('Edm.DateTime'), "/Date(2147483647000)/", "datetime'2038-01-19T3:14:7'", + datetime.datetime(2038, 1, 19, hour=3, minute=14, second=7, tzinfo=datetime.timezone.utc)) + } + + +def define_complex_type(complex_type_property_declarations, nullable = True): + complex_typ = StructType('TestComplexType', 'Label Complex Type', False) + + for name, prop_decl in complex_type_property_declarations.items(): + prop = StructTypeProperty(name, prop_decl[0], nullable, None, None, None, + None, None, None, None, None, None, None, None, None, None, None, None) + + prop.typ = Types.from_name(prop.type_info.name) + complex_typ._properties[prop.name] = prop + prop.struct_type = complex_typ + + return complex_typ + + +@pytest.fixture +def complex_type_with_nullable_props(complex_type_property_declarations, nullable = True): + return define_complex_type(complex_type_property_declarations, nullable=True) + + +@pytest.fixture +def complex_type_without_nullable_props(complex_type_property_declarations, nullable = True): + return define_complex_type(complex_type_property_declarations, nullable=False) + + +def test_nullable_from_json_null_properties(complex_type_with_nullable_props, complex_type_property_declarations): + entity_json = { prop_name: None for prop_name in complex_type_property_declarations.keys() } + + entity_odata = complex_type_with_nullable_props.traits.from_json(entity_json) + + assert entity_json.keys() == entity_odata.keys() + + for name, value in entity_odata.items(): + assert value is None, f'Property: {name}' + + +def test_non_nullable_from_json_null_properties(complex_type_without_nullable_props, complex_type_property_declarations): + for prop_name in complex_type_property_declarations.keys(): + entity_json = { prop_name : None } + with pytest.raises(PyODataException): + entity_odata = complex_type_without_nullable_props.traits.from_json(entity_json) + + +def test_non_nullable_from_json(complex_type_without_nullable_props, complex_type_property_declarations): + entity_json = { prop_name : prop_decl[1] for prop_name, prop_decl in complex_type_property_declarations.items() } + + entity_odata =complex_type_without_nullable_props.traits.from_json(entity_json) + + assert entity_json.keys() == entity_odata.keys() + + for name, value in entity_odata.items(): + assert value == complex_type_property_declarations[name][3], f'Value of {name}' + + +def test_nullable_from_literal_null_properties(complex_type_with_nullable_props, complex_type_property_declarations): + entity_literal = { prop_name: None for prop_name in complex_type_property_declarations.keys() } + + entity_odata = complex_type_with_nullable_props.traits.from_literal(entity_literal) + + assert entity_literal.keys() == entity_odata.keys() + + for name, value in entity_odata.items(): + assert value is None, f'Property: {name}' + + +def test_non_nullable_from_literal_null_properties(complex_type_without_nullable_props, complex_type_property_declarations): + for prop_name in complex_type_property_declarations.keys(): + entity_literal = { prop_name : None } + with pytest.raises(PyODataException): + entity_odata = complex_type_without_nullable_props.traits.from_literal(entity_literal) + + +def test_non_nullable_from_literal(complex_type_without_nullable_props, complex_type_property_declarations): + entity_literal = { prop_name : prop_decl[2] for prop_name, prop_decl in complex_type_property_declarations.items() } + + entity_odata =complex_type_without_nullable_props.traits.from_literal(entity_literal) + + assert entity_literal.keys() == entity_odata.keys() + + for name, value in entity_odata.items(): + assert value == complex_type_property_declarations[name][3], f'Value of {name}' diff --git a/tests/test_model_v2_VariableDeclaration.py b/tests/test_model_v2_VariableDeclaration.py new file mode 100644 index 00000000..1dfd86a6 --- /dev/null +++ b/tests/test_model_v2_VariableDeclaration.py @@ -0,0 +1,74 @@ +"""Tests of OData Model: class VariableDeclaration""" + +import pytest +from pyodata.v2.model import VariableDeclaration, Types +from pyodata.exceptions import PyODataException + + +@pytest.fixture +def variable_of_string_nullable(): + variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), True, None, None, None) + variable.typ = Types.from_name(variable.type_info.name) + return variable + +@pytest.fixture +def variable_of_string(): + variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), False, None, None, None) + variable.typ = Types.from_name(variable.type_info.name) + return variable + + +def test_variable_of_string_nullable_from_json_none(variable_of_string_nullable): + assert variable_of_string_nullable.from_json(None) is None + + +def test_variable_of_string_nullable_to_json_none(variable_of_string_nullable): + assert variable_of_string_nullable.to_json(None) is None + + +def test_variable_of_string_nullable_from_literal_none(variable_of_string_nullable): + assert variable_of_string_nullable.from_literal(None) is None + + +def test_variable_of_string_nullable_to_literal_none(variable_of_string_nullable): + assert variable_of_string_nullable.to_literal(None) is None + + +def test_variable_of_string_nullable_from_json_non_none(variable_of_string_nullable): + assert variable_of_string_nullable.from_json('FromJSON') == 'FromJSON' + + +def test_variable_of_string_nullable_to_json(variable_of_string_nullable): + assert variable_of_string_nullable.to_json('ToJSON') == 'ToJSON' + + +def test_variable_of_string_nullable_from_literal(variable_of_string_nullable): + assert variable_of_string_nullable.from_literal("'FromLiteral'") == 'FromLiteral' + + +def test_variable_of_string_nullable_to_literal(variable_of_string_nullable): + assert variable_of_string_nullable.to_literal('ToLiteral') == "'ToLiteral'" + + +def test_variable_of_string_from_json_none(variable_of_string): + with pytest.raises(PyODataException) as e_info: + variable_of_string.from_json(None) + assert str(e_info.value).startswith('Cannot convert null JSON to value of VariableDeclaration(TestVariable)') + + +def test_variable_of_string_to_json_none(variable_of_string): + with pytest.raises(PyODataException) as e_info: + variable_of_string.to_json(None) + assert str(e_info.value).startswith('Cannot convert None to JSON of VariableDeclaration(TestVariable)') + + +def test_variable_of_string_from_literal_none(variable_of_string): + with pytest.raises(PyODataException) as e_info: + variable_of_string.from_literal(None) + assert str(e_info.value).startswith('Cannot convert null URL literal to value of VariableDeclaration(TestVariable)') + + +def test_variable_of_string_to_literal_none(variable_of_string): + with pytest.raises(PyODataException) as e_info: + variable_of_string.to_literal(None) + assert str(e_info.value).startswith('Cannot convert None to URL literal of VariableDeclaration(TestVariable)')