fiddler-client 2.4.0.dev2__py3-none-any.whl → 2.5.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 +51 -1
- fiddler/api/webhooks_mixin.py +6 -8
- fiddler/core_objects.py +67 -30
- fiddler/schema/alert.py +8 -1
- fiddler/schemas/custom_features.py +13 -0
- fiddler3/__init__.py +1 -36
- fiddler3/connection.py +68 -1
- fiddler3/constants/alert_rule.py +30 -0
- fiddler3/constants/events.py +7 -0
- fiddler3/entities/__init__.py +3 -0
- fiddler3/entities/alert_record.py +106 -0
- fiddler3/entities/alert_rule.py +246 -0
- fiddler3/entities/base.py +2 -16
- fiddler3/entities/events.py +120 -0
- fiddler3/entities/files.py +170 -0
- fiddler3/entities/helpers.py +6 -6
- fiddler3/entities/model.py +67 -18
- fiddler3/entities/model_artifact.py +48 -220
- fiddler3/entities/project.py +1 -1
- fiddler3/entities/webhook.py +128 -0
- fiddler3/entities/xai.py +267 -0
- fiddler3/libs/json_encoder.py +1 -1
- fiddler3/schemas/alert_record.py +27 -0
- fiddler3/schemas/alert_rule.py +32 -0
- fiddler3/schemas/custom_features.py +34 -0
- fiddler3/schemas/events.py +15 -0
- fiddler3/schemas/files.py +23 -0
- fiddler3/schemas/model.py +1 -6
- fiddler3/schemas/webhook.py +26 -0
- fiddler3/tests/apis/test_alert_record.py +99 -0
- fiddler3/tests/apis/test_alert_rule.py +323 -0
- fiddler3/tests/apis/test_events.py +128 -0
- fiddler3/tests/apis/test_files.py +164 -0
- fiddler3/tests/apis/test_model.py +55 -8
- fiddler3/tests/apis/test_model_artifact.py +21 -0
- fiddler3/tests/apis/test_project.py +2 -2
- fiddler3/tests/apis/test_webhook.py +280 -0
- fiddler3/tests/apis/test_xai.py +233 -1
- fiddler3/tests/conftest.py +2 -2
- fiddler3/tests/constants.py +5 -0
- fiddler3/tests/test_connection.py +24 -1
- fiddler3/tests/test_json_encoder.py +2 -2
- {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/METADATA +1 -1
- {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/RECORD +50 -34
- tests/fiddler/test_alert.py +95 -5
- fiddler3/schemas/model_artifact.py +0 -6
- {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/LICENSE.txt +0 -0
- {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/WHEEL +0 -0
- {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Iterator
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from fiddler3.constants.alert_rule import AlertCondition, BinSize, CompareTo, Priority
|
|
8
|
+
from fiddler3.decorators import handle_api_error
|
|
9
|
+
from fiddler3.entities.base import BaseEntity
|
|
10
|
+
from fiddler3.entities.baseline import Baseline, BaselineCompactMixin
|
|
11
|
+
from fiddler3.entities.model import Model, ModelCompactMixin
|
|
12
|
+
from fiddler3.entities.project import ProjectCompactMixin
|
|
13
|
+
from fiddler3.schemas.alert_rule import AlertRuleResp
|
|
14
|
+
from fiddler3.schemas.filter_query import OperatorType, QueryCondition, QueryRule
|
|
15
|
+
from fiddler3.utils.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AlertRule(
|
|
21
|
+
BaseEntity, ModelCompactMixin, ProjectCompactMixin, BaselineCompactMixin
|
|
22
|
+
):
|
|
23
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
model_id: UUID,
|
|
27
|
+
metric_id: str | UUID,
|
|
28
|
+
priority: Priority | str,
|
|
29
|
+
compare_to: CompareTo | str,
|
|
30
|
+
condition: AlertCondition | str,
|
|
31
|
+
bin_size: BinSize | str,
|
|
32
|
+
critical_threshold: float,
|
|
33
|
+
warning_threshold: float | None = None,
|
|
34
|
+
columns: list[str] | None = None,
|
|
35
|
+
baseline_id: UUID | None = None,
|
|
36
|
+
compare_bin_delta: int | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Construct a alert rule instance."""
|
|
39
|
+
self.name = name
|
|
40
|
+
self.model_id = model_id
|
|
41
|
+
self.metric_id = metric_id
|
|
42
|
+
self.columns = columns
|
|
43
|
+
self.baseline_id = baseline_id
|
|
44
|
+
self.priority = priority
|
|
45
|
+
self.compare_to = compare_to
|
|
46
|
+
self.compare_bin_delta = compare_bin_delta
|
|
47
|
+
self.warning_threshold = warning_threshold
|
|
48
|
+
self.critical_threshold = critical_threshold
|
|
49
|
+
self.condition = condition
|
|
50
|
+
self.bin_size = bin_size
|
|
51
|
+
|
|
52
|
+
self.id: UUID | None = None
|
|
53
|
+
self.project_id: UUID | None = None
|
|
54
|
+
|
|
55
|
+
self.created_at: datetime | None = None
|
|
56
|
+
self.updated_at: datetime | None = None
|
|
57
|
+
|
|
58
|
+
# Deserialized response object
|
|
59
|
+
self._resp: AlertRuleResp | None = None
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _get_url(id_: UUID | str | None = None) -> str:
|
|
63
|
+
"""Get model resource/item url."""
|
|
64
|
+
url = '/v2/alert-configs'
|
|
65
|
+
return url if not id_ else f'{url}/{id_}'
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _from_dict(cls, data: dict) -> AlertRule:
|
|
69
|
+
"""Build entity object from the given dictionary."""
|
|
70
|
+
|
|
71
|
+
# Deserialize the response
|
|
72
|
+
resp_obj = AlertRuleResp(**data)
|
|
73
|
+
|
|
74
|
+
# Initialize
|
|
75
|
+
instance = cls(
|
|
76
|
+
name=resp_obj.name,
|
|
77
|
+
model_id=resp_obj.model.id,
|
|
78
|
+
metric_id=resp_obj.metric_id,
|
|
79
|
+
priority=resp_obj.priority,
|
|
80
|
+
compare_to=resp_obj.compare_to,
|
|
81
|
+
condition=resp_obj.condition,
|
|
82
|
+
bin_size=resp_obj.bin_size,
|
|
83
|
+
critical_threshold=resp_obj.critical_threshold,
|
|
84
|
+
warning_threshold=resp_obj.warning_threshold,
|
|
85
|
+
columns=resp_obj.columns,
|
|
86
|
+
baseline_id=resp_obj.baseline_id,
|
|
87
|
+
compare_bin_delta=resp_obj.compare_bin_delta,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Add remaining fields
|
|
91
|
+
fields = [
|
|
92
|
+
'id',
|
|
93
|
+
# 'notifications_config',
|
|
94
|
+
'created_at',
|
|
95
|
+
'updated_at',
|
|
96
|
+
]
|
|
97
|
+
for field in fields:
|
|
98
|
+
setattr(instance, field, getattr(resp_obj, field, None))
|
|
99
|
+
|
|
100
|
+
instance.project_id = resp_obj.project.id
|
|
101
|
+
instance._resp = resp_obj
|
|
102
|
+
|
|
103
|
+
return instance
|
|
104
|
+
|
|
105
|
+
def _refresh(self, data: dict) -> None:
|
|
106
|
+
"""Refresh the fields of this instance from the given response dictionary"""
|
|
107
|
+
# Deserialize the response
|
|
108
|
+
resp_obj = AlertRuleResp(**data)
|
|
109
|
+
|
|
110
|
+
# Reset properties
|
|
111
|
+
self.model_id = resp_obj.model.id
|
|
112
|
+
self.project_id = resp_obj.project.id
|
|
113
|
+
|
|
114
|
+
# Add remaining fields
|
|
115
|
+
fields = [
|
|
116
|
+
'id',
|
|
117
|
+
'created_at',
|
|
118
|
+
'updated_at',
|
|
119
|
+
]
|
|
120
|
+
for field in fields:
|
|
121
|
+
setattr(self, field, getattr(resp_obj, field, None))
|
|
122
|
+
|
|
123
|
+
self._resp = resp_obj
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
@handle_api_error
|
|
127
|
+
def get(cls, id_: UUID | str) -> AlertRule:
|
|
128
|
+
"""
|
|
129
|
+
Get the alert rule instance using alert rule id.
|
|
130
|
+
|
|
131
|
+
:param id_: unique uuid format identifier for alert rule
|
|
132
|
+
:return: alert rule instance
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
response = cls._client().get(url=cls._get_url(id_=id_))
|
|
136
|
+
return cls._from_response(response=response)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
@handle_api_error
|
|
140
|
+
def list( # pylint: disable=too-many-arguments
|
|
141
|
+
cls,
|
|
142
|
+
model_id: UUID | None = None,
|
|
143
|
+
project_id: UUID | None = None,
|
|
144
|
+
metric_id: UUID | str | None = None,
|
|
145
|
+
columns: list[str] | None = None,
|
|
146
|
+
baseline_id: UUID | None = None,
|
|
147
|
+
ordering: list[str] | None = None,
|
|
148
|
+
) -> Iterator[AlertRule]:
|
|
149
|
+
"""Get a list of all alert rules in the organization."""
|
|
150
|
+
rules: list[QueryRule | QueryCondition] = []
|
|
151
|
+
|
|
152
|
+
if model_id:
|
|
153
|
+
rules.append(
|
|
154
|
+
QueryRule(field='model_id', operator=OperatorType.EQUAL, value=model_id)
|
|
155
|
+
)
|
|
156
|
+
if project_id:
|
|
157
|
+
rules.append(
|
|
158
|
+
QueryRule(
|
|
159
|
+
field='project_id', operator=OperatorType.EQUAL, value=project_id
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if baseline_id:
|
|
163
|
+
rules.append(
|
|
164
|
+
QueryRule(
|
|
165
|
+
field='baseline_id', operator=OperatorType.EQUAL, value=baseline_id
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if metric_id:
|
|
169
|
+
rules.append(
|
|
170
|
+
QueryRule(
|
|
171
|
+
field='metric_id', operator=OperatorType.EQUAL, value=metric_id
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
if columns:
|
|
175
|
+
for column in columns:
|
|
176
|
+
rules.append(
|
|
177
|
+
QueryRule(field='columns', operator=OperatorType.ANY, value=column)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
_filter = QueryCondition(rules=rules)
|
|
181
|
+
params: dict[str, Any] = {'filter': _filter.json()}
|
|
182
|
+
|
|
183
|
+
if ordering:
|
|
184
|
+
params['ordering'] = ','.join(ordering)
|
|
185
|
+
|
|
186
|
+
params['organization_name'] = cls.get_organization_name()
|
|
187
|
+
for rule in cls._paginate(url=cls._get_url(), params=params):
|
|
188
|
+
yield cls._from_dict(data=rule)
|
|
189
|
+
|
|
190
|
+
@handle_api_error
|
|
191
|
+
def delete(self) -> None:
|
|
192
|
+
"""Delete an alert rule."""
|
|
193
|
+
assert self.id is not None
|
|
194
|
+
|
|
195
|
+
self._client().delete(url=self._get_url(id_=self.id))
|
|
196
|
+
|
|
197
|
+
@handle_api_error
|
|
198
|
+
def create(self) -> AlertRule:
|
|
199
|
+
"""Create a new alert rule."""
|
|
200
|
+
model = Model.get(self.model_id)
|
|
201
|
+
payload: dict[str, Any] = {
|
|
202
|
+
'name': self.name,
|
|
203
|
+
'organization_name': self.organization_name,
|
|
204
|
+
'model_name': model.name,
|
|
205
|
+
'project_name': model.project.name,
|
|
206
|
+
'metric_id': self.metric_id,
|
|
207
|
+
'priority': self.priority,
|
|
208
|
+
'compare_to': self.compare_to,
|
|
209
|
+
'condition': self.condition,
|
|
210
|
+
'bin_size': self.bin_size,
|
|
211
|
+
'critical_threshold': self.critical_threshold,
|
|
212
|
+
'warning_threshold': self.warning_threshold,
|
|
213
|
+
'feature_names': self.columns,
|
|
214
|
+
'compare_bin_delta': self.compare_bin_delta,
|
|
215
|
+
}
|
|
216
|
+
if self.baseline_id:
|
|
217
|
+
baseline = Baseline.get(self.baseline_id)
|
|
218
|
+
payload['baseline_name'] = baseline.name
|
|
219
|
+
|
|
220
|
+
response = self._client().post(
|
|
221
|
+
url=self._get_url(),
|
|
222
|
+
data=payload,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
self._refresh_from_response(response=response)
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
@handle_api_error
|
|
229
|
+
def enable_notifications(self) -> None:
|
|
230
|
+
"""Enable notifications for an alert rule"""
|
|
231
|
+
self._client().patch(
|
|
232
|
+
url=self._get_url(id_=self.id), data={'enable_notification': True}
|
|
233
|
+
)
|
|
234
|
+
logger.info(
|
|
235
|
+
'Notifications have been enabled for alert rule with id: %s', self.id
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@handle_api_error
|
|
239
|
+
def disable_notifications(self) -> None:
|
|
240
|
+
"""Diable notifications for an alert rule"""
|
|
241
|
+
self._client().patch(
|
|
242
|
+
url=self._get_url(id_=self.id), data={'enable_notification': False}
|
|
243
|
+
)
|
|
244
|
+
logger.info(
|
|
245
|
+
'Notifications have been disabled for alert rule with id: %s', self.id
|
|
246
|
+
)
|
fiddler3/entities/base.py
CHANGED
|
@@ -6,8 +6,7 @@ from typing import Iterator, TypeVar
|
|
|
6
6
|
from requests import Response
|
|
7
7
|
|
|
8
8
|
from fiddler3.configs import DEFAULT_PAGE_SIZE
|
|
9
|
-
from fiddler3.connection import
|
|
10
|
-
from fiddler3.libs.http_client import RequestClient
|
|
9
|
+
from fiddler3.connection import ConnectionMixin
|
|
11
10
|
from fiddler3.schemas.response import PaginatedApiResponse
|
|
12
11
|
|
|
13
12
|
BaseEntityType = TypeVar( # pylint: disable=invalid-name
|
|
@@ -15,20 +14,7 @@ BaseEntityType = TypeVar( # pylint: disable=invalid-name
|
|
|
15
14
|
)
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
class BaseEntity(ABC):
|
|
19
|
-
@classmethod
|
|
20
|
-
def _connection(cls) -> Connection:
|
|
21
|
-
"""Fiddler connection instance"""
|
|
22
|
-
from fiddler3 import connection # pylint: disable=import-outside-toplevel
|
|
23
|
-
|
|
24
|
-
assert connection is not None
|
|
25
|
-
return connection
|
|
26
|
-
|
|
27
|
-
@classmethod
|
|
28
|
-
def _client(cls) -> RequestClient:
|
|
29
|
-
"""Request client instance"""
|
|
30
|
-
return cls._connection().client
|
|
31
|
-
|
|
17
|
+
class BaseEntity(ABC, ConnectionMixin):
|
|
32
18
|
@classmethod
|
|
33
19
|
def _from_response(cls: type[BaseEntityType], response: Response) -> BaseEntityType:
|
|
34
20
|
"""Build entity object from the given response"""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from requests import Response
|
|
9
|
+
|
|
10
|
+
from fiddler3.decorators import handle_api_error
|
|
11
|
+
from fiddler3.entities.files import File
|
|
12
|
+
from fiddler3.entities.job import Job
|
|
13
|
+
from fiddler3.schemas.dataset import EnvType
|
|
14
|
+
from fiddler3.schemas.events import EventsSource, FileSource
|
|
15
|
+
from fiddler3.schemas.job import JobCompactResp
|
|
16
|
+
|
|
17
|
+
STREAM_EVENTS_MAX = 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventMixin:
|
|
21
|
+
id: UUID | None
|
|
22
|
+
_client: Callable
|
|
23
|
+
|
|
24
|
+
@handle_api_error
|
|
25
|
+
def publish(
|
|
26
|
+
self,
|
|
27
|
+
source: list[dict[Any, Any]] | str | Path | pd.DataFrame,
|
|
28
|
+
environment: EnvType,
|
|
29
|
+
dataset_name: str | None = None,
|
|
30
|
+
) -> list[UUID] | Job:
|
|
31
|
+
"""
|
|
32
|
+
Publish Pre-production or Production data
|
|
33
|
+
|
|
34
|
+
:param source: source can be:
|
|
35
|
+
Path or str path: path for data file.
|
|
36
|
+
list[dict]: list of event dicts. max_len=1000. EnvType.PRE_PRODUCTION not supported
|
|
37
|
+
dataframe: events dataframe. EnvType.PRE_PRODUCTION not supported.
|
|
38
|
+
:param environment: either EnvType.PRE_PRODUCTION or EnvType.PRODUCTION
|
|
39
|
+
:param dataset_name: name of the dataset. Not supported for EnvType.PRODUCTION
|
|
40
|
+
|
|
41
|
+
:return: list[UUID] for list of dicts or dataframe source and Job object for file path source.
|
|
42
|
+
"""
|
|
43
|
+
_sources = self._get_sources(source)
|
|
44
|
+
if isinstance(_sources, FileSource):
|
|
45
|
+
return self._publish_file(
|
|
46
|
+
source=_sources,
|
|
47
|
+
environment=environment,
|
|
48
|
+
dataset_name=dataset_name,
|
|
49
|
+
)
|
|
50
|
+
return self._publish_stream(
|
|
51
|
+
sources=_sources,
|
|
52
|
+
environment=environment,
|
|
53
|
+
dataset_name=dataset_name,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _publish_stream(
|
|
57
|
+
self,
|
|
58
|
+
sources: list[EventsSource],
|
|
59
|
+
environment: EnvType,
|
|
60
|
+
dataset_name: str | None = None,
|
|
61
|
+
) -> list[UUID]:
|
|
62
|
+
fiddler_ids = []
|
|
63
|
+
for _source in sources:
|
|
64
|
+
response = self._publish_call(
|
|
65
|
+
source=_source,
|
|
66
|
+
environment=environment,
|
|
67
|
+
dataset_name=dataset_name,
|
|
68
|
+
)
|
|
69
|
+
fiddler_ids.extend(response.json()['data']['fiddler_ids'])
|
|
70
|
+
|
|
71
|
+
return fiddler_ids
|
|
72
|
+
|
|
73
|
+
def _publish_file(
|
|
74
|
+
self,
|
|
75
|
+
source: FileSource,
|
|
76
|
+
environment: EnvType,
|
|
77
|
+
dataset_name: str | None = None,
|
|
78
|
+
) -> Job:
|
|
79
|
+
response = self._publish_call(
|
|
80
|
+
source=source, environment=environment, dataset_name=dataset_name
|
|
81
|
+
)
|
|
82
|
+
job_compact = JobCompactResp(**response.json()['data']['job'])
|
|
83
|
+
return Job.get(id_=job_compact.id)
|
|
84
|
+
|
|
85
|
+
def _get_sources(
|
|
86
|
+
self, source: list[dict[str, Any]] | str | Path | pd.DataFrame
|
|
87
|
+
) -> list[EventsSource] | FileSource:
|
|
88
|
+
_source = []
|
|
89
|
+
if isinstance(source, (str, Path)):
|
|
90
|
+
file = File(path=Path(source)).upload()
|
|
91
|
+
assert file.id is not None
|
|
92
|
+
return FileSource(file_id=file.id)
|
|
93
|
+
|
|
94
|
+
if isinstance(source, list):
|
|
95
|
+
_source.append(EventsSource(events=source))
|
|
96
|
+
|
|
97
|
+
elif isinstance(source, pd.DataFrame):
|
|
98
|
+
for event_dict in [
|
|
99
|
+
source[i : i + STREAM_EVENTS_MAX]
|
|
100
|
+
for i in range(0, source.shape[0], STREAM_EVENTS_MAX)
|
|
101
|
+
]:
|
|
102
|
+
_source.append(EventsSource(events=event_dict.to_dict('records')))
|
|
103
|
+
|
|
104
|
+
return _source
|
|
105
|
+
|
|
106
|
+
def _publish_call(
|
|
107
|
+
self,
|
|
108
|
+
source: FileSource | EventsSource,
|
|
109
|
+
environment: EnvType,
|
|
110
|
+
dataset_name: str | None = None,
|
|
111
|
+
) -> Response:
|
|
112
|
+
return self._client().post(
|
|
113
|
+
url='/v3/events',
|
|
114
|
+
data={
|
|
115
|
+
'source': source.dict(),
|
|
116
|
+
'model_id': self.id,
|
|
117
|
+
'env_type': environment,
|
|
118
|
+
'env_name': dataset_name,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from requests import Response
|
|
11
|
+
from requests_toolbelt import MultipartEncoder
|
|
12
|
+
|
|
13
|
+
from fiddler3.constants.common import MULTI_PART_CHUNK_SIZE
|
|
14
|
+
from fiddler3.decorators import handle_api_error
|
|
15
|
+
from fiddler3.entities.base import BaseEntity
|
|
16
|
+
from fiddler3.entities.user import CreatedByMixin, UpdatedByMixin
|
|
17
|
+
from fiddler3.schemas.files import FileResp
|
|
18
|
+
from fiddler3.utils.logger import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class File(BaseEntity, CreatedByMixin, UpdatedByMixin):
|
|
24
|
+
def __init__(self, path: str | Path, name: str | None = None) -> None:
|
|
25
|
+
"""Construct a files instance."""
|
|
26
|
+
self.path = path
|
|
27
|
+
self.name = name or os.path.basename(self.path)
|
|
28
|
+
self.id: UUID | None = None
|
|
29
|
+
self.status: str | None = None
|
|
30
|
+
self.type: str | None = None
|
|
31
|
+
|
|
32
|
+
self.created_at: datetime | None = None
|
|
33
|
+
self.updated_at: datetime | None = None
|
|
34
|
+
|
|
35
|
+
# Deserialized response object
|
|
36
|
+
self._resp: FileResp | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def _from_dict(cls, data: dict) -> File:
|
|
40
|
+
"""Build entity object from the given dictionary"""
|
|
41
|
+
raise NotImplementedError()
|
|
42
|
+
|
|
43
|
+
def _refresh(self, data: dict) -> None:
|
|
44
|
+
"""Refresh the fields of this instance from the given response dictionary"""
|
|
45
|
+
resp_obj = FileResp(**data)
|
|
46
|
+
|
|
47
|
+
# Reset fields
|
|
48
|
+
self.name = resp_obj.name
|
|
49
|
+
|
|
50
|
+
# Add remaining fields
|
|
51
|
+
fields = [
|
|
52
|
+
'id',
|
|
53
|
+
'status',
|
|
54
|
+
'type',
|
|
55
|
+
'created_at',
|
|
56
|
+
'updated_at',
|
|
57
|
+
]
|
|
58
|
+
for field in fields:
|
|
59
|
+
setattr(self, field, getattr(resp_obj, field, None))
|
|
60
|
+
|
|
61
|
+
self._resp = resp_obj
|
|
62
|
+
|
|
63
|
+
@handle_api_error
|
|
64
|
+
def upload(
|
|
65
|
+
self,
|
|
66
|
+
chunk_size: int = MULTI_PART_CHUNK_SIZE,
|
|
67
|
+
) -> File:
|
|
68
|
+
"""Upload file. Single or multipart upload depends on file size"""
|
|
69
|
+
upload_method = self._get_upload_method(chunk_size=chunk_size)
|
|
70
|
+
return upload_method(
|
|
71
|
+
chunk_size=chunk_size,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@handle_api_error
|
|
75
|
+
def single_upload(
|
|
76
|
+
self,
|
|
77
|
+
chunk_size: int = MULTI_PART_CHUNK_SIZE, # pylint: disable=unused-argument
|
|
78
|
+
) -> File:
|
|
79
|
+
"""Single part file upload"""
|
|
80
|
+
with open(self.path, 'rb') as f:
|
|
81
|
+
multipart_data = MultipartEncoder(fields={'file': (self.name, f)})
|
|
82
|
+
|
|
83
|
+
response = self._client().post(
|
|
84
|
+
url='/v3/files/upload',
|
|
85
|
+
data=multipart_data,
|
|
86
|
+
headers={'Content-Type': multipart_data.content_type},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self._refresh_from_response(response)
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
@handle_api_error
|
|
93
|
+
def multipart_upload(
|
|
94
|
+
self,
|
|
95
|
+
chunk_size: int = MULTI_PART_CHUNK_SIZE,
|
|
96
|
+
) -> File:
|
|
97
|
+
"""Multi part file upload"""
|
|
98
|
+
|
|
99
|
+
# init multipart upload
|
|
100
|
+
self._init_multipart_upload()
|
|
101
|
+
|
|
102
|
+
assert self.id is not None
|
|
103
|
+
# multipart parts upload
|
|
104
|
+
parts = self._multipart_upload(
|
|
105
|
+
chunk_size=chunk_size,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# multipart upload complete
|
|
109
|
+
response = self._multipart_upload_complete(parts=parts)
|
|
110
|
+
|
|
111
|
+
self._refresh_from_response(response)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def _init_multipart_upload(self) -> File:
|
|
115
|
+
logger.info('Multi-part upload initializing')
|
|
116
|
+
response = self._client().post(
|
|
117
|
+
url='/v3/files/multipart-init',
|
|
118
|
+
data={'filename': self.name},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self._refresh_from_response(response)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def _multipart_upload(self, chunk_size: int) -> list[dict[str, Any]]:
|
|
125
|
+
parts: list[dict[str, Any]] = []
|
|
126
|
+
part_number = 1
|
|
127
|
+
|
|
128
|
+
with open(self.path, 'rb') as f:
|
|
129
|
+
while True:
|
|
130
|
+
data = f.read(chunk_size)
|
|
131
|
+
if not data:
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
logger.info('Uploading part %d', part_number)
|
|
135
|
+
|
|
136
|
+
response = self._client().post(
|
|
137
|
+
url='/v3/files/multipart-upload',
|
|
138
|
+
files={
|
|
139
|
+
'file': (f'part_{part_number}_{self.name}', io.BytesIO(data)),
|
|
140
|
+
'file_id': (None, str(self.id)),
|
|
141
|
+
'part_number': (None, part_number),
|
|
142
|
+
},
|
|
143
|
+
headers={'Content-Type': None},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
parts.append(
|
|
147
|
+
{'number': part_number, 'id': response.json()['data']['part_id']}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
part_number += 1
|
|
151
|
+
|
|
152
|
+
return parts
|
|
153
|
+
|
|
154
|
+
def _multipart_upload_complete(self, parts: list[dict[str, Any]]) -> Response:
|
|
155
|
+
|
|
156
|
+
response = self._client().post(
|
|
157
|
+
url='/v3/files/multipart-complete',
|
|
158
|
+
json={
|
|
159
|
+
'file_id': str(self.id),
|
|
160
|
+
'parts': parts,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
logger.info('Multi-part upload complete')
|
|
165
|
+
return response
|
|
166
|
+
|
|
167
|
+
def _get_upload_method(self, chunk_size: int) -> Callable:
|
|
168
|
+
if os.path.getsize(self.path) < chunk_size:
|
|
169
|
+
return self.single_upload
|
|
170
|
+
return self.multipart_upload
|
fiddler3/entities/helpers.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from http import HTTPStatus
|
|
2
2
|
|
|
3
3
|
from fiddler3.exceptions import NotFound
|
|
4
|
-
from fiddler3.schemas.response import ErrorData
|
|
4
|
+
from fiddler3.schemas.response import ErrorData, ErrorItem
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def raise_not_found(message: str) -> None:
|
|
@@ -11,11 +11,11 @@ def raise_not_found(message: str) -> None:
|
|
|
11
11
|
code=HTTPStatus.NOT_FOUND,
|
|
12
12
|
message=message,
|
|
13
13
|
errors=[
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
ErrorItem(
|
|
15
|
+
reason='ObjectNotFound',
|
|
16
|
+
message=message,
|
|
17
|
+
help='',
|
|
18
|
+
),
|
|
19
19
|
],
|
|
20
20
|
),
|
|
21
21
|
)
|