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.""" """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] publishers: list[Publisher]
series: list[Serie] series: list[Serie]
types: list[SerieType] types: list[SerieType]
box_volumes: list[BoxVolume] | None box_volumes: list[BoxVolume] | None = None
boxes: list[Boxe] | None boxes: list[Boxe] | None = None
box_editions: list[BoxEdition] | 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): class LoanCreateRequest(BaseModel):
"""Association entre un emprunteur et une possession à prêter.""" """Association entre un emprunteur et une possessions à prêter."""
borrower_id: ID borrower_id: ID
possession_id: ID possession_id: ID
class LoansCreateRequest(BaseModel): class LoansCreateRequest(BaseModel):
"""Liste des associations emprunteur-possession à créer.""" """Liste des associations emprunteur-possessions à créer."""
loans: list[LoanCreateRequest] 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): class DeleteBoxPossessionMultipleRequest(BaseModel):
"""Box possession à supprimer.""" """Box possessions à supprimer."""
id: ID 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.dto.catalogue import AuthorResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -4,8 +4,8 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.authors_response import AuthorsResponse from mangacollec.dto.catalogue import AuthorsResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
class CallAuthorsEndpoint(IEndpoint): class CallAuthorsEndpoint(IEndpoint):
@@ -4,8 +4,8 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import BoxResponse from mangacollec.dto.catalogue import BoxResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import EditionResponse from mangacollec.dto.catalogue import EditionResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.kinds_response import KindsResponse from mangacollec.dto.catalogue.kinds_response import KindsResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
class CallKindsEndpoint(IEndpoint): class CallKindsEndpoint(IEndpoint):
@@ -4,7 +4,7 @@
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.news_response import NewsResponse from mangacollec.dto.news_response import NewsResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
class CallNewsEndpoint(IEndpoint): class CallNewsEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import PublisherResponse from mangacollec.dto.catalogue import PublisherResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.publishers_response import PublishersResponse from mangacollec.dto.catalogue.publishers_response import PublishersResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
class CallPublishesEndpoint(IEndpoint): class CallPublishesEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto import SerieResponse from mangacollec.dto.catalogue import SerieResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.series_response import SeriesResponse from mangacollec.dto.catalogue.series_response import SeriesResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
class CallSeriesEndpoint(IEndpoint): class CallSeriesEndpoint(IEndpoint):
@@ -3,8 +3,8 @@
# \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / / # \__ \ / __ \ / __ \ / __ \ / __/ / _ \ / __/ ____ / / / / / _ \ | | / /
# ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ / # ___/ / / / / / / /_/ / / /_/ / / /_ / __/ / / /___/ / /_/ / / __/ | |/ /
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
from mangacollec.dto.volume_response import VolumeResponse from mangacollec.dto.catalogue.volume_response import VolumeResponse
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,8 +5,8 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de l'endpoint de création d'un suivi d'édition.""" """Wrapper de l'endpoint de création d'un suivi d'édition."""
from mangacollec.dto import FollowEditionCreateRequest from mangacollec.dto.follows import FollowEditionCreateRequest
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.model.possessions import VolumeFollowEdition from mangacollec.model.possessions import VolumeFollowEdition
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,7 +5,7 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression d'un suivi d'édition.""" """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 from mangacollec.value_object import ID
@@ -7,8 +7,8 @@
from typing import Literal from typing import Literal
from mangacollec.dto import BorrowerCreateRequest, BorrowerCreateResponse from mangacollec.dto.loans import BorrowerCreateRequest, BorrowerCreateResponse
from mangacollec.interface.mutation_client_interface import IPostClient from mangacollec.interface import IPostClient
class CallCreateBorrowerEndpoint: class CallCreateBorrowerEndpoint:
@@ -5,8 +5,8 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de prêts.""" """Endpoint de création groupée de prêts."""
from mangacollec.dto import LoanCreateRequest, LoansCreateRequest, LoansCreateResponse from mangacollec.dto.loans import LoanCreateRequest, LoansCreateRequest, LoansCreateResponse
from mangacollec.interface.mutation_client_interface import IPostClient from mangacollec.interface import IPostClient
class CallCreateLoansMultipleEndpoint: class CallCreateLoansMultipleEndpoint:
@@ -5,7 +5,7 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression d'un emprunteur.""" """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 from mangacollec.value_object import ID
@@ -5,9 +5,9 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression groupée de prêts.""" """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.exceptions.exceptions import DomainValidationError
from mangacollec.interface.mutation_client_interface import IDeleteClient from mangacollec.interface import IDeleteClient
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,13 +5,13 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de possessions.""" """Endpoint de création groupée de possessions."""
from mangacollec.dto import ( from mangacollec.dto.possessions import (
CreateBoxesPossessionsMultipleRequest, CreateBoxesPossessionsMultipleRequest,
CreateBoxPossessionMultipleRequest, CreateBoxPossessionMultipleRequest,
CreateBoxPossessionsMultipleResponse, CreateBoxPossessionsMultipleResponse,
) )
from mangacollec.exceptions.exceptions import DomainValidationError from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,13 +5,13 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de création groupée de possessions.""" """Endpoint de création groupée de possessions."""
from mangacollec.dto import ( from mangacollec.dto.possessions import (
CreateVolumePossessionMultipleRequest, CreateVolumePossessionMultipleRequest,
CreateVolumePossessionsMultipleResponse, CreateVolumePossessionsMultipleResponse,
CreateVolumesPossessionsMultipleRequest, CreateVolumesPossessionsMultipleRequest,
) )
from mangacollec.exceptions.exceptions import DomainValidationError from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,14 +5,14 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Endpoint de suppression groupée de possessions de boxes.""" """Endpoint de suppression groupée de possessions de boxes."""
from mangacollec.dto import ( from mangacollec.dto.possessions import (
DeleteBoxPossessionMultipleRequest, DeleteBoxPossessionMultipleRequest,
DeleteBoxPossessionsMultipleRequest, DeleteBoxPossessionsMultipleRequest,
DeleteBoxPossessionsMultipleResponse, DeleteBoxPossessionsMultipleResponse,
DeletePossessionMultipleRequest, DeletePossessionMultipleRequest,
) )
from mangacollec.exceptions.exceptions import DomainValidationError from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -7,11 +7,11 @@
from pydantic import BaseModel from pydantic import BaseModel
from mangacollec.dto import ( from mangacollec.dto.possessions import (
DeleteVolumePossessionsMultipleResponse, DeleteVolumePossessionsMultipleResponse,
) )
from mangacollec.exceptions.exceptions import DomainValidationError from mangacollec.exceptions.exceptions import DomainValidationError
from mangacollec.interface.endpoint_interface import IEndpoint from mangacollec.interface import IEndpoint
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -7,10 +7,10 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from mangacollec.dto import ReadCreateRequest, ReadsCreateRequest, ReadsCreateResponse from mangacollec.dto.reads import ReadCreateRequest, ReadsCreateRequest, ReadsCreateResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from mangacollec.interface.mutation_client_interface import IPostClient from mangacollec.interface import IPostClient
from mangacollec.value_object import ID from mangacollec.value_object import ID
@@ -5,9 +5,9 @@
# /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/ # /____/ /_/ /_/ \____/ \____/ \__/ \___/ /_/ _______ /______/ \___/ |___/
"""Wrapper de suppression groupée de lectures.""" """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.exceptions.exceptions import DomainValidationError
from mangacollec.interface.mutation_client_interface import IDeleteClient from mangacollec.interface import IDeleteClient
from mangacollec.value_object import ID from mangacollec.value_object import ID
+31 -26
View File
@@ -29,6 +29,7 @@ class Mangacollec:
_AUTHENTICATION_PATH: str = "oauth/token" _AUTHENTICATION_PATH: str = "oauth/token"
_DEFAULT_REQUEST_TIMEOUT: float = 30.0 _DEFAULT_REQUEST_TIMEOUT: float = 30.0
_TOKEN_SAFETY_MARGIN: int = 30 * 60 _TOKEN_SAFETY_MARGIN: int = 30 * 60
_RATE_LIMIT_STATUS: int = 429
_SERVER_ERROR_MIN: int = 500 _SERVER_ERROR_MIN: int = 500
_SERVER_ERROR_MAX: int = 600 _SERVER_ERROR_MAX: int = 600
_NO_CONTENT_STATUS: int = 204 _NO_CONTENT_STATUS: int = 204
@@ -37,17 +38,21 @@ class Mangacollec:
_oauth_client: httpx.AsyncClient _oauth_client: httpx.AsyncClient
_is_auth: bool _is_auth: bool
_MANGACOLLEC_CLIENT_ID: str _client_id: str
_MANGACOLLEC_CLIENT_SECRET: str _client_secret: str
def __init__(self, proxy: str | None = None) -> None: def __init__(self, client_id: str, client_secret: str, proxy: str | None = None) -> None:
"""Configure HTTP clients with environment-provided credentials. """Configure HTTP clients with explicitly provided credentials.
Les requêtes vers les ressources peuvent passer par ``proxy``. Les échanges Les requêtes vers les ressources peuvent passer par ``proxy``. Les échanges
OAuth restent toujours directs afin de ne pas transmettre les identifiants OAuth restent toujours directs afin de ne pas transmettre les identifiants
de l'application à un proxy tiers. 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 = { self._headers = {
"Accept": "application/json", "Accept": "application/json",
@@ -104,19 +109,6 @@ class Mangacollec:
return str(proxy_url) 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 @classmethod
def _request_timeout_from_environment(cls) -> float: def _request_timeout_from_environment(cls) -> float:
"""Read a positive finite HTTP timeout from the local environment.""" """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 retryable = method.upper() == "GET" or url == self._AUTHENTICATION_PATH
request_client = self._client if is_token_required else self._oauth_client request_client = self._client if is_token_required else self._oauth_client
for retry_count in range(RetryPolicy.max_retries + 1): for retry_count in range(RetryPolicy.max_retries + 1):
retry_after: str | None = None
try: try:
response = await request_client.request(method=method, url=url, params=param, json=data) 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) 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: if not retryable or retry_count == RetryPolicy.max_retries:
response.raise_for_status() response.raise_for_status()
retry_after = response.headers.get("Retry-After")
else: else:
response.raise_for_status() response.raise_for_status()
if response.status_code == self._NO_CONTENT_STATUS: if response.status_code == self._NO_CONTENT_STATUS:
@@ -194,7 +190,16 @@ class Mangacollec:
if not retryable or retry_count == RetryPolicy.max_retries: if not retryable or retry_count == RetryPolicy.max_retries:
raise 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.") raise RemoteRequestError("La requête a échoué après cinq reprises.")
@@ -229,8 +234,8 @@ class Mangacollec:
async def login(self) -> None: async def login(self) -> None:
"""Obtenir et installer un token OAuth avec les identifiants du client.""" """Obtenir et installer un token OAuth avec les identifiants du client."""
credential = Credential( credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID, client_id=self._client_id,
client_secret=self._MANGACOLLEC_CLIENT_SECRET, client_secret=self._client_secret,
grant_type="client_credentials", grant_type="client_credentials",
) )
await self._authenticate(credential) await self._authenticate(credential)
@@ -238,8 +243,8 @@ class Mangacollec:
async def login_password(self, username: str, password: str) -> None: async def login_password(self, username: str, password: str) -> None:
"""Obtenir et installer un token OAuth avec un compte utilisateur.""" """Obtenir et installer un token OAuth avec un compte utilisateur."""
credential = Credential( credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID, client_id=self._client_id,
client_secret=self._MANGACOLLEC_CLIENT_SECRET, client_secret=self._client_secret,
grant_type="password", grant_type="password",
username=username, username=username,
password=password, password=password,
@@ -310,8 +315,8 @@ class Mangacollec:
logger.info("Renouvellement du token MangaCollec en cours.") logger.info("Renouvellement du token MangaCollec en cours.")
previous_refresh_token = self._token.refresh_token previous_refresh_token = self._token.refresh_token
credential = Credential( credential = Credential(
client_id=self._MANGACOLLEC_CLIENT_ID, client_id=self._client_id,
client_secret=self._MANGACOLLEC_CLIENT_SECRET, client_secret=self._client_secret,
grant_type="refresh_token", grant_type="refresh_token",
refresh_token=previous_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 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 from typing import ClassVar
class RetryPolicy: 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 max_retries: ClassVar[int] = 5
initial_delay_seconds: ClassVar[float] = 0.5 initial_delay_seconds: ClassVar[float] = 0.5
maximum_delay_seconds: ClassVar[float] = 8.0 maximum_delay_seconds: ClassVar[float] = 8.0
retry_after_multiplier: ClassVar[float] = 1.05
@classmethod @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.""" """Return the delay for a scheduled retry, where the first retry is one."""
if retry_count < 1: if retry_count < 1:
raise ValueError("Le numéro de reprise doit être supérieur ou égal à un.") 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) delay_seconds: float = cls.initial_delay_seconds * 2 ** (retry_count - 1)
if delay_seconds >= cls.maximum_delay_seconds: if delay_seconds >= cls.maximum_delay_seconds:
return 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)