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_file
allows us to send the contents of a file to the clientsend_from_directory
allows us to send a specific file from a directory (Recommended)safe_join
allows us to safely join a filename with a file/directory pathabort
allows 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
static
directory calledclient
- Inside of the
client
directory, we’ll create 3 more directories,img
,csv
andpdf
- 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_name
variable passed in from the URLas_attachment=True
- Allows the client to download the file as an attachmentsend_from_directory
is 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_directory
where 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.