Welcome, guest | Sign In | My Account | Store | Cart

This class decorator factory is useful for replacing the following:

class MyTuple(namedtuple('MyTuple', "a b c")):
    """Something special."""
    @classmethod
    def from_defaults(cls, a, b=None, c=5):
        return cls(a, b, c)

or even:

class MyTuple(namedtuple('MyTuple', "a b c")):
    """Something special."""
    def __new__(cls, a, b=None, c=5):
        return super().__new__(cls, a, b, c)

with this:

@as_namedtuple("a b c", None, c=5)
class MyTuple:
    """Something special."""

I found that I often subclass named tuples to add on some functionality or even just a nice docstring. Plus with the class syntax there's no missing that a class is bound to the name (and it's a little easier to search for the definition). When you subclass a named tuple the boilerplate involved really jumps out.

One of the main reasons Adding support for defaults to namedtuple would mitigate the need for that functionality here, but I'm not going to hold my breath on that.

One nice (though minor) thing is that you don't have to repeat the name when defining the namedtuple.

Python, 52 lines
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from collections import namedtuple as namedtuple


def as_namedtuple(*fields_and_global_default, **defaults):
    """A class decorator factory joining the class with a namedtuple.

    If any of the expected arguments are not passed to the class, they
    are set to the specific default value or global default value (if any).

    """
    num_args = len(fields_and_global_default)
    if num_args > 2 or num_args < 1:
        raise TypeError("as_namedtuple() takes at 1 or 2 positional-only "
                        "arguments, {} given".format(num_args))
    else:
        fields, *global_default_arg = fields_and_global_default
        if isinstance(fields, str):
            fields = fields.replace(',', ' ').split()

    for field in defaults:
        if field not in fields:
            raise ValueError("got default for a non-existant field ({!r})"
                             .format(field))

    # XXX unnecessary if namedtuple() got support for defaults.
    @classmethod
    def with_defaults(cls, *args, **kwargs):
        """Return an instance with defaults populated as necessary."""
        # XXX or dynamically build this method with appropriate signature
        for field, arg in zip(fields, args):
            if field in kwargs:
                raise TypeError("with_defaults() got multiple values for "
                                "keyword argument {!r}".format(field))
            kwargs[field] = arg
        for field, default in defaults.items():
            if field not in kwargs:
                kwargs[field] = default
        if global_default_arg:
            default = global_default_arg[0]
            for field in fields:
                if field not in kwargs:
                    kwargs[field] = default
        return cls(**kwargs)

    def decorator(cls):
        """Return a new nametuple-based subclass of cls."""
        # Using super() (i.e. the MRO) makes this work correctly.
        bases = (namedtuple(cls.__name__, fields), cls)
        namespace = {'__doc__': cls.__doc__, '__slots__': (),
                     'with_defaults': with_defaults}
        return type(cls.__name__, bases, namespace)
    return decorator

Another interesting aspect of this recipe is that it demonstrates how to use a class decorator to generate a customized subclass. You avoid needing a metaclass by doing it this way.

By returning an appropriately generated subclass of the decorated class, you will have your functionality inherited. If you want to do something on each future subclass, though, you'll still need to use a metaclass (since class decorators are not inherited).

Also, you can get even fancier with this subclassing approach by adding other (even dynamically generated) mixins:

def as_namedtuple(*fields_and_global_default, **defaults):
    ...

    class _GeneratedBaseClass:
        @classmethod
        def with_defaults(cls, *args, **kwargs):
            """Return an instance with defaults populated as necessary."""
            for field, arg in zip(fields, args):
                if field in kwargs:
                    raise TypeError("with_defaults() got multiple values for "
                                    "keyword argument {!r}".format(field))
                kwargs[field] = arg
            for field, default in defaults.items():
                if field not in kwargs:
                    kwargs[field] = default
            if global_default_arg:
                default = global_default_arg[0]
                for field in fields:
                    if field not in kwargs:
                        kwargs[field] = default
            return cls(**kwargs)

    def decorator(cls):
        """Return a new nametuple-based subclass of cls."""
        # Using super() (the MRO) makes this work correctly.
        bases = (cls,
                 _namedtuple(cls.__name__, fields),
                 _GeneratedBaseClass)
        namespace = {'__doc__': cls.__doc__, '__slots__': ()}
        return type(cls.__name__, bases, namespace)

1 comment

Chris Johnson 7 years, 8 months ago  # | flag

Have you run this code? In which version of Python? It fails in 2.7. Line 16 is a syntax error.