diff --git a/analytix/__init__.py b/analytix/__init__.py index 10e286e..83bfeb5 100644 --- a/analytix/__init__.py +++ b/analytix/__init__.py @@ -37,7 +37,7 @@ ) __productname__ = "analytix" -__version__ = "3.0.0" +__version__ = "3.0.1" __description__ = "A simple yet powerful wrapper for the YouTube Analytics API." __url__ = "https://github.com/parafoxia/analytix" __docs__ = "https://analytix.readthedocs.io" diff --git a/analytix/analytics.py b/analytix/analytics.py index d543bdd..1191ac5 100644 --- a/analytix/analytics.py +++ b/analytix/analytics.py @@ -137,7 +137,10 @@ def _retrieve_tokens(self) -> Tokens: data, headers = oauth.access_data_and_headers(code, self.secrets) r = self._session.post(self.secrets.token_uri, data=data, headers=headers) - r.raise_for_status() + if r.is_error: + error = r.json()["error"] + raise errors.AuthenticationError(error["code"], error["message"]) + return Tokens.from_data(r.json()) def needs_refresh(self) -> bool: @@ -174,8 +177,12 @@ def refresh_access_token(self) -> None: ) r = self._session.post(self.secrets.token_uri, data=data, headers=headers) - r.raise_for_status() - self._tokens.update(r.json()) + if not r.is_error: + self._tokens.update(r.json()) + else: + log.info("Your refresh token has expired; you will need to reauthorise") + self._tokens = self._retrieve_tokens() + self._tokens.write(self._token_path) def authorise( # nosec B107 diff --git a/analytix/async_analytics.py b/analytix/async_analytics.py index 9c9f065..6dd9e4b 100644 --- a/analytix/async_analytics.py +++ b/analytix/async_analytics.py @@ -138,7 +138,10 @@ async def _retrieve_tokens(self) -> Tokens: data, headers = oauth.access_data_and_headers(code, self.secrets) r = await self._session.post(self.secrets.token_uri, data=data, headers=headers) - r.raise_for_status() + if r.is_error: + error = r.json()["error"] + raise errors.AuthenticationError(error["code"], error["message"]) + return Tokens.from_data(r.json()) async def needs_refresh(self) -> bool: @@ -177,8 +180,12 @@ async def refresh_access_token(self) -> None: ) r = await self._session.post(self.secrets.token_uri, data=data, headers=headers) - r.raise_for_status() - self._tokens.update(r.json()) + if not r.is_error: + self._tokens.update(r.json()) + else: + log.info("Your refresh token has expired; you will need to reauthorise") + self._tokens = await self._retrieve_tokens() + await self._tokens.awrite(self._token_path) async def authorise( # nosec B107 diff --git a/analytix/errors.py b/analytix/errors.py index ad0a662..9a40da5 100644 --- a/analytix/errors.py +++ b/analytix/errors.py @@ -53,6 +53,14 @@ def __init__(self, code: str, message: str) -> None: super().__init__(f"API returned {code}: {message}") +class AuthenticationError(AnalytixError): + """Exception thrown when something goes wrong during the OAuth + authentication process.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(f"Authentication failure ({code}): {message}") + + class DataFrameConversionError(AnalytixError): """Exception thrown when converting a report to a DataFrame fails.""" diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 1d8bdf4..28eec2d 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -37,7 +37,7 @@ import pytest from analytix import Analytics -from analytix.errors import APIError +from analytix.errors import APIError, AuthenticationError from analytix.report_types import TimeBasedActivity from analytix.secrets import Secrets from analytix.tokens import Tokens @@ -182,6 +182,26 @@ def test_refresh_access_tokens_with_no_tokens(client, caplog): assert "There are no tokens to refresh" in caplog.text +def test_retrieve_token_refresh_token_failure(client, tokens): + with mock.patch.object(httpx.Client, "post") as mock_post: + mock_post.return_value = httpx.Response( + status_code=403, + request=mock.Mock(), + json={"error": {"code": 403, "message": "You suck"}}, + ) + + client._tokens = tokens + + with mock.patch.object(builtins, "input") as mock_input: + mock_input.return_value = "lol ecks dee" + + # If we get here, refreshing failed, and we can also test + # retrieval failure. + with pytest.raises(AuthenticationError) as exc: + client.refresh_access_token() + assert str(exc.value) == "Authentication failure (403): You suck" + + def test_needs_refresh_with_valid(client): with mock.patch.object(httpx.Client, "get") as mock_get: with mock.patch.object(Tokens, "write") as mock_write: diff --git a/tests/test_async_analytics.py b/tests/test_async_analytics.py index 3264e67..4f7d610 100644 --- a/tests/test_async_analytics.py +++ b/tests/test_async_analytics.py @@ -41,7 +41,7 @@ import pytest_asyncio from analytix import AsyncAnalytics -from analytix.errors import APIError +from analytix.errors import APIError, AuthenticationError from analytix.report_types import TimeBasedActivity from analytix.secrets import Secrets from analytix.tokens import Tokens @@ -185,6 +185,26 @@ async def test_refresh_access_tokens_with_no_tokens(client, caplog): assert "There are no tokens to refresh" in caplog.text +async def test_retrieve_token_refresh_token_failure(client, tokens): + with mock.patch.object(httpx.AsyncClient, "post") as mock_post: + mock_post.return_value = httpx.Response( + status_code=403, + request=mock.Mock(), + json={"error": {"code": 403, "message": "You suck"}}, + ) + + client._tokens = tokens + + with mock.patch.object(builtins, "input") as mock_input: + mock_input.return_value = "lol ecks dee" + + # If we get here, refreshing failed, and we can also test + # retrieval failure. + with pytest.raises(AuthenticationError) as exc: + await client.refresh_access_token() + assert str(exc.value) == "Authentication failure (403): You suck" + + async def test_needs_refresh_with_valid(client): with mock.patch.object(httpx.AsyncClient, "get") as mock_get: mock_get.return_value = httpx.Response(