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

So maybe it's not quite a mutable namedtuple. However, I borrowed heavily against the namedtuple implementation for this one.

I wanted to have a collection of classes that described data types without any functionality on them, sort of state holders. However, I wanted to use defaults and I wanted it to be mutable.

I noticed, as I started building my classes, that each was following the same pattern, so I extracted it out into this recipe. Wrapping namedtuple to get the same result would probably be feasible, but I enjoyed doing this too.

The distinction between parameters and properties is mostly one I was maintaining between single objects and collections. Realistically everything could have been parameters.

Python, 122 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
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
from collections import namedtuple


Param = namedtuple("Param", "name default doc")
ReqParam = namedtuple("ReqParam", "name doc")
Getter = namedtuple("Getter", "name initial doc")


PROPERTY_TEMPLATE = """\
@property
def {name}(self):
    "{doc}"
    if not hasattr(self, "_{name}"):
        self._{name} = {initial}
    return self._{name}"""


def dataclass(typename, doc, params, properties, verbose=False):
    "Return a new class around the params and properties."

    namespace = {}

    # handle class docstring
    if not doc:
        doc = ""
    docstring = doc.splitlines()
    if not docstring:
        docstring = ['""']
    elif len(docstring) > 1:
        docstring.insert(0, '"""')
        docstring.append('"""')
        docstring.extend(line for line in doc.splitlines)
    else:
        docstring = ['"{}"'.format(docstring[0])]

    # handle __init__ and params
    params_constant = []
    __init__ = []
    if params:
        params_constant = ["PARAMS = ("]
        doc = []
        assignment = []
        for param in params:
            params_constant.append("        {}{},".format(
                    param.__class__.__name__,
                    param.__getnewargs__(),
                    ))
            namespace[param.__class__.__name__] = param.__class__
            default = ""
            if hasattr(param, "default"):
                default = param.default
                if hasattr(default, "__name__"):
                    namespace[default.__name__] = default
                    default = default.__name__
                default = "={}".format(default)
            __init__.append("    {}{},".format(param.name, default))
            if param.doc:
                doc.append("  {} - {}".format(param.name, param.doc))
            assignment.append("self.{} = {}".format(param.name, param.name))
        __init__.append("):")
        params_constant.append("        )")
        if doc:
            __init__.append('"""')
            __init__.append("Parameters:")
            __init__.extend(doc)
            __init__.append("")
            __init__.append('"""')
            __init__.append("")
        __init__.extend(assignment)

        __init__ = ["    "+line for line in __init__]
        __init__.insert(0, "def __init__(self,")

    # handle properties
    properties_constant = []
    props = []
    for prop in properties:
        properties_constant.append("        {}{},".format(
                prop.__class__.__name__,
                prop.__getnewargs__(),
                ))
        namespace[prop.__class__.__name__] = prop.__class__
        initial = prop.initial
        if hasattr(initial, "__name__"):
            namespace[initial.__name__] = initial
            initial = initial.__name__
        prop = PROPERTY_TEMPLATE.format(
                name=prop.name,
                initial=initial,
                doc=prop.doc,
                )
        props.extend(prop.splitlines())
        props.append("")
    if properties_constant:
        properties_constant.insert(0, "PROPERTIES = (")
        properties_constant.append("        )")

    # put it all together
    template = ["class {}(object):".format(typename)]
    template.extend("    "+line for line in docstring)
    template.extend("    "+line for line in params_constant)
    template.extend("    "+line for line in properties_constant)
    template.append("")
    template.extend("    "+line for line in __init__)
    template.append("")
    template.extend("    "+line for line in props)
    template.append("")
    template = "\n".join(template)

    if verbose:
        print(template)
    try:
        exec(template, namespace)
    #except SyntaxError, e:
    except SyntaxError as e:
        raise SyntaxError(e.msg + ':\n' + template)
    return namespace[typename]


def data(cls):
    "A class decorator for the dataclass function."
    return dataclass(cls.__name__, cls.__doc__, cls.PARAMS, cls.PROPERTIES)

Here is an example:

@data
class User:
    PARAMS = (
            ReqParam("username", ""),
            Param("password", None, ""),
            )
    PROPERTIES = (
            Getter("email_addresses", [], "All addresses pointing to this user."),
            )

And here is another:

class EmailAddress:
    "A prototypical email address."
    PARAMS = (
            ReqParam("address", "the actual address"),
            )
    PROPERTIES = (
            Getter("users", [], "Target users for this address."),
            )
EmailAddress = dataclass(
        EmailAddress.__name__, 
        EmailAddress.__doc__,
        EmailAddress.PARAMS,
        EmailAddress.PROPERTIES,
        True,
        )

One thing I had considered is expanding on this to have all the names be descriptors, and have get/set/del handlers registered against the classes, tied to the descriptors. But that is something for another day. For now anything like that will just be handled more directly in superclasses.