Skip to content
Merged
7 changes: 7 additions & 0 deletions tensorboard/backend/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ py_library(
deps = [
":empty_path_redirect",
":experiment_id",
":experimental_plugin",
":http_util",
":path_prefix",
":security_validator",
Expand Down Expand Up @@ -140,6 +141,12 @@ py_test(
],
)

py_library(
name = "experimental_plugin",
srcs = ["experimental_plugin.py"],
srcs_version = "PY2AND3",
)

py_library(
name = "path_prefix",
srcs = ["path_prefix.py"],
Expand Down
31 changes: 29 additions & 2 deletions tensorboard/backend/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from tensorboard import plugin_util
from tensorboard.backend import empty_path_redirect
from tensorboard.backend import experiment_id
from tensorboard.backend import experimental_plugin
from tensorboard.backend import http_util
from tensorboard.backend import path_prefix
from tensorboard.backend import security_validator
Expand Down Expand Up @@ -89,6 +90,8 @@
PLUGINS_LISTING_ROUTE = "/plugins_listing"
PLUGIN_ENTRY_ROUTE = "/plugin_entry.html"

EXPERIMENTAL_PLUGINS_QUERY_PARAM = "experimentalPlugin"

# Slashes in a plugin name could throw the router for a loop. An empty
# name would be confusing, too. To be safe, let's restrict the valid
# names as follows.
Expand Down Expand Up @@ -246,20 +249,33 @@ def TensorBoardWSGIApp(
window_title=flags.window_title,
)
tbplugins = []
experimental_plugins = []
for plugin_spec in plugins:
loader = make_plugin_loader(plugin_spec)
plugin = loader.load(context)
if plugin is None:
continue
tbplugins.append(plugin)
if isinstance(
loader, experimental_plugin.ExperimentalPlugin
) or isinstance(plugin, experimental_plugin.ExperimentalPlugin):
experimental_plugins.append(plugin.plugin_name)
plugin_name_to_instance[plugin.plugin_name] = plugin
return TensorBoardWSGI(tbplugins, flags.path_prefix, data_provider)
return TensorBoardWSGI(
tbplugins, flags.path_prefix, data_provider, experimental_plugins
)


class TensorBoardWSGI(object):
"""The TensorBoard WSGI app that delegates to a set of TBPlugin."""

def __init__(self, plugins, path_prefix="", data_provider=None):
def __init__(
self,
plugins,
path_prefix="",
data_provider=None,
experimental_plugins=None,
):
"""Constructs TensorBoardWSGI instance.

Args:
Expand All @@ -268,6 +284,10 @@ def __init__(self, plugins, path_prefix="", data_provider=None):
data_provider: `tensorboard.data.provider.DataProvider` or
`None`; if present, will inform the "active" state of
`/plugins_listing`.
experimental_plugins: A list of plugin names that are only provided
experimentally. The corresponding plugins will only be activated for
a user if the user has specified the plugin with the experimentalPlugin
query parameter in the URL.

Returns:
A WSGI application for the set of all TBPlugin instances.
Expand All @@ -285,6 +305,7 @@ def __init__(self, plugins, path_prefix="", data_provider=None):
self._plugins = plugins
self._path_prefix = path_prefix
self._data_provider = data_provider
self._experimental_plugins = frozenset(experimental_plugins or ())
if self._path_prefix.endswith("/"):
# Should have been fixed by `fix_flags`.
raise ValueError(
Expand Down Expand Up @@ -467,7 +488,13 @@ def _serve_plugins_listing(self, request):
if self._data_provider is not None
else frozenset()
)
plugins_to_skip = self._experimental_plugins - frozenset(
request.args.getlist(EXPERIMENTAL_PLUGINS_QUERY_PARAM)
)
for plugin in self._plugins:
if plugin.plugin_name in plugins_to_skip:
continue

if (
type(plugin) is core_plugin.CorePlugin
): # pylint: disable=unidiomatic-typecheck
Expand Down
58 changes: 58 additions & 0 deletions tensorboard/backend/application_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,64 @@ def fake_is_active(self):
self.assertEqual(parsed_object["foo"]["enabled"], False)
self.assertEqual(parsed_object["baz"]["enabled"], True)

def testPluginsListingWithExperimentalPlugin(self):
plugins = [
FakePlugin(plugin_name="bar"),
FakePlugin(plugin_name="foo"),
FakePlugin(plugin_name="bazz"),
]
app = application.TensorBoardWSGI(plugins, experimental_plugins=["foo"])
self._install_server(app)

plugins_without_flag = self._get_json("/data/plugins_listing")
self.assertIsNotNone(plugins_without_flag.get("bar"))
self.assertIsNone(plugins_without_flag.get("foo"))
self.assertIsNotNone(plugins_without_flag.get("bazz"))

plugins_with_flag = self._get_json(
"/data/plugins_listing?experimentalPlugin=foo"
)
self.assertIsNotNone(plugins_with_flag.get("bar"))
self.assertIsNotNone(plugins_with_flag.get("foo"))
self.assertIsNotNone(plugins_with_flag.get("bazz"))

plugins_with_useless_flag = self._get_json(
"/data/plugins_listing?experimentalPlugin=bar"
)
self.assertIsNotNone(plugins_with_useless_flag.get("bar"))
self.assertIsNone(plugins_with_useless_flag.get("foo"))
self.assertIsNotNone(plugins_with_useless_flag.get("bazz"))

def testPluginsListingWithMultipleExperimentalPlugins(self):
plugins = [
FakePlugin(plugin_name="bar"),
FakePlugin(plugin_name="foo"),
FakePlugin(plugin_name="bazz"),
]
app = application.TensorBoardWSGI(
plugins, experimental_plugins=["bar", "bazz"]
)
self._install_server(app)

plugins_without_flag = self._get_json("/data/plugins_listing")
self.assertIsNone(plugins_without_flag.get("bar"))
self.assertIsNotNone(plugins_without_flag.get("foo"))
self.assertIsNone(plugins_without_flag.get("bazz"))

plugins_with_one_flag = self._get_json(
"/data/plugins_listing?experimentalPlugin=bar"
)
self.assertIsNotNone(plugins_with_one_flag.get("bar"))
self.assertIsNotNone(plugins_with_one_flag.get("foo"))
self.assertIsNone(plugins_with_one_flag.get("bazz"))

plugins_with_multiple_flags = self._get_json(
"/data/plugins_listing?experimentalPlugin=bar&experimentalPlugin=bazz"
)
self.assertIsNotNone(plugins_with_multiple_flags.get("bar"))
self.assertIsNotNone(plugins_with_multiple_flags.get("foo"))
self.assertIsNotNone(plugins_with_multiple_flags.get("bazz"))

def testPluginEntry(self):
"""Test the data/plugin_entry.html endpoint."""
response = self.server.get("/data/plugin_entry.html?name=baz")
Expand Down
55 changes: 55 additions & 0 deletions tensorboard/backend/experimental_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2020 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Experimental plugin support for TensorBoard.

Contains the mechanism for marking plugins as experimental.
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function


class ExperimentalPlugin(object):
"""A marker class used to annotate a plugin as experimental.

Experimental plugins are hidden from users by default. The plugin will only
be enabled for a user if the user has specified the plugin with the
experimentalPlugin query parameter in the URL.

The marker class can annotate either TBPlugin or TBLoader instances, whichever
is most convenient.

Typical usage is to create a new class that inherits from both an existing
TBPlugin/TBLoader class and this marker class. For example:

class ExperimentalGraphsPlugin(
graphs_plugin.GraphsPlugin,
experimental_plugin.ExperimentalPlugin,
):
pass


class ExperimentalDebuggerPluginLoader(
debugger_plugin_loader.DebuggerPluginLoader,
experimental_plugin.ExperimentalPlugin
):
pass

Note: This class is itself an experimental mechanism and is subject to
modification or removal without warning.
"""

pass
18 changes: 16 additions & 2 deletions tensorboard/components/tf_backend/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ See the License for the specific language governing permissions and
limitations under the License.
==============================================================================*/
namespace tf_backend {
const EXPERIMENTAL_PLUGINS_QUERY_PARAM = 'experimentalPlugin';

export interface Router {
environment: () => string;
experiments: () => string;
Expand All @@ -34,7 +36,10 @@ namespace tf_backend {
*
* @param dataDir {string=} The base prefix for data endpoints.
*/
export function createRouter(dataDir = 'data'): Router {
export function createRouter(
dataDir = 'data',
urlSearchParams = new URLSearchParams(window.location.search)
): Router {
if (dataDir[dataDir.length - 1] === '/') {
dataDir = dataDir.slice(0, dataDir.length - 1);
}
Expand All @@ -52,7 +57,16 @@ namespace tf_backend {
params
);
},
pluginsListing: () => createDataPath(dataDir, '/plugins_listing'),
pluginsListing: () =>
createDataPath(
dataDir,
'/plugins_listing',
createSearchParam({
[EXPERIMENTAL_PLUGINS_QUERY_PARAM]: urlSearchParams.getAll(
EXPERIMENTAL_PLUGINS_QUERY_PARAM
),
})
),
runs: () => createDataPath(dataDir, '/runs'),
runsForExperiment: (id) => {
return createDataPath(
Expand Down
28 changes: 24 additions & 4 deletions tensorboard/components/tf_backend/test/backendTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,6 @@ namespace tf_backend {
});
});

it('returns correct value for #pluginsListing', () => {
assert.equal(router.pluginsListing(), 'data/plugins_listing');
});

it('returns correct value for #runs', () => {
assert.equal(router.runs(), 'data/runs');
});
Expand All @@ -187,6 +183,30 @@ namespace tf_backend {
);
});
});

describe('#pluginsListing', () => {
it('returns /plugins_listing with no query params', () => {
const router = createRouter('data', new URLSearchParams(''));
assert.equal(router.pluginsListing(), 'data/plugins_listing');
});

it('returns /plugins_listing with experimentalPlugin query params', () => {
const router = createRouter(
'data',
new URLSearchParams(
'experimentalPlugin=plugin1&' +
'to_ignore=ignoreme&' +
'experimentalPlugin=plugin2'
)
);
assert.equal(
router.pluginsListing(),
'data/plugins_listing?' +
'experimentalPlugin=plugin1&' +
'experimentalPlugin=plugin2'
);
});
});
});
});
} // namespace tf_backend