diff --git a/tensorboard/uploader/BUILD b/tensorboard/uploader/BUILD index 88c1ae5eac..9f25a6f5e6 100644 --- a/tensorboard/uploader/BUILD +++ b/tensorboard/uploader/BUILD @@ -44,6 +44,26 @@ py_test( ], ) +py_library( + name = "formatters", + srcs = ["formatters.py"], + srcs_version = "PY3", + deps = [ + ":util", + ], +) + +py_test( + name = "formatters_test", + srcs = ["formatters_test.py"], + deps = [ + ":formatters", + ":util", + "//tensorboard:test", + "//tensorboard/uploader/proto:protos_all_py_pb2", + ], +) + py_binary( name = "uploader", srcs = ["uploader_main.py"], @@ -60,6 +80,7 @@ py_library( ":dev_creds", ":exporter_lib", ":flags_parser", + ":formatters", ":server_info", ":uploader_lib", ":util", diff --git a/tensorboard/uploader/flags_parser.py b/tensorboard/uploader/flags_parser.py index 035e7382da..147386a191 100644 --- a/tensorboard/uploader/flags_parser.py +++ b/tensorboard/uploader/flags_parser.py @@ -177,6 +177,11 @@ def define_flags(parser): "list", help="list previously uploaded experiments" ) list_parser.set_defaults(**{SUBCOMMAND_FLAG: SUBCOMMAND_KEY_LIST}) + list_parser.add_argument( + "--json", + action="store_true", + help="print the experiments as JSON objects", + ) export = subparsers.add_parser( "export", help="download all your experiment data" diff --git a/tensorboard/uploader/formatters.py b/tensorboard/uploader/formatters.py new file mode 100644 index 0000000000..ba83d92dfb --- /dev/null +++ b/tensorboard/uploader/formatters.py @@ -0,0 +1,99 @@ +# 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. +# ============================================================================== +"""Helpers that format the information about experiments as strings.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import abc +import collections +import json + +from tensorboard.uploader import util + + +class BaseExperimentFormatter(object): + """Abstract base class for formatting experiment information as a string.""" + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def format_experiment(self, experiment, experiment_url): + """Format the information about an experiment as a representing string. + + Args: + experiment: An `experiment_pb2.Experiment` protobuf message for the + experiment to be formatted. + experiment_url: The URL at which the experiment can be accessed via + TensorBoard. + + Returns: + A string that represents the experiment. + """ + pass + + +class ReadableFormatter(BaseExperimentFormatter): + """A formatter implementation that outputs human-readable text.""" + + _NAME_COLUMN_WIDTH = 12 + + def __init__(self): + super(ReadableFormatter, self).__init__() + + def format_experiment(self, experiment, experiment_url): + output = [] + output.append(experiment_url) + data = [ + ("Name", experiment.name or "[No Name]"), + ("Description", experiment.description or "[No Description]"), + ("Id", experiment.experiment_id), + ("Created", util.format_time(experiment.create_time)), + ("Updated", util.format_time(experiment.update_time)), + ("Runs", str(experiment.num_runs)), + ("Tags", str(experiment.num_tags)), + ("Scalars", str(experiment.num_scalars)), + ] + for name, value in data: + output.append( + "\t%s %s" % (name.ljust(self._NAME_COLUMN_WIDTH), value,) + ) + return "\n".join(output) + + +class JsonFormatter(object): + """A formatter implementation: outputs experiment as JSON.""" + + _JSON_INDENT = 2 + + def __init__(self): + super(JsonFormatter, self).__init__() + + def format_experiment(self, experiment, experiment_url): + data = [ + ("url", experiment_url), + ("name", experiment.name), + ("description", experiment.description), + ("id", experiment.experiment_id), + ("created", util.format_time_absolute(experiment.create_time)), + ("updated", util.format_time_absolute(experiment.update_time)), + ("runs", experiment.num_runs), + ("tags", experiment.num_tags), + ("scalars", experiment.num_scalars), + ] + return json.dumps( + collections.OrderedDict(data), indent=self._JSON_INDENT, + ) diff --git a/tensorboard/uploader/formatters_test.py b/tensorboard/uploader/formatters_test.py new file mode 100644 index 0000000000..7404b15d71 --- /dev/null +++ b/tensorboard/uploader/formatters_test.py @@ -0,0 +1,113 @@ +# 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. +# ============================================================================== +# Lint as: python3 +"""Tests for tensorboard.uploader.formatters.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +from tensorboard import test as tb_test +from tensorboard.uploader import formatters +from tensorboard.uploader.proto import experiment_pb2 + +from tensorboard.uploader import util + + +class TensorBoardExporterTest(tb_test.TestCase): + def testReadableFormatterWithNonemptyNameAndDescription(self): + experiment = experiment_pb2.Experiment( + experiment_id="deadbeef", + name="A name for the experiment", + description="A description for the experiment", + num_runs=2, + num_tags=4, + num_scalars=60, + ) + util.set_timestamp(experiment.create_time, 981173106) + util.set_timestamp(experiment.update_time, 1015218367) + experiment_url = "http://tensorboard.dev/deadbeef" + formatter = formatters.ReadableFormatter() + output = formatter.format_experiment(experiment, experiment_url) + expected_lines = [ + "http://tensorboard.dev/deadbeef", + "\tName A name for the experiment", + "\tDescription A description for the experiment", + "\tId deadbeef", + "\tCreated 2001-02-03 04:05:06", + "\tUpdated 2002-03-04 05:06:07", + "\tRuns 2", + "\tTags 4", + "\tScalars 60", + ] + self.assertEqual(output.split("\n"), expected_lines) + + def testReadableFormatterWithEmptyNameAndDescription(self): + experiment = experiment_pb2.Experiment( + experiment_id="deadbeef", + # NOTE(cais): `name` and `description` are missing here. + num_runs=2, + num_tags=4, + num_scalars=60, + ) + util.set_timestamp(experiment.create_time, 981173106) + util.set_timestamp(experiment.update_time, 1015218367) + experiment_url = "http://tensorboard.dev/deadbeef" + formatter = formatters.ReadableFormatter() + output = formatter.format_experiment(experiment, experiment_url) + expected_lines = [ + "http://tensorboard.dev/deadbeef", + "\tName [No Name]", + "\tDescription [No Description]", + "\tId deadbeef", + "\tCreated 2001-02-03 04:05:06", + "\tUpdated 2002-03-04 05:06:07", + "\tRuns 2", + "\tTags 4", + "\tScalars 60", + ] + self.assertEqual(output.split("\n"), expected_lines) + + def testJsonFormatterWithEmptyNameAndDescription(self): + experiment = experiment_pb2.Experiment( + experiment_id="deadbeef", + # NOTE(cais): `name` and `description` are missing here. + num_runs=2, + num_tags=4, + num_scalars=60, + ) + util.set_timestamp(experiment.create_time, 981173106) + util.set_timestamp(experiment.update_time, 1015218367) + experiment_url = "http://tensorboard.dev/deadbeef" + formatter = formatters.JsonFormatter() + output = formatter.format_experiment(experiment, experiment_url) + expected_lines = [ + "{", + ' "url": "http://tensorboard.dev/deadbeef",', + ' "name": "",', + ' "description": "",', + ' "id": "deadbeef",', + ' "created": "2001-02-03T04:05:06Z",', + ' "updated": "2002-03-04T05:06:07Z",', + ' "runs": 2,', + ' "tags": 4,', + ' "scalars": 60', + "}", + ] + self.assertEqual(output.split("\n"), expected_lines) + + +if __name__ == "__main__": + tb_test.main() diff --git a/tensorboard/uploader/uploader_main.py b/tensorboard/uploader/uploader_main.py index 41cef50db0..17c87458db 100644 --- a/tensorboard/uploader/uploader_main.py +++ b/tensorboard/uploader/uploader_main.py @@ -36,6 +36,7 @@ from tensorboard.uploader import auth from tensorboard.uploader import exporter as exporter_lib from tensorboard.uploader import flags_parser +from tensorboard.uploader import formatters from tensorboard.uploader import server_info as server_info_lib from tensorboard.uploader import uploader as uploader_lib from tensorboard.uploader import util @@ -332,6 +333,15 @@ class _ListIntent(_Intent): """ ) + def __init__(self, json=None): + """Constructor of _ListIntent. + + Args: + json: If and only if `True`, will print the list as pretty-formatted + JSON objects, one object for each experiment. + """ + self.json = json + def get_ack_message_body(self): return self._MESSAGE @@ -348,23 +358,16 @@ def execute(self, server_info, channel): ) gen = exporter_lib.list_experiments(api_client, fieldmask=fieldmask) count = 0 + + if self.json: + formatter = formatters.JsonFormatter() + else: + formatter = formatters.ReadableFormatter() for experiment in gen: count += 1 experiment_id = experiment.experiment_id url = server_info_lib.experiment_url(server_info, experiment_id) - print(url) - data = [ - ("Name", experiment.name or "[No Name]"), - ("Description", experiment.description or "[No Description]"), - ("Id", experiment.experiment_id), - ("Created", util.format_time(experiment.create_time)), - ("Updated", util.format_time(experiment.update_time)), - ("Scalars", str(experiment.num_scalars)), - ("Runs", str(experiment.num_runs)), - ("Tags", str(experiment.num_tags)), - ] - for (name, value) in data: - print("\t%s %s" % (name.ljust(12), value)) + print(formatter.format_experiment(experiment, url)) sys.stdout.flush() if not count: sys.stderr.write( @@ -550,7 +553,7 @@ def _get_intent(flags): "Must specify experiment to delete via `--experiment_id`." ) elif cmd == flags_parser.SUBCOMMAND_KEY_LIST: - return _ListIntent() + return _ListIntent(json=flags.json) elif cmd == flags_parser.SUBCOMMAND_KEY_EXPORT: if flags.outdir: return _ExportIntent(flags.outdir)