From 568a47fa9bf5a0d8172346a6c113735a6bf5c568 Mon Sep 17 00:00:00 2001 From: Joe Taylor Date: Sun, 27 Dec 2020 13:19:09 +0100 Subject: [PATCH] Allow multiple subclasses of RegistersSubclasses to have the same register_as value if they have different inheritance paths --- features/registered_subclasses.feature | 237 +++++++++++++++++++++++++ features/steps/steps.py | 39 +++- wysdom/__version__.py | 2 +- wysdom/mixins/RegistersSubclasses.py | 92 +++++++--- wysdom/user_objects/UserObject.py | 3 +- 5 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 features/registered_subclasses.feature diff --git a/features/registered_subclasses.feature b/features/registered_subclasses.feature new file mode 100644 index 0000000..86fca9c --- /dev/null +++ b/features/registered_subclasses.feature @@ -0,0 +1,237 @@ +Feature: Generic mixin for registering subclasses by name + + Scenario: Test basic usage + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def __init__(self, name): + self.name = name + + def speak(self): + raise NotImplementedError() + + class Dog(Animal, register_as="dog"): + + def speak(self): + return f"{self.name} says Woof!" + + class Cat(Animal, register_as="cat"): + + def speak(self): + return f"{self.name} says Miaow!" + """ + Then the following statements are true: + """ + Animal.registered_subclass("dog") is Dog + Animal.registered_subclass("cat") is Cat + type(Animal.registered_subclass_instance("dog", "Spot")) is Dog + type(Animal.registered_subclass_instance("cat", "Whiskers")) is Cat + Animal.registered_subclass_instance("dog", "Spot").speak() == "Spot says Woof!" + Animal.registered_subclass_instance("cat", "Whiskers").speak() == "Whiskers says Miaow!" + """ + + Scenario: Allow more distant descendants to be registered + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def speak(self): + raise NotImplementedError() + + class Pet(Animal): + + def __init__(self, name): + self.name = name + + class Dog(Pet, register_as="dog"): + + def speak(self): + return f"{self.name} says Woof!" + + class Cat(Pet, register_as="cat"): + + def speak(self): + return f"{self.name} says Miaow!" + """ + Then the following statements are true: + """ + Animal.registered_subclass("dog") is Dog + Animal.registered_subclass("cat") is Cat + Pet.registered_subclass("dog") is Dog + Pet.registered_subclass("cat") is Cat + type(Pet.registered_subclass_instance("dog", "Spot")) is Dog + type(Pet.registered_subclass_instance("cat", "Whiskers")) is Cat + Pet.registered_subclass_instance("dog", "Spot").speak() == "Spot says Woof!" + Pet.registered_subclass_instance("cat", "Whiskers").speak() == "Whiskers says Miaow!" + """ + + Scenario: Raise KeyError when a class key is not defined + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def __init__(self, name): + self.name = name + """ + And we try to execute the following python code: + """ + Animal.registered_subclass("elephant") + """ + Then a KeyError is raised with text: + """ + The key 'elephant' matches no proper subclasses of . + """ + + Scenario: Raise KeyError when a class key is only defined on a superclass + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def speak(self): + raise NotImplementedError() + + class Pet(Animal): + + def __init__(self, name): + self.name = name + + class Dog(Pet, register_as="dog"): + + def speak(self): + return f"{self.name} says Woof!" + + class Cat(Animal, register_as="cat"): + + def speak(self): + return f"Cat says Miaow!" + """ + And we try to execute the following python code: + """ + Pet.registered_subclass("cat") + """ + Then a KeyError is raised with text: + """ + The key 'cat' matches no proper subclasses of . + """ + + Scenario: Raise KeyError when a class key matches more than one subclass + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def __init__(self, name): + self.name = name + + def speak(self): + raise NotImplementedError() + + class Dog(Animal, register_as="animal"): + + def speak(self): + return f"{self.name} says Woof!" + + class Cat(Animal, register_as="animal"): + + def speak(self): + return f"{self.name} says Miaow!" + """ + And we try to execute the following python code: + """ + Animal.registered_subclass("animal") + """ + Then a KeyError is raised with text: + """ + The key 'animal' is ambiguous as it matches multiple proper subclasses of : + [, ] + """ + + Scenario: Allow non-unique register_as values for different inheritance paths + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def speak(self): + raise NotImplementedError() + + class Pet(Animal): + + def __init__(self, name): + self.name = name + + class Dog(Animal, register_as="dog"): + + def speak(self): + return f"The dog says Woof!" + + class PetDog(Pet, Dog, register_as="dog"): + + def speak(self): + return f"{self.name} says Woof!" + """ + + Then the following statements are true: + """ + Animal.registered_subclass("dog") is Dog + Pet.registered_subclass("dog") is PetDog + type(Animal.registered_subclass_instance("dog")) is Dog + type(Pet.registered_subclass_instance("dog", "Spot")) is PetDog + Animal.registered_subclass_instance("dog").speak() == "The dog says Woof!" + Pet.registered_subclass_instance("dog", "Spot").speak() == "Spot says Woof!" + """ + + + Scenario: Raise KeyError if return_common_superclass is False and class names are ambiguous + + When we execute the following python code: + """ + from wysdom.mixins import RegistersSubclasses + + class Animal(RegistersSubclasses): + + def speak(self): + raise NotImplementedError() + + class Pet(Animal): + + def __init__(self, name): + self.name = name + + class Dog(Animal, register_as="dog"): + + def speak(self): + return f"The dog says Woof!" + + class PetDog(Pet, Dog, register_as="dog"): + + def speak(self): + return f"{self.name} says Woof!" + """ + + And we try to execute the following python code: + """ + Animal.registered_subclass("dog", return_common_superclass=False) + """ + Then a KeyError is raised with text: + """ + The key 'dog' is ambiguous as it matches multiple proper subclasses of : + [, ] + """ \ No newline at end of file diff --git a/features/steps/steps.py b/features/steps/steps.py index 7b07be0..0a82778 100644 --- a/features/steps/steps.py +++ b/features/steps/steps.py @@ -28,22 +28,35 @@ def load_python_module(context, module): def step_impl(context, module): try: load_python_module(context, module) - context.load_python_module_error = None + context.exception = None except Exception as e: - context.load_python_module_error = e + context.exception = e @then("a {exception_type} is raised with text") def step_impl(context, exception_type): - if context.load_python_module_error is None: + if context.exception is None: raise Exception("No exception was raised.") - if exception_type != context.load_python_module_error.__class__.__name__: + if exception_type != context.exception.__class__.__name__: raise Exception( - f"Expected exception type {exception_type}, got {type(context.load_python_module_error)}." + f"Expected exception type {exception_type}, got {type(context.exception)}: " + + str(context.exception) ) - if context.text.strip() != str(context.load_python_module_error).strip(): + + def remove_whitespace(text): + return " ".join( + line.strip() + for line in context.text.splitlines() + ).strip() + + if remove_whitespace(context.text) != remove_whitespace(context.exception): raise Exception( - f"Expected error message '{context.text}', got '{context.load_python_module_error}'." + f""" + Expected error message: + {context.text} + Got: + {context.exception} + """ ) @@ -53,11 +66,21 @@ def step_impl(context, variable_name): @when("we execute the following python code") -def step_impl(context): +def execute_python(context): exec(context.text) globals().update(locals()) +@when("we try to execute the following python code") +def step_impl(context): + context.scenario.text = context.text + try: + execute_python(context) + context.exception = None + except Exception as e: + context.exception = e + + @then("the following statements are true") def step_impl(context): assert callable(document) diff --git a/wysdom/__version__.py b/wysdom/__version__.py index d9139ec..dfd69f9 100644 --- a/wysdom/__version__.py +++ b/wysdom/__version__.py @@ -1,3 +1,3 @@ -VERSION = (0, 1, 5) +VERSION = (0, 2, 0) __version__ = '.'.join(map(str, VERSION)) diff --git a/wysdom/mixins/RegistersSubclasses.py b/wysdom/mixins/RegistersSubclasses.py index 69e99ce..0b13226 100644 --- a/wysdom/mixins/RegistersSubclasses.py +++ b/wysdom/mixins/RegistersSubclasses.py @@ -2,7 +2,7 @@ from abc import ABC -from typing import Type, Optional, Any, Dict +from typing import Type, Optional, Any, Dict, List class RegistersSubclasses(ABC): @@ -13,7 +13,7 @@ class RegistersSubclasses(ABC): """ registered_name = None - __registered_subclasses__: Optional[Dict[str, Type[RegistersSubclasses]]] = None + __registered_subclasses__: Optional[Dict[str, RegisteredSubclassList]] = None def __init_subclass__( cls, @@ -25,52 +25,87 @@ def __init_subclass__( if cls.__registered_subclasses__ is None and RegistersSubclasses in cls.__bases__: cls.__registered_subclasses__ = {} - key = register_as or f"{cls.__module__}.{cls.__name__}" - if not isinstance(key, str): + name = register_as or f"{cls.__module__}.{cls.__name__}" + if not isinstance(name, str): raise TypeError( f"Parameter register_as must be a string, not {type(register_as)}") - if ( - key in cls.__registered_subclasses__ - and cls.__registered_subclasses__[key] is not cls - ): - raise ValueError( - f""" - Cannot register {cls} as {key}: - Already used by {cls.__registered_subclasses__[key]}. - """) - cls.__registered_subclasses__[key] = cls - cls.registered_name = key + + cls.__registered_subclasses__.setdefault(name, []) + if cls not in cls.__registered_subclasses__[name]: + cls.__registered_subclasses__[name].append(cls) + cls.registered_name = name + + print((cls, cls.__registered_subclasses__), flush=True) def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @classmethod - def registered_subclasses(cls) -> Dict[str, Type[RegistersSubclasses]]: + def registered_subclasses(cls) -> Dict[str, RegisteredSubclassList]: """ Return all of the registered subclasses in this class's namespace. :return: A dictionary of subclasses, indexed by registered name. """ return { - name: subclass - for name, subclass in cls.__registered_subclasses__.items() - if issubclass(subclass, cls) - and subclass is not cls + name: cls.registered_subclasses_by_name(name) + for name in cls.__registered_subclasses__.keys() + if len(cls.registered_subclasses_by_name(name)) > 0 } @classmethod - def registered_subclass(cls, name: str) -> Type[RegistersSubclasses]: + def registered_subclasses_by_name(cls, name) -> RegisteredSubclassList: """ - Return a registered subclass by name. + Return all of the registered subclasses for a given name that are a + proper subclass of this class. :param name: The registered name of the subclass, as defined in `register_as` when the class was declared. - :return: A subclass of the base class for the namespace. + :return: A list of subclasses of the class from which this method was called. + """ + return [ + subclass + for subclass in cls.__registered_subclasses__.get(name, []) + if issubclass(subclass, cls) + and subclass is not cls + ] + + @classmethod + def registered_subclass( + cls, + name: str, + return_common_superclass: bool = True + ) -> Type[RegistersSubclasses]: + """ + Return a registered subclass by name. + + :param name: The registered name of the subclass, as defined in + `register_as` when the class was declared. + :param return_common_superclass: Disambiguate multiple valid subclasses by returning + the common superclass among them, if one exists. + Defaults to True. + :return: A subclass of the class from which this method was called. + :raises KeyError: If no matching subclass is found or if multiple ambiguous + subclasses are found. """ - if name not in cls.__registered_subclasses__: + matched_subclasses = cls.registered_subclasses_by_name(name) + if len(matched_subclasses) == 0: raise KeyError( - f"Unknown registered subclass key: {name}") - return cls.__registered_subclasses__[name] + f"The key '{name}' matches no proper subclasses of {cls}.") + elif len(matched_subclasses) > 1: + if return_common_superclass: + for matched_subclass in matched_subclasses: + if all( + issubclass(other_subclass, matched_subclass) + for other_subclass in matched_subclasses + ): + return matched_subclass + raise KeyError( + f""" + The key '{name}' is ambiguous as it matches multiple proper subclasses of {cls}: + {matched_subclasses} + """) + return matched_subclasses[0] @classmethod def registered_subclass_instance( @@ -86,6 +121,9 @@ def registered_subclass_instance( `register_as` when the class was declared. :param args: Positional arguments to pass to the subclass. :param kwargs: Keyword arguments to pass to the subclass. - :return: + :return: An instance of a subclass of the class from which this method was called. """ return cls.registered_subclass(name)(*args, **kwargs) + + +RegisteredSubclassList = List[Type[RegistersSubclasses]] diff --git a/wysdom/user_objects/UserObject.py b/wysdom/user_objects/UserObject.py index 31cde84..ae5eca9 100644 --- a/wysdom/user_objects/UserObject.py +++ b/wysdom/user_objects/UserObject.py @@ -113,7 +113,8 @@ def __json_schema__(cls) -> Schema: return SchemaAnyOf( ( subclass.__json_schema__() - for subclass in cls.registered_subclasses().values() + for subclass_list in cls.registered_subclasses().values() + for subclass in subclass_list if issubclass(subclass, UserObject) and not isinstance(subclass.__json_schema__(), SchemaAnyOf) ),