Create + Download file upon clicking a button

Summary

My app is showing multiple plots at once. The plots can change depending on the user input through widgets on the sidebar. Every change to those widgets leads to a new script run from top to bottom in which the plots get rendered again.
Problem:
I want to include a download button on the top of the page providing a zip file of those plots. Now, since the script is re-run every time the user input changes, the zipfile will be created over and over again. Depending on the number of plots, this can be expensive.

How can I trigger the generation AND download of the zip file only upon clicking a button?

Code

MWE:

import streamlit as st
import numpy as np
import matplotlib.pyplot as plt


# create zip file from list of figs
def figs2zip(figs: list[plt.figure]) -> bytes:
    """THIS WILL BE RUN ON EVERY SCRIPT RUN"""
    import io 
    import zipfile
    zip_buf = io.BytesIO()
    with zipfile.ZipFile(file=zip_buf, mode='w', compression=zipfile.ZIP_DEFLATED) as z:
        for i,fig in enumerate(figs):
            buf = io.BytesIO()
            fig.savefig(buf, format='png', bbox_inches='tight')
            filename = f'{i}.png'
            z.writestr(zinfo_or_arcname=filename, data=buf.getvalue() )
            buf.close()
    
    buf = zip_buf.getvalue()
    zip_buf.close()
    return buf


# some plots
x = np.linspace(0, np.pi*2, 50)
fig1, ax1 = plt.subplots(1,1)
ax1.plot(x, np.sin(x))
fig2, ax2 = plt.subplots(1,1)
ax2.plot(x, np.cos(x))

# show plots on page
st.markdown('# Just some plots')

placeholder = st.empty()

st.markdown('### Sin')
st.pyplot(fig1)
st.markdown('### Cos')
st.pyplot(fig2)

# show download button on top
with placeholder:
    st.download_button(label='Download plots', data=figs2zip([fig1, fig2]), file_name='plots.zip')

The above code works, but will create a zip file every time the script is run again from top to bottom.
I want to only create the zip file upon clicking on a button and directly trigger its download. So I replaced the with placeholder:part with some html/javascript by @snehankekre I found here Automatic Download / Select and Download File with Single Button Click - #4 by snehankekre .

# show download button on top
with placeholder:

    def trigger_download(data, filename) -> str:
        import base64
        b64 = base64.b64encode(data).decode()
        dl_link = f"""
                    <html>
                    <head>
                    <script src="http://code.jquery.com/jquery-3.2.1.min.js"></script>
                    <script>
                    $('<a href="data:application/octet-stream;base64,{b64}" download="{filename}">')[0].click()
                    </script>
                    </head>
                    </html>"""
        return dl_link
    
    
    def callback_button() -> None:
        import streamlit.components.v1 as components
        trigger = trigger_download(figs2zip([fig1, fig2]), "plots.zip")
        components.html(html=trigger, height=0, width=0)
        return

    st.button('Download plots', on_click=callback_button)

While this technically works, it will lead to some ugly space added at the top after clicking the download button:
streamlit-test-app-download-2022-10-29-00-10-17

What is the proper way to solve this problem?

Hey @Wally,

Have you considered putting the download button at the bottom of the page (after the plots)? I think that would be the most logical organization, if you want users to look at the plots and then download them. This would also prevent the shifting of the plots.

Hi Caroline,
thanks for having a look at my question. Yes, I started out with using the st.download_button and plugging in my figs2zip function as the data argument.
However, the downside of this is, that the zipfile will be created on every re-run as I pointed out in my post. In the MWE this is negligible, however in my actual app I am showing multiple images (like 20-25). Also there are settings in the sidebar (like sliders and toggles) that will change the underlying data and hence change the plots. So the problem is: As the user is playing around with these settings, the app gets a bit slow and less performant which can be attributed to the fact that the zip file is created fresh every time the script is re-run/the user changes a value of one of the widgets.

So I wanted to implement a solution that would only create (i.e. run my figs2zipfunction) + trigger the download of the file after clicking a button, rather than creating the zipfile on every script run in advance and hence slowing down the app, even though the user wouldn’t click on the st.download_button most of the times.

I hope this explanation makes sense.

Regarding the shifting of the content: It happens because streamlit puts another <div> around the html element. I am not very skilled in CSS, but I wonder if there is a way to somehow “hide” this <div>? Or even better, inject the javascript from above without creating the surrounding <div> at all.

I have a solution for you, but it’s a messy, ‘just make it work’ kind of thing…

Since the juggling around of the content is because of the iframe getting generated up top when the script posts/runs, I set up containers to control where that was happening.

I created three containers:

placeholder = st.container()
plots = st.container()
hide_me = st.columns(2)[0]

Why the thing with columns? If I use empty, it doesn’t work and if I use container I get a “Bad message format” error. So random columns it is!

The button goes in placeholder, all the plots are rendered in plots, and I added an argument to the callback_button function to pass in hide_me so the html can be rendered there (after the plots).

import streamlit as st
import numpy as np
import matplotlib.pyplot as plt

# create zip file from list of figs
def figs2zip(figs: list[plt.figure]) -> bytes:
    """THIS WILL BE RUN ON EVERY SCRIPT RUN"""
    import io 
    import zipfile
    zip_buf = io.BytesIO()
    with zipfile.ZipFile(file=zip_buf, mode='w', compression=zipfile.ZIP_DEFLATED) as z:
        for i,fig in enumerate(figs):
            buf = io.BytesIO()
            fig.savefig(buf, format='png', bbox_inches='tight')
            filename = f'{i}.png'
            z.writestr(zinfo_or_arcname=filename, data=buf.getvalue() )
            buf.close()
    
    buf = zip_buf.getvalue()
    zip_buf.close()
    return buf

# some plots
x = np.linspace(0, np.pi*2, 50)
fig1, ax1 = plt.subplots(1,1)
ax1.plot(x, np.sin(x))
fig2, ax2 = plt.subplots(1,1)
ax2.plot(x, np.cos(x))

# show plots on page
st.markdown('# Just some plots')

# Containers to prevent content from superceding what is desired
# Order locked in at this point
placeholder = st.container()
plots = st.container()
hide_me = st.columns(2)[0]

# show download button on top -> in placeholder
with placeholder:

    def trigger_download(data, filename) -> str:
        import base64
        b64 = base64.b64encode(data).decode()
        dl_link = f"""<script src="http://code.jquery.com/jquery-3.2.1.min.js"></script>
                    <script>
                    $('<a href="data:application/octet-stream;base64,{b64}" download="{filename}">')[0].click()
                    </script>"""
        return dl_link
    
    
    # argument added to callback to control where the hidden script will load
    def callback_button(hidden) -> None:
        import streamlit.components.v1 as components
        trigger = trigger_download(figs2zip([fig1, fig2]), "plots.zip")
        # script is dumped at the end of the page
        with hidden:
            components.html(html=trigger, height=0, width=0)
        return

    st.button('Download plots', on_click=callback_button, args=(hide_me,))

# plots kept above the script, even when the button is clicked by keeping them in plots container
with plots:
    st.markdown('### Sin')
    st.pyplot(fig1)
    st.markdown('### Cos')
    st.pyplot(fig2)

I don’t get any shifting of the screen with this. Does that work for you?