Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

## Prérequis

- Python 3.10 ou une version ultérieure
- Python 3.11 ou une version ultérieure
- [uv](https://docs.astral.sh/uv/)

## Installation
Expand Down
71 changes: 52 additions & 19 deletions allocine/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import logging
from datetime import date as Date
from datetime import timedelta
from os import PathLike

import backoff
import httpx2
import wreq
from wreq.blocking import Client, Response

from allocine.cache import CACHE_MISS, CachedResponse, CacheOption, HttpCache
from allocine.cache import CACHE_MISS, CachedResponse, CacheOption, HttpCache, HttpResponse

logger = logging.getLogger(__name__)

SHOWTIMES_BASE_URL = "https://www.allocine.fr/_/showtimes"
THEATERS_BASE_URL = "https://www.allocine.fr/salle/cinema"
Expand All @@ -23,14 +28,17 @@ def __init__(
cache: CacheOption = False,
cache_dir: str | PathLike[str] | None = None,
):
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; \
Intel Mac OS X 10.14; rv:63.0) \
Gecko/20100101 Firefox/63.0",
}
self.session = httpx2.Client(headers=headers, timeout=30)
self.session = self._new_client()
self.cache = HttpCache(cache, cache_dir)

def _new_client(self) -> Client:
return Client(emulation=wreq.Emulation.random(), timeout=timedelta(seconds=30))

def _recreate_client(self) -> None:
logger.info("Recreating HTTP client")
self.session.close()
self.session = self._new_client()

def get_showtimelist_by_theater_id(
self,
theater_id: str,
Expand Down Expand Up @@ -105,7 +113,7 @@ def _request(
*args,
not_found_ok: bool = False,
**kwargs,
) -> httpx2.Response:
) -> HttpResponse:
params = kwargs.get("params")
cached = self.cache.get(url, params)
if cached is not CACHE_MISS and cached.is_fresh():
Expand All @@ -121,23 +129,36 @@ def _request(

try:
response = self._fetch(url, expected_status, *args, not_found_ok=not_found_ok, **kwargs)
except (_RateLimitError, ValueError, httpx2.HTTPError):
except (
_RateLimitError,
ValueError,
wreq.BodyError,
wreq.ConnectionError,
wreq.ConnectionResetError,
wreq.DecodingError,
wreq.RedirectError,
wreq.RequestError,
wreq.TimeoutError,
wreq.TlsError,
):
if cached is not CACHE_MISS and cached.can_serve_on_error():
return cached.to_response()
raise

if response.status_code == 304 and cached is not CACHE_MISS:
response = self._revalidated_response(cached, response)
self.cache.set(url, response, params)
if response.status_code == 200:
self.cache.set(url, response, params)
return response

@backoff.on_exception(
backoff.expo,
_RateLimitError,
factor=5,
max_value=120,
factor=10,
max_value=60,
max_tries=7,
jitter=None,
on_backoff=lambda details: details["args"][0]._recreate_client(),
)
@backoff.on_exception(backoff.expo, ValueError, max_tries=5, max_time=30)
def _fetch(
Expand All @@ -147,14 +168,26 @@ def _fetch(
*args,
not_found_ok: bool = False,
**kwargs,
) -> httpx2.Response:
) -> HttpResponse:
if params := kwargs.pop("params", None):
kwargs["query"] = params
ret = self.session.get(url, *args, **kwargs)
if ret.status_code == 429:
status_code = ret.status.as_int()
if status_code == 429:
ret.close()
raise _RateLimitError(f"{url!r}: rate limit exceeded")
if ret.status_code not in {expected_status, 304} and not (not_found_ok and ret.status_code == 404):
raise ValueError("{!r} : expected status {}, received {}".format(url, expected_status, ret.status_code))
return ret
try:
response = HttpResponse(status_code, self._response_headers(ret), ret.bytes())
finally:
ret.close()
if status_code not in {expected_status, 304} and not (not_found_ok and status_code == 404):
raise ValueError("{!r} : expected status {}, received {}".format(url, expected_status, status_code))
return response

@staticmethod
def _response_headers(response: Response) -> dict[str, str]:
return {name.decode("ascii"): value.decode("latin-1") for name, value in response.headers}

@staticmethod
def _revalidated_response(cached: CachedResponse, response: httpx2.Response) -> httpx2.Response:
def _revalidated_response(cached: CachedResponse, response: HttpResponse) -> HttpResponse:
return cached.to_response(response.headers)
24 changes: 19 additions & 5 deletions allocine/cache.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from collections.abc import Mapping
from dataclasses import dataclass
from json import loads
from os import PathLike
from time import time
from urllib.parse import urlencode
from weakref import finalize

import httpx2
from diskcache import Cache
from platformdirs import user_cache_path

Expand All @@ -15,6 +15,20 @@
CacheOption = bool | Cache


@dataclass
class HttpResponse:
status_code: int
headers: dict[str, str]
content: bytes = b""

@property
def text(self) -> str:
return self.content.decode("utf-8", errors="replace")

def json(self):
return loads(self.content)


@dataclass(frozen=True)
class CachedResponse:
status_code: int
Expand All @@ -39,12 +53,12 @@ def retention(self) -> float:
stale = max(self.stale_if_error, self.stale_while_revalidate)
return self.max_age + stale - self.initial_age

def to_response(self, revalidation_headers: Mapping[str, str] | None = None) -> httpx2.Response:
def to_response(self, revalidation_headers: Mapping[str, str] | None = None) -> HttpResponse:
headers = dict(self.headers)
if revalidation_headers is not None:
headers.pop("age", None)
headers.update(revalidation_headers)
return httpx2.Response(self.status_code, headers=_decoded_headers(headers), content=self.content)
return HttpResponse(self.status_code, _decoded_headers(headers), self.content)


class HttpCache:
Expand All @@ -69,7 +83,7 @@ def get(self, url: str, params: dict | None = None):
return CACHE_MISS
return self._backend.get(self._key(url, params), default=CACHE_MISS)

def set(self, url: str, response: httpx2.Response, params: dict | None = None) -> None:
def set(self, url: str, response: HttpResponse, params: dict | None = None) -> None:
if self._backend is None or (cached := _to_cached_response(response)) is None:
return
if (retention := cached.retention()) > 0:
Expand All @@ -88,7 +102,7 @@ def _key(url: str, params: dict | None) -> str:
return f"http:v{CACHE_VERSION}:{url}?{query}"


def _to_cached_response(response: httpx2.Response) -> CachedResponse | None:
def _to_cached_response(response: HttpResponse) -> CachedResponse | None:
directives = _parse_cache_control(response.headers.get("cache-control", ""))
if "public" not in directives or "no-store" in directives or "private" in directives:
return None
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ name = "allocine"
dynamic = ["version"]
description = "Non official Python wrapper for allocine.fr"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Thibault Ducret", email = "hello@tducret.com" },
{ name = "Thibault Ducret", email = "thibault@ducret.dev" },
]
keywords = [
"allocine",
Expand All @@ -29,11 +29,11 @@ dependencies = [
"backoff>=2.2.1",
"click>=8.5.0",
"diskcache>=5.6,<6",
"httpx2>=2.13.1",
"jmespath>=1.1.0",
"parsel>=1.10.0",
"platformdirs>=4.3",
"rich>=14.2.0",
"wreq>=0.12.3",
]

[project.optional-dependencies]
Expand Down
57 changes: 51 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import json
from http import HTTPStatus
from pathlib import Path
from urllib.parse import urlencode

import pytest
import vcr
from syrupy.extensions.json import JSONSnapshotExtension
from vcr.request import Request

from allocine.cache import HttpResponse

CASSETTES = Path(__file__).parent / "cassettes"

Expand All @@ -30,8 +35,9 @@ def deserialize(cassette_string):
cassette = json.loads(cassette_string)
for interaction in cassette["interactions"]:
response = interaction["response"]
if not response["body"].get("string"):
body = json.dumps(response["body"], ensure_ascii=False, separators=(",", ":"))
response_body = response["body"]
if not isinstance(response_body, dict) or set(response_body) != {"string"}:
body = json.dumps(response_body, ensure_ascii=False, separators=(",", ":"))
response["body"] = {"string": body}
response["headers"]["content-length"] = [str(len(body.encode()))]
return cassette
Expand All @@ -43,17 +49,56 @@ def remove_response_cookies(response):


@pytest.fixture
def allocine_vcr(request):
def allocine_vcr(request, monkeypatch):
recorder = vcr.VCR(
cassette_library_dir=str(CASSETTES),
before_record_response=remove_response_cookies,
decode_compressed_response=True,
filter_headers=["cookie"],
record_mode="once",
)
recorder.register_serializer("json_body", JsonBodySerializer)
recorder.serializer = "json_body"

cassette_name = f"{request.node.name.removeprefix('test_')}.json"
with recorder.use_cassette(cassette_name):
yield recorder
allocine = request.getfixturevalue("allocine")
original_fetch = allocine._client._fetch

with recorder.use_cassette(cassette_name) as cassette:

def fetch(url, expected_status, *args, not_found_ok=False, **kwargs):
query = urlencode(sorted((kwargs.get("params") or {}).items()), doseq=True)
uri = f"{url}?{query}" if query else url
recorded_request = Request("GET", uri, None, kwargs.get("headers") or {})

if cassette.write_protected:
recorded = cassette.play_response(recorded_request)
body = recorded["body"]["string"]
response = HttpResponse(
recorded["status"]["code"],
{name: values[0] for name, values in recorded["headers"].items()},
body if isinstance(body, bytes) else body.encode(),
)
else:
response = original_fetch(url, expected_status, *args, not_found_ok=not_found_ok, **kwargs)
cassette.append(
recorded_request,
{
"status": {
"code": response.status_code,
"message": HTTPStatus(response.status_code).phrase,
},
"headers": {name: [value] for name, value in response.headers.items()},
"body": {"string": response.content.decode("utf-8", errors="replace")},
},
)

if response.status_code not in {expected_status, 304} and not (
not_found_ok and response.status_code == 404
):
raise ValueError(
f"{url!r} : expected status {expected_status}, received {response.status_code}",
)
return response

monkeypatch.setattr(allocine._client, "_fetch", fetch)
yield cassette
9 changes: 4 additions & 5 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import httpx2
import pytest
from diskcache import Cache

from allocine.cache import CACHE_MISS, HttpCache
from allocine.cache import CACHE_MISS, HttpCache, HttpResponse

CACHE_CONTROL = "max-age=3600, public, stale-if-error=172800, stale-while-revalidate=86400"


def response(cache_control: str = CACHE_CONTROL) -> httpx2.Response:
result = httpx2.Response(
200,
def response(cache_control: str = CACHE_CONTROL) -> HttpResponse:
result = HttpResponse(
status_code=200,
headers={"cache-control": cache_control, "age": "300", "last-modified": "Sun, 27 Sep 2026 20:55:44 GMT"},
content=b'{"results": []}',
)
Expand Down
Loading
Loading