├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── config.py │ ├── files.py │ └── models.py ├── assets │ ├── my_video.mp4 │ ├── my_placeholder.png │ └── my_video_update.mp4 ├── test_path │ └── blogs │ │ └── 1 │ │ └── my_video.mp4 ├── test_file_utils.py ├── test_config.py ├── test_model_utils.py ├── test_model.py ├── app.py └── test_file_upload.py ├── flask_file_upload ├── __init__.py ├── _exceptions.py ├── column.py ├── _config.py ├── file_utils.py ├── model.py ├── _model_utils.py └── file_upload.py ├── assets ├── dir1.png └── logo.png ├── docs ├── source │ ├── model.rst │ ├── column.rst │ ├── file_upload.rst │ ├── index.rst │ └── conf.py ├── Makefile └── make.bat ├── .travis.yml ├── Makefile ├── Pipfile ├── tox.ini ├── .github └── workflows │ ├── python-publish.yml │ └── python-app.yml ├── LICENSE.md ├── setup.py ├── CHANGELOG.md ├── .gitignore ├── README.md └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flask_file_upload/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_upload import FileUpload 2 | -------------------------------------------------------------------------------- /assets/dir1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/assets/dir1.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/assets/logo.png -------------------------------------------------------------------------------- /docs/source/model.rst: -------------------------------------------------------------------------------- 1 | Model API 2 | ========= 3 | .. automodule:: flask_file_upload.model 4 | :members: 5 | -------------------------------------------------------------------------------- /tests/assets/my_video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/tests/assets/my_video.mp4 -------------------------------------------------------------------------------- /docs/source/column.rst: -------------------------------------------------------------------------------- 1 | Column Class 2 | ============ 3 | .. automodule:: flask_file_upload.column 4 | :members: 5 | -------------------------------------------------------------------------------- /tests/assets/my_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/tests/assets/my_placeholder.png -------------------------------------------------------------------------------- /tests/assets/my_video_update.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/tests/assets/my_video_update.mp4 -------------------------------------------------------------------------------- /docs/source/file_upload.rst: -------------------------------------------------------------------------------- 1 | File Upload API 2 | =============== 3 | .. automodule:: flask_file_upload.file_upload 4 | :members: 5 | -------------------------------------------------------------------------------- /tests/test_path/blogs/1/my_video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joegasewicz/flask-file-upload/HEAD/tests/test_path/blogs/1/my_video.mp4 -------------------------------------------------------------------------------- /tests/fixtures/config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_file_upload._config import Config 4 | 5 | from tests.app import app 6 | 7 | 8 | @pytest.fixture 9 | def test_config(): 10 | return Config().init_config(app) 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | install: 5 | - pip install -U tox 6 | - pip install -U codecov 7 | 8 | matrix: 9 | include: 10 | - python: "3.7-dev" 11 | 12 | script: 13 | - tox -v -e py 14 | - codecov -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | docs: 2 | make -C docs make html 3 | 4 | release: 5 | python setup.py sdist 6 | twine upload dist/* --verbose 7 | 8 | test: 9 | pipenv run pytest -vvv 10 | 11 | install: 12 | pipenv install 13 | pipenv install --dev 14 | 15 | tox: 16 | pipenv run tox -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | flask-sqlalchemy = "*" 9 | twine = "*" 10 | wheel = "*" 11 | tox = "*" 12 | 13 | [packages] 14 | flask-file-upload = {editable = true,path = "."} 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py36-dev, py37-dev, py38-dev, py39-dev}-flask{1, 2} 3 | [pytest] 4 | 5 | 6 | addopts = -p no:warnings 7 | 8 | [testenv] 9 | passenv = CI TRAVIS TRAVIS_* 10 | deps = 11 | pytest 12 | flask1: Flask>=1.0,<2.0 13 | flask2: Flask>=2.0 14 | 15 | commands = 16 | python -m pytest 17 | -------------------------------------------------------------------------------- /flask_file_upload/_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask File Upload Custom Exception 3 | """ 4 | 5 | 6 | class FlaskInstanceOrSqlalchemyIsNone(Exception): 7 | message = "You must pass your Flask app instance and " \ 8 | "the SqlAlchemy instance to FileUpload class or" \ 9 | "the init_app method. See https://flask-file-upload.readthedocs.io/en/latest/file_upload.html" 10 | 11 | def __init__(self, err=""): 12 | super(FlaskInstanceOrSqlalchemyIsNone, self).__init__(f"{err}\n{self.message}") 13 | -------------------------------------------------------------------------------- /tests/fixtures/files.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from werkzeug.datastructures import FileStorage 3 | import io 4 | import json 5 | 6 | 7 | @pytest.fixture 8 | def video_file(): 9 | mock_file = FileStorage( 10 | stream=io.BytesIO(b"123456"), 11 | filename="my_video.mp4", 12 | content_type="video/mpeg", 13 | ) 14 | return mock_file 15 | 16 | 17 | @pytest.fixture 18 | def png_file(): 19 | mock_file = FileStorage( 20 | stream=io.BytesIO(b"123456"), 21 | filename="my_png.png", 22 | content_type="image/png", 23 | ) 24 | return mock_file 25 | -------------------------------------------------------------------------------- /tests/test_file_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import request 3 | import pytest 4 | 5 | from flask_file_upload.file_utils import FileUtils 6 | from .fixtures.models import mock_blog_model 7 | from .fixtures.config import test_config 8 | from .app import create_app 9 | 10 | 11 | class TestFileUtils: 12 | 13 | file = os.path.join("tests/assets/my_video.mp4") 14 | 15 | @pytest.mark.r 16 | def test_save_file(self, create_app): 17 | 18 | rv = create_app.post("/config_test", data={"file": (self.file, "my_video.mp4")}, content_type='multipart/form-data') 19 | 20 | assert "200" in str(rv.status) 21 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | from flask_file_upload._config import Config 5 | 6 | 7 | class TestConfig: 8 | 9 | app = Flask(__name__) 10 | 11 | app.config["UPLOAD_FOLDER"] = "/test_path" 12 | app.config["ALLOWED_EXTENSIONS"] = ["jpg", "png", "mov", "mp4", "mpg"] 13 | app.config["MAX_CONTENT_LENGTH"] = 1000 * 1024 * 1024 14 | 15 | def test_init_config(self): 16 | config = Config() 17 | config.init_config(self.app) 18 | 19 | assert config.upload_folder == "/test_path" 20 | assert config.allowed_extensions == ["jpg", "png", "mov", "mp4", "mpg"] 21 | assert config.max_content_length == 1048576000 22 | -------------------------------------------------------------------------------- /flask_file_upload/column.py: -------------------------------------------------------------------------------- 1 | from warnings import warn 2 | 3 | 4 | class Column: 5 | """ 6 | The Column class is used to define the file attributes on 7 | a SqlAlchemy class model. 8 | Column class can be used when an SQLAlchemy class 9 | is decorated with Model `flask_file_upload.Model` 10 | constructor:: 11 | 12 | my_video = file_upload.Column() 13 | """ 14 | def __init__(self, db=None): 15 | if db: 16 | warn( 17 | DeprecationWarning( 18 | "FLASK-FILE-UPLOAD: Passing db to Column class is now not " 19 | "not required. This will be removed in v0.1.0" 20 | ) 21 | ) 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask File Upload documentation master file, created by 2 | sphinx-quickstart on Fri Dec 27 22:23:06 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Flask File Upload's documentation! 7 | ============================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | model 14 | file_upload 15 | column 16 | 17 | 18 | Features 19 | +++++++++++++++++++++++++++++++++++++++ 20 | * Easily store files on your server & database 21 | * Stream audio / video files from your Flask app 22 | * Update & Delete your files easily 23 | * Gets the url paths of your files 24 | 25 | 26 | Installation:: 27 | 28 | pip install flask-file-upload 29 | 30 | 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install Python Environment 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install pipenv 25 | pip install setuptools wheel twine 26 | - name: Install dependencies 27 | run: | 28 | pipenv install 29 | - name: Build and publish 30 | env: 31 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 32 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 33 | run: | 34 | python setup.py sdist bdist_wheel 35 | twine upload dist/* 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joe Gasewicz 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 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="flask-file-upload", 8 | version="0.2.1", 9 | description="Library that works with Flask & SqlAlchemy to store files in your database and server.", 10 | packages=["flask_file_upload"], 11 | py_modules=["flask_file_upload"], 12 | install_requires=[ 13 | 'flask', 14 | 'Flask-SQLAlchemy' 15 | ], 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.6", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 23 | "Operating System :: OS Independent", 24 | ], 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | url="https://github.com/joegasewicz/Flask-File-Upload", 28 | author="Joe Gasewicz", 29 | author_email="joegasewicz@gmail.com", 30 | ) 31 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: TOX Pytest 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-18.04 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 3.6 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.6 22 | - name: Set up Python 3.7 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: 3.7 26 | - name: Set up Python 3.8 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: 3.8 30 | - name: Set up Python 3.9 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: 3.9 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install pipenv 38 | - name: Tox 39 | run: | 40 | pipenv install --dev 41 | pipenv run tox 42 | -------------------------------------------------------------------------------- /tests/test_model_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import Column, String 3 | 4 | from flask_file_upload._model_utils import _ModelUtils 5 | from tests.fixtures.models import MockModel 6 | 7 | 8 | class Test_ModelUtils: 9 | 10 | def test_create_keys(self): 11 | model_data = { 12 | "my_video__file_name": Column(String, key="my_video__file_name", name="my_video__file_name"), 13 | "my_video__mime_type": Column(String, key="my_video__mime_type", name="my_video__mime_type"), 14 | "my_video__ext": Column(String, key="my_video__ext", name="my_video__ext"), 15 | } 16 | 17 | results = _ModelUtils.create_keys( 18 | _ModelUtils.keys, 19 | "my_video", 20 | lambda key, name: Column(String, key=key, name=name) 21 | ) 22 | 23 | assert str(results["my_video__file_name"]) == str(model_data["my_video__file_name"]) 24 | assert str(results["my_video__mime_type"]) == str(model_data["my_video__mime_type"]) 25 | assert str(results["my_video__ext"]) == str(model_data["my_video__ext"]) 26 | 27 | def test_get_by_postfix(self): 28 | # TODO remove 'in' from assertion 29 | 30 | assert "mp4" in _ModelUtils.get_by_postfix(MockModel, "my_video", _ModelUtils.column_suffix.EXT.value) 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### Changed 4 | 5 | **Release 0.2.1** - 2021-12-07 6 | - Lazily load via_ checking db.Model & flask instance 🪲 [Issue #111](https://github.com/joegasewicz/flask-file-upload/issues/111) 7 | - Updated readme for properly importing the module 🎈 [Issue #121](https://github.com/joegasewicz/flask-file-upload/issues/121) 8 | 9 | **Release 0.2.0** - 2021-06-04 10 | - not setting commit_session should warn not throw 🪲 [Issue #113](https://github.com/joegasewicz/flask-file-upload/issues/113) 11 | - Let user by default commit the session 🎈 [Issue #112](https://github.com/joegasewicz/flask-file-upload/issues/112) 12 | - Not setting commit_session should warn not throw 🪲 [Issue #113](https://github.com/joegasewicz/flask-file-upload/issues/108) 13 | - Bump cryptography from 3.3.1 to 3.3.2 #106 14 | - Add `__table_args__` to sqlalchemy_attr [Issue #108](https://github.com/joegasewicz/flask-file-upload/issues/108) 15 | - Docs need fixing 🪲 [Issue #110](https://github.com/joegasewicz/flask-file-upload/issues/110) 16 | 17 | 18 | **Release 0.1.5** - 2020-09-25 19 | - Fixed bug for init_app method from regression bug 🪲 [Issue #99](https://github.com/joegasewicz/flask-file-upload/issues/99) 20 | - Fixed bug for init_app method from regression bug 🪲 [Issue #97](https://github.com/joegasewicz/flask-file-upload/issues/97) 21 | 22 | **Unreleased 23 | 24 | -------------------------------------------------------------------------------- /flask_file_upload/_config.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from warnings import warn 3 | 4 | 5 | class Config: 6 | 7 | upload_folder: str = "" 8 | 9 | server_name: str = "" 10 | 11 | allowed_extensions: List = [] 12 | 13 | max_content_length: int = 0 14 | 15 | def init_config(self, app, **kwargs): 16 | upload_folder = kwargs.get("upload_folder") 17 | allowed_extensions = kwargs.get("allowed_extensions") 18 | max_content_length = kwargs.get("max_content_length") 19 | sqlalchemy_database_uri = kwargs.get("sqlalchemy_database_uri") 20 | 21 | app.config["UPLOAD_FOLDER"] = upload_folder or app.config.get("UPLOAD_FOLDER") 22 | app.config["ALLOWED_EXTENSIONS"] = allowed_extensions or app.config.get("ALLOWED_EXTENSIONS") 23 | app.config["MAX_CONTENT_LENGTH"] = max_content_length or app.config.get("MAX_CONTENT_LENGTH") 24 | app.config["SQLALCHEMY_DATABASE_URI"] = sqlalchemy_database_uri or app.config.get("SQLALCHEMY_DATABASE_URI") 25 | 26 | self.server_name = app.config["SERVER_NAME"] 27 | try: 28 | self.upload_folder = app.config["UPLOAD_FOLDER"] 29 | except KeyError as _: 30 | raise KeyError("Flask-File-Uploads: UPLOAD_FOLDER must be set") 31 | try: 32 | self.allowed_extensions = app.config["ALLOWED_EXTENSIONS"] 33 | except KeyError as _: 34 | self.allowed_extensions = ["jpg", "png", "mov", "mp4", "mpg"] 35 | warn("Flask-File-Uploads: ALLOWED_EXTENSIONS is not set." 36 | f"Defaulting to: {self.allowed_extensions}") 37 | self.max_content_length = app.config.get("MAX_CONTENT_LENGTH") 38 | -------------------------------------------------------------------------------- /tests/fixtures/models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import sqlalchemy 4 | 5 | from tests.app import db, file_upload 6 | 7 | 8 | @file_upload.Model 9 | class NewsModel(db.Model): 10 | __tablename__ = "news" 11 | id = db.Column(db.Integer(), primary_key=True) 12 | title = db.Column(db.String(100)) 13 | blog_id = db.Column(db.Integer, db.ForeignKey("blogs.id")) 14 | 15 | news_image = file_upload.Column() 16 | news_video = file_upload.Column() 17 | 18 | 19 | @file_upload.Model 20 | class MockBlogModel(db.Model): 21 | __tablename__ = "blogs" 22 | __table_args__ = {'extend_existing': True} 23 | __mapper_args__ = {'always_refresh': True} 24 | id = db.Column(db.Integer(), primary_key=True) 25 | name = db.Column(db.String(100)) 26 | # Relationships 27 | news = db.relationship(NewsModel, backref="news") 28 | # File attributes 29 | my_placeholder = file_upload.Column() 30 | my_video = file_upload.Column() 31 | 32 | 33 | def get_name(self): 34 | return "joe" 35 | 36 | def get_blog(self, id=1): 37 | return self.query.filter_by(id=id).one() 38 | 39 | @staticmethod 40 | def get_all_blogs(id=1): 41 | return MockBlogModel.query.all() 42 | 43 | @staticmethod 44 | def get_blog_by_id(): 45 | return 1 46 | 47 | class MockModel: 48 | __tablename__ = "blogs" 49 | my_video__file_name = "video1" 50 | my_video__mime_type = "video/mpeg" 51 | my_video__ext = "mp4" 52 | my_placeholder__file_name = "placeholder1" 53 | my_placeholder__mime_type = "image/jpeg" 54 | my_placeholder__ext = "jpg" 55 | 56 | 57 | @pytest.fixture 58 | def mock_model(): 59 | return MockModel 60 | 61 | @pytest.fixture 62 | def mock_news_model(): 63 | return NewsModel 64 | 65 | 66 | @pytest.fixture 67 | def mock_blog_model(): 68 | return MockBlogModel 69 | 70 | -------------------------------------------------------------------------------- /flask_file_upload/file_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper class 3 | """ 4 | import os 5 | import errno 6 | 7 | from ._config import Config 8 | from ._model_utils import _ModelUtils 9 | 10 | 11 | class FileUtils: 12 | 13 | table_name: str 14 | 15 | model = None 16 | 17 | config: Config 18 | 19 | id: int 20 | 21 | def __init__(self, model, config: Config): 22 | self.config = config 23 | self.model = model 24 | self.id = _ModelUtils.get_primary_key(model) 25 | self.table_name = _ModelUtils.get_table_name(model) 26 | 27 | @staticmethod 28 | def allowed_file(filename, config: Config) -> bool: 29 | """ 30 | :param filename: 31 | :param config: 32 | :return bool: 33 | """ 34 | return "." in filename and \ 35 | filename.rsplit(".", 1)[1].lower() in config.allowed_extensions 36 | 37 | def postfix_file_path(self, id: int, filename: str) -> str: 38 | """ 39 | :param id: 40 | :param filename: 41 | :return str: 42 | """ 43 | return f"/{self.table_name}/{id}/{filename}" 44 | 45 | def get_file_path(self, model_id: int, filename: str) -> str: 46 | """ 47 | :param model_id: 48 | :param filename: 49 | :return str: 50 | """ 51 | return os.path.join(f"{self.config.upload_folder}{self.postfix_file_path(model_id, filename)}") 52 | 53 | def save_file(self, file, model_id: int) -> None: 54 | """ 55 | :param file: 56 | :param model_id: 57 | :return None: 58 | """ 59 | file_path = self.get_file_path(model_id, file.filename) 60 | if not os.path.exists(os.path.dirname(file_path)): 61 | try: 62 | os.makedirs(os.path.dirname(file_path)) 63 | except OSError as err: 64 | if err.errno != errno.EEXIST: 65 | raise OSError("[FLASK_FILE_UPLOAD_ERROR]: Couldn't create file path: " 66 | f"{file_path}") 67 | file.save(file_path) 68 | 69 | def get_stream_path(self, model_id: int): 70 | return os.path.join(f"{self.config.upload_folder}/{self.table_name}/{model_id}") 71 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_file_upload.file_upload import FileUpload 4 | from tests.app import db 5 | from tests.fixtures.models import MockBlogModel, mock_blog_model 6 | from flask_file_upload.model import create_model 7 | from flask_file_upload._model_utils import _ModelUtils 8 | 9 | class TestModel: 10 | 11 | test_results = { 12 | "my_video__file_name": "video1", 13 | "my_video__mime_type": "video/mpeg", 14 | "my_video__ext": "mp4", 15 | "my_placeholder__file_name": "placeholder1", 16 | "my_placeholder__mime_type": "image/jpeg", 17 | "my_placeholder__ext": "jpg", 18 | "id": 1, 19 | } 20 | 21 | def test_model(self): 22 | 23 | model_test = MockBlogModel(**self.test_results) 24 | 25 | assert hasattr(model_test, "my_video__file_name") 26 | assert hasattr(model_test, "my_video__mime_type") 27 | assert hasattr(model_test, "my_video__ext") 28 | assert not hasattr(model_test, "my_video") 29 | assert hasattr(model_test, "my_placeholder__file_name") 30 | assert hasattr(model_test, "my_placeholder__mime_type") 31 | assert hasattr(model_test, "my_placeholder__ext") 32 | assert not hasattr(model_test, "my_placeholder") 33 | assert hasattr(model_test, "id") 34 | 35 | assert model_test.my_video__file_name == "video1" 36 | assert model_test.my_video__mime_type == "video/mpeg" 37 | assert model_test.my_video__ext == "mp4" 38 | assert model_test.my_placeholder__file_name == "placeholder1" 39 | assert model_test.my_placeholder__mime_type == "image/jpeg" 40 | assert model_test.my_placeholder__ext == "jpg" 41 | assert model_test.id == 1 42 | 43 | def test_model_attr(self, mock_blog_model): 44 | # Test static members: 45 | assert hasattr(MockBlogModel, "get_blog_by_id") 46 | print(dir(db.Model)) 47 | assert MockBlogModel.get_blog_by_id() == 1 48 | 49 | for k in _ModelUtils.sqlalchemy_attr: 50 | assert hasattr(MockBlogModel, k) 51 | 52 | # Test instance members 53 | blog = mock_blog_model(name="test_name") 54 | assert hasattr(blog, "get_name") 55 | assert blog.get_name() == "joe" 56 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import os 18 | import sys 19 | sys.path.insert(0, os.path.abspath('../..')) 20 | sys.setrecursionlimit(1500) 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'Flask File Upload' 24 | copyright = '2019, Joe Gasewicz' 25 | author = 'Joe Gasewicz' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = '0.0.1' 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ['sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.autodoc'] 37 | autodoc_mock_imports = ["jwt"] 38 | 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = [] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | 51 | # The theme to use for HTML and HTML Help pages. See the documentation for 52 | # a list of builtin themes. 53 | # 54 | html_theme = 'alabaster' 55 | 56 | # Add any paths that contain custom static files (such as style sheets) here, 57 | # relative to this directory. They are copied after the builtin static files, 58 | # so a file named "default.css" will overwrite the builtin "default.css". 59 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | .venv_one 108 | venv_one/ 109 | venv_one.bak/ 110 | 111 | # VS Code project settings 112 | .vscode 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | #.idea 130 | .idea/ 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | tests/test_path/blogs/ 135 | /pyproject.toml 136 | -------------------------------------------------------------------------------- /flask_file_upload/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-File-Upload (FFU) setup requires each SQLAlchemy model that wants to use 3 | FFU library to be decorated with ``@file_upload.Model``.This will enable FFU 4 | to update your database with the extra columns required to store 5 | files in your database. 6 | Declare your attributes as normal but assign a value of 7 | ``file_upload.Column()``. Example:: 8 | 9 | Full example:: 10 | 11 | from my_app import db, file_upload 12 | 13 | @file_upload.Model 14 | class blogModel(db.Model): 15 | __tablename__ = "blogs" 16 | id = db.Column(db.Integer, primary_key=True) 17 | my_placeholder = file_upload.Column() 18 | my_video = file_upload.Column() 19 | """ 20 | from ._model_utils import _ModelUtils 21 | 22 | 23 | def create_model(db): 24 | #: We pass the db instance here as ``_ModelUtils.get_attr_from_model`` 25 | #: requires access to the SQLAlchemy object. 26 | class Model: 27 | 28 | def __new__(cls, _class=None, *args, **kwargs): 29 | """ 30 | We create a new instance of Model with all the attributes of 31 | the wrapped SqlAlchemy Model class. this is because we cannot 32 | make a call to self.query = _class.query as this will then 33 | create a a new session (_class.query calls to a __get__ descriptor). 34 | :param _class: Is the wrapped SqlAlchemy model 35 | :param args: The first arg is the wrapped SqlAlchemy model if exists. 36 | This means the __call__ method is being called either because Model 37 | is decorating an SQLAlchemy model or it is being reference, ie calling 38 | a static method e.g. Blog.query.filter_by() 39 | :param kwargs: 40 | :return: 41 | """ 42 | if not isinstance(args, tuple): 43 | # Model is being reference 44 | instance = super(Model, cls).__new__(args[0], *args, **kwargs) 45 | else: 46 | # Model is being instantiated 47 | instance = _class 48 | new_cols = [] 49 | filenames = [] 50 | new_cols_list, filenames_list = _ModelUtils.get_attr_from_model(instance, new_cols, filenames, db) 51 | # Add new attributes to the SQLAlchemy model 52 | _ModelUtils.set_columns(instance, new_cols_list) 53 | # The original model's attributes set by the user for files get removed here 54 | _ModelUtils.remove_unused_cols(instance, filenames_list) 55 | return instance 56 | 57 | return Model 58 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Flask, request, send_from_directory, current_app 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | from flask_file_upload.file_upload import FileUpload 6 | from flask_file_upload.file_utils import FileUtils 7 | from flask_file_upload._config import Config 8 | 9 | app = Flask(__name__) 10 | app.config["SERVERNAME"] = "127.0.0.1:5000" 11 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 12 | app.config["UPLOAD_FOLDER"] = "tests/test_path" 13 | app.config["ALLOWED_EXTENSIONS"] = ["jpg", "png", "mov", "mp4", "mpg"] 14 | app.config["MAX_CONTENT_LENGTH"] = 1000 * 1024 * 1024 15 | db = SQLAlchemy(app) 16 | file_upload = FileUpload(app, db) 17 | 18 | 19 | @app.route("/config_test", methods=["POST"]) 20 | def config_test(): 21 | from tests.fixtures.models import MockBlogModel 22 | 23 | file = request.files["file"] 24 | current_app.config["UPLOAD_FOLDER"] = "tests/test_path" 25 | config = Config() 26 | config.init_config(app) 27 | 28 | file_util = FileUtils(MockBlogModel(name="test_save"), config) 29 | file_util.save_file(file, 1) 30 | 31 | return { 32 | "data": "hello" 33 | }, 200 34 | 35 | @app.route("/blogs", methods=["GET"]) 36 | def blogs(): 37 | from tests.fixtures.models import MockBlogModel 38 | 39 | blogs = MockBlogModel.get_all_blogs() 40 | 41 | results = file_upload.add_file_urls_to_models( 42 | blogs, 43 | filenames=["my_video", "my_placeholder"], 44 | backref={ 45 | "name": "news", 46 | "filenames": ["news_image", "news_video"], 47 | }) 48 | 49 | return { 50 | "results": { 51 | "my_video_url": results[0].my_video_url, 52 | "my_placeholder_url": results[0].my_placeholder_url, 53 | "my_video_url_2": results[1].my_video_url, 54 | "my_placeholder_url_2": results[1].my_placeholder_url, 55 | "news_image_url": results[0].news[0].news_image_url, 56 | "news_video_url": results[0].news[0].news_video_url, 57 | "news_image_url_2": results[0].news[1].news_image_url, 58 | "news_video_url_2": results[0].news[1].news_video_url, 59 | }, 60 | }, 200 61 | 62 | 63 | @app.route("/blog", methods=["GET", "POST"]) 64 | def blog(): 65 | from tests.fixtures.models import MockBlogModel 66 | if request.method == "GET": 67 | 68 | blog_post = MockBlogModel( 69 | id=1, 70 | name="My Blog Post", 71 | my_video__file_name="my_video.mp4", 72 | my_video__mime_type="video/mpeg", 73 | my_video__ext="mp4", 74 | ) 75 | file_upload = FileUpload(app, db) 76 | # Warning - The UPLOAD_FOLDER - only needs to be reset for testing! 77 | current_app.config["UPLOAD_FOLDER"] = "test_path" 78 | file_upload.init_app(app, db) 79 | return file_upload.stream_file(blog_post, filename="my_video") 80 | 81 | if request.method == "POST": 82 | 83 | my_video = request.files["my_video"] 84 | my_placeholder = request.files["my_placeholder"] 85 | 86 | blog_post = MockBlogModel(id=2, name="My Blog Post") 87 | 88 | file_upload = FileUpload(app, db) 89 | 90 | blog = file_upload.save_files(blog_post, files={ 91 | "my_video": my_video, 92 | "my_placeholder": my_placeholder, 93 | }) 94 | blog_data = blog_post.get_blog(2) 95 | 96 | return { 97 | "blog": f"{blog_data}" 98 | }, 200 99 | 100 | 101 | @pytest.fixture 102 | def flask_app(): 103 | from tests.fixtures.models import mock_blog_model 104 | db.init_app(app) 105 | db.create_all() 106 | return app 107 | 108 | 109 | @pytest.fixture 110 | def create_app(): 111 | from tests.fixtures.models import MockBlogModel, MockModel 112 | 113 | app.config["UPLOAD_FOLDER"] = "tests/test_path" 114 | file_upload.init_app(app, db) 115 | 116 | with app.app_context(): 117 | 118 | db.create_all() 119 | 120 | testing_client = app.test_client() 121 | 122 | ctx = app.app_context() 123 | ctx.push() 124 | 125 | yield testing_client 126 | 127 | with app.app_context(): 128 | db.session.remove() 129 | db.drop_all() 130 | 131 | ctx.pop() 132 | -------------------------------------------------------------------------------- /flask_file_upload/_model_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Behaviours required by Model class _so we can keep SqlAlchemy Model 3 | free of methods & other members. 4 | """ 5 | from typing import List, Any, Dict, Tuple, ClassVar, Callable 6 | import inspect 7 | from warnings import warn 8 | from enum import Enum 9 | 10 | from .column import Column 11 | from ._exceptions import FlaskInstanceOrSqlalchemyIsNone 12 | 13 | 14 | class _ColumnSuffix(Enum): 15 | MIME_TYPE = "mime_type" 16 | EXT = "ext" 17 | FILE_NAME = "file_name" 18 | 19 | 20 | class _ModelUtils: 21 | 22 | column_suffix = _ColumnSuffix 23 | 24 | keys = [e.value for e in _ColumnSuffix] 25 | 26 | sqlalchemy_attr: List[str] = [ 27 | '__table__', 28 | '__tablename__', 29 | '__table_args__', 30 | '__mapper_args__', 31 | # '_decl_class_registry', 32 | '_sa_class_manager', 33 | 'metadata', 34 | 'query', 35 | '__mapper__', 36 | ] 37 | 38 | @staticmethod 39 | def create_keys(keys: Tuple[str], filename: str, fn: Callable = None) -> Dict[str, None]: 40 | """ 41 | Adds the SqlAlchemy Column object with key & name kwargs defined to the returned dict 42 | :param keys: 43 | :param filename: 44 | :param fn: Generates a SqlAlchemy Column with key & name kwargs set 45 | :return: 46 | """ 47 | col_dict = {} 48 | for k in keys: 49 | key = f"{filename}__{k}" 50 | col_dict[key] = fn(key, key) 51 | return col_dict 52 | 53 | @staticmethod 54 | def get_primary_key(model): 55 | """ 56 | This will always target the first primary key in 57 | the list (in case there are multiple being used) 58 | :param model: 59 | :return str: 60 | """ 61 | try: 62 | return model.__mapper__.primary_key[0].name 63 | except AttributeError as err: 64 | raise AttributeError("[FLASK_FILE_UPLOADS_ERROR] You must pass a model instance" 65 | f"to the save_file method. Full error: {err}" 66 | ) 67 | 68 | @staticmethod 69 | def get_table_name(model: Any) -> str: 70 | """ 71 | Set on class initiation 72 | :return: None 73 | """ 74 | return model.__tablename__ 75 | 76 | @staticmethod 77 | def get_id_value(model) -> int: 78 | """ 79 | :param model: 80 | :return:s 81 | """ 82 | return getattr(model, _ModelUtils.get_primary_key(model), None) 83 | 84 | @staticmethod 85 | def columns_dict(file_name: str, db) -> Dict[str, Any]: 86 | """ 87 | We must define the SqlAlchemy Column object with key & name kwargs 88 | otherwise sqlAlchemy will define these incorrectly if they are set to None 89 | :param file_name: 90 | :param db: 91 | :return Dict[str, Any]: 92 | """ 93 | def create_col(key, name): 94 | """ 95 | issue #58 - Increases the string length to 1000 96 | """ 97 | str_len = int(len(key)) + 1000 98 | if not isinstance(str_len, int): 99 | str_len = 1000 100 | try: 101 | return db.Column(db.String(str_len), key=key, name=name) 102 | except AttributeError as err: 103 | raise FlaskInstanceOrSqlalchemyIsNone(err) 104 | return _ModelUtils.create_keys( 105 | _ModelUtils.keys, 106 | file_name, 107 | create_col 108 | ) 109 | 110 | @staticmethod 111 | def set_columns(wrapped: ClassVar, new_cols: Tuple[Dict[str, Any]]) -> None: 112 | """ 113 | Sets related file data to a SqlAlchemy Model 114 | :return: 115 | """ 116 | for col_dict in new_cols: 117 | for k, v in col_dict.items(): 118 | setattr(wrapped, k, v) 119 | 120 | @staticmethod 121 | def remove_unused_cols(wrapped: ClassVar, filenames: Tuple[str]) -> None: 122 | """ 123 | Removes the original named attributes 124 | (this could be a good place to store 125 | metadata in a dict for example...) 126 | :return: 127 | """ 128 | for col_name in filenames: 129 | delattr(wrapped, col_name) 130 | 131 | @staticmethod 132 | def get_attr_from_model(wrapped: ClassVar, new_cols: List, file_names: List, db: Any) -> Any: 133 | """ 134 | Adds values to new_cols & file_names so as not to 135 | change the size of the dict at runtime 136 | :return None: 137 | """ 138 | for attr, value in wrapped.__dict__.items(): 139 | if isinstance(value, Column): 140 | new_cols.append(_ModelUtils.columns_dict(attr, db)) 141 | file_names.append(str(attr)) 142 | return new_cols, file_names 143 | 144 | @staticmethod 145 | def add_postfix(filename: str, postfix: str) -> str: 146 | """ 147 | :param filename: 148 | :param postfix: 149 | :return str: 150 | """ 151 | return f"{filename}__{postfix}" 152 | 153 | @staticmethod 154 | def get_original_file_name(filename: str, model: Any) -> str: 155 | """ 156 | :param filename: Werkzueg's file.filename value 157 | :param model: 158 | :return: the filename from the db e.g. *"my_video.mp4"* 159 | """ 160 | return getattr(model, f"{filename}__file_name", None) 161 | 162 | @staticmethod 163 | def get_by_postfix(model: ClassVar, filename: str, postfix: str) -> str: 164 | """ 165 | :param model: 166 | :param filename: 167 | :param postfix: 168 | :return str: 169 | """ 170 | return getattr(model, _ModelUtils.add_postfix(filename, postfix)) 171 | 172 | @staticmethod 173 | def commit_session(db, model: Any, commit: bool = True) -> Any: 174 | """Commit changes to current session if exists""" 175 | if db and commit: 176 | try: 177 | current_session = db.session.object_session(model) or db.session 178 | current_session.add(model) 179 | current_session.commit() 180 | except AttributeError as err: 181 | raise AttributeError( 182 | "[FLASK_FILE_UPLOAD_ERROR]: You must pass the SQLAlchemy" 183 | f" instance (db) to FileUpload(). Full Error: {err}" 184 | ) 185 | else: 186 | warn( 187 | "Flask-File-Upload: Make sure to add & commit these changes. " 188 | "We recommended using the file_upload.add_files method instead, For examples visit: " 189 | "https://flask-file-upload.readthedocs.io/en/latest/file_upload.html" 190 | ) 191 | 192 | return model 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Upload Python Package](https://github.com/joegasewicz/flask-file-upload/actions/workflows/python-publish.yml/badge.svg)](https://github.com/joegasewicz/flask-file-upload/actions/workflows/python-publish.yml) 2 | [![Python application](https://github.com/joegasewicz/flask-file-upload/actions/workflows/python-app.yml/badge.svg)](https://github.com/joegasewicz/flask-file-upload/actions/workflows/python-app.yml) 3 | [![Documentation Status](https://readthedocs.org/projects/flask-file-upload/badge/?version=latest)](https://flask-file-upload.readthedocs.io/en/latest/?badge=latest) 4 | [![PyPI version](https://badge.fury.io/py/flask-file-upload.svg)](https://badge.fury.io/py/flask-file-upload) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flask-file-upload) 6 | ![FlaskFileUpload](assets/logo.png?raw=true "Title") 7 | 8 | Library that works with Flask (version 1 or 2) and SqlAlchemy to store 9 | files on your server & in your database 10 | 11 | Read the docs: [Documentation](https://flask-file-upload.readthedocs.io/en/latest/) 12 | 13 | ## Installation 14 | Please install the latest release: 15 | ```bash 16 | pip install flask-file-upload 17 | ``` 18 | 19 | *If you are updating from >=0.1 then please read the [upgrading instruction](https://github.com/joegasewicz/flask-file-upload#upgrading-from-v01-to-v02)* 20 | 21 | #### General Flask config options 22 | (Important: The below configuration variables need to be set before initiating `FileUpload`) 23 | ````python 24 | from flask_file_upload.file_upload import FileUpload 25 | from os.path import join, dirname, realpath 26 | 27 | # This is the directory that flask-file-upload saves files to. Make sure the UPLOAD_FOLDER is the same as Flasks's static_folder or a child. For example: 28 | app.config["UPLOAD_FOLDER"] = join(dirname(realpath(__file__)), "static/uploads") 29 | 30 | # Other FLASK config varaibles ... 31 | app.config["ALLOWED_EXTENSIONS"] = ["jpg", "png", "mov", "mp4", "mpg"] 32 | app.config["MAX_CONTENT_LENGTH"] = 1000 * 1024 * 1024 # 1000mb 33 | app.config["SQLALCHEMY_DATABASE_URI"] = "postgresql://localhost:5432/blog_db" 34 | ```` 35 | 36 | #### Setup 37 | We can either pass the instance to FileUpload(app) or to the init_app(app) method: 38 | ````python 39 | from flask_file_upload import FileUpload 40 | 41 | 42 | app = Flask(__name__, static_folder="static") # IMPORTANT: This is your root directory for serving ALL static content! 43 | 44 | db = SQLAlchemy() 45 | 46 | file_upload = FileUpload() 47 | 48 | # An example using the Flask factory pattern 49 | def create_app(): 50 | db.init_app(app) 51 | # Pass the Flask app instance as the 1st arg & 52 | # the SQLAlchemy object as the 2nd arg to file_upload.init_app. 53 | file_upload.init_app(app, db) 54 | 55 | # If you require importing your SQLAlchemy models then make sure you import 56 | # your models after calling `file_upload.init_app(app, db)` or `FileUpload(app, db)`. 57 | from .model import * 58 | 59 | # Or we can pass the Flask app instance directly & the Flask-SQLAlchemy instance: 60 | db = SQLAlchemy(app) 61 | # Pass the Flask app instance as the 1st arg & 62 | # the SQLAlchemy object as the 2nd arg to FileUpload 63 | file_upload = FileUpload(app, db) 64 | app: Flask = None 65 | ```` 66 | 67 | #### Decorate your SqlAlchemy models 68 | Flask-File-Upload (FFU) setup requires each SqlAlchemy model that wants to use FFU 69 | library to be decorated with `@file_upload.Model` .This will enable FFU to update your 70 | database with the extra columns required to store files in your database. 71 | Declare your attributes as normal but assign a value of `file_upload.Column`. 72 | This is easy if you are using Flask-SqlAlchemy: 73 | ```python 74 | from flask_sqlalchemy import SqlAlchemy 75 | 76 | db = SqlAlchemy() 77 | ``` 78 | Full example: 79 | ````python 80 | from my_app import file_upload 81 | 82 | @file_upload.Model 83 | class blogModel(db.Model): 84 | __tablename__ = "blogs" 85 | id = db.Column(db.Integer, primary_key=True) 86 | 87 | # Use flask-file-upload's `file_upload.Column()` to associate a file with a SQLAlchemy Model: 88 | my_placeholder = file_upload.Column() 89 | my_video = file_upload.Column() 90 | ```` 91 | 92 | #### define files to be uploaded: 93 | ````python 94 | # A common scenario could be a video with placeholder image. 95 | # So first lets grab the files from Flask's request object: 96 | my_video = request.files["my_video"] 97 | placeholder_img = request.files["placeholder_img"] 98 | ```` 99 | 100 | 101 | #### Save files 102 | To add files to your model, pass a dict of keys that reference the attribute 103 | name(s) defined in your SqlAlchemy model & values that are your files. 104 | For Example: 105 | 106 | ````python 107 | file_upload.add_files(blog_post, files={ 108 | "my_video": my_video, 109 | "placeholder_img": placeholder_img, 110 | }) 111 | 112 | # Now commit the changes to your db 113 | db.session.add(blog_post) 114 | db.session.commit() 115 | ```` 116 | It's always good practise to commit the changes to your db as close to the end 117 | of your view handlers as possible (we encourage you to use `add_files` over the `save_files` 118 | method for this reason). 119 | 120 | If you wish to let flask-file-upload handle adding & committing to 121 | the current session then use `file_upload.save_files` - this method is only recommended 122 | if you are sure nothing else needs committing after you have added you files. 123 | For example: 124 | ```python 125 | file_upload.save_files(blog_post, files={ 126 | "my_video": my_video, 127 | "placeholder_img": placeholder_img, 128 | }) 129 | ``` 130 | ##### If you followed the setup above you will see the following structure saved to your app: 131 | ![FlaskFileUpload](assets/dir1.png?raw=true "Directory example") 132 | 133 | #### Update files 134 | ````python 135 | blog_post = file_upload.update_files(blog_post, files={ 136 | "my_video": new_my_video, 137 | "placeholder_img": new_placeholder_img, 138 | }) 139 | ```` 140 | 141 | 142 | #### Delete files 143 | 144 | Deleting files from the db & server can be non trivial, especially to keep 145 | both in sync. The `file_upload.delete_files` method can be called with a 146 | kwarg of `clean_up` & then depending of the string value passed it will 147 | provide 2 types of clean up functionality: 148 | - `files` will clean up files on the server but not update the model 149 | - `model` will update the model but not attempt to remove the files 150 | from the server. 151 | See [delete_files Docs](https://flask-file-upload.readthedocs.io/en/latest/file_upload.html#flask_file_upload.file_upload.FileUpload.delete_files) 152 | for more details 153 | ````python 154 | # Example using a SqlAlchemy model with an appended 155 | # method that fetches a single `blog` 156 | blogModel = BlogModel() 157 | blog_results = blogModel.get_one() 158 | 159 | # We pass the blog & files 160 | blog = file_upload.delete_files(blog_result, files=["my_video"]) 161 | 162 | # If parent kwarg is set to True then the root primary directory & all its contents will be removed. 163 | # The model will also get cleaned up by default unless set to `False`. 164 | blog_result = file_upload.delete_files(blog_result, parent=True, files=["my_video"]) 165 | 166 | 167 | # If the kwarg `commit` is not set or set to True then the updates are persisted. 168 | # to the session. And therefore the session has been commited. 169 | blog = file_upload.delete_files(blog_result, files=["my_video"]) 170 | 171 | # Example of cleaning up files but not updating the model: 172 | blog = file_upload.delete_files(blog_result, files=["my_video"], clean_up="files") 173 | ```` 174 | 175 | 176 | #### Stream a file 177 | ````python 178 | file_upload.stream_file(blog_post, filename="my_video") 179 | ```` 180 | 181 | 182 | #### File Url paths 183 | ````python 184 | file_upload.get_file_url(blog_post, filename="placeholder_img") 185 | ```` 186 | 187 | Example for getting file urls from many objects: 188 | ```python 189 | # If blogs_model are many blogs: 190 | for blog in blog_models: 191 | blog_image_url = file_upload.get_file_url(blog, filename="blog_image") 192 | setattr(blog, "blog_image", blog_image_url) 193 | ``` 194 | 195 | #### Set file paths to multiple objects - *Available in `0.1.0-rc.6` & `v0.1.0`* 196 | The majority of requests will require many entities to be returned 197 | & these entities may have SQLAlchemy `backrefs` with 198 | relationships that may also contain Flask-File-Upload (FFU) modified SQLAlchemy 199 | models. To make this trivial, this method will set the appropriate 200 | filename urls to your SQLAlchemy model objects (if the transaction 201 | hasn't completed then **add_file_urls_to_models** will complete the 202 | transaction by default). 203 | 204 | The first argument required by this method is `models` - the SQLAlchemy model(s). 205 | 206 | Then pass in the required kwarg `filenames` which references the parent's 207 | FFU Model values - this is the `file_upload.Model` decorated SQLALchemy model 208 | - `file_upload.Column()` method. 209 | 210 | Important! Also take note that each attribute set by this method postfixes 211 | a `_url` tag. e.g `blog_image` becomes `blog_image_url` 212 | 213 | Example for many SQLAlchemy entity objects (*or rows in your table*):: 214 | ```python 215 | @file_upload.Model 216 | class BlogModel(db.Model): 217 | 218 | blog_image = file_upload.Column() 219 | ``` 220 | 221 | Now we can use the `file_upload.add_file_urls_to_models` to add file urls to 222 | each SQLAlchemy object. For example:: 223 | ```python 224 | blogs = add_file_urls_to_models(blogs, filenames="blog_image") 225 | 226 | # Notice that we can get the file path `blog_image` + `_url` 227 | assert blogs[0].blog_image_url == "path/to/blogs/1/blog_image_url.png" 228 | ``` 229 | 230 | To set filename attributes to a a single or multiple SQLAlchemy parent models with backrefs 231 | to multiple child SQLAlchemy models, we can assign to the optional `backref` 232 | kwarg the name of the backref model & a list of the file attributes we set 233 | with the FFU Model decorated SQLAlchemy model. 234 | 235 | To use backrefs we need to declare a kwarg of `backref` & pass 2 keys: 236 | - **name**: The name of the backref relation 237 | - **filenames**: The FFU attribute values assigned to the backref model 238 | 239 | For example:: 240 | ```python 241 | # Parent model 242 | @file_upload.Model 243 | class BlogModel(db.Model): 244 | # The backref: 245 | blog_news = db.relationship("BlogNewsModel", backref="blogs") 246 | blog_image = file_upload.Column() 247 | blog_video = file_upload.Column() 248 | 249 | # Model that has a foreign key back up to `BlogModel 250 | @file_upload.Model 251 | class BlogNewsModel(db.Model): 252 | # The foreign key assigned to this model: 253 | blog_id = db.Column(db.Integer, db.ForeignKey("blogs.blog_id")) 254 | news_image = file_upload.Column() 255 | news_video = file_upload.Column() 256 | ``` 257 | 258 | The kwarg `backref` keys represent the backref model or entity (in the above example 259 | this would be the `BlogNewsModel` which we have named `blog_news`. Example:: 260 | ```python 261 | blogs = add_file_urls_to_models(blogs, filenames=["blog_image, blog_video"], 262 | backref={ 263 | "name": "blog_news",` 264 | "filenames": ["news_image", "news_video], 265 | }) 266 | ``` 267 | 268 | WARNING: You must not set the relationship kwarg: `lazy="dynamic"`! 269 | If `backref` is set to *"dynamic"* then back-referenced entity's 270 | filenames will not get set. Example:: 271 | ```python 272 | # This will work 273 | blog_news = db.relationship("BlogNewsModel", backref="blog") 274 | 275 | # this will NOT set filenames on your model class 276 | blog_news = db.relationship("BlogNewsModel", backref="blog", lazy="dynamic") 277 | ``` 278 | 279 | ### Running Flask-Migration After including Flask-File-Upload in your project 280 | The arguments below will also run if you're using vanilla Alembic. 281 | ```bash 282 | export FLASK_APP=flask_app.py # Path to your Flask app 283 | 284 | # with pip 285 | flask db stamp head 286 | flask db migrate 287 | flask db upgrade 288 | 289 | # with pipenv 290 | pipenv run flask db stamp head 291 | pipenv run flask db migrate 292 | pipenv run flask db upgrade 293 | ``` 294 | 295 | ### Upgrading from v0.1 to v0.2 296 | You will need to create a migration script with the below column name changes: 297 | - `[you_file_name]__file_type` becomes `[you_file_name]__mime_type` 298 | - `[you_file_name]__mime_type` becomes `[you_file_name]__ext` 299 | - `[you_file_name]__file_name` stays the same 300 | -------------------------------------------------------------------------------- /tests/test_file_upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from flask import Flask, current_app 4 | from werkzeug.datastructures import FileStorage 5 | from shutil import copyfile 6 | from flask_sqlalchemy import SQLAlchemy 7 | import time 8 | import shutil 9 | 10 | from flask_file_upload._config import Config 11 | from flask_file_upload.file_upload import FileUpload 12 | from tests.fixtures.models import mock_blog_model, mock_model, mock_news_model 13 | from tests.app import create_app, flask_app, db, file_upload, app 14 | from tests.fixtures.files import video_file, png_file 15 | 16 | 17 | class TestFileUploads: 18 | 19 | my_video = os.path.join("tests/assets/my_video.mp4") 20 | my_video_update = os.path.join("tests/assets/my_video_update.mp4") 21 | my_placeholder = os.path.join("tests/assets/my_placeholder.png") 22 | 23 | dest_my_video = os.path.join("tests/test_path/blogs/1/my_video.mp4") 24 | dest_my_video_update = "tests/test_path/blogs/1/my_video_updated.mp4" 25 | dest_my_placeholder = "tests/test_path/blogs/1/my_placeholder.png" 26 | 27 | attrs = { 28 | "id": 1, 29 | "name": "test_name", 30 | "my_video__file_name": "my_video.mp4", 31 | "my_video__mime_type": "video/mpeg", 32 | "my_video__ext": "mp4", 33 | "my_placeholder__file_name": "my_placeholder.png", 34 | "my_placeholder__mime_type": "image/png", 35 | "my_placeholder__ext": "jpg", 36 | } 37 | 38 | file_data = [ 39 | { 40 | "my_video__file_name": "my_video.mp4", 41 | "my_video__mime_type": "video/mpeg", 42 | "my_video__ext": "mp4", 43 | }, 44 | { 45 | "my_placeholder__file_name": "my_placeholder.png", 46 | "my_placeholder__mime_type": "image/png", 47 | "my_placeholder__ext": "jpg", 48 | } 49 | ] 50 | 51 | def setup_method(self): 52 | # Copy files from asset dir to test dir here: 53 | app.config["UPLOAD_FOLDER"] = "tests/test_path" 54 | blog_path = "tests/test_path/blogs/1" 55 | if not os.path.exists(blog_path): 56 | os.mkdir(blog_path) 57 | 58 | copyfile("tests/assets/my_video.mp4", "tests/test_path/blogs/1/my_video.mp4") 59 | 60 | def teardown_method(self): 61 | # Delete the files from the test dir here: 62 | try: 63 | shutil.rmtree("tests/test_path/blogs/1") 64 | shutil.rmtree("tests/test_path/blogs/2") 65 | shutil.rmtree("tests/test_path/blogs/None") 66 | except: 67 | pass 68 | 69 | def test_add_file_urls_to_models(self, create_app, mock_blog_model, mock_news_model): 70 | db.init_app(app) 71 | db.create_all() 72 | file_upload = FileUpload() 73 | file_upload.init_app(app, db) 74 | 75 | blog1 = mock_blog_model( 76 | name="hello", 77 | my_video__file_name="my_video.mp4", 78 | my_video__mime_type="video/mpeg", 79 | my_video__ext="mp4", 80 | my_placeholder__file_name="my_placeholder1.png", 81 | my_placeholder__mime_type="image/png", 82 | my_placeholder__ext="png", 83 | ) 84 | blog2 = mock_blog_model( 85 | name="hello2", 86 | my_video__file_name="my_video2.mp4", 87 | my_video__mime_type="video/mpeg", 88 | my_video__ext="mp4", 89 | my_placeholder__file_name="my_placeholder2.png", 90 | my_placeholder__mime_type="image/png", 91 | my_placeholder__ext="png", 92 | ) 93 | 94 | mock_news_model(title="news_1", blog_id=1) 95 | 96 | db.session.add_all([ 97 | blog1, 98 | blog2, 99 | mock_news_model( 100 | title="news_1", 101 | blog_id=1, 102 | news_video__file_name="news_video1.mp4", 103 | news_video__mime_type="video/mpeg", 104 | news_video__ext="mp4", 105 | news_image__file_name="news_image.png", 106 | news_image__mime_type="image/png", 107 | news_image__ext="png", 108 | ), 109 | mock_news_model( 110 | title="news_2", 111 | blog_id=1, 112 | news_video__file_name="news_video2.mp4", 113 | news_video__mime_type="video/mpeg", 114 | news_video__ext="mp4", 115 | news_image__file_name="news_image.png", 116 | news_image__mime_type="image/png", 117 | news_image__ext="png", 118 | ) 119 | ]) 120 | 121 | db.session.commit() 122 | 123 | rv = create_app.get("/blogs") 124 | assert "200" in rv.status 125 | 126 | assert rv.get_json()["results"]["my_video_url"] == "http://localhost/static/blogs/1/my_video.mp4" 127 | assert rv.get_json()["results"]["my_video_url_2"] == "http://localhost/static/blogs/2/my_video2.mp4" 128 | assert rv.get_json()["results"]["my_placeholder_url"] == "http://localhost/static/blogs/1/my_placeholder1.png" 129 | assert rv.get_json()["results"]["my_placeholder_url_2"] == "http://localhost/static/blogs/2/my_placeholder2.png" 130 | assert rv.get_json()["results"]["news_image_url"] == "http://localhost/static/news/1/news_image.png" 131 | assert rv.get_json()["results"]["news_image_url_2"] == "http://localhost/static/news/2/news_image.png" 132 | assert rv.get_json()["results"]["news_video_url"] == "http://localhost/static/news/1/news_video1.mp4" 133 | assert rv.get_json()["results"]["news_video_url_2"] == "http://localhost/static/news/2/news_video2.mp4" 134 | 135 | 136 | def test_init_app(self, create_app, mock_blog_model, flask_app): 137 | 138 | file_upload = FileUpload() 139 | file_upload.init_app(flask_app, db) 140 | assert isinstance(file_upload.app, Flask) 141 | 142 | def test_set_model_attrs(self, mock_model): 143 | file_upload = FileUpload() 144 | file_upload.file_data = self.file_data 145 | file_upload._set_model_attrs(mock_model) 146 | 147 | assert mock_model.my_video__file_name == "my_video.mp4" 148 | assert mock_model.my_video__mime_type == "video/mpeg" 149 | assert mock_model.my_video__ext == "mp4" 150 | assert mock_model.my_placeholder__file_name == "my_placeholder.png" 151 | assert mock_model.my_placeholder__mime_type == "image/png" 152 | assert mock_model.my_placeholder__ext == "jpg" 153 | 154 | with pytest.raises(AttributeError): 155 | file_upload.file_data[0]["bananas"] = "bananas" 156 | file_upload._set_model_attrs(mock_model) 157 | 158 | def test_stream_file(self, create_app): 159 | rv = create_app.get("/blog") 160 | assert "200" in rv.status 161 | 162 | def test_get_file_url(self, mock_blog_model): 163 | db.init_app(app) 164 | db.create_all() 165 | file_upload = FileUpload() 166 | file_upload.init_app(app, db) 167 | m = mock_blog_model(**self.attrs) 168 | with app.test_request_context(): 169 | url = file_upload.get_file_url(m, filename="my_video") 170 | assert url == "http://localhost/static/blogs/1/my_video.mp4" 171 | 172 | with app.test_request_context(): 173 | file_upload.config.upload_folder = "static/uploads" 174 | url = file_upload.get_file_url(m, filename="my_video") 175 | assert url == "http://localhost/static/uploads/blogs/1/my_video.mp4" 176 | 177 | 178 | def test_update_files(self, create_app, mock_blog_model): 179 | m = mock_blog_model( 180 | name="hello", 181 | my_video__file_name="my_video.mp4", 182 | my_video__mime_type="video/mpeg", 183 | my_video__ext="mp4", 184 | ) 185 | 186 | db.session.add(m) 187 | db.session.commit() 188 | 189 | new_file = FileStorage( 190 | stream=open(self.my_video_update, "rb"), 191 | filename="my_video_updated.mp4", 192 | content_type="video/mpeg", 193 | ) 194 | 195 | blog = m.get_blog() 196 | 197 | file_upload.update_files( 198 | blog, 199 | db, 200 | files={"my_video": new_file}, 201 | ) 202 | 203 | # Test files / dirs 204 | assert "my_video_updated.mp4" in os.listdir("tests/test_path/blogs/1") 205 | assert "my_video.mp4" not in os.listdir("tests/test_path/blogs/1") 206 | 207 | def test_delete_files(self, create_app, mock_blog_model): 208 | 209 | 210 | assert "my_video.mp4" in os.listdir("tests/test_path/blogs/1") 211 | m = mock_blog_model( 212 | name="hello", 213 | my_video__file_name="my_video.mp4", 214 | my_video__mime_type="video/mpeg", 215 | my_video__ext="mp4", 216 | ) 217 | 218 | db.session.add(m) 219 | db.session.commit() 220 | 221 | blog = m.get_blog() 222 | 223 | assert getattr(blog, "my_video__file_name") == "my_video.mp4" 224 | assert getattr(blog, "my_video__mime_type") == "video/mpeg" 225 | assert getattr(blog, "my_video__ext") == "mp4" 226 | 227 | file_upload.delete_files(blog, db, files=["my_video"]) 228 | 229 | db.session.add(m) 230 | db.session.commit() 231 | 232 | result = m.get_blog() 233 | 234 | assert "my_video.mp4" not in os.listdir("tests/test_path/blogs/1") 235 | assert getattr(result, "my_video__file_name") is None 236 | assert getattr(result, "my_video__mime_type") is None 237 | assert getattr(result, "my_video__ext") is None 238 | 239 | def test_delete_files_kwargs_files(self, create_app, mock_blog_model): 240 | assert "my_video.mp4" in os.listdir("tests/test_path/blogs/1") 241 | m = mock_blog_model( 242 | name="hello", 243 | my_video__file_name="my_video.mp4", 244 | my_video__mime_type="video/mpeg", 245 | my_video__ext="mp4", 246 | ) 247 | 248 | db.session.add(m) 249 | db.session.commit() 250 | 251 | blog = m.get_blog() 252 | 253 | assert getattr(blog, "my_video__file_name") == "my_video.mp4" 254 | assert getattr(blog, "my_video__mime_type") == "video/mpeg" 255 | assert getattr(blog, "my_video__ext") == "mp4" 256 | 257 | file_upload.delete_files(blog, db, files=["my_video"], clean_up="files") 258 | 259 | db.session.add(m) 260 | db.session.commit() 261 | result = m.get_blog() 262 | 263 | assert "my_video.mp4" not in os.listdir("tests/test_path/blogs/1") 264 | assert getattr(blog, "my_video__file_name") == "my_video.mp4" 265 | assert getattr(blog, "my_video__mime_type") == "video/mpeg" 266 | assert getattr(blog, "my_video__ext") == "mp4" 267 | 268 | def test_delete_files_kwargs_model(self, create_app, mock_blog_model): 269 | assert "my_video.mp4" in os.listdir("tests/test_path/blogs/1") 270 | m = mock_blog_model( 271 | name="hello", 272 | my_video__file_name="my_video.mp4", 273 | my_video__mime_type="video/mpeg", 274 | my_video__ext="mp4", 275 | ) 276 | 277 | db.session.add(m) 278 | db.session.commit() 279 | 280 | blog = m.get_blog() 281 | 282 | assert getattr(blog, "my_video__file_name") == "my_video.mp4" 283 | assert getattr(blog, "my_video__mime_type") == "video/mpeg" 284 | assert getattr(blog, "my_video__ext") == "mp4" 285 | 286 | file_upload.delete_files(blog, db, files=["my_video"], clean_up="model") 287 | 288 | db.session.add(m) 289 | db.session.commit() 290 | result = m.get_blog() 291 | 292 | assert "my_video.mp4" in os.listdir("tests/test_path/blogs/1") 293 | assert getattr(result, "my_video__file_name") is None 294 | assert getattr(result, "my_video__mime_type") is None 295 | assert getattr(result, "my_video__ext") is None 296 | 297 | def test_delete_with_parent_true(self, create_app, mock_blog_model): 298 | assert "my_video.mp4" in os.listdir("tests/test_path/blogs/1") 299 | m = mock_blog_model( 300 | name="hello", 301 | my_video__file_name="my_video.mp4", 302 | my_video__mime_type="video/mpeg", 303 | my_video__ext="mp4", 304 | ) 305 | 306 | db.session.add(m) 307 | db.session.commit() 308 | 309 | blog = m.get_blog() 310 | 311 | assert getattr(blog, "my_video__file_name") == "my_video.mp4" 312 | assert getattr(blog, "my_video__mime_type") == "video/mpeg" 313 | assert getattr(blog, "my_video__ext") == "mp4" 314 | assert ["1"] == os.listdir("tests/test_path/blogs") 315 | assert blog.id == 1 316 | 317 | file_upload.delete_files(blog, db, parent=True, files=["my_video"]) 318 | result = os.listdir("tests/test_path/blogs") 319 | 320 | assert [] == result 321 | assert getattr(blog, "my_video__file_name") is not "my_video.mp4" 322 | assert getattr(blog, "my_video__mime_type") is not "video/mpeg" 323 | assert getattr(blog, "my_video__ext") is not "mp4" 324 | 325 | def test_update_files_2(self, mock_blog_model): 326 | 327 | db.init_app(app) 328 | db.create_all() 329 | file_upload = FileUpload() 330 | file_upload.init_app(app, db) 331 | 332 | new_file = FileStorage( 333 | stream=open(self.my_video_update, "rb"), 334 | filename="my_video_updated.mp4", 335 | content_type="video/mpeg", 336 | ) 337 | 338 | model = mock_blog_model(**self.attrs) 339 | 340 | assert model.my_video__file_name == "my_video.mp4" 341 | assert model.my_video__mime_type == "video/mpeg" 342 | assert model.my_video__ext == "mp4" 343 | 344 | result = file_upload.update_files( 345 | model, 346 | files={"my_video": new_file}, 347 | ) 348 | 349 | assert result.my_video__file_name == "my_video_updated.mp4" 350 | assert result.my_video__mime_type == "video/mpeg" 351 | assert result.my_video__ext == "mp4" 352 | 353 | def test_add_files(self, flask_app, mock_blog_model, video_file, png_file): 354 | 355 | with flask_app.test_request_context() as conn: 356 | b = mock_blog_model(name="test_name") 357 | file_upload.add_files(b, files={ 358 | "my_video": video_file, 359 | "my_placeholder": png_file, 360 | }) 361 | 362 | assert b.my_video__file_name == "my_video.mp4" 363 | assert b.my_video__mime_type == "video/mpeg" 364 | assert b.my_video__ext == "mp4" 365 | 366 | def test_save_files(self, create_app): 367 | """This test resets the upload folder so needs to be run last""" 368 | data = { 369 | "my_video": (self.my_video, "my_video.mp4"), 370 | "my_placeholder": (self.my_placeholder, "my_placeholder.png") 371 | } 372 | rv = create_app.post("/blog", data=data, content_type="multipart/form-data") 373 | assert "200" in rv.status 374 | 375 | -------------------------------------------------------------------------------- /flask_file_upload/file_upload.py: -------------------------------------------------------------------------------- 1 | """ 2 | FileUpload Class 3 | ================ 4 | """ 5 | import os 6 | import shutil 7 | from warnings import warn 8 | from flask import send_from_directory, Flask, request, url_for 9 | from werkzeug.utils import secure_filename 10 | from typing import Any, List, Dict, Union 11 | 12 | from ._config import Config 13 | from .model import create_model 14 | from .column import Column 15 | from .file_utils import FileUtils 16 | from ._model_utils import _ModelUtils 17 | 18 | 19 | class _ModelStub: 20 | 21 | def __init__(self, db=None): 22 | pass 23 | 24 | class FileUpload: 25 | """ 26 | :param app: The Flask application instance: ``app = Flask(__name__, static_folder="uploads")``. 27 | :kwargs: 28 | :key allowed_extensions: A list of accepted file types eg. ['.jpg', etc..] 29 | :key upload_folder: where the uploaded files are stored 30 | :key max_content_length: Limit the amount of file memory 31 | :key sqlalchemy_database_uri: The database URI that should be used for the connection 32 | """ 33 | 34 | #: Flask-File-Upload (**FFU**) requires Flask application configuration variables 35 | #: passed directly to the ``FileUpload`` class. This is because **FFU** needs to do some 36 | #: work on your SqlAlchemy models before the application instance is created. One 37 | #: way to make this setup dynamic, is to create your environment variables before 38 | #: creating a ``FileUpload`` instance & if any of these variables need to get 39 | #: reset dynamically when the Flask application instance is returned (*when using the 40 | #: factory-pattern*), then update them within the *create_app* function. 41 | #: 42 | #: We can then call the ``file_upload.init_app`` method & this will now receive the dynamically set 43 | #: variables. For Example:: 44 | #: 45 | #: app = Flask(__name__, static_folder="uploads") 46 | #: 47 | #: db = SQLAlchemy()` 48 | #: 49 | #: # Environment variables 50 | #: app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 51 | #: UPLOAD_FOLDER = join(dirname(realpath(__file__)), "uploads") 52 | #: ALLOWED_EXTENSIONS = ["jpg", "png", "mov", "mp4", "mpg"] 53 | #: MAX_CONTENT_LENGTH = 1000 * 1024 * 1024 # 1000mb 54 | #: SQLALCHEMY_DATABASE_URI = "postgresql://localhost:5432/blog_database" 55 | #: 56 | #: file_upload = FileUpload( 57 | #: app, 58 | #: db, 59 | #: upload_folder=UPLOAD_FOLDER, 60 | #: allowed_extensions=ALLOWED_EXTENSIONS, 61 | #: max_content_length=MAX_CONTENT_LENGTH, 62 | #: sqlalchemy_database_uri=SQLALCHEMY_DATABASE_URI, 63 | #: ) 64 | #: 65 | #: # An example using the Flask factory pattern 66 | #: def create_app(): 67 | #: 68 | #: # Dynamically set config variables: 69 | #: app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER 70 | #: app.config["ALLOWED_EXTENSIONS"] = ALLOWED_EXTENSIONS 71 | #: app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH 72 | #: app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI 73 | #: 74 | #: file_upload.init_app(app, db) 75 | app: Flask = None 76 | 77 | #: The configuration class used for this library. 78 | #: See :class:`~flask_file_upload._config` for more information. 79 | config: Config = Config() 80 | 81 | #: All the file related model attributes & values are stored 82 | #: here as a list of dicts. 83 | file_data: List[Dict[str, str]] = [] 84 | 85 | #: A record of the original filenames used when saving files 86 | #: to the server. 87 | files: Any = [] 88 | 89 | #: A class containing utility methods for working with files. 90 | #: See :class:`~flask_file_upload.file_utils` for more information. 91 | file_utils: FileUtils = None 92 | 93 | #: The Flask-SQLAlchemy `SQLAlchemy()` instance` 94 | _db = None 95 | 96 | #: See :class:`~flask_file_upload.Model` 97 | Model = _ModelStub 98 | 99 | def __init__(self, app=None, db=None, *args, **kwargs): 100 | """ 101 | :param app: The Flask application instance: ``app = Flask(__name__, static_folder="uploads")``. 102 | :param kwargs: 103 | :key allowed_extensions: A list of accepted file types eg. ['.jpg', etc..] 104 | :key upload_folder: where the uploaded files are stored 105 | :key max_content_length: Limit the amount of file memory 106 | :key sqlalchemy_database_uri: The database URI that should be used for the connection 107 | """ 108 | self.Column = Column 109 | if app and db: 110 | self.Model = create_model(db) 111 | self.init_app(app, db, **kwargs) 112 | 113 | def add_file_urls_to_models(self, models, **kwargs): 114 | """ 115 | The majority of requests will require many entities to be returned 116 | & these entities may have SQLAlchemy `backrefs` & these back reference 117 | relationships may also contain Flask-File-Upload (FFU) modified SQLAlchemy 118 | models. To make this trivial, this method will set the appropriate 119 | filename urls to to your SQLAlchemy model object (if the transaction 120 | hasn't completed then **add_file_urls_to_models** will complete the 121 | transaction by default). 122 | 123 | The the only argument is `models` the SQLAlchemy model (which is is 124 | normally many but can be a single item from your table. 125 | 126 | Then pass in the required kwarg `filenames` which references the parent(s) 127 | FFU Model values - this is the `file_upload.Model` decorated SQLALchemy model 128 | - `file_upload.Column()` method. 129 | 130 | Important! Also take note that each attribute set by this method postfixes 131 | a `_url` tag. e.g `blog_image` becomes `blog_image_url` 132 | 133 | Example for many SQLAlchemy entity objects (*or rows in your table*):: 134 | 135 | @file_upload.Model 136 | class BlogModel(db.Model): 137 | 138 | blog_image = file_upload.Column() 139 | 140 | Now we can use the `file_upload.add_file_urls_to_models` to add file urls to 141 | each SQLAlchemy object. For example:: 142 | 143 | blogs = add_file_urls_to_models(blogs, filenames="blog_image") 144 | 145 | # Notice that we can get the file path `blog_image` + `_url` 146 | assert blogs[0].blog_image_url == "path/to/blogs/1/blog_image.png" 147 | 148 | To set filename attributes to a single or multiple SQLAlchemy parent models with backrefs 149 | to multiple child SQLAlchemy models, we can assign to the optional `backref` 150 | kwarg the name of the backref model & a list of the file attributes we set 151 | with the FFU Model decorated SQLAlchemy model. 152 | 153 | To use backrefs we need to declare a kwarg of `backref` & pass 2 keys: 154 | - **name**: The name of the backref relation 155 | - **filenames**: The FFU attribute values assigned to the backref model 156 | 157 | For example:: 158 | 159 | # Parent model 160 | @file_upload.Model 161 | class BlogModel(db.Model): 162 | # The backref: 163 | blog_news = db.relationship("BlogNewsModel", backref="blogs") 164 | blog_image = file_upload.Column() 165 | blog_video = file_upload.Column() 166 | 167 | # Model that has a foreign key back up to `BlogModel 168 | @file_upload.Model 169 | class BlogNewsModel(db.Model): 170 | # The foreign key assigned to this model: 171 | blog_id = db.Column(db.Integer, db.ForeignKey("blogs.blog_id")) 172 | news_image = file_upload.Column() 173 | news_video = file_upload.Column() 174 | 175 | The kwarg `backref` keys represent the backref model or entity (in the above example 176 | this would be the `BlogNewsModel` which we have named `blog_news`. Example:: 177 | 178 | blogs = add_file_urls_to_models(blogs, filenames=["blog_image, blog_video"], 179 | backref={ 180 | "name": "blog_news",` 181 | "filenames": ["news_image", "news_video], 182 | }) 183 | 184 | WARNING: You must not set the relationship kwarg: `lazy="dynamic"`! 185 | If `backref` is set to *"dynamic"* then back-referenced entity's 186 | filenames will not get set. Example:: 187 | 188 | # This will work 189 | blog_news = db.relationship("BlogNewsModel", backref="blog") 190 | 191 | # this will NOT set filenames on your model class 192 | blog_news = db.relationship("BlogNewsModel", backref="blog", lazy="dynamic") 193 | 194 | 195 | :param models: SQLAlchemy models (this must be many entities) 196 | :key filename: The Parent models. This can be a single string or a list of strings 197 | :kwargs backref: 198 | - **name**: The name of the backref relation 199 | - **filenames**: The FFU attribute value assigned to this model This can be a \ 200 | single string or a list of strings 201 | 202 | :return: A list or nested list of SQLAlchemy Model objects. 203 | """ 204 | filenames = kwargs.get("filenames") 205 | backref = kwargs.get("backref") 206 | backref_name = None 207 | backref_filenames = None 208 | is_list = True 209 | if backref: 210 | try: 211 | backref_name = backref["name"] 212 | backref_filenames = backref["filenames"] 213 | if not isinstance(filenames, list): 214 | filenames = [filenames] 215 | if not isinstance(backref_filenames, list): 216 | backref_filenames = [backref_filenames] 217 | except TypeError: 218 | raise TypeError( 219 | "Flask-File_Upload Error: If `backref` kwarg is declared " 220 | "then you must include `filenames` & `name` keys. See " 221 | "https://github.com/joegasewicz/flask-file-upload" 222 | ) 223 | 224 | _models = [] 225 | try: 226 | _models = models.all() 227 | except: 228 | if isinstance(models, list): 229 | _models = models 230 | else: 231 | is_list = False 232 | _models.append(models) 233 | if not backref: 234 | for model in _models: 235 | for filename in filenames: 236 | model_img_url = self.get_file_url(model, filename=filename) 237 | setattr(model, f"{filename}_url", model_img_url) 238 | if not is_list: 239 | return _models[0] 240 | else: 241 | return _models 242 | else: 243 | for model in _models: 244 | for filename in filenames: 245 | model_img_url = self.get_file_url(model, filename=filename) 246 | setattr(model, f"{filename}_url", model_img_url) 247 | backref_models = getattr(model, backref_name) 248 | for backref_filename in backref_filenames: 249 | if backref and backref_models: 250 | for br_model in backref_models: 251 | br_model_img_url = self.get_file_url(br_model, filename=backref_filename) 252 | setattr(br_model, f"{backref_filename}_url", br_model_img_url) 253 | if not is_list: 254 | return _models[0] 255 | else: 256 | return _models 257 | 258 | def delete_files(self, model: Any, db=None, **kwargs) -> Union[Any, None]: 259 | """ 260 | Public method for removing stored files from the server & database. 261 | This method will remove all files passed to the kwarg ``files`` list 262 | from the server. It will also update the passed in SqlAlchemy ``model`` 263 | object & return the updated model. 264 | 265 | If `commit` kwarg has not been set then the session is updated & session commited & 266 | this method returns the current updated model 267 | 268 | *If a current session exists then Flask-File-Upload will use this before attempting to 269 | create a new session*. 270 | Example:: 271 | 272 | # Example using a SqlAlchemy model with an appended 273 | # method that fetches a single `blog` 274 | blogModel = BlogModel() 275 | blog_results = blogModel.get_one() 276 | 277 | # We pass the blog & files 278 | blog = file_upload.delete_files(blog_result, commit=False, files=["my_video"]) 279 | 280 | # As the `commit` kwarg has been set to False, 281 | # the changes would need persisting to the database: 282 | db.session.add(blog) 283 | db.session.commit() 284 | 285 | # If `commit` kwarg is not set (default is True) then the updates are persisted. 286 | # to the session. And therefore the session has been commited. 287 | blog = file_upload.delete_files(blog_result, files=["my_video"]) 288 | 289 | 290 | :param model: Instance of a SqlAlchemy Model 291 | :key files: A list of the file names declared on your model. 292 | :key commit: Default is set to True. If set to False then the changed to the 293 | model class will not be updated or commited. 294 | :key parent: Default is set to False. If set to True then the root primary directory 295 | & all its contents will be removed. The model will also get cleaned up by default 296 | unless set to `False`. 297 | ``files`` is required, see above. For example:: 298 | 299 | # This will also set ``clean_up`` to ``model``(see the ``clean_up`` kwarg below). 300 | blog_result = file_upload.delete_files(blog_result, parent=True, files=["my_video"]) 301 | 302 | 303 | :key clean_up: Default is None. There are 2 possible ``clean_up`` values 304 | you can pass to the ``clean_up`` kwarg: 305 | - ``files`` will clean up files on the server but not update the model 306 | - ``model`` will update the model but not attempt to remove the files 307 | from the server. 308 | 309 | Example:: 310 | 311 | # To clean up files on your server pass in the args as follows: 312 | file_upload.delete_files(blog_result, files=["my_video"], clean_up="files") 313 | 314 | # To clean up the model pass in the kargs as follows: 315 | file_upload.delete_files(blog_result, files=["my_video"], clean_up="model") 316 | 317 | The root directory (*The directory containing the files*) which is named after the model 318 | id, is never deleted. Only the files within this directory are removed from the server. 319 | :return: SqlAlchemy model object 320 | """ 321 | 322 | if db: 323 | warn( 324 | DeprecationWarning( 325 | "FLASK-FILE-UPLOAD: Passing `db` as a second argument to `update_files` method. " 326 | "is now not required. The second argument to `update_files` method will be " 327 | "removed in version v0.1.0" 328 | ) 329 | ) 330 | 331 | self.file_utils = FileUtils(model, self.config) 332 | 333 | clean_up = kwargs.get("clean_up") 334 | commit = kwargs.get("commit") or True 335 | parent = kwargs.get("parent") or False 336 | 337 | primary_key = _ModelUtils.get_primary_key(model) 338 | model_id = getattr(model, primary_key, None) 339 | 340 | try: 341 | files: List[str] = kwargs["files"] 342 | except KeyError: 343 | raise("'files' is a Required Argument!") 344 | 345 | if parent: 346 | file_path = self.file_utils.get_stream_path(model_id) 347 | shutil.rmtree(file_path) 348 | elif clean_up is None or clean_up is "files": 349 | for f in files: 350 | original_filename = _ModelUtils.get_original_file_name(f, model) 351 | file_path = f"{self.file_utils.get_stream_path(model_id)}/{original_filename}" 352 | os.remove(f"{file_path}") 353 | 354 | if clean_up is None or clean_up is "model": 355 | for f_name in files: 356 | for postfix in _ModelUtils.keys: 357 | setattr(model, _ModelUtils.add_postfix(f_name, postfix), None) 358 | return _ModelUtils.commit_session(self.db, model, commit) 359 | else: 360 | return model 361 | 362 | def _check_attrs(self, model: Any, attr: str): 363 | """ 364 | Before we can set the attribute on the Model we check 365 | that this attributes exists so it matches with the 366 | db's table columns. 367 | :param model: 368 | :param attr: 369 | :return: 370 | """ 371 | if not hasattr(model, attr): 372 | raise AttributeError( 373 | f"Flask-File-Upload: Attribute {attr} does not exist on your model, " 374 | "please check your files has been declared correctly on your model. " 375 | "See https://github.com/joegasewicz/Flask-File-Upload" 376 | ) 377 | 378 | def _clean_up(self, error) -> None: 379 | """Clean list data & state""" 380 | self.files.clear() 381 | self.file_data.clear() 382 | 383 | def _create_file_dict(self, file, attr_name: str): 384 | """ 385 | :param file: 386 | :param attr_name: 387 | :return: 388 | """ 389 | if file.filename != "" and file and FileUtils.allowed_file(file.filename, self.config): 390 | filename = file.filename 391 | filename_key = attr_name 392 | mime_type = file.content_type 393 | file_ext = file.filename.split(".")[1] 394 | return { 395 | f"{filename_key}__{_ModelUtils.column_suffix.FILE_NAME.value}": filename, 396 | f"{filename_key}__{_ModelUtils.column_suffix.MIME_TYPE.value}": mime_type, 397 | f"{filename_key}__{_ModelUtils.column_suffix.EXT.value}": file_ext, 398 | } 399 | else: 400 | warn("Flask-File-Upload: No files were saved") 401 | return {} 402 | 403 | def get_file_url(self, model: Any, **kwargs) -> str: 404 | """ 405 | Returns the url path to a file on your server. 406 | To access the file, it must reference the attribute name 407 | defined on your SqlAlchemy model. For example:: 408 | 409 | @file_upload.Model 410 | class ModelTest(db.Model): 411 | 412 | my_video = file_upload.Column() 413 | 414 | To return the url of the above file, pass in attribute name 415 | to the ``filename`` kwarg. Example:: 416 | 417 | file_upload.get_file_url(blog_post, filename="my_video") 418 | 419 | :param model: 420 | :param kwargs: 421 | :return: 422 | """ 423 | try: 424 | filename = kwargs["filename"] 425 | self.file_utils = FileUtils(model, self.config) 426 | 427 | primary_key = _ModelUtils.get_primary_key(model) 428 | model_id = getattr(model, primary_key, None) 429 | file_name = _ModelUtils.get_by_postfix(model, filename, "file_name") 430 | file_path = self.file_utils.postfix_file_path(model_id, file_name) 431 | 432 | url_root = request.url_root 433 | if url_root[-1] == "/": 434 | url_root = url_root[:-1] 435 | 436 | upload_folder_list = self.config.upload_folder.split("static") 437 | if len(upload_folder_list) == 2: 438 | upload_folder = f"static{upload_folder_list[1]}" 439 | image_path = f"{upload_folder}{file_path}".replace("//", "/") 440 | else: 441 | image_path = url_for("static", filename=file_path).replace("//", "/") 442 | if image_path[0] == "/": 443 | image_path = image_path[1:] 444 | if filename: 445 | return f"{url_root}/{image_path}" 446 | except AttributeError: 447 | AttributeError("[FLASK_FILE_UPLOAD] You must declare a filename kwarg") 448 | 449 | def init_app(self, app, db=None, **kwargs) -> None: 450 | """ 451 | If you are using the Flask factory pattern, normally you 452 | will, by convention, create a method called ``create_app``. 453 | At runtime, you may then set configuration values before initiating 454 | FileUpload etc. Example:: 455 | 456 | app = Flask(__name__, static_folder="uploads/media") 457 | file_upload = FileUpload() 458 | 459 | def create_app(): 460 | file_upload.init_app(app, db) 461 | 462 | :param app: The Flask application instance: ``app = Flask(__name__, static_folder="uploads")``. 463 | :return: None 464 | """ 465 | # Let Flask request hook handle calling `self._clean_up` 466 | @app.teardown_request 467 | def _cu(_): 468 | self._clean_up(error=None) 469 | 470 | db = db or self.db 471 | self.app = app 472 | self.Model = create_model(db) 473 | self.config.init_config(app, **kwargs) 474 | self._db = db 475 | if db: 476 | app.extensions["file_upload"] = { 477 | "db": db, 478 | } 479 | 480 | def add_files(self, model, **kwargs) -> Any: 481 | """ 482 | The file(s) must reference the attribute name(s) 483 | defined in your SqlAlchemy model. For example:: 484 | 485 | @file_upload.Model 486 | class ModelTest(db.Model): 487 | 488 | my_video = file_upload.Column() 489 | placeholder_img = file_upload.Column() 490 | 491 | This example demonstrates creating a new row in your database 492 | using a SqlAlchemy model which is is then pass as the first 493 | argument to ``file_upload.add_files``. Normally, you will 494 | access your files from Flask's ``request`` object:: 495 | 496 | from Flask import request 497 | 498 | my_video = request.files['my_video'] 499 | placeholder_img = request.files['placeholder_img'] 500 | 501 | Then, we need to pass to the kwarg ``files`` a dict of keys that 502 | reference the attribute name(s) defined in your SqlAlchemy 503 | model & values that are your files.:: 504 | 505 | blog_post = BlogPostModel(title="Hello World Today") 506 | 507 | file_upload.add_files(blog_post, files={ 508 | "my_video": my_video, 509 | "placeholder_img": placeholder_img, 510 | }) 511 | 512 | # Now commit the changes to your db 513 | db.session.add(blog_post) 514 | db.session.commit() 515 | 516 | If you wish to let flask-file-upload handle adding & commiting to 517 | the current session then use ``file_upload.save_files`` 518 | 519 | :param model: The SqlAlchemy model instance 520 | :key files: A *Dict of attribute name(s) defined in your SqlAlchemy model 521 | :return: The updated SqlAlchemy model instance 522 | """ 523 | # Warning: These methods need to set members on the Model class 524 | # before we instantiate FileUtils() 525 | self._set_file_data(**kwargs) 526 | self._set_model_attrs(model) 527 | self.file_utils = FileUtils(model, self.config) 528 | self._save_files_to_dir(model) 529 | return model 530 | 531 | def save_files(self, model, **kwargs) -> Any: 532 | """ 533 | This method is identical to ``file_upload.add_files`` 534 | except it also adds & commits the changes to the db. 535 | The file(s) must reference the attribute name(s) 536 | defined in your SqlAlchemy model. For example:: 537 | 538 | @file_upload.Model 539 | class ModelTest(db.Model): 540 | 541 | my_video = file_upload.Column() 542 | placeholder_img = file_upload.Column() 543 | 544 | This example demonstrates creating a new row in your database 545 | using a SqlAlchemy model which is is then pass as the first 546 | argument to ``file_upload.save_files``. Normally, you will 547 | access your files from Flask's ``request`` object:: 548 | 549 | from Flask import request 550 | 551 | my_video = request.files['my_video'] 552 | placeholder_img = request.files['placeholder_img'] 553 | 554 | Then, we need to pass to the kwarg ``files`` a dict of keys that 555 | reference the attribute name(s) defined in your SqlAlchemy 556 | model & values that are your files.:: 557 | 558 | blog_post = BlogPostModel(title="Hello World Today") 559 | 560 | file_upload.save_files(blog_post, files={ 561 | "my_video": my_video, 562 | "placeholder_img": placeholder_img, 563 | }) 564 | 565 | :param model: The SqlAlchemy model instance 566 | :key files: A *Dict of attribute name(s) defined in your SqlAlchemy model 567 | :key commit_session: Default is `True` or if you have not passed in 568 | a SQLAlchemy instance to ``FileUpload()`` then it is also set to False. 569 | *If you prefer to handle the session yourself*. 570 | 571 | :return: The updated SqlAlchemy model instance 572 | """ 573 | # Warning: These methods need to set members on the Model class 574 | # before we instantiate FileUtils() 575 | self._set_file_data(**kwargs) 576 | self._set_model_attrs(model) 577 | 578 | self.file_utils = FileUtils(model, self.config) 579 | 580 | commit_session = kwargs.get("commit_session") or True 581 | model = _ModelUtils.commit_session(self.db, model, commit_session) 582 | self._save_files_to_dir(model) 583 | return model 584 | 585 | def _save_files_to_dir(self, model: Any) -> None: 586 | """ 587 | :param model: 588 | :return None: 589 | """ 590 | for f in self.files: 591 | id_val = _ModelUtils.get_id_value(model) 592 | self.file_utils.save_file(f, id_val) 593 | 594 | def _set_file_data(self, **file_data) -> List[Dict[str, str]]: 595 | """ 596 | Adds items to files & file_data 597 | :key files: Dict[str: Any] Key is the filename & Value 598 | is the file. 599 | :return: List[Dict[str, str]] 600 | """ 601 | for k, v in file_data.get("files").items(): 602 | setattr(v, "filename", secure_filename(getattr(v, "filename"))) 603 | self.files.append(v) 604 | self.file_data.append(self._create_file_dict(v, k)) 605 | return self.file_data 606 | 607 | def _set_model_attrs(self, model: Any) -> None: 608 | """ 609 | :param model: 610 | :return: None 611 | """ 612 | for d in self.file_data: 613 | for k, v in d.items(): 614 | self._check_attrs(model, k) 615 | setattr(model, k, v) 616 | 617 | def stream_file(self, model, **kwargs) -> Any: 618 | """ 619 | Streams a file from the directory defined by 620 | the Flask's ``UPLOAD_FOLDER``, in your Flask app's 621 | configuration settings. To access the file, 622 | it must reference the attribute name defined on 623 | your SqlAlchemy model. For example:: 624 | 625 | @file_upload.Model 626 | class ModelTest(db.Model): 627 | 628 | my_video = file_upload.Column() 629 | 630 | The required steps to stream files using ``file_upload.stream_file`` are: 631 | 632 | 1. Fetch the required row from your database using SqlAlchemy:: 633 | 634 | blog = BlogModel.query.filter_by(id=id).first() 635 | 636 | 2. Pass the SqlAlchemy instance to ``file_upload.stream_file`` & 637 | reference the attribute name defined on your SqlAlchemy model:: 638 | 639 | file_upload.stream_file(blogs, filename="my_video") 640 | 641 | :param model: SqlAlchemy model instance. 642 | :key filename: The attribute name defined on your SqlAlchemy model 643 | :return: Updated SqlAlchemy model instance or None. 644 | """ 645 | try: 646 | filename = kwargs['filename'] 647 | except KeyError: 648 | warn("'files' is a Required Argument") 649 | return None 650 | 651 | self.file_utils = FileUtils(model, self.config) 652 | primary_key = _ModelUtils.get_primary_key(model) 653 | model_id = getattr(model, primary_key, None) 654 | return send_from_directory( 655 | self.file_utils.get_stream_path(model_id), 656 | _ModelUtils.get_original_file_name(filename, model), 657 | conditional=True, 658 | ) 659 | 660 | def update_files(self, model: Any, db=None, **kwargs): 661 | """ 662 | First reference the attribute name defined on your 663 | SqlAlchemy model. For example:: 664 | 665 | @file_upload.Model 666 | class ModelTest(db.Model): 667 | 668 | my_video = file_upload.Column() 669 | 670 | Pass the model instance as a first argument to ``file_upload.update_files``. 671 | The kwarg ``files`` requires the file(s) returned from Flask's request body:: 672 | 673 | from Flask import request 674 | 675 | my_video = request.files['my_video'] 676 | placeholder_img = request.files['placeholder_img'] 677 | 678 | Then, we need to pass to the kwarg ``files`` a dict of keys that 679 | reference the attribute name(s) defined in your SqlAlchemy 680 | model & values that are your files.:: 681 | 682 | 683 | blog_post = BlogPostModel(title="Hello World Today") 684 | blog_post = file_upload.update_files(blog_post, files={ 685 | "my_video": new_my_video, 686 | "placeholder_img": new_placeholder_img, 687 | }) 688 | 689 | :param model: SqlAlchemy model instance. 690 | :key files Dict[str, Any]: A dict with the key representing the model attr 691 | name & file as value. 692 | :key commit: Default is True which is recommended. If commit is False, 693 | then only the files on the server are removed & the model is updated with 694 | each attribute set to None but the session is not commited (This could cause 695 | your database & files on server to be out of sync if you fail to commit 696 | the session. 697 | :return Any: Returns the model back 698 | """ 699 | try: 700 | files = kwargs["files"] 701 | except KeyError: 702 | warn("'files' is a Required Argument") 703 | return None 704 | 705 | commit = kwargs.get("commit") or True 706 | 707 | if db: 708 | warn( 709 | DeprecationWarning( 710 | "FLASK-FILE-UPLOAD: Passing `db` as a second argument to `update_files` method. " 711 | "is now not required. The second argument to `update_files` method will be " 712 | "removed in version v0.1.0" 713 | ) 714 | ) 715 | 716 | original_file_names = [] 717 | 718 | for f in files: 719 | value = _ModelUtils.get_by_postfix(model, f, "file_name") 720 | original_file_names.append(value) 721 | 722 | # Set file_data 723 | self._set_file_data(**kwargs) 724 | self._set_model_attrs(model) 725 | 726 | self.file_utils = FileUtils(model, self.config) 727 | 728 | # Save files to dirs 729 | self._save_files_to_dir(model) 730 | 731 | # remove original files from directory 732 | for f in original_file_names: 733 | primary_key = _ModelUtils.get_primary_key(model) 734 | model_id = getattr(model, primary_key, None) 735 | # If the model is updated later with file attributes the file path 736 | # then has not yet been created, so we do not have to remove the old 737 | # files etc: 738 | try: 739 | os.remove(f"{self.file_utils.get_stream_path(model_id)}/{f}") 740 | except FileNotFoundError: 741 | pass 742 | 743 | return _ModelUtils.commit_session(self.db, model, commit) 744 | 745 | @property 746 | def db(self): 747 | if self._db: 748 | return self._db 749 | else: 750 | try: 751 | self.app.extensions["file_upload"].get("db") 752 | except (AttributeError, KeyError): 753 | raise( 754 | "FLASK-FILE-UPLOAD: You must pass an instance of SQLAlchemy to " 755 | "`FileUpload(app, db)` or `file_upload.init_app(app, db)` as a " 756 | "second argument." 757 | ) 758 | 759 | @db.setter 760 | def db(self, db): 761 | self._db = db 762 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e99983d396d0c68e1e369a0a2ea247969ceb5dafe382165aed046c2af6f0bdd8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "click": { 18 | "hashes": [ 19 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 20 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 21 | ], 22 | "version": "==8.0.1" 23 | }, 24 | "flask": { 25 | "hashes": [ 26 | "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55", 27 | "sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9" 28 | ], 29 | "version": "==2.0.1" 30 | }, 31 | "flask-file-upload": { 32 | "editable": true, 33 | "path": "." 34 | }, 35 | "flask-sqlalchemy": { 36 | "hashes": [ 37 | "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", 38 | "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" 39 | ], 40 | "version": "==2.5.1" 41 | }, 42 | "greenlet": { 43 | "hashes": [ 44 | "sha256:03f28a5ea20201e70ab70518d151116ce939b412961c33827519ce620957d44c", 45 | "sha256:06d7ac89e6094a0a8f8dc46aa61898e9e1aec79b0f8b47b2400dd51a44dbc832", 46 | "sha256:06ecb43b04480e6bafc45cb1b4b67c785e183ce12c079473359e04a709333b08", 47 | "sha256:096cb0217d1505826ba3d723e8981096f2622cde1eb91af9ed89a17c10aa1f3e", 48 | "sha256:0c557c809eeee215b87e8a7cbfb2d783fb5598a78342c29ade561440abae7d22", 49 | "sha256:0de64d419b1cb1bfd4ea544bedea4b535ef3ae1e150b0f2609da14bbf48a4a5f", 50 | "sha256:14927b15c953f8f2d2a8dffa224aa78d7759ef95284d4c39e1745cf36e8cdd2c", 51 | "sha256:16183fa53bc1a037c38d75fdc59d6208181fa28024a12a7f64bb0884434c91ea", 52 | "sha256:206295d270f702bc27dbdbd7651e8ebe42d319139e0d90217b2074309a200da8", 53 | "sha256:22002259e5b7828b05600a762579fa2f8b33373ad95a0ee57b4d6109d0e589ad", 54 | "sha256:2325123ff3a8ecc10ca76f062445efef13b6cf5a23389e2df3c02a4a527b89bc", 55 | "sha256:258f9612aba0d06785143ee1cbf2d7361801c95489c0bd10c69d163ec5254a16", 56 | "sha256:3096286a6072553b5dbd5efbefc22297e9d06a05ac14ba017233fedaed7584a8", 57 | "sha256:3d13da093d44dee7535b91049e44dd2b5540c2a0e15df168404d3dd2626e0ec5", 58 | "sha256:408071b64e52192869129a205e5b463abda36eff0cebb19d6e63369440e4dc99", 59 | "sha256:598bcfd841e0b1d88e32e6a5ea48348a2c726461b05ff057c1b8692be9443c6e", 60 | "sha256:5d928e2e3c3906e0a29b43dc26d9b3d6e36921eee276786c4e7ad9ff5665c78a", 61 | "sha256:5f75e7f237428755d00e7460239a2482fa7e3970db56c8935bd60da3f0733e56", 62 | "sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c", 63 | "sha256:6b1d08f2e7f2048d77343279c4d4faa7aef168b3e36039cba1917fffb781a8ed", 64 | "sha256:70bd1bb271e9429e2793902dfd194b653221904a07cbf207c3139e2672d17959", 65 | "sha256:76ed710b4e953fc31c663b079d317c18f40235ba2e3d55f70ff80794f7b57922", 66 | "sha256:7920e3eccd26b7f4c661b746002f5ec5f0928076bd738d38d894bb359ce51927", 67 | "sha256:7db68f15486d412b8e2cfcd584bf3b3a000911d25779d081cbbae76d71bd1a7e", 68 | "sha256:8833e27949ea32d27f7e96930fa29404dd4f2feb13cce483daf52e8842ec246a", 69 | "sha256:944fbdd540712d5377a8795c840a97ff71e7f3221d3fddc98769a15a87b36131", 70 | "sha256:9a6b035aa2c5fcf3dbbf0e3a8a5bc75286fc2d4e6f9cfa738788b433ec894919", 71 | "sha256:9bdcff4b9051fb1aa4bba4fceff6a5f770c6be436408efd99b76fc827f2a9319", 72 | "sha256:a9017ff5fc2522e45562882ff481128631bf35da444775bc2776ac5c61d8bcae", 73 | "sha256:aa4230234d02e6f32f189fd40b59d5a968fe77e80f59c9c933384fe8ba535535", 74 | "sha256:ad80bb338cf9f8129c049837a42a43451fc7c8b57ad56f8e6d32e7697b115505", 75 | "sha256:adb94a28225005890d4cf73648b5131e885c7b4b17bc762779f061844aabcc11", 76 | "sha256:b3090631fecdf7e983d183d0fad7ea72cfb12fa9212461a9b708ff7907ffff47", 77 | "sha256:b33b51ab057f8a20b497ffafdb1e79256db0c03ef4f5e3d52e7497200e11f821", 78 | "sha256:b97c9a144bbeec7039cca44df117efcbeed7209543f5695201cacf05ba3b5857", 79 | "sha256:be13a18cec649ebaab835dff269e914679ef329204704869f2f167b2c163a9da", 80 | "sha256:be9768e56f92d1d7cd94185bab5856f3c5589a50d221c166cc2ad5eb134bd1dc", 81 | "sha256:c1580087ab493c6b43e66f2bdd165d9e3c1e86ef83f6c2c44a29f2869d2c5bd5", 82 | "sha256:c35872b2916ab5a240d52a94314c963476c989814ba9b519bc842e5b61b464bb", 83 | "sha256:c70c7dd733a4c56838d1f1781e769081a25fade879510c5b5f0df76956abfa05", 84 | "sha256:c767458511a59f6f597bfb0032a1c82a52c29ae228c2c0a6865cfeaeaac4c5f5", 85 | "sha256:c87df8ae3f01ffb4483c796fe1b15232ce2b219f0b18126948616224d3f658ee", 86 | "sha256:ca1c4a569232c063615f9e70ff9a1e2fee8c66a6fb5caf0f5e8b21a396deec3e", 87 | "sha256:cc407b68e0a874e7ece60f6639df46309376882152345508be94da608cc0b831", 88 | "sha256:da862b8f7de577bc421323714f63276acb2f759ab8c5e33335509f0b89e06b8f", 89 | "sha256:dfe7eac0d253915116ed0cd160a15a88981a1d194c1ef151e862a5c7d2f853d3", 90 | "sha256:ed1377feed808c9c1139bdb6a61bcbf030c236dd288d6fca71ac26906ab03ba6", 91 | "sha256:f42ad188466d946f1b3afc0a9e1a266ac8926461ee0786c06baac6bd71f8a6f3", 92 | "sha256:f92731609d6625e1cc26ff5757db4d32b6b810d2a3363b0ff94ff573e5901f6f" 93 | ], 94 | "markers": "python_version >= '3'", 95 | "version": "==1.1.0" 96 | }, 97 | "itsdangerous": { 98 | "hashes": [ 99 | "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", 100 | "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0" 101 | ], 102 | "version": "==2.0.1" 103 | }, 104 | "jinja2": { 105 | "hashes": [ 106 | "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", 107 | "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" 108 | ], 109 | "version": "==3.0.1" 110 | }, 111 | "markupsafe": { 112 | "hashes": [ 113 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 114 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 115 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 116 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 117 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 118 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 119 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 120 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 121 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 122 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 123 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 124 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 125 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 126 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 127 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 128 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 129 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 130 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 131 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 132 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 133 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 134 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 135 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 136 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 137 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 138 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 139 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 140 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 141 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 142 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 143 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 144 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 145 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 146 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 147 | ], 148 | "version": "==2.0.1" 149 | }, 150 | "sqlalchemy": { 151 | "hashes": [ 152 | "sha256:196fb6bb2733834e506c925d7532f8eabad9d2304deef738a40846e54c31e236", 153 | "sha256:1dd77acbc19bee9c0ba858ff5e4e5d5c60895495c83b4df9bcdf4ad5e9b74f21", 154 | "sha256:216ff28fe803885ceb5b131dcee6507d28d255808dd5bcffcb3b5fa75be2e102", 155 | "sha256:461a4ea803ce0834822f372617a68ac97f9fa1281f2a984624554c651d7c3ae1", 156 | "sha256:4b09191ed22af149c07a880f309b7740f3f782ff13325bae5c6168a6aa57e715", 157 | "sha256:4c5e20666b33b03bf7f14953f0deb93007bf8c1342e985bd7c7cf25f46fac579", 158 | "sha256:4d93b62e98248e3e1ac1e91c2e6ee1e7316f704be1f734338b350b6951e6c175", 159 | "sha256:5732858e56d32fa7e02468f4fd2d8f01ddf709e5b93d035c637762890f8ed8b6", 160 | "sha256:58c02d1771bb0e61bc9ced8f3b36b5714d9ece8fd4bdbe2a44a892574c3bbc3c", 161 | "sha256:651cdb3adcee13624ba22d5ff3e96f91e16a115d2ca489ddc16a8e4c217e8509", 162 | "sha256:6fe1c8dc26bc0005439cb78ebc78772a22cccc773f5a0e67cb3002d791f53f0f", 163 | "sha256:7222f3236c280fab3a2d76f903b493171f0ffc29667538cc388a5d5dd0216a88", 164 | "sha256:7dc3d3285fb682316d580d84e6e0840fdd8ffdc05cb696db74b9dd746c729908", 165 | "sha256:7e45043fe11d503e1c3f9dcf5b42f92d122a814237cd9af68a11dae46ecfcae1", 166 | "sha256:7eb55d5583076c03aaf1510473fad2a61288490809049cb31028af56af7068ee", 167 | "sha256:82922a320d38d7d6aa3a8130523ec7e8c70fa95f7ca7d0fd6ec114b626e4b10b", 168 | "sha256:8e133e2551fa99c75849848a4ac08efb79930561eb629dd7d2dc9b7ee05256e6", 169 | "sha256:949ac299903d2ed8419086f81847381184e2264f3431a33af4679546dcc87f01", 170 | "sha256:a2d225c8863a76d15468896dc5af36f1e196b403eb9c7e0151e77ffab9e7df57", 171 | "sha256:a5f00a2be7d777119e15ccfb5ba0b2a92e8a193959281089d79821a001095f80", 172 | "sha256:b0ad951a6e590bbcfbfeadc5748ef5ec8ede505a8119a71b235f7481cc08371c", 173 | "sha256:b59b2c0a3b1d93027f6b6b8379a50c354483fe1ebe796c6740e157bb2e06d39a", 174 | "sha256:bc89e37c359dcd4d75b744e5e81af128ba678aa2ecea4be957e80e6e958a1612", 175 | "sha256:bde055c019e6e449ebc4ec61abd3e08690abeb028c7ada2a3b95d8e352b7b514", 176 | "sha256:c367ed95d41df584f412a9419b5ece85b0d6c2a08a51ae13ae47ef74ff9a9349", 177 | "sha256:dde05ae0987e43ec84e64d6722ce66305eda2a5e2b7d6fda004b37aabdfbb909", 178 | "sha256:ee6e7ca09ff274c55d19a1e15ee6f884fa0230c0d9b8d22a456e249d08dee5bf", 179 | "sha256:f1c68f7bd4a57ffdb85eab489362828dddf6cd565a4c18eda4c446c1d5d3059d", 180 | "sha256:f63e1f531a8bf52184e2afb53648511f3f8534decb7575b483a583d3cd8d13ed", 181 | "sha256:fdad4a33140b77df61d456922b7974c1f1bb2c35238f6809f078003a620c4734" 182 | ], 183 | "version": "==1.4.17" 184 | }, 185 | "werkzeug": { 186 | "hashes": [ 187 | "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42", 188 | "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8" 189 | ], 190 | "version": "==2.0.1" 191 | } 192 | }, 193 | "develop": { 194 | "appdirs": { 195 | "hashes": [ 196 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 197 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 198 | ], 199 | "version": "==1.4.4" 200 | }, 201 | "attrs": { 202 | "hashes": [ 203 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 204 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 205 | ], 206 | "version": "==21.2.0" 207 | }, 208 | "bleach": { 209 | "hashes": [ 210 | "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", 211 | "sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433" 212 | ], 213 | "version": "==3.3.0" 214 | }, 215 | "certifi": { 216 | "hashes": [ 217 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 218 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 219 | ], 220 | "version": "==2021.5.30" 221 | }, 222 | "cffi": { 223 | "hashes": [ 224 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 225 | "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", 226 | "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", 227 | "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", 228 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 229 | "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", 230 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 231 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 232 | "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", 233 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 234 | "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", 235 | "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", 236 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 237 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 238 | "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", 239 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 240 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 241 | "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", 242 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 243 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 244 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 245 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 246 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 247 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 248 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 249 | "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", 250 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 251 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 252 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 253 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 254 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 255 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 256 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 257 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 258 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 259 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 260 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 261 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 262 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 263 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 264 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 265 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 266 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 267 | "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", 268 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 269 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 270 | "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", 271 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 272 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 273 | ], 274 | "version": "==1.14.5" 275 | }, 276 | "chardet": { 277 | "hashes": [ 278 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 279 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 280 | ], 281 | "version": "==4.0.0" 282 | }, 283 | "click": { 284 | "hashes": [ 285 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 286 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 287 | ], 288 | "version": "==8.0.1" 289 | }, 290 | "colorama": { 291 | "hashes": [ 292 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 293 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 294 | ], 295 | "version": "==0.4.4" 296 | }, 297 | "cryptography": { 298 | "hashes": [ 299 | "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", 300 | "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", 301 | "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", 302 | "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", 303 | "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", 304 | "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", 305 | "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", 306 | "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", 307 | "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", 308 | "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", 309 | "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", 310 | "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" 311 | ], 312 | "version": "==3.4.7" 313 | }, 314 | "distlib": { 315 | "hashes": [ 316 | "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736", 317 | "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c" 318 | ], 319 | "version": "==0.3.2" 320 | }, 321 | "docutils": { 322 | "hashes": [ 323 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 324 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 325 | ], 326 | "version": "==0.17.1" 327 | }, 328 | "filelock": { 329 | "hashes": [ 330 | "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", 331 | "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" 332 | ], 333 | "version": "==3.0.12" 334 | }, 335 | "flask": { 336 | "hashes": [ 337 | "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55", 338 | "sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9" 339 | ], 340 | "version": "==2.0.1" 341 | }, 342 | "flask-sqlalchemy": { 343 | "hashes": [ 344 | "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", 345 | "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" 346 | ], 347 | "version": "==2.5.1" 348 | }, 349 | "greenlet": { 350 | "hashes": [ 351 | "sha256:03f28a5ea20201e70ab70518d151116ce939b412961c33827519ce620957d44c", 352 | "sha256:06d7ac89e6094a0a8f8dc46aa61898e9e1aec79b0f8b47b2400dd51a44dbc832", 353 | "sha256:06ecb43b04480e6bafc45cb1b4b67c785e183ce12c079473359e04a709333b08", 354 | "sha256:096cb0217d1505826ba3d723e8981096f2622cde1eb91af9ed89a17c10aa1f3e", 355 | "sha256:0c557c809eeee215b87e8a7cbfb2d783fb5598a78342c29ade561440abae7d22", 356 | "sha256:0de64d419b1cb1bfd4ea544bedea4b535ef3ae1e150b0f2609da14bbf48a4a5f", 357 | "sha256:14927b15c953f8f2d2a8dffa224aa78d7759ef95284d4c39e1745cf36e8cdd2c", 358 | "sha256:16183fa53bc1a037c38d75fdc59d6208181fa28024a12a7f64bb0884434c91ea", 359 | "sha256:206295d270f702bc27dbdbd7651e8ebe42d319139e0d90217b2074309a200da8", 360 | "sha256:22002259e5b7828b05600a762579fa2f8b33373ad95a0ee57b4d6109d0e589ad", 361 | "sha256:2325123ff3a8ecc10ca76f062445efef13b6cf5a23389e2df3c02a4a527b89bc", 362 | "sha256:258f9612aba0d06785143ee1cbf2d7361801c95489c0bd10c69d163ec5254a16", 363 | "sha256:3096286a6072553b5dbd5efbefc22297e9d06a05ac14ba017233fedaed7584a8", 364 | "sha256:3d13da093d44dee7535b91049e44dd2b5540c2a0e15df168404d3dd2626e0ec5", 365 | "sha256:408071b64e52192869129a205e5b463abda36eff0cebb19d6e63369440e4dc99", 366 | "sha256:598bcfd841e0b1d88e32e6a5ea48348a2c726461b05ff057c1b8692be9443c6e", 367 | "sha256:5d928e2e3c3906e0a29b43dc26d9b3d6e36921eee276786c4e7ad9ff5665c78a", 368 | "sha256:5f75e7f237428755d00e7460239a2482fa7e3970db56c8935bd60da3f0733e56", 369 | "sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c", 370 | "sha256:6b1d08f2e7f2048d77343279c4d4faa7aef168b3e36039cba1917fffb781a8ed", 371 | "sha256:70bd1bb271e9429e2793902dfd194b653221904a07cbf207c3139e2672d17959", 372 | "sha256:76ed710b4e953fc31c663b079d317c18f40235ba2e3d55f70ff80794f7b57922", 373 | "sha256:7920e3eccd26b7f4c661b746002f5ec5f0928076bd738d38d894bb359ce51927", 374 | "sha256:7db68f15486d412b8e2cfcd584bf3b3a000911d25779d081cbbae76d71bd1a7e", 375 | "sha256:8833e27949ea32d27f7e96930fa29404dd4f2feb13cce483daf52e8842ec246a", 376 | "sha256:944fbdd540712d5377a8795c840a97ff71e7f3221d3fddc98769a15a87b36131", 377 | "sha256:9a6b035aa2c5fcf3dbbf0e3a8a5bc75286fc2d4e6f9cfa738788b433ec894919", 378 | "sha256:9bdcff4b9051fb1aa4bba4fceff6a5f770c6be436408efd99b76fc827f2a9319", 379 | "sha256:a9017ff5fc2522e45562882ff481128631bf35da444775bc2776ac5c61d8bcae", 380 | "sha256:aa4230234d02e6f32f189fd40b59d5a968fe77e80f59c9c933384fe8ba535535", 381 | "sha256:ad80bb338cf9f8129c049837a42a43451fc7c8b57ad56f8e6d32e7697b115505", 382 | "sha256:adb94a28225005890d4cf73648b5131e885c7b4b17bc762779f061844aabcc11", 383 | "sha256:b3090631fecdf7e983d183d0fad7ea72cfb12fa9212461a9b708ff7907ffff47", 384 | "sha256:b33b51ab057f8a20b497ffafdb1e79256db0c03ef4f5e3d52e7497200e11f821", 385 | "sha256:b97c9a144bbeec7039cca44df117efcbeed7209543f5695201cacf05ba3b5857", 386 | "sha256:be13a18cec649ebaab835dff269e914679ef329204704869f2f167b2c163a9da", 387 | "sha256:be9768e56f92d1d7cd94185bab5856f3c5589a50d221c166cc2ad5eb134bd1dc", 388 | "sha256:c1580087ab493c6b43e66f2bdd165d9e3c1e86ef83f6c2c44a29f2869d2c5bd5", 389 | "sha256:c35872b2916ab5a240d52a94314c963476c989814ba9b519bc842e5b61b464bb", 390 | "sha256:c70c7dd733a4c56838d1f1781e769081a25fade879510c5b5f0df76956abfa05", 391 | "sha256:c767458511a59f6f597bfb0032a1c82a52c29ae228c2c0a6865cfeaeaac4c5f5", 392 | "sha256:c87df8ae3f01ffb4483c796fe1b15232ce2b219f0b18126948616224d3f658ee", 393 | "sha256:ca1c4a569232c063615f9e70ff9a1e2fee8c66a6fb5caf0f5e8b21a396deec3e", 394 | "sha256:cc407b68e0a874e7ece60f6639df46309376882152345508be94da608cc0b831", 395 | "sha256:da862b8f7de577bc421323714f63276acb2f759ab8c5e33335509f0b89e06b8f", 396 | "sha256:dfe7eac0d253915116ed0cd160a15a88981a1d194c1ef151e862a5c7d2f853d3", 397 | "sha256:ed1377feed808c9c1139bdb6a61bcbf030c236dd288d6fca71ac26906ab03ba6", 398 | "sha256:f42ad188466d946f1b3afc0a9e1a266ac8926461ee0786c06baac6bd71f8a6f3", 399 | "sha256:f92731609d6625e1cc26ff5757db4d32b6b810d2a3363b0ff94ff573e5901f6f" 400 | ], 401 | "markers": "python_version >= '3'", 402 | "version": "==1.1.0" 403 | }, 404 | "idna": { 405 | "hashes": [ 406 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 407 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 408 | ], 409 | "version": "==2.10" 410 | }, 411 | "importlib-metadata": { 412 | "hashes": [ 413 | "sha256:960d52ba7c21377c990412aca380bf3642d734c2eaab78a2c39319f67c6a5786", 414 | "sha256:e592faad8de1bda9fe920cf41e15261e7131bcf266c30306eec00e8e225c1dd5" 415 | ], 416 | "version": "==4.4.0" 417 | }, 418 | "iniconfig": { 419 | "hashes": [ 420 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 421 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 422 | ], 423 | "version": "==1.1.1" 424 | }, 425 | "itsdangerous": { 426 | "hashes": [ 427 | "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", 428 | "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0" 429 | ], 430 | "version": "==2.0.1" 431 | }, 432 | "jeepney": { 433 | "hashes": [ 434 | "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657", 435 | "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae" 436 | ], 437 | "markers": "sys_platform == 'linux'", 438 | "version": "==0.6.0" 439 | }, 440 | "jinja2": { 441 | "hashes": [ 442 | "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", 443 | "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" 444 | ], 445 | "version": "==3.0.1" 446 | }, 447 | "keyring": { 448 | "hashes": [ 449 | "sha256:045703609dd3fccfcdb27da201684278823b72af515aedec1a8515719a038cb8", 450 | "sha256:8f607d7d1cc502c43a932a275a56fe47db50271904513a379d39df1af277ac48" 451 | ], 452 | "version": "==23.0.1" 453 | }, 454 | "markupsafe": { 455 | "hashes": [ 456 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 457 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 458 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 459 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 460 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 461 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 462 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 463 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 464 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 465 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 466 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 467 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 468 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 469 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 470 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 471 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 472 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 473 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 474 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 475 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 476 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 477 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 478 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 479 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 480 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 481 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 482 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 483 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 484 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 485 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 486 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 487 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 488 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 489 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 490 | ], 491 | "version": "==2.0.1" 492 | }, 493 | "packaging": { 494 | "hashes": [ 495 | "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", 496 | "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" 497 | ], 498 | "version": "==20.9" 499 | }, 500 | "pkginfo": { 501 | "hashes": [ 502 | "sha256:029a70cb45c6171c329dfc890cde0879f8c52d6f3922794796e06f577bb03db4", 503 | "sha256:9fdbea6495622e022cc72c2e5e1b735218e4ffb2a2a69cde2694a6c1f16afb75" 504 | ], 505 | "version": "==1.7.0" 506 | }, 507 | "pluggy": { 508 | "hashes": [ 509 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 510 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 511 | ], 512 | "version": "==0.13.1" 513 | }, 514 | "py": { 515 | "hashes": [ 516 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 517 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 518 | ], 519 | "version": "==1.10.0" 520 | }, 521 | "pycparser": { 522 | "hashes": [ 523 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 524 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 525 | ], 526 | "version": "==2.20" 527 | }, 528 | "pygments": { 529 | "hashes": [ 530 | "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", 531 | "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" 532 | ], 533 | "version": "==2.9.0" 534 | }, 535 | "pyparsing": { 536 | "hashes": [ 537 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 538 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 539 | ], 540 | "version": "==2.4.7" 541 | }, 542 | "pytest": { 543 | "hashes": [ 544 | "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", 545 | "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" 546 | ], 547 | "index": "pypi", 548 | "version": "==6.2.4" 549 | }, 550 | "readme-renderer": { 551 | "hashes": [ 552 | "sha256:63b4075c6698fcfa78e584930f07f39e05d46f3ec97f65006e430b595ca6348c", 553 | "sha256:92fd5ac2bf8677f310f3303aa4bce5b9d5f9f2094ab98c29f13791d7b805a3db" 554 | ], 555 | "version": "==29.0" 556 | }, 557 | "requests": { 558 | "hashes": [ 559 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 560 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 561 | ], 562 | "version": "==2.25.1" 563 | }, 564 | "requests-toolbelt": { 565 | "hashes": [ 566 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 567 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 568 | ], 569 | "version": "==0.9.1" 570 | }, 571 | "rfc3986": { 572 | "hashes": [ 573 | "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", 574 | "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" 575 | ], 576 | "version": "==1.5.0" 577 | }, 578 | "secretstorage": { 579 | "hashes": [ 580 | "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f", 581 | "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195" 582 | ], 583 | "markers": "sys_platform == 'linux'", 584 | "version": "==3.3.1" 585 | }, 586 | "six": { 587 | "hashes": [ 588 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 589 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 590 | ], 591 | "version": "==1.16.0" 592 | }, 593 | "sqlalchemy": { 594 | "hashes": [ 595 | "sha256:196fb6bb2733834e506c925d7532f8eabad9d2304deef738a40846e54c31e236", 596 | "sha256:1dd77acbc19bee9c0ba858ff5e4e5d5c60895495c83b4df9bcdf4ad5e9b74f21", 597 | "sha256:216ff28fe803885ceb5b131dcee6507d28d255808dd5bcffcb3b5fa75be2e102", 598 | "sha256:461a4ea803ce0834822f372617a68ac97f9fa1281f2a984624554c651d7c3ae1", 599 | "sha256:4b09191ed22af149c07a880f309b7740f3f782ff13325bae5c6168a6aa57e715", 600 | "sha256:4c5e20666b33b03bf7f14953f0deb93007bf8c1342e985bd7c7cf25f46fac579", 601 | "sha256:4d93b62e98248e3e1ac1e91c2e6ee1e7316f704be1f734338b350b6951e6c175", 602 | "sha256:5732858e56d32fa7e02468f4fd2d8f01ddf709e5b93d035c637762890f8ed8b6", 603 | "sha256:58c02d1771bb0e61bc9ced8f3b36b5714d9ece8fd4bdbe2a44a892574c3bbc3c", 604 | "sha256:651cdb3adcee13624ba22d5ff3e96f91e16a115d2ca489ddc16a8e4c217e8509", 605 | "sha256:6fe1c8dc26bc0005439cb78ebc78772a22cccc773f5a0e67cb3002d791f53f0f", 606 | "sha256:7222f3236c280fab3a2d76f903b493171f0ffc29667538cc388a5d5dd0216a88", 607 | "sha256:7dc3d3285fb682316d580d84e6e0840fdd8ffdc05cb696db74b9dd746c729908", 608 | "sha256:7e45043fe11d503e1c3f9dcf5b42f92d122a814237cd9af68a11dae46ecfcae1", 609 | "sha256:7eb55d5583076c03aaf1510473fad2a61288490809049cb31028af56af7068ee", 610 | "sha256:82922a320d38d7d6aa3a8130523ec7e8c70fa95f7ca7d0fd6ec114b626e4b10b", 611 | "sha256:8e133e2551fa99c75849848a4ac08efb79930561eb629dd7d2dc9b7ee05256e6", 612 | "sha256:949ac299903d2ed8419086f81847381184e2264f3431a33af4679546dcc87f01", 613 | "sha256:a2d225c8863a76d15468896dc5af36f1e196b403eb9c7e0151e77ffab9e7df57", 614 | "sha256:a5f00a2be7d777119e15ccfb5ba0b2a92e8a193959281089d79821a001095f80", 615 | "sha256:b0ad951a6e590bbcfbfeadc5748ef5ec8ede505a8119a71b235f7481cc08371c", 616 | "sha256:b59b2c0a3b1d93027f6b6b8379a50c354483fe1ebe796c6740e157bb2e06d39a", 617 | "sha256:bc89e37c359dcd4d75b744e5e81af128ba678aa2ecea4be957e80e6e958a1612", 618 | "sha256:bde055c019e6e449ebc4ec61abd3e08690abeb028c7ada2a3b95d8e352b7b514", 619 | "sha256:c367ed95d41df584f412a9419b5ece85b0d6c2a08a51ae13ae47ef74ff9a9349", 620 | "sha256:dde05ae0987e43ec84e64d6722ce66305eda2a5e2b7d6fda004b37aabdfbb909", 621 | "sha256:ee6e7ca09ff274c55d19a1e15ee6f884fa0230c0d9b8d22a456e249d08dee5bf", 622 | "sha256:f1c68f7bd4a57ffdb85eab489362828dddf6cd565a4c18eda4c446c1d5d3059d", 623 | "sha256:f63e1f531a8bf52184e2afb53648511f3f8534decb7575b483a583d3cd8d13ed", 624 | "sha256:fdad4a33140b77df61d456922b7974c1f1bb2c35238f6809f078003a620c4734" 625 | ], 626 | "version": "==1.4.17" 627 | }, 628 | "toml": { 629 | "hashes": [ 630 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 631 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 632 | ], 633 | "version": "==0.10.2" 634 | }, 635 | "tox": { 636 | "hashes": [ 637 | "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3", 638 | "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b" 639 | ], 640 | "index": "pypi", 641 | "version": "==3.23.1" 642 | }, 643 | "tqdm": { 644 | "hashes": [ 645 | "sha256:736524215c690621b06fc89d0310a49822d75e599fcd0feb7cc742b98d692493", 646 | "sha256:cd5791b5d7c3f2f1819efc81d36eb719a38e0906a7380365c556779f585ea042" 647 | ], 648 | "version": "==4.61.0" 649 | }, 650 | "twine": { 651 | "hashes": [ 652 | "sha256:16f706f2f1687d7ce30e7effceee40ed0a09b7c33b9abb5ef6434e5551565d83", 653 | "sha256:a56c985264b991dc8a8f4234eb80c5af87fa8080d0c224ad8f2cd05a2c22e83b" 654 | ], 655 | "index": "pypi", 656 | "version": "==3.4.1" 657 | }, 658 | "urllib3": { 659 | "hashes": [ 660 | "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", 661 | "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" 662 | ], 663 | "index": "pypi", 664 | "version": "==1.26.5" 665 | }, 666 | "virtualenv": { 667 | "hashes": [ 668 | "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467", 669 | "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76" 670 | ], 671 | "version": "==20.4.7" 672 | }, 673 | "webencodings": { 674 | "hashes": [ 675 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 676 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 677 | ], 678 | "version": "==0.5.1" 679 | }, 680 | "werkzeug": { 681 | "hashes": [ 682 | "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42", 683 | "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8" 684 | ], 685 | "version": "==2.0.1" 686 | }, 687 | "wheel": { 688 | "hashes": [ 689 | "sha256:78b5b185f0e5763c26ca1e324373aadd49182ca90e825f7853f4b2509215dc0e", 690 | "sha256:e11eefd162658ea59a60a0f6c7d493a7190ea4b9a85e335b33489d9f17e0245e" 691 | ], 692 | "index": "pypi", 693 | "version": "==0.36.2" 694 | }, 695 | "zipp": { 696 | "hashes": [ 697 | "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", 698 | "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" 699 | ], 700 | "version": "==3.4.1" 701 | } 702 | } 703 | } 704 | --------------------------------------------------------------------------------