Repository: mlflow/mlflow

Issue:
[FR] Add `search_model_versions` to high-level fluent API
### Willingness to contribute

Yes. I can contribute this feature independently.

### Proposal Summary

While the MLflow high-level fluent API provides a convenient function to search registered models according to filter criteria (`mlflow.search_registered_models`), an equivalent convenient function to search model versions of registered models is missing. My proposal is to add a corresponding function (`mlflow.search_model_versions`).

### Motivation

Described in the proposal summary.

### Details

The implementation is straightforward. I contribute with a corresponding PR.

### What component(s) does this bug affect?

- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [X] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/recipes`: Recipes, Recipe APIs, Recipe configs, Recipe Templates
- [ ] `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

### What interface(s) does this bug affect?

- [ ] `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

### What language(s) does this bug affect?

- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

### What integration(s) does this bug affect?

- [ ] `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
@@ -159,7 +159,11 @@
     autolog,
     last_active_run,
 )
-from mlflow.tracking._model_registry.fluent import register_model, search_registered_models
+from mlflow.tracking._model_registry.fluent import (
+    register_model,
+    search_registered_models,
+    search_model_versions,
+)
 from mlflow.tracking import (
     get_tracking_uri,
     set_tracking_uri,
@@ -202,6 +206,7 @@
     "get_experiment_by_name",
     "search_experiments",
     "search_registered_models",
+    "search_model_versions",
     "create_experiment",
     "set_experiment",
     "delete_experiment",
diff --git a/mlflow/tracking/_model_registry/fluent.py b/mlflow/tracking/_model_registry/fluent.py
--- a/mlflow/tracking/_model_registry/fluent.py
+++ b/mlflow/tracking/_model_registry/fluent.py
@@ -7,7 +7,10 @@
 from mlflow.utils.logging_utils import eprint
 from mlflow.utils import get_results_from_paginated_fn
 from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS
-from mlflow.store.model_registry import SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT
+from mlflow.store.model_registry import (
+    SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
+    SEARCH_MODEL_VERSION_MAX_RESULTS_DEFAULT,
+)
 from typing import Any, Dict, Optional, List
 
 
@@ -200,3 +203,23 @@ def pagination_wrapper_func(number_to_get, next_page_token):
         SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
         max_results,
     )
+
+
+def search_model_versions(
+    max_results: Optional[int] = None,
+    filter_string: Optional[str] = None,
+    order_by: Optional[List[str]] = None,
+) -> List[ModelVersion]:
+    def pagination_wrapper_func(number_to_get, next_page_token):
+        return MlflowClient().search_model_versions(
+            max_results=number_to_get,
+            filter_string=filter_string,
+            order_by=order_by,
+            page_token=next_page_token,
+        )
+
+    return get_results_from_paginated_fn(
+        paginated_fn=pagination_wrapper_func,
+        max_results_per_page=SEARCH_MODEL_VERSION_MAX_RESULTS_DEFAULT,
+        max_results=max_results,
+    )


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
@@ -2,7 +2,10 @@
 from importlib import reload
 from itertools import zip_longest
 
-from mlflow.store.model_registry import SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT
+from mlflow.store.model_registry import (
+    SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
+    SEARCH_MODEL_VERSION_MAX_RESULTS_DEFAULT,
+)
 from mlflow.store.tracking import SEARCH_MAX_RESULTS_DEFAULT
 
 import os
@@ -470,6 +473,59 @@ def test_search_registered_models(tmp_path):
     assert [m.name for m in models] == sorted(model_names, reverse=True)[:3]
 
 
+def test_search_model_versions(tmp_path):
+    sqlite_uri = "sqlite:///{}".format(tmp_path.joinpath("test.db"))
+    mlflow.set_tracking_uri(sqlite_uri)
+
+    num_all_model_versions = SEARCH_MODEL_VERSION_MAX_RESULTS_DEFAULT + 1
+    num_a_model_versions = num_all_model_versions // 4
+    num_b_model_versions = num_all_model_versions - num_a_model_versions
+
+    a_model_version_names = ["AModel" for i in range(num_a_model_versions)]
+    b_model_version_names = ["BModel" for i in range(num_b_model_versions)]
+    model_version_names = b_model_version_names + a_model_version_names
+
+    MlflowClient().create_registered_model(name="AModel")
+    MlflowClient().create_registered_model(name="BModel")
+
+    tag_values = ["x", "x", "y"]
+    for tag, model_name in zip_longest(tag_values, model_version_names):
+        MlflowClient().create_model_version(
+            name=model_name, source="foo/bar", tags={"tag": tag} if tag else None
+        )
+
+    # max_results is unspecified
+    model_versions = mlflow.search_model_versions()
+    assert len(model_versions) == num_all_model_versions
+
+    # max_results is larger than the number of model versions in the database
+    model_versions = mlflow.search_model_versions(max_results=num_all_model_versions + 1)
+    assert len(model_versions) == num_all_model_versions
+
+    # max_results is equal to the number of model versions in the database
+    model_versions = mlflow.search_model_versions(max_results=num_all_model_versions)
+    assert len(model_versions) == num_all_model_versions
+    # max_results is smaller than the number of models in the database
+    model_versions = mlflow.search_model_versions(max_results=num_all_model_versions - 1)
+    assert len(model_versions) == num_all_model_versions - 1
+
+    # Filter by name
+    model_versions = mlflow.search_model_versions(filter_string="name = 'AModel'")
+    assert [m.name for m in model_versions] == a_model_version_names
+    model_versions = mlflow.search_model_versions(filter_string="name ILIKE 'bmodel'")
+    assert len(model_versions) == num_b_model_versions
+
+    # Filter by tags
+    model_versions = mlflow.search_model_versions(filter_string="tags.tag = 'x'")
+    assert [m.name for m in model_versions] == model_version_names[:2]
+    model_versions = mlflow.search_model_versions(filter_string="tags.tag = 'y'")
+    assert [m.name for m in model_versions] == [model_version_names[2]]
+
+    # Order by version_number
+    model_versions = mlflow.search_model_versions(order_by=["version_number ASC"], max_results=5)
+    assert [m.version for m in model_versions] == [1, 1, 2, 2, 3]
+
+
 @pytest.fixture
 def empty_active_run_stack():
     with mock.patch("mlflow.tracking.fluent._active_run_stack", []):