diff --git a/README.rst b/README.rst index 3745e23..01af913 100644 --- a/README.rst +++ b/README.rst @@ -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 ======== @@ -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 ======================== @@ -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:: diff --git a/pure_interface/__init__.py b/pure_interface/__init__.py index c90547d..25215f6 100644 --- a/pure_interface/__init__.py +++ b/pure_interface/__init__.py @@ -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' diff --git a/pure_interface/_sub_interface.py b/pure_interface/_sub_interface.py new file mode 100644 index 0000000..6874471 --- /dev/null +++ b/pure_interface/_sub_interface.py @@ -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 diff --git a/pure_interface/adaption.py b/pure_interface/adaption.py index 1b9fbac..516940d 100644 --- a/pure_interface/adaption.py +++ b/pure_interface/adaption.py @@ -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 @@ -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. @@ -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 @@ -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__), @@ -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 diff --git a/pure_interface/delegation.py b/pure_interface/delegation.py index 7864a46..c482f9e 100644 --- a/pure_interface/delegation.py +++ b/pure_interface/delegation.py @@ -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. diff --git a/pure_interface/errors.py b/pure_interface/errors.py index 88cbb22..5f624dd 100644 --- a/pure_interface/errors.py +++ b/pure_interface/errors.py @@ -11,4 +11,4 @@ class InterfaceError(PureInterfaceError, TypeError): class AdaptionError(PureInterfaceError, ValueError): """ An adaption error """ - pass \ No newline at end of file + pass diff --git a/pure_interface/interface.py b/pure_interface/interface.py index a2faae8..82cc491 100644 --- a/pure_interface/interface.py +++ b/pure_interface/interface.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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): @@ -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 @@ -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: diff --git a/setup.cfg b/setup.cfg index e8f9316..9ee2427 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/tests/test_adapt_args_anno.py b/tests/test_adapt_args_anno.py index 0866ac4..7f63d7b 100644 --- a/tests/test_adapt_args_anno.py +++ b/tests/test_adapt_args_anno.py @@ -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): @@ -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') diff --git a/tests/test_adaption.py b/tests/test_adaption.py index d516382..0bbb79d 100644 --- a/tests/test_adaption.py +++ b/tests/test_adaption.py @@ -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() @@ -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') @@ -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())) diff --git a/tests/test_dataclass_support.py b/tests/test_dataclass_support.py index 41c093a..d574b3f 100644 --- a/tests/test_dataclass_support.py +++ b/tests/test_dataclass_support.py @@ -38,3 +38,20 @@ def test_data_arg_order(self): self.assertEqual('a=2, b=two, c=34.0', f.foo()) except Exception as exc: self.fail(str(exc)) + + def test_data_class_with_args(self): + try: + @dataclass(frozen=True) + class FrozenFoo(IFoo, object): + c: float = 12.0 + + def foo(self): + return 'a={}, b={}, c={}'.format(self.a, self.b, self.c) + + except Exception as exc: + self.fail(str(exc)) + + f = Foo(a=1, b='two') + self.assertEqual(1, f.a) + self.assertEqual('two', f.b) + self.assertEqual(12.0, f.c) diff --git a/tests/test_delegate.py b/tests/test_delegate.py index 07347d6..737061f 100644 --- a/tests/test_delegate.py +++ b/tests/test_delegate.py @@ -301,6 +301,10 @@ def test_delegate_subclass_fallback2(self): d = DSubFallback2(Talker()) self.assertEqual('chat', d.chat()) + def test_delegate_provides_fails(self): + with self.assertRaises(pure_interface.InterfaceError): + DFallback.provided_by(ITalker) + class CompositionTest(unittest.TestCase): @@ -376,3 +380,12 @@ def __init__(self): except Exception as exc: self.fail(str(exc)) + + def test_fail_on_unsupported_type(self): + with self.assertRaises(ValueError): + delegation.composed_type(str, int) + + def test_too_many_interfaces(self): + with mock.patch('pure_interface.delegation._letters', 'a'): + with self.assertRaises(ValueError): + delegation.composed_type(ITalker, IPoint) diff --git a/tests/test_func_sigs3.py b/tests/test_func_sigs3.py index 131903e..5239b7d 100644 --- a/tests/test_func_sigs3.py +++ b/tests/test_func_sigs3.py @@ -157,7 +157,7 @@ def iter_kw_args(spec_sig): class TestFunctionSigsPy3(unittest.TestCase): @classmethod def setUpClass(cls): - interface.is_development = True + pure_interface.set_is_development(True) def check_signatures(self, int_func, impl_func, expected_result): interface_sig = pure_interface.interface.signature(int_func) diff --git a/tests/test_function_sigs.py b/tests/test_function_sigs.py index a9f26a4..ec0a9d6 100644 --- a/tests/test_function_sigs.py +++ b/tests/test_function_sigs.py @@ -124,7 +124,7 @@ def test_call(spec_func: types.FunctionType, impl_func: types.FunctionType) -> b class TestFunctionSignatureChecks(unittest.TestCase): @classmethod def setUpClass(cls): - interface.is_development = True + pure_interface.set_is_development(True) def check_signatures(self, int_func, impl_func, expected_result): interface_sig = pure_interface.interface.signature(int_func) @@ -335,7 +335,7 @@ def grow(self, height): class TestDisableFunctionSignatureChecks(unittest.TestCase): @classmethod def setUpClass(cls): - interface.is_development = False + pure_interface.set_is_development(False) def test_too_many_passes(self): try: diff --git a/tests/test_implementation_checks.py b/tests/test_implementation_checks.py index 801d687..f692976 100644 --- a/tests/test_implementation_checks.py +++ b/tests/test_implementation_checks.py @@ -5,10 +5,8 @@ from pure_interface import * from pure_interface import interface -try: - from unittest import mock -except ImportError: - import mock +from unittest import mock + class ADescriptor(object): def __init__(self, value): @@ -223,7 +221,7 @@ def foo(): def test_missing_methods_warning(self): # assemble - interface.is_development = True + set_is_development(True) interface.missing_method_warnings = [] # act @@ -279,6 +277,11 @@ class Test(HeightDescr, IPlant): self.assertEqual(frozenset([]), Test._pi.abstractproperties) + def test_set_development(self): + for value in True, False: + set_is_development(value) + self.assertEqual(value, get_is_development()) + class TestPropertyImplementations(unittest.TestCase): def test_abstract_property_override_passes(self): diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index 1e14fe6..1689411 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -1,7 +1,6 @@ import unittest import pure_interface -from pure_interface import interface class IGrowingThing(pure_interface.Interface): @@ -66,14 +65,14 @@ class Y(object): class TestInheritance(unittest.TestCase): def test_bad_mixin_class_is_checked(self): - interface.is_development = True + pure_interface.set_is_development(True) with self.assertRaises(pure_interface.InterfaceError): class Growing(BadGrowingMixin, IGrowingThing): pass def test_ok_mixin_class_passes(self): - interface.is_development = True + pure_interface.set_is_development(True) class Growing(GrowingMixin, IGrowingThing): pass diff --git a/tests/test_isinstance.py b/tests/test_isinstance.py index 41daf93..4fb8a40 100644 --- a/tests/test_isinstance.py +++ b/tests/test_isinstance.py @@ -4,7 +4,6 @@ import warnings import pure_interface -from pure_interface import interface from tests.interface_module import IAnimal @@ -61,7 +60,7 @@ def happy(self): Cat.provided_by(c, allow_implicit=False) def test_warning_issued_once(self): - interface.is_development = True + pure_interface.set_is_development(True) class Cat2(object): def speak(self, volume): @@ -79,7 +78,7 @@ def height(self): self.assertEqual(warn.call_count, 1) def test_warning_not_issued(self): - interface.is_development = False + pure_interface.set_is_development(False) class Cat3(object): def speak(self, volume): @@ -96,7 +95,7 @@ def height(self): warn.assert_not_called() def test_warning_contents(self): - interface.is_development = True + pure_interface.set_is_development(True) class Cat4(object): def speak(self, volume): @@ -117,7 +116,7 @@ def height(self): self.assertIn('IAnimal', msg) def test_warning_contents_adapt(self): - interface.is_development = True + pure_interface.set_is_development(True) class Cat5(object): def speak(self, volume): diff --git a/tests/test_py38/test_func_sigs_po.py b/tests/test_py38/test_func_sigs_po.py index 2446b22..794aeb3 100644 --- a/tests/test_py38/test_func_sigs_po.py +++ b/tests/test_py38/test_func_sigs_po.py @@ -82,7 +82,7 @@ def func5ex2(a, b='b', c='c'): class TestFunctionSigsPositionalOnly(unittest.TestCase): @classmethod def setUpClass(cls): - pure_interface.interface.is_development = True + pure_interface.set_is_development(True) def check_signatures(self, int_func, impl_func, expected_result): reality = test_func_sigs3.test_call(int_func, impl_func) diff --git a/tests/test_sub_interfaces.py b/tests/test_sub_interfaces.py new file mode 100644 index 0000000..13cd46a --- /dev/null +++ b/tests/test_sub_interfaces.py @@ -0,0 +1,106 @@ +import unittest + +import pure_interface + + +class ILarger(pure_interface.Interface): + a: int + b: int + c: int + + def e(self): + pass + + def f(self, arg1, arg2, *kwargs): + pass + + def g(self, /, a, *, b): + pass + + +@pure_interface.sub_interface_of(ILarger) +class ISmaller(pure_interface.Interface): + b: int + + def e(self): + pass + + +@pure_interface.adapts(int, ILarger) +def larger_int(i): + return Larger(i, i, i) + + +@pure_interface.dataclass +class Larger(ILarger, object): + + def e(self): + return 'e' + + def f(self, arg1, arg2, *kwargs): + return arg1, arg2, kwargs + + def g(self, /, a, *, b): + return a, b + + +class TestAdaption(unittest.TestCase): + def test_large_registered(self): + big = Larger(1, 2, 3) + self.assertTrue(isinstance(big, ISmaller)) + + def test_large_adapts(self): + # sanity check + big = ILarger.adapt(5) + self.assertEqual(big.b, 5) + # assert + try: + s = ISmaller.adapt(4) + except pure_interface.InterfaceError: + self.fail('ISmaller does not adapt ILarger') + + self.assertEqual(s.b, 4) + + def test_fails_when_empty(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, 'Sub-interface IEmpty is empty'): + @pure_interface.sub_interface_of(ILarger) + class IEmpty(pure_interface.Interface): + pass + + def test_fails_when_arg_not_interface(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, + "sub_interface_of argument is not an interface type"): + @pure_interface.sub_interface_of(int) + class ISubInterface(pure_interface.Interface): + def __sub__(self, other): + pass + + def test_fails_when_class_not_interface(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, + 'class decorated by sub_interface_of must be an interface type'): + @pure_interface.sub_interface_of(ILarger) + class NotInterface: + pass + + def test_fails_when_attr_mismatch(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, + 'NotSmaller has attributes that are not on ILarger: z'): + @pure_interface.sub_interface_of(ILarger) + class INotSmaller(pure_interface.Interface): + z: int + + def test_fails_when_methods_mismatch(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, + 'NotSmaller has methods that are not on ILarger: x'): + @pure_interface.sub_interface_of(ILarger) + class INotSmaller(pure_interface.Interface): + def x(self): + pass + + def test_fails_when_signatures_mismatch(self): + with self.assertRaisesRegex(pure_interface.InterfaceError, + 'Signature of method f on ILarger and INotSmaller must match exactly'): + @pure_interface.sub_interface_of(ILarger) + class INotSmaller(pure_interface.Interface): + def f(self, arg1, arg2, foo=3): + pass diff --git a/tests/test_tracker.py b/tests/test_tracker.py index 850652f..63fce12 100644 --- a/tests/test_tracker.py +++ b/tests/test_tracker.py @@ -82,3 +82,29 @@ def factory(): tracker.adapt(t, ISpeaker) self.assertTrue(len(mocks) > 1) + + def test_adapt_or_none_works(self): + tracker = AdapterTracker() + t = Talker() + + speaker = tracker.adapt_or_none(t, ISpeaker) + + self.assertIsInstance(speaker, ISpeaker) + + def test_adapt_or_none_returns_none(self): + tracker = AdapterTracker() + + speaker = tracker.adapt_or_none('hello', ISpeaker) + + self.assertIsNone(speaker) + + def test_clear(self): + tracker = AdapterTracker() + t = Talker() + speaker1 = tracker.adapt_or_none(t, ISpeaker) + + tracker.clear() + + speaker2 = tracker.adapt_or_none(t, ISpeaker) + self.assertIsNot(speaker1, speaker2) +