Repository: kubeflow/model-registry
Pull request #1031: feat(py): Add support for OCI Auth/Login via MR Client
Author: login: syntaxsdev
Labels: name: approved
name: lgtm
name: size/L
name: Area/MR Python client
Created: 2025-04-25T20:59:24Z | Merged: 2025-04-29T14:52:02Z

<!--- Provide a general summary of your changes in the Title above -->
Support to login to an OCI Registry (both src and dest registries) in MR client, and automatic detection of default environment variables to facilitate logins.

## Description
This PR adds support for OCI Auth Login in both:
1. environment variables
2. parameters supplied to `OCIParams` object

The default environment variable used is `.dockerconfigjson`
The `.dockerconfigjson` expects a "pull secret" style auth file as such:

```json
    {
        "auths": {
            "localhost:xxxx": {
            "auth": "base64 encoded auth here",
            "email": ""
            }
        }
    }
```

When passing `oci_username` and `oci_password` to the `OCIParams` you do not need to use base64.

The function is then wrapped in a secure error catcher so any failures will filter out sensitive data to keep the password/auth JSON from ending up in logs.


<!--- Describe your changes in detail -->
<!--- [UI] Include any screenshots of changed UI; Include any gifs if it was a flow / UX change -->

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
These changes were tested by use of `test_save_to_oci_registry_auth_params`
where a real skopeo BackendDefinition was used but the PullFn and PushFn were mocked and replaced with functions that check for the correct param usage (eg. `--src-password=passwordhere`) since the local registry in the E2E was not configured to have authentication.

This was also tested locally to confirm it worked for Quay.io and docker.io
## Merge criteria:
<!--- This PR will be merged by any repository approver when it meets all the points in the checklist -->
- All the commits have been [_signed-off_](https://github.com/kubeflow/community/tree/master/dco-signoff-hook#signing-off-commits)  (To pass the `DCO` check)

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] The commits have meaningful messages; the author will squash them after approval or in case of manual merges will ask to merge with squash.
- [x] Testing instructions have been added in the PR body (for PRs involving changes that are not immediately obvious).
- [x] The developer has manually tested the changes and verified that the changes work.
- [x] Code changes follow the [kubeflow contribution guidelines](https://www.kubeflow.org/docs/about/contributing/).
- [ ] **For first time contributors**: Please reach out to the [Reviewers](../OWNERS) to ensure all tests are being run, ensuring the label `ok-to-test` has been added to the PR.

If you have UI changes

<!--- You can ignore these if you are doing Go Model Registry REST server, MR Python client, manifest, controller, internal logic, etc changes; aka non-UI / visual changes -->
- [ ] The developer has added tests or explained why testing cannot be added.
- [ ] Included any necessary screenshots or gifs if it was a UI change.
- [ ] Verify that UI/UX changes conform the UX guidelines for Kubeflow.



Diff:
diff --git a/clients/python/README.md b/clients/python/README.md
index 5998ddea81..0c092b7b56 100644
--- a/clients/python/README.md
+++ b/clients/python/README.md
@@ -219,7 +219,29 @@ registered_model = client.upload_artifact_and_register_model(
 
 #### OCI-registry based storage
 First, you must ensure you are logged in the to appropriate OCI registry using
-`skopeo login`, `podman login`, or using another way of authenticating or subsequent lines below will fail.
+`skopeo login`, `podman login`, or you can authenticate via the `OCIParams` by either:
+
+1. Reading a pull secret JSON from an environment variable. Default is `.dockerconfigjson`.
+
+    ```python
+    OCIParams(
+        ...,
+        oci_auth_env_var="DOCKER_CONFIG_JSON"
+    )
+    ```
+
+2. Passing in username and password directly.
+
+    ```python
+    OCIParams(
+        ...,
+        oci_username="user",
+        oci_password="userpass"
+    )
+    ```
+
+Full example:
+
 ```python
 oci_upload_params = OCIParams(
     base_image="busybox",
diff --git a/clients/python/poetry.lock b/clients/python/poetry.lock
index 2b00b53399..ce99848c45 100644
--- a/clients/python/poetry.lock
+++ b/clients/python/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -782,7 +782,7 @@ version = "0.16.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 optional = false
 python-versions = ">=3.8"
-groups = ["dev", "docs"]
+groups = ["docs"]
 files = [
     {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
     {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -856,7 +856,7 @@ description = "Read metadata from Python packages"
 optional = false
 python-versions = ">=3.8"
 groups = ["docs"]
-markers = "python_version < \"3.10\""
+markers = "python_version == \"3.9\""
 files = [
     {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"},
     {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"},
@@ -1363,15 +1363,15 @@ files = [
 
 [[package]]
 name = "olot"
-version = "0.1.5"
+version = "0.1.6"
 description = "oci layers on top"
 optional = true
 python-versions = "<4.0,>=3.9"
 groups = ["main"]
 markers = "extra == \"olot\""
 files = [
-    {file = "olot-0.1.5-py3-none-any.whl", hash = "sha256:5c9acd6c7f854b63ac91b9c99740a07ce96c11ea75c4c25577ec0837cbfc2dc0"},
-    {file = "olot-0.1.5.tar.gz", hash = "sha256:e0eb64db7279be714b54095244dd6570b3f119ceb577cfa260f90fefcc0b0838"},
+    {file = "olot-0.1.6-py3-none-any.whl", hash = "sha256:7ab8461a019ff280fd508fb75af8abaec3a9e625b578a4b6224dc9346faad002"},
+    {file = "olot-0.1.6.tar.gz", hash = "sha256:aaf8f9196d80ef7ba25a4bf9e7c9c6128a60d5f4032c776763aa6f57a16d4290"},
 ]
 
 [package.dependencies]
@@ -2747,7 +2747,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
 python-versions = ">=3.8"
 groups = ["docs"]
-markers = "python_version < \"3.10\""
+markers = "python_version == \"3.9\""
 files = [
     {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"},
     {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"},
@@ -2765,4 +2765,4 @@ olot = ["olot"]
 [metadata]
 lock-version = "2.1"
 python-versions = ">= 3.9, < 4.0"
-content-hash = "d7515932e9c725e4ff6e1df412ec27677c0bd5261f8ecb7a90108291f659aabc"
+content-hash = "dce978a008624eb0606f45d3f630d42443e3da92a033eb519c5a6cc0060cbafb"
diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml
index e0bb7cbbda..18dbd34f60 100644
--- a/clients/python/pyproject.toml
+++ b/clients/python/pyproject.toml
@@ -27,7 +27,7 @@ nest-asyncio = "^1.6.0"
 eval-type-backport = "^0.2.0"
 
 huggingface-hub = { version = ">=0.20.1,<0.31.0", optional = true }
-olot = { version = "^0.1.4", optional = true }
+olot = { version = "^0.1.6", optional = true }
 boto3 = { version = "^1.37.34", optional = true } 
 
 [tool.poetry.extras]
diff --git a/clients/python/src/model_registry/_client.py b/clients/python/src/model_registry/_client.py
index bc70d6c0cd..44033a1c20 100644
--- a/clients/python/src/model_registry/_client.py
+++ b/clients/python/src/model_registry/_client.py
@@ -248,8 +248,12 @@ def upload_artifact_and_register_model(
                 **asdict(upload_params), path=model_files_path
             )
         elif isinstance(upload_params, OCIParams):
+            dict_params = asdict(upload_params)
+            del dict_params["custom_oci_backend"]
             destination_uri = save_to_oci_registry(
-                **asdict(upload_params), model_files_path=model_files_path
+                **dict_params,
+                custom_oci_backend=upload_params.custom_oci_backend,
+                model_files_path=model_files_path,
             )
         else:
             msg = 'Param "upload_params" is required to perform an upload. Please ensure the value provided is valid'
diff --git a/clients/python/src/model_registry/utils.py b/clients/python/src/model_registry/utils.py
index 4d67ea57cc..dfd299c67b 100644
--- a/clients/python/src/model_registry/utils.py
+++ b/clients/python/src/model_registry/utils.py
@@ -2,17 +2,23 @@
 
 from __future__ import annotations
 
+import base64
+import json
 import os
 import tempfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Callable, TypedDict
+from subprocess import CalledProcessError
+from typing import Callable, Protocol, TypeVar
 
-from typing_extensions import overload
+from typing_extensions import Literal, overload
 
 from ._utils import required_args
 from .exceptions import MissingMetadata, StoreError
 
+# Generic return type
+T = TypeVar("T")
+
 
 @overload
 def s3_uri_from(
@@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
+
 
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
 
-def _get_skopeo_backend() -> BackendDefinition:
+    Args:
+        kwargs: The keyword args dict.
+    """
+    args = []
+    for k, v in kwargs.items():
+        args.append(f"{k}")
+        args.append(str(v))
+    return args
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
 
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
-def _get_oras_backend() -> BackendDefinition:
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
+
+    # Actual param specifications
+    if username := kwargs.pop("username", None):
+        kwargs[f"{prefix}-username"] = username
+
+    if password := kwargs.pop("password", None):
+        kwargs[f"{prefix}-password"] = password
+
+    return kwargs
+
+
+def _scrub_errors(func: Callable[[], T]) -> T:
+    """Scrub errors of any subprocess command with sensitive data.
+
+    Args:
+        func: A partial or lambda function that has not been yet executed.
+    """
+    try:
+        return func()
+    except (CalledProcessError, Exception) as e:
+        msg = """Problem with command"""
+        raise RuntimeError(msg, e.returncode, e.stderr) from None
 
 
 @dataclass
@@ -145,6 +254,9 @@ class OCIParams:
     backend: str = "skopeo"
     modelcard: os.PathLike | None = None
     custom_oci_backend: BackendDefinition = None
+    oci_auth_env_var: str | None = None
+    oci_username: str | None = None
+    oci_password: str | None = None
 
 
 @dataclass
@@ -171,7 +283,7 @@ class S3Params:
 }
 
 
-def save_to_oci_registry(
+def save_to_oci_registry(  # noqa: C901 ( complex args >8 )
     base_image: str,
     oci_ref: str,
     model_files_path: str | os.PathLike,
@@ -179,6 +291,9 @@ def save_to_oci_registry(
     backend: str = "skopeo",
     modelcard: os.PathLike | None = None,
     custom_oci_backend: BackendDefinition | None = None,
+    oci_auth_env_var: str | None = None,
+    oci_username: str | None = None,
+    oci_password: str | None = None,
 ) -> str:
     """Appends a list of files to an OCI-based image.
 
@@ -188,8 +303,12 @@ def save_to_oci_registry(
         oci_ref: Destination of where to push the newly layered image to. eg, "quay.io/my-org/my-registry:1.0.0"
         model_files_path: Path to the files to add to the base_image as layers
         backend: The CLI tool to use to perform the oci image pull/push. One of: "skopeo", "oras"
-        modelcard: Optional, path to the modelcard to additionally include as a layer
-        custom_oci_backend: Optional, if you would like to use your own OCI Backend layer, you can provide it here
+        modelcard: [Optional] Path to the modelcard to additionally include as a layer
+        custom_oci_backend: [Optional] If you would like to use your own OCI Backend layer, you can provide it here
+        oci_auth_env_var: [Optional] The environment variable that holds the auth/config JSON for OCI registry auth.
+        oci_username: [Optional] The username to the OCI registry.
+        oci_password: [Optional] (Must be used with OCI username) The password to the OCI registry.
+
     Raises:
         ValueError: If the chosen backend is not installed on the host
         ValueError: If the chosen backend is an invalid option
@@ -213,6 +332,18 @@ def save_to_oci_registry(
         """
         raise StoreError(msg) from e
 
+    # Check for OCI Auth Env and a default
+    auth: str = None
+    if oci_auth_env_var:
+        env_value = _validate_env_var(oci_auth_env_var)
+        auth = _extract_auth_json(env_value)
+    else:
+        try:
+            env_value = _validate_env_var(".dockerconfigjson")
+            auth = _extract_auth_json(env_value)
+        except ValueError:
+            pass
+
     # If a custom backend is provided, use it, else fetch the backend out of the registry
     if custom_oci_backend:
         backend_def = custom_oci_backend
@@ -222,19 +353,31 @@ def save_to_oci_registry(
     else:
         msg = f"'{backend}' is not an available backend to use. Available backends: {DEFAULT_BACKENDS.keys()}"
         raise ValueError(msg)
-
-    if not backend_def["is_available"]():
+    if not backend_def.is_available():
         msg = f"Backend '{backend}' is selected, but not available on the system. Ensure the dependencies for '{backend}' are installed in your environment."
         raise ValueError(msg)
 
     if dest_dir is None:
         dest_dir = tempfile.mkdtemp()
     local_image_path = Path(dest_dir)
-    backend_def["pull"](base_image, local_image_path)
+
+    # Set params
+    params = {}
+
+    # User/pass
+    if auth:
+        usr_pass = auth.split(":")
+        params["username"] = usr_pass[0]
+        params["password"] = usr_pass[-1]
+    elif oci_username and oci_password:
+        params["username"] = oci_username
+        params["password"] = oci_password
+
+    backend_def.pull(base_image, local_image_path, **params)
     # Extract the absolute path from the files found in the path
     files = [file[0] for file in get_files_from_path(model_files_path)]
     oci_layers_on_top(local_image_path, files, modelcard)
-    backend_def["push"](local_image_path, oci_ref)
+    backend_def.push(local_image_path, oci_ref, **params)
 
     # Return the OCI URI
 
@@ -278,3 +421,44 @@ def get_files_from_path(path: str) -> list[tuple[str, str]]:
             files.append((absolute_path, relative_path))
 
     return files
+
+
+def _validate_env_var(var: str) -> str:
+    """Validate that an env var exists.
+
+    Args:
+        var: The env var to lookup.
+    """
+    if not (env_var := os.getenv(var)):
+        msg = f"Cannot find environment variable '{var}'."
+        raise ValueError(msg)
+    return env_var
+
+
+def _extract_auth_json(auth_data: str) -> str:
+    """Extract the auth JSON from a string value.
+
+    Args:
+        auth_data: The Auth JSON string.
+    """
+    try:
+        auth_json = json.loads(auth_data)
+        if type(auth_json) is not dict:
+            msg = ""
+            raise TypeError(msg)
+        registries = auth_json["auths"]
+        reg_keys = list(registries.keys())
+        if len(reg_keys) > 1:
+            msg = f"Auth JSON has multiple registries ({', '.join(reg_keys)}). This is not supported."
+            raise ValueError(msg)
+
+        key = registries[reg_keys[0]]["auth"]
+        auth = base64.b64decode(key)
+        return auth.decode()
+
+    except (AttributeError, KeyError) as e:
+        msg = "This is an invalid Auth JSON."
+        raise ValueError(msg) from e
+    except json.JSONDecodeError as e:
+        invalid_json_msg = "Auth data does not contain valid JSON."
+        raise ValueError(invalid_json_msg) from e
diff --git a/clients/python/tests/conftest.py b/clients/python/tests/conftest.py
index 2cfcda61cf..1b4ec43256 100644
--- a/clients/python/tests/conftest.py
+++ b/clients/python/tests/conftest.py
@@ -1,5 +1,7 @@
 import asyncio
+import base64
 import inspect
+import json
 import os
 import pathlib
 import shutil
@@ -8,7 +10,7 @@
 import time
 from contextlib import asynccontextmanager
 from pathlib import Path
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 from urllib.parse import urlparse
 
 import pytest
@@ -16,6 +18,7 @@
 import uvloop
 
 from model_registry import ModelRegistry
+from model_registry.utils import BackendDefinition, _get_skopeo_backend
 
 
 def pytest_addoption(parser):
@@ -237,13 +240,39 @@ def get_mock_custom_oci_backend():
     pull_mock = Mock()
     push_mock = Mock()
 
-    def pull_mock_imple(base_image, dest_dir):
+    def pull_mock_imple(base_image, dest_dir, **kwargs):
         pathlib.Path(dest_dir).joinpath("oci-layout").write_text(oci_layout_contents)
         pathlib.Path(dest_dir).joinpath("index.json").write_text(index_json_contents)
 
     pull_mock.side_effect = pull_mock_imple
-    return {
-        "is_available": is_available_mock,
-        "pull": pull_mock,
-        "push": push_mock,
+    return BackendDefinition(
+        is_available=is_available_mock, pull=pull_mock, push=push_mock
+    )
+
+
+@pytest.fixture
+def get_mock_skopeo_backend_for_auth(monkeypatch):
+    user_auth = b"myuser:passwordhere"
+    upc_encoded = base64.b64encode(user_auth)
+    auth_data = {
+        "auths": {"localhost:5001": {"auth": upc_encoded.decode(), "email": ""}}
     }
+    auth_json = json.dumps(auth_data)
+    monkeypatch.setenv(".dockerconfigjson", auth_json)
+    generic_auth_vars = ["--username", "myuser", "--password", "mypasswordhere"]
+
+    with (
+        patch("olot.backend.skopeo.skopeo_pull") as skopeo_pull_mock,
+        patch("olot.backend.skopeo.skopeo_push") as skopeo_push_mock,
+        patch("olot.basics.oci_layers_on_top"),
+    ):
+        backend = _get_skopeo_backend(
+            pull_args=generic_auth_vars, push_args=generic_auth_vars
+        )
+
+        def mock_override(base_image, dest_dir, params):
+            return params
+
+        skopeo_pull_mock.side_effect = mock_override
+        skopeo_push_mock.side_effect = mock_override
+        yield backend, skopeo_pull_mock, skopeo_push_mock, generic_auth_vars
diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py
index 0d671bf187..65bd220611 100644
--- a/clients/python/tests/test_client.py
+++ b/clients/python/tests/test_client.py
@@ -7,7 +7,6 @@
 from model_registry import ModelRegistry, utils
 from model_registry.exceptions import StoreError
 from model_registry.types import ModelArtifact
-from tests.test_utils import get_skopeo_backend_for_local_e2e_testing
 
 from .extras.async_task_runner import AsyncTaskRunner
 
@@ -889,11 +888,12 @@ def test_upload_artifact_and_register_model_with_default_oci(
     oci_ref = "localhost:5001/foo/bar:latest"
 
     model_dir, _ = get_temp_dir_with_models
-
     upload_params = utils.OCIParams(
         "quay.io/mmortari/hello-world-wait:latest",
         oci_ref,
-        custom_oci_backend=get_skopeo_backend_for_local_e2e_testing(),
+        custom_oci_backend=utils._get_skopeo_backend(
+            push_args=["--dest-tls-verify=false"]
+        ),
     )
 
     assert client.upload_artifact_and_register_model(
diff --git a/clients/python/tests/test_utils.py b/clients/python/tests/test_utils.py
index 22fa7619f4..96665643fb 100644
--- a/clients/python/tests/test_utils.py
+++ b/clients/python/tests/test_utils.py
@@ -84,15 +84,19 @@ def test_save_to_oci_registry_with_skopeo(get_temp_dir_with_models, get_temp_dir
     dest_dir, _ = get_temp_dir_with_models
     oci_ref = "localhost:5001/foo/bar:latest"
 
-    save_to_oci_registry(base_image, oci_ref, dest_dir, get_temp_dir, custom_oci_backend=get_skopeo_backend_for_local_e2e_testing())
-
-
-def get_skopeo_backend_for_local_e2e_testing():
-    # we implement a custom_backend because we want to OCI Push to a non-TLS OCI Registry in this (local) e2e-test
-    custom_backend = utils._get_skopeo_backend()
-    original_fn = custom_backend["push"]
-    custom_backend["push"] = lambda src, oci_ref : original_fn(src, oci_ref, ["--dest-tls-verify=false"])
-    return custom_backend
+    save_to_oci_registry(
+        base_image=base_image,
+        oci_ref=oci_ref,
+        model_files_path=dest_dir,
+        dest_dir=get_temp_dir,
+        custom_oci_backend=utils._get_skopeo_backend(
+            push_args=[
+                "--dest-tls-verify=false",
+                "--src-username=non_user",
+                "--src-password=nonpassword",
+            ],
+        ),
+    )
 
 
 def test_save_to_oci_registry_with_custom_backend(
@@ -114,12 +118,44 @@ def test_save_to_oci_registry_with_custom_backend(
         custom_oci_backend=get_mock_custom_oci_backend,
     )
     # Ensure our mocked backend was called
-    get_mock_custom_oci_backend["is_available"].assert_called_once()
-    get_mock_custom_oci_backend["pull"].assert_called_once()
-    get_mock_custom_oci_backend["push"].assert_called_once()
+    get_mock_custom_oci_backend.is_available.assert_called_once()
+    get_mock_custom_oci_backend.pull.assert_called_once()
+    get_mock_custom_oci_backend.push.assert_called_once()
     assert uri == f"oci://{oci_ref}"
 
 
+@pytest.mark.e2e(type="oci")
+def test_save_to_oci_registry_auth_params(
+    get_temp_dir_with_models,
+    get_temp_dir,
+    get_mock_skopeo_backend_for_auth,
+):
+    # similar to other test
+    base_image = "busybox:latest"
+    dest_dir, _ = get_temp_dir_with_models
+    oci_ref = "localhost:5001/foo/bar:latest"
+    backend, skopeo_pull_mock, skopeo_push_mock, generic_params = (
+        get_mock_skopeo_backend_for_auth
+    )
+
+    assert os.getenv(".dockerconfigjson") is not None  # noqa: SIM112 (no capitalization)
+
+    save_to_oci_registry(
+        base_image=base_image,
+        oci_ref=oci_ref,
+        model_files_path=dest_dir,
+        dest_dir=get_temp_dir,
+        modelcard=None,
+        custom_oci_backend=backend,
+    )
+    skopeo_pull_mock.assert_called_once()
+    skopeo_push_mock.assert_called_once()
+    args, _ = skopeo_pull_mock.call_args
+    params = args[2]
+    assert generic_params[0] in params
+    assert generic_params[-1] in params
+
+
 def test_save_to_oci_registry_backend_not_found():
     backend = "non-existent"
     with pytest.raises(


Reviews:
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:25:45Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:26:04Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:26:45Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:29:17Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:30:04Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:30:56Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T21:32:52Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T05:35:15Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T05:37:20Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T05:39:22Z
user: login: tarilabs
body: thanks a lot @syntaxsdev 

could you consider adding a line to the README.md about the env variable name for authentication, please?

Also some comments below for your consideration
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T06:04:58Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T13:38:42Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T14:13:46Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T15:01:50Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T17:10:51Z
user: login: dhirajsb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T17:23:15Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T17:31:47Z
user: login: tarilabs
body: thanks a lot @syntaxsdev 

/assign @Crazyglue 
given this changes some part of the orchestration to support this use-case

/lgtm
waiting for chime-in
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T17:34:03Z
user: login: dhirajsb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T17:54:56Z
user: login: Crazyglue
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:30:09Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T18:35:43Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T18:47:50Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T18:54:44Z
user: login: Crazyglue
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:59:12Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T19:01:55Z
user: login: Crazyglue
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T19:09:56Z
user: login: Crazyglue
body: /lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T19:10:06Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-28T19:11:02Z
user: login: syntaxsdev
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-29T14:43:24Z
user: login: tarilabs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:49:35Z
user: login: tarilabs
body: - https://github.com/kubeflow/model-registry/pull/1031#pullrequestreview-2800430001
 
we can indeed follow-up in subsequent PR for any broader scope changes

/approve
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T14:51:10Z

Review comments:
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: Converted the TypedDict of callables to linter-IDE and developer type hint friendly definition.. (also this way can eliminate calling via indexing (`["pull"]()` -> `.pull()`)
created_at: 2025-04-25T21:25:45Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: previous typo
created_at: 2025-04-25T21:26:04Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: converts kwargs to CLI style args (--arg=x)
created_at: 2025-04-25T21:26:45Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: wrap the `olot` skopeo functions so we can neatly pass them in
created_at: 2025-04-25T21:29:17Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
 
-def _get_oras_backend() -> BackendDefinition:
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
+
+    # Actual param specifications
+    if username := kwargs.pop("username", None):
+        kwargs[f"{prefix}-username"] = username
+
+    if password := kwargs.pop("password", None):
+        kwargs[f"{prefix}-password"] = password
+
+    return kwargs
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: pretty much DRY. not too hard coded and expandable if olot adds other backends
created_at: 2025-04-25T21:30:04Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
 
-def _get_oras_backend() -> BackendDefinition:
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
+
+    # Actual param specifications
+    if username := kwargs.pop("username", None):
+        kwargs[f"{prefix}-username"] = username
+
+    if password := kwargs.pop("password", None):
+        kwargs[f"{prefix}-password"] = password
+
+    return kwargs
+
+
+def _scrub_errors(func: Callable[[], T]) -> T:
+    """Scrub errors of any sensitive data when passing around auth data.
+
+    Args:
+        func: A partial or lambda function that has not been yet executed.
+    """
+    filtered = ["username", "password", "=="]
+
+    def determine_sensitivity(e: Exception):
+        err = str(e)
+        return any(err.lower().find(filter) >= 0 for filter in filtered)
+
+    try:
+        return func()
+    except CalledProcessError as e:
+        if determine_sensitivity(e):
+            msg = """Sensitive operation when running a command line process failed.
+                (secrets were detected and scrubbed)"""
+            raise RuntimeError(msg) from None
+        raise e
+    except Exception as e:
+        if determine_sensitivity(e):
+            msg = """Sensitive operation failed before running the command line process.
+                (secrets were detected and scrubbed)"""
+            raise RuntimeError(msg) from None
+        raise e
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: self explanatory, takes in callable and scrubs any errors that are sensitive.
created_at: 2025-04-25T21:30:56Z
author_association: NONE
diff_hunk: @@ -114,12 +118,38 @@ def test_save_to_oci_registry_with_custom_backend(
         custom_oci_backend=get_mock_custom_oci_backend,
     )
     # Ensure our mocked backend was called
-    get_mock_custom_oci_backend["is_available"].assert_called_once()
-    get_mock_custom_oci_backend["pull"].assert_called_once()
-    get_mock_custom_oci_backend["push"].assert_called_once()
+    get_mock_custom_oci_backend.is_available.assert_called_once()
+    get_mock_custom_oci_backend.pull.assert_called_once()
+    get_mock_custom_oci_backend.push.assert_called_once()
path: clients/python/tests/test_utils.py
user: login: syntaxsdev
body: like mentioned, much nicer now internally and adds typing safety
created_at: 2025-04-25T21:32:51Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: super handy, thanks!
created_at: 2025-04-28T05:35:15Z
author_association: MEMBER
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
 
-def _get_oras_backend() -> BackendDefinition:
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
+
+    # Actual param specifications
+    if username := kwargs.pop("username", None):
+        kwargs[f"{prefix}-username"] = username
+
+    if password := kwargs.pop("password", None):
+        kwargs[f"{prefix}-password"] = password
+
+    return kwargs
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: helpful, I would have even separated earlier, ie avoid first determining prefix, and for each backend spell-out the full argument, but even in current form is very okay and we can always come back later 👍 
created_at: 2025-04-28T05:37:20Z
author_association: MEMBER
diff_hunk: @@ -114,12 +118,38 @@ def test_save_to_oci_registry_with_custom_backend(
         custom_oci_backend=get_mock_custom_oci_backend,
     )
     # Ensure our mocked backend was called
-    get_mock_custom_oci_backend["is_available"].assert_called_once()
-    get_mock_custom_oci_backend["pull"].assert_called_once()
-    get_mock_custom_oci_backend["push"].assert_called_once()
+    get_mock_custom_oci_backend.is_available.assert_called_once()
+    get_mock_custom_oci_backend.pull.assert_called_once()
+    get_mock_custom_oci_backend.push.assert_called_once()
path: clients/python/tests/test_utils.py
user: login: tarilabs
body: indeed this is very helpful
created_at: 2025-04-28T05:39:22Z
author_association: MEMBER
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: I have a question here, please.

If instead of using the concatenated CLI form, ie `--key=value` we settle to use the CLI form of `--key value`, this array list instead of being of the form

```
[ "--key1=value1", "--key2=value2", ... ]
```

it should be possible to keep it in the form

```
[ "--key1", variable1, "--key2", variable2 ]
```

when passed to the utility function of calling the backends, 
thus avoiding the need of "scrubbing errors" from the stacktrace (of python)

Can you kindly dedicate some time to investigate this possibility instead please. cc @Crazyglue since we had discussed on this possibility iirc, maybe we could all sync on this point?
created_at: 2025-04-28T05:44:53Z
author_association: MEMBER
diff_hunk: @@ -237,13 +238,44 @@ def get_mock_custom_oci_backend():
     pull_mock = Mock()
     push_mock = Mock()
 
-    def pull_mock_imple(base_image, dest_dir):
+    def pull_mock_imple(base_image, dest_dir, **kwargs):
         pathlib.Path(dest_dir).joinpath("oci-layout").write_text(oci_layout_contents)
         pathlib.Path(dest_dir).joinpath("index.json").write_text(index_json_contents)
 
     pull_mock.side_effect = pull_mock_imple
-    return {
-        "is_available": is_available_mock,
-        "pull": pull_mock,
-        "push": push_mock,
+    return BackendDefinition(
+        is_available=is_available_mock, pull=pull_mock, push=push_mock
+    )
+
+
+@pytest.fixture
+def get_mock_skopeo_backend_for_auth(monkeypatch):
+    # ZmFrZV91c2VyOmFwYXNzd29yZGhlcmUK = fake_user:passwordhere (in base64)
+    auth_json = """
+    {
+        "auths": {
+            "localhost:5001": {
+            "auth": "ZmFrZV91c2VyOmFwYXNzd29yZGhlcmUK",
path: clients/python/tests/conftest.py
user: login: tarilabs
body: I would recommend to build the base64 encoded string in code, so to avoid "security scan" false positive on the repo when a base64 literal is detected?


ie something ~like:

```py
value = base64.b64encode(b'fake_user:passwordhere')
json = f' ... {value.decode("utf-8")} ...'
```

or similar, wdyt?
created_at: 2025-04-28T06:03:31Z
author_association: MEMBER
diff_hunk: @@ -237,13 +238,44 @@ def get_mock_custom_oci_backend():
     pull_mock = Mock()
     push_mock = Mock()
 
-    def pull_mock_imple(base_image, dest_dir):
+    def pull_mock_imple(base_image, dest_dir, **kwargs):
         pathlib.Path(dest_dir).joinpath("oci-layout").write_text(oci_layout_contents)
         pathlib.Path(dest_dir).joinpath("index.json").write_text(index_json_contents)
 
     pull_mock.side_effect = pull_mock_imple
-    return {
-        "is_available": is_available_mock,
-        "pull": pull_mock,
-        "push": push_mock,
+    return BackendDefinition(
+        is_available=is_available_mock, pull=pull_mock, push=push_mock
+    )
+
+
+@pytest.fixture
+def get_mock_skopeo_backend_for_auth(monkeypatch):
+    # ZmFrZV91c2VyOmFwYXNzd29yZGhlcmUK = fake_user:passwordhere (in base64)
+    auth_json = """
+    {
+        "auths": {
+            "localhost:5001": {
+            "auth": "ZmFrZV91c2VyOmFwYXNzd29yZGhlcmUK",
path: clients/python/tests/conftest.py
user: login: syntaxsdev
body: I agree on this.
created_at: 2025-04-28T13:38:42Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: to complement, something like:

```py
import subprocess

def go():
    variable1 = "asd"
    variable2 = "asdf"
    value = [ "--key1", variable1, "--key2", variable2 ]

    try:
        subprocess.run(["skopeo", "copy", *value, "unknown"], check=True)
    except subprocess.CalledProcessError as e:
        raise ValueError("Problem with command", e.returncode, e.output, e.stderr) from None

if __name__=="__main__":
    go()
```

imho would keep it consistent to expectations, without the need of scrubbing?

```
% python test.py
FATA[0000] unknown flag: --key1                         
Traceback (most recent call last):
  File "/Users/mmortari/git/test.py", line 14, in <module>
    go()
  File "/Users/mmortari/git/test.py", line 11, in go
    raise ValueError("Problem with command", e.returncode, e.output, e.stderr) from None
ValueError: ('Problem with command', 1, None, None)
```
created_at: 2025-04-28T14:13:46Z
author_association: MEMBER
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
+
+    Args:
+        kwargs: The keyword args dict.
+    """
+    return [f"{k}={v}" for k, v in kwargs.items()]
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: I like this approach, thanks
created_at: 2025-04-28T15:01:50Z
author_association: NONE
diff_hunk: @@ -84,15 +84,19 @@ def test_save_to_oci_registry_with_skopeo(get_temp_dir_with_models, get_temp_dir
     dest_dir, _ = get_temp_dir_with_models
     oci_ref = "localhost:5001/foo/bar:latest"
 
-    save_to_oci_registry(base_image, oci_ref, dest_dir, get_temp_dir, custom_oci_backend=get_skopeo_backend_for_local_e2e_testing())
-
-
-def get_skopeo_backend_for_local_e2e_testing():
-    # we implement a custom_backend because we want to OCI Push to a non-TLS OCI Registry in this (local) e2e-test
-    custom_backend = utils._get_skopeo_backend()
-    original_fn = custom_backend["push"]
-    custom_backend["push"] = lambda src, oci_ref : original_fn(src, oci_ref, ["--dest-tls-verify=false"])
-    return custom_backend
+    save_to_oci_registry(
+        base_image=base_image,
+        oci_ref=oci_ref,
+        model_files_path=dest_dir,
+        dest_dir=get_temp_dir,
+        custom_oci_backend=utils._get_skopeo_backend(
+            push_args=[
+                "--dest-tls-verify=false",
+                "--src-username=non_user",
+                "--src-password=nonpassword",
path: clients/python/tests/test_utils.py
user: login: syntaxsdev
body: both approaches work by default.

```python
push_args=[
  "--src-username=x",
]
# or
push_args=[
  "--src-username", "x"
]
```
created_at: 2025-04-28T17:10:51Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: dhirajsb
body: How about using something like [gila](https://pypi.org/project/gila/)? It seems it supports config files, and env variables like spf13. 
created_at: 2025-04-28T17:23:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: I think this is more related to how we need to pass down args to the "backends" (ie skopeo) but Gila is imho a good suggestion in general for config managment for MR py client as a whole, +1
created_at: 2025-04-28T17:31:47Z
author_association: MEMBER
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: dhirajsb
body: It'll give us more options for how to pass args via either configmaps, env variables, or cmdline for the future copy model jobs feature. 
created_at: 2025-04-28T17:54:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -248,8 +248,12 @@ def upload_artifact_and_register_model(
                 **asdict(upload_params), path=model_files_path
             )
         elif isinstance(upload_params, OCIParams):
+            dict_params = asdict(upload_params)
+            del dict_params["custom_oci_backend"]
             destination_uri = save_to_oci_registry(
-                **asdict(upload_params), model_files_path=model_files_path
+                **dict_params,
+                custom_oci_backend=upload_params.custom_oci_backend,
+                model_files_path=model_files_path,
path: clients/python/src/model_registry/_client.py
user: login: Crazyglue
body: I'm not sure i follow the purpose behind this change, isnt the old code equivalent to the new code?
created_at: 2025-04-28T18:15:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,17 +2,23 @@
 
 from __future__ import annotations
 
+import base64
+import json
 import os
 import tempfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Callable, TypedDict
+from subprocess import CalledProcessError
+from typing import Callable, Protocol, TypeVar
 
-from typing_extensions import overload
+from typing_extensions import Literal, overload
 
 from ._utils import required_args
 from .exceptions import MissingMetadata, StoreError
 
+# Generic return type
+T = TypeVar("T")
+
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: There is a `types/` folder, should this live there?
created_at: 2025-04-28T18:16:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: Why do this? doesn't this mean the `PullFn` definition above must be instantiated before it is called? Wouldn't that impact consumption of the `custom_oci_backend` as a result?

ie, usage would look like this

```
pull = PullFn()
pull()
```
created_at: 2025-04-28T18:19:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
+
 
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
 
-def _get_skopeo_backend() -> BackendDefinition:
+    Args:
+        kwargs: The keyword args dict.
+    """
+    args = []
+    for k, v in kwargs.items():
+        args.append(f"{k}")
+        args.append(str(v))
+    return args
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
 
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
-def _get_oras_backend() -> BackendDefinition:
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: Not urgent, but this might have scaling issues if we add additional backends or want to support things other than 'push' and 'pull'. It might be better to encode these params as data rather than branching logic, however that is of course additional work that may never actually be needed. Up to you!
created_at: 2025-04-28T18:24:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,3 +421,44 @@ def get_files_from_path(path: str) -> list[tuple[str, str]]:
             files.append((absolute_path, relative_path))
 
     return files
+
+
+def _validate_env_var(var: str) -> str:
+    """Validate that an env var exists.
+
+    Args:
+        var: The env var to lookup.
+    """
+    if not (env_var := os.getenv(var)):
+        msg = f"Cannot find environment variable '{var}'."
+        raise ValueError(msg)
+    return env_var
+
+
+def _extract_auth_json(auth_data: str) -> str:
+    """Extract the auth JSON from a string value.
+
+    Args:
+        auth_data: The Auth JSON string.
+    """
+    try:
+        auth_json = json.loads(auth_data)
+        if type(auth_json) is not dict:
+            msg = ""
+            raise TypeError(msg)
+        registries = auth_json["auths"]
+        reg_keys = list(registries.keys())
+        if len(reg_keys) > 1:
+            msg = f"Auth JSON has multiple registries ({', '.join(reg_keys)}). This is not supported."
+            raise ValueError(msg)
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: Why would this not be supported? Shouldn't it just be a matter of checking the hostname of the OCI registry the user is attempting to upload to against the entries in the dockerconfig file?

afaik the shape of this particular part of the json is like so

```
"auths": {
    "quay.io": {
      "auth": "base64-encoded-credentials"
    },
    "another-registry.com": { ... }
```
created_at: 2025-04-28T18:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,17 +2,23 @@
 
 from __future__ import annotations
 
+import base64
+import json
 import os
 import tempfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Callable, TypedDict
+from subprocess import CalledProcessError
+from typing import Callable, Protocol, TypeVar
 
-from typing_extensions import overload
+from typing_extensions import Literal, overload
 
 from ._utils import required_args
 from .exceptions import MissingMetadata, StoreError
 
+# Generic return type
+T = TypeVar("T")
+
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: I wouldn't think it's necessary to put there, it's essentially "instantiating" the generic (`<T>` in other languages), so its odd to import the generic, it's typeless and has no definition.
created_at: 2025-04-28T18:35:43Z
author_association: NONE
diff_hunk: @@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: I see your concern, it might be the way I named.
`PullFn` is just a definition of what the function should be like.

It's essentially the same as what you did here:
https://github.com/kubeflow/model-registry/blob/ffa006bc7043d3286b8697fda0e0392d15c82e08/clients/python/src/model_registry/utils.py#L107

except I changed the type to be a `Protocol`, this helps the IDE for type-hinting for developers or those who want to expand the functionality and define their own custom definition, because they will know immediately if their `PullFn` or `PushFn` meets the right criteria.

PullFn does not need to be instantiated, it is just a callable reference of the function.  It just needs to be the same definitions of the protocol.

Ex: I pass this function
https://github.com/kubeflow/model-registry/blob/79fb84db45b173d2dc69ffcdd992b1f21e277c9d/clients/python/src/model_registry/utils.py#L152

to `BackendDefinition` here:
https://github.com/kubeflow/model-registry/blob/79fb84db45b173d2dc69ffcdd992b1f21e277c9d/clients/python/src/model_registry/utils.py#L166-L168

created_at: 2025-04-28T18:47:50Z
author_association: NONE
diff_hunk: @@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
+
 
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
+    """Convert kwargs to list of params.
 
-def _get_skopeo_backend() -> BackendDefinition:
+    Args:
+        kwargs: The keyword args dict.
+    """
+    args = []
+    for k, v in kwargs.items():
+        args.append(f"{k}")
+        args.append(str(v))
+    return args
+
+
+def _get_skopeo_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.skopeo import is_skopeo, skopeo_pull, skopeo_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.skopeo'. Ensure that 'olot' is installed if you want to use the 'skopeo' backend."
         raise ImportError(msg) from e
 
-    return {"is_available": is_skopeo, "pull": skopeo_pull, "push": skopeo_push}
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: skopeo_pull(base_image, dest, params))
 
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("skopeo", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
 
-def _get_oras_backend() -> BackendDefinition:
+        return _scrub_errors(lambda: skopeo_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_skopeo, pull=wrapped_pull, push=wrapped_push
+    )
+
+
+def _get_oras_backend(
+    pull_args: list[str] | None = None, push_args: list[str] | None = None
+) -> BackendDefinition:
     try:
         from olot.backend.oras_cp import is_oras, oras_pull, oras_push
     except ImportError as e:
         msg = "Could not import 'olot.backend.oras_cp'. Ensure that 'olot' is installed if you want to use the 'oras_cp' backend."
         raise ImportError(msg) from e
 
-    return {
-        "is_available": is_oras,
-        "pull": oras_pull,
-        "push": oras_push,
-    }
+    def wrapped_pull(base_image: str, dest: Path, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "pull", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(pull_args or [])
+
+        return _scrub_errors(lambda: oras_pull(base_image, dest, params))
+
+    def wrapped_push(src: Path, oci_ref: str, **kwargs) -> T:
+        kwargs = _backend_specific_params("oras", "push", **kwargs)
+        params = _kwargs_to_params(kwargs)
+        params.extend(push_args or [])
+
+        return _scrub_errors(lambda: oras_push(src, oci_ref, params))
+
+    return BackendDefinition(
+        is_available=is_oras,
+        pull=wrapped_pull,
+        push=wrapped_push,
+    )
+
+
+def _backend_specific_params(
+    backend: Literal["skopeo", "oras"], type: Literal["push", "pull"], **kwargs
+) -> dict:
+    """Generate params based on the backend and action.
+
+    Args:
+        backend: The backend to use supported in olot.
+        type: The action to perform.
+        kwargs: Additional args defined below.
+
+    Keyword Args:
+        username: the usrername of the registry.
+        password: the password of the registry.
+    """
+    # Determine backend
+    if backend == "skopeo":
+        prefix = "--src" if type == "pull" else "--dest"
+    elif backend == "oras":
+        prefix = "--from" if type == "pull" else "--to"
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: If we ever need a third branch, I'll swap for a lookup tree 🌳😉
created_at: 2025-04-28T18:54:44Z
author_association: NONE
diff_hunk: @@ -95,41 +101,144 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: Understood! Thanks for the explanation
created_at: 2025-04-28T18:59:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,3 +421,44 @@ def get_files_from_path(path: str) -> list[tuple[str, str]]:
             files.append((absolute_path, relative_path))
 
     return files
+
+
+def _validate_env_var(var: str) -> str:
+    """Validate that an env var exists.
+
+    Args:
+        var: The env var to lookup.
+    """
+    if not (env_var := os.getenv(var)):
+        msg = f"Cannot find environment variable '{var}'."
+        raise ValueError(msg)
+    return env_var
+
+
+def _extract_auth_json(auth_data: str) -> str:
+    """Extract the auth JSON from a string value.
+
+    Args:
+        auth_data: The Auth JSON string.
+    """
+    try:
+        auth_json = json.loads(auth_data)
+        if type(auth_json) is not dict:
+            msg = ""
+            raise TypeError(msg)
+        registries = auth_json["auths"]
+        reg_keys = list(registries.keys())
+        if len(reg_keys) > 1:
+            msg = f"Auth JSON has multiple registries ({', '.join(reg_keys)}). This is not supported."
+            raise ValueError(msg)
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: yes.. I've thought on that too. the issue I found was that too much custom logic is needed for that..

for example:
1. user supplies domainless image+tag. do we just assume it's going to be from docker.io?
2. (rare*) what if user is pushing and pulling from the same registries, but different auth, how would we know which one is going to be authenticated 
3. it's for user safety, avoiding guessing or automatically using (odh off-topic discussion)
4. can also not use the same env var, you just supply a different `OCIParams` and set the env var to use based on use case

happy to take more feedback on this @tarilabs @Crazyglue but I think for now we can suffice once we have more data and use cases? don't want to overbuild
created_at: 2025-04-28T19:01:55Z
author_association: NONE
diff_hunk: @@ -278,3 +421,44 @@ def get_files_from_path(path: str) -> list[tuple[str, str]]:
             files.append((absolute_path, relative_path))
 
     return files
+
+
+def _validate_env_var(var: str) -> str:
+    """Validate that an env var exists.
+
+    Args:
+        var: The env var to lookup.
+    """
+    if not (env_var := os.getenv(var)):
+        msg = f"Cannot find environment variable '{var}'."
+        raise ValueError(msg)
+    return env_var
+
+
+def _extract_auth_json(auth_data: str) -> str:
+    """Extract the auth JSON from a string value.
+
+    Args:
+        auth_data: The Auth JSON string.
+    """
+    try:
+        auth_json = json.loads(auth_data)
+        if type(auth_json) is not dict:
+            msg = ""
+            raise TypeError(msg)
+        registries = auth_json["auths"]
+        reg_keys = list(registries.keys())
+        if len(reg_keys) > 1:
+            msg = f"Auth JSON has multiple registries ({', '.join(reg_keys)}). This is not supported."
+            raise ValueError(msg)
path: clients/python/src/model_registry/utils.py
user: login: Crazyglue
body: Understood and agree, when we have use cases fleshed out more we can always adjust things!
created_at: 2025-04-28T19:09:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -248,8 +248,12 @@ def upload_artifact_and_register_model(
                 **asdict(upload_params), path=model_files_path
             )
         elif isinstance(upload_params, OCIParams):
+            dict_params = asdict(upload_params)
+            del dict_params["custom_oci_backend"]
             destination_uri = save_to_oci_registry(
-                **asdict(upload_params), model_files_path=model_files_path
+                **dict_params,
+                custom_oci_backend=upload_params.custom_oci_backend,
+                model_files_path=model_files_path,
path: clients/python/src/model_registry/_client.py
user: login: syntaxsdev
body: yes but because I changed the type of BackendDefinition from a TypedDict to an object that holds functions 

using `asdict` will verbatim try to convert those the protocol definitions to dicts


what I'm doing here is converting ahead of time and then replacing the definition as is again 
created_at: 2025-04-28T19:11:02Z
author_association: NONE
diff_hunk: @@ -95,41 +101,155 @@ def s3_uri_from(
     return f"s3://{bucket}/{path}?endpoint={endpoint}&defaultRegion={region}"
 
 
-class BackendDefinition(TypedDict):
+class PullFn(Protocol):
+    """Pull function definition."""
+
+    def __call__(self, base: str, dest: Path, **kwargs) -> T: ...  # noqa: D102
+
+
+class PushFn(Protocol):
+    """Push function definition."""
+
+    def __call__(self, src: Path, oci_ref: str, **kwargs) -> T: ...  # noqa: D102
+
+
+@dataclass
+class BackendDefinition:
     """Holds the 3 core callables for a backend.
 
     - is_available() -> bool
-    - pull(base_image: str, dest_dir: Path) -> None
-    - push(local_image_path: Path, oci_ref: str) -> None.
+    - pull(base_image: str, dest_dir: Path, **kwargs) -> T
+    - push(local_image_path: Path, oci_ref: str, **kwargs) -> T.
     """
 
-    available: Callable[[], bool]
-    pull: Callable[[str, Path], None]
-    push: Callable[[Path, str], None]
+    is_available: Callable[[], bool]
+    pull: PullFn
+    push: PushFn
 
 
-def _get_skopeo_backend() -> BackendDefinition:
+def _kwargs_to_params(kwargs: dict[str, str]) -> list[str]:
path: clients/python/src/model_registry/utils.py
user: login: syntaxsdev
body: Good idea @dhirajsb, maybe we implement that in another feature later?
created_at: 2025-04-29T14:43:24Z
author_association: NONE
diff_hunk: @@ -278,3 +421,44 @@ def get_files_from_path(path: str) -> list[tuple[str, str]]:
             files.append((absolute_path, relative_path))
 
     return files
+
+
+def _validate_env_var(var: str) -> str:
+    """Validate that an env var exists.
+
+    Args:
+        var: The env var to lookup.
+    """
+    if not (env_var := os.getenv(var)):
+        msg = f"Cannot find environment variable '{var}'."
+        raise ValueError(msg)
+    return env_var
+
+
+def _extract_auth_json(auth_data: str) -> str:
+    """Extract the auth JSON from a string value.
+
+    Args:
+        auth_data: The Auth JSON string.
+    """
+    try:
+        auth_json = json.loads(auth_data)
+        if type(auth_json) is not dict:
+            msg = ""
+            raise TypeError(msg)
+        registries = auth_json["auths"]
+        reg_keys = list(registries.keys())
+        if len(reg_keys) > 1:
+            msg = f"Auth JSON has multiple registries ({', '.join(reg_keys)}). This is not supported."
+            raise ValueError(msg)
path: clients/python/src/model_registry/utils.py
user: login: tarilabs
body: I'd recommend in a followup PR, since this type of Secret (ODH Connection, etc) are per-registry anyway
created_at: 2025-04-29T14:49:35Z
author_association: MEMBER

PR comments:
user: login: syntaxsdev
created_at: 2025-04-28T13:43:55Z
body: thanks for review :D I am still working on README.
author_association: NONE
user: login: google-oss-prow[bot]
created_at: 2025-04-29T14:51:16Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeflow/model-registry/pull/1031#pullrequestreview-2803992939" title="Approved">tarilabs</a>*

The full list of commands accepted by this bot can be found [here](https://oss.gprow.dev/command-help?repo=kubeflow%2Fmodel-registry).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubeflow/model-registry/blob/main/OWNERS)~~ [tarilabs]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR