From afe49a8cba0c089fee1551af0306a3d0bda2807e Mon Sep 17 00:00:00 2001 From: "Tim.Mitchell" Date: Fri, 28 Apr 2023 11:16:39 +1200 Subject: [PATCH] 91: fix delegate subclasses overriding base class methods. --- README.rst | 19 ++++++++++-- pure_interface/__init__.py | 2 +- pure_interface/delegation.py | 26 +++++++++++++--- setup.cfg | 2 +- tests/test_delegate.py | 60 +++++++++++++++++++++++++++++++++--- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 9e1e7e3..c18ac68 100644 --- a/README.rst +++ b/README.rst @@ -455,7 +455,9 @@ the ``Delegate`` class assists with this task reducing boiler plate code such as def method(self): return self.impl.method() -The ``Delegate`` class provides 3 special attributes to route attributes to a child object. +The ``Delegate`` class provides 3 special attributes to route attributes to a child object. Only attributes and mothods +not defined on the class (or super-classes) are routed. (Attributes and methods defined on an interface sub-class are not +considered part of the implementation and these attributes are routed.) Any one or combination of attributes is allowed. pi_attr_delegates @@ -537,7 +539,20 @@ Methods and properties defined on the delegate class itself take precedence (as return 'my bar' However, attempting to set an instance attribute as an override will just set the attribute on the underlying delegate -instead. +instead. If you want to override using an instance attribute, first define it as a class attribute:: + + class MyDelegate(Delegate, IFoo): + pi_attr_delegates = {'impl': IFoo} + foo = None # prevents delegation of foo to `impl` + + def __init__(self, impl): + self.impl = impl + self.foo = 3 + +If you supply more than one delegation rule (e.g. both ``pi_attr_mapping`` and ``pi_attr_fallack``) then + ``pi_attr_delegates`` delegates are created first and any attributes defined there are now part of the class. +Then ``pi_attr_mapping`` delegates are created (and become part of the class) and finally ``pi_attr_fallback`` is processed. +Thus if there are duplicate delegates defined, the one defined first takes precedence. Composition ----------- diff --git a/pure_interface/__init__.py b/pure_interface/__init__.py index 54db1a9..f32c6d7 100644 --- a/pure_interface/__init__.py +++ b/pure_interface/__init__.py @@ -11,4 +11,4 @@ except ImportError: pass -__version__ = '7.0.0' +__version__ = '7.0.1' diff --git a/pure_interface/delegation.py b/pure_interface/delegation.py index b77a294..d1f05d1 100644 --- a/pure_interface/delegation.py +++ b/pure_interface/delegation.py @@ -98,13 +98,31 @@ def bar(self, baz): return 'my bar' However, attempting to set an instance attribute as an override will just set the property on the underlying - delegate. + delegate. If you want to override using an instance attribute, first define it as a class attribute + + class MyDelegate(Delegate, IFoo): + pi_attr_delegates = {'impl': IFoo} + foo = None # prevents delegation of foo to `impl` + + def __init__(self, impl): + self.impl = impl + self.foo = 3 + """ pi_attr_fallback = None pi_attr_delegates = {} pi_attr_mapping = {} def __init_subclass__(cls, **kwargs): + # get non-interface base class ignoring abc.ABC and object. + non_interface_bases = [base for base in cls.mro()[:-2] if not type_is_interface(base)] + + def i_have_attribute(attrib): + for klass in non_interface_bases: + if attrib in klass.__dict__: + return True + return False + for delegate, attr_list in cls.pi_attr_delegates.items(): if isinstance(attr_list, type): attr_list = list(get_interface_names(attr_list)) @@ -113,19 +131,19 @@ def __init_subclass__(cls, **kwargs): for attr in attr_list: if attr in cls.pi_attr_mapping: raise ValueError(f'{attr} in pi_attr_map and handled by delegate {delegate}') - if attr in cls.__dict__: + if i_have_attribute(attr): continue dotted_name = f'{delegate}.{attr}' setattr(cls, attr, _Delegated(dotted_name)) for attr, dotted_name in cls.pi_attr_mapping.items(): - if attr not in cls.__dict__: + if not i_have_attribute(attr): setattr(cls, attr, _Delegated(dotted_name)) if cls.pi_attr_fallback: fallback = cls.pi_attr_fallback for interface in get_type_interfaces(cls): interface_names = get_interface_names(interface) for attr in interface_names: - if attr not in cls.__dict__: + if not i_have_attribute(attr): dotted_name = f'{fallback}.{attr}' setattr(cls, attr, _Delegated(dotted_name)) diff --git a/setup.cfg b/setup.cfg index 95e6ab4..c91c877 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pure_interface -version = 7.0.0 +version = 7.0.1 description = A Python interface library that disallows function body content on interfaces and supports adaption. keywords = abc interface adapt adaption mapper structural typing dataclass author = Tim Mitchell diff --git a/tests/test_delegate.py b/tests/test_delegate.py index 3c89763..1889354 100644 --- a/tests/test_delegate.py +++ b/tests/test_delegate.py @@ -1,3 +1,5 @@ +import dataclasses + import pure_interface import unittest from unittest import mock @@ -29,12 +31,29 @@ class IPoint(pure_interface.Interface): x: int y: int + def to_str(self) -> str: + pass + + +class IPoint3(IPoint): + z: int + + +@dataclasses.dataclass +class PointImpl: + x: int + y: int + z: int + class Point(IPoint, object): def __init__(self, x=0, y=1): self.x = int(x) self.y = int(y) + def to_str(self) -> str: + return f'{self.x}, {self.y}' + class DFallback(delegation.Delegate, ITalker): pi_attr_fallback = 'impl' @@ -45,8 +64,9 @@ def __init__(self, impl): class DAttrMap(delegation.Delegate, IPoint): pi_attr_mapping = {'x': 'a.x', - 'y': 'b.y', - } + 'y': 'b.y', + 'to_str': 'b.to_str', + } def __init__(self, a, b): self.a = a @@ -54,8 +74,8 @@ def __init__(self, a, b): class DDelegateList(delegation.Delegate, IPoint): - pi_attr_delegates = {'a': ['x'], - 'b': ['x', 'y']} + pi_attr_delegates = {'a': ['x', 'to_str'], + 'b': ['x', 'y']} def __init__(self, a, b): self.a = a @@ -110,6 +130,28 @@ def __init__(self): self.a = a +class ScaledPoint(pure_interface.Delegate, IPoint): + pi_attr_fallback = '_p' + + def __init__(self, point): + self._p = point + + @property + def y(self): + return self._p.y * 2 + + @y.setter + def y(self, value): + self._p.y = int(value // 2) + + def to_str(self) -> str: + return f'{self.x}, {self.y}' + + +class ScaledPoint3(ScaledPoint, IPoint3): + pi_attr_fallback = '_p' + + class DelegateTest(unittest.TestCase): def test_descriptor_get_class(self): d = pure_interface.delegation._Delegated('foo.bar') @@ -223,6 +265,16 @@ def test_double_dotted_delegate(self): d.x = 1 self.assertEqual(1, d.a.b.x) + def test_delegate_subclass(self): + """test that subclass methods are not delegated """ + p = PointImpl(1, 2, 3) + d3 = ScaledPoint3(p) + self.assertEqual(4, d3.y) + self.assertEqual('1, 4', d3.to_str()) + d3.y = 8 # delegates to p + self.assertEqual(4, p.y) + self.assertEqual(3, d3.z) + class CompositionTest(unittest.TestCase):