├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── __init__.py ├── app.py ├── application.py ├── assets ├── core.css └── favicon.ico ├── create_tables.py ├── example.gif ├── fly.toml ├── models ├── password_change.py └── user.py ├── pages ├── __init__.py ├── auth_pages │ ├── __init__.py │ ├── change_password.py │ ├── forgot_password.py │ ├── login.py │ ├── logout.py │ └── register.py ├── home.py ├── page.py └── profile.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── users.db └── utils ├── __init__.py ├── auth.py ├── config.py ├── pw.py └── user.py /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | venv 3 | dev-venv 4 | .git 5 | example.gif 6 | # ... whatever else you want -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # REQUIRED 2 | HOME_PATH="/" 3 | APP_NAME="Dash Auth Flow" 4 | SQLALCHEMY_DATABASE_URI="sqlite:///users.db" 5 | TRANSITION_DELAY=1.5 6 | ALERT_DELAY=3000 7 | 8 | # OPTIONAL - only necessary if using the email send functionality. 9 | MAILJET_API_KEY="" 10 | MAILJET_API_SECRET="" 11 | FROM_EMAIL="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints 2 | __pycache__ 3 | *pycache 4 | _local 5 | updateReqs 6 | *.html 7 | .vscode 8 | env 9 | venv 10 | dev-venv 11 | *users.db* 12 | testbench 13 | bkpMySQL 14 | run_gunicorn 15 | run_flask_server 16 | push_to_server 17 | 18 | #Standard gitignore 19 | # Compiled source # 20 | ################### 21 | *.com 22 | *.class 23 | *.dll 24 | *.exe 25 | *.o 26 | *.so 27 | 28 | # Packages # 29 | ############ 30 | # it's better to unpack these files and commit the raw source 31 | # git has its own built in compression methods 32 | *.7z 33 | *.dmg 34 | *.gz 35 | *.iso 36 | *.jar 37 | *.rar 38 | *.tar 39 | *.zip 40 | 41 | # Logs and databases # 42 | ###################### 43 | *.log 44 | *.sql 45 | *.sqlite 46 | *.db 47 | 48 | # OS generated files # 49 | ###################### 50 | .DS_Store 51 | .DS_Store? 52 | ._* 53 | .Spotlight-V100 54 | .Trashes 55 | ehthumbs.db 56 | Thumbs.db 57 | 58 | # python 59 | .env 60 | Pipfile 61 | Pipfile.lock -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bullseye 2 | 3 | ENV PYTHONUNBUFFERED True 4 | ENV APP_HOME /app 5 | WORKDIR $APP_HOME 6 | COPY . ./ 7 | 8 | RUN pip install -r requirements.txt 9 | 10 | CMD ["gunicorn", "--workers","3","app:server", "-b", "0.0.0.0:8080"] 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Russell Romney 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app:server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dash-auth-flow 2 | 3 | Batteries-included authentication flow in [Dash](dash.plot.ly) with Dash Pages. 4 | 5 | This has landing pages and functions to run the entire authentication flow: 6 | 7 | - home 8 | - login 9 | - logout 10 | - register 11 | - forgot password 12 | - change password 13 | 14 | This uses `flask-login` on the backend, taking some inspiration from the very useful [dash-flask-login](https://github.com/RafaelMiquelino/dash-flask-login). Data is held in `users.db`. 15 | 16 | ### The `.env` File 17 | 18 | The provided `.env.example` is just an example. Copy that file to a `.env` file and fill in the details. It's set up this way so you don't accidentally expose your credentials in git. The app won't run correctly if `.env` doesn't have the required values filled out. 19 | 20 | 21 | 22 | ### Run Locally 23 | 24 | ```shell 25 | # with plain virtualenv 26 | python -m venv venv 27 | source venv/bin/activate 28 | pip install -r requirements.txt 29 | python create_tables.py 30 | python app.py 31 | 32 | # with pipenv 33 | pip install pipenv 34 | pipenv install --ignore-pipfile 35 | pipenv python create_tables.py 36 | pipenv run python app.py 37 | 38 | # with poetry 39 | pip install poetry 40 | poetry run python create_tables.py 41 | poetry run python app.py 42 | 43 | # either: deactivate virtual environment 44 | deactivate 45 | ``` 46 | 47 | ![](example.gif) 48 | 49 | ## Notes: 50 | 51 | - this uses MailJet as the email API. You need a [free MailJet API key](https://www.mailjet.com/email-api/) 52 | - your send-from email and API key/secret need to be entered in `.env` 53 | - if you want to use a different email provider, change the `send_password_key` function in `utilities/auth.py` 54 | - add pages in `pages/`. Make sure to register the page at a path with `register_page(__name__, pathname="/path")` 55 | - the app's basic layout and routing happens in `app.py` 56 | - app is created and auth is built in `server.py` 57 | - config is in `utilities/config.txt` and `utilities/config.py` 58 | 59 | ## Deploying to fly.io or Heroku 60 | 61 | I've provided a `Procfile` for Heroku, there are many resources for Heroku deployment. 62 | 63 | My preferred host is [Fly.io](https://fly.io). I've included a `Dockerfile`, `.dockerignore`, and `fly.toml` for an example. 64 | 65 | ```shell 66 | # first time 67 | fly launch 68 | 69 | # after any change, deploy an updated version with 70 | fly deploy 71 | ``` 72 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/__init__.py -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import dash_bootstrap_components as dbc 2 | from dash import Input, Output, State, html, dcc, register_page, callback 3 | import dash 4 | from application import app, server 5 | from flask_login import current_user 6 | from utils.config import config 7 | 8 | header = dbc.Navbar( 9 | dbc.Container( 10 | [ 11 | dbc.NavbarBrand( 12 | [ 13 | html.Img(src="/assets/favicon.ico", height="30px"), 14 | html.Span("Dash Auth Flow", style=dict(marginLeft="10px")), 15 | ], 16 | href=config["HOME_PATH"], 17 | style=dict(maxWidth="300px"), 18 | ), 19 | dbc.Nav( 20 | [ 21 | dbc.NavItem(dbc.NavLink("Home", href=config["HOME_PATH"])), 22 | dbc.NavItem(dbc.NavLink("Page", href="/page")), 23 | dbc.NavItem( 24 | dbc.NavLink( 25 | html.Span(id="user-name-nav"), 26 | href="/profile", 27 | ) 28 | ), 29 | dbc.NavItem(dbc.NavLink("Login", id="user-action", href="/login")), 30 | ], 31 | ), 32 | ], 33 | ), 34 | # className="mb-5", 35 | color="dark", 36 | dark=True, 37 | ) 38 | 39 | 40 | app.layout = html.Div( 41 | [ 42 | header, 43 | html.Br(), 44 | dbc.Container(dash.page_container), 45 | dcc.Location(id="url", refresh=True), 46 | html.Div(id="profile-trigger", style=dict(display="none")), 47 | ] 48 | ) 49 | 50 | 51 | @callback( 52 | Output("user-name-nav", "children"), 53 | Input("url", "pathname"), 54 | Input("profile-trigger", "children"), 55 | ) 56 | def profile_link(_, __): 57 | """ 58 | Returns a navbar link to the user profile if the user is authenticated 59 | """ 60 | if current_user.is_authenticated: 61 | return [ 62 | html.I(className="bi bi-person-circle", style=dict(marginRight="5px")), 63 | current_user.first, 64 | ] 65 | else: 66 | return "" 67 | 68 | 69 | @callback( 70 | Output("user-action", "children"), 71 | Output("user-action", "href"), 72 | Input("url", "pathname"), 73 | ) 74 | def user_logout(_): 75 | """ 76 | returns a navbar link to /logout or /login, respectively, if the user is authenticated or not 77 | """ 78 | if current_user.is_authenticated: 79 | out = "Logout", "/logout" 80 | else: 81 | out = "Login", "/login" 82 | return out 83 | 84 | 85 | if __name__ == "__main__": 86 | app.run_server(debug=True) 87 | -------------------------------------------------------------------------------- /application.py: -------------------------------------------------------------------------------- 1 | # external imports 2 | import dash_bootstrap_components as dbc 3 | import dash 4 | from flask import Flask, request, current_app 5 | from flask_login import LoginManager 6 | import sqlalchemy 7 | 8 | # local imports 9 | from models.user import User 10 | from utils.auth import protect_layouts 11 | from utils.config import get_session, make_engine 12 | 13 | 14 | def create_app(): 15 | server = Flask(__name__) 16 | app = dash.Dash( 17 | __name__, 18 | server=server, 19 | external_stylesheets=[ 20 | dbc.themes.LITERA, 21 | "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css", 22 | ], 23 | use_pages=True, 24 | suppress_callback_exceptions=True, 25 | title="Dash Auth Flow", 26 | update_title=None, 27 | ) 28 | 29 | # app.css.config.serve_locally = True 30 | # app.scripts.config.serve_locally = True 31 | 32 | # config 33 | server.config.update( 34 | SECRET_KEY="make this key random or hard to guess", 35 | ) 36 | 37 | server.engine = make_engine() 38 | 39 | # Setup the LoginManager for the server 40 | login_manager = LoginManager() 41 | login_manager.init_app(server) 42 | login_manager.login_view = "/login" 43 | 44 | # callback to reload the user object 45 | @login_manager.user_loader 46 | def load_user(user_id): 47 | with get_session() as session: 48 | return session.get(User, user_id) 49 | 50 | return app, server 51 | 52 | 53 | app, server = create_app() 54 | protect_layouts(default=True) 55 | -------------------------------------------------------------------------------- /assets/core.css: -------------------------------------------------------------------------------- 1 | .page-content { 2 | max-width: 800px; 3 | margin: auto; 4 | } 5 | 6 | .auth-page { 7 | max-width: 500px; 8 | margin: auto; 9 | } 10 | 11 | ._dash-loading { 12 | margin: auto; 13 | color: transparent; 14 | width: 2rem; 15 | height: 2rem; 16 | text-align: center; 17 | } 18 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/assets/favicon.ico -------------------------------------------------------------------------------- /create_tables.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from logzero import logger 3 | from flask import current_app 4 | from sqlmodel import SQLModel, select 5 | 6 | from application import server 7 | from utils.config import get_session 8 | from utils.user import add_user, show_users 9 | from models.user import User 10 | from models.password_change import PasswordChange 11 | 12 | 13 | def main(): 14 | """ 15 | Create all the tables in the current SQLModel metadata. 16 | 17 | Clear the tables. 18 | Re-create the test values. 19 | """ 20 | # engine is open to sqlite///users.db (or whatever is in the .env files) 21 | SQLModel.metadata.create_all(current_app.engine) 22 | 23 | # add a test user to the database 24 | users = [ 25 | dict( 26 | first="test", 27 | last="test", 28 | email="test@test.com", 29 | password="test", 30 | ) 31 | ] 32 | with get_session() as session: 33 | # delete existing users 34 | logger.info("DELETING USERS") 35 | existing = session.exec(select(User)).all() 36 | i = 0 37 | for x in existing: 38 | session.delete(x) 39 | i += 1 40 | session.commit() 41 | logger.info(f"DELETED {i} USERS") 42 | 43 | # add new users 44 | logger.info("ADDING USERS") 45 | i = 0 46 | for vals in users: 47 | add_user(**vals) 48 | i += 1 49 | session.commit() 50 | logger.info(f"ADDED {i} USERS") 51 | 52 | # show that the users exists 53 | logger.info("USERS ARE:") 54 | show_users() 55 | 56 | # confirm that user exists 57 | assert User.from_email(users[0]["email"]) 58 | logger.info(f"DONE") 59 | 60 | 61 | if __name__ == "__main__": 62 | with server.app_context(): 63 | main() 64 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/example.gif -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for dash-auth-flow on 2023-09-03T17:32:38-06:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "dash-auth-flow" 7 | primary_region = "ewr" 8 | kill_signal = "SIGINT" 9 | kill_timeout = "5s" 10 | 11 | [experimental] 12 | auto_rollback = true 13 | 14 | [build] 15 | 16 | [env] 17 | PORT = "8080" 18 | 19 | [[services]] 20 | protocol = "tcp" 21 | internal_port = 8080 22 | auto_stop_machines = true 23 | auto_start_machines = true 24 | 25 | [[services.ports]] 26 | port = 80 27 | handlers = ["http"] 28 | force_https = true 29 | 30 | [[services.ports]] 31 | port = 443 32 | handlers = ["tls", "http"] 33 | [services.concurrency] 34 | type = "connections" 35 | hard_limit = 25 36 | soft_limit = 20 37 | 38 | [[services.tcp_checks]] 39 | interval = "15s" 40 | timeout = "2s" 41 | grace_period = "1s" 42 | -------------------------------------------------------------------------------- /models/password_change.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel, Field 2 | import datetime 3 | import uuid 4 | 5 | 6 | class PasswordChange(SQLModel, table=True): 7 | __tablename__ = "password_change" 8 | id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4) 9 | email: str 10 | password_key: str 11 | timestamp: datetime.datetime 12 | -------------------------------------------------------------------------------- /models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Union 3 | from flask_login import UserMixin 4 | from sqlmodel import SQLModel, Field, select 5 | import sqlalchemy 6 | import uuid 7 | 8 | from utils.config import get_session 9 | 10 | 11 | class AuthUser(UserMixin): 12 | # this is for Flask-Login to work correctly 13 | # https://github.com/tiangolo/sqlmodel/issues/476 14 | # https://github.com/tiangolo/sqlmodel/pull/256#issuecomment-1112188647 (link from the first thread) 15 | __config__ = None 16 | 17 | 18 | class User(SQLModel, AuthUser, table=True): 19 | __tablename__ = "dash_user" 20 | 21 | id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4) 22 | first: str 23 | last: str 24 | email: str 25 | password: str 26 | 27 | @classmethod 28 | def from_id(cls, id: str) -> Union[User, None]: 29 | with get_session() as session: 30 | return session.get(User, id) 31 | 32 | @classmethod 33 | def from_email(cls, email: str) -> Union[User, None]: 34 | with get_session() as session: 35 | try: 36 | return session.exec(select(User).where(User.email == email)).one() 37 | except sqlalchemy.exc.NoResultFound: 38 | return None 39 | -------------------------------------------------------------------------------- /pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/pages/__init__.py -------------------------------------------------------------------------------- /pages/auth_pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/pages/auth_pages/__init__.py -------------------------------------------------------------------------------- /pages/auth_pages/change_password.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import dash_bootstrap_components as dbc 4 | from dash import Input, Output, State, html, dcc, no_update, callback, register_page 5 | from validate_email import validate_email 6 | 7 | from utils.auth import redirect_authenticated, unprotected 8 | from utils.pw import validate_password_key, change_password 9 | from utils.user import change_password 10 | from utils.config import config 11 | 12 | register_page(__name__, path="/change") 13 | 14 | 15 | @unprotected 16 | @redirect_authenticated(config["HOME_PATH"]) 17 | def layout(): 18 | return dbc.Row( 19 | dbc.Col( 20 | [ 21 | html.H3("Change Password"), 22 | dbc.Row( 23 | dbc.Col( 24 | [ 25 | html.Div(id="change-alert"), 26 | dcc.Loading( 27 | [ 28 | html.Div( 29 | id="change-trigger", style=dict(display="none") 30 | ), 31 | html.Div(id="change-redirect"), 32 | ], 33 | id=uuid.uuid4().hex, 34 | ), 35 | html.Br(), 36 | dbc.FormText("Email"), 37 | dbc.Input(id="change-email", autoFocus=True), 38 | html.Br(), 39 | dbc.FormText("Code"), 40 | dbc.Input(id="change-key", type="password"), 41 | html.Br(), 42 | dbc.FormText("New password"), 43 | dbc.Input(id="change-password", type="password"), 44 | html.Br(), 45 | dbc.FormText("Confirm new password"), 46 | dbc.Input(id="change-confirm", type="password"), 47 | html.Br(), 48 | dbc.Button( 49 | "Submit password change", 50 | id="change-button", 51 | color="primary", 52 | ), 53 | ] 54 | ) 55 | ), 56 | ], 57 | className="auth-page", 58 | ) 59 | ) 60 | 61 | 62 | success_alert = dbc.Alert( 63 | "Reset successful. Taking you to login!", 64 | color="success", 65 | ) 66 | failure_alert = dbc.Alert( 67 | "Reset unsuccessful. Are you sure the email and code were correct?", 68 | color="danger", 69 | dismissable=True, 70 | duration=config["ALERT_DELAY"], 71 | ) 72 | 73 | 74 | # function to validate inputs 75 | @callback( 76 | Output("change-password", "valid"), 77 | Output("change-password", "invalid"), 78 | Output("change-confirm", "valid"), 79 | Output("change-confirm", "invalid"), 80 | Output("change-email", "valid"), 81 | Output("change-email", "invalid"), 82 | Output("change-button", "disabled"), 83 | Input("change-password", "value"), 84 | Input("change-confirm", "value"), 85 | Input("change-email", "value"), 86 | prevent_initial_call=True, 87 | ) 88 | def change_validate_inputs(password, confirm, email): 89 | password_valid = False 90 | password_invalid = False 91 | confirm_valid = False 92 | confirm_invalid = True 93 | email_valid = False 94 | email_invalid = True 95 | disabled = True 96 | bad = [None, ""] 97 | if password not in bad and isinstance(password, str): 98 | password_valid, password_invalid = True, False 99 | if confirm not in bad and confirm == password: 100 | confirm_valid, confirm_invalid = True, False 101 | if email not in bad and validate_email(email): 102 | email_valid, email_invalid = True, False 103 | if password_valid and confirm_valid and email_valid: 104 | disabled = False 105 | return ( 106 | password_valid, 107 | password_invalid, 108 | confirm_valid, 109 | confirm_invalid, 110 | email_valid, 111 | email_invalid, 112 | disabled, 113 | ) 114 | 115 | 116 | @callback( 117 | Output("change-alert", "children"), 118 | Output("change-trigger", "pathname"), 119 | Input("change-button", "n_clicks"), 120 | State("change-email", "value"), 121 | State("change-key", "value"), 122 | State("change-password", "value"), 123 | State("change-confirm", "value"), 124 | prevent_initial_call=True, 125 | ) 126 | def submit_change(submit, email, key, password, confirm): 127 | # all inputs have been previously validated 128 | if validate_password_key(email, key): 129 | # if that returns true, update the user information 130 | if change_password(email, password): 131 | return success_alert, 1 132 | return failure_alert, no_update 133 | 134 | 135 | @callback( 136 | Output("url", "pathname", allow_duplicate=True), 137 | Output("change-redirect", "children"), 138 | Input("change-trigger", "children"), 139 | prevent_initial_call=True, 140 | ) 141 | def change_redirect(trigger): 142 | if trigger: 143 | time.sleep(config["TRANSITION_DELAY"]) 144 | return "/forgot", "" 145 | return no_update, no_update 146 | -------------------------------------------------------------------------------- /pages/auth_pages/forgot_password.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import dash_bootstrap_components as dbc 4 | from dash import Input, Output, State, html, dcc, no_update, callback, register_page 5 | from models.user import User 6 | 7 | from utils.auth import redirect_authenticated, unprotected 8 | from utils.pw import send_password_key 9 | from utils.config import config 10 | 11 | register_page(__name__, path="/forgot") 12 | 13 | 14 | @unprotected 15 | @redirect_authenticated(config["HOME_PATH"]) 16 | def layout(): 17 | return dbc.Row( 18 | dbc.Col( 19 | [ 20 | html.H3("Forgot Password"), 21 | dbc.Row( 22 | dbc.Col( 23 | [ 24 | html.Div(id="forgot-alert"), 25 | html.Br(), 26 | dbc.FormText("Email"), 27 | dbc.Input(id="forgot-email", autoFocus=True), 28 | html.Br(), 29 | dcc.Loading( 30 | [ 31 | html.Div( 32 | id="forgot-trigger", style=dict(display="none") 33 | ), 34 | html.Div(id="forgot-redirect"), 35 | ], 36 | id=uuid.uuid4().hex, 37 | ), 38 | dbc.Button( 39 | "Submit email to receive code", 40 | id="forgot-button", 41 | color="primary", 42 | ), 43 | ] 44 | ) 45 | ), 46 | ], 47 | className="auth-page", 48 | ) 49 | ) 50 | 51 | 52 | success_alert = dbc.Alert( 53 | "Reset successful. Taking you to change password.", 54 | color="success", 55 | ) 56 | failure_alert = dbc.Alert( 57 | "Reset unsuccessful. Are you sure that email was correct?", 58 | color="danger", 59 | dismissable=True, 60 | duration=config["ALERT_DELAY"], 61 | ) 62 | 63 | 64 | @callback( 65 | Output("forgot-alert", "children"), 66 | Output("forgot-trigger", "children"), 67 | Input("forgot-button", "n_clicks"), 68 | State("forgot-email", "value"), 69 | prevent_initial_call=True, 70 | ) 71 | def forgot_submit(_, email): 72 | # get first name 73 | user = User.from_email(email) 74 | if not user: 75 | return failure_alert, no_update 76 | 77 | # if it does, send password reset and save info 78 | if send_password_key(email, user.first): 79 | return success_alert, 1 80 | else: 81 | return failure_alert, no_update 82 | 83 | 84 | @callback( 85 | Output("url", "pathname", allow_duplicate=True), 86 | Output("forgot-redirect", "children"), 87 | Input("forgot-trigger", "children"), 88 | prevent_initial_call=True, 89 | ) 90 | def forgot_redirect(trigger): 91 | if trigger: 92 | time.sleep(config["TRANSITION_DELAY"]) 93 | return "/change", "" 94 | return no_update, no_update 95 | -------------------------------------------------------------------------------- /pages/auth_pages/login.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import dash_bootstrap_components as dbc 4 | from dash import Input, Output, State, html, dcc, no_update, register_page, callback 5 | from flask_login import current_user, login_user 6 | from werkzeug.security import check_password_hash 7 | 8 | from models.user import User 9 | from utils.auth import redirect_authenticated, unprotected 10 | from utils.config import config 11 | 12 | register_page(__name__, path="/login") 13 | 14 | 15 | @unprotected 16 | @redirect_authenticated(config["HOME_PATH"]) 17 | def layout(): 18 | return dbc.Row( 19 | dbc.Col( 20 | [ 21 | dbc.Row( 22 | dbc.Col( 23 | [ 24 | dbc.Alert( 25 | "Try test@test.com / test", 26 | color="info", 27 | dismissable=True, 28 | ), 29 | html.Div(id="login-alert"), 30 | html.Br(), 31 | dbc.FormText("Email"), 32 | dbc.Input(id="login-email", autoFocus=True), 33 | html.Br(), 34 | dbc.FormText("Password"), 35 | dbc.Input( 36 | id="login-password", type="password", debounce=True 37 | ), 38 | html.Br(), 39 | dbc.Button( 40 | "Submit", color="primary", id="login-button", n_clicks=0 41 | ), 42 | dcc.Loading( 43 | [ 44 | html.Div( 45 | id="login-trigger", style=dict(display="none") 46 | ), 47 | html.Div(id="login-redirect"), 48 | ], 49 | id=uuid.uuid4().hex, 50 | ), 51 | html.Br(), 52 | html.Br(), 53 | dcc.Link("Register", href="/register"), 54 | html.Br(), 55 | dcc.Link("Forgot Password", href="/forgot"), 56 | ] 57 | ) 58 | ), 59 | ], 60 | className="auth-page", 61 | ) 62 | ) 63 | 64 | 65 | success_alert = dbc.Alert("Success! Taking you home", color="success") 66 | failure_alert = dbc.Alert( 67 | "Login failed. Check your email and password.", 68 | color="danger", 69 | dismissable=True, 70 | duration=config["ALERT_DELAY"], 71 | ) 72 | 73 | 74 | @callback( 75 | Output("login-trigger", "children"), 76 | Output("login-alert", "children"), 77 | Input("login-button", "n_clicks"), 78 | Input("login-password", "value"), 79 | State("login-email", "value"), 80 | prevent_initial_call=True, 81 | ) 82 | def login_success(n_clicks, password, email): 83 | if not n_clicks: 84 | return no_update 85 | if password is not None: 86 | user = User.from_email(email) 87 | if user: 88 | if check_password_hash(user.password, password): 89 | login_user(user) 90 | return 1, success_alert 91 | return no_update, failure_alert 92 | return no_update 93 | 94 | 95 | @callback( 96 | Output("login-redirect", "children"), 97 | Output("url", "pathname", allow_duplicate=True), 98 | Input("login-trigger", "children"), 99 | prevent_initial_call=True, 100 | ) 101 | def login_redirect(trigger): 102 | if trigger: 103 | time.sleep(config["TRANSITION_DELAY"]) 104 | return "", config["HOME_PATH"] 105 | return no_update, no_update 106 | -------------------------------------------------------------------------------- /pages/auth_pages/logout.py: -------------------------------------------------------------------------------- 1 | from logzero import logger 2 | import time 3 | from dash import register_page, dcc, html, Input, Output, no_update, callback 4 | from flask_login import current_user, logout_user 5 | 6 | from utils.auth import protected 7 | from utils.config import config 8 | 9 | register_page(__name__, path="/logout") 10 | 11 | 12 | @protected 13 | def layout(): 14 | if current_user.is_authenticated: 15 | logout_user() 16 | return dcc.Location(id="redirect-logout-to-login", pathname="/login") 17 | -------------------------------------------------------------------------------- /pages/auth_pages/register.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import dash_bootstrap_components as dbc 3 | from dash import Input, Output, State, html, dcc, no_update, register_page, callback 4 | from flask_login import current_user 5 | import time 6 | from validate_email import validate_email 7 | from models.user import User 8 | 9 | from utils.auth import redirect_authenticated, unprotected 10 | from utils.user import add_user 11 | from utils.config import config 12 | 13 | register_page(__name__, path="/register") 14 | 15 | 16 | @unprotected 17 | @redirect_authenticated(config["HOME_PATH"]) 18 | def layout(): 19 | return dbc.Row( 20 | dbc.Col( 21 | [ 22 | html.Div(id="register-alert"), 23 | dbc.Row( 24 | dbc.Col( 25 | [ 26 | dbc.FormText("First"), 27 | dbc.Input(id="register-first", autoFocus=True), 28 | html.Br(), 29 | dbc.FormText("Last"), 30 | dbc.Input(id="register-last"), 31 | html.Br(), 32 | dbc.FormText( 33 | "Email", id="register-email-formtext", color="secondary" 34 | ), 35 | dbc.Input(id="register-email"), 36 | html.Br(), 37 | dbc.FormText("Password"), 38 | dbc.Input(id="register-password", type="password"), 39 | html.Br(), 40 | dbc.FormText("Confirm password"), 41 | dbc.Input(id="register-confirm", type="password"), 42 | html.Br(), 43 | dbc.Button("Submit", color="primary", id="register-button"), 44 | dcc.Loading( 45 | [ 46 | html.Div( 47 | id="register-trigger", 48 | style=dict(display="none"), 49 | ), 50 | html.Div(id="register-redirect"), 51 | ], 52 | id=uuid.uuid4().hex, 53 | ), 54 | ] 55 | ), 56 | ), 57 | ], 58 | className="auth-page", 59 | ) 60 | ) 61 | 62 | 63 | success_alert = dbc.Alert( 64 | "Registered successfully. Taking you to login.", color="success" 65 | ) 66 | failure_alert = dbc.Alert( 67 | "Registration unsuccessful. Try again.", 68 | color="danger", 69 | dismissable=True, 70 | duration=config["ALERT_DELAY"], 71 | ) 72 | 73 | 74 | @callback( 75 | Output("register-first", "valid"), 76 | Output("register-last", "valid"), 77 | Output("register-email", "valid"), 78 | Output("register-password", "valid"), 79 | Output("register-confirm", "valid"), 80 | Output("register-first", "invalid"), 81 | Output("register-last", "invalid"), 82 | Output("register-email", "invalid"), 83 | Output("register-password", "invalid"), 84 | Output("register-confirm", "invalid"), 85 | Output("register-button", "disabled"), 86 | Output("register-email-formtext", "children"), 87 | Output("register-email-formtext", "color"), 88 | Input("register-first", "value"), 89 | Input("register-last", "value"), 90 | Input("register-email", "value"), 91 | Input("register-password", "value"), 92 | Input("register-confirm", "value"), 93 | prevent_initial_call=True, 94 | ) 95 | def register_validate_inputs(first, last, email, password, confirm): 96 | """ 97 | validate all inputs 98 | """ 99 | 100 | email_formtext = "Email" 101 | email_formcolor = "secondary" 102 | disabled = True 103 | bad = [None, ""] 104 | 105 | v = { 106 | k: f 107 | for k, f in zip( 108 | ["first", "last", "email", "password", "confirm"], 109 | [first, last, email, password, confirm], 110 | ) 111 | } 112 | # if all the values are empty, leave everything blank and disable button 113 | if sum([x in bad for x in v.values()]) == 5: 114 | return [False for x in range(10)] + [disabled, email_formtext, email_formcolor] 115 | 116 | d = {} 117 | d["valid"] = {x: False for x in ["first", "last", "email", "password", "confirm"]} 118 | d["invalid"] = {x: False for x in ["first", "last", "email", "password", "confirm"]} 119 | 120 | def validate(x, inst): 121 | if v[x] in bad: 122 | pass 123 | elif not isinstance(v[x], inst): 124 | d["valid"][x], d["invalid"][x] = False, True 125 | else: 126 | d["valid"][x], d["invalid"][x] = True, False 127 | 128 | for k in ["first", "last", "password"]: 129 | validate(k, str) 130 | 131 | x = "confirm" 132 | if v[x] in bad: 133 | pass 134 | d["valid"][x] = not v[x] in bad and v["password"] == v[x] 135 | d["invalid"][x] = not v["confirm"] 136 | 137 | # if it's a valid email, check if it already exists 138 | # if it exists, invalidate it and let the user know 139 | x = "email" 140 | if v[x] in bad: 141 | pass 142 | else: 143 | d["valid"][x] = validate_email(v[x]) 144 | d["invalid"][x] = not d["valid"][x] 145 | if User.from_email(v[x]): 146 | d["valid"][x] = False 147 | d["invalid"][x] = True 148 | email_formcolor = "danger" 149 | email_formtext = "Email is already registered." 150 | 151 | # if all are valid, enable the button 152 | if sum(d["valid"].values()) == 5: 153 | disabled = False 154 | 155 | return [ 156 | *list(d["valid"].values()), 157 | *list(d["invalid"].values()), 158 | disabled, 159 | email_formtext, 160 | email_formcolor, 161 | ] 162 | 163 | 164 | @callback( 165 | Output("register-alert", "children"), 166 | Output("register-trigger", "children"), 167 | Input("register-button", "n_clicks"), 168 | State("register-first", "value"), 169 | State("register-last", "value"), 170 | State("register-email", "value"), 171 | State("register-password", "value"), 172 | State("register-confirm", "value"), 173 | prevent_initial_call=True, 174 | ) 175 | def register_success(n_clicks, first, last, email, password, confirm): 176 | if add_user(first, last, password, email): 177 | return ( 178 | success_alert, 179 | 1, 180 | ) 181 | else: 182 | return failure_alert, no_update 183 | 184 | 185 | @callback( 186 | Output("url", "pathname", allow_duplicate=True), 187 | Output("register-redirect", "children"), 188 | Input("register-trigger", "children"), 189 | prevent_initial_call=True, 190 | ) 191 | def register_redirect(trigger): 192 | if trigger: 193 | if trigger == 1: 194 | time.sleep(config["TRANSITION_DELAY"]) 195 | return "/login", "" 196 | return no_update, no_update 197 | -------------------------------------------------------------------------------- /pages/home.py: -------------------------------------------------------------------------------- 1 | import dash_bootstrap_components as dbc 2 | from dash import Output, Input, State, dcc, html, no_update, register_page, callback 3 | import time 4 | from utils.config import config 5 | 6 | 7 | register_page(__name__, path=config["HOME_PATH"]) 8 | 9 | 10 | def layout(): 11 | return dbc.Row( 12 | dbc.Col( 13 | [ 14 | html.H1("Home page"), 15 | html.Br(), 16 | html.H5("Welcome to the home page!"), 17 | html.Br(), 18 | html.P( 19 | "The section below is updated after a callback that takes 1 second!" 20 | ), 21 | html.Div(id="home-test-trigger"), 22 | dcc.Loading( 23 | html.Div("before update", id="home-test"), 24 | id="loading-home-test-trigger", 25 | style=dict(width="100%"), 26 | ), 27 | ], 28 | className="page-content", 29 | ), 30 | ) 31 | 32 | 33 | @callback(Output("home-test", "children"), Input("home-test-trigger", "children")) 34 | def home_div_update(_): 35 | """Updates arbitrary value on home page as example of callback.""" 36 | time.sleep(1) 37 | return html.Div("after the update", style=dict(color="red")) 38 | -------------------------------------------------------------------------------- /pages/page.py: -------------------------------------------------------------------------------- 1 | import dash_bootstrap_components as dbc 2 | from dash import Output, Input, State, dcc, html, no_update, register_page, callback 3 | import time 4 | 5 | register_page(__name__, path="/page") 6 | 7 | 8 | def layout(): 9 | return dbc.Row( 10 | dbc.Col( 11 | [ 12 | html.H1("page"), 13 | html.Br(), 14 | html.H5("Welcome to this page!"), 15 | html.Br(), 16 | html.P( 17 | "Below is an iframe of another website that loads after 1 second:" 18 | ), 19 | html.Div(id="page-test-trigger"), 20 | dcc.Loading( 21 | html.Iframe( 22 | id="page-test", style=dict(height="500px", width="100%") 23 | ), 24 | id="page-loading", 25 | style=dict(width="100%"), 26 | ), 27 | ], 28 | className="page-content", 29 | ) 30 | ) 31 | 32 | 33 | @callback(Output("page-test", "src"), Input("page-test-trigger", "children")) 34 | def page_div_update(_): 35 | """ 36 | updates iframe with example.com 37 | """ 38 | time.sleep(1) 39 | return "https://example.cypress.io/" 40 | -------------------------------------------------------------------------------- /pages/profile.py: -------------------------------------------------------------------------------- 1 | import time 2 | import dash_bootstrap_components as dbc 3 | from dash import Input, Output, State, html, dcc, no_update, register_page, callback 4 | from flask_login import current_user 5 | from utils.config import get_session, config 6 | from utils.user import change_password, User 7 | 8 | register_page(__name__, path="/profile") 9 | 10 | 11 | def layout(): 12 | return dbc.Row( 13 | dbc.Col( 14 | [ 15 | html.H3("Profile", id="profile-title"), 16 | html.Div(id="profile-alert"), 17 | html.Div(id="profile-alert-login"), 18 | html.Div(id="profile-login-trigger", style=dict(display="none")), 19 | dcc.Loading( 20 | html.Div(id="loading-profile-trigger"), 21 | id="loading-profile", 22 | ), 23 | html.Br(), 24 | dbc.Row( 25 | dbc.Col( 26 | [ 27 | dbc.Label("First:", id="profile-first"), 28 | dbc.Input( 29 | placeholder="Change first name...", 30 | id="profile-first-input", 31 | ), 32 | dbc.FormText( 33 | id="profile-first-formtext", color="secondary" 34 | ), 35 | html.Br(), 36 | # 37 | # 38 | dbc.Label("Last:", id="profile-last"), 39 | dbc.Input( 40 | placeholder="Change last name...", 41 | id="profile-last-input", 42 | ), 43 | dbc.FormText(id="profile-last-formtext", color="secondary"), 44 | html.Hr(), 45 | # 46 | # 47 | dbc.Label("Email:", id="profile-email"), 48 | html.Br(), 49 | dbc.FormText("Cannot change email", color="secondary"), 50 | html.Hr(), 51 | # 52 | # 53 | dbc.Label("New password", id="profile-password"), 54 | dbc.Input( 55 | placeholder="Change password...", 56 | id="profile-password-input", 57 | type="password", 58 | ), 59 | html.Br(), 60 | dbc.Label("Confirm new password", id="profile-password"), 61 | dbc.Input( 62 | placeholder="Confirm password...", 63 | id="profile-password-confirm", 64 | type="password", 65 | ), 66 | html.Hr(), 67 | # 68 | # 69 | dbc.Button( 70 | "Save changes", 71 | color="primary", 72 | id="profile-submit", 73 | disabled=True, 74 | ), 75 | ] 76 | ) 77 | ), 78 | ], 79 | className="auth-page", 80 | ) 81 | ) 82 | 83 | 84 | success_alert = dbc.Alert( 85 | "Changes saved successfully.", 86 | color="success", 87 | dismissable=True, 88 | duration=config["ALERT_DELAY"], 89 | ) 90 | failure_alert = dbc.Alert( 91 | "Unable to save changes.", 92 | color="danger", 93 | dismissable=True, 94 | duration=config["ALERT_DELAY"], 95 | ) 96 | 97 | 98 | @callback( 99 | Output("loading-profile-trigger", "children"), 100 | Output("profile-first", "children"), 101 | Output("profile-last", "children"), 102 | Output("profile-email", "children"), 103 | Output("profile-first-input", "value"), 104 | Output("profile-last-input", "value"), 105 | Input("profile-trigger", "children"), 106 | Input("profile-login-trigger", "children"), 107 | ) 108 | def profile_values(_, __): 109 | """Triggered by loading the page or saving new values. 110 | Loads values for user from database. 111 | """ 112 | if current_user.is_authenticated: 113 | time.sleep(1) 114 | return ( 115 | "", 116 | ["First: ", html.Strong(current_user.first)], 117 | ["Last: ", html.Strong(current_user.last)], 118 | ["Email: ", html.Strong(current_user.email)], 119 | current_user.first, 120 | current_user.last, 121 | ) 122 | return "", "First: ", "Last: ", "Email:", "", "", "" 123 | 124 | 125 | # function to validate changes input 126 | @callback( 127 | Output("profile-first-input", "valid"), 128 | Output("profile-last-input", "valid"), 129 | Output("profile-password-input", "valid"), 130 | Output("profile-password-confirm", "valid"), 131 | Output("profile-first-input", "invalid"), 132 | Output("profile-last-input", "invalid"), 133 | Output("profile-password-input", "invalid"), 134 | Output("profile-password-confirm", "invalid"), 135 | Output("profile-submit", "disabled"), 136 | Input("profile-first-input", "value"), 137 | Input("profile-last-input", "value"), 138 | Input("profile-password-input", "value"), 139 | Input("profile-password-confirm", "value"), 140 | prevent_initial_call=True, 141 | ) 142 | def profile_validate(first, last, password, confirm): 143 | disabled = True 144 | bad = ["", None] 145 | values = [first, last, password, confirm] 146 | valids = [False for x in range(4)] 147 | invalids = [False for x in range(4)] 148 | # if all are invalid 149 | if sum([x in bad for x in values]) == 4: 150 | return valids + invalids + [disabled] 151 | # first name 152 | i = 0 153 | if first in bad: 154 | pass 155 | else: 156 | if isinstance(first, str): 157 | valids[i] = True 158 | else: 159 | invalids[i] = True 160 | # last name 161 | i = 1 162 | if last in bad: 163 | pass 164 | else: 165 | if isinstance(last, str): 166 | valids[i] = True 167 | else: 168 | invalids[i] = True 169 | # password 170 | i = 2 171 | if password in bad: 172 | pass 173 | else: 174 | if isinstance(password, str): 175 | valids[i] = True 176 | i = 3 177 | if confirm == password: 178 | valids[i] = True 179 | else: 180 | invalids[i] = True 181 | else: 182 | invalids[i] = True 183 | # if all inputs are either valid or empty, enable the button 184 | if ( 185 | sum( 186 | [ 187 | 1 188 | if (v == False and inv == False) or (v == True and inv == False) 189 | else 0 190 | for v, inv in zip(valids, invalids) 191 | ] 192 | ) 193 | == 4 194 | ): 195 | disabled = False 196 | return valids + invalids + [disabled] 197 | 198 | 199 | # function to save changes 200 | @callback( 201 | Output("profile-alert", "children"), 202 | Output("profile-trigger", "children"), 203 | Input("profile-submit", "n_clicks"), 204 | State("profile-first-input", "value"), 205 | State("profile-last-input", "value"), 206 | State("profile-password-input", "value"), 207 | prevent_initial_call=True, 208 | ) 209 | def profile_save_changes(n_clicks, first, last, password): 210 | """ 211 | Change profile values to values in inputs. 212 | 213 | If password is blank, pull the current password and submit it. 214 | 215 | Assumes all inputs are valid and checked by validator callback 216 | before submitting (enforced by disabling button otherwise) 217 | """ 218 | if not n_clicks: 219 | return no_update, no_update 220 | 221 | email = current_user.email 222 | 223 | user = User.from_email(email) 224 | if not user: 225 | return failure_alert, 0 226 | user.first = first 227 | user.last = last 228 | user.email = email 229 | with get_session() as session: 230 | session.add(user) 231 | session.commit() 232 | session.refresh(user) 233 | 234 | if password not in ["", None]: 235 | change_password(user.email, password) 236 | 237 | return success_alert, 1 238 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "ansi2html" 5 | version = "1.8.0" 6 | description = "" 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "ansi2html-1.8.0-py3-none-any.whl", hash = "sha256:ef9cc9682539dbe524fbf8edad9c9462a308e04bce1170c32daa8fdfd0001785"}, 11 | {file = "ansi2html-1.8.0.tar.gz", hash = "sha256:38b82a298482a1fa2613f0f9c9beb3db72a8f832eeac58eb2e47bf32cd37f6d5"}, 12 | ] 13 | 14 | [package.extras] 15 | docs = ["Sphinx", "setuptools-scm", "sphinx-rtd-theme"] 16 | test = ["pytest", "pytest-cov"] 17 | 18 | [[package]] 19 | name = "black" 20 | version = "23.7.0" 21 | description = "The uncompromising code formatter." 22 | optional = false 23 | python-versions = ">=3.8" 24 | files = [ 25 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, 26 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, 27 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, 28 | {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, 29 | {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, 30 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, 31 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, 32 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, 33 | {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, 34 | {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, 35 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, 36 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, 37 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, 38 | {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, 39 | {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, 40 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, 41 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, 42 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, 43 | {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, 44 | {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, 45 | {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, 46 | {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, 47 | ] 48 | 49 | [package.dependencies] 50 | click = ">=8.0.0" 51 | mypy-extensions = ">=0.4.3" 52 | packaging = ">=22.0" 53 | pathspec = ">=0.9.0" 54 | platformdirs = ">=2" 55 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 56 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 57 | 58 | [package.extras] 59 | colorama = ["colorama (>=0.4.3)"] 60 | d = ["aiohttp (>=3.7.4)"] 61 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 62 | uvloop = ["uvloop (>=0.15.2)"] 63 | 64 | [[package]] 65 | name = "certifi" 66 | version = "2023.7.22" 67 | description = "Python package for providing Mozilla's CA Bundle." 68 | optional = false 69 | python-versions = ">=3.6" 70 | files = [ 71 | {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, 72 | {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, 73 | ] 74 | 75 | [[package]] 76 | name = "charset-normalizer" 77 | version = "3.2.0" 78 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 79 | optional = false 80 | python-versions = ">=3.7.0" 81 | files = [ 82 | {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, 83 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, 84 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, 85 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, 86 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, 87 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, 88 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, 89 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, 90 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, 91 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, 92 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, 93 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, 94 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, 95 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, 96 | {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, 97 | {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, 98 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, 99 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, 100 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, 101 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, 102 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, 103 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, 104 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, 105 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, 106 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, 107 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, 108 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, 109 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, 110 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, 111 | {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, 112 | {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, 113 | {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, 114 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, 115 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, 116 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, 117 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, 118 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, 119 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, 120 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, 121 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, 122 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, 123 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, 124 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, 125 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, 126 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, 127 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, 128 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, 129 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, 130 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, 131 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, 132 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, 133 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, 134 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, 135 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, 136 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, 137 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, 138 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, 139 | {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, 140 | {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, 141 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, 142 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, 143 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, 144 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, 145 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, 146 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, 147 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, 148 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, 149 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, 150 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, 151 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, 152 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, 153 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, 154 | {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, 155 | {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, 156 | {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, 157 | ] 158 | 159 | [[package]] 160 | name = "click" 161 | version = "8.1.7" 162 | description = "Composable command line interface toolkit" 163 | optional = false 164 | python-versions = ">=3.7" 165 | files = [ 166 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 167 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 168 | ] 169 | 170 | [package.dependencies] 171 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 172 | 173 | [[package]] 174 | name = "colorama" 175 | version = "0.4.6" 176 | description = "Cross-platform colored terminal text." 177 | optional = false 178 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 179 | files = [ 180 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 181 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 182 | ] 183 | 184 | [[package]] 185 | name = "dash" 186 | version = "2.13.0" 187 | description = "A Python framework for building reactive web-apps. Developed by Plotly." 188 | optional = false 189 | python-versions = ">=3.6" 190 | files = [ 191 | {file = "dash-2.13.0-py3-none-any.whl", hash = "sha256:ca21f01f720652c7e2d16d04d4e27803c2b60c4c2a382e750c3d8d778c06e209"}, 192 | {file = "dash-2.13.0.tar.gz", hash = "sha256:07c192db694b9bb4c87d57b6da877413f2695bfcb1d5c51f08995de7dcdd1e92"}, 193 | ] 194 | 195 | [package.dependencies] 196 | ansi2html = "*" 197 | dash-core-components = "2.0.0" 198 | dash-html-components = "2.0.0" 199 | dash-table = "5.0.0" 200 | Flask = ">=1.0.4,<2.3.0" 201 | nest-asyncio = "*" 202 | plotly = ">=5.0.0" 203 | requests = "*" 204 | retrying = "*" 205 | setuptools = "*" 206 | typing-extensions = ">=4.1.1" 207 | Werkzeug = "<2.3.0" 208 | 209 | [package.extras] 210 | celery = ["celery[redis] (>=5.1.2)", "importlib-metadata (<5)", "redis (>=3.5.3)"] 211 | ci = ["black (==21.6b0)", "black (==22.3.0)", "dash-dangerously-set-inner-html", "dash-flow-example (==0.0.5)", "flake8 (==3.9.2)", "flaky (==3.7.0)", "flask-talisman (==1.0.0)", "isort (==4.3.21)", "jupyterlab (<4.0.0)", "mimesis", "mock (==4.0.3)", "numpy", "openpyxl", "orjson (==3.5.4)", "orjson (==3.6.7)", "pandas (==1.1.5)", "pandas (>=1.4.0)", "preconditions", "pyarrow", "pyarrow (<3)", "pylint (==2.13.5)", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "xlrd (<2)", "xlrd (>=2.0.1)"] 212 | compress = ["flask-compress"] 213 | dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] 214 | diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] 215 | testing = ["beautifulsoup4 (>=4.8.2)", "cryptography (<3.4)", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"] 216 | 217 | [[package]] 218 | name = "dash-bootstrap-components" 219 | version = "1.4.2" 220 | description = "Bootstrap themed components for use in Plotly Dash" 221 | optional = false 222 | python-versions = ">=3.7, <4" 223 | files = [ 224 | {file = "dash-bootstrap-components-1.4.2.tar.gz", hash = "sha256:b7514be30e229a1701db5010a47d275882a94d1efff4c803ac42a9d222ed86e0"}, 225 | {file = "dash_bootstrap_components-1.4.2-py3-none-any.whl", hash = "sha256:4f59352a2f81cb0c41ae75dd3e0814f64049a4520f935397298e9a093ace727c"}, 226 | ] 227 | 228 | [package.dependencies] 229 | dash = ">=2.0.0" 230 | 231 | [package.extras] 232 | pandas = ["numpy", "pandas"] 233 | 234 | [[package]] 235 | name = "dash-core-components" 236 | version = "2.0.0" 237 | description = "Core component suite for Dash" 238 | optional = false 239 | python-versions = "*" 240 | files = [ 241 | {file = "dash_core_components-2.0.0-py3-none-any.whl", hash = "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346"}, 242 | {file = "dash_core_components-2.0.0.tar.gz", hash = "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee"}, 243 | ] 244 | 245 | [[package]] 246 | name = "dash-html-components" 247 | version = "2.0.0" 248 | description = "Vanilla HTML components for Dash" 249 | optional = false 250 | python-versions = "*" 251 | files = [ 252 | {file = "dash_html_components-2.0.0-py3-none-any.whl", hash = "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63"}, 253 | {file = "dash_html_components-2.0.0.tar.gz", hash = "sha256:8703a601080f02619a6390998e0b3da4a5daabe97a1fd7a9cebc09d015f26e50"}, 254 | ] 255 | 256 | [[package]] 257 | name = "dash-table" 258 | version = "5.0.0" 259 | description = "Dash table" 260 | optional = false 261 | python-versions = "*" 262 | files = [ 263 | {file = "dash_table-5.0.0-py3-none-any.whl", hash = "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9"}, 264 | {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, 265 | ] 266 | 267 | [[package]] 268 | name = "flask" 269 | version = "2.2.5" 270 | description = "A simple framework for building complex web applications." 271 | optional = false 272 | python-versions = ">=3.7" 273 | files = [ 274 | {file = "Flask-2.2.5-py3-none-any.whl", hash = "sha256:58107ed83443e86067e41eff4631b058178191a355886f8e479e347fa1285fdf"}, 275 | {file = "Flask-2.2.5.tar.gz", hash = "sha256:edee9b0a7ff26621bd5a8c10ff484ae28737a2410d99b0bb9a6850c7fb977aa0"}, 276 | ] 277 | 278 | [package.dependencies] 279 | click = ">=8.0" 280 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 281 | itsdangerous = ">=2.0" 282 | Jinja2 = ">=3.0" 283 | Werkzeug = ">=2.2.2" 284 | 285 | [package.extras] 286 | async = ["asgiref (>=3.2)"] 287 | dotenv = ["python-dotenv"] 288 | 289 | [[package]] 290 | name = "flask-login" 291 | version = "0.6.2" 292 | description = "User authentication and session management for Flask." 293 | optional = false 294 | python-versions = ">=3.7" 295 | files = [ 296 | {file = "Flask-Login-0.6.2.tar.gz", hash = "sha256:c0a7baa9fdc448cdd3dd6f0939df72eec5177b2f7abe6cb82fc934d29caac9c3"}, 297 | {file = "Flask_Login-0.6.2-py3-none-any.whl", hash = "sha256:1ef79843f5eddd0f143c2cd994c1b05ac83c0401dc6234c143495af9a939613f"}, 298 | ] 299 | 300 | [package.dependencies] 301 | Flask = ">=1.0.4" 302 | Werkzeug = ">=1.0.1" 303 | 304 | [[package]] 305 | name = "flask-sqlalchemy" 306 | version = "3.0.5" 307 | description = "Add SQLAlchemy support to your Flask application." 308 | optional = false 309 | python-versions = ">=3.7" 310 | files = [ 311 | {file = "flask_sqlalchemy-3.0.5-py3-none-any.whl", hash = "sha256:cabb6600ddd819a9f859f36515bb1bd8e7dbf30206cc679d2b081dff9e383283"}, 312 | {file = "flask_sqlalchemy-3.0.5.tar.gz", hash = "sha256:c5765e58ca145401b52106c0f46178569243c5da25556be2c231ecc60867c5b1"}, 313 | ] 314 | 315 | [package.dependencies] 316 | flask = ">=2.2.5" 317 | sqlalchemy = ">=1.4.18" 318 | 319 | [[package]] 320 | name = "greenlet" 321 | version = "2.0.2" 322 | description = "Lightweight in-process concurrent programming" 323 | optional = false 324 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 325 | files = [ 326 | {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, 327 | {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, 328 | {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, 329 | {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, 330 | {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, 331 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, 332 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, 333 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, 334 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, 335 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, 336 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, 337 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, 338 | {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, 339 | {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, 340 | {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, 341 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, 342 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, 343 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, 344 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, 345 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, 346 | {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, 347 | {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, 348 | {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, 349 | {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, 350 | {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, 351 | {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, 352 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, 353 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, 354 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, 355 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, 356 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, 357 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, 358 | {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, 359 | {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, 360 | {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, 361 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, 362 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, 363 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, 364 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, 365 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, 366 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, 367 | {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, 368 | {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, 369 | {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, 370 | {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, 371 | {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, 372 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, 373 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, 374 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, 375 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, 376 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, 377 | {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, 378 | {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, 379 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, 380 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, 381 | {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, 382 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, 383 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, 384 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, 385 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, 386 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, 387 | {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, 388 | {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, 389 | {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, 390 | ] 391 | 392 | [package.extras] 393 | docs = ["Sphinx", "docutils (<0.18)"] 394 | test = ["objgraph", "psutil"] 395 | 396 | [[package]] 397 | name = "gunicorn" 398 | version = "21.2.0" 399 | description = "WSGI HTTP Server for UNIX" 400 | optional = false 401 | python-versions = ">=3.5" 402 | files = [ 403 | {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, 404 | {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, 405 | ] 406 | 407 | [package.dependencies] 408 | packaging = "*" 409 | 410 | [package.extras] 411 | eventlet = ["eventlet (>=0.24.1)"] 412 | gevent = ["gevent (>=1.4.0)"] 413 | setproctitle = ["setproctitle"] 414 | tornado = ["tornado (>=0.2)"] 415 | 416 | [[package]] 417 | name = "idna" 418 | version = "3.4" 419 | description = "Internationalized Domain Names in Applications (IDNA)" 420 | optional = false 421 | python-versions = ">=3.5" 422 | files = [ 423 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 424 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 425 | ] 426 | 427 | [[package]] 428 | name = "importlib-metadata" 429 | version = "6.8.0" 430 | description = "Read metadata from Python packages" 431 | optional = false 432 | python-versions = ">=3.8" 433 | files = [ 434 | {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, 435 | {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, 436 | ] 437 | 438 | [package.dependencies] 439 | zipp = ">=0.5" 440 | 441 | [package.extras] 442 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 443 | perf = ["ipython"] 444 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] 445 | 446 | [[package]] 447 | name = "itsdangerous" 448 | version = "2.1.2" 449 | description = "Safely pass data to untrusted environments and back." 450 | optional = false 451 | python-versions = ">=3.7" 452 | files = [ 453 | {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, 454 | {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, 455 | ] 456 | 457 | [[package]] 458 | name = "jinja2" 459 | version = "3.1.2" 460 | description = "A very fast and expressive template engine." 461 | optional = false 462 | python-versions = ">=3.7" 463 | files = [ 464 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 465 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 466 | ] 467 | 468 | [package.dependencies] 469 | MarkupSafe = ">=2.0" 470 | 471 | [package.extras] 472 | i18n = ["Babel (>=2.7)"] 473 | 474 | [[package]] 475 | name = "logzero" 476 | version = "1.7.0" 477 | description = "Robust and effective logging for Python 2 and 3" 478 | optional = false 479 | python-versions = "*" 480 | files = [ 481 | {file = "logzero-1.7.0-py2.py3-none-any.whl", hash = "sha256:23eb1f717a2736f9ab91ca0d43160fd2c996ad49ae6bad34652d47aba908769d"}, 482 | {file = "logzero-1.7.0.tar.gz", hash = "sha256:7f73ddd3ae393457236f081ffebd044a3aa2e423a47ae6ddb5179ab90d0ad082"}, 483 | ] 484 | 485 | [package.dependencies] 486 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 487 | 488 | [[package]] 489 | name = "mailjet-rest" 490 | version = "1.3.4" 491 | description = "Mailjet V3 API wrapper" 492 | optional = false 493 | python-versions = "*" 494 | files = [ 495 | {file = "mailjet_rest-1.3.4-py3-none-any.whl", hash = "sha256:635d53ac3fd61020f309c24ee977ae3458654ab39f9c36fc4b50c74e5d8ad410"}, 496 | {file = "mailjet_rest-1.3.4.tar.gz", hash = "sha256:e02663fa0369543bcd48c37a146e8143bb12b9f3512af2d5ba6dfbcc99e64a2d"}, 497 | ] 498 | 499 | [package.dependencies] 500 | requests = ">=2.4.3" 501 | 502 | [[package]] 503 | name = "markupsafe" 504 | version = "2.1.3" 505 | description = "Safely add untrusted strings to HTML/XML markup." 506 | optional = false 507 | python-versions = ">=3.7" 508 | files = [ 509 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, 510 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, 511 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, 512 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, 513 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, 514 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, 515 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, 516 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, 517 | {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, 518 | {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, 519 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, 520 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, 521 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, 522 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, 523 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, 524 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, 525 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, 526 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, 527 | {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, 528 | {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, 529 | {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, 530 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, 531 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, 532 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, 533 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, 534 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, 535 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, 536 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, 537 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, 538 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, 539 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, 540 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, 541 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, 542 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, 543 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, 544 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, 545 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, 546 | {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, 547 | {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, 548 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, 549 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, 550 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, 551 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, 552 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, 553 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, 554 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, 555 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, 556 | {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, 557 | {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, 558 | {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, 559 | ] 560 | 561 | [[package]] 562 | name = "mypy-extensions" 563 | version = "1.0.0" 564 | description = "Type system extensions for programs checked with the mypy type checker." 565 | optional = false 566 | python-versions = ">=3.5" 567 | files = [ 568 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 569 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 570 | ] 571 | 572 | [[package]] 573 | name = "nest-asyncio" 574 | version = "1.5.7" 575 | description = "Patch asyncio to allow nested event loops" 576 | optional = false 577 | python-versions = ">=3.5" 578 | files = [ 579 | {file = "nest_asyncio-1.5.7-py3-none-any.whl", hash = "sha256:5301c82941b550b3123a1ea772ba9a1c80bad3a182be8c1a5ae6ad3be57a9657"}, 580 | {file = "nest_asyncio-1.5.7.tar.gz", hash = "sha256:6a80f7b98f24d9083ed24608977c09dd608d83f91cccc24c9d2cba6d10e01c10"}, 581 | ] 582 | 583 | [[package]] 584 | name = "packaging" 585 | version = "23.1" 586 | description = "Core utilities for Python packages" 587 | optional = false 588 | python-versions = ">=3.7" 589 | files = [ 590 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 591 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 592 | ] 593 | 594 | [[package]] 595 | name = "pathspec" 596 | version = "0.11.2" 597 | description = "Utility library for gitignore style pattern matching of file paths." 598 | optional = false 599 | python-versions = ">=3.7" 600 | files = [ 601 | {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, 602 | {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, 603 | ] 604 | 605 | [[package]] 606 | name = "platformdirs" 607 | version = "3.10.0" 608 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 609 | optional = false 610 | python-versions = ">=3.7" 611 | files = [ 612 | {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, 613 | {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, 614 | ] 615 | 616 | [package.extras] 617 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 618 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 619 | 620 | [[package]] 621 | name = "plotly" 622 | version = "5.16.1" 623 | description = "An open-source, interactive data visualization library for Python" 624 | optional = false 625 | python-versions = ">=3.6" 626 | files = [ 627 | {file = "plotly-5.16.1-py2.py3-none-any.whl", hash = "sha256:19cc34f339acd4e624177806c14df22f388f23fb70658b03aad959a0e650a0dc"}, 628 | {file = "plotly-5.16.1.tar.gz", hash = "sha256:295ac25edeb18c893abb71dcadcea075b78fd6fdf07cee4217a4e1009667925b"}, 629 | ] 630 | 631 | [package.dependencies] 632 | packaging = "*" 633 | tenacity = ">=6.2.0" 634 | 635 | [[package]] 636 | name = "pydantic" 637 | version = "1.10.12" 638 | description = "Data validation and settings management using python type hints" 639 | optional = false 640 | python-versions = ">=3.7" 641 | files = [ 642 | {file = "pydantic-1.10.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a1fcb59f2f355ec350073af41d927bf83a63b50e640f4dbaa01053a28b7a7718"}, 643 | {file = "pydantic-1.10.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7ccf02d7eb340b216ec33e53a3a629856afe1c6e0ef91d84a4e6f2fb2ca70fe"}, 644 | {file = "pydantic-1.10.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fb2aa3ab3728d950bcc885a2e9eff6c8fc40bc0b7bb434e555c215491bcf48b"}, 645 | {file = "pydantic-1.10.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:771735dc43cf8383959dc9b90aa281f0b6092321ca98677c5fb6125a6f56d58d"}, 646 | {file = "pydantic-1.10.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca48477862372ac3770969b9d75f1bf66131d386dba79506c46d75e6b48c1e09"}, 647 | {file = "pydantic-1.10.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a5e7add47a5b5a40c49b3036d464e3c7802f8ae0d1e66035ea16aa5b7a3923ed"}, 648 | {file = "pydantic-1.10.12-cp310-cp310-win_amd64.whl", hash = "sha256:e4129b528c6baa99a429f97ce733fff478ec955513630e61b49804b6cf9b224a"}, 649 | {file = "pydantic-1.10.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0d191db0f92dfcb1dec210ca244fdae5cbe918c6050b342d619c09d31eea0cc"}, 650 | {file = "pydantic-1.10.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:795e34e6cc065f8f498c89b894a3c6da294a936ee71e644e4bd44de048af1405"}, 651 | {file = "pydantic-1.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69328e15cfda2c392da4e713443c7dbffa1505bc9d566e71e55abe14c97ddc62"}, 652 | {file = "pydantic-1.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2031de0967c279df0d8a1c72b4ffc411ecd06bac607a212892757db7462fc494"}, 653 | {file = "pydantic-1.10.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ba5b2e6fe6ca2b7e013398bc7d7b170e21cce322d266ffcd57cca313e54fb246"}, 654 | {file = "pydantic-1.10.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2a7bac939fa326db1ab741c9d7f44c565a1d1e80908b3797f7f81a4f86bc8d33"}, 655 | {file = "pydantic-1.10.12-cp311-cp311-win_amd64.whl", hash = "sha256:87afda5539d5140cb8ba9e8b8c8865cb5b1463924d38490d73d3ccfd80896b3f"}, 656 | {file = "pydantic-1.10.12-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:549a8e3d81df0a85226963611950b12d2d334f214436a19537b2efed61b7639a"}, 657 | {file = "pydantic-1.10.12-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:598da88dfa127b666852bef6d0d796573a8cf5009ffd62104094a4fe39599565"}, 658 | {file = "pydantic-1.10.12-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba5c4a8552bff16c61882db58544116d021d0b31ee7c66958d14cf386a5b5350"}, 659 | {file = "pydantic-1.10.12-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c79e6a11a07da7374f46970410b41d5e266f7f38f6a17a9c4823db80dadf4303"}, 660 | {file = "pydantic-1.10.12-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab26038b8375581dc832a63c948f261ae0aa21f1d34c1293469f135fa92972a5"}, 661 | {file = "pydantic-1.10.12-cp37-cp37m-win_amd64.whl", hash = "sha256:e0a16d274b588767602b7646fa05af2782576a6cf1022f4ba74cbb4db66f6ca8"}, 662 | {file = "pydantic-1.10.12-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6a9dfa722316f4acf4460afdf5d41d5246a80e249c7ff475c43a3a1e9d75cf62"}, 663 | {file = "pydantic-1.10.12-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a73f489aebd0c2121ed974054cb2759af8a9f747de120acd2c3394cf84176ccb"}, 664 | {file = "pydantic-1.10.12-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b30bcb8cbfccfcf02acb8f1a261143fab622831d9c0989707e0e659f77a18e0"}, 665 | {file = "pydantic-1.10.12-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fcfb5296d7877af406ba1547dfde9943b1256d8928732267e2653c26938cd9c"}, 666 | {file = "pydantic-1.10.12-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2f9a6fab5f82ada41d56b0602606a5506aab165ca54e52bc4545028382ef1c5d"}, 667 | {file = "pydantic-1.10.12-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dea7adcc33d5d105896401a1f37d56b47d443a2b2605ff8a969a0ed5543f7e33"}, 668 | {file = "pydantic-1.10.12-cp38-cp38-win_amd64.whl", hash = "sha256:1eb2085c13bce1612da8537b2d90f549c8cbb05c67e8f22854e201bde5d98a47"}, 669 | {file = "pydantic-1.10.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef6c96b2baa2100ec91a4b428f80d8f28a3c9e53568219b6c298c1125572ebc6"}, 670 | {file = "pydantic-1.10.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c076be61cd0177a8433c0adcb03475baf4ee91edf5a4e550161ad57fc90f523"}, 671 | {file = "pydantic-1.10.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d5a58feb9a39f481eda4d5ca220aa8b9d4f21a41274760b9bc66bfd72595b86"}, 672 | {file = "pydantic-1.10.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5f805d2d5d0a41633651a73fa4ecdd0b3d7a49de4ec3fadf062fe16501ddbf1"}, 673 | {file = "pydantic-1.10.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1289c180abd4bd4555bb927c42ee42abc3aee02b0fb2d1223fb7c6e5bef87dbe"}, 674 | {file = "pydantic-1.10.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5d1197e462e0364906cbc19681605cb7c036f2475c899b6f296104ad42b9f5fb"}, 675 | {file = "pydantic-1.10.12-cp39-cp39-win_amd64.whl", hash = "sha256:fdbdd1d630195689f325c9ef1a12900524dceb503b00a987663ff4f58669b93d"}, 676 | {file = "pydantic-1.10.12-py3-none-any.whl", hash = "sha256:b749a43aa51e32839c9d71dc67eb1e4221bb04af1033a32e3923d46f9effa942"}, 677 | {file = "pydantic-1.10.12.tar.gz", hash = "sha256:0fe8a415cea8f340e7a9af9c54fc71a649b43e8ca3cc732986116b3cb135d303"}, 678 | ] 679 | 680 | [package.dependencies] 681 | typing-extensions = ">=4.2.0" 682 | 683 | [package.extras] 684 | dotenv = ["python-dotenv (>=0.10.4)"] 685 | email = ["email-validator (>=1.0.3)"] 686 | 687 | [[package]] 688 | name = "python-dotenv" 689 | version = "1.0.0" 690 | description = "Read key-value pairs from a .env file and set them as environment variables" 691 | optional = false 692 | python-versions = ">=3.8" 693 | files = [ 694 | {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, 695 | {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, 696 | ] 697 | 698 | [package.extras] 699 | cli = ["click (>=5.0)"] 700 | 701 | [[package]] 702 | name = "requests" 703 | version = "2.31.0" 704 | description = "Python HTTP for Humans." 705 | optional = false 706 | python-versions = ">=3.7" 707 | files = [ 708 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 709 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 710 | ] 711 | 712 | [package.dependencies] 713 | certifi = ">=2017.4.17" 714 | charset-normalizer = ">=2,<4" 715 | idna = ">=2.5,<4" 716 | urllib3 = ">=1.21.1,<3" 717 | 718 | [package.extras] 719 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 720 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 721 | 722 | [[package]] 723 | name = "retrying" 724 | version = "1.3.4" 725 | description = "Retrying" 726 | optional = false 727 | python-versions = "*" 728 | files = [ 729 | {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"}, 730 | {file = "retrying-1.3.4.tar.gz", hash = "sha256:345da8c5765bd982b1d1915deb9102fd3d1f7ad16bd84a9700b85f64d24e8f3e"}, 731 | ] 732 | 733 | [package.dependencies] 734 | six = ">=1.7.0" 735 | 736 | [[package]] 737 | name = "setuptools" 738 | version = "68.1.2" 739 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 740 | optional = false 741 | python-versions = ">=3.8" 742 | files = [ 743 | {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, 744 | {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, 745 | ] 746 | 747 | [package.extras] 748 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 749 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 750 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 751 | 752 | [[package]] 753 | name = "six" 754 | version = "1.16.0" 755 | description = "Python 2 and 3 compatibility utilities" 756 | optional = false 757 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 758 | files = [ 759 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 760 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 761 | ] 762 | 763 | [[package]] 764 | name = "sqlalchemy" 765 | version = "1.4.41" 766 | description = "Database Abstraction Library" 767 | optional = false 768 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 769 | files = [ 770 | {file = "SQLAlchemy-1.4.41-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:13e397a9371ecd25573a7b90bd037db604331cf403f5318038c46ee44908c44d"}, 771 | {file = "SQLAlchemy-1.4.41-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2d6495f84c4fd11584f34e62f9feec81bf373787b3942270487074e35cbe5330"}, 772 | {file = "SQLAlchemy-1.4.41-cp27-cp27m-win32.whl", hash = "sha256:e570cfc40a29d6ad46c9aeaddbdcee687880940a3a327f2c668dd0e4ef0a441d"}, 773 | {file = "SQLAlchemy-1.4.41-cp27-cp27m-win_amd64.whl", hash = "sha256:5facb7fd6fa8a7353bbe88b95695e555338fb038ad19ceb29c82d94f62775a05"}, 774 | {file = "SQLAlchemy-1.4.41-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f37fa70d95658763254941ddd30ecb23fc4ec0c5a788a7c21034fc2305dab7cc"}, 775 | {file = "SQLAlchemy-1.4.41-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:361f6b5e3f659e3c56ea3518cf85fbdae1b9e788ade0219a67eeaaea8a4e4d2a"}, 776 | {file = "SQLAlchemy-1.4.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0990932f7cca97fece8017414f57fdd80db506a045869d7ddf2dda1d7cf69ecc"}, 777 | {file = "SQLAlchemy-1.4.41-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cd767cf5d7252b1c88fcfb58426a32d7bd14a7e4942497e15b68ff5d822b41ad"}, 778 | {file = "SQLAlchemy-1.4.41-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5102fb9ee2c258a2218281adcb3e1918b793c51d6c2b4666ce38c35101bb940e"}, 779 | {file = "SQLAlchemy-1.4.41-cp310-cp310-win32.whl", hash = "sha256:2082a2d2fca363a3ce21cfa3d068c5a1ce4bf720cf6497fb3a9fc643a8ee4ddd"}, 780 | {file = "SQLAlchemy-1.4.41-cp310-cp310-win_amd64.whl", hash = "sha256:e4b12e3d88a8fffd0b4ca559f6d4957ed91bd4c0613a4e13846ab8729dc5c251"}, 781 | {file = "SQLAlchemy-1.4.41-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:90484a2b00baedad361402c257895b13faa3f01780f18f4a104a2f5c413e4536"}, 782 | {file = "SQLAlchemy-1.4.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b67fc780cfe2b306180e56daaa411dd3186bf979d50a6a7c2a5b5036575cbdbb"}, 783 | {file = "SQLAlchemy-1.4.41-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad2b727fc41c7f8757098903f85fafb4bf587ca6605f82d9bf5604bd9c7cded"}, 784 | {file = "SQLAlchemy-1.4.41-cp311-cp311-win32.whl", hash = "sha256:59bdc291165b6119fc6cdbc287c36f7f2859e6051dd923bdf47b4c55fd2f8bd0"}, 785 | {file = "SQLAlchemy-1.4.41-cp311-cp311-win_amd64.whl", hash = "sha256:d2e054aed4645f9b755db85bc69fc4ed2c9020c19c8027976f66576b906a74f1"}, 786 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:4ba7e122510bbc07258dc42be6ed45997efdf38129bde3e3f12649be70683546"}, 787 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0dcf127bb99458a9d211e6e1f0f3edb96c874dd12f2503d4d8e4f1fd103790b"}, 788 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e16c2be5cb19e2c08da7bd3a87fed2a0d4e90065ee553a940c4fc1a0fb1ab72b"}, 789 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5ebeeec5c14533221eb30bad716bc1fd32f509196318fb9caa7002c4a364e4c"}, 790 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-win32.whl", hash = "sha256:3e2ef592ac3693c65210f8b53d0edcf9f4405925adcfc031ff495e8d18169682"}, 791 | {file = "SQLAlchemy-1.4.41-cp36-cp36m-win_amd64.whl", hash = "sha256:eb30cf008850c0a26b72bd1b9be6730830165ce049d239cfdccd906f2685f892"}, 792 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:c23d64a0b28fc78c96289ffbd0d9d1abd48d267269b27f2d34e430ea73ce4b26"}, 793 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb8897367a21b578b26f5713833836f886817ee2ffba1177d446fa3f77e67c8"}, 794 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:14576238a5f89bcf504c5f0a388d0ca78df61fb42cb2af0efe239dc965d4f5c9"}, 795 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:639e1ae8d48b3c86ffe59c0daa9a02e2bfe17ca3d2b41611b30a0073937d4497"}, 796 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-win32.whl", hash = "sha256:0005bd73026cd239fc1e8ccdf54db58b6193be9a02b3f0c5983808f84862c767"}, 797 | {file = "SQLAlchemy-1.4.41-cp37-cp37m-win_amd64.whl", hash = "sha256:5323252be2bd261e0aa3f33cb3a64c45d76829989fa3ce90652838397d84197d"}, 798 | {file = "SQLAlchemy-1.4.41-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:05f0de3a1dc3810a776275763764bb0015a02ae0f698a794646ebc5fb06fad33"}, 799 | {file = "SQLAlchemy-1.4.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0002e829142b2af00b4eaa26c51728f3ea68235f232a2e72a9508a3116bd6ed0"}, 800 | {file = "SQLAlchemy-1.4.41-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22ff16cedab5b16a0db79f1bc99e46a6ddececb60c396562e50aab58ddb2871c"}, 801 | {file = "SQLAlchemy-1.4.41-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccfd238f766a5bb5ee5545a62dd03f316ac67966a6a658efb63eeff8158a4bbf"}, 802 | {file = "SQLAlchemy-1.4.41-cp38-cp38-win32.whl", hash = "sha256:58bb65b3274b0c8a02cea9f91d6f44d0da79abc993b33bdedbfec98c8440175a"}, 803 | {file = "SQLAlchemy-1.4.41-cp38-cp38-win_amd64.whl", hash = "sha256:ce8feaa52c1640de9541eeaaa8b5fb632d9d66249c947bb0d89dd01f87c7c288"}, 804 | {file = "SQLAlchemy-1.4.41-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:199a73c31ac8ea59937cc0bf3dfc04392e81afe2ec8a74f26f489d268867846c"}, 805 | {file = "SQLAlchemy-1.4.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676d51c9f6f6226ae8f26dc83ec291c088fe7633269757d333978df78d931ab"}, 806 | {file = "SQLAlchemy-1.4.41-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:036d8472356e1d5f096c5e0e1a7e0f9182140ada3602f8fff6b7329e9e7cfbcd"}, 807 | {file = "SQLAlchemy-1.4.41-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2307495d9e0ea00d0c726be97a5b96615035854972cc538f6e7eaed23a35886c"}, 808 | {file = "SQLAlchemy-1.4.41-cp39-cp39-win32.whl", hash = "sha256:9c56e19780cd1344fcd362fd6265a15f48aa8d365996a37fab1495cae8fcd97d"}, 809 | {file = "SQLAlchemy-1.4.41-cp39-cp39-win_amd64.whl", hash = "sha256:f5fa526d027d804b1f85cdda1eb091f70bde6fb7d87892f6dd5a48925bc88898"}, 810 | {file = "SQLAlchemy-1.4.41.tar.gz", hash = "sha256:0292f70d1797e3c54e862e6f30ae474014648bc9c723e14a2fda730adb0a9791"}, 811 | ] 812 | 813 | [package.dependencies] 814 | greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} 815 | 816 | [package.extras] 817 | aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] 818 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] 819 | asyncio = ["greenlet (!=0.4.17)"] 820 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"] 821 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"] 822 | mssql = ["pyodbc"] 823 | mssql-pymssql = ["pymssql"] 824 | mssql-pyodbc = ["pyodbc"] 825 | mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"] 826 | mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"] 827 | mysql-connector = ["mysql-connector-python"] 828 | oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"] 829 | postgresql = ["psycopg2 (>=2.7)"] 830 | postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 831 | postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] 832 | postgresql-psycopg2binary = ["psycopg2-binary"] 833 | postgresql-psycopg2cffi = ["psycopg2cffi"] 834 | pymysql = ["pymysql", "pymysql (<1)"] 835 | sqlcipher = ["sqlcipher3-binary"] 836 | 837 | [[package]] 838 | name = "sqlalchemy2-stubs" 839 | version = "0.0.2a35" 840 | description = "Typing Stubs for SQLAlchemy 1.4" 841 | optional = false 842 | python-versions = ">=3.6" 843 | files = [ 844 | {file = "sqlalchemy2-stubs-0.0.2a35.tar.gz", hash = "sha256:bd5d530697d7e8c8504c7fe792ef334538392a5fb7aa7e4f670bfacdd668a19d"}, 845 | {file = "sqlalchemy2_stubs-0.0.2a35-py3-none-any.whl", hash = "sha256:593784ff9fc0dc2ded1895e3322591689db3be06f3ca006e3ef47640baf2d38a"}, 846 | ] 847 | 848 | [package.dependencies] 849 | typing-extensions = ">=3.7.4" 850 | 851 | [[package]] 852 | name = "sqlmodel" 853 | version = "0.0.8" 854 | description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." 855 | optional = false 856 | python-versions = ">=3.6.1,<4.0.0" 857 | files = [ 858 | {file = "sqlmodel-0.0.8-py3-none-any.whl", hash = "sha256:0fd805719e0c5d4f22be32eb3ffc856eca3f7f20e8c7aa3e117ad91684b518ee"}, 859 | {file = "sqlmodel-0.0.8.tar.gz", hash = "sha256:3371b4d1ad59d2ffd0c530582c2140b6c06b090b32af9b9c6412986d7b117036"}, 860 | ] 861 | 862 | [package.dependencies] 863 | pydantic = ">=1.8.2,<2.0.0" 864 | SQLAlchemy = ">=1.4.17,<=1.4.41" 865 | sqlalchemy2-stubs = "*" 866 | 867 | [[package]] 868 | name = "tenacity" 869 | version = "8.2.3" 870 | description = "Retry code until it succeeds" 871 | optional = false 872 | python-versions = ">=3.7" 873 | files = [ 874 | {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, 875 | {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, 876 | ] 877 | 878 | [package.extras] 879 | doc = ["reno", "sphinx", "tornado (>=4.5)"] 880 | 881 | [[package]] 882 | name = "tomli" 883 | version = "2.0.1" 884 | description = "A lil' TOML parser" 885 | optional = false 886 | python-versions = ">=3.7" 887 | files = [ 888 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 889 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 890 | ] 891 | 892 | [[package]] 893 | name = "typing-extensions" 894 | version = "4.7.1" 895 | description = "Backported and Experimental Type Hints for Python 3.7+" 896 | optional = false 897 | python-versions = ">=3.7" 898 | files = [ 899 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 900 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 901 | ] 902 | 903 | [[package]] 904 | name = "urllib3" 905 | version = "2.0.4" 906 | description = "HTTP library with thread-safe connection pooling, file post, and more." 907 | optional = false 908 | python-versions = ">=3.7" 909 | files = [ 910 | {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, 911 | {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, 912 | ] 913 | 914 | [package.extras] 915 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 916 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 917 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 918 | zstd = ["zstandard (>=0.18.0)"] 919 | 920 | [[package]] 921 | name = "validate-email" 922 | version = "1.3" 923 | description = "Validate_email verify if an email address is valid and really exists." 924 | optional = false 925 | python-versions = "*" 926 | files = [ 927 | {file = "validate_email-1.3.tar.gz", hash = "sha256:784719dc5f780be319cdd185dc85dd93afebdb6ebb943811bc4c7c5f9c72aeaf"}, 928 | ] 929 | 930 | [[package]] 931 | name = "werkzeug" 932 | version = "2.2.3" 933 | description = "The comprehensive WSGI web application library." 934 | optional = false 935 | python-versions = ">=3.7" 936 | files = [ 937 | {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"}, 938 | {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"}, 939 | ] 940 | 941 | [package.dependencies] 942 | MarkupSafe = ">=2.1.1" 943 | 944 | [package.extras] 945 | watchdog = ["watchdog"] 946 | 947 | [[package]] 948 | name = "zipp" 949 | version = "3.16.2" 950 | description = "Backport of pathlib-compatible object wrapper for zip files" 951 | optional = false 952 | python-versions = ">=3.8" 953 | files = [ 954 | {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, 955 | {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, 956 | ] 957 | 958 | [package.extras] 959 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 960 | testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 961 | 962 | [metadata] 963 | lock-version = "2.0" 964 | python-versions = "^3.9" 965 | content-hash = "7ce5eff17cfb134fcd3b0ed37bf20421d9c49d64380cb913e79e66ea890403e9" 966 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dash-auth-flow" 3 | version = "0.2.0" 4 | description = "Batteries-included authentication flow with Dash" 5 | authors = ["Your Name "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "dash_auth_flow"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.9" 12 | dash = "^2.13.0" 13 | black = "^23.7.0" 14 | flask-login = "^0.6.2" 15 | logzero = "^1.7.0" 16 | mailjet-rest = "^1.3.4" 17 | gunicorn = "^21.2.0" 18 | sqlmodel = "^0.0.8" 19 | flask-sqlalchemy = "^3.0.5" 20 | validate-email = "^1.3" 21 | dash-bootstrap-components = "^1.4.2" 22 | python-dotenv = "^1.0.0" 23 | 24 | 25 | [build-system] 26 | requires = ["poetry-core"] 27 | build-backend = "poetry.core.masonry.api" 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ansi2html==1.8.0 2 | black==23.7.0 3 | Brotli==1.0.9 4 | certifi==2023.7.22 5 | charset-normalizer==3.2.0 6 | click==8.1.7 7 | dash==2.13.0 8 | dash-bootstrap-components==1.4.2 9 | dash-core-components==2.0.0 10 | dash-html-components==2.0.0 11 | dash-table==5.0.0 12 | Flask==2.2.5 13 | Flask-Compress==1.13 14 | Flask-Login==0.6.2 15 | Flask-SQLAlchemy==3.0.5 16 | gunicorn==21.2.0 17 | idna==3.4 18 | itsdangerous==2.1.2 19 | Jinja2==3.1.2 20 | logzero==1.7.0 21 | mailjet-rest==1.3.4 22 | MarkupSafe==2.1.3 23 | mypy-extensions==1.0.0 24 | nest-asyncio==1.5.7 25 | packaging==23.1 26 | pathspec==0.11.2 27 | platformdirs==3.10.0 28 | plotly==5.16.1 29 | pydantic==1.10.12 30 | python-dotenv==1.0.0 31 | requests==2.31.0 32 | retrying==1.3.4 33 | six==1.16.0 34 | SQLAlchemy==1.4.41 35 | sqlalchemy2-stubs==0.0.2a35 36 | sqlmodel==0.0.8 37 | tenacity==8.2.3 38 | typing_extensions==4.7.1 39 | urllib3==2.0.4 40 | validate-email==1.3 41 | Werkzeug==2.2.3 42 | -------------------------------------------------------------------------------- /users.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/users.db -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/russellromney/dash-auth-flow/b2057d05266081c6bddce0880749c6ea1df22c59/utils/__init__.py -------------------------------------------------------------------------------- /utils/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from dash import dcc, page_registry 3 | from flask import current_app 4 | from flask_login import current_user 5 | 6 | 7 | def unprotected(f: Callable) -> Callable: 8 | """Used in conjunction with Dash Pages and `protect_layouts`. 9 | Decorates a Dash page layout function and explicitly 10 | allows any user to access the layout function output. 11 | 12 | @unprotected 13 | def layout(): 14 | return html.Div(...) 15 | """ 16 | f.is_protected = False 17 | return f 18 | 19 | 20 | def protected(f: Callable) -> Callable: 21 | """Used in conjunction with Dash Pages and `protect_layouts`. 22 | Decorates a Dash page layout function and explicitly 23 | requires a user to be authenticated to access the layout function output. 24 | 25 | NOTE: Must be the first/outermost decorator. 26 | 27 | @protected 28 | @other_decorator 29 | def layout(): 30 | return html.Div(...) 31 | """ 32 | f.is_protected = True 33 | return f 34 | 35 | 36 | def _protect_layout(f: Callable) -> Callable: 37 | def wrapped(*args, **kwargs): 38 | if not current_user.is_authenticated: 39 | return dcc.Location( 40 | id="redirect-unauthenticated-user-to-login", 41 | pathname=current_app.login_manager.login_view, 42 | ) 43 | return f(*args, **kwargs) 44 | 45 | return wrapped 46 | 47 | 48 | def redirect_authenticated(pathname: str) -> Callable: 49 | """ 50 | If the user is authenticated, redirect them to the provided page pathname. 51 | """ 52 | 53 | def wrapper(f: Callable): 54 | def wrapped(*args, **kwargs): 55 | if current_user.is_authenticated: 56 | return dcc.Location( 57 | id="redirect-authenticated-user-to-path", 58 | pathname=pathname, 59 | ) 60 | return f(*args, **kwargs) 61 | 62 | return wrapped 63 | 64 | return wrapper 65 | 66 | 67 | def protect_layouts(default: bool = True): 68 | """ 69 | Call this after defining the global dash.Dash object. 70 | Protect any explicitly protected views and *don't* protect any explicitly unprotected views. 71 | Otherwise, protect all or none according to the `default`. 72 | """ 73 | for page in page_registry.values(): 74 | if hasattr(page["layout"], "is_protected"): 75 | if bool(getattr(page["layout"], "is_protected")) == False: 76 | continue 77 | else: 78 | page["layout"] = _protect_layout(page["layout"]) 79 | elif default == True: 80 | page["layout"] = _protect_layout(page["layout"]) 81 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | from sqlmodel import Session, create_engine 3 | from flask import current_app 4 | from dotenv import dotenv_values 5 | 6 | # Load a config dictionary from the dotenv file. 7 | config = dotenv_values(".env") 8 | config["TRANSITION_DELAY"] = float(config["TRANSITION_DELAY"]) 9 | config["ALERT_DELAY"] = int(config["ALERT_DELAY"]) 10 | 11 | 12 | def make_engine() -> sqlalchemy.engine.Engine: 13 | return create_engine(config["SQLALCHEMY_DATABASE_URI"]) 14 | 15 | 16 | def get_session() -> Session: 17 | return Session(current_app.engine) 18 | -------------------------------------------------------------------------------- /utils/pw.py: -------------------------------------------------------------------------------- 1 | from logzero import logger 2 | import traceback 3 | from sqlmodel import select 4 | import random 5 | from mailjet_rest import Client 6 | from datetime import datetime, timedelta 7 | 8 | from models.password_change import PasswordChange 9 | from models.user import User 10 | from utils.config import get_session, config 11 | from utils.user import change_password 12 | 13 | 14 | def send_password_key(email, firstname): 15 | """ 16 | ensure email exists 17 | create random 6-number password key 18 | send email with Twilio Sendgrid containing that password key 19 | return True if that all worked 20 | return False if one step fails 21 | """ 22 | 23 | # make sure email exists 24 | user = User.from_email(email) 25 | if not user: 26 | return False 27 | 28 | # generate a random key and send it to the user's email 29 | key = "".join([random.choice("1234567890") for x in range(6)]) 30 | try: 31 | mailjet = Client( 32 | auth=(config.get("MAILJET_API_KEY"), config.get("MAILJET_API_SECRET")), 33 | version="v3.1", 34 | ) 35 | data = { 36 | "Messages": [ 37 | { 38 | "From": { 39 | "Email": config.get("FROM_EMAIL"), 40 | "Name": config["APP_NAME"], 41 | }, 42 | "To": [ 43 | { 44 | "Email": email, 45 | "Name": user.first, 46 | } 47 | ], 48 | "Subject": "Greetings from Dash-Auth-Flow.", 49 | "TextPart": f"{config['APP_NAME']} password reset code", 50 | "HTMLPart": f"

Dear {user.first},

Your {config['APP_NAME']} password reset code is: {key}", 51 | "CustomID": "AppGettingStartedTest", 52 | } 53 | ] 54 | } 55 | result = mailjet.send.create(data=data) 56 | if result.status_code != 200: 57 | logger.info(f"Mailjet returned a non-200 status: {result.status_code}") 58 | logger.info(result.json()) 59 | return False 60 | except Exception as e: 61 | traceback.print_exc(e) 62 | return False 63 | 64 | # store that key in the password_key table 65 | with get_session() as session: 66 | change = PasswordChange(email=email, password_key=key, timestamp=datetime.now()) 67 | session.add(change) 68 | session.commit() 69 | session.refresh(change) 70 | 71 | # change their current password to a random string 72 | # first, get first and last name 73 | random_password = "".join([random.choice("1234567890") for x in range(15)]) 74 | res = change_password(email, random_password) 75 | if res: 76 | return True 77 | return False 78 | 79 | 80 | def validate_password_key(email: str, key: str) -> bool: 81 | # email exists 82 | if not User.from_email(email): 83 | return False 84 | 85 | # there is entry matching key and email 86 | with get_session() as session: 87 | out = session.exec( 88 | select(PasswordChange) 89 | .where(PasswordChange.email == email) 90 | .where(PasswordChange.password_key == key) 91 | ).all() 92 | if out: 93 | if (out[0].timestamp - (datetime.now() - timedelta(1))).days < 1: 94 | return True 95 | return False 96 | -------------------------------------------------------------------------------- /utils/user.py: -------------------------------------------------------------------------------- 1 | from logzero import logger 2 | from werkzeug.security import generate_password_hash 3 | from sqlmodel import select 4 | 5 | from models.user import User 6 | from utils.config import get_session 7 | 8 | 9 | def add_user(first: str, last: str, password: str, email: str) -> bool: 10 | hashed_password = generate_password_hash(password, method="sha256") 11 | 12 | with get_session() as session: 13 | this = User(first=first, last=last, email=email, password=hashed_password) 14 | session.add(this) 15 | session.commit() 16 | return True 17 | 18 | 19 | def show_users(): 20 | with get_session() as session: 21 | out = session.exec(select(User)).all() 22 | for row in out: 23 | logger.info(row.dict()) 24 | 25 | 26 | def change_password(email: str, password: str) -> bool: 27 | """ 28 | Change user password. Just changes the password; 29 | does NOT handle password change email functionality. 30 | """ 31 | if not User.from_email(email): 32 | return False 33 | 34 | this = User.from_email(email) 35 | hashed_password = generate_password_hash(password, method="sha256") 36 | this.password = hashed_password 37 | with get_session() as session: 38 | session.add(this) 39 | session.commit() 40 | return True 41 | --------------------------------------------------------------------------------