Source code for codetransformer.utils.immutable

"""
codetransformer.utils.immutable
-------------------------------

Utilities for creating and working with immutable objects.
"""

from collections import ChainMap
from inspect import getfullargspec
from itertools import starmap, repeat
from textwrap import dedent
from weakref import WeakKeyDictionary


[docs]class immutableattr: """An immutable attribute of a class. Parameters ---------- attr : any The attribute. """ def __init__(self, attr): self._attr = attr def __get__(self, instance, owner): return self._attr
[docs]class lazyval: """A memoizing property. Parameters ---------- func : callable The function used to compute the value of the descriptor. """ def __init__(self, func): self._cache = WeakKeyDictionary() self._func = func def __get__(self, instance, owner): if instance is None: return self cache = self._cache try: return cache[instance] except KeyError: cache[instance] = val = self._func(instance) return val
def _no_arg_init(self): pass object_setattr = object.__setattr__ def initialize_slot(obj, name, value): """Initalize an unitialized slot to a value. If there is already a value for this slot, this is a nop. Parameters ---------- obj : immutable An immutable object. name : str The name of the slot to initialize. value : any The value to initialize the slot to. """ if not hasattr(obj, name): object_setattr(obj, name, value) def _create_init(name, slots, defaults): """Create the __init__ function for an immutable object. Parameters ---------- name : str The name of the immutable class. slots : iterable of str The __slots__ field from the class. defaults : dict or None The default values for the arguments to __init__. Returns ------- init : callable The __init__ function for the new immutable class. """ if any(s.startswith('__') for s in slots): raise TypeError( "immutable classes may not have slots that start with '__'", ) # If we have no defaults, ignore all of this. kwdefaults = None if defaults is not None: hit_default = False _defaults = [] # positional defaults kwdefaults = {} # kwonly defaults kwdefs = False for s in slots: if s not in defaults and hit_default: raise SyntaxError( 'non-default argument follows default argument' ) if not kwdefs: try: # Try to grab the next default. # Pop so that we know they were all consumed when we # are done. _defaults.append(defaults.pop(s)) except KeyError: # Not in the dict, we haven't hit any defaults yet. pass else: # We are now consuming default arguments. hit_default = True if s.startswith('*'): if s in defaults: raise TypeError( 'cannot set default for var args or var kwargs', ) if not s.startswith('**'): kwdefs = True else: kwdefaults[s] = defaults.pop(s) if defaults: # We didn't consume all of the defaults. raise TypeError( 'default value for non-existent argument%s: %s' % ( 's' if len(defaults) > 1 else '', ', '.join(starmap('{0}={1!r}'.format, defaults.items())), ) ) # cast back to tuples defaults = tuple(_defaults) if not slots: return _no_arg_init, () ns = {'__initialize_slot': initialize_slot} # filter out lone star slotnames = tuple(filter(None, (s.strip('*') for s in slots))) # We are using exec here so that we can later inspect the call signature # of the __init__. This makes the positional vs keywords work as intended. # This is totally reasonable, no h8 m8! exec( 'def __init__(_{name}__self, {args}): \n {assign}'.format( name=name, args=', '.join(slots), assign='\n '.join( map( '__initialize_slot(_{1}__self, "{0}", {0})'.format, slotnames, repeat(name), ), ), ), ns, ) init = ns['__init__'] init.__defaults__ = defaults init.__kwdefaults__ = kwdefaults return init, slotnames def _wrapinit(init): """Wrap an existing initialize function by thawing self for the duration of the init. Parameters ---------- init : callable The user-provided init. Returns ------- wrapped : callable The wrapped init method. """ try: spec = getfullargspec(init) except TypeError: # we cannot preserve the type signature. def __init__(*args, **kwargs): self = args[0] __setattr__._initializing.add(self) init(*args, **kwargs) __setattr__._initializing.remove(self) _check_missing_slots(self) return __init__ args = spec.args varargs = spec.varargs if not (args or varargs): raise TypeError( "%r must accept at least one positional argument for 'self'" % getattr(init, '__qualname__', getattr(init, '__name__', init)), ) if not args: self = '%s[0]' % varargs forward = argspec = '*' + varargs else: self = args[0] forward = argspec = ', '.join(args) if args and varargs: forward = '%s, *%s' % (forward, spec.varargs) argspec = '%s, *%s' % (argspec, spec.varargs) if spec.kwonlyargs: forward = '%s, %s' % ( forward, ', '.join(map('{0}={0}'.format, spec.kwonlyargs)) ) argspec = '%s,%s%s' % ( argspec, '*, ' if not spec.varargs else '', ', '.join(spec.kwonlyargs), ) if spec.varkw: forward = '%s, **%s' % (forward, spec.varkw) argspec = '%s, **%s' % (argspec, spec.varkw) ns = { '__init': init, '__initializing': __setattr__._initializing, '__check_missing_slots': _check_missing_slots, } exec( dedent( """\ def __init__({argspec}): __initializing.add({self}) __init({forward}) __initializing.remove({self}) __check_missing_slots({self}) """.format( argspec=argspec, self=self, forward=forward, ), ), ns, ) __init__ = ns['__init__'] __init__.__defaults__ = spec.defaults __init__.__kwdefaults__ = spec.kwonlydefaults __init__.__annotations__ = spec.annotations return __init__ def _check_missing_slots(ob): """Check that all slots have been initialized when a custom __init__ method is provided. Parameters ---------- ob : immutable The instance that was just initialized. Raises ------ TypeError Raised when the instance has not set values that are named in the __slots__. """ missing_slots = tuple( filter(lambda s: not hasattr(ob, s), ob.__slots__), ) if missing_slots: raise TypeError( 'not all slots initialized in __init__, missing: {0}'.format( missing_slots, ), ) def __setattr__(self, name, value): if self not in __setattr__._initializing: raise AttributeError('cannot mutate immutable object') object_setattr(self, name, value) __setattr__._initializing = set() def __repr__(self): return '{cls}({args})'.format( cls=type(self).__name__, args=', '.join(starmap( '{0}={1!r}'.format, ((s, getattr(self, s)) for s in self.__slots__), )), ) class ImmutableMeta(type): """A metaclass for creating immutable objects. """ def __new__(mcls, name, bases, dict_, *, defaults=None): if '__slots__' not in dict_: raise TypeError('immutable classes must have a __slots__') if '__setattr__' in dict_: raise TypeError('immutable classes cannot have a __setattr__') try: dict_['__init__'] = _wrapinit(dict_['__init__']) except KeyError: dict_['__init__'], dict_['__slots__'] = _create_init( name, dict_['__slots__'], defaults, ) dict_['__setattr__'] = __setattr__ cls = super().__new__(mcls, name, bases, dict_) if cls.__repr__ is object.__repr__: # Put a namedtuple-like repr on this class if there is no custom # repr on the class. cls.__repr__ = __repr__ return cls def __init__(self, *args, defaults=None): # ignore the defaults kwarg. return super().__init__(*args)
[docs]class immutable(metaclass=ImmutableMeta): """A base class for immutable objects. """ __slots__ = () def to_dict(self): return {s: getattr(self, s) for s in self.__slots__} def update(self, **updates): return type(self)(**ChainMap(updates, self.to_dict()))