Sending files with Flask | Learning Flask Ep. 14
How to send and allow users to download files with Flask
Allowing users to download files from your website of application is an often required feature of any website or application and Flask provides us with some useful function to do so.
In this example, we’re going to allow our users to download 3 types of files, images, CSV’s and PDF’s simply by accessing a route and providing a unique id to the resource.
Let’s get started.
Flask imports
First up, we’re going to need some imports from flask. Go ahead and import the following:
from flask import send_file, send_from_directory, safe_join, abort
send_fileallows us to send the contents of a file to the clientsend_from_directoryallows us to send a specific file from a directory (Recommended)safe_joinallows us to safely join a filename with a file/directory pathabortallows us to abort a request and return an HTTP status code of our choosing
Variable rules
Before we jump in and create any routes, I want to quickly discuss variable rules which we’ve touched on before.
Variable rules allow values to be passed into the URL using <this_syntax> and allows us to work with variable data coming in via the URL.
Although not a necessity, Flask provides us with some useful converters to add an additional layer of validation to any values soming in via the URL.
We an use converters in our URL routes like so:
@app.route("/get-image/<image_name>") # No converter (defaults to string)
@app.route("/get-image/<int:image_number>") # Integer
@app.route("/get-image/<uuid:image_uuid>") # uuid
Full list of variable rules:
Converter
Function
string
Accepts any text without a slash (Default)
int
Accepts positive integers
float
Accepts positive floating point values
path
Like string but also accepts slashes
uuid
Accepts UUID strings (Universally unique identifier) (e.g 118bc9c3-1af4-4d46-87a1-266db2e64e7a)
Using any of the converters listed above will convert the incoming variable into it’s related type.
For example, if you define a url with <int:some_integer>, Flask will try to convert it into an integer, <path:path_to_some_file> will allow a path like string, including slashes etc..
Directory structure
Like many other important application configuration variables, we’re going to add 3 new entries to our app.config object, each with a path to the directories we’ve created to hold the files we want to make available for our users.
But before we do so, we’re going to create some new directories and add some files for our users to download:
- We’re first going to create a new directory inside our
staticdirectory calledclient - Inside of the
clientdirectory, we’ll create 3 more directories,img,csvandpdf - We’ll then place 2 of each file type in their parent folders.
Note - Feel free to use dirrefent file & directory names, just be sure to update the examples in this guide with your own names
Our applications directory/file structure now looks like this (pay attention to the client directory in the static directory):
app
├── app
│ ├── __init__.py
│ ├── admin_views.py
│ ├── static
│ │ ├── client
│ │ │ ├── csv
│ │ │ │ ├── sales_report.csv
│ │ │ │ └── users.csv
│ │ │ ├── img
│ │ │ │ ├── 001.jpg
│ │ │ │ └── 002.jpg
│ │ │ └── pdf
│ │ │ ├── 202de685-1dcb-4272-9aff-3dc10b65ef77.pdf
│ │ │ └── 7471eaf0-f85a-48b9-8450-4ccb5d493210.pdf
│ │ ├── css
│ │ │ └── style.css
│ │ ├── img
│ │ │ ├── flask.png
│ │ │ └── uploads
│ │ │ ├── YT-THUMB.png
│ │ │ ├── post-img.png
│ │ │ └── pythonise_favicon.png
│ │ └── js
│ │ └── app.js
│ ├── templates
│ │ ├── admin
│ │ │ ├── dashboard.html
│ │ │ └── templates
│ │ │ └── admin_template.html
│ │ ├── macros
│ │ │ └── input_macros.html
│ │ └── public
│ │ ├── guestbook.html
│ │ ├── index.html
│ │ ├── jinja.html
│ │ ├── profile.html
│ │ ├── sign_up.html
│ │ ├── templates
│ │ │ └── public_template.html
│ │ └── upload_image.html
│ └── views.py
├── config.py
├── requirements.txt
└── run.py
Now that we’ve got our directories and files in place, let’s update our app.config.
App config
We’re going to create 3 new entries in our app.config object, each containing an absolute path to their corresponding directories:
# The absolute path of the directory containing images for users to download
app.config["CLIENT_IMAGES"] = "/mnt/c/wsl/projects/pythonise/tutorials/flask_series/app/app/static/client/img"
# The absolute path of the directory containing CSV files for users to download
app.config["CLIENT_CSV"] = "/mnt/c/wsl/projects/pythonise/tutorials/flask_series/app/app/static/client/csv"
# The absolute path of the directory containing PDF files for users to download
app.config["CLIENT_PDF"] = "/mnt/c/wsl/projects/pythonise/tutorials/flask_series/app/app/static/client/pdf"
Now that we’ve updated our app config, let’s go ahead and create our routes (I’d recommend using a config file for this which you can read more about here).
Send from directory
The send_from_directory function is the recommended secure way to allow a user to download a file from our application.
Let’s create our first route and discuss it after:
@app.route("/get-image/<image_name>")
def get_image(image_name):
try:
return send_from_directory(app.config["CLIENT_IMAGES"], filename=image_name, as_attachment=True)
except FileNotFoundError:
abort(404)
Let’s step through what we’ve done:
We’re using <image_name> in the URL and expect to receive the filename of the image without any slashes. As we haven’t set a variable rule, Flask will default to string and not allow any slashes.
@app.route("/get-image/<image_name>")
Tip - As a reminder, if you replaced <image_name> with <path:image_name> and a user went to /get-image/path/to/the/image.png, the my_image variable would be path/to/the/image.png, so use with caution.
We then pass the image_name string to the get_image() function.
def get_image(image_name):
We setup a try & except block to catch if the filename isn’t found on the server by using the FileNotFoundError handler.
try:
return send_from_directory(app.config["CLIENT_IMAGES"], filename=image_name, as_attachment=True)
except FileNotFoundError:
abort(404)
Inside the try: block, we call the send_from_directory function and pass it 3 arguments:
app.config["CLIENT_IMAGES"]- The path to the directory containing the images we’re allowing our users to downloadfilename=image_name- Theimage_namevariable passed in from the URLas_attachment=True- Allows the client to download the file as an attachmentsend_from_directoryis then returned
Inside the except FileNotFoundError: block, we call abort() and pass it an HTTP status code, a 404 in the case that the file doesn’t exist.
abort(404)
If you now go to either http://127.0.0.1:5000/get-image/001.png or http://127.0.0.1:5000/get-image/002.png, you’ll instantly download the file!
If you try a filename that doesn’t exist, you’ll get a Not Found error in your browser.
Let’s setup our remaining 2 routes to serve CSV’s and PDF’s.
You’ll notice these 2 routes are very similar to the first, with the addition of the filename variable.
CSV route:
@app.route("/get-csv/<csv_id>")
def get_csv(csv_id):
filename = f"{csv_id}.csv"
try:
return send_from_directory(app.config["CLIENT_CSV"], filename=filename, as_attachment=True)
except FileNotFoundError:
abort(404)
PDF route:
@app.route("/get-pdf/<pdf_id>")
def get_pdf(pdf_id):
filename = f"{pdf_id}.csv"
try:
return send_from_directory(app.config["CLIENT_PDF"], filename=filename, as_attachment=True)
except FileNotFoundError:
abort(404)
Both router are identical apart from the addition of their corresponding file extensions in the filename variable, where we’ve just used an f string to append the extension to the filename.
We’ve hard coded the extension this way as we’re only allowing that type of file extension from their given route. You could of course omit it and ask the user to provide the file extension too.
File path in the URL
You may want a nested directory structure within your trusted base directory, where users can provide a path to a file in the URL to retrieve a file.
Let’s say reports is our trusted base directory, containing several sub-directories and files, like so:
├── app
│ ├── __init__.py
│ ├── admin_views.py
│ ├── static
│ │ ├── client
│ │ │ └── reports
│ │ │ ├── 2017
│ │ │ │ ├── feb
│ │ │ │ │ └── sales
│ │ │ │ │ └── sales_report.csv
│ │ │ │ └── jan
│ │ │ │ └── sales
│ │ │ │ └── sales_report.csv
│ │ │ ├── 2018
│ │ │ │ ├── feb
│ │ │ │ │ └── sales
│ │ │ │ │ └── sales_report.csv
│ │ │ │ └── jan
│ │ │ │ └── sales
│ │ │ │ └── sales_report.csv
│ │ │ └── 2019
│ │ │ ├── feb
│ │ │ │ └── sales
│ │ │ │ └── sales_report.csv
│ │ │ └── jan
│ │ │ └── sales
│ │ │ └── sales_report.csv
Without using a database, we can create a dynamic system of URL’s and allow users to provide a path to a file.
Let’s create a new route and put this into practice, allowing our user to download a report by providing a path in the URL.
First up, we’ll add our reports directory to our app.config:
app.config["CLIENT_REPORTS"] = "/mnt/c/wsl/projects/pythonise/tutorials/flask_series/app/app/static/client/reports"
Now we’ll create the route:
@app.route("/get-report/<path:path>")
def get_report(path):
try:
return send_from_directory(app.config["CLIENT_REPORTS"], filename=path, as_attachment=True)
except FileNotFoundError:
abort(404)
We’re doing exactly the same as above, with the exception of adding the path prefix to the URL variable.
@app.route("/get-report/<path:path>")
The path should be relative from the reports directory saved in our app.config!
If you were to go to /get-report/2018/feb/sales/sales_report.csv, the file would be downloaded. Likewise any non-existent filenames would throw a 404 error.
Send file and safe join
The send_file function is another way to allow users to download and directly access files on your server, however it’s not recommended for any application that may take a filename from user sources.
Tip - Always use
send_from_directorywhere possible.
The reason? send_file will happily return any file from a specified path! I’m sure you wouldn’t want users to be able to downlaod any file from your application at their own will. If you do intend on using send_file, make sure your input source is trusted.
Let’s setup a route to show send_file in action, using Flask’s safe_join function:
@app.route("/get-csv/<path:filename>")
def get_csv(filename):
safe_path = safe_join(app.config["CLIENT_CSV"], filename)
try:
return send_file(safe_path, as_attachment=True)
except FileNotFoundError:
abort(404)
We use safe_join and pass it 2 arguments:
- The TRUSTED base directory
- The UNTRUSTED path to the file
This function will safely join the base directory and zero or more pathnames/filenames and return it to the safe_path variable.
Again, you can send files this way but it’s recommended to use send_from_directory
We then call send_files and pass it the safe_path along with as_attachment=True to allow the user to download the file.
Read more about sending files in Flask over at the official documentation, linked here
Last modified · 28 Feb 2019
Written with StackEdit.