Motivation
In the standard Python library enum
, a subclass of Enum
would create all its members upon class creation. This can be a huge waste of time and memory, especially if the class contains lots of members but only a fraction of them would ever get called.
As a practice project on metaclasses, I decided to implement a lazy version of Enum
. This is not intended to be a full-fledged library, so I didn't implement most of the features in the standard enum
library, just some basic functionalities.
In LazyEnum
, a member would be created only when we ask to and it would be created only once. For example, let say MyClass
is a subclass of LazyEnum
. The first time we call MyClass(1)
an object of type MyClass
is created. Any subsequent call to MyClass(1)
simply return the same object. Moreover, I want to put some validation on member creation, say we may only allow n
to be a positive integer when calling MyClass(n)
.
This idea is inspired by Ethan Furman's reply to my previous question here and also a discussion on Udemy with Fred Baptiste, who is the instructor of the Python 3 Deep Dive series.
Before looking at the code, let me provide an example of how to use LazyEnum
.
Example
from lazyenum import LazyEnum
class Product(LazyEnum):
def _validate_identifier_value(product_id):
# special method used by metaclass for validation
return isinstance(product_id, int) and (1001 <= product_id <= 9999)
COMPANY_NAME = 'Our Example Company'
def __init__(self, product_id, product_title):
# no need to store 'product_id' as instance attribute
self.product_title = product_title
def __repr__(self):
return f'Product({self.product_id!r}, {self.product_title!r})'
Remark:
LazyEnum
is created by a private metaclass_LazyEnumMeta
.- The first non-self parameter of
__init__
is automatically grabbed by the metaclass and cached, so we don't need to set it as an instance attribute. If we writeself.product_id = product_id
, it would raise an error when we try to initialize a new member. This parameter is called the identifier field and its value is called the identifier value, which uniquely identify each member. - The metaclass would look for a method named
_validate_identifier_value
and use it for validation. It can be defined as a static method or a class method, but if we define it as a class method, we would need to decorate it with@classmethod
. Here we just define it as a static method.
Let us see some example outputs. First, we can initialize a member as usual and call it by its identifier value.
>>> prod1 = Product(1001, 'Our Nice First Product')
>>> Product(1001)
Product(1001, 'Our Nice First Product')
>>> prod1 is Product(1001)
True
We can get the identifier value by using .identifier_field
or directly calling the instance attribute (.product_id
in this case). The .identifier_field
would give us a nametuple called Identifier
, whose first entry is the attribute name and the second entry is the value.
>>> prod1.identifier_field
Identifier(field_name='product_id', value=1001)
>>> prod1.product_id
1001
Error would be raised if we attempt to create a new member with an existing identifier value. Of course, the same thing would happen if we use an invalid identifier value.
>>> Product(1001, 'This Is Still The First Product')
ValueError: Member with identifier value 1001 already exists. Cannont pass additional arguments ('This Is Still The First Product',) or {}.
>>> Product(1, 'Product With Invaild ID')
ValueError: Identifier field 'product_id' has invalid value 1.
In the regular Enum
, you can set aliases to a member. Right now we didn't set any alias, but we can do so using dot notation and see all aliases of a member using .all_aliases
. We can also simultaneously create a new member and set an alias to it.
>>> prod1.all_aliases
[]
>>> Product.product_one = prod1
>>> Product.first_product = Product.product_one
>>> prod1.all_aliases
['product_one', 'first_product']
>>>
>>> Product.product_two = Product(1002, 'The Amazing Second Product')
>>> Product.product_two
Product(1002, 'The Amazing Second Product')
>>> Product(1002).all_aliases
['product_two']
But be careful, we may accidentally overwrite other class attributes.
>>> Product.COMPANY_NAME
'Our Example Company'
>>> Product.COMPANY_NAME = prod1
>>> prod1.all_aliases
['product_one', 'first_product', 'COMPANY_NAME']
>>> Product.COMPANY_NAME
Product(1001, 'Our Nice First Product')
>>>
>>> Product.COMPANY_NAME = 'Our Example Company'
>>> prod1.all_aliases
['product_one', 'first_product']
We can change instance attributes that are not the identifier field. Attempting to change the identifier field would raise an error.
>>> prod1.product_title = 'First Product With New Name'
>>> prod1
Product(1001, 'First Product With New Name')
>>> prod1.product_id = 2001
AttributeError: can't set attribute
We can iterate over the class members.
>>> Product(1003, 'Even More Amazing Third Product')
Product(1003, 'Even More Amazing Third Product')
>>> for prod in Product: print(prod)
Product(1001, 'First Product With New Name')
Product(1002, 'The Amazing Second Product')
Product(1003, 'Even More Amazing Third Product')
>>> len(Product)
3
Finally, the class has properties .identifier_value_map
and .alias_to_member_map
, which help inspect all members. Note that we didn't set any alias to Product(1003)
.
>>> Product.identifier_value_map
mappingproxy({
1001: Product(1001, 'First Product With New Name'),
1002: Product(1002, 'The Amazing Second Product'),
1003: Product(1003, 'Even More Amazing Third Product')
})
>>> Product.alias_to_member_map
mappingproxy(OrderedDict([
('product_one', Product(1001, 'First Product With New Name')),
('first_product', Product(1001, 'First Product With New Name')),
('product_two', Product(1002, 'The Amazing Second Product'))
]))
The Code
Here is the code.
# lazyenum.py
from collections import namedtuple, OrderedDict
from types import MappingProxyType
_Identifier = namedtuple('Identifier', 'field_name value')
def _get_identifier_value(self):
# use this function to monkey patch the class
id_map = type(self)._object_id_to_value_map
return id_map[id(self)]
class _LazyEnumMeta(type):
def __new__(mcls, name, bases, attrs):
attrs['_object_id_to_value_map'] = {}
attrs['_identifier_value_map'] = {}
attrs['_alias_to_member_map'] = OrderedDict()
cls = super().__new__(mcls, name, bases, attrs)
# grab the first parameter name from the __init__ method
# then inject it to the class as a read-only property
id_name = cls.__init__.__code__.co_varnames[1]
cls._identifier_field_name = id_name
setattr(cls, id_name, property(_get_identifier_value))
return cls
def __call__(cls, value, *args, **kwargs):
# rely on the class to provide the validation method
if not cls._validate_identifier_value(value):
raise ValueError(f'Identifier field {cls._identifier_field_name!r} '
f'has invalid value {value!r}.')
# create a new memeber iff no existing member has the same identifier value
if value not in cls._identifier_value_map:
new_member = super().__call__(value, *args, **kwargs)
cls._object_id_to_value_map[id(new_member)] = value
cls._identifier_value_map[value] = new_member
elif args or kwargs:
raise ValueError(f'Member with identifier value {value!r} already exists. '
f'Cannont pass additional arguments {args} or {kwargs}.')
return cls._identifier_value_map[value]
def __contains__(cls, other):
return other in cls._identifier_value_map.values()
def __len__(cls):
return len(cls._identifier_value_map)
def __iter__(cls):
yield from cls._identifier_value_map.values()
def __setattr__(cls, attr_name, attr_value):
if attr_name in cls._alias_to_member_map:
del cls._alias_to_member_map[attr_name]
# check if we are setting name to a class member
if attr_value in cls:
cls._alias_to_member_map[attr_name] = attr_value
super().__setattr__(attr_name, attr_value)
def __delattr__(cls, attr_name):
if attr_name in cls._alias_to_member_map:
del cls._alias_to_member_map[attr_name]
super().__delattr__(attr_name)
@property
def identifier_value_map(cls):
return MappingProxyType(cls._identifier_value_map)
@property
def alias_to_member_map(cls):
return MappingProxyType(cls._alias_to_member_map)
class LazyEnum(metaclass=_LazyEnumMeta):
# the first two methods serve as the defaults if a subclass didn't provide them
# to avoid error when _LazyEnumMeta attempts to use those two methods
def _validate_identifier_value(value):
return True
def __init__(self, identifier_value):
pass
@property
def identifier_field(self):
id_name = type(self)._identifier_field_name
return _Identifier(id_name, getattr(self, id_name))
@property
def all_aliases(self):
pairs = type(self)._alias_to_member_map.items()
return [alias for alias, member in pairs if member is self]
Questions
1.
The above code doesn't work well with dataclasses. If we write
from lazyenum import LazyEnum
from dataclasses import dataclass
@dataclass
class Product(LazyEnum):
def _validate_identifier_value(product_id):
return isinstance(product_id, int) and (1001 <= product_id <= 9999)
product_id : int
product_title : str
then type the following in the console:
>>> prod1 = Product(1001, 'First Product')
>>> prod1.product_id = 2001
>>> Product(2001)
TypeError: __init__() missing 1 required positional argument: 'product_title'
>>> Product(1001)
Product(product_id=2001, product_title='First Product')
We can change the product_id
but the member is still identified by the old value! How can I fix this?
- Apart from the issue of dataclasses, is there any problem in the above code? Where can I make improvements?
LazyEnum
is not a great choice of name as your usage does not appear to be enum related (see Python Enum -- when and where to use? \$\endgroup\$