Skip to content

Commit d3b87fc

Browse files
committed
Logging refactors and improvements
Move response logging to a new `LoggingNetworkResponse` class. This allows us to decide whether to log the response body, based on whether the caller reads or streams the content. This fixes a bug introduced by Pull Request #166, where the `LoggingNetwork` would always omit logging the response body. Add a `network_response_constructor` property to `DefaultNetwork`, to make it easier for subclasses to return their own `NetworkResponse` subclasses. Use this in `LoggingNetwork`, to return `LoggingNetworkResponse` instances. Also add it as an optional property on the `Network` interface class. In `network_interface.py`, switch some usages of `@abc.abstractmethod` to `@abc.abstractproperty`. This doesn't matter on recent versions of Python 3, but there is a difference between the two on Python 2, plus it makes the interface clearer. This is a backwards-compatible change, because the SDK uses these as properties, so any custom implementations would've needed to implement these with `property` anyway. Update the logging format strings in `LoggingNetwork`, to make them more informative and also easier to override (by using keyword format placeholders instead of positional ones). This is a breaking change for clients that were overriding these class attributes. Add logging for request exceptions in `LoggingNetwork`. Use the `LoggingNetwork` during functional tests. Switch some stateful mock fixtures from session scope to function scope.
1 parent 8d0a165 commit d3b87fc

File tree

10 files changed

+741
-154
lines changed

10 files changed

+741
-154
lines changed

boxsdk/network/default_network.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ def request(self, method, url, access_token, **kwargs):
2020
"""Base class override.
2121
Make a network request using a requests.Session.
2222
"""
23-
return DefaultNetworkResponse(self._session.request(method, url, **kwargs), access_token)
23+
return self.network_response_constructor(
24+
request_response=self._session.request(method, url, **kwargs),
25+
access_token_used=access_token,
26+
)
2427

2528
def retry_after(self, delay, request_method, *args, **kwargs):
2629
"""Base class override.
@@ -29,6 +32,16 @@ def retry_after(self, delay, request_method, *args, **kwargs):
2932
time.sleep(delay)
3033
return request_method(*args, **kwargs)
3134

35+
@property
36+
def network_response_constructor(self):
37+
"""Baseclass override.
38+
39+
A callable that accepts `request_response` and `access_token_used`
40+
keyword arguments for the :class:`DefaultNetworkResponse` constructor,
41+
and returns an instance of :class:`DefaultNetworkResponse`.
42+
"""
43+
return DefaultNetworkResponse
44+
3245

3346
class DefaultNetworkResponse(NetworkResponse):
3447
"""Implementation of the network interface using the requests library."""

boxsdk/network/logging_network.py

Lines changed: 216 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
# coding: utf-8
22

3-
from __future__ import unicode_literals
3+
from __future__ import absolute_import, unicode_literals
4+
5+
from functools import partial
46
from pprint import pformat
5-
from boxsdk.network.default_network import DefaultNetwork
7+
import sys
8+
9+
from six import text_type
10+
11+
from boxsdk.network.default_network import DefaultNetwork, DefaultNetworkResponse
612
from boxsdk.util.log import setup_logging
713

814

@@ -11,10 +17,11 @@ class LoggingNetwork(DefaultNetwork):
1117
SDK Network subclass that logs requests and responses.
1218
"""
1319
LOGGER_NAME = 'boxsdk.network'
14-
REQUEST_FORMAT = '\x1b[36m%s %s %s\x1b[0m'
15-
SUCCESSFUL_RESPONSE_FORMAT = '\x1b[32m%s\x1b[0m'
16-
ERROR_RESPONSE_FORMAT = '\x1b[31m%s\n%s\n%s\n\x1b[0m'
17-
STREAM_CONTENT_NOT_LOGGED = '<File download contents unavailable for logging>'
20+
REQUEST_FORMAT = '\x1b[36m%(method)s %(url)s %(request_kwargs)s\x1b[0m'
21+
EXCEPTION_FORMAT = '\x1b[31mRequest "%(method)s %(url)s" failed with %(exc_type_name)s exception: %(exc_value)r\x1b[0m'
22+
_COMMON_RESPONSE_FORMAT = '"%(method)s %(url)s" %(status_code)s %(content_length)s\n%(headers)s\n%(content)s\n'
23+
SUCCESSFUL_RESPONSE_FORMAT = '\x1b[32m{0}\x1b[0m'.format(_COMMON_RESPONSE_FORMAT)
24+
ERROR_RESPONSE_FORMAT = '\x1b[31m{0}\x1b[0m'.format(_COMMON_RESPONSE_FORMAT)
1825

1926
def __init__(self, logger=None):
2027
"""
@@ -48,32 +55,216 @@ def _log_request(self, method, url, **kwargs):
4855
:type access_token:
4956
`unicode`
5057
"""
51-
self._logger.info(self.REQUEST_FORMAT, method, url, pformat(kwargs))
58+
self._logger.info(self.REQUEST_FORMAT, {'method': method, 'url': url, 'request_kwargs': pformat(kwargs)})
5259

53-
def _log_response(self, response):
54-
"""
55-
Logs information about the Box API response.
60+
def _log_exception(self, method, url, exc_info):
61+
"""Log information at WARNING level about the exception that was raised when trying to make the request.
5662
57-
:param response: The Box API response.
63+
:param method: The HTTP verb that was used to make the request.
64+
:type method: `unicode`
65+
:param url: The URL for the request.
66+
:type url: `unicode`
67+
:param exc_info: The exception info returned from `sys.exc_info()`.
5868
"""
59-
if response.ok:
60-
if response.request_response.raw is not None:
61-
self._logger.info(self.SUCCESSFUL_RESPONSE_FORMAT, self.STREAM_CONTENT_NOT_LOGGED)
62-
else:
63-
self._logger.info(self.SUCCESSFUL_RESPONSE_FORMAT, response.content)
64-
else:
65-
self._logger.warning(
66-
self.ERROR_RESPONSE_FORMAT,
67-
response.status_code,
68-
response.headers,
69-
pformat(response.content),
70-
)
69+
exc_type, exc_value, _ = exc_info
70+
self._logger.warning(
71+
self.EXCEPTION_FORMAT,
72+
{'method': method, 'url': url, 'exc_type_name': exc_type.__name__, 'exc_value': exc_value},
73+
)
7174

7275
def request(self, method, url, access_token, **kwargs):
7376
"""
7477
Base class override. Logs information about an API request and response in addition to making the request.
78+
79+
Also logs exceptions before re-raising them.
80+
81+
The logging of the response is deferred to
82+
:class:`LoggingNetworkResponse`. See that class's docstring for more
83+
info.
7584
"""
7685
self._log_request(method, url, **kwargs)
77-
response = super(LoggingNetwork, self).request(method, url, access_token, **kwargs)
78-
self._log_response(response)
86+
try:
87+
return super(LoggingNetwork, self).request(method, url, access_token, **kwargs)
88+
except Exception:
89+
self._log_exception(method, url, sys.exc_info())
90+
raise
91+
92+
@property
93+
def network_response_constructor(self):
94+
"""Baseclass override.
95+
96+
A callable that passes additional required keyword arguments to the
97+
:class:`LoggingNetworkResponse` constructor, and returns an instance of
98+
:class:`LoggingNetworkResponse`.
99+
"""
100+
return partial(
101+
LoggingNetworkResponse,
102+
logger=self._logger,
103+
successful_response_format=self.SUCCESSFUL_RESPONSE_FORMAT,
104+
error_response_format=self.ERROR_RESPONSE_FORMAT,
105+
)
106+
107+
108+
class LoggingNetworkResponse(DefaultNetworkResponse):
109+
"""Response subclass that defers LoggingNetwork response logging until it is safe to do so.
110+
111+
:class:`DefaultNetwork` is based off the `requests` library.
112+
:class:`requests.Response` has a few mutually-exclusive ways to read the
113+
content of the response:
114+
115+
- With the `Response.raw` attribute, an `io.IOBase` instance returned
116+
from the `urllib3` library, that can be read once in chunks from
117+
beginning to end.
118+
- With `Response.iter_content()` and other iter_* generators, which
119+
also can only be read once and advance the `Response.raw` IO stream.
120+
- With the `Response.content` property (and other attributes such as
121+
`Response.text` and `Response.json()`), which reads and caches the
122+
remaining response content in memory. Can be accessed multiple times,
123+
but cannot be safely accessed if any of the previous mechanisms have
124+
been used at all. And if this property has already been accessed,
125+
then the other mechanisms will have been exhausted, and attempting to
126+
read from them will make it appear like the response content is
127+
empty.
128+
129+
Any of these mechanisms may be used to read any response, regardless of
130+
whether `stream=True` or `stream=False` on the request.
131+
132+
If the caller uses `Response.content`, then it is safe for
133+
:class:`LoggingNetwork` to also access it. But if the caller uses any of
134+
the streaming mechanisms, then it is not safe for :class:`LoggingNetwork`
135+
to ever read any of the content. Thus, the options available are:
136+
137+
- Never log the content of a response.
138+
- Make logging part of the :class:`Network` interface, and add an
139+
optional keyword argument that callers can use to specify when it is
140+
unsafe to log the content of a response.
141+
- Defer logging until it is possible to auto-detect which mechanism is
142+
being used.
143+
144+
This class is an implementation of the latter option. Instead of response
145+
logging taking place in `LoggingNetwork.request()`, it takes place in this
146+
`DefaultNetworkResponse` subclass, as soon as the caller starts reading the
147+
content. If `content` or `json()` are accessed, then the response will be
148+
logged with its content. Whereas if `response_as_stream` or
149+
`request_response` are accessed, then the response will be logged with a
150+
placeholder for the actual content.
151+
152+
In theory, this could make the logs less useful, by adding a delay between
153+
when the network response was actually received, and when it is logged. Or
154+
the response may never be logged, if the content is never accessed. In
155+
practice, this is unlikely to happen, because nearly all SDK methods
156+
immediately read the content.
157+
"""
158+
159+
STREAM_CONTENT_NOT_LOGGED = '<File download contents unavailable for logging>'
160+
161+
def __init__(self, logger, successful_response_format, error_response_format, **kwargs):
162+
"""Extends baseclass method.
163+
164+
:param logger: The logger to use.
165+
:type logger: :class:`Logger`
166+
:param successful_response_format:
167+
The logger %-style format string to use for logging ok responses.
168+
169+
May use the following format placeholders:
170+
171+
- %(method)s : The HTTP request method ('GET', 'POST', etc.).
172+
- %(url)s : The url of the request.
173+
- %(status_code)s : The HTTP status code of the response.
174+
- %(content_length)s : The Content-Length of the response body.
175+
- %(headers)s : The HTTP headers (as a pretty-printed dict).
176+
- %(content)s : The response body.
177+
178+
:type successful_response_format: `unicode`
179+
:param error_response_format:
180+
The logger %-style format string to use for logging ok responses.
181+
182+
May use the same format placeholders as
183+
`successful_response_format`.
184+
:type error_response_format: `unicode`
185+
"""
186+
super(LoggingNetworkResponse, self).__init__(**kwargs)
187+
self._logger = logger
188+
self._successful_response_format = successful_response_format
189+
self._error_response_format = error_response_format
190+
self._did_log = False
191+
192+
def log(self, can_safely_log_content=False):
193+
"""Logs information about the Box API response.
194+
195+
Will only execute once. Subsequent calls will be no-ops. This is
196+
partially because we only want to log responses once, and partially
197+
because this is necessary to prevent this method from infinite
198+
recursing with its use of the `content` property.
199+
200+
:param can_safely_log_content:
201+
(optional) `True` if the caller is accessing the `content`
202+
property, `False` otherwise.
203+
204+
As stated in the class docstring, it is unsafe for this logging
205+
method to access `content` unless the caller is also accessing it.
206+
207+
Defaults to `False`.
208+
:type can_safely_log_content: `bool`
209+
"""
210+
if self._did_log:
211+
return
212+
self._did_log = True
213+
content_length = self.headers.get('Content-Length', None)
214+
content = self.STREAM_CONTENT_NOT_LOGGED
215+
if can_safely_log_content:
216+
if content_length is None:
217+
content_length = text_type(len(self.content))
218+
219+
# If possible, get the content as a JSON `dict`, that way
220+
# `pformat(content)` will return pretty-printed JSON.
221+
try:
222+
content = self.json()
223+
except ValueError:
224+
content = self.content
225+
content = pformat(content)
226+
if content_length is None:
227+
content_length = '?'
228+
if self.ok:
229+
logger_method, response_format = self._logger.info, self._successful_response_format
230+
else:
231+
logger_method, response_format = self._logger.warning, self._error_response_format
232+
logger_method(
233+
response_format,
234+
{
235+
'method': self.request_response.request.method,
236+
'url': self.request_response.request.url,
237+
'status_code': self.status_code,
238+
'content_length': content_length,
239+
'headers': pformat(self.headers),
240+
'content': content,
241+
},
242+
)
243+
244+
def json(self):
245+
"""Extends baseclass method."""
246+
try:
247+
return super(LoggingNetworkResponse, self).json()
248+
finally:
249+
self.log(can_safely_log_content=True)
250+
251+
@property
252+
def content(self):
253+
"""Extends baseclass method."""
254+
content = super(LoggingNetworkResponse, self).content
255+
self.log(can_safely_log_content=True)
256+
return content
257+
258+
@property
259+
def response_as_stream(self):
260+
"""Extends baseclass method."""
261+
stream = super(LoggingNetworkResponse, self).response_as_stream
262+
self.log(can_safely_log_content=False)
263+
return stream
264+
265+
@property
266+
def request_response(self):
267+
"""Extends baseclass method."""
268+
response = super(LoggingNetworkResponse, self).request_response
269+
self.log(can_safely_log_content=False)
79270
return response

boxsdk/network/network_interface.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import unicode_literals
44

5-
from abc import ABCMeta, abstractmethod
5+
from abc import ABCMeta, abstractmethod, abstractproperty
66
from six import add_metaclass
77

88

@@ -29,6 +29,7 @@ def request(self, method, url, access_token, **kwargs):
2929
The OAuth2 access token used to authorize the request.
3030
:type access_token:
3131
`unicode`
32+
:rtype: :class:`NetworkResponse`
3233
"""
3334
raise NotImplementedError # pragma: no cover
3435

@@ -45,9 +46,30 @@ def retry_after(self, delay, request_method, *args, **kwargs):
4546
A callable that will execute the request.
4647
:type request_method:
4748
`callable`
49+
:rtype: :class:`NetworkResponse`
4850
"""
4951
raise NotImplementedError # pragma: no cover
5052

53+
@property
54+
def network_response_constructor(self):
55+
"""The constructor to use for creating NetworkResponse instances.
56+
57+
This is not implemented by default, and is not a required part of the
58+
interface.
59+
60+
It is recommended that implementations of `request()` call this to
61+
construct their responses, rather than hard-coding the construction.
62+
That way, subclasses of the implementation can easily extend the
63+
construction of :class:`NetworkResponse` instances, by overriding this
64+
property, instead of needing to override `request()`.
65+
66+
:return:
67+
A callable that returns an instance of :class:`NetworkResponse`.
68+
Most commonly, this will be a subclass of :class:`NetworkResponse`.
69+
:rtype: `type` or `callable`
70+
"""
71+
return NetworkResponse
72+
5173

5274
@add_metaclass(ABCMeta)
5375
class NetworkResponse(object):
@@ -62,7 +84,7 @@ def json(self):
6284
"""
6385
raise NotImplementedError # pragma: no cover
6486

65-
@abstractmethod
87+
@abstractproperty
6688
def content(self):
6789
"""Return the content of the response body.
6890
@@ -71,7 +93,7 @@ def content(self):
7193
"""
7294
raise NotImplementedError # pragma: no cover
7395

74-
@abstractmethod
96+
@abstractproperty
7597
def status_code(self):
7698
"""Return the HTTP status code of the response.
7799
@@ -80,7 +102,7 @@ def status_code(self):
80102
"""
81103
raise NotImplementedError # pragma: no cover
82104

83-
@abstractmethod
105+
@abstractproperty
84106
def ok(self):
85107
"""Return whether or not the request was successful.
86108
@@ -90,7 +112,7 @@ def ok(self):
90112
# pylint:disable=invalid-name
91113
raise NotImplementedError # pragma: no cover
92114

93-
@abstractmethod
115+
@abstractproperty
94116
def headers(self):
95117
"""Return the response headers.
96118
@@ -99,7 +121,7 @@ def headers(self):
99121
"""
100122
raise NotImplementedError # pragma: no cover
101123

102-
@abstractmethod
124+
@abstractproperty
103125
def response_as_stream(self):
104126
"""Return a stream containing the raw network response.
105127
@@ -108,7 +130,7 @@ def response_as_stream(self):
108130
"""
109131
raise NotImplementedError # pragma: no cover
110132

111-
@abstractmethod
133+
@abstractproperty
112134
def access_token_used(self):
113135
"""Return the access token used to make the request.
114136

0 commit comments

Comments
 (0)