Repository: box/box-python-sdk

Issue:
Add an Event class
Right now `get_events()` method in the `Events` class returns a `dict`. Ideally, we should have an `Event` class, and `Event` objects can also be translated. So `Events.get_events()` call will return a list of `Event` objects.

Discussion:
boxcla: Hi @kelseymorris95, thanks for the pull request. Before we can merge it, we need you to sign our Contributor License Agreement. You can do so electronically here: http://opensource.box.com/cla

Once you have signed, just add a comment to this pull request saying, "CLA signed". Thanks!
kelseymorris95: CLA signed
boxcla: Verified that @kelseymorris95 has just signed the CLA. Thanks, and we look forward to your contribution.
jmoldow: Something to think about: `Translator.translate()` takes a type, and tries to translate it. If it fails, it'll return `BaseObject`. Then, the usual procedure is to pass `session, object_id, response_object` to the class that is returned.

This has two problems:
- Ideally, the default return value would be either `BaseObject` or `APIJSONObject`, depending on the object. I would choose `BaseObject` if there were an `id`, and `APIJSONObject` otherwise. However, we can't do that since the method only accepts type. We'd need to make a backwards-incompatible change to make it accept additional information.
- The types of classes we return have different `__init__` parameters. So you can't initialize an object unless you know in advance which type it's going to be. We could fix this by:
  - Moving the object creation inside of `Translator.translate()` (after dealing with the above problem).
  - Making `APIJSONObject.__init__` have the same argument list as `BaseObject.__init__`, even though it doesn't need most of them.

Let's not worry about this right now. What we have now works well enough to push what you have as-is. But we'll want to revisit this before or during work on #140.
HootyMcOwlface: :+1:

jmoldow: 👍 

Thanks @kelseymorris95 ! This all looks great. I'm going to merge it now.

Before releasing this to PyPI, over the next few days, let's make sure we're happy with the names of the new classes, make a final decision about `Mapping` vs `MutableMapping` vs `dict`, and double-check that we aren't introducing any other backwards incompatibilities.

Patch:
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index ffd3611..e4fdb61 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -5,4 +5,4 @@
 from six.moves import map   # pylint:disable=redefined-builtin
 
 
-__all__ = list(map(str, ['collaboration', 'events', 'file', 'folder', 'group', 'group_membership', 'search', 'user']))
+__all__ = list(map(str, ['collaboration', 'events', 'event', 'file', 'folder', 'group', 'group_membership', 'search', 'user']))
diff --git a/boxsdk/object/api_json_object.py b/boxsdk/object/api_json_object.py
new file mode 100644
index 0000000..e407b1c
--- /dev/null
+++ b/boxsdk/object/api_json_object.py
@@ -0,0 +1,26 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+from collections import Mapping
+from abc import ABCMeta
+import six
+
+from .base_api_json_object import BaseAPIJSONObject, BaseAPIJSONObjectMeta
+
+
+class APIJSONObjectMeta(BaseAPIJSONObjectMeta, ABCMeta):
+    """
+    Avoid conflicting metaclass definitions for APIJSONObject.
+    http://code.activestate.com/recipes/204197-solving-the-metaclass-conflict/
+    """
+    pass
+
+
+class APIJSONObject(six.with_metaclass(APIJSONObjectMeta, BaseAPIJSONObject, Mapping)):
+    """Class representing objects that are not part of the REST API."""
+
+    def __len__(self):
+        return len(self._response_object)
+
+    def __iter__(self):
+        return iter(self._response_object)
diff --git a/boxsdk/object/base_api_json_object.py b/boxsdk/object/base_api_json_object.py
new file mode 100644
index 0000000..8dbacde
--- /dev/null
+++ b/boxsdk/object/base_api_json_object.py
@@ -0,0 +1,63 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+import six
+
+from ..util.translator import Translator
+
+
+class BaseAPIJSONObjectMeta(type):
+    """
+    Metaclass for Box API objects. Registers classes so that API responses can be translated to the correct type.
+    Relies on the _item_type field defined on the classes to match the type property of the response json.
+    But the type-class mapping will only be registered if the module of the class is imported.
+    So it's also important to add the module name to __all__ in object/__init__.py.
+    """
+    def __init__(cls, name, bases, attrs):
+        super(BaseAPIJSONObjectMeta, cls).__init__(name, bases, attrs)
+        item_type = attrs.get('_item_type', None)
+        if item_type is not None:
+            Translator().register(item_type, cls)
+
+
+@six.add_metaclass(BaseAPIJSONObjectMeta)
+class BaseAPIJSONObject(object):
+    """Base class containing basic logic shared between true REST objects and other objects (such as an Event)"""
+
+    _item_type = None
+
+    def __init__(self, response_object=None, **kwargs):
+        """
+        :param response_object:
+            A JSON object representing the object returned from a Box API request.
+        :type response_object:
+            `dict`
+        """
+        super(BaseAPIJSONObject, self).__init__(**kwargs)
+        self._response_object = response_object or {}
+        self.__dict__.update(self._response_object)
+
+    def __getitem__(self, item):
+        """
+        Try to get the attribute from the API response object.
+
+        :param item:
+            The attribute to retrieve from the API response object.
+        :type item:
+            `unicode`
+        """
+        return self._response_object[item]
+
+    def __repr__(self):
+        """Base class override. Return a human-readable representation using the Box ID or name of the object."""
+        extra_description = ' - {0}'.format(self._description) if self._description else ''
+        description = '<Box {0}{1}>'.format(self.__class__.__name__, extra_description)
+        if six.PY2:
+            return description.encode('utf-8')
+        else:
+            return description
+
+    @property
+    def _description(self):
+        """Return a description of the object if one exists."""
+        return ""
diff --git a/boxsdk/object/base_endpoint.py b/boxsdk/object/base_endpoint.py
index 76b0ffe..d24d8ca 100644
--- a/boxsdk/object/base_endpoint.py
+++ b/boxsdk/object/base_endpoint.py
@@ -1,19 +1,23 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
+from __future__ import unicode_literals, absolute_import
 
 
 class BaseEndpoint(object):
     """A Box API endpoint."""
 
-    def __init__(self, session):
+    def __init__(self, session, **kwargs):
         """
-
         :param session:
             The Box session used to make requests.
         :type session:
             :class:`BoxSession`
+        :param kwargs:
+            Keyword arguments for base class constructors.
+        :type kwargs:
+            `dict`
         """
+        super(BaseEndpoint, self).__init__(**kwargs)
         self._session = session
 
     def get_url(self, endpoint, *args):
diff --git a/boxsdk/object/base_object.py b/boxsdk/object/base_object.py
index 3fe1d8b..5823222 100644
--- a/boxsdk/object/base_object.py
+++ b/boxsdk/object/base_object.py
@@ -1,35 +1,15 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
-from abc import ABCMeta
+from __future__ import unicode_literals, absolute_import
 import json
 
-import six
+from .base_endpoint import BaseEndpoint
+from .base_api_json_object import BaseAPIJSONObject
+from ..util.translator import Translator
 
-from boxsdk.object.base_endpoint import BaseEndpoint
-from boxsdk.util.translator import Translator
 
-
-class ObjectMeta(ABCMeta):
-    """
-    Metaclass for Box API objects. Registers classes so that API responses can be translated to the correct type.
-    Relies on the _item_type field defined on the classes to match the type property of the response json.
-    But the type-class mapping will only be registered if the module of the class is imported.
-    So it's also important to add the module name to __all__ in object/__init__.py.
-    """
-    def __init__(cls, name, bases, attrs):
-        super(ObjectMeta, cls).__init__(name, bases, attrs)
-        item_type = attrs.get('_item_type', None)
-        if item_type is not None:
-            Translator().register(item_type, cls)
-
-
-@six.add_metaclass(ObjectMeta)
-class BaseObject(BaseEndpoint):
-    """
-    A Box API endpoint for interacting with a Box object.
-    """
-    _item_type = None
+class BaseObject(BaseEndpoint, BaseAPIJSONObject):
+    """A Box API endpoint for interacting with a Box object."""
 
     def __init__(self, session, object_id, response_object=None):
         """
@@ -42,29 +22,16 @@ def __init__(self, session, object_id, response_object=None):
         :type object_id:
             `unicode`
         :param response_object:
-            The Box API response representing the object.
+            A JSON object representing the object returned from a Box API request.
         :type response_object:
-            :class:`BoxResponse`
+            `dict`
         """
-        super(BaseObject, self).__init__(session)
+        super(BaseObject, self).__init__(session=session, response_object=response_object)
         self._object_id = object_id
-        self._response_object = response_object or {}
-        self.__dict__.update(self._response_object)
-
-    def __getitem__(self, item):
-        """Base class override. Try to get the attribute from the API response object."""
-        return self._response_object[item]
-
-    def __repr__(self):
-        """Base class override. Return a human-readable representation using the Box ID or name of the object."""
-        description = '<Box {0} - {1}>'.format(self.__class__.__name__, self._description)
-        if six.PY2:
-            return description.encode('utf-8')
-        else:
-            return description
 
     @property
     def _description(self):
+        """Base class override.  Return a description for the object."""
         if 'name' in self._response_object:
             return '{0} ({1})'.format(self._object_id, self.name)  # pylint:disable=no-member
         else:
@@ -185,7 +152,7 @@ def delete(self, params=None, headers=None):
         return box_response.ok
 
     def __eq__(self, other):
-        """Base class override. Equality is determined by object id."""
+        """Equality as determined by object id"""
         return self._object_id == other.object_id
 
     def _paging_wrapper(self, url, starting_index, limit, factory=None):
diff --git a/boxsdk/object/event.py b/boxsdk/object/event.py
new file mode 100644
index 0000000..8025d80
--- /dev/null
+++ b/boxsdk/object/event.py
@@ -0,0 +1,11 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+from .api_json_object import APIJSONObject
+
+
+class Event(APIJSONObject):
+    """Represents a single Box event."""
+
+    _item_type = 'event'
diff --git a/boxsdk/object/events.py b/boxsdk/object/events.py
index dbb321f..3d62b43 100644
--- a/boxsdk/object/events.py
+++ b/boxsdk/object/events.py
@@ -1,14 +1,14 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
-
+from __future__ import unicode_literals, absolute_import
 from requests.exceptions import Timeout
 from six import with_metaclass
 
-from boxsdk.object.base_endpoint import BaseEndpoint
-from boxsdk.util.enum import ExtendableEnumMeta
-from boxsdk.util.lru_cache import LRUCache
-from boxsdk.util.text_enum import TextEnum
+from .base_endpoint import BaseEndpoint
+from ..util.enum import ExtendableEnumMeta
+from ..util.lru_cache import LRUCache
+from ..util.text_enum import TextEnum
+from ..util.translator import Translator
 
 
 # pylint:disable=too-many-ancestors
@@ -79,8 +79,7 @@ def get_events(self, limit=100, stream_position=0, stream_type=UserEventsStreamT
         :type stream_type:
             :enum:`EventsStreamType`
         :returns:
-            JSON response from the Box /events endpoint. Contains the next stream position to use for the next call,
-            along with some number of events.
+            Dictionary containing the next stream position along with a list of some number of events.
         :rtype:
             `dict`
         """
@@ -91,7 +90,10 @@ def get_events(self, limit=100, stream_position=0, stream_type=UserEventsStreamT
             'stream_type': stream_type,
         }
         box_response = self._session.get(url, params=params)
-        return box_response.json()
+        response = box_response.json().copy()
+        if 'entries' in response:
+            response['entries'] = [Translator().translate(item['type'])(item) for item in response['entries']]
+        return response
 
     def get_latest_stream_position(self, stream_type=UserEventsStreamType.ALL):
         """
diff --git a/boxsdk/object/folder.py b/boxsdk/object/folder.py
index 3aaf1c2..db07b60 100644
--- a/boxsdk/object/folder.py
+++ b/boxsdk/object/folder.py
@@ -4,6 +4,7 @@
 import json
 import os
 from six import text_type
+
 from boxsdk.config import API
 from boxsdk.object.collaboration import Collaboration
 from boxsdk.object.file import File
diff --git a/boxsdk/object/item.py b/boxsdk/object/item.py
index cc885af..d0d99cd 100644
--- a/boxsdk/object/item.py
+++ b/boxsdk/object/item.py
@@ -1,7 +1,6 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
-
+from __future__ import unicode_literals, absolute_import
 import json
 
 from .base_object import BaseObject
@@ -111,6 +110,10 @@ def rename(self, name):
     def get(self, fields=None, etag=None):
         """Base class override.
 
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
         :param etag:
             If specified, instruct the Box API to get the info only if the current version's etag doesn't match.
         :type etag:
diff --git a/boxsdk/object/metadata.py b/boxsdk/object/metadata.py
index 9c3fed0..7d5adee 100644
--- a/boxsdk/object/metadata.py
+++ b/boxsdk/object/metadata.py
@@ -1,6 +1,6 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
+from __future__ import unicode_literals, absolute_import
 import json
 from boxsdk.object.base_endpoint import BaseEndpoint
 


Test patch:
diff --git a/test/functional/test_events.py b/test/functional/test_events.py
index b0ccd49..590682d 100644
--- a/test/functional/test_events.py
+++ b/test/functional/test_events.py
@@ -8,6 +8,7 @@
 import requests
 
 from boxsdk.object.folder import FolderSyncState
+from boxsdk.object.event import Event as BoxEvent
 
 
 @pytest.fixture
@@ -36,6 +37,7 @@ def helper(get_item, event_type, stream_position=0):
         assert event['event_type'] == event_type
         assert event['source']['name'] == item.name
         assert event['source']['id'] == item.id
+        assert isinstance(event, BoxEvent)
 
     return helper
 
diff --git a/test/unit/object/test_api_json_object.py b/test/unit/object/test_api_json_object.py
new file mode 100644
index 0000000..1be04ef
--- /dev/null
+++ b/test/unit/object/test_api_json_object.py
@@ -0,0 +1,21 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+import pytest
+
+from boxsdk.object.api_json_object import APIJSONObject
+
+
+@pytest.fixture(params=[{'foo': 'bar'}, {'a': {'b': 'c'}}])
+def api_json_object(request):
+    return request.param, APIJSONObject(request.param)
+
+
+def test_len(api_json_object):
+    dictionary, test_object = api_json_object
+    assert len(dictionary) == len(test_object)
+
+
+def test_api_json_object_dict(api_json_object):
+    dictionary, test_object = api_json_object
+    assert dictionary == test_object
diff --git a/test/unit/object/test_base_api_json_object.py b/test/unit/object/test_base_api_json_object.py
new file mode 100644
index 0000000..3032f11
--- /dev/null
+++ b/test/unit/object/test_base_api_json_object.py
@@ -0,0 +1,24 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+import pytest
+
+from boxsdk.object.base_api_json_object import BaseAPIJSONObject
+
+
+@pytest.fixture(params=[{'foo': 'bar'}, {'a': {'b': 'c'}}])
+def response(request):
+    return request.param
+
+
+@pytest.fixture()
+def base_api_json_object(response):
+    dictionary_response = response
+    return dictionary_response, BaseAPIJSONObject(dictionary_response)
+
+
+def test_getitem(base_api_json_object):
+    dictionary_response, test_object = base_api_json_object
+    assert isinstance(test_object, BaseAPIJSONObject)
+    for key in dictionary_response:
+        assert test_object[key] == dictionary_response[key]
diff --git a/test/unit/object/test_event.py b/test/unit/object/test_event.py
new file mode 100644
index 0000000..1c4cd2e
--- /dev/null
+++ b/test/unit/object/test_event.py
@@ -0,0 +1,20 @@
+# coding: utf-8
+
+from __future__ import unicode_literals
+
+from boxsdk.object.event import Event
+
+
+def test_init_event():
+    event = Event(
+        {
+            "type": "event",
+            "event_id": "f82c3ba03e41f7e8a7608363cc6c0390183c3f83",
+            "source":
+            {
+                "type": "folder",
+                "id": "11446498",
+            },
+        })
+    assert event['type'] == 'event'
+    assert event['event_id'] == 'f82c3ba03e41f7e8a7608363cc6c0390183c3f83'
diff --git a/test/unit/object/test_events.py b/test/unit/object/test_events.py
index bf45687..ff6c88e 100644
--- a/test/unit/object/test_events.py
+++ b/test/unit/object/test_events.py
@@ -1,6 +1,6 @@
 # coding: utf-8
 
-from __future__ import unicode_literals
+from __future__ import unicode_literals, absolute_import
 
 from itertools import chain
 import json
@@ -13,6 +13,7 @@
 
 from boxsdk.network.default_network import DefaultNetworkResponse
 from boxsdk.object.events import Events, EventsStreamType, UserEventsStreamType
+from boxsdk.object.event import Event
 from boxsdk.session.box_session import BoxResponse
 from boxsdk.util.ordered_dict import OrderedDict
 
@@ -169,22 +170,22 @@ def max_retries_long_poll_response(make_mock_box_request):
 
 
 @pytest.fixture()
-def mock_event():
+def mock_event_json():
     return {
         "type": "event",
         "event_id": "f82c3ba03e41f7e8a7608363cc6c0390183c3f83",
         "source": {
             "type": "folder",
             "id": "11446498",
-        }
+        },
     }
 
 
 @pytest.fixture()
-def events_response(initial_stream_position, mock_event, make_mock_box_request):
+def events_response(initial_stream_position, mock_event_json, make_mock_box_request):
     # pylint:disable=redefined-outer-name
     mock_box_response, _ = make_mock_box_request(
-        response={"next_stream_position": initial_stream_position, "entries": [mock_event]},
+        response={"next_stream_position": initial_stream_position, "entries": [mock_event_json]},
     )
     return mock_box_response
 
@@ -205,6 +206,10 @@ def test_get_events(
         expected_url,
         params=dict(limit=100, stream_position=0, **expected_stream_type_params),
     )
+    event_entries = events['entries']
+    assert event_entries == events_response.json.return_value['entries']
+    for event in event_entries:
+        assert isinstance(event, Event)
 
 
 def test_get_long_poll_options(
@@ -234,7 +239,7 @@ def test_generate_events_with_long_polling(
         new_change_long_poll_response,
         reconnect_long_poll_response,
         max_retries_long_poll_response,
-        mock_event,
+        mock_event_json,
         stream_type_kwargs,
         expected_stream_type,
         expected_stream_type_params,
@@ -253,7 +258,7 @@ def test_generate_events_with_long_polling(
         empty_events_response,
     ]
     events = test_events.generate_events_with_long_polling(**stream_type_kwargs)
-    assert next(events) == mock_event
+    assert next(events) == Event(mock_event_json)
     with pytest.raises(StopIteration):
         next(events)
     events.close()