Skip to content

Commit 9848420

Browse files
authored
Adds generic fetch_vertical method and re-organizes code structure (#57)
Closes #48 ## `fetch_vertical` - Takes in a `vertical`, `request_params` for to be sent in the actual HTTP request, and `params`, which are optional key-word arguments specific to each method. - Works by using the vertical's plural name to generate the method name and calling that with the latter two arguments described above. May seem a little risky to call a method based on a name, but given that we check if the vertical is supported and generate the method names based on the vertical names, I think it should be okay (and I wrote tests). As always, open to feedback! - For the sake of consistency, I added a `request_params` argument to every `fetch_` method (which I think is smart anyway since our pardners may want fine-grained configurability and may have familiarity with the APIs). ## Exception reorganization - Moved all exception definitions from `services/base.py` to `exceptions.py` - Made the list arguments in the exceptions constructors optionally lists to get around having to generate one element lists to raise the exception. ## Misc - Made helper method (`is_vertical_supported(...)`) for checking if a vertical is supported using `_supported_verticals` since it's not unexpected that our pardners may want to check before adding a vertical - Renamed `fetch_athlete_activities(...)` to `fetch_physical_activities(...)` for consistency - Moved `request_params` to be first argument in all the `fetch_` methods.
1 parent e494b94 commit 9848420

12 files changed

Lines changed: 174 additions & 66 deletions

File tree

src/pardner/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from pardner import exceptions as exceptions
12
from pardner import services as services
23
from pardner import verticals as verticals

src/pardner/exceptions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from pardner.verticals import Vertical
2+
3+
4+
class InsufficientScopeException(Exception):
5+
def __init__(self, *unsupported_verticals: Vertical, service_name: str) -> None:
6+
combined_verticals = ', '.join(unsupported_verticals)
7+
super().__init__(
8+
f'Cannot add {combined_verticals} to {service_name} with current scope.'
9+
)
10+
11+
12+
class UnsupportedVerticalException(Exception):
13+
def __init__(self, *unsupported_verticals: Vertical, service_name: str) -> None:
14+
combined_verticals = ', '.join(unsupported_verticals)
15+
is_more_than_one_vertical = len(unsupported_verticals) > 1
16+
super().__init__(
17+
f'Cannot fetch {combined_verticals} from {service_name} because '
18+
f'{"they are" if is_more_than_one_vertical else "it is"} not supported.'
19+
)
20+
21+
22+
class UnsupportedRequestException(Exception):
23+
def __init__(self, service_name: str, message: str):
24+
super().__init__(f'Cannot fetch data from {service_name}: {message}')

src/pardner/services/base.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,11 @@
55
from requests import Response
66
from requests_oauthlib import OAuth2Session
77

8+
from pardner.exceptions import InsufficientScopeException, UnsupportedVerticalException
89
from pardner.services.utils import scope_as_set, scope_as_string
910
from pardner.verticals import Vertical
1011

1112

12-
class InsufficientScopeException(Exception):
13-
def __init__(
14-
self, unsupported_verticals: Iterable[Vertical], service_name: str
15-
) -> None:
16-
combined_verticals = ' '.join(unsupported_verticals)
17-
super().__init__(
18-
f'Cannot add {combined_verticals} to {service_name} with current scope.'
19-
)
20-
21-
22-
class UnsupportedVerticalException(Exception):
23-
def __init__(
24-
self, unsupported_verticals: Iterable[Vertical], service_name: str
25-
) -> None:
26-
combined_verticals = ' '.join(unsupported_verticals)
27-
super().__init__(
28-
f'Cannot add {combined_verticals} to {service_name} because they are not supported.'
29-
)
30-
31-
32-
class UnsupportedRequestException(Exception):
33-
def __init__(self, service_name: str, message: str):
34-
super().__init__(f'Cannot fetch data from {service_name}: {message}')
35-
36-
3713
class BaseTransferService(ABC):
3814
"""
3915
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:
11894
unsupported_verticals = [
11995
vertical
12096
for vertical in verticals
121-
if vertical not in self._supported_verticals
97+
if not self.is_vertical_supported(vertical)
12298
]
12399
if len(unsupported_verticals) > 0:
124-
raise UnsupportedVerticalException(unsupported_verticals, self.name)
100+
raise UnsupportedVerticalException(
101+
*unsupported_verticals, service_name=self.name
102+
)
125103
self._verticals = set(verticals)
126104

127105
def _get_resource(self, uri: str, params: dict[str, Any] = {}) -> Response:
@@ -192,7 +170,7 @@ def add_verticals(
192170
new_scopes = self.scope_for_verticals(new_verticals)
193171

194172
if not new_scopes.issubset(self.scope) and not should_reauth:
195-
raise InsufficientScopeException(verticals, self.name)
173+
raise InsufficientScopeException(*verticals, service_name=self.name)
196174
elif not new_scopes.issubset(self.scope):
197175
self.verticals = new_verticals | self.verticals
198176
del self._oAuth2Session.access_token
@@ -202,6 +180,16 @@ def add_verticals(
202180
self.verticals = new_verticals | self.verticals
203181
return True
204182

183+
def is_vertical_supported(self, vertical: Vertical) -> bool:
184+
"""
185+
Utility for indicating whether ``vertical`` is supported by the service or not.
186+
187+
:param vertical: the ``vertical`` from which validity is checked.
188+
189+
:returns: ``True`` if supported, ``False`` otherwise.
190+
"""
191+
return vertical in self._supported_verticals
192+
205193
def fetch_token(
206194
self,
207195
code: Optional[str] = None,
@@ -252,3 +240,28 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
252240
:returns: Scope names corresponding to `verticals`.
253241
"""
254242
pass
243+
244+
def fetch(
245+
self, vertical: Vertical, request_params: dict[str, Any] = {}, **params: Any
246+
) -> Any:
247+
"""
248+
Generic method for fetching data of a specific vertical.
249+
250+
:param vertical: the :class:`Vertical` to fetch from the
251+
service.
252+
:param request_params: additional request parameters to be sent with the HTTP
253+
request. Requires familiarity with the API of the service being used.
254+
:param params: optional keyword arguments to be passed to the methods for
255+
fetching ``vertical``.
256+
257+
:returns: the JSON response resulting from making the request.
258+
259+
:raises: :class:`UnsupportedVerticalException` if the vertical is not supported.
260+
"""
261+
if not self.is_vertical_supported(vertical):
262+
raise UnsupportedVerticalException(
263+
vertical, service_name=self._service_name
264+
)
265+
266+
method_name = f'fetch_{vertical.plural}'
267+
getattr(self, method_name)(request_params=request_params, **params)

src/pardner/services/groupme.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from requests import Response
77
from requests_oauthlib import OAuth2Session
88

9-
from pardner.services.base import BaseTransferService, UnsupportedRequestException
9+
from pardner.exceptions import UnsupportedRequestException
10+
from pardner.services import BaseTransferService
1011
from pardner.verticals import Vertical
1112

1213

@@ -113,24 +114,28 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
113114
# GroupMe does not require scope
114115
return set()
115116

116-
def fetch_user_data(self) -> Any:
117+
def fetch_user_data(self, request_params: dict[str, Any] = {}) -> Any:
117118
"""
118119
Fetches user identifiers and profile data. Also sets ``self._user_id``, which
119120
is necessary for most requests.
120121
121122
:returns: user identifiers and profile data in dictionary format.
122123
"""
123-
user_data = self._get_resource_from_path('users/me').json().get('response')
124+
user_data = (
125+
self._get_resource_from_path('users/me', request_params)
126+
.json()
127+
.get('response')
128+
)
124129
self._user_id = user_data['id']
125130
return user_data
126131

127-
def fetch_blocked_users(self) -> Any:
132+
def fetch_blocked_users(self, request_params: dict[str, Any] = {}) -> Any:
128133
"""
129134
Sends a GET request to fetch the users blocked by the authenticated user.
130135
131136
:returns: a JSON object with the result of the request.
132137
"""
133-
blocked_users = self._fetch_resource_common('blocks')
138+
blocked_users = self._fetch_resource_common('blocks', request_params)
134139

135140
if 'blocks' not in blocked_users:
136141
raise ValueError(
@@ -139,15 +144,17 @@ def fetch_blocked_users(self) -> Any:
139144

140145
return blocked_users['blocks']
141146

142-
def fetch_chat_bots(self) -> Any:
147+
def fetch_chat_bots(self, request_params: dict[str, Any] = {}) -> Any:
143148
"""
144149
Sends a GET request to fetch the chat bots created by the authenticated user.
145150
146151
:returns: a JSON object with the result of the request.
147152
"""
148-
return self._fetch_resource_common('bots')
153+
return self._fetch_resource_common('bots', request_params)
149154

150-
def fetch_conversations_direct(self, count: int = 10) -> Any:
155+
def fetch_conversations_direct(
156+
self, request_params: dict[str, Any] = {}, count: int = 10
157+
) -> Any:
151158
"""
152159
Sends a GET request to fetch the conversations the authenticated user is a part
153160
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:
159166
:returns: a JSON object with the result of the request.
160167
"""
161168
if count <= 10:
162-
return self._fetch_resource_common('chats', params={'per_page': count})
169+
return self._fetch_resource_common(
170+
'chats', params={**request_params, 'per_page': count}
171+
)
163172
raise UnsupportedRequestException(
164173
self._service_name,
165174
'can only make a request for at most 10 direct conversations at a time.',
166175
)
167176

168-
def fetch_conversations_group(self, count: int = 10) -> Any:
177+
def fetch_conversations_group(
178+
self, request_params: dict[str, Any] = {}, count: int = 10
179+
) -> Any:
169180
"""
170181
Sends a GET request to fetch the group conversations the authenticated user is
171182
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:
176187
:returns: a JSON object with the result of the request.
177188
"""
178189
if count <= 10:
179-
return self._fetch_resource_common('groups', params={'per_page': count})
190+
return self._fetch_resource_common(
191+
'groups', params={**request_params, 'per_page': count}
192+
)
180193
raise UnsupportedRequestException(
181194
self._service_name,
182195
'can only make a request for at most 10 group conversations at a time.',

src/pardner/services/strava.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
from typing import Any, Iterable, Optional, override
22

3-
from pardner.services.base import (
4-
BaseTransferService,
5-
UnsupportedRequestException,
6-
UnsupportedVerticalException,
7-
)
3+
from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException
4+
from pardner.services import BaseTransferService
85
from pardner.services.utils import scope_as_set, scope_as_string
96
from pardner.verticals import Vertical
107

@@ -59,14 +56,16 @@ def fetch_token(
5956
def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
6057
sub_scopes: set[str] = set()
6158
for vertical in verticals:
62-
if vertical not in self._supported_verticals:
63-
raise UnsupportedVerticalException([vertical], self._service_name)
59+
if not self.is_vertical_supported(vertical):
60+
raise UnsupportedVerticalException(
61+
vertical, service_name=self._service_name
62+
)
6463
if vertical == Vertical.PhysicalActivity:
6564
sub_scopes.update(['activity:read', 'profile:read_all'])
6665
return sub_scopes
6766

68-
def fetch_athlete_activities(
69-
self, count: int = 30, request_params: dict[str, Any] = {}
67+
def fetch_physical_activities(
68+
self, request_params: dict[str, Any] = {}, count: int = 30
7069
) -> list[Any]:
7170
"""
7271
Fetches and returns activities completed by the authorized user.

src/pardner/services/tumblr.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Any, Iterable, Optional, override
22

3+
from pardner.exceptions import UnsupportedRequestException
34
from pardner.services import BaseTransferService
4-
from pardner.services.base import UnsupportedRequestException
55
from pardner.verticals import Vertical
66

77

@@ -50,9 +50,9 @@ def fetch_token(
5050

5151
def fetch_feed_posts(
5252
self,
53+
request_params: dict[str, Any] = {},
5354
count: int = 20,
5455
text_only: bool = True,
55-
request_params: dict[str, Any] = {},
5656
) -> list[Any]:
5757
"""
5858
Fetches posts from Tumblr feed for user account whose token was

src/pardner/verticals/base.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
from enum import StrEnum
2+
from typing import Optional
23

34

45
class Vertical(StrEnum):
56
"""
6-
Represents the verticals, or categories of data, that are supported by `pardner`.
7+
Represents the verticals, or categories of data, that are supported by this library.
78
Not all verticals are supported by every transfer service.
89
"""
910

1011
BlockedUser = 'blocked_user'
1112
ChatBot = 'chat_bot'
12-
ConversationDirect = 'conversation_direct'
13-
ConversationGroup = 'conversation_group'
13+
ConversationDirect = 'conversation_direct', 'conversations_direct'
14+
ConversationGroup = 'conversation_group', 'conversations_group'
1415
ConversationMessage = 'conversation_message'
1516
FeedPost = 'feed_post'
16-
PhysicalActivity = 'physical_activity'
17+
PhysicalActivity = 'physical_activity', 'physical_activities'
18+
19+
plural: str
20+
21+
def __new__(cls, singular: str, plural: Optional[str] = None) -> 'Vertical':
22+
vertical_obj = str.__new__(cls, singular)
23+
vertical_obj._value_ = singular
24+
vertical_obj.plural = plural if plural else f'{singular}s'
25+
return vertical_obj

tests/test_transfer_services/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@
1212
# FIXTURES
1313

1414

15+
@pytest.fixture
16+
def mock_nested_dict(mocker):
17+
def nested_dict():
18+
mock_dict = mocker.MagicMock(spec=dict, name='mock_nested_dict')
19+
mock_dict.__getitem__.side_effect = lambda _: nested_dict()
20+
mock_dict.get.side_effect = lambda _, default=None: nested_dict()
21+
mock_dict.__contains__.side_effect = lambda _: True
22+
return mock_dict
23+
24+
return nested_dict()
25+
26+
1527
@pytest.fixture
1628
def mock_oauth2_session_request(mocker):
1729
return mocker.patch('requests_oauthlib.OAuth2Session.request')
@@ -76,6 +88,23 @@ def mock_oauth2_session_get_bad_response(mocker, mock_oauth2_bad_response):
7688
return oauth2_session_get
7789

7890

91+
@pytest.fixture
92+
def mock_oauth2_good_response(mocker, mock_nested_dict):
93+
mock_response = mocker.create_autospec(Response)
94+
mock_response.ok = True
95+
mock_response.status_code = 200
96+
mock_response.url = 'fake url'
97+
mock_response.json.return_value = mock_nested_dict
98+
return mock_response
99+
100+
101+
@pytest.fixture
102+
def mock_oauth2_session_get_good_response(mocker, mock_oauth2_good_response):
103+
oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True)
104+
oauth2_session_get.return_value = mock_oauth2_good_response
105+
return oauth2_session_get
106+
107+
79108
# HELPERS
80109

81110

tests/test_transfer_services/test_groupme.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from pardner.services.base import UnsupportedRequestException
3+
from pardner.exceptions import UnsupportedRequestException
44
from tests.test_transfer_services.conftest import mock_oauth2_session_get
55

66
USER_ID = 'fake_user_id'

0 commit comments

Comments
 (0)