Repository: SeldonIO/MLServer

Issue:
Make the model's URI relative to the model-settings.json file
Currently, the model URI is expected to be relative to where the `mlserver` command is running from. However, this can be unintuitive for users, who may expect that path to be relative to where it's specified, i.e. the `model-settings.json` file.

It would be good to consider whether changing the reference for the path could make this more intuitive for users and what could its side-effects.


Discussion:

Resolving patch:
diff --git a/mlserver/repository.py b/mlserver/repository.py
--- a/mlserver/repository.py
+++ b/mlserver/repository.py
@@ -42,9 +42,11 @@ async def list(self) -> List[ModelSettings]:
 
     def _load_model_settings(self, model_settings_path: str) -> ModelSettings:
         model_settings = ModelSettings.parse_file(model_settings_path)
+        model_settings._source = model_settings_path
 
         # If name not present, default to folder name
-        default_model_name = os.path.basename(os.path.dirname(model_settings_path))
+        model_settings_folder = os.path.dirname(model_settings_path)
+        default_model_name = os.path.basename(model_settings_folder)
         if model_settings.name:
             if model_settings.name != default_model_name:
                 # Raise warning if name is different than folder's name
diff --git a/mlserver/settings.py b/mlserver/settings.py
--- a/mlserver/settings.py
+++ b/mlserver/settings.py
@@ -70,6 +70,10 @@ class Config:
 class ModelSettings(BaseSettings):
     class Config:
         env_prefix = ENV_PREFIX_MODEL_SETTINGS
+        underscore_attrs_are_private = True
+
+    # Source points to the file where model settings were loaded from
+    _source: Optional[str] = None
 
     """Name of the model."""
     name: str = ""
diff --git a/mlserver/utils.py b/mlserver/utils.py
--- a/mlserver/utils.py
+++ b/mlserver/utils.py
@@ -1,7 +1,7 @@
 import os
 import uuid
 
-from typing import Callable, List
+from typing import Callable, Optional, List
 
 from .settings import ModelSettings
 from .errors import InvalidModelURI
@@ -14,25 +14,36 @@ async def get_model_uri(
         raise InvalidModelURI(settings.name)
 
     model_uri = settings.parameters.uri
-
     if not model_uri:
         raise InvalidModelURI(settings.name)
 
-    if os.path.isfile(model_uri):
-        return model_uri
+    full_model_uri = _to_absolute_path(settings._source, model_uri)
+    if os.path.isfile(full_model_uri):
+        return full_model_uri
 
-    if os.path.isdir(model_uri):
-        # If model_uri is a folder, search for a well-known model filename
+    if os.path.isdir(full_model_uri):
+        # If full_model_uri is a folder, search for a well-known model filename
         for fname in wellknown_filenames:
-            model_path = os.path.join(model_uri, fname)
+            model_path = os.path.join(full_model_uri, fname)
             if os.path.isfile(model_path):
                 return model_path
 
         # If none, return the folder
-        return model_uri
+        return full_model_uri
 
     # Otherwise, the uri is neither a file nor a folder
-    raise InvalidModelURI(settings.name, model_uri)
+    raise InvalidModelURI(settings.name, full_model_uri)
+
+
+def _to_absolute_path(source: Optional[str], model_uri: str) -> str:
+    if source is None:
+        # Treat path as either absolute or relative to the working directory of
+        # the MLServer instance
+        return model_uri
+
+    parent_folder = os.path.dirname(source)
+    unnormalised = os.path.join(parent_folder, model_uri)
+    return os.path.normpath(unnormalised)
 
 
 def get_wrapped_method(f: Callable) -> Callable:


Test patch:
diff --git a/tests/test_repository.py b/tests/test_repository.py
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -13,25 +13,38 @@ async def test_list(
     settings_list = await model_repository.list()
 
     assert len(settings_list) == 1
-    assert settings_list[0].name == sum_model_settings.name
+
+    loaded_model_settings = settings_list[0]
+    assert loaded_model_settings.name == sum_model_settings.name
     assert (
-        settings_list[0].parameters.version  # type: ignore
+        loaded_model_settings.parameters.version  # type: ignore
         == sum_model_settings.parameters.version  # type: ignore
     )
-    assert settings_list[0].parameters.uri == str(  # type: ignore
+    assert loaded_model_settings.parameters.uri == str(  # type: ignore
         model_repository._root
     )
+    assert loaded_model_settings._source == os.path.join(
+        model_repository._root, DEFAULT_MODEL_SETTINGS_FILENAME
+    )
 
 
 async def test_list_multi_model(multi_model_folder: str):
-    multi_model_loader = ModelRepository(multi_model_folder)
+    multi_model_repository = ModelRepository(multi_model_folder)
 
-    settings_list = await multi_model_loader.list()
+    settings_list = await multi_model_repository.list()
     settings_list.sort(key=lambda ms: ms.parameters.version)  # type: ignore
 
     assert len(settings_list) == 5
     for idx, model_settings in enumerate(settings_list):
+        model_settings_path = os.path.join(
+            multi_model_folder,
+            model_settings.name,
+            model_settings.parameters.version,
+            DEFAULT_MODEL_SETTINGS_FILENAME,
+        )
+
         assert model_settings.parameters.version == f"v{idx}"  # type: ignore
+        assert model_settings._source == model_settings_path
 
 
 async def test_list_fallback(
@@ -56,11 +69,14 @@ async def test_list_fallback(
     all_settings = await model_repository.list()
 
     assert len(all_settings) == 1
-    assert all_settings[0].name == sum_model_settings.name
+
+    default_model_settings = all_settings[0]
+    assert default_model_settings.name == sum_model_settings.name
     assert (
-        all_settings[0].parameters.version  # type: ignore
+        default_model_settings.parameters.version  # type: ignore
         == sum_model_settings.parameters.version  # type: ignore
     )
+    assert default_model_settings._source is None
 
 
 async def test_name_fallback(model_folder: str, model_repository: ModelRepository):
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,37 @@
+import pytest
+
+from typing import Optional
+from unittest.mock import patch
+
+from mlserver.utils import get_model_uri
+from mlserver.settings import ModelSettings, ModelParameters
+
+
+@pytest.mark.parametrize(
+    "uri, source, expected",
+    [
+        ("my-model.bin", None, "my-model.bin"),
+        (
+            "my-model.bin",
+            "./my-model-folder/model-settings.json",
+            "my-model-folder/my-model.bin",
+        ),
+        (
+            "my-model.bin",
+            "./my-model-folder/../model-settings.json",
+            "my-model.bin",
+        ),
+        (
+            "/an/absolute/path/my-model.bin",
+            "/mnt/models/model-settings.json",
+            "/an/absolute/path/my-model.bin",
+        ),
+    ],
+)
+async def test_get_model_uri(uri: str, source: Optional[str], expected: str):
+    model_settings = ModelSettings(parameters=ModelParameters(uri=uri))
+    model_settings._source = source
+    with patch("os.path.isfile", return_value=True):
+        model_uri = await get_model_uri(model_settings)
+
+    assert model_uri == expected