Skip to content

Commit d83a8b3

Browse files
authored
APICallWrapper.__get__ must return a bound method (#218)
This magic method was previously returning a plain, unbound function. This tricks some introspection code, which expects it to be a bound method. Also add unit test coverage for that module. Bump version to 2.0.0a6.
1 parent e5149f6 commit d83a8b3

File tree

4 files changed

+155
-16
lines changed

4 files changed

+155
-16
lines changed

HISTORY.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ Release History
108108
whether the caller reads or streams the content.
109109
- Add more information to the request/response logs from ``LoggingNetwork``.
110110
- Add logging for request exceptions in ``LoggingNetwork``.
111+
- Bugfix so that the return value of ``JWTAuth.refresh()`` correctly matches
112+
that of the auth interface (by returning a tuple of
113+
((access token), (refresh token or None)), instead of just the access token).
114+
In particular, this fixes an exception in ``BoxSession`` that always occurred
115+
when it tried to refresh any ``JWTAuth`` object.
111116
- Fixed an exception that was being raised from ``ExtendableEnumMeta.__dir__()``.
112117
- CPython 3.6 support.
113118

boxsdk/util/api_call_decorator.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from functools import update_wrapper, wraps
66

7+
from ..object.cloneable import Cloneable
8+
79

810
def api_call(method):
911
"""
@@ -33,26 +35,42 @@ class APICallWrapper(object):
3335
def __init__(self, func_that_makes_an_api_call):
3436
super(APICallWrapper, self).__init__()
3537
self._func_that_makes_an_api_call = func_that_makes_an_api_call
38+
self.__name__ = func_that_makes_an_api_call.__name__
3639
update_wrapper(self, func_that_makes_an_api_call)
3740

41+
def __call__(self, cloneable_instance, *args, **kwargs):
42+
return self.__get__(cloneable_instance, type(cloneable_instance))(*args, **kwargs)
43+
3844
def __get__(self, _instance, owner):
45+
# `APICallWrapper` is imitating a function. For native functions,
46+
# ```func.__get__(None, cls)``` always returns `func`.
47+
if _instance is None:
48+
return self
49+
50+
if isinstance(owner, type) and not issubclass(owner, Cloneable):
51+
raise TypeError(
52+
"descriptor {name!r} must be owned by a 'Cloneable' subclass, not {owner.__name__}"
53+
.format(name=self.__name__, owner=owner)
54+
)
55+
expected_type = owner or Cloneable
56+
if not isinstance(_instance, expected_type):
57+
raise TypeError(
58+
"descriptor {name!r} for {expected_type.__name__!r} objects doesn't apply to {instance.__class__.__name__!r} object"
59+
.format(name=self.__name__, expected_type=expected_type, instance=_instance)
60+
)
61+
3962
@wraps(self._func_that_makes_an_api_call)
40-
def call(*args, **kwargs):
41-
instance = _instance
42-
if instance is None:
43-
# If this is being called as an unbound method, the instance is the first arg.
44-
if owner is not None and args and isinstance(args[0], owner):
45-
instance = args[0]
46-
args = args[1:]
47-
else:
48-
raise TypeError
63+
def call(instance, *args, **kwargs):
4964
extra_network_parameters = kwargs.pop('extra_network_parameters', None)
5065
if extra_network_parameters:
5166
# If extra_network_parameters is specified, then clone the instance, and specify the parameters
5267
# as the defaults to be used.
53-
# pylint: disable=protected-access
54-
instance = instance.clone(instance._session.with_default_network_request_kwargs(extra_network_parameters))
55-
# pylint: enable=protected-access
56-
response = self._func_that_makes_an_api_call(instance, *args, **kwargs)
57-
return response
58-
return call
68+
instance = instance.clone(instance.session.with_default_network_request_kwargs(extra_network_parameters))
69+
70+
method = self._func_that_makes_an_api_call.__get__(instance, owner)
71+
return method(*args, **kwargs)
72+
73+
# Since the caller passed a non-`None` instance to `__get__()`, they
74+
# want a bound method back, not an unbound function. Thus, we must bind
75+
# `call()` to `_instance` and then return that bound method.
76+
return call.__get__(_instance, owner)

boxsdk/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from __future__ import unicode_literals, absolute_import
44

55

6-
__version__ = '2.0.0a5'
6+
__version__ = '2.0.0a6'
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# coding: utf-8
2+
3+
from __future__ import absolute_import, unicode_literals
4+
5+
from boxsdk.object.cloneable import Cloneable
6+
from boxsdk.util.api_call_decorator import api_call
7+
from mock import NonCallableMock
8+
import pytest
9+
10+
11+
@pytest.fixture
12+
def api_call_result():
13+
return {'bar': 'ƒøø'}
14+
15+
16+
@pytest.fixture(name='api_call_method')
17+
def api_call_method_fixture(api_call_result):
18+
19+
@api_call
20+
def api_call_method(self, *args, **kwargs):
21+
return self, args, kwargs, api_call_result
22+
23+
return api_call_method
24+
25+
26+
@pytest.fixture
27+
def cloneable_subclass_with_api_call_method(api_call_method):
28+
api_call_method_fixture = api_call_method
29+
30+
# pylint:disable=abstract-method
31+
class CloneableSubclass(Cloneable):
32+
api_call_method = api_call_method_fixture
33+
34+
return CloneableSubclass
35+
36+
37+
@pytest.fixture
38+
def mock_cloneable(cloneable_subclass_with_api_call_method):
39+
40+
# pylint:disable=abstract-method
41+
class MockCloneable(cloneable_subclass_with_api_call_method, NonCallableMock):
42+
pass
43+
44+
return MockCloneable(spec_set=cloneable_subclass_with_api_call_method, name='Cloneable')
45+
46+
47+
def test_api_call_is_decorator():
48+
49+
@api_call
50+
def func():
51+
pass
52+
53+
assert callable(func)
54+
assert hasattr(func, '__get__')
55+
56+
57+
def test_api_call_decorated_function_must_be_a_method():
58+
59+
@api_call
60+
def func():
61+
pass
62+
63+
with pytest.raises(TypeError):
64+
func()
65+
66+
67+
def test_api_call_decorated_method_must_be_a_cloneable_method():
68+
69+
class Cls(object):
70+
@api_call
71+
def func(self):
72+
pass
73+
74+
obj = Cls()
75+
with pytest.raises(TypeError):
76+
obj.func()
77+
78+
79+
def test_api_call_decorated_method_must_be_bound_to_an_instance_of_the_owner(mock_cloneable, api_call_method):
80+
# pylint:disable=abstract-method
81+
class CloneableSubclass2(Cloneable):
82+
pass
83+
84+
with pytest.raises(TypeError):
85+
api_call_method.__get__(mock_cloneable, CloneableSubclass2)
86+
87+
88+
def test_api_call_decorated_method_returns_itself_when_bound_to_none(api_call_method, cloneable_subclass_with_api_call_method):
89+
assert api_call_method.__get__(None, Cloneable) is api_call_method
90+
assert not hasattr(api_call_method.__get__(None, Cloneable), '__self__')
91+
assert cloneable_subclass_with_api_call_method.api_call_method is api_call_method
92+
assert not hasattr(cloneable_subclass_with_api_call_method.api_call_method, '__self__')
93+
94+
95+
def test_api_call_decorated_method_binds_to_instance(mock_cloneable, api_call_method):
96+
assert api_call_method.__get__(mock_cloneable, Cloneable) is not api_call_method
97+
assert api_call_method.__get__(mock_cloneable, Cloneable).__self__ is mock_cloneable
98+
assert mock_cloneable.api_call_method is not api_call_method
99+
assert mock_cloneable.api_call_method.__self__ is mock_cloneable
100+
101+
102+
def test_api_call_decorated_method_delegates_to_wrapped_method(mock_cloneable, api_call_result):
103+
args = (1, 2, 'ƒøø', 'bar')
104+
kwargs = {'bar': 'ƒøø'}
105+
assert mock_cloneable.api_call_method(*args, **kwargs) == (mock_cloneable, args, kwargs, api_call_result)
106+
107+
108+
def test_api_call_decorated_method_can_be_called_as_an_unbound_method_with_an_instance_as_the_first_argument(
109+
mock_cloneable,
110+
api_call_result,
111+
cloneable_subclass_with_api_call_method,
112+
):
113+
args = (1, 2, 'ƒøø', 'bar')
114+
kwargs = {'bar': 'ƒøø'}
115+
api_call_method = cloneable_subclass_with_api_call_method.api_call_method
116+
assert api_call_method(mock_cloneable, *args, **kwargs) == (mock_cloneable, args, kwargs, api_call_result)

0 commit comments

Comments
 (0)