Skip to content

Commit a322279

Browse files
bileschinfelt
authored andcommitted
Support experiment name and description in uploader (#3277)
* Adds name and description to the output of "list" subcommand * Adds name and description as upload options to the uploader * add subcommand for update-metadata to change name and description * black * adds additional testing. Fixes a logging error * Adds additional testing. Changes print output to log.info * black * stray keystroke * black * Adds test for INVALID_ARGUMENT. Addresses more reviewer critiques * black & stray edit * evenblacker
1 parent 7624b6e commit a322279

File tree

3 files changed

+360
-6
lines changed

3 files changed

+360
-6
lines changed

tensorboard/uploader/uploader.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import six
2727

2828
from tensorboard.uploader.proto import write_service_pb2
29+
from tensorboard.uploader.proto import experiment_pb2
2930
from tensorboard.uploader import logdir_loader
3031
from tensorboard.uploader import util
3132
from tensorboard import data_compat
@@ -64,7 +65,14 @@
6465
class TensorBoardUploader(object):
6566
"""Uploads a TensorBoard logdir to TensorBoard.dev."""
6667

67-
def __init__(self, writer_client, logdir, rpc_rate_limiter=None):
68+
def __init__(
69+
self,
70+
writer_client,
71+
logdir,
72+
rpc_rate_limiter=None,
73+
name=None,
74+
description=None,
75+
):
6876
"""Constructs a TensorBoardUploader.
6977
7078
Args:
@@ -77,9 +85,13 @@ def __init__(self, writer_client, logdir, rpc_rate_limiter=None):
7785
of chunks. Note the chunk stream is internally rate-limited by
7886
backpressure from the server, so it is not a concern that we do not
7987
explicitly rate-limit within the stream here.
88+
name: String name to assign to the experiment.
89+
description: String description to assign to the experiment.
8090
"""
8191
self._api = writer_client
8292
self._logdir = logdir
93+
self._name = name
94+
self._description = description
8395
self._request_sender = None
8496
if rpc_rate_limiter is None:
8597
self._rpc_rate_limiter = util.RateLimiter(
@@ -103,7 +115,9 @@ def __init__(self, writer_client, logdir, rpc_rate_limiter=None):
103115
def create_experiment(self):
104116
"""Creates an Experiment for this upload session and returns the ID."""
105117
logger.info("Creating experiment")
106-
request = write_service_pb2.CreateExperimentRequest()
118+
request = write_service_pb2.CreateExperimentRequest(
119+
name=self._name, description=self._description
120+
)
107121
response = grpc_util.call_with_retries(
108122
self._api.CreateExperiment, request
109123
)
@@ -140,6 +154,50 @@ def _upload_once(self):
140154
self._request_sender.send_requests(run_to_events)
141155

142156

157+
def update_experiment_metadata(
158+
writer_client, experiment_id, name=None, description=None
159+
):
160+
"""Modifies user data associated with an experiment.
161+
162+
Args:
163+
writer_client: a TensorBoardWriterService stub instance
164+
experiment_id: string ID of the experiment to modify
165+
name: If provided, modifies name of experiment to this value.
166+
description: If provided, modifies the description of the experiment to
167+
this value
168+
169+
Raises:
170+
ExperimentNotFoundError: If no such experiment exists.
171+
PermissionDeniedError: If the user is not authorized to modify this
172+
experiment.
173+
InvalidArgumentError: If the server rejected the name or description, if,
174+
for instance, the size limits have changed on the server.
175+
"""
176+
logger.info("Modifying experiment %r", experiment_id)
177+
request = write_service_pb2.UpdateExperimentRequest()
178+
request.experiment.experiment_id = experiment_id
179+
if name is not None:
180+
logger.info("Setting exp %r name to %r", experiment_id, name)
181+
request.experiment.name = name
182+
request.experiment_mask.name = True
183+
if description is not None:
184+
logger.info(
185+
"Setting exp %r description to %r", experiment_id, description
186+
)
187+
request.experiment.description = description
188+
request.experiment_mask.description = True
189+
try:
190+
grpc_util.call_with_retries(writer_client.UpdateExperiment, request)
191+
except grpc.RpcError as e:
192+
if e.code() == grpc.StatusCode.NOT_FOUND:
193+
raise ExperimentNotFoundError()
194+
if e.code() == grpc.StatusCode.PERMISSION_DENIED:
195+
raise PermissionDeniedError()
196+
if e.code() == grpc.StatusCode.INVALID_ARGUMENT:
197+
raise InvalidArgumentError(e.details())
198+
raise
199+
200+
143201
def delete_experiment(writer_client, experiment_id):
144202
"""Permanently deletes an experiment and all of its contents.
145203
@@ -166,6 +224,10 @@ def delete_experiment(writer_client, experiment_id):
166224
raise
167225

168226

227+
class InvalidArgumentError(RuntimeError):
228+
pass
229+
230+
169231
class ExperimentNotFoundError(RuntimeError):
170232
pass
171233

tensorboard/uploader/uploader_main.py

Lines changed: 166 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,20 @@
6565
_SUBCOMMAND_KEY_DELETE = "DELETE"
6666
_SUBCOMMAND_KEY_LIST = "LIST"
6767
_SUBCOMMAND_KEY_EXPORT = "EXPORT"
68+
_SUBCOMMAND_KEY_UPDATE_METADATA = "UPDATEMETADATA"
6869
_SUBCOMMAND_KEY_AUTH = "AUTH"
6970
_AUTH_SUBCOMMAND_FLAG = "_uploader__subcommand_auth"
7071
_AUTH_SUBCOMMAND_KEY_REVOKE = "REVOKE"
7172

7273
_DEFAULT_ORIGIN = "https://tensorboard.dev"
7374

7475

76+
# Size limits for input fields not bounded at a wire level. "Chars" in this
77+
# context refers to Unicode code points as stipulated by https://aip.dev/210.
78+
_EXPERIMENT_NAME_MAX_CHARS = 100
79+
_EXPERIMENT_DESCRIPTION_MAX_CHARS = 600
80+
81+
7582
def _prompt_for_user_ack(intent):
7683
"""Prompts for user consent, exiting the program if they decline."""
7784
body = intent.get_ack_message_body()
@@ -139,6 +146,46 @@ def _define_flags(parser):
139146
default=None,
140147
help="Directory containing the logs to process",
141148
)
149+
upload.add_argument(
150+
"--name",
151+
type=str,
152+
default=None,
153+
help="Title of the experiment. Max 100 characters.",
154+
)
155+
upload.add_argument(
156+
"--description",
157+
type=str,
158+
default=None,
159+
help="Experiment description. Markdown format. Max 600 characters.",
160+
)
161+
162+
update_metadata = subparsers.add_parser(
163+
"update-metadata",
164+
help="change the name, description, or other user "
165+
"metadata associated with an experiment.",
166+
)
167+
update_metadata.set_defaults(
168+
**{_SUBCOMMAND_FLAG: _SUBCOMMAND_KEY_UPDATE_METADATA}
169+
)
170+
update_metadata.add_argument(
171+
"--experiment_id",
172+
metavar="EXPERIMENT_ID",
173+
type=str,
174+
default=None,
175+
help="ID of the experiment on which to modify the metadata.",
176+
)
177+
update_metadata.add_argument(
178+
"--name",
179+
type=str,
180+
default=None,
181+
help="Title of the experiment. Max 100 characters.",
182+
)
183+
update_metadata.add_argument(
184+
"--description",
185+
type=str,
186+
default=None,
187+
help="Experiment description. Markdown format. Max 600 characters.",
188+
)
142189

143190
delete = subparsers.add_parser(
144191
"delete",
@@ -372,6 +419,72 @@ def execute(self, server_info, channel):
372419
print("Deleted experiment %s." % experiment_id)
373420

374421

422+
class _UpdateMetadataIntent(_Intent):
423+
"""The user intends to update the metadata for an experiment."""
424+
425+
_MESSAGE_TEMPLATE = textwrap.dedent(
426+
u"""\
427+
This will modify the metadata associated with the experiment on
428+
https://tensorboard.dev with the following experiment ID:
429+
430+
{experiment_id}
431+
432+
You have chosen to modify an experiment. All experiments uploaded
433+
to TensorBoard.dev are publicly visible. Do not upload sensitive
434+
data.
435+
"""
436+
)
437+
438+
def __init__(self, experiment_id, name=None, description=None):
439+
self.experiment_id = experiment_id
440+
self.name = name
441+
self.description = description
442+
443+
def get_ack_message_body(self):
444+
return self._MESSAGE_TEMPLATE.format(experiment_id=self.experiment_id)
445+
446+
def execute(self, server_info, channel):
447+
api_client = write_service_pb2_grpc.TensorBoardWriterServiceStub(
448+
channel
449+
)
450+
experiment_id = self.experiment_id
451+
_die_if_bad_experiment_name(self.name)
452+
_die_if_bad_experiment_description(self.description)
453+
if not experiment_id:
454+
raise base_plugin.FlagsError(
455+
"Must specify a non-empty experiment ID to modify."
456+
)
457+
try:
458+
uploader_lib.update_experiment_metadata(
459+
api_client,
460+
experiment_id,
461+
name=self.name,
462+
description=self.description,
463+
)
464+
except uploader_lib.ExperimentNotFoundError:
465+
_die(
466+
"No such experiment %s. Either it never existed or it has "
467+
"already been deleted." % experiment_id
468+
)
469+
except uploader_lib.PermissionDeniedError:
470+
_die(
471+
"Cannot modify experiment %s because it is owned by a "
472+
"different user." % experiment_id
473+
)
474+
except uploader_lib.InvalidArgumentError as cm:
475+
_die(
476+
"Server cannot modify experiment as requested.\n"
477+
"Server responded: %s" % cm.description()
478+
)
479+
except grpc.RpcError as e:
480+
_die("Internal error modifying experiment: %s" % e)
481+
logging.info("Modified experiment %s.", experiment_id)
482+
if self.name is not None:
483+
logging.info("Set name to %r", self.name)
484+
if self.description is not None:
485+
logging.info(f"Set description to %r", repr(self.description))
486+
487+
375488
class _ListIntent(_Intent):
376489
"""The user intends to list all their experiments."""
377490

@@ -409,6 +522,8 @@ def execute(self, server_info, channel):
409522
url = server_info_lib.experiment_url(server_info, experiment_id)
410523
print(url)
411524
data = [
525+
("Name", experiment.name or "[No Name]"),
526+
("Description", experiment.description or "[No Description]"),
412527
("Id", experiment.experiment_id),
413528
("Created", util.format_time(experiment.create_time)),
414529
("Updated", util.format_time(experiment.update_time)),
@@ -417,7 +532,7 @@ def execute(self, server_info, channel):
417532
("Tags", str(experiment.num_tags)),
418533
]
419534
for (name, value) in data:
420-
print("\t%s %s" % (name.ljust(10), value))
535+
print("\t%s %s" % (name.ljust(12), value))
421536
sys.stdout.flush()
422537
if not count:
423538
sys.stderr.write(
@@ -428,6 +543,24 @@ def execute(self, server_info, channel):
428543
sys.stderr.flush()
429544

430545

546+
def _die_if_bad_experiment_name(name):
547+
if name and len(name) > _EXPERIMENT_NAME_MAX_CHARS:
548+
_die(
549+
"Experiment name is too long. Limit is "
550+
"%s characters.\n"
551+
"%r was provided." % (_EXPERIMENT_NAME_MAX_CHARS, name)
552+
)
553+
554+
555+
def _die_if_bad_experiment_description(description):
556+
if description and len(description) > _EXPERIMENT_DESCRIPTION_MAX_CHARS:
557+
_die(
558+
"Experiment description is too long. Limit is %s characters.\n"
559+
"%r was provided."
560+
% (_EXPERIMENT_DESCRIPTION_MAX_CHARS, description)
561+
)
562+
563+
431564
class _UploadIntent(_Intent):
432565
"""The user intends to upload an experiment from the given logdir."""
433566

@@ -443,8 +576,10 @@ class _UploadIntent(_Intent):
443576
"""
444577
)
445578

446-
def __init__(self, logdir):
579+
def __init__(self, logdir, name=None, description=None):
447580
self.logdir = logdir
581+
self.name = name
582+
self.description = description
448583

449584
def get_ack_message_body(self):
450585
return self._MESSAGE_TEMPLATE.format(logdir=self.logdir)
@@ -453,7 +588,14 @@ def execute(self, server_info, channel):
453588
api_client = write_service_pb2_grpc.TensorBoardWriterServiceStub(
454589
channel
455590
)
456-
uploader = uploader_lib.TensorBoardUploader(api_client, self.logdir)
591+
_die_if_bad_experiment_name(self.name)
592+
_die_if_bad_experiment_description(self.description)
593+
uploader = uploader_lib.TensorBoardUploader(
594+
api_client,
595+
self.logdir,
596+
name=self.name,
597+
description=self.description,
598+
)
457599
experiment_id = uploader.create_experiment()
458600
url = server_info_lib.experiment_url(server_info, experiment_id)
459601
print(
@@ -541,11 +683,31 @@ def _get_intent(flags):
541683
raise base_plugin.FlagsError("Must specify subcommand (try --help).")
542684
if cmd == _SUBCOMMAND_KEY_UPLOAD:
543685
if flags.logdir:
544-
return _UploadIntent(os.path.expanduser(flags.logdir))
686+
return _UploadIntent(
687+
os.path.expanduser(flags.logdir),
688+
name=flags.name,
689+
description=flags.description,
690+
)
545691
else:
546692
raise base_plugin.FlagsError(
547693
"Must specify directory to upload via `--logdir`."
548694
)
695+
if cmd == _SUBCOMMAND_KEY_UPDATE_METADATA:
696+
if flags.experiment_id:
697+
if flags.name is not None or flags.description is not None:
698+
return _UpdateMetadataIntent(
699+
flags.experiment_id,
700+
name=flags.name,
701+
description=flags.description,
702+
)
703+
else:
704+
raise base_plugin.FlagsError(
705+
"Must specify either `--name` or `--description`."
706+
)
707+
else:
708+
raise base_plugin.FlagsError(
709+
"Must specify experiment to modify via `--experiment_id`."
710+
)
549711
elif cmd == _SUBCOMMAND_KEY_DELETE:
550712
if flags.experiment_id:
551713
return _DeleteExperimentIntent(flags.experiment_id)

0 commit comments

Comments
 (0)