I've recently been using bokeh to produce some charts for my team on a regular basis and I found it a remarkable tool for the job. I particularly appreciate that it will serve web pages from my machine so that I can share the graphs without needing to install anything on my manager's machine.
During this process I created a bokeh script with several command line options to specify which data should be shown over what time frame. While running this over the course of a week or so, I struggled every morning to remember the right set of command line options to get the data I wanted on the external-facing IP address on a fresh port.
I really wanted to have a self-contained script that would launch bokeh as part of its operation, rather than remembering which command line options I needed to specify. I found this SO article which got me part of the way there, but I really wanted to specify IP address and port automatically.
Below we'll walk through my current solution, which is not perfect, but works for what I need. Hopefully it can help you out as well.
Command Line Args
Let's start with the user interface. For this example, you'll just have one option for your graph and two options to pass to bokeh:
def get_command_line_args():
""" Read command line args, of course."""
parser = argparse.ArgumentParser()
parser.add_argument("-b", "--blue", action="store_true")
parser.add_argument(
"-e",
"--external",
action="store_true",
help="serve on local ip address instead of localhost",
)
parser.add_argument("-p", "--port", default="5006", help="socket port")
args = parser.parse_args()
args.ip_addr = get_local_ip_addr(args.external)
if args.blue:
args.color = "blue"
else:
args.color = "red"
return args
The function starts with the graph option, -b
which changes the color of the line from red to blue. You will expand this out to be whatever options you need to pass to your script, or remove it if there are none. We'll see below how this gets passed to the "script" part of my program.
The next option is -e
which switches the bokeh server from localhost
mode to serving the web page on an externally facing IP address. This option is just a bool, but we need to provide bokeh with a full IP address to use. You'll see that code below.
Finally the -p
option is allowed for specifying the starting port number for serving. There is some code to retry with an increasing port number if the starting one is already in use. Again, we'll see this in another section below.
Once you've read the command line args, you can do a little processing on them. Let's skip the get_local_ip_addr()
call at the moment and jump straight to the -b
option. The code uses this bool
to set a new value in args
to indicate if the graph line should be red
or blue
.
Note: This could also be done by reading in a string value with the color in it. There are lots of options here, but this seemed the easiest.
Now you can go and look at the IP address code.
Get Local IP Address
The code shown here is based closely on a Stack Overflow answer:
def get_local_ip_addr(external):
if not external:
return "localhost"
# NOTE: this algorithm is not perfect. It will definitely not work if you
# do not have an external internet connection
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80)) # known valid external IP address
ip_addr = s.getsockname()[0]
s.close()
return ip_addr
As the comment indicates, this is not foolproof but should work for many reasonable situations. It works by opening a network socket to a public DNS server run by Google. It should work with any other reliable IP address that is reachable from your machine.
Once the socket is open, you use .getsockname()
which returns the IP address and port on the local machine for that socket.
Again, there are situations in which this will not work, but generally it will.
Starting the Server
Once we have the command line options set up, we can get to the actual bokeh part of the code. Here is start_server()
:
def start_server(address, port, url, attempts=100):
while attempts:
attempts -= 1
try:
server = Server(
{url: graph_it},
num_procs=1,
port=port,
address=address,
allow_websocket_origin=[f"{address}:{port}",],
)
server.start()
return server, port
except OSError as ex:
if "Address already in use" in str(ex):
print(f"Port {port} busy")
port += 1
else:
raise ex
raise Exception("Failed to find available port")
There's a bunch of code here, but the main part is creating the Server
obejct. In get_command_line_args()
we made sure that both address
and port
are set to valid values, localhost
and 5006
by default.
These values and the passed-in url
are used to create the Server
:
server = Server(
{url: graph_it},
num_procs=1,
port=port,
address=address,
allow_websocket_origin=[f"{address}:{port}",],
)
The first parameter to the constructor maps the passed-in url
to graph_it()
which is a basic bokeh plot I shamelessly stole from an example online.
The num_procs
parameter allows bokeh to use multiprocessing for the underlying tornado server to handle multiple connections. For my example, one was sufficient, but it could be handy to increase in some circumstances.
The port
and address
parameters are used twice, once to tell bokeh where to serve the graph, and once to allow cross-site connections to the address and port it's serving.
Once you've created the Server
object, you call server.start()
to get it running.
The rest of the function is a while
loop that looks for an Address already in use
error to try to find an open port. I found this handy while testing.
The Graphing Function
The graphing function is the actual bokeh script you want to run. This example is the shortest function I could find without too much effort. You should replace this with the script you're passing in to bokeh.
The interesting portion is the hack I for the color arguments. There is a way to pass command line arguments through bokeh to the script it is calling, and I suspect there's an elegant way to do something similar here, but I'll admit I went for the easy round and just made the args
parameter a global and referenced in here:
def graph_it(doc):
global args
p = figure(plot_width=400, plot_height=400, title="My Line Plot")
p.line([1, 2, 3, 4, 5], [6, 7, 2, 4, 5], line_width=2, line_color=args.color)
doc.add_root(p)
If you figure out a better solution, please contact me and I'll update the example! I'd love to see it.
Running the I/O Loop
Finally you've hit the main portion of the script which puts all these pieces together:
if __name__ == "__main__":
args = get_command_line_args()
port = int(args.port)
address = args.ip_addr
url = "/"
try:
server, port = start_server(address, port, url)
except Exception as ex:
print("Failed:", ex)
sys.exit()
try:
print(f"Opening Bokeh application on http://{address}:{port}{url}")
server.io_loop.add_callback(server.show, url)
server.io_loop.start()
except KeyboardInterrupt:
print("\nShutting Down")
You start by reading the command line arguments and setting up the port
, address
, and url
parameters used to run the server.
Next you create the server and start it. Note that this routine passed back the port that it selected as it can make many attempts to find an open port.
The last block starts up the io_loop
for the underlying tornado server which manages the I/O to your bokeh app.
Conclusion
I hope someone found this helpful as I was happy with the solution for my particular problem and I was unable to find a good source of how to do this in my searching.
For completeness I'll include the full script below. Please contact me if you have questions or suggestions to make this article or code better!
Here's the full script:
#!/usr/bin/env python3
import argparse
from bokeh.plotting import figure
from bokeh.server.server import Server
import socket
import sys
def get_local_ip_addr(external):
if not external:
return "localhost"
# NOTE: this algorithm is not perfect. It will definitely not work if you
# do not have an external internet connection
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80)) # known valid external IP address
ip_addr = s.getsockname()[0]
s.close()
return ip_addr
def get_command_line_args():
""" Read command line args, of course."""
parser = argparse.ArgumentParser()
parser.add_argument("-b", "--blue", action="store_true")
parser.add_argument(
"-e",
"--external",
action="store_true",
help="serve on local ip address instead of localhost",
)
parser.add_argument("-p", "--port", default="5006", help="socket port")
args = parser.parse_args()
args.ip_addr = get_local_ip_addr(args.external)
if args.blue:
args.color = "blue"
else:
args.color = "red"
return args
def graph_it(doc):
global args
p = figure(plot_width=400, plot_height=400, title="My Line Plot")
p.line([1, 2, 3, 4, 5], [6, 7, 2, 4, 5], line_width=2, line_color=args.color)
doc.add_root(p)
def start_server(address, port, url, attempts=100):
while attempts:
attempts -= 1
try:
server = Server(
{url: graph_it},
num_procs=1,
port=port,
address=address,
allow_websocket_origin=[f"{address}:{port}",],
)
server.start()
return server, port
except OSError as ex:
if "Address already in use" in str(ex):
print(f"Port {port} busy")
port += 1
else:
raise ex
raise Exception("Failed to find available port")
if __name__ == "__main__":
args = get_command_line_args()
port = int(args.port)
address = args.ip_addr
url = "/"
try:
server, port = start_server(address, port, url)
except Exception as ex:
print("Failed:", ex)
sys.exit()
try:
print(f"Opening Bokeh application on http://{address}:{port}{url}")
server.io_loop.add_callback(server.show, url)
server.io_loop.start()
except KeyboardInterrupt:
print("\nShutting Down")