Skip to content

Commit 4f132dc

Browse files
committed
uploader: include metadata in list output (#2941)
Summary: This commit teaches the uploader to display experiment metadata included in `StreamExperiments` responses by supported servers. For servers without this support, the change is a backward-compatible no-op. The format is intentionally undocumented and not under any compatibility guarantees, but is designed to be easily parseable for ad hoc usage. For instance, this simple one-liner finds experiments with lots of points so that the user can delete them: ``` tensorboard dev list | awk '$1 == "Id" { id = $2 } $1 == "Scalars" && $2 > 1000 { print id }' ``` Test Plan: Running against current prod, which does not yet support the new RPCs, the behavior is unchanged: ``` $ bazel run //tensorboard -- dev list https://tensorboard.dev/experiment/IAVF94GPSWWBTvonQe4kgQ/ https://tensorboard.dev/experiment/LiQNYkOHRSGEWj42xtgtjA/ <snip> Total: 12 experiment(s) ``` Running against a local server with support for the new RPCs, we see lots of additional data (tested on both Linux and Windows): ``` $ bazel run //tensorboard -- dev --origin http://localhost:8080 --grpc_creds_type ssl_dev list http://localhost:8080/experiment/WtPawgPIQXi2SZ1fQszOFA/ Id WtPawgPIQXi2SZ1fQszOFA Created 2019-11-25 10:30:18 (23 seconds ago) Updated 2019-11-25 10:30:39 (just now) Scalars 18814 Runs 21 Tags 7 http://localhost:8080/experiment/jD7Qc7l6S8Wy5gWKYTAHOA/ Id jD7Qc7l6S8Wy5gWKYTAHOA Created 2019-11-13 18:32:06 Updated 2019-11-13 18:32:06 Scalars 0 Runs 0 Tags 0 http://localhost:8080/experiment/do8uvvEOSNWOUEANmQIprQ/ Id do8uvvEOSNWOUEANmQIprQ Created 2019-11-13 18:15:25 Updated 2019-11-13 18:15:37 Scalars 3208 Runs 8 Tags 4 <snip> Total: 9 experiment(s) ``` Also tested that the `tensorboard dev export` service still works against both old and new servers. wchargin-branch: uploader-list-metadata
1 parent 757f15a commit 4f132dc

File tree

6 files changed

+154
-11
lines changed

6 files changed

+154
-11
lines changed

tensorboard/uploader/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ py_library(
2020
"//tensorboard:expect_grpc_installed",
2121
"//tensorboard/uploader/proto:protos_all_py_pb2",
2222
"//tensorboard/util:grpc_util",
23+
"@org_pythonhosted_six",
2324
],
2425
)
2526

@@ -58,6 +59,7 @@ py_library(
5859
":exporter_lib",
5960
":server_info",
6061
":uploader_lib",
62+
":util",
6163
"//tensorboard:expect_absl_app_installed",
6264
"//tensorboard:expect_absl_flags_argparse_flags_installed",
6365
"//tensorboard:expect_absl_flags_installed",

tensorboard/uploader/exporter.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import string
2727
import time
2828

29+
import six
30+
2931
from tensorboard.uploader.proto import export_service_pb2
3032
from tensorboard.uploader import util
3133
from tensorboard.util import grpc_util
@@ -126,7 +128,13 @@ def export(self, read_time=None):
126128

127129
def _request_experiment_ids(self, read_time):
128130
"""Yields all of the calling user's experiment IDs, as strings."""
129-
return list_experiments(self._api, read_time=read_time)
131+
for experiment in list_experiments(self._api, read_time=read_time):
132+
if isinstance(experiment, export_service_pb2.Experiment):
133+
yield experiment.experiment_id
134+
elif isinstance(experiment, six.string_types):
135+
yield experiment
136+
else:
137+
raise AssertionError("Unexpected experiment type: %r" % (experiment,))
130138

131139
def _request_scalar_data(self, experiment_id, read_time):
132140
"""Yields JSON-serializable blocks of scalar data."""
@@ -157,31 +165,36 @@ def _request_scalar_data(self, experiment_id, read_time):
157165
}
158166

159167

160-
def list_experiments(api_client, read_time=None):
161-
"""Yields all of the calling user's experiment IDs.
168+
def list_experiments(api_client, fieldmask=None, read_time=None):
169+
"""Yields all of the calling user's experiments.
162170
163171
Args:
164172
api_client: A TensorBoardExporterService stub instance.
173+
fieldmask: An optional `export_service_pb2.ExperimentMask` value.
165174
read_time: A fixed timestamp from which to export data, as float seconds
166175
since epoch (like `time.time()`). Optional; defaults to the current
167176
time.
168177
169178
Yields:
170-
One string for each experiment owned by the calling user, in arbitrary
171-
order.
179+
For each experiment owned by the user, an `export_service_pb2.Experiment`
180+
value, or a simple string experiment ID for older servers.
172181
"""
173182
if read_time is None:
174183
read_time = time.time()
175184
request = export_service_pb2.StreamExperimentsRequest(limit=_MAX_INT64)
176185
util.set_timestamp(request.read_timestamp, read_time)
186+
if fieldmask:
187+
request.experiments_mask.CopyFrom(fieldmask)
177188
stream = api_client.StreamExperiments(
178189
request, metadata=grpc_util.version_metadata())
179190
for response in stream:
180-
if not response.experiments:
191+
if response.experiments:
192+
for experiment in response.experiments:
193+
yield experiment
194+
else:
195+
# Old servers.
181196
for experiment_id in response.experiment_ids:
182197
yield experiment_id
183-
for experiment in response.experiments:
184-
yield experiment.experiment_id
185198

186199

187200
class OutputDirectoryExistsError(ValueError):

tensorboard/uploader/exporter_test.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,15 @@ def stream_experiments(request, **kwargs):
360360
mock_api_client.StreamExperiments = mock.Mock(wraps=stream_experiments)
361361
gen = exporter_lib.list_experiments(mock_api_client)
362362
mock_api_client.StreamExperiments.assert_not_called()
363-
self.assertEqual(list(gen), ["123", "456", "789", "012", "345", "678"])
363+
expected = [
364+
"123",
365+
"456",
366+
export_service_pb2.Experiment(experiment_id="789"),
367+
export_service_pb2.Experiment(experiment_id="012"),
368+
export_service_pb2.Experiment(experiment_id="345"),
369+
export_service_pb2.Experiment(experiment_id="678"),
370+
]
371+
self.assertEqual(list(gen), expected)
364372

365373

366374
class MkdirPTest(tb_test.TestCase):

tensorboard/uploader/uploader_main.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@
3131
import six
3232

3333
from tensorboard.uploader import dev_creds
34+
from tensorboard.uploader.proto import export_service_pb2
3435
from tensorboard.uploader.proto import export_service_pb2_grpc
3536
from tensorboard.uploader.proto import write_service_pb2_grpc
3637
from tensorboard.uploader import auth
3738
from tensorboard.uploader import exporter as exporter_lib
3839
from tensorboard.uploader import server_info as server_info_lib
3940
from tensorboard.uploader import uploader as uploader_lib
41+
from tensorboard.uploader import util
4042
from tensorboard.uploader.proto import server_info_pb2
4143
from tensorboard import program
4244
from tensorboard.plugins import base_plugin
@@ -356,12 +358,34 @@ def get_ack_message_body(self):
356358

357359
def execute(self, server_info, channel):
358360
api_client = export_service_pb2_grpc.TensorBoardExporterServiceStub(channel)
359-
gen = exporter_lib.list_experiments(api_client)
361+
fieldmask = export_service_pb2.ExperimentMask(
362+
create_time=True,
363+
update_time=True,
364+
num_scalars=True,
365+
num_runs=True,
366+
num_tags=True,
367+
)
368+
gen = exporter_lib.list_experiments(api_client, fieldmask=fieldmask)
360369
count = 0
361-
for experiment_id in gen:
370+
for experiment in gen:
362371
count += 1
372+
if not isinstance(experiment, export_service_pb2.Experiment):
373+
url = server_info_lib.experiment_url(server_info, experiment)
374+
print(url)
375+
continue
376+
experiment_id = experiment.experiment_id
363377
url = server_info_lib.experiment_url(server_info, experiment_id)
364378
print(url)
379+
data = [
380+
('Id', experiment.experiment_id),
381+
('Created', util.format_time(experiment.create_time)),
382+
('Updated', util.format_time(experiment.update_time)),
383+
('Scalars', str(experiment.num_scalars)),
384+
('Runs', str(experiment.num_runs)),
385+
('Tags', str(experiment.num_tags)),
386+
]
387+
for (name, value) in data:
388+
print('\t%s %s' % (name.ljust(10), value))
365389
sys.stdout.flush()
366390
if not count:
367391
sys.stderr.write(

tensorboard/uploader/util.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from __future__ import division
1919
from __future__ import print_function
2020

21+
import datetime
2122
import errno
2223
import os
2324
import os.path
@@ -112,3 +113,51 @@ def set_timestamp(pb, seconds_since_epoch):
112113
"""
113114
pb.seconds = int(seconds_since_epoch)
114115
pb.nanos = int(round((seconds_since_epoch % 1) * 10**9))
116+
117+
118+
def format_time(timestamp_pb, now=None):
119+
"""Converts a `timestamp_pb2.Timestamp` to human-readable string.
120+
121+
This always includes the absolute date and time, and for recent dates
122+
may include a relative time like "(just now)" or "(2 hours ago)".
123+
124+
Args:
125+
timestamp_pb: A `google.protobuf.timestamp_pb2.Timestamp` value to
126+
convert to string. The input will not be modified.
127+
now: A `datetime.datetime` object representing the current time,
128+
used for determining relative times like "just now". Optional;
129+
defaults to `datetime.datetime.now()`.
130+
131+
Returns:
132+
A string suitable for human consumption.
133+
"""
134+
135+
# Add and subtract a day for <https://bugs.python.org/issue29097>,
136+
# which breaks early datetime conversions on Windows for small
137+
# timestamps.
138+
dt = datetime.datetime.fromtimestamp(timestamp_pb.seconds + 86400)
139+
dt = dt - datetime.timedelta(seconds=86400)
140+
141+
if now is None:
142+
now = datetime.datetime.now()
143+
ago = now.replace(microsecond=0) - dt
144+
145+
def ago_text(n, singular, plural):
146+
return "%d %s ago" % (n, singular if n == 1 else plural)
147+
148+
relative = None
149+
if ago < datetime.timedelta(seconds=5):
150+
relative = "just now"
151+
elif ago < datetime.timedelta(minutes=1):
152+
relative = ago_text(int(ago.total_seconds()), "second", "seconds")
153+
elif ago < datetime.timedelta(hours=1):
154+
relative = ago_text(int(ago.total_seconds()) // 60, "minute", "minutes")
155+
elif ago < datetime.timedelta(days=1):
156+
relative = ago_text(int(ago.total_seconds()) // 3600, "hour", "hours")
157+
158+
relative_part = " (%s)" % relative if relative is not None else ""
159+
return str(dt) + relative_part
160+
161+
162+
def _ngettext(n, singular, plural):
163+
return "%d %s ago" % (n, singular if n == 1 else plural)

tensorboard/uploader/util_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
from __future__ import division
1919
from __future__ import print_function
2020

21+
import datetime
2122
import os
2223
import unittest
24+
import mock
2325

2426

2527
try:
@@ -195,5 +197,50 @@ def test_set_timestamp(self):
195197
self.assertEqual(pb.nanos, 7812500)
196198

197199

200+
class FormatTimeTest(tb_test.TestCase):
201+
202+
def _run(self, t=None, now=None):
203+
timestamp_pb = timestamp_pb2.Timestamp()
204+
util.set_timestamp(timestamp_pb, t)
205+
now = datetime.datetime.fromtimestamp(now)
206+
with mock.patch.dict(os.environ, {"TZ": "UTC"}):
207+
return util.format_time(timestamp_pb, now=now)
208+
209+
def test_just_now(self):
210+
base = 1546398245
211+
actual = self._run(t=base, now=base + 1)
212+
self.assertEqual(actual, "2019-01-02 03:04:05 (just now)")
213+
214+
def test_seconds_ago(self):
215+
base = 1546398245
216+
actual = self._run(t=base, now=base + 10)
217+
self.assertEqual(actual, "2019-01-02 03:04:05 (10 seconds ago)")
218+
219+
def test_minute_ago(self):
220+
base = 1546398245
221+
actual = self._run(t=base, now=base + 66)
222+
self.assertEqual(actual, "2019-01-02 03:04:05 (1 minute ago)")
223+
224+
def test_minutes_ago(self):
225+
base = 1546398245
226+
actual = self._run(t=base, now=base + 222)
227+
self.assertEqual(actual, "2019-01-02 03:04:05 (3 minutes ago)")
228+
229+
def test_hour_ago(self):
230+
base = 1546398245
231+
actual = self._run(t=base, now=base + 3601)
232+
self.assertEqual(actual, "2019-01-02 03:04:05 (1 hour ago)")
233+
234+
def test_hours_ago(self):
235+
base = 1546398245
236+
actual = self._run(t=base, now=base + 9999)
237+
self.assertEqual(actual, "2019-01-02 03:04:05 (2 hours ago)")
238+
239+
def test_long_ago(self):
240+
base = 1546398245
241+
actual = self._run(t=base, now=base + 7 * 86400)
242+
self.assertEqual(actual, "2019-01-02 03:04:05")
243+
244+
198245
if __name__ == "__main__":
199246
tb_test.main()

0 commit comments

Comments
 (0)