Started working on backend

main
Eloi Zalczer 2023-08-11 16:12:25 +02:00
parent c3498b5584
commit 63168af266
15 changed files with 509 additions and 59 deletions

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,13 @@
## Flimder.
### Overview
TODO
### Other documentation
| Name | Path |
| ---------- | -------------- |
| Swagger Ui | `/` |
| Redoc | `/doc/redoc` |
| Rapidoc | `/doc/rapidoc` |

View File

@ -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

View File

@ -0,0 +1,3 @@
from flimder.tomlconfig import Config
config: Config = Config()

View File

@ -0,0 +1,2 @@
from marshmallow.fields import *
from webargs.fields import DelimitedList # noqa=W0611

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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