Skip to content
This repository was archived by the owner on Apr 22, 2020. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion easypy/meta.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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)
34 changes: 34 additions & 0 deletions easypy/meta_mixins.py
Original file line number Diff line number Diff line change
@@ -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)


87 changes: 86 additions & 1 deletion tests/test_meta.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from easypy.meta import EasyMeta
import pytest

from easypy.meta import EasyMeta, EasyMixin


def test_easy_meta_after_cls_init():
Expand All @@ -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'