Skip to content

Commit 8ec5fd0

Browse files
committed
ch25: added checkeddeco example
1 parent 177d914 commit 8ec5fd0

File tree

4 files changed

+242
-10
lines changed

4 files changed

+242
-10
lines changed

25-class-metaprog/checked/checkedlib.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,18 @@ def __init_subclass__(subclass) -> None: # <2>
104104
setattr(subclass, name, Field(name, constructor)) # <5>
105105

106106
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>
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) # <10>
112+
113+
def __setattr__(self, name: str, value: Any) -> None: # <11>
114+
if name in self._fields(): # <12>
115115
cls = self.__class__
116116
descriptor = getattr(cls, name)
117-
descriptor.__set__(self, value) # <12>
118-
else: # <13>
117+
descriptor.__set__(self, value) # <13>
118+
else: # <14>
119119
self.__flag_unknown_attrs(name)
120120

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

0 commit comments

Comments
 (0)