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

Nullable values #100

Merged
merged 3 commits into from
Apr 30, 2020
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]

61 changes: 54 additions & 7 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
@@ -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 ({})'
19 changes: 9 additions & 10 deletions pyodata/v2/service.py
Original file line number Diff line number Diff line change
@@ -204,15 +204,15 @@ 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:
# if key_prop.name not in self.__dict__['_cache']:
# 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)
12 changes: 10 additions & 2 deletions tests/test_model_v2.py
Original file line number Diff line number Diff line change
@@ -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,19 +481,22 @@ 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'")
assert testdate.year == 1976
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,19 +518,22 @@ 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)/")
assert testdate.year == 1976
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:
99 changes: 99 additions & 0 deletions tests/test_model_v2_EdmStructTypeSerializer.py
Original file line number Diff line number Diff line change
@@ -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}'
74 changes: 74 additions & 0 deletions tests/test_model_v2_VariableDeclaration.py
Original file line number Diff line number Diff line change
@@ -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)')