I have a simple NamedTuple that I want to enforce a constraint on. Is it possible?
Take the following example:
from typing import NamedTuple
class Person(NamedTuple):
first_name: str
last_name: str
If I had a desired maximum length for the name fields (e.g. 50 characters), how can I ensure that you cannot make a Person object with a name longer than that?
Normally, if this were just a class, not a NamedTuple, I'd handle this with a @property, @attr.setter and override the __init__ method. But NamedTuples can't have an __init__, and I can't see a way of having just a setter for one of the attributes (and if I could, I don't know if upon construction, the NamedTuple would even use it).
So, is this possible?
Note: I specifically want to use a NamedTuple (rather than trying to make a class immutable via my own methods/magic)
So I coded something that basically does what I wanted. I forgot to post it here, so it's evolved slightly from my original question, but I thought I'd best post here so that others can make use of it if they want.
import inspect
from collections import namedtuple
class TypedTuple:
_coerce_types = True
def __new__(cls, *args, **kwargs):
# Get the specified public attributes on the class definition
typed_attrs = cls._get_typed_attrs()
# For each positional argument, get the typed attribute, and check it's validity
new_args = []
for i, attr_value in enumerate(args):
typed_attr = typed_attrs[i]
new_value = cls.__parse_attribute(typed_attr, attr_value)
# Build a new args list to construct the namedtuple with
new_args.append(new_value)
# For each keyword argument, get the typed attribute, and check it's validity
new_kwargs = {}
for attr_name, attr_value in kwargs.items():
typed_attr = (attr_name, getattr(cls, attr_name))
new_value = cls.__parse_attribute(typed_attr, attr_value)
# Build a new kwargs object to construct the namedtuple with
new_kwargs[attr_name] = new_value
# Return a constructed named tuple using the named attribute, and the supplied arguments
return namedtuple(cls.__name__, [attr[0] for attr in typed_attrs])(*new_args, **new_kwargs)
@classmethod
def __parse_attribute(cls, typed_attr, attr_value):
# Try to find a function defined on the class to do checks on the supplied value
check_func = getattr(cls, f'_parse_{typed_attr[0]}', None)
if inspect.isroutine(check_func):
attr_value = check_func(attr_value)
else:
# If the supplied value is not the correct type, attempt to coerce it if _coerce_type is True
if not isinstance(attr_value, typed_attr[1]):
if cls._coerce_types:
# Coerce the value to the type, and assign back to the attr_value for further validation
attr_value = typed_attr[1](attr_value)
else:
raise TypeError(f'{typed_attr[0]} is not of type {typed_attr[1]}')
# Return the original value
return attr_value
@classmethod
def _get_typed_attrs(cls) -> tuple:
all_items = cls.__dict__.items()
public_items = filter(lambda attr: not attr[0].startswith('_') and not attr[0].endswith('_'), all_items)
public_attrs = filter(lambda attr: not inspect.isroutine(attr[1]), public_items)
return [attr for attr in public_attrs if isinstance(attr[1], type)]
This is my TypedTuple class, it basically behaves like a NamedTuple, except that you get type checking. It has the following basic usage:
>>> class Person(TypedTuple):
... """ Note, syntax is var=type, not annotation-style var: type
... """
... name=str
... age=int
...
>>> Person('Dave', 21)
Person(name='Dave', age=21)
>>>
>>> # Like NamedTuple, argument order matters
>>> Person(21, 'dave')
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'dave'
>>>
>>> # Can used named arguments
>>> Person(age=21, name='Dave')
Person(name='Dave', age=21)
So now you have a named tuple, which behaves in basically the same way, but it will type check the arguments you supply.
By default, the TypedTuple will also attempt to coerce the data you give it, into the types you say that it should be:
>>> dave = Person('Dave', '21')
>>> type(dave.age)
<class 'int'>
This behaviour can be turned off:
>>> class Person(TypedTuple):
... _coerce_types = False
... name=str
... age=int
...
>>> Person('Dave', '21')
Traceback (most recent call last):
...
TypeError: age is not of type <class 'int'>
Finally, you can also specify special parse methods, that can do any specific checking or coercing you want to do. These methods have the naming convention _parse_ATTR:
>>> class Person(TypedTuple):
... name=str
... age=int
...
... def _parse_age(value):
... if value < 0:
... raise ValueError('Age cannot be less than 0')
...
>>> Person('dave', -3)
Traceback (most recent call last):
...
ValueError: Age cannot be less than 0
I hope someone else finds this useful.
(Please note, this code will only work in Python3)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With