Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/pardner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from pardner import services as services
from pardner import verticals as verticals
1 change: 1 addition & 0 deletions src/pardner/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
from pardner.services.base import (
UnsupportedVerticalException as UnsupportedVerticalException,
)
from pardner.services.groupme import GroupMeTransferService as GroupMeTransferService
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have to do 'as' if you're not renaming it ... right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I set up the linter to flag unused imports, so re-exporting with the same name is communicating to the linter that it's being done intentionally (in this case, to publicly expose the class!). More info: https://docs.astral.sh/ruff/rules/unused-import/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you, I did not realize that.

from pardner.services.strava import StravaTransferService as StravaTransferService
from pardner.services.tumblr import TumblrTransferService as TumblrTransferService
41 changes: 38 additions & 3 deletions src/pardner/services/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Iterable, Optional
from urllib.parse import urljoin

from requests import Response
from requests_oauthlib import OAuth2Session
Expand Down Expand Up @@ -41,7 +42,7 @@ class BaseTransferService(ABC):

_authorization_url: str
_base_url: str
_client_secret: str
_client_secret: str | None
_oAuth2Session: OAuth2Session
_service_name: str
_supported_verticals: set[Vertical] = set()
Expand All @@ -52,9 +53,9 @@ def __init__(
self,
service_name: str,
client_id: str,
client_secret: str,
redirect_uri: str,
supported_verticals: set[Vertical],
client_secret: Optional[str] = None,
state: Optional[str] = None,
verticals: set[Vertical] = set(),
) -> None:
Expand All @@ -64,9 +65,9 @@ def __init__(

:param service_name: Name of the service for which the transfer is being built.
:param client_id: Client identifier given by the OAuth provider upon registration.
:param client_secret: The `client_secret` paired to the `client_id`.
:param redirect_uri: The registered callback URI.
:param supported_verticals: The `Vertical`s that can be fetched on the service.
:param client_secret: The `client_secret` paired to the `client_id`.
:param state: State string used to prevent CSRF and identify flow.
:param verticals: The `Vertical`s for which the transfer service has
appropriate scope to fetch.
Expand Down Expand Up @@ -137,6 +138,40 @@ def _get_resource(self, uri: str, params: dict[str, Any] = {}) -> Response:
response.raise_for_status()
return response

def _build_resource_url(self, path_suffix: str, base: Optional[str] = None) -> str:
"""
Constructs the resource URL from a domain and path suffix.

:param path_suffix: the path to append to ``base``.
:param base: the prefix of the resource URL. If not given, defaults to
``self._base_url``.

:returns: the complete resource URL.
"""
if not base:
base = self._base_url
if not base.endswith('/'):
base += '/'

if path_suffix.startswith('/'):
path_suffix = path_suffix[1:]

return urljoin(base, path_suffix)

def _get_resource_from_path(
self, path_suffix: str, params: dict[str, Any] = {}
) -> Response:
"""
Sends a GET request to the endpoint URL built using ``endpoint_path``.

:param path_suffix: the path of the endpoint being accessed.
:param params: the extra parameters to be send with the request, optionally.

:returns: The :class:`requests.Response` object obtained from making the request.
"""
resource_url = self._build_resource_url(path_suffix)
return self._get_resource(resource_url, params)

def add_verticals(
self, verticals: Iterable[Vertical], should_reauth: bool = False
) -> bool:
Expand Down
183 changes: 183 additions & 0 deletions src/pardner/services/groupme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import json
from typing import Any, Iterable, Optional, override
from urllib.parse import parse_qs, urlparse

from oauthlib.oauth2 import MobileApplicationClient
from requests import Response
from requests_oauthlib import OAuth2Session

from pardner.services.base import BaseTransferService, UnsupportedRequestException
from pardner.verticals import Vertical


class GroupMeTransferService(BaseTransferService):
"""
Class responsible for obtaining end-user authorization to make requests to GroupMe's
API.
See API documentation: https://dev.groupme.com/docs/v3

Note that GroupMe authorizes user access via the implicit OAuth flow as of August
2025. This means the access token is obtained after the first request to GroupMe's
servers. This is less secure than the traditional OAuth 2.0 flow.
"""

_authorization_url = 'https://oauth.groupme.com/oauth/authorize'
_base_url = 'https://api.groupme.com/v3/'
_token_url = 'https://oauth.groupme.com/oauth/authorize'
_user_id: str | None

def __init__(
self, client_id: str, redirect_uri: str, verticals: set[Vertical] = set()
) -> None:
super().__init__(
service_name='GroupMe',
client_id=client_id,
redirect_uri=redirect_uri,
supported_verticals={
Vertical.BlockedUser,
Vertical.ChatBot,
Vertical.ConversationDirect,
Vertical.ConversationGroup,
},
verticals=verticals,
)
implicit_grant_application_client = MobileApplicationClient(client_id=client_id)
self._oAuth2Session = OAuth2Session(
client=implicit_grant_application_client, redirect_uri=redirect_uri
)

@override
def _get_resource_from_path(
self, path_suffix: str, params: dict[str, Any] = {}
) -> Response:
access_token = self._oAuth2Session.token.get('access_token')
if not access_token:
raise UnsupportedRequestException(
self._service_name,
'Must send token as a parameter for GET requests. Use fetch_token(...) '
'to automatically fetch and send the token.',
)
return super()._get_resource_from_path(
path_suffix, params={'token': access_token, **params}
)

def _fetch_resource_common(
self, path_suffix: str, params: dict[str, Any] = {}
) -> Any:
"""
Helper method for fetching data that follows a common format with common
parameters.

:param path_suffix: the part of the path that identifies a GroupMe API endpoint.
:param params: an optional dictionary of parameters to pass with the GET request
to GroupMe's API.

:returns: a JSON object with the result of the request.
"""
if not self._user_id:
self.fetch_user_data()

return (
self._get_resource_from_path(
path_suffix, params={'user': self._user_id, **params}
)
.json()
.get('response')
)

@override
def fetch_token(
self,
code: Optional[str] = None,
authorization_response: Optional[str] = None,
include_client_id: bool = True,
) -> dict[str, Any]:
if not authorization_response:
raise ValueError(
'GroupMe requires an authorization response URL instead of code.'
)
url_query = urlparse(authorization_response).query
url_query_params = parse_qs(url_query)

access_token_list = url_query_params.get('access_token')
if not access_token_list:
raise ValueError(
f'Could not fetch access token using the {authorization_response}.'
)

self._oAuth2Session.token['access_token'] = access_token_list[0]
return self._oAuth2Session.token if self._oAuth2Session.token else {}

@override
def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
# GroupMe does not require scope
return set()

def fetch_user_data(self) -> 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')
self._user_id = user_data['id']
return user_data

def fetch_blocked_users(self) -> 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')

if 'blocks' not in blocked_users:
raise ValueError(
f'Unexpected response format: {json.dumps(blocked_users, indent=2)}'
)

return blocked_users['blocks']

def fetch_chat_bots(self) -> 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')

def fetch_conversations_direct(self, 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
include metadata associated with the conversation, but no more than one message
from the conversation.

:param count: the number of conversations to fetch. Defaults to 10.

:returns: a JSON object with the result of the request.
"""
if count <= 10:
return self._fetch_resource_common('chats', 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:
"""
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,
but no more than one message from the conversation.

:param count: the number of conversations to fetch. Defaults to 10.

:returns: a JSON object with the result of the request.
"""
if count <= 10:
return self._fetch_resource_common('groups', params={'per_page': count})
raise UnsupportedRequestException(
self._service_name,
'can only make a request for at most 10 group conversations at a time.',
)
10 changes: 3 additions & 7 deletions src/pardner/services/strava.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any, Iterable, Optional, override
from urllib.parse import urljoin

from pardner.services.base import (
BaseTransferService,
Expand All @@ -18,7 +17,7 @@ class StravaTransferService(BaseTransferService):
"""

_authorization_url = 'https://www.strava.com/oauth/authorize'
_base_url = 'https://www.strava.com/'
_base_url = 'https://www.strava.com/api/v3/'
_token_url = 'https://www.strava.com/oauth/token'

def __init__(
Expand Down Expand Up @@ -85,12 +84,9 @@ def fetch_athlete_activities(
"""
max_count = 30
if count <= max_count:
athlete_activities_uri = urljoin(
self._base_url, '/api/v3/athlete/activities'
)
return list(
self._get_resource(
athlete_activities_uri, params={'per_page': count, **request_params}
self._get_resource_from_path(
'athlete/activities', params={'per_page': count, **request_params}
).json()
)
raise UnsupportedRequestException(
Expand Down
8 changes: 3 additions & 5 deletions src/pardner/services/tumblr.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any, Iterable, Optional, override
from urllib.parse import urljoin

from pardner.services import BaseTransferService
from pardner.services.base import UnsupportedRequestException
Expand All @@ -14,7 +13,7 @@ class TumblrTransferService(BaseTransferService):
"""

_authorization_url = 'https://www.tumblr.com/oauth2/authorize'
_base_url = 'https://api.tumblr.com/'
_base_url = 'https://api.tumblr.com/v2/'
_token_url = 'https://api.tumblr.com/v2/oauth2/token'

def __init__(
Expand Down Expand Up @@ -71,10 +70,9 @@ def fetch_feed_posts(
:raises: :class:`UnsupportedRequestException` if the request is unable to be
made.
"""
dashboard_uri = urljoin(self._base_url, '/v2/user/dashboard')
if count <= 20:
dashboard_response = self._get_resource(
dashboard_uri,
dashboard_response = self._get_resource_from_path(
'user/dashboard',
{
'limit': count,
'npf': True,
Expand Down
5 changes: 5 additions & 0 deletions src/pardner/verticals/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@ class Vertical(StrEnum):
Not all verticals are supported by every transfer service.
"""

BlockedUser = 'blocked_user'
ChatBot = 'chat_bot'
ConversationDirect = 'conversation_direct'
ConversationGroup = 'conversation_group'
ConversationMessage = 'conversation_message'
FeedPost = 'feed_post'
PhysicalActivity = 'physical_activity'
25 changes: 21 additions & 4 deletions tests/test_transfer_services/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
from requests import Response
from requests_oauthlib import OAuth2Session

from pardner.services.strava import StravaTransferService
from pardner.services.tumblr import TumblrTransferService
from pardner.verticals.base import Vertical
from pardner.services import (
GroupMeTransferService,
StravaTransferService,
TumblrTransferService,
)
from pardner.verticals import Vertical

# FIXTURES

Expand Down Expand Up @@ -41,6 +44,20 @@ def mock_strava_transfer_service(verticals=[Vertical.PhysicalActivity]):
)


@pytest.fixture
def mock_groupme_transfer_service(
verticals=[Vertical.BlockedUser, Vertical.ConversationDirect],
):
groupme = GroupMeTransferService(
client_id='fake_client_id',
redirect_uri='https://redirect_uri',
verticals=verticals,
)
groupme._oAuth2Session.token = {'access_token': 'fake_token'}
groupme._user_id = None
return groupme


@pytest.fixture
def mock_oauth2_bad_response(mocker):
mock_response = mocker.create_autospec(Response)
Expand All @@ -62,7 +79,7 @@ def mock_oauth2_session_get_bad_response(mocker, mock_oauth2_bad_response):
# HELPERS


def mock_oauth2_session_get(mocker, response_object):
def mock_oauth2_session_get(mocker, response_object=None):
oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True)
oauth2_session_get.return_value = response_object
return oauth2_session_get
Loading