├── .coveragerc ├── .gitignore ├── AUTHORS ├── LICENSE ├── README.rst ├── examples ├── JPEGr │ ├── JPEGr │ │ ├── __init__.py │ │ ├── app.py │ │ ├── config.py │ │ ├── form.py │ │ ├── static │ │ │ └── .gitignore │ │ ├── templates │ │ │ ├── base.html │ │ │ ├── index.html │ │ │ └── upload.html │ │ ├── transfer.py │ │ └── utils.py │ ├── README.md │ └── run.py └── allotr │ ├── README.md │ ├── allotr │ ├── app.py │ ├── form.py │ ├── static │ │ └── .gitignore │ ├── templates │ │ └── index.html │ └── transfer.py │ └── run.py ├── flask_transfer ├── __init__.py ├── exc.py ├── transfer.py └── validators.py ├── quickstart.rst ├── release.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_transfer.py └── test_validators.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | # re-enable default 4 | pragma: no cover 5 | 6 | # don't complain about reprs 7 | def __repr__ 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # ipython notebooks 63 | *.ipynb 64 | .ipynb_checkpoints/ 65 | 66 | # ropeproject 67 | .ropeproject/ 68 | # Created by https://www.gitignore.io 69 | 70 | ### Vim ### 71 | [._]*.s[a-w][a-z] 72 | [._]s[a-w][a-z] 73 | *.un~ 74 | Session.vim 75 | .netrwhist 76 | *~ 77 | 78 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | #Maintainer 2 | Alec Reiter https://github.com/justanr 3 | 4 | #Contributors 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alec Nikolas Reiter 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 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | Flask-Transfer 3 | ============== 4 | 5 | Validate, process and persist file uploads easily through a single 6 | object instead of cramming all that stuff into your routes. 7 | 8 | Tired of this? 9 | -------------- 10 | 11 | .. code:: python 12 | 13 | @app.route('/upload', methods=['GET', 'POST']) 14 | def handle_upload(): 15 | form = FileUploadForm() 16 | 17 | if form.validate_on_submit(): 18 | filehandle = form.uploaded.data 19 | allowed_exts = ('md', 'txt', 'rst') 20 | if not os.path.splitext(filehandle.filename)[1] in allowed_exts: 21 | raise SomeError('Unallowed extension!') 22 | if filehandle.read() != b'Hello World!': 23 | raise SomeError('File contents not allowed!') 24 | filehandle.seek(0) 25 | 26 | username = g.current_user.name 27 | upload_dir = current_app.config['UPLOAD_DIR'] 28 | full_path = os.path.join(upload_dir, username, secure_filename(filehandle.filename)) 29 | filehandle.save(full_path) 30 | flash("Uploaded {}!".format(filehandle.filename), 'success') 31 | return redirect(url_for('handle_upload')) 32 | else: 33 | return render_template('upload.html', form=form) 34 | 35 | That's a mess. Your test runner is literally running away from you. 36 | There's like four or five different things going on in this single 37 | route! Argh. 38 | 39 | There's a better way 40 | -------------------- 41 | 42 | .. code:: python 43 | 44 | from flask_transfer import Transfer, UploadError 45 | from flask_transfer.validators import AllowedExts 46 | 47 | TextFileTransfer = Transfer(validators=[AllowedExts('md', 'rst', 'txt')]) 48 | 49 | @TextFileTransfer.destination 50 | def save_to_user_dir(filehandle, metadata): 51 | username = g.current_user.name 52 | upload_path = current_app.config['UPLOAD_DIR'] 53 | full_path = os.path.join(upload_dir, username, secure_filename(filehandle.filename)) 54 | filehandle.save(full_path) 55 | 56 | 57 | @TextFileTransfer.validator 58 | def check_file_contents(filehandle, metadata): 59 | if filehandle.read() != metadata['allowed_contents']: 60 | raise UploadError('File contents not allowed!') 61 | filehandle.seek(0) 62 | return True 63 | 64 | 65 | @app.route('/upload', methods=['GET', 'POST']) 66 | def handle_upload(): 67 | form = FileUploadForm() 68 | 69 | if form.validate_on_submit(): 70 | filehandle = form.uploaded.data 71 | TextFileTransfer.save(filehandle, metadata={'allowed_contents': b'Hello World!'}) 72 | flash('Uploaded {}!'.format(filehandle.filename), 'success') 73 | return redirect(url_for('handle_upload')) 74 | else: 75 | return render_template('upload.html', form=form) 76 | 77 | Aaaah. Sure, it's a little bit more code. But it's separated out into 78 | bits and pieces. It's easy to test each bit and the intent in the route 79 | is very clear. 80 | 81 | More Power 82 | ---------- 83 | 84 | Flask-Transfer supplies hooks for validation, preprocessing and 85 | postprocessing file uploads via decorators. If you need to always create 86 | thumbnails of uploaded images, you can supply a callable to 87 | ``MyTransfer.preprocessor`` or ``MyTransfer.postprocessor`` that'll do 88 | that for you. 89 | 90 | And validation beyond just simple extension checking is at your 91 | fingertips as well. Perhaps, you've limited your user to a certain 92 | amount of disk space and they should be told to delete data before 93 | uploading more. Write a simple function to check current disk usage and 94 | if the upload would exceed the cap. Then hook it to your Transfer object 95 | with ``MyTransfer.validator``. 96 | 97 | Finally, persisting files is easy! Maybe you're running on Heroku and 98 | can't rely on the local filesystem. Just write a callable that'll pass 99 | the file to your S3 bucket! Hook it in with ``MyTransfer.destination``. 100 | Flask-Transfer handles using string paths and writable objects as 101 | destinations as well. 102 | 103 | Check out the `quickstart `__ for some more information, 104 | as well! 105 | 106 | Todo 107 | ---- 108 | 109 | There's still quite a bit to do. For example, better error handle. 110 | Perhaps a tighter integration with Flask, or running the opposite way 111 | and cleaving the already few dependencies on werkzeug to become 112 | framework independent. 113 | 114 | Contributions 115 | ------------- 116 | 117 | Given the infancy of this project, pull requests and issue are more than 118 | welcome. Just add yourself to the authors file, write some tests for the 119 | added or change functionality and submit it! 120 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import app 2 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/app.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | from .form import UploadForm 3 | from .transfer import PDFTransfer, pdf_saver 4 | from . import utils 5 | from flask import (Flask, render_template, redirect, abort, 6 | url_for, send_from_directory) 7 | from flask_bootstrap import Bootstrap 8 | import os 9 | 10 | 11 | app = Flask(__name__) 12 | app.config.from_object(Config) 13 | Bootstrap(app) 14 | Config.init_app(app) 15 | 16 | 17 | @app.route('/') 18 | def index(): 19 | return render_template('index.html', links=utils.build_image_links()) 20 | 21 | 22 | @app.route('/upload', methods=['GET', 'POST']) 23 | def upload(): 24 | form = UploadForm() 25 | if form.validate_on_submit(): 26 | meta = {'width': form.width.data or 1080} 27 | PDFTransfer.save(form.pdf.data, destination=pdf_saver, metadata=meta) 28 | return redirect(url_for('index')) 29 | else: 30 | return render_template('upload.html', form=form) 31 | 32 | 33 | @app.route('/pdf/') 34 | def display_pdf(pdf): 35 | path = utils.get_save_path(pdf) 36 | if not os.path.exists(path): 37 | abort(404) 38 | else: 39 | return send_from_directory(*os.path.split(path)) 40 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config(object): 5 | DEBUG = True 6 | SECRET_KEY = "it's a secret to everybody" 7 | UPLOAD_PATH = 'pdf' 8 | 9 | @staticmethod 10 | def init_app(app): 11 | save_path = os.path.join(app.static_folder, Config.UPLOAD_PATH) 12 | if not os.path.exists(save_path): 13 | os.mkdir(save_path) 14 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from flask_wtf.file import FileField, FileAllowed, FileRequired 3 | from wtforms import SubmitField, IntegerField 4 | 5 | 6 | class UploadForm(Form): 7 | pdf = FileField( 8 | label='Select a PDF to Convert:', 9 | validators=[FileAllowed(upload_set=['pdf']), FileRequired()] 10 | ) 11 | 12 | width = IntegerField(label='New Width') 13 | submit = SubmitField('Convert!') 14 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/static/.gitignore: -------------------------------------------------------------------------------- 1 | # ignore everything in this directory 2 | . 3 | # except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "bootstrap/base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | {% import "bootstrap/fixes.html" as fixes %} 4 | {% import "bootstrap/utils.html" as util %} 5 | 6 | {% block navbar %} 7 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Welcome to jpegr{% endblock %} 4 | 5 | 6 | {% block content %} 7 |
8 |
9 | {{ util.flashed_messages() }} 10 |
11 |
12 |

jpegr: A PDF to JPG converter as a service

13 |

jpegr takes your crummy non-webscale PDF and converts it into a beautiful, if slightly grainy, Web1.0 JPG image

14 |

Convert your crappy PDF

15 |
    16 | {% for link in links %} 17 |
  • 18 | View a converted PDF! 19 |
  • 20 | {% else %} 21 |
  • 22 | Unbelievable! There are no converted PDFs! 23 |
  • 24 | {% endfor %} 25 |
26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/templates/upload.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}PDF Uploader{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Ready to semi-webscale?!

9 |

Upload your crappy PDF and get a wonderful semi-webscale JPG!

10 | {{ wtf.quick_form(form, enctype="multipart/form-data") }} 11 |
12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/transfer.py: -------------------------------------------------------------------------------- 1 | from flask import flash 2 | from flask_transfer import Transfer 3 | from io import BytesIO 4 | from itertools import count 5 | import os 6 | from wand.image import Image 7 | from wand.color import Color 8 | from werkzeug import secure_filename 9 | from .utils import get_save_path 10 | 11 | PDFTransfer = Transfer() 12 | 13 | 14 | @PDFTransfer.preprocessor 15 | def pdftojpg(filehandle, meta): 16 | """Converts a PDF to a JPG and places it back onto the FileStorage instance 17 | passed to it as a BytesIO object. 18 | 19 | Optional meta arguments are: 20 | * resolution: int or (int, int) used for wand to determine resolution, 21 | defaults to 300. 22 | * width: new width of the image for resizing, defaults to 1080 23 | * bgcolor: new background color, defaults to 'white' 24 | """ 25 | resolution = meta.get('resolution', 300) 26 | width = meta.get('width', 1080) 27 | bgcolor = Color(meta.get('bgcolor', 'white')) 28 | stream = BytesIO() 29 | 30 | with Image(blob=filehandle.stream, resolution=resolution) as img: 31 | img.background_color = bgcolor 32 | img.alpha_channel = False 33 | img.format = 'jpeg' 34 | ratio = width / img.width 35 | img.resize(width, int(ratio * img.height)) 36 | img.compression_quality = 90 37 | img.save(file=stream) 38 | 39 | stream.seek(0) 40 | filehandle.stream = stream 41 | return filehandle 42 | 43 | 44 | @PDFTransfer.preprocessor 45 | def change_filename(filehandle, meta): 46 | """Changes the filename to reflect the conversion from PDF to JPG. 47 | This method will preserve the original filename in the meta dictionary. 48 | """ 49 | filename = secure_filename(meta.get('filename', filehandle.filename)) 50 | basename, _ = os.path.splitext(filename) 51 | meta['original_filename'] = filehandle.filename 52 | filehandle.filename = filename + '.jpg' 53 | return filehandle 54 | 55 | 56 | @PDFTransfer.preprocessor 57 | def avoid_name_collisions(filehandle, meta): 58 | """Manipulates a filename until it's unique. This can be disabled by 59 | setting meta['avoid_name_collision'] to any falsey value. 60 | """ 61 | if meta.get('avoid_name_collision', True): 62 | filename = filehandle.filename 63 | original, ext = os.path.splitext(filehandle.filename) 64 | counter = count() 65 | while os.path.exists(get_save_path(filename)): 66 | fixer = str(next(counter)) 67 | filename = '{}_{}{}'.format(original, fixer, ext) 68 | filehandle.filename = filename 69 | return filehandle 70 | 71 | 72 | @PDFTransfer.postprocessor 73 | def flash_success(filehandle, meta): 74 | flash('Converted {} to {}'.format( 75 | meta['original_filename'], filehandle.filename), 76 | 'success') 77 | return filehandle 78 | 79 | 80 | def pdf_saver(filehandle, *args, **kwargs): 81 | "Uses werkzeug.FileStorage instance to save the converted image." 82 | fullpath = get_save_path(filehandle.filename) 83 | filehandle.save(fullpath, buffer_size=kwargs.get('buffer_size', 16384)) 84 | -------------------------------------------------------------------------------- /examples/JPEGr/JPEGr/utils.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, url_for 2 | from glob import glob 3 | import os 4 | 5 | 6 | def get_save_path(filename): 7 | static_path = current_app.static_folder 8 | upload_path = current_app.config['UPLOAD_PATH'] 9 | return os.path.join(static_path, upload_path, filename) 10 | 11 | 12 | def find_jpgs(): 13 | path = os.path.join(current_app.static_folder, 14 | current_app.config['UPLOAD_PATH'], 15 | '*.jpg') 16 | return glob(path) 17 | 18 | 19 | def build_image_links(): 20 | images = [] 21 | for jpg in find_jpgs(): 22 | jpg = os.path.basename(jpg) 23 | images.append(url_for('display_pdf', pdf=jpg)) 24 | return images 25 | -------------------------------------------------------------------------------- /examples/JPEGr/README.md: -------------------------------------------------------------------------------- 1 | #JPEGr 2 | 3 | This was actually partially the reason why I developed Flask-Transfer. To create a displayable representation of a PDF, specifically for resumes (yes, yes, a text representation of a resume is a better choice, but let's ignore that). 4 | 5 | This highlight's Flask-Transfer's strengths: hiding the complexity of manipulating uploads behind a few lines of code. Inside `jpegr/transfer.py` are five functions that handle: converting a PDF to a JPG, changing the filename from a pdf extension to a jpg extension, ensuring there are no name collisions, and flashing the success to the user. There's also an example use of the metadata attribute for the pre and postprocessors. 6 | 7 | Rather than cramming all of that into the routing function, it's tucked away into its own module, registered on a Transfer instance and invoked with `PDFTransfer.save`. 8 | 9 | ##Dependecies 10 | There are dependencies outside of Flask and Flask-Transfer: 11 | 12 | * flask-bootstrap 13 | * flask-wtf 14 | * wand 15 | * ImageMagick (wand system level dependency) 16 | 17 | ##Running 18 | Navigate to this folder and run `python run.py` 19 | 20 | -------------------------------------------------------------------------------- /examples/JPEGr/run.py: -------------------------------------------------------------------------------- 1 | from JPEGr import app 2 | 3 | app.run() 4 | -------------------------------------------------------------------------------- /examples/allotr/README.md: -------------------------------------------------------------------------------- 1 | 2 | #Allotr 3 | 4 | Flask allows rejecting incoming files if they're too big. However, what if you wanted to reject files if they caused a directory to grow too large? This example application uses a really bad implementation of `du -s` to determine the current size of a directory and then checks if the uploaded file causes the directory to exceed it's allotment (by default 20kb). 5 | 6 | ##Dependencies 7 | 8 | * Flask 9 | * Flask-Bootstrap 10 | * Flask-WTF 11 | * Flask-Transfer 12 | 13 | ## Running 14 | Navigate here after install dependencies and run `python run.py` 15 | -------------------------------------------------------------------------------- /examples/allotr/allotr/app.py: -------------------------------------------------------------------------------- 1 | from flask import flash, url_for, redirect, render_template, Flask 2 | from flask_bootstrap import Bootstrap 3 | from flask_transfer import UploadError 4 | from .transfer import UserUpload, really_bad_du, list_files 5 | from .form import UploadForm 6 | import os 7 | 8 | app = Flask(__name__) 9 | Bootstrap(app) 10 | app.config['UPLOAD_PATH'] = os.path.join(app.static_folder, 'uploads') 11 | app.config['SECRET_KEY'] = "it's a secret to everybody" 12 | app.config['MAX_UPLOAD_SIZE'] = 20 * 1024 13 | 14 | 15 | @app.before_first_request 16 | def create_upload_dir(): 17 | if not os.path.exists(app.config['UPLOAD_PATH']): 18 | os.makedirs(app.config['UPLOAD_PATH']) 19 | 20 | 21 | @app.errorhandler(UploadError) 22 | def flash_upload_error(error): 23 | flash(error.args[0], 'error') 24 | flash('Redirecting to home', 'error') 25 | return redirect(url_for('index')) 26 | 27 | 28 | @app.route('/', methods=['GET', 'POST']) 29 | def index(): 30 | form = UploadForm() 31 | if form.validate_on_submit(): 32 | destination = os.path.join(app.config['UPLOAD_PATH'], 33 | form.upload.data.filename) 34 | UserUpload.save(form.upload.data, destination=destination) 35 | 36 | max = app.config['MAX_UPLOAD_SIZE'] // 1024 37 | current = really_bad_du(app.config['UPLOAD_PATH']) // 1024 38 | files = [os.path.basename(fp) for fp in list_files(app.config['UPLOAD_PATH'])] 39 | 40 | return render_template('index.html', form=form, max=max, 41 | current=current, files=files) 42 | -------------------------------------------------------------------------------- /examples/allotr/allotr/form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from flask_wtf.file import FileField, FileRequired 3 | from wtforms import SubmitField 4 | 5 | 6 | class UploadForm(Form): 7 | upload = FileField(label="Select a file to upload", 8 | validators=[FileRequired()]) 9 | submit = SubmitField(label='Engage!') 10 | -------------------------------------------------------------------------------- /examples/allotr/allotr/static/.gitignore: -------------------------------------------------------------------------------- 1 | # ignore everything in this directory 2 | . 3 | # except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /examples/allotr/allotr/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "bootstrap/base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | {% import "bootstrap/fixes.html" as fixes %} 4 | {% import "bootstrap/utils.html" as util %} 5 | 6 | 7 | {% block title %}Allotr{% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 | {{ util.flashed_messages() }} 13 |
14 |
15 |

Allotr

16 |

Flask will allow you to reject files that exceed a certain file size. 17 | But Flask-Transfer will allow you to create a check that will reject 18 | files that cause a directory to grow beyond a certain size.

19 |

Try It Out

20 |

Upload some files, but once a file causes the upload directory to 21 | exceed 20 kilobytes, you'll get an error.

22 |
23 |
24 |

Upload a File

25 | {{ wtf.quick_form(form, enctype="multipart/form-data") }} 26 |
27 |
28 | 29 |
30 |
31 |
32 |

Source Code

33 | 34 | 35 | Github 36 | 37 |
38 |
39 |

Total Upload Usages

40 |

{{ current }}kb of {{ max }}kb

41 |
42 |
43 |

Files Uploaded

44 |
    45 | {% for file in files %} 46 |
  • {{ file }}
  • 47 | {% else %} 48 |
  • No files uploaded
  • 49 | {% endfor %} 50 |
51 |
52 |
53 |
54 | 59 | 60 |
61 | 62 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /examples/allotr/allotr/transfer.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, flash 2 | from flask_transfer import Transfer, UploadError 3 | import os 4 | 5 | 6 | UserUpload = Transfer() 7 | 8 | 9 | def list_files(path): 10 | files = [] 11 | for name in os.listdir(path): 12 | path = os.path.join(path, name) 13 | if os.path.isfile(path): 14 | files.append(path) 15 | return files 16 | 17 | 18 | def really_bad_du(path): 19 | "Don't actually use this, it's just an example." 20 | return sum([os.path.getsize(fp) for fp in list_files(path)]) 21 | 22 | 23 | @UserUpload.validator 24 | def check_disk_usage(filehandle, meta): 25 | """Checks the upload directory to see if the uploaded file would exceed 26 | the total disk allotment. Meant as a quick and dirty example. 27 | """ 28 | # limit it at twenty kilobytes if no default is provided 29 | MAX_DISK_USAGE = current_app.config.get('MAX_DISK_USAGE', 20 * 1024) 30 | CURRENT_USAGE = really_bad_du(current_app.config['UPLOAD_PATH']) 31 | filehandle.seek(0, os.SEEK_END) 32 | 33 | if CURRENT_USAGE + filehandle.tell() > MAX_DISK_USAGE: 34 | filehandle.close() 35 | raise UploadError("Upload exceeds allotment.") 36 | filehandle.seek(0) 37 | return filehandle 38 | 39 | 40 | @UserUpload.postprocessor 41 | def flash_success(filehandle, meta): 42 | message = meta.get('message', 'Uploaded {}'.format(filehandle.filename)) 43 | flash(message, 'success') 44 | -------------------------------------------------------------------------------- /examples/allotr/run.py: -------------------------------------------------------------------------------- 1 | from allotr.app import app 2 | 3 | if __name__ == '__main__': 4 | app.run(debug=True) 5 | -------------------------------------------------------------------------------- /flask_transfer/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | flask_transfer 3 | ~~~~~~~~~~~~~~ 4 | Provides hooks for validating, preprocessing and postprocessing file 5 | uploads. 6 | """ 7 | 8 | from .exc import UploadError 9 | from .transfer import Transfer 10 | from . import validators 11 | 12 | 13 | __version__ = "0.1.0" 14 | __author__ = 'Alec Reiter' 15 | -------------------------------------------------------------------------------- /flask_transfer/exc.py: -------------------------------------------------------------------------------- 1 | class UploadError(Exception): 2 | "Base exception for Flask-Transfer" 3 | -------------------------------------------------------------------------------- /flask_transfer/transfer.py: -------------------------------------------------------------------------------- 1 | from werkzeug._compat import string_types 2 | from .exc import UploadError 3 | 4 | __all__ = ['Transfer'] 5 | 6 | 7 | def _use_filehandle_to_save(dest): 8 | def saver(filehandle, metadata): 9 | "Uses the save method on the filehandle to save to the destination" 10 | buffer_size = metadata.get('buffer_size', 16384) 11 | filehandle.save(dest, buffer_size) 12 | return saver 13 | 14 | 15 | def _make_destination_callable(dest): 16 | """Creates a callable out of the destination. If it's already callable, 17 | the destination is returned. Instead, if the object is a string or a 18 | writable object, it's wrapped in a closure to be used later. 19 | """ 20 | if callable(dest): 21 | return dest 22 | elif hasattr(dest, 'write') or isinstance(dest, string_types): 23 | return _use_filehandle_to_save(dest) 24 | else: 25 | raise TypeError("Destination must be a string, writable or callable object.") 26 | 27 | 28 | class Transfer(object): 29 | """A Transfer object is a self-contained validators, processor and saver. 30 | These items can be provided at instantiation time, or provided later 31 | through decorators (for validators and processors) or at save time 32 | (for saving mechanisms). 33 | 34 | For saving, he destination can be a string path, a writable object 35 | or a callable that will do something with the filehandle. Transfer will 36 | handle transforming these as needed. The precedence is callables then 37 | writables then string paths. All three of these are valid inputs to 38 | Transfer's `destination` param 39 | 40 | .. code-block:: python 41 | 42 | # use callable to handle persisting 43 | def _save_to_current_user_dir(filehandle, *args, **kwargs): 44 | "Saves a file to the current user's directory" 45 | name = g.current_user.name 46 | path = current_app.config.get('USER_UPLOAD_DIR') 47 | fullpath = os.path.join(path, filehandle.name) 48 | filehandle.save(fullpath) 49 | 50 | Transfer(destination=_save_to_current_user_dir) 51 | 52 | # writeable object 53 | Transfer(destination=BytesIO()) 54 | 55 | # string path name 56 | Transfer(destination="path/to/uploads") 57 | 58 | `destination` may also be None, but a destination *must* be provided 59 | when saving. 60 | 61 | :param destination: Default destination to pass filehandles to. 62 | Callables, writables and string paths are all acceptable inputs. 63 | None may be provided to specify no default path. 64 | :param validators: List-like of validations to run against the 65 | filehandle. May be None to run no validations. 66 | :param preprocessors: List-like of processors to run on the filehandle 67 | before passing it to the destination. May be None to run no pre 68 | processing. 69 | :param postprocessors: List-like of processors to run on the filehandle 70 | after passing it to the destination. Maybe be None to run no post 71 | processing. 72 | """ 73 | 74 | def __init__(self, destination=None, validators=None, preprocessors=None, 75 | postprocessors=None): 76 | if destination is not None: 77 | self._destination = _make_destination_callable(destination) 78 | else: 79 | self._destination = None 80 | self._validators = validators or [] 81 | self._preprocessors = preprocessors or [] 82 | self._postprocessors = postprocessors or [] 83 | 84 | def validator(self, fn): 85 | """Adds a validator to the Transfer instance 86 | 87 | .. code-block:: python 88 | 89 | from wand.image import Image 90 | ImageTransfer = Transfer() 91 | 92 | @ImageTransfer.validator 93 | def has_appropirate_dimensions(filehandle, metadata): 94 | with Image(file=filehandle.stream) as img: 95 | height, width = img.height, img.width 96 | 97 | return (height <= metadata['height'] and 98 | width <= metadata['width']) 99 | """ 100 | self._validators.append(fn) 101 | return fn 102 | 103 | def preprocessor(self, fn): 104 | """Adds a preprocessor to the Transfer instance. 105 | 106 | .. code-block:: python 107 | 108 | Text = Transfer(validators=[AllowedExts('txt')) 109 | 110 | @Text.preprocessor 111 | def make_uppercase(filehandle, meta): 112 | "Makes a text document all uppercase" 113 | filehandle.stream = filehandle.stream.upper() 114 | return filehandle 115 | """ 116 | self._preprocessors.append(fn) 117 | return fn 118 | 119 | def postprocessor(self, fn): 120 | """Adds a postprocessor ito the Transfer instance. 121 | 122 | .. code-block:: python 123 | 124 | from wand.image import Image 125 | 126 | ImageTransfer = Transfer(validators=[AllowedExts('png', 'jpg')]) 127 | 128 | @ImageTransfer.postprocessor 129 | def thumbnailify(filehandle, meta): 130 | with Image(file=filehandle.stream) as img: 131 | img.resolution = meta['resolution'] 132 | ratio = meta['width'] / img.width 133 | img.resize(width, int(ratio * img.height) 134 | img.save(filename=meta['thumbnail_path']) 135 | """ 136 | self._postprocessors.append(fn) 137 | return fn 138 | 139 | def destination(self, dest): 140 | """Changes the default destination of the Transfer object. Can be used 141 | as a standard method or as a decorator. 142 | """ 143 | self._destination = _make_destination_callable(dest) 144 | return dest 145 | 146 | def _validate(self, filehandle, metadata, catch_all_errors=False): 147 | """Runs all attached validators on the provided filehandle. 148 | In the base implmentation of Transfer, the result of `_validate` isn't 149 | checked. Rather validators are expected to raise UploadError to report 150 | failure. 151 | 152 | `_validate` can optionally catch all UploadErrors that occur or bail out 153 | and the first one by toggling the `catch_all_errors` flag. If 154 | catch_all_errors is Truthy then a single UploadError is raised 155 | consisting of all UploadErrors raised. 156 | """ 157 | errors = [] 158 | DEFAULT_ERROR_MSG = '{0!r}({1!r}, {2!r}) returned False' 159 | 160 | for validator in self._validators: 161 | try: 162 | if not validator(filehandle, metadata): 163 | msg = DEFAULT_ERROR_MSG.format(validator, filehandle, metadata) 164 | raise UploadError(msg) 165 | except UploadError as e: 166 | if catch_all_errors: 167 | errors.append(e.args[0]) 168 | else: 169 | raise 170 | 171 | if errors: 172 | raise UploadError(errors) 173 | 174 | def _preprocess(self, filehandle, metadata): 175 | "Runs all attached preprocessors on the provided filehandle." 176 | for process in self._preprocessors: 177 | filehandle = process(filehandle, metadata) 178 | return filehandle 179 | 180 | def _postprocess(self, filehandle, metadata): 181 | "Runs all attached postprocessors on the provided filehandle." 182 | for process in self._postprocessors: 183 | filehandle = process(filehandle, metadata) 184 | return filehandle 185 | 186 | def save(self, filehandle, destination=None, metadata=None, 187 | validate=True, catch_all_errors=False, *args, **kwargs): 188 | """Saves the filehandle to the provided destination or the attached 189 | default destination. Allows passing arbitrary positional and keyword 190 | arguments to the saving mechanism 191 | 192 | :param filehandle: werkzeug.FileStorage instance 193 | :param dest: String path, callable or writable destination to pass the 194 | filehandle off to. Transfer handles transforming a string or 195 | writable object into a callable automatically. 196 | :param metadata: Optional mapping of metadata to pass to validators, 197 | preprocessors, and postprocessors. 198 | :param validate boolean: Toggle validation, defaults to True 199 | :param catch_all_errors boolean: Toggles if validation should collect 200 | all UploadErrors and raise a collected error message or bail out on 201 | the first one. 202 | """ 203 | destination = destination or self._destination 204 | if destination is None: 205 | raise RuntimeError("Destination for filehandle must be provided.") 206 | 207 | elif destination is not self._destination: 208 | destination = _make_destination_callable(destination) 209 | 210 | if metadata is None: 211 | metadata = {} 212 | 213 | if validate: 214 | self._validate(filehandle, metadata) 215 | 216 | filehandle = self._preprocess(filehandle, metadata) 217 | destination(filehandle, metadata) 218 | filehandle = self._postprocess(filehandle, metadata) 219 | return filehandle 220 | 221 | def __call__(self, filehandle, destination=None, metadata=None, 222 | validate=True, catch_all_errors=False, *args, **kwargs): 223 | "Short cut to Transfer.save." 224 | return self.save(filehandle=filehandle, destination=destination, 225 | metadata=metadata, validate=validate, 226 | catch_all_errors=catch_all_errors, *args, **kwargs) 227 | -------------------------------------------------------------------------------- /flask_transfer/validators.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | from functools import update_wrapper 4 | from .exc import UploadError 5 | import os 6 | 7 | 8 | class BaseValidator(object): 9 | """BaseValidator class for flask_transfer. Provides utility methods for 10 | combining validators together. Subclasses should implement `_validates`. 11 | 12 | Validators can signal failure in one of two ways: 13 | * Raise UploadError with a message 14 | * Return non-Truthy value. 15 | 16 | Raising UploadError is the preferred method, as it allows a more descriptive 17 | message to reach the caller, however by supporting returning Falsey values, 18 | lambdas can be used as validators as well. 19 | """ 20 | def _validate(self, filehandle, metadata): 21 | raise NotImplementedError("_validate not implemented") 22 | 23 | def __call__(self, filehandle, metadata): 24 | return self._validate(filehandle, metadata) 25 | 26 | def __repr__(self): 27 | return self.__class__.__name__ 28 | 29 | def __and__(self, other): 30 | return AndValidator(self, other) 31 | 32 | def __or__(self, other): 33 | return OrValidator(self, other) 34 | 35 | def __invert__(self): 36 | return NegatedValidator(self) 37 | 38 | 39 | class AndValidator(BaseValidator): 40 | """Combination validator analogous to the builtin `all` function. Every 41 | nested validator must return Truthy or the entire validator is considered 42 | False. 43 | 44 | On its own, AndValidator is redundant with how validators are run, however, 45 | it's useful when combined with `OrValidator` or `NegatedValidator`. 46 | 47 | .. code-block:: python 48 | 49 | ImagesButNotPSD = AndValidator(AllowedExts('gif', 'png', 'jpg'), # etc 50 | DeniedExts('psd')) 51 | ImagesButNotPSD(DummyFile(filename='awesome.png')) # True 52 | ImagesButNotPSD(DummyFile(filename='awesome.psd')) # UploadError(...) 53 | 54 | It's also possible to shortcut to creating an AndValidator by using the 55 | logical/bitwise and (`&`) operator. 56 | 57 | .. code-block:: python 58 | 59 | ImagesButNotPSD = AllowedExts('png', 'gif', 'jpg') & DeniedExts('psd') 60 | 61 | This shortcut is available to any validator inheriting from BaseValidator. 62 | However, when combining many nested validators together, it's better to 63 | use the explicit constructor. 64 | 65 | .. code-block:: python 66 | 67 | # Ends up nesting an AndValidator inside another 68 | AllowedExts('png') & DeniedExts('psd') & MyValidator() 69 | 70 | # Creates a flat AndValidator 71 | AndValidator(AllowedExts('png'), DeniedExts('psd'), MyValidator()) 72 | """ 73 | def __init__(self, *validators): 74 | self._validators = validators 75 | 76 | def _validate(self, filehandle, metadata): 77 | msg = '{0!r}({1!r}, {2!r}) returned false in {3!r}' 78 | for validator in self._validators: 79 | if not validator(filehandle, metadata): 80 | raise UploadError(msg.format(validator, filehandle, 81 | metadata, self)) 82 | return True 83 | 84 | def __repr__(self): 85 | validators = ', '.join([repr(v) for v in self._validators]) 86 | return 'AndValidator({0})'.format(validators) 87 | 88 | 89 | class OrValidator(BaseValidator): 90 | """Combination validator analogous to the builtin `any` function. As long as 91 | a single nested validator returns True, so does this validator. 92 | 93 | .. code-block:: python 94 | 95 | ImagesOrText = OrValidator(AllowedExts('jpg', 'png' 'gif'), # etc 96 | AllowedExts('txt')) 97 | ImagesOrText(DummyFile(filename='awesome.txt')) # True 98 | ImagesOrText(DummyFile(filename='awesome.png')) # True 99 | ImagesOrText(DummyFile(filename='notawesome.doc')) # UploadError(...) 100 | 101 | It's also possible to shortcut to creating an OrValidator by using the 102 | logical/bitwise or (`|`) operator. 103 | 104 | .. code-block:: python 105 | 106 | ImagesOrText = AllowedExts('png', 'jpg', 'gif') | AllowedExts('txt') 107 | 108 | This shortcut exists on every validator inheriting from BaseValidator. 109 | However, when chaining many validator together, it's better to use the 110 | explicit constructor rather than the shortcut. 111 | 112 | .. code-block:: python 113 | 114 | # nests an OrValidator inside of another 115 | AllowedExts('png') | AllowedExts('txt') | MyValidator() 116 | 117 | # creates a flat validator 118 | OrValidator(AllowedExts('png'), AllowedExts('txt'), MyValidator()) 119 | """ 120 | def __init__(self, *validators): 121 | self._validators = validators 122 | 123 | def _validate(self, filehandle, metadata): 124 | errors = [] 125 | msg = '{0!r}({1!r}, {2!r}) returned false in {3!r}.' 126 | for validator in self._validators: 127 | try: 128 | if not validator(filehandle, metadata): 129 | raise UploadError(msg.format(validator, filehandle, 130 | metadata, self)) 131 | except UploadError as e: 132 | errors.append(e.args[0]) 133 | else: 134 | return True 135 | raise UploadError(errors) 136 | 137 | def __repr__(self): 138 | validators = ', '.join([repr(v) for v in self._validators]) 139 | return 'OrValidator({0})'.format(validators) 140 | 141 | 142 | class NegatedValidator(BaseValidator): 143 | """Flips a validation failure into a success and validator success into 144 | failure. This is analogous to the `not` keyword (or `operator.not_` 145 | function). If the nested validator returns False or raises UploadError, 146 | NegatedValidator instead returns True. However, if the nested returns True, 147 | it raises an UploadError instead. 148 | 149 | .. code-block:: python 150 | 151 | NotImages = NegatedValidator(AllowedExts('png', 'jpg', 'gif')) 152 | NotImages(DummyFile(filename='awesome.txt')) # True 153 | NotImages(DummyFile(filename='awesome.png')) # UploadError(...) 154 | 155 | There is also an operator shortcut: the logical/bitwise inversion (`~`) 156 | operator. The reason `~` was chosen over `-` is that inversion is *always* 157 | unary, where as `-` may be unary or binary depending on surrounding context, 158 | using logical inversion also keeps it in theme with AndValidator and 159 | OrValidator. 160 | 161 | .. code-block:: python 162 | 163 | ~OrValidator(AllowedExts('png'), AllowedExts('txt')) 164 | # NegatedValidator(OrValidator(AllowedExts('png'), AllowedExts('txt'))) 165 | 166 | This shortcut exists on every validator inheriting from BaseValidator. 167 | However, there is special behavior for `AllowedExts` and `DeniedExts` which 168 | simply flip between one another when used with the invert operator. 169 | 170 | .. code-block:: python 171 | 172 | ~AllowedExts('png') # becomes DeniedExts('png') 173 | ~DeniedExts('txt') # becomes AllowedExts('txt') 174 | """ 175 | def __init__(self, nested): 176 | self._nested = nested 177 | 178 | def _validate(self, filehandle, metadata): 179 | try: 180 | if not self._nested(filehandle, metadata): 181 | return True 182 | except UploadError: 183 | # UploadError would only be raised to signal a failed condition 184 | # but we want to flip a False into a True anyways. 185 | return True 186 | # looks strange that we're tossing an error out if we reach here 187 | # but we'll need to signal a failure to the caller with some information 188 | msg = '{0!r}({1!r}, {2!r}) returned false' 189 | raise UploadError(msg.format(self, filehandle, metadata)) 190 | 191 | def __repr__(self): 192 | return 'NegatedValidator({0!r})'.format(self._nested) 193 | 194 | 195 | class FunctionValidator(BaseValidator): 196 | def __init__(self, wrapped): 197 | update_wrapper(self, wrapped) 198 | self._nested = wrapped 199 | 200 | def _validate(self, filehandle, metadata): 201 | return self._nested(filehandle, metadata) 202 | 203 | def __repr__(self): 204 | return 'FunctionValidator({0!r})'.format(self._nested) 205 | 206 | 207 | class ExtValidator(BaseValidator): 208 | """Base filename extension class. Extensions are lowercased and placed into 209 | a frozenset. Also defines a helper staticmethod `_getext` that extracts the 210 | lowercase extension from a filename. 211 | 212 | Checked extensions should not have the dot included in them. 213 | """ 214 | def __init__(self, *exts): 215 | self.exts = frozenset(map(str.lower, exts)) 216 | 217 | def __repr__(self): 218 | exts = ', '.join(self.exts) 219 | return "{0.__class__.__name__}({1})".format(self, exts) 220 | 221 | @staticmethod 222 | def _getext(filename): 223 | "Returns the lowercased file extension." 224 | return os.path.splitext(filename)[-1][1:].lower() 225 | 226 | 227 | class AllowedExts(ExtValidator): 228 | """Filename extension validator that whitelists certain extensions 229 | 230 | .. code-block:: python 231 | 232 | ImagesAllowed = AllowedExts('jpg', 'png', 'gif') 233 | ImagesAllowed(DummyFile(name='awesome.jpg'), {}) 234 | # True 235 | ImagesAllowed(DummyFile('awesome.psd'), {}) 236 | # UploadError(awesome.psd has an invalid extension...) 237 | 238 | """ 239 | def _validate(self, filehandle, metadata): 240 | if self._getext(filehandle.filename) not in self.exts: 241 | exts = ', '.join(self.exts) 242 | msg = '{0} has an invalid extension, allowed extensions: {1}' 243 | raise UploadError(msg.format(filehandle.filename, exts)) 244 | 245 | return True 246 | 247 | def __invert__(self): 248 | return DeniedExts(*self.exts) 249 | 250 | 251 | class DeniedExts(ExtValidator): 252 | """Filename extension validator that blacklists certain extensions 253 | 254 | .. code-block:: python 255 | 256 | DocumentsDenied = DeniedExts('doc', 'xls', 'ppt') 257 | DocumentsDenied(DummyFile('awesome.pdf'), {}) 258 | # True 259 | DocumentsDenied(DummyFile('awesome.ppt'), {}) 260 | # UploadError(awesome.ppt has an invalid extension...) 261 | 262 | """ 263 | def _validate(self, filehandle, metadata): 264 | if self._getext(filehandle.filename) in self.exts: 265 | exts = ', '.join(self.exts) 266 | msg = '{0} has an invalid extension, denied extensions {1}' 267 | raise UploadError(msg.format(filehandle.filename, exts)) 268 | 269 | return True 270 | 271 | def __invert__(self): 272 | return AllowedExts(*self.exts) 273 | 274 | 275 | # just a little dynamic instance creation, nothing to see here. 276 | AllowAll = type('All', (BaseValidator,), {'_validate': lambda *a, **k: True, 277 | '__repr__': lambda _: 'All', 278 | '__doc__': 'Allows everything.'}) 279 | DenyAll = type('Deny', (BaseValidator,), {'_validate': lambda *a, **k: False, 280 | '__repr__': lambda _: 'Deny', 281 | '__doc__': 'Denies everything.'}) 282 | -------------------------------------------------------------------------------- /quickstart.rst: -------------------------------------------------------------------------------- 1 | 2 | Saving 3 | ------ 4 | 5 | Saving with Flask-Transfer is *super* easy. Once you have FileStorage 6 | object (probably from Flask-WTF), just call 7 | ``Transfer.save(filehandle)``. That's mostly it. This'll validate the 8 | file, run the preprocessors, persist the file and then call the 9 | postprocessors. 10 | 11 | Destinations 12 | ~~~~~~~~~~~~ 13 | 14 | As mentioned before, destinations can be callables, writables or string 15 | file paths (and the preference is in that order, too). The conversion to 16 | a common interface is done behind the scenes for you. There's also three 17 | ways destinations can be provided, in order of preference: 18 | 19 | - When calling ``Transfer.save``, provide the destination keyword 20 | argument 21 | 22 | .. code:: python 23 | 24 | MyTransfer = Transfer() 25 | MyTransfer.save(filehandle, metadata, destination='somewhere') 26 | 27 | - Use the ``@Transfer.destination`` callable -- either as a decorator 28 | or a method call. This callable will accept functions, strings and 29 | writable objects and handle all the behind the scenes conversion for 30 | you. 31 | 32 | .. code:: python 33 | 34 | MyTransfer = Transfer() 35 | 36 | @MyTransfer.destination 37 | def storeit(filehandle, metadata): 38 | # do stuff... 39 | return filehandle 40 | 41 | #OR 42 | 43 | MyTransfer.destination('~/') 44 | 45 | #OR 46 | 47 | MyTransfer.destination(BytesIO()) 48 | 49 | - At instance creation, to provide a "default" destination. 50 | 51 | .. code:: python 52 | 53 | MyTransfer = Transfer(destination='~/') 54 | 55 | Other stuff 56 | ~~~~~~~~~~~ 57 | 58 | When calling ``Transfer.save`` it's possible to supply metadata to the 59 | validators, preprocessors and postprocessors with the ``metadata`` 60 | argument. This can be any object, but defaults to an empty dictionary if 61 | not supplied and probably possible to mutate the object, do what you 62 | will with that information. 63 | 64 | Validation can optionally be turned off. Maybe you rely on Flask-WTF to 65 | validate incoming stuff, so doing double validation isn't cool. Just 66 | pass ``validate=False`` to the method. 67 | 68 | Finally, if you need to pass positional or keyword arguments down to the 69 | saving mechanism, it's possible to do that as well. ``Transfer.save`` 70 | will pass ``*args`` and ``**kwargs`` down to it (and unpack them there 71 | as well). 72 | 73 | Validators 74 | ---------- 75 | 76 | Flask-Transfer comes with a handful of predefined validators. Validators 77 | can be loaded into a Transfer object when it's created through the 78 | ``validators`` keyword (in this case it should be a list or list-like 79 | object). Or added after the fact with the ``Transfer.validator`` 80 | decorator. 81 | 82 | .. code:: python 83 | 84 | # load at instance creation 85 | MyTransfer = Transfer(validators=[ImagesAllowed]) 86 | 87 | # load after the fact 88 | @MyTransfer.validator 89 | def my_first_validator(filehandle, metadata): 90 | # do stuff 91 | 92 | Extension Validators 93 | ~~~~~~~~~~~~~~~~~~~~ 94 | 95 | There are two extension validators: AllowedExts and DeniedExts. They 96 | both do what you think and creating them is easy peasy: 97 | 98 | .. code:: python 99 | 100 | ImagesAllowed = AllowedExts('jpg', 'png', 'gif') 101 | ImagesDenied = DeniedExts('psd', 'tiff') 102 | 103 | Function Validators 104 | ~~~~~~~~~~~~~~~~~~~ 105 | 106 | If you already have a perfectly good function or callable that fits 107 | Flask-Transfer's validator protocol, but you want to take advantage of 108 | the ability to combine validators together with ``&`` and ``|``, you can 109 | use ``FunctionValidator`` to lift your callable into this context: 110 | 111 | .. code:: python 112 | 113 | EvenBetterPerfectlyGood = FunctionValidator(perfectly_good_validator) 114 | 115 | ``FunctionValidator`` can also be used as a decorator: 116 | 117 | .. code:: python 118 | 119 | @FunctionValidator 120 | def perfectly_good(filehandle, metadata): 121 | return True 122 | 123 | Manipulating Validators 124 | ~~~~~~~~~~~~~~~~~~~~~~~ 125 | 126 | Flask-Transfer also allows combining and negating validators easily. If 127 | you have a condition where *two* things need to be true, there's the 128 | ``AndValidator`` and its shortcut ``&``: 129 | 130 | .. code:: python 131 | 132 | ImagesAndPerfectlyGood = ImagesAllowed & EvenBetterPerfectlyGood 133 | 134 | For conditions that are better expressed as an or, there's 135 | ``OrValidator`` and its shortcut ``|``: 136 | 137 | .. code:: python 138 | 139 | ImagesOrText = ImagesAllowed | AllowExts('txt', 'md', 'rst') 140 | 141 | And for conditions that are the opposite of what they currently are, 142 | there's ``NegatedValidator`` and its shortcut ``~`` (yes, that's a tilde 143 | instead of a subtraction sign): 144 | 145 | .. code:: python 146 | 147 | NotImages = ~ImagesAllowed 148 | 149 | BYOV: Bring Your Own Validators 150 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 151 | 152 | Aside from just wrapping a function with FunctionValidator, you can 153 | inherit from ``BaseValidator`` and implement ``_validate``. The only 154 | thing you need to know is that a validator needs to accept a 155 | ``werkzeug.FileStorage`` (or whatever you're using internally) instance 156 | and a metadata object (I use dictionaries, but I also make no 157 | presumptions). 158 | 159 | Pre and Post processing 160 | ----------------------- 161 | 162 | Preprocessing happens before saving the filehandle and postprocessing 163 | happens afterwards. Both of these receive the FileStorage instance and a 164 | metadata object (again, dict, object, whatever) and need to return a 165 | FileStorage instance (the same one, a different one, a manipulated one, 166 | doesn't matter). Processors just need to be callable: Functions, classes 167 | with ``__call__``, a method on a class or instance, doesn't matter as 168 | long as it adheres to the calling convention. 169 | 170 | Preprocessing 171 | ~~~~~~~~~~~~~ 172 | 173 | These calls are made before calling the save mechanism. Potentially, 174 | they can manipulate the filehandle before it's persisted. Or perhaps use 175 | them to ensure name collision doesn't happen. Or whatever. 176 | 177 | Postprocessing 178 | ~~~~~~~~~~~~~~ 179 | 180 | These calls are made after calling the save mechanism. Perhaps after 181 | persisting the filehandle, you need to create thumbnails or shove 182 | something in the database. 183 | 184 | Not good enough? 185 | ---------------- 186 | 187 | Subclass ``Transfer`` and do your own thing. Maybe you'd like validators 188 | and processors to map to a dictionary instead of a list. 189 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # borrowed from pgcli 3 | 4 | from __future__ import print_function 5 | import re 6 | import ast 7 | import subprocess 8 | import sys 9 | 10 | DEBUG = True 11 | 12 | 13 | def version(version_file): 14 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 15 | 16 | with open(version_file, 'rb') as f: 17 | ver = str(ast.literal_eval(_version_re.search( 18 | f.read().decode('utf-8')).group(1))) 19 | 20 | return ver 21 | 22 | 23 | def commit_for_release(version_file, ver): 24 | cmd = ['git', 'reset'] 25 | print(' '.join(cmd)) 26 | subprocess.check_output(cmd) 27 | cmd = ['git', 'add', version_file] 28 | print(' '.join(cmd)) 29 | subprocess.check_output(cmd) 30 | cmd = ['git', 'commit', '--message', 'Releasing version %s' % ver] 31 | print(' '.join(cmd)) 32 | subprocess.check_output(cmd) 33 | 34 | 35 | def create_git_tag(tag_name): 36 | cmd = ['git', 'tag', tag_name] 37 | print(' '.join(cmd)) 38 | subprocess.check_output(cmd) 39 | 40 | 41 | def register_with_pypi(): 42 | cmd = ['python', 'setup.py', 'register'] 43 | print(' '.join(cmd)) 44 | subprocess.check_output(cmd) 45 | 46 | 47 | def create_source_tarball(): 48 | cmd = ['python', 'setup.py', 'sdist'] 49 | print(' '.join(cmd)) 50 | subprocess.check_output(cmd) 51 | 52 | 53 | def push_to_github(): 54 | cmd = ['git', 'push', '-u', 'origin'] 55 | print(' '.join(cmd)) 56 | subprocess.check_output(cmd) 57 | 58 | 59 | def push_tags_to_github(): 60 | cmd = ['git', 'push', '-u', '--tags', 'origin'] 61 | print(' '.join(cmd)) 62 | subprocess.check_output(cmd) 63 | 64 | 65 | if __name__ == '__main__': 66 | if DEBUG: 67 | subprocess.check_output = lambda x: x 68 | 69 | ver = version('flask_transfer/__init__.py') 70 | tests = raw_input("Did you remember to run the tests? (y/N) ") 71 | if tests.lower() != 'y': 72 | sys.exit(1) 73 | 74 | print('Releasing Version:', ver) 75 | choice = raw_input('Are you sure? (y/N) ') 76 | if choice.lower() != 'y': 77 | sys.exit(1) 78 | 79 | commit_for_release('flask_transfer/__init__.py', ver) 80 | create_git_tag('v%s' % ver) 81 | register_with_pypi() 82 | create_source_tarball() 83 | push_to_github() 84 | push_tags_to_github() 85 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools.command.test import test as TestCommand 3 | import sys 4 | import re 5 | import ast 6 | 7 | 8 | def _get_version(): 9 | version_re = re.compile(r'__version__\s+=\s+(.*)') 10 | 11 | with open('flask_transfer/__init__.py', 'rb') as fh: 12 | version = ast.literal_eval( 13 | version_re.search(fh.read().decode('utf-8')).group(1)) 14 | 15 | return str(version) 16 | 17 | 18 | class ToxTest(TestCommand): 19 | user_options = [('tox-args=', 'a', 'Arguments to pass to tox')] 20 | 21 | def initialize_options(self): 22 | TestCommand.initialize_options(self) 23 | self.tox_args = None 24 | 25 | def finalize_options(self): 26 | TestCommand.finalize_options(self) 27 | self.test_args = [] 28 | self.test_suite = True 29 | 30 | def run_tests(self): 31 | import tox 32 | import shlex 33 | args = [] 34 | if self.tox_args: 35 | args = shlex.split(self.tox_args) 36 | 37 | errno = tox.cmdline(args=args) 38 | sys.exit(errno) 39 | 40 | 41 | if __name__ == "__main__": 42 | setup( 43 | name='flask-transfer', 44 | version=_get_version(), 45 | author='Alec Nikolas Reiter', 46 | author_email='alecreiter@gmail.com', 47 | description='Validate and process file uploads in Flask easily', 48 | license='MIT', 49 | packages=['flask_transfer'], 50 | zip_safe=False, 51 | url="https://github.com/justanr/Flask-Transfer", 52 | keywords=['flask', 'uploads'], 53 | classifiers=[ 54 | 'Development Status :: 3 - Alpha', 55 | 'Framework :: Flask', 56 | 'Environment :: Web Environment', 57 | 'Intended Audience :: Developers', 58 | 'License :: OSI Approved :: MIT License', 59 | 'Programming Language :: Python :: 2.7', 60 | 'Programming Language :: Python :: 3.4' 61 | ], 62 | install_requires=['Flask'], 63 | test_suite='test', 64 | tests_require=['tox'], 65 | cmdclass={'tox': ToxTest}, 66 | ) 67 | -------------------------------------------------------------------------------- /tests/test_transfer.py: -------------------------------------------------------------------------------- 1 | from flask_transfer import transfer, UploadError 2 | from werkzeug import FileStorage 3 | import pytest 4 | 5 | try: 6 | from io import BytesIO 7 | except ImportError: 8 | from StringIO import StringIO as BytesIO 9 | 10 | try: 11 | from unittest import mock 12 | except ImportError: 13 | import mock 14 | 15 | 16 | @pytest.fixture 17 | def transf(): 18 | return transfer.Transfer() 19 | 20 | 21 | class ReportingTransfer(transfer.Transfer): 22 | def __init__(self, *args, **kwargs): 23 | super(ReportingTransfer, self).__init__(*args, **kwargs) 24 | self._validated = False 25 | self._preprocessed = False 26 | self._postprocessed = False 27 | self._saved = False 28 | 29 | def _validate(self, fh, meta): 30 | self._validated = True 31 | 32 | def _preprocess(self, fh, meta): 33 | self._preprocessed = True 34 | 35 | def _postprocess(self, fh, meta): 36 | self._postprocessed = True 37 | 38 | def save(self, *args, **kwargs): 39 | def destination(filehandle, metadata): 40 | self._saved = True 41 | 42 | kwargs['destination'] = destination 43 | super(ReportingTransfer, self).save(*args, **kwargs) 44 | 45 | def verify(self): 46 | return self._validated and self._preprocessed and self._saved and \ 47 | self._postprocessed 48 | 49 | 50 | @pytest.mark.parametrize('save_to', [ 51 | lambda a: a, BytesIO(), 'dummy/path' 52 | ]) 53 | def test_make_destination_callable(save_to): 54 | assert callable(transfer._make_destination_callable(save_to)) 55 | 56 | 57 | def test_make_destination_callable_raises(): 58 | with pytest.raises(TypeError) as excinfo: 59 | transfer._make_destination_callable(object()) 60 | 61 | assert "Destination must be a string, writable or callable object." == \ 62 | str(excinfo.value) 63 | 64 | 65 | def test_writeable_saving(): 66 | destination = BytesIO() 67 | filehandle = FileStorage(stream=BytesIO(b'hello world')) 68 | dummy_save = transfer._use_filehandle_to_save(destination) 69 | 70 | with mock.patch('werkzeug.FileStorage.save') as mocked_save: 71 | dummy_save(filehandle, {'buffer_size': 1}) 72 | 73 | assert mocked_save.call_count == 1 74 | assert mocked_save.call_args == mock.call(destination, 1) 75 | 76 | 77 | def test_string_path_saving(): 78 | source = BytesIO() 79 | filehandle = FileStorage(stream=source, filename='test.png') 80 | dummy_save = transfer._use_filehandle_to_save('test.png') 81 | 82 | with mock.patch('werkzeug.FileStorage.save') as mocked_save: 83 | dummy_save(filehandle, {'buffer_size': None}) 84 | 85 | assert mocked_save.call_args == mock.call('test.png', None) 86 | 87 | 88 | def test_Transfer_setup_blank(): 89 | t = transfer.Transfer() 90 | assert t._destination is None 91 | assert t._validators == [] 92 | assert t._preprocessors == [] 93 | assert t._postprocessors == [] 94 | 95 | 96 | @pytest.mark.parametrize('destination', [ 97 | lambda a: a, BytesIO(), 'dummy/path' 98 | ]) 99 | def test_Transfer_setup_with_destination(destination): 100 | t = transfer.Transfer(destination=destination) 101 | assert callable(t._destination) 102 | 103 | 104 | def test_register_validator(transf): 105 | @transf.validator 106 | def _(fh, meta): 107 | return fh 108 | 109 | assert len(transf._validators) == 1 110 | 111 | 112 | def test_register_preprocessor(transf): 113 | @transf.preprocessor 114 | def _(fh, meta): 115 | return fh 116 | 117 | assert len(transf._preprocessors) == 1 118 | 119 | 120 | def test_register_postprocessor(transf): 121 | @transf.postprocessor 122 | def _(fh, meta): 123 | return fh 124 | 125 | assert len(transf._postprocessors) == 1 126 | 127 | 128 | def test_register_destination(transf): 129 | @transf.destination 130 | def save_path(filehandle, metadata): 131 | pass 132 | 133 | assert transf._destination is save_path 134 | 135 | 136 | def test_Transfer_save_raises_with_no_destination(transf): 137 | with pytest.raises(RuntimeError) as excinfo: 138 | transf.save(FileStorage(), destination=None) 139 | 140 | assert "Destination for filehandle must be provided." == str(excinfo.value) 141 | 142 | 143 | def test_Transfer_validate(transf): 144 | validator = mock.MagicMock() 145 | source = FileStorage(stream=BytesIO(b'Hello World')) 146 | transf.validator(validator) 147 | transf._validate(source, {}) 148 | 149 | assert validator.call_args == mock.call(source, {}) 150 | 151 | 152 | def test_Transfer_validate_raises_with_falsey(transf): 153 | source = FileStorage(stream=BytesIO(), filename='test.conf') 154 | 155 | @transf.validator 156 | def bad_validator(fh, m): False 157 | 158 | with pytest.raises(UploadError) as excinfo: 159 | transf.save(source, metadata={}, catch_all_errors=False, 160 | destination=lambda *a, **k: None) 161 | 162 | expected = "{0!r}({1!r}, {2!r}) returned False".format(bad_validator, 163 | source, {}) 164 | 165 | assert str(excinfo.value) == expected 166 | 167 | 168 | def test_Transfer_validate_catch_all_errors(transf): 169 | @transf.validator 170 | @transf.validator 171 | def derp(filehandle, meta): 172 | raise UploadError('error') 173 | 174 | with pytest.raises(UploadError) as excinfo: 175 | transf._validate('', {}, catch_all_errors=True) 176 | 177 | assert excinfo.value.args[0] == ['error', 'error'] 178 | 179 | 180 | def test_Transfer_validate_bail_on_first_error(transf): 181 | counter = iter(range(2)) 182 | 183 | @transf.validator 184 | @transf.validator 185 | def derp(filehandle, meta): 186 | raise UploadError(str(next(counter))) 187 | 188 | with pytest.raises(UploadError) as excinfo: 189 | transf._validate('', {}, catch_all_errors=False) 190 | 191 | assert str(excinfo.value) == '0' 192 | assert next(counter) == 1 193 | 194 | 195 | def test_Transfer_preprocess(transf): 196 | @transf.preprocessor 197 | def to_upper(filehandle, meta): 198 | filehandle.stream = BytesIO(filehandle.stream.read().lower()) 199 | return filehandle 200 | 201 | source = FileStorage(stream=BytesIO(b'Hello World')) 202 | transf._preprocess(source, {}) 203 | source.seek(0) 204 | assert source.read() == b'hello world' 205 | 206 | 207 | def test_Transfer_postprocess(transf): 208 | @transf.postprocessor 209 | def to_uppercase(filehandle, meta): 210 | filehandle.stream = BytesIO(filehandle.stream.read().upper()) 211 | return filehandle 212 | 213 | source = FileStorage(stream=BytesIO(b'Hello World')) 214 | transf._postprocess(source, {}) 215 | source.seek(0) 216 | 217 | assert source.read() == b'HELLO WORLD' 218 | 219 | 220 | def test_Transfer_save(): 221 | t = ReportingTransfer() 222 | t.save('') 223 | assert t.verify() 224 | 225 | 226 | def test_Transfer_save_toggle_validate(): 227 | t = ReportingTransfer() 228 | t.save('', validate=False) 229 | 230 | assert not t._validated 231 | 232 | 233 | def test_Transfer_callable(transf): 234 | kwargs = {'filehandle': FileStorage(stream=BytesIO(), filename='test.png'), 235 | 'metadata': {}, 'validate': True, 'catch_all_errors': False, 236 | 'destination': 'test.png'} 237 | with mock.patch('flask_transfer.Transfer.save') as mocked_save: 238 | transf(**kwargs) 239 | 240 | assert mocked_save.called 241 | assert mocked_save.call_args == mock.call(**kwargs) 242 | 243 | 244 | def test_nest_Transfer_objs(): 245 | Outer = transfer.Transfer() 246 | Inner = ReportingTransfer() 247 | 248 | Outer.postprocessor(Inner) 249 | 250 | dummy_file = FileStorage(stream=BytesIO(), filename='derp.png') 251 | Outer.save(dummy_file, metadata={}, destination=lambda *a, **k: True) 252 | 253 | assert Inner.verify() 254 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from flask_transfer import validators, UploadError 2 | import pytest 3 | 4 | try: 5 | from unittest import mock 6 | except ImportError: 7 | import mock 8 | 9 | 10 | class DummyFile(object): 11 | def __init__(self, filename): 12 | self.filename = filename 13 | 14 | def __repr__(self): 15 | return 'DummyFile(filename={0})'.format(self.filename) 16 | 17 | 18 | def filename_all_lower(filehandle, metadata): 19 | "Validates that the filename is all lowercase." 20 | if not filehandle.filename.islower(): 21 | raise UploadError('require lowercase filename') 22 | return True 23 | 24 | 25 | def test_raise_with_unimplemented_validate(): 26 | with pytest.raises(NotImplementedError) as excinfo: 27 | validators.BaseValidator()('', {}) 28 | 29 | assert '_validate not implemented' == str(excinfo.value) 30 | 31 | 32 | def test_make_AndValidator(): 33 | v1, v2 = validators.AllowedExts('jpg'), validators.DeniedExts('png') 34 | and_validator = v1 & v2 35 | 36 | assert isinstance(and_validator, validators.AndValidator) 37 | assert and_validator._validators == (v1, v2) 38 | 39 | 40 | def test_AndValidator_success(): 41 | DummyValidator = mock.MagicMock(return_value=True) 42 | dummy_file = DummyFile('awesome.jpg') 43 | and_validator = validators.AndValidator(DummyValidator, 44 | DummyValidator) 45 | assert and_validator(dummy_file, {}) 46 | assert DummyValidator.call_count == 2 47 | assert DummyValidator.call_args == mock.call(dummy_file, {}) 48 | 49 | 50 | def test_AndValidator_failure_with_callable(): 51 | dummy_file = DummyFile(filename='doesntmatter.exe') 52 | truthy = mock.MagicMock(return_value=True) 53 | falsey = mock.MagicMock(return_value=False) 54 | and_validator = validators.AndValidator(falsey, truthy) 55 | 56 | with pytest.raises(UploadError) as excinfo: 57 | and_validator(dummy_file, {}) 58 | 59 | assert 'returned false in' in str(excinfo.value) 60 | assert falsey.call_count == 1 61 | assert falsey.call_args == mock.call(dummy_file, {}) 62 | assert truthy.call_count == 0 63 | 64 | 65 | def test_AndValidator_failure_with_exception(): 66 | toss_error = mock.MagicMock(side_effect=UploadError('something bad happened')) 67 | 68 | dummy_file = DummyFile(filename='kindamatters.exe') 69 | and_validator = validators.AndValidator(toss_error, toss_error) 70 | 71 | with pytest.raises(UploadError) as excinfo: 72 | and_validator(dummy_file, {}) 73 | 74 | assert 'something bad happened' == str(excinfo.value) 75 | 76 | 77 | def test_make_OrValidator(): 78 | v1, v2 = validators.AllowedExts('png'), validators.AllowedExts('jpg') 79 | or_validator = v1 | v2 80 | 81 | assert isinstance(or_validator, validators.OrValidator) 82 | assert or_validator._validators == (v1, v2) 83 | 84 | 85 | def test_OrValidator_success(): 86 | def only_jpgs(fh, m): 87 | return fh.filename.endswith('jpg') 88 | 89 | def only_pngs(fh, m): 90 | return fh.filename.endswith('png') 91 | 92 | or_validator = validators.OrValidator(only_jpgs, only_pngs) 93 | 94 | assert or_validator(DummyFile(filename='awesome.png'), {}) 95 | assert or_validator(DummyFile(filename='awesome.jpg'), {}) 96 | 97 | 98 | def test_OrValidator_failure_with_callable(): 99 | falsey = mock.MagicMock(return_value=False) 100 | or_validator = validators.OrValidator(falsey, falsey) 101 | 102 | with pytest.raises(UploadError) as excinfo: 103 | or_validator(DummyFile(filename='wolololo.wav'), {}) 104 | 105 | assert falsey.call_count == 2 106 | assert len(excinfo.value.args[0]) == 2 107 | assert 'returned false in' in excinfo.value.args[0][0] 108 | 109 | 110 | def test_OrValidator_failure_with_exception(): 111 | toss_error = mock.MagicMock(side_effect=UploadError('something bad happened')) 112 | 113 | or_validator = validators.OrValidator(toss_error, toss_error, toss_error) 114 | with pytest.raises(UploadError) as excinfo: 115 | or_validator(DummyFile(filename='wolololo.wav'), {}) 116 | 117 | assert toss_error.call_count == 3 118 | assert len(excinfo.value.args[0]) == 3 119 | assert all('something bad happened' == e for e in excinfo.value.args[0]) 120 | 121 | 122 | def test_make_NegatedValidator(): 123 | class MyValidator(validators.BaseValidator): 124 | def _validate(fh, m): 125 | return False 126 | 127 | mv = MyValidator() 128 | negated_mv = ~mv 129 | 130 | assert isinstance(negated_mv, validators.NegatedValidator) 131 | assert negated_mv._nested is mv 132 | 133 | 134 | def test_NegatedValidator_turns_false_to_true(): 135 | my_negated = validators.NegatedValidator(lambda fh, m: False) 136 | assert my_negated(DummyFile(filename='cat.lol'), {}) 137 | 138 | 139 | def test_NegatedValidator_turns_UploadError_to_true(): 140 | def toss_error(fh, m): 141 | raise UploadError('never actually seen') 142 | 143 | my_negated = validators.NegatedValidator(toss_error) 144 | assert my_negated(DummyFile(filename='awesome.sh'), {}) 145 | 146 | 147 | def test_NegatedValidator_raises_on_failure(): 148 | def truthy(fh, m): 149 | return True 150 | 151 | my_negated = validators.NegatedValidator(truthy) 152 | 153 | with pytest.raises(UploadError) as excinfo: 154 | my_negated(DummyFile(filename='perfectlyokay.php'), {}) 155 | 156 | assert 'returned false' in str(excinfo.value) 157 | 158 | 159 | def test_FunctionValidator_wraps(): 160 | my_func_validator = validators.FunctionValidator(filename_all_lower) 161 | # don't bother with qualname/annotations because 2.6/2.7 compat 162 | assert my_func_validator.__name__ == filename_all_lower.__name__ 163 | assert my_func_validator.__doc__ == filename_all_lower.__doc__ 164 | assert my_func_validator.__module__ == filename_all_lower.__module__ 165 | 166 | 167 | def test_FunctionValidator_as_decorator(): 168 | @validators.FunctionValidator 169 | def do_nothing(fh, m): 170 | return True 171 | 172 | assert isinstance(do_nothing, validators.FunctionValidator) 173 | assert do_nothing.__name__ == 'do_nothing' 174 | 175 | 176 | def test_FuncionValidator_validates(): 177 | fake = mock.Mock(return_value=True) 178 | fake.__name__ = fake.__doc__ = fake.__module__ = '' 179 | fake_file = DummyFile(filename='inconceivable.py') 180 | 181 | my_func_validator = validators.FunctionValidator(fake) 182 | my_func_validator(fake_file, {}) 183 | 184 | assert fake.call_args == mock.call(fake_file, {}) 185 | 186 | 187 | def test_AllowedExts_raises(): 188 | allowed = validators.AllowedExts('jpg') 189 | with pytest.raises(UploadError) as excinfo: 190 | allowed._validate(DummyFile('sad.png'), {}) 191 | 192 | assert "has an invalid extension" in str(excinfo.value) 193 | 194 | 195 | def test_AllowedExts_okays(): 196 | allowed = validators.AllowedExts('jpg') 197 | assert allowed._validate(DummyFile('awesome.jpg'), {}) 198 | 199 | 200 | def test_invert_AllowedExts(): 201 | exts = frozenset(['jpg', 'png', 'gif']) 202 | flipped = ~validators.AllowedExts(*exts) 203 | assert isinstance(flipped, validators.DeniedExts) and flipped.exts == exts 204 | 205 | 206 | def test_DeniedExts_raises(): 207 | denied = validators.DeniedExts('png') 208 | with pytest.raises(UploadError) as excinfo: 209 | denied._validate(DummyFile('sad.png'), {}) 210 | 211 | assert "has an invalid extension" in str(excinfo.value) 212 | 213 | 214 | def test_DeniedExts_okays(): 215 | denied = validators.DeniedExts('png') 216 | assert denied._validate(DummyFile('awesome.jpg'), {}) 217 | 218 | 219 | def test_invert_DeniedExts(): 220 | exts = frozenset(['jpg', 'gif', 'png']) 221 | flipped = ~validators.DeniedExts(*exts) 222 | assert isinstance(flipped, validators.AllowedExts) and flipped.exts == exts 223 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26,py27,py33,py34 3 | 4 | [testenv] 5 | commands = 6 | py.test -vv --cov={envsitepackagesdir}/flask_transfer --cov-report=term-missing 7 | pep8 --show-source {envsitepackagesdir}/flask_transfer 8 | deps = 9 | pep8 10 | pytest 11 | pytest-cov 12 | py26,py27: mock 13 | 14 | [pep8] 15 | ignore = E123,E133,E731 16 | max-line-length = 100 17 | --------------------------------------------------------------------------------