Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make number of retries configurable #1

Merged
merged 11 commits into from
Mar 16, 2024
23 changes: 18 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
name: Tests

on: [push, pull_request, workflow_dispatch]
on:
push:
branches:
- main
paths:
- 'src/**'
pull_request:
branches:
- main
paths:
- 'src/**'
workflow_dispatch:

defaults:
run:
Expand All @@ -10,7 +21,6 @@ jobs:
test:
name: Tests
strategy:
max-parallel: 1
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
Expand Down Expand Up @@ -46,13 +56,16 @@ jobs:
run: poetry install

- name: Run tests
run: |
poetry run pytest
run: poetry run pytest

- name: Build
run: poetry build

- name: Get version
id: version
run: echo "version=$(poetry run python -c 'import pyanilist; print(pyanilist.__version__)')" >> $GITHUB_OUTPUT

- uses: actions/upload-artifact@v4
with:
name: pyanilist-${{ matrix.python-version }}-${{ matrix.os }}
name: pyanilist-${{ steps.version.outputs.version }}-${{ matrix.python-version }}-${{ matrix.os }}
path: "dist/*"
26 changes: 16 additions & 10 deletions src/pyanilist/_clients/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import httpx
from pydantic import validate_call
from tenacity import retry, stop_after_attempt, wait_incrementing
from tenacity import AsyncRetrying, stop_after_attempt, wait_incrementing

from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType
from .._models import Media
Expand All @@ -14,26 +14,26 @@


class AsyncAnilist:
def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_async_client_kwargs: Any) -> None:
def __init__(
self, api_url: str = "https://graphql.anilist.co", retries: int = 5, **httpx_async_client_kwargs: Any
) -> None:
"""
Async Anilist API client.

Parameters
----------
api_url : str, optional
The URL of the Anilist API. Default is "https://graphql.anilist.co".
retries : int, optional
Number of times to retry a failed request before raising an error. Default is 5.
httpx_async_client_kwargs : Any, optional
Keyword arguments to pass to the internal [httpx.AsyncClient()](https://www.python-httpx.org/api/#asyncclient)
used to make the POST request.
"""
self.api_url = api_url
self.retries = retries
self.httpx_async_client_kwargs = httpx_async_client_kwargs

@retry(
stop=stop_after_attempt(5),
wait=wait_incrementing(start=0, increment=1),
reraise=True,
)
async def _post_request(
self,
id: AnilistID | None = None,
Expand Down Expand Up @@ -92,9 +92,15 @@ async def _post_request(
"variables": {key: value for key, value in query_variables.items() if value is not None},
}

async with httpx.AsyncClient(**self.httpx_async_client_kwargs) as client:
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self.retries),
wait=wait_incrementing(start=0, increment=1),
reraise=True,
):
with attempt:
async with httpx.AsyncClient(**self.httpx_async_client_kwargs) as client:
response = await client.post(self.api_url, json=payload)
response.raise_for_status()

return response

Expand Down
24 changes: 15 additions & 9 deletions src/pyanilist/_clients/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import httpx
from pydantic import validate_call
from tenacity import retry, stop_after_attempt, wait_incrementing
from tenacity import Retrying, stop_after_attempt, wait_incrementing

from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType
from .._models import Media
Expand All @@ -14,26 +14,26 @@


class Anilist:
def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_client_kwargs: Any) -> None:
def __init__(
self, api_url: str = "https://graphql.anilist.co", retries: int = 5, **httpx_client_kwargs: Any
) -> None:
"""
Anilist API client.

Parameters
----------
api_url : str, optional
The URL of the Anilist API. Default is "https://graphql.anilist.co".
retries : int, optional
Number of times to retry a failed request before raising an error. Default is 5.
httpx_client_kwargs : Any, optional
Keyword arguments to pass to the internal [httpx.Client()](https://www.python-httpx.org/api/#client)
used to make the POST request.
"""
self.api_url = api_url
self.retries = retries
self.httpx_client_kwargs = httpx_client_kwargs

@retry(
stop=stop_after_attempt(5),
wait=wait_incrementing(start=0, increment=1),
reraise=True,
)
def _post_request(
self,
id: AnilistID | None = None,
Expand Down Expand Up @@ -92,8 +92,14 @@ def _post_request(
"variables": {key: value for key, value in query_variables.items() if value is not None},
}

with httpx.Client(**self.httpx_client_kwargs) as client:
response = client.post(self.api_url, json=payload).raise_for_status()
for attempt in Retrying(
stop=stop_after_attempt(self.retries),
wait=wait_incrementing(start=0, increment=1),
reraise=True,
):
with attempt:
with httpx.Client(**self.httpx_client_kwargs) as client:
response = client.post(self.api_url, json=payload).raise_for_status()

return response

Expand Down
23 changes: 0 additions & 23 deletions tests/test_anilist.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import pytest
from pyanilist import (
Anilist,
HTTPStatusError,
HttpUrl,
MediaFormat,
MediaSeason,
MediaSource,
MediaStatus,
MediaType,
ValidationError,
)


Expand Down Expand Up @@ -57,30 +54,10 @@ def test_anilist_with_all_constraints() -> None:
assert media.site_url == HttpUrl("https://anilist.co/anime/21459")


def test_anilist_title_doesnt_exist() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
Anilist().search("Title does not exist", type=MediaType.MANGA)


def test_anilist_bad_search_combo() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
Anilist().search("Attack on titan", season_year=1999)


def test_anilist_wrong_input_types() -> None:
with pytest.raises(ValidationError):
Anilist().search(123456789, season_year="hello", type=True) # type: ignore


def test_anilist_id() -> None:
media = Anilist().get(16498)
assert media.title.romaji == "Shingeki no Kyojin"
assert media.start_date.year == 2013
assert media.source == MediaSource.MANGA
assert media.type == MediaType.ANIME
assert media.site_url == HttpUrl("https://anilist.co/anime/16498")


def test_anilist_bad_id() -> None:
with pytest.raises(HTTPStatusError, match="400 Bad Request"):
Anilist().get(9999999999)
23 changes: 0 additions & 23 deletions tests/test_async_anilist.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import pytest
from pyanilist import (
AsyncAnilist,
HTTPStatusError,
HttpUrl,
MediaFormat,
MediaSeason,
MediaSource,
MediaStatus,
MediaType,
ValidationError,
)


Expand Down Expand Up @@ -57,30 +54,10 @@ async def test_anilist_with_all_constraints() -> None:
assert media.site_url == HttpUrl("https://anilist.co/anime/21459")


async def test_anilist_title_doesnt_exist() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
await AsyncAnilist().search("Title does not exist", type=MediaType.MANGA)


async def test_anilist_bad_search_combo() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
await AsyncAnilist().search("Attack on titan", season_year=1999)


async def test_anilist_wrong_input_types() -> None:
with pytest.raises(ValidationError):
await AsyncAnilist().search(123456789, season_year="hello", type=True) # type: ignore


async def test_anilist_id() -> None:
media = await AsyncAnilist().get(16498)
assert media.title.romaji == "Shingeki no Kyojin"
assert media.start_date.year == 2013
assert media.source == MediaSource.MANGA
assert media.type == MediaType.ANIME
assert media.site_url == HttpUrl("https://anilist.co/anime/16498")


async def test_anilist_bad_id() -> None:
with pytest.raises(HTTPStatusError, match="400 Bad Request"):
await AsyncAnilist().get(9999999999)
29 changes: 29 additions & 0 deletions tests/test_async_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest
from pyanilist import (
AsyncAnilist,
HTTPStatusError,
MediaType,
ValidationError,
)

anilist = AsyncAnilist(retries=1)


async def test_anilist_title_doesnt_exist() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
await anilist.search("Title does not exist", type=MediaType.MANGA)


async def test_anilist_bad_search_combo() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
await anilist.search("Attack on titan", season_year=1999)


async def test_anilist_wrong_input_types() -> None:
with pytest.raises(ValidationError):
await anilist.search(123456789, season_year="hello", type=True) # type: ignore


async def test_anilist_bad_id() -> None:
with pytest.raises(HTTPStatusError, match="400 Bad Request"):
await anilist.get(9999999999)
29 changes: 29 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest
from pyanilist import (
Anilist,
HTTPStatusError,
MediaType,
ValidationError,
)

anilist = Anilist(retries=1)


def test_anilist_title_doesnt_exist() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
anilist.search("Title does not exist", type=MediaType.MANGA)


def test_anilist_bad_search_combo() -> None:
with pytest.raises(HTTPStatusError, match="Not Found."):
anilist.search("Attack on titan", season_year=1999)


def test_anilist_wrong_input_types() -> None:
with pytest.raises(ValidationError):
anilist.search(123456789, season_year="hello", type=True) # type: ignore


def test_anilist_bad_id() -> None:
with pytest.raises(HTTPStatusError, match="400 Bad Request"):
anilist.get(9999999999)