viettelcloud-aiplatform 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. viettelcloud/__init__.py +1 -0
  2. viettelcloud/aiplatform/__init__.py +15 -0
  3. viettelcloud/aiplatform/common/__init__.py +0 -0
  4. viettelcloud/aiplatform/common/constants.py +22 -0
  5. viettelcloud/aiplatform/common/types.py +28 -0
  6. viettelcloud/aiplatform/common/utils.py +40 -0
  7. viettelcloud/aiplatform/hub/OWNERS +14 -0
  8. viettelcloud/aiplatform/hub/__init__.py +25 -0
  9. viettelcloud/aiplatform/hub/api/__init__.py +13 -0
  10. viettelcloud/aiplatform/hub/api/_proxy_client.py +355 -0
  11. viettelcloud/aiplatform/hub/api/model_registry_client.py +561 -0
  12. viettelcloud/aiplatform/hub/api/model_registry_client_test.py +462 -0
  13. viettelcloud/aiplatform/optimizer/__init__.py +45 -0
  14. viettelcloud/aiplatform/optimizer/api/__init__.py +0 -0
  15. viettelcloud/aiplatform/optimizer/api/optimizer_client.py +248 -0
  16. viettelcloud/aiplatform/optimizer/backends/__init__.py +13 -0
  17. viettelcloud/aiplatform/optimizer/backends/base.py +77 -0
  18. viettelcloud/aiplatform/optimizer/backends/kubernetes/__init__.py +13 -0
  19. viettelcloud/aiplatform/optimizer/backends/kubernetes/backend.py +563 -0
  20. viettelcloud/aiplatform/optimizer/backends/kubernetes/utils.py +112 -0
  21. viettelcloud/aiplatform/optimizer/constants/__init__.py +13 -0
  22. viettelcloud/aiplatform/optimizer/constants/constants.py +59 -0
  23. viettelcloud/aiplatform/optimizer/types/__init__.py +13 -0
  24. viettelcloud/aiplatform/optimizer/types/algorithm_types.py +87 -0
  25. viettelcloud/aiplatform/optimizer/types/optimization_types.py +135 -0
  26. viettelcloud/aiplatform/optimizer/types/search_types.py +95 -0
  27. viettelcloud/aiplatform/py.typed +0 -0
  28. viettelcloud/aiplatform/trainer/__init__.py +82 -0
  29. viettelcloud/aiplatform/trainer/api/__init__.py +3 -0
  30. viettelcloud/aiplatform/trainer/api/trainer_client.py +277 -0
  31. viettelcloud/aiplatform/trainer/api/trainer_client_test.py +72 -0
  32. viettelcloud/aiplatform/trainer/backends/__init__.py +0 -0
  33. viettelcloud/aiplatform/trainer/backends/base.py +94 -0
  34. viettelcloud/aiplatform/trainer/backends/container/adapters/base.py +195 -0
  35. viettelcloud/aiplatform/trainer/backends/container/adapters/docker.py +231 -0
  36. viettelcloud/aiplatform/trainer/backends/container/adapters/podman.py +258 -0
  37. viettelcloud/aiplatform/trainer/backends/container/backend.py +668 -0
  38. viettelcloud/aiplatform/trainer/backends/container/backend_test.py +867 -0
  39. viettelcloud/aiplatform/trainer/backends/container/runtime_loader.py +631 -0
  40. viettelcloud/aiplatform/trainer/backends/container/runtime_loader_test.py +637 -0
  41. viettelcloud/aiplatform/trainer/backends/container/types.py +67 -0
  42. viettelcloud/aiplatform/trainer/backends/container/utils.py +213 -0
  43. viettelcloud/aiplatform/trainer/backends/kubernetes/__init__.py +0 -0
  44. viettelcloud/aiplatform/trainer/backends/kubernetes/backend.py +710 -0
  45. viettelcloud/aiplatform/trainer/backends/kubernetes/backend_test.py +1344 -0
  46. viettelcloud/aiplatform/trainer/backends/kubernetes/constants.py +15 -0
  47. viettelcloud/aiplatform/trainer/backends/kubernetes/utils.py +636 -0
  48. viettelcloud/aiplatform/trainer/backends/kubernetes/utils_test.py +582 -0
  49. viettelcloud/aiplatform/trainer/backends/localprocess/__init__.py +0 -0
  50. viettelcloud/aiplatform/trainer/backends/localprocess/backend.py +306 -0
  51. viettelcloud/aiplatform/trainer/backends/localprocess/backend_test.py +501 -0
  52. viettelcloud/aiplatform/trainer/backends/localprocess/constants.py +90 -0
  53. viettelcloud/aiplatform/trainer/backends/localprocess/job.py +184 -0
  54. viettelcloud/aiplatform/trainer/backends/localprocess/types.py +52 -0
  55. viettelcloud/aiplatform/trainer/backends/localprocess/utils.py +302 -0
  56. viettelcloud/aiplatform/trainer/constants/__init__.py +0 -0
  57. viettelcloud/aiplatform/trainer/constants/constants.py +179 -0
  58. viettelcloud/aiplatform/trainer/options/__init__.py +52 -0
  59. viettelcloud/aiplatform/trainer/options/common.py +55 -0
  60. viettelcloud/aiplatform/trainer/options/kubernetes.py +502 -0
  61. viettelcloud/aiplatform/trainer/options/kubernetes_test.py +259 -0
  62. viettelcloud/aiplatform/trainer/options/localprocess.py +20 -0
  63. viettelcloud/aiplatform/trainer/test/common.py +22 -0
  64. viettelcloud/aiplatform/trainer/types/__init__.py +0 -0
  65. viettelcloud/aiplatform/trainer/types/types.py +517 -0
  66. viettelcloud/aiplatform/trainer/types/types_test.py +115 -0
  67. viettelcloud_aiplatform-0.3.0.dist-info/METADATA +226 -0
  68. viettelcloud_aiplatform-0.3.0.dist-info/RECORD +71 -0
  69. viettelcloud_aiplatform-0.3.0.dist-info/WHEEL +4 -0
  70. viettelcloud_aiplatform-0.3.0.dist-info/licenses/LICENSE +201 -0
  71. viettelcloud_aiplatform-0.3.0.dist-info/licenses/NOTICE +36 -0
@@ -0,0 +1 @@
1
+ # Namespace package for viettelcloud
@@ -0,0 +1,15 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ __version__ = "0.3.0"
File without changes
@@ -0,0 +1,22 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # The default Kubernetes namespace.
16
+ DEFAULT_NAMESPACE = "default"
17
+
18
+ # How long to wait in seconds for requests to the Kubernetes API Server.
19
+ DEFAULT_TIMEOUT = 120
20
+
21
+ # Unknown indicates that the value can't be identified.
22
+ UNKNOWN = "Unknown"
@@ -0,0 +1,28 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Optional
16
+
17
+ from kubernetes import client
18
+ from pydantic import BaseModel
19
+
20
+
21
+ class KubernetesBackendConfig(BaseModel):
22
+ namespace: Optional[str] = None
23
+ config_file: Optional[str] = None
24
+ context: Optional[str] = None
25
+ client_configuration: Optional[client.Configuration] = None
26
+
27
+ class Config:
28
+ arbitrary_types_allowed = True
@@ -0,0 +1,40 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import os
15
+ from typing import Optional
16
+
17
+ from kubernetes import config
18
+
19
+ from viettelcloud.aiplatform.common import constants
20
+
21
+
22
+ def is_running_in_k8s() -> bool:
23
+ return os.path.isdir("/var/run/secrets/kubernetes.io/")
24
+
25
+
26
+ def get_default_target_namespace(context: Optional[str] = None) -> str:
27
+ if not is_running_in_k8s():
28
+ try:
29
+ all_contexts, current_context = config.list_kube_config_contexts()
30
+ # If context is set, we should get namespace from it.
31
+ if context:
32
+ for c in all_contexts:
33
+ if isinstance(c, dict) and c.get("name") == context:
34
+ return c["context"]["namespace"]
35
+ # Otherwise, try to get namespace from the current context.
36
+ return current_context["context"]["namespace"]
37
+ except Exception:
38
+ return constants.DEFAULT_NAMESPACE
39
+ with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
40
+ return f.readline()
@@ -0,0 +1,14 @@
1
+ approvers:
2
+ - Al-Pragliola
3
+ - ederign
4
+ - jonburdo
5
+ - pboyd
6
+ - rareddy
7
+ - tarilabs
8
+ emeritus_approvers:
9
+ - andreyvelich
10
+ - ckadner
11
+ - Tomcli
12
+ - zijianjoy
13
+ reviewers:
14
+ - fege
@@ -0,0 +1,25 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from viettelcloud.aiplatform.hub.api.model_registry_client import ModelRegistryClient
16
+ from viettelcloud.aiplatform.hub.api._proxy_client import (
17
+ AuthenticationError,
18
+ PermissionDeniedError,
19
+ )
20
+
21
+ __all__ = [
22
+ "ModelRegistryClient",
23
+ "AuthenticationError",
24
+ "PermissionDeniedError",
25
+ ]
@@ -0,0 +1,13 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
@@ -0,0 +1,355 @@
1
+ # Copyright 2025 The Kubeflow Authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Internal HTTP client for cmp-backend proxy mode."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Iterator, Optional
21
+
22
+ import requests
23
+ from requests.adapters import HTTPAdapter
24
+ from urllib3.util.retry import Retry
25
+
26
+ LOG = logging.getLogger(__name__)
27
+
28
+
29
+ class AuthenticationError(Exception):
30
+ """Raised when PAT token is invalid or expired."""
31
+
32
+ pass
33
+
34
+
35
+ class PermissionDeniedError(Exception):
36
+ """Raised when user lacks permission for the operation."""
37
+
38
+ pass
39
+
40
+
41
+ class ProxyHTTPClient:
42
+ """
43
+ HTTP client for cmp-backend Model Registry proxy.
44
+
45
+ This client handles communication with cmp-backend which:
46
+ - Validates PAT tokens
47
+ - Enforces RBAC permissions
48
+ - Routes to the correct MR instance based on project and region
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ cmp_backend_url: str,
54
+ pat_token: str,
55
+ project_id: str,
56
+ region: str,
57
+ timeout: float = 30,
58
+ ):
59
+ """
60
+ Initialize proxy client.
61
+
62
+ Args:
63
+ cmp_backend_url: Base URL of cmp-backend (e.g., https://api.viettelcloud.vn)
64
+ pat_token: Personal Access Token for authentication
65
+ project_id: Project UUID or slug
66
+ region: Region UUID or name (e.g., "HN", "HCM")
67
+ timeout: Request timeout in seconds
68
+ """
69
+ self._base_url = f"{cmp_backend_url.rstrip('/')}/v2/model-registry-proxy/proxy"
70
+ self._project_id = project_id
71
+ self._region = region
72
+ self._timeout = timeout
73
+
74
+ self._session = requests.Session()
75
+ self._session.headers.update({
76
+ "Authorization": f"Token {pat_token}",
77
+ "X-Project-ID": project_id,
78
+ "X-Region": region,
79
+ "Content-Type": "application/json",
80
+ "Accept": "application/json",
81
+ })
82
+
83
+ # Retry configuration
84
+ retries = Retry(
85
+ total=3,
86
+ backoff_factor=0.5,
87
+ status_forcelist=[502, 503, 504],
88
+ )
89
+ adapter = HTTPAdapter(
90
+ pool_connections=10,
91
+ pool_maxsize=10,
92
+ max_retries=retries,
93
+ )
94
+ self._session.mount("http://", adapter)
95
+ self._session.mount("https://", adapter)
96
+
97
+ def __del__(self):
98
+ if hasattr(self, "_session") and self._session:
99
+ try:
100
+ self._session.close()
101
+ except Exception:
102
+ pass
103
+
104
+ def _request(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ body: Optional[dict] = None,
109
+ params: Optional[dict] = None,
110
+ ) -> requests.Response:
111
+ """
112
+ Send request to cmp-backend proxy.
113
+
114
+ Args:
115
+ method: HTTP method
116
+ path: API path
117
+ body: Request body (JSON)
118
+ params: Query parameters
119
+
120
+ Returns:
121
+ Response object
122
+
123
+ Raises:
124
+ AuthenticationError: If PAT token is invalid/expired
125
+ PermissionDeniedError: If user lacks permission
126
+ requests.HTTPError: For other HTTP errors
127
+ """
128
+ url = f"{self._base_url}{path}"
129
+
130
+ try:
131
+ LOG.debug(f"Proxy request: {method} {url}")
132
+ resp = self._session.request(
133
+ method,
134
+ url,
135
+ json=body,
136
+ params=params,
137
+ timeout=self._timeout,
138
+ )
139
+
140
+ # Handle auth errors
141
+ if resp.status_code == 401:
142
+ raise AuthenticationError("PAT token is invalid or expired")
143
+ if resp.status_code == 403:
144
+ error_msg = "Permission denied"
145
+ try:
146
+ error_msg = resp.json().get("detail", error_msg)
147
+ except Exception:
148
+ pass
149
+ raise PermissionDeniedError(error_msg)
150
+
151
+ resp.raise_for_status()
152
+ return resp
153
+
154
+ except requests.exceptions.ConnectionError as e:
155
+ LOG.error(f"Connection error: {url} - {e}")
156
+ raise ConnectionError(f"Failed to connect to cmp-backend: {e}") from e
157
+
158
+ except requests.exceptions.Timeout as e:
159
+ LOG.error(f"Timeout: {url} - {e}")
160
+ raise TimeoutError(f"Request to cmp-backend timed out: {e}") from e
161
+
162
+ def _get(self, path: str, params: Optional[dict] = None) -> Any:
163
+ """GET request, returns JSON."""
164
+ resp = self._request("GET", path, params=params)
165
+ return resp.json()
166
+
167
+ def _post(self, path: str, body: Optional[dict] = None) -> Any:
168
+ """POST request, returns JSON."""
169
+ resp = self._request("POST", path, body=body)
170
+ return resp.json()
171
+
172
+ def _patch(self, path: str, body: Optional[dict] = None) -> Any:
173
+ """PATCH request, returns JSON."""
174
+ resp = self._request("PATCH", path, body=body)
175
+ return resp.json()
176
+
177
+ def _delete(self, path: str) -> None:
178
+ """DELETE request."""
179
+ self._request("DELETE", path)
180
+
181
+ # =========================================================================
182
+ # Registered Models
183
+ # =========================================================================
184
+
185
+ def list_registered_models(
186
+ self,
187
+ page_size: Optional[int] = None,
188
+ order_by: Optional[str] = None,
189
+ next_page_token: Optional[str] = None,
190
+ ) -> dict:
191
+ """
192
+ List registered models.
193
+
194
+ Args:
195
+ page_size: Maximum number of items to return
196
+ order_by: Sort order (e.g., "name", "-createTime")
197
+ next_page_token: Token for pagination
198
+
199
+ Returns:
200
+ Dict with 'items' and optional 'nextPageToken'
201
+ """
202
+ params = {}
203
+ if page_size:
204
+ params["pageSize"] = page_size
205
+ if order_by:
206
+ params["orderBy"] = order_by
207
+ if next_page_token:
208
+ params["nextPageToken"] = next_page_token
209
+ return self._get("/registered_models/", params=params)
210
+
211
+ def get_registered_model(self, model_id: str) -> dict:
212
+ """Get a registered model by ID or name."""
213
+ return self._get(f"/registered_models/{model_id}/")
214
+
215
+ def create_registered_model(
216
+ self,
217
+ name: str,
218
+ description: Optional[str] = None,
219
+ owner: Optional[str] = None,
220
+ custom_properties: Optional[dict] = None,
221
+ ) -> dict:
222
+ """Create a registered model."""
223
+ data = {"name": name}
224
+ if description:
225
+ data["description"] = description
226
+ if owner:
227
+ data["owner"] = owner
228
+ if custom_properties:
229
+ data["customProperties"] = custom_properties
230
+ return self._post("/registered_models/", body=data)
231
+
232
+ def update_registered_model(
233
+ self,
234
+ model_id: str,
235
+ description: Optional[str] = None,
236
+ custom_properties: Optional[dict] = None,
237
+ ) -> dict:
238
+ """Update a registered model."""
239
+ data = {}
240
+ if description is not None:
241
+ data["description"] = description
242
+ if custom_properties is not None:
243
+ data["customProperties"] = custom_properties
244
+ return self._patch(f"/registered_models/{model_id}/", body=data)
245
+
246
+ def delete_registered_model(self, model_id: str) -> None:
247
+ """Delete a registered model."""
248
+ self._delete(f"/registered_models/{model_id}/")
249
+
250
+ # =========================================================================
251
+ # Model Versions
252
+ # =========================================================================
253
+
254
+ def list_model_versions(
255
+ self,
256
+ model_name: str,
257
+ page_size: Optional[int] = None,
258
+ order_by: Optional[str] = None,
259
+ next_page_token: Optional[str] = None,
260
+ ) -> dict:
261
+ """List versions of a model."""
262
+ params = {}
263
+ if page_size:
264
+ params["pageSize"] = page_size
265
+ if order_by:
266
+ params["orderBy"] = order_by
267
+ if next_page_token:
268
+ params["nextPageToken"] = next_page_token
269
+ return self._get(f"/registered_models/{model_name}/versions/", params=params)
270
+
271
+ def get_model_version(self, model_name: str, version: str) -> dict:
272
+ """Get a specific model version."""
273
+ return self._get(f"/registered_models/{model_name}/versions/{version}/")
274
+
275
+ def create_model_version(
276
+ self,
277
+ model_name: str,
278
+ version_name: str,
279
+ description: Optional[str] = None,
280
+ author: Optional[str] = None,
281
+ custom_properties: Optional[dict] = None,
282
+ ) -> dict:
283
+ """Create a model version."""
284
+ data = {"name": version_name}
285
+ if description:
286
+ data["description"] = description
287
+ if author:
288
+ data["author"] = author
289
+ if custom_properties:
290
+ data["customProperties"] = custom_properties
291
+ return self._post(f"/registered_models/{model_name}/versions/", body=data)
292
+
293
+ def update_model_version(
294
+ self,
295
+ model_name: str,
296
+ version: str,
297
+ description: Optional[str] = None,
298
+ custom_properties: Optional[dict] = None,
299
+ ) -> dict:
300
+ """Update a model version."""
301
+ data = {}
302
+ if description is not None:
303
+ data["description"] = description
304
+ if custom_properties is not None:
305
+ data["customProperties"] = custom_properties
306
+ return self._patch(f"/registered_models/{model_name}/versions/{version}/", body=data)
307
+
308
+ # =========================================================================
309
+ # Model Artifacts
310
+ # =========================================================================
311
+
312
+ def get_model_artifact(self, artifact_id: str) -> dict:
313
+ """Get a model artifact."""
314
+ return self._get(f"/model_artifacts/{artifact_id}/")
315
+
316
+ def create_model_artifact(
317
+ self,
318
+ name: str,
319
+ uri: str,
320
+ description: Optional[str] = None,
321
+ model_format_name: Optional[str] = None,
322
+ model_format_version: Optional[str] = None,
323
+ storage_key: Optional[str] = None,
324
+ storage_path: Optional[str] = None,
325
+ custom_properties: Optional[dict] = None,
326
+ ) -> dict:
327
+ """Create a model artifact."""
328
+ data = {"name": name, "uri": uri}
329
+ if description:
330
+ data["description"] = description
331
+ if model_format_name:
332
+ data["modelFormatName"] = model_format_name
333
+ if model_format_version:
334
+ data["modelFormatVersion"] = model_format_version
335
+ if storage_key:
336
+ data["storageKey"] = storage_key
337
+ if storage_path:
338
+ data["storagePath"] = storage_path
339
+ if custom_properties:
340
+ data["customProperties"] = custom_properties
341
+ return self._post("/model_artifacts/", body=data)
342
+
343
+ def update_model_artifact(
344
+ self,
345
+ artifact_id: str,
346
+ description: Optional[str] = None,
347
+ custom_properties: Optional[dict] = None,
348
+ ) -> dict:
349
+ """Update a model artifact."""
350
+ data = {}
351
+ if description is not None:
352
+ data["description"] = description
353
+ if custom_properties is not None:
354
+ data["customProperties"] = custom_properties
355
+ return self._patch(f"/model_artifacts/{artifact_id}/", body=data)