allocine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
allocine/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """Top-level package for Allociné."""
2
+
3
+ from importlib.metadata import version as _distribution_version
4
+
5
+ from allocine.api import AllocineApi
6
+ from allocine.client import DEFAULT_DATE_FORMAT, Allocine
7
+ from allocine.models import (
8
+ Movie,
9
+ MovieVersion,
10
+ Schedule,
11
+ Showtime,
12
+ Theater,
13
+ day_str,
14
+ get_french_month,
15
+ get_hour_short_str,
16
+ short_day_str,
17
+ to_french_short_weekday,
18
+ to_french_weekday,
19
+ )
20
+ from allocine.schedules import (
21
+ build_program_str,
22
+ build_weekly_schedule_str,
23
+ check_schedules_within_week,
24
+ create_weekdays_str,
25
+ get_available_dates,
26
+ get_showtimes_of_a_day,
27
+ group_showtimes_per_schedule,
28
+ )
29
+
30
+ __author__ = "Thibault Ducret"
31
+ __email__ = "hello@tducret.com"
32
+ __version__ = _distribution_version("allocine")
33
+
34
+ __all__ = [
35
+ "Allocine",
36
+ "AllocineApi",
37
+ "DEFAULT_DATE_FORMAT",
38
+ "Movie",
39
+ "MovieVersion",
40
+ "Schedule",
41
+ "Showtime",
42
+ "Theater",
43
+ "build_program_str",
44
+ "build_weekly_schedule_str",
45
+ "check_schedules_within_week",
46
+ "create_weekdays_str",
47
+ "day_str",
48
+ "get_available_dates",
49
+ "get_french_month",
50
+ "get_hour_short_str",
51
+ "get_showtimes_of_a_day",
52
+ "group_showtimes_per_schedule",
53
+ "short_day_str",
54
+ "to_french_short_weekday",
55
+ "to_french_weekday",
56
+ ]
allocine/api.py ADDED
@@ -0,0 +1,132 @@
1
+ from datetime import date as Date
2
+ from os import PathLike
3
+
4
+ import backoff
5
+ import httpx2
6
+
7
+ from allocine.cache import CACHE_MISS, CachedResponse, CacheOption, HttpCache
8
+
9
+ SHOWTIMES_BASE_URL = "https://www.allocine.fr/_/showtimes"
10
+ THEATERS_BASE_URL = "https://www.allocine.fr/salle/cinema"
11
+
12
+
13
+ class AllocineApi:
14
+ """Client to process requests with the Allocine APIs."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ cache: CacheOption = False,
20
+ cache_dir: str | PathLike[str] | None = None,
21
+ ):
22
+ headers = {
23
+ "User-Agent": "Mozilla/5.0 (Macintosh; \
24
+ Intel Mac OS X 10.14; rv:63.0) \
25
+ Gecko/20100101 Firefox/63.0",
26
+ }
27
+ self.session = httpx2.Client(headers=headers, timeout=30)
28
+ self.cache = HttpCache(cache, cache_dir)
29
+
30
+ def get_showtimelist_by_theater_id(
31
+ self,
32
+ theater_id: str,
33
+ page: int | None = None,
34
+ date: Date | None = None,
35
+ ):
36
+ url = f"{SHOWTIMES_BASE_URL}/theater-{theater_id}/"
37
+ if date:
38
+ url += f"d-{date.isoformat()}/"
39
+ if page:
40
+ url += f"p-{page}/"
41
+
42
+ response = self._request(url, 200, not_found_ok=True)
43
+ if response.status_code == 404:
44
+ return {"results": []}
45
+ return response.json()
46
+
47
+ def get_showtimes_by_movie_and_theater_id(
48
+ self,
49
+ movie_id: int,
50
+ theater_id: str,
51
+ ):
52
+ url = f"{SHOWTIMES_BASE_URL}/ope/movie-{movie_id}/theater-{theater_id}/"
53
+ return self._get(url)
54
+
55
+ def get_theater_page(self, theater_id: str) -> str:
56
+ url = f"https://www.allocine.fr/seance/salle_gen_csalle={theater_id}.html"
57
+ return self._get_text(url)
58
+
59
+ def get_theaterlist_by_geocode(self, geocode: int | str, page: int = 1) -> str:
60
+ url = f"{THEATERS_BASE_URL}/ville-{geocode}/"
61
+ params = {"page": page} if page > 1 else None
62
+ return self._get_text(url, params=params)
63
+
64
+ def clear_cache(self) -> int:
65
+ return self.cache.clear()
66
+
67
+ def close(self) -> None:
68
+ self.session.close()
69
+ self.cache.close()
70
+
71
+ def __enter__(self):
72
+ return self
73
+
74
+ def __exit__(self, exc_type, exc_value, traceback):
75
+ self.close()
76
+
77
+ def _get(self, url: str, expected_status: int = 200, *args, **kwargs) -> dict:
78
+ return self._request(url, expected_status, *args, **kwargs).json()
79
+
80
+ def _get_text(self, url: str, expected_status: int = 200, *args, **kwargs) -> str:
81
+ return self._request(url, expected_status, *args, **kwargs).text
82
+
83
+ def _request(
84
+ self,
85
+ url: str,
86
+ expected_status: int,
87
+ *args,
88
+ not_found_ok: bool = False,
89
+ **kwargs,
90
+ ) -> httpx2.Response:
91
+ params = kwargs.get("params")
92
+ cached = self.cache.get(url, params)
93
+ if cached is not CACHE_MISS and cached.is_fresh():
94
+ return cached.to_response()
95
+
96
+ if cached is not CACHE_MISS:
97
+ headers = dict(kwargs.get("headers") or {})
98
+ if etag := cached.headers.get("etag"):
99
+ headers["If-None-Match"] = etag
100
+ if last_modified := cached.headers.get("last-modified"):
101
+ headers["If-Modified-Since"] = last_modified
102
+ kwargs["headers"] = headers
103
+
104
+ try:
105
+ response = self._fetch(url, expected_status, *args, not_found_ok=not_found_ok, **kwargs)
106
+ except (ValueError, httpx2.HTTPError):
107
+ if cached is not CACHE_MISS and cached.can_serve_on_error():
108
+ return cached.to_response()
109
+ raise
110
+
111
+ if response.status_code == 304 and cached is not CACHE_MISS:
112
+ response = self._revalidated_response(cached, response)
113
+ self.cache.set(url, response, params)
114
+ return response
115
+
116
+ @backoff.on_exception(backoff.expo, ValueError, max_tries=5, max_time=30)
117
+ def _fetch(
118
+ self,
119
+ url: str,
120
+ expected_status: int,
121
+ *args,
122
+ not_found_ok: bool = False,
123
+ **kwargs,
124
+ ) -> httpx2.Response:
125
+ ret = self.session.get(url, *args, **kwargs)
126
+ if ret.status_code not in {expected_status, 304} and not (not_found_ok and ret.status_code == 404):
127
+ raise ValueError("{!r} : expected status {}, received {}".format(url, expected_status, ret.status_code))
128
+ return ret
129
+
130
+ @staticmethod
131
+ def _revalidated_response(cached: CachedResponse, response: httpx2.Response) -> httpx2.Response:
132
+ return cached.to_response(response.headers)
allocine/cache.py ADDED
@@ -0,0 +1,134 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from os import PathLike
4
+ from time import time
5
+ from urllib.parse import urlencode
6
+ from weakref import finalize
7
+
8
+ import httpx2
9
+ from diskcache import Cache
10
+ from platformdirs import user_cache_path
11
+
12
+ CACHE_VERSION = 3
13
+ CACHE_MISS = object()
14
+
15
+ CacheOption = bool | Cache
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CachedResponse:
20
+ status_code: int
21
+ headers: dict[str, str]
22
+ content: bytes
23
+ stored_at: float
24
+ initial_age: int
25
+ max_age: int
26
+ stale_if_error: int
27
+ stale_while_revalidate: int
28
+
29
+ def age(self, now: float | None = None) -> float:
30
+ return self.initial_age + (time() if now is None else now) - self.stored_at
31
+
32
+ def is_fresh(self, now: float | None = None) -> bool:
33
+ return self.age(now) < self.max_age
34
+
35
+ def can_serve_on_error(self, now: float | None = None) -> bool:
36
+ return self.age(now) < self.max_age + self.stale_if_error
37
+
38
+ def retention(self) -> float:
39
+ stale = max(self.stale_if_error, self.stale_while_revalidate)
40
+ return self.max_age + stale - self.initial_age
41
+
42
+ def to_response(self, revalidation_headers: Mapping[str, str] | None = None) -> httpx2.Response:
43
+ headers = dict(self.headers)
44
+ if revalidation_headers is not None:
45
+ headers.pop("age", None)
46
+ headers.update(revalidation_headers)
47
+ return httpx2.Response(self.status_code, headers=_decoded_headers(headers), content=self.content)
48
+
49
+
50
+ class HttpCache:
51
+ def __init__(
52
+ self,
53
+ cache: CacheOption = False,
54
+ cache_dir: str | PathLike[str] | None = None,
55
+ ) -> None:
56
+ if cache_dir is not None and cache is not True:
57
+ raise ValueError("cache_dir can only be used when cache=True")
58
+
59
+ self._owns_backend = cache is True
60
+ self._backend = (
61
+ Cache(cache_dir or user_cache_path("allocine")) if cache is True else None if cache is False else cache
62
+ )
63
+ self._finalizer = (
64
+ finalize(self, self._backend.close) if self._owns_backend and self._backend is not None else None
65
+ )
66
+
67
+ def get(self, url: str, params: dict | None = None):
68
+ if self._backend is None:
69
+ return CACHE_MISS
70
+ return self._backend.get(self._key(url, params), default=CACHE_MISS)
71
+
72
+ def set(self, url: str, response: httpx2.Response, params: dict | None = None) -> None:
73
+ if self._backend is None or (cached := _to_cached_response(response)) is None:
74
+ return
75
+ if (retention := cached.retention()) > 0:
76
+ self._backend.set(self._key(url, params), cached, expire=retention)
77
+
78
+ def clear(self) -> int:
79
+ return self._backend.clear() if self._backend is not None else 0
80
+
81
+ def close(self) -> None:
82
+ if self._finalizer is not None:
83
+ self._finalizer()
84
+
85
+ @staticmethod
86
+ def _key(url: str, params: dict | None) -> str:
87
+ query = urlencode(sorted((params or {}).items()), doseq=True)
88
+ return f"http:v{CACHE_VERSION}:{url}?{query}"
89
+
90
+
91
+ def _to_cached_response(response: httpx2.Response) -> CachedResponse | None:
92
+ directives = _parse_cache_control(response.headers.get("cache-control", ""))
93
+ if "public" not in directives or "no-store" in directives or "private" in directives:
94
+ return None
95
+ if (max_age := _seconds(directives, "max-age")) is None:
96
+ return None
97
+
98
+ return CachedResponse(
99
+ status_code=response.status_code,
100
+ headers=_decoded_headers(response.headers),
101
+ content=response.content,
102
+ stored_at=time(),
103
+ initial_age=_header_seconds(response.headers.get("age")) or 0,
104
+ max_age=max_age,
105
+ stale_if_error=_seconds(directives, "stale-if-error") or 0,
106
+ stale_while_revalidate=_seconds(directives, "stale-while-revalidate") or 0,
107
+ )
108
+
109
+
110
+ def _parse_cache_control(value: str) -> dict[str, str | None]:
111
+ directives = {}
112
+ for item in value.split(","):
113
+ name, separator, directive_value = item.strip().partition("=")
114
+ if name:
115
+ directives[name.lower()] = directive_value.strip('"') if separator else None
116
+ return directives
117
+
118
+
119
+ def _decoded_headers(headers: Mapping[str, str]) -> dict[str, str]:
120
+ decoded_headers = dict(headers)
121
+ for name in ("content-encoding", "content-length", "transfer-encoding"):
122
+ decoded_headers.pop(name, None)
123
+ return decoded_headers
124
+
125
+
126
+ def _seconds(directives: dict[str, str | None], name: str) -> int | None:
127
+ return _header_seconds(directives.get(name))
128
+
129
+
130
+ def _header_seconds(value: str | None) -> int | None:
131
+ try:
132
+ return max(0, int(value)) if value is not None else None
133
+ except ValueError:
134
+ return None
allocine/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command-line interface for Allociné showtimes."""
2
+
3
+ from datetime import date, datetime, timedelta
4
+ from io import StringIO
5
+
6
+ import click
7
+ from rich import box
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from allocine.client import Allocine
12
+ from allocine.schedules import get_showtimes_of_a_day
13
+
14
+
15
+ def clear_cache(ctx, param, value):
16
+ if not value or ctx.resilient_parsing:
17
+ return
18
+ with Allocine(cache=True) as allocine:
19
+ removed = allocine.clear_cache()
20
+ click.echo(f"Cache vidé ({removed} entrée(s)).")
21
+ ctx.exit()
22
+
23
+
24
+ def extract_field_names(dict_list):
25
+ """Return a sorted list of field names from a dictionary list."""
26
+ field_names = []
27
+ for row_dict in dict_list:
28
+ field_names += row_dict.keys()
29
+ field_names = list(set(field_names))
30
+ return sorted(field_names)
31
+
32
+
33
+ @click.command()
34
+ @click.argument("id_cinema", type=str, required=True)
35
+ @click.option(
36
+ "--clear-cache",
37
+ is_flag=True,
38
+ is_eager=True,
39
+ expose_value=False,
40
+ callback=clear_cache,
41
+ help="vide entièrement le cache et quitte",
42
+ )
43
+ @click.option(
44
+ "--jour",
45
+ "-j",
46
+ type=str,
47
+ help="jour des séances souhaitées \
48
+ (au format DD/MM/YYYY ou +1 pour demain), par défaut : aujourd’hui",
49
+ )
50
+ @click.option(
51
+ "--semaine",
52
+ "-s",
53
+ is_flag=True,
54
+ help="affiche les séance pour les 7 prochains jours",
55
+ )
56
+ @click.option(
57
+ "--entrelignes",
58
+ "-e",
59
+ is_flag=True,
60
+ help="ajoute une ligne entre chaque film pour améliorer la lisibilité",
61
+ )
62
+ def main(id_cinema, entrelignes, jour=None, semaine=None):
63
+ """
64
+ Les séances de votre cinéma dans le terminal, avec
65
+ ID_CINEMA : identifiant du cinéma sur Allociné,
66
+ ex: C0159 pour l’UGC Ciné Cité Les Halles. Se trouve dans l’url :
67
+ http://allocine.fr/seance/salle_gen_csalle=<ID_CINEMA>.html
68
+ """
69
+ today = date.today()
70
+ allocine = Allocine(cache=True)
71
+
72
+ jours = []
73
+ if semaine is False:
74
+ if jour is None:
75
+ jours.append(today.strftime("%d/%m/%Y"))
76
+ elif jour[0] == "+":
77
+ delta_jours = int(jour[1:])
78
+ jour_obj = today + timedelta(days=delta_jours)
79
+ jours.append(jour_obj.strftime("%d/%m/%Y"))
80
+ else:
81
+ jours.append(jour)
82
+ else:
83
+ for delta in range(0, 7):
84
+ jour_obj = today + timedelta(days=delta)
85
+ jours.append(jour_obj.strftime("%d/%m/%Y"))
86
+
87
+ requested_dates = [datetime.strptime(jour, "%d/%m/%Y").date() for jour in jours]
88
+ showtimes = allocine.get_showtimes(
89
+ theater_id=id_cinema,
90
+ from_date=min(requested_dates),
91
+ to_date=max(requested_dates),
92
+ )
93
+ try:
94
+ theater = allocine.get_theater(theater_id=id_cinema)
95
+ except ValueError as error:
96
+ raise click.ClickException(str(error)) from None
97
+
98
+ print("{}, le ".format(theater.name), end="")
99
+ for jour in jours:
100
+ print(get_showtime_table(showtimes=showtimes, entrelignes=entrelignes, jour=jour))
101
+ print()
102
+
103
+
104
+ def get_showtime_table(showtimes, entrelignes, jour):
105
+ showtime_table = []
106
+
107
+ date_obj = datetime.strptime(jour, "%d/%m/%Y").date()
108
+ day_showtimes = get_showtimes_of_a_day(showtimes, date=date_obj)
109
+ movies_available_today = set(showtime.movie for showtime in day_showtimes)
110
+
111
+ for movie_version in movies_available_today:
112
+ title = movie_version.title
113
+ if len(title) >= 31: # On tronque les titres trop longs
114
+ title = title[:31] + "..."
115
+
116
+ # '*1_film' pour être sûr que cela soit la 1ère colonne
117
+ movie_row = {"*1_film": "{} ({}) - {}".format(title, movie_version.version, movie_version.duration_str)}
118
+
119
+ movie_row["*2_note"] = "{}*".format(movie_version.rating_str)
120
+
121
+ for showtime in day_showtimes:
122
+ if showtime.movie != movie_version:
123
+ continue
124
+ hour = showtime.hour_str.split(":")[0] # 11:15 => 11
125
+ movie_row[hour] = showtime.hour_str
126
+
127
+ showtime_table.append(movie_row)
128
+
129
+ seances = showtime_table
130
+
131
+ retour = "{}\n".format(jour)
132
+
133
+ if len(seances) <= 0:
134
+ retour += "Aucune séance"
135
+
136
+ else:
137
+ field_names = extract_field_names(seances)
138
+ table = Table(box=box.SQUARE, show_header=False, show_lines=entrelignes)
139
+ for field_name in field_names:
140
+ table.add_column(justify="left" if field_name == "*1_film" else "center", no_wrap=True)
141
+
142
+ for seances_film in sorted(seances, key=lambda seance: seance["*1_film"]):
143
+ table.add_row(*(seances_film.get(field_name, "") for field_name in field_names))
144
+
145
+ output = StringIO()
146
+ Console(file=output, color_system=None, width=1000, markup=False).print(table)
147
+ retour += output.getvalue().rstrip("\n")
148
+
149
+ return retour
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()