renumbering chapters >= 19
This commit is contained in:
22
24-class-metaprog/autoconst/autoconst.py
Normal file
22
24-class-metaprog/autoconst/autoconst.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# tag::WilyDict[]
|
||||
class WilyDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__next_value = 0
|
||||
|
||||
def __missing__(self, key):
|
||||
if key.startswith('__') and key.endswith('__'):
|
||||
raise KeyError(key)
|
||||
self[key] = value = self.__next_value
|
||||
self.__next_value += 1
|
||||
return value
|
||||
# end::WilyDict[]
|
||||
|
||||
# tag::AUTOCONST[]
|
||||
class AutoConstMeta(type):
|
||||
def __prepare__(name, bases, **kwargs):
|
||||
return WilyDict()
|
||||
|
||||
class AutoConst(metaclass=AutoConstMeta):
|
||||
pass
|
||||
# end::AUTOCONST[]
|
||||
55
24-class-metaprog/autoconst/autoconst_demo.py
Executable file
55
24-class-metaprog/autoconst/autoconst_demo.py
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Testing ``WilyDict``::
|
||||
|
||||
>>> from autoconst import WilyDict
|
||||
>>> wd = WilyDict()
|
||||
>>> len(wd)
|
||||
0
|
||||
>>> wd['first']
|
||||
0
|
||||
>>> wd
|
||||
{'first': 0}
|
||||
>>> wd['second']
|
||||
1
|
||||
>>> wd['third']
|
||||
2
|
||||
>>> len(wd)
|
||||
3
|
||||
>>> wd
|
||||
{'first': 0, 'second': 1, 'third': 2}
|
||||
>>> wd['__magic__']
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
KeyError: '__magic__'
|
||||
|
||||
Testing ``AutoConst``::
|
||||
|
||||
>>> from autoconst import AutoConst
|
||||
|
||||
# tag::AUTOCONST[]
|
||||
>>> class Flavor(AutoConst):
|
||||
... banana
|
||||
... coconut
|
||||
... vanilla
|
||||
...
|
||||
>>> Flavor.vanilla
|
||||
2
|
||||
>>> Flavor.banana, Flavor.coconut
|
||||
(0, 1)
|
||||
|
||||
# end::AUTOCONST[]
|
||||
|
||||
"""
|
||||
|
||||
from autoconst import AutoConst
|
||||
|
||||
|
||||
class Flavor(AutoConst):
|
||||
banana
|
||||
coconut
|
||||
vanilla
|
||||
|
||||
|
||||
print('Flavor.vanilla ==', Flavor.vanilla)
|
||||
34
24-class-metaprog/bulkfood/README.md
Normal file
34
24-class-metaprog/bulkfood/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Legacy Class Descriptor and Metaclass Examples
|
||||
|
||||
Examples from _Fluent Python, First Edition_—Chapter 21, _Class Metaprogramming_,
|
||||
that are mentioned in _Fluent Python, Second Edition_—Chapter 25, _Class Metaprogramming_.
|
||||
|
||||
These examples were developed with Python 3.4.
|
||||
They run correctly in Python 3.9, but now it is easier to fullfill the same requirements
|
||||
without resorting to class decorators or metaclasses.
|
||||
|
||||
I have preserved them here as examples of class metaprogramming techniques
|
||||
that you may find in legacy code, and that can be refactored to simpler code
|
||||
using a base class with `__init_subclass__` and decorators implementing `__set_name__`.
|
||||
|
||||
## Suggested Exercise
|
||||
|
||||
If you'd like to practice the concepts presented in chapters 24 and 25 of
|
||||
_Fluent Python, Second Edition_,
|
||||
you may to refactor the most advanced example, `model_v8.py` with these changes:
|
||||
|
||||
1. Simplify the `AutoStorage` descriptor by implementing `__set_name__`.
|
||||
This will allow you to simplify the `EntityMeta` metaclass as well.
|
||||
|
||||
2. Rewrite the `Entity` class to use `__init_subclass__` instead of the `EntityMeta` metaclass—which you can then delete.
|
||||
|
||||
Nothing should change in the `bulkfood_v8.py` code, and its doctests should still pass.
|
||||
|
||||
To run the doctests while refactoring, it's often convenient to pass the `-f` option,
|
||||
to exit the test runner on the first failing test.
|
||||
|
||||
```
|
||||
$ python3 -m doctest -f bulkfood_v8.py
|
||||
```
|
||||
|
||||
Enjoy!
|
||||
84
24-class-metaprog/bulkfood/bulkfood_v6.py
Normal file
84
24-class-metaprog/bulkfood/bulkfood_v6.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
|
||||
A line item for a bulk food order has description, weight and price fields::
|
||||
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> raisins.weight, raisins.description, raisins.price
|
||||
(10, 'Golden raisins', 6.95)
|
||||
|
||||
A ``subtotal`` method gives the total price for that line item::
|
||||
|
||||
>>> raisins.subtotal()
|
||||
69.5
|
||||
|
||||
The weight of a ``LineItem`` must be greater than 0::
|
||||
|
||||
>>> raisins.weight = -20
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value must be > 0
|
||||
|
||||
No change was made::
|
||||
|
||||
>>> raisins.weight
|
||||
10
|
||||
|
||||
The value of the attributes managed by the descriptors are stored in
|
||||
alternate attributes, created by the descriptors in each ``LineItem``
|
||||
instance::
|
||||
|
||||
# tag::LINEITEM_V6_DEMO[]
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> dir(raisins)[:3]
|
||||
['_NonBlank#description', '_Quantity#price', '_Quantity#weight']
|
||||
>>> LineItem.description.storage_name
|
||||
'_NonBlank#description'
|
||||
>>> raisins.description
|
||||
'Golden raisins'
|
||||
>>> getattr(raisins, '_NonBlank#description')
|
||||
'Golden raisins'
|
||||
|
||||
# end::LINEITEM_V6_DEMO[]
|
||||
|
||||
If the descriptor is accessed in the class, the descriptor object is
|
||||
returned:
|
||||
|
||||
>>> LineItem.weight # doctest: +ELLIPSIS
|
||||
<model_v6.Quantity object at 0x...>
|
||||
>>> LineItem.weight.storage_name
|
||||
'_Quantity#weight'
|
||||
|
||||
|
||||
The `NonBlank` descriptor prevents empty or blank strings to be used
|
||||
for the description:
|
||||
|
||||
>>> br_nuts = LineItem('Brazil Nuts', 10, 34.95)
|
||||
>>> br_nuts.description = ' '
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
>>> void = LineItem('', 1, 1)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# tag::LINEITEM_V6[]
|
||||
import model_v6 as model
|
||||
|
||||
@model.entity # <1>
|
||||
class LineItem:
|
||||
description = model.NonBlank()
|
||||
weight = model.Quantity()
|
||||
price = model.Quantity()
|
||||
|
||||
def __init__(self, description, weight, price):
|
||||
self.description = description
|
||||
self.weight = weight
|
||||
self.price = price
|
||||
|
||||
def subtotal(self):
|
||||
return self.weight * self.price
|
||||
# end::LINEITEM_V6[]
|
||||
79
24-class-metaprog/bulkfood/bulkfood_v7.py
Normal file
79
24-class-metaprog/bulkfood/bulkfood_v7.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
|
||||
A line item for a bulk food order has description, weight and price fields::
|
||||
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> raisins.weight, raisins.description, raisins.price
|
||||
(10, 'Golden raisins', 6.95)
|
||||
|
||||
A ``subtotal`` method gives the total price for that line item::
|
||||
|
||||
>>> raisins.subtotal()
|
||||
69.5
|
||||
|
||||
The weight of a ``LineItem`` must be greater than 0::
|
||||
|
||||
>>> raisins.weight = -20
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value must be > 0
|
||||
|
||||
No change was made::
|
||||
|
||||
>>> raisins.weight
|
||||
10
|
||||
|
||||
The value of the attributes managed by the descriptors are stored in
|
||||
alternate attributes, created by the descriptors in each ``LineItem``
|
||||
instance::
|
||||
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> dir(raisins)[:3]
|
||||
['_NonBlank#description', '_Quantity#price', '_Quantity#weight']
|
||||
>>> LineItem.description.storage_name
|
||||
'_NonBlank#description'
|
||||
>>> raisins.description
|
||||
'Golden raisins'
|
||||
>>> getattr(raisins, '_NonBlank#description')
|
||||
'Golden raisins'
|
||||
|
||||
If the descriptor is accessed in the class, the descriptor object is
|
||||
returned:
|
||||
|
||||
>>> LineItem.weight # doctest: +ELLIPSIS
|
||||
<model_v7.Quantity object at 0x...>
|
||||
>>> LineItem.weight.storage_name
|
||||
'_Quantity#weight'
|
||||
|
||||
|
||||
The `NonBlank` descriptor prevents empty or blank strings to be used
|
||||
for the description:
|
||||
|
||||
>>> br_nuts = LineItem('Brazil Nuts', 10, 34.95)
|
||||
>>> br_nuts.description = ' '
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
>>> void = LineItem('', 1, 1)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
|
||||
"""
|
||||
|
||||
# tag::LINEITEM_V7[]
|
||||
import model_v7 as model
|
||||
|
||||
class LineItem(model.Entity): # <1>
|
||||
description = model.NonBlank()
|
||||
weight = model.Quantity()
|
||||
price = model.Quantity()
|
||||
|
||||
def __init__(self, description, weight, price):
|
||||
self.description = description
|
||||
self.weight = weight
|
||||
self.price = price
|
||||
|
||||
def subtotal(self):
|
||||
return self.weight * self.price
|
||||
# end::LINEITEM_V7[]
|
||||
86
24-class-metaprog/bulkfood/bulkfood_v8.py
Normal file
86
24-class-metaprog/bulkfood/bulkfood_v8.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
|
||||
A line item for a bulk food order has description, weight and price fields::
|
||||
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> raisins.weight, raisins.description, raisins.price
|
||||
(10, 'Golden raisins', 6.95)
|
||||
|
||||
A ``subtotal`` method gives the total price for that line item::
|
||||
|
||||
>>> raisins.subtotal()
|
||||
69.5
|
||||
|
||||
The weight of a ``LineItem`` must be greater than 0::
|
||||
|
||||
>>> raisins.weight = -20
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value must be > 0
|
||||
|
||||
No change was made::
|
||||
|
||||
>>> raisins.weight
|
||||
10
|
||||
|
||||
>>> raisins = LineItem('Golden raisins', 10, 6.95)
|
||||
>>> dir(raisins)[:3]
|
||||
['_NonBlank#description', '_Quantity#price', '_Quantity#weight']
|
||||
>>> LineItem.description.storage_name
|
||||
'_NonBlank#description'
|
||||
>>> raisins.description
|
||||
'Golden raisins'
|
||||
>>> getattr(raisins, '_NonBlank#description')
|
||||
'Golden raisins'
|
||||
|
||||
If the descriptor is accessed in the class, the descriptor object is
|
||||
returned:
|
||||
|
||||
>>> LineItem.weight # doctest: +ELLIPSIS
|
||||
<model_v8.Quantity object at 0x...>
|
||||
>>> LineItem.weight.storage_name
|
||||
'_Quantity#weight'
|
||||
|
||||
|
||||
The `NonBlank` descriptor prevents empty or blank strings to be used
|
||||
for the description:
|
||||
|
||||
>>> br_nuts = LineItem('Brazil Nuts', 10, 34.95)
|
||||
>>> br_nuts.description = ' '
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
>>> void = LineItem('', 1, 1)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: value cannot be empty or blank
|
||||
|
||||
|
||||
Fields can be retrieved in the order they were declared:
|
||||
|
||||
# tag::LINEITEM_V8_DEMO[]
|
||||
>>> for name in LineItem.field_names():
|
||||
... print(name)
|
||||
...
|
||||
description
|
||||
weight
|
||||
price
|
||||
|
||||
# end::LINEITEM_V8_DEMO[]
|
||||
|
||||
"""
|
||||
|
||||
import model_v8 as model
|
||||
|
||||
class LineItem(model.Entity):
|
||||
description = model.NonBlank()
|
||||
weight = model.Quantity()
|
||||
price = model.Quantity()
|
||||
|
||||
def __init__(self, description, weight, price):
|
||||
self.description = description
|
||||
self.weight = weight
|
||||
self.price = price
|
||||
|
||||
def subtotal(self):
|
||||
return self.weight * self.price
|
||||
60
24-class-metaprog/bulkfood/model_v6.py
Normal file
60
24-class-metaprog/bulkfood/model_v6.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import abc
|
||||
|
||||
|
||||
class AutoStorage:
|
||||
__counter = 0
|
||||
|
||||
def __init__(self):
|
||||
cls = self.__class__
|
||||
prefix = cls.__name__
|
||||
index = cls.__counter
|
||||
self.storage_name = '_{}#{}'.format(prefix, index)
|
||||
cls.__counter += 1
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None:
|
||||
return self
|
||||
else:
|
||||
return getattr(instance, self.storage_name)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
setattr(instance, self.storage_name, value)
|
||||
|
||||
|
||||
class Validated(abc.ABC, AutoStorage):
|
||||
|
||||
def __set__(self, instance, value):
|
||||
value = self.validate(instance, value)
|
||||
super().__set__(instance, value)
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate(self, instance, value):
|
||||
"""return validated value or raise ValueError"""
|
||||
|
||||
|
||||
class Quantity(Validated):
|
||||
"""a number greater than zero"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
if value <= 0:
|
||||
raise ValueError('value must be > 0')
|
||||
return value
|
||||
|
||||
|
||||
class NonBlank(Validated):
|
||||
"""a string with at least one non-space character"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
value = value.strip()
|
||||
if len(value) == 0:
|
||||
raise ValueError('value cannot be empty or blank')
|
||||
return value
|
||||
|
||||
# tag::MODEL_V6[]
|
||||
def entity(cls): # <1>
|
||||
for key, attr in cls.__dict__.items(): # <2>
|
||||
if isinstance(attr, Validated): # <3>
|
||||
type_name = type(attr).__name__
|
||||
attr.storage_name = '_{}#{}'.format(type_name, key) # <4>
|
||||
return cls # <5>
|
||||
# end::MODEL_V6[]
|
||||
66
24-class-metaprog/bulkfood/model_v7.py
Normal file
66
24-class-metaprog/bulkfood/model_v7.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import abc
|
||||
|
||||
|
||||
class AutoStorage:
|
||||
__counter = 0
|
||||
|
||||
def __init__(self):
|
||||
cls = self.__class__
|
||||
prefix = cls.__name__
|
||||
index = cls.__counter
|
||||
self.storage_name = '_{}#{}'.format(prefix, index)
|
||||
cls.__counter += 1
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None:
|
||||
return self
|
||||
else:
|
||||
return getattr(instance, self.storage_name)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
setattr(instance, self.storage_name, value)
|
||||
|
||||
|
||||
class Validated(abc.ABC, AutoStorage):
|
||||
|
||||
def __set__(self, instance, value):
|
||||
value = self.validate(instance, value)
|
||||
super().__set__(instance, value)
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate(self, instance, value):
|
||||
"""return validated value or raise ValueError"""
|
||||
|
||||
|
||||
class Quantity(Validated):
|
||||
"""a number greater than zero"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
if value <= 0:
|
||||
raise ValueError('value must be > 0')
|
||||
return value
|
||||
|
||||
|
||||
class NonBlank(Validated):
|
||||
"""a string with at least one non-space character"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
value = value.strip()
|
||||
if len(value) == 0:
|
||||
raise ValueError('value cannot be empty or blank')
|
||||
return value
|
||||
|
||||
# tag::MODEL_V7[]
|
||||
class EntityMeta(type):
|
||||
"""Metaclass for business entities with validated fields"""
|
||||
|
||||
def __init__(cls, name, bases, attr_dict):
|
||||
super().__init__(name, bases, attr_dict) # <1>
|
||||
for key, attr in attr_dict.items(): # <2>
|
||||
if isinstance(attr, Validated):
|
||||
type_name = type(attr).__name__
|
||||
attr.storage_name = '_{}#{}'.format(type_name, key)
|
||||
|
||||
class Entity(metaclass=EntityMeta): # <3>
|
||||
"""Business entity with validated fields"""
|
||||
# end::MODEL_V7[]
|
||||
80
24-class-metaprog/bulkfood/model_v8.py
Normal file
80
24-class-metaprog/bulkfood/model_v8.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import abc
|
||||
import collections
|
||||
|
||||
|
||||
class AutoStorage:
|
||||
__counter = 0
|
||||
|
||||
def __init__(self):
|
||||
cls = self.__class__
|
||||
prefix = cls.__name__
|
||||
index = cls.__counter
|
||||
self.storage_name = '_{}#{}'.format(prefix, index)
|
||||
cls.__counter += 1
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None:
|
||||
return self
|
||||
else:
|
||||
return getattr(instance, self.storage_name)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
setattr(instance, self.storage_name, value)
|
||||
|
||||
|
||||
class Validated(abc.ABC, AutoStorage):
|
||||
|
||||
def __set__(self, instance, value):
|
||||
value = self.validate(instance, value)
|
||||
super().__set__(instance, value)
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate(self, instance, value):
|
||||
"""return validated value or raise ValueError"""
|
||||
|
||||
|
||||
class Quantity(Validated):
|
||||
"""a number greater than zero"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
if value <= 0:
|
||||
raise ValueError('value must be > 0')
|
||||
return value
|
||||
|
||||
|
||||
class NonBlank(Validated):
|
||||
"""a string with at least one non-space character"""
|
||||
|
||||
def validate(self, instance, value):
|
||||
value = value.strip()
|
||||
if len(value) == 0:
|
||||
raise ValueError('value cannot be empty or blank')
|
||||
return value
|
||||
|
||||
# tag::MODEL_V8[]
|
||||
class EntityMeta(type):
|
||||
"""Metaclass for business entities with validated fields"""
|
||||
|
||||
@classmethod
|
||||
def __prepare__(cls, name, bases):
|
||||
return collections.OrderedDict() # <1>
|
||||
|
||||
def __init__(cls, name, bases, attr_dict):
|
||||
super().__init__(name, bases, attr_dict)
|
||||
cls._field_names = [] # <2>
|
||||
for key, attr in attr_dict.items(): # <3>
|
||||
if isinstance(attr, Validated):
|
||||
type_name = type(attr).__name__
|
||||
attr.storage_name = '_{}#{}'.format(type_name, key)
|
||||
cls._field_names.append(key) # <4>
|
||||
|
||||
|
||||
class Entity(metaclass=EntityMeta):
|
||||
"""Business entity with validated fields"""
|
||||
|
||||
@classmethod
|
||||
def field_names(cls): # <5>
|
||||
for name in cls._field_names:
|
||||
yield name
|
||||
|
||||
# end::MODEL_V8[]
|
||||
152
24-class-metaprog/checked/decorator/checkeddeco.py
Normal file
152
24-class-metaprog/checked/decorator/checkeddeco.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
A ``Checked`` subclass definition requires that keyword arguments are
|
||||
used to create an instance, and provides a nice ``__repr__``::
|
||||
|
||||
# tag::MOVIE_DEFINITION[]
|
||||
|
||||
>>> @checked
|
||||
... class Movie:
|
||||
... title: str
|
||||
... year: int
|
||||
... box_office: float
|
||||
...
|
||||
>>> movie = Movie(title='The Godfather', year=1972, box_office=137)
|
||||
>>> movie.title
|
||||
'The Godfather'
|
||||
>>> movie
|
||||
Movie(title='The Godfather', year=1972, box_office=137.0)
|
||||
|
||||
# end::MOVIE_DEFINITION[]
|
||||
|
||||
The type of arguments is runtime checked when an attribute is set,
|
||||
including during instantiation::
|
||||
|
||||
# tag::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'billions' is not compatible with box_office:float
|
||||
>>> movie.year = 'MCMLXXII'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'MCMLXXII' is not compatible with year:int
|
||||
|
||||
# end::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
Attributes not passed as arguments to the constructor are initialized with
|
||||
default values::
|
||||
|
||||
# tag::MOVIE_DEFAULTS[]
|
||||
|
||||
>>> Movie(title='Life of Brian')
|
||||
Movie(title='Life of Brian', year=0, box_office=0.0)
|
||||
|
||||
# end::MOVIE_DEFAULTS[]
|
||||
|
||||
Providing extra arguments to the constructor is not allowed::
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office=2000,
|
||||
... director='James Cameron')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' has no attribute 'director'
|
||||
|
||||
Creating new attributes at runtime is restricted as well::
|
||||
|
||||
>>> movie.director = 'Francis Ford Coppola'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' has no attribute 'director'
|
||||
|
||||
The `_asdict` instance method creates a `dict` from the attributes
|
||||
of a `Movie` object::
|
||||
|
||||
>>> movie._asdict()
|
||||
{'title': 'The Godfather', 'year': 1972, 'box_office': 137.0}
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Callable # <1>
|
||||
from typing import Any, NoReturn, get_type_hints
|
||||
|
||||
class Field:
|
||||
def __init__(self, name: str, constructor: Callable) -> None: # <2>
|
||||
if not callable(constructor) or constructor is type(None):
|
||||
raise TypeError(f'{name!r} type hint must be callable')
|
||||
self.name = name
|
||||
self.constructor = constructor
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None: # <3>
|
||||
if value is ...: # <4>
|
||||
value = self.constructor()
|
||||
else:
|
||||
try:
|
||||
value = self.constructor(value) # <5>
|
||||
except (TypeError, ValueError) as e:
|
||||
type_name = self.constructor.__name__
|
||||
msg = (
|
||||
f'{value!r} is not compatible with {self.name}:{type_name}'
|
||||
)
|
||||
raise TypeError(msg) from e
|
||||
instance.__dict__[self.name] = value # <6>
|
||||
|
||||
|
||||
# tag::CHECKED_DECORATOR[]
|
||||
def checked(cls: type) -> type: # <1>
|
||||
for name, constructor in _fields(cls).items(): # <2>
|
||||
setattr(cls, name, Field(name, constructor)) # <3>
|
||||
|
||||
cls._fields = classmethod(_fields) # type: ignore # <4>
|
||||
|
||||
instance_methods = ( # <5>
|
||||
__init__,
|
||||
__repr__,
|
||||
__setattr__,
|
||||
_asdict,
|
||||
__flag_unknown_attrs,
|
||||
)
|
||||
for method in instance_methods: # <6>
|
||||
setattr(cls, method.__name__, method)
|
||||
|
||||
return cls # <7>
|
||||
# end::CHECKED_DECORATOR[]
|
||||
|
||||
# tag::CHECKED_METHODS[]
|
||||
def _fields(cls: type) -> dict[str, type]:
|
||||
return get_type_hints(cls)
|
||||
|
||||
def __init__(self: Any, **kwargs: Any) -> None:
|
||||
for name in self._fields():
|
||||
value = kwargs.pop(name, ...)
|
||||
setattr(self, name, value)
|
||||
if kwargs:
|
||||
self.__flag_unknown_attrs(*kwargs)
|
||||
|
||||
def __setattr__(self: Any, name: str, value: Any) -> None:
|
||||
if name in self._fields():
|
||||
cls = self.__class__
|
||||
descriptor = getattr(cls, name)
|
||||
descriptor.__set__(self, value)
|
||||
else:
|
||||
self.__flag_unknown_attrs(name)
|
||||
|
||||
def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn:
|
||||
plural = 's' if len(names) > 1 else ''
|
||||
extra = ', '.join(f'{name!r}' for name in names)
|
||||
cls_name = repr(self.__class__.__name__)
|
||||
raise AttributeError(f'{cls_name} has no attribute{plural} {extra}')
|
||||
|
||||
def _asdict(self: Any) -> dict[str, Any]:
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name, attr in self.__class__.__dict__.items()
|
||||
if isinstance(attr, Field)
|
||||
}
|
||||
|
||||
def __repr__(self: Any) -> str:
|
||||
kwargs = ', '.join(
|
||||
f'{key}={value!r}' for key, value in self._asdict().items()
|
||||
)
|
||||
return f'{self.__class__.__name__}({kwargs})'
|
||||
# end::CHECKED_METHODS[]
|
||||
26
24-class-metaprog/checked/decorator/checkeddeco_demo.py
Executable file
26
24-class-metaprog/checked/decorator/checkeddeco_demo.py
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from checkeddeco import checked
|
||||
|
||||
@checked
|
||||
class Movie:
|
||||
title: str
|
||||
year: int
|
||||
box_office: float
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# No static type checker can understand this...
|
||||
movie = Movie(title='The Godfather', year=1972, box_office=137) # type: ignore
|
||||
print(movie.title)
|
||||
print(movie)
|
||||
try:
|
||||
# remove the "type: ignore" comment to see Mypy correctly spot the error
|
||||
movie.year = 'MCMLXXII' # type: ignore
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
try:
|
||||
# Again, no static type checker can understand this...
|
||||
blockbuster = Movie(title='Avatar', year=2009, box_office='billions') # type: ignore
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
50
24-class-metaprog/checked/decorator/checkeddeco_test.py
Normal file
50
24-class-metaprog/checked/decorator/checkeddeco_test.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from checkeddeco import checked
|
||||
|
||||
|
||||
def test_field_descriptor_validation_type_error():
|
||||
@checked
|
||||
class Cat:
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight=None)
|
||||
|
||||
assert str(e.value) == 'None is not compatible with weight:float'
|
||||
|
||||
|
||||
def test_field_descriptor_validation_value_error():
|
||||
@checked
|
||||
class Cat:
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight='half stone')
|
||||
|
||||
assert str(e.value) == "'half stone' is not compatible with weight:float"
|
||||
|
||||
|
||||
def test_constructor_attribute_error():
|
||||
@checked
|
||||
class Cat:
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix = Cat(name='Felix', weight=3.2, age=7)
|
||||
|
||||
assert str(e.value) == "'Cat' has no attribute 'age'"
|
||||
|
||||
|
||||
def test_field_invalid_constructor():
|
||||
with pytest.raises(TypeError) as e:
|
||||
@checked
|
||||
class Cat:
|
||||
name: str
|
||||
weight: None
|
||||
|
||||
assert str(e.value) == "'weight' type hint must be callable"
|
||||
23
24-class-metaprog/checked/initsub/checked_demo.py
Executable file
23
24-class-metaprog/checked/initsub/checked_demo.py
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from checkedlib import Checked
|
||||
|
||||
class Movie(Checked):
|
||||
title: str
|
||||
year: int
|
||||
box_office: float
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
movie = Movie(title='The Godfather', year=1972, box_office=137)
|
||||
print(movie.title)
|
||||
print(movie)
|
||||
try:
|
||||
# remove the "type: ignore" comment to see Mypy error
|
||||
movie.year = 'MCMLXXII' # type: ignore
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
try:
|
||||
blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
142
24-class-metaprog/checked/initsub/checkedlib.py
Normal file
142
24-class-metaprog/checked/initsub/checkedlib.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
A ``Checked`` subclass definition requires that keyword arguments are
|
||||
used to create an instance, and provides a nice ``__repr__``::
|
||||
|
||||
# tag::MOVIE_DEFINITION[]
|
||||
|
||||
>>> class Movie(Checked): # <1>
|
||||
... title: str # <2>
|
||||
... year: int
|
||||
... box_office: float
|
||||
...
|
||||
>>> movie = Movie(title='The Godfather', year=1972, box_office=137) # <3>
|
||||
>>> movie.title
|
||||
'The Godfather'
|
||||
>>> movie # <4>
|
||||
Movie(title='The Godfather', year=1972, box_office=137.0)
|
||||
|
||||
# end::MOVIE_DEFINITION[]
|
||||
|
||||
The type of arguments is runtime checked during instantiation
|
||||
and when an attribute is set::
|
||||
|
||||
# tag::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'billions' is not compatible with box_office:float
|
||||
>>> movie.year = 'MCMLXXII'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'MCMLXXII' is not compatible with year:int
|
||||
|
||||
# end::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
Attributes not passed as arguments to the constructor are initialized with
|
||||
default values::
|
||||
|
||||
# tag::MOVIE_DEFAULTS[]
|
||||
|
||||
>>> Movie(title='Life of Brian')
|
||||
Movie(title='Life of Brian', year=0, box_office=0.0)
|
||||
|
||||
# end::MOVIE_DEFAULTS[]
|
||||
|
||||
Providing extra arguments to the constructor is not allowed::
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office=2000,
|
||||
... director='James Cameron')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' object has no attribute 'director'
|
||||
|
||||
Creating new attributes at runtime is restricted as well::
|
||||
|
||||
>>> movie.director = 'Francis Ford Coppola'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' object has no attribute 'director'
|
||||
|
||||
The `_asdict` instance method creates a `dict` from the attributes
|
||||
of a `Movie` object::
|
||||
|
||||
>>> movie._asdict()
|
||||
{'title': 'The Godfather', 'year': 1972, 'box_office': 137.0}
|
||||
|
||||
"""
|
||||
|
||||
# tag::CHECKED_FIELD[]
|
||||
from collections.abc import Callable # <1>
|
||||
from typing import Any, NoReturn, get_type_hints
|
||||
|
||||
|
||||
class Field:
|
||||
def __init__(self, name: str, constructor: Callable) -> None: # <2>
|
||||
if not callable(constructor) or constructor is type(None): # <3>
|
||||
raise TypeError(f'{name!r} type hint must be callable')
|
||||
self.name = name
|
||||
self.constructor = constructor
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None:
|
||||
if value is ...: # <4>
|
||||
value = self.constructor()
|
||||
else:
|
||||
try:
|
||||
value = self.constructor(value) # <5>
|
||||
except (TypeError, ValueError) as e: # <6>
|
||||
type_name = self.constructor.__name__
|
||||
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
|
||||
raise TypeError(msg) from e
|
||||
instance.__dict__[self.name] = value # <7>
|
||||
# end::CHECKED_FIELD[]
|
||||
|
||||
# tag::CHECKED_TOP[]
|
||||
class Checked:
|
||||
@classmethod
|
||||
def _fields(cls) -> dict[str, type]: # <1>
|
||||
return get_type_hints(cls)
|
||||
|
||||
def __init_subclass__(subclass) -> None: # <2>
|
||||
super().__init_subclass__() # <3>
|
||||
for name, constructor in subclass._fields().items(): # <4>
|
||||
setattr(subclass, name, Field(name, constructor)) # <5>
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
for name in self._fields(): # <6>
|
||||
value = kwargs.pop(name, ...) # <7>
|
||||
setattr(self, name, value) # <8>
|
||||
if kwargs: # <9>
|
||||
self.__flag_unknown_attrs(*kwargs) # <10>
|
||||
|
||||
# end::CHECKED_TOP[]
|
||||
|
||||
# tag::CHECKED_BOTTOM[]
|
||||
def __setattr__(self, name: str, value: Any) -> None: # <1>
|
||||
if name in self._fields(): # <2>
|
||||
cls = self.__class__
|
||||
descriptor = getattr(cls, name)
|
||||
descriptor.__set__(self, value) # <3>
|
||||
else: # <4>
|
||||
self.__flag_unknown_attrs(name)
|
||||
|
||||
def __flag_unknown_attrs(self, *names: str) -> NoReturn: # <5>
|
||||
plural = 's' if len(names) > 1 else ''
|
||||
extra = ', '.join(f'{name!r}' for name in names)
|
||||
cls_name = repr(self.__class__.__name__)
|
||||
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')
|
||||
|
||||
def _asdict(self) -> dict[str, Any]: # <6>
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name, attr in self.__class__.__dict__.items()
|
||||
if isinstance(attr, Field)
|
||||
}
|
||||
|
||||
def __repr__(self) -> str: # <7>
|
||||
kwargs = ', '.join(
|
||||
f'{key}={value!r}' for key, value in self._asdict().items()
|
||||
)
|
||||
return f'{self.__class__.__name__}({kwargs})'
|
||||
|
||||
# end::CHECKED_BOTTOM[]
|
||||
59
24-class-metaprog/checked/initsub/checkedlib_test.py
Normal file
59
24-class-metaprog/checked/initsub/checkedlib_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from checkedlib import Checked
|
||||
|
||||
|
||||
def test_field_validation_type_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight=None)
|
||||
|
||||
assert str(e.value) == 'None is not compatible with weight:float'
|
||||
|
||||
|
||||
def test_field_validation_value_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight='half stone')
|
||||
|
||||
assert str(e.value) == "'half stone' is not compatible with weight:float"
|
||||
|
||||
|
||||
def test_constructor_attribute_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix = Cat(name='Felix', weight=3.2, age=7)
|
||||
|
||||
assert str(e.value) == "'Cat' object has no attribute 'age'"
|
||||
|
||||
|
||||
def test_assignment_attribute_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
felix = Cat(name='Felix', weight=3.2)
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix.color = 'tan'
|
||||
|
||||
assert str(e.value) == "'Cat' object has no attribute 'color'"
|
||||
|
||||
|
||||
def test_field_invalid_constructor():
|
||||
with pytest.raises(TypeError) as e:
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: None
|
||||
|
||||
assert str(e.value) == "'weight' type hint must be callable"
|
||||
|
||||
25
24-class-metaprog/checked/metaclass/checked_demo.py
Executable file
25
24-class-metaprog/checked/metaclass/checked_demo.py
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# tag::MOVIE_DEMO[]
|
||||
from checkedlib import Checked
|
||||
|
||||
class Movie(Checked):
|
||||
title: str
|
||||
year: int
|
||||
box_office: float
|
||||
|
||||
if __name__ == '__main__':
|
||||
movie = Movie(title='The Godfather', year=1972, box_office=137)
|
||||
print(movie)
|
||||
print(movie.title)
|
||||
# end::MOVIE_DEMO[]
|
||||
|
||||
try:
|
||||
# remove the "type: ignore" comment to see Mypy error
|
||||
movie.year = 'MCMLXXII' # type: ignore
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
try:
|
||||
blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
|
||||
except TypeError as e:
|
||||
print(e)
|
||||
149
24-class-metaprog/checked/metaclass/checkedlib.py
Normal file
149
24-class-metaprog/checked/metaclass/checkedlib.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
A ``Checked`` subclass definition requires that keyword arguments are
|
||||
used to create an instance, and provides a nice ``__repr__``::
|
||||
|
||||
# tag::MOVIE_DEFINITION[]
|
||||
|
||||
>>> class Movie(Checked): # <1>
|
||||
... title: str # <2>
|
||||
... year: int
|
||||
... box_office: float
|
||||
...
|
||||
>>> movie = Movie(title='The Godfather', year=1972, box_office=137) # <3>
|
||||
>>> movie.title
|
||||
'The Godfather'
|
||||
>>> movie # <4>
|
||||
Movie(title='The Godfather', year=1972, box_office=137.0)
|
||||
|
||||
# end::MOVIE_DEFINITION[]
|
||||
|
||||
The type of arguments is runtime checked during instantiation
|
||||
and when an attribute is set::
|
||||
|
||||
# tag::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'billions' is not compatible with box_office:float
|
||||
>>> movie.year = 'MCMLXXII'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'MCMLXXII' is not compatible with year:int
|
||||
|
||||
# end::MOVIE_TYPE_VALIDATION[]
|
||||
|
||||
Attributes not passed as arguments to the constructor are initialized with
|
||||
default values::
|
||||
|
||||
# tag::MOVIE_DEFAULTS[]
|
||||
|
||||
>>> Movie(title='Life of Brian')
|
||||
Movie(title='Life of Brian', year=0, box_office=0.0)
|
||||
|
||||
# end::MOVIE_DEFAULTS[]
|
||||
|
||||
Providing extra arguments to the constructor is not allowed::
|
||||
|
||||
>>> blockbuster = Movie(title='Avatar', year=2009, box_office=2000,
|
||||
... director='James Cameron')
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' object has no attribute 'director'
|
||||
|
||||
Creating new attributes at runtime is restricted as well::
|
||||
|
||||
>>> movie.director = 'Francis Ford Coppola'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Movie' object has no attribute 'director'
|
||||
|
||||
The `_asdict` instance method creates a `dict` from the attributes
|
||||
of a `Movie` object::
|
||||
|
||||
>>> movie._asdict()
|
||||
{'title': 'The Godfather', 'year': 1972, 'box_office': 137.0}
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, NoReturn, get_type_hints
|
||||
|
||||
# tag::CHECKED_FIELD[]
|
||||
class Field:
|
||||
def __init__(self, name: str, constructor: Callable) -> None:
|
||||
if not callable(constructor) or constructor is type(None):
|
||||
raise TypeError(f'{name!r} type hint must be callable')
|
||||
self.name = name
|
||||
self.storage_name = '_' + name # <1>
|
||||
self.constructor = constructor
|
||||
|
||||
def __get__(self, instance, owner=None): # <2>
|
||||
return getattr(instance, self.storage_name) # <3>
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None:
|
||||
if value is ...:
|
||||
value = self.constructor()
|
||||
else:
|
||||
try:
|
||||
value = self.constructor(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
type_name = self.constructor.__name__
|
||||
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
|
||||
raise TypeError(msg) from e
|
||||
setattr(instance, self.storage_name, value) # <4>
|
||||
# end::CHECKED_FIELD[]
|
||||
|
||||
# tag::CHECKED_META[]
|
||||
class CheckedMeta(type):
|
||||
|
||||
def __new__(meta_cls, cls_name, bases, cls_dict): # <1>
|
||||
if '__slots__' not in cls_dict: # <2>
|
||||
slots = []
|
||||
type_hints = cls_dict.get('__annotations__', {}) # <3>
|
||||
for name, constructor in type_hints.items(): # <4>
|
||||
field = Field(name, constructor) # <5>
|
||||
cls_dict[name] = field # <6>
|
||||
slots.append(field.storage_name) # <7>
|
||||
|
||||
cls_dict['__slots__'] = slots # <8>
|
||||
|
||||
return super().__new__(
|
||||
meta_cls, cls_name, bases, cls_dict) # <9>
|
||||
# end::CHECKED_META[]
|
||||
|
||||
# tag::CHECKED_CLASS[]
|
||||
class Checked(metaclass=CheckedMeta):
|
||||
__slots__ = () # skip CheckedMeta.__new__ processing
|
||||
|
||||
@classmethod
|
||||
def _fields(cls) -> dict[str, type]:
|
||||
return get_type_hints(cls)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
for name in self._fields():
|
||||
value = kwargs.pop(name, ...)
|
||||
setattr(self, name, value)
|
||||
if kwargs:
|
||||
self.__flag_unknown_attrs(*kwargs)
|
||||
|
||||
def __flag_unknown_attrs(self, *names: str) -> NoReturn:
|
||||
plural = 's' if len(names) > 1 else ''
|
||||
extra = ', '.join(f'{name!r}' for name in names)
|
||||
cls_name = repr(self.__class__.__name__)
|
||||
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')
|
||||
|
||||
def _asdict(self) -> dict[str, Any]:
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name, attr in self.__class__.__dict__.items()
|
||||
if isinstance(attr, Field)
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kwargs = ', '.join(
|
||||
f'{key}={value!r}' for key, value in self._asdict().items()
|
||||
)
|
||||
return f'{self.__class__.__name__}({kwargs})'
|
||||
|
||||
# end::CHECKED_CLASS[]
|
||||
58
24-class-metaprog/checked/metaclass/checkedlib_test.py
Normal file
58
24-class-metaprog/checked/metaclass/checkedlib_test.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from checkedlib import Checked
|
||||
|
||||
|
||||
def test_field_descriptor_validation_type_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight=None)
|
||||
|
||||
assert str(e.value) == 'None is not compatible with weight:float'
|
||||
|
||||
|
||||
def test_field_descriptor_validation_value_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight='half stone')
|
||||
|
||||
assert str(e.value) == "'half stone' is not compatible with weight:float"
|
||||
|
||||
|
||||
def test_constructor_attribute_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix = Cat(name='Felix', weight=3.2, age=7)
|
||||
|
||||
assert str(e.value) == "'Cat' object has no attribute 'age'"
|
||||
|
||||
|
||||
def test_assignment_attribute_error():
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
felix = Cat(name='Felix', weight=3.2)
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix.color = 'tan'
|
||||
|
||||
assert str(e.value) == "'Cat' object has no attribute 'color'"
|
||||
|
||||
|
||||
def test_field_invalid_constructor():
|
||||
with pytest.raises(TypeError) as e:
|
||||
class Cat(Checked):
|
||||
name: str
|
||||
weight: None
|
||||
|
||||
assert str(e.value) == "'weight' type hint must be callable"
|
||||
50
24-class-metaprog/evaltime/builderlib.py
Normal file
50
24-class-metaprog/evaltime/builderlib.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# tag::BUILDERLIB_TOP[]
|
||||
print('@ builderlib module start')
|
||||
|
||||
class Builder: # <1>
|
||||
print('@ Builder body')
|
||||
|
||||
def __init_subclass__(cls): # <2>
|
||||
print(f'@ Builder.__init_subclass__({cls!r})')
|
||||
|
||||
def inner_0(self): # <3>
|
||||
print(f'@ SuperA.__init_subclass__:inner_0({self!r})')
|
||||
|
||||
cls.method_a = inner_0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
print(f'@ Builder.__init__({self!r})')
|
||||
|
||||
|
||||
def deco(cls): # <4>
|
||||
print(f'@ deco({cls!r})')
|
||||
|
||||
def inner_1(self): # <5>
|
||||
print(f'@ deco:inner_1({self!r})')
|
||||
|
||||
cls.method_b = inner_1
|
||||
return cls # <6>
|
||||
# end::BUILDERLIB_TOP[]
|
||||
|
||||
# tag::BUILDERLIB_BOTTOM[]
|
||||
class Descriptor: # <1>
|
||||
print('@ Descriptor body')
|
||||
|
||||
def __init__(self): # <2>
|
||||
print(f'@ Descriptor.__init__({self!r})')
|
||||
|
||||
def __set_name__(self, owner, name): # <3>
|
||||
args = (self, owner, name)
|
||||
print(f'@ Descriptor.__set_name__{args!r}')
|
||||
|
||||
def __set__(self, instance, value): # <4>
|
||||
args = (self, instance, value)
|
||||
print(f'@ Descriptor.__set__{args!r}')
|
||||
|
||||
def __repr__(self):
|
||||
return '<Descriptor instance>'
|
||||
|
||||
|
||||
print('@ builderlib module end')
|
||||
# end::BUILDERLIB_BOTTOM[]
|
||||
30
24-class-metaprog/evaltime/evaldemo.py
Executable file
30
24-class-metaprog/evaltime/evaldemo.py
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from builderlib import Builder, deco, Descriptor
|
||||
|
||||
print('# evaldemo module start')
|
||||
|
||||
@deco # <1>
|
||||
class Klass(Builder): # <2>
|
||||
print('# Klass body')
|
||||
|
||||
attr = Descriptor() # <3>
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
print(f'# Klass.__init__({self!r})')
|
||||
|
||||
def __repr__(self):
|
||||
return '<Klass instance>'
|
||||
|
||||
|
||||
def main(): # <4>
|
||||
obj = Klass()
|
||||
obj.method_a()
|
||||
obj.method_b()
|
||||
obj.attr = 999
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
print('# evaldemo module end')
|
||||
33
24-class-metaprog/evaltime/evaldemo_meta.py
Executable file
33
24-class-metaprog/evaltime/evaldemo_meta.py
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from builderlib import Builder, deco, Descriptor
|
||||
from metalib import MetaKlass # <1>
|
||||
|
||||
print('# evaldemo_meta module start')
|
||||
|
||||
@deco
|
||||
class Klass(Builder, metaclass=MetaKlass): # <2>
|
||||
print('# Klass body')
|
||||
|
||||
attr = Descriptor()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
print(f'# Klass.__init__({self!r})')
|
||||
|
||||
def __repr__(self):
|
||||
return '<Klass instance>'
|
||||
|
||||
|
||||
def main():
|
||||
obj = Klass()
|
||||
obj.method_a()
|
||||
obj.method_b()
|
||||
obj.method_c() # <3>
|
||||
obj.attr = 999
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
print('# evaldemo_meta module end')
|
||||
43
24-class-metaprog/evaltime/metalib.py
Normal file
43
24-class-metaprog/evaltime/metalib.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# tag::METALIB_TOP[]
|
||||
print('% metalib module start')
|
||||
|
||||
import collections
|
||||
|
||||
class NosyDict(collections.UserDict):
|
||||
def __setitem__(self, key, value):
|
||||
args = (self, key, value)
|
||||
print(f'% NosyDict.__setitem__{args!r}')
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def __repr__(self):
|
||||
return '<NosyDict instance>'
|
||||
# end::METALIB_TOP[]
|
||||
|
||||
# tag::METALIB_BOTTOM[]
|
||||
class MetaKlass(type):
|
||||
print('% MetaKlass body')
|
||||
|
||||
@classmethod # <1>
|
||||
def __prepare__(meta_cls, cls_name, bases): # <2>
|
||||
args = (meta_cls, cls_name, bases)
|
||||
print(f'% MetaKlass.__prepare__{args!r}')
|
||||
return NosyDict() # <3>
|
||||
|
||||
def __new__(meta_cls, cls_name, bases, cls_dict): # <4>
|
||||
args = (meta_cls, cls_name, bases, cls_dict)
|
||||
print(f'% MetaKlass.__new__{args!r}')
|
||||
def inner_2(self):
|
||||
print(f'% MetaKlass.__new__:inner_2({self!r})')
|
||||
|
||||
cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data) # <5>
|
||||
|
||||
cls.method_c = inner_2 # <6>
|
||||
|
||||
return cls # <7>
|
||||
|
||||
def __repr__(cls): # <8>
|
||||
cls_name = cls.__name__
|
||||
return f"<class {cls_name!r} built by MetaKlass>"
|
||||
|
||||
print('% metalib module end')
|
||||
# end::METALIB_BOTTOM[]
|
||||
74
24-class-metaprog/factories.py
Normal file
74
24-class-metaprog/factories.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
record_factory: create simple classes just for holding data fields
|
||||
|
||||
# tag::RECORD_FACTORY_DEMO[]
|
||||
>>> Dog = record_factory('Dog', 'name weight owner') # <1>
|
||||
>>> rex = Dog('Rex', 30, 'Bob')
|
||||
>>> rex # <2>
|
||||
Dog(name='Rex', weight=30, owner='Bob')
|
||||
>>> name, weight, _ = rex # <3>
|
||||
>>> name, weight
|
||||
('Rex', 30)
|
||||
>>> "{2}'s dog weighs {1}kg".format(*rex) # <4>
|
||||
"Bob's dog weighs 30kg"
|
||||
>>> rex.weight = 32 # <5>
|
||||
>>> rex
|
||||
Dog(name='Rex', weight=32, owner='Bob')
|
||||
>>> Dog.__mro__ # <6>
|
||||
(<class 'factories.Dog'>, <class 'object'>)
|
||||
|
||||
# end::RECORD_FACTORY_DEMO[]
|
||||
|
||||
The factory also accepts a list or tuple of identifiers:
|
||||
|
||||
>>> Dog = record_factory('Dog', ['name', 'weight', 'owner'])
|
||||
>>> Dog.__slots__
|
||||
('name', 'weight', 'owner')
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# tag::RECORD_FACTORY[]
|
||||
from typing import Union, Any
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
FieldNames = Union[str, Iterable[str]] # <1>
|
||||
|
||||
def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]: # <2>
|
||||
|
||||
slots = parse_identifiers(field_names) # <3>
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None: # <4>
|
||||
attrs = dict(zip(self.__slots__, args))
|
||||
attrs.update(kwargs)
|
||||
for name, value in attrs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
def __iter__(self) -> Iterator[Any]: # <5>
|
||||
for name in self.__slots__:
|
||||
yield getattr(self, name)
|
||||
|
||||
def __repr__(self): # <6>
|
||||
values = ', '.join(
|
||||
'{}={!r}'.format(*i) for i in zip(self.__slots__, self)
|
||||
)
|
||||
cls_name = self.__class__.__name__
|
||||
return f'{cls_name}({values})'
|
||||
|
||||
cls_attrs = dict( # <7>
|
||||
__slots__=slots,
|
||||
__init__=__init__,
|
||||
__iter__=__iter__,
|
||||
__repr__=__repr__,
|
||||
)
|
||||
|
||||
return type(cls_name, (object,), cls_attrs) # <8>
|
||||
|
||||
|
||||
def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
|
||||
if isinstance(names, str):
|
||||
names = names.replace(',', ' ').split() # <9>
|
||||
if not all(s.isidentifier() for s in names):
|
||||
raise ValueError('names must all be valid identifiers')
|
||||
return tuple(names)
|
||||
# end::RECORD_FACTORY[]
|
||||
79
24-class-metaprog/factories_ducktyped.py
Normal file
79
24-class-metaprog/factories_ducktyped.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
record_factory: create simple classes just for holding data fields
|
||||
|
||||
# tag::RECORD_FACTORY_DEMO[]
|
||||
>>> Dog = record_factory('Dog', 'name weight owner') # <1>
|
||||
>>> rex = Dog('Rex', 30, 'Bob')
|
||||
>>> rex # <2>
|
||||
Dog(name='Rex', weight=30, owner='Bob')
|
||||
>>> name, weight, _ = rex # <3>
|
||||
>>> name, weight
|
||||
('Rex', 30)
|
||||
>>> "{2}'s dog weighs {1}kg".format(*rex) # <4>
|
||||
"Bob's dog weighs 30kg"
|
||||
>>> rex.weight = 32 # <5>
|
||||
>>> rex
|
||||
Dog(name='Rex', weight=32, owner='Bob')
|
||||
>>> Dog.__mro__ # <6>
|
||||
(<class 'factories_ducktyped.Dog'>, <class 'object'>)
|
||||
|
||||
# end::RECORD_FACTORY_DEMO[]
|
||||
|
||||
The factory also accepts a list or tuple of identifiers:
|
||||
|
||||
>>> Dog = record_factory('Dog', ['name', 'weight', 'owner'])
|
||||
>>> Dog.__slots__
|
||||
('name', 'weight', 'owner')
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# tag::RECORD_FACTORY[]
|
||||
from typing import Union, Any
|
||||
from collections.abc import Sequence, Iterator
|
||||
|
||||
FieldNames = Union[str, Sequence[str]]
|
||||
|
||||
|
||||
def parse_identifiers(names):
|
||||
try:
|
||||
names = names.replace(',', ' ').split() # <1>
|
||||
except AttributeError: # no .replace or .split
|
||||
pass # assume it's already a sequence of strings
|
||||
if not all(s.isidentifier() for s in names):
|
||||
raise ValueError('names must all be valid identifiers')
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def record_factory(cls_name, field_names):
|
||||
|
||||
field_identifiers = parse_identifiers(field_names)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None: # <4>
|
||||
attrs = dict(zip(self.__slots__, args))
|
||||
attrs.update(kwargs)
|
||||
for name, value in attrs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
def __iter__(self) -> Iterator[Any]: # <5>
|
||||
for name in self.__slots__:
|
||||
yield getattr(self, name)
|
||||
|
||||
def __repr__(self): # <6>
|
||||
values = ', '.join(
|
||||
'{}={!r}'.format(*i) for i in zip(self.__slots__, self)
|
||||
)
|
||||
cls_name = self.__class__.__name__
|
||||
return f'{cls_name}({values})'
|
||||
|
||||
cls_attrs = dict(
|
||||
__slots__=field_identifiers, # <7>
|
||||
__init__=__init__,
|
||||
__iter__=__iter__,
|
||||
__repr__=__repr__,
|
||||
)
|
||||
|
||||
return type(cls_name, (object,), cls_attrs) # <8>
|
||||
|
||||
|
||||
# end::RECORD_FACTORY[]
|
||||
115
24-class-metaprog/hours/hours.py
Normal file
115
24-class-metaprog/hours/hours.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Abusing ``__class_getitem__`` to make a nano-DSL for working
|
||||
with hours, minutes, and seconds--these last two in base 60.
|
||||
|
||||
``H`` is an alias for the ``Hours`` class::
|
||||
|
||||
>>> H[1]
|
||||
1:00
|
||||
>>> H[1:30]
|
||||
1:30
|
||||
>>> H[1::5]
|
||||
1:00:05
|
||||
>>> H[::5]
|
||||
0:00:05
|
||||
|
||||
An ``H`` instance can be converted to a float number of hours::
|
||||
|
||||
>>> float(H[1:15])
|
||||
1.25
|
||||
>>> float(H[1:30:30]) # doctest: +ELLIPSIS
|
||||
1.5083333...
|
||||
>>> float(H[1::5]) # doctest: +ELLIPSIS
|
||||
1.0013888...
|
||||
|
||||
The ``H`` constructor accepts hours, minutes, and/or seconds::
|
||||
|
||||
>>> H(1.5)
|
||||
1:30
|
||||
>>> H(1.9)
|
||||
1:54
|
||||
>>> H(1, 30, 30)
|
||||
1:30:30
|
||||
>>> H(s = 7205)
|
||||
2:00:05
|
||||
>>> H(1/3)
|
||||
0:20
|
||||
>>> H(1/1000)
|
||||
0:00:03.6
|
||||
|
||||
An ``H`` instance is iterable, for convenient unpacking::
|
||||
|
||||
>>> hms = H[1:22:33]
|
||||
>>> h, m, s = hms
|
||||
>>> h, m, s
|
||||
(1, 22, 33)
|
||||
>>> tuple(hms)
|
||||
(1, 22, 33)
|
||||
|
||||
|
||||
``H`` instances can be added::
|
||||
|
||||
>>> H[1:45:12] + H[2:15:50]
|
||||
4:01:02
|
||||
"""
|
||||
|
||||
from typing import Tuple, Union
|
||||
|
||||
|
||||
def normalize(s: float) -> Tuple[int, int, float]:
|
||||
h, r = divmod(s, 3600)
|
||||
m, s = divmod(r, 60)
|
||||
return int(h), int(m), s
|
||||
|
||||
|
||||
def valid_base_60(n, unit):
|
||||
if not (0 <= n < 60):
|
||||
raise ValueError(f'invalid {unit} {n}')
|
||||
return n
|
||||
|
||||
|
||||
class Hours:
|
||||
h: int
|
||||
_m: int
|
||||
_s: float
|
||||
|
||||
def __class_getitem__(cls, parts: Union[slice, float]) -> 'Hours':
|
||||
if isinstance(parts, slice):
|
||||
h = parts.start or 0
|
||||
m = valid_base_60(parts.stop or 0, 'minutes')
|
||||
s = valid_base_60(parts.step or 0, 'seconds')
|
||||
else:
|
||||
h, m, s = normalize(parts * 3600)
|
||||
return Hours(h, m, s)
|
||||
|
||||
def __init__(self, h: float = 0, m: float = 0, s: float = 0):
|
||||
if h < 0 or m < 0 or s < 0:
|
||||
raise ValueError('invalid negative argument')
|
||||
self.h, self.m, self.s = normalize(h * 3600 + m * 60 + s)
|
||||
|
||||
def __repr__(self):
|
||||
h, m, s = self
|
||||
display_s = f'{s:06.3f}'
|
||||
display_s = display_s.rstrip('0').rstrip('.')
|
||||
if display_s == '00':
|
||||
return f'{h}:{m:02d}'
|
||||
return f'{h}:{m:02d}:{display_s}'
|
||||
|
||||
def __float__(self):
|
||||
return self.h + self.m / 60 + self.s / 3600
|
||||
|
||||
def __eq__(self, other):
|
||||
return repr(self) == repr(other)
|
||||
|
||||
def __iter__(self):
|
||||
yield self.h
|
||||
yield self.m
|
||||
yield self.s
|
||||
|
||||
def __add__(self, other):
|
||||
if not isinstance(other, Hours):
|
||||
return NotImplemented
|
||||
return Hours(*(a + b for a, b in zip(self, other)))
|
||||
|
||||
|
||||
H = Hours
|
||||
71
24-class-metaprog/hours/hours_test.py
Normal file
71
24-class-metaprog/hours/hours_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# content of test_expectation.py
|
||||
from math import isclose
|
||||
|
||||
import pytest
|
||||
|
||||
from hours import normalize, H
|
||||
|
||||
HOURS_TO_HMS = [
|
||||
[1, (1, 0, 0.0)],
|
||||
[1.5, (1, 30, 0.0)],
|
||||
[1.1, (1, 6, 0.0)],
|
||||
[1.9, (1, 54, 0.0)],
|
||||
[1.01, (1, 0, 36.0)],
|
||||
[1.09, (1, 5, 24.0)],
|
||||
[2 + 1/60, (2, 1, 0.0)],
|
||||
[3 + 1/3600, (3, 0, 1.0)],
|
||||
[1.251, (1, 15, 3.6)],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hours, expected', HOURS_TO_HMS)
|
||||
def test_normalize(hours, expected):
|
||||
h, m, s = expected
|
||||
got_h, got_m, got_s = normalize(hours * 3600)
|
||||
assert (h, m) == (got_h, got_m)
|
||||
assert isclose(s, got_s, abs_tol=1e-12)
|
||||
got_hours = got_h + got_m / 60 + got_s / 3600
|
||||
assert isclose(hours, got_hours)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('h, expected', [
|
||||
(H[1], '1:00'),
|
||||
(H[1:0], '1:00'),
|
||||
(H[1:3], '1:03'),
|
||||
(H[1:59], '1:59'),
|
||||
(H[1:0:0], '1:00'),
|
||||
(H[1:2:3], '1:02:03'),
|
||||
(H[1:2:3.4], '1:02:03.4'),
|
||||
(H[1:2:0.1], '1:02:00.1'),
|
||||
(H[1:2:0.01], '1:02:00.01'),
|
||||
(H[1:2:0.001], '1:02:00.001'),
|
||||
(H[1:2:0.0001], '1:02'),
|
||||
])
|
||||
def test_repr(h, expected):
|
||||
assert expected == repr(h), f'seconds: {h.s}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('expected, hms', HOURS_TO_HMS)
|
||||
def test_float(expected, hms):
|
||||
got = float(H[slice(*hms)])
|
||||
assert isclose(expected, got)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hms, units', [
|
||||
((0, 60, 0), 'minutes'),
|
||||
((0, 0, 60), 'seconds'),
|
||||
((0, 60, 60), 'minutes'),
|
||||
])
|
||||
def test_class_getitem_errors(hms, units):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
H[slice(*hms)]
|
||||
assert units in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hms1, hms2, expected', [
|
||||
(H[0:30], H[0:15], H[0:45]),
|
||||
(H[0:30], H[0:30], H[1:00]),
|
||||
(H[0:59:59], H[0:00:1], H[1:00]),
|
||||
])
|
||||
def test_add(hms1, hms2, expected):
|
||||
assert expected == hms1 + hms2
|
||||
20
24-class-metaprog/metabunch/README.md
Normal file
20
24-class-metaprog/metabunch/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Examples from Python in a Nutshell, 3rd edition
|
||||
|
||||
The metaclass `MetaBunch` example in `original/bunch.py` is an exact copy of the
|
||||
last example in the _How a Metaclass Creates a Class_ section of
|
||||
_Chapter 4: Object Oriented Python_ from
|
||||
[_Python in a Nutshell, 3rd edition_](https://learning.oreilly.com/library/view/python-in-a/9781491913833)
|
||||
by Alex Martelli, Anna Ravenscroft, and Steve Holden.
|
||||
|
||||
The version in `pre3.6/bunch.py` is slightly simplified by taking advantage
|
||||
of Python 3 `super()` and removing comments and docstrings,
|
||||
to make it easier to compare to the `from3.6` version.
|
||||
|
||||
The version in `from3.6/bunch.py` is further simplified by taking advantage
|
||||
of the order-preserving `dict` that appeared in Python 3.6,
|
||||
as well as other simplifications,
|
||||
such as leveraging closures in `__init__` and `__repr__`
|
||||
to avoid adding a `__defaults__` mapping to the class.
|
||||
|
||||
The external behavior of all three versions is the same, and
|
||||
the test files `bunch_test.py` are identical in the three directories.
|
||||
77
24-class-metaprog/metabunch/from3.6/bunch.py
Normal file
77
24-class-metaprog/metabunch/from3.6/bunch.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
The `MetaBunch` metaclass is a simplified version of the
|
||||
last example in the _How a Metaclass Creates a Class_ section
|
||||
of _Chapter 4: Object Oriented Python_ from
|
||||
[_Python in a Nutshell, 3rd edition_](https://learning.oreilly.com/library/view/python-in-a/9781491913833)
|
||||
by Alex Martelli, Anna Ravenscroft, and Steve Holden.
|
||||
|
||||
Here are a few tests. ``bunch_test.py`` has a few more.
|
||||
|
||||
# tag::BUNCH_POINT_DEMO_1[]
|
||||
>>> class Point(Bunch):
|
||||
... x = 0.0
|
||||
... y = 0.0
|
||||
... color = 'gray'
|
||||
...
|
||||
>>> Point(x=1.2, y=3, color='green')
|
||||
Point(x=1.2, y=3, color='green')
|
||||
>>> p = Point()
|
||||
>>> p.x, p.y, p.color
|
||||
(0.0, 0.0, 'gray')
|
||||
>>> p
|
||||
Point()
|
||||
|
||||
# end::BUNCH_POINT_DEMO_1[]
|
||||
|
||||
# tag::BUNCH_POINT_DEMO_2[]
|
||||
|
||||
>>> Point(x=1, y=2, z=3)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Point' object has no attribute 'z'
|
||||
>>> p = Point(x=21)
|
||||
>>> p.y = 42
|
||||
>>> p
|
||||
Point(x=21, y=42)
|
||||
>>> p.flavor = 'banana'
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
AttributeError: 'Point' object has no attribute 'flavor'
|
||||
|
||||
# end::BUNCH_POINT_DEMO_2[]
|
||||
"""
|
||||
|
||||
# tag::METABUNCH[]
|
||||
class MetaBunch(type): # <1>
|
||||
def __new__(meta_cls, cls_name, bases, cls_dict): # <2>
|
||||
|
||||
defaults = {} # <3>
|
||||
|
||||
def __init__(self, **kwargs): # <4>
|
||||
for name, default in defaults.items(): # <5>
|
||||
setattr(self, name, kwargs.pop(name, default))
|
||||
if kwargs: # <6>
|
||||
setattr(self, *kwargs.popitem())
|
||||
|
||||
def __repr__(self): # <7>
|
||||
rep = ', '.join(f'{name}={value!r}'
|
||||
for name, default in defaults.items()
|
||||
if (value := getattr(self, name)) != default)
|
||||
return f'{cls_name}({rep})'
|
||||
|
||||
new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__) # <8>
|
||||
|
||||
for name, value in cls_dict.items(): # <9>
|
||||
if name.startswith('__') and name.endswith('__'): # <10>
|
||||
if name in new_dict:
|
||||
raise AttributeError(f"Can't set {name!r} in {cls_name!r}")
|
||||
new_dict[name] = value
|
||||
else: # <11>
|
||||
new_dict['__slots__'].append(name)
|
||||
defaults[name] = value
|
||||
return super().__new__(meta_cls, cls_name, bases, new_dict) # <12>
|
||||
|
||||
|
||||
class Bunch(metaclass=MetaBunch): # <13>
|
||||
pass
|
||||
# end::METABUNCH[]
|
||||
59
24-class-metaprog/metabunch/from3.6/bunch_test.py
Normal file
59
24-class-metaprog/metabunch/from3.6/bunch_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from bunch import Bunch
|
||||
|
||||
class Point(Bunch):
|
||||
""" A point has x and y coordinates, defaulting to 0.0,
|
||||
and a color, defaulting to 'gray'—and nothing more,
|
||||
except what Python and the metaclass conspire to add,
|
||||
such as __init__ and __repr__
|
||||
"""
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
color = 'gray'
|
||||
|
||||
|
||||
def test_init_defaults():
|
||||
p = Point()
|
||||
assert repr(p) == 'Point()'
|
||||
|
||||
|
||||
def test_init():
|
||||
p = Point(x=1.2, y=3.4, color='red')
|
||||
assert repr(p) == "Point(x=1.2, y=3.4, color='red')"
|
||||
|
||||
|
||||
def test_init_wrong_argument():
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p = Point(x=1.2, y=3.4, flavor='coffee')
|
||||
assert "no attribute 'flavor'" in str(exc.value)
|
||||
|
||||
|
||||
def test_slots():
|
||||
p = Point()
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p.z = 5.6
|
||||
assert "no attribute 'z'" in str(exc.value)
|
||||
|
||||
|
||||
def test_dunder_permitted():
|
||||
class Cat(Bunch):
|
||||
name = ''
|
||||
weight = 0
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.weight} kg)'
|
||||
|
||||
cheshire = Cat(name='Cheshire')
|
||||
assert str(cheshire) == 'Cheshire (0 kg)'
|
||||
|
||||
|
||||
def test_dunder_forbidden():
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
class Cat(Bunch):
|
||||
name = ''
|
||||
weight = 0
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
assert "Can't set '__init__' in 'Cat'" in str(exc.value)
|
||||
85
24-class-metaprog/metabunch/nutshell3e/bunch.py
Normal file
85
24-class-metaprog/metabunch/nutshell3e/bunch.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import collections
|
||||
import warnings
|
||||
|
||||
class MetaBunch(type):
|
||||
"""
|
||||
Metaclass for new and improved "Bunch": implicitly defines
|
||||
__slots__, __init__ and __repr__ from variables bound in
|
||||
class scope.
|
||||
A class statement for an instance of MetaBunch (i.e., for a
|
||||
class whose metaclass is MetaBunch) must define only
|
||||
class-scope data attributes (and possibly special methods, but
|
||||
NOT __init__ and __repr__). MetaBunch removes the data
|
||||
attributes from class scope, snuggles them instead as items in
|
||||
a class-scope dict named __dflts__, and puts in the class a
|
||||
__slots__ with those attributes' names, an __init__ that takes
|
||||
as optional named arguments each of them (using the values in
|
||||
__dflts__ as defaults for missing ones), and a __repr__ that
|
||||
shows the repr of each attribute that differs from its default
|
||||
value (the output of __repr__ can be passed to __eval__ to make
|
||||
an equal instance, as per usual convention in the matter, if
|
||||
each non-default-valued attribute respects the convention too).
|
||||
|
||||
In v3, the order of data attributes remains the same as in the
|
||||
class body; in v2, there is no such guarantee.
|
||||
"""
|
||||
def __prepare__(name, *bases, **kwargs):
|
||||
# precious in v3—harmless although useless in v2
|
||||
return collections.OrderedDict()
|
||||
|
||||
def __new__(mcl, classname, bases, classdict):
|
||||
""" Everything needs to be done in __new__, since
|
||||
type.__new__ is where __slots__ are taken into account.
|
||||
"""
|
||||
# define as local functions the __init__ and __repr__ that
|
||||
# we'll use in the new class
|
||||
def __init__(self, **kw):
|
||||
""" Simplistic __init__: first set all attributes to
|
||||
default values, then override those explicitly
|
||||
passed in kw.
|
||||
"""
|
||||
for k in self.__dflts__:
|
||||
setattr(self, k, self.__dflts__[k])
|
||||
for k in kw:
|
||||
setattr(self, k, kw[k])
|
||||
def __repr__(self):
|
||||
""" Clever __repr__: show only attributes that differ
|
||||
from default values, for compactness.
|
||||
"""
|
||||
rep = ['{}={!r}'.format(k, getattr(self, k))
|
||||
for k in self.__dflts__
|
||||
if getattr(self, k) != self.__dflts__[k]
|
||||
]
|
||||
return '{}({})'.format(classname, ', '.join(rep))
|
||||
# build the newdict that we'll use as class-dict for the
|
||||
# new class
|
||||
newdict = { '__slots__':[],
|
||||
'__dflts__':collections.OrderedDict(),
|
||||
'__init__':__init__, '__repr__':__repr__, }
|
||||
for k in classdict:
|
||||
if k.startswith('__') and k.endswith('__'):
|
||||
# dunder methods: copy to newdict, or warn
|
||||
# about conflicts
|
||||
if k in newdict:
|
||||
warnings.warn(
|
||||
"Can't set attr {!r} in bunch-class {!r}".
|
||||
format(k, classname))
|
||||
else:
|
||||
newdict[k] = classdict[k]
|
||||
else:
|
||||
# class variables, store name in __slots__, and
|
||||
# name and value as an item in __dflts__
|
||||
newdict['__slots__'].append(k)
|
||||
newdict['__dflts__'][k] = classdict[k]
|
||||
# finally delegate the rest of the work to type.__new__
|
||||
return super(MetaBunch, mcl).__new__(
|
||||
mcl, classname, bases, newdict)
|
||||
|
||||
class Bunch(metaclass=MetaBunch):
|
||||
""" For convenience: inheriting from Bunch can be used to get
|
||||
the new metaclass (same as defining metaclass= yourself).
|
||||
|
||||
In v2, remove the (metaclass=MetaBunch) above and add
|
||||
instead __metaclass__=MetaBunch as the class body.
|
||||
"""
|
||||
pass
|
||||
38
24-class-metaprog/metabunch/nutshell3e/bunch_test.py
Normal file
38
24-class-metaprog/metabunch/nutshell3e/bunch_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
|
||||
from bunch import Bunch
|
||||
|
||||
class Point(Bunch):
|
||||
""" A point has x and y coordinates, defaulting to 0.0,
|
||||
and a color, defaulting to 'gray'—and nothing more,
|
||||
except what Python and the metaclass conspire to add,
|
||||
such as __init__ and __repr__
|
||||
"""
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
color = 'gray'
|
||||
|
||||
|
||||
def test_init_defaults():
|
||||
p = Point()
|
||||
assert repr(p) == 'Point()'
|
||||
|
||||
|
||||
def test_init():
|
||||
p = Point(x=1.2, y=3.4, color='red')
|
||||
assert repr(p) == "Point(x=1.2, y=3.4, color='red')"
|
||||
|
||||
|
||||
def test_init_wrong_argument():
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p = Point(x=1.2, y=3.4, flavor='coffee')
|
||||
assert "no attribute 'flavor'" in str(exc.value)
|
||||
|
||||
|
||||
def test_slots():
|
||||
p = Point()
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p.z = 5.6
|
||||
assert "no attribute 'z'" in str(exc.value)
|
||||
|
||||
|
||||
70
24-class-metaprog/metabunch/original/bunch.py
Normal file
70
24-class-metaprog/metabunch/original/bunch.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import warnings
|
||||
|
||||
class metaMetaBunch(type):
|
||||
"""
|
||||
metaclass for new and improved "Bunch": implicitly defines
|
||||
__slots__, __init__ and __repr__ from variables bound in class scope.
|
||||
|
||||
An instance of metaMetaBunch (a class whose metaclass is metaMetaBunch)
|
||||
defines only class-scope variables (and possibly special methods, but
|
||||
NOT __init__ and __repr__!). metaMetaBunch removes those variables from
|
||||
class scope, snuggles them instead as items in a class-scope dict named
|
||||
__dflts__, and puts in the class a __slots__ listing those variables'
|
||||
names, an __init__ that takes as optional keyword arguments each of
|
||||
them (using the values in __dflts__ as defaults for missing ones), and
|
||||
a __repr__ that shows the repr of each attribute that differs from its
|
||||
default value (the output of __repr__ can be passed to __eval__ to make
|
||||
an equal instance, as per the usual convention in the matter).
|
||||
"""
|
||||
|
||||
def __new__(cls, classname, bases, classdict):
|
||||
""" Everything needs to be done in __new__, since type.__new__ is
|
||||
where __slots__ are taken into account.
|
||||
"""
|
||||
|
||||
# define as local functions the __init__ and __repr__ that we'll
|
||||
# use in the new class
|
||||
|
||||
def __init__(self, **kw):
|
||||
""" Simplistic __init__: first set all attributes to default
|
||||
values, then override those explicitly passed in kw.
|
||||
"""
|
||||
for k in self.__dflts__: setattr(self, k, self.__dflts__[k])
|
||||
for k in kw: setattr(self, k, kw[k])
|
||||
|
||||
def __repr__(self):
|
||||
""" Clever __repr__: show only attributes that differ from the
|
||||
respective default values, for compactness.
|
||||
"""
|
||||
rep = [ '%s=%r' % (k, getattr(self, k)) for k in self.__dflts__
|
||||
if getattr(self, k) != self.__dflts__[k]
|
||||
]
|
||||
return '%s(%s)' % (classname, ', '.join(rep))
|
||||
|
||||
# build the newdict that we'll use as class-dict for the new class
|
||||
newdict = { '__slots__':[], '__dflts__':{},
|
||||
'__init__':__init__, '__repr__':__repr__, }
|
||||
|
||||
for k in classdict:
|
||||
if k.startswith('__'):
|
||||
# special methods &c: copy to newdict, warn about conflicts
|
||||
if k in newdict:
|
||||
warnings.warn("Can't set attr %r in bunch-class %r" % (
|
||||
k, classname))
|
||||
else:
|
||||
newdict[k] = classdict[k]
|
||||
else:
|
||||
# class variables, store name in __slots__ and name and
|
||||
# value as an item in __dflts__
|
||||
newdict['__slots__'].append(k)
|
||||
newdict['__dflts__'][k] = classdict[k]
|
||||
|
||||
# finally delegate the rest of the work to type.__new__
|
||||
return type.__new__(cls, classname, bases, newdict)
|
||||
|
||||
|
||||
class MetaBunch(metaclass=metaMetaBunch):
|
||||
""" For convenience: inheriting from MetaBunch can be used to get
|
||||
the new metaclass (same as defining __metaclass__ yourself).
|
||||
"""
|
||||
__metaclass__ = metaMetaBunch
|
||||
38
24-class-metaprog/metabunch/original/bunch_test.py
Normal file
38
24-class-metaprog/metabunch/original/bunch_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
|
||||
from bunch import MetaBunch
|
||||
|
||||
class Point(MetaBunch):
|
||||
""" A point has x and y coordinates, defaulting to 0.0,
|
||||
and a color, defaulting to 'gray'—and nothing more,
|
||||
except what Python and the metaclass conspire to add,
|
||||
such as __init__ and __repr__
|
||||
"""
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
color = 'gray'
|
||||
|
||||
|
||||
def test_init_defaults():
|
||||
p = Point()
|
||||
assert repr(p) == 'Point()'
|
||||
|
||||
|
||||
def test_init():
|
||||
p = Point(x=1.2, y=3.4, color='red')
|
||||
assert repr(p) == "Point(x=1.2, y=3.4, color='red')"
|
||||
|
||||
|
||||
def test_init_wrong_argument():
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p = Point(x=1.2, y=3.4, flavor='coffee')
|
||||
assert "no attribute 'flavor'" in str(exc.value)
|
||||
|
||||
|
||||
def test_slots():
|
||||
p = Point()
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p.z = 5.6
|
||||
assert "no attribute 'z'" in str(exc.value)
|
||||
|
||||
|
||||
41
24-class-metaprog/metabunch/pre3.6/bunch.py
Normal file
41
24-class-metaprog/metabunch/pre3.6/bunch.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import collections
|
||||
import warnings
|
||||
|
||||
class MetaBunch(type):
|
||||
def __prepare__(name, *bases, **kwargs):
|
||||
return collections.OrderedDict()
|
||||
|
||||
def __new__(meta_cls, cls_name, bases, cls_dict):
|
||||
def __init__(self, **kw):
|
||||
for k in self.__defaults__:
|
||||
setattr(self, k, self.__defaults__[k])
|
||||
for k in kw:
|
||||
setattr(self, k, kw[k])
|
||||
|
||||
def __repr__(self):
|
||||
rep = ['{}={!r}'.format(k, getattr(self, k))
|
||||
for k in self.__defaults__
|
||||
if getattr(self, k) != self.__defaults__[k]
|
||||
]
|
||||
return '{}({})'.format(cls_name, ', '.join(rep))
|
||||
|
||||
new_dict = { '__slots__':[],
|
||||
'__defaults__':collections.OrderedDict(),
|
||||
'__init__':__init__, '__repr__':__repr__, }
|
||||
|
||||
for k in cls_dict:
|
||||
if k.startswith('__') and k.endswith('__'):
|
||||
if k in new_dict:
|
||||
warnings.warn(
|
||||
"Can't set attr {!r} in bunch-class {!r}".
|
||||
format(k, cls_name))
|
||||
else:
|
||||
new_dict[k] = cls_dict[k]
|
||||
else:
|
||||
new_dict['__slots__'].append(k)
|
||||
new_dict['__defaults__'][k] = cls_dict[k]
|
||||
|
||||
return super().__new__(meta_cls, cls_name, bases, new_dict)
|
||||
|
||||
class Bunch(metaclass=MetaBunch):
|
||||
pass
|
||||
38
24-class-metaprog/metabunch/pre3.6/bunch_test.py
Normal file
38
24-class-metaprog/metabunch/pre3.6/bunch_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
|
||||
from bunch import Bunch
|
||||
|
||||
class Point(Bunch):
|
||||
""" A point has x and y coordinates, defaulting to 0.0,
|
||||
and a color, defaulting to 'gray'—and nothing more,
|
||||
except what Python and the metaclass conspire to add,
|
||||
such as __init__ and __repr__
|
||||
"""
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
color = 'gray'
|
||||
|
||||
|
||||
def test_init_defaults():
|
||||
p = Point()
|
||||
assert repr(p) == 'Point()'
|
||||
|
||||
|
||||
def test_init():
|
||||
p = Point(x=1.2, y=3.4, color='red')
|
||||
assert repr(p) == "Point(x=1.2, y=3.4, color='red')"
|
||||
|
||||
|
||||
def test_init_wrong_argument():
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p = Point(x=1.2, y=3.4, flavor='coffee')
|
||||
assert "no attribute 'flavor'" in str(exc.value)
|
||||
|
||||
|
||||
def test_slots():
|
||||
p = Point()
|
||||
with pytest.raises(AttributeError) as exc:
|
||||
p.z = 5.6
|
||||
assert "no attribute 'z'" in str(exc.value)
|
||||
|
||||
|
||||
1
24-class-metaprog/persistent/.gitignore
vendored
Normal file
1
24-class-metaprog/persistent/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.db
|
||||
161
24-class-metaprog/persistent/dblib.py
Normal file
161
24-class-metaprog/persistent/dblib.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# SQLite3 does not support parameterized table and field names,
|
||||
# for CREATE TABLE and PRAGMA so we must use Python string formatting.
|
||||
# Applying `check_identifier` to parameters prevents SQL injection.
|
||||
|
||||
import sqlite3
|
||||
from typing import NamedTuple, Optional, Iterator, Any
|
||||
|
||||
DEFAULT_DB_PATH = ':memory:'
|
||||
CONNECTION: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
||||
class NoConnection(Exception):
|
||||
"""Call connect() to open connection."""
|
||||
|
||||
|
||||
class SchemaMismatch(ValueError):
|
||||
"""The table schema doesn't match the class."""
|
||||
|
||||
def __init__(self, table_name):
|
||||
self.table_name = table_name
|
||||
|
||||
|
||||
class NoSuchRecord(LookupError):
|
||||
"""The given primary key does not exist."""
|
||||
|
||||
def __init__(self, pk):
|
||||
self.pk = pk
|
||||
|
||||
|
||||
class UnexpectedMultipleResults(Exception):
|
||||
"""Query returned more than 1 row."""
|
||||
|
||||
|
||||
SQLType = str
|
||||
|
||||
TypeMap = dict[type, SQLType]
|
||||
|
||||
SQL_TYPES: TypeMap = {
|
||||
int: 'INTEGER',
|
||||
str: 'TEXT',
|
||||
float: 'REAL',
|
||||
bytes: 'BLOB',
|
||||
}
|
||||
|
||||
|
||||
class ColumnSchema(NamedTuple):
|
||||
name: str
|
||||
sql_type: SQLType
|
||||
|
||||
|
||||
FieldMap = dict[str, type]
|
||||
|
||||
|
||||
def check_identifier(name: str) -> None:
|
||||
if not name.isidentifier():
|
||||
raise ValueError(f'{name!r} is not an identifier')
|
||||
|
||||
|
||||
def connect(db_path: str = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
||||
global CONNECTION
|
||||
CONNECTION = sqlite3.connect(db_path)
|
||||
CONNECTION.row_factory = sqlite3.Row
|
||||
return CONNECTION
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
if CONNECTION is None:
|
||||
raise NoConnection()
|
||||
return CONNECTION
|
||||
|
||||
|
||||
def gen_columns_sql(fields: FieldMap) -> Iterator[ColumnSchema]:
|
||||
for name, py_type in fields.items():
|
||||
check_identifier(name)
|
||||
try:
|
||||
sql_type = SQL_TYPES[py_type]
|
||||
except KeyError as e:
|
||||
raise ValueError(f'type {py_type!r} is not supported') from e
|
||||
yield ColumnSchema(name, sql_type)
|
||||
|
||||
|
||||
def make_schema_sql(table_name: str, fields: FieldMap) -> str:
|
||||
check_identifier(table_name)
|
||||
pk = 'pk INTEGER PRIMARY KEY,'
|
||||
spcs = ' ' * 4
|
||||
columns = ',\n '.join(
|
||||
f'{field_name} {sql_type}'
|
||||
for field_name, sql_type in gen_columns_sql(fields)
|
||||
)
|
||||
return f'CREATE TABLE {table_name} (\n{spcs}{pk}\n{spcs}{columns}\n)'
|
||||
|
||||
|
||||
def create_table(table_name: str, fields: FieldMap) -> None:
|
||||
con = get_connection()
|
||||
con.execute(make_schema_sql(table_name, fields))
|
||||
|
||||
|
||||
def read_columns_sql(table_name: str) -> list[ColumnSchema]:
|
||||
check_identifier(table_name)
|
||||
con = get_connection()
|
||||
rows = con.execute(f'PRAGMA table_info({table_name!r})')
|
||||
return [ColumnSchema(r['name'], r['type']) for r in rows]
|
||||
|
||||
|
||||
def valid_table(table_name: str, fields: FieldMap) -> bool:
|
||||
table_columns = read_columns_sql(table_name)
|
||||
return set(gen_columns_sql(fields)) <= set(table_columns)
|
||||
|
||||
|
||||
def ensure_table(table_name: str, fields: FieldMap) -> None:
|
||||
table_columns = read_columns_sql(table_name)
|
||||
if len(table_columns) == 0:
|
||||
create_table(table_name, fields)
|
||||
if not valid_table(table_name, fields):
|
||||
raise SchemaMismatch(table_name)
|
||||
|
||||
|
||||
def insert_record(table_name: str, data: dict[str, Any]) -> int:
|
||||
check_identifier(table_name)
|
||||
con = get_connection()
|
||||
placeholders = ', '.join(['?'] * len(data))
|
||||
sql = f'INSERT INTO {table_name} VALUES (NULL, {placeholders})'
|
||||
cursor = con.execute(sql, tuple(data.values()))
|
||||
pk = cursor.lastrowid
|
||||
con.commit()
|
||||
cursor.close()
|
||||
return pk
|
||||
|
||||
|
||||
def fetch_record(table_name: str, pk: int) -> sqlite3.Row:
|
||||
check_identifier(table_name)
|
||||
con = get_connection()
|
||||
sql = f'SELECT * FROM {table_name} WHERE pk = ? LIMIT 2'
|
||||
result = list(con.execute(sql, (pk,)))
|
||||
if len(result) == 0:
|
||||
raise NoSuchRecord(pk)
|
||||
elif len(result) == 1:
|
||||
return result[0]
|
||||
else:
|
||||
raise UnexpectedMultipleResults()
|
||||
|
||||
|
||||
def update_record(
|
||||
table_name: str, pk: int, data: dict[str, Any]
|
||||
) -> tuple[str, tuple[Any, ...]]:
|
||||
check_identifier(table_name)
|
||||
con = get_connection()
|
||||
names = ', '.join(data.keys())
|
||||
placeholders = ', '.join(['?'] * len(data))
|
||||
values = tuple(data.values()) + (pk,)
|
||||
sql = f'UPDATE {table_name} SET ({names}) = ({placeholders}) WHERE pk = ?'
|
||||
con.execute(sql, values)
|
||||
con.commit()
|
||||
return sql, values
|
||||
|
||||
|
||||
def delete_record(table_name: str, pk: int) -> sqlite3.Cursor:
|
||||
con = get_connection()
|
||||
check_identifier(table_name)
|
||||
sql = f'DELETE FROM {table_name} WHERE pk = ?'
|
||||
return con.execute(sql, (pk,))
|
||||
131
24-class-metaprog/persistent/dblib_test.py
Normal file
131
24-class-metaprog/persistent/dblib_test.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
from dblib import gen_columns_sql, make_schema_sql, connect, read_columns_sql
|
||||
from dblib import ColumnSchema, insert_record, fetch_record, update_record
|
||||
from dblib import NoSuchRecord, delete_record, valid_table
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_movies_sql():
|
||||
sql = '''
|
||||
CREATE TABLE movies (
|
||||
pk INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
revenue REAL
|
||||
)
|
||||
'''
|
||||
return dedent(sql).strip()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'fields, expected',
|
||||
[
|
||||
(
|
||||
dict(title=str, awards=int),
|
||||
[('title', 'TEXT'), ('awards', 'INTEGER')],
|
||||
),
|
||||
(
|
||||
dict(picture=bytes, score=float),
|
||||
[('picture', 'BLOB'), ('score', 'REAL')],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_gen_columns_sql(fields, expected):
|
||||
result = list(gen_columns_sql(fields))
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_make_schema_sql(create_movies_sql):
|
||||
fields = dict(title=str, revenue=float)
|
||||
result = make_schema_sql('movies', fields)
|
||||
assert result == create_movies_sql
|
||||
|
||||
|
||||
def test_read_columns_sql(create_movies_sql):
|
||||
expected = [
|
||||
ColumnSchema(name='pk', sql_type='INTEGER'),
|
||||
ColumnSchema(name='title', sql_type='TEXT'),
|
||||
ColumnSchema(name='revenue', sql_type='REAL'),
|
||||
]
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
result = read_columns_sql('movies')
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_read_columns_sql_no_such_table(create_movies_sql):
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
result = read_columns_sql('no_such_table')
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_insert_record(create_movies_sql):
|
||||
fields = dict(title='Frozen', revenue=1_290_000_000)
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
for _ in range(3):
|
||||
result = insert_record('movies', fields)
|
||||
assert result == 3
|
||||
|
||||
|
||||
def test_fetch_record(create_movies_sql):
|
||||
fields = dict(title='Frozen', revenue=1_290_000_000)
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
pk = insert_record('movies', fields)
|
||||
row = fetch_record('movies', pk)
|
||||
assert tuple(row) == (1, 'Frozen', 1_290_000_000.0)
|
||||
|
||||
|
||||
def test_fetch_record_no_such_pk(create_movies_sql):
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
with pytest.raises(NoSuchRecord) as e:
|
||||
fetch_record('movies', 42)
|
||||
assert e.value.pk == 42
|
||||
|
||||
|
||||
def test_update_record(create_movies_sql):
|
||||
fields = dict(title='Frozen', revenue=1_290_000_000)
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
pk = insert_record('movies', fields)
|
||||
fields['revenue'] = 1_299_999_999
|
||||
sql, values = update_record('movies', pk, fields)
|
||||
row = fetch_record('movies', pk)
|
||||
assert sql == 'UPDATE movies SET (title, revenue) = (?, ?) WHERE pk = ?'
|
||||
assert values == ('Frozen', 1_299_999_999, 1)
|
||||
assert tuple(row) == (1, 'Frozen', 1_299_999_999.0)
|
||||
|
||||
|
||||
def test_delete_record(create_movies_sql):
|
||||
fields = dict(title='Frozen', revenue=1_290_000_000)
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
pk = insert_record('movies', fields)
|
||||
delete_record('movies', pk)
|
||||
with pytest.raises(NoSuchRecord) as e:
|
||||
fetch_record('movies', pk)
|
||||
assert e.value.pk == pk
|
||||
|
||||
|
||||
def test_persistent_valid_table(create_movies_sql):
|
||||
fields = dict(title=str, revenue=float)
|
||||
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
con.commit()
|
||||
assert valid_table('movies', fields)
|
||||
|
||||
|
||||
def test_persistent_valid_table_false(create_movies_sql):
|
||||
# year field not in movies_sql
|
||||
fields = dict(title=str, revenue=float, year=int)
|
||||
|
||||
with connect() as con:
|
||||
con.execute(create_movies_sql)
|
||||
con.commit()
|
||||
assert not valid_table('movies', fields)
|
||||
143
24-class-metaprog/persistent/persistlib.py
Normal file
143
24-class-metaprog/persistent/persistlib.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
A ``Persistent`` class definition::
|
||||
|
||||
>>> class Movie(Persistent):
|
||||
... title: str
|
||||
... year: int
|
||||
... box_office: float
|
||||
|
||||
Implemented behavior::
|
||||
|
||||
>>> Movie._connect() # doctest: +ELLIPSIS
|
||||
<sqlite3.Connection object at 0x...>
|
||||
>>> movie = Movie(title='The Godfather', year=1972, box_office=137)
|
||||
>>> movie.title
|
||||
'The Godfather'
|
||||
>>> movie.box_office
|
||||
137.0
|
||||
|
||||
Instances always have a ``._pk`` attribute, but it is ``None`` until the
|
||||
object is saved::
|
||||
|
||||
>>> movie._pk is None
|
||||
True
|
||||
>>> movie._save()
|
||||
1
|
||||
>>> movie._pk
|
||||
1
|
||||
|
||||
Delete the in-memory ``movie``, and fetch the record from the database,
|
||||
using ``Movie[pk]``—item access on the class itself::
|
||||
|
||||
>>> del movie
|
||||
>>> film = Movie[1]
|
||||
>>> film
|
||||
Movie(title='The Godfather', year=1972, box_office=137.0, _pk=1)
|
||||
|
||||
By default, the table name is the class name lowercased, with an appended
|
||||
"s" for plural::
|
||||
|
||||
>>> Movie._TABLE_NAME
|
||||
'movies'
|
||||
|
||||
If needed, a custom table name can be given as a keyword argument in the
|
||||
class declaration::
|
||||
|
||||
>>> class Aircraft(Persistent, table='aircraft'):
|
||||
... registration: str
|
||||
... model: str
|
||||
...
|
||||
>>> Aircraft._TABLE_NAME
|
||||
'aircraft'
|
||||
|
||||
"""
|
||||
|
||||
from typing import Any, ClassVar, get_type_hints
|
||||
|
||||
import dblib as db
|
||||
|
||||
|
||||
class Field:
|
||||
def __init__(self, name: str, py_type: type) -> None:
|
||||
self.name = name
|
||||
self.type = py_type
|
||||
|
||||
def __set__(self, instance: 'Persistent', value: Any) -> None:
|
||||
try:
|
||||
value = self.type(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
type_name = self.type.__name__
|
||||
msg = f'{value!r} is not compatible with {self.name}:{type_name}.'
|
||||
raise TypeError(msg) from e
|
||||
instance.__dict__[self.name] = value
|
||||
|
||||
|
||||
class Persistent:
|
||||
_TABLE_NAME: ClassVar[str]
|
||||
_TABLE_READY: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def _fields(cls) -> dict[str, type]:
|
||||
return {
|
||||
name: py_type
|
||||
for name, py_type in get_type_hints(cls).items()
|
||||
if not name.startswith('_')
|
||||
}
|
||||
|
||||
def __init_subclass__(cls, *, table: str = '', **kwargs: Any):
|
||||
super().__init_subclass__(**kwargs) # type:ignore
|
||||
cls._TABLE_NAME = table if table else cls.__name__.lower() + 's'
|
||||
for name, py_type in cls._fields().items():
|
||||
setattr(cls, name, Field(name, py_type))
|
||||
|
||||
def __init__(self, *, _pk=None, **kwargs):
|
||||
field_names = self._asdict().keys()
|
||||
for name, arg in kwargs.items():
|
||||
if name not in field_names:
|
||||
msg = f'{self.__class__.__name__!r} has no attribute {name!r}'
|
||||
raise AttributeError(msg)
|
||||
setattr(self, name, arg)
|
||||
self._pk = _pk
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kwargs = ', '.join(
|
||||
f'{key}={value!r}' for key, value in self._asdict().items()
|
||||
)
|
||||
cls_name = self.__class__.__name__
|
||||
if self._pk is None:
|
||||
return f'{cls_name}({kwargs})'
|
||||
return f'{cls_name}({kwargs}, _pk={self._pk})'
|
||||
|
||||
def _asdict(self) -> dict[str, Any]:
|
||||
return {
|
||||
name: getattr(self, name)
|
||||
for name, attr in self.__class__.__dict__.items()
|
||||
if isinstance(attr, Field)
|
||||
}
|
||||
|
||||
|
||||
# database methods
|
||||
|
||||
@staticmethod
|
||||
def _connect(db_path: str = db.DEFAULT_DB_PATH):
|
||||
return db.connect(db_path)
|
||||
|
||||
@classmethod
|
||||
def _ensure_table(cls) -> str:
|
||||
if not cls._TABLE_READY:
|
||||
db.ensure_table(cls._TABLE_NAME, cls._fields())
|
||||
cls._TABLE_READY = True
|
||||
return cls._TABLE_NAME
|
||||
|
||||
def __class_getitem__(cls, pk: int) -> 'Persistent':
|
||||
field_names = ['_pk'] + list(cls._fields())
|
||||
values = db.fetch_record(cls._TABLE_NAME, pk)
|
||||
return cls(**dict(zip(field_names, values)))
|
||||
|
||||
def _save(self) -> int:
|
||||
table = self.__class__._ensure_table()
|
||||
if self._pk is None:
|
||||
self._pk = db.insert_record(table, self._asdict())
|
||||
else:
|
||||
db.update_record(table, self._pk, self._asdict())
|
||||
return self._pk
|
||||
37
24-class-metaprog/persistent/persistlib_test.py
Normal file
37
24-class-metaprog/persistent/persistlib_test.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from persistlib import Persistent
|
||||
|
||||
|
||||
def test_field_descriptor_validation_type_error():
|
||||
class Cat(Persistent):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight=None)
|
||||
|
||||
assert str(e.value) == 'None is not compatible with weight:float.'
|
||||
|
||||
|
||||
def test_field_descriptor_validation_value_error():
|
||||
class Cat(Persistent):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(TypeError) as e:
|
||||
felix = Cat(name='Felix', weight='half stone')
|
||||
|
||||
assert str(e.value) == "'half stone' is not compatible with weight:float."
|
||||
|
||||
|
||||
def test_constructor_attribute_error():
|
||||
class Cat(Persistent):
|
||||
name: str
|
||||
weight: float
|
||||
|
||||
with pytest.raises(AttributeError) as e:
|
||||
felix = Cat(name='Felix', weight=3.2, age=7)
|
||||
|
||||
assert str(e.value) == "'Cat' has no attribute 'age'"
|
||||
5
24-class-metaprog/qualname/fakedjango.py
Normal file
5
24-class-metaprog/qualname/fakedjango.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class models:
|
||||
class Model:
|
||||
"nothing to see here"
|
||||
class IntegerField:
|
||||
"nothing to see here"
|
||||
13
24-class-metaprog/qualname/models.py
Executable file
13
24-class-metaprog/qualname/models.py
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from fakedjango import models
|
||||
|
||||
class Ox(models.Model):
|
||||
horn_length = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
ordering = ['horn_length']
|
||||
verbose_name_plural = 'oxen'
|
||||
|
||||
print(Ox.Meta.__name__)
|
||||
print(Ox.Meta.__qualname__)
|
||||
41
24-class-metaprog/sentinel/sentinel.py
Normal file
41
24-class-metaprog/sentinel/sentinel.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
This module provides a ``Sentinel`` class that can be used directly as a
|
||||
sentinel singleton, or subclassed if a distinct sentinel singleton is needed.
|
||||
|
||||
The ``repr`` of a ``Sentinel`` class is its name::
|
||||
|
||||
>>> class Missing(Sentinel): pass
|
||||
>>> Missing
|
||||
Missing
|
||||
|
||||
If a different ``repr`` is required,
|
||||
you can define it as a class attribute::
|
||||
|
||||
>>> class CustomRepr(Sentinel):
|
||||
... repr = '<CustomRepr>'
|
||||
...
|
||||
>>> CustomRepr
|
||||
<CustomRepr>
|
||||
|
||||
``Sentinel`` classes cannot be instantiated::
|
||||
|
||||
>>> Missing()
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: 'Missing' is a sentinel and cannot be instantiated
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class _SentinelMeta(type):
|
||||
def __repr__(cls):
|
||||
try:
|
||||
return cls.repr
|
||||
except AttributeError:
|
||||
return cls.__name__
|
||||
|
||||
|
||||
class Sentinel(metaclass=_SentinelMeta):
|
||||
def __new__(cls):
|
||||
msg = 'is a sentinel and cannot be instantiated'
|
||||
raise TypeError(f"'{cls!r}' {msg}")
|
||||
41
24-class-metaprog/sentinel/sentinel_test.py
Normal file
41
24-class-metaprog/sentinel/sentinel_test.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import pickle
|
||||
|
||||
import pytest
|
||||
|
||||
from sentinel import Sentinel
|
||||
|
||||
|
||||
class PlainSentinel(Sentinel):
|
||||
pass
|
||||
|
||||
|
||||
class SentinelCustomRepr(Sentinel):
|
||||
repr = '***SentinelRepr***'
|
||||
|
||||
|
||||
def test_repr():
|
||||
assert repr(PlainSentinel) == 'PlainSentinel'
|
||||
|
||||
|
||||
def test_cannot_instantiate():
|
||||
with pytest.raises(TypeError) as e:
|
||||
PlainSentinel()
|
||||
msg = "'PlainSentinel' is a sentinel and cannot be instantiated"
|
||||
assert msg in str(e.value)
|
||||
|
||||
|
||||
def test_custom_repr():
|
||||
assert repr(SentinelCustomRepr) == '***SentinelRepr***'
|
||||
|
||||
|
||||
def test_pickle():
|
||||
s = pickle.dumps(SentinelCustomRepr)
|
||||
ps = pickle.loads(s)
|
||||
assert ps is SentinelCustomRepr
|
||||
|
||||
|
||||
def test_sentinel_comes_ready_to_use():
|
||||
assert repr(Sentinel) == 'Sentinel'
|
||||
s = pickle.dumps(Sentinel)
|
||||
ps = pickle.loads(s)
|
||||
assert ps is Sentinel
|
||||
19
24-class-metaprog/setattr/example_from_leo.py
Executable file
19
24-class-metaprog/setattr/example_from_leo.py
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
class Foo:
|
||||
@property
|
||||
def bar(self):
|
||||
return self._bar
|
||||
|
||||
@bar.setter
|
||||
def bar(self, value):
|
||||
self._bar = value
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
print(f'setting {name!r} to {value!r}')
|
||||
super().__setattr__(name, value)
|
||||
|
||||
o = Foo()
|
||||
o.bar = 8
|
||||
print(o.bar)
|
||||
print(o._bar)
|
||||
48
24-class-metaprog/slots/slots_timing.py
Executable file
48
24-class-metaprog/slots/slots_timing.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
class Wrong:
|
||||
|
||||
def __init_subclass__(subclass):
|
||||
subclass.__slots__ = ('x', 'y')
|
||||
|
||||
|
||||
class Klass0(Wrong):
|
||||
pass
|
||||
|
||||
|
||||
o = Klass0()
|
||||
o.z = 3
|
||||
print('o.z = 3 # did not raise Attribute error because __slots__ was created too late')
|
||||
|
||||
|
||||
class Correct1(type):
|
||||
|
||||
def __new__(meta_cls, cls_name, bases, cls_dict):
|
||||
cls_dict['__slots__'] = ('x', 'y')
|
||||
return super().__new__(
|
||||
meta_cls, cls_name, bases, cls_dict)
|
||||
|
||||
|
||||
class Klass1(metaclass=Correct1):
|
||||
pass
|
||||
|
||||
o = Klass1()
|
||||
try:
|
||||
o.z = 3
|
||||
except AttributeError as e:
|
||||
print('Raised as expected:', e)
|
||||
|
||||
|
||||
class Correct2(type):
|
||||
def __prepare__(name, bases):
|
||||
return dict(__slots__=('x', 'y'))
|
||||
|
||||
class Klass2(metaclass=Correct2):
|
||||
pass
|
||||
|
||||
o = Klass2()
|
||||
try:
|
||||
o.z = 3
|
||||
except AttributeError as e:
|
||||
print('Raised as expected:', e)
|
||||
|
||||
65
24-class-metaprog/tinyenums/microenum.py
Normal file
65
24-class-metaprog/tinyenums/microenum.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# This is an implementation of an idea by João S. O. Bueno (@gwidion)
|
||||
# shared privately with me, with permission to use in Fluent Python 2e.
|
||||
|
||||
"""
|
||||
Testing ``WilyDict``::
|
||||
|
||||
>>> adict = WilyDict()
|
||||
>>> len(adict)
|
||||
0
|
||||
>>> adict['first']
|
||||
0
|
||||
>>> adict
|
||||
{'first': 0}
|
||||
>>> adict['second']
|
||||
1
|
||||
>>> adict['third']
|
||||
2
|
||||
>>> len(adict)
|
||||
3
|
||||
>>> adict
|
||||
{'first': 0, 'second': 1, 'third': 2}
|
||||
>>> adict['__magic__']
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
KeyError: '__magic__'
|
||||
|
||||
Testing ``MicroEnum``::
|
||||
|
||||
>>> class Flavor(MicroEnum):
|
||||
... cocoa
|
||||
... coconut
|
||||
... vanilla
|
||||
>>> Flavor.cocoa, Flavor.vanilla
|
||||
(0, 2)
|
||||
>>> Flavor[1]
|
||||
'coconut'
|
||||
"""
|
||||
|
||||
|
||||
class WilyDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__next_value = 0
|
||||
|
||||
def __missing__(self, key):
|
||||
if key.startswith('__') and key.endswith('__'):
|
||||
raise KeyError(key)
|
||||
self[key] = value = self.__next_value
|
||||
self.__next_value += 1
|
||||
return value
|
||||
|
||||
|
||||
class MicroEnumMeta(type):
|
||||
def __prepare__(name, bases, **kwargs):
|
||||
return WilyDict()
|
||||
|
||||
def __getitem__(cls, key):
|
||||
for k, v in cls.__dict__.items():
|
||||
if v == key:
|
||||
return k
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class MicroEnum(metaclass=MicroEnumMeta):
|
||||
pass
|
||||
17
24-class-metaprog/tinyenums/microenum_demo.py
Normal file
17
24-class-metaprog/tinyenums/microenum_demo.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Testing ``Flavor``::
|
||||
|
||||
>>> Flavor.cocoa, Flavor.coconut, Flavor.vanilla
|
||||
(0, 1, 2)
|
||||
>>> Flavor[1]
|
||||
'coconut'
|
||||
|
||||
"""
|
||||
|
||||
from microenum import MicroEnum
|
||||
|
||||
|
||||
class Flavor(MicroEnum):
|
||||
cocoa
|
||||
coconut
|
||||
vanilla
|
||||
41
24-class-metaprog/tinyenums/nanoenum.py
Normal file
41
24-class-metaprog/tinyenums/nanoenum.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# This is a simplification of an idea by João S. O. Bueno (@gwidion)
|
||||
# shared privately with me, with permission to use in Fluent Python 2e.
|
||||
|
||||
"""
|
||||
Testing ``KeyIsValueDict``::
|
||||
|
||||
>>> adict = KeyIsValueDict()
|
||||
>>> len(adict)
|
||||
0
|
||||
>>> adict['first']
|
||||
'first'
|
||||
>>> adict
|
||||
{'first': 'first'}
|
||||
>>> adict['second']
|
||||
'second'
|
||||
>>> len(adict)
|
||||
2
|
||||
>>> adict
|
||||
{'first': 'first', 'second': 'second'}
|
||||
>>> adict['__magic__']
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
KeyError: '__magic__'
|
||||
"""
|
||||
|
||||
|
||||
class KeyIsValueDict(dict):
|
||||
def __missing__(self, key):
|
||||
if key.startswith('__') and key.endswith('__'):
|
||||
raise KeyError(key)
|
||||
self[key] = key
|
||||
return key
|
||||
|
||||
|
||||
class NanoEnumMeta(type):
|
||||
def __prepare__(name, bases, **kwargs):
|
||||
return KeyIsValueDict()
|
||||
|
||||
|
||||
class NanoEnum(metaclass=NanoEnumMeta):
|
||||
pass
|
||||
17
24-class-metaprog/tinyenums/nanoenum_demo.py
Normal file
17
24-class-metaprog/tinyenums/nanoenum_demo.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Testing ``Flavor``::
|
||||
|
||||
>>> Flavor.coconut
|
||||
'coconut'
|
||||
>>> Flavor.cocoa, Flavor.vanilla
|
||||
('cocoa', 'vanilla')
|
||||
|
||||
"""
|
||||
|
||||
from nanoenum import NanoEnum
|
||||
|
||||
|
||||
class Flavor(NanoEnum):
|
||||
cocoa
|
||||
coconut
|
||||
vanilla
|
||||
Reference in New Issue
Block a user