Repository: mlflow/mlflow

Issue:
[FR] [Roadmap] Make `set_experiment_tag()` a fluent API
## MLflow Roadmap Item

This is an MLflow Roadmap item that has been prioritized by the MLflow maintainers. We’ve identified this feature as a highly requested addition to the MLflow package based on community feedback. 
We're seeking a community contribution for the implementation of this feature and will enthusiastically support the development and review of a submitted PR for this.

## Contribution Note

As with other roadmap items, there may be a desire for multiple contributors to work on an issue. While we don’t discourage collaboration, we strongly encourage that a primary contributor is assigned to roadmap issues to simplify the merging process. 
The items on the roadmap are of a high priority. Due to the wide-spread demand of roadmap features, we encourage potential contributors to only agree to take on the work of creating a PR, making changes, and ensuring that test coverage is adequately created for the feature if they are willing and able to see the implementation through to a merged state. 

## Feature scope

This roadmap feature’s complexity is classified as:

- [X] `good-first-issue`: This feature is limited in complexity and effort required to implement.
- [ ] `simple`: This feature does not require a large amount of effort to implement and / or is clear enough to not need a design discussion with maintainers.
- [ ] `involved`: This feature will require a substantial amount of development effort but does not require an agreed-upon design from the maintainers. The feedback given during the PR phase may be involved and necessitate multiple iterations before approval. (Please bear with us as we collaborate with you to make a great contribution)
- [ ] `design-recommended`: This is a substantial feature that should have a design document approved prior to working on an implementation (to save your time, not ours). After agreeing to work on this feature, a maintainer will be assigned to support you throughout the development process.

## Proposal Summary

The function `set_experiment_tag()` exists as a function for the client and the store APIs. 
Client implementation: https://github.com/mlflow/mlflow/blob/ead653faf44eb7ee870a18d08bb8f864e37a2deb/mlflow/tracking/client.py#L745-L774

This FR is for implementing a fluent API version of this client API similar to how `set_tag()` functions here (for runs): https://github.com/mlflow/mlflow/blob/ead653faf44eb7ee870a18d08bb8f864e37a2deb/mlflow/tracking/fluent.py#L508-L530

This fluent API implementation of `set_experiment_tag()` should get the reference to the current active experiment through the `fluent._get_experiment_id()` private function (https://github.com/mlflow/mlflow/blob/ead653faf44eb7ee870a18d08bb8f864e37a2deb/mlflow/tracking/fluent.py#L1450-L1455) and apply the specified tag key:value pair to that object returned from calling `_get_experiment_id()`. 

Please ensure tests are written within the fluent API test suite that validates this new functionality (without calling the client API for setting experiment tags).
Example test for validating setting run tags: https://github.com/mlflow/mlflow/blob/ead653faf44eb7ee870a18d08bb8f864e37a2deb/tests/tracking/test_tracking.py#L435-L448

## Motivation
> #### What is the use case for this feature? 
  Simplify applying tags to experiments so that users don't have to instantiate an MLflow client to apply tags dynamically outside of experiment creation.
> #### Why is this use case valuable to support for MLflow users in general? 
  General quality of life feature.

### What component(s), interfaces, languages, and integrations does this feature affect?
Components 
- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [ ] `area/server-infra`: MLflow Tracking server backend
- [X] `area/tracking`: Tracking Service, tracking client APIs, autologging

Interfaces
- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

Languages 
- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

Integrations
- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations



Discussion:

Resolving patch:
diff --git a/mlflow/__init__.py b/mlflow/__init__.py
--- a/mlflow/__init__.py
+++ b/mlflow/__init__.py
@@ -152,6 +152,8 @@
 set_experiment = mlflow.tracking.fluent.set_experiment
 log_params = mlflow.tracking.fluent.log_params
 log_metrics = mlflow.tracking.fluent.log_metrics
+set_experiment_tags = mlflow.tracking.fluent.set_experiment_tags
+set_experiment_tag = mlflow.tracking.fluent.set_experiment_tag
 set_tags = mlflow.tracking.fluent.set_tags
 delete_experiment = mlflow.tracking.fluent.delete_experiment
 delete_run = mlflow.tracking.fluent.delete_run
@@ -168,6 +170,8 @@
     "log_params",
     "log_metric",
     "log_metrics",
+    "set_experiment_tags",
+    "set_experiment_tag",
     "set_tag",
     "set_tags",
     "delete_tag",
diff --git a/mlflow/tracking/fluent.py b/mlflow/tracking/fluent.py
--- a/mlflow/tracking/fluent.py
+++ b/mlflow/tracking/fluent.py
@@ -505,6 +505,30 @@ def log_param(key: str, value: Any) -> None:
     MlflowClient().log_param(run_id, key, value)
 
 
+def set_experiment_tag(key: str, value: Any) -> None:
+    """
+    Set a tag on the current experiment. Value is converted to a string.
+
+    :param key: Tag name (string). This string may only contain alphanumerics, underscores
+                (_), dashes (-), periods (.), spaces ( ), and slashes (/).
+                All backend stores will support keys up to length 250, but some may
+                support larger keys.
+    :param value: Tag value (string, but will be string-ified if not).
+                  All backend stores will support values up to length 5000, but some
+                  may support larger values.
+
+    .. code-block:: python
+        :caption: Example
+
+        import mlflow
+
+        with mlflow.start_run():
+           mlflow.set_experiment_tag("release.version", "2.2.0")
+    """
+    experiment_id = _get_experiment_id()
+    MlflowClient().set_experiment_tag(experiment_id, key, value)
+
+
 def set_tag(key: str, value: Any) -> None:
     """
     Set a tag under the current run. If no run is active, this method will create a
@@ -639,6 +663,29 @@ def log_params(params: Dict[str, Any]) -> None:
     MlflowClient().log_batch(run_id=run_id, metrics=[], params=params_arr, tags=[])
 
 
+def set_experiment_tags(tags: Dict[str, Any]) -> None:
+    """
+    Set tags for the current active experiment.
+
+    :param tags: Dictionary containing tag names and corresponding values.
+
+    .. code-block:: python
+        :caption: Example
+
+        import mlflow
+
+        tags = {"engineering": "ML Platform",
+                "release.candidate": "RC1",
+                "release.version": "2.2.0"}
+
+        # Set a batch of tags
+        with mlflow.start_run():
+            mlflow.set_experiment_tags(tags)
+    """
+    for key, value in tags.items():
+        set_experiment_tag(key, value)
+
+
 def set_tags(tags: Dict[str, Any]) -> None:
     """
     Log a batch of tags for the current run. If no run is active, this method will create a


Test patch:
diff --git a/tests/tracking/fluent/test_fluent.py b/tests/tracking/fluent/test_fluent.py
--- a/tests/tracking/fluent/test_fluent.py
+++ b/tests/tracking/fluent/test_fluent.py
@@ -1050,3 +1050,35 @@ def test_last_active_run_returns_most_recently_ended_active_run():
     assert last_active_run.info.run_id == run_id
     assert last_active_run.data.metrics == {"a": 1.0}
     assert last_active_run.data.params == {"b": "2"}
+
+
+def test_set_experiment_tag():
+    test_tags = {"new_test_tag_1": "abc", "new_test_tag_2": 5, "new/nested/tag": "cbd"}
+    tag_counter = 0
+    with start_run() as active_run:
+        test_experiment = active_run.info.experiment_id
+        current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment)
+        assert len(current_experiment.tags) == 0
+        for tag_key, tag_value in test_tags.items():
+            mlflow.set_experiment_tag(tag_key, tag_value)
+            tag_counter += 1
+            current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment)
+            assert tag_counter == len(current_experiment.tags)
+        finished_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment)
+        assert len(finished_experiment.tags) == len(test_tags)
+        for tag_key, tag_value in test_tags.items():
+            assert str(test_tags[tag_key] == tag_value)
+
+
+def test_set_experiment_tags():
+    exact_expected_tags = {"name_1": "c", "name_2": "b", "nested/nested/name": 5}
+    with start_run() as active_run:
+        test_experiment = active_run.info.experiment_id
+        current_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment)
+        assert len(current_experiment.tags) == 0
+        mlflow.set_experiment_tags(exact_expected_tags)
+    finished_experiment = mlflow.tracking.MlflowClient().get_experiment(test_experiment)
+    # Validate tags
+    assert len(finished_experiment.tags) == len(exact_expected_tags)
+    for tag_key, tag_value in finished_experiment.tags.items():
+        assert str(exact_expected_tags[tag_key]) == tag_value