diff --git a/easypy/meta.py b/easypy/meta.py index 680e7bd6..34105e27 100644 --- a/easypy/meta.py +++ b/easypy/meta.py @@ -1,6 +1,7 @@ -from abc import ABCMeta +from abc import ABCMeta, abstractmethod from functools import wraps from collections import OrderedDict +from enum import Enum from .decorations import kwargs_resilient @@ -33,6 +34,11 @@ def __new__(mcs, name, bases, dct, **kwargs): if isinstance(base, EasyMeta): hooks.extend(base._em_hooks) + if hooks.hooks['before_subclass_init']: + bases = list(bases) + hooks.before_subclass_init(name, bases, dct) + bases = tuple(bases) + new_type = super().__new__(mcs, name, bases, dct) new_type._em_hooks = hooks @@ -75,6 +81,30 @@ def extend(self, other): for k, v in other.hooks.items(): self.hooks[k].extend(v) + @hook + def before_subclass_init(self, name, bases, dct): + """ + Invoked before a subclass is being initialized + + :param name: The name of the class. Immutable. + :param list bases: The bases of the class. A list, so it can be changed. + :param dct: The body of the class. + + >>> class NoB(metaclass=EasyMeta): + >>> @EasyMeta.Hook + >>> def before_subclass_init(name, bases, dct): + >>> dct.pop('b', None) + >>> + >>> class Foo(NoB): + >>> a = 1 + >>> b = 2 + >>> + >>> Foo.a + 1 + >>> Foo.b + AttributeError: type object 'Foo' has no attribute 'b' + """ + @hook def after_subclass_init(self, cls): """ @@ -102,3 +132,100 @@ def __setitem__(self, name, value): self.hooks.add(value.dlg) else: return super().__setitem__(name, value) + + +class EasyMixinStage(Enum): + BASE_MIXIN_CLASS = 1 + MIXIN_GENERATOR = 2 + ACTUAL_MIXIN_SPECS = 3 + + +class EasyMixinMeta(ABCMeta): + def __new__(mcs, name, bases, dct, **kwargs): + try: + stage = dct['_easy_mixin_stage_'] + except KeyError: + stage = min(b._easy_mixin_stage_ for b in bases if hasattr(b, '_easy_mixin_stage_')) + stage = EasyMixinStage(stage.value + 1) + if stage == EasyMixinStage.ACTUAL_MIXIN_SPECS: + base, = bases + return base(name, bases, dct)._generate_class() + else: + dct['_easy_mixin_stage_'] = stage + return super().__new__(mcs, name, bases, dct) + + +class EasyMixin(metaclass=EasyMixinMeta): + """ + Create mixins creators. + + Direct subclasses (hereinafter "mixin creators") of this class will be + created normally, but subclasses of these subclasses (hereinafter "mixins") + will be new classes that are not subclasses of neither the mixin creator nor + ``EasyMixin`` nor their other base classes, and will not necessarily contain + the content of their bodies. Instead, the mixin class' body and its other + base classes will be passed to methods of the mixin creator, which will be + able to affect the resulting mixin class. + + >>> class Foo(EasyMixin): # the mixin creator + >>> def prepare(self): + >>> # `orig_dct` contains the original body - to affect the new one + >>> # we use `dct` + >>> self.dct['value_of_' + self.name] = self.orig_dct['value_of_name'] + >>> + >>> class Bar(Foo): # the mixin + >>> value_of_name = 'Baz' + >>> + >>> Bar.value_of_Bar + 'Baz' + """ + + _easy_mixin_stage_ = EasyMixinStage.BASE_MIXIN_CLASS + metaclass = EasyMeta + """The metaclass for the mixin""" + + def __init__(self, name, bases, dct): + self.name = name + """The name of the to-be-created mixin. Can be changed.""" + self.bases = () + """The bases of the to-be-created mixin. Can be changed.""" + self.orig_bases = bases + """The bases of the mixin's body. Not carried to the created mixin.""" + self.orig_dct = dct + """The the mixin's body. Not carried to the created mixin.""" + + @abstractmethod + def prepare(self): + """ + Override this to control the mixin creation. + + * Alter ``self.name``, ``self.bases`` and ``self.dct``. + * Use ``self.add_hook`` to add easymeta hooks. + * Access the declaration of the mixin with ``self.orig_bases`` and self.orig_dct``. + """ + + def _generate_class(self): + self.dct = EasyMetaDslDict() + self.dct.update(__module__=self.orig_dct['__module__'], __qualname__=self.orig_dct['__qualname__']) + self.prepare() + return self.metaclass(self.name, self.bases, self.dct) + + def add_hook(self, fn): + """ + Add EasyMeta hooks to the created mixins. Use inside ``prepare``. + + >>> class Foo(EasyMixin): + >>> def prepare(self): + >>> @self.add_hook + >>> def after_subclass_init(cls): + >>> # Note that the hook will not run on Bar - only on Baz + >>> print(self.orig_dct['template'].format(cls)) + >>> + >>> class Bar(Foo): + >>> template = 'Creating subclass {0.__name__}' + >>> + >>> class Baz(Bar): + >>> pass + Creating subclass Baz + """ + self.dct[fn.__name__] = EasyMeta.Hook(fn) diff --git a/easypy/meta_mixins.py b/easypy/meta_mixins.py new file mode 100644 index 00000000..8c0c9183 --- /dev/null +++ b/easypy/meta_mixins.py @@ -0,0 +1,34 @@ +from .meta import EasyMixin + + +class UninheritedDefaults(EasyMixin): + """ + Fields declared in this class will be default in subclasses even if overwritten. + + + >>> class Foo(UninheritedDefaults): + >>> a = 1 + >>> Foo.a + 1 + >>> + >>> class Bar(Foo): + >>> a = 2 + >>> Bar.a + 2 + >>> + >>> class Baz(Bar): + >>> pass + >>> Baz.a # gets the value from Foo, not from Bar + 1 + """ + + def prepare(self): + defaults = {k: v for k, v in self.orig_dct.items() if not k.startswith('__')} + self.dct.update(defaults) + + @self.add_hook + def before_subclass_init(name, bases, dct): + for k, v in defaults.items(): + dct.setdefault(k, v) + + diff --git a/tests/test_meta.py b/tests/test_meta.py index 236e7462..45d3d7e6 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,4 +1,6 @@ -from easypy.meta import EasyMeta +import pytest + +from easypy.meta import EasyMeta, EasyMixin def test_easy_meta_after_cls_init(): @@ -23,3 +25,86 @@ def after_subclass_init(cls): assert Baz.foo_init == 'Baz' assert Baz.bar_init == 'Baz' assert not hasattr(Baz, 'baz_init'), 'after_subclass_init declared in Baz invoked on Baz' + + +def test_easy_meta_before_cls_init(): + class Foo(metaclass=EasyMeta): + @EasyMeta.Hook + def before_subclass_init(name, bases, dct): + try: + base = dct.pop('CLASS') + except KeyError: + pass + else: + bases.insert(0, base) + + class Bar: + pass + + class Baz(Foo): + CLASS = Bar + + a = 1 + b = 2 + + + assert not hasattr(Baz, 'CLASS') + assert issubclass(Baz, Bar) + + +def test_easy_mixin(): + class MyMixinCreator(EasyMixin): + def prepare(self): + verify = {k: v for k, v in self.orig_dct.items() if not k.startswith('__')} + + @self.add_hook + def before_subclass_init(name, bases, dct): + for k, v in verify.items(): + assert v == dct[k], '%s is %s - needs to be %s' % (k, dct[k], v) + + class MyMixin(MyMixinCreator): + a = 1 + b = 2 + + class Foo(MyMixin): + a = 1 + b = 2 + + with pytest.raises(AssertionError) as exc: + class Bar(MyMixin): + a = 2 + b = 2 + + assert 'a is 2 - needs to be 1' in (str(exc.value)) + + +def test_uninherited_defaults(): + from easypy.meta_mixins import UninheritedDefaults + + class Defaults(UninheritedDefaults): + a = 1 + b = 2 + + class Foo(Defaults): + a = 3 + c = 4 + + class Bar(Foo): + b = 5 + c = 6 + + class Baz(Bar): + pass + + + assert Foo.a == 3 + assert Foo.b == 2 + assert Foo.c == 4 + + assert Bar.a == 1, 'Bar.a should come from Defaults, not Foo' + assert Bar.b == 5 + assert Bar.c == 6 + + assert Baz.a == 1, 'Baz.a should come from Defaults, not Bar' + assert Baz.b == 2, 'Baz.b should come from Defaults, not Bar' + assert Baz.c == 6, 'Baz.c is not in Defaults so it should come from Bar'