Started working on backend
parent
c3498b5584
commit
63168af266
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
## Flimder.
|
||||
|
||||
### Overview
|
||||
|
||||
TODO
|
||||
|
||||
### Other documentation
|
||||
|
||||
| Name | Path |
|
||||
| ---------- | -------------- |
|
||||
| Swagger Ui | `/` |
|
||||
| Redoc | `/doc/redoc` |
|
||||
| Rapidoc | `/doc/rapidoc` |
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from flimder.tomlconfig import Config
|
||||
|
||||
config: Config = Config()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
from marshmallow.fields import *
|
||||
from webargs.fields import DelimitedList # noqa=W0611
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue