Indexing with namedtuple
Dictionaries/HashMaps/
HashTables are great. Having items stored and returned at constant time complexity makes building up data structures, and finding items within them, a breeze.
But what if my item has multiple features? What if I want to get all the items that have a certain values for some of those features? Ideally, I could index a dictionary with a few different attributes (in the form of another dictionary) and get back the item (if a full key is passed), or a set of items if a partial key is passed. An example usage of what I want might look like this...
>>> T = {}
>>> T[{"a":1,"b":True, "c":"string"}] = 4
>>> T[{"a":2,"b":True, "c":"string"}] = 5
>>> T[{"a":3,"b":False,"c":"string"}] = 6
>>> T[{"b":True}]
{{'{{"a":1, "c":"string"}: 4, {"a":2, "c":"string"}: 5}'}}
>>> T[{"a":3,"b":False,"c":"string"}]
6
If the above code is executed, the interpreter will inform you that dictionaries are unhashable, as they are
mutable, and thus cannot be used as keys. However, if the set of attributes are known beforehand, this can be fixed with a special Python data structure. Bringforth the elegance of the
namedtuple. Namedtuples are a sort of mini-class, with a succinctly defined constructor, as shown in this completely valid example:
from collections import namedtuple
# Peanut constructor, defined by namedtuple invocation
p = namedtuple("Peanut",["shell","salted"])
salty = p(shell="closed",salted=True)
print(salty.shell)
not_salty = p(shell="closed",salted=False)
eaten = p(shell="open",salted=False)
# 20 salty peanuts and 50 unsalted
Bowl_Contents = {salty:20, not_salty:50}
# Get back the number of salty peanuts
print(Bowl_Contents[p(shell="closed",salted=True)])
Above we have defined a reference
p
to a peanut namedtuple constructor. It can subsequently be called to invoke new peanuts, with different shell and saltiness specifications. Furthermore, the fact that namedtuples are tuples, and therefore
immutable, means they can serve as dictionary keys.
Below, I offer a basic class which utilizes named tuples to allow for indexing with dictionaries. Additionally, just as was desired in the first theoretical code example, subsets of full keys can also be used as indexes, returning all applicable items. This is a very limited (purely for fun) implementation, as normal dictionary methods like keys()
aren't present - however they can easily be added though by wrapping calls to self.storage
's methods in new method definitions.
from pprint import pformat
from collections import namedtuple
class MetaDict(object):
''' Sliceable, subsetable dictionary '''
def __init__(self, key_components):
self.storage = {}
self.key = namedtuple("multiKey",key_components)
def __repr__(self):
''' pretty print representation '''
return "{}:\n{}\n".format(
str(self.__class__)[17:-2],
pformat(self.storage)
)
def __setitem__(self, index, item):
''' Set an item using a dictionary as an index '''
# Hey! Some of those keys fields aren't right!
k = index.keys()
fields = self.key.fields
if not set(k) <= set(fields):
invalid = set(k) - set(fields)
raise Exception("Incorrect Key: {}".format(invalid))
else:
self.storage[self.key(**index)] = item
def __getitem__(self, query):
''' If the query is a full key, return the item.
Otherwise, return a smaller MetaDict object
containing all items which have key elements
matching those specified in the query '''
# If the query is a full key, return the value
keys = query.keys()
fields = self.key.fields
if set(keys) == set(fields):
return self.storage[self.key(**query)]
# Hey! Some of those keys fields aren't right!
if not set(keys) <= set(fields):
invalid = set(keys) - set(fields)
raise Exception("Invalid Key: {}".format(invalid))
# The new key for the smaller MetaDict
# will have the elements
# not listed in the query.
newfields = [k for k in fields if k not in keys]
if len(newfields) > 1:
results = MetaDict(newfields)
mutiple_field_key = True
else:
# Defaults to normal dictionary
results = {}
mutiple_field_key = False
# Loop through all current entries
for key in self.storage:
if all(getattr(key,k) == v for (k,v) in query.items()):
# create a dictionary of items NOT included in the query
# to be used as the new sub key
sub_key = {
k:v for (k,v) in key._asdict().items()
if k not in keys
}
if mutiple_field_key:
# Store the value in the new MetaDict
results[sub_key] = self.storage[key]
else:
# No longer need multidimensional indexing
results[list(sub_key.values())[0]] = self.storage[key]
return results
Finally, here is an example usage of the above class (thrown into a file metadict.py
), with the exact indexing and sub-setting we were hoping for!
>>> from metadict import MetaDict
>>> T = MetaDict(["a","b","c"])
>>> T[{"a":1,"b":True, "c":"string"}] = 4
>>> T[{"a":2,"b":True, "c":"string"}] = 5
>>> T[{"a":3,"b":False,"c":"string"}] = 6
>>> T[{"a":2}]
MetaDict:
{multiKey(b=True, c='string'): 5}
>>> T[{"b":True}]
MetaDict:
{multiKey(a=1, c='string'): 4, multiKey(a=2, c='string'): 5}
>>> T[{"a":3,"b":False,"c":"string"}]
6