Skip to content

Instantly share code, notes, and snippets.

@ncoghlan
Last active July 26, 2017 03:37
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ncoghlan/a79e7a1b3f7dac11c6cfbbf59b189621 to your computer and use it in GitHub Desktop.
Save ncoghlan/a79e7a1b3f7dac11c6cfbbf59b189621 to your computer and use it in GitHub Desktop.
Auto-defined named tuples in Python 3.6+
# Licensed under BSD 2-clause license
from collections import namedtuple
_AUTO_NTUPLE_FIELD_SEPARATOR = "__"
_AUTO_NTUPLE_PREFIX = "_ntuple" + _AUTO_NTUPLE_FIELD_SEPARATOR
def _fields_to_class_name(fields):
"""Generate a class name based on the given field names"""
return _AUTO_NTUPLE_PREFIX + _AUTO_NTUPLE_FIELD_SEPARATOR.join(fields)
def _class_name_to_fields(cls_name):
"""Extract field names from an auto-generated class name"""
parts = cls_name.split(_AUTO_NTUPLE_FIELD_SEPARATOR)
return tuple(parts[1:])
class _AutoNamedTupleTypeCache(dict):
"""Pickle compatibility helper for autogenerated collections.namedtuple type definitions"""
def __new__(cls):
# Ensure that unpickling reuses the existing cache instance
self = globals().get("_AUTO_NTUPLE_TYPE_CACHE")
if self is None:
maybe_self = super().__new__(cls)
self = globals().setdefault("_AUTO_NTUPLE_TYPE_CACHE", maybe_self)
return self
def __missing__(self, fields):
if any(_AUTO_NTUPLE_FIELD_SEPARATOR in field for field in fields):
msg = "Field names {!r} cannot include field separator {!r}"
raise ValueError(msg.format(fields, _AUTO_NTUPLE_FIELD_SEPARATOR))
cls_name = _fields_to_class_name(fields)
return self._define_new_type(cls_name, fields)
def __getattr__(self, cls_name):
if not cls_name.startswith(_AUTO_NTUPLE_PREFIX):
raise AttributeError(cls_name)
fields = _class_name_to_fields(cls_name)
return self._define_new_type(cls_name, fields)
def _define_new_type(self, cls_name, fields):
cls = namedtuple(cls_name, fields)
cls.__name__ = "auto_ntuple"
cls.__module__ = __name__
cls.__qualname__ = "_AUTO_NTUPLE_TYPE_CACHE." + cls_name
# Rely on setdefault to handle race conditions between threads
return self.setdefault(fields, cls)
_AUTO_NTUPLE_TYPE_CACHE = _AutoNamedTupleTypeCache()
def auto_ntuple(**items):
"""Create a named tuple instance from ordered keyword arguments
Automatically defines and caches a new type if necessary.
"""
cls = _AUTO_NTUPLE_TYPE_CACHE[tuple(items)]
return cls(*items.values())
# The following two APIs allow particular sequences of fields to be
# assigned symbolic names in place of the auto-generated ones
# They are definitely *NOT* thread safe in their current form.
def set_ntuple_name(cls_name, fields):
"""Assigns a meaningful symbolic name to a sequence of field names
Adjusts __name__ and __qualname__ on the type to use the symbolic
name instead of the auto-generated name.
"""
fields = tuple(fields)
cls_by_name = globals().get(cls_name)
if cls_by_name is not None:
assigned_fields = cls_by_name._fields
if assigned_fields == fields:
# Name is already assigned as requested
return
msg = "{!r} already used for {!r} ntuples, can't assign to {!r}"
raise RuntimeError(msg.format(cls_name, assigned_fields, fields))
cls_by_fields = _AUTO_NTUPLE_TYPE_CACHE[fields]
assigned_name = cls_by_fields.__name__
if assigned_name != "auto_ntuple":
msg = "{!r} ntuples already named as {!r}, can't rename as {!r}"
raise RuntimeError(msg.format(fields, assigned_name, cls_name))
cls_by_fields.__name__ = cls_by_fields.__qualname__ = cls_name
globals()[cls_name] = cls_by_fields
def reset_ntuple_name(fields):
"""Resets an ntuple's name back to the default auto-generated name.
This allows it to be renamed."""
fields = tuple(fields)
auto_cls_name = _fields_to_class_name(fields)
cls = _AUTO_NTUPLE_TYPE_CACHE[fields]
cls.__name__ = "auto_ntuple"
cls.__qualname__ = "_AUTO_NTUPLE_TYPE_CACHE." + auto_cls_name
# For pickle compatibility, any entry in the module globals is retained
>>> p1 = auto_ntuple(x=1, y=2)
>>> p2 = auto_ntuple(x=4, y=5)
>>> type(p1) is type(p2)
True
>>>
>>> import pickle
>>> p3 = pickle.loads(pickle.dumps(p1))
>>> p1 == p3
True
>>> type(p1) is type(p3)
True
>>>
>>> p1, p2, p3
(auto_ntuple(x=1, y=2), auto_ntuple(x=4, y=5), auto_ntuple(x=1, y=2))
>>> type(p1)
<class '__main__._AUTO_NTUPLE_TYPE_CACHE._ntuple__x__y'>
>>>
>>> p4 = auto_ntuple(with_underscores="ok")
>>> p4
auto_ntuple(with_underscores='ok')
>>>
>>> auto_ntuple(with__double__underscores="not ok")
Traceback (most recent call last):
...
ValueError: Field names ('with__double__underscores',) cannot include field separator '__'
>>>
>>> set_ntuple_name("Point2D", "x y".split())
>>> p1, p2, p3, p4
(Point2D(x=1, y=2), Point2D(x=4, y=5), Point2D(x=1, y=2), auto_ntuple(with_underscores='ok'))
>>> set_ntuple_name("CartesianCoordinates", "x y".split())
Traceback (most recent call last):
...
RuntimeError: ('x', 'y') ntuples already named as 'Point2D', can't rename as 'CartesianCoordinates'
>>> reset_ntuple_name("x y".split())
>>> p1, p2, p3
(auto_ntuple(x=1, y=2), auto_ntuple(x=4, y=5), auto_ntuple(x=1, y=2))
>>> set_ntuple_name("CartesianCoordinates", "x y".split())
>>> p1, p2, p3
(CartesianCoordinates(x=1, y=2), CartesianCoordinates(x=4, y=5), CartesianCoordinates(x=1, y=2))
>>> type(p1)
<class '__main__.CartesianCoordinates'>
>>>
>>> p5 = pickle.loads(pickle.dumps(p1))
>>> p5 == p1
True
>>> type(p5) is type(p1)
True
Copyright 2017 Nicholas Coghlan. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are
permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of
conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list
of conditions and the following disclaimer in the documentation and/or other materials
provided with the distribution.
THIS SOFTWARE IS PROVIDED BY NICHOLAS COGHLAN ''AS IS'' AND ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
p1 = auto_ntuple(x=1, y=2)
p2 = auto_ntuple(x=4, y=5)
type(p1) is type(p2)
import pickle
p3 = pickle.loads(pickle.dumps(p1))
p1 == p3
type(p1) is type(p3)
p1, p2, p3
type(p1)
p4 = auto_ntuple(with_underscores="ok")
p4
auto_ntuple(with__double__underscores="not ok")
set_ntuple_name("Point2D", "x y".split())
p1, p2, p3, p4
set_ntuple_name("CartesianCoordinates", "x y".split())
reset_ntuple_name("x y".split())
p1, p2, p3
set_ntuple_name("CartesianCoordinates", "x y".split())
p1, p2, p3
type(p1)
p5 = pickle.loads(pickle.dumps(p1))
p5 == p1
type(p5) is type(p1)
@ncoghlan
Copy link
Author

Updated to set cls.__name__ = "auto_ntuple" so the instance repr refers to the factory function rather than the implicitly generated type name.

@ncoghlan
Copy link
Author

Updated to allow single underscores in field names by switching to double underscores as the field name separator

@ncoghlan
Copy link
Author

Added a license notice to make it explicit that this is freely reusable under the terms of the 2-clause BSD license

@ncoghlan
Copy link
Author

Updated to extract magic constants and the class name/field names transformations, and to add input validation when implicitly deriving one from the other.

@ncoghlan
Copy link
Author

Added set_ntuple_name and reset_ntuple_name to illustrate how to give the autogenerated types prettier runtime aliases without breaking pickle compatibility.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment