From f83ab35eb643019320365b068dc5aa74a56683cd Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 18:08:42 +0100 Subject: [PATCH 1/6] Implement regex checking for UserProperty --- features/dict.feature | 32 ++++++++++++++++++----- features/examples/modules/dict_module.py | 7 +++-- wysdom/base_schema/SchemaPattern.py | 33 ++++++++++++++++++++++++ wysdom/base_schema/__init__.py | 3 +-- wysdom/user_objects/UserProperty.py | 15 ++++++++--- 5 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 wysdom/base_schema/SchemaPattern.py diff --git a/features/dict.feature b/features/dict.feature index 6ec76af..2d9700c 100644 --- a/features/dict.feature +++ b/features/dict.feature @@ -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"} }, @@ -111,8 +111,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 @@ -156,7 +156,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" @@ -179,7 +179,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 """ @@ -195,7 +195,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 """ @@ -232,3 +232,23 @@ 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) + """ diff --git a/features/examples/modules/dict_module.py b/features/examples/modules/dict_module.py index 26909ee..42aab66 100644 --- a/features/examples/modules/dict_module.py +++ b/features/examples/modules/dict_module.py @@ -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) @@ -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), + default={}, + persist_defaults=True) diff --git a/wysdom/base_schema/SchemaPattern.py b/wysdom/base_schema/SchemaPattern.py new file mode 100644 index 0000000..f3d68e9 --- /dev/null +++ b/wysdom/base_schema/SchemaPattern.py @@ -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 + } diff --git a/wysdom/base_schema/__init__.py b/wysdom/base_schema/__init__.py index ccfa597..9ffff21 100644 --- a/wysdom/base_schema/__init__.py +++ b/wysdom/base_schema/__init__.py @@ -5,5 +5,4 @@ from .SchemaNone import SchemaNone from .SchemaConst import SchemaConst from .SchemaEnum import SchemaEnum - - +from .SchemaPattern import SchemaPattern diff --git a/wysdom/user_objects/UserProperty.py b/wysdom/user_objects/UserProperty.py index f9f47e2..9181bd3 100644 --- a/wysdom/user_objects/UserProperty.py +++ b/wysdom/user_objects/UserProperty.py @@ -1,6 +1,6 @@ from typing import Type, Any, Union, Optional, Callable -from ..base_schema import Schema +from ..base_schema import Schema, SchemaPattern from ..object_schema import resolve_arg_to_schema from ..dom import DOMObject, DOMInfo, document @@ -45,6 +45,9 @@ class UserProperty(object): data object. This is often desirable behavior if the UserProperty returns another object and your code expects it to return the same object instance each time it is accessed. + + :param pattern: A regex pattern to validate the values of this property against. + Use only for `str` properties. """ def __init__( @@ -54,7 +57,8 @@ def __init__( name: Optional[str] = None, default: Optional[Any] = None, default_function: Optional[Callable] = None, - persist_defaults: Optional[bool] = None + persist_defaults: Optional[bool] = None, + pattern: Optional[str] = None ) -> None: if default is not None or default_function is not None: if default is not None and default_function is not None: @@ -65,7 +69,12 @@ def __init__( self.optional = True else: self.optional = bool(optional) - self.schema_type = resolve_arg_to_schema(property_type) + if pattern: + if property_type is not str: + raise TypeError("Parameter 'pattern' can only be set if 'property_type' is str.") + self.schema_type = SchemaPattern(pattern) + else: + self.schema_type = resolve_arg_to_schema(property_type) self.name = name self.default = default self.default_function = default_function From aa23a85b0421b26d005ead174d560ef775790051 Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 18:21:22 +0100 Subject: [PATCH 2/6] Implement regex checking for SchemaDict keys --- features/dict.feature | 38 ++++++++++++++++++++++++ features/examples/modules/dict_module.py | 2 +- wysdom/object_schema/SchemaDict.py | 22 ++++++++------ wysdom/object_schema/SchemaObject.py | 11 ++++++- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/features/dict.feature b/features/dict.feature index 2d9700c..b3749a2 100644 --- a/features/dict.feature +++ b/features/dict.feature @@ -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" } }, @@ -252,3 +256,37 @@ Feature: Test dictionary DOM objects """ 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) + """ + + diff --git a/features/examples/modules/dict_module.py b/features/examples/modules/dict_module.py index 42aab66..a97072c 100644 --- a/features/examples/modules/dict_module.py +++ b/features/examples/modules/dict_module.py @@ -33,6 +33,6 @@ class Person(UserObject): default_function=lambda person: person.previous_addresses[0]) previous_addresses: List[Address] = UserProperty(SchemaArray(Address)) vehicles: Dict[str, Vehicle] = UserProperty( - SchemaDict(Vehicle), + SchemaDict(Vehicle, key_pattern=r"^[a-f0-9]{6}$"), default={}, persist_defaults=True) diff --git a/wysdom/object_schema/SchemaDict.py b/wysdom/object_schema/SchemaDict.py index 4036261..793072d 100644 --- a/wysdom/object_schema/SchemaDict.py +++ b/wysdom/object_schema/SchemaDict.py @@ -1,9 +1,9 @@ -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 @@ -11,19 +11,23 @@ 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=SchemaPattern(key_pattern) ) def __call__( diff --git a/wysdom/object_schema/SchemaObject.py b/wysdom/object_schema/SchemaObject.py index 54d9536..7ced915 100644 --- a/wysdom/object_schema/SchemaObject.py +++ b/wysdom/object_schema/SchemaObject.py @@ -19,6 +19,7 @@ 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" @@ -26,6 +27,7 @@ class SchemaObject(SchemaType): required: Set[str] = None additional_properties: Union[bool, Schema] = False schema_ref_name: Optional[str] = None + property_names: Optional[Schema] = None def __init__( self, @@ -33,13 +35,15 @@ def __init__( 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, @@ -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 {} ) } From cdfa207e4d7407cc0c106ad6fbaac2e251373b05 Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 18:34:00 +0100 Subject: [PATCH 3/6] Fix regression if key_pattern is not supplied --- wysdom/object_schema/SchemaDict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wysdom/object_schema/SchemaDict.py b/wysdom/object_schema/SchemaDict.py index 793072d..bc18e86 100644 --- a/wysdom/object_schema/SchemaDict.py +++ b/wysdom/object_schema/SchemaDict.py @@ -27,7 +27,7 @@ def __init__( ) -> None: super().__init__( additional_properties=resolve_arg_to_schema(items), - property_names=SchemaPattern(key_pattern) + property_names=(None if key_pattern is None else SchemaPattern(key_pattern)) ) def __call__( From d1c477ce7255a24441135a2d8fe540adfc01609d Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 21:56:34 +0100 Subject: [PATCH 4/6] Document regex functionality --- docs/source/user_docs.rst | 38 ++++++++++++++++++++++++++++++ docs/source/wysdom.base_schema.rst | 16 +++++++++++++ 2 files changed, 54 insertions(+) diff --git a/docs/source/user_docs.rst b/docs/source/user_docs.rst index 39e76f2..06ec1d5 100644 --- a/docs/source/user_docs.rst +++ b/docs/source/user_docs.rst @@ -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 ---------------- @@ -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 ============= diff --git a/docs/source/wysdom.base_schema.rst b/docs/source/wysdom.base_schema.rst index 354a9d8..35b278a 100644 --- a/docs/source/wysdom.base_schema.rst +++ b/docs/source/wysdom.base_schema.rst @@ -25,6 +25,14 @@ SchemaConst :undoc-members: :show-inheritance: +SchemaEnum +------------------------------------- + +.. autoclass:: wysdom.SchemaEnum + :members: + :undoc-members: + :show-inheritance: + SchemaNone ------------------------------------- @@ -33,6 +41,14 @@ SchemaNone :undoc-members: :show-inheritance: +SchemaPattern +------------------------------------------ + +.. autoclass:: wysdom.SchemaPattern + :members: + :undoc-members: + :show-inheritance: + SchemaPrimitive ------------------------------------------ From 5591d696836037fcc10b433cf6a03ba2f17b629d Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 22:17:01 +0100 Subject: [PATCH 5/6] Fix error message testing --- features/steps/steps.py | 10 +++++----- wysdom/mixins/RegistersSubclasses.py | 6 ++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/features/steps/steps.py b/features/steps/steps.py index 0a82778..19cba35 100644 --- a/features/steps/steps.py +++ b/features/steps/steps.py @@ -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))} """ ) diff --git a/wysdom/mixins/RegistersSubclasses.py b/wysdom/mixins/RegistersSubclasses.py index 7dbf2cb..c916c98 100644 --- a/wysdom/mixins/RegistersSubclasses.py +++ b/wysdom/mixins/RegistersSubclasses.py @@ -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 From de64dc6ced2bed3ae18752d6a7051c4bc4c75b53 Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Fri, 12 Mar 2021 22:17:32 +0100 Subject: [PATCH 6/6] Test for TypeError if pattern is used with a property_type that is not str --- features/dict.feature | 7 +++++++ features/examples/modules/invalid_pattern_not_str.py | 6 ++++++ 2 files changed, 13 insertions(+) create mode 100644 features/examples/modules/invalid_pattern_not_str.py diff --git a/features/dict.feature b/features/dict.feature index b3749a2..4d116e0 100644 --- a/features/dict.feature +++ b/features/dict.feature @@ -290,3 +290,10 @@ Feature: Test dictionary DOM objects """ + 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. + """ diff --git a/features/examples/modules/invalid_pattern_not_str.py b/features/examples/modules/invalid_pattern_not_str.py new file mode 100644 index 0000000..b7af262 --- /dev/null +++ b/features/examples/modules/invalid_pattern_not_str.py @@ -0,0 +1,6 @@ +from wysdom import UserObject, UserProperty + + +class Address(UserObject): + zip_code: int = UserProperty(int, pattern=r"^[0-9]{5}$") +