Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can you have constraints on Python3 NamedTuple attributes?

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)

like image 372
David Downes Avatar asked May 21 '26 05:05

David Downes


1 Answers

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)

like image 193
David Downes Avatar answered May 24 '26 02:05

David Downes