From 63168af26666e313cf652cbaf2aa1452ab7a56d2 Mon Sep 17 00:00:00 2001 From: Eloi Zalczer Date: Fri, 11 Aug 2023 16:12:25 +0200 Subject: [PATCH] Started working on backend --- server/officiel_des_spectacles.py | 59 ------ server/pyproject.toml | 65 ++++++ server/requirements.txt | 7 + server/src/flimder/__init__.py | 49 +++++ server/src/flimder/apidoc.md | 13 ++ server/src/flimder/config.toml | 24 +++ server/src/flimder/ext.py | 3 + server/src/flimder/fields.py | 2 + server/src/flimder/providers/base.py | 9 + .../officiel_des_spectacles/favicon.ico | Bin 0 -> 1150 bytes .../officiel_des_spectacles/provider.py | 55 +++++ server/src/flimder/repository.py | 12 ++ server/src/flimder/routes.py | 46 ++++ server/src/flimder/schemas.py | 27 +++ server/src/flimder/tomlconfig.py | 197 ++++++++++++++++++ 15 files changed, 509 insertions(+), 59 deletions(-) delete mode 100644 server/officiel_des_spectacles.py create mode 100644 server/pyproject.toml create mode 100644 server/requirements.txt create mode 100644 server/src/flimder/__init__.py create mode 100644 server/src/flimder/apidoc.md create mode 100644 server/src/flimder/config.toml create mode 100644 server/src/flimder/ext.py create mode 100644 server/src/flimder/fields.py create mode 100644 server/src/flimder/providers/base.py create mode 100644 server/src/flimder/providers/officiel_des_spectacles/favicon.ico create mode 100644 server/src/flimder/providers/officiel_des_spectacles/provider.py create mode 100644 server/src/flimder/repository.py create mode 100644 server/src/flimder/routes.py create mode 100644 server/src/flimder/schemas.py create mode 100644 server/src/flimder/tomlconfig.py diff --git a/server/officiel_des_spectacles.py b/server/officiel_des_spectacles.py deleted file mode 100644 index 40273e5..0000000 --- a/server/officiel_des_spectacles.py +++ /dev/null @@ -1,59 +0,0 @@ -import datetime -from typing import Dict -import requests - -from bs4 import BeautifulSoup - -import re - -def get_showings(film: str) -> Dict: - r = requests.get(f"https://www.offi.fr/cinema/evenement/{film}") - - soup = BeautifulSoup(r.text) - - screenings = soup.find_all("div", {"id": re.compile(r"t_sceances_[a-zA-Z0-9]+")}) - - output = {} - - for screening in screenings: - datematch = re.match(r"t_sceances_([0-9]{4})-([0-9]{2})-([0-9]{2})", screening["id"]) - - date = datetime.date(int(datematch.group(1)), int(datematch.group(2)), int(datematch.group(3))) - - datedata = {} - - links = screening.find_all("a", {"class": "text-dark"}) - - for link in links: - - cinema = link.find("span").text - - cinetimes = [] - - times = link.find_next("div", {"class": "event-times-container"}) - - for time in times.find_all("span"): - cinetimes.append(time.text) - - datedata[cinema] = cinetimes - - output[date] = datedata - - return output - -FILMS = [ - "le-ciel-peut-attendre-5799", - "tideland-26007", - "sommeil-dhiver-53367", - "a-girl-at-my-door-54742", - "37-degres-2-le-matin-228", - "mission-version-restauree-92072", - "funny-games-us-30758", - "huit-et-demi-13525", - "climax-69321", - "the-first-slam-dunk-92878" -] - -if __name__ == "__main__": - for film in FILMS: - print(get_showings(film)) \ No newline at end of file diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..4fc70bf --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,65 @@ +# pyproject.toml reference / quickstart: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "flimder" +authors = [ + { name = "Eloi Zalczer", email = "eloi@zalczer.fr" }, +] +description = "Find flims wherever they play" +readme = "README.md" +requires-python = ">=3.8" +keywords = ["flimder"] +dynamic = ["version"] +dependencies = [ + "requests==2.28.2", + "beautifulsoup4==4.12.2", + "flask-smorest==0.40.0", + "Flask==2.2.3", + "python-dotenv==0.17.1", + "gunicorn", + "toml", +] + +[tool.setuptools.dynamic] +version = { attr = "flimder.__version__" } + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "--cov --cov-report=html --quiet -ra" # -ra --> report on all but "passed" +testpaths = ["test"] +python_files = ["test*.py"] +python_functions = ["test*"] + +# coverage configuration reference: https://coverage.readthedocs.io/en/stable/config.html +[tool.coverage.run] +branch = true +parallel = true +include = ["src/*"] + +[tool.coverage.report] +show_missing = true +precision = 2 + +[tool.coverage.paths] +source = [ + "src/flimder", + "*/site-packages/flimder", +] + +[tool.black] +# black configuration reference: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html +line-length = 120 +target-version = ["py37", "py38", "py39", "py310", "py311"] + +[tool.isort] +# isort configuration reference: https://pycqa.github.io/isort/docs/configuration/options.html +profile = "black" +line_length = 120 +src_paths = ["src", "tests"] + +[tool.pylint.format] +max-line-length = 120 diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..00a6a9a --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,7 @@ +requests==2.28.2 +beautifulsoup4==4.12.2 +flask-smorest==0.40.0 +Flask==2.2.3 +python-dotenv==0.17.1 +gunicorn +toml \ No newline at end of file diff --git a/server/src/flimder/__init__.py b/server/src/flimder/__init__.py new file mode 100644 index 0000000..21b07a8 --- /dev/null +++ b/server/src/flimder/__init__.py @@ -0,0 +1,49 @@ +import logging +import os + +import flask +import werkzeug + +from flimder import ext +from flimder.routes import blueprint + +from flask_smorest import Api + +__version__ = "0.1.0" + + +BASE_DIR = os.path.dirname(__file__) +API_DOC_PATH = os.path.join(BASE_DIR, "apidoc.md") +CONFIG_PATH = os.environ.get("FLASK_CONF_PATH", os.path.join(BASE_DIR, "config.toml")) +CONFIG_NAME = os.environ.get("FLASK_CONF_NAME", "LOCALDEV") + + +def create_app(configname: str = CONFIG_NAME, configpath: str = CONFIG_PATH) -> flask.Flask: + """Flask app factory. + + Returns: + flask.Flask: The created flask application object, ready to be run. + """ + + # Set HTTP protocol version to 1.1 to profit from the 'keep-alive' feature + # 'keep-alive' maintains the TCP connection open for a certain time allowing to re-use it when + # sending multiple requests + werkzeug.serving.WSGIRequestHandler.protocol_version = "HTTP/1.1" + + app = flask.Flask(__name__) + + ext.config.configname = configname + ext.config.configpath = configpath + + ext.config.init_app(app) + + with open(API_DOC_PATH, "r") as file: + apidoc = file.read() + + app.config.setdefault("API_SPEC_OPTIONS", {}).setdefault("info", {}) + app.config["API_SPEC_OPTIONS"]["info"]["description"] = apidoc + + api = Api(app) + api.register_blueprint(blueprint) + + return app diff --git a/server/src/flimder/apidoc.md b/server/src/flimder/apidoc.md new file mode 100644 index 0000000..9d5dcbb --- /dev/null +++ b/server/src/flimder/apidoc.md @@ -0,0 +1,13 @@ +## Flimder. + +### Overview + +TODO + +### Other documentation + +| Name | Path | +| ---------- | -------------- | +| Swagger Ui | `/` | +| Redoc | `/doc/redoc` | +| Rapidoc | `/doc/rapidoc` | diff --git a/server/src/flimder/config.toml b/server/src/flimder/config.toml new file mode 100644 index 0000000..2accb71 --- /dev/null +++ b/server/src/flimder/config.toml @@ -0,0 +1,24 @@ +[DEFAULT] + +OPENAPI_VERSION = "3.0.2" +API_VERSION = "0.1" +API_TITLE = "Flimder" + +OPENAPI_JSON_PATH = "api-spec.json" +OPENAPI_URL_PREFIX = "/" +OPENAPI_REDOC_PATH = "/redoc" +OPENAPI_REDOC_URL = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js" +OPENAPI_SWAGGER_UI_PATH = "/" +OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" +OPENAPI_RAPIDOC_PATH = "/rapidoc" +OPENAPI_RAPIDOC_URL = "https://unpkg.com/rapidoc/dist/rapidoc-min.js" + +[LOCALDEV] + +DEBUG = true +TESTING = true + +[PRODUCTION] + +DEBUG = false +TESTING = false diff --git a/server/src/flimder/ext.py b/server/src/flimder/ext.py new file mode 100644 index 0000000..f3eae08 --- /dev/null +++ b/server/src/flimder/ext.py @@ -0,0 +1,3 @@ +from flimder.tomlconfig import Config + +config: Config = Config() diff --git a/server/src/flimder/fields.py b/server/src/flimder/fields.py new file mode 100644 index 0000000..85bce36 --- /dev/null +++ b/server/src/flimder/fields.py @@ -0,0 +1,2 @@ +from marshmallow.fields import * +from webargs.fields import DelimitedList # noqa=W0611 diff --git a/server/src/flimder/providers/base.py b/server/src/flimder/providers/base.py new file mode 100644 index 0000000..69e5aa4 --- /dev/null +++ b/server/src/flimder/providers/base.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod +from typing import Dict + + +class ScreeningsProvider(ABC): + @classmethod + @abstractmethod + def get_screenings(cls, movies: Dict[str, Dict]): + pass diff --git a/server/src/flimder/providers/officiel_des_spectacles/favicon.ico b/server/src/flimder/providers/officiel_des_spectacles/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ccc70688a5840423e676602e74ee2bd1d27c00c2 GIT binary patch literal 1150 zcmZ`(O-vI}5Pk$B@qknhh$aR!8aa8<=z)V#;b3Cr;NQiA@#MjS9<(%tScoPHN=#5< zs)>maqQ=xndmuyya9e6q+p^nnX507DY;`x^&hE^7-O4hA_*}XH}?FJ2U~l&IVm$?(s=C?cLb3^!Lw#M>VX@jzTK<4(ri$ zf5Q8k??uCRbu`d_@Jspnso;CTCoYf0oR@V!X@(yj)BW>L&pnr2);^v?RvzgP{EX}c zkEPT*+`G1x(__mY2X(%5ef!TpOM%@U%f>v{@Y=ROb~!9GsF#sP?XWm@Q}~prVNn-4 z*}@x3Kj)M6_w9pRVnO5^Z(^@5>3pwsbC&pOE>ja6IP$4nW2R>({F)kqf?BMZG-&V2 z^b0W$p~1FE;uG@emAcUV)(_eMJ&+H3x+~_qG9Y#Dh|@ZPd$m^c-`OwW%%A<&w`A0#9MZy>I84Z{ o%r{o@7>gca%wC76!M{T_qN;?_&%%zDFoB3-EQwGt7fWS-0WD?eVE_OC literal 0 HcmV?d00001 diff --git a/server/src/flimder/providers/officiel_des_spectacles/provider.py b/server/src/flimder/providers/officiel_des_spectacles/provider.py new file mode 100644 index 0000000..a5306e0 --- /dev/null +++ b/server/src/flimder/providers/officiel_des_spectacles/provider.py @@ -0,0 +1,55 @@ +import datetime +import re +from typing import Dict + +import requests +from bs4 import BeautifulSoup + + +class OfficielDesSpectaclesProvider: + name: str = "officiel_des_spectacles" + + @classmethod + def _get_movie_screenings(cls, url: str): + r = requests.get(url) + + soup = BeautifulSoup(r.text, "html.parser") + + screenings = soup.find_all("div", {"id": re.compile(r"t_sceances_[a-zA-Z0-9]+")}) + + output = {} + + for screening in screenings: + datematch = re.match(r"t_sceances_([0-9]{4})-([0-9]{2})-([0-9]{2})", screening["id"]) + + date = datetime.date(int(datematch.group(1)), int(datematch.group(2)), int(datematch.group(3))) + + datedata = {} + + links = screening.find_all("a", {"class": "text-dark"}) + + for link in links: + cinema = link.find("span").text + + cinetimes = [] + + times = link.find_next("div", {"class": "event-times-container"}) + + for time in times.find_all("span"): + cinetimes.append(time.text) + + datedata[cinema] = cinetimes + + output[date] = datedata + + return output + + @classmethod + def get_screenings(cls, movies: Dict[str, Dict]) -> Dict: + out = {} + + for movie, params in movies.items(): + screenings = cls._get_movie_screenings(params["url"]) + out[movie] = screenings + + return out diff --git a/server/src/flimder/repository.py b/server/src/flimder/repository.py new file mode 100644 index 0000000..303e6b5 --- /dev/null +++ b/server/src/flimder/repository.py @@ -0,0 +1,12 @@ +import json +import os +from typing import Dict + +_MOVIES_REPOSITORY_PATH = os.path.join(__file__, "..", "movies.json") + + +def get_movies() -> Dict: + with open(_MOVIES_REPOSITORY_PATH, "r", encoding="utf-8") as jsonfile: + data = json.load(jsonfile) + + return data diff --git a/server/src/flimder/routes.py b/server/src/flimder/routes.py new file mode 100644 index 0000000..a00c66b --- /dev/null +++ b/server/src/flimder/routes.py @@ -0,0 +1,46 @@ +from typing import Dict + +from flask_smorest import Blueprint +from flimder.providers.officiel_des_spectacles.provider import OfficielDesSpectaclesProvider +from flimder.repository import get_movies +from flimder.schemas import MovieSchema, ProviderSchema, ScreeningsSchema + +PROVIDERS = [ + OfficielDesSpectaclesProvider, +] + +# TODO description from dunder variable. +blueprint = Blueprint("Flims finder", __name__, url_prefix="/", description="Flims finder.") + + +@blueprint.get("/screenings") +@blueprint.response(200, schema=ScreeningsSchema(many=True)) +def get_screenings(): + movies = get_movies() + + out = [] + + for provider in PROVIDERS: + compatible_movies = {} + for movie in movies: + if provider.name in movie["providers_params"]: + compatible_movies[movie["title"]] = movie["providers_params"][provider.name] + + screenings = provider.get_screenings(compatible_movies) + + print(screenings) + + return out + + +@blueprint.post("/movie") +@blueprint.arguments(schema=MovieSchema) +@blueprint.response(201) +def add_movie(body: Dict): + pass + + +@blueprint.post("/providers") +@blueprint.response(200, schema=ProviderSchema(many=True)) +def get_providers(): + pass diff --git a/server/src/flimder/schemas.py b/server/src/flimder/schemas.py new file mode 100644 index 0000000..19475f5 --- /dev/null +++ b/server/src/flimder/schemas.py @@ -0,0 +1,27 @@ +from marshmallow import Schema + +from flimder import fields + + +class MovieSchema(Schema): + title = fields.String() + + +class ScreeningSchema(Schema): + time = fields.String() + + +class TheaterScreeningsSchema(Schema): + theater_name = fields.String() + screenings = fields.List(fields.Nested(ScreeningSchema())) + + +class ScreeningsSchema(Schema): + movie = fields.Nested(MovieSchema()) + screenings = fields.Mapping(fields.String(), fields.Nested(TheaterScreeningsSchema())) + + +class ProviderSchema(Schema): + name = fields.String() + icon = fields.Url() + formfields = fields.List() diff --git a/server/src/flimder/tomlconfig.py b/server/src/flimder/tomlconfig.py new file mode 100644 index 0000000..1d00a7b --- /dev/null +++ b/server/src/flimder/tomlconfig.py @@ -0,0 +1,197 @@ +"""Small Flask Extension to load flask app configuration from a TOML file. + +Heavily inspired by the YAML config parser in SpecsDB, adapted for TOML since support is included in +Python standard lib starting from python 3.11.""" + +import copy +import functools +import os +import re +import warnings +from collections import abc +from typing import Any, Dict, Mapping, Optional + +import flask +import toml + +_ENV_REG = re.compile(r"\$\{([a-zA-Z\_][a-zA-Z0-9\_]+)\|([^\n\r\0\}]*)\}") + + +@functools.lru_cache(maxsize=None) +def _decoder() -> toml.TomlDecoder: + """Initialize a TOML decoder and keep it in cache. + + Returns: + Repo: a TOML decoder. + """ + return toml.TomlDecoder() + + +def load_value(v: str) -> Any: + """Load a single value using the TOML logic. + + Args: + v (str): the string to parse. + + Returns: + Any: the parsed value. If the value could not be parsed, the input string is returned. + """ + try: + return _decoder().load_value(v)[0] + except ValueError: + return v + + +class Config: + """Flask extension class; allow easy configuration from TOML config file. + + Use `confs` property to get the configurations loaded from file. + The configuration will be loaded in the Flask app once, the 3 elements are set: + - flask app via constuctor or with `initApp` method + - configpath via constuctor or with `configpath` property + - configname via constuctor or with `configname` property + + Args: + app: The flask application, can be set at init or through initApp method + configpath: The configuration file path + configname: The selected configuration name + extendDefault: If True when using any conf, will first look for a conf with name + 'default' and load its value before updating them with the selected conf values. + """ + + def __init__( + self, + app: Optional[flask.Flask] = None, + configpath: Optional[str] = None, + configname: Optional[str] = None, + extendDefault: bool = True, + ): + self._app: Optional[flask.Flask] = app + self._configpath: Optional[str] = configpath + self._configname: Optional[str] = configname + self._extendDefault: bool = extendDefault + self._configs: Optional[Mapping[str, Mapping[str, Any]]] = None + + if configpath is not None: + self._configs = self.read_file(configpath) + + self._load() + + def init_app(self, app: flask.Flask): + """Register app.""" + self._app = app + self._load() + + @property + def app(self) -> Optional[flask.Flask]: + """The linked Flask application.""" + return self._app + + @property + def configpath(self) -> Optional[str]: + """The path of the configuration file.""" + return self._configpath + + @configpath.setter + def configpath(self, path: str): + self._configs = self.read_file(path) + self._configpath = path + self._load() + + @property + def configname(self) -> Optional[str]: + """The used configuration name""" + return self._configname + + @configname.setter + def configname(self, name: str): + if self._configs is not None: + assert name.upper() in self.configs + self._configname = name.upper() + self._load() + + @property + def configs(self) -> Mapping[str, Mapping[str, Any]]: + """Return configurations parsed from file as a dictionnary""" + if self._configs is None: + return None + if self._extendDefault: + default = self._configs.get("DEFAULT", {}) + extended = {} + for configname, conf in self._configs.items(): + extended[configname] = copy.deepcopy(default) + extended[configname].update(conf) + return extended + return copy.deepcopy(self._configs) + + def _load(self) -> None: + """Load `configname` in app.""" + if self.app is None or self.configpath is None or self.configname is None: + return + + conf = self.configs[self.configname] + for key in ("ENV",): + if key in conf: + msg = ( + f"'{self.configpath}:{self.configname}': {key} should be set through " + f"'FLASK_{key}' environment variable, the value is ignored" + ) + del conf[key] + warnings.warn(msg) + self.app.config.from_mapping(conf) + + @classmethod + def read_file(cls, path: str) -> Dict[str, Dict[str, Any]]: + """Read TOML conf file, interpolate environment variables and capitalize all keys. + + Patterns such as $VAR, ${VAR} and %VAR% in values are considered as environment variable + placeholders, and if a environment variable with the specified name exist then the pattern + is replaced with the value. + + The pattern ${VAR:default_value} is considered as an environment variable placeholder with + a default value, the pattern will either be replaced with the corresponding environment + variable value, or with the default value. + Limitations: + - `VAR` can only contain letters, numbers and '_' and cannot start with a number + - `default_value` cannot contain '}' + + Args: + path: The path of the TOML file to read + + Returns: + The parsed TOML file as a dict. + """ + with open(path, "r") as file: + data = file.read() + return cls._recurse_expandvars(toml.loads(data, decoder=_decoder())) + + @classmethod + def _recurse_expandvars(cls, obj: Any) -> Any: + """Expand environment variable""" + if isinstance(obj, str): + return cls._expandvars(obj) + if isinstance(obj, abc.Mapping): + return {key: cls._recurse_expandvars(val) for key, val in obj.items()} + if isinstance(obj, abc.Sequence): + return [cls._recurse_expandvars(elt) for elt in obj] + return obj + + @classmethod + def _expandvars(cls, string: str) -> str: + """Like os.path.expandvars but support default value in the format ${ENVNAME:default_val} + + By definition, and contrary to expandvars behaviour, environment variable with a + default value are always expanded. + + The default value cannot contains new line character, null charcter and will stop at the + first '}' encountered. + """ + string = os.path.expandvars(string) + while True: + match = _ENV_REG.search(string) + if not match: + break + pattern, envvar, default = match.group(0, 1, 2) + envval = os.environ.get(envvar, load_value(default)) + string = string.replace(pattern, envval) + return string