diff --git a/tensorboard/BUILD b/tensorboard/BUILD index 5c44779f727..d8d42f068a3 100644 --- a/tensorboard/BUILD +++ b/tensorboard/BUILD @@ -171,6 +171,7 @@ py_library( srcs_version = "PY2AND3", visibility = ["//visibility:public"], deps = [ + "//tensorboard:expect_pkg_resources_installed", "//tensorboard/plugins:base_plugin", "//tensorboard/plugins/audio:audio_plugin", "//tensorboard/plugins/beholder:beholder_plugin_loader", @@ -192,6 +193,21 @@ py_library( ], ) +py_test( + name = "default_test", + size = "small", + srcs = ["default_test.py"], + srcs_version = "PY2AND3", + tags = ["support_notf"], + deps = [ + ":default", + ":test", + "//tensorboard:expect_pkg_resources_installed", + "//tensorboard/plugins:base_plugin", + "@org_pythonhosted_mock", + ], +) + py_library( name = "version", srcs = ["version.py"], @@ -272,6 +288,14 @@ py_library( visibility = ["//visibility:public"], ) +py_library( + name = "expect_pkg_resources_installed", + # This is a dummy rule used as a pkg-resources dependency in open-source. + # We expect pkg-resources to already be installed on the system, e.g., via + # `pip install setuptools`. + visibility = ["//visibility:public"], +) + py_library( name = "tf_contrib_ffmpeg", # This is a dummy rule for the open source world, which indicates diff --git a/tensorboard/backend/application.py b/tensorboard/backend/application.py index b6da83d6a94..6534e43ea4f 100644 --- a/tensorboard/backend/application.py +++ b/tensorboard/backend/application.py @@ -277,7 +277,15 @@ def _serve_plugins_listing(self, request): response = {} for plugin in self._plugins: start = time.time() - response[plugin.plugin_name] = plugin.is_active() + module_path = None + if plugin.es_module_path() is not None: + module_path = (self._path_prefix + DATA_PREFIX + PLUGIN_PREFIX + '/' + + plugin.plugin_name + plugin.es_module_path()) + + response[plugin.plugin_name] = { + 'enabled': plugin.is_active(), + 'es_module_path': module_path, + } elapsed = time.time() - start logger.info( 'Plugin listing: is_active() for %s took %0.3f seconds', diff --git a/tensorboard/backend/application_test.py b/tensorboard/backend/application_test.py index 84d671f3abb..7c2c8e7082f 100644 --- a/tensorboard/backend/application_test.py +++ b/tensorboard/backend/application_test.py @@ -80,6 +80,7 @@ def __init__(self, plugin_name, is_active_value, routes_mapping, + es_module_path_value=None, construction_callback=None): """Constructs a fake plugin. @@ -90,12 +91,15 @@ def __init__(self, is_active_value: Whether the plugin is active. routes_mapping: A dictionary mapping from route (string URL path) to the method called when a user issues a request to that route. + es_module_path_value: An optional string value that indicates a frontend + module entry to the plugin. Must be one of the keys of routes_mapping. construction_callback: An optional callback called when the plugin is constructed. The callback is passed the TBContext. """ self.plugin_name = plugin_name self._is_active_value = is_active_value self._routes_mapping = routes_mapping + self._es_module_path_value = es_module_path_value if construction_callback: construction_callback(context) @@ -116,6 +120,15 @@ def is_active(self): """ return self._is_active_value + def es_module_path(self): + """Returns a path to plugin frontend entry. + + Returns: + A string that corresponds to a key of routes_mapping. For non-dynamic + plugins, it returns None. + """ + return self._es_module_path_value + class ApplicationTest(tb_test.TestCase): def setUp(self): @@ -124,6 +137,15 @@ def setUp(self): None, plugin_name='foo', is_active_value=True, routes_mapping={}), FakePlugin( None, plugin_name='bar', is_active_value=False, routes_mapping={}), + FakePlugin( + None, + plugin_name='baz', + is_active_value=True, + routes_mapping={ + '/esmodule': lambda req: None, + }, + es_module_path_value='/esmodule' + ), ] app = application.TensorBoardWSGI(plugins) self.server = werkzeug_test.Client(app, wrappers.BaseResponse) @@ -146,8 +168,23 @@ def testRequestNonexistentPage(self): def testPluginsListing(self): """Test the format of the data/plugins_listing endpoint.""" parsed_object = self._get_json('/data/plugins_listing') - # Plugin foo is active. Plugin bar is not. - self.assertEqual(parsed_object, {'foo': True, 'bar': False}) + self.assertEqual( + parsed_object, + { + 'foo': { + 'enabled': True, + 'es_module_path': None, + }, + 'bar': { + 'enabled': False, + 'es_module_path': None, + }, + 'baz': { + 'enabled': True, + 'es_module_path': '/data/plugin/baz/esmodule', + }, + } + ) class ApplicationBaseUrlTest(tb_test.TestCase): @@ -158,6 +195,15 @@ def setUp(self): None, plugin_name='foo', is_active_value=True, routes_mapping={}), FakePlugin( None, plugin_name='bar', is_active_value=False, routes_mapping={}), + FakePlugin( + None, + plugin_name='baz', + is_active_value=True, + routes_mapping={ + '/esmodule': lambda req: None, + }, + es_module_path_value='/esmodule' + ), ] app = application.TensorBoardWSGI(plugins, path_prefix=self.path_prefix) self.server = werkzeug_test.Client(app, wrappers.BaseResponse) @@ -186,8 +232,23 @@ def testBaseUrlNonexistentPluginsListing(self): def testPluginsListing(self): """Test the format of the data/plugins_listing endpoint.""" parsed_object = self._get_json(self.path_prefix + '/data/plugins_listing') - # Plugin foo is active. Plugin bar is not. - self.assertEqual(parsed_object, {'foo': True, 'bar': False}) + self.assertEqual( + parsed_object, + { + 'foo': { + 'enabled': True, + 'es_module_path': None, + }, + 'bar': { + 'enabled': False, + 'es_module_path': None, + }, + 'baz': { + 'enabled': True, + 'es_module_path': '/test/data/plugin/baz/esmodule', + }, + } + ) class ApplicationPluginNameTest(tb_test.TestCase): diff --git a/tensorboard/components/tf_tensorboard/tf-tensorboard.html b/tensorboard/components/tf_tensorboard/tf-tensorboard.html index 79cc038686b..7779eb2af0a 100644 --- a/tensorboard/components/tf_tensorboard/tf-tensorboard.html +++ b/tensorboard/components/tf_tensorboard/tf-tensorboard.html @@ -465,17 +465,18 @@

There’s no dashboard by the name of “[[_selectedDashboard]].” value: _.values(tf_tensorboard.dashboardRegistry), }, + _pluginsListing: { + type: Object, + value: () => ({}), + }, + /** - * The set of currently active dashboards. `null` if not yet fetched. - * - * TODO(@wchargin): Consider templating in an initial value for - * this property. - * + * The set of currently active dashboards. * @type {Array?} */ _activeDashboards: { type: Array, - value: null, + computed: '_computeActiveDashboard(_dashboardData, _pluginsListing)' }, /** @type {tf_tensorboard.ActiveDashboardsLoadState} */ @@ -812,15 +813,29 @@

There’s no dashboard by the name of “[[_selectedDashboard]].” this._lastReloadTime = new Date().toString(); }, - _fetchActiveDashboards() { + _computeActiveDashboard() { + return this._dashboardData + .map((d) => d.plugin) + .filter((dashboardName) => { + // TODO(stephanwlee): Remove boolean code path when releasing + // 2.0. + // PluginsListing can be an object whose key is name of the + // plugin and value is a boolean indicating whether if it is + // enabled. This is deprecated but we will maintain backwards + // compatibility for some time. + const maybeMetadata = this._pluginsListing[dashboardName]; + if (typeof maybeMetadata === 'boolean') return maybeMetadata; + return maybeMetadata && maybeMetadata.enabled; + }); + }, + + _fetchPluginsListing() { this._canceller.cancelAll(); - const updateActiveDashboards = this._canceller.cancellable(result => { + const updatePluginsListing = this._canceller.cancellable(result => { if (result.cancelled) { return; } - const activePlugins = result.value; - this._activeDashboards = this._dashboardData.map( - d => d.plugin).filter(p => activePlugins[p]); + this._pluginsListing = result.value; this._activeDashboardsLoadState = tf_tensorboard.ActiveDashboardsLoadState.LOADED; }); const onFailure = () => { @@ -834,7 +849,7 @@

There’s no dashboard by the name of “[[_selectedDashboard]].” }; return this._requestManager .request(tf_backend.getRouter().pluginsListing()) - .then(updateActiveDashboards, onFailure); + .then(updatePluginsListing, onFailure); }, _computeActiveDashboardsNotLoaded(state) { @@ -878,7 +893,7 @@

There’s no dashboard by the name of “[[_selectedDashboard]].” _reloadData() { this._refreshing = true; return Promise.all([ - this._fetchActiveDashboards(), + this._fetchPluginsListing(), tf_backend.environmentStore.refresh(), tf_backend.runsStore.refresh(), tf_backend.experimentsStore.refresh(), diff --git a/tensorboard/default.py b/tensorboard/default.py index 78591db7b55..d2d7f331564 100644 --- a/tensorboard/default.py +++ b/tensorboard/default.py @@ -1,4 +1,4 @@ -# Copyright 2017 The TensorFlow Authors. All Rights Reserved. +# Copyright 2019 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. @@ -31,6 +31,8 @@ import logging import os +import pkg_resources + from tensorboard.compat import tf from tensorboard.plugins import base_plugin from tensorboard.plugins.audio import audio_plugin @@ -86,4 +88,24 @@ def get_plugins(): :rtype: list[Union[base_plugin.TBLoader, Type[base_plugin.TBPlugin]]] """ + return _PLUGINS[:] + + +def get_dynamic_plugins(): + """Returns a list specifying TensorBoard's dynamically loaded plugins. + + A dynamic TensorBoard plugin is specified using entry_points [1] and it is + the robust way to integrate plugins into TensorBoard. + + This list can be passed to the `tensorboard.program.TensorBoard` API. + + Returns: + list of base_plugin.TBLoader or base_plugin.TBPlugin. + + [1]: https://packaging.python.org/specifications/entry-points/ + """ + return [ + entry_point.load() + for entry_point in pkg_resources.iter_entry_points('tensorboard_plugins') + ] diff --git a/tensorboard/default_test.py b/tensorboard/default_test.py new file mode 100644 index 00000000000..c53462edc05 --- /dev/null +++ b/tensorboard/default_test.py @@ -0,0 +1,74 @@ +# Copyright 2017 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. +# ============================================================================== +"""Unit tests for `tensorboard.default`.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +try: + # python version >= 3.3 + from unittest import mock # pylint: disable=g-import-not-at-top +except ImportError: + import mock # pylint: disable=g-import-not-at-top,unused-import + +import pkg_resources + +from tensorboard import default +from tensorboard.plugins import base_plugin +from tensorboard import test + + +class FakePlugin(base_plugin.TBPlugin): + """FakePlugin for testing.""" + + plugin_name = 'fake' + + +class FakeEntryPoint(pkg_resources.EntryPoint): + """EntryPoint class that fake loads FakePlugin.""" + + @classmethod + def create(cls): + """Creates an instance of FakeEntryPoint. + + Returns: + instance of FakeEntryPoint + """ + return cls('foo', 'bar') + + def load(self): + """Returns FakePlugin instead of resolving module. + + Returns: + FakePlugin + """ + return FakePlugin + + +class DefaultTest(test.TestCase): + + @mock.patch.object(pkg_resources, 'iter_entry_points') + def test_get_dynamic_plugin(self, mock_iter_entry_points): + mock_iter_entry_points.return_value = [FakeEntryPoint.create()] + + actual_plugins = default.get_dynamic_plugins() + + mock_iter_entry_points.assert_called_with('tensorboard_plugins') + self.assertEqual(actual_plugins, [FakePlugin]) + + +if __name__ == "__main__": + test.main() diff --git a/tensorboard/http_api.md b/tensorboard/http_api.md index 2144e931946..b4acd28a285 100644 --- a/tensorboard/http_api.md +++ b/tensorboard/http_api.md @@ -35,13 +35,35 @@ The `logdir` argument is the path of the directory that contains events files. ## `data/plugins_listing` -Returns a dict mapping from plugin name to a boolean indicating whether +Returns a dict mapping from plugin name to an object that describes a +plugin. + +The object contains a property `enabled`, a boolean indicating whether the plugin is active. A plugin might be inactive, for instance, if it lacks relevant data. Every plugin has a key. This route allows the frontend to hide or deprioritize inactive plugins so that the user can focus on the plugins that have data. Note that inactive plugins may still be rendered if the user explicitly requests this. +Another property `es_module_path` is an optional one that describes a +path to the main JavaScript plugin module that will be loaded onto an +iframe. For "v1" plugins whose JavaScript source is incorporated into +webfiles.zip, the field is null. + +Example response: + + { + "scalars": { + "enabled": true, + "es_module_path": null + }, + "my_shiny_plugin": { + "enabled": true, + "es_module_path": "/data/plugin/my_shiny_plugin/main.js" + } + } + + ## `data/runs` Returns an array containing the names of all the runs known to the diff --git a/tensorboard/main.py b/tensorboard/main.py index a4823b68d9a..da0521a852d 100644 --- a/tensorboard/main.py +++ b/tensorboard/main.py @@ -54,8 +54,9 @@ def run_main(): print("TensorFlow installation not found - running with reduced feature set.", file=sys.stderr) - tensorboard = program.TensorBoard(default.get_plugins(), - program.get_default_assets_zip_provider()) + tensorboard = program.TensorBoard( + default.get_plugins() + default.get_dynamic_plugins(), + program.get_default_assets_zip_provider()) try: from absl import app # Import this to check that app.run() will accept the flags_parser argument. diff --git a/tensorboard/pip_package/setup.py b/tensorboard/pip_package/setup.py index f029890eb4d..4c2f4f0f908 100644 --- a/tensorboard/pip_package/setup.py +++ b/tensorboard/pip_package/setup.py @@ -30,6 +30,7 @@ 'markdown >= 2.6.8', 'numpy >= 1.12.0', 'protobuf >= 3.6.0', + 'setuptools >= 41.0.0', 'six >= 1.10.0', 'werkzeug >= 0.11.15', # python3 specifically requires wheel 0.26 diff --git a/tensorboard/plugins/base_plugin.py b/tensorboard/plugins/base_plugin.py index f510c8cda82..ccf82945018 100644 --- a/tensorboard/plugins/base_plugin.py +++ b/tensorboard/plugins/base_plugin.py @@ -75,6 +75,21 @@ def is_active(self): """ raise NotImplementedError() + def es_module_path(self): + """Returns one of the keys in get_plugin_apps that is an entry ES module. + + For a plugin that is loaded into an iframe, a frontend entry point has to be + specified. For a plugin that is bundled with TensorBoard as part of + webfiles.zip, return None. + + TODO(tensorboard-team): describe the contract/API for the ES module when + it is better defined. + + Returns: + A key in the result of `get_plugin_apps()`, or None. + """ + return None + class TBContext(object): """Magic container of information passed from TensorBoard core to plugins.