Skip to content

Commit 26b973f

Browse files
committed
notebook: add Jupyter integration
Summary: This commit adds a module `tensorboard.notebook` that can be loaded as a Jupyter extension, providing the `%tensorboard` cell magic to launch and display TensorBoard instances within a notebook. This code doesn’t have to be perfect right now; one main goal for this PR is to sync the structure of this code into google3, where it’s easier to work on and test Colab integration. The UI looks like this: ![Screenshot of a `%tensorboard` command in the Jupyter UI.][1] [1]: https://user-images.githubusercontent.com/4317806/52386325-7ae7eb80-2a3a-11e9-93ab-fc9a689de51c.png Test Plan: Currently, this requires a bit of path hackery to get working: 1. Run `bazel build //tensorboard` to build the main binary. 2. Run `which tensorboard` to find the `tensorboard` binary provided by your virtualenv, and hit it with `chmod -x` to make it not executable. 3. Run `export PATH="$(readlink -e ./bazel-bin/tensorboard):$PATH"` to add it to your path, and confirm that `which tensorboard` points to the built version rather than the version in your virtualenv. 4. Run `jupyter notebook` to start the Jupyter server. 5. Create a notebook and execute `%load_ext tensorboard.notebook` to load the extension; henceforth, `%tensorboard` should work until you restart the Jupyter kernel. (Step (2) is necessary because the `jupyter notebook` runtime adds the virtualenv _back_ to the front of your `PATH`. An alternative is to patch `os.environ["PATH"]` from within the Jupyter notebook.) After setting it up as above, the following makes a good test plan (assuming that you have no other TensorBoard instances running): - `%tensorboard --logdir ~/tb/mnist --port 6006` (should launch) - `%tensorboard --logdir ~/tb/mnist --port 6006` (should reuse) - `%tensorboard --logdir ~/tb/images_demo --port 6006` (should fail) - `%tensorboard --logdir ~/tb/images_demo --port 6007` (should launch) - `%tensorboard --logdir ~/tb/mnist --port 6006` (should reuse #1) - multiple `%tensorboard`s in a single cell: ```py for i in ("images_demo", "audio_demo"): %tensorboard --logdir ~/tb/$i --port 0 ``` - `from tensorboard import notebook` - `notebook.list()` (should list four instances) - `notebook.display(port=6006)` - `notebook.display(height=800)` Finally, if you skip (or revert) step (2) from the setup instructions, you can see the timeout behavior, because we’ll invoke the `tensorboard` provided by PyPI, which does not yet know how to write TensorboardInfo. wchargin-branch: notebook-jupyter
1 parent d1e1403 commit 26b973f

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

tensorboard/BUILD

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ py_test(
117117
],
118118
)
119119

120+
py_library(
121+
name = "notebook",
122+
srcs = ["notebook.py"],
123+
srcs_version = "PY2AND3",
124+
visibility = ["//visibility:public"],
125+
deps = [
126+
":manager",
127+
],
128+
)
129+
120130
py_library(
121131
name = "program",
122132
srcs = ["program.py"],

tensorboard/manager.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@
7979
_TENSORBOARD_INFO_FIELDS,
8080
)
8181

82+
83+
def data_source_from_info(info):
84+
"""Format the data location for the given TensorboardInfo.
85+
86+
Args:
87+
info: A TensorboardInfo value.
88+
89+
Returns:
90+
A human-readable string describing the logdir or database connection
91+
used by the server: e.g., "logdir /tmp/logs".
92+
"""
93+
if info.db:
94+
return "db %s" % info.db
95+
else:
96+
return "logdir %s" % info.logdir
97+
98+
8299
def _info_to_string(info):
83100
"""Convert a `TensorboardInfo` to string form to be stored on disk.
84101

tensorboard/manager_test.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ def test_deserialization_rejects_bad_types(self):
158158
"'2001-02-03T04:05:06'"):
159159
manager._info_from_string(bad_input)
160160

161+
def test_logdir_data_source_format(self):
162+
info = _make_info()._replace(logdir="~/foo", db="")
163+
self.assertEqual(manager.data_source_from_info(info), "logdir ~/foo")
164+
165+
def test_db_data_source_format(self):
166+
info = _make_info()._replace(logdir="", db="sqlite:~/bar")
167+
self.assertEqual(manager.data_source_from_info(info), "db sqlite:~/bar")
168+
161169

162170
class CacheKeyTest(tf.test.TestCase):
163171
"""Unit tests for `manager.cache_key`."""

tensorboard/notebook.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# Copyright 2019 The TensorFlow Authors. All Rights Reserved.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ==============================================================================
14+
"""Utilities for using TensorBoard in notebook contexts, like Colab.
15+
16+
These APIs are experimental and subject to change.
17+
"""
18+
19+
from __future__ import absolute_import
20+
from __future__ import division
21+
from __future__ import print_function
22+
23+
import datetime
24+
import shlex
25+
import sys
26+
27+
from tensorboard import manager
28+
29+
30+
# Return values for `_get_context` (see that function's docs for
31+
# details).
32+
_CONTEXT_COLAB = "_CONTEXT_COLAB"
33+
_CONTEXT_IPYTHON = "_CONTEXT_IPYTHON"
34+
_CONTEXT_NONE = "_CONTEXT_NONE"
35+
36+
37+
def _get_context():
38+
"""Determine the most specific context that we're in.
39+
40+
Returns:
41+
_CONTEXT_COLAB: If in Colab with an IPython notebook context.
42+
_CONTEXT_IPYTHON: If not in Colab, but we are in an IPython notebook
43+
context (e.g., from running `jupyter notebook` at the command
44+
line).
45+
_CONTEXT_NONE: Otherwise (e.g., by running a Python script at the
46+
command-line or using the `ipython` interactive shell).
47+
"""
48+
# In Colab, the `google.colab` module is available, but the shell
49+
# returned by `IPython.get_ipython` does not have a `get_trait`
50+
# method.
51+
try:
52+
import google.colab
53+
import IPython
54+
except ImportError:
55+
pass
56+
else:
57+
if IPython.get_ipython() is not None:
58+
# We'll assume that we're in a Colab notebook context.
59+
return _CONTEXT_COLAB
60+
61+
# In an IPython command line shell or Jupyter notebook, we can
62+
# directly query whether we're in a notebook context.
63+
try:
64+
import IPython
65+
except ImportError:
66+
pass
67+
else:
68+
ipython = IPython.get_ipython()
69+
if ipython is not None and ipython.has_trait("kernel"):
70+
return _CONTEXT_IPYTHON
71+
72+
# Otherwise, we're not in a known notebook context.
73+
return _CONTEXT_NONE
74+
75+
76+
def load_ipython_extension(ipython):
77+
"""IPython API entry point.
78+
79+
Only intended to be called by the IPython runtime.
80+
81+
See:
82+
https://ipython.readthedocs.io/en/stable/config/extensions/index.html
83+
"""
84+
_register_magics(ipython)
85+
86+
87+
def _register_magics(ipython):
88+
"""Register IPython line/cell magics.
89+
90+
Args:
91+
ipython: An `InteractiveShell` instance.
92+
"""
93+
ipython.register_magic_function(
94+
_start_magic,
95+
magic_kind="line",
96+
magic_name="tensorboard",
97+
)
98+
99+
100+
def _start_magic(line):
101+
"""Implementation of the `%tensorboard` line magic."""
102+
return start(line)
103+
104+
105+
def start(args_string):
106+
"""Launch and display a TensorBoard instance as if at the command line.
107+
108+
Args:
109+
args_string: Command-line arguments to TensorBoard, to be
110+
interpreted by `shlex.split`: e.g., "--logdir ./logs --port 0".
111+
Shell metacharacters are not supported: e.g., "--logdir 2>&1" will
112+
point the logdir at the literal directory named "2>&1".
113+
"""
114+
context = _get_context()
115+
try:
116+
import IPython
117+
import IPython.display
118+
except ImportError:
119+
IPython = None
120+
121+
if context == _CONTEXT_NONE:
122+
handle = None
123+
print("Launching TensorBoard...")
124+
else:
125+
handle = IPython.display.display(
126+
IPython.display.Pretty("Launching TensorBoard..."),
127+
display_id=True,
128+
)
129+
130+
def print_or_update(message):
131+
if handle is None:
132+
print(message)
133+
else:
134+
handle.update(IPython.display.Pretty(message))
135+
136+
parsed_args = shlex.split(args_string, comments=True, posix=True)
137+
start_result = manager.start(parsed_args)
138+
139+
if isinstance(start_result, manager.StartLaunched):
140+
_display(
141+
port=start_result.info.port,
142+
print_message=False,
143+
display_handle=handle,
144+
)
145+
146+
elif isinstance(start_result, manager.StartReused):
147+
template = (
148+
"Reusing TensorBoard on port {port} (pid {pid}), started {delta} ago. "
149+
"(Use '!kill {pid}' to kill it.)"
150+
)
151+
message = template.format(
152+
port=start_result.info.port,
153+
pid=start_result.info.pid,
154+
delta=_time_delta_from_info(start_result.info),
155+
)
156+
print_or_update(message)
157+
_display(
158+
port=start_result.info.port,
159+
print_message=False,
160+
display_handle=None,
161+
)
162+
163+
elif isinstance(start_result, manager.StartFailed):
164+
def format_stream(name, value):
165+
if value == "":
166+
return ""
167+
elif value is None:
168+
return "\n<could not read %s>" % name
169+
else:
170+
return "\nContents of %s:\n%s" % (name, value.strip())
171+
message = (
172+
"ERROR: Failed to launch TensorBoard (exited with %d).%s%s" %
173+
(
174+
start_result.exit_code,
175+
format_stream("stderr", start_result.stderr),
176+
format_stream("stdout", start_result.stdout),
177+
)
178+
)
179+
print_or_update(message)
180+
181+
elif isinstance(start_result, manager.StartTimedOut):
182+
message = (
183+
"ERROR: Timed out waiting for TensorBoard to start. "
184+
"It may still be running as pid %d."
185+
% start_result.pid
186+
)
187+
print_or_update(message)
188+
189+
else:
190+
raise TypeError(
191+
"Unexpected result from `manager.start`: %r.\n"
192+
"This is a TensorBoard bug; please report it."
193+
% start_result
194+
)
195+
196+
197+
def _time_delta_from_info(info):
198+
"""Format the elapsed time for the given TensorboardInfo.
199+
200+
Args:
201+
info: A TensorboardInfo value.
202+
203+
Returns:
204+
A human-readable string describing the time since the server
205+
described by `info` started: e.g., "2 days, 0:48:58".
206+
"""
207+
now = datetime.datetime.now()
208+
then = info.start_time
209+
return str(now.replace(microsecond=0) - then.replace(microsecond=0))
210+
211+
212+
def display(port=None, height=None):
213+
"""Display a TensorBoard instance already running on this machine.
214+
215+
Args:
216+
port: The port on which the TensorBoard server is listening, as an
217+
`int`, or `None` to automatically select the most recently
218+
launched TensorBoard.
219+
height: The height of the frame into which to render the TensorBoard
220+
UI, as an `int` number of pixels, or `None` to use a default value
221+
(currently 600).
222+
"""
223+
_display(port=port, height=height, print_message=True, display_handle=None)
224+
225+
226+
def _display(port=None, height=None, print_message=False, display_handle=None):
227+
"""Internal version of `display`.
228+
229+
Args:
230+
port: As with `display`.
231+
height: As with `display`.
232+
print_message: True to print which TensorBoard instance was selected
233+
for display (if applicable), or False otherwise.
234+
display_handle: If not None, an IPython display handle into which to
235+
render TensorBoard.
236+
"""
237+
if height is None:
238+
height = 600
239+
240+
if port is None:
241+
infos = manager.get_all()
242+
if not infos:
243+
raise ValueError("Can't display TensorBoard: no known instances running.")
244+
else:
245+
info = max(manager.get_all(), key=lambda x: x.start_time)
246+
port = info.port
247+
else:
248+
infos = [i for i in manager.get_all() if i.port == port]
249+
info = (
250+
max(infos, key=lambda x: x.start_time)
251+
if infos
252+
else None
253+
)
254+
255+
if print_message:
256+
if info is not None:
257+
message = (
258+
"Selecting TensorBoard with {data_source} "
259+
"(started {delta} ago; port {port}, pid {pid})."
260+
).format(
261+
data_source=manager.data_source_from_info(info),
262+
delta=_time_delta_from_info(info),
263+
port=info.port,
264+
pid=info.pid,
265+
)
266+
print(message)
267+
else:
268+
# The user explicitly provided a port, and we don't have any
269+
# additional information. There's nothing useful to say.
270+
pass
271+
272+
fn = {
273+
_CONTEXT_COLAB: _display_colab,
274+
_CONTEXT_IPYTHON: _display_ipython,
275+
_CONTEXT_NONE: _display_cli,
276+
}[_get_context()]
277+
return fn(port=port, height=height, display_handle=display_handle)
278+
279+
280+
def _display_colab(port, height, display_handle):
281+
# TODO(@wchargin): Implement this after merging this code into
282+
# google3, where it's easier to develop and test against Colab.
283+
raise NotImplementedError()
284+
285+
286+
def _display_ipython(port, height, display_handle):
287+
import IPython.display
288+
iframe = IPython.display.IFrame(
289+
src="http://localhost:%d" % port,
290+
height=height,
291+
width="100%",
292+
)
293+
if display_handle:
294+
display_handle.update(iframe)
295+
else:
296+
IPython.display.display(iframe)
297+
298+
299+
def _display_cli(port, height, display_handle):
300+
del height # unused
301+
del display_handle # unused
302+
message = "Please visit http://localhost:%d in a web browser." % port
303+
print(message)
304+
305+
306+
def list():
307+
"""Print a listing of known running TensorBoard instances.
308+
309+
TensorBoard instances that were killed uncleanly (e.g., with SIGKILL
310+
or SIGQUIT) may appear in this list even if they are no longer
311+
running. Conversely, this list may be missing some entries if your
312+
operating system's temporary directory has been cleared since a
313+
still-running TensorBoard instance started.
314+
"""
315+
infos = manager.get_all()
316+
if not infos:
317+
print("No known TensorBoard instances running.")
318+
return
319+
320+
print("Known TensorBoard instances:")
321+
for info in infos:
322+
template = " - port {port}: {data_source} (started {delta} ago; pid {pid})"
323+
print(template.format(
324+
port=info.port,
325+
data_source=manager.data_source_from_info(info),
326+
delta=_time_delta_from_info(info),
327+
pid=info.pid,
328+
))

0 commit comments

Comments
 (0)