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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dev = [
"mypy>=1.17.0",
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
"pytest-mock>=3.14.1",
"ruff>=0.12.3",
"types-requests-oauthlib>=2.0.0.20250516",
]
Expand Down
9 changes: 7 additions & 2 deletions src/pardner/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from pardner.services.base import BaseTransferService as BaseTransferService
from pardner.services.base import InsufficientScopeException as InsufficientScopeException
from pardner.services.base import UnsupportedVerticalException as UnsupportedVerticalException
from pardner.services.base import (
InsufficientScopeException as InsufficientScopeException,
)
from pardner.services.base import (
UnsupportedVerticalException as UnsupportedVerticalException,
)
from pardner.services.tumblr import TumblrTransferService as TumblrTransferService
37 changes: 35 additions & 2 deletions src/pardner/services/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from abc import ABC, abstractmethod
from typing import Iterable
from typing import Any, Iterable, Optional

from requests_oauthlib import OAuth2Session

from pardner.verticals.base import Vertical
from pardner.verticals import Vertical


class InsufficientScopeException(Exception):
Expand Down Expand Up @@ -32,10 +32,12 @@ class BaseTransferService(ABC):
OAuth 2.0 and data transfers.
"""

_authorization_url: str
_client_secret: str
_oAuth2Session: OAuth2Session
_service_name: str
_supported_verticals: set[Vertical] = set()
_token_url: str
_verticals: set[Vertical] = set()

def __init__(
Expand Down Expand Up @@ -129,6 +131,37 @@ def add_verticals(
self.verticals = new_verticals | self.verticals
return True

@abstractmethod
def fetch_token(
self, code: Optional[str] = None, authorization_response: Optional[str] = None
) -> dict[str, Any]:
"""
Once the end-user authorizes the application to access their data, the
resource server sends a request to `redirect_uri` with the authorization code as
a parameter. Using this authorization code, this method makes a request to the
resource server to obtain the access token.

One of either `code` or `authorization_response` must not be None.

:param code: the code obtained from parsing the callback URL which the end-user's
browser redirected to.
:param authorization_response: the URL (with parameters) the end-user's browser
redirected to after authorization.

:returns: the authorization URL and state, respectively.
"""
pass

@abstractmethod
def authorization_url(self) -> tuple[str, str]:
"""
Builds the authorization URL and state. Once the end-user (i.e., resource owner)
navigates to the authorization URL they can begin the authorization flow.

:returns: the authorization URL and state, respectively.
"""
pass

@abstractmethod
def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
"""
Expand Down
50 changes: 50 additions & 0 deletions src/pardner/services/tumblr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import Any, Iterable, Optional

from pardner.services import BaseTransferService
from pardner.verticals import Vertical


class TumblrTransferService(BaseTransferService):
"""
Class responsible for obtaining end-user authorization to make requests to
Tumblr's API.
See API documentation: https://www.tumblr.com/docs/en/api/v2
"""

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

def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str,
verticals: set[Vertical] = set(),
) -> None:
super().__init__(
service_name='Tumblr',
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
supported_verticals={Vertical.FeedPost},
verticals=verticals,
)

def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
# Tumblr only needs 'base' for read access requests
return {'base'}

def authorization_url(self) -> tuple[str, str]:
return self._oAuth2Session.authorization_url(self._authorization_url)

def fetch_token(
self, code: Optional[str] = None, authorization_response: Optional[str] = None
) -> dict[str, Any]:
# Requires client_id
return self._oAuth2Session.fetch_token(
token_url=self._token_url,
code=code,
authorization_response=authorization_response,
include_client_id=True,
client_secret=self._client_secret,
)
2 changes: 1 addition & 1 deletion src/pardner/verticals/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ class Vertical(StrEnum):
Not all verticals are supported by every transfer service.
"""

pass
FeedPost = 'feed_post'
23 changes: 12 additions & 11 deletions tests/test_transfer_services/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ def __init__(self, supported_verticals, verticals):
self._supported_verticals = set(supported_verticals)
self._verticals = set(verticals)

def authorization_url(self):
pass

def fetch_token(self, code=None, authorization_response=None):
pass

def scope_for_verticals(self, verticals):
return sample_scope


@pytest.fixture
def mock_vertical(monkeypatch):
Vertical.FAKE_VERTICAL = 'random_vertical'
Vertical.NEW_VERTICAL = 'new_vertical'
Vertical.NEW_VERTICAL_EXTRA_SCOPE = 'new_vertical_unsupported'

Expand All @@ -34,23 +39,19 @@ def blank_transfer_service(monkeypatch):

def test_add_verticals_raises_exception(mock_vertical, blank_transfer_service):
with pytest.raises(InsufficientScopeException):
blank_transfer_service.add_verticals([Vertical.FAKE_VERTICAL])
blank_transfer_service.add_verticals([Vertical.FeedPost])


def test_set_verticals_raises_exception(mock_vertical, blank_transfer_service):
with pytest.raises(UnsupportedVerticalException):
blank_transfer_service.verticals = [Vertical.FAKE_VERTICAL]
blank_transfer_service.verticals = [Vertical.FeedPost]


@pytest.fixture
def transfer_service(mock_vertical):
mock_transfer_service = FakeTransferService(
[
Vertical.FAKE_VERTICAL,
Vertical.NEW_VERTICAL,
Vertical.NEW_VERTICAL_EXTRA_SCOPE,
],
[Vertical.FAKE_VERTICAL],
[Vertical.FeedPost, Vertical.NEW_VERTICAL, Vertical.NEW_VERTICAL_EXTRA_SCOPE],
[Vertical.FeedPost],
)
mock_transfer_service.scope = sample_scope
return mock_transfer_service
Expand All @@ -63,7 +64,7 @@ def test_set_supported_verticals(mock_vertical, transfer_service):

def test_add_supported_verticals(mock_vertical, transfer_service):
assert transfer_service.add_verticals([Vertical.NEW_VERTICAL])
assert transfer_service.verticals == {Vertical.FAKE_VERTICAL, Vertical.NEW_VERTICAL}
assert transfer_service.verticals == {Vertical.FeedPost, Vertical.NEW_VERTICAL}


def test_add_unsupported_vertical_new_scope_required(
Expand All @@ -84,6 +85,6 @@ def _mock_scope_for_verticals(verticals):
assert not transfer_service._oAuth2Session.access_token
assert transfer_service.scope == {'fake', 'scope', 'new_scope'}
assert transfer_service.verticals == {
Vertical.FAKE_VERTICAL,
Vertical.FeedPost,
Vertical.NEW_VERTICAL_EXTRA_SCOPE,
}
51 changes: 51 additions & 0 deletions tests/test_transfer_services/test_tumblr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from urllib import parse

import pytest

from pardner.services import TumblrTransferService
from pardner.verticals import Vertical

sample_scope = {'fake', 'scope'}


@pytest.fixture
def mock_tumblr_transfer_service(monkeypatch, verticals=[Vertical.FeedPost]):
return TumblrTransferService(
'fake_client_id', 'fake_client_secret', 'https://redirect_uri', verticals
)


@pytest.mark.parametrize(
['verticals', 'expected_scope'], [([], {'base'}), ([Vertical.FeedPost, {'base'}])]
)
def test_scope_for_vertical(mock_tumblr_transfer_service, verticals, expected_scope):
assert mock_tumblr_transfer_service.scope_for_verticals(verticals) == expected_scope


def test_authorization_url(mock_tumblr_transfer_service):
auth_url, state = mock_tumblr_transfer_service.authorization_url()

auth_url_query = parse.urlsplit(auth_url).query
auth_url_params = dict(parse.parse_qsl(auth_url_query))

assert 'client_id' in auth_url_params
assert auth_url_params['client_id'] == 'fake_client_id'
assert 'redirect_uri' in auth_url_params
assert auth_url_params['redirect_uri'] == 'https://redirect_uri'
assert 'state' in auth_url_params
assert auth_url_params['state'] == state


def test_fetch_token_raises_error(mock_tumblr_transfer_service):
with pytest.raises(ValueError):
mock_tumblr_transfer_service.fetch_token()


def test_fetch_token(mocker, mock_tumblr_transfer_service):
mock_oauth2session_request = mocker.patch('requests_oauthlib.OAuth2Session.request')
mock_client_parse_request_body_response = mocker.patch(
'oauthlib.oauth2.rfc6749.clients.WebApplicationClient.parse_request_body_response'
)
mock_tumblr_transfer_service.fetch_token(code='123code123')
mock_oauth2session_request.assert_called_once()
mock_client_parse_request_body_response.assert_called_once()
14 changes: 14 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.