diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b5c32bb..0000000 --- a/.dockerignore +++ /dev/null @@ -1,107 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -# VSCode -.vscode/ \ No newline at end of file diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 79a16af..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1dc807f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + quality: + name: Quality checks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install uv and Python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + python-version: "3.14" + version: "0.12.19" + + - name: Install the project + run: uv sync --locked --extra dev + + - name: Lint + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + + - name: Type-check + run: uv run ty check + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install uv and Python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + version: "0.12.19" + + - name: Install the project + run: uv sync --locked --extra dev + + - name: Run tests + run: uv run pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5b8de34 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Publish release to PyPI + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+[ab][0-9]+" + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + env: + RELEASE_TAG: ${{ github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false + version: "0.12.19" + + - name: Build distributions + run: uv build --no-sources + + - name: Smoke-test wheel + run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py + + - name: Smoke-test source distribution + run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: distributions + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/allocine/ + permissions: + id-token: write + steps: + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false + version: "0.12.19" + + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: distributions + path: dist/ + + - name: Generate PEP 740 attestations + uses: astral-sh/attest-action@f589a42a7efb6fe400b4f400de60b4bc90390027 # v0.0.6 + + - name: Publish distributions + run: uv publish diff --git a/.gitignore b/.gitignore index b5c32bb..21eceb0 100644 --- a/.gitignore +++ b/.gitignore @@ -103,5 +103,10 @@ venv.bak/ # mypy .mypy_cache/ -# VSCode -.vscode/ \ No newline at end of file +# Ruff +.ruff_cache/ + +# VS Code workspace configuration +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..56f4141 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: .venv/bin/ruff check --fix + language: system + types_or: [python, pyi] + - id: ruff-format + name: ruff format + entry: .venv/bin/ruff format + language: system + types_or: [python, pyi] + - id: ty-check + name: ty check + entry: .venv/bin/ty check + language: system + pass_filenames: false + always_run: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 08d51fb..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python -python: - - '3.7' -before_script: -- pip install -r requirements.txt -- pip install coveralls -- pip install pytest-cov -install: -- pip install . -script: -- pytest -after_success: coveralls -deploy: - provider: pypi - user: thibdct - password: - secure: C8oVggIPJgI6OV35g+lP+cFfP7MMkJQYC1/a98I8wSjaW1GBzJdcZgqIGnyaWwWe4ihMtBhSCEF9mxk262o8cPvwwLhP/9Rve+X+dzJogBLSZ5x8Np7dbRcyMjlTqStNNGlIbijB8lX5HiZA+XA/Rs717DCQseilf1aJX3K9S9vq8+dfdgy9PGQDnWoOxV3DjCp/XtRaITpBr/K8g1bxdW2YB0Tp8NbtJbiSLAKZfXfCCDrdrtHt11SB+3BiczM2zS76Ir5zyr+PfJL3HRgNzuHq2K7afEFSca1ryLt4T+dQ3YSmj/Pxic9pmKbfcN1S4HdQK+IOZEpYIL94dqMie+92OlY5OIA+u1nlu8fGj1EZTD7nZ49EIVLRzqNCAC0iHAzJfNfFYsbwsbgXiYt2NeCRIRKwx71kvagJ2t3IjJnkBrHCwG+Atbzuo5MMOV8xicLItOBxTysDP2V9Wg2HJ85362wwFmLC4MxhRGa+YKtlKZFL3Z2vNor5wj+zLr/F9Ehd6AyObn9Fa6hJGc87+LTPLRmGOa/E4JvvaP55VUnWPaJu7/eyBROoWD+j+6aTlTpOSd7drPqlVwv69XgIfINU8wS5/Wh070IEGwrD9aSwxGnOdTZlTrQ9x2P7lEZYNK3TWfvmc4quFNd6zCkjm0GrfuHu32/QZmYm3F0V/mA= - on: - tags: true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9056685 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "astral-sh.ty", + "charliermarsh.ruff", + "ms-python.python" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a1e8166 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.languageServer": "None", + "ruff.configurationPreference": "filesystemFirst", + "ruff.interpreter": ["${workspaceFolder}/.venv/bin/python"], + "ruff.nativeServer": "on", + "ty.diagnosticMode": "workspace", + "ty.interpreter": ["${workspaceFolder}/.venv/bin/python"], + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + } +} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 1245e7c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.7-slim AS build-env - -# You can build the docker image with the command : -# docker build --no-cache -t seances . - -# You can create a container with : -# docker run -it --rm seances [ID_CINEMA] - -RUN pip install -U --no-cache-dir --target /app allocine \ -&& find /app | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf - -FROM gcr.io/distroless/python3-debian10 - -COPY --from=build-env /app /app - -ENV PYTHONPATH=/app -ENV LC_ALL=C.UTF-8 -ENV LANG=C.UTF-8 - -ENTRYPOINT ["python", "/app/bin/seances.py"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e506b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: check + +check: + .venv/bin/ruff check --fix . + .venv/bin/ruff format . + .venv/bin/ty check diff --git a/README.md b/README.md index 21565bb..f568c01 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # Allociné -[![Travis](https://img.shields.io/travis/tducret/allocine-python.svg)](https://travis-ci.org/tducret/allocine-python) -[![Coveralls github](https://img.shields.io/coveralls/github/tducret/allocine-python.svg)](https://coveralls.io/github/tducret/allocine-python) [![PyPI](https://img.shields.io/pypi/v/allocine.svg)](https://pypi.org/project/allocine/) -[![Docker Image size](https://img.shields.io/microbadger/image-size/thibdct/seances.svg)](https://hub.docker.com/r/thibdct/seances/) ![License](https://img.shields.io/github/license/tducret/allocine-python.svg) ![Cinéma](cinema.jpg) @@ -12,34 +9,32 @@ **Avec cet outil, vous récupérez les horaires des séances ciné directement dans le terminal**. -## Requirements +## Prérequis -- Python 3.7 and above -- pip3 +- Python 3.10 ou une version ultérieure +- [uv](https://docs.astral.sh/uv/) ## Installation ```bash -pip3 install -U allocine +uv tool install --upgrade allocine ``` -> You can also use it with Docker. Have a look at [this section](#docker) +## Utilisation en ligne de commande -## CLI tool usage +Commencez par rechercher l’identifiant de votre cinéma sur [allocine.fr](https://www.allocine.fr/). -You just need to look for your theater identifier on [allocine.fr](allocine.fr). +Recherchez votre cinéma et relevez son identifiant dans l’URL. Dans cet exemple, il s’agit de `P0645`. -Just search for your theater, and take note of the identifier in the URL. Here, it is `P0645`. +![Identifiant du cinéma](snapshot_theater_id.png) -![Theater identifier](snapshot_theater_id.png) +![Capture terminal](demo.gif) -![Capture terminal](capture.svg) - -#### Help +#### Aide ```bash -seances.py --help -Usage: seances.py [OPTIONS] ID_CINEMA +seances --help +Usage: seances [OPTIONS] ID_CINEMA Les séances de votre cinéma dans le terminal, avec ID_CINEMA : identifiant du cinéma sur Allociné, ex: C0159 pour l’UGC Ciné Cité Les Halles. Se @@ -55,10 +50,10 @@ Options: --help Show this message and exit. ``` -#### Basic usage +#### Utilisation simple ```bash -seances.py P2235 +seances P2235 27/12/2018 ┌──────────────────────────────────────────────────────────┬──────┬───────┬───────┬───────┬───────┐ @@ -69,10 +64,10 @@ seances.py P2235 └──────────────────────────────────────────────────────────┴──────┴───────┴───────┴───────┴───────┘ ``` -#### For tomorrow, with interlines +#### Pour demain, avec des interlignes ```bash -seances.py P2235 -j+1 --entrelignes +seances P2235 -j+1 --entrelignes 28/12/2018 ┌────────────────────────────────────────────────────┬──────┬───────┬───────┬───────┐ @@ -84,32 +79,31 @@ seances.py P2235 -j+1 --entrelignes └────────────────────────────────────────────────────┴──────┴───────┴───────┴───────┘ ``` -#### For a specific date +#### Pour une date précise ```bash -seances.py P2235 --jour 29/12/2018 +seances P2235 --jour 29/12/2018 ``` -#### For the full week +#### Pour toute la semaine ```bash -seances.py P2235 --semaine +seances P2235 --semaine ``` -## Package usage +## Utilisation de la bibliothèque ```python -# -*- coding: utf-8 -*- from allocine import Allocine -allocine = Allocine() -theater = allocine.get_theater("P2235") +with Allocine() as allocine: + showtimes = allocine.get_showtimes("P2235") -for showtime in theater.showtimes: +for showtime in showtimes: print(showtime) ``` -Example output : +Exemple de sortie : ```bash 27/12/2018 10:15 : Astérix - Le Secret de la Potion Magique [244560] (VF) (01h25) @@ -123,45 +117,66 @@ Example output : [...] ``` -# Docker +Le cache est activé par défaut. Les réponses sont enregistrées dans le répertoire de cache utilisateur du système : -You can use the `seances` tool with the [Docker image](https://hub.docker.com/r/thibdct/seances/) +- macOS: `~/Library/Caches/allocine` +- Linux: `${XDG_CACHE_HOME:-~/.cache}/allocine` +- Windows: `%LOCALAPPDATA%\\allocine\\Cache` -You may execute : +Videz le cache en ligne de commande (aucun identifiant de cinéma n’est nécessaire) : -`docker run -it --rm thibdct/seances P2235` +```bash +seances --clear-cache +``` -> The Docker image is built on top of [Google Distroless image](https://github.com/GoogleContainerTools/distroless), so it is tiny :) +## Développement -## 🤘 The easy way 🤘 +Créez l’environnement virtuel et installez le paquet avec ses dépendances de développement : -I also built a bash wrapper to execute the Docker container easily. +```bash +uv sync --extra dev +``` -Install it with : +Installez les hooks pre-commit une première fois, puis utilisez `make check` pour analyser, formater et vérifier les types +de l’ensemble du code : ```bash -curl -s https://raw.githubusercontent.com/tducret/allocine-python/master/seances \ -> /usr/local/bin/seances && chmod +x /usr/local/bin/seances +uv run pre-commit install +make check ``` -*You may replace `/usr/local/bin` with another folder that is in your $PATH* -Check that it works : - -*On the first execution, the script will download the Docker image, so please be patient* +Exécutez chaque vérification sans modifier les fichiers : ```bash -seances --help -seances P2235 -j+1 --entrelignes +uv run ruff check . +uv run ruff format --check . +uv run ty check ``` -You can upgrade the app with : +Exécutez manuellement tous les hooks pre-commit avec : ```bash -seances --upgrade +uv run pre-commit run --all-files ``` -and even uninstall with : +Les utilisateurs de VS Code doivent accepter les recommandations d’extensions de l’espace de travail afin d’activer les +diagnostics Ruff et ty en temps réel, ainsi que le formatage Ruff à l’enregistrement. + +## Publication + +Le workflow de publication publie sur PyPI les tags de version tels que `0.0.13` grâce à Trusted Publishing, sans +stocker de jeton d’API. Avant la première publication : + +1. Créez un environnement GitHub nommé `pypi`. Il est recommandé d’exiger une approbation avant tout déploiement. +2. Dans les paramètres de publication PyPI du projet `allocine`, ajoutez un Trusted Publisher GitHub avec le + propriétaire `tducret`, le dépôt `allocine-python`, le workflow `release.yml` et l’environnement `pypi`. + +Le tag Git définit la version du paquet ; aucun fichier de version ne doit être mis à jour. Ajoutez le tag au commit à +publier. Le workflow rejette les distributions dont la version ne correspond pas au tag et effectue un test rapide du +wheel et de la distribution source avant leur publication : ```bash -seances --uninstall +VERSION=0.0.13 +git tag -a ${VERSION} -m ${VERSION} +git push origin ${VERSION} ``` diff --git a/allocine/__init__.py b/allocine/__init__.py index de3c686..b30db02 100644 --- a/allocine/__init__.py +++ b/allocine/__init__.py @@ -1,649 +1,56 @@ -# -*- coding: utf-8 -*- - """Top-level package for Allociné.""" -from collections import OrderedDict -from dataclasses import dataclass -from datetime import datetime, timedelta, date, time -import logging -import re -from typing import List, Optional -import unicodedata - -import backoff -import jmespath -import requests - -from allocine import nationalities - -__author__ = """Thibault Ducret""" -__email__ = 'hello@tducret.com' -__version__ = '0.0.12' - -DEFAULT_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S' -BASE_URL = 'http://api.allocine.fr/rest/v3' -PARTNER_KEY = '000042532791' - -logger = logging.getLogger(__name__) - - -# === Models === -@dataclass -class Movie: - movie_id: int - title: str - original_title: str - rating: Optional[float] - duration: Optional[timedelta] - genres: str - countries: List[str] - directors: str - actors: str - synopsis: str - year: int - - @property - def duration_str(self): - if self.duration is not None: - return _strfdelta(self.duration, '{hours:02d}h{minutes:02d}') - else: - return 'HH:MM' - - @property - def duration_short_str(self) -> str: - if self.duration is not None: - return _strfdelta(self.duration, '{hours:d}h{minutes:02d}') - else: - return 'NA' - - @property - def rating_str(self): - return '{0:.1f}'.format(self.rating) if self.rating else '' - - @property - def nationalities(self): - """ Return the nationality tuples, from the movie countries. - Example: if self.countries = ['France'] => [('français', 'française')] - """ - if self.countries: - nationality_tuples = [] - for country_name in self.countries: - normalized_country_name = _strip_accents(country_name).lower() - country_code = nationalities.countries.get(normalized_country_name) - - if country_code is not None: - nationality_tuples.append(nationalities.nationalities[country_code]) - else: - logger.warning(f'Country {country_name!r} not found in nationalities') - nationality_tuples.append((f'de {country_name}', f'de {country_name}')) - return nationality_tuples - else: - return None - - def __str__(self): - return f'{self.title} [{self.movie_id}] ({self.duration_str})' - - def __eq__(self, other): - return (self.movie_id) == (other.movie_id) - - def __hash__(self): - """ This function allows us - to do a set(list_of_Movie_objects) """ - return hash(self.movie_id) - - -@dataclass -class MovieVersion(Movie): - language: str - screen_format: str - - @property - def version(self): - version = 'VF' if self.language == 'Français' else 'VOST' - if self.screen_format != 'Numérique': - version += f' {self.screen_format}' - return version - - def get_movie(self): - return Movie( - movie_id=self.movie_id, - title=self.title, - rating=self.rating, - duration=self.duration, - original_title=self.original_title, - year=self.year, - genres=self.genres, - countries=self.countries, - directors=self.directors, - actors=self.actors, - synopsis=self.synopsis, - ) - - def __str__(self): - movie_str = super().__str__() - return f'{movie_str} ({self.version})' - - def __eq__(self, other): - return (self.movie_id, self.version) == (other.movie_id, other.version) - - def __hash__(self): - """ This function allows us - to do a set(list_of_MovieVersion_objects) """ - return hash((self.movie_id, self.version)) - - -@dataclass -class Schedule: - date_time: datetime - - @property - def date(self) -> date: - return self.date_time.date() - - @property - def hour(self) -> datetime.time: - return self.date_time.time() - - @property - def hour_str(self) -> str: - return self.date_time.strftime('%H:%M') - - @property - def hour_short_str(self) -> str: - return get_hour_short_str(self.hour) - - @property - def date_str(self) -> date: - return self.date_time.strftime('%d/%m/%Y %H:%M') - - @property - def day_str(self) -> str: - return day_str(self.date) - - @property - def short_day_str(self) -> str: - return short_day_str(self.date) - - -def get_hour_short_str(hour: datetime.time) -> str: - # Ex: 9h, 11h, 23h30 - # Minus in '%-H' removes the leading 0 - return hour.strftime('%-Hh%M').replace('h00', 'h') - - -@dataclass -class Showtime(Schedule): - movie: MovieVersion - - def __str__(self): - return f'{self.date_str} : {self.movie}' - - -def day_str(date: date) -> str: - return to_french_weekday(date.weekday()) - - -def to_french_weekday(weekday: int) -> str: - DAYS = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] - return DAYS[weekday] - - -def get_french_month(month_number: int) -> str: - MONTHS = [ - 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', - 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre' - ] - return MONTHS[month_number-1] - - -def to_french_short_weekday(weekday: int) -> str: - return to_french_weekday(weekday)[:3] - - -def short_day_str(date: date) -> str: - return day_str(date)[:3] - - -@dataclass -class Theater: - theater_id: str - name: str - showtimes: List[Showtime] - address: str - zipcode: str - city: str - - @property - def address_str(self): - address_str = f'{self.address}, ' if self.address else '' - address_str += f'{self.zipcode} {self.city}' - return address_str - - def get_showtimes_of_a_movie(self, movie_version: MovieVersion, date: date = None): - movie_showtimes = [showtime for showtime in self.showtimes - if showtime.movie == movie_version] - if date: - return [showtime for showtime in movie_showtimes - if showtime.date == date] - else: - return movie_showtimes - - def get_showtimes_of_a_day(self, date: date): - return get_showtimes_of_a_day(showtimes=self.showtimes, date=date) - - def get_movies_available_for_a_day(self, date: date): - """ Returns a list of movies available on a specified day """ - movies = [showtime.movie for showtime in self.get_showtimes_of_a_day(date)] - return list(set(movies)) - - def get_showtimes_per_movie_version(self): - movies = {} - for showtime in self.showtimes: - if movies.get(showtime.movie) is None: - movies[showtime.movie] = [] - movies[showtime.movie].append(showtime) - return movies - - def get_showtimes_per_movie(self): - movies = {} - for showtime in self.showtimes: - movie = showtime.movie.get_movie() # Without language nor screen_format - if movies.get(movie) is None: - movies[movie] = [] - movies[movie].append(showtime) - return movies - - def get_program_per_movie(self): - program_per_movie = {} - for movie, showtimes in self.get_showtimes_per_movie().items(): - program_per_movie[movie] = build_program_str(showtimes=showtimes) - return program_per_movie - - def filter_showtimes(self, date_min: date = None, date_max: date = None): - if date_min: - self.showtimes = [s for s in self.showtimes if s.date >= date_min] - if date_max: - self.showtimes = [s for s in self.showtimes if s.date <= date_max] - - def __eq__(self, other): - return (self.theater_id) == (other.theater_id) - - def __hash__(self): - """ This function allows us to do a set(list_of_Theaters_objects) """ - return hash(self.theater_id) - - -# == Utils == -def get_available_dates(showtimes: List[Showtime]): - dates = [s.date for s in showtimes] - return sorted(list(set(dates))) - - -def group_showtimes_per_schedule(showtimes: List[Showtime]): - showtimes_per_date = {} - available_dates = get_available_dates(showtimes=showtimes) - for available_date in available_dates: - showtimes_per_date[available_date] = get_showtimes_of_a_day(showtimes=showtimes, date=available_date) - - grouped_showtimes = {} - for available_date in available_dates: - hours = [s.hour_short_str for s in showtimes_per_date[available_date]] - hours_str = ', '.join(hours) - if grouped_showtimes.get(hours_str) is None: - grouped_showtimes[hours_str] = [] - grouped_showtimes[hours_str].append(available_date) - return grouped_showtimes - - -def build_program_str(showtimes: List[Showtime]): - schedules = [Schedule(s.date_time) for s in showtimes] - return build_weekly_schedule_str(schedules) - - -def check_schedules_within_week(schedule_list: List[Schedule]) -> bool: - schedule_dates = [s.date for s in schedule_list] - min_date = min(schedule_dates) - max_date = max(schedule_dates) - delta = (max_date - min_date) - if delta >= timedelta(days=7): - raise ValueError( - 'Schedule list contains more days than the typical movie week') - # Check that the week is not from Mon/Tue to Wed/Thu/Fri/Sat/Sun - # because a typical week is from Wed to Tue - # but we need to handle the case of a schedule_list with only a few day - # ex: Wed, Mon = OK ; Tue = OK ; Mon, Wed : NOK - MONDAY = 0 - TUESDAY = 1 - WEDNESDAY = 2 - if delta > timedelta(days=0): - if (min_date.weekday() == MONDAY and max_date.weekday() >= WEDNESDAY) \ - or (min_date.weekday() == TUESDAY): - raise ValueError( - 'Schedule list should not start before wednesday or end after tuesday') - - return True - - -def create_weekdays_str(dates: List[date]) -> str: - """ - Returns a compact string from a list of dates. - Examples: - - [0,1] -> 'Lun, Mar' - - [0,1,2,3,4] -> 'sf Sam, Dim' - - [0,1,2,3,4,5,6] -> '' # Everyday is empty string - - [0,2] -> 'Mer, Lun' # And not 'Lun, Mer' because we sort chrologically - """ - FULL_WEEK = range(0, 7) - unique_dates = sorted(list(set(dates))) - week_days = [d.weekday() for d in unique_dates] - - if len(unique_dates) == 7: - return '' - elif len(unique_dates) <= 4: - return ', '.join([to_french_short_weekday(d) for d in week_days]) - else: - missing_days = list(set(week_days).symmetric_difference(FULL_WEEK)) - return 'sf {}'.format(', '.join([to_french_short_weekday(d) for d in missing_days])) - - -def __get_time_weight_in_list(d: dict) -> timedelta: - """ Returns the minimum time weight from the time list contained in the dict values - ex: {'key': [time(hour=12), time(hour=9)]} => timedelta(hour=9) - """ - weights = [__get_time_weight(t) for t in d[1]] - return min(weights) - - -def __get_time_weight(t: time) -> timedelta: - """ Return a timedelta taking into account night time. - Basically, it allows to sort a list of times 18h>23h>0h30 - and not 0h30>18h>23h - """ - _NIGHT_TIME = [time(hour=0), time(hour=5)] - delta = timedelta(hours=t.hour, minutes=t.minute) - if t >= min(_NIGHT_TIME) and t <= max(_NIGHT_TIME): - delta += timedelta(days=1) - return delta - - -def build_weekly_schedule_str(schedule_list: List[Schedule]) -> str: - check_schedules_within_week(schedule_list) - - _hours_hashmap = {} # ex: {16h: [Lun, Mar], 17h: [Lun], 17h30: [Lun]} - _grouped_date_hashmap = {} # ex: {[Lun]: [16h, 17h30], [Lun, Mar]: [17h]} - - for s in schedule_list: - - if _hours_hashmap.get(s.hour) is None: - _hours_hashmap[s.hour] = [] - _hours_hashmap[s.hour].append(s.date) - - for hour, grouped_dates in _hours_hashmap.items(): - grouped_dates_str = create_weekdays_str(grouped_dates) - if _grouped_date_hashmap.get(grouped_dates_str) is None: - _grouped_date_hashmap[grouped_dates_str] = [] - _grouped_date_hashmap[grouped_dates_str].append(hour) - - # Then sort it chronologically - for grouped_dates_str, hours in _grouped_date_hashmap.items(): - # Sort the hours inside - hours = list(set(hours)) - hours.sort() - _grouped_date_hashmap[grouped_dates_str] = hours - - grouped_date_hashmap = OrderedDict() - _grouped_date_hashmap = sorted(_grouped_date_hashmap.items(), key=__get_time_weight_in_list) - grouped_date_hashmap = OrderedDict(_grouped_date_hashmap) - - hours_hashmap = OrderedDict() - for t in sorted(_hours_hashmap.keys(), key=__get_time_weight): - hours_hashmap[t] = _hours_hashmap.get(t) - - different_showtimes = len(grouped_date_hashmap) - - # True if at least one schedule is available everyday - some_schedules_available_everyday = grouped_date_hashmap.get('') is not None - - weekly_schedule = '' - - if some_schedules_available_everyday: - for hour, grouped_dates in hours_hashmap.items(): - hour_str = get_hour_short_str(hour) - grouped_dates_str = create_weekdays_str(grouped_dates) - if grouped_dates_str: - weekly_schedule += f'{hour_str} ({grouped_dates_str}), ' - else: # Available everyday - weekly_schedule += f'{hour_str}, ' - else: - for grouped_dates, hours in grouped_date_hashmap.items(): - hours_str = ', '.join([get_hour_short_str(h) for h in hours]) - if different_showtimes == 1: - weekly_schedule += f'{grouped_dates} {hours_str}, ' - else: - if some_schedules_available_everyday: - weekly_schedule += f'{hours_str} ({grouped_dates}), ' - else: - weekly_schedule += f'{grouped_dates} {hours_str}; ' - - if weekly_schedule: - weekly_schedule = weekly_schedule[:-2] # Remove trailing comma - return weekly_schedule - - -def get_showtimes_of_a_day(showtimes: List[Showtime], *, date: date): - return [showtime for showtime in showtimes - if showtime.date == date] - - -# === Main class === -class Allocine: - def __init__(self, base_url=BASE_URL): - self.__client = Client(base_url=base_url) - self.__movie_store = {} # Dict to store the movie info (and avoid useless requests) - - def get_theater(self, theater_id: str): - ret = self.__client.get_showtimelist_by_theater_id(theater_id=theater_id) - if jmespath.search('feed.totalResults', ret) == 0: - raise ValueError(f'Theater not found. Is theater id {theater_id!r} correct?') - - theaters = self.__get_theaters_from_raw_showtimelist(raw_showtimelist=ret) - if len(theaters) != 1: - raise ValueError('Expecting 1 theater but received {}'.format(len(theaters))) - - return theaters[0] - - def __get_theaters_from_raw_showtimelist(self, raw_showtimelist: dict, distance_max_inclusive: int = 0): - theaters = [] - for theater_showtime in jmespath.search('feed.theaterShowtimes', raw_showtimelist): - raw_theater = jmespath.search('place.theater', theater_showtime) - - if raw_theater.get('distance') is not None: - # distance is not present when theater ids were used for search - if raw_theater.get('distance') > distance_max_inclusive: - # Skip theaters that are above the max distance specified - continue - - raw_showtimes = jmespath.search('movieShowtimes', theater_showtime) - showtimes = self.__parse_showtimes(raw_showtimes=raw_showtimes) - theater = Theater( - theater_id=raw_theater.get('code'), - name=raw_theater.get('name'), - address=raw_theater.get('address'), - zipcode=raw_theater.get('postalCode'), - city=raw_theater.get('city'), - showtimes=showtimes - ) - theaters.append(theater) - return theaters - - def search_theaters(self, geocode: int): - theaters = [] - page = 1 - while True: - ret = self.__client.get_showtimelist_from_geocode(geocode=geocode, page=page) - total_results = jmespath.search('feed.totalResults', ret) - if total_results == 0: - raise ValueError(f'Theater not found. Is geocode {geocode!r} correct?') - - theaters_to_parse = jmespath.search('feed.theaterShowtimes', ret) - if theaters_to_parse: - theaters += self.__get_theaters_from_raw_showtimelist( - raw_showtimelist=ret, - distance_max_inclusive=0 - ) - page += 1 - else: - break - - return theaters - - def __parse_showtimes(self, raw_showtimes: dict): - showtimes = [] - for s in raw_showtimes: - raw_movie = jmespath.search('onShow.movie', s) - language = jmespath.search('version."$"', s) - screen_format = jmespath.search('screenFormat."$"', s) - duration = raw_movie.get('runtime') - duration_obj = timedelta(seconds=duration) if duration else None - - rating = jmespath.search('statistics.userRating', raw_movie) - try: - rating = float(rating) - except (ValueError, TypeError): - rating = None - - movie_id = raw_movie.get('code') - movie_info = self.get_movie_info(movie_id) - countries = jmespath.search('nationality[]."$"', movie_info) - year = movie_info.get('productionYear') - if year: - year = int(year) - movie = MovieVersion( - movie_id=movie_id, - title=raw_movie.get('title'), - rating=rating, - language=language, - screen_format=screen_format, - synopsis=_clean_synopsis(movie_info.get('synopsis')), - original_title=movie_info.get('originalTitle'), - year=year, - countries=countries, - genres=', '.join(jmespath.search('genre[]."$"', movie_info)), - directors=jmespath.search('castingShort.directors', movie_info), - actors=jmespath.search('castingShort.actors', movie_info), - duration=duration_obj) - for showtimes_of_day in s.get('scr') or []: - day = showtimes_of_day.get('d') - for one_showtime in showtimes_of_day.get('t'): - datetime_str = '{}T{}:00'.format(day, one_showtime.get('$')) - datetime_obj = _str_datetime_to_datetime_obj(datetime_str) - showtime = Showtime( - date_time=datetime_obj, - movie=movie, - ) - showtimes.append(showtime) - return showtimes - - def get_movie_info(self, movie_id: int): - movie_info = self.__movie_store.get(movie_id) - if movie_info is None: - movie_info = self.__client.get_movie_info_by_id(movie_id).get('movie') - self.__movie_store[movie_id] = movie_info - return movie_info - - -# === Client to execute requests with Allociné APIs === -class SingletonMeta(type): - _instance = None - - def __call__(self, *args, **kwargs): - if self._instance is None: - self._instance = super().__call__(*args, **kwargs) - return self._instance - - -class Error503(Exception): - pass - - -class Client(metaclass=SingletonMeta): - """ Client to process the requests with allocine APIs. - This is a singleton to avoid the creation of a new session for every theater. - """ - def __init__(self, base_url): - self.base_url = base_url - headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; \ - Intel Mac OS X 10.14; rv:63.0) \ - Gecko/20100101 Firefox/63.0', - } - self.session = requests.session() - self.session.headers.update(headers) - - @backoff.on_exception(backoff.expo, Error503, max_tries=5, max_time=30) - def _get(self, url: str, expected_status: int = 200, *args, **kwargs): - ret = self.session.get(url, *args, **kwargs) - if ret.status_code != expected_status: - if ret.status_code == 503: - raise Error503 - raise ValueError('{!r} : expected status {}, received {}'.format( - url, expected_status, ret.status_code)) - return ret.json() - - def get_showtimelist_by_theater_id(self, theater_id: str, page: int = 1, count: int = 10): - url = ( - f'{self.base_url}/showtimelist?partner={PARTNER_KEY}&format=json' - f'&theaters={theater_id}&page={page}&count={count}' - ) - return self._get(url=url) - - def get_theater_info_by_id(self, theater_id: str): - url = f'{self.base_url}/theater?partner={PARTNER_KEY}&format=json&code={theater_id}' - return self._get(url=url) - - def get_showtimelist_from_geocode(self, geocode: int, page: int = 1, count: int = 10): - url = ( - f'{self.base_url}/showtimelist?partner={PARTNER_KEY}&format=json' - f'&geocode={geocode}&page={page}&count={count}' - ) - return self._get(url=url) - - def get_movie_info_by_id(self, movie_id: int): - url = ( - f'{self.base_url}/movie?partner={PARTNER_KEY}&format=json&code={movie_id}' - ) - return self._get(url=url) - - -def _strfdelta(tdelta, fmt): - """ Format a timedelta object """ - # Thanks to https://stackoverflow.com/questions/8906926 - d = {"days": tdelta.days} - d["hours"], rem = divmod(tdelta.seconds, 3600) - d["minutes"], d["seconds"] = divmod(rem, 60) - return fmt.format(**d) - - -def _str_datetime_to_datetime_obj(datetime_str, date_format=DEFAULT_DATE_FORMAT): - return datetime.strptime(datetime_str, date_format) - - -def _cleanhtml(raw_html): - cleanr = re.compile('<.*?>') - cleantext = re.sub(cleanr, '', raw_html) - return cleantext - - -def _clean_synopsis(raw_synopsis): - if raw_synopsis is None: - return None - - synopsis = _cleanhtml(raw_synopsis) # Remove HTML tags (ex: ) - synopsis = synopsis.replace('\xa0', ' ') - return unicodedata.normalize("NFKD", synopsis) - - -def _strip_accents(s): - # https://stackoverflow.com/a/518232/8748757 - return ''.join(c for c in unicodedata.normalize('NFD', s) - if unicodedata.category(c) != 'Mn') +from importlib.metadata import version as _distribution_version + +from allocine.api import AllocineApi +from allocine.client import DEFAULT_DATE_FORMAT, Allocine +from allocine.models import ( + Movie, + MovieVersion, + Schedule, + Showtime, + Theater, + day_str, + get_french_month, + get_hour_short_str, + short_day_str, + to_french_short_weekday, + to_french_weekday, +) +from allocine.schedules import ( + build_program_str, + build_weekly_schedule_str, + check_schedules_within_week, + create_weekdays_str, + get_available_dates, + get_showtimes_of_a_day, + group_showtimes_per_schedule, +) + +__author__ = "Thibault Ducret" +__email__ = "hello@tducret.com" +__version__ = _distribution_version("allocine") + +__all__ = [ + "Allocine", + "AllocineApi", + "DEFAULT_DATE_FORMAT", + "Movie", + "MovieVersion", + "Schedule", + "Showtime", + "Theater", + "build_program_str", + "build_weekly_schedule_str", + "check_schedules_within_week", + "create_weekdays_str", + "day_str", + "get_available_dates", + "get_french_month", + "get_hour_short_str", + "get_showtimes_of_a_day", + "group_showtimes_per_schedule", + "short_day_str", + "to_french_short_weekday", + "to_french_weekday", +] diff --git a/allocine/api.py b/allocine/api.py new file mode 100644 index 0000000..37a875b --- /dev/null +++ b/allocine/api.py @@ -0,0 +1,132 @@ +from datetime import date as Date +from os import PathLike + +import backoff +import httpx2 + +from allocine.cache import CACHE_MISS, CachedResponse, CacheOption, HttpCache + +SHOWTIMES_BASE_URL = "https://www.allocine.fr/_/showtimes" +THEATERS_BASE_URL = "https://www.allocine.fr/salle/cinema" + + +class AllocineApi: + """Client to process requests with the Allocine APIs.""" + + def __init__( + self, + *, + 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.cache = HttpCache(cache, cache_dir) + + def get_showtimelist_by_theater_id( + self, + theater_id: str, + page: int | None = None, + date: Date | None = None, + ): + url = f"{SHOWTIMES_BASE_URL}/theater-{theater_id}/" + if date: + url += f"d-{date.isoformat()}/" + if page: + url += f"p-{page}/" + + response = self._request(url, 200, not_found_ok=True) + if response.status_code == 404: + return {"results": []} + return response.json() + + def get_showtimes_by_movie_and_theater_id( + self, + movie_id: int, + theater_id: str, + ): + url = f"{SHOWTIMES_BASE_URL}/ope/movie-{movie_id}/theater-{theater_id}/" + return self._get(url) + + def get_theater_page(self, theater_id: str) -> str: + url = f"https://www.allocine.fr/seance/salle_gen_csalle={theater_id}.html" + return self._get_text(url) + + def get_theaterlist_by_geocode(self, geocode: int | str, page: int = 1) -> str: + url = f"{THEATERS_BASE_URL}/ville-{geocode}/" + params = {"page": page} if page > 1 else None + return self._get_text(url, params=params) + + def clear_cache(self) -> int: + return self.cache.clear() + + def close(self) -> None: + self.session.close() + self.cache.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def _get(self, url: str, expected_status: int = 200, *args, **kwargs) -> dict: + return self._request(url, expected_status, *args, **kwargs).json() + + def _get_text(self, url: str, expected_status: int = 200, *args, **kwargs) -> str: + return self._request(url, expected_status, *args, **kwargs).text + + def _request( + self, + url: str, + expected_status: int, + *args, + not_found_ok: bool = False, + **kwargs, + ) -> httpx2.Response: + params = kwargs.get("params") + cached = self.cache.get(url, params) + if cached is not CACHE_MISS and cached.is_fresh(): + return cached.to_response() + + if cached is not CACHE_MISS: + headers = dict(kwargs.get("headers") or {}) + if etag := cached.headers.get("etag"): + headers["If-None-Match"] = etag + if last_modified := cached.headers.get("last-modified"): + headers["If-Modified-Since"] = last_modified + kwargs["headers"] = headers + + try: + response = self._fetch(url, expected_status, *args, not_found_ok=not_found_ok, **kwargs) + except (ValueError, httpx2.HTTPError): + 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) + return response + + @backoff.on_exception(backoff.expo, ValueError, max_tries=5, max_time=30) + def _fetch( + self, + url: str, + expected_status: int, + *args, + not_found_ok: bool = False, + **kwargs, + ) -> httpx2.Response: + ret = self.session.get(url, *args, **kwargs) + 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 + + @staticmethod + def _revalidated_response(cached: CachedResponse, response: httpx2.Response) -> httpx2.Response: + return cached.to_response(response.headers) diff --git a/allocine/cache.py b/allocine/cache.py new file mode 100644 index 0000000..cb6b72f --- /dev/null +++ b/allocine/cache.py @@ -0,0 +1,134 @@ +from collections.abc import Mapping +from dataclasses import dataclass +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 + +CACHE_VERSION = 3 +CACHE_MISS = object() + +CacheOption = bool | Cache + + +@dataclass(frozen=True) +class CachedResponse: + status_code: int + headers: dict[str, str] + content: bytes + stored_at: float + initial_age: int + max_age: int + stale_if_error: int + stale_while_revalidate: int + + def age(self, now: float | None = None) -> float: + return self.initial_age + (time() if now is None else now) - self.stored_at + + def is_fresh(self, now: float | None = None) -> bool: + return self.age(now) < self.max_age + + def can_serve_on_error(self, now: float | None = None) -> bool: + return self.age(now) < self.max_age + self.stale_if_error + + 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: + 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) + + +class HttpCache: + def __init__( + self, + cache: CacheOption = False, + cache_dir: str | PathLike[str] | None = None, + ) -> None: + if cache_dir is not None and cache is not True: + raise ValueError("cache_dir can only be used when cache=True") + + self._owns_backend = cache is True + self._backend = ( + Cache(cache_dir or user_cache_path("allocine")) if cache is True else None if cache is False else cache + ) + self._finalizer = ( + finalize(self, self._backend.close) if self._owns_backend and self._backend is not None else None + ) + + def get(self, url: str, params: dict | None = None): + if self._backend is 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: + if self._backend is None or (cached := _to_cached_response(response)) is None: + return + if (retention := cached.retention()) > 0: + self._backend.set(self._key(url, params), cached, expire=retention) + + def clear(self) -> int: + return self._backend.clear() if self._backend is not None else 0 + + def close(self) -> None: + if self._finalizer is not None: + self._finalizer() + + @staticmethod + def _key(url: str, params: dict | None) -> str: + query = urlencode(sorted((params or {}).items()), doseq=True) + return f"http:v{CACHE_VERSION}:{url}?{query}" + + +def _to_cached_response(response: httpx2.Response) -> 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 + if (max_age := _seconds(directives, "max-age")) is None: + return None + + return CachedResponse( + status_code=response.status_code, + headers=_decoded_headers(response.headers), + content=response.content, + stored_at=time(), + initial_age=_header_seconds(response.headers.get("age")) or 0, + max_age=max_age, + stale_if_error=_seconds(directives, "stale-if-error") or 0, + stale_while_revalidate=_seconds(directives, "stale-while-revalidate") or 0, + ) + + +def _parse_cache_control(value: str) -> dict[str, str | None]: + directives = {} + for item in value.split(","): + name, separator, directive_value = item.strip().partition("=") + if name: + directives[name.lower()] = directive_value.strip('"') if separator else None + return directives + + +def _decoded_headers(headers: Mapping[str, str]) -> dict[str, str]: + decoded_headers = dict(headers) + for name in ("content-encoding", "content-length", "transfer-encoding"): + decoded_headers.pop(name, None) + return decoded_headers + + +def _seconds(directives: dict[str, str | None], name: str) -> int | None: + return _header_seconds(directives.get(name)) + + +def _header_seconds(value: str | None) -> int | None: + try: + return max(0, int(value)) if value is not None else None + except ValueError: + return None diff --git a/allocine/cli.py b/allocine/cli.py new file mode 100644 index 0000000..338f13b --- /dev/null +++ b/allocine/cli.py @@ -0,0 +1,153 @@ +"""Command-line interface for Allociné showtimes.""" + +from datetime import date, datetime, timedelta +from io import StringIO + +import click +from rich import box +from rich.console import Console +from rich.table import Table + +from allocine.client import Allocine +from allocine.schedules import get_showtimes_of_a_day + + +def clear_cache(ctx, param, value): + if not value or ctx.resilient_parsing: + return + with Allocine(cache=True) as allocine: + removed = allocine.clear_cache() + click.echo(f"Cache vidé ({removed} entrée(s)).") + ctx.exit() + + +def extract_field_names(dict_list): + """Return a sorted list of field names from a dictionary list.""" + field_names = [] + for row_dict in dict_list: + field_names += row_dict.keys() + field_names = list(set(field_names)) + return sorted(field_names) + + +@click.command() +@click.argument("id_cinema", type=str, required=True) +@click.option( + "--clear-cache", + is_flag=True, + is_eager=True, + expose_value=False, + callback=clear_cache, + help="vide entièrement le cache et quitte", +) +@click.option( + "--jour", + "-j", + type=str, + help="jour des séances souhaitées \ +(au format DD/MM/YYYY ou +1 pour demain), par défaut : aujourd’hui", +) +@click.option( + "--semaine", + "-s", + is_flag=True, + help="affiche les séance pour les 7 prochains jours", +) +@click.option( + "--entrelignes", + "-e", + is_flag=True, + help="ajoute une ligne entre chaque film pour améliorer la lisibilité", +) +def main(id_cinema, entrelignes, jour=None, semaine=None): + """ + Les séances de votre cinéma dans le terminal, avec + ID_CINEMA : identifiant du cinéma sur Allociné, + ex: C0159 pour l’UGC Ciné Cité Les Halles. Se trouve dans l’url : + http://allocine.fr/seance/salle_gen_csalle=.html + """ + today = date.today() + allocine = Allocine(cache=True) + + jours = [] + if semaine is False: + if jour is None: + jours.append(today.strftime("%d/%m/%Y")) + elif jour[0] == "+": + delta_jours = int(jour[1:]) + jour_obj = today + timedelta(days=delta_jours) + jours.append(jour_obj.strftime("%d/%m/%Y")) + else: + jours.append(jour) + else: + for delta in range(0, 7): + jour_obj = today + timedelta(days=delta) + jours.append(jour_obj.strftime("%d/%m/%Y")) + + requested_dates = [datetime.strptime(jour, "%d/%m/%Y").date() for jour in jours] + showtimes = allocine.get_showtimes( + theater_id=id_cinema, + from_date=min(requested_dates), + to_date=max(requested_dates), + ) + try: + theater = allocine.get_theater(theater_id=id_cinema) + except ValueError as error: + raise click.ClickException(str(error)) from None + + print("{}, le ".format(theater.name), end="") + for jour in jours: + print(get_showtime_table(showtimes=showtimes, entrelignes=entrelignes, jour=jour)) + print() + + +def get_showtime_table(showtimes, entrelignes, jour): + showtime_table = [] + + date_obj = datetime.strptime(jour, "%d/%m/%Y").date() + day_showtimes = get_showtimes_of_a_day(showtimes, date=date_obj) + movies_available_today = set(showtime.movie for showtime in day_showtimes) + + for movie_version in movies_available_today: + title = movie_version.title + if len(title) >= 31: # On tronque les titres trop longs + title = title[:31] + "..." + + # '*1_film' pour être sûr que cela soit la 1ère colonne + movie_row = {"*1_film": "{} ({}) - {}".format(title, movie_version.version, movie_version.duration_str)} + + movie_row["*2_note"] = "{}*".format(movie_version.rating_str) + + for showtime in day_showtimes: + if showtime.movie != movie_version: + continue + hour = showtime.hour_str.split(":")[0] # 11:15 => 11 + movie_row[hour] = showtime.hour_str + + showtime_table.append(movie_row) + + seances = showtime_table + + retour = "{}\n".format(jour) + + if len(seances) <= 0: + retour += "Aucune séance" + + else: + field_names = extract_field_names(seances) + table = Table(box=box.SQUARE, show_header=False, show_lines=entrelignes) + for field_name in field_names: + table.add_column(justify="left" if field_name == "*1_film" else "center", no_wrap=True) + + for seances_film in sorted(seances, key=lambda seance: seance["*1_film"]): + table.add_row(*(seances_film.get(field_name, "") for field_name in field_names)) + + output = StringIO() + Console(file=output, color_system=None, width=1000, markup=False).print(table) + retour += output.getvalue().rstrip("\n") + + return retour + + +if __name__ == "__main__": + main() diff --git a/allocine/client.py b/allocine/client.py new file mode 100644 index 0000000..90ad7ca --- /dev/null +++ b/allocine/client.py @@ -0,0 +1,241 @@ +import json +import re +import unicodedata +from datetime import date as Date +from datetime import datetime, timedelta +from os import PathLike + +import jmespath +from parsel import Selector + +from allocine.api import AllocineApi +from allocine.cache import CacheOption +from allocine.models import MovieVersion, Showtime, Theater + +DEFAULT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +class Allocine: + def __init__( + self, + *, + cache: CacheOption = True, + cache_dir: str | PathLike[str] | None = None, + ): + self._client = AllocineApi(cache=cache, cache_dir=cache_dir) + + def get_theater(self, theater_id: str) -> Theater: + resp = self._client.get_showtimelist_by_theater_id(theater_id=theater_id) + if not resp.get("results"): + raise ValueError(f"Theater not found. Is theater id {theater_id!r} correct?") + + movie_id: int | None = jmespath.search("results[0].movie.internalId", resp) + assert movie_id is not None, "We need at least one showtime to get details about a theater" + + return self._get_theater_details_from_movie_id(theater_id, movie_id) + + def get_showtimes( + self, + theater_id: str, + from_date: Date | None = None, + to_date: Date | None = None, + ) -> list[Showtime]: + today = Date.today() + from_date = max(from_date or today, today) + to_date = to_date or from_date + if from_date > to_date: + raise ValueError("from_date must be before or equal to to_date") + + showtimes = [] + requested_date = from_date + while requested_date <= to_date: + response = self._client.get_showtimelist_by_theater_id( + theater_id=theater_id, + date=requested_date, + ) + day_showtimes = self._parse_showtimes(response.get("results") or []) + for page in range(2, (jmespath.search("pagination.totalPages", response) or 1) + 1): + response = self._client.get_showtimelist_by_theater_id( + theater_id=theater_id, + date=requested_date, + page=page, + ) + day_showtimes.extend(self._parse_showtimes(response.get("results") or [])) + + showtimes.extend(showtime for showtime in day_showtimes if showtime.date == requested_date) + requested_date += timedelta(days=1) + + return showtimes + + def search_theaters(self, geocode: int | str) -> list[Theater]: + theaters = [] + page = 1 + while True: + selector = Selector(text=self._client.get_theaterlist_by_geocode(geocode=geocode, page=page)) + theaters.extend(_parse_theaters(selector)) + if not selector.css(".button-right:not(.button-disabled)"): + return theaters + page += 1 + + def clear_cache(self) -> int: + return self._client.clear_cache() + + def close(self) -> None: + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def _get_theater_details_from_movie_id(self, theater_id: str, movie_id: int) -> Theater: + operation = self._client.get_showtimes_by_movie_and_theater_id( + movie_id=movie_id, + theater_id=theater_id, + ) + raw_theater = operation["results"]["theater"] + if raw_theater is None: + return self._get_theater_details_from_page(theater_id) + + location = raw_theater["location"] + return Theater( + theater_id=raw_theater["internalId"], + name=raw_theater["name"], + address=location["address"], + zipcode=location["zip"], + city=location["city"], + ) + + def _get_theater_details_from_page(self, theater_id: str) -> Theater: + selector = Selector(text=self._client.get_theater_page(theater_id)) + for raw_data in selector.css('script[type="application/ld+json"]::text').getall(): + theater_data = json.loads(raw_data) + if theater_data.get("@type") != "MovieTheater": + continue + + address = theater_data.get("address") or {} + return Theater( + theater_id=theater_id, + name=theater_data["name"], + address=address.get("streetAddress") or "", + zipcode=address.get("postalCode") or "", + city=address.get("addressLocality") or "", + ) + + raise ValueError(f"Theater details not found for theater id {theater_id!r}") + + def _parse_showtimes(self, raw_showtimes: list[dict]): + showtimes = [] + for showtime_data in raw_showtimes: + raw_movie = showtime_data.get("movie") + if not raw_movie: + continue + duration_match = re.fullmatch(r"(?:(\d+)h)?\s*(?:(\d+)min)?", raw_movie.get("runtime") or "") + duration_obj = None + if duration_match and any(duration_match.groups()): + hours, minutes = duration_match.groups(default="0") + duration_obj = timedelta(hours=int(hours), minutes=int(minutes)) + + rating = jmespath.search("stats.userRating.score", raw_movie) + try: + rating = float(rating) + except (ValueError, TypeError): + rating = None + + countries = [country.get("localizedName") for country in raw_movie.get("countries") or []] + countries = [country for country in countries if country] + if year := jmespath.search("data.productionYear", raw_movie): + year = int(year) + + directors = [] + for credit in sorted(raw_movie.get("credits") or [], key=lambda item: item.get("rank") or 0): + if jmespath.search("position.name", credit) == "DIRECTOR": + if name := _person_name(credit.get("person")): + directors.append(name) + + actors = [] + cast = jmespath.search("cast.nodes", raw_movie) or [] + for cast_member in sorted(cast, key=lambda item: item.get("rank") or 0): + person = ( + cast_member.get("actor") or cast_member.get("originalVoiceActor") or cast_member.get("voiceActor") + ) + if name := _person_name(person): + actors.append(name) + + genres = ", ".join(genre["translate"] for genre in raw_movie.get("genres") or [] if genre.get("translate")) + for showtime_group in (showtime_data.get("showtimes") or {}).values(): + for raw_showtime in showtime_group: + tags = raw_showtime.get("tags") or [] + language = ( + "Français" + if "Localization.Language.French" in tags + or raw_showtime.get("diffusionVersion") in {"DUBBED", "LOCAL"} + else "Version originale" + ) + screen_formats = [ + {"E_4DX": "4DX", "PLF": "PLF"}.get(value, value) + for value in raw_showtime.get("experience") or [] + ] + screen_formats.extend( + {"DIGITAL": "Numérique", "IMAX": "IMAX", "F_3D": "3D"}.get(value, value) + for value in raw_showtime.get("projection") or [] + if value != "DIGITAL" or not screen_formats + ) + + if synopsis := raw_movie.get("synopsis"): + synopsis = re.sub(r"<.*?>", "", synopsis) + synopsis = synopsis.replace("\xa0", " ") # Remove HTML tags (ex: ) + synopsis = unicodedata.normalize("NFKD", synopsis) + + movie = MovieVersion( + movie_id=raw_movie["internalId"], + title=raw_movie.get("title"), + rating=rating, + language=language, + screen_format=" ".join(screen_formats) or "Numérique", + synopsis=synopsis, + original_title=raw_movie.get("originalTitle"), + year=year, + countries=countries, + genres=genres, + directors=", ".join(directors), + actors=", ".join(actors), + duration=duration_obj, + ) + showtimes.append( + Showtime( + date_time=datetime.strptime(raw_showtime["startsAt"], DEFAULT_DATE_FORMAT), + movie=movie, + ) + ) + return showtimes + + +def _person_name(person): + if not person: + return None + return " ".join(part for part in (person.get("firstName"), person.get("lastName")) if part) + + +def _parse_theaters(selector: Selector) -> list[Theater]: + theaters = [] + for card in selector.css("div.theater-card"): + theater_json = card.css(".add-theater-anchor[data-theater]::attr(data-theater)").get() + if not theater_json: + continue + + theater_data = json.loads(theater_json) + full_address = " ".join((card.css("address.address").xpath("string()").get() or "").split()) + address_match = re.fullmatch(r"(.*)\s+(\d{5})\s+(.+)", full_address) + address, zipcode, city = address_match.groups() if address_match else (full_address, "", "") + theaters.append( + Theater( + theater_id=theater_data["id"], + name=theater_data["name"], + address=address, + zipcode=zipcode, + city=city, + ) + ) + return theaters diff --git a/allocine/models.py b/allocine/models.py new file mode 100644 index 0000000..6c488e8 --- /dev/null +++ b/allocine/models.py @@ -0,0 +1,233 @@ +import logging +import unicodedata +from dataclasses import dataclass +from datetime import date as Date +from datetime import datetime, timedelta +from datetime import time as Time +from typing import List, Optional + +from allocine import nationalities + +logger = logging.getLogger(__name__) + + +@dataclass +class Movie: + movie_id: int + title: str + original_title: str + rating: Optional[float] + duration: Optional[timedelta] + genres: str + countries: List[str] + directors: str + actors: str + synopsis: str + year: int + + @property + def duration_str(self): + if self.duration is not None: + return _strfdelta(self.duration, "{hours:02d}h{minutes:02d}") + else: + return "HH:MM" + + @property + def duration_short_str(self) -> str: + if self.duration is not None: + return _strfdelta(self.duration, "{hours:d}h{minutes:02d}") + else: + return "NA" + + @property + def rating_str(self): + return "{0:.1f}".format(self.rating) if self.rating else "" + + @property + def nationalities(self): + """Return the nationality tuples, from the movie countries. + Example: if self.countries = ['France'] => [('français', 'française')] + """ + if self.countries: + nationality_tuples = [] + for country_name in self.countries: + normalized_country_name = _strip_accents(country_name).lower() + country_code = nationalities.countries.get(normalized_country_name) + + if country_code is not None: + nationality_tuples.append(nationalities.nationalities[country_code]) + else: + logger.warning(f"Country {country_name!r} not found in nationalities") + nationality_tuples.append((f"de {country_name}", f"de {country_name}")) + return nationality_tuples + else: + return None + + def __str__(self): + return f"{self.title} [{self.movie_id}] ({self.duration_str})" + + def __eq__(self, other): + return (self.movie_id) == (other.movie_id) + + def __hash__(self): + """This function allows us + to do a set(list_of_Movie_objects)""" + return hash(self.movie_id) + + +@dataclass +class MovieVersion(Movie): + language: str + screen_format: str + + @property + def version(self): + version = "VF" if self.language == "Français" else "VOST" + if self.screen_format != "Numérique": + version += f" {self.screen_format}" + return version + + def get_movie(self): + return Movie( + movie_id=self.movie_id, + title=self.title, + rating=self.rating, + duration=self.duration, + original_title=self.original_title, + year=self.year, + genres=self.genres, + countries=self.countries, + directors=self.directors, + actors=self.actors, + synopsis=self.synopsis, + ) + + def __str__(self): + movie_str = super().__str__() + return f"{movie_str} ({self.version})" + + def __eq__(self, other): + return (self.movie_id, self.version) == (other.movie_id, other.version) + + def __hash__(self): + """This function allows us + to do a set(list_of_MovieVersion_objects)""" + return hash((self.movie_id, self.version)) + + +@dataclass +class Schedule: + date_time: datetime + + @property + def date(self) -> Date: + return self.date_time.date() + + @property + def hour(self) -> Time: + return self.date_time.time() + + @property + def hour_str(self) -> str: + return self.date_time.strftime("%H:%M") + + @property + def hour_short_str(self) -> str: + return get_hour_short_str(self.hour) + + @property + def date_str(self) -> str: + return self.date_time.strftime("%d/%m/%Y %H:%M") + + @property + def day_str(self) -> str: + return day_str(self.date) + + @property + def short_day_str(self) -> str: + return short_day_str(self.date) + + +def get_hour_short_str(hour: Time) -> str: + # Ex: 9h, 11h, 23h30 + # Minus in '%-H' removes the leading 0 + return hour.strftime("%-Hh%M").replace("h00", "h") + + +@dataclass +class Showtime(Schedule): + movie: MovieVersion + + def __str__(self): + return f"{self.date_str} : {self.movie}" + + +def day_str(date: Date) -> str: + return to_french_weekday(date.weekday()) + + +def to_french_weekday(weekday: int) -> str: + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] + return days[weekday] + + +def get_french_month(month_number: int) -> str: + months = [ + "Janvier", + "Février", + "Mars", + "Avril", + "Mai", + "Juin", + "Juillet", + "Août", + "Septembre", + "Octobre", + "Novembre", + "Décembre", + ] + return months[month_number - 1] + + +def to_french_short_weekday(weekday: int) -> str: + return to_french_weekday(weekday)[:3] + + +def short_day_str(date: Date) -> str: + return day_str(date)[:3] + + +@dataclass +class Theater: + theater_id: str + name: str + address: str + zipcode: str + city: str + + @property + def address_str(self): + address_str = f"{self.address}, " if self.address else "" + address_str += f"{self.zipcode} {self.city}" + return address_str + + def __eq__(self, other): + return (self.theater_id) == (other.theater_id) + + def __hash__(self): + """This function allows us to do a set(list_of_Theaters_objects)""" + return hash(self.theater_id) + + +def _strfdelta(tdelta, fmt): + """Format a timedelta object""" + # Thanks to https://stackoverflow.com/questions/8906926 + d = {"days": tdelta.days} + d["hours"], rem = divmod(tdelta.seconds, 3600) + d["minutes"], d["seconds"] = divmod(rem, 60) + return fmt.format(**d) + + +def _strip_accents(s): + # https://stackoverflow.com/a/518232/8748757 + return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") diff --git a/allocine/nationalities.py b/allocine/nationalities.py index f2b73a2..4bd9cad 100644 --- a/allocine/nationalities.py +++ b/allocine/nationalities.py @@ -1,446 +1,448 @@ # Based on https://gist.github.com/Mathieu-Castets/e36488c518d1fc4a03fa (thank you!) nationalities = { - 'AD': ('andorran', 'andorrane'), - 'AE': ('émirien', 'émirienne'), - 'AF': ('afghan', 'afghane'), - 'AG': ('antiguayen', 'antiguayenne'), - 'AI': ('anguillais', 'anguillaise'), - 'AL': ('albanais', 'albanaise'), - 'AM': ('arménien', 'arménienne'), - 'AO': ('angolais', 'angolaise'), - 'AR': ('argentin', 'argentine'), - 'AS': ('samoan', 'samoane'), - 'AT': ('autrichien', 'autrichienne'), - 'AU': ('australien', 'australienne'), - 'AW': ('arubain', 'arubaine'), - 'AX': ('ålandais', 'ålandaise'), - 'AZ': ('azerbaïdjanais', 'azerbaïdjanaise'), - 'BA': ('bosnien', 'bosnienne'), - 'BB': ('barbadien', 'barbadienne'), - 'BD': ('bangladais', 'bangladaise'), - 'BE': ('belge', 'belge'), - 'BF': ('burkinabè', 'burkinabè'), - 'BG': ('bulgare', 'bulgare'), - 'BH': ('bahreïnien', 'bahreïnienne'), - 'BI': ('burundais', 'burundaise'), - 'BJ': ('béninois', 'béninoise'), - 'BM': ('bermudien', 'bermudienne'), - 'BN': ('brunéiens', 'brunéiennes'), - 'BO': ('bolivien', 'bolivienne'), - 'BR': ('brésilien', 'brésilienne'), - 'BS': ('bahamien', 'bahamienne'), - 'BT': ('bhoutanais', 'bhoutanaise'), - 'BW': ('botswanais', 'botswanaise'), - 'BY': ('biélorusse', 'biélorusse'), - 'BZ': ('bélizien', 'bélizienne'), - 'CA': ('canadien', 'canadienne'), - 'CD': ('congolais', 'congolaise'), - 'CF': ('centrafricain', 'centrafricaine'), - 'CG': ('congolais', 'congolaise'), - 'CH': ('suisse', 'suissesse'), - 'CI': ('ivoirien', 'ivoirienne'), - 'CK': ('cookien', 'cookienne'), - 'CL': ('chilien', 'chilienne'), - 'CM': ('camerounais', 'camerounaise'), - 'CN': ('chinois', 'chinoise'), - 'CO': ('colombien', 'colombienne'), - 'CR': ('costaricien', 'costaricienne'), - 'CU': ('cubain', 'cubaine'), - 'CV': ('cap-verdien', 'cap-verdienne'), - 'CY': ('chypriote', 'chypriote'), - 'CS': ('tchécoslovaque', 'tchécoslovaque'), - 'CZ': ('tchèque', 'tchèque'), - 'DE': ('allemand', 'allemande'), - 'DJ': ('djiboutien', 'djiboutienne'), - 'DK': ('danois', 'danoise'), - 'DM': ('dominiquais', 'dominiquaise'), - 'DO': ('dominicain', 'dominicaine'), - 'DZ': ('algérien', 'algérienne'), - 'EC': ('équatorien', 'équatorienne'), - 'EE': ('estonien', 'estonienne'), - 'EG': ('égyptien', 'égyptienne'), - 'EH': ('sahraoui', 'sahraouie'), - 'EL': ('grec', 'grecque'), - 'ER': ('érythréen', 'érythréenne'), - 'ES': ('espagnol', 'espagnole'), - 'ET': ('éthiopien', 'éthiopienne'), - 'FI': ('finlandais', 'finlandaise'), - 'FJ': ('fidjien', 'fidjienne'), - 'FK': ('malouin', 'malouine'), - 'FM': ('micronésien', 'micronésienne'), - 'FO': ('féroïen', 'féroïenne'), - 'FR': ('français', 'française'), - 'GA': ('gabonais', 'gabonaise'), - 'GB': ('britannique', 'britannique'), - 'GD': ('grenadin', 'grenadine'), - 'GE': ('géorgien', 'géorgienne'), - 'GH': ('ghanéen', 'ghanéenne'), - 'GI': ('gibraltarien', 'gibraltarienne'), - 'GL': ('groenlandais', 'groenlandaise'), - 'GM': ('gambien', 'gambienne'), - 'GN': ('guinéen', 'guinéenne'), - 'GQ': ('équatoguinéen', 'équatoguinéenne'), - 'GR': ('grec', 'grecque'), - 'GT': ('guatémaltèque', 'guatémaltèque'), - 'GU': ('guamien', 'guamienne'), - 'GW': ('bissaoguinéen', 'bissaoguinéenne'), - 'GY': ('guyanien', 'guyanienne'), - 'HK': ('hongkongais', 'hongkongaise'), - 'HN': ('hondurien', 'hondurienne'), - 'HR': ('croate', 'croate'), - 'HT': ('haïtien', 'haïtienne'), - 'HU': ('hongrois', 'hongroise'), - 'ID': ('indonésien', 'indonésienne'), - 'IE': ('irlandais', 'iirlandaise'), - 'IL': ('israélien', 'israélienne'), - 'IN': ('indien', 'indienne'), - 'IQ': ('iraquien', 'iraquienne'), - 'IR': ('iranien', 'iranienne'), - 'IS': ('islandais', 'islandaise'), - 'IT': ('italien', 'italienne'), - 'JM': ('jamaïcain', 'jamaïcaine'), - 'JO': ('jordanien', 'jordanienne'), - 'JP': ('japonais', 'japonaise'), - 'KE': ('kényan', 'kényane'), - 'KG': ('kirghize', 'kirghize'), - 'KH': ('cambodgien', 'cambodgienne'), - 'KI': ('kiribatien', 'kiribatienne'), - 'KM': ('comorien', 'comorienne'), - 'KN': ('christophien', 'christophienne'), - 'KP': ('nord-coréen', 'nord-coréenne'), - 'KR': ('sud-coréen', 'sud-coréenne'), - 'KW': ('koweïtien', 'koweïtienne'), - 'KY': ('caïmanais', 'caïmanaise'), - 'KZ': ('kazakh', 'kazakhe'), - 'LA': ('laotien', 'laotienne'), - 'LB': ('libanais', 'libanaise'), - 'LC': ('saint-lucien', 'saint-lucienne'), - 'LI': ('liechtensteinois', 'liechtensteinoise'), - 'LK': ('sri-lankais', 'sri-lankaise'), - 'LR': ('libérien', 'libérienne'), - 'LS': ('mosotho', 'mosotho'), - 'LT': ('lituanien', 'lituanienne'), - 'LU': ('luxembourgeois', 'luxembourgeoise'), - 'LV': ('letton', 'lettone'), - 'LY': ('libyen', 'libyenne'), - 'MA': ('marocain', 'marocaine'), - 'MC': ('monégasque', 'monégasque'), - 'MD': ('moldave', 'moldave'), - 'ME': ('monténégrin', 'monténégrine'), - 'MG': ('malgache', 'malgache'), - 'MH': ('marshallais', 'marshallaise'), - 'MK': ('macédonien', 'macédonienne'), - 'ML': ('malien', 'malienne'), - 'MM': ('birman', 'birmane'), - 'MN': ('mongol', 'mongole'), - 'MO': ('macanais', 'macanaise'), - 'MP': ('mariannais', 'mariannaise'), - 'MR': ('mauritanien', 'mauritanienne'), - 'MS': ('montserratien', 'montserratienne'), - 'MT': ('maltais', 'maltaise'), - 'MU': ('mauricien', 'mauricienne'), - 'MV': ('maldivien', 'maldivienne'), - 'MW': ('malawien', 'malawienne'), - 'MX': ('mexicain', 'mexicaine'), - 'MY': ('malaisien', 'malaisienne'), - 'MZ': ('mozambicain', 'mozambicaine'), - 'NA': ('namibien', 'namibienne'), - 'NE': ('nigérien', 'nigérienne'), - 'NG': ('nigérian', 'nigériane'), - 'NI': ('nicaraguayen', 'nicaraguayenne'), - 'NL': ('néerlandais', 'néerlandaise'), - 'NO': ('norvégien', 'norvégienne'), - 'NP': ('népalais', 'népalaise'), - 'NR': ('nauruan', 'nauruane'), - 'NU': ('niuéan', 'niuéane'), - 'NZ': ('néo-zélandais', 'néo-zélandaise'), - 'OM': ('omanais', 'omanaise'), - 'PA': ('panaméen', 'panaméenne'), - 'PE': ('péruvien', 'péruvienne'), - 'PG': ('papouasien', 'papouasienne'), - 'PH': ('philippin', 'philippine'), - 'PK': ('pakistanais', 'pakistanaise'), - 'PL': ('polonais', 'polonaise'), - 'PR': ('portoricain', 'portoricaine'), - 'PS': ('palestinien', 'palestinienne'), - 'PT': ('portugais', 'portugaise'), - 'PW': ('palaois', 'palaoise'), - 'PY': ('paraguayen', 'paraguayenne'), - 'QA': ('qatarien', 'qatarienne'), - 'RO': ('roumain', 'roumaine'), - 'RS': ('serbe', 'serbe'), - 'RU': ('russe', 'russe'), - 'RW': ('rwandais', 'rwandaise'), - 'SA': ('saoudien', 'saoudienne'), - 'SB': ('salomonais', 'salomonaise'), - 'SC': ('seychellois', 'seychelloise'), - 'SD': ('soudanais', 'soudanaise'), - 'SE': ('suédois', 'suédoise'), - 'SG': ('singapourien', 'singapourienne'), - 'SI': ('slovène', 'slovène'), - 'SK': ('slovaque', 'slovaque'), - 'SL': ('sierraléonais', 'sierraléonaise'), - 'SM': ('saint-marinais', 'saint-marinaise'), - 'SN': ('sénégalais', 'sénégalaise'), - 'SO': ('somalien', 'somalienne'), - 'SR': ('surinamais', 'surinamaise'), - 'SS': ('sud-soudanais', 'sud-soudanaise'), - 'ST': ('santoméen', 'santoméenne'), - 'SU': ('soviétique', 'soviétique'), - 'SV': ('salvadorien', 'salvadorienne'), - 'SY': ('syrien', 'syrienne'), - 'SZ': ('swazi', 'swazie'), - 'TC': ('émirats arabes unis', 'émirats arabes unis'), - 'TD': ('tchadien', 'tchadienne'), - 'TG': ('togolais', 'togolaise'), - 'TH': ('thaïlandais', 'thaïlandaise'), - 'TJ': ('tadjik', 'tadjike'), - 'TK': ('tokélaouen', 'tokélaouenne'), - 'TL': ('timorais', 'timoraise'), - 'TM': ('turkmène', 'turkmène'), - 'TN': ('tunisien', 'tunisienne'), - 'TO': ('tongan', 'tongane'), - 'TP': ('est-timorais', 'est-timoraise'), - 'TR': ('turc', 'turque'), - 'TT': ('trinidadien', 'trinidadienne'), - 'TV': ('tuvaluan', 'tuvaluane'), - 'TW': ('taïwanais', 'taïwanaise'), - 'TZ': ('tanzanien', 'tanzanienne'), - 'UA': ('ukrainien', 'ukrainienne'), - 'UG': ('ougandais', 'ougandaise'), - 'UK': ('britannique', 'britannique'), - 'US': ('américain', 'américaine'), - 'UY': ('uruguayen', 'uruguayenne'), - 'UZ': ('ouzbek', 'ouzbèke'), - 'VC': ('vincentais', 'vincentaise'), - 'VE': ('vénézuélien', 'vénézuélienne'), - 'VG': ('insulaires des îles vierges britanniques', 'insulaires des îles vierges britanniques'), - 'VI': ('insulaires des îles vierges', 'insulaires des îles vierges'), - 'VN': ('vietnamien', 'vietnamienne'), - 'VU': ('vanuatuan', 'vanuatuane'), - 'WS': ('samoan', 'samoane'), - 'XK': ('kosovar', 'kosovare'), - 'YE': ('yéménite', 'yéménite'), - 'YU': ('serbe', 'serbe'), - 'ZA': ('sud-africain', 'sud-africaine'), - 'ZM': ('zambien', 'zambienne'), - 'ZW': ('zimbabwéen', 'zimbabwéenne'), + "AD": ("andorran", "andorrane"), + "AE": ("émirien", "émirienne"), + "AF": ("afghan", "afghane"), + "AG": ("antiguayen", "antiguayenne"), + "AI": ("anguillais", "anguillaise"), + "AL": ("albanais", "albanaise"), + "AM": ("arménien", "arménienne"), + "AO": ("angolais", "angolaise"), + "AR": ("argentin", "argentine"), + "AS": ("samoan", "samoane"), + "AT": ("autrichien", "autrichienne"), + "AU": ("australien", "australienne"), + "AW": ("arubain", "arubaine"), + "AX": ("ålandais", "ålandaise"), + "AZ": ("azerbaïdjanais", "azerbaïdjanaise"), + "BA": ("bosnien", "bosnienne"), + "BB": ("barbadien", "barbadienne"), + "BD": ("bangladais", "bangladaise"), + "BE": ("belge", "belge"), + "BF": ("burkinabè", "burkinabè"), + "BG": ("bulgare", "bulgare"), + "BH": ("bahreïnien", "bahreïnienne"), + "BI": ("burundais", "burundaise"), + "BJ": ("béninois", "béninoise"), + "BM": ("bermudien", "bermudienne"), + "BN": ("brunéiens", "brunéiennes"), + "BO": ("bolivien", "bolivienne"), + "BR": ("brésilien", "brésilienne"), + "BS": ("bahamien", "bahamienne"), + "BT": ("bhoutanais", "bhoutanaise"), + "BW": ("botswanais", "botswanaise"), + "BY": ("biélorusse", "biélorusse"), + "BZ": ("bélizien", "bélizienne"), + "CA": ("canadien", "canadienne"), + "CD": ("congolais", "congolaise"), + "CF": ("centrafricain", "centrafricaine"), + "CG": ("congolais", "congolaise"), + "CH": ("suisse", "suissesse"), + "CI": ("ivoirien", "ivoirienne"), + "CK": ("cookien", "cookienne"), + "CL": ("chilien", "chilienne"), + "CM": ("camerounais", "camerounaise"), + "CN": ("chinois", "chinoise"), + "CO": ("colombien", "colombienne"), + "CR": ("costaricien", "costaricienne"), + "CU": ("cubain", "cubaine"), + "CV": ("cap-verdien", "cap-verdienne"), + "CY": ("chypriote", "chypriote"), + "CS": ("tchécoslovaque", "tchécoslovaque"), + "CZ": ("tchèque", "tchèque"), + "DE": ("allemand", "allemande"), + "DJ": ("djiboutien", "djiboutienne"), + "DK": ("danois", "danoise"), + "DM": ("dominiquais", "dominiquaise"), + "DO": ("dominicain", "dominicaine"), + "DZ": ("algérien", "algérienne"), + "EC": ("équatorien", "équatorienne"), + "EE": ("estonien", "estonienne"), + "EG": ("égyptien", "égyptienne"), + "EH": ("sahraoui", "sahraouie"), + "EL": ("grec", "grecque"), + "ER": ("érythréen", "érythréenne"), + "ES": ("espagnol", "espagnole"), + "ET": ("éthiopien", "éthiopienne"), + "FI": ("finlandais", "finlandaise"), + "FJ": ("fidjien", "fidjienne"), + "FK": ("malouin", "malouine"), + "FM": ("micronésien", "micronésienne"), + "FO": ("féroïen", "féroïenne"), + "FR": ("français", "française"), + "GA": ("gabonais", "gabonaise"), + "GB": ("britannique", "britannique"), + "GD": ("grenadin", "grenadine"), + "GE": ("géorgien", "géorgienne"), + "GH": ("ghanéen", "ghanéenne"), + "GI": ("gibraltarien", "gibraltarienne"), + "GL": ("groenlandais", "groenlandaise"), + "GM": ("gambien", "gambienne"), + "GN": ("guinéen", "guinéenne"), + "GQ": ("équatoguinéen", "équatoguinéenne"), + "GR": ("grec", "grecque"), + "GT": ("guatémaltèque", "guatémaltèque"), + "GU": ("guamien", "guamienne"), + "GW": ("bissaoguinéen", "bissaoguinéenne"), + "GY": ("guyanien", "guyanienne"), + "HK": ("hongkongais", "hongkongaise"), + "HN": ("hondurien", "hondurienne"), + "HR": ("croate", "croate"), + "HT": ("haïtien", "haïtienne"), + "HU": ("hongrois", "hongroise"), + "ID": ("indonésien", "indonésienne"), + "IE": ("irlandais", "iirlandaise"), + "IL": ("israélien", "israélienne"), + "IN": ("indien", "indienne"), + "IQ": ("iraquien", "iraquienne"), + "IR": ("iranien", "iranienne"), + "IS": ("islandais", "islandaise"), + "IT": ("italien", "italienne"), + "JM": ("jamaïcain", "jamaïcaine"), + "JO": ("jordanien", "jordanienne"), + "JP": ("japonais", "japonaise"), + "KE": ("kényan", "kényane"), + "KG": ("kirghize", "kirghize"), + "KH": ("cambodgien", "cambodgienne"), + "KI": ("kiribatien", "kiribatienne"), + "KM": ("comorien", "comorienne"), + "KN": ("christophien", "christophienne"), + "KP": ("nord-coréen", "nord-coréenne"), + "KR": ("sud-coréen", "sud-coréenne"), + "KW": ("koweïtien", "koweïtienne"), + "KY": ("caïmanais", "caïmanaise"), + "KZ": ("kazakh", "kazakhe"), + "LA": ("laotien", "laotienne"), + "LB": ("libanais", "libanaise"), + "LC": ("saint-lucien", "saint-lucienne"), + "LI": ("liechtensteinois", "liechtensteinoise"), + "LK": ("sri-lankais", "sri-lankaise"), + "LR": ("libérien", "libérienne"), + "LS": ("mosotho", "mosotho"), + "LT": ("lituanien", "lituanienne"), + "LU": ("luxembourgeois", "luxembourgeoise"), + "LV": ("letton", "lettone"), + "LY": ("libyen", "libyenne"), + "MA": ("marocain", "marocaine"), + "MC": ("monégasque", "monégasque"), + "MD": ("moldave", "moldave"), + "ME": ("monténégrin", "monténégrine"), + "MG": ("malgache", "malgache"), + "MH": ("marshallais", "marshallaise"), + "MK": ("macédonien", "macédonienne"), + "ML": ("malien", "malienne"), + "MM": ("birman", "birmane"), + "MN": ("mongol", "mongole"), + "MO": ("macanais", "macanaise"), + "MP": ("mariannais", "mariannaise"), + "MR": ("mauritanien", "mauritanienne"), + "MS": ("montserratien", "montserratienne"), + "MT": ("maltais", "maltaise"), + "MU": ("mauricien", "mauricienne"), + "MV": ("maldivien", "maldivienne"), + "MW": ("malawien", "malawienne"), + "MX": ("mexicain", "mexicaine"), + "MY": ("malaisien", "malaisienne"), + "MZ": ("mozambicain", "mozambicaine"), + "NA": ("namibien", "namibienne"), + "NE": ("nigérien", "nigérienne"), + "NG": ("nigérian", "nigériane"), + "NI": ("nicaraguayen", "nicaraguayenne"), + "NL": ("néerlandais", "néerlandaise"), + "NO": ("norvégien", "norvégienne"), + "NP": ("népalais", "népalaise"), + "NR": ("nauruan", "nauruane"), + "NU": ("niuéan", "niuéane"), + "NZ": ("néo-zélandais", "néo-zélandaise"), + "OM": ("omanais", "omanaise"), + "PA": ("panaméen", "panaméenne"), + "PE": ("péruvien", "péruvienne"), + "PG": ("papouasien", "papouasienne"), + "PH": ("philippin", "philippine"), + "PK": ("pakistanais", "pakistanaise"), + "PL": ("polonais", "polonaise"), + "PR": ("portoricain", "portoricaine"), + "PS": ("palestinien", "palestinienne"), + "PT": ("portugais", "portugaise"), + "PW": ("palaois", "palaoise"), + "PY": ("paraguayen", "paraguayenne"), + "QA": ("qatarien", "qatarienne"), + "RO": ("roumain", "roumaine"), + "RS": ("serbe", "serbe"), + "RU": ("russe", "russe"), + "RW": ("rwandais", "rwandaise"), + "SA": ("saoudien", "saoudienne"), + "SB": ("salomonais", "salomonaise"), + "SC": ("seychellois", "seychelloise"), + "SD": ("soudanais", "soudanaise"), + "SE": ("suédois", "suédoise"), + "SG": ("singapourien", "singapourienne"), + "SI": ("slovène", "slovène"), + "SK": ("slovaque", "slovaque"), + "SL": ("sierraléonais", "sierraléonaise"), + "SM": ("saint-marinais", "saint-marinaise"), + "SN": ("sénégalais", "sénégalaise"), + "SO": ("somalien", "somalienne"), + "SR": ("surinamais", "surinamaise"), + "SS": ("sud-soudanais", "sud-soudanaise"), + "ST": ("santoméen", "santoméenne"), + "SU": ("soviétique", "soviétique"), + "SV": ("salvadorien", "salvadorienne"), + "SY": ("syrien", "syrienne"), + "SZ": ("swazi", "swazie"), + "TC": ("émirats arabes unis", "émirats arabes unis"), + "TD": ("tchadien", "tchadienne"), + "TG": ("togolais", "togolaise"), + "TH": ("thaïlandais", "thaïlandaise"), + "TJ": ("tadjik", "tadjike"), + "TK": ("tokélaouen", "tokélaouenne"), + "TL": ("timorais", "timoraise"), + "TM": ("turkmène", "turkmène"), + "TN": ("tunisien", "tunisienne"), + "TO": ("tongan", "tongane"), + "TP": ("est-timorais", "est-timoraise"), + "TR": ("turc", "turque"), + "TT": ("trinidadien", "trinidadienne"), + "TV": ("tuvaluan", "tuvaluane"), + "TW": ("taïwanais", "taïwanaise"), + "TZ": ("tanzanien", "tanzanienne"), + "UA": ("ukrainien", "ukrainienne"), + "UG": ("ougandais", "ougandaise"), + "UK": ("britannique", "britannique"), + "US": ("américain", "américaine"), + "UY": ("uruguayen", "uruguayenne"), + "UZ": ("ouzbek", "ouzbèke"), + "VC": ("vincentais", "vincentaise"), + "VE": ("vénézuélien", "vénézuélienne"), + "VG": ("insulaires des îles vierges britanniques", "insulaires des îles vierges britanniques"), + "VI": ("insulaires des îles vierges", "insulaires des îles vierges"), + "VN": ("vietnamien", "vietnamienne"), + "VU": ("vanuatuan", "vanuatuane"), + "WS": ("samoan", "samoane"), + "XK": ("kosovar", "kosovare"), + "YE": ("yéménite", "yéménite"), + "YU": ("serbe", "serbe"), + "ZA": ("sud-africain", "sud-africaine"), + "ZM": ("zambien", "zambienne"), + "ZW": ("zimbabwéen", "zimbabwéenne"), } countries = { - 'andorre': 'AD', - 'emirats arabes unis': 'AE', - 'afghanistan': 'AF', - 'antigua et barbuda': 'AG', - 'anguilla': 'AI', - 'albanie': 'AL', - 'curacao': 'AN', - 'angola': 'AO', - 'argentine': 'AR', - 'samoa americaines': 'AS', - 'autriche': 'AT', - 'australie': 'AU', - 'aruba': 'AW', - 'azerbaidjan': 'AZ', - 'bosnie-herzegovine': 'BA', - 'barbade': 'BB', - 'bangladesh': 'BD', - 'belgique': 'BE', - 'burkina faso': 'BF', - 'bulgarie': 'BG', - 'bahrein': 'BH', - 'burundi': 'BI', - 'benin': 'BJ', - 'bermudes': 'BM', - 'brunei': 'BN', - 'bolivie': 'BO', - 'bresil': 'BR', - 'bahamas': 'BS', - 'bhoutan': 'BT', - 'botswana': 'BW', - 'bielorussie': 'BY', - 'belize': 'BZ', - 'canada': 'CA', - 'congo rdc': 'CD', - 'republique centrafricaine': 'CF', - 'congo (brazzaville)': 'CG', - 'suisse': 'CH', - 'cote d\'ivoire': 'CI', - 'iles cook': 'CK', - 'chili': 'CL', - 'cameroun': 'CM', - 'chine': 'CN', - 'colombie': 'CO', - 'costa rica': 'CR', - 'cuba': 'CU', - 'cap vert': 'CV', - 'chypre': 'CY', - 'republique tcheque': 'CZ', - 'tchecoslovaquie': 'CS', - 'allemagne': 'DE', - 'allemagne de l\'ouest': 'DE', - 'allemagne de l\'est': 'DE', - 'djibouti': 'DJ', - 'danemark': 'DK', - 'dominique': 'DM', - 'republique dominicaine': 'DO', - 'algerie': 'DZ', - 'equateur': 'EC', - 'estonie': 'EE', - 'egypte': 'EG', - 'erythree': 'ER', - 'espagne': 'ES', - 'ethiopie': 'ET', - 'finlande': 'FI', - 'fidji': 'FJ', - 'iles malouines': 'FK', - 'micronesie': 'FM', - 'france': 'FR', - 'gabon': 'GA', - 'grande-bretagne': 'GB', # modified based on allocine value, originally 'Royaume-Uni' - 'grenade': 'GD', - 'georgie': 'GE', - 'ghana': 'GH', - 'gibraltar': 'GI', - 'gambie': 'GM', - 'guinee': 'GN', - 'guinee equatoriale': 'GQ', - 'grece': 'GR', - 'guatemala': 'GT', - 'guam': 'GU', - 'guinee-bissau': 'GW', - 'guyana': 'GY', - 'hong kong': 'HK', - 'hong-kong': 'HK', - 'honduras': 'HN', - 'croatie': 'HR', - 'haiti': 'HT', - 'hongrie': 'HU', - 'indonesie': 'ID', - 'irlande': 'IE', - 'israel': 'IL', - 'inde': 'IN', - 'irak': 'IQ', - 'islande': 'IS', - 'italie': 'IT', - 'jamaique': 'JM', - 'jordanie': 'JO', - 'japon': 'JP', - 'kenya': 'KE', - 'kyrghyzstan': 'KG', - 'cambodge': 'KH', - 'kiribati': 'KI', - 'comores': 'KM', - 'saint-christophe-et-nieves': 'KN', - 'coree du sud': 'KR', # modified based on allocine value, originally 'Coree, Republique de' - 'koweit': 'KW', - 'iles caimans': 'KY', - 'kazakhstan': 'KZ', - 'laos': 'LA', - 'liban': 'LB', - 'sainte-lucie': 'LC', - 'liechtenstein': 'LI', - 'sri lanka': 'LK', - 'liberia': 'LR', - 'lesotho': 'LS', - 'lituanie': 'LT', - 'luxembourg': 'LU', - 'lettonie': 'LV', - 'libye': 'LY', - 'maroc': 'MA', - 'monaco': 'MC', - 'moldavie': 'MD', - 'montenegro': 'ME', - 'madagascar': 'MG', - 'iles marshall': 'MH', - 'macedoine': 'MK', - 'mali': 'ML', - 'birmanie': 'MM', - 'mongolie': 'MN', - 'macao': 'MO', - 'iles mariannes du nord': 'MP', - 'mauritanie': 'MR', - 'montserrat': 'MS', - 'malte': 'MT', - 'maurice': 'MU', - 'maldives': 'MV', - 'malawi': 'MW', - 'mexique': 'MX', - 'malaisie': 'MY', - 'mozambique': 'MZ', - 'namibie': 'NA', - 'niger': 'NE', - 'nigeria': 'NG', - 'nicaragua': 'NI', - 'pays-bas': 'NL', - 'norvege': 'NO', - 'nepal': 'NP', - 'nauru': 'NR', - 'nioue': 'NU', - 'nouvelle-zelande': 'NZ', - 'oman': 'OM', - 'panama': 'PA', - 'perou': 'PE', - 'papouasie-nouvelle-guinee': 'PG', - 'philippines': 'PH', - 'pakistan': 'PK', - 'pologne': 'PL', - 'porto rico': 'PR', - 'autorite palestinienne': 'PS', - 'portugal': 'PT', - 'palaos': 'PW', - 'paraguay': 'PY', - 'qatar': 'QA', - 'roumanie': 'RO', - 'russie': 'RU', - 'rwanda': 'RW', - 'arabie saoudite': 'SA', - 'iles salomon': 'SB', - 'soudan': 'SD', - 'suede': 'SE', - 'singapour': 'SG', - 'slovenie': 'SI', - 'slovaquie': 'SK', - 'sierra leone': 'SL', - 'senegal': 'SN', - 'somalie': 'SO', - 'suriname': 'SR', - 'sao tome-et-principe': 'ST', - 'el salvador': 'SV', - 'saint-martin': 'SX', - 'syrie': 'SY', - 'iles turques-et-caiques': 'TC', - 'tchad': 'TD', - 'togo': 'TG', - 'thailande': 'TH', - 'tadjikistan': 'TJ', - 'turkmenistan': 'TM', - 'tunisie': 'TN', - 'tonga': 'TO', - 'timor oriental': 'TP', - 'turquie': 'TR', - 'trinite-et-tobago': 'TT', - 'tuvalu': 'TV', - 'taiwan': 'TW', - 'tanzanie': 'TZ', - 'ukraine': 'UA', - 'ouganda': 'UG', - 'u.r.s.s.': 'SU', - 'u.s.a.': 'US', # modified based on allocine value, originally 'etats-unis' - 'uruguay': 'UY', - 'ouzbekistan': 'UZ', - 'saint-vincent-et-les grenadines': 'VC', - 'venezuela': 'VE', - 'iles vierges (britanniques)': 'VG', - 'iles vierges': 'VI', - 'vietnam': 'VN', - 'vanuatu': 'VU', - 'samoa occidentales': 'WS', - 'kosovo': 'XK', - 'yemen': 'YE', - 'serbie': 'YU', - 'afrique du sud': 'ZA', - 'zambie': 'ZM', - 'zimbabwe': 'ZW', + "andorre": "AD", + "emirats arabes unis": "AE", + "afghanistan": "AF", + "antigua et barbuda": "AG", + "anguilla": "AI", + "albanie": "AL", + "curacao": "AN", + "angola": "AO", + "argentine": "AR", + "samoa americaines": "AS", + "autriche": "AT", + "australie": "AU", + "aruba": "AW", + "azerbaidjan": "AZ", + "bosnie-herzegovine": "BA", + "barbade": "BB", + "bangladesh": "BD", + "belgique": "BE", + "burkina faso": "BF", + "bulgarie": "BG", + "bahrein": "BH", + "burundi": "BI", + "benin": "BJ", + "bermudes": "BM", + "brunei": "BN", + "bolivie": "BO", + "bresil": "BR", + "bahamas": "BS", + "bhoutan": "BT", + "botswana": "BW", + "bielorussie": "BY", + "belize": "BZ", + "canada": "CA", + "congo rdc": "CD", + "republique centrafricaine": "CF", + "congo (brazzaville)": "CG", + "suisse": "CH", + "cote d'ivoire": "CI", + "iles cook": "CK", + "chili": "CL", + "cameroun": "CM", + "chine": "CN", + "colombie": "CO", + "costa rica": "CR", + "cuba": "CU", + "cap vert": "CV", + "chypre": "CY", + "republique tcheque": "CZ", + "tchecoslovaquie": "CS", + "allemagne": "DE", + "allemagne de l'ouest": "DE", + "allemagne de l'est": "DE", + "djibouti": "DJ", + "danemark": "DK", + "dominique": "DM", + "republique dominicaine": "DO", + "algerie": "DZ", + "equateur": "EC", + "estonie": "EE", + "egypte": "EG", + "erythree": "ER", + "espagne": "ES", + "ethiopie": "ET", + "finlande": "FI", + "fidji": "FJ", + "iles malouines": "FK", + "micronesie": "FM", + "france": "FR", + "gabon": "GA", + "grande-bretagne": "GB", # modified based on allocine value, originally 'Royaume-Uni' + "grenade": "GD", + "georgie": "GE", + "ghana": "GH", + "gibraltar": "GI", + "gambie": "GM", + "guinee": "GN", + "guinee equatoriale": "GQ", + "grece": "GR", + "guatemala": "GT", + "guam": "GU", + "guinee-bissau": "GW", + "guyana": "GY", + "hong kong": "HK", + "hong-kong": "HK", + "honduras": "HN", + "croatie": "HR", + "haiti": "HT", + "hongrie": "HU", + "indonesie": "ID", + "irlande": "IE", + "israel": "IL", + "inde": "IN", + "irak": "IQ", + "islande": "IS", + "italie": "IT", + "jamaique": "JM", + "jordanie": "JO", + "japon": "JP", + "kenya": "KE", + "kyrghyzstan": "KG", + "cambodge": "KH", + "kiribati": "KI", + "comores": "KM", + "saint-christophe-et-nieves": "KN", + "coree du sud": "KR", # modified based on allocine value, originally 'Coree, Republique de' + "koweit": "KW", + "iles caimans": "KY", + "kazakhstan": "KZ", + "laos": "LA", + "liban": "LB", + "sainte-lucie": "LC", + "liechtenstein": "LI", + "sri lanka": "LK", + "liberia": "LR", + "lesotho": "LS", + "lituanie": "LT", + "luxembourg": "LU", + "lettonie": "LV", + "libye": "LY", + "maroc": "MA", + "monaco": "MC", + "moldavie": "MD", + "montenegro": "ME", + "madagascar": "MG", + "iles marshall": "MH", + "macedoine": "MK", + "mali": "ML", + "birmanie": "MM", + "mongolie": "MN", + "macao": "MO", + "iles mariannes du nord": "MP", + "mauritanie": "MR", + "montserrat": "MS", + "malte": "MT", + "maurice": "MU", + "maldives": "MV", + "malawi": "MW", + "mexique": "MX", + "malaisie": "MY", + "mozambique": "MZ", + "namibie": "NA", + "niger": "NE", + "nigeria": "NG", + "nicaragua": "NI", + "pays-bas": "NL", + "norvege": "NO", + "nepal": "NP", + "nauru": "NR", + "nioue": "NU", + "nouvelle-zelande": "NZ", + "oman": "OM", + "panama": "PA", + "perou": "PE", + "papouasie-nouvelle-guinee": "PG", + "philippines": "PH", + "pakistan": "PK", + "pologne": "PL", + "porto rico": "PR", + "autorite palestinienne": "PS", + "palestine": "PS", + "portugal": "PT", + "palaos": "PW", + "paraguay": "PY", + "qatar": "QA", + "roumanie": "RO", + "russie": "RU", + "rwanda": "RW", + "arabie saoudite": "SA", + "iles salomon": "SB", + "soudan": "SD", + "suede": "SE", + "singapour": "SG", + "slovenie": "SI", + "slovaquie": "SK", + "sierra leone": "SL", + "senegal": "SN", + "somalie": "SO", + "suriname": "SR", + "sao tome-et-principe": "ST", + "el salvador": "SV", + "saint-martin": "SX", + "syrie": "SY", + "iles turques-et-caiques": "TC", + "tchad": "TD", + "togo": "TG", + "thailande": "TH", + "tadjikistan": "TJ", + "turkmenistan": "TM", + "tunisie": "TN", + "tonga": "TO", + "timor oriental": "TP", + "turquie": "TR", + "trinite-et-tobago": "TT", + "tuvalu": "TV", + "taiwan": "TW", + "tanzanie": "TZ", + "ukraine": "UA", + "ouganda": "UG", + "u.r.s.s.": "SU", + "urss": "SU", + "u.s.a.": "US", # modified based on allocine value, originally 'etats-unis' + "uruguay": "UY", + "ouzbekistan": "UZ", + "saint-vincent-et-les grenadines": "VC", + "venezuela": "VE", + "iles vierges (britanniques)": "VG", + "iles vierges": "VI", + "vietnam": "VN", + "vanuatu": "VU", + "samoa occidentales": "WS", + "kosovo": "XK", + "yemen": "YE", + "serbie": "YU", + "afrique du sud": "ZA", + "zambie": "ZM", + "zimbabwe": "ZW", } diff --git a/allocine/schedules.py b/allocine/schedules.py new file mode 100644 index 0000000..b41de85 --- /dev/null +++ b/allocine/schedules.py @@ -0,0 +1,162 @@ +from collections import OrderedDict +from datetime import date as Date +from datetime import time as Time +from datetime import timedelta +from typing import List, Tuple + +from allocine.models import Schedule, Showtime, get_hour_short_str, to_french_short_weekday + + +def get_available_dates(showtimes: List[Showtime]): + dates = [s.date for s in showtimes] + return sorted(list(set(dates))) + + +def group_showtimes_per_schedule(showtimes: List[Showtime]): + showtimes_per_date = {} + available_dates = get_available_dates(showtimes=showtimes) + for available_date in available_dates: + showtimes_per_date[available_date] = get_showtimes_of_a_day(showtimes=showtimes, date=available_date) + + grouped_showtimes = {} + for available_date in available_dates: + hours = [s.hour_short_str for s in showtimes_per_date[available_date]] + hours_str = ", ".join(hours) + if grouped_showtimes.get(hours_str) is None: + grouped_showtimes[hours_str] = [] + grouped_showtimes[hours_str].append(available_date) + return grouped_showtimes + + +def build_program_str(showtimes: List[Showtime]): + schedules = [Schedule(s.date_time) for s in showtimes] + return build_weekly_schedule_str(schedules) + + +def check_schedules_within_week(schedule_list: List[Schedule]) -> bool: + schedule_dates = [s.date for s in schedule_list] + min_date = min(schedule_dates) + max_date = max(schedule_dates) + delta = max_date - min_date + if delta >= timedelta(days=7): + raise ValueError("Schedule list contains more days than the typical movie week") + # Check that the week is not from Mon/Tue to Wed/Thu/Fri/Sat/Sun + # because a typical week is from Wed to Tue + # but we need to handle the case of a schedule_list with only a few day + # ex: Wed, Mon = OK ; Tue = OK ; Mon, Wed : NOK + monday = 0 + tuesday = 1 + wednesday = 2 + if delta > timedelta(days=0): + if (min_date.weekday() == monday and max_date.weekday() >= wednesday) or (min_date.weekday() == tuesday): + raise ValueError("Schedule list should not start before wednesday or end after tuesday") + + return True + + +def create_weekdays_str(dates: List[Date]) -> str: + """ + Returns a compact string from a list of dates. + Examples: + - [0,1] -> 'Lun, Mar' + - [0,1,2,3,4] -> 'sf Sam, Dim' + - [0,1,2,3,4,5,6] -> '' # Everyday is empty string + - [0,2] -> 'Mer, Lun' # And not 'Lun, Mer' because we sort chrologically + """ + full_week = range(0, 7) + unique_dates = sorted(list(set(dates))) + week_days = [d.weekday() for d in unique_dates] + + if len(unique_dates) == 7: + return "" + elif len(unique_dates) <= 4: + return ", ".join([to_french_short_weekday(d) for d in week_days]) + else: + missing_days = list(set(week_days).symmetric_difference(full_week)) + return "sf {}".format(", ".join([to_french_short_weekday(d) for d in missing_days])) + + +def _get_time_weight_in_list(item: Tuple[str, List[Time]]) -> timedelta: + """Returns the minimum time weight from the time list contained in the dict values + ex: {'key': [time(hour=12), time(hour=9)]} => timedelta(hour=9) + """ + weights = [_get_time_weight(t) for t in item[1]] + return min(weights) + + +def _get_time_weight(t: Time) -> timedelta: + """Return a timedelta taking into account night time. + Basically, it allows to sort a list of times 18h>23h>0h30 + and not 0h30>18h>23h + """ + night_time = [Time(hour=0), Time(hour=5)] + delta = timedelta(hours=t.hour, minutes=t.minute) + if t >= min(night_time) and t <= max(night_time): + delta += timedelta(days=1) + return delta + + +def build_weekly_schedule_str(schedule_list: List[Schedule]) -> str: + check_schedules_within_week(schedule_list) + + hours_hashmap_raw = {} # ex: {16h: [Lun, Mar], 17h: [Lun], 17h30: [Lun]} + grouped_date_hashmap_raw = {} # ex: {[Lun]: [16h, 17h30], [Lun, Mar]: [17h]} + + for schedule in schedule_list: + if hours_hashmap_raw.get(schedule.hour) is None: + hours_hashmap_raw[schedule.hour] = [] + hours_hashmap_raw[schedule.hour].append(schedule.date) + + for hour, grouped_dates in hours_hashmap_raw.items(): + grouped_dates_str = create_weekdays_str(grouped_dates) + if grouped_date_hashmap_raw.get(grouped_dates_str) is None: + grouped_date_hashmap_raw[grouped_dates_str] = [] + grouped_date_hashmap_raw[grouped_dates_str].append(hour) + + # Then sort it chronologically + for grouped_dates_str, hours in grouped_date_hashmap_raw.items(): + # Sort the hours inside + hours = list(set(hours)) + hours.sort() + grouped_date_hashmap_raw[grouped_dates_str] = hours + + grouped_date_hashmap_raw = sorted(grouped_date_hashmap_raw.items(), key=_get_time_weight_in_list) + grouped_date_hashmap = OrderedDict(grouped_date_hashmap_raw) + + hours_hashmap = OrderedDict() + for hour in sorted(hours_hashmap_raw.keys(), key=_get_time_weight): + hours_hashmap[hour] = hours_hashmap_raw.get(hour) + + different_showtimes = len(grouped_date_hashmap) + + # True if at least one schedule is available everyday + some_schedules_available_everyday = grouped_date_hashmap.get("") is not None + + weekly_schedule = "" + + if some_schedules_available_everyday: + for hour, grouped_dates in hours_hashmap.items(): + hour_str = get_hour_short_str(hour) + grouped_dates_str = create_weekdays_str(grouped_dates) + if grouped_dates_str: + weekly_schedule += f"{hour_str} ({grouped_dates_str}), " + else: # Available everyday + weekly_schedule += f"{hour_str}, " + else: + for grouped_dates, hours in grouped_date_hashmap.items(): + hours_str = ", ".join([get_hour_short_str(h) for h in hours]) + if different_showtimes == 1: + weekly_schedule += f"{grouped_dates} {hours_str}, " + else: + if some_schedules_available_everyday: + weekly_schedule += f"{hours_str} ({grouped_dates}), " + else: + weekly_schedule += f"{grouped_dates} {hours_str}; " + + if weekly_schedule: + weekly_schedule = weekly_schedule[:-2] # Remove trailing comma + return weekly_schedule + + +def get_showtimes_of_a_day(showtimes: List[Showtime], *, date: Date): + return [showtime for showtime in showtimes if showtime.date == date] diff --git a/capture.svg b/capture.svg deleted file mode 100644 index af644ca..0000000 --- a/capture.svg +++ /dev/null @@ -1 +0,0 @@ -bash-3.2$seances.pyP0645GaumontToulouseLabegeIMAX,le27/12/2018┌─────────────────────────────────────────────────────────────┬──────┬───────┬───────┐│AStarIsBorn(VOST)-02h16│4.5*││22:15││Aquaman(VF3D,Salle4DX)-02h24│4.0*││22:00││Astérix-LeSecretdelaPotionMagique...(VF3D)-01h25│4.1*││22:15││Bumblebee(VFIMAX3D)-01h54│3.8*│21:50│││Bumblebee(VF)-01h54│3.8*││22:30││Enliberté!(VF)-01h48│3.2*││22:25││L'EmpereurdeParis(VF)-01h50│3.4*││22:30││L'ExorcismedeHannahGrace(VF)-01h25│2.4*││22:45││LeGendredemavie(VF)-01h40│2.4*││22:30││LeGrandBain(VF)-01h58│4.0*│21:45│││LeRetourdeMaryPoppins(VF)-02h11│3.5*│21:45│││MortalEngines(VF)-02h08│3.4*││22:00││Secondechance(VF)-01h44│3.2*││22:45││Unfriended:DarkWeb(VF)-01h28│3.3*││22:45│└─────────────────────────────────────────────────────────────┴──────┴───────┴───────┘bash-3.2$sbash-3.2$sebash-3.2$seabash-3.2$seanbash-3.2$seances.pybash-3.2$seances.pyPbash-3.2$seances.pyP0bash-3.2$seances.pyP06bash-3.2$seances.pyP064│L'ExorcismedeHannahGracebash-3.2$bash-3.2$exit \ No newline at end of file diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..5b447af Binary files /dev/null and b/demo.gif differ diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..300c3d8 --- /dev/null +++ b/demo.tape @@ -0,0 +1,95 @@ +# To update demo.gif, run: +# brew install vhs +# vhs demo.tape + +# VHS documentation +# +# Output: +# Output .gif Create a GIF output at the given +# Output .mp4 Create an MP4 output at the given +# Output .webm Create a WebM output at the given +# +# Require: +# Require Ensure a program is on the $PATH to proceed +# +# Settings: +# Set FontSize Set the font size of the terminal +# Set FontFamily Set the font family of the terminal +# Set Height Set the height of the terminal +# Set Width Set the width of the terminal +# Set LetterSpacing Set the font letter spacing (tracking) +# Set LineHeight Set the font line height +# Set LoopOffset % Set the starting frame offset for the GIF loop +# Set Theme Set the theme of the terminal +# Set Padding Set the padding of the terminal +# Set Framerate Set the framerate of the recording +# Set PlaybackSpeed Set the playback speed of the recording +# Set MarginFill Set the file or color the margin will be filled with. +# Set Margin Set the size of the margin. Has no effect if MarginFill isn't set. +# Set BorderRadius Set terminal border radius, in pixels. +# Set WindowBar Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) +# Set WindowBarSize Set window bar size, in pixels. Default is 40. +# Set TypingSpeed