Open an external URL generated inside callback

Hi everyone

I’m facing some trouble trying to open an external URL in a Dash App that runs on Cloud Run.
In the app, I use the dash_bootstrap_components.Button to activate the callback that generates the URL and the idea is to automatically open it after it’s generated.
Localy I have used the webbroser Python native library and it works fine. But on Cloud Run when I open the app, the Dash front end doesn’t even load when using this library (I haven’t found why this happens).
I had try using a html.a component with the button embedded inside of it, modifying the HREF of the html.a component, but it doesn’t seem to work neither.

Is the any way to open this URL automatically? Or should I just display a html.a component under the button with the URL attached to it?

Hi @FrancoCapeletti

Can you share a minimal example of how you are creating the URL in the callback?

Hi @AnnMarieW

I’m using GCP Cloud Storage Python Library to generate a signed URL. It would be like any another URL (It’s a long URL, but that shouldn’t be the problem)

@app.callback(Output("download_data_button", "href"),
              [Input("download_data_button", "n_clicks"),
               Input("user", "value")])
def download_data_to_csv(n_clicks, user_current):
   if "download_prod_data_button.n_clicks" in ctx.triggered_prop_ids:
        #This function returns a set of selection of the current user
        data = get_selections(user_current)
       #Function that returns a filtered pandas Data Frame 
        df = apply_filters(production_df, data)
       #Prepare the credentials to conect towards the Cloud Storage
        credentials, project_id = auth.default()
        credentials.refresh(auth.transport.requests.Request())
        service_account_email = credentials.service_account_email
        #Connect to the Cloud Storage Client
        storage_client = storage.Client()
        bucket_storage = storage_client.bucket("my_bucket")
        user_blob_path = f"{user_current}/my_dataframe.csv"
        df_blob = bucket_storage.blob(user_blob_path)
       #Write the DF into Cloud Storage to create the signed URL
        df_blob.upload_from_string(df.to_csv(), 'text/csv', timeout=360)
        url = df_blob.generate_signed_url(
            version="v4",
            expiration=datetime.timedelta(minutes=1),
            method="GET",
            service_account_email=service_account_email,
            access_token=credentials.token
        )
        # all done
        return url

I have printed the URL to the logs and it appears. If I go to that URL, the file is downloaded automatically.

If I add the following code after generating the URL, the Dash page goes blank when opening.

webbrowser.open(url, new=2)

Hello @FrancoCapeletti,

Using webbrowser is probably opening on your server’s environment (or crashes), when testing on your local this was working as you were expecting. :slight_smile:

You can either use dcc.Download to send your data directly as a blob, or… you can have your app take this url and open it in a new window.

It almost looks like you need to separate components, vs the one that you have…

“prepare download” to update the download file link and “download file” which is your newly created link that opens in a new window.

Hi @jinnyzor.

Well, at least that clarifies why webbrowser wasn’t working.

Can you be a little bit more specific with the second part? I don’t have much experience with Dash.

I can’t do a directly download, as this Data Frame can be heavier than 32 MB, and that leads to an Request/Response error cause by a limitation of Cloud Run (The app itself is bigger than this and it was already developed when I got the task to make changes into it a deployed it to the Cloud, so it would be a big task to recode the app to work directly with the dcc.Download)

The app should stay within the same page were the user clicks the Download Button. It doesn’t matter if this URL opens in a new tab or window, as it close itself and downloads the file automatically. Is that part of opening the URL on the client side that I don’t get to achieve.

In your case, I would have two components, one prepares the link and the other just displays it for the user to click:

html.Div([
    html.Button('prepare download link', id='prepareDownload'),
    dcc.Link('need to prepare', id='downloadLink', target='_blank', href='')
])

Then in your callback, return both the href and children to the downloadLink:

return url, 'link available'

Something like this should help it work.

I will try it. The idea was avoiding and extra step to the user, but well, one click more doesn’t hurt anybody, right?
Thanks

If you want to remove the click, then you can have a clientside_callback that responsed to the href being updated and then opens the window for them…

Typically this isnt a good way to perform interactions (frowned upon by browsers), but since the user is in a roundabout way triggering it, I think you will be ok.

Well, I have tried, but it seems that something is not working.
In the callback of download_data_to_csv I remove the previous Output and added the one towards the href, adding the link in the return. But after clicking the button and then the link, it opens a new tab, but it opens the app once again.

Any idea?

P.D: I tried also adding a style to hide the display of the link until the button was clicked, but when doing so, the app wouldn’t display when opened.

The url address is going to be within the app, or is it one that is outside of the app?

Make sure that your url is coming back properly and updating the href of the component.

It’s an URL that goes outside the app. It goes to GCP Cloud Storage.

For what I see in the logs, the URL is generated inside the callback, but, from the perspective of the app, anything has change.

@app.callback([Output("data_dowload_link", "href")
              Output("data_download_link", "value"],
              [Input("download_data_button", "n_clicks"),
               Input("user", "value")])
def download_data_to_csv(n_clicks, user_current):
   if "download_prod_data_button.n_clicks" in ctx.triggered_prop_ids:
        #This function returns a set of selection of the current user
        data = get_selections(user_current)
       #Function that returns a filtered pandas Data Frame 
        df = apply_filters(production_df, data)
       #Prepare the credentials to conect towards the Cloud Storage
        credentials, project_id = auth.default()
        credentials.refresh(auth.transport.requests.Request())
        service_account_email = credentials.service_account_email
        #Connect to the Cloud Storage Client
        storage_client = storage.Client()
        bucket_storage = storage_client.bucket("my_bucket")
        user_blob_path = f"{user_current}/my_dataframe.csv"
        df_blob = bucket_storage.blob(user_blob_path)
       #Write the DF into Cloud Storage to create the signed URL
        df_blob.upload_from_string(df.to_csv(), 'text/csv', timeout=360)
        url = df_blob.generate_signed_url(
            version="v4",
            expiration=datetime.timedelta(minutes=1),
            method="GET",
            service_account_email=service_account_email,
            access_token=credentials.token
        )
        # all done
        return url, "Link to download"

The layout part (There is more code than this, but this is the part that is being modified)

dbc.Row([
    dbc.Col([
        dbc.Button("Download Data",
                   outline=True,
                   color="primary",
                   className="me-1",
                   id="download_data_button",
                   n_clicks=0)], width=5
    ),
    dbc.Col([
        dbc.Button("Download Other Dataset",
                   outline=True,
                   color="primary",
                   className="me-1",
                   id="download_other_data_button",
                   n_clicks=0),
        dcc.Download(id="download_sahara_data")], width=5
    ),
]),
dbc.Row([
    dbc.Col(
        dcc.Link('Not ready', id='data_download_link', target='_blank', href='')
    )
])

Needs to be data_download_link property children, not value. I think this is probably what is causing this issue.

Nope, that hasn’t worked neither. I tried with both “value” and “children” (My bad, “children” is the correct one) but not even the text of the link component has change. I even tried just changing the text itself without modifying the href, but big no no. And there is no error message on the logs.
Inside the function everything works, because I get the logs messages until the end of the function. Is while sending the callback back to dash where it stops. In the browser marks “Updating…” but then finishes and nothing.
Maybe is some kind of problem with dash itself? Bc I don’t get if there is something wrong in the code. Everything you have proposed makes sense and should work. Maybe going for the workaround of forcing a clientside callback?

Can you print the url to console?

Also, what happens if you try running this code locally?

Yes, the URL can be showed in console and it appears.

Localy is the same result. Nothing changes. Even when I replace the generated URL and return in the function “https://www.google.com”, the result is the same, the link sends me to the Dash app again, as it has never changed.

Pass refresh=True.

https://dash.plotly.com/dash-core-components/link

Sorry for not answering, yesterday I decided to leave this aside for a bit while I went on with another project.

The refresh didn’t work either, so I decidec to do a twist and try with the clientside callback. I added in the layout a hidden P component (Children = “None”) that would hold the URL that the clientside callback will use. Then, I modify my previous callback

@app.callback(Output("data_dowload_link", "children"),
              [Input("download_data_button", "n_clicks"),
               Input("user", "value")])
def download_data_to_csv(n_clicks, user_current):
   if "download_prod_data_button.n_clicks" in ctx.triggered_prop_ids:
        #This function returns a set of selection of the current user
        data = get_selections(user_current)
       #Function that returns a filtered pandas Data Frame 
        df = apply_filters(production_df, data)
       #Prepare the credentials to conect towards the Cloud Storage
        credentials, project_id = auth.default()
        credentials.refresh(auth.transport.requests.Request())
        service_account_email = credentials.service_account_email
        #Connect to the Cloud Storage Client
        storage_client = storage.Client()
        bucket_storage = storage_client.bucket("my_bucket")
        user_blob_path = f"{user_current}/my_dataframe.csv"
        df_blob = bucket_storage.blob(user_blob_path)
       #Write the DF into Cloud Storage to create the signed URL
        df_blob.upload_from_string(df.to_csv(), 'text/csv', timeout=360)
        url = df_blob.generate_signed_url(
            version="v4",
            expiration=datetime.timedelta(minutes=1),
            method="GET",
            service_account_email=service_account_email,
            access_token=credentials.token
        )
        # all done
        return url

And then added the next code, following the information given in Dash documentation and some other post:

app.clientside_callback(
   """
  function open_cloud_storage_download_url(download_url) {
      if (download_url != "None") {
         window.open(download_url, "_blank");
         return "None"; //Erase the url after use it
      }
   }
   """
   Output("data_download_link", "children"),
   Input("data_download_link", "children")
)

This “shooooouuuuuuld” work, but it doesn’t. To be more precise, when I open the app, once again it is blank. I might have clue of what is going on.

To go back on how the app is build (Once again, I’m not a Dash expert and the person who build the app neither):
The main layout of the app are two div components and a dcc.Location component inside a main div component. The first div is a menu bar that allows to navigate around the page, and it has various dbc.NavLink components inside a dbc.Nav. Meanwhile, the second div is where the different pages of the app are displayed. When opening the app, in the second div appears the initial page, the “Home page” of the app, where the download button is located. When the user clicks on any of the dbc.NavLink of the first div, a callback is triggered that gets the pathname of the dcc.Location and changes the second div display depending on the value of the pathname.

On code:

app.layout = htmlDiv([dcc.Location(id="app_url"), menu_bar, main_content,
                      html.P("None", id="data_download_link", style= {"display": "none"})])

#This would be another .py file that has the callback for the first div
@app.callback(Output("page-content", "children"),
              [Input("app_url", "pathname")])
def show_page_content(pathname):
   #Each value of the returns are a layout imported from others .py
    if pathname == "/":
        return main_page
    elif pathname == "/page-1":
        return first_page
    elif pathname == "/page-2":
        return second_page


#This would be another .py file that has the layout of the two divs
MENU_STYLE = {
    "position": "fixed",
    "top": 0,
    "left": 0,
    "bottom": 0,
    "width": "16rem",
    "padding": "2rem 2rem",
    "background-color": "#f8f9fa",
}

menu_bar = html.Div(
    [
        dbc.Nav(
            [
                dbc.NavLink("Main Page",
                            id="home-navlink",
                            disabled=False,
                            href="/",
                            active="exact"),
                dbc.NavLink("Page 1",
                            id="first-navlink",
                            disabled=False,
                            href="/page-1",
                            active="exact"),
                dbc.NavLink("Page 2",
                            id="second-navlink",
                            href="/page-2",
                            disabled=False,
                            active="exact")
            ],
            vertical=True,
            pills=True,
        ),
    ],
    style=SIDEBAR_STYLE,
)

CONTENT_STYLE = {
    "top": 0,
    "left": 0,
    "bottom": 0,
    "padding": "2rem 2rem",
    "margin-left": "15rem",
    "margin-right": "0rem",
}

main_content = html.Div(id="page-content", style=CONTENT_STYLE)

I might think that, somehow the java script code that calls the windows.open interferes with the dcc.Location the dbc.NavLink somehow and the main_content component, given that when I open the app, the menu bar appears, but the main content does not. And if I click any of the NavLink, the main_content doesnt change. But I take out the clientside_callback, everything works fine (Except that I don’t get to call the URL)

Any leads from there?

Somehow, your layout isnt being registered, and your url is getting the leading bit of your page.

I’m not sure how this is happening. I cant see all of your code or run even a demo app.

I can not share the whole code, as it is a client aplication.

I could write some dummy version of it. But, in addition to the code of my previous response, what is left are the files of layouts of the main_page, first_page and second_page, and the respective files that have the callbacks of each of them. The only thing important would be the layout of the main_page, so you have the button, but the rest of callbacks and layouts are not necesary (I think), since the interaction that is failing is between the main layout with the location, the callback and the client side callback

Here is an example of it working as it should:

from dash import Dash, Input, Output, html, dcc, no_update
import time

app = Dash(__name__)

app.layout = html.Div([html.Button(id='generateLink', children='Populate New Link'), dcc.Link(target='_blank', href='', refresh=True, children='Pending', id='link')])

links = ['https://google.com', 'https://community.plotly.com', 'https://dash.plotly.com']

@app.callback(
    Output('link', 'children'), Output('link', 'href'),
    Input('generateLink', 'n_clicks')
)
def generate(n):
    if n:
        int = n%3
        print(int)
        time.sleep(1)
        return links[int], links[int]
    return [no_update]*2

app.run(debug=True)
1 Like