3

I have been trying to validate classes that users can create in a framework style setting. I can ensure that a class attribute is present in child classes in the following manner:

from abc import ABC, abstractmethod

class A(ABC):
    @property
    @classmethod
    @abstractmethod
    def s(self):
        raise NotImplementedError

class ClassFromA(A):
    pass


ClassFromA()

Which leads to the following Exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class ClassFromA with abstract methods s

I can also check the type of the class attribute s at class creation time with a decorator, like so:

from abc import ABC, abstractmethod

def validate_class_s(cls):
    if not isinstance(cls.s, int):
        raise ValueError("S NOT INT!!!")
    return cls

class A(ABC):
    @property
    @classmethod
    @abstractmethod
    def s(self):
        raise NotImplementedError

@validate_class_s
class ClassFromA(A):
    s = 'a string'

Resulting in:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 3, in validate_class_s
ValueError: S NOT INT!!!

Which is useful in the eventual checking of class attributes. But this leads to verbose class definitions where each of the child classes would have to be decorated.

Is there a way to validate the class attribute (s in the examples) in the base class? Preferably not in a too verbose way?

Guilherme Marthe
  • 1,104
  • 9
  • 18
  • Ideally at class definition time, just for cohesiveness sake. But if a solution is only viable at instantiation time I could adjust. – Guilherme Marthe Oct 03 '19 at 04:48
  • Nope, I'm trying to avoid code repetition (which is what I could do with my second solution) and ensure the type/structure of the class attribute (which I can't with my first solution). – Guilherme Marthe Oct 03 '19 at 12:32

2 Answers2

7

You can use the new in Python 3.6 __init_subclass__ feature.
This is a classmethod defined on your baseclass, that will be called once for each subclass that is created, at creation time. For most asserting usecases it can be more useful than Python's ABC which will only raise an error on class instantiation time (and, conversely, if you want to subclass other abstrateclasses before getting to a concrete class, you will have to check for that on your code).

So, for example, if you want to indicate the desired methods and attributes on the subclass by making annotations on your baseclass you can do:

_sentinel = type("_", (), {})

class Base:
    def __init_subclass__(cls, **kwargs):
        errors = []
        for attr_name, type_ in cls.__annotations__.items():
            if not isinstance(getattr(cls, attr_name, _sentinel), type_):
                errors.append((attr_name, type))
        if errors:
            raise TypeError(f"Class {cls.__name__} failed to initialize the following attributes: {errors}")
        super().__init_subclass__(**kwargs)

    s: int


class B(Base):
    pass

You can put collections.abc.Callable on the annotation for requiring methods, or a tuple like (type(None), int) for an optional integer, but isinstance unfortunatelly won't work with the versatile semantics provided by the "typing" module. If you want that, I suggest taking a look at the pydantic project and make use of it.

jsbueno
  • 99,910
  • 10
  • 151
  • 209
1

Another approach, with a configurable validator as a decorator you can use on several different subclasses and base classes, saving some verbosity. The base class declares the attributes using type annotation

def validate_with(baseclass):
    def validator(cls):
        for n, t in baseclass.__annotations__.items():
            if not isinstance(getattr(cls, n), t):
                raise ValueError(f"{n} is not of type {t}!!!")
        return cls
    return validator


class BaseClass:
    s: str
    i: int


@validate_with(BaseClass)
class SubClass(BaseClass):
    i = 3
    s = 'xyz'

It raises ValueError if the type doesn't match and AttributeError if the attribute is not present.

Of course you can collect the errors (as in the previous answer) and present them all in one go instead of stopping at the first error

Pynchia
  • 10,996
  • 5
  • 34
  • 43