From 79b92fa7d94f80d39745d1393f0b21cfa0c38231 Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Sat, 16 Jan 2021 23:48:39 +0100 Subject: [PATCH] Defer evaluation of a UserObject schema entirely to resolve #55 --- .../examples/modules/late_subclass_module.py | 41 +++++++++ features/subclass.feature | 28 +++--- wysdom/mixins/RegistersSubclasses.py | 12 +++ wysdom/mixins/__init__.py | 2 +- wysdom/object_schema/SchemaAnyOf.py | 8 +- wysdom/user_objects/UserObject.py | 86 +++++++++++-------- 6 files changed, 125 insertions(+), 52 deletions(-) create mode 100644 features/examples/modules/late_subclass_module.py diff --git a/features/examples/modules/late_subclass_module.py b/features/examples/modules/late_subclass_module.py new file mode 100644 index 0000000..d2b586e --- /dev/null +++ b/features/examples/modules/late_subclass_module.py @@ -0,0 +1,41 @@ +from typing import List + +from abc import ABC, abstractmethod + +from wysdom import UserObject, UserProperty, SchemaArray, SchemaConst +from wysdom.mixins import RegistersSubclasses + + +class Pet(UserObject, RegistersSubclasses, ABC): + pet_type: str = UserProperty(str) + name: str = UserProperty(str) + + @abstractmethod + def speak(self): + pass + + +class Person(UserObject): + first_name: str = UserProperty(str) + last_name: str = UserProperty(str) + pets: List[Pet] = UserProperty(SchemaArray(Pet)) + + +class Dog(Pet): + + def speak(self): + return f"{self.name} says Woof!" + + +class Greyhound(Dog): + pet_type: str = UserProperty(SchemaConst("greyhound")) + + def speak(self): + return f"{self.name}, the greyhound, says Woof!" + + +class Cat(Pet): + pet_type: str = UserProperty(SchemaConst("cat")) + + def speak(self): + return f"{self.name} says Miaow!" diff --git a/features/subclass.feature b/features/subclass.feature index 2baf7f5..4566924 100644 --- a/features/subclass.feature +++ b/features/subclass.feature @@ -1,8 +1,8 @@ Feature: Test subclassed DOM objects - Scenario: Test good input string + Scenario Outline: Test good input string - Given the Python module subclass_module.py + Given the Python module .py When we execute the following python code: """ example_dict_input = { @@ -19,12 +19,12 @@ Feature: Test subclassed DOM objects } ] } - example = subclass_module.Person(example_dict_input) + example = .Person(example_dict_input) expected_schema = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/subclass_module.Person", + "$ref": "#/definitions/.Person", "definitions": { - "subclass_module.Greyhound": { + ".Greyhound": { "type": "object", "properties": { "pet_type": {"const": "greyhound"}, @@ -33,7 +33,7 @@ Feature: Test subclassed DOM objects "required": ["name", "pet_type"], "additionalProperties": False }, - "subclass_module.Cat": { + ".Cat": { "type": "object", "properties": { "pet_type": {"const": "cat"}, @@ -42,20 +42,20 @@ Feature: Test subclassed DOM objects "required": ["name", "pet_type"], "additionalProperties": False }, - "subclass_module.Pet": { + ".Pet": { "anyOf": [ - {"$ref": "#/definitions/subclass_module.Greyhound"}, - {"$ref": "#/definitions/subclass_module.Cat"} + {"$ref": "#/definitions/.Greyhound"}, + {"$ref": "#/definitions/.Cat"} ] }, - "subclass_module.Person": { + ".Person": { "type": "object", "properties": { "first_name": {"type": "string"}, "last_name": {"type": "string"}, "pets": { "array": { - "items": {"$ref": "#/definitions/subclass_module.Pet"} + "items": {"$ref": "#/definitions/.Pet"} } } }, @@ -81,3 +81,9 @@ Feature: Test subclassed DOM objects copy.copy(example).to_builtin() == example_dict_input copy.deepcopy(example).to_builtin() == example_dict_input """ + + Examples: + | module | + | subclass_module | + | late_subclass_module | + diff --git a/wysdom/mixins/RegistersSubclasses.py b/wysdom/mixins/RegistersSubclasses.py index 83be767..7dbf2cb 100644 --- a/wysdom/mixins/RegistersSubclasses.py +++ b/wysdom/mixins/RegistersSubclasses.py @@ -125,3 +125,15 @@ def registered_subclass_instance( RegisteredSubclassList = List[Type[RegistersSubclasses]] + + +def has_registered_subclasses(cls: type) -> bool: + """ + Tests whether any class is a subclass of RegistersSubclasses and whether + it has any registered subclasses. If either is false, returns false. + """ + has_subclasses = False + if issubclass(cls, RegistersSubclasses): + if cls.registered_subclasses(): + has_subclasses = True + return has_subclasses diff --git a/wysdom/mixins/__init__.py b/wysdom/mixins/__init__.py index 7491795..9a6e60f 100644 --- a/wysdom/mixins/__init__.py +++ b/wysdom/mixins/__init__.py @@ -1,3 +1,3 @@ from .ReadsJSON import ReadsJSON from .ReadsYAML import ReadsYAML -from .RegistersSubclasses import RegistersSubclasses +from .RegistersSubclasses import RegistersSubclasses, has_registered_subclasses diff --git a/wysdom/object_schema/SchemaAnyOf.py b/wysdom/object_schema/SchemaAnyOf.py index 959c20b..0338363 100644 --- a/wysdom/object_schema/SchemaAnyOf.py +++ b/wysdom/object_schema/SchemaAnyOf.py @@ -15,7 +15,7 @@ class SchemaAnyOf(Schema): is referred to by other schemas. """ - _allowed_schemas: Tuple[Schema] = None + allowed_schemas: Tuple[Schema] = None schema_ref_name: Optional[str] = None def __init__( @@ -23,7 +23,7 @@ def __init__( allowed_schemas: Iterable[Schema], schema_ref_name: Optional[str] = None ) -> None: - self._allowed_schemas = tuple(allowed_schemas) + self.allowed_schemas = tuple(allowed_schemas) self.schema_ref_name = schema_ref_name def __call__( @@ -47,10 +47,6 @@ def __call__( ) return valid_schemas[0](value, dom_info) - @property - def allowed_schemas(self) -> Tuple[Schema]: - return self._allowed_schemas - @property def referenced_schemas(self) -> Dict[str, Schema]: referenced_schemas = {} diff --git a/wysdom/user_objects/UserObject.py b/wysdom/user_objects/UserObject.py index 0780f7c..e12416b 100644 --- a/wysdom/user_objects/UserObject.py +++ b/wysdom/user_objects/UserObject.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import Any, Type, Iterator, Union, Mapping, List +from typing import Any, Optional, Type, Iterator, Union, Mapping, Dict import inspect -from ..mixins import RegistersSubclasses +from ..mixins import RegistersSubclasses, has_registered_subclasses from ..base_schema import Schema from ..object_schema import SchemaObject, SchemaAnyOf from ..dom import ( @@ -57,12 +57,16 @@ def _schema_superclasses(self) -> Iterator[Type[UserObject]]: yield superclass -class SchemaAnyRegisteredSubclass(SchemaAnyOf): +class UserObjectSchema(Schema): """ - A SchemaAnyOf that allows any registered subclass of a UserObject. - Exists so that allowed_schemas can be populated dynamically and is not fixed - based on the object's current subclasses, but will also include any - future subclasses that are defined after this object's creation. + A Schema for a given UserObject, which may behave like a SchemaAnyOf if + the UserObject has registered subclasses, or a SchemaObject if it + does not. + + The exact behavior (determined by UserObjectSchema.inner_schema) is + determined dynamically to ensure that the schema will include any + future registered subclasses that are defined after the UserObject's + creation. :param object_type: The UserObject subclass. """ @@ -71,22 +75,49 @@ def __init__( self, object_type: Type[UserObject] ): - super().__init__( - allowed_schemas=[], - schema_ref_name=f"{object_type.__module__}.{object_type.__name__}" - ) - assert issubclass(object_type, RegistersSubclasses) self.object_type = object_type @property - def allowed_schemas(self) -> List[Schema]: - return [ - subclass.__json_schema__() - for subclass_list in self.object_type.registered_subclasses().values() - for subclass in subclass_list - if issubclass(subclass, UserObject) - and not isinstance(subclass.__json_schema__(), SchemaAnyOf) - ] + def inner_schema(self) -> Schema: + if has_registered_subclasses(self.object_type): + assert issubclass(self.object_type, RegistersSubclasses) + return SchemaAnyOf( + allowed_schemas=[ + subclass.__json_schema__() + for subclass_list in self.object_type.registered_subclasses().values() + for subclass in subclass_list + if issubclass(subclass, UserObject) + and not has_registered_subclasses(subclass) + ], + schema_ref_name=f"{self.object_type.__module__}.{self.object_type.__name__}" + ) + else: + return SchemaObject( + properties=self.object_type.__json_schema_properties__.properties, + required=self.object_type.__json_schema_properties__.required, + additional_properties=self.object_type.__json_schema_properties__.additional_properties, + object_type=self.object_type, + schema_ref_name=f"{self.object_type.__module__}.{self.object_type.__name__}" + ) + + def __call__( + self, + value: Any, + dom_info: DOMInfo = None + ) -> Any: + return self.inner_schema.__call__(value, dom_info) + + @property + def referenced_schemas(self) -> Dict[str, Schema]: + return self.inner_schema.referenced_schemas + + @property + def jsonschema_definition(self) -> Dict[str, Any]: + return self.inner_schema.jsonschema_definition + + @property + def schema_ref_name(self) -> Optional[str]: + return self.inner_schema.schema_ref_name class UserObject(DOMObject): @@ -137,19 +168,6 @@ def __init__( @classmethod def __json_schema__(cls) -> Schema: - has_subclasses = False - if issubclass(cls, RegistersSubclasses): - if cls.registered_subclasses(): - has_subclasses = True - if has_subclasses: - return SchemaAnyRegisteredSubclass(cls) - else: - return SchemaObject( - properties=cls.__json_schema_properties__.properties, - required=cls.__json_schema_properties__.required, - additional_properties=cls.__json_schema_properties__.additional_properties, - object_type=cls, - schema_ref_name=f"{cls.__module__}.{cls.__name__}" - ) + return UserObjectSchema(cls)