Skip to content

Commit

Permalink
Issue 62 - implement regex pattern validation (#65)
Browse files Browse the repository at this point in the history
* Implement regex checking for UserProperty

* Implement regex checking for SchemaDict keys

* Document regex functionality

* Fix error message testing

* Test for TypeError if pattern is used with a property_type that is not str
  • Loading branch information
jtv8 authored Mar 12, 2021
1 parent 741edf5 commit 9d98b86
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 32 deletions.
38 changes: 38 additions & 0 deletions docs/source/user_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ When this object is translated to a JSON Schema, the `enum` keyword will be used
to define the permitted values of the property.


Patterns
--------

If you need to restrict the values that a UserProperty
can take according to a regex pattern, you can specify this
using the `pattern` parameter::

class Vehicle(UserObject):
rgb_hex_color = UserProperty(str, pattern=r"^[0-9a-f]{6}$")


Note that this will throw a `TypeError` if any type other than `str` is
supplied.

When the object is translated to a JSON Schema, the `pattern` keyword will be used
to validate the permitted values of the property.


Arrays and Dicts
----------------

Expand All @@ -179,6 +197,26 @@ For both SchemaArray and SchemaDict you may pass in any type definition that
you would pass to a UserProperty.


Dict Key Validation via Regex
-----------------------------

If your dictionary only has certain keys that are valid for your application
according to a regex pattern, you can specify this with the parameter
`key_pattern`::

color_names = UserProperty(
SchemaDict(ColorName),
key_pattern=r"^[0-9a-f]{6}$"
)


This will translate to the following in the object's JSON Schema definition::

"propertyNames": {
"pattern": "^[0-9a-f]{6}$"
}


DOM functions
=============

Expand Down
16 changes: 16 additions & 0 deletions docs/source/wysdom.base_schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ SchemaConst
:undoc-members:
:show-inheritance:

SchemaEnum
-------------------------------------

.. autoclass:: wysdom.SchemaEnum
:members:
:undoc-members:
:show-inheritance:

SchemaNone
-------------------------------------

Expand All @@ -33,6 +41,14 @@ SchemaNone
:undoc-members:
:show-inheritance:

SchemaPattern
------------------------------------------

.. autoclass:: wysdom.SchemaPattern
:members:
:undoc-members:
:show-inheritance:

SchemaPrimitive
------------------------------------------

Expand Down
77 changes: 71 additions & 6 deletions features/dict.feature
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Feature: Test dictionary DOM objects
"type": "object",
"properties": {
"city": {"type": "string"},
"first_line": {"type": "string"},
"first_line": {"type": "string", "pattern": r"^(\d)+.*$"},
"second_line": {"type": "string"},
"postal_code": {"type": "integer"}
},
Expand Down Expand Up @@ -68,6 +68,10 @@ Feature: Test dictionary DOM objects
"properties": {},
"required": [],
"additionalProperties": {"$ref": "#/definitions/dict_module.Vehicle"},
"propertyNames": {
"type": "string",
"pattern": r"^[a-f0-9]{6}$"
},
"type": "object"
}
},
Expand Down Expand Up @@ -111,8 +115,8 @@ Feature: Test dictionary DOM objects
parent(example["vehicles"]["eabf04"]) is example.vehicles
document(example["vehicles"]["eabf04"]) is example
key(example["vehicles"]["eabf04"]) == "eabf04"
schema(example).is_valid(example_dict_input)
schema(example).jsonschema_full_schema == expected_schema
schema(dict_module.Person).is_valid(example_dict_input)
schema(dict_module.Person).jsonschema_full_schema == expected_schema
example_dict_output == example_dict_input
copy.copy(example).to_builtin() == example_dict_input
copy.deepcopy(example).to_builtin() == example_dict_input
Expand Down Expand Up @@ -156,7 +160,7 @@ Feature: Test dictionary DOM objects
"""
Then the following statements are true:
"""
schema(example).is_valid(example_dict_input)
schema(dict_module.Person).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"
Expand All @@ -179,7 +183,7 @@ Feature: Test dictionary DOM objects
"""
Then the following statements are true:
"""
not(schema(example).is_valid(example_dict_input))
not(schema(dict_module.Person).is_valid(example_dict_input))
"""
And the following statement raises ValidationError
"""
Expand All @@ -195,7 +199,7 @@ Feature: Test dictionary DOM objects
"""
Then the following statements are true:
"""
not(schema(example).is_valid(example_dict_input))
not(schema(dict_module.Person).is_valid(example_dict_input))
"""
And the following statement raises ValidationError
"""
Expand Down Expand Up @@ -232,3 +236,64 @@ Feature: Test dictionary DOM objects
document(example.vehicles) is example
example.vehicles is example.vehicles
"""

Scenario: Test invalid value for property pattern

Given the Python module dict_module.py
When we execute the following python code:
"""
example_dict_input = {
"first_line": "Bad Address",
"city": "Springfield",
"postal_code": 58008
}
"""
Then the following statements are true:
"""
not(schema(dict_module.Address).is_valid(example_dict_input))
"""
And the following statement raises ValidationError
"""
dict_module.Address(example_dict_input)
"""

Scenario: Test invalid value for dictionary key pattern

Given the Python module dict_module.py
When we execute the following python code:
"""
example_dict_input = {
"first_name": "Marge",
"last_name": "Simpson",
"current_address": {
"first_line": "123 Fake Street",
"second_line": "",
"city": "Springfield",
"postal_code": 58008
},
"previous_addresses": [],
"vehicles": {
"badkey": {
"color": "orange",
"description": "Station Wagon"
}
}
}
"""
Then the following statements are true:
"""
not(schema(dict_module.Person).is_valid(example_dict_input))
"""
And the following statement raises ValidationError
"""
dict_module.Person(example_dict_input)
"""


Scenario: Fail if pattern is supplied and property_type is not str

When we try to load the Python module invalid_pattern_not_str.py
Then a TypeError is raised with text:
"""
Parameter 'pattern' can only be set if 'property_type' is str.
"""
7 changes: 5 additions & 2 deletions features/examples/modules/dict_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def license(self):


class Address(UserObject):
first_line: str = UserProperty(str)
first_line: str = UserProperty(str, pattern=r"^(\d)+.*$")
second_line: str = UserProperty(str, optional=True)
city: str = UserProperty(str)
postal_code: str = UserProperty(int)
Expand All @@ -32,4 +32,7 @@ class Person(UserObject):
Address,
default_function=lambda person: person.previous_addresses[0])
previous_addresses: List[Address] = UserProperty(SchemaArray(Address))
vehicles: Dict[str, Vehicle] = UserProperty(SchemaDict(Vehicle), default={}, persist_defaults=True)
vehicles: Dict[str, Vehicle] = UserProperty(
SchemaDict(Vehicle, key_pattern=r"^[a-f0-9]{6}$"),
default={},
persist_defaults=True)
6 changes: 6 additions & 0 deletions features/examples/modules/invalid_pattern_not_str.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from wysdom import UserObject, UserProperty


class Address(UserObject):
zip_code: int = UserProperty(int, pattern=r"^[0-9]{5}$")

10 changes: 5 additions & 5 deletions features/steps/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,17 @@ def step_impl(context, exception_type):

def remove_whitespace(text):
return " ".join(
line.strip()
for line in context.text.splitlines()
line.strip('"')
for line in text.splitlines()
).strip()

if remove_whitespace(context.text) != remove_whitespace(context.exception):
if remove_whitespace(context.text) != remove_whitespace(str(context.exception)):
raise Exception(
f"""
Expected error message:
{context.text}
{remove_whitespace(context.text)}
Got:
{context.exception}
{remove_whitespace(str(context.exception))}
"""
)

Expand Down
33 changes: 33 additions & 0 deletions wysdom/base_schema/SchemaPattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any, Dict, Tuple

import re

from .SchemaPrimitive import SchemaPrimitive


class SchemaPattern(SchemaPrimitive):
"""
A schema requiring a match for a regex pattern.
"""

pattern: str = None

def __init__(self, pattern: str) -> None:
super().__init__(python_type=str)
self.pattern = pattern

def __call__(
self,
value: str,
dom_info: Tuple = None
) -> Any:
if not re.match(self.pattern, value):
raise ValueError(f"Parameter value {value} does not match regex pattern {self.pattern}.")
return super().__call__(value)

@property
def jsonschema_definition(self) -> Dict[str, Any]:
return {
"type": self.type_name,
"pattern": self.pattern
}
3 changes: 1 addition & 2 deletions wysdom/base_schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@
from .SchemaNone import SchemaNone
from .SchemaConst import SchemaConst
from .SchemaEnum import SchemaEnum


from .SchemaPattern import SchemaPattern
6 changes: 2 additions & 4 deletions wysdom/mixins/RegistersSubclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,8 @@ def registered_subclass(
):
return matched_subclass
raise KeyError(
f"""
The key '{name}' is ambiguous as it matches multiple proper subclasses of {cls}:
{matched_subclasses}
""")
f"The key '{name}' is ambiguous as it matches multiple proper subclasses of {cls}: "
f"{matched_subclasses}")
return matched_subclasses[0]

@classmethod
Expand Down
22 changes: 13 additions & 9 deletions wysdom/object_schema/SchemaDict.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
from typing import Any, Type, Union
from typing import Any, Type, Union, Optional

from ..dom import DOMInfo, DOMDict

from .SchemaObject import SchemaObject
from ..base_schema import Schema
from ..base_schema import Schema, SchemaPattern
from .resolve_arg_to_type import resolve_arg_to_schema


class SchemaDict(SchemaObject):
"""
A schema specifying an object with dynamic properties (corresponding to a Python dict)
:param items: The permitted data type or schema for the properties of this object. Must
be one of:
A primitive Python type (str, int, bool, float)
A subclass of `UserObject`
An instance of `Schema`
:param items: The permitted data type or schema for the properties of this object.
Must be one of:
A primitive Python type (str, int, bool, float)
A subclass of `UserObject`
An instance of `Schema`
:param key_pattern: A regex pattern to validate the keys of the dictionary against.
"""

def __init__(
self,
items: Union[Type, Schema]
items: Union[Type, Schema],
key_pattern: Optional[str] = None
) -> None:
super().__init__(
additional_properties=resolve_arg_to_schema(items)
additional_properties=resolve_arg_to_schema(items),
property_names=(None if key_pattern is None else SchemaPattern(key_pattern))
)

def __call__(
Expand Down
11 changes: 10 additions & 1 deletion wysdom/object_schema/SchemaObject.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,31 @@ class SchemaObject(SchemaType):
from this schema.
:param schema_ref_name: An optional unique reference name to use when this schema
is referred to by other schemas.
:param property_names: An optional Schema that property names must be validated against.
"""

type_name: str = "object"
properties: Optional[Dict[str, Schema]] = None
required: Set[str] = None
additional_properties: Union[bool, Schema] = False
schema_ref_name: Optional[str] = None
property_names: Optional[Schema] = None

def __init__(
self,
properties: Optional[Dict[str, Schema]] = None,
required: Set[str] = None,
additional_properties: Union[bool, Schema] = False,
object_type: Type = dict,
schema_ref_name: Optional[str] = None
schema_ref_name: Optional[str] = None,
property_names: Optional[Schema] = None
) -> None:
self.properties = properties or {}
self.required = required or set()
self.additional_properties = additional_properties
self.object_type = object_type
self.schema_ref_name = schema_ref_name
self.property_names = property_names

def __call__(
self,
Expand Down Expand Up @@ -72,6 +76,11 @@ def jsonschema_definition(self) -> Dict[str, Any]:
self.additional_properties.jsonschema_ref_schema
if isinstance(self.additional_properties, Schema)
else self.additional_properties
),
**(
{"propertyNames": self.property_names.jsonschema_ref_schema}
if self.property_names is not None
else {}
)
}

Expand Down
Loading

0 comments on commit 9d98b86

Please sign in to comment.