From 7139a22fc76ecebcb9b127e58d7b2f9a4ed8fbcb Mon Sep 17 00:00:00 2001 From: Isabella do Amaral Date: Mon, 23 Sep 2024 16:47:39 -0300 Subject: [PATCH] py: add basic service URL resolver Signed-off-by: Isabella do Amaral --- clients/python/README.md | 14 +- clients/python/poetry.lock | 163 ++++++++++++++++++- clients/python/pyproject.toml | 1 + clients/python/src/model_registry/_client.py | 75 +++++++++ 4 files changed, 248 insertions(+), 5 deletions(-) diff --git a/clients/python/README.md b/clients/python/README.md index f5e0cb88d..bd01c0efc 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -37,18 +37,18 @@ pip install huggingface-hub ### Connecting to MR -You can connect to a secure Model Registry using the default constructor (recommended): +You can connect to a secure Model Registry using the service constructor (recommended): ```py from model_registry import ModelRegistry -registry = ModelRegistry("https://server-address", author="Ada Lovelace") # Defaults to a secure connection via port 443 +registry = ModelRegistry.from_service("modelregistry-sample", "Ada Lovelace") # Defaults to a secure connection via port 443 ``` Or you can set the `is_secure` flag to `False` to connect **without** TLS (not recommended): ```py -registry = ModelRegistry("http://server-address", 8080, author="Ada Lovelace", is_secure=False) # insecure port set to 8080 +registry = ModelRegistry.from_service("modelregistry-sample", "Ada Lovelace", is_secure=False) # insecure port set to 8080 ``` ### Registering models @@ -190,6 +190,14 @@ This is necessary as the test suite will manage a Model Registry server and an M each run. You can use `make test` to execute `pytest`. +### Connecting to MR outside a cluster + +You can simply use the default `ModelRegistry` constructor: + +```py +registry = ModelRegistry("http://server-address", 8080, author="Ada Lovelace", is_secure=False) # insecure port set to 8080 +``` + ### Running Locally on Mac M1 or M2 (arm64 architecture) Check out our [recommendations on setting up your docker engine](https://github.com/kubeflow/model-registry/blob/main/CONTRIBUTING.md#docker-engine) on an ARM processor. diff --git a/clients/python/poetry.lock b/clients/python/poetry.lock index b3d6dd1f4..2f71ab842 100644 --- a/clients/python/poetry.lock +++ b/clients/python/poetry.lock @@ -304,6 +304,17 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + [[package]] name = "certifi" version = "2024.7.4" @@ -537,6 +548,16 @@ files = [ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] +[[package]] +name = "durationpy" +version = "0.7" +description = "Module for converting between datetime.timedelta and Go's Duration strings." +optional = false +python-versions = "*" +files = [ + {file = "durationpy-0.7.tar.gz", hash = "sha256:8447c43df4f1a0b434e70c15a38d77f5c9bd17284bfc1ff1d430f233d5083732"}, +] + [[package]] name = "eval-type-backport" version = "0.2.0" @@ -719,6 +740,29 @@ pygments = ">=2.7" sphinx = ">=6.0,<9.0" sphinx-basic-ng = ">=1.0.0.beta2" +[[package]] +name = "google-auth" +version = "2.35.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.35.0-py2.py3-none-any.whl", hash = "sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f"}, + {file = "google_auth-2.35.0.tar.gz", hash = "sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + [[package]] name = "h11" version = "0.14.0" @@ -833,6 +877,33 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "kubernetes" +version = "31.0.0" +description = "Kubernetes python client" +optional = false +python-versions = ">=3.6" +files = [ + {file = "kubernetes-31.0.0-py2.py3-none-any.whl", hash = "sha256:bf141e2d380c8520eada8b351f4e319ffee9636328c137aa432bc486ca1200e1"}, + {file = "kubernetes-31.0.0.tar.gz", hash = "sha256:28945de906c8c259c1ebe62703b56a03b714049372196f854105afe4e6d014c0"}, +] + +[package.dependencies] +certifi = ">=14.05.14" +durationpy = ">=0.7" +google-auth = ">=1.0.1" +oauthlib = ">=3.2.2" +python-dateutil = ">=2.5.3" +pyyaml = ">=5.4.1" +requests = "*" +requests-oauthlib = "*" +six = ">=1.9.0" +urllib3 = ">=1.24.2" +websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" + +[package.extras] +adal = ["adal (>=1.0.2)"] + [[package]] name = "linkify-it-py" version = "2.0.2" @@ -1171,6 +1242,22 @@ files = [ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "packaging" version = "23.2" @@ -1224,6 +1311,31 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + [[package]] name = "pydantic" version = "2.9.2" @@ -1460,7 +1572,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1516,6 +1627,38 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "ruff" version = "0.6.7" @@ -1989,6 +2132,22 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "websockets" version = "12.0" @@ -2194,4 +2353,4 @@ hf = ["huggingface-hub"] [metadata] lock-version = "2.0" python-versions = ">= 3.9, < 4.0" -content-hash = "eb040e095cac78f72e10d4a1600df0f53fd03a3a78de324afe7bf33c9502abcc" +content-hash = "5cf2a4b20ea84b85866c6fa46634e6fe66ec7d97fcac2230abd2d53ef2f14cf3" diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 13b43f9cd..cfd3fa8a6 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -27,6 +27,7 @@ nest-asyncio = "^1.6.0" eval-type-backport = "^0.2.0" huggingface-hub = { version = ">=0.20.1,<0.26.0", optional = true } +kubernetes = "^31.0.0" [tool.poetry.extras] hf = ["huggingface-hub"] diff --git a/clients/python/src/model_registry/_client.py b/clients/python/src/model_registry/_client.py index e2355f580..d382cb587 100644 --- a/clients/python/src/model_registry/_client.py +++ b/clients/python/src/model_registry/_client.py @@ -22,6 +22,11 @@ ModelTypes = t.Union[RegisteredModel, ModelVersion, ModelArtifact] TModel = t.TypeVar("TModel", bound=ModelTypes) +DSC_CRD = "datasciencecluster.opendatahub.io/v1" +DEFAULT_NS = "kubeflow" +DSC_NS_CONFIG = "registriesNamespace" +EXTERNAL_ADDR_ANNOTATION = "routing.opendatahub.io/external-address-rest" + class ModelRegistry: """Model registry client.""" @@ -87,6 +92,76 @@ def __init__( server_address, port, user_token ) + @classmethod + def from_service( + cls, name: str, author: str, *, ns: str | None = None, is_secure: bool = True + ) -> ModelRegistry: + """Create a client from a service name. + + Args: + name: Service name. + author: Name of the author. + + Keyword Args: + ns: Namespace. Defaults to DSC registriesNamespace, or `kubeflow` if unavailable. + is_secure: Whether to use a secure connection. Defaults to True. + """ + from kubernetes import client, config + + config.load_incluster_config() + if not ns: + kcustom = client.CustomObjectsApi() + g, v = DSC_CRD.split("/") + p = f"{g.split('.')[0]}s" + try: + dsc_raw = kcustom.list_cluster_custom_object( + group=g, + version=v, + plural=p, + ) + except client.ApiException as e: + msg = f"Failed to list {p}: {e}" + warn(msg, stacklevel=2) + ns = DEFAULT_NS + else: + ns = t.cast( + dict[str, t.Any], + dsc_raw["items"][0], + )["status"]["components"]["modelregistry"][DSC_NS_CONFIG] + + kcore = client.CoreV1Api() + serv = t.cast(client.V1Service, kcore.read_namespaced_service(name, ns)) + meta = t.cast(client.V1ObjectMeta, serv.metadata) + ext_addr = t.cast(dict[str, str], meta.annotations).get( + EXTERNAL_ADDR_ANNOTATION + ) + if ext_addr: + host, port = ext_addr.split(":") + host = f"https://{host}" + port = int(port) + elif not is_secure: + host = f"http://{meta.name}" + port = next( + ( + int(str(port.port)) + for port in t.cast( + list[client.V1ServicePort], + t.cast(client.V1ServiceSpec, serv.spec).ports, + ) + if port.app_protocol == "http" + ), + 8080, + ) + else: + msg = "No external address found for secure connection" + raise StoreError(msg) + + return cls( + host, + port, + author=author, + ) + def async_runner(self, coro: t.Any) -> t.Any: import asyncio