update mangacollec

This commit is contained in:
shooter-dev committed 2026-10-07 16:50:30 +02:00
1 parent e4865224cd
commit 4f564e2962
64 files changed
+531 -174

No files matched your search

@@ -1 +1,5 @@
"""Client library for MangaCollec."""
from .mangacollec import Mangacollec
__all__ = ["Mangacollec"]
@@ -3,98 +3,3 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.author_response import AuthorResponse
from mangacollec.dto.authors_response import AuthorsResponse
from mangacollec.dto.borrower_create_request import BorrowerCreateRequest
from mangacollec.dto.borrower_create_response import BorrowerCreateResponse
from mangacollec.dto.box_response import BoxResponse
from mangacollec.dto.create_box_possessions_multiple_request import (
CreateBoxesPossessionsMultipleRequest,
CreateBoxPossessionMultipleRequest,
)
from mangacollec.dto.create_box_possessions_multiple_response import (
CreateBoxPossessionsMultipleResponse,
)
from mangacollec.dto.create_volume_possessions_multiple_request import (
CreateVolumePossessionMultipleRequest,
CreateVolumesPossessionsMultipleRequest,
)
from mangacollec.dto.create_volume_possessions_multiple_response import (
CreateVolumePossessionsMultipleResponse,
)
from mangacollec.dto.delete_box_possessions_multiple_request import (
DeleteBoxPossessionMultipleRequest,
DeleteBoxPossessionsMultipleRequest,
DeletePossessionMultipleRequest,
)
from mangacollec.dto.delete_box_possessions_multiple_response import (
DeleteBoxPossessionsMultipleResponse,
)
from mangacollec.dto.delete_box_possessions_multiple_response import (
DeletedResource as DeletedBoxResource,
)
from mangacollec.dto.delete_volume_possessions_multiple_response import (
DeletedResource,
DeleteVolumePossessionsMultipleResponse,
)
from mangacollec.dto.edition_response import EditionResponse
from mangacollec.dto.follow_edition_create_request import FollowEditionCreateRequest
from mangacollec.dto.kinds_response import KindsResponse
from mangacollec.dto.loans_create_request import LoanCreateRequest, LoansCreateRequest
from mangacollec.dto.loans_create_response import LoanCreateError, LoansCreateResponse
from mangacollec.dto.loans_delete_request import LoanDeleteRequest, LoansDeleteRequest
from mangacollec.dto.loans_delete_response import LoansDeleteResponse
from mangacollec.dto.news_response import NewsResponse
from mangacollec.dto.publisher_response import PublisherResponse
from mangacollec.dto.publishers_response import PublishersResponse
from mangacollec.dto.reads_create_request import ReadCreateRequest, ReadsCreateRequest
from mangacollec.dto.reads_create_response import ReadsCreateResponse
from mangacollec.dto.reads_delete_request import ReadDeleteRequest, ReadsDeleteRequest
from mangacollec.dto.reads_delete_response import ReadDeleteError, ReadsDeleteResponse
from mangacollec.dto.serie_response import SerieResponse
from mangacollec.dto.series_response import SeriesResponse
from mangacollec.dto.volume_response import VolumeResponse
__all__ = [
"AuthorResponse",
"AuthorsResponse",
"BorrowerCreateRequest",
"BorrowerCreateResponse",
"BoxResponse",
"CreateBoxPossessionMultipleRequest",
"CreateBoxPossessionsMultipleResponse",
"CreateBoxesPossessionsMultipleRequest",
"CreateVolumePossessionMultipleRequest",
"CreateVolumePossessionsMultipleResponse",
"CreateVolumesPossessionsMultipleRequest",
"DeleteBoxPossessionMultipleRequest",
"DeleteBoxPossessionsMultipleRequest",
"DeleteBoxPossessionsMultipleResponse",
"DeletePossessionMultipleRequest",
"DeleteVolumePossessionsMultipleResponse",
"DeletedBoxResource",
"DeletedResource",
"EditionResponse",
"FollowEditionCreateRequest",
"KindsResponse",
"LoanCreateError",
"LoanCreateRequest",
"LoanDeleteRequest",
"LoansCreateRequest",
"LoansCreateResponse",
"LoansDeleteRequest",
"LoansDeleteResponse",
"NewsResponse",
"PublisherResponse",
"PublishersResponse",
"ReadCreateRequest",
"ReadDeleteError",
"ReadDeleteRequest",
"ReadsCreateRequest",
"ReadsCreateResponse",
"ReadsDeleteRequest",
"ReadsDeleteResponse",
"SerieResponse",
"SeriesResponse",
"VolumeResponse",
]
@@ -0,0 +1,25 @@
from mangacollec.dto.news_response import NewsResponse
from mangacollec.dto.catalogue.box_response import BoxResponse
from mangacollec.dto.catalogue.edition_response import EditionResponse
from mangacollec.dto.catalogue.kinds_response import KindsResponse
from mangacollec.dto.catalogue.publisher_response import PublisherResponse
from mangacollec.dto.catalogue.publishers_response import PublishersResponse
from mangacollec.dto.catalogue.author_response import AuthorResponse
from mangacollec.dto.catalogue.authors_response import AuthorsResponse
from mangacollec.dto.catalogue.serie_response import SerieResponse
from mangacollec.dto.catalogue.series_response import SeriesResponse
from mangacollec.dto.catalogue.volume_response import VolumeResponse
__all__ = [
"AuthorResponse",
"AuthorsResponse",
"BoxResponse",
"NewsResponse",
"PublisherResponse",
"PublishersResponse",
"EditionResponse",
"KindsResponse",
"SerieResponse",
"SeriesResponse",
"VolumeResponse",
]
@@ -25,6 +25,6 @@ class VolumeResponse(BaseModel):
publishers: list[Publisher]
series: list[Serie]
types: list[SerieType]
box_volumes: list[BoxVolume] | None
boxes: list[Boxe] | None
box_editions: list[BoxEdition] | None
box_volumes: list[BoxVolume] | None = None
boxes: list[Boxe] | None = None
box_editions: list[BoxEdition] | None = None
@@ -0,0 +1,5 @@
from mangacollec.dto.follows.follow_edition_create_request import FollowEditionCreateRequest
__all__ = [
"FollowEditionCreateRequest"
]
@@ -0,0 +1,18 @@
from mangacollec.dto.loans.borrower_create_request import BorrowerCreateRequest
from mangacollec.dto.loans.borrower_create_response import BorrowerCreateResponse
from mangacollec.dto.loans.loans_create_request import LoanCreateRequest, LoansCreateRequest
from mangacollec.dto.loans.loans_create_response import LoanCreateError, LoansCreateResponse
from mangacollec.dto.loans.loans_delete_request import LoanDeleteRequest, LoansDeleteRequest
from mangacollec.dto.loans.loans_delete_response import LoansDeleteResponse
__all__ = [
"BorrowerCreateRequest",
"BorrowerCreateResponse",
"LoanCreateError",
"LoanCreateRequest",
"LoanDeleteRequest",
"LoansCreateRequest",
"LoansCreateResponse",
"LoansDeleteRequest",
"LoansDeleteResponse"
]
@@ -11,14 +11,14 @@ from mangacollec.value_object import ID
class LoanCreateRequest(BaseModel):
"""Association entre un emprunteur et une possession à prêter."""
"""Association entre un emprunteur et une possessions à prêter."""
borrower_id: ID
possession_id: ID
class LoansCreateRequest(BaseModel):
"""Liste des associations emprunteur-possession à créer."""
"""Liste des associations emprunteur-possessions à créer."""
loans: list[LoanCreateRequest]
@@ -0,0 +1,45 @@
from mangacollec.dto.possessions.create_box_possessions_multiple_request import (
CreateBoxesPossessionsMultipleRequest,
CreateBoxPossessionMultipleRequest,
)
from mangacollec.dto.possessions.create_box_possessions_multiple_response import (
CreateBoxPossessionsMultipleResponse,
)
from mangacollec.dto.possessions.create_volume_possessions_multiple_request import (
CreateVolumePossessionMultipleRequest,
CreateVolumesPossessionsMultipleRequest,
)
from mangacollec.dto.possessions.create_volume_possessions_multiple_response import (
CreateVolumePossessionsMultipleResponse,
)
from mangacollec.dto.possessions.delete_box_possessions_multiple_request import (
DeleteBoxPossessionMultipleRequest,
DeleteBoxPossessionsMultipleRequest,
DeletePossessionMultipleRequest,
)
from mangacollec.dto.possessions.delete_box_possessions_multiple_response import (
DeleteBoxPossessionsMultipleResponse,
)
from mangacollec.dto.possessions.delete_box_possessions_multiple_response import (
DeletedResource as DeletedBoxResource,
)
from mangacollec.dto.possessions.delete_volume_possessions_multiple_response import (
DeletedResource,
DeleteVolumePossessionsMultipleResponse,
)
__all__ = [
"CreateBoxPossessionMultipleRequest",
"CreateBoxPossessionsMultipleResponse",
"CreateBoxesPossessionsMultipleRequest",
"CreateVolumePossessionMultipleRequest",
"CreateVolumePossessionsMultipleResponse",
"CreateVolumesPossessionsMultipleRequest",
"DeleteBoxPossessionMultipleRequest",
"DeleteBoxPossessionsMultipleRequest",
"DeleteBoxPossessionsMultipleResponse",
"DeletePossessionMultipleRequest",
"DeleteVolumePossessionsMultipleResponse",
"DeletedBoxResource",
"DeletedResource"
]
@@ -11,7 +11,7 @@ from mangacollec.value_object import ID
class DeleteBoxPossessionMultipleRequest(BaseModel):
"""Box possession à supprimer."""
"""Box possessions à supprimer."""
id: ID
@@ -0,0 +1,15 @@
from mangacollec.dto.reads.reads_create_request import ReadCreateRequest, ReadsCreateRequest
from mangacollec.dto.reads.reads_create_response import ReadsCreateResponse
from mangacollec.dto.reads.reads_delete_request import ReadDeleteRequest, ReadsDeleteRequest
from mangacollec.dto.reads.reads_delete_response import ReadDeleteError, ReadsDeleteResponse
__all__ = [
"ReadCreateRequest",
"ReadDeleteError",
"ReadDeleteRequest",
"ReadsCreateRequest",
"ReadsCreateResponse",
"ReadsDeleteRequest",
"ReadsDeleteResponse"
]
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import AuthorResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import AuthorResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -4,8 +4,8 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.authors_response import AuthorsResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import AuthorsResponse
from mangacollec.interface import IEndpoint
class CallAuthorsEndpoint(IEndpoint):
@@ -4,8 +4,8 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import BoxResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import BoxResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import EditionResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import EditionResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.kinds_response import KindsResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue.kinds_response import KindsResponse
from mangacollec.interface import IEndpoint
class CallKindsEndpoint(IEndpoint):
@@ -4,7 +4,7 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.news_response import NewsResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
class CallNewsEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import PublisherResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import PublisherResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.publishers_response import PublishersResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue.publishers_response import PublishersResponse
from mangacollec.interface import IEndpoint
class CallPublishesEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import SerieResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue import SerieResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.series_response import SeriesResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue.series_response import SeriesResponse
from mangacollec.interface import IEndpoint
class CallSeriesEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.volume_response import VolumeResponse
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.catalogue.volume_response import VolumeResponse
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -5,8 +5,8 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de l'endpoint de création d'un suivi d'édition."""
from mangacollec.dto import FollowEditionCreateRequest
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.dto.follows import FollowEditionCreateRequest
from mangacollec.interface import IEndpoint
from mangacollec.model.possessions import VolumeFollowEdition
from mangacollec.value_object import ID
@@ -5,7 +5,7 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression d'un suivi d'édition."""
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -7,8 +7,8 @@
from typing import Literal
from mangacollec.dto import BorrowerCreateRequest, BorrowerCreateResponse
from mangacollec.interface.mutation_client_interface import IPostClient
from mangacollec.dto.loans import BorrowerCreateRequest, BorrowerCreateResponse
from mangacollec.interface import IPostClient
class CallCreateBorrowerEndpoint:
@@ -5,8 +5,8 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de prêts."""
from mangacollec.dto import LoanCreateRequest, LoansCreateRequest, LoansCreateResponse
from mangacollec.interface.mutation_client_interface import IPostClient
from mangacollec.dto.loans import LoanCreateRequest, LoansCreateRequest, LoansCreateResponse
from mangacollec.interface import IPostClient
class CallCreateLoansMultipleEndpoint:
@@ -5,7 +5,7 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression d'un emprunteur."""
from mangacollec.interface.mutation_client_interface import IDeleteClient
from mangacollec.interface import IDeleteClient
from mangacollec.value_object import ID
@@ -5,9 +5,9 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression groupée de prêts."""
from mangacollec.dto import LoanDeleteRequest, LoansDeleteRequest, LoansDeleteResponse
from mangacollec.dto.loans import LoanDeleteRequest, LoansDeleteRequest, LoansDeleteResponse
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.mutation_client_interface import IDeleteClient
from mangacollec.interface import IDeleteClient
from mangacollec.value_object import ID
@@ -5,13 +5,13 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de possessions."""
from mangacollec.dto import (
from mangacollec.dto.possessions import (
CreateBoxesPossessionsMultipleRequest,
CreateBoxPossessionMultipleRequest,
CreateBoxPossessionsMultipleResponse,
)
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -5,13 +5,13 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de possessions."""
from mangacollec.dto import (
from mangacollec.dto.possessions import (
CreateVolumePossessionMultipleRequest,
CreateVolumePossessionsMultipleResponse,
CreateVolumesPossessionsMultipleRequest,
)
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -5,14 +5,14 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de suppression groupée de possessions de boxes."""
from mangacollec.dto import (
from mangacollec.dto.possessions import (
DeleteBoxPossessionMultipleRequest,
DeleteBoxPossessionsMultipleRequest,
DeleteBoxPossessionsMultipleResponse,
DeletePossessionMultipleRequest,
)
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -7,11 +7,11 @@
from pydantic import BaseModel
from mangacollec.dto import (
from mangacollec.dto.possessions import (
DeleteVolumePossessionsMultipleResponse,
)
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint
from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID
@@ -7,10 +7,10 @@
from typing import TYPE_CHECKING
from mangacollec.dto import ReadCreateRequest, ReadsCreateRequest, ReadsCreateResponse
from mangacollec.dto.reads import ReadCreateRequest, ReadsCreateRequest, ReadsCreateResponse
if TYPE_CHECKING:
from mangacollec.interface.mutation_client_interface import IPostClient
from mangacollec.interface import IPostClient
from mangacollec.value_object import ID
@@ -5,9 +5,9 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression groupée de lectures."""
from mangacollec.dto import ReadDeleteRequest, ReadsDeleteRequest, ReadsDeleteResponse
from mangacollec.dto.reads import ReadDeleteRequest, ReadsDeleteRequest, ReadsDeleteResponse
from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.mutation_client_interface import IDeleteClient
from mangacollec.interface import IDeleteClient
from mangacollec.value_object import ID
+31 -26
View File
@@ -29,6 +29,7 @@ class Mangacollec:
_AUTHENTICATION_PATH: str = "oauth/token"
_DEFAULT_REQUEST_TIMEOUT: float = 30.0
_TOKEN_SAFETY_MARGIN: int = 30 * 60
_RATE_LIMIT_STATUS: int = 429
_SERVER_ERROR_MIN: int = 500
_SERVER_ERROR_MAX: int = 600
_NO_CONTENT_STATUS: int = 204
@@ -37,17 +38,21 @@ class Mangacollec:
_oauth_client: httpx.AsyncClient
_is_auth: bool
_MANGACOLLEC_CLIENT_ID: str
_MANGACOLLEC_CLIENT_SECRET: str
_client_id: str
_client_secret: str
def __init__(self, proxy: str | None = None) -> None:
"""Configure HTTP clients with environment-provided credentials.
def __init__(self, client_id: str, client_secret: str, proxy: str | None = None) -> None:
"""Configure HTTP clients with explicitly provided credentials.
Les requêtes vers les ressources peuvent passer par ``proxy``. Les échanges
OAuth restent toujours directs afin de ne pas transmettre les identifiants
de l'application à un proxy tiers.
"""
self.validate_mangacollec_credentials()
if not client_id or not client_secret:
raise ConfigurationError("Les identifiants MangaCollec sont requis.")
self._client_id = client_id
self._client_secret = client_secret
self._headers = {
"Accept": "application/json",
@@ -104,19 +109,6 @@ class Mangacollec:
return str(proxy_url)
def validate_mangacollec_credentials(self) -> None:
"""Charger et valider les identifiants MangaCollec depuis l'environnement."""
client_id = os.environ.get("MANGACOLLEC_CLIENT_ID", None)
client_secret = os.environ.get("MANGACOLLEC_CLIENT_SECRET", None)
if client_id is None or client_secret is None:
raise ConfigurationError("Les identifiants MangaCollec sont requis.")
self._MANGACOLLEC_CLIENT_ID = client_id
self._MANGACOLLEC_CLIENT_SECRET = client_secret
@classmethod
def _request_timeout_from_environment(cls) -> float:
"""Read a positive finite HTTP timeout from the local environment."""
@@ -167,13 +159,17 @@ class Mangacollec:
retryable = method.upper() == "GET" or url == self._AUTHENTICATION_PATH
request_client = self._client if is_token_required else self._oauth_client
for retry_count in range(RetryPolicy.max_retries + 1):
retry_after: str | None = None
try:
response = await request_client.request(method=method, url=url, params=param, json=data)
logger.info("%s | %s | %s", method.upper(), response.url, response.status_code)
if self._SERVER_ERROR_MIN <= response.status_code < self._SERVER_ERROR_MAX:
if response.status_code == self._RATE_LIMIT_STATUS or (
self._SERVER_ERROR_MIN <= response.status_code < self._SERVER_ERROR_MAX
):
if not retryable or retry_count == RetryPolicy.max_retries:
response.raise_for_status()
retry_after = response.headers.get("Retry-After")
else:
response.raise_for_status()
if response.status_code == self._NO_CONTENT_STATUS:
@@ -194,7 +190,16 @@ class Mangacollec:
if not retryable or retry_count == RetryPolicy.max_retries:
raise
await asyncio.sleep(RetryPolicy.delay_for_retry(retry_count + 1))
delay_seconds = RetryPolicy.delay_for_retry(retry_count + 1, retry_after=retry_after)
logger.warning(
"%s %s : reprise %d/%d dans %s secondes.",
method.upper(),
url,
retry_count + 1,
RetryPolicy.max_retries,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
raise RemoteRequestError("La requête a échoué après cinq reprises.")
@@ -229,8 +234,8 @@ class Mangacollec:
async def login(self) -> None:
"""Obtenir et installer un token OAuth avec les identifiants du client."""
credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID,
client_secret=self._MANGACOLLEC_CLIENT_SECRET,
client_id=self._client_id,
client_secret=self._client_secret,
grant_type="client_credentials",
)
await self._authenticate(credential)
@@ -238,8 +243,8 @@ class Mangacollec:
async def login_password(self, username: str, password: str) -> None:
"""Obtenir et installer un token OAuth avec un compte utilisateur."""
credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID,
client_secret=self._MANGACOLLEC_CLIENT_SECRET,
client_id=self._client_id,
client_secret=self._client_secret,
grant_type="password",
username=username,
password=password,
@@ -310,8 +315,8 @@ class Mangacollec:
logger.info("Renouvellement du token MangaCollec en cours.")
previous_refresh_token = self._token.refresh_token
credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID,
client_secret=self._MANGACOLLEC_CLIENT_SECRET,
client_id=self._client_id,
client_secret=self._client_secret,
grant_type="refresh_token",
refresh_token=previous_refresh_token,
)
@@ -3,7 +3,7 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Modèle d'un prêt de possession à un emprunteur."""
"""Modèle d'un prêt de possessions à un emprunteur."""
from datetime import datetime
@@ -4,23 +4,33 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Fixed retry policy shared by MangaCollec transports."""
"""Retry delays shared by MangaCollec transports."""
import math
from typing import ClassVar
class RetryPolicy:
"""Define the bounded exponential delay used before retrying MangaCollec requests."""
"""Prefer Retry-After seconds with a margin over bounded exponential delays."""
max_retries: ClassVar[int] = 5
initial_delay_seconds: ClassVar[float] = 0.5
maximum_delay_seconds: ClassVar[float] = 8.0
retry_after_multiplier: ClassVar[float] = 1.05
@classmethod
def delay_for_retry(cls, retry_count: int) -> float:
def delay_for_retry(cls, retry_count: int, *, retry_after: str | None = None) -> float:
"""Return the delay for a scheduled retry, where the first retry is one."""
if retry_count < 1:
raise ValueError("Le numéro de reprise doit être supérieur ou égal à un.")
if retry_after is not None:
seconds = retry_after.strip()
if seconds.isascii() and seconds.isdecimal():
retry_after_delay = float(seconds) * cls.retry_after_multiplier
if math.isfinite(retry_after_delay):
return retry_after_delay
delay_seconds: float = cls.initial_delay_seconds * 2 ** (retry_count - 1)
if delay_seconds >= cls.maximum_delay_seconds:
return cls.maximum_delay_seconds
+69
View File
@@ -0,0 +1,69 @@
"""Fixtures for MangaCollec requests without network access or real waiting."""
import asyncio
from collections.abc import Iterator
from unittest.mock import AsyncMock
import httpx
import pytest
from mangacollec.mangacollec import Mangacollec
from pytest_mock import MockerFixture
@pytest.fixture
def responses() -> list[httpx.Response | httpx.RequestError]:
"""Return the sequence of HTTP responses and transport errors to simulate."""
return []
@pytest.fixture
def sent_requests() -> list[httpx.Request]:
"""Record requests made through the mock transport."""
return []
@pytest.fixture
def client(
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Mangacollec]:
"""Provide an authenticated client with mocked resource and OAuth transports."""
monkeypatch.setenv("BOOKSYNC_REQUEST_TIMEOUT", "30")
client = Mangacollec("test-client", "test-secret")
asyncio.run(client.close())
def handle_request(request: httpx.Request) -> httpx.Response:
"""Consume the next simulated response and record its request."""
sent_requests.append(request)
response = responses.pop(0)
if isinstance(response, httpx.RequestError):
raise response
return response
transport = httpx.MockTransport(handle_request)
client._client = httpx.AsyncClient(
base_url="https://api.mangacollec.com/", transport=transport, trust_env=False,
)
client._oauth_client = httpx.AsyncClient(
base_url="https://api.mangacollec.com/", transport=transport, trust_env=False,
)
client._set_token({
"access_token": "test-token",
"token_type": "Bearer",
"expires_in": 86400,
"created_at": 0,
})
try:
yield client
finally:
asyncio.run(client.close())
@pytest.fixture
def sleep_mock(mocker: MockerFixture) -> AsyncMock:
"""Record asynchronous waits while completing them immediately."""
sleep = AsyncMock(return_value=None)
mocker.patch("mangacollec.mangacollec.asyncio.sleep", new=sleep)
return sleep
+201
View File
@@ -0,0 +1,201 @@
"""Tests for HTTP retry behavior through simulated MangaCollec transports."""
import asyncio
from unittest.mock import AsyncMock, call
import httpx
import pytest
from mangacollec.mangacollec import Mangacollec
class TestRequest:
"""Exercise HTTP retries, transport errors and retry eligibility."""
@pytest.mark.parametrize("status_code", [429, 500, 503, 599])
@pytest.mark.parametrize("retry_after, expected_delay", [
("120", 126.0), ("0", 0.0), (None, 0.5), ("invalid", 0.5),
])
def test_retry_then_success(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
status_code: int,
retry_after: str | None,
expected_delay: float,
) -> None:
"""Retry rate limits and server errors using the current response's delay."""
headers = {} if retry_after is None else {"retry-after": retry_after}
responses.extend([
httpx.Response(status_code, headers=headers),
httpx.Response(200, json={"series": []}),
])
payload = asyncio.run(client.get("series"))
assert payload == {"series": []}
assert len(sent_requests) == 2
sleep_mock.assert_awaited_once_with(expected_delay)
@pytest.mark.parametrize("status_code", [429, 503])
@pytest.mark.parametrize("retry_after", [None, "120"])
def test_retry_limit(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
status_code: int,
retry_after: str | None,
) -> None:
"""Raise the final HTTP error after five waits and six attempts."""
headers = {} if retry_after is None else {"Retry-After": retry_after}
responses.extend(httpx.Response(status_code, headers=headers) for _ in range(6))
final_response = responses[-1]
with pytest.raises(httpx.HTTPStatusError) as error:
asyncio.run(client.get("series"))
assert error.value.response is final_response
assert len(sent_requests) == 6
expected_delays = [0.5, 1.0, 2.0, 4.0, 8.0] if retry_after is None else [126.0] * 5
assert sleep_mock.await_args_list == [call(delay) for delay in expected_delays]
@pytest.mark.parametrize("error_type", [
httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError,
])
def test_network_error_resets_header(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
error_type: type[httpx.RequestError],
) -> None:
"""Use exponential fallback after a transport error rather than stale headers."""
responses.extend([
httpx.Response(429, headers={"Retry-After": "120"}),
error_type("Simulated network error"),
httpx.Response(200, json={}),
])
assert asyncio.run(client.get("series")) == {}
assert len(sent_requests) == 3
assert sleep_mock.await_args_list == [call(126.0), call(1.0)]
def test_network_error_limit(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
) -> None:
"""Propagate the final transport error without an additional wait."""
responses.extend(httpx.ConnectError("Simulated network error") for _ in range(6))
final_error = responses[-1]
with pytest.raises(httpx.ConnectError) as error:
asyncio.run(client.get("series"))
assert error.value is final_error
assert len(sent_requests) == 6
assert sleep_mock.await_args_list == [call(delay) for delay in [0.5, 1.0, 2.0, 4.0, 8.0]]
@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"])
@pytest.mark.parametrize("status_code", [429, 503])
def test_mutation_not_retried(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
method: str,
status_code: int,
) -> None:
"""Preserve immediate errors for resource mutations despite Retry-After."""
responses.append(httpx.Response(status_code, headers={"Retry-After": "120"}))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(client._request(method, "possessions", data={}))
assert len(sent_requests) == 1
sleep_mock.assert_not_awaited()
@pytest.mark.parametrize("status_code", [400, 401, 403, 404])
def test_other_client_errors_not_retried(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
status_code: int,
) -> None:
"""Do not expand retries to other client errors carrying the header."""
responses.append(httpx.Response(status_code, headers={"Retry-After": "120"}))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(client.get("series"))
assert len(sent_requests) == 1
sleep_mock.assert_not_awaited()
@pytest.mark.parametrize("status_code", [429, 503])
def test_oauth_retry(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sent_requests: list[httpx.Request],
sleep_mock: AsyncMock,
status_code: int,
) -> None:
"""Apply the server delay to retriable OAuth POST requests."""
responses.extend([
httpx.Response(status_code, headers={"Retry-After": "120"}),
httpx.Response(200, json={
"access_token": "new-test-token",
"token_type": "Bearer",
"expires_in": 86400,
"created_at": 0,
}),
])
asyncio.run(client.login())
assert client.authorization_header == "Bearer new-test-token"
assert len(sent_requests) == 2
assert all(request.method == "POST" for request in sent_requests)
assert all(request.url.path == "/oauth/token" for request in sent_requests)
sleep_mock.assert_awaited_once_with(126.0)
@pytest.mark.parametrize("status_code", [200, 204])
def test_success_does_not_wait(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sleep_mock: AsyncMock,
status_code: int,
) -> None:
"""Return successful responses immediately even if they have Retry-After."""
responses.append(httpx.Response(status_code, headers={"Retry-After": "120"}, json={}))
assert asyncio.run(client.get("series")) == {}
sleep_mock.assert_not_awaited()
def test_logs_scheduled_wait(
self,
client: Mangacollec,
responses: list[httpx.Response | httpx.RequestError],
sleep_mock: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Log the retry number and the actual wait duration."""
responses.extend([
httpx.Response(429, headers={"Retry-After": "120"}),
httpx.Response(200, json={}),
])
asyncio.run(client.get("series"))
assert any("reprise 1/5 dans 126.0 secondes" in message for message in caplog.messages)
sleep_mock.assert_awaited_once_with(126.0)
@@ -0,0 +1,55 @@
"""Tests for server-directed waits and exponential fallback delays."""
import pytest
from mangacollec.retry_policy import RetryPolicy
class TestDelayForRetry:
"""Verify the delay policy independently of HTTP requests."""
@pytest.mark.parametrize("retry_count", [1, 5])
@pytest.mark.parametrize("retry_after, expected", [
("120", 126.0),
("8", 8.4),
("1", 1.05),
("0", 0.0),
(" 120 ", 126.0),
("00120", 126.0),
])
def test_server_delay(self, retry_count: int, retry_after: str, expected: float) -> None:
"""Use server seconds plus five percent, without the fallback's cap."""
delay = RetryPolicy.delay_for_retry(retry_count, retry_after=retry_after)
assert delay == pytest.approx(expected)
@pytest.mark.parametrize("retry_after", [
None, "", " ", "-1", "+1", "1.5", "1e2", "invalid", "NaN", "Infinity",
"١٢٠", "120, 60", "Fri, 31 Dec 1999 23:59:59 GMT",
])
def test_invalid_header(self, retry_after: str | None) -> None:
"""Fall back for missing headers and values outside the seconds format."""
assert RetryPolicy.delay_for_retry(1, retry_after=retry_after) == 0.5
assert RetryPolicy.delay_for_retry(5, retry_after=retry_after) == 8.0
@pytest.mark.parametrize("retry_after", [
pytest.param("9" * 400, id="non-finite-seconds"),
pytest.param("179" + "0" * 306, id="non-finite-margin"),
pytest.param("9" * 5000, id="oversized-number"),
])
def test_non_finite_delay(self, retry_after: str) -> None:
"""Avoid passing infinite delays to the asynchronous wait."""
assert RetryPolicy.delay_for_retry(1, retry_after=retry_after) == 0.5
@pytest.mark.parametrize("retry_count, expected", [
(1, 0.5), (2, 1.0), (3, 2.0), (4, 4.0), (5, 8.0), (6, 8.0),
])
def test_exponential_fallback(self, retry_count: int, expected: float) -> None:
"""Keep the existing exponential delays and maximum intact."""
assert RetryPolicy.delay_for_retry(retry_count) == expected
@pytest.mark.parametrize("retry_count", [0, -1])
@pytest.mark.parametrize("retry_after", [None, "120"])
def test_invalid_retry_count(self, retry_count: int, retry_after: str | None) -> None:
"""Validate the retry number even when the server supplies a delay."""
with pytest.raises(ValueError):
RetryPolicy.delay_for_retry(retry_count, retry_after=retry_after)