Skip to content

Commit 092e744

Browse files
authored
uploader: add simple list subcommand (#2903)
Summary: The new `tensorboard dev list` command prints links to your experiments. This is implemented by repurposing the `StreamExperiments` export RPC, which only includes experiment IDs. We can expand this to additionally show useful metadata: experiment creation time and last-modified time; total number of scalars; counts of runs, tags, or time series; and selected run and tag names could all be useful to include. Test Plan: Ran `tensorboard dev list` on an account with 12 experiments and an account with no experiments, starting from both logged-in and logged-out states. Verified that the printed experiment links resolve correctly. Verified that the normal export flow still works. wchargin-branch: uploader-list
1 parent 80a2a4f commit 092e744

File tree

3 files changed

+93
-16
lines changed

3 files changed

+93
-16
lines changed

tensorboard/uploader/exporter.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,7 @@ def export(self, read_time=None):
126126

127127
def _request_experiment_ids(self, read_time):
128128
"""Yields all of the calling user's experiment IDs, as strings."""
129-
request = export_service_pb2.StreamExperimentsRequest(limit=_MAX_INT64)
130-
util.set_timestamp(request.read_timestamp, read_time)
131-
stream = self._api.StreamExperiments(
132-
request, metadata=grpc_util.version_metadata())
133-
for response in stream:
134-
for experiment_id in response.experiment_ids:
135-
yield experiment_id
129+
return list_experiments(self._api, read_time=read_time)
136130

137131
def _request_scalar_data(self, experiment_id, read_time):
138132
"""Yields JSON-serializable blocks of scalar data."""
@@ -163,6 +157,30 @@ def _request_scalar_data(self, experiment_id, read_time):
163157
}
164158

165159

160+
def list_experiments(api_client, read_time=None):
161+
"""Yields all of the calling user's experiment IDs.
162+
163+
Args:
164+
api_client: A TensorBoardExporterService stub instance.
165+
read_time: A fixed timestamp from which to export data, as float seconds
166+
since epoch (like `time.time()`). Optional; defaults to the current
167+
time.
168+
169+
Yields:
170+
One string for each experiment owned by the calling user, in arbitrary
171+
order.
172+
"""
173+
if read_time is None:
174+
read_time = time.time()
175+
request = export_service_pb2.StreamExperimentsRequest(limit=_MAX_INT64)
176+
util.set_timestamp(request.read_timestamp, read_time)
177+
stream = api_client.StreamExperiments(
178+
request, metadata=grpc_util.version_metadata())
179+
for response in stream:
180+
for experiment_id in response.experiment_ids:
181+
yield experiment_id
182+
183+
166184
class OutputDirectoryExistsError(ValueError):
167185
pass
168186

tensorboard/uploader/exporter_test.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,7 @@
4545
class TensorBoardExporterTest(tb_test.TestCase):
4646

4747
def _create_mock_api_client(self):
48-
# Create a stub instance (using a test channel) in order to derive a mock
49-
# from it with autospec enabled. Mocking TensorBoardExporterServiceStub
50-
# itself doesn't work with autospec because grpc constructs stubs via
51-
# metaclassing.
52-
test_channel = grpc_testing.channel(
53-
service_descriptors=[], time=grpc_testing.strict_real_time())
54-
stub = export_service_pb2_grpc.TensorBoardExporterServiceStub(test_channel)
55-
mock_api_client = mock.create_autospec(stub)
56-
return mock_api_client
48+
return _create_mock_api_client()
5749

5850
def _make_experiments_response(self, eids):
5951
return export_service_pb2.StreamExperimentsResponse(experiment_ids=eids)
@@ -323,6 +315,24 @@ def test_propagates_mkdir_errors(self):
323315
mock_api_client.StreamExperimentData.assert_not_called()
324316

325317

318+
class ListExperimentsTest(tb_test.TestCase):
319+
320+
def test(self):
321+
mock_api_client = _create_mock_api_client()
322+
323+
def stream_experiments(request, **kwargs):
324+
del request # unused
325+
yield export_service_pb2.StreamExperimentsResponse(
326+
experiment_ids=["123", "456"])
327+
yield export_service_pb2.StreamExperimentsResponse(
328+
experiment_ids=["789"])
329+
330+
mock_api_client.StreamExperiments = mock.Mock(wraps=stream_experiments)
331+
gen = exporter_lib.list_experiments(mock_api_client)
332+
mock_api_client.StreamExperiments.assert_not_called()
333+
self.assertEqual(list(gen), ["123", "456", "789"])
334+
335+
326336
class MkdirPTest(tb_test.TestCase):
327337

328338
def test_makes_full_chain(self):
@@ -384,5 +394,17 @@ def test_propagates_other_errors(self):
384394
self.assertEqual(cm.exception.errno, errno.ENOENT)
385395

386396

397+
def _create_mock_api_client():
398+
# Create a stub instance (using a test channel) in order to derive a mock
399+
# from it with autospec enabled. Mocking TensorBoardExporterServiceStub
400+
# itself doesn't work with autospec because grpc constructs stubs via
401+
# metaclassing.
402+
test_channel = grpc_testing.channel(
403+
service_descriptors=[], time=grpc_testing.strict_real_time())
404+
stub = export_service_pb2_grpc.TensorBoardExporterServiceStub(test_channel)
405+
mock_api_client = mock.create_autospec(stub)
406+
return mock_api_client
407+
408+
387409
if __name__ == "__main__":
388410
tb_test.main()

tensorboard/uploader/uploader_main.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
_SUBCOMMAND_FLAG = '_uploader__subcommand'
6060
_SUBCOMMAND_KEY_UPLOAD = 'UPLOAD'
6161
_SUBCOMMAND_KEY_DELETE = 'DELETE'
62+
_SUBCOMMAND_KEY_LIST = 'LIST'
6263
_SUBCOMMAND_KEY_EXPORT = 'EXPORT'
6364
_SUBCOMMAND_KEY_AUTH = 'AUTH'
6465
_AUTH_SUBCOMMAND_FLAG = '_uploader__subcommand_auth'
@@ -135,6 +136,10 @@ def _define_flags(parser):
135136
default=None,
136137
help='ID of an experiment to delete permanently')
137138

139+
list_parser = subparsers.add_parser(
140+
'list', help='list previously uploaded experiments')
141+
list_parser.set_defaults(**{_SUBCOMMAND_FLAG: _SUBCOMMAND_KEY_LIST})
142+
138143
export = subparsers.add_parser(
139144
'export', help='download all your experiment data')
140145
export.set_defaults(**{_SUBCOMMAND_FLAG: _SUBCOMMAND_KEY_EXPORT})
@@ -312,6 +317,36 @@ def execute(self, channel):
312317
print('Deleted experiment %s.' % experiment_id)
313318

314319

320+
class _ListIntent(_Intent):
321+
"""The user intends to list all their experiments."""
322+
323+
_MESSAGE = textwrap.dedent(u"""\
324+
This will list all experiments that you've uploaded to
325+
https://tensorboard.dev. TensorBoard.dev experiments are visible
326+
to everyone. Do not upload sensitive data.
327+
""")
328+
329+
def get_ack_message_body(self):
330+
return self._MESSAGE
331+
332+
def execute(self, channel):
333+
api_client = export_service_pb2_grpc.TensorBoardExporterServiceStub(channel)
334+
gen = exporter_lib.list_experiments(api_client)
335+
count = 0
336+
for experiment_id in gen:
337+
count += 1
338+
# TODO(@wchargin): Once #2879 is in, remove this hard-coded URL pattern.
339+
url = 'https://tensorboard.dev/experiment/%s/' % experiment_id
340+
print(url)
341+
sys.stdout.flush()
342+
if not count:
343+
sys.stderr.write(
344+
'No experiments. Use `tensorboard dev upload` to get started.\n')
345+
else:
346+
sys.stderr.write('Total: %d experiment(s)\n' % count)
347+
sys.stderr.flush()
348+
349+
315350
class _UploadIntent(_Intent):
316351
"""The user intends to upload an experiment from the given logdir."""
317352

@@ -421,6 +456,8 @@ def _get_intent(flags):
421456
else:
422457
raise base_plugin.FlagsError(
423458
'Must specify experiment to delete via `--experiment_id`.')
459+
elif cmd == _SUBCOMMAND_KEY_LIST:
460+
return _ListIntent()
424461
elif cmd == _SUBCOMMAND_KEY_EXPORT:
425462
if flags.outdir:
426463
return _ExportIntent(flags.outdir)

0 commit comments

Comments
 (0)