|
| 1 | +""" |
| 2 | +A ``Checked`` subclass definition requires that keyword arguments are |
| 3 | +used to create an instance, and provides a nice ``__repr__``:: |
| 4 | +
|
| 5 | +# tag::MOVIE_DEFINITION[] |
| 6 | +
|
| 7 | + >>> @checked |
| 8 | + ... class Movie: |
| 9 | + ... title: str |
| 10 | + ... year: int |
| 11 | + ... megabucks: float |
| 12 | + ... |
| 13 | + >>> movie = Movie(title='The Godfather', year=1972, megabucks=137) # <3> |
| 14 | + >>> movie.title |
| 15 | + 'The Godfather' |
| 16 | + >>> movie # <4> |
| 17 | + Movie(title='The Godfather', year=1972, megabucks=137.0) |
| 18 | +
|
| 19 | +# end::MOVIE_DEFINITION[] |
| 20 | +
|
| 21 | +The type of arguments is runtime checked when an attribute is set, |
| 22 | +including during instantiation:: |
| 23 | +
|
| 24 | +# tag::MOVIE_TYPE_VALIDATION[] |
| 25 | +
|
| 26 | + >>> movie.year = 'MCMLXXII' # <1> |
| 27 | + Traceback (most recent call last): |
| 28 | + ... |
| 29 | + TypeError: 'MCMLXXII' is not compatible with year:int |
| 30 | + >>> blockbuster = Movie(title='Avatar', year=2009, megabucks='billions') # <2> |
| 31 | + Traceback (most recent call last): |
| 32 | + ... |
| 33 | + TypeError: 'billions' is not compatible with megabucks:float |
| 34 | +
|
| 35 | +# end::MOVIE_TYPE_VALIDATION[] |
| 36 | +
|
| 37 | +Attributes not passed as arguments to the constructor are initialized with |
| 38 | +default values:: |
| 39 | +
|
| 40 | +# tag::MOVIE_DEFAULTS[] |
| 41 | +
|
| 42 | + >>> Movie(title='Life of Brian') |
| 43 | + Movie(title='Life of Brian', year=0, megabucks=0.0) |
| 44 | +
|
| 45 | +# end::MOVIE_DEFAULTS[] |
| 46 | +
|
| 47 | +Providing extra arguments to the constructor is not allowed:: |
| 48 | +
|
| 49 | + >>> blockbuster = Movie(title='Avatar', year=2009, megabucks=2000, |
| 50 | + ... director='James Cameron') |
| 51 | + Traceback (most recent call last): |
| 52 | + ... |
| 53 | + AttributeError: 'Movie' has no attribute 'director' |
| 54 | +
|
| 55 | +Creating new attributes at runtime is restricted as well:: |
| 56 | +
|
| 57 | + >>> movie.director = 'Francis Ford Coppola' |
| 58 | + Traceback (most recent call last): |
| 59 | + ... |
| 60 | + AttributeError: 'Movie' has no attribute 'director' |
| 61 | +
|
| 62 | +The `_as_dict` instance creates a `dict` from the attributes of a `Movie` object:: |
| 63 | +
|
| 64 | + >>> movie._asdict() |
| 65 | + {'title': 'The Godfather', 'year': 1972, 'megabucks': 137.0} |
| 66 | +
|
| 67 | +""" |
| 68 | + |
| 69 | +from collections.abc import Callable # <1> |
| 70 | +from typing import Any, NoReturn, get_type_hints |
| 71 | + |
| 72 | +MISSING = object() # <2> |
| 73 | + |
| 74 | + |
| 75 | +class Field: |
| 76 | + def __init__(self, name: str, constructor: Callable) -> None: # <3> |
| 77 | + self.name = name |
| 78 | + self.constructor = constructor |
| 79 | + |
| 80 | + def __set__(self, instance: Any, value: Any) -> None: # <4> |
| 81 | + if value is MISSING: # <5> |
| 82 | + value = self.constructor() |
| 83 | + else: |
| 84 | + try: |
| 85 | + value = self.constructor(value) # <6> |
| 86 | + except (TypeError, ValueError) as e: |
| 87 | + type_name = self.constructor.__name__ |
| 88 | + msg = ( |
| 89 | + f'{value!r} is not compatible with {self.name}:{type_name}' |
| 90 | + ) |
| 91 | + raise TypeError(msg) from e |
| 92 | + instance.__dict__[self.name] = value # <7> |
| 93 | + |
| 94 | + |
| 95 | +# tag::CHECKED_DECORATOR_TOP[] |
| 96 | +_methods_to_inject: list[Callable] = [] |
| 97 | +_classmethods_to_inject: list[Callable] = [] |
| 98 | + |
| 99 | +def checked(cls: type) -> type: # <2> |
| 100 | + for func in _methods_to_inject: |
| 101 | + name = func.__name__ |
| 102 | + setattr(cls, name, func) # <5> |
| 103 | + |
| 104 | + for func in _classmethods_to_inject: |
| 105 | + name = func.__name__ |
| 106 | + setattr(cls, name, classmethod(func)) # <5> |
| 107 | + |
| 108 | + for name, constructor in _fields(cls).items(): # <4> |
| 109 | + setattr(cls, name, Field(name, constructor)) # <5> |
| 110 | + |
| 111 | + return cls |
| 112 | + |
| 113 | + |
| 114 | +def _method(func: Callable) -> Callable: |
| 115 | + _methods_to_inject.append(func) |
| 116 | + return func |
| 117 | + |
| 118 | + |
| 119 | +def _classmethod(func: Callable) -> Callable: |
| 120 | + _classmethods_to_inject.append(func) |
| 121 | + return func |
| 122 | + |
| 123 | +# tag::CHECKED_METHODS_TOP[] |
| 124 | +@_classmethod |
| 125 | +def _fields(cls: type) -> dict[str, type]: # <1> |
| 126 | + return get_type_hints(cls) |
| 127 | + |
| 128 | +@_method |
| 129 | +def __init__(self: Any, **kwargs: Any) -> None: |
| 130 | + for name in self._fields(): # <6> |
| 131 | + value = kwargs.pop(name, MISSING) # <7> |
| 132 | + setattr(self, name, value) # <8> |
| 133 | + if kwargs: # <9> |
| 134 | + self.__flag_unknown_attrs(*kwargs) # <10> |
| 135 | + |
| 136 | +@_method |
| 137 | +def __setattr__(self: Any, name: str, value: Any) -> None: # <11> |
| 138 | + if name in self._fields(): # <12> |
| 139 | + cls = self.__class__ |
| 140 | + descriptor = getattr(cls, name) |
| 141 | + descriptor.__set__(self, value) # <13> |
| 142 | + else: # <14> |
| 143 | + self.__flag_unknown_attrs(name) |
| 144 | +# end::CHECKED_METHODS_TOP[] |
| 145 | + |
| 146 | +# tag::CHECKED_METHODS_BOTTOM[] |
| 147 | +@_method |
| 148 | +def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn: # <1> |
| 149 | + plural = 's' if len(names) > 1 else '' |
| 150 | + extra = ', '.join(f'{name!r}' for name in names) |
| 151 | + cls_name = repr(self.__class__.__name__) |
| 152 | + raise AttributeError(f'{cls_name} has no attribute{plural} {extra}') |
| 153 | + |
| 154 | + |
| 155 | +@_method |
| 156 | +def _asdict(self: Any) -> dict[str, Any]: # <2> |
| 157 | + return { |
| 158 | + name: getattr(self, name) |
| 159 | + for name, attr in self.__class__.__dict__.items() |
| 160 | + if isinstance(attr, Field) |
| 161 | + } |
| 162 | + |
| 163 | + |
| 164 | +@_method |
| 165 | +def __repr__(self: Any) -> str: # <3> |
| 166 | + kwargs = ', '.join( |
| 167 | + f'{key}={value!r}' for key, value in self._asdict().items() |
| 168 | + ) |
| 169 | + return f'{self.__class__.__name__}({kwargs})' |
| 170 | +# end::CHECKED_METHODS_BOTTOM[] |
0 commit comments