Skip to content

Commit 177d914

Browse files
committed
ch25: new example with __init_subclas__
1 parent 4ff0a59 commit 177d914

File tree

3 files changed

+201
-0
lines changed

3 files changed

+201
-0
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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[]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from checkedlib import Checked
2+
3+
class Movie(Checked):
4+
title: str
5+
year: int
6+
megabucks: float
7+
8+
9+
if __name__ == '__main__':
10+
movie = Movie(title='The Godfather', year=1972, megabucks=137)
11+
print(movie.title)
12+
print(movie)
13+
try:
14+
# remove the "type: ignore" comment to see Mypy error
15+
movie.year = 'MCMLXXII' # type: ignore
16+
except TypeError as e:
17+
print(e)
18+
try:
19+
blockbuster = Movie(title='Avatar', year=2009, megabucks='billions')
20+
except TypeError as e:
21+
print(e)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
3+
4+
from checkedlib import Checked
5+
6+
7+
def test_field_descriptor_validation_type_error():
8+
class Cat(Checked):
9+
name: str
10+
weight: float
11+
12+
with pytest.raises(TypeError) as e:
13+
felix = Cat(name='Felix', weight=None)
14+
15+
assert str(e.value) == 'None is not compatible with weight:float'
16+
17+
18+
def test_field_descriptor_validation_value_error():
19+
class Cat(Checked):
20+
name: str
21+
weight: float
22+
23+
with pytest.raises(TypeError) as e:
24+
felix = Cat(name='Felix', weight='half stone')
25+
26+
assert str(e.value) == "'half stone' is not compatible with weight:float"
27+
28+
29+
def test_constructor_attribute_error():
30+
class Cat(Checked):
31+
name: str
32+
weight: float
33+
34+
with pytest.raises(AttributeError) as e:
35+
felix = Cat(name='Felix', weight=3.2, age=7)
36+
37+
assert str(e.value) == "'Cat' has no attribute 'age'"

0 commit comments

Comments
 (0)