diff --git a/src/pardner/__init__.py b/src/pardner/__init__.py index 149291f..fa2db8d 100644 --- a/src/pardner/__init__.py +++ b/src/pardner/__init__.py @@ -1,2 +1,3 @@ +from pardner import exceptions as exceptions from pardner import services as services from pardner import verticals as verticals diff --git a/src/pardner/exceptions.py b/src/pardner/exceptions.py new file mode 100644 index 0000000..09be30c --- /dev/null +++ b/src/pardner/exceptions.py @@ -0,0 +1,24 @@ +from pardner.verticals import Vertical + + +class InsufficientScopeException(Exception): + def __init__(self, *unsupported_verticals: Vertical, service_name: str) -> None: + combined_verticals = ', '.join(unsupported_verticals) + super().__init__( + f'Cannot add {combined_verticals} to {service_name} with current scope.' + ) + + +class UnsupportedVerticalException(Exception): + def __init__(self, *unsupported_verticals: Vertical, service_name: str) -> None: + combined_verticals = ', '.join(unsupported_verticals) + is_more_than_one_vertical = len(unsupported_verticals) > 1 + super().__init__( + f'Cannot fetch {combined_verticals} from {service_name} because ' + f'{"they are" if is_more_than_one_vertical else "it is"} not supported.' + ) + + +class UnsupportedRequestException(Exception): + def __init__(self, service_name: str, message: str): + super().__init__(f'Cannot fetch data from {service_name}: {message}') diff --git a/src/pardner/services/base.py b/src/pardner/services/base.py index eee69f1..90c4598 100644 --- a/src/pardner/services/base.py +++ b/src/pardner/services/base.py @@ -5,35 +5,11 @@ from requests import Response from requests_oauthlib import OAuth2Session +from pardner.exceptions import InsufficientScopeException, UnsupportedVerticalException from pardner.services.utils import scope_as_set, scope_as_string from pardner.verticals import Vertical -class InsufficientScopeException(Exception): - def __init__( - self, unsupported_verticals: Iterable[Vertical], service_name: str - ) -> None: - combined_verticals = ' '.join(unsupported_verticals) - super().__init__( - f'Cannot add {combined_verticals} to {service_name} with current scope.' - ) - - -class UnsupportedVerticalException(Exception): - def __init__( - self, unsupported_verticals: Iterable[Vertical], service_name: str - ) -> None: - combined_verticals = ' '.join(unsupported_verticals) - super().__init__( - f'Cannot add {combined_verticals} to {service_name} because they are not supported.' - ) - - -class UnsupportedRequestException(Exception): - def __init__(self, service_name: str, message: str): - super().__init__(f'Cannot fetch data from {service_name}: {message}') - - class BaseTransferService(ABC): """ A base class to be extended by service-specific classes that implement logic for @@ -118,10 +94,12 @@ def verticals(self, verticals: Iterable[Vertical]) -> None: unsupported_verticals = [ vertical for vertical in verticals - if vertical not in self._supported_verticals + if not self.is_vertical_supported(vertical) ] if len(unsupported_verticals) > 0: - raise UnsupportedVerticalException(unsupported_verticals, self.name) + raise UnsupportedVerticalException( + *unsupported_verticals, service_name=self.name + ) self._verticals = set(verticals) def _get_resource(self, uri: str, params: dict[str, Any] = {}) -> Response: @@ -192,7 +170,7 @@ def add_verticals( new_scopes = self.scope_for_verticals(new_verticals) if not new_scopes.issubset(self.scope) and not should_reauth: - raise InsufficientScopeException(verticals, self.name) + raise InsufficientScopeException(*verticals, service_name=self.name) elif not new_scopes.issubset(self.scope): self.verticals = new_verticals | self.verticals del self._oAuth2Session.access_token @@ -202,6 +180,16 @@ def add_verticals( self.verticals = new_verticals | self.verticals return True + def is_vertical_supported(self, vertical: Vertical) -> bool: + """ + Utility for indicating whether ``vertical`` is supported by the service or not. + + :param vertical: the ``vertical`` from which validity is checked. + + :returns: ``True`` if supported, ``False`` otherwise. + """ + return vertical in self._supported_verticals + def fetch_token( self, code: Optional[str] = None, @@ -252,3 +240,28 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: :returns: Scope names corresponding to `verticals`. """ pass + + def fetch( + self, vertical: Vertical, request_params: dict[str, Any] = {}, **params: Any + ) -> Any: + """ + Generic method for fetching data of a specific vertical. + + :param vertical: the :class:`Vertical` to fetch from the + service. + :param request_params: additional request parameters to be sent with the HTTP + request. Requires familiarity with the API of the service being used. + :param params: optional keyword arguments to be passed to the methods for + fetching ``vertical``. + + :returns: the JSON response resulting from making the request. + + :raises: :class:`UnsupportedVerticalException` if the vertical is not supported. + """ + if not self.is_vertical_supported(vertical): + raise UnsupportedVerticalException( + vertical, service_name=self._service_name + ) + + method_name = f'fetch_{vertical.plural}' + getattr(self, method_name)(request_params=request_params, **params) diff --git a/src/pardner/services/groupme.py b/src/pardner/services/groupme.py index 114fec5..4c59667 100644 --- a/src/pardner/services/groupme.py +++ b/src/pardner/services/groupme.py @@ -6,7 +6,8 @@ from requests import Response from requests_oauthlib import OAuth2Session -from pardner.services.base import BaseTransferService, UnsupportedRequestException +from pardner.exceptions import UnsupportedRequestException +from pardner.services import BaseTransferService from pardner.verticals import Vertical @@ -113,24 +114,28 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: # GroupMe does not require scope return set() - def fetch_user_data(self) -> Any: + def fetch_user_data(self, request_params: dict[str, Any] = {}) -> Any: """ Fetches user identifiers and profile data. Also sets ``self._user_id``, which is necessary for most requests. :returns: user identifiers and profile data in dictionary format. """ - user_data = self._get_resource_from_path('users/me').json().get('response') + user_data = ( + self._get_resource_from_path('users/me', request_params) + .json() + .get('response') + ) self._user_id = user_data['id'] return user_data - def fetch_blocked_users(self) -> Any: + def fetch_blocked_users(self, request_params: dict[str, Any] = {}) -> Any: """ Sends a GET request to fetch the users blocked by the authenticated user. :returns: a JSON object with the result of the request. """ - blocked_users = self._fetch_resource_common('blocks') + blocked_users = self._fetch_resource_common('blocks', request_params) if 'blocks' not in blocked_users: raise ValueError( @@ -139,15 +144,17 @@ def fetch_blocked_users(self) -> Any: return blocked_users['blocks'] - def fetch_chat_bots(self) -> Any: + def fetch_chat_bots(self, request_params: dict[str, Any] = {}) -> Any: """ Sends a GET request to fetch the chat bots created by the authenticated user. :returns: a JSON object with the result of the request. """ - return self._fetch_resource_common('bots') + return self._fetch_resource_common('bots', request_params) - def fetch_conversations_direct(self, count: int = 10) -> Any: + def fetch_conversations_direct( + self, request_params: dict[str, Any] = {}, count: int = 10 + ) -> Any: """ Sends a GET request to fetch the conversations the authenticated user is a part of with only one other member (i.e., a direct message). The response will @@ -159,13 +166,17 @@ def fetch_conversations_direct(self, count: int = 10) -> Any: :returns: a JSON object with the result of the request. """ if count <= 10: - return self._fetch_resource_common('chats', params={'per_page': count}) + return self._fetch_resource_common( + 'chats', params={**request_params, 'per_page': count} + ) raise UnsupportedRequestException( self._service_name, 'can only make a request for at most 10 direct conversations at a time.', ) - def fetch_conversations_group(self, count: int = 10) -> Any: + def fetch_conversations_group( + self, request_params: dict[str, Any] = {}, count: int = 10 + ) -> Any: """ Sends a GET request to fetch the group conversations the authenticated user is a part of. The response will include metadata associated with the conversation, @@ -176,7 +187,9 @@ def fetch_conversations_group(self, count: int = 10) -> Any: :returns: a JSON object with the result of the request. """ if count <= 10: - return self._fetch_resource_common('groups', params={'per_page': count}) + return self._fetch_resource_common( + 'groups', params={**request_params, 'per_page': count} + ) raise UnsupportedRequestException( self._service_name, 'can only make a request for at most 10 group conversations at a time.', diff --git a/src/pardner/services/strava.py b/src/pardner/services/strava.py index 46a07d1..ea3a6c0 100644 --- a/src/pardner/services/strava.py +++ b/src/pardner/services/strava.py @@ -1,10 +1,7 @@ from typing import Any, Iterable, Optional, override -from pardner.services.base import ( - BaseTransferService, - UnsupportedRequestException, - UnsupportedVerticalException, -) +from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException +from pardner.services import BaseTransferService from pardner.services.utils import scope_as_set, scope_as_string from pardner.verticals import Vertical @@ -59,14 +56,16 @@ def fetch_token( def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: sub_scopes: set[str] = set() for vertical in verticals: - if vertical not in self._supported_verticals: - raise UnsupportedVerticalException([vertical], self._service_name) + if not self.is_vertical_supported(vertical): + raise UnsupportedVerticalException( + vertical, service_name=self._service_name + ) if vertical == Vertical.PhysicalActivity: sub_scopes.update(['activity:read', 'profile:read_all']) return sub_scopes - def fetch_athlete_activities( - self, count: int = 30, request_params: dict[str, Any] = {} + def fetch_physical_activities( + self, request_params: dict[str, Any] = {}, count: int = 30 ) -> list[Any]: """ Fetches and returns activities completed by the authorized user. diff --git a/src/pardner/services/tumblr.py b/src/pardner/services/tumblr.py index af4f523..856f1b4 100644 --- a/src/pardner/services/tumblr.py +++ b/src/pardner/services/tumblr.py @@ -1,7 +1,7 @@ from typing import Any, Iterable, Optional, override +from pardner.exceptions import UnsupportedRequestException from pardner.services import BaseTransferService -from pardner.services.base import UnsupportedRequestException from pardner.verticals import Vertical @@ -50,9 +50,9 @@ def fetch_token( def fetch_feed_posts( self, + request_params: dict[str, Any] = {}, count: int = 20, text_only: bool = True, - request_params: dict[str, Any] = {}, ) -> list[Any]: """ Fetches posts from Tumblr feed for user account whose token was diff --git a/src/pardner/verticals/base.py b/src/pardner/verticals/base.py index 4cef5b4..89b2fd3 100644 --- a/src/pardner/verticals/base.py +++ b/src/pardner/verticals/base.py @@ -1,16 +1,25 @@ from enum import StrEnum +from typing import Optional class Vertical(StrEnum): """ - Represents the verticals, or categories of data, that are supported by `pardner`. + Represents the verticals, or categories of data, that are supported by this library. Not all verticals are supported by every transfer service. """ BlockedUser = 'blocked_user' ChatBot = 'chat_bot' - ConversationDirect = 'conversation_direct' - ConversationGroup = 'conversation_group' + ConversationDirect = 'conversation_direct', 'conversations_direct' + ConversationGroup = 'conversation_group', 'conversations_group' ConversationMessage = 'conversation_message' FeedPost = 'feed_post' - PhysicalActivity = 'physical_activity' + PhysicalActivity = 'physical_activity', 'physical_activities' + + plural: str + + def __new__(cls, singular: str, plural: Optional[str] = None) -> 'Vertical': + vertical_obj = str.__new__(cls, singular) + vertical_obj._value_ = singular + vertical_obj.plural = plural if plural else f'{singular}s' + return vertical_obj diff --git a/tests/test_transfer_services/conftest.py b/tests/test_transfer_services/conftest.py index 4ac42d7..fbe78cc 100644 --- a/tests/test_transfer_services/conftest.py +++ b/tests/test_transfer_services/conftest.py @@ -12,6 +12,18 @@ # FIXTURES +@pytest.fixture +def mock_nested_dict(mocker): + def nested_dict(): + mock_dict = mocker.MagicMock(spec=dict, name='mock_nested_dict') + mock_dict.__getitem__.side_effect = lambda _: nested_dict() + mock_dict.get.side_effect = lambda _, default=None: nested_dict() + mock_dict.__contains__.side_effect = lambda _: True + return mock_dict + + return nested_dict() + + @pytest.fixture def mock_oauth2_session_request(mocker): return mocker.patch('requests_oauthlib.OAuth2Session.request') @@ -76,6 +88,23 @@ def mock_oauth2_session_get_bad_response(mocker, mock_oauth2_bad_response): return oauth2_session_get +@pytest.fixture +def mock_oauth2_good_response(mocker, mock_nested_dict): + mock_response = mocker.create_autospec(Response) + mock_response.ok = True + mock_response.status_code = 200 + mock_response.url = 'fake url' + mock_response.json.return_value = mock_nested_dict + return mock_response + + +@pytest.fixture +def mock_oauth2_session_get_good_response(mocker, mock_oauth2_good_response): + oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True) + oauth2_session_get.return_value = mock_oauth2_good_response + return oauth2_session_get + + # HELPERS diff --git a/tests/test_transfer_services/test_groupme.py b/tests/test_transfer_services/test_groupme.py index bd6be83..f2e27c6 100644 --- a/tests/test_transfer_services/test_groupme.py +++ b/tests/test_transfer_services/test_groupme.py @@ -1,6 +1,6 @@ import pytest -from pardner.services.base import UnsupportedRequestException +from pardner.exceptions import UnsupportedRequestException from tests.test_transfer_services.conftest import mock_oauth2_session_get USER_ID = 'fake_user_id' diff --git a/tests/test_transfer_services/test_strava.py b/tests/test_transfer_services/test_strava.py index 5a3944c..659e4e2 100644 --- a/tests/test_transfer_services/test_strava.py +++ b/tests/test_transfer_services/test_strava.py @@ -1,10 +1,7 @@ import pytest from requests import HTTPError -from pardner.services.base import ( - UnsupportedRequestException, - UnsupportedVerticalException, -) +from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException from pardner.verticals import Vertical from tests.test_transfer_services.conftest import mock_oauth2_session_get @@ -26,19 +23,19 @@ def test_scope_for_verticals_raises_error(mock_strava_transfer_service, mock_ver mock_strava_transfer_service.scope_for_verticals([Vertical.NEW_VERTICAL]) -def test_fetch_athlete_activities_raises_exception(mock_strava_transfer_service): +def test_fetch_physical_activities_raises_exception(mock_strava_transfer_service): with pytest.raises(UnsupportedRequestException): - mock_strava_transfer_service.fetch_athlete_activities(count=31) + mock_strava_transfer_service.fetch_physical_activities(count=31) -def test_fetch_athlete_activities_raises_http_exception( +def test_fetch_physical_activities_raises_http_exception( mock_strava_transfer_service, mock_oauth2_session_get_bad_response ): with pytest.raises(HTTPError): - mock_strava_transfer_service.fetch_athlete_activities() + mock_strava_transfer_service.fetch_physical_activities() -def test_fetch_athlete_activities(mocker, mock_strava_transfer_service): +def test_fetch_physical_activities(mocker, mock_strava_transfer_service): sample_response = [{'object': 1}, {'object': 2}] response_object = mocker.MagicMock() @@ -46,7 +43,7 @@ def test_fetch_athlete_activities(mocker, mock_strava_transfer_service): oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - assert mock_strava_transfer_service.fetch_athlete_activities() == sample_response + assert mock_strava_transfer_service.fetch_physical_activities() == sample_response assert ( oauth2_session_get.call_args.args[1] == 'https://www.strava.com/api/v3/athlete/activities' diff --git a/tests/test_transfer_services/test_transfer_services_common.py b/tests/test_transfer_services/test_transfer_services_common.py index 0d1cd06..64eb519 100644 --- a/tests/test_transfer_services/test_transfer_services_common.py +++ b/tests/test_transfer_services/test_transfer_services_common.py @@ -1,5 +1,8 @@ import pytest +from pardner.exceptions import UnsupportedVerticalException +from pardner.verticals import Vertical + @pytest.mark.parametrize( 'mock_transfer_service_name', @@ -19,3 +22,23 @@ def test_fetch_token( mock_oauth2_session_request.call_args.kwargs['data']['client_id'] == 'fake_client_id' ) + + +@pytest.mark.parametrize( + 'mock_transfer_service_name', + [ + 'mock_tumblr_transfer_service', + 'mock_strava_transfer_service', + 'mock_groupme_transfer_service', + ], +) +def test_fetch_vertical_generic( + request, mock_oauth2_session_get_good_response, mock_transfer_service_name +): + mock_transfer_service = request.getfixturevalue(mock_transfer_service_name) + for vertical in Vertical: + if not mock_transfer_service.is_vertical_supported(vertical): + with pytest.raises(UnsupportedVerticalException): + mock_transfer_service.fetch(vertical) + else: + mock_transfer_service.fetch(vertical) diff --git a/tests/test_transfer_services/test_tumblr.py b/tests/test_transfer_services/test_tumblr.py index 2ce6d80..77688d7 100644 --- a/tests/test_transfer_services/test_tumblr.py +++ b/tests/test_transfer_services/test_tumblr.py @@ -1,7 +1,7 @@ import pytest from requests import HTTPError -from pardner.services.base import UnsupportedRequestException +from pardner.exceptions import UnsupportedRequestException from pardner.verticals import Vertical from tests.test_transfer_services.conftest import mock_oauth2_session_get