|
| 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 | + >>> class Movie(Checked): # <1> |
| 8 | + ... title: str # <2> |
| 9 | + ... year: int |
| 10 | + ... megabucks: float |
| 11 | + ... |
| 12 | + >>> movie = Movie(title='The Godfather', year=1972, megabucks=137) # <3> |
| 13 | + >>> movie.title |
| 14 | + 'The Godfather' |
| 15 | + >>> movie # <4> |
| 16 | + Movie(title='The Godfather', year=1972, megabucks=137.0) |
| 17 | +
|
| 18 | +# end::MOVIE_DEFINITION[] |
| 19 | +
|
| 20 | +The type of arguments is runtime checked when an attribute is set, |
| 21 | +including during instantiation:: |
| 22 | +
|
| 23 | +# tag::MOVIE_TYPE_VALIDATION[] |
| 24 | +
|
| 25 | + >>> movie.year = 'MCMLXXII' # <1> |
| 26 | + Traceback (most recent call last): |
| 27 | + ... |
| 28 | + TypeError: 'MCMLXXII' is not compatible with year:int |
| 29 | + >>> blockbuster = Movie(title='Avatar', year=2009, megabucks='billions') # <2> |
| 30 | + Traceback (most recent call last): |
| 31 | + ... |
| 32 | + TypeError: 'billions' is not compatible with megabucks:float |
| 33 | +
|
| 34 | +# end::MOVIE_TYPE_VALIDATION[] |
| 35 | +
|
| 36 | +Attributes not passed as arguments to the constructor are initialized with |
| 37 | +default values:: |
| 38 | +
|
| 39 | +# tag::MOVIE_DEFAULTS[] |
| 40 | +
|
| 41 | + >>> Movie(title='Life of Brian') |
| 42 | + Movie(title='Life of Brian', year=0, megabucks=0.0) |
| 43 | +
|
| 44 | +# end::MOVIE_DEFAULTS[] |
| 45 | +
|
| 46 | +Providing extra arguments to the constructor is not allowed:: |
| 47 | +
|
| 48 | + >>> blockbuster = Movie(title='Avatar', year=2009, megabucks=2000, |
| 49 | + ... director='James Cameron') |
| 50 | + Traceback (most recent call last): |
| 51 | + ... |
| 52 | + AttributeError: 'Movie' has no attribute 'director' |
| 53 | +
|
| 54 | +Creating new attributes at runtime is restricted as well:: |
| 55 | +
|
| 56 | + >>> movie.director = 'Francis Ford Coppola' |
| 57 | + Traceback (most recent call last): |
| 58 | + ... |
| 59 | + AttributeError: 'Movie' has no attribute 'director' |
| 60 | +
|
| 61 | +The `_as_dict` instance creates a `dict` from the attributes of a `Movie` object:: |
| 62 | +
|
| 63 | + >>> movie._asdict() |
| 64 | + {'title': 'The Godfather', 'year': 1972, 'megabucks': 137.0} |
| 65 | +
|
| 66 | +""" |
| 67 | + |
| 68 | +# tag::CHECKED_FIELD[] |
| 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: 'Checked', 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 = f'{value!r} is not compatible with {self.name}:{type_name}' |
| 89 | + raise TypeError(msg) from e |
| 90 | + instance.__dict__[self.name] = value # <7> |
| 91 | + |
| 92 | + |
| 93 | +# end::CHECKED_FIELD[] |
| 94 | + |
| 95 | +# tag::CHECKED_TOP[] |
| 96 | +class Checked: |
| 97 | + @classmethod |
| 98 | + def _fields(cls) -> dict[str, type]: # <1> |
| 99 | + return get_type_hints(cls) |
| 100 | + |
| 101 | + def __init_subclass__(subclass) -> None: # <2> |
| 102 | + super().__init_subclass__() # <3> |
| 103 | + for name, constructor in subclass._fields().items(): # <4> |
| 104 | + setattr(subclass, name, Field(name, constructor)) # <5> |
| 105 | + |
| 106 | + def __init__(self, **kwargs: Any) -> None: |
| 107 | + for name in self._fields(): # <6> |
| 108 | + value = kwargs.pop(name, MISSING) # <7> |
| 109 | + setattr(self, name, value) # <8> |
| 110 | + if kwargs: # <9> |
| 111 | + self.__flag_unknown_attrs(*kwargs) |
| 112 | + |
| 113 | + def __setattr__(self, name: str, value: Any) -> None: # <10> |
| 114 | + if name in self._fields(): # <11> |
| 115 | + cls = self.__class__ |
| 116 | + descriptor = getattr(cls, name) |
| 117 | + descriptor.__set__(self, value) # <12> |
| 118 | + else: # <13> |
| 119 | + self.__flag_unknown_attrs(name) |
| 120 | + |
| 121 | + # end::CHECKED_TOP[] |
| 122 | + |
| 123 | + # tag::CHECKED_BOTTOM[] |
| 124 | + def __flag_unknown_attrs(self, *names: str) -> NoReturn: # <1> |
| 125 | + plural = 's' if len(names) > 1 else '' |
| 126 | + extra = ', '.join(f'{name!r}' for name in names) |
| 127 | + cls_name = repr(self.__class__.__name__) |
| 128 | + raise AttributeError(f'{cls_name} has no attribute{plural} {extra}') |
| 129 | + |
| 130 | + def _asdict(self) -> dict[str, Any]: # <2> |
| 131 | + return { |
| 132 | + name: getattr(self, name) |
| 133 | + for name, attr in self.__class__.__dict__.items() |
| 134 | + if isinstance(attr, Field) |
| 135 | + } |
| 136 | + |
| 137 | + def __repr__(self) -> str: # <3> |
| 138 | + kwargs = ', '.join( |
| 139 | + f'{key}={value!r}' for key, value in self._asdict().items() |
| 140 | + ) |
| 141 | + return f'{self.__class__.__name__}({kwargs})' |
| 142 | + |
| 143 | +# end::CHECKED_BOTTOM[] |
0 commit comments