chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import ray
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray._private.thirdparty.tabulate.tabulate import tabulate
|
||||
from ray.air.config import RunConfig, ScalingConfig
|
||||
from ray.train import BackendConfig, Checkpoint
|
||||
from ray.train._internal import session
|
||||
from ray.train._internal.backend_executor import BackendExecutor, TrialInfo
|
||||
from ray.train._internal.data_config import DataConfig
|
||||
from ray.train._internal.session import _TrainingResult, get_session
|
||||
from ray.train._internal.utils import construct_train_func, count_required_parameters
|
||||
from ray.train.base_trainer import _TRAINER_RESTORE_DEPRECATION_WARNING
|
||||
from ray.train.constants import RAY_TRAIN_ENABLE_STATE_TRACKING
|
||||
from ray.train.trainer import BaseTrainer, GenDataset, TrainingIterator
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI
|
||||
from ray.widgets import Template
|
||||
from ray.widgets.util import repr_with_fallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DataParallelTrainer(BaseTrainer):
|
||||
"""A Trainer for data parallel training.
|
||||
|
||||
You should subclass this Trainer if your Trainer follows SPMD (single program,
|
||||
multiple data) programming paradigm - you want multiple processes to run the same
|
||||
function, but on different data.
|
||||
|
||||
This Trainer runs the function ``train_loop_per_worker`` on multiple Ray
|
||||
Actors.
|
||||
|
||||
The ``train_loop_per_worker`` function is expected to take in either 0 or 1
|
||||
arguments:
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker():
|
||||
...
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker(config: Dict):
|
||||
...
|
||||
|
||||
If ``train_loop_per_worker`` accepts an argument, then
|
||||
``train_loop_config`` will be passed in as the argument. This is useful if you
|
||||
want to tune the values in ``train_loop_config`` as hyperparameters.
|
||||
|
||||
If the ``datasets`` dict contains a training dataset (denoted by
|
||||
the "train" key), then it will be split into multiple dataset
|
||||
shards that can then be accessed by ``train.get_dataset_shard("train")`` inside
|
||||
``train_loop_per_worker``. All the other datasets will not be split and
|
||||
``train.get_dataset_shard(...)`` will return the entire Dataset.
|
||||
|
||||
Inside the ``train_loop_per_worker`` function, you can use any of the
|
||||
:ref:`Ray Train loop methods <train-loop-api>`.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray import train
|
||||
|
||||
def train_loop_per_worker():
|
||||
# Report intermediate results for callbacks or logging and
|
||||
# checkpoint data.
|
||||
train.report(...)
|
||||
|
||||
# Returns dict of last saved checkpoint.
|
||||
train.get_checkpoint()
|
||||
|
||||
# Returns the Dataset shard for the given key.
|
||||
train.get_dataset_shard("my_dataset")
|
||||
|
||||
# Returns the total number of workers executing training.
|
||||
train.get_context().get_world_size()
|
||||
|
||||
# Returns the rank of this worker.
|
||||
train.get_context().get_world_rank()
|
||||
|
||||
# Returns the rank of the worker on the current node.
|
||||
train.get_context().get_local_rank()
|
||||
|
||||
Any returns from the ``train_loop_per_worker`` will be discarded and not
|
||||
used or persisted anywhere.
|
||||
|
||||
**How do I use DataParallelTrainer or any of its subclasses?**
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
def train_loop_for_worker():
|
||||
dataset_shard_for_this_worker = train.get_dataset_shard("train")
|
||||
|
||||
# 3 items for 3 workers, each worker gets 1 item
|
||||
batches = list(dataset_shard_for_this_worker.iter_batches(batch_size=1))
|
||||
assert len(batches) == 1
|
||||
|
||||
train_dataset = ray.data.from_items([1, 2, 3])
|
||||
assert train_dataset.count() == 3
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_for_worker,
|
||||
scaling_config=ScalingConfig(num_workers=3),
|
||||
datasets={"train": train_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
**How do I develop on top of DataParallelTrainer?**
|
||||
|
||||
In many cases, using DataParallelTrainer directly is sufficient to execute
|
||||
functions on multiple actors.
|
||||
|
||||
However, you may want to subclass ``DataParallelTrainer`` and create a custom
|
||||
Trainer for the following 2 use cases:
|
||||
|
||||
- **Use Case 1:** You want to do data parallel training, but want to have
|
||||
a predefined ``training_loop_per_worker``.
|
||||
|
||||
- **Use Case 2:** You want to implement a custom
|
||||
:py:class:`~ray.train.backend.Backend` that automatically handles
|
||||
additional setup or teardown logic on each actor, so that the users of this
|
||||
new trainer do not have to implement this logic. For example, a
|
||||
``TensorflowTrainer`` can be built on top of ``DataParallelTrainer``
|
||||
that automatically handles setting the proper environment variables for
|
||||
distributed Tensorflow on each actor.
|
||||
|
||||
For 1, you can set a predefined training loop in __init__
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
class MyDataParallelTrainer(DataParallelTrainer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
predefined_train_loop_per_worker = lambda: 1
|
||||
super().__init__(predefined_train_loop_per_worker, *args, **kwargs)
|
||||
|
||||
|
||||
For 2, you can implement the ``ray.train.Backend`` and ``ray.train.BackendConfig``
|
||||
interfaces.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from dataclasses import dataclass
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
|
||||
class MyBackend(Backend):
|
||||
def on_start(self, worker_group, backend_config):
|
||||
def set_env_var(env_var_value):
|
||||
import os
|
||||
os.environ["MY_ENV_VAR"] = env_var_value
|
||||
|
||||
worker_group.execute(set_env_var, backend_config.env_var)
|
||||
|
||||
@dataclass
|
||||
class MyBackendConfig(BackendConfig):
|
||||
env_var: str = "default_value"
|
||||
|
||||
def backend_cls(self):
|
||||
return MyBackend
|
||||
|
||||
class MyTrainer(DataParallelTrainer):
|
||||
def __init__(self, train_loop_per_worker, my_backend_config:
|
||||
MyBackendConfig, **kwargs):
|
||||
|
||||
super().__init__(
|
||||
train_loop_per_worker,
|
||||
backend_config=my_backend_config, **kwargs)
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute.
|
||||
This can either take in no arguments or a ``config`` dict.
|
||||
train_loop_config: Configurations to pass into
|
||||
``train_loop_per_worker`` if it accepts an argument.
|
||||
backend_config: Configuration for setting up a Backend (e.g. Torch,
|
||||
Tensorflow, Horovod) on each worker to enable distributed
|
||||
communication. If no Backend should be set up, then set this to None.
|
||||
scaling_config: Configuration for how to scale data parallel training.
|
||||
dataset_config: Configuration for dataset ingest. This is merged with the
|
||||
default dataset config for the given trainer (`cls._dataset_config`).
|
||||
run_config: Configuration for the execution of the training run.
|
||||
datasets: Ray Datasets to use for training and evaluation.
|
||||
This is a dict where the key is the name of the dataset, which
|
||||
can be accessed from within the ``train_loop_per_worker`` by calling
|
||||
``train.get_dataset_shard(dataset_key)``.
|
||||
By default, all datasets are sharded equally across workers.
|
||||
This can be configured via ``dataset_config``.
|
||||
metadata: Dict that should be made available via
|
||||
`train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
"""
|
||||
|
||||
# Exposed here for testing purposes. Should never need
|
||||
# to be overridden.
|
||||
_backend_executor_cls: Type[BackendExecutor] = BackendExecutor
|
||||
_training_iterator_cls: Type[TrainingIterator] = TrainingIterator
|
||||
|
||||
_scaling_config_allowed_keys = BaseTrainer._scaling_config_allowed_keys + [
|
||||
"num_workers",
|
||||
"resources_per_worker",
|
||||
"use_gpu",
|
||||
"placement_strategy",
|
||||
"accelerator_type",
|
||||
]
|
||||
|
||||
# For backwards compatibility with the legacy dataset config API.
|
||||
_dataset_config = None
|
||||
|
||||
_fields_for_tuner_param_space = BaseTrainer._fields_for_tuner_param_space + [
|
||||
"train_loop_config"
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Union[Callable[[], None], Callable[[Dict], None]],
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
backend_config: Optional[BackendConfig] = None,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
dataset_config: Optional[DataConfig] = None,
|
||||
run_config: Optional[RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
self._train_loop_per_worker = train_loop_per_worker
|
||||
self._train_loop_config = train_loop_config
|
||||
|
||||
if dataset_config is None:
|
||||
dataset_config = DataConfig()
|
||||
|
||||
if not isinstance(dataset_config, DataConfig):
|
||||
raise ValueError(
|
||||
"`dataset_config` must be an instance of ray.train.DataConfig, "
|
||||
f"was: {dataset_config}"
|
||||
)
|
||||
self._data_config = dataset_config
|
||||
|
||||
backend_config = (
|
||||
backend_config if backend_config is not None else BackendConfig()
|
||||
)
|
||||
self._backend_config = backend_config
|
||||
|
||||
super(DataParallelTrainer, self).__init__(
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
metadata=metadata,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
)
|
||||
|
||||
train_total_resources = self.scaling_config.total_resources
|
||||
self._data_config.set_train_total_resources(
|
||||
train_total_resources.get("CPU", 0),
|
||||
train_total_resources.get("GPU", 0),
|
||||
)
|
||||
|
||||
if env_integer(RAY_TRAIN_ENABLE_STATE_TRACKING, 0):
|
||||
from ray.train._internal.state.state_actor import get_or_create_state_actor
|
||||
|
||||
get_or_create_state_actor()
|
||||
|
||||
@classmethod
|
||||
@Deprecated(message=_TRAINER_RESTORE_DEPRECATION_WARNING)
|
||||
def restore(
|
||||
cls,
|
||||
path: str,
|
||||
train_loop_per_worker: Optional[
|
||||
Union[Callable[[], None], Callable[[Dict], None]]
|
||||
] = None,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Restores a DataParallelTrainer from a previously interrupted/failed run.
|
||||
|
||||
Args:
|
||||
path: The path to the experiment directory to restore from.
|
||||
train_loop_per_worker: Optionally re-specified train loop function.
|
||||
This should be used to re-specify a function that is not
|
||||
restorable in a new Ray cluster (e.g., it holds onto outdated
|
||||
object references). This should be the same training loop
|
||||
that was passed to the original trainer constructor.
|
||||
train_loop_config: Optionally re-specified train config.
|
||||
This should similarly be used if the original `train_loop_config`
|
||||
contained outdated object references, and it should not be modified
|
||||
from what was originally passed in.
|
||||
**kwargs: Additional arguments forwarded to
|
||||
:meth:`BaseTrainer.restore() <ray.train.trainer.BaseTrainer.restore>`.
|
||||
|
||||
See :meth:`BaseTrainer.restore() <ray.train.trainer.BaseTrainer.restore>`
|
||||
for descriptions of the other arguments.
|
||||
|
||||
Returns:
|
||||
A restored instance of the ``DataParallelTrainer``.
|
||||
"""
|
||||
return super(DataParallelTrainer, cls).restore(
|
||||
path=path,
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _validate_attributes(self):
|
||||
super()._validate_attributes()
|
||||
|
||||
self._validate_train_loop_per_worker(
|
||||
self._train_loop_per_worker, "train_loop_per_worker"
|
||||
)
|
||||
|
||||
def _validate_train_loop_per_worker(
|
||||
self, train_loop_per_worker: Callable, fn_name: str
|
||||
) -> None:
|
||||
num_required_params = count_required_parameters(train_loop_per_worker)
|
||||
if num_required_params > 1:
|
||||
raise ValueError(
|
||||
f"{fn_name} should take in 0 or 1 arguments, "
|
||||
f"but it accepts {num_required_params} arguments instead."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_scaling_config(cls, scaling_config: ScalingConfig) -> ScalingConfig:
|
||||
scaling_config = super(DataParallelTrainer, cls)._validate_scaling_config(
|
||||
scaling_config
|
||||
)
|
||||
|
||||
# This validation happens after the scaling config is updated from
|
||||
# its specification in the Tuner `param_space`
|
||||
if not scaling_config.use_gpu and "GPU" in ray.available_resources():
|
||||
logger.info(
|
||||
"GPUs are detected in your Ray cluster, but GPU "
|
||||
"training is not enabled for this trainer. To enable "
|
||||
"GPU training, make sure to set `use_gpu` to True "
|
||||
"in your scaling config."
|
||||
)
|
||||
|
||||
if scaling_config.num_workers is None:
|
||||
raise ValueError(
|
||||
"You must specify the 'num_workers' in `scaling_config` as either an "
|
||||
f"argument of `{cls.__name__}` or through the `param_space` of a "
|
||||
"`Tuner` (if performing hyperparameter tuning)."
|
||||
)
|
||||
|
||||
if scaling_config.num_workers <= 0:
|
||||
raise ValueError(
|
||||
"'num_workers' in `scaling_config` must be a positive "
|
||||
f"integer. Received {scaling_config.num_workers}"
|
||||
)
|
||||
|
||||
return scaling_config
|
||||
|
||||
def _run_training(self, training_iterator: TrainingIterator) -> None:
|
||||
"""This method loops over the `TrainingIterator`:
|
||||
The actual iteration (for ... in ...) waits for the training function
|
||||
on each worker to report a result and supplies it as a list of results.
|
||||
Afterwards (in the body of the loop), it will report the result
|
||||
to the Tune session.
|
||||
The iterator ends after the training function on each worker has finished.
|
||||
"""
|
||||
for training_results in training_iterator:
|
||||
# TODO(ml-team): add ability to report results from multiple workers.
|
||||
self._propagate_results(training_results)
|
||||
|
||||
def _propagate_results(self, training_results: List[_TrainingResult]):
|
||||
first_worker_result = training_results[0]
|
||||
assert all(isinstance(result, _TrainingResult) for result in training_results)
|
||||
|
||||
tune_session = get_session()
|
||||
|
||||
# Check if any workers reported a checkpoint.
|
||||
# If so, report a checkpoint pointing to the persisted location
|
||||
# to Tune for book-keeping.
|
||||
# NOTE: This removes the restriction for any individual worker
|
||||
# (ex: global rank 0 worker) from needing to report a checkpoint.
|
||||
# All workers reported a checkpoint to the same fs path, so there's
|
||||
# no need to report multiple checkpoints to Tune.
|
||||
worker_checkpoints = [
|
||||
result.checkpoint
|
||||
for result in training_results
|
||||
if result.checkpoint is not None
|
||||
]
|
||||
at_least_one_reported_checkpoint = len(worker_checkpoints) > 0
|
||||
|
||||
if at_least_one_reported_checkpoint:
|
||||
# Update the coordinator's checkpoint index to the latest.
|
||||
# This is what keeps the checkpoint index in line with the workers.
|
||||
tune_session.storage._update_checkpoint_index(first_worker_result.metrics)
|
||||
|
||||
# Make sure that all workers uploaded to the same location.
|
||||
assert all(
|
||||
checkpoint.path == tune_session.storage.checkpoint_fs_path
|
||||
for checkpoint in worker_checkpoints
|
||||
)
|
||||
|
||||
checkpoint = (
|
||||
Checkpoint(
|
||||
filesystem=tune_session.storage.storage_filesystem,
|
||||
path=tune_session.storage.checkpoint_fs_path,
|
||||
)
|
||||
if at_least_one_reported_checkpoint
|
||||
else None
|
||||
)
|
||||
|
||||
tracked_training_result = _TrainingResult(
|
||||
checkpoint=checkpoint,
|
||||
metrics=first_worker_result.metrics,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Report (metrics, checkpoint) to the Tune session:\n"
|
||||
f" metrics={tracked_training_result.metrics}\n"
|
||||
f" checkpoint={tracked_training_result.checkpoint}"
|
||||
)
|
||||
|
||||
# Report the metrics and checkpoint to Tune.
|
||||
tune_session._report_training_result(tracked_training_result)
|
||||
|
||||
def training_loop(self) -> None:
|
||||
scaling_config = self._validate_scaling_config(self.scaling_config)
|
||||
|
||||
train_loop_per_worker = construct_train_func(
|
||||
self._train_loop_per_worker,
|
||||
self._train_loop_config,
|
||||
train_func_context=self._backend_config.train_func_context,
|
||||
fn_arg_name="train_loop_per_worker",
|
||||
discard_returns=True,
|
||||
)
|
||||
|
||||
trial_info = TrialInfo(
|
||||
name=session.get_trial_name(),
|
||||
id=session.get_trial_id(),
|
||||
resources=session.get_trial_resources(),
|
||||
logdir=session.get_trial_dir(),
|
||||
driver_ip=ray.util.get_node_ip_address(),
|
||||
driver_node_id=ray.get_runtime_context().get_node_id(),
|
||||
experiment_name=session.get_experiment_name(),
|
||||
run_id=uuid.uuid4().hex,
|
||||
)
|
||||
|
||||
backend_executor = self._backend_executor_cls(
|
||||
backend_config=self._backend_config,
|
||||
trial_info=trial_info,
|
||||
num_workers=scaling_config.num_workers,
|
||||
resources_per_worker=scaling_config._resources_per_worker_not_none,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
# Start the remote actors.
|
||||
backend_executor.start()
|
||||
|
||||
training_iterator = self._training_iterator_cls(
|
||||
backend_executor=backend_executor,
|
||||
backend_config=self._backend_config,
|
||||
train_func=train_loop_per_worker,
|
||||
datasets=self.datasets,
|
||||
metadata=self.metadata,
|
||||
data_config=self._data_config,
|
||||
checkpoint=self.starting_checkpoint,
|
||||
)
|
||||
|
||||
self._run_training(training_iterator)
|
||||
|
||||
# Shutdown workers.
|
||||
backend_executor.shutdown()
|
||||
|
||||
def get_dataset_config(self) -> DataConfig:
|
||||
"""Returns a copy of this Trainer's final dataset configs.
|
||||
|
||||
Returns:
|
||||
The merged default + user-supplied dataset config.
|
||||
"""
|
||||
|
||||
return self._data_config
|
||||
|
||||
@repr_with_fallback(["ipywidgets", "8"])
|
||||
def _repr_mimebundle_(self, **kwargs: Any):
|
||||
"""Returns a mimebundle with an ipywidget repr and a simple text repr.
|
||||
|
||||
Depending on the frontend where the data is being displayed,
|
||||
different mimetypes will be used from this bundle.
|
||||
See https://ipython.readthedocs.io/en/stable/config/integrating.html
|
||||
for information about this method, and
|
||||
https://ipywidgets.readthedocs.io/en/latest/embedding.html
|
||||
for more information about the jupyter widget mimetype.
|
||||
|
||||
Args:
|
||||
**kwargs: Standard Jupyter mimebundle kwargs (e.g. ``include``,
|
||||
``exclude``); unused by this implementation.
|
||||
|
||||
Returns:
|
||||
A mimebundle containing an ipywidget repr and a simple text repr.
|
||||
"""
|
||||
from ipywidgets import HTML, Layout, Tab, VBox
|
||||
|
||||
title = HTML(f"<h2>{self.__class__.__name__}</h2>")
|
||||
|
||||
children = []
|
||||
titles = []
|
||||
|
||||
if self.datasets:
|
||||
children.append(self._datasets_repr_())
|
||||
titles.append("Datasets")
|
||||
|
||||
children.append(HTML(self._data_config_repr_html_()))
|
||||
titles.append("Data Config")
|
||||
|
||||
if self._train_loop_config:
|
||||
children.append(HTML(self._train_loop_config_repr_html_()))
|
||||
titles.append("Train Loop Config")
|
||||
|
||||
if self.scaling_config:
|
||||
children.append(HTML(self.scaling_config._repr_html_()))
|
||||
titles.append("Scaling Config")
|
||||
|
||||
if self.run_config:
|
||||
children.append(HTML(self.run_config._repr_html_()))
|
||||
titles.append("Run Config")
|
||||
|
||||
if self._backend_config:
|
||||
children.append(HTML(self._backend_config._repr_html_()))
|
||||
titles.append("Backend Config")
|
||||
|
||||
tab = Tab(children, titles=titles)
|
||||
widget = VBox([title, tab], layout=Layout(width="100%"))
|
||||
bundle = widget._repr_mimebundle_(**kwargs)
|
||||
bundle.update(
|
||||
{
|
||||
"text/plain": repr(self),
|
||||
}
|
||||
)
|
||||
return bundle
|
||||
|
||||
def _train_loop_config_repr_html_(self) -> str:
|
||||
if self._train_loop_config:
|
||||
table_data = {}
|
||||
for k, v in self._train_loop_config.items():
|
||||
if isinstance(v, str) or str(v).isnumeric():
|
||||
table_data[k] = v
|
||||
elif hasattr(v, "_repr_html_"):
|
||||
table_data[k] = v._repr_html_()
|
||||
else:
|
||||
table_data[k] = str(v)
|
||||
|
||||
return Template("title_data.html.j2").render(
|
||||
title="Train Loop Config",
|
||||
data=Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
table_data.items(),
|
||||
headers=["Setting", "Value"],
|
||||
showindex=False,
|
||||
tablefmt="unsafehtml",
|
||||
),
|
||||
max_height="none",
|
||||
),
|
||||
)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def _data_config_repr_html_(self) -> str:
|
||||
# TODO make this rendering nicer.
|
||||
content = [str(self._data_config)]
|
||||
return Template("rendered_html_common.html.j2").render(content=content)
|
||||
|
||||
def _datasets_repr_(self) -> str:
|
||||
from ipywidgets import HTML, Layout, VBox
|
||||
|
||||
content = []
|
||||
if self.datasets:
|
||||
for name, config in self.datasets.items():
|
||||
tab = config._tab_repr_()
|
||||
if tab:
|
||||
content.append(
|
||||
HTML(
|
||||
Template("title_data.html.j2").render(
|
||||
title=f"Dataset - <code>{name}</code>", data=None
|
||||
)
|
||||
)
|
||||
)
|
||||
content.append(config._tab_repr_())
|
||||
|
||||
return VBox(content, layout=Layout(width="100%"))
|
||||
Reference in New Issue
Block a user