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/client.py ADDED
@@ -0,0 +1,241 @@
1
+ import json
2
+ import re
3
+ import unicodedata
4
+ from datetime import date as Date
5
+ from datetime import datetime, timedelta
6
+ from os import PathLike
7
+
8
+ import jmespath
9
+ from parsel import Selector
10
+
11
+ from allocine.api import AllocineApi
12
+ from allocine.cache import CacheOption
13
+ from allocine.models import MovieVersion, Showtime, Theater
14
+
15
+ DEFAULT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
16
+
17
+
18
+ class Allocine:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ cache: CacheOption = True,
23
+ cache_dir: str | PathLike[str] | None = None,
24
+ ):
25
+ self._client = AllocineApi(cache=cache, cache_dir=cache_dir)
26
+
27
+ def get_theater(self, theater_id: str) -> Theater:
28
+ resp = self._client.get_showtimelist_by_theater_id(theater_id=theater_id)
29
+ if not resp.get("results"):
30
+ raise ValueError(f"Theater not found. Is theater id {theater_id!r} correct?")
31
+
32
+ movie_id: int | None = jmespath.search("results[0].movie.internalId", resp)
33
+ assert movie_id is not None, "We need at least one showtime to get details about a theater"
34
+
35
+ return self._get_theater_details_from_movie_id(theater_id, movie_id)
36
+
37
+ def get_showtimes(
38
+ self,
39
+ theater_id: str,
40
+ from_date: Date | None = None,
41
+ to_date: Date | None = None,
42
+ ) -> list[Showtime]:
43
+ today = Date.today()
44
+ from_date = max(from_date or today, today)
45
+ to_date = to_date or from_date
46
+ if from_date > to_date:
47
+ raise ValueError("from_date must be before or equal to to_date")
48
+
49
+ showtimes = []
50
+ requested_date = from_date
51
+ while requested_date <= to_date:
52
+ response = self._client.get_showtimelist_by_theater_id(
53
+ theater_id=theater_id,
54
+ date=requested_date,
55
+ )
56
+ day_showtimes = self._parse_showtimes(response.get("results") or [])
57
+ for page in range(2, (jmespath.search("pagination.totalPages", response) or 1) + 1):
58
+ response = self._client.get_showtimelist_by_theater_id(
59
+ theater_id=theater_id,
60
+ date=requested_date,
61
+ page=page,
62
+ )
63
+ day_showtimes.extend(self._parse_showtimes(response.get("results") or []))
64
+
65
+ showtimes.extend(showtime for showtime in day_showtimes if showtime.date == requested_date)
66
+ requested_date += timedelta(days=1)
67
+
68
+ return showtimes
69
+
70
+ def search_theaters(self, geocode: int | str) -> list[Theater]:
71
+ theaters = []
72
+ page = 1
73
+ while True:
74
+ selector = Selector(text=self._client.get_theaterlist_by_geocode(geocode=geocode, page=page))
75
+ theaters.extend(_parse_theaters(selector))
76
+ if not selector.css(".button-right:not(.button-disabled)"):
77
+ return theaters
78
+ page += 1
79
+
80
+ def clear_cache(self) -> int:
81
+ return self._client.clear_cache()
82
+
83
+ def close(self) -> None:
84
+ self._client.close()
85
+
86
+ def __enter__(self):
87
+ return self
88
+
89
+ def __exit__(self, exc_type, exc_value, traceback):
90
+ self.close()
91
+
92
+ def _get_theater_details_from_movie_id(self, theater_id: str, movie_id: int) -> Theater:
93
+ operation = self._client.get_showtimes_by_movie_and_theater_id(
94
+ movie_id=movie_id,
95
+ theater_id=theater_id,
96
+ )
97
+ raw_theater = operation["results"]["theater"]
98
+ if raw_theater is None:
99
+ return self._get_theater_details_from_page(theater_id)
100
+
101
+ location = raw_theater["location"]
102
+ return Theater(
103
+ theater_id=raw_theater["internalId"],
104
+ name=raw_theater["name"],
105
+ address=location["address"],
106
+ zipcode=location["zip"],
107
+ city=location["city"],
108
+ )
109
+
110
+ def _get_theater_details_from_page(self, theater_id: str) -> Theater:
111
+ selector = Selector(text=self._client.get_theater_page(theater_id))
112
+ for raw_data in selector.css('script[type="application/ld+json"]::text').getall():
113
+ theater_data = json.loads(raw_data)
114
+ if theater_data.get("@type") != "MovieTheater":
115
+ continue
116
+
117
+ address = theater_data.get("address") or {}
118
+ return Theater(
119
+ theater_id=theater_id,
120
+ name=theater_data["name"],
121
+ address=address.get("streetAddress") or "",
122
+ zipcode=address.get("postalCode") or "",
123
+ city=address.get("addressLocality") or "",
124
+ )
125
+
126
+ raise ValueError(f"Theater details not found for theater id {theater_id!r}")
127
+
128
+ def _parse_showtimes(self, raw_showtimes: list[dict]):
129
+ showtimes = []
130
+ for showtime_data in raw_showtimes:
131
+ raw_movie = showtime_data.get("movie")
132
+ if not raw_movie:
133
+ continue
134
+ duration_match = re.fullmatch(r"(?:(\d+)h)?\s*(?:(\d+)min)?", raw_movie.get("runtime") or "")
135
+ duration_obj = None
136
+ if duration_match and any(duration_match.groups()):
137
+ hours, minutes = duration_match.groups(default="0")
138
+ duration_obj = timedelta(hours=int(hours), minutes=int(minutes))
139
+
140
+ rating = jmespath.search("stats.userRating.score", raw_movie)
141
+ try:
142
+ rating = float(rating)
143
+ except (ValueError, TypeError):
144
+ rating = None
145
+
146
+ countries = [country.get("localizedName") for country in raw_movie.get("countries") or []]
147
+ countries = [country for country in countries if country]
148
+ if year := jmespath.search("data.productionYear", raw_movie):
149
+ year = int(year)
150
+
151
+ directors = []
152
+ for credit in sorted(raw_movie.get("credits") or [], key=lambda item: item.get("rank") or 0):
153
+ if jmespath.search("position.name", credit) == "DIRECTOR":
154
+ if name := _person_name(credit.get("person")):
155
+ directors.append(name)
156
+
157
+ actors = []
158
+ cast = jmespath.search("cast.nodes", raw_movie) or []
159
+ for cast_member in sorted(cast, key=lambda item: item.get("rank") or 0):
160
+ person = (
161
+ cast_member.get("actor") or cast_member.get("originalVoiceActor") or cast_member.get("voiceActor")
162
+ )
163
+ if name := _person_name(person):
164
+ actors.append(name)
165
+
166
+ genres = ", ".join(genre["translate"] for genre in raw_movie.get("genres") or [] if genre.get("translate"))
167
+ for showtime_group in (showtime_data.get("showtimes") or {}).values():
168
+ for raw_showtime in showtime_group:
169
+ tags = raw_showtime.get("tags") or []
170
+ language = (
171
+ "Français"
172
+ if "Localization.Language.French" in tags
173
+ or raw_showtime.get("diffusionVersion") in {"DUBBED", "LOCAL"}
174
+ else "Version originale"
175
+ )
176
+ screen_formats = [
177
+ {"E_4DX": "4DX", "PLF": "PLF"}.get(value, value)
178
+ for value in raw_showtime.get("experience") or []
179
+ ]
180
+ screen_formats.extend(
181
+ {"DIGITAL": "Numérique", "IMAX": "IMAX", "F_3D": "3D"}.get(value, value)
182
+ for value in raw_showtime.get("projection") or []
183
+ if value != "DIGITAL" or not screen_formats
184
+ )
185
+
186
+ if synopsis := raw_movie.get("synopsis"):
187
+ synopsis = re.sub(r"<.*?>", "", synopsis)
188
+ synopsis = synopsis.replace("\xa0", " ") # Remove HTML tags (ex: <span>)
189
+ synopsis = unicodedata.normalize("NFKD", synopsis)
190
+
191
+ movie = MovieVersion(
192
+ movie_id=raw_movie["internalId"],
193
+ title=raw_movie.get("title"),
194
+ rating=rating,
195
+ language=language,
196
+ screen_format=" ".join(screen_formats) or "Numérique",
197
+ synopsis=synopsis,
198
+ original_title=raw_movie.get("originalTitle"),
199
+ year=year,
200
+ countries=countries,
201
+ genres=genres,
202
+ directors=", ".join(directors),
203
+ actors=", ".join(actors),
204
+ duration=duration_obj,
205
+ )
206
+ showtimes.append(
207
+ Showtime(
208
+ date_time=datetime.strptime(raw_showtime["startsAt"], DEFAULT_DATE_FORMAT),
209
+ movie=movie,
210
+ )
211
+ )
212
+ return showtimes
213
+
214
+
215
+ def _person_name(person):
216
+ if not person:
217
+ return None
218
+ return " ".join(part for part in (person.get("firstName"), person.get("lastName")) if part)
219
+
220
+
221
+ def _parse_theaters(selector: Selector) -> list[Theater]:
222
+ theaters = []
223
+ for card in selector.css("div.theater-card"):
224
+ theater_json = card.css(".add-theater-anchor[data-theater]::attr(data-theater)").get()
225
+ if not theater_json:
226
+ continue
227
+
228
+ theater_data = json.loads(theater_json)
229
+ full_address = " ".join((card.css("address.address").xpath("string()").get() or "").split())
230
+ address_match = re.fullmatch(r"(.*)\s+(\d{5})\s+(.+)", full_address)
231
+ address, zipcode, city = address_match.groups() if address_match else (full_address, "", "")
232
+ theaters.append(
233
+ Theater(
234
+ theater_id=theater_data["id"],
235
+ name=theater_data["name"],
236
+ address=address,
237
+ zipcode=zipcode,
238
+ city=city,
239
+ )
240
+ )
241
+ return theaters
allocine/models.py ADDED
@@ -0,0 +1,233 @@
1
+ import logging
2
+ import unicodedata
3
+ from dataclasses import dataclass
4
+ from datetime import date as Date
5
+ from datetime import datetime, timedelta
6
+ from datetime import time as Time
7
+ from typing import List, Optional
8
+
9
+ from allocine import nationalities
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class Movie:
16
+ movie_id: int
17
+ title: str
18
+ original_title: str
19
+ rating: Optional[float]
20
+ duration: Optional[timedelta]
21
+ genres: str
22
+ countries: List[str]
23
+ directors: str
24
+ actors: str
25
+ synopsis: str
26
+ year: int
27
+
28
+ @property
29
+ def duration_str(self):
30
+ if self.duration is not None:
31
+ return _strfdelta(self.duration, "{hours:02d}h{minutes:02d}")
32
+ else:
33
+ return "HH:MM"
34
+
35
+ @property
36
+ def duration_short_str(self) -> str:
37
+ if self.duration is not None:
38
+ return _strfdelta(self.duration, "{hours:d}h{minutes:02d}")
39
+ else:
40
+ return "NA"
41
+
42
+ @property
43
+ def rating_str(self):
44
+ return "{0:.1f}".format(self.rating) if self.rating else ""
45
+
46
+ @property
47
+ def nationalities(self):
48
+ """Return the nationality tuples, from the movie countries.
49
+ Example: if self.countries = ['France'] => [('français', 'française')]
50
+ """
51
+ if self.countries:
52
+ nationality_tuples = []
53
+ for country_name in self.countries:
54
+ normalized_country_name = _strip_accents(country_name).lower()
55
+ country_code = nationalities.countries.get(normalized_country_name)
56
+
57
+ if country_code is not None:
58
+ nationality_tuples.append(nationalities.nationalities[country_code])
59
+ else:
60
+ logger.warning(f"Country {country_name!r} not found in nationalities")
61
+ nationality_tuples.append((f"de {country_name}", f"de {country_name}"))
62
+ return nationality_tuples
63
+ else:
64
+ return None
65
+
66
+ def __str__(self):
67
+ return f"{self.title} [{self.movie_id}] ({self.duration_str})"
68
+
69
+ def __eq__(self, other):
70
+ return (self.movie_id) == (other.movie_id)
71
+
72
+ def __hash__(self):
73
+ """This function allows us
74
+ to do a set(list_of_Movie_objects)"""
75
+ return hash(self.movie_id)
76
+
77
+
78
+ @dataclass
79
+ class MovieVersion(Movie):
80
+ language: str
81
+ screen_format: str
82
+
83
+ @property
84
+ def version(self):
85
+ version = "VF" if self.language == "Français" else "VOST"
86
+ if self.screen_format != "Numérique":
87
+ version += f" {self.screen_format}"
88
+ return version
89
+
90
+ def get_movie(self):
91
+ return Movie(
92
+ movie_id=self.movie_id,
93
+ title=self.title,
94
+ rating=self.rating,
95
+ duration=self.duration,
96
+ original_title=self.original_title,
97
+ year=self.year,
98
+ genres=self.genres,
99
+ countries=self.countries,
100
+ directors=self.directors,
101
+ actors=self.actors,
102
+ synopsis=self.synopsis,
103
+ )
104
+
105
+ def __str__(self):
106
+ movie_str = super().__str__()
107
+ return f"{movie_str} ({self.version})"
108
+
109
+ def __eq__(self, other):
110
+ return (self.movie_id, self.version) == (other.movie_id, other.version)
111
+
112
+ def __hash__(self):
113
+ """This function allows us
114
+ to do a set(list_of_MovieVersion_objects)"""
115
+ return hash((self.movie_id, self.version))
116
+
117
+
118
+ @dataclass
119
+ class Schedule:
120
+ date_time: datetime
121
+
122
+ @property
123
+ def date(self) -> Date:
124
+ return self.date_time.date()
125
+
126
+ @property
127
+ def hour(self) -> Time:
128
+ return self.date_time.time()
129
+
130
+ @property
131
+ def hour_str(self) -> str:
132
+ return self.date_time.strftime("%H:%M")
133
+
134
+ @property
135
+ def hour_short_str(self) -> str:
136
+ return get_hour_short_str(self.hour)
137
+
138
+ @property
139
+ def date_str(self) -> str:
140
+ return self.date_time.strftime("%d/%m/%Y %H:%M")
141
+
142
+ @property
143
+ def day_str(self) -> str:
144
+ return day_str(self.date)
145
+
146
+ @property
147
+ def short_day_str(self) -> str:
148
+ return short_day_str(self.date)
149
+
150
+
151
+ def get_hour_short_str(hour: Time) -> str:
152
+ # Ex: 9h, 11h, 23h30
153
+ # Minus in '%-H' removes the leading 0
154
+ return hour.strftime("%-Hh%M").replace("h00", "h")
155
+
156
+
157
+ @dataclass
158
+ class Showtime(Schedule):
159
+ movie: MovieVersion
160
+
161
+ def __str__(self):
162
+ return f"{self.date_str} : {self.movie}"
163
+
164
+
165
+ def day_str(date: Date) -> str:
166
+ return to_french_weekday(date.weekday())
167
+
168
+
169
+ def to_french_weekday(weekday: int) -> str:
170
+ days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
171
+ return days[weekday]
172
+
173
+
174
+ def get_french_month(month_number: int) -> str:
175
+ months = [
176
+ "Janvier",
177
+ "Février",
178
+ "Mars",
179
+ "Avril",
180
+ "Mai",
181
+ "Juin",
182
+ "Juillet",
183
+ "Août",
184
+ "Septembre",
185
+ "Octobre",
186
+ "Novembre",
187
+ "Décembre",
188
+ ]
189
+ return months[month_number - 1]
190
+
191
+
192
+ def to_french_short_weekday(weekday: int) -> str:
193
+ return to_french_weekday(weekday)[:3]
194
+
195
+
196
+ def short_day_str(date: Date) -> str:
197
+ return day_str(date)[:3]
198
+
199
+
200
+ @dataclass
201
+ class Theater:
202
+ theater_id: str
203
+ name: str
204
+ address: str
205
+ zipcode: str
206
+ city: str
207
+
208
+ @property
209
+ def address_str(self):
210
+ address_str = f"{self.address}, " if self.address else ""
211
+ address_str += f"{self.zipcode} {self.city}"
212
+ return address_str
213
+
214
+ def __eq__(self, other):
215
+ return (self.theater_id) == (other.theater_id)
216
+
217
+ def __hash__(self):
218
+ """This function allows us to do a set(list_of_Theaters_objects)"""
219
+ return hash(self.theater_id)
220
+
221
+
222
+ def _strfdelta(tdelta, fmt):
223
+ """Format a timedelta object"""
224
+ # Thanks to https://stackoverflow.com/questions/8906926
225
+ d = {"days": tdelta.days}
226
+ d["hours"], rem = divmod(tdelta.seconds, 3600)
227
+ d["minutes"], d["seconds"] = divmod(rem, 60)
228
+ return fmt.format(**d)
229
+
230
+
231
+ def _strip_accents(s):
232
+ # https://stackoverflow.com/a/518232/8748757
233
+ return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")