diff --git a/tensorboard/backend/BUILD b/tensorboard/backend/BUILD index 29e434ad77..5925039344 100644 --- a/tensorboard/backend/BUILD +++ b/tensorboard/backend/BUILD @@ -64,6 +64,7 @@ py_library( deps = [ ":empty_path_redirect", ":experiment_id", + ":experimental_plugin", ":http_util", ":path_prefix", ":security_validator", @@ -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"], diff --git a/tensorboard/backend/application.py b/tensorboard/backend/application.py index a9aa08c280..32384e12a0 100644 --- a/tensorboard/backend/application.py +++ b/tensorboard/backend/application.py @@ -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 @@ -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. @@ -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: @@ -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. @@ -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( @@ -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 diff --git a/tensorboard/backend/application_test.py b/tensorboard/backend/application_test.py index b6da5ff591..346454f9e2 100644 --- a/tensorboard/backend/application_test.py +++ b/tensorboard/backend/application_test.py @@ -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") diff --git a/tensorboard/backend/experimental_plugin.py b/tensorboard/backend/experimental_plugin.py new file mode 100644 index 0000000000..7a6b83ed0f --- /dev/null +++ b/tensorboard/backend/experimental_plugin.py @@ -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 diff --git a/tensorboard/components/tf_backend/router.ts b/tensorboard/components/tf_backend/router.ts index e9ba029ca9..5c8b283a4e 100644 --- a/tensorboard/components/tf_backend/router.ts +++ b/tensorboard/components/tf_backend/router.ts @@ -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; @@ -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); } @@ -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( diff --git a/tensorboard/components/tf_backend/test/backendTests.ts b/tensorboard/components/tf_backend/test/backendTests.ts index 4225e37540..d0e64e500c 100644 --- a/tensorboard/components/tf_backend/test/backendTests.ts +++ b/tensorboard/components/tf_backend/test/backendTests.ts @@ -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'); }); @@ -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