I would like my SpreadSheet class below to be considered a dict subclass by the isinstance() built-in, but when I try to register it as such, an AttributeError exception is thrown (also shown below).
What is a (or the) way to do something like this?
Note: My question is similar to Is it possible to be a virtual subclass of a built in type?, but its accepted answer doesn't address the titular question asked (so please don't vote to close this as a duplicate).
The primary motivation for wanting to do this is to allow an instance of the class to be passed to json.dump() and be treated just like a Python dict. This is needed because — for reasons I don't understand — the JSONEncoder class uses isinstance(value, dict), rather than isinstance(value, Mapping).
from collections.abc import MutableMapping
class SpreadSheet(MutableMapping):
def __init__(self, tools=None, **kwargs):
self._cells = {}
self._tools = {'__builtins__': None}
if tools is not None:
self._tools.update(tools) # Add caller supplied functions.
def clear(self):
return self._cells.clear()
def __contains__(self, k):
return k in self._cells
def __setitem__(self, key, formula):
self._cells[key] = formula
def __getitem__(self, key ):
return eval(self._cells[key], self._tools, self)
def __len__(self):
return len(self._cells)
def __iter__(self):
return iter(self._cells)
def __delitem__(self, k):
del self._cells[k]
def getformula(self, key):
return self._cells[key]
type(dict).register(SpreadSheet) # Register class as dict subclass.
ss = SpreadSheet()
print(f'isinstance(ss, dict): {isinstance(ss, dict)}') # Result should be True.
Exception:
Traceback (most recent call last):
File "spreadsheet.py", line 35, in <module>
type(dict).register(SpreadSheet) # Register class as dict subclass.
AttributeError: type object 'type' has no attribute 'register'
Chosen Solution
As the accepted answer to Is it possible to be a virtual subclass of a built in type? says, it's impossible as primitive types are essentially immutable.
However it is possible to make json.dump() treat a Mapping just like a dict by patching the module as shown in the second approach presented in @jsbueno's answer. The implementation shown below patches the encoder in a slightly different, simpler way that gives equivalent results. I especially liked a bonus feature it has which is that it also prevents the C optimized version from being used (which silently fails).
from collections.abc import Mapping, MutableMapping
from functools import partial
import json
class SpreadSheet(MutableMapping):
def __init__(self, tools=None, **kwargs):
self._cells = {}
self._tools = {'__builtins__': None} # Prevent eval() from supplying.
if tools is not None:
self._tools.update(tools) # Add any caller-supplied functions.
def clear(self):
return self._cells.clear()
def __contains__(self, key):
return key in self._cells
def __setitem__(self, key, formula):
self._cells[key] = formula
def __getitem__(self, key):
return eval(self._cells[key], self._tools, self)
def __len__(self):
return len(self._cells)
def __iter__(self):
return iter(self._cells)
def __delitem__(self, key):
del self._cells[key]
def getformula(self, key):
""" Return raw un-evaluated contents of cell. """
return self._cells[key]
def update(self, *args, **kwargs):
for k, v in dict(*args, **kwargs).iteritems():
self[k] = v
# Monkey-path json module
# Changes check for isinstance(obj, dict) to isinstance(obj, Mapping)
# https://github.com/python/cpython/blob/3.8/Lib/json/encoder.py#L321
# This changes the default value of the function's dict= keyword to be
# a Mapping instead of a dict. The isinstance() call uses whatever it's
# set to.
_new__make_iterencode = partial(json.encoder._make_iterencode, dict=Mapping)
json.encoder._make_iterencode = _new__make_iterencode
json.encoder.c_make_encoder = None # Disables use of C version of make encoder
if __name__ == '__main__':
import json
from math import cos, sin, pi, tan
# A small set of safe built-ins.
tools = dict(len=len, sin=sin, cos=cos, pi=pi, tan=tan)
ss = SpreadSheet(tools)
ss['a1'] = '5'
ss['a2'] = 'a1*6'
ss['a3'] = 'a2*7'
ss['b1'] = 'sin(pi/4)'
print()
print('isinstance(SpreadSheet(tools), dict) -> {}'.format(isinstance(ss, dict)))
print()
print('Static Contents via getformula():')
print(json.dumps({k: ss.getformula(k) for k in ss.keys()}, indent=4))
print()
print('Dynamic Contents via __getitem__():')
print(" ss['a1'] -> {!r}".format(ss['a1']))
print(" ss['a2'] -> {!r}".format(ss['a2']))
print(" ss['a3'] -> {!r}".format(ss['a3']))
print(" ss['b1'] -> {!r}".format(ss['b1']))
print()
print("via json.dumps(ss, indent=4):")
print(json.dumps(ss, indent=4))
print()
print("via json.dumps(ss):") # Works, too.
print(json.dumps(ss)) # -> {}
print()
print('dict(**ss): {}'.format(dict(**ss))) # Gets dynamic contents.