Skip to content

Instantly share code, notes, and snippets.

@exzhawk
Last active March 26, 2024 18:03
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save exzhawk/33e5dcfc8859e3b6ff4e5269b1ba0ba4 to your computer and use it in GitHub Desktop.
Save exzhawk/33e5dcfc8859e3b6ff4e5269b1ba0ba4 to your computer and use it in GitHub Desktop.
# -*- coding: utf-8 -*-
import os
from html.parser import HTMLParser
import dash
import pandas as pd
import plotly.express as px
import requests
from dash import html, dcc, dash_table, Input, Output
def patch_file(file_path: str, content: bytes, extra: dict = None) -> bytes:
if file_path == 'index.html':
index_html_content = content.decode('utf8')
extra_jsons = f'''
var patched_jsons_content={{
{','.join(["'/" + k + "':" + v.decode("utf8") + "" for k, v in extra.items()])}
}};
'''
patched_content = index_html_content.replace(
'<footer>',
f'''
<footer>
<script>
''' + extra_jsons + '''
const origFetch = window.fetch;
window.fetch = function () {
const e = arguments[0]
if (patched_jsons_content.hasOwnProperty(e)) {
return Promise.resolve({
json: () => Promise.resolve(patched_jsons_content[e]),
headers: new Headers({'content-type': 'application/json'}),
status: 200,
});
} else {
return origFetch.apply(this, arguments)
}
}
</script>
'''
).replace(
'href="/',
'href="'
).replace(
'src="/',
'src="'
)
return patched_content.encode('utf8')
else:
return content
def write_file(file_path: str, content: bytes, target_dir='target', ):
target_file_path = os.path.join(target_dir, file_path.lstrip('/').split('?')[0])
target_leaf_dir = os.path.dirname(target_file_path)
os.makedirs(target_leaf_dir, exist_ok=True)
with open(target_file_path, 'wb') as f:
f.write(content)
pass
class ExternalResourceParser(HTMLParser):
def __init__(self):
super().__init__()
self.resources = []
def handle_starttag(self, tag, attrs):
if tag == 'link':
for k, v in attrs:
if k == 'href':
self.resources.append(v)
if tag == 'script':
for k, v in attrs:
if k == 'src':
self.resources.append(v)
def make_static(base_url, target_dir='target'):
index_html_bytes = requests.get(base_url).content
json_paths = ['_dash-layout', '_dash-dependencies', ]
extra_json = {}
for json_path in json_paths:
json_content = requests.get(base_url + json_path).content
extra_json[json_path] = json_content
patched_bytes = patch_file('index.html', index_html_bytes, extra=extra_json)
write_file('index.html', patched_bytes, target_dir)
parser = ExternalResourceParser()
parser.feed(patched_bytes.decode('utf8'))
extra_js = [
'_dash-component-suites/dash/dcc/async-graph.js',
'_dash-component-suites/dash/dcc/async-plotlyjs.js',
'_dash-component-suites/dash/dash_table/async-table.js',
'_dash-component-suites/dash/dash_table/async-highlight.js'
]
for resource_url in parser.resources + extra_js:
resource_url_full = base_url + resource_url
print(f'get {resource_url_full}')
resource_bytes = requests.get(resource_url_full).content
patched_bytes = patch_file(resource_url, resource_bytes)
write_file(resource_url, patched_bytes, target_dir)
def main():
port = 9050
app = dash.Dash(__name__)
df = pd.DataFrame({
"Fruit": ["Apples", "Oranges", "Bananas", "Apples", "Oranges", "Bananas"],
"Amount": [4, 1, 2, 2, 4, 5],
"City": ["SF", "SF", "SF", "Montreal", "Montreal", "Montreal"]
})
fig = px.bar(df, x="Fruit", y="Amount", color="City", barmode="group")
app.layout = html.Div(children=[
html.Button('save static', id='save', n_clicks=0),
html.Span('', id='saved'),
html.H1(children='Hello Dash'),
html.Div(children='''
Dash: A web application framework for your data.
'''),
dcc.Graph(
id='example-graph',
figure=fig
),
dash_table.DataTable(
id='table',
columns=[{"name": i, "id": i} for i in df.columns],
data=df.to_dict('records'),
)
])
@app.callback(
Output('saved', 'children'),
Input('save', 'n_clicks'),
)
def save_result(n_clicks):
if n_clicks == 0:
return 'not saved'
else:
make_static(f'http://127.0.0.1:{port}/')
return 'saved'
app.run_server(debug=False, port=port)
if __name__ == '__main__':
main()
@exzhawk
Copy link
Author

exzhawk commented Dec 19, 2021

This script shows how to make a Dash layout fully offline.

To see the demo:
Download the Python file and run it. You will see a simple bar chart as well as a table. After clicking the left upper button, you will find a folder besides the Python file named "target". In which you will have a fully offline static HTML file with all other files needed (hopefully). You may stop Python and open the index.html directly inside the "target" folder. You may send the whole "target" folder to others and let them open it without any Python runtime or HTTP server, just with a browser.

Under the hood:
The code is actually simple but hacky. The dash web page relies on ajax communication to fetch JSON data to render. So the make_static function downloads all resources and the JSON, patches the JSON into the index.html file, and tells the scripts in the page to get data from index.html instead of requesting to Python backend.

Limitation:
It's a static HTML file bro. No fancy callbacks anymore. You can only save the initial state rather than the ideal "current layout when the save button is clicked".

@manueldac
Copy link

Hi,

Just to clarify, if I make a callback with this code it ain't possible to save it as the current layout?, I think you were really specific about it, but I'd like to know why it won't do it.

@exzhawk
Copy link
Author

exzhawk commented May 9, 2023

it ain't possible to save it as the current layout?

After opening the page, Dash serves the initial layout. When a callback is invoked (your click, input, etc), Dash then only transfers differential data to patch the layout. The code above opens a new page and saves it. It can not invoke callbacks (mimic your click, input, etc.), save differential data or the patched layout. That's why it won't work with callbacks and current layout.

Technically it is possible, by invoking callbacks or directly dumping current layout from the frontend, but I didn't think it worthwhile to dig that.

@manueldac
Copy link

Thanks for your answer!

@karimkallel
Copy link

karimkallel commented Nov 10, 2023

Hey,
Great Post Man!!!
Is it possible to save to html directly (line135, After defining the layout)

@RajonDawn
Copy link

Hi, exzhawk

I have run your code in vscode, and click the "save" button in the server, then create a 'target' folder normally. But there is no figure graph in the 'index.html', only the table and other divs in the page.

There is the console log in html page. and an error in vscode terminal. Could you help me with this issue? Thanks.
code
index
server

@lesart
Copy link

lesart commented Mar 26, 2024

Hi. Very nice snippet!!!
For those encountering the issue mentioned by @RajonDawn, just change these:
extra_js = [
"_dash-component-suites/dash/dcc/async-graph.js",
# "_dash-component-suites/dash/dcc/async-plotlyjs.js", # TODO: remove
"_dash-component-suites/dash/dash_table/async-table.js",
"_dash-component-suites/dash/dash_table/async-highlight.js",
"_dash-component-suites/plotly/package_data/plotly.min.js", # TODO: add
]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment