diff --git a/pyproject.toml b/pyproject.toml index d01205c..b74466f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/pardner/services/__init__.py b/src/pardner/services/__init__.py index d8a720e..09a3c85 100644 --- a/src/pardner/services/__init__.py +++ b/src/pardner/services/__init__.py @@ -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 diff --git a/src/pardner/services/base.py b/src/pardner/services/base.py index f39b065..4a3db36 100644 --- a/src/pardner/services/base.py +++ b/src/pardner/services/base.py @@ -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): @@ -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__( @@ -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]: """ diff --git a/src/pardner/services/tumblr.py b/src/pardner/services/tumblr.py new file mode 100644 index 0000000..8cb48bb --- /dev/null +++ b/src/pardner/services/tumblr.py @@ -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, + ) diff --git a/src/pardner/verticals/base.py b/src/pardner/verticals/base.py index 3d72cd6..101a45f 100644 --- a/src/pardner/verticals/base.py +++ b/src/pardner/verticals/base.py @@ -7,4 +7,4 @@ class Vertical(StrEnum): Not all verticals are supported by every transfer service. """ - pass + FeedPost = 'feed_post' diff --git a/tests/test_transfer_services/test_base.py b/tests/test_transfer_services/test_base.py index f4e3601..741e579 100644 --- a/tests/test_transfer_services/test_base.py +++ b/tests/test_transfer_services/test_base.py @@ -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' @@ -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 @@ -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( @@ -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, } diff --git a/tests/test_transfer_services/test_tumblr.py b/tests/test_transfer_services/test_tumblr.py new file mode 100644 index 0000000..cdea3cb --- /dev/null +++ b/tests/test_transfer_services/test_tumblr.py @@ -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() diff --git a/uv.lock b/uv.lock index c18378c..9ed3cc2 100644 --- a/uv.lock +++ b/uv.lock @@ -218,6 +218,7 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "ruff" }, { name = "types-requests-oauthlib" }, ] @@ -233,6 +234,7 @@ dev = [ { name = "mypy", specifier = ">=1.17.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-cov", specifier = ">=6.2.1" }, + { name = "pytest-mock", specifier = ">=3.14.1" }, { name = "ruff", specifier = ">=0.12.3" }, { name = "types-requests-oauthlib", specifier = ">=2.0.0.20250516" }, ] @@ -294,6 +296,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + [[package]] name = "requests" version = "2.32.4"