Skip to content

Commit

Permalink
Merge pull request #99 from seequent/#96-sub-interface
Browse files Browse the repository at this point in the history
96: Add sub_interface_of decorator function.
  • Loading branch information
tim-mitchell authored Jul 21, 2023
2 parents 85d7a80 + ef94282 commit 3eb91bd
Show file tree
Hide file tree
Showing 20 changed files with 324 additions and 74 deletions.
24 changes: 22 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,22 @@ When all your imports are complete you can check if this list is empty.::

Note that missing properties are NOT checked for as they may be provided by instance attributes.

Sub-Interfaces
==============
Sometimes your code only uses a small part of a large interface. It can be useful (eg. for test mocking) to specify
the sub part of the interface that your code requires. This can be done with the ``sub_interface_of`` decorator.::

@sub_interface_of(IAnimal)
class IHeight(pure_interface.Interface):
height: float

def my_code(h: IHeight):
return "That's tall" if h.height > 100 else "Not so tall"

The ``sub_interface_of`` decorator checks that the attributes and methods of the smaller interface match the larger interface.
Function signatures must match exactly (not just be compatible). The decorator will also use ``abc.register`` so that
``isinstance(Animal(), IHeight)`` returns ``True``.

Adaption
========

Expand Down Expand Up @@ -324,6 +340,10 @@ Adapters from sub-interfaces may be used to perform adaption if necessary. For e
Then ``IA.adapt(4)`` will use the ``IntToB`` adapter to adapt ``4`` to ``IA`` (unless there is already an adapter
from ``int`` to ``IA``)

Further, if an interface is decorated with ``sub_interface_of``, adapters for the larger interface will be used if
a direct adapter is not found.


Structural Type Checking
========================

Expand Down Expand Up @@ -554,8 +574,8 @@ If you supply more than one delegation rule (e.g. both ``pi_attr_mapping`` and `
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
-----------
Type Composition
----------------
A special case where all delegated attributes are defined in an ``Interface`` is handled by the ``composed_type`` factory function.
``composed_type`` takes 2 or more interfaces and returns a new type that inherits from all the interfaces with a
constructor that takes instances that implement those interfaces (in the same order). For exmaple::
Expand Down
11 changes: 4 additions & 7 deletions pure_interface/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
from .errors import PureInterfaceError, InterfaceError, AdaptionError
from .interface import Interface, InterfaceType
from .interface import type_is_interface, type_is_pure_interface, get_type_interfaces
from .interface import type_is_interface, get_type_interfaces
from .interface import get_interface_names, get_interface_method_names, get_interface_attribute_names
from .interface import get_is_development, set_is_development, get_missing_method_warnings
from ._sub_interface import sub_interface_of
from .adaption import adapts, register_adapter, AdapterTracker, adapt_args
from .delegation import Delegate
from .data_classes import dataclass

try:
from .data_classes import dataclass
except ImportError:
pass

__version__ = '7.2.0'
__version__ = '7.3.0'
58 changes: 58 additions & 0 deletions pure_interface/_sub_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
""" decorator function for sub-interfaces
A sub-interface is a non-empty subset of another, larger, interface.
The decorator checks that the sub-interface is infact a subset and
registers the larger interface as an implementation of the sub-interface.
"""
from inspect import signature
from typing import Callable, TypeVar, Type
from . import errors, interface

AnotherInterfaceType = TypeVar('AnotherInterfaceType', bound=Type[interface.Interface])


def _check_interfaces_match(large_interface, small_interface):
large_attributes = interface.get_interface_attribute_names(large_interface)
small_attributes = interface.get_interface_attribute_names(small_interface)
large_methods = interface.get_interface_method_names(large_interface)
small_methods = interface.get_interface_method_names(small_interface)

if len(small_attributes) + len(small_methods) == 0:
raise interface.InterfaceError(f'Sub-interface {small_interface.__name__} is empty')

if not small_attributes.issubset(large_attributes):
new_attrs = sorted(small_attributes.difference(large_attributes))
new_attrs = ', '.join(new_attrs)
msg = f'{small_interface.__name__} has attributes that are not on {large_interface.__name__}: {new_attrs}'
raise interface.InterfaceError(msg)

if not small_methods.issubset(large_methods):
new_methods = sorted(small_methods.difference(large_methods))
new_methods = ', '.join(new_methods)
msg = f'{small_interface.__name__} has methods that are not on {large_interface.__name__}: {new_methods}'
raise interface.InterfaceError(msg)

for method_name in small_methods:
large_method = getattr(large_interface, method_name)
small_method = getattr(small_interface, method_name)
if signature(large_method) != signature(small_method):
msg = (f'Signature of method {method_name} on {large_interface.__name__} '
f'and {small_interface.__name__} must match exactly')
raise interface.InterfaceError(msg)


def sub_interface_of(
large_interface: interface.AnInterfaceType
) -> Callable[[AnotherInterfaceType], AnotherInterfaceType]:
if not interface.type_is_interface(large_interface):
raise errors.InterfaceError(f'sub_interface_of argument {large_interface} is not an interface type')

def decorator(small_interface: AnotherInterfaceType) -> AnotherInterfaceType:
if not interface.type_is_interface(small_interface):
raise errors.InterfaceError('class decorated by sub_interface_of must be an interface type')
_check_interfaces_match(large_interface, small_interface)
small_interface.register(large_interface) # type: ignore[arg-type]

return small_interface

return decorator
21 changes: 8 additions & 13 deletions pure_interface/adaption.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import warnings

from .errors import InterfaceError, AdaptionError
from .interface import AnInterface, Interface, InterfaceType, AnInterfaceType
from .interface import AnInterface, Interface, InterfaceType, AnInterfaceType, type_is_interface
from .interface import get_type_interfaces, get_pi_attribute


Expand Down Expand Up @@ -46,7 +46,10 @@ def decorator(cls):
return decorator


def register_adapter(adapter: Callable, from_type: Type, to_interface: AnInterfaceType) -> None:
def register_adapter(
adapter: Callable[[Type], AnInterfaceType],
from_type: Type,
to_interface: AnInterfaceType) -> None:
""" Registers adapter to convert instances of from_type to objects that provide to_interface
for the to_interface.adapt() method.
Expand Down Expand Up @@ -119,11 +122,8 @@ def _interface_from_anno(annotation: Any) -> Optional[AnInterfaceType]:
if annotation.__origin__ is not typing.Union:
return None
for arg_type in annotation.__args__:
try:
if issubclass(arg_type, Interface):
return arg_type
except TypeError:
pass
if type_is_interface(arg_type):
return arg_type

return None

Expand Down Expand Up @@ -177,8 +177,6 @@ def wrapped(*args, **kwargs):
raise AdaptionError('keyword parameters not permitted with positional argument')
funcn = func_arg[0]
annotations = typing.get_type_hints(funcn)
if annotations is None:
annotations = {}
if not annotations:
warnings.warn('No annotations for {}. '
'Add annotations or pass explicit argument types to adapt_args'.format(funcn.__name__),
Expand All @@ -190,10 +188,7 @@ def wrapped(*args, **kwargs):
return decorator(funcn)

for key, i_face in kwarg_types.items():
try:
can_adapt = issubclass(i_face, Interface)
except TypeError:
can_adapt = False
can_adapt = type_is_interface(i_face)
if not can_adapt:
raise AdaptionError('adapt_args parameter values must be subtypes of Interface')
return decorator
2 changes: 1 addition & 1 deletion pure_interface/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def __composed_init__(self, *args):
setattr(self, attr, impl)


def composed_type(*interface_types: AnInterfaceType) -> type:
def composed_type(*interface_types: AnInterfaceType) -> Type[Delegate]:
"""Returns a new class which implements all the passed interfaces.
If the interfaces have duplicate attribute or method names, the first enountered implementation is used.
Instances of the returned type are passed implementations of the given interfaces in the same order.
Expand Down
2 changes: 1 addition & 1 deletion pure_interface/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ class InterfaceError(PureInterfaceError, TypeError):

class AdaptionError(PureInterfaceError, ValueError):
""" An adaption error """
pass
pass
36 changes: 12 additions & 24 deletions pure_interface/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import inspect
from inspect import signature, Signature, Parameter
import types
from typing import Any, Callable, List, Optional, Iterable, FrozenSet, Type, TypeVar, Generic, Dict, Set, Tuple, Union
from typing import Any, Callable, List, Optional, Iterable, FrozenSet, Type, TypeVar, Generic, Dict, Set, Tuple, cast, \
Union
import sys
import warnings
import weakref
Expand Down Expand Up @@ -59,6 +60,7 @@ def __init__(self, type_is_interface: bool,
self.interface_attribute_names = frozenset(interface_attribute_names)
self.interface_method_signatures = interface_method_signatures
self.adapters = weakref.WeakKeyDictionary() # type: ignore
self.registered_types = weakref.WeakSet() # type: ignore
self.structural_subclasses: Set[type] = set()
self.impl_wrapper_type: Optional[type] = None

Expand Down Expand Up @@ -222,7 +224,7 @@ def _is_empty_function(func: Any, unwrap: bool = False) -> bool:
if not (instruction.opname == 'LOAD_CONST' and code_obj.co_consts[instruction.arg] is None): # TOS is None
return False # return is not None
instructions = instructions[:-2]
if instructions[-1].opname == 'RETURN_CONST' and instructions[-1].argval is None: # returns constant
if len(instructions) > 0 and instructions[-1].opname == 'RETURN_CONST' and instructions[-1].argval is None: # returns constant
instructions.pop(-1)
if len(instructions) == 0:
return True
Expand Down Expand Up @@ -266,21 +268,6 @@ def _signature_info(arg_spec: Iterable[Parameter]) -> _ParamTypes:
)


def _required_params(param_list: List[Parameter]) -> List[Parameter]:
""" return params without a default"""
# params with defaults come last
for i, p in enumerate(param_list):
if p.default is not Parameter.empty:
return param_list[:i]
# no defaults
return param_list


def _kw_names_match(func, base):
func_names = set(p.name for p in func)
return all(p.name in func_names for p in base)


def _positional_args_match(func_list, base_list, vararg, base_kwo):
# arguments are positional - so name doesn't matter
# func may not have fewer parameters
Expand Down Expand Up @@ -490,7 +477,8 @@ def _get_adapter(cls: AnInterfaceType, obj_type: Type) -> Optional[Callable]:
""" Returns a callable that adapts objects of type obj_type to this interface or None if no adapter exists.
"""
adapters = {} # type: ignore
candidate_interfaces = [cls] + cls.__subclasses__()
# registered interfaces can come from cls.register(AnotherInterface) or @sub_interface_of(AnotherInterface)(cls)
candidate_interfaces = [cls] + cls.__subclasses__() + list(cls._pi.registered_types)
candidate_interfaces.reverse() # prefer this class over sub-class adapters
for subcls in candidate_interfaces:
if type_is_interface(subcls):
Expand Down Expand Up @@ -687,10 +675,15 @@ def optional_adapt(cls, obj, allow_implicit=False, interface_only=None):
return None
return InterfaceType.adapt(cls, obj, allow_implicit=allow_implicit, interface_only=interface_only)

def register(cls, subclass: Type[_T]) -> Type[_T]:
if type_is_interface(cls):
cls._pi.registered_types.add(subclass) # type: ignore[attr-defined]
return super().register(subclass)


class Interface(abc.ABC, metaclass=InterfaceType):
# These methods don't need to be here, as they would resolve to the meta-class methods anyway.
# However including them here means we can add type hints that would otherwise be ambiguous on the meta-class.
# However, including them here means we can add type hints that would otherwise be ambiguous on the meta-class.
_pi: _PIAttributes

@classmethod
Expand Down Expand Up @@ -754,11 +747,6 @@ def type_is_interface(cls: Type) -> bool: # -> TypeGuard[AnInterfaceType]
return get_pi_attribute(cls, 'type_is_interface', False)


def type_is_pure_interface(cls: Type):
warnings.warn('type_is_pure_interface has been renamed to type_is_interface.')
return type_is_pure_interface(cls)


def get_type_interfaces(cls: Type) -> List[AnInterfaceType]:
""" Returns all interfaces in the cls mro including cls itself if it is an interface """
try:
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = pure_interface
version = 7.2.0
version = 7.3.0
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
Expand Down
24 changes: 18 additions & 6 deletions tests/test_adapt_args_anno.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from pure_interface import adapt_args, AdaptionError
import pure_interface
from typing import Optional
from typing import Optional, List


class I1(pure_interface.Interface):
Expand Down Expand Up @@ -112,31 +112,43 @@ def no_anno(x, y):

self.assertEqual(1, warn.call_count)

def test_type_error_raised_if_arg_not_subclass(self):
def test_adaption_error_raised_if_arg_not_subclass(self):
with self.assertRaises(AdaptionError):
@adapt_args(x=int)
def some_func(x):
pass

def test_type_error_raised_if_positional_arg_not_func(self):
def test_adaption_error_raised_if_positional_arg_not_func(self):
with self.assertRaises(AdaptionError):
@adapt_args(I2)
def some_func(x):
pass

def test_type_error_raised_if_multiple_positional_args(self):
def test_adaption_error_raised_if_multiple_positional_args(self):
with self.assertRaises(AdaptionError):
@adapt_args(I1, I2)
def some_func(x):
pass

def test_type_error_raised_if_mixed_args(self):
def test_adaption_error_raised_if_mixed_args(self):
with self.assertRaises(AdaptionError):
@adapt_args(I1, y=I2)
def some_func(x, y):
pass

def test_wrong_args_type_raises(self):
def test_wrong_args_type_raises_adaption_error(self):
thing2 = Thing2()
with self.assertRaises(ValueError):
some_func(3, thing2)

def test_mixed_args_type_raises_adaption_error(self):
with self.assertRaises(AdaptionError):
adapt_args(some_func, x=3)

def test_unsupported_generic_annotations_are_skipped(self):
try:
@adapt_args
def some_func(x: List[int], y: I2):
pass
except Exception:
self.fail('Failed to ignore unsupported annotation')
21 changes: 19 additions & 2 deletions tests/test_adaption.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def __len__(self):
class TestAdaption(unittest.TestCase):
@classmethod
def setUpClass(cls):
interface.is_development = True
pure_interface.set_is_development(True)

def test_adaption_passes(self):
talker = Talker()
Expand Down Expand Up @@ -233,11 +233,24 @@ def test_implicit_filter_adapt(self):
self.assertIsInstance(speaker, TalkerToSpeaker)
self.assertIs(speaker._talker, a_talker)

def test_fail_if_no_to_interface_for_func(self):
with self.assertRaises(interface.InterfaceError):
@pure_interface.adapts(int)
def foo(arg):
return None

def test_manual_interface_only(self):
topic_speaker = TopicSpeaker('Python')
s = ITopicSpeaker.interface_only(topic_speaker)

self.assertIsInstance(s, interface._ImplementationWrapper)
self.assertIsInstance(s, ITopicSpeaker)


class TestAdaptionToInterfaceOnly(unittest.TestCase):
@classmethod
def setUpClass(cls):
interface.is_development = True
pure_interface.set_is_development(True)

def test_wrapping_works(self):
topic_speaker = TopicSpeaker('Python')
Expand Down Expand Up @@ -380,3 +393,7 @@ def test_adapt_callable_is_callable(self):
len(dunder)
except:
self.fail('len() interface only failed')

def test_can_adapt(self):
self.assertFalse(ITalker.can_adapt('hello'))
self.assertTrue(ITalker.can_adapt(Talker()))
Loading

0 comments on commit 3eb91bd

Please sign in to comment.