Repository: box/box-python-sdk
Pull request #280 (2018-06-16 02:57:55)

Add Comments functionality

boxcla: Verified that @mattwiller has signed the CLA. Thanks for the pull request!
jmoldow: @mattwiller I left some more comments, but I think I did it wrong. The Github UI makes them look like they're outdated, but they are in fact new comments.

Patch:
diff --git a/.gitignore b/.gitignore
index d2062ad..feafe4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
+.pytest_cache/
 
 # C extensions
 *.so
@@ -70,6 +71,9 @@ target/
 .idea/
 *.iml
 
+# VSCode
+.vscode/
+
 # Mac
 .DS_Store
 
diff --git a/boxsdk/__init__.py b/boxsdk/__init__.py
index 9fc7f49..fd6b368 100644
--- a/boxsdk/__init__.py
+++ b/boxsdk/__init__.py
@@ -4,6 +4,7 @@
 
 from .auth import JWTAuth, OAuth2
 from .client import *  # pylint:disable=wildcard-import,redefined-builtin
+from .exception import *  # pylint:disable=wildcard-import
 from .object import *  # pylint:disable=wildcard-import,redefined-builtin
 from .util.log import setup_logging
 from .version import __version__
diff --git a/boxsdk/auth/jwt_auth.py b/boxsdk/auth/jwt_auth.py
index 0079f99..463dadd 100644
--- a/boxsdk/auth/jwt_auth.py
+++ b/boxsdk/auth/jwt_auth.py
@@ -16,7 +16,7 @@
 from ..exception import BoxOAuthException
 from .oauth2 import OAuth2
 from ..object.user import User
-from ..util.compat import NoneType, total_seconds
+from ..util.compat import NoneType
 
 
 class JWTAuth(OAuth2):
@@ -198,7 +198,7 @@ def _construct_and_send_jwt_auth(self, sub, sub_type, now_time=None):
                 'box_sub_type': sub_type,
                 'aud': 'https://api.box.com/oauth2/token',
                 'jti': jti,
-                'exp': int(total_seconds(now_plus_30 - datetime(1970, 1, 1))),
+                'exp': int((now_plus_30 - datetime(1970, 1, 1)).total_seconds()),
             },
             self._rsa_private_key,
             algorithm=self._jwt_algorithm,
diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py
index 34f51a6..7605b7d 100644
--- a/boxsdk/auth/oauth2.py
+++ b/boxsdk/auth/oauth2.py
@@ -14,11 +14,12 @@
 # pylint:enable=import-error,no-name-in-module,relative-import
 import six
 
-from boxsdk.config import API
-from boxsdk.exception import BoxOAuthException
-from boxsdk.network.default_network import DefaultNetwork
-from boxsdk.object.base_api_json_object import BaseAPIJSONObject
-from boxsdk.util.text_enum import TextEnum
+from ..config import API
+from ..exception import BoxOAuthException
+from ..network.default_network import DefaultNetwork
+from ..object.base_api_json_object import BaseAPIJSONObject
+from ..util.json import is_json_response
+from ..util.text_enum import TextEnum
 
 
 class TokenScope(TextEnum):
@@ -342,17 +343,41 @@ def _execute_token_request(self, data, access_token, expect_refresh_token=True):
             access_token=access_token,
         )
         if not network_response.ok:
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
+            raise self._oauth_exception(network_response, url)
         try:
             token_response = TokenResponse(network_response.json())
         except ValueError:
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
+            raise self._oauth_exception(network_response, url)
 
         if ('access_token' not in token_response) or (expect_refresh_token and 'refresh_token' not in token_response):
-            raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
+            raise self._oauth_exception(network_response, url)
 
         return token_response
 
+    @staticmethod
+    def _oauth_exception(network_response, url):
+        """
+        Create a BoxOAuthException instance to raise. If the error response is JSON, parse it and include the
+        code and message in the exception.
+
+        :rtype:     :class:`BoxOAuthException`
+        """
+        exception_kwargs = dict(
+            status=network_response.status_code,
+            url=url,
+            method='POST',
+            network_response=network_response,
+        )
+        if is_json_response(network_response):
+            json_response = network_response.json()
+            exception_kwargs.update(dict(
+                code=json_response.get('code'),
+                message=json_response.get('message'),
+            ))
+        else:
+            exception_kwargs['message'] = network_response.content
+        return BoxOAuthException(**exception_kwargs)
+
     def send_token_request(self, data, access_token, expect_refresh_token=True):
         """
         Send the request to acquire or refresh an access token, and store the tokens.
@@ -397,7 +422,13 @@ def revoke(self):
                 access_token=access_token,
             )
             if not network_response.ok:
-                raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST', network_response)
+                raise BoxOAuthException(
+                    network_response.status_code,
+                    network_response.content,
+                    url,
+                    'POST',
+                    network_response,
+                )
             self._store_tokens(None, None)
 
     def close(self, revoke=True):
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 02a5677..fc00146 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -98,6 +98,21 @@ def file(self, file_id):
         """
         return self.translator.translate('file')(session=self._session, object_id=file_id)
 
+    def comment(self, comment_id):
+        """
+        Initialize a :class:`Comment` object, whose Box ID is comment_id.
+
+        :param comment_id:
+            The Box ID of the :class:`Comment` object.
+        :type comment_id:
+            `unicode`
+        :return:
+            A :class:`Comment` object with the given comment ID.
+        :rtype:
+            :class:`Comment`
+        """
+        return self.translator.translate('comment')(session=self._session, object_id=comment_id)
+
     def user(self, user_id='me'):
         """
         Initialize a :class:`User` object, whose box id is user_id.
diff --git a/boxsdk/exception.py b/boxsdk/exception.py
index 5488137..f7a8cb6 100644
--- a/boxsdk/exception.py
+++ b/boxsdk/exception.py
@@ -1,7 +1,11 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
-from six import PY2
+
+import attr
+
+from .network.network_interface import NetworkResponse
+from .util.log import sanitize_dictionary
 
 
 class BoxException(Exception):
@@ -9,9 +13,10 @@ class BoxException(Exception):
     Base class exception for all errors raised from the SDK.
     """
     def __str__(self):
-        # pylint:disable=no-member
-        # <https://github.com/box/box-python-sdk/issues/117>
-        return self.__unicode__().encode('utf-8') if PY2 else self.__unicode__()
+        return '{}'.format(self.__class__.__name__)
+
+    def __repr__(self):
+        return '<{}>'.format(self.__class__.__name__)
 
 
 class BoxNetworkException(BoxException):
@@ -21,204 +26,122 @@ class BoxNetworkException(BoxException):
     pass
 
 
+@attr.s(repr=True, slots=True, frozen=True)
 class BoxAPIException(BoxException):
     """
     Exception raised from the box session layer.
-    """
-    def __init__(
-            self,
-            status,
-            code=None,
-            message=None,
-            request_id=None,
-            headers=None,
-            url=None,
-            method=None,
-            context_info=None,
-            network_response=None,
-    ):
-        """
-        :param status:
-            HTTP status code of the failed response
-        :type status:
-            `int`
-        :param code:
-            The 'code' field of the failed response
-        :type code:
-            `unicode` or None
-        :param message:
-            A message to associate with the exception, e.g. 'message' field of the json in the failed response
-        :type message:
-            `unicode` or None
-        :param request_id:
-            The 'request_id' field of the json in the failed response
-        :type request_id:
-            `unicode` or None
-        :param headers:
-            The HTTP headers in the failed response
-        :type headers:
-            `dict`
-        :param url:
-            The url which raised the exception
-        :type url:
-            `unicode`
-        :param method:
-            The HTTP verb used to make the request.
-        :type method:
-            `unicode`
-        :param context_info:
-            The context_info returned in the failed response.
-        :type context_info:
-            `dict` or None
-        :param network_response:
-            The failed response
-        :type network_response:
-            Requests `Response`
-        """
-        super(BoxAPIException, self).__init__()
-        self._status = status
-        self._code = code
-        self._message = message
-        self._request_id = request_id
-        self._headers = headers
-        self._url = url
-        self._method = method
-        self._context_info = context_info
-        self._network_response = network_response
-
-    def __unicode__(self):
-        return '\nMessage: {0}\nStatus: {1}\nCode: {2}\nRequest id: {3}\nHeaders: {4}\nURL: {5}\nMethod: {6}\nContext info: {7}'.format(
-            self._message,
-            self._status,
-            self._code,
-            self._request_id,
-            self._headers,
-            self._url,
-            self._method,
-            self._context_info,
-        )
 
-    @property
-    def status(self):
-        """
-        The status code of the network response that is responsible for the exception.
-
-        :rtype: `int`
-        """
-        return self._status
-
-    @property
-    def code(self):
-        """
-        The explanation of the status code of the network response that is responsible for the exception.
-
-        :rtype: `int`
-        """
-        return self._code
-
-    @property
-    def message(self):
-        """
-        The message associated with the exception, e.g. 'message' field of the json in the failed response.
-
-        :rtype:   `unicode`
-        """
-        return self._message
-
-    @property
-    def request_id(self):
-        """
-        The id the network request that is responsible for the exception.
-
-        :rtype: `unicode`
-        """
-        return self._request_id
-
-    @property
-    def url(self):
-        """
-        The URL of the network request that is responsible for the exception.
-
-        :rtype: `unicode`
-        """
-        return self._url
-
-    @property
-    def method(self):
-        """
-        The HTTP verb of the request that is responsible for the exception.
-
-        :rtype: `unicode`
-        """
-        return self._method
-
-    @property
-    def context_info(self):
-        """
+    :param status:
+        HTTP status code of the failed response
+    :type status:
+        `int`
+    :param code:
+        The 'code' field of the failed response
+    :type code:
+        `unicode` or None
+    :param message:
+        A message to associate with the exception, e.g. 'message' field of the json in the failed response
+    :type message:
+        `unicode` or None
+    :param request_id:
+        The 'request_id' field of the json in the failed response
+    :type request_id:
+        `unicode` or None
+    :param headers:
+        The HTTP headers in the failed response
+    :type headers:
+        `dict`
+    :param url:
+        The url which raised the exception
+    :type url:
+        `unicode`
+    :param method:
+        The HTTP verb used to make the request.
+    :type method:
+        `unicode`
+    :param context_info:
         The context_info returned in the failed response.
+    :type context_info:
+        `dict` or None
+    :param network_response:
+        The failed response
+    :type network_response:
+        Requests `Response`
+    """
+    status = attr.ib()
+    code = attr.ib(default=None)
+    message = attr.ib(default=None)
+    request_id = attr.ib(default=None)
+    headers = attr.ib(default=None, hash=False)
+    url = attr.ib(default=None)
+    method = attr.ib(default=None)
+    context_info = attr.ib(default=None)
+    network_response = attr.ib(default=None, repr=False)
 
-        :rtype: `dict`
-        """
-        return self._context_info
-
-    @property
-    def network_response(self):
-        """
-        The response returned from the network.
-
-        :rtype: `NetworkResponse`
-        """
-        return self._network_response
-
-
+    def __str__(self):
+        return '\n'.join((
+            'Message: {self.message}',
+            'Status: {self.status}',
+            'Code: {self.code}',
+            'Request ID: {self.request_id}',
+            'Headers: {headers}',
+            'URL: {self.url}',
+            'Method: {self.method}',
+            'Context Info: {self.context_info}',
+        )).format(self=self, headers=sanitize_dictionary(self.headers))
+
+
+@attr.s(repr=True, slots=True, frozen=True)
 class BoxOAuthException(BoxException):
     """
     Exception raised during auth.
+
+    :param status:
+        HTTP status code of the auth response
+    :type status:
+        `int`
+    :param message:
+        A message to associate with the exception, e.g. HTTP content of the auth response
+    :type message:
+        `unicode`
+    :param url:
+        The url which raised the exception
+    :type url:
+        `unicode`
+    :param method:
+        The HTTP verb used to make the request.
+    :type method:
+        `unicode`
+    :param network_response:
+        The network response for the request.
+    :type network_response:
+        :class:`NetworkResponse`
+    :param code:
+        The 'code' field of the failed response
+    :type code:
+        `unicode` or None
     """
-    def __init__(self, status, message=None, url=None, method=None, network_response=None):
-        """
-        :param status:
-            HTTP status code of the auth response
-        :type status:
-            `int`
-        :param message:
-            A message to associate with the exception, e.g. HTTP content of the auth response
-        :type message:
-            `unicode`
-        :param url:
-            The url which raised the exception
-        :type url:
-            `unicode`
-        :param method:
-            The HTTP verb used to make the request.
-        :type method:
-            `unicode`
-        :param network_response:
-            The network response for the request.
-        :type network_response:
-            :class:`NetworkResponse`
-        """
-        super(BoxOAuthException, self).__init__()
-        self._status = status
-        self._message = message
-        self._url = url
-        self._method = method
-        self._network_response = network_response
-
-    @property
-    def network_response(self):
-        """
-        The response returned from the network.
-
-        :rtype: :class:`NetworkResponse`
-        """
-        return self._network_response
-
-    def __unicode__(self):
+    status = attr.ib()
+    message = attr.ib(default=None)
+    url = attr.ib(default=None)
+    method = attr.ib(default=None)
+    network_response = attr.ib(default=None, repr=False, type=NetworkResponse)
+    code = attr.ib(default=None)
+
+    def __str__(self):
+        # pylint:disable=no-member
+        if self.network_response:
+            headers = sanitize_dictionary(self.network_response.headers)
+        # pylint:enable=no-member
+        else:
+            headers = 'N/A'
         return '\nMessage: {0}\nStatus: {1}\nURL: {2}\nMethod: {3}\nHeaders: {4}'.format(
-            self._message,
-            self._status,
-            self._url,
-            self._method,
-            self.network_response.headers if self.network_response else 'N/A',
+            self.message,
+            self.status,
+            self.url,
+            self.method,
+            headers,
         )
+
+
+__all__ = list(map(str, ['BoxException', 'BoxAPIException', 'BoxOAuthException', 'BoxNetworkException']))
diff --git a/boxsdk/network/default_network.py b/boxsdk/network/default_network.py
index f819f79..5f3c512 100644
--- a/boxsdk/network/default_network.py
+++ b/boxsdk/network/default_network.py
@@ -11,6 +11,7 @@
 from six import text_type, PY2
 
 from .network_interface import Network, NetworkResponse
+from ..util.log import sanitize_dictionary
 
 
 class DefaultNetwork(Network):
@@ -80,7 +81,10 @@ def _log_request(self, method, url, **kwargs):
         :type access_token:
             `unicode`
         """
-        self._logger.info(self.REQUEST_FORMAT, {'method': method, 'url': url, 'request_kwargs': pformat(kwargs)})
+        self._logger.info(
+            self.REQUEST_FORMAT,
+            {'method': method, 'url': url, 'request_kwargs': pformat(sanitize_dictionary(kwargs))},
+        )
 
     def _log_exception(self, method, url, exc_info):
         """Log information at WARNING level about the exception that was raised when trying to make the request.
@@ -248,7 +252,7 @@ def log(self, can_safely_log_content=False):
                 content = self.json()
             except ValueError:
                 content = self.content
-            content = pformat(content)
+            content = pformat(sanitize_dictionary(content))
         if content_length is None:
             content_length = '?'
         if self.ok:
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index d6bccfb..39c7a79 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -5,4 +5,16 @@
 from six.moves import map   # pylint:disable=redefined-builtin
 
 
-__all__ = list(map(str, ['collaboration', 'events', 'event', 'file', 'folder', 'group', 'group_membership', 'recent_item', 'search', 'user']))
+__all__ = list(map(str, [
+    'collaboration',
+    'comment',
+    'events',
+    'event',
+    'file',
+    'folder',
+    'group',
+    'group_membership',
+    'recent_item',
+    'search',
+    'user',
+]))
diff --git a/boxsdk/object/comment.py b/boxsdk/object/comment.py
new file mode 100644
index 0000000..ad8087d
--- /dev/null
+++ b/boxsdk/object/comment.py
@@ -0,0 +1,57 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+import json
+
+from boxsdk.object.base_object import BaseObject
+from ..util.api_call_decorator import api_call
+
+
+class Comment(BaseObject):
+    """An object that represents a comment on an item"""
+    _item_type = 'comment'
+
+    @staticmethod
+    def construct_params_from_message(message):
+        message_type = 'tagged_message' if '@[' in message else 'message'
+        return {
+            message_type: message
+        }
+
+    @api_call
+    def reply(self, message):
+        """
+        Add a reply to the comment.
+
+        :param message:
+            The content of the reply comment.
+        :type message:
+            `unicode`
+        """
+        url = self.get_type_url()
+        data = self.construct_params_from_message(message)
+        data['item'] = {
+            'type': 'comment',
+            'id': self.object_id
+        }
+        box_response = self._session.post(url, data=json.dumps(data))
+        response = box_response.json()
+        return self.__class__(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    @api_call
+    def edit(self, message):
+        """
+        Edit the message of the comment.
+
+        :param message:
+            The content of the reply comment.
+        :type message:
+            `unicode`
+        """
+        data = self.construct_params_from_message(message)
+        return self.update_info(data)
diff --git a/boxsdk/object/file.py b/boxsdk/object/file.py
index 06a50d9..4cc4440 100644
--- a/boxsdk/object/file.py
+++ b/boxsdk/object/file.py
@@ -2,8 +2,11 @@
 
 from __future__ import unicode_literals
 
+import json
+
 from .item import Item
 from ..util.api_call_decorator import api_call
+from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 
 
 class File(Item):
@@ -281,3 +284,59 @@ def get_shared_link_download_url(
             password=password,
         )
         return item.shared_link['download_url']  # pylint:disable=no-member
+
+    @api_call
+    def get_comments(self, limit=None, offset=0, fields=None):
+        """
+        Get the comments on the file.
+
+        :param limit:
+            The maximum number of items to return per page. If not specified, then will use the server-side default.
+        :type limit:
+            `int` or None
+        :param offset:
+            The index at which to start returning items.
+        :type offset:
+            `int`
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the items in the folder.
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return LimitOffsetBasedObjectCollection(
+            self.session,
+            self.get_url('comments'),
+            limit=limit,
+            fields=fields,
+            offset=offset,
+            return_full_pages=False,
+        )
+
+    @api_call
+    def add_comment(self, message):
+        """
+        Add a comment to the file.
+
+        :param message:
+            The content of the reply comment.
+        :type message:
+            `unicode`
+        """
+        url = self._session.get_url('comments')
+        comment_class = self._session.translator.translate('comment')
+        data = comment_class.construct_params_from_message(message)
+        data['item'] = {
+            'type': 'file',
+            'id': self.object_id
+        }
+        box_response = self._session.post(url, data=json.dumps(data))
+        response = box_response.json()
+        return self._session.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
diff --git a/boxsdk/session/box_request.py b/boxsdk/session/box_request.py
index 94ae75e..2c8493d 100644
--- a/boxsdk/session/box_request.py
+++ b/boxsdk/session/box_request.py
@@ -4,6 +4,8 @@
 
 import attr
 
+from ..util.log import sanitize_dictionary
+
 
 @attr.s(slots=True)
 class BoxRequest(object):
@@ -25,3 +27,9 @@ class BoxRequest(object):
     headers = attr.ib(default=attr.Factory(dict))
     auto_session_renewal = attr.ib(default=True)
     expect_json_response = attr.ib(default=True)
+
+    def __repr__(self):
+        return '<BoxRequest for {self.method} {self.url} with headers {headers}'.format(
+            self=self,
+            headers=sanitize_dictionary(self.headers),
+        )
diff --git a/boxsdk/session/box_session.py b/boxsdk/session/box_session.py
index d8ec3bf..64b1a1f 100644
--- a/boxsdk/session/box_session.py
+++ b/boxsdk/session/box_session.py
@@ -9,6 +9,7 @@
 from .box_response import BoxResponse as _BoxResponse
 from ..config import API, Client
 from ..exception import BoxAPIException
+from ..util.json import is_json_response
 from ..util.multipart_stream import MultipartStream
 from ..util.shared_link import get_shared_link_header
 from ..util.translator import Translator
@@ -201,21 +202,6 @@ def _renew_session(self, access_token_used):
         new_access_token, _ = self._oauth.refresh(access_token_used)
         return new_access_token
 
-    @staticmethod
-    def _is_json_response(network_response):
-        """Return whether or not the network response content is json.
-
-        :param network_response:
-            The response from the Box API.
-        :type network_response:
-            :class:`NetworkResponse`
-        """
-        try:
-            network_response.json()
-            return True
-        except ValueError:
-            return False
-
     def _get_retry_after_time(self, attempt_number, retry_after_header):
         """
         Get the amount of time to wait before retrying the API request.
@@ -278,7 +264,8 @@ def _get_retry_request_callable(self, network_response, attempt_number, request)
             )
         return None
 
-    def _raise_on_unsuccessful_request(self, network_response, request):
+    @staticmethod
+    def _raise_on_unsuccessful_request(network_response, request):
         """
         Raise an exception if the request was unsuccessful.
 
@@ -308,7 +295,7 @@ def _raise_on_unsuccessful_request(self, network_response, request):
                 context_info=response_json.get('context_info', None),
                 network_response=network_response
             )
-        if request.expect_json_response and not self._is_json_response(network_response):
+        if request.expect_json_response and not is_json_response(network_response):
             raise BoxAPIException(
                 status=network_response.status_code,
                 headers=network_response.headers,
diff --git a/boxsdk/util/compat.py b/boxsdk/util/compat.py
index 4951484..5ffa4cf 100644
--- a/boxsdk/util/compat.py
+++ b/boxsdk/util/compat.py
@@ -2,8 +2,6 @@
 
 from __future__ import absolute_import, division, unicode_literals
 
-from datetime import timedelta
-
 import six
 from six.moves import map
 
@@ -11,18 +9,6 @@
 NoneType = type(None)
 
 
-if not hasattr(timedelta, 'total_seconds'):
-    def total_seconds(delta):
-        """
-        Return the total number of seconds represented by this timedelta.
-        Python 2.6 does not define this method.
-        """
-        return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
-else:
-    def total_seconds(delta):
-        return delta.total_seconds()
-
-
 def with_metaclass(meta, *bases, **with_metaclass_kwargs):
     """Extends the behavior of six.with_metaclass.
 
diff --git a/boxsdk/util/enum.py b/boxsdk/util/enum.py
index 1988d96..c6a0555 100644
--- a/boxsdk/util/enum.py
+++ b/boxsdk/util/enum.py
@@ -3,6 +3,7 @@
 
 from __future__ import absolute_import, unicode_literals
 
+from collections import OrderedDict
 from itertools import chain
 import sys
 
@@ -10,8 +11,6 @@
 from six import reraise
 from six.moves import map   # pylint:disable=redefined-builtin
 
-from .ordered_dict import OrderedDict
-
 
 __all__ = list(map(str, ['ExtendableEnumMeta']))
 
diff --git a/boxsdk/util/json.py b/boxsdk/util/json.py
new file mode 100644
index 0000000..d3bf383
--- /dev/null
+++ b/boxsdk/util/json.py
@@ -0,0 +1,18 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+
+def is_json_response(network_response):
+    """Return whether or not the network response content is json.
+
+    :param network_response:
+        The response from the Box API.
+    :type network_response:
+        :class:`NetworkResponse`
+    """
+    try:
+        network_response.json()
+        return True
+    except ValueError:
+        return False
diff --git a/boxsdk/util/log.py b/boxsdk/util/log.py
index bb4be59..7a90774 100644
--- a/boxsdk/util/log.py
+++ b/boxsdk/util/log.py
@@ -2,6 +2,7 @@
 
 from __future__ import unicode_literals
 
+from collections import Mapping
 import logging
 try:
     from logging import NullHandler
@@ -11,7 +12,7 @@ def emit(self, record):
             pass
 import sys
 
-from six import string_types
+from six import string_types, iteritems
 
 
 _no_logger = object()
@@ -19,6 +20,21 @@ def emit(self, record):
 
 class Logging(object):
     _has_setup = False
+    KEYS_TO_SANITIZE = (
+        'Authorization',
+        'access_token',
+        'refresh_token',
+        'subject_token',
+        'token',
+        'client_id',
+        'client_secret',
+        'code',
+        'shared_link',
+        'download_url',
+        'jwt_private_key',
+        'jwt_private_key_passphrase',
+        'password',
+    )
 
     def setup_logging(self, stream_or_file=_no_logger, debug=False, name=None):
         if not self._has_setup:
@@ -34,6 +50,23 @@ def _setup_logging(stream_or_file=_no_logger, debug=False, name=None):
             logger.addHandler(logging.StreamHandler(stream_or_file or sys.stdout))
         logger.setLevel(logging.DEBUG if debug else logging.INFO)
 
+    @staticmethod
+    def sanitize_value(value):
+        return '---{}'.format(value[-4:])
+
+    def sanitize_dictionary(self, dictionary):
+        if not isinstance(dictionary, Mapping):
+            return dictionary
+        sanitized_dictionary = {}
+        for key, value in iteritems(dictionary):
+            if key in self.KEYS_TO_SANITIZE:
+                sanitized_dictionary[key] = self.sanitize_value(value)
+            elif isinstance(value, Mapping):
+                sanitized_dictionary[key] = self.sanitize_dictionary(value)
+            else:
+                sanitized_dictionary[key] = value
+        return sanitized_dictionary
+
 
 _logging = Logging()
 
@@ -62,7 +95,20 @@ def setup_logging(stream_or_file=_no_logger, debug=False, name=None):
     _logging.setup_logging(stream_or_file, debug, name)
 
 
+def sanitize_dictionary(dictionary):
+    """
+    Get a copy of a dictionary that has sensitive information redacted. Should be called on objects that will be
+    logged or printed.
+
+    :param dictionary:      Dictionary that may contain sensitive information.
+    :type dictionary:       :class:`Mapping`
+    :return:                Copy of the dictionary with sensitive information redacted.
+    :rtype:                 `dict`
+    """
+    return _logging.sanitize_dictionary(dictionary)
+
+
 logging.getLogger(__name__).addHandler(NullHandler())
 
 
-__all__ = list(map(str, ['setup_logging']))
+__all__ = list(map(str, ['setup_logging', 'sanitize_dictionary']))
diff --git a/boxsdk/util/lru_cache.py b/boxsdk/util/lru_cache.py
index ee13942..0d9380a 100644
--- a/boxsdk/util/lru_cache.py
+++ b/boxsdk/util/lru_cache.py
@@ -1,7 +1,8 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
-from boxsdk.util.ordered_dict import OrderedDict
+
+from collections import OrderedDict
 
 
 class LRUCache(object):
diff --git a/boxsdk/util/multipart_stream.py b/boxsdk/util/multipart_stream.py
index 03b0fc7..9a94dda 100644
--- a/boxsdk/util/multipart_stream.py
+++ b/boxsdk/util/multipart_stream.py
@@ -2,9 +2,9 @@
 
 from __future__ import unicode_literals
 
-from requests_toolbelt.multipart.encoder import MultipartEncoder
+from collections import OrderedDict
 
-from boxsdk.util.ordered_dict import OrderedDict
+from requests_toolbelt.multipart.encoder import MultipartEncoder
 
 
 class MultipartStream(MultipartEncoder):
diff --git a/boxsdk/util/ordered_dict.py b/boxsdk/util/ordered_dict.py
deleted file mode 100644
index 1ebf4b2..0000000
--- a/boxsdk/util/ordered_dict.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding: utf-8
-
-from __future__ import unicode_literals
-# pylint:disable=unused-import, import-error
-try:
-    from collections import OrderedDict
-except ImportError:
-    from ordereddict import OrderedDict
diff --git a/docs/source/boxsdk.util.rst b/docs/source/boxsdk.util.rst
index 3a69205..46bf8c1 100644
--- a/docs/source/boxsdk.util.rst
+++ b/docs/source/boxsdk.util.rst
@@ -36,6 +36,14 @@ boxsdk\.util\.enum module
     :undoc-members:
     :show-inheritance:
 
+boxsdk\.util\.json module
+-------------------------
+
+.. automodule:: boxsdk.util.json
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 boxsdk\.util\.log module
 ------------------------
 
@@ -60,14 +68,6 @@ boxsdk\.util\.multipart\_stream module
     :undoc-members:
     :show-inheritance:
 
-boxsdk\.util\.ordered\_dict module
-----------------------------------
-
-.. automodule:: boxsdk.util.ordered_dict
-    :members:
-    :undoc-members:
-    :show-inheritance:
-
 boxsdk\.util\.shared\_link module
 ---------------------------------
 
diff --git a/setup.py b/setup.py
index 344e09c..fde00cc 100644
--- a/setup.py
+++ b/setup.py
@@ -54,7 +54,7 @@ def run_tests(self):
 
 def main():
     base_dir = dirname(__file__)
-    install_requires = ['requests>=2.4.3', 'six>=1.9.0', 'requests-toolbelt>=0.4.0']
+    install_requires = ['attrs>=16.0.0', 'requests>=2.4.3', 'six>=1.9.0', 'requests-toolbelt>=0.4.0']
     redis_requires = ['redis>=2.10.3']
     jwt_requires = ['pyjwt>=1.3.0', 'cryptography>=0.9.2']
     extra_requires = defaultdict(list)
@@ -71,9 +71,8 @@ def main():
         #
         # [1] <https://www.python.org/dev/peps/pep-0426/#environment-markers>
         # [2] <https://www.python.org/dev/peps/pep-0345/#environment-markers>
-        'chainmap>=1.0.2': ['2.6', '2.7'],  # <'3.3'
-        'enum34>=1.0.4': ['2.6', '2.7', '3.3'],   # <'3.4'
-        'ordereddict>=1.1': ['2.6'],   # <'2.7'
+        'chainmap>=1.0.2': ['2.7'],  # <'3.4'
+        'enum34>=1.0.4': ['2.7'],   # <'3.4'
     }
     for requirement, python_versions in conditional_dependencies.items():
         for python_version in python_versions:
@@ -89,18 +88,11 @@ def main():
         'pylint',
         'sqlalchemy',
         'tox',
+        'pytest>=2.8.3',
         'pytest-cov',
         'pytest-xdist',
         'pytz',
     ]
-    # pytest>=3.3.0 has dropped support for python 2.6 and 3.3; we pin lower than 3.3.0 for those python versions
-    # we require attrs >= 16.0.0 but newer versions have also dropped support for 2.6 and 3.3, so we pin to 16.0.0
-    if sys.version_info[:2] <= (2, 6) or (sys.version_info[0] == 3 and sys.version_info[1] <= 3):
-        test_requires.append('pytest>=2.8.3,<3.3.0')
-        install_requires.append('attrs==16.0.0')
-    else:
-        test_requires.append('pytest>=2.8.3')
-        install_requires.append('attrs>=16.0.0')
     extra_requires['test'] = test_requires
     with open('boxsdk/version.py', 'r', encoding='utf-8') as config_py:
         version = re.search(r'^\s+__version__\s*=\s*[\'"]([^\'"]*)[\'"]', config_py.read(), re.MULTILINE).group(1)


Test patch:
diff --git a/test/unit/auth/test_jwt_auth.py b/test/unit/auth/test_jwt_auth.py
index eba14c8..1117e09 100644
--- a/test/unit/auth/test_jwt_auth.py
+++ b/test/unit/auth/test_jwt_auth.py
@@ -23,7 +23,6 @@
 from boxsdk.exception import BoxOAuthException
 from boxsdk.config import API
 from boxsdk.object.user import User
-from boxsdk.util.compat import total_seconds
 
 
 @pytest.fixture(params=[16, 32, 128])
@@ -280,7 +279,7 @@ def _jwt_auth_auth_mocks(sub, sub_type, oauth, assertion, client_id, secret, ass
                 mock_datetime.utcnow.return_value = datetime(2015, 7, 6, 12, 1, 2)
                 mock_datetime.return_value = datetime(1970, 1, 1)
                 now_plus_30 = mock_datetime.utcnow.return_value + timedelta(seconds=30)
-                exp = int(total_seconds(now_plus_30 - datetime(1970, 1, 1)))
+                exp = int((now_plus_30 - datetime(1970, 1, 1)).total_seconds())
                 system_random = mock_system_random.return_value
                 system_random.randint.return_value = jti_length
                 random_choices = [random.random() for _ in range(jti_length)]
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index a9da671..9c0264e 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -7,6 +7,7 @@
 import pytest
 from six import int2byte, PY2
 from boxsdk.object.collaboration import Collaboration
+from boxsdk.object.comment import Comment
 from boxsdk.object.file import File
 from boxsdk.object.folder import Folder
 from boxsdk.object.group import Group
@@ -69,6 +70,11 @@ def test_file(mock_box_session, mock_object_id):
     return File(mock_box_session, mock_object_id)
 
 
+@pytest.fixture()
+def test_comment(mock_box_session, mock_object_id):
+    return Comment(mock_box_session, mock_object_id)
+
+
 @pytest.fixture()
 def test_folder(mock_box_session, mock_object_id):
     return Folder(mock_box_session, mock_object_id)
@@ -199,3 +205,20 @@ def shared_link_password(request):
 @pytest.fixture(params=(date(2015, 5, 5), None))
 def shared_link_unshared_at(request):
     return request.param
+
+
+@pytest.fixture(params=[
+    # Test case for plain message
+    (
+        'message',
+        'Hello there!'
+    ),
+
+    # Test case for tagged message
+    (
+        'tagged_message',
+        '@[22222:Test User] Hi!'
+    )
+])
+def comment_params(request):
+    return request.param
diff --git a/test/unit/object/test_comment.py b/test/unit/object/test_comment.py
new file mode 100644
index 0000000..637501c
--- /dev/null
+++ b/test/unit/object/test_comment.py
@@ -0,0 +1,67 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+import json
+
+from boxsdk.object.comment import Comment
+
+
+# pylint:disable=protected-access
+# pylint:disable=redefined-outer-name
+
+def test_reply(test_comment, mock_box_session, comment_params):
+    expected_url = mock_box_session.get_url('comments')
+    message_type, message = comment_params
+    expected_data = {
+        message_type: message,
+        'item': {
+            'type': 'comment',
+            'id': test_comment.object_id
+        }
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'type': 'comment',
+        'id': '12345',
+        message_type: message
+    }
+    reply_comment = test_comment.reply(message)
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_data))
+    assert isinstance(reply_comment, Comment)
+    assert reply_comment.object_id == '12345'
+
+
+def test_edit(test_comment, mock_box_session, comment_params):
+    expected_url = mock_box_session.get_url('comments', test_comment.object_id)
+    message_type, message = comment_params
+    expected_data = {
+        message_type: message,
+    }
+    mock_box_session.put.return_value.json.return_value = {
+        'type': 'comment',
+        'id': '12345',
+        message_type: message
+    }
+    updated_comment = test_comment.edit(message)
+    mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(expected_data), headers=None, params=None)
+    assert isinstance(updated_comment, Comment)
+    assert updated_comment[message_type] == message
+
+
+def test_get(mock_box_session):
+    comment_id = '1235'
+    expected_url = mock_box_session.get_url('comments', comment_id)
+    mock_box_session.get.return_value.json.return_value = {
+        'type': 'comment',
+        'id': comment_id,
+        'message': 'Hi!'
+    }
+    comment = Comment(mock_box_session, comment_id).get()
+    mock_box_session.get.assert_called_once_with(expected_url, headers=None, params=None)
+    assert comment.object_id == comment_id
+    assert comment.message == 'Hi!'  # pylint:disable=no-member
+
+
+def test_delete(test_comment, mock_box_session):
+    expected_url = test_comment.get_url()
+    test_comment.delete()
+    mock_box_session.delete.assert_called_once_with(expected_url, expect_json_response=False, headers=None, params={})
diff --git a/test/unit/object/test_events.py b/test/unit/object/test_events.py
index 1a5cea4..7db2d88 100644
--- a/test/unit/object/test_events.py
+++ b/test/unit/object/test_events.py
@@ -2,6 +2,7 @@
 
 from __future__ import unicode_literals, absolute_import
 
+from collections import OrderedDict
 from itertools import chain
 import json
 
@@ -17,7 +18,6 @@
 from boxsdk.object.events import Events, EventsStreamType, UserEventsStreamType
 from boxsdk.object.event import Event
 from boxsdk.session.box_response import BoxResponse
-from boxsdk.util.ordered_dict import OrderedDict
 
 
 @pytest.fixture()
diff --git a/test/unit/object/test_file.py b/test/unit/object/test_file.py
index 67b3c41..8952f62 100644
--- a/test/unit/object/test_file.py
+++ b/test/unit/object/test_file.py
@@ -7,6 +7,7 @@
 from six import BytesIO
 from boxsdk.config import API
 from boxsdk.exception import BoxAPIException
+from boxsdk.object.comment import Comment
 from boxsdk.object.file import File
 
 
@@ -278,3 +279,54 @@ def test_get_shared_link_download_url(
         params=None,
     )
     assert url == test_url
+
+
+def test_get_comments(test_file, mock_box_session):
+    expected_url = test_file.get_url('comments')
+    mock_comment1 = {
+        'type': 'comment',
+        'id': '11111',
+        'message': 'Foo'
+    }
+    mock_comment2 = {
+        'type': 'comment',
+        'id': '22222',
+        'tagged_message': 'Well hello there, @[33333:friend]!'
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'total_count': 2,
+        'offset': 0,
+        'limit': 100,
+        'entries': [mock_comment1, mock_comment2]
+    }
+    comments = test_file.get_comments()
+    comment1 = comments.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={'offset': 0})
+    assert comment1.object_id == mock_comment1['id']
+    assert comment1.message == mock_comment1['message']
+
+    comment2 = comments.next()
+    assert comment2.object_id == mock_comment2['id']
+    assert comment2.tagged_message == mock_comment2['tagged_message']
+
+
+def test_add_comment(test_file, mock_box_session, comment_params):
+    expected_url = 'https://api.box.com/2.0/comments'
+    comment_id = '12345'
+    (message_type, message) = comment_params
+    expected_data = {
+        message_type: message,
+        'item': {
+            'type': 'file',
+            'id': test_file.object_id
+        }
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'type': 'comment',
+        'id': comment_id,
+        message_type: message
+    }
+    comment = test_file.add_comment(message)
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_data))
+    assert isinstance(comment, Comment)
+    assert comment.object_id == comment_id
diff --git a/test/unit/test_exception.py b/test/unit/test_exception.py
index c332d95..041a2eb 100644
--- a/test/unit/test_exception.py
+++ b/test/unit/test_exception.py
@@ -32,19 +32,18 @@ def test_box_api_exception():
     assert box_exception.code == code
     assert box_exception.message == message
     assert box_exception.request_id == request_id
-    assert box_exception._headers == headers  # pylint:disable=protected-access
+    assert box_exception.headers == headers  # pylint:disable=protected-access
     assert box_exception.url == url
     assert box_exception.method == method
     assert box_exception.context_info == context_info
-    assert str(box_exception) == '''
-Message: {0}
+    assert str(box_exception) == '''Message: {0}
 Status: {1}
 Code: {2}
-Request id: {3}
+Request ID: {3}
 Headers: {4}
 URL: {5}
 Method: {6}
-Context info: {7}'''.format(message, status, code, request_id, headers, url, method, context_info)
+Context Info: {7}'''.format(message, status, code, request_id, headers, url, method, context_info)
 
 
 @pytest.mark.parametrize('has_network_response', [True, False])
diff --git a/test/unit/util/test_compat.py b/test/unit/util/test_compat.py
index 4226375..2cad2f0 100644
--- a/test/unit/util/test_compat.py
+++ b/test/unit/util/test_compat.py
@@ -3,7 +3,7 @@
 from __future__ import unicode_literals
 from datetime import datetime, timedelta
 import pytest
-from boxsdk.util.compat import total_seconds, with_metaclass
+from boxsdk.util.compat import with_metaclass
 
 
 @pytest.fixture(params=(
@@ -18,7 +18,7 @@ def total_seconds_data(request):
 def test_total_seconds(total_seconds_data):
     # pylint:disable=redefined-outer-name
     delta, seconds = total_seconds_data
-    assert total_seconds(delta) == seconds
+    assert delta.total_seconds() == seconds
 
 
 def test_with_metaclass():
diff --git a/test/unit/util/test_enum.py b/test/unit/util/test_enum.py
index 4b67f95..d681690 100644
--- a/test/unit/util/test_enum.py
+++ b/test/unit/util/test_enum.py
@@ -2,12 +2,12 @@
 
 from __future__ import absolute_import, unicode_literals
 
+from collections import OrderedDict
 from enum import Enum
 import pytest
 
 from boxsdk.util.compat import with_metaclass
 from boxsdk.util.enum import ExtendableEnumMeta
-from boxsdk.util.ordered_dict import OrderedDict
 
 
 # pylint:disable=invalid-name