diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py index ffd3611b4..e4fdb61ea 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 000000000..e407b1c9c --- /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 000000000..8dbacde50 --- /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 = ''.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 76b0ffe6a..d24d8ca41 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 3fe1d8b6b..582322293 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 = ''.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 000000000..8025d8075 --- /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 dbb321f76..3d62b4370 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 3aaf1c28e..db07b6050 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 cc885af72..d0d99cd44 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 9c3fed06f..7d5adee82 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 diff --git a/test/functional/test_events.py b/test/functional/test_events.py index b0ccd4989..590682de1 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 000000000..1be04ef62 --- /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 000000000..3032f1147 --- /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 000000000..1c4cd2ebf --- /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 bf456876e..ff6c88e24 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()