Skip to content

Commit

Permalink
Merge pull request #40 from jetavator/issue-38-Add_support_for_option…
Browse files Browse the repository at this point in the history
…al_parameters_and_explicitly_list_non-optional_properties_in_jsonschema_required_properties

Issue 38 add support for optional parameters and explicitly list non optional properties in jsonschema required properties
  • Loading branch information
jtv8 authored Jun 6, 2020
2 parents 3c8cc60 + 998ed51 commit 3fce591
Show file tree
Hide file tree
Showing 15 changed files with 217 additions and 41 deletions.
22 changes: 22 additions & 0 deletions docs/source/user_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ may specify it explicitly using the `name` parameter::
class Person(UserObject):
last_name = UserProperty(str, name="surname")

Optional Properties
-------------------

If a UserProperty is intended to be an optional property in the underlying
data object, it can be specified as such using the `optional` parameter::

class Person(UserObject):
...
middle_name = UserProperty(str, optional=True)

If a parameter is not specified as `optional`, it will appear in the
`required` list in the generated JSON schema and will therefore throw a
ValidationError if it is missing from any underlying data object that is
loaded.

If optional properties do not have a `default` or `default_function`, they
will default to None if not set.

Defaults
--------

Expand All @@ -90,6 +108,10 @@ properties, you may use the `default_function` parameter::
default_function=lambda person: person.first_name
)

A UserProperty may not have both a `default` and a `default_function`,
and if either `default` or `default_function` is set then `optional`
defaults to True (and cannot be explicitly set to False).

Constants
---------

Expand Down
66 changes: 59 additions & 7 deletions features/dict.feature
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Feature: Test dictionary DOM objects
}],
"vehicles": {
"eabf04": {
"color": "orange",
"description": "Station Wagon"
"color": "orange",
"description": "Station Wagon"
}
}
}
Expand All @@ -38,9 +38,10 @@ Feature: Test dictionary DOM objects
"properties": {
"city": {"type": "string"},
"first_line": {"type": "string"},
"postal_code": {"type": "integer"},
"second_line": {"type": "string"}
"second_line": {"type": "string"},
"postal_code": {"type": "integer"}
},
"required": ["city", "first_line", "postal_code"],
"additionalProperties": False
},
"dict_module.Vehicle": {
Expand All @@ -49,6 +50,7 @@ Feature: Test dictionary DOM objects
"color": {"type": "string"},
"description": {"type": "string"}
},
"required": ["color", "description"],
"additionalProperties": False
},
"dict_module.Person": {
Expand All @@ -58,16 +60,18 @@ Feature: Test dictionary DOM objects
"last_name": {"type": "string"},
"current_address": {"$ref": "#/definitions/dict_module.Address"},
"previous_addresses": {
"array": {
"items": {"$ref": "#/definitions/dict_module.Address"}
}
"array": {
"items": {"$ref": "#/definitions/dict_module.Address"}
}
},
"vehicles": {
"properties": {},
"required": [],
"additionalProperties": {"$ref": "#/definitions/dict_module.Vehicle"},
"type": "object"
}
},
"required": ["first_name", "last_name", "previous_addresses"],
"additionalProperties": False
}
}
Expand Down Expand Up @@ -133,6 +137,54 @@ Feature: Test dictionary DOM objects
| "orange" | example | example.vehicles["eabf04"] | "color" |
| "Station Wagon" | example | example.vehicles["eabf04"] | "description" |

Scenario: Succeed if missing parameters are optional

Given the Python module dict_module.py
When we execute the following python code:
"""
example_dict_input = {
"first_name": "Marge",
"last_name": "Simpson",
"previous_addresses": [{
"first_line": "742 Evergreen Terrace",
"city": "Springfield",
"postal_code": 58008
}]
}
example = dict_module.Person(example_dict_input)
"""
Then the following statements are true:
"""
schema(example).is_valid(example_dict_input)
example.current_address.second_line is None
example.previous_addresses[0].second_line is None
example.current_address.first_line == "742 Evergreen Terrace"
len(example.vehicles) == 0
"""

Scenario: Fail if missing parameters are non-optional

Given the Python module dict_module.py
When we execute the following python code:
"""
example_dict_input = {
"first_name": "Marge",
"previous_addresses": [{
"first_line": "742 Evergreen Terrace",
"city": "Springfield",
"postal_code": 58008
}]
}
"""
Then the following statements are true:
"""
not(schema(example).is_valid(example_dict_input))
"""
And the following statement raises ValidationError
"""
dict_module.Person(example_dict_input)
"""

Scenario: Test bad input string

Given the Python module dict_module.py
Expand Down
8 changes: 5 additions & 3 deletions features/examples/modules/dict_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ def license(self):

class Address(UserObject):
first_line: str = UserProperty(str)
second_line: str = UserProperty(str)
second_line: str = UserProperty(str, optional=True)
city: str = UserProperty(str)
postal_code: str = UserProperty(int)


class Person(UserObject):
first_name: str = UserProperty(str)
last_name: str = UserProperty(str)
current_address: Address = UserProperty(Address)
current_address: Address = UserProperty(
Address,
default_function=lambda person: person.previous_addresses[0])
previous_addresses: List[Address] = UserProperty(SchemaArray(Address))
vehicles: Dict[str, Vehicle] = UserProperty(SchemaDict(Vehicle))
vehicles: Dict[str, Vehicle] = UserProperty(SchemaDict(Vehicle), default={})
6 changes: 6 additions & 0 deletions features/examples/modules/invalid_both_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from wysdom import UserObject, UserProperty


class Person(UserObject):
first_name: str = UserProperty(str)
last_name: str = UserProperty(str, default="", default_function=lambda x: x.first_name)
6 changes: 6 additions & 0 deletions features/examples/modules/invalid_required_and_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from wysdom import UserObject, UserProperty


class Person(UserObject):
first_name: str = UserProperty(str)
last_name: str = UserProperty(str, default="", optional=False)
17 changes: 17 additions & 0 deletions features/invalid.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Feature: Test that invalid module specifications fail

Scenario: Fail if both default and default_function are set

When we try to load the Python module invalid_both_defaults.py
Then a ValueError is raised with text:
"""
Cannot use both default and default_function.
"""

Scenario: Fail if both default is set and optional is set to False

When we try to load the Python module invalid_required_and_default.py
Then a ValueError is raised with text:
"""
Cannot set optional to False if default or default_function are specified.
"""
28 changes: 26 additions & 2 deletions features/steps/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


@given("the Python module {module}.py")
def step_impl(context, module):
def load_python_module(context, module):
spec = importlib.util.spec_from_file_location(
module,
os.path.join(
Expand All @@ -23,7 +23,30 @@ def step_impl(context, module):
globals()[module] = loaded_module


@step(u"the following string, {variable_name}")
@when("we try to load the Python module {module}.py")
def step_impl(context, module):
try:
load_python_module(context, module)
context.load_python_module_error = None
except Exception as e:
context.load_python_module_error = e


@then("a {exception_type} is raised with text")
def step_impl(context, exception_type):
if context.load_python_module_error is None:
raise Exception("No exception was raised.")
if exception_type != context.load_python_module_error.__class__.__name__:
raise Exception(
f"Expected exception type {exception_type}, got {type(context.load_python_module_error)}."
)
if context.text.strip() != str(context.load_python_module_error).strip():
raise Exception(
f"Expected error message '{context.text}', got '{context.load_python_module_error}'."
)


@given(u"the following string, {variable_name}")
def step_impl(context, variable_name):
globals()[variable_name] = context.text

Expand Down Expand Up @@ -55,6 +78,7 @@ def step_impl(context, exception_type):
if e.__class__.__name__ != exception_type:
raise


@then("the list {variable_name} contains the following tuples")
def step_impl(context, variable_name):
tuple_list = eval(variable_name)
Expand Down
3 changes: 3 additions & 0 deletions features/subclass.feature
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Feature: Test subclassed DOM objects
"pet_type": {"const": "greyhound"},
"name": {"type": "string"}
},
"required": ["name", "pet_type"],
"additionalProperties": False
},
"subclass_module.Cat": {
Expand All @@ -38,6 +39,7 @@ Feature: Test subclassed DOM objects
"pet_type": {"const": "cat"},
"name": {"type": "string"}
},
"required": ["name", "pet_type"],
"additionalProperties": False
},
"subclass_module.Pet": {
Expand All @@ -57,6 +59,7 @@ Feature: Test subclassed DOM objects
}
}
},
"required": ["first_name", "last_name", "pets"],
"additionalProperties": False
}
}
Expand Down
2 changes: 1 addition & 1 deletion wysdom/__version__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VERSION = (0, 1, 2)
VERSION = (0, 1, 3)

__version__ = '.'.join(map(str, VERSION))
12 changes: 11 additions & 1 deletion wysdom/base_schema/Schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Tuple, Optional

from ..exceptions import ValidationError
import jsonschema
from jsonschema.validators import validator_for

from ..exceptions import ValidationError


class Schema(ABC):
"""
Expand Down Expand Up @@ -100,6 +102,14 @@ def jsonschema_full_schema(self) -> Dict[str, Any]:
output_schema.update(self.jsonschema_ref_schema)
return output_schema

def validate(self, value: Any) -> None:
"""
Determine whether a given object conforms to this schema, and throw an error if not.
:param value: An object to test for validity against this schema
"""
jsonschema.validate(value, self.jsonschema_full_schema)

def is_valid(self, value: Any) -> bool:
"""
Determine whether a given object conforms to this schema.
Expand Down
50 changes: 29 additions & 21 deletions wysdom/dom/DOMObject.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Any, Iterator, Dict
from typing import Any, Optional, Iterator, Dict

from collections.abc import Mapping, MutableMapping

Expand All @@ -10,7 +10,7 @@
from .DOMElement import DOMElement
from . import DOMInfo
from .DOMProperties import DOMProperties
from .functions import document
from .functions import document, schema


class DOMObject(DOMElement, MutableMapping):
Expand All @@ -19,7 +19,7 @@ class DOMObject(DOMElement, MutableMapping):
"""

__json_schema_properties__: DOMProperties = None
__json_element_data__: Dict[str, DOMElement] = None
__json_element_data__: Dict[str, Optional[DOMElement]] = None

def __init__(
self,
Expand All @@ -36,6 +36,7 @@ def __init__(
raise ValidationError(
f"Cannot validate input. Object is not a mapping: {value}"
)
schema(self).validate(value)
super().__init__(None, json_dom_info)
self.__json_element_data__ = {}
try:
Expand All @@ -44,27 +45,34 @@ def __init__(
except KeyError as e:
raise ValidationError(str(e))

def __getitem__(self, key: str) -> DOMElement:
def __getitem__(self, key: str) -> Optional[DOMElement]:
return self.__json_element_data__[key]

def __setitem__(self, key: str, value: DOMElement) -> None:
item_class = self.__json_schema_properties__.properties.get(
key, self.__json_schema_properties__.additional_properties)
if item_class is True:
item_class = SchemaAnything()
if not item_class:
raise KeyError(
f"No property named '{key}' exists, and "
"additional properties are not allowed."
def __setitem__(self, key: str, value: Optional[DOMElement]) -> None:
if value is None:
if key in self.__json_schema_properties__.required:
raise ValueError(
f"The property '{key}' is not optional and cannot be None."
)
self.__json_element_data__[key] = None
else:
item_class = self.__json_schema_properties__.properties.get(
key, self.__json_schema_properties__.additional_properties)
if item_class is True:
item_class = SchemaAnything()
if not item_class:
raise KeyError(
f"No property named '{key}' exists, and "
"additional properties are not allowed."
)
self.__json_element_data__[key] = item_class(
value,
DOMInfo(
document=document(self),
parent=self,
element_key=key
)
)
self.__json_element_data__[key] = item_class(
value,
DOMInfo(
document=document(self),
parent=self,
element_key=key
)
)

def __delitem__(self, key: str) -> None:
del self.__json_element_data__[key]
Expand Down
Loading

0 comments on commit 3fce591

Please sign in to comment.