fiddler-client 2.3.0.dev2__py3-none-any.whl → 2.4.0.dev1__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.
- fiddler/__init__.py +2 -0
- fiddler/_version.py +1 -1
- fiddler/api/alert_mixin.py +1 -1
- fiddler/api/api.py +2 -0
- fiddler/api/webhooks_mixin.py +9 -7
- fiddler/core_objects.py +98 -47
- fiddler/schemas/custom_features.py +25 -19
- fiddler3/constants/baseline.py +19 -0
- fiddler3/constants/common.py +12 -0
- fiddler3/constants/dataset.py +7 -0
- fiddler3/constants/model_deployment.py +15 -0
- fiddler3/constants/xai.py +11 -0
- fiddler3/entities/__init__.py +5 -1
- fiddler3/entities/base.py +22 -18
- fiddler3/entities/baseline.py +228 -0
- fiddler3/entities/dataset.py +149 -0
- fiddler3/entities/job.py +9 -8
- fiddler3/entities/model.py +160 -29
- fiddler3/entities/model_artifact.py +350 -0
- fiddler3/entities/model_deployment.py +112 -0
- fiddler3/entities/model_surrogate.py +92 -0
- fiddler3/entities/organization.py +38 -0
- fiddler3/entities/project.py +69 -8
- fiddler3/entities/user.py +45 -0
- fiddler3/entities/xai.py +309 -0
- fiddler3/libs/http_client.py +24 -13
- fiddler3/libs/json_encoder.py +12 -0
- fiddler3/schemas/__init__.py +4 -0
- fiddler3/schemas/baseline.py +37 -0
- fiddler3/schemas/custom_features.py +23 -18
- fiddler3/schemas/dataset.py +27 -0
- fiddler3/schemas/deployment_params.py +25 -0
- fiddler3/schemas/filter_query.py +54 -0
- fiddler3/schemas/job.py +2 -2
- fiddler3/schemas/model.py +9 -9
- fiddler3/schemas/model_artifact.py +6 -0
- fiddler3/schemas/model_deployment.py +26 -0
- fiddler3/schemas/organization.py +9 -1
- fiddler3/schemas/project.py +4 -4
- fiddler3/schemas/server_info.py +2 -2
- fiddler3/schemas/user.py +1 -1
- fiddler3/schemas/xai.py +32 -0
- fiddler3/tests/apis/test_baseline.py +292 -0
- fiddler3/tests/apis/test_dataset.py +173 -0
- fiddler3/tests/apis/test_mixin.py +77 -0
- fiddler3/tests/apis/test_model.py +102 -7
- fiddler3/tests/apis/test_model_artifact.py +175 -0
- fiddler3/tests/apis/test_model_deployment.py +98 -0
- fiddler3/tests/apis/test_model_surrogate.py +159 -0
- fiddler3/tests/apis/test_project.py +36 -20
- fiddler3/tests/apis/test_xai.py +690 -0
- fiddler3/tests/constants.py +10 -0
- fiddler3/tests/test_json_encoder.py +16 -0
- fiddler3/tests/test_logger.py +12 -0
- fiddler3/tests/test_utils.py +19 -0
- fiddler3/utils/__init__.py +1 -0
- fiddler3/utils/helpers.py +43 -0
- fiddler3/utils/logger.py +15 -0
- fiddler3/utils/validations.py +11 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/METADATA +7 -5
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/RECORD +65 -33
- tests/fiddler/test_segments.py +227 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/LICENSE.txt +0 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/WHEEL +0 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/top_level.txt +0 -0
fiddler3/entities/model.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import builtins
|
|
4
|
+
import typing
|
|
5
|
+
from dataclasses import dataclass
|
|
3
6
|
from datetime import datetime
|
|
4
7
|
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Iterator
|
|
6
9
|
from uuid import UUID
|
|
7
10
|
|
|
8
11
|
import pandas as pd
|
|
@@ -12,20 +15,34 @@ from fiddler3.decorators import handle_api_error
|
|
|
12
15
|
from fiddler3.entities.base import BaseEntity
|
|
13
16
|
from fiddler3.entities.helpers import raise_not_found
|
|
14
17
|
from fiddler3.entities.job import Job
|
|
15
|
-
from fiddler3.
|
|
16
|
-
from fiddler3.
|
|
18
|
+
from fiddler3.entities.model_artifact import ArtifactMixin
|
|
19
|
+
from fiddler3.entities.model_surrogate import SurrogateMixin
|
|
20
|
+
from fiddler3.entities.project import ProjectCompactMixin
|
|
21
|
+
from fiddler3.entities.user import CreatedByMixin, UpdatedByMixin
|
|
22
|
+
from fiddler3.entities.xai import XaiMixin
|
|
23
|
+
from fiddler3.schemas.filter_query import OperatorType, QueryCondition, QueryRule
|
|
24
|
+
from fiddler3.schemas.job import JobCompactResp
|
|
25
|
+
from fiddler3.schemas.model import ModelResp
|
|
17
26
|
from fiddler3.schemas.model_schema import Column, ModelSchema
|
|
18
27
|
from fiddler3.schemas.model_spec import ModelSpec
|
|
19
28
|
from fiddler3.schemas.model_task_params import ModelTaskParams
|
|
20
29
|
from fiddler3.schemas.xai_params import XaiParams
|
|
21
|
-
from fiddler3.utils.logger import get_logger
|
|
22
30
|
from fiddler3.utils.model_schema_generator import SchemaGeneratorFactory
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
if typing.TYPE_CHECKING:
|
|
33
|
+
from fiddler3.entities.dataset import Dataset
|
|
25
34
|
|
|
26
35
|
|
|
27
|
-
class Model(
|
|
28
|
-
|
|
36
|
+
class Model(
|
|
37
|
+
BaseEntity,
|
|
38
|
+
ArtifactMixin,
|
|
39
|
+
CreatedByMixin,
|
|
40
|
+
ProjectCompactMixin,
|
|
41
|
+
UpdatedByMixin,
|
|
42
|
+
SurrogateMixin,
|
|
43
|
+
XaiMixin,
|
|
44
|
+
): # pylint: disable=too-many-ancestors
|
|
45
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
29
46
|
self,
|
|
30
47
|
name: str,
|
|
31
48
|
project_id: UUID,
|
|
@@ -40,9 +57,7 @@ class Model(BaseEntity):
|
|
|
40
57
|
event_ts_format: str | None = None,
|
|
41
58
|
xai_params: XaiParams | None = None,
|
|
42
59
|
) -> None:
|
|
43
|
-
"""
|
|
44
|
-
Construct a model instance
|
|
45
|
-
"""
|
|
60
|
+
"""Construct a model instance"""
|
|
46
61
|
self.name = name
|
|
47
62
|
self.project_id = project_id
|
|
48
63
|
self.schema = schema
|
|
@@ -69,20 +84,20 @@ class Model(BaseEntity):
|
|
|
69
84
|
self.updated_at: datetime | None = None
|
|
70
85
|
|
|
71
86
|
# Deserialized response object
|
|
72
|
-
self._resp:
|
|
87
|
+
self._resp: ModelResp | None = None
|
|
73
88
|
|
|
74
89
|
@staticmethod
|
|
75
90
|
def _get_url(id_: UUID | str | None = None) -> str:
|
|
76
|
-
"""Get model resource/item url"""
|
|
91
|
+
"""Get model resource/item url."""
|
|
77
92
|
url = '/v3/models'
|
|
78
93
|
return url if not id_ else f'{url}/{id_}'
|
|
79
94
|
|
|
80
95
|
@classmethod
|
|
81
96
|
def _from_dict(cls, data: dict) -> Model:
|
|
82
|
-
"""Build entity object from the given dictionary"""
|
|
97
|
+
"""Build entity object from the given dictionary."""
|
|
83
98
|
|
|
84
99
|
# Deserialize the response
|
|
85
|
-
resp_obj =
|
|
100
|
+
resp_obj = ModelResp(**data)
|
|
86
101
|
|
|
87
102
|
# Initialize
|
|
88
103
|
instance = cls(
|
|
@@ -120,21 +135,69 @@ class Model(BaseEntity):
|
|
|
120
135
|
instance._resp = resp_obj
|
|
121
136
|
return instance
|
|
122
137
|
|
|
138
|
+
def _refresh(self, data: dict) -> None:
|
|
139
|
+
"""Refresh the fields of this instance from the given response dictionary"""
|
|
140
|
+
# Deserialize the response
|
|
141
|
+
resp_obj = ModelResp(**data)
|
|
142
|
+
|
|
143
|
+
# Reset fields
|
|
144
|
+
self.schema = resp_obj.schema_
|
|
145
|
+
self.project_id = resp_obj.project.id
|
|
146
|
+
|
|
147
|
+
fields = [
|
|
148
|
+
'id',
|
|
149
|
+
'name',
|
|
150
|
+
'spec',
|
|
151
|
+
'input_type',
|
|
152
|
+
'task',
|
|
153
|
+
'task_params',
|
|
154
|
+
'description',
|
|
155
|
+
'event_id_col',
|
|
156
|
+
'event_ts_col',
|
|
157
|
+
'event_ts_format',
|
|
158
|
+
'xai_params',
|
|
159
|
+
'created_at',
|
|
160
|
+
'updated_at',
|
|
161
|
+
'artifact_status',
|
|
162
|
+
'artifact_files',
|
|
163
|
+
'input_cols',
|
|
164
|
+
'output_cols',
|
|
165
|
+
'target_cols',
|
|
166
|
+
'metadata_cols',
|
|
167
|
+
'decision_cols',
|
|
168
|
+
'is_binary_ranking_model',
|
|
169
|
+
]
|
|
170
|
+
for field in fields:
|
|
171
|
+
setattr(self, field, getattr(resp_obj, field, None))
|
|
172
|
+
|
|
173
|
+
self._resp = resp_obj
|
|
174
|
+
|
|
123
175
|
@classmethod
|
|
124
176
|
@handle_api_error
|
|
125
177
|
def get(cls, id_: UUID | str) -> Model:
|
|
126
|
-
"""Get the model instance using model id"""
|
|
178
|
+
"""Get the model instance using model id."""
|
|
127
179
|
response = cls._client().get(url=cls._get_url(id_))
|
|
128
180
|
return cls._from_response(response=response)
|
|
129
181
|
|
|
130
182
|
@classmethod
|
|
131
183
|
@handle_api_error
|
|
132
|
-
def from_name(cls,
|
|
133
|
-
"""Get the model instance using model name and project name"""
|
|
184
|
+
def from_name(cls, name: str, project_name: str) -> Model:
|
|
185
|
+
"""Get the model instance using model name and project name."""
|
|
186
|
+
_filter = QueryCondition(
|
|
187
|
+
rules=[
|
|
188
|
+
QueryRule(field='name', operator=OperatorType.EQUAL, value=name),
|
|
189
|
+
QueryRule(
|
|
190
|
+
field='project_name',
|
|
191
|
+
operator=OperatorType.EQUAL,
|
|
192
|
+
value=project_name,
|
|
193
|
+
),
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
|
|
134
197
|
response = cls._client().get(
|
|
135
|
-
url=cls._get_url(),
|
|
136
|
-
params={'name': model_name, 'project_name': project_name},
|
|
198
|
+
url=cls._get_url(), params={'filter': _filter.json()}
|
|
137
199
|
)
|
|
200
|
+
|
|
138
201
|
if response.json()['data']['total'] == 0:
|
|
139
202
|
raise_not_found('Model not found for the given identifier')
|
|
140
203
|
|
|
@@ -142,7 +205,7 @@ class Model(BaseEntity):
|
|
|
142
205
|
|
|
143
206
|
@handle_api_error
|
|
144
207
|
def create(self) -> Model:
|
|
145
|
-
"""Create a new model"""
|
|
208
|
+
"""Create a new model."""
|
|
146
209
|
response = self._client().post(
|
|
147
210
|
url=self._get_url(),
|
|
148
211
|
data={
|
|
@@ -160,24 +223,68 @@ class Model(BaseEntity):
|
|
|
160
223
|
'xai_params': self.xai_params.dict(),
|
|
161
224
|
},
|
|
162
225
|
)
|
|
163
|
-
|
|
226
|
+
self._refresh_from_response(response=response)
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
@handle_api_error
|
|
230
|
+
def update(self) -> None:
|
|
231
|
+
"""Update an existing model."""
|
|
232
|
+
body: dict[str, Any] = {
|
|
233
|
+
'xai_params': self.xai_params.dict(),
|
|
234
|
+
'description': self.description,
|
|
235
|
+
'event_id_col': self.event_id_col,
|
|
236
|
+
'event_ts_col': self.event_ts_col,
|
|
237
|
+
'event_ts_format': self.event_ts_format,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
response = self._client().patch(url=self._get_url(id_=self.id), data=body)
|
|
241
|
+
self._refresh_from_response(response=response)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
@handle_api_error
|
|
245
|
+
def list(
|
|
246
|
+
cls,
|
|
247
|
+
project_id: UUID | None = None,
|
|
248
|
+
) -> Iterator[ModelCompact]:
|
|
249
|
+
"""
|
|
250
|
+
Get a list of all models with the given filters
|
|
251
|
+
|
|
252
|
+
:param project_id: Project identifier
|
|
253
|
+
:return: ModelCompact iterator
|
|
254
|
+
"""
|
|
255
|
+
params = {}
|
|
256
|
+
if project_id:
|
|
257
|
+
params['project_id'] = project_id
|
|
258
|
+
|
|
259
|
+
for model in cls._paginate(url=cls._get_url(), params=params):
|
|
260
|
+
yield ModelCompact(id=model['id'], name=model['name'])
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def datasets(self) -> Iterator[Dataset]:
|
|
264
|
+
"""Fetch all the datasets of this model"""
|
|
265
|
+
from fiddler3.entities.dataset import ( # pylint: disable=import-outside-toplevel
|
|
266
|
+
Dataset,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
yield from Dataset.list(model_id=self.id)
|
|
164
270
|
|
|
165
271
|
@classmethod
|
|
166
272
|
@handle_api_error
|
|
167
273
|
def generate_schema(
|
|
168
274
|
cls,
|
|
169
|
-
source: pd.DataFrame | Path | list[dict[str, Any]] | str,
|
|
275
|
+
source: pd.DataFrame | Path | builtins.list[dict[str, Any]] | str,
|
|
170
276
|
max_cardinality: int | None = None,
|
|
171
277
|
sample_size: int | None = None,
|
|
172
278
|
enrich: bool = True,
|
|
173
279
|
) -> ModelSchema:
|
|
174
280
|
"""
|
|
175
|
-
Generate model schema from the given data
|
|
281
|
+
Generate model schema from the given data.
|
|
282
|
+
|
|
176
283
|
:param source: Data source - Dataframe or path to csv or parquet file.
|
|
177
|
-
:param max_cardinality: Max cardinality to detect categorical columns
|
|
178
|
-
:param sample_size: No. of samples to use for generating schema
|
|
179
|
-
:param enrich: Enrich the model schema client side by scanning all data
|
|
180
|
-
:return: Generated ModelSchema object
|
|
284
|
+
:param max_cardinality: Max cardinality to detect categorical columns.
|
|
285
|
+
:param sample_size: No. of samples to use for generating schema.
|
|
286
|
+
:param enrich: Enrich the model schema client side by scanning all data.
|
|
287
|
+
:return: Generated ModelSchema object.
|
|
181
288
|
"""
|
|
182
289
|
schema_generator = SchemaGeneratorFactory.create(
|
|
183
290
|
source=source,
|
|
@@ -190,9 +297,33 @@ class Model(BaseEntity):
|
|
|
190
297
|
|
|
191
298
|
@handle_api_error
|
|
192
299
|
def delete(self) -> Job:
|
|
193
|
-
"""Delete a model and it's associated resources"""
|
|
300
|
+
"""Delete a model and it's associated resources."""
|
|
194
301
|
assert self.id is not None
|
|
195
302
|
response = self._client().delete(url=self._get_url(id_=self.id))
|
|
196
303
|
|
|
197
|
-
job_compact =
|
|
304
|
+
job_compact = JobCompactResp(**response.json()['data']['job'])
|
|
198
305
|
return Job.get(id_=job_compact.id)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass
|
|
309
|
+
class ModelCompact:
|
|
310
|
+
id: UUID
|
|
311
|
+
name: str
|
|
312
|
+
|
|
313
|
+
def fetch(self) -> Model:
|
|
314
|
+
"""Fetch model instance"""
|
|
315
|
+
return Model.get(id_=self.id)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ModelCompactMixin:
|
|
319
|
+
@property
|
|
320
|
+
def model(self) -> ModelCompact:
|
|
321
|
+
"""Model instance"""
|
|
322
|
+
response = getattr(self, '_resp', None)
|
|
323
|
+
if not response or not hasattr(response, 'model'):
|
|
324
|
+
raise AttributeError(
|
|
325
|
+
'This property is available only for objects generated from API '
|
|
326
|
+
'response.'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return ModelCompact(id=response.model.id, name=response.model.name)
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from fiddler3.constants.common import (
|
|
13
|
+
CONTENT_TYPE_OCTET_STREAM_HEADER,
|
|
14
|
+
MULTI_PART_CHUNK_SIZE,
|
|
15
|
+
)
|
|
16
|
+
from fiddler3.decorators import handle_api_error
|
|
17
|
+
from fiddler3.entities.job import Job
|
|
18
|
+
from fiddler3.schemas.deployment_params import ArtifactType, DeploymentParams
|
|
19
|
+
from fiddler3.schemas.job import JobCompactResp
|
|
20
|
+
from fiddler3.schemas.model_artifact import ModelArtifactDeployMultiPartUploadResp
|
|
21
|
+
from fiddler3.utils.logger import get_logger
|
|
22
|
+
from fiddler3.utils.validations import validate_artifact_dir
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArtifactMixin:
|
|
28
|
+
id: UUID | None
|
|
29
|
+
_client: Callable
|
|
30
|
+
|
|
31
|
+
def _get_method(self, update: bool = False) -> Callable:
|
|
32
|
+
"""Get HTTP method"""
|
|
33
|
+
return self._client().put if update else self._client().post
|
|
34
|
+
|
|
35
|
+
def _deploy_model_artifact(
|
|
36
|
+
self,
|
|
37
|
+
model_dir: str | Path,
|
|
38
|
+
deployment_params: DeploymentParams | None = None,
|
|
39
|
+
update: bool = False,
|
|
40
|
+
) -> Job:
|
|
41
|
+
"""
|
|
42
|
+
Upload and deploy model artifact for an existing model
|
|
43
|
+
|
|
44
|
+
:param model_dir: Model artifact directory
|
|
45
|
+
:param deployment_params: Model deployment parameters
|
|
46
|
+
:param update: Set True for updating artifact, False for adding artifact
|
|
47
|
+
:return: Async job
|
|
48
|
+
"""
|
|
49
|
+
model_dir = Path(model_dir)
|
|
50
|
+
validate_artifact_dir(model_dir)
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
deployment_params
|
|
54
|
+
and deployment_params.artifact_type.upper() == ArtifactType.SURROGATE
|
|
55
|
+
):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f'{ArtifactType.SURROGATE} artifact_type is an invalid value for this '
|
|
58
|
+
f'method. Use {ArtifactType.PYTHON_PACKAGE} instead.'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
62
|
+
# Archive model artifact directory
|
|
63
|
+
logger.info(
|
|
64
|
+
'Model[%s] - Tarring model artifact directory - %s',
|
|
65
|
+
self.id,
|
|
66
|
+
model_dir,
|
|
67
|
+
)
|
|
68
|
+
file_path = shutil.make_archive(
|
|
69
|
+
base_name=str(Path(tmp) / 'files'),
|
|
70
|
+
format='tar',
|
|
71
|
+
root_dir=str(model_dir),
|
|
72
|
+
base_dir='.',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
'Model[%s] - Model artifact tar file created at %s',
|
|
77
|
+
self.id,
|
|
78
|
+
file_path,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Choose deployer based on archive file size
|
|
82
|
+
if os.path.getsize(file_path) < MULTI_PART_CHUNK_SIZE:
|
|
83
|
+
job = self._artifact_deploy(
|
|
84
|
+
file_path=Path(file_path),
|
|
85
|
+
deployment_params=deployment_params,
|
|
86
|
+
update=update,
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
job = self._artifact_deploy_multipart(
|
|
90
|
+
file_path=Path(file_path),
|
|
91
|
+
deployment_params=deployment_params,
|
|
92
|
+
update=update,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
logger.info(
|
|
96
|
+
'Model[%s] - Submitted job (%s) for deploying model artifact',
|
|
97
|
+
self.id,
|
|
98
|
+
job.id,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return job
|
|
102
|
+
|
|
103
|
+
def _initialize_multi_part_upload(self, update: bool = False) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Initialize multi-part upload request
|
|
106
|
+
|
|
107
|
+
:return: Multi-part upload id
|
|
108
|
+
"""
|
|
109
|
+
logger.info(
|
|
110
|
+
'Model[%s] - Initializing multi-part model upload',
|
|
111
|
+
self.id,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
method = self._get_method(update)
|
|
115
|
+
|
|
116
|
+
response = method(url=f'/v3/models/{self.id}/deploy-artifact-multi-part-init')
|
|
117
|
+
|
|
118
|
+
response_data = response.json().get('data', {})
|
|
119
|
+
|
|
120
|
+
logger.info(
|
|
121
|
+
'Model[%s] - Multi-part model upload initialized',
|
|
122
|
+
self.id,
|
|
123
|
+
)
|
|
124
|
+
return response_data.get('upload_id', '')
|
|
125
|
+
|
|
126
|
+
def _upload_multi_part_chunk(
|
|
127
|
+
self, data: bytes, upload_id: str, part_number: int, update: bool
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""
|
|
130
|
+
Upload data chunk
|
|
131
|
+
|
|
132
|
+
:param data: Data chunk
|
|
133
|
+
:param upload_id: Multi-part upload id
|
|
134
|
+
:param part_number: Chunk part number
|
|
135
|
+
:return: Part details
|
|
136
|
+
"""
|
|
137
|
+
method = self._get_method(update)
|
|
138
|
+
|
|
139
|
+
response = method(
|
|
140
|
+
url=f'/v3/models/{self.id}/deploy-artifact-multi-part-upload',
|
|
141
|
+
params={
|
|
142
|
+
'upload_id': upload_id,
|
|
143
|
+
'part_number': part_number,
|
|
144
|
+
},
|
|
145
|
+
data=data,
|
|
146
|
+
headers=CONTENT_TYPE_OCTET_STREAM_HEADER,
|
|
147
|
+
)
|
|
148
|
+
response_data = ModelArtifactDeployMultiPartUploadResp(
|
|
149
|
+
**response.json()['data']
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
'etag': response_data.etag,
|
|
154
|
+
'part_number': response_data.part_number,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def _complete_multi_part_upload(
|
|
158
|
+
self,
|
|
159
|
+
upload_id: str,
|
|
160
|
+
parts: list[dict],
|
|
161
|
+
update: bool,
|
|
162
|
+
deployment_params: DeploymentParams | None = None,
|
|
163
|
+
) -> Job:
|
|
164
|
+
"""
|
|
165
|
+
Complete multi-part upload request and deploy model artifact
|
|
166
|
+
|
|
167
|
+
:param upload_id: Multi-part upload id
|
|
168
|
+
:param parts: List of all parts
|
|
169
|
+
:param deployment_params: Deployment parameters
|
|
170
|
+
:return: Async job
|
|
171
|
+
"""
|
|
172
|
+
method = self._get_method(update)
|
|
173
|
+
payload: dict[str, Any] = {
|
|
174
|
+
'upload_id': upload_id,
|
|
175
|
+
'parts': parts,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if deployment_params:
|
|
179
|
+
payload['deployment_params'] = deployment_params.dict(exclude_unset=True)
|
|
180
|
+
|
|
181
|
+
response = method(
|
|
182
|
+
url=f'/v3/models/{self.id}/deploy-artifact-multi-part-complete',
|
|
183
|
+
data=payload,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.info(
|
|
187
|
+
'Model[%s] - Multi-part model upload completed',
|
|
188
|
+
self.id,
|
|
189
|
+
)
|
|
190
|
+
job_compact = JobCompactResp(**response.json()['data']['job'])
|
|
191
|
+
return Job.get(id_=job_compact.id)
|
|
192
|
+
|
|
193
|
+
def _artifact_deploy_multipart(
|
|
194
|
+
self,
|
|
195
|
+
file_path: Path,
|
|
196
|
+
deployment_params: DeploymentParams | None = None,
|
|
197
|
+
update: bool = False,
|
|
198
|
+
) -> Job:
|
|
199
|
+
"""
|
|
200
|
+
Upload and deploy model artifact
|
|
201
|
+
|
|
202
|
+
:param file_path: Path to model artifact tar file
|
|
203
|
+
:param deployment_params: Model deployment parameters
|
|
204
|
+
:param update: Flag for add or update artifacts
|
|
205
|
+
:return: Async job uuid
|
|
206
|
+
"""
|
|
207
|
+
# 1. Initialize multi-part upload request
|
|
208
|
+
upload_id = self._initialize_multi_part_upload(update)
|
|
209
|
+
part_number = 1
|
|
210
|
+
parts = []
|
|
211
|
+
file_size = os.path.getsize(file_path)
|
|
212
|
+
total_chunks = math.ceil(file_size / MULTI_PART_CHUNK_SIZE)
|
|
213
|
+
|
|
214
|
+
# 2. Chunk and upload
|
|
215
|
+
with open(file_path, 'rb') as f:
|
|
216
|
+
while True:
|
|
217
|
+
data = f.read(MULTI_PART_CHUNK_SIZE)
|
|
218
|
+
if not data:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
logger.info(
|
|
222
|
+
'Model[%s] - Uploading multi-part chunk - %d/%d',
|
|
223
|
+
self.id,
|
|
224
|
+
part_number,
|
|
225
|
+
total_chunks,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
part = self._upload_multi_part_chunk(
|
|
229
|
+
data=data,
|
|
230
|
+
upload_id=upload_id,
|
|
231
|
+
part_number=part_number,
|
|
232
|
+
update=update,
|
|
233
|
+
)
|
|
234
|
+
parts.append(part)
|
|
235
|
+
logger.info(
|
|
236
|
+
'Model[%s] - Uploaded multi-part chunk - %d/%d',
|
|
237
|
+
self.id,
|
|
238
|
+
part_number,
|
|
239
|
+
total_chunks,
|
|
240
|
+
)
|
|
241
|
+
part_number += 1
|
|
242
|
+
|
|
243
|
+
# 3: Complete the upload
|
|
244
|
+
return self._complete_multi_part_upload(
|
|
245
|
+
upload_id=upload_id,
|
|
246
|
+
parts=parts,
|
|
247
|
+
update=update,
|
|
248
|
+
deployment_params=deployment_params,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _artifact_deploy(
|
|
252
|
+
self,
|
|
253
|
+
file_path: Path,
|
|
254
|
+
deployment_params: DeploymentParams | None = None,
|
|
255
|
+
update: bool = False,
|
|
256
|
+
) -> Job:
|
|
257
|
+
"""Artifact deploy base method."""
|
|
258
|
+
method = self._get_method(update)
|
|
259
|
+
params = {}
|
|
260
|
+
if deployment_params:
|
|
261
|
+
params['deployment_params'] = json.dumps(
|
|
262
|
+
deployment_params.dict(exclude_unset=True)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
with open(file_path, 'rb') as f:
|
|
266
|
+
data = f.read()
|
|
267
|
+
response = method(
|
|
268
|
+
url=f'/v3/models/{self.id}/deploy-artifact',
|
|
269
|
+
params=params,
|
|
270
|
+
data=data,
|
|
271
|
+
headers=CONTENT_TYPE_OCTET_STREAM_HEADER,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
job_compact = JobCompactResp(**response.json()['data']['job'])
|
|
275
|
+
return Job.get(id_=job_compact.id)
|
|
276
|
+
|
|
277
|
+
@handle_api_error
|
|
278
|
+
def add_artifact(
|
|
279
|
+
self,
|
|
280
|
+
model_dir: str | Path,
|
|
281
|
+
deployment_params: DeploymentParams | None = None,
|
|
282
|
+
) -> Job:
|
|
283
|
+
"""
|
|
284
|
+
Upload and deploy model artifact.
|
|
285
|
+
|
|
286
|
+
:param model_dir: Path to model artifact tar file
|
|
287
|
+
:param deployment_params: Model deployment parameters
|
|
288
|
+
:return: Async job.
|
|
289
|
+
"""
|
|
290
|
+
self._check_id_attributes()
|
|
291
|
+
job = self._deploy_model_artifact(
|
|
292
|
+
model_dir=model_dir, deployment_params=deployment_params
|
|
293
|
+
)
|
|
294
|
+
return job
|
|
295
|
+
|
|
296
|
+
@handle_api_error
|
|
297
|
+
def update_artifact(
|
|
298
|
+
self,
|
|
299
|
+
model_dir: str | Path,
|
|
300
|
+
deployment_params: DeploymentParams | None = None,
|
|
301
|
+
) -> Job:
|
|
302
|
+
"""
|
|
303
|
+
Update existing model artifact.
|
|
304
|
+
|
|
305
|
+
:param model_dir: Path to model artifact tar file
|
|
306
|
+
:param deployment_params: Model deployment parameters
|
|
307
|
+
:return: Async job.
|
|
308
|
+
"""
|
|
309
|
+
self._check_id_attributes()
|
|
310
|
+
job = self._deploy_model_artifact(
|
|
311
|
+
model_dir=model_dir, deployment_params=deployment_params, update=True
|
|
312
|
+
)
|
|
313
|
+
return job
|
|
314
|
+
|
|
315
|
+
@handle_api_error
|
|
316
|
+
def download_artifact(
|
|
317
|
+
self,
|
|
318
|
+
output_dir: str | Path,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Download existing model artifact.
|
|
322
|
+
|
|
323
|
+
:param output_dir: Path to download model artifact tar file
|
|
324
|
+
"""
|
|
325
|
+
self._check_id_attributes()
|
|
326
|
+
output_dir = Path(output_dir)
|
|
327
|
+
if output_dir.exists():
|
|
328
|
+
raise ValueError(f'Output dir already exists {output_dir}')
|
|
329
|
+
|
|
330
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
331
|
+
# Download tar file
|
|
332
|
+
tar_file_path = os.path.join(tmp_dir, 'artifact.tar')
|
|
333
|
+
|
|
334
|
+
with self._client().get(
|
|
335
|
+
url=f'/v3/models/{self.id}/download-artifact'
|
|
336
|
+
) as resp:
|
|
337
|
+
resp.raise_for_status()
|
|
338
|
+
with open(tar_file_path, mode='wb') as f:
|
|
339
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
340
|
+
if chunk:
|
|
341
|
+
f.write(chunk)
|
|
342
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
343
|
+
shutil.unpack_archive(tar_file_path, extract_dir=output_dir, format='tar')
|
|
344
|
+
|
|
345
|
+
def _check_id_attributes(self) -> None:
|
|
346
|
+
if not self.id:
|
|
347
|
+
raise AttributeError(
|
|
348
|
+
'This method is available only for model object generated from '
|
|
349
|
+
'API response.'
|
|
350
|
+
)
|