__all__ = ['recordtype'] import sys from textwrap import dedent from keyword import iskeyword def recordtype(typename, field_names, verbose=False, **default_kwds): '''Returns a new class with named fields. @keyword field_defaults: A mapping from (a subset of) field names to default values. @keyword default: If provided, the default value for all fields without an explicit default in `field_defaults`. >>> Point = recordtype('Point', 'x y', default=0) >>> Point.__doc__ # docstring for the new class 'Point(x, y)' >>> Point() # instantiate with defaults Point(x=0, y=0) >>> p = Point(11, y=22) # instantiate with positional args or keywords >>> p[0] + p.y # accessible by name and index 33 >>> p.x = 100; p[1] =200 # modifiable by name and index >>> p Point(x=100, y=200) >>> x, y = p # unpack >>> x, y (100, 200) >>> d = p.todict() # convert to a dictionary >>> d['x'] 100 >>> Point(**d) == p # convert from a dictionary True ''' # Parse and validate the field names. Validation serves two purposes, # generating informative error messages and preventing template injection attacks. if isinstance(field_names, basestring): # names separated by whitespace and/or commas field_names = field_names.replace(',', ' ').split() field_names = tuple(map(str, field_names)) if not field_names: raise ValueError('Records must have at least one field') for name in (typename,) + field_names: if not min(c.isalnum() or c=='_' for c in name): raise ValueError('Type names and field names can only contain ' 'alphanumeric characters and underscores: %r' % name) if iskeyword(name): raise ValueError('Type names and field names cannot be a keyword: %r' % name) if name[0].isdigit(): raise ValueError('Type names and field names cannot start with a ' 'number: %r' % name) seen_names = set() for name in field_names: if name.startswith('_'): raise ValueError('Field names cannot start with an underscore: %r' % name) if name in seen_names: raise ValueError('Encountered duplicate field name: %r' % name) seen_names.add(name) # determine the func_defaults of __init__ field_defaults = default_kwds.pop('field_defaults', {}) if 'default' in default_kwds: default = default_kwds.pop('default') init_defaults = tuple(field_defaults.get(f,default) for f in field_names) elif not field_defaults: init_defaults = None else: default_fields = field_names[-len(field_defaults):] if set(default_fields) != set(field_defaults): raise ValueError('Missing default parameter values') init_defaults = tuple(field_defaults[f] for f in default_fields) if default_kwds: raise ValueError('Invalid keyword arguments: %s' % default_kwds) # Create and fill-in the class template numfields = len(field_names) argtxt = ', '.join(field_names) reprtxt = ', '.join('%s=%%r' % f for f in field_names) dicttxt = ', '.join('%r: self.%s' % (f,f) for f in field_names) tupletxt = repr(tuple('self.%s' % f for f in field_names)).replace("'",'') inittxt = '; '.join('self.%s=%s' % (f,f) for f in field_names) itertxt = '; '.join('yield self.%s' % f for f in field_names) eqtxt = ' and '.join('self.%s==other.%s' % (f,f) for f in field_names) template = dedent(''' class %(typename)s(object): '%(typename)s(%(argtxt)s)' __slots__ = %(field_names)r def __init__(self, %(argtxt)s): %(inittxt)s def __len__(self): return %(numfields)d def __iter__(self): %(itertxt)s def __getitem__(self, index): return getattr(self, self.__slots__[index]) def __setitem__(self, index, value): return setattr(self, self.__slots__[index], value) def todict(self): 'Return a new dict which maps field names to their values' return {%(dicttxt)s} def __repr__(self): return '%(typename)s(%(reprtxt)s)' %% %(tupletxt)s def __eq__(self, other): return isinstance(other, self.__class__) and %(eqtxt)s def __ne__(self, other): return not self==other def __getstate__(self): return %(tupletxt)s def __setstate__(self, state): %(tupletxt)s = state ''') % locals() # Execute the template string in a temporary namespace namespace = {} try: exec template in namespace if verbose: print template except SyntaxError, e: raise SyntaxError(e.message + ':\n' + template) cls = namespace[typename] cls.__init__.im_func.func_defaults = init_defaults # For pickling to work, the __module__ variable needs to be set to the frame # where the named tuple is created. Bypass this step in enviroments where # sys._getframe is not defined (Jython for example). if hasattr(sys, '_getframe') and sys.platform != 'cli': cls.__module__ = sys._getframe(1).f_globals['__name__'] return cls if __name__ == '__main__': import doctest TestResults = recordtype('TestResults', 'failed, attempted') print TestResults(*doctest.testmod())