From c1fcde26b0168c0568af626160d766d9a3851b4a Mon Sep 17 00:00:00 2001 From: Federico Ressi Date: Wed, 17 Mar 2021 16:39:10 +0100 Subject: [PATCH] Implement call proxy handling - Implement call proxy handling. Example: import tobiko class MyProtocol(tobiko.Protocol): def do_something(some='argument'): raise NotImplementedError def handle_call(method, *args, **kwargs) print(f'do something with some={some}') proxy = tobiko.call_proxy(MyProtocol, handle_call) proxy.do_something('aple') It will print: do something with some=apple - Add new depenency from decorator library Change-Id: I4a933e779739cfd953cafb275b074a308822aaac --- lower-constraints.txt | 1 + requirements.txt | 1 + tobiko/__init__.py | 7 +++ tobiko/common/_proxy.py | 106 ++++++++++++++++++++++++++++++++ tobiko/tests/unit/test_proxy.py | 65 ++++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 tobiko/common/_proxy.py create mode 100644 tobiko/tests/unit/test_proxy.py diff --git a/lower-constraints.txt b/lower-constraints.txt index 0b7c3b203..6e083e716 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -1,5 +1,6 @@ # from requirements.txt +decorator===4.4.2 docker==4.4.1 fixtures==3.0.0 Jinja2==2.11.2 diff --git a/requirements.txt b/requirements.txt index 24a1322da..4bfbefd68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Tobiko framework requirements +decorator>=4.4.2 # BSD docker>=4.4.1 # Apache-2.0 fixtures>=3.0.0 # Apache-2.0/BSD Jinja2>=2.11.2 # BSD diff --git a/tobiko/__init__.py b/tobiko/__init__.py index 323e8e703..d199054d7 100644 --- a/tobiko/__init__.py +++ b/tobiko/__init__.py @@ -23,6 +23,7 @@ from tobiko.common import _logging from tobiko.common.managers import loader as loader_manager from tobiko.common import _operation from tobiko.common import _os +from tobiko.common import _proxy from tobiko.common import _retry from tobiko.common import _select from tobiko.common import _skip @@ -83,6 +84,12 @@ get_operation = _operation.get_operation get_operation_name = _operation.get_operation_name operation_config = _operation.operation_config +Protocol = _proxy.Protocol +call_proxy = _proxy.call_proxy +call_proxy_class = _proxy.call_proxy_class +CallHandler = _proxy.CallHandler +CallProxy = _proxy.CallProxy + retry = _retry.retry retry_attempt = _retry.retry_attempt retry_on_exception = _retry.retry_on_exception diff --git a/tobiko/common/_proxy.py b/tobiko/common/_proxy.py new file mode 100644 index 000000000..a4c81e510 --- /dev/null +++ b/tobiko/common/_proxy.py @@ -0,0 +1,106 @@ +# Copyright 2021 Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import absolute_import + +import abc +import functools +import inspect +import typing + +import decorator + + +class ProtocolMeta(abc.ABCMeta): + + def __new__(mcls, name, bases, namespace, **kwargs): + cls = super().__new__(mcls, name, bases, namespace, **kwargs) + cls._is_protocol = True + return cls + + +class Protocol(abc.ABC, metaclass=ProtocolMeta): + pass + + +T = typing.TypeVar('T') + + +def is_protocol_class(cls): + return cls.__dict__.get('_is_protocol', False) + + +def is_public_function(obj): + return (inspect.isfunction(obj) and + getattr(obj, '__name__', '_')[0] != '_') + + +class CallHandler(abc.ABC): + + def _handle_call(self, method: typing.Callable, *args, **kwargs): + raise NotImplementedError + + +class CallProxy(CallHandler): + + _handle_call: typing.Callable + + def __init__(self, handle_call: typing.Callable): + setattr(self, '_handle_call', handle_call) + + +@functools.lru_cache() +def call_proxy_class(protocol_class: type, + class_name: typing.Optional[str] = None, + handler_class: typing.Type[CallHandler] = CallProxy) \ + -> type: + if not is_protocol_class(protocol_class): + raise TypeError(f"{protocol_class} is not a subclass of {Protocol}") + if class_name is None: + class_name = protocol_class.__name__ + 'Proxy' + namespace: typing.Dict[str, typing.Any] = {} + for name, member in protocol_class.__dict__.items(): + if is_public_function(member): + method = call_proxy_method(member) + namespace[name] = method + + return type(class_name, (handler_class, protocol_class), namespace) + + +def call_proxy(protocol_class: typing.Type[T], handle_call: typing.Callable) \ + -> T: + proxy_class = call_proxy_class(typing.cast(type, protocol_class)) + return proxy_class(handle_call) + + +@functools.lru_cache() +def stack_classes(name: str, cls: type, *classes) -> type: + return type(name, (cls,) + classes, {}) + + +@functools.lru_cache() +def list_protocols(cls: type) -> typing.Tuple[typing.Type[Protocol], ...]: + subclasses = inspect.getmro(cls) + protocols = tuple(typing.cast(typing.Type[Protocol], cls) + for cls in subclasses + if is_protocol_class(cls)) + return tuple(protocols) + + +def call_proxy_method(func: typing.Callable) -> typing.Callable: + return decorator.decorate(func, _call_proxy_method) + + +def _call_proxy_method(func, self: CallHandler, *args, **kwargs): + # pylint: disable=protected-access + return self._handle_call(func, *args, **kwargs) diff --git a/tobiko/tests/unit/test_proxy.py b/tobiko/tests/unit/test_proxy.py new file mode 100644 index 000000000..c57b5fc5e --- /dev/null +++ b/tobiko/tests/unit/test_proxy.py @@ -0,0 +1,65 @@ +# Copyright 2021 Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import absolute_import + +import inspect +import typing + +import mock + +import tobiko +from tobiko.tests import unit + + +class MyProto1(tobiko.Protocol): + # pylint: disable=unused-argument + + def call_one(self, arg='a') -> int: + return 42 + + def call_two(self, *args) -> int: + return 42 + + def call_three(self, **kwargs) -> int: + return 42 + + +class ProxyTest(unit.TobikoUnitTest): + + def test_call_proxy(self): + + def handle_call(method: typing.Callable, *_args, **_kwargs): + self.assertTrue(inspect.isfunction(method)) + + handler = mock.MagicMock(side_effect=handle_call) + proxy = tobiko.call_proxy(MyProto1, + typing.cast(typing.Callable, handler)) + self.assertIsInstance(proxy, MyProto1) + self.assertTrue(callable(proxy.call_one)) + self.assertEqual(inspect.signature(MyProto1.call_one), + inspect.signature(type(proxy).call_one)) + self.assertIsNot(MyProto1.call_one, + proxy.call_one) + proxy.call_one() + handler.assert_called_with(MyProto1.call_one, 'a') + proxy.call_one('b') + handler.assert_called_with(MyProto1.call_one, 'b') + proxy.call_one(arg='c') + handler.assert_called_with(MyProto1.call_one, 'c') + + proxy.call_two(1, 2, 3) + handler.assert_called_with(MyProto1.call_two, 1, 2, 3) + + proxy.call_three(a='a', b='b') + handler.assert_called_with(MyProto1.call_three, a='a', b='b')