renumbering chapters >= 19

This commit is contained in:
Luciano Ramalho
2021-09-10 12:34:39 -03:00
parent cbd13885fc
commit 4ae4096c4c
154 changed files with 7 additions and 1134 deletions

View 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[]

View 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)

View 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!

View 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[]

View 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[]

View 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

View 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[]

View 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[]

View 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[]

View 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[]

View 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)

View 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"

View 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)

View 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[]

View 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"

View 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)

View 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[]

View 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"

View 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[]

View 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')

View 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')

View 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[]

View 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[]

View 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[]

View 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

View 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

View 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.

View 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[]

View 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)

View 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

View 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)

View 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

View 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)

View 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

View 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)

View File

@@ -0,0 +1 @@
*.db

View 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,))

View 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)

View 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

View 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'"

View File

@@ -0,0 +1,5 @@
class models:
class Model:
"nothing to see here"
class IntegerField:
"nothing to see here"

View 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__)

View 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}")

View 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

View 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)

View 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)

View 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

View 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

View 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

View 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