fiddler-client 2.4.0.dev2__py3-none-any.whl → 2.5.0.dev2__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 (61) hide show
  1. fiddler/__init__.py +2 -0
  2. fiddler/_version.py +1 -1
  3. fiddler/api/alert_mixin.py +51 -1
  4. fiddler/api/webhooks_mixin.py +6 -8
  5. fiddler/core_objects.py +76 -26
  6. fiddler/schema/alert.py +9 -2
  7. fiddler/schemas/custom_features.py +44 -1
  8. fiddler3/__init__.py +121 -38
  9. fiddler3/configs.py +3 -0
  10. fiddler3/connection.py +68 -1
  11. fiddler3/constants/alert_rule.py +30 -0
  12. fiddler3/constants/events.py +7 -0
  13. fiddler3/constants/model.py +9 -0
  14. fiddler3/entities/__init__.py +0 -7
  15. fiddler3/entities/alert_record.py +106 -0
  16. fiddler3/entities/alert_rule.py +323 -0
  17. fiddler3/entities/base.py +2 -16
  18. fiddler3/entities/baseline.py +21 -15
  19. fiddler3/entities/dataset.py +1 -1
  20. fiddler3/entities/events.py +136 -0
  21. fiddler3/entities/file.py +162 -0
  22. fiddler3/entities/model.py +141 -24
  23. fiddler3/entities/model_artifact.py +52 -224
  24. fiddler3/entities/model_deployment.py +12 -1
  25. fiddler3/entities/project.py +2 -2
  26. fiddler3/entities/{model_surrogate.py → surrogate.py} +27 -29
  27. fiddler3/entities/webhook.py +128 -0
  28. fiddler3/entities/xai.py +271 -5
  29. fiddler3/libs/http_client.py +1 -1
  30. fiddler3/libs/json_encoder.py +1 -1
  31. fiddler3/schemas/__init__.py +0 -9
  32. fiddler3/schemas/alert_record.py +27 -0
  33. fiddler3/schemas/alert_rule.py +39 -0
  34. fiddler3/schemas/custom_features.py +68 -12
  35. fiddler3/schemas/events.py +15 -0
  36. fiddler3/schemas/file.py +23 -0
  37. fiddler3/schemas/model.py +1 -6
  38. fiddler3/schemas/model_deployment.py +5 -4
  39. fiddler3/schemas/webhook.py +23 -0
  40. fiddler3/tests/apis/test_alert_record.py +99 -0
  41. fiddler3/tests/apis/test_alert_rule.py +393 -0
  42. fiddler3/tests/apis/test_events.py +169 -0
  43. fiddler3/tests/apis/test_files.py +164 -0
  44. fiddler3/tests/apis/test_model.py +60 -9
  45. fiddler3/tests/apis/test_model_artifact.py +21 -0
  46. fiddler3/tests/apis/test_project.py +3 -3
  47. fiddler3/tests/apis/test_webhook.py +257 -0
  48. fiddler3/tests/apis/test_xai.py +233 -1
  49. fiddler3/tests/conftest.py +2 -2
  50. fiddler3/tests/constants.py +5 -0
  51. fiddler3/tests/test_connection.py +24 -1
  52. fiddler3/tests/test_json_encoder.py +2 -2
  53. fiddler3/utils/helpers.py +20 -0
  54. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev2.dist-info}/METADATA +15 -5
  55. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev2.dist-info}/RECORD +59 -44
  56. tests/fiddler/test_alert.py +95 -5
  57. fiddler3/entities/helpers.py +0 -21
  58. fiddler3/schemas/model_artifact.py +0 -6
  59. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev2.dist-info}/LICENSE.txt +0 -0
  60. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev2.dist-info}/WHEEL +0 -0
  61. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Iterator
5
+ from uuid import UUID
6
+
7
+ import pandas as pd
8
+ from requests import Response
9
+
10
+ from fiddler3.configs import STREAM_EVENT_LIMIT
11
+ from fiddler3.connection import ConnectionMixin
12
+ from fiddler3.decorators import handle_api_error
13
+ from fiddler3.entities.file import File
14
+ from fiddler3.entities.job import Job
15
+ from fiddler3.schemas.dataset import EnvType
16
+ from fiddler3.schemas.events import EventsSource, FileSource
17
+ from fiddler3.schemas.job import JobCompactResp
18
+
19
+
20
+ class EventPublisher(ConnectionMixin):
21
+ STREAM_LIMIT = STREAM_EVENT_LIMIT
22
+
23
+ def __init__(self, model_id: UUID) -> None:
24
+ """
25
+ Event publishing methods
26
+
27
+ :param model_id: Model identifier
28
+ """
29
+ self.model_id = model_id
30
+
31
+ def _get_stream_chunks(
32
+ self, source: pd.DataFrame | list[dict[str, Any]]
33
+ ) -> Iterator[list[dict[str, Any]]]:
34
+ """Chunk the source based on stream limit"""
35
+
36
+ for i in range(0, len(source), self.STREAM_LIMIT):
37
+ chunk = source[i : i + self.STREAM_LIMIT]
38
+
39
+ if isinstance(chunk, pd.DataFrame):
40
+ events = chunk.to_dict('records')
41
+ else:
42
+ events = chunk
43
+
44
+ yield events
45
+
46
+ @handle_api_error
47
+ def publish(
48
+ self,
49
+ source: list[dict[str, Any]] | str | Path | pd.DataFrame,
50
+ environment: EnvType = EnvType.PRODUCTION,
51
+ dataset_name: str | None = None,
52
+ update: bool = False,
53
+ ) -> list[UUID] | Job:
54
+ """
55
+ Publish Pre-production or Production data
56
+
57
+ :param source: one of:
58
+ Path or str path: path for data file.
59
+ list[dict]: list of event dicts (max 1000) EnvType.PRE_PRODUCTION is not supported
60
+ dataframe: events dataframe. EnvType.PRE_PRODUCTION not supported.
61
+ :param environment: Either EnvType.PRE_PRODUCTION or EnvType.PRODUCTION
62
+ :param dataset_name: Name of the dataset. Not supported for EnvType.PRODUCTION
63
+ :param update: flag indicating if the events are updates to previously published rows
64
+
65
+ :return: list[UUID] for list of dicts or dataframe source and Job object for file path source.
66
+ """
67
+ if isinstance(source, (str, Path)):
68
+ return self._publish_file(
69
+ source=source,
70
+ environment=environment,
71
+ dataset_name=dataset_name,
72
+ update=update,
73
+ )
74
+
75
+ if isinstance(source, (list, pd.DataFrame)):
76
+ return self._publish_stream(source=source, update=update)
77
+
78
+ raise ValueError(f'Unsupported source - {type(source)}')
79
+
80
+ def _publish_stream(
81
+ self,
82
+ source: list[dict[str, Any]] | pd.DataFrame,
83
+ update: bool = False,
84
+ ) -> list[UUID]:
85
+ fiddler_ids = []
86
+ for events in self._get_stream_chunks(source=source):
87
+ response = self._publish_call(
88
+ source=EventsSource(events=events),
89
+ environment=EnvType.PRODUCTION,
90
+ dataset_name=None,
91
+ update=update,
92
+ )
93
+ fiddler_ids.extend(response.json()['data']['fiddler_ids'])
94
+
95
+ return fiddler_ids
96
+
97
+ def _publish_file(
98
+ self,
99
+ source: Path | str,
100
+ environment: EnvType,
101
+ dataset_name: str | None = None,
102
+ update: bool = False,
103
+ ) -> Job:
104
+ file = File(path=Path(source)).upload()
105
+
106
+ assert file.id is not None
107
+
108
+ response = self._publish_call(
109
+ source=FileSource(file_id=file.id),
110
+ environment=environment,
111
+ dataset_name=dataset_name,
112
+ update=update,
113
+ )
114
+ job_compact = JobCompactResp(**response.json()['data']['job'])
115
+ return Job.get(id_=job_compact.id)
116
+
117
+ def _publish_call(
118
+ self,
119
+ source: FileSource | EventsSource,
120
+ environment: EnvType,
121
+ dataset_name: str | None = None,
122
+ update: bool = False,
123
+ ) -> Response:
124
+ if update:
125
+ method = self._client().patch
126
+ else:
127
+ method = self._client().post
128
+ return method(
129
+ url='/v3/events',
130
+ data={
131
+ 'source': source.dict(),
132
+ 'model_id': self.model_id,
133
+ 'env_type': environment,
134
+ 'env_name': dataset_name,
135
+ },
136
+ )
@@ -0,0 +1,162 @@
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
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.file 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
+ if os.path.getsize(self.path) < chunk_size:
70
+ return self.single_upload()
71
+
72
+ return self.multipart_upload(chunk_size=chunk_size)
73
+
74
+ @handle_api_error
75
+ def single_upload(self) -> File:
76
+ """Single part file upload"""
77
+ with open(self.path, 'rb') as f:
78
+ multipart_data = MultipartEncoder(fields={'file': (self.name, f)})
79
+
80
+ response = self._client().post(
81
+ url='/v3/files/upload',
82
+ data=multipart_data,
83
+ headers={'Content-Type': multipart_data.content_type},
84
+ )
85
+
86
+ self._refresh_from_response(response)
87
+ return self
88
+
89
+ @handle_api_error
90
+ def multipart_upload(
91
+ self,
92
+ chunk_size: int = MULTI_PART_CHUNK_SIZE,
93
+ ) -> File:
94
+ """Multi part file upload"""
95
+
96
+ # init multipart upload
97
+ self._init_multipart_upload()
98
+
99
+ assert self.id is not None
100
+ # multipart parts upload
101
+ parts = self._multipart_upload(
102
+ chunk_size=chunk_size,
103
+ )
104
+
105
+ # multipart upload complete
106
+ response = self._multipart_upload_complete(parts=parts)
107
+
108
+ self._refresh_from_response(response)
109
+ return self
110
+
111
+ def _init_multipart_upload(self) -> File:
112
+ logger.info('Multi-part upload initializing')
113
+ response = self._client().post(
114
+ url='/v3/files/multipart-init',
115
+ data={'filename': self.name},
116
+ )
117
+
118
+ self._refresh_from_response(response)
119
+ return self
120
+
121
+ def _multipart_upload(self, chunk_size: int) -> list[dict[str, Any]]:
122
+ parts: list[dict[str, Any]] = []
123
+ part_number = 1
124
+
125
+ with open(self.path, 'rb') as f:
126
+ while True:
127
+ data = f.read(chunk_size)
128
+ if not data:
129
+ break
130
+
131
+ logger.info('Uploading part %d', part_number)
132
+
133
+ response = self._client().post(
134
+ url='/v3/files/multipart-upload',
135
+ files={
136
+ 'file': (f'part_{part_number}_{self.name}', io.BytesIO(data)),
137
+ 'file_id': (None, str(self.id)),
138
+ 'part_number': (None, part_number),
139
+ },
140
+ headers={'Content-Type': None},
141
+ )
142
+
143
+ parts.append(
144
+ {'number': part_number, 'id': response.json()['data']['part_id']}
145
+ )
146
+
147
+ part_number += 1
148
+
149
+ return parts
150
+
151
+ def _multipart_upload_complete(self, parts: list[dict[str, Any]]) -> Response:
152
+
153
+ response = self._client().post(
154
+ url='/v3/files/multipart-complete',
155
+ json={
156
+ 'file_id': str(self.id),
157
+ 'parts': parts,
158
+ },
159
+ )
160
+
161
+ logger.info('Multi-part upload complete')
162
+ return response
@@ -4,29 +4,34 @@ import builtins
4
4
  import typing
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime
7
+ from functools import cached_property
7
8
  from pathlib import Path
8
9
  from typing import Any, Iterator
9
10
  from uuid import UUID
10
11
 
11
12
  import pandas as pd
12
13
 
14
+ from fiddler3.constants.dataset import EnvType
13
15
  from fiddler3.constants.model import ModelInputType, ModelTask
14
16
  from fiddler3.decorators import handle_api_error
15
17
  from fiddler3.entities.base import BaseEntity
16
- from fiddler3.entities.helpers import raise_not_found
18
+ from fiddler3.entities.events import EventPublisher
17
19
  from fiddler3.entities.job import Job
18
- from fiddler3.entities.model_artifact import ArtifactMixin
19
- from fiddler3.entities.model_surrogate import SurrogateMixin
20
+ from fiddler3.entities.model_artifact import ModelArtifact
21
+ from fiddler3.entities.model_deployment import ModelDeployment
20
22
  from fiddler3.entities.project import ProjectCompactMixin
23
+ from fiddler3.entities.surrogate import Surrogate
21
24
  from fiddler3.entities.user import CreatedByMixin, UpdatedByMixin
22
25
  from fiddler3.entities.xai import XaiMixin
26
+ from fiddler3.schemas.deployment_params import DeploymentParams
23
27
  from fiddler3.schemas.filter_query import OperatorType, QueryCondition, QueryRule
24
28
  from fiddler3.schemas.job import JobCompactResp
25
29
  from fiddler3.schemas.model import ModelResp
26
- from fiddler3.schemas.model_schema import Column, ModelSchema
30
+ from fiddler3.schemas.model_schema import ModelSchema
27
31
  from fiddler3.schemas.model_spec import ModelSpec
28
32
  from fiddler3.schemas.model_task_params import ModelTaskParams
29
33
  from fiddler3.schemas.xai_params import XaiParams
34
+ from fiddler3.utils.helpers import raise_not_found
30
35
  from fiddler3.utils.model_schema_generator import SchemaGeneratorFactory
31
36
 
32
37
  if typing.TYPE_CHECKING:
@@ -35,11 +40,9 @@ if typing.TYPE_CHECKING:
35
40
 
36
41
  class Model(
37
42
  BaseEntity,
38
- ArtifactMixin,
39
43
  CreatedByMixin,
40
44
  ProjectCompactMixin,
41
45
  UpdatedByMixin,
42
- SurrogateMixin,
43
46
  XaiMixin,
44
47
  ): # pylint: disable=too-many-ancestors
45
48
  def __init__( # pylint: disable=too-many-arguments
@@ -47,7 +50,7 @@ class Model(
47
50
  name: str,
48
51
  project_id: UUID,
49
52
  schema: ModelSchema,
50
- spec: ModelSpec | None = None,
53
+ spec: ModelSpec,
51
54
  input_type: str = ModelInputType.TABULAR,
52
55
  task: str = ModelTask.NOT_SET,
53
56
  task_params: ModelTaskParams | None = None,
@@ -67,18 +70,13 @@ class Model(
67
70
  self.event_id_col = event_id_col
68
71
  self.event_ts_col = event_ts_col
69
72
  self.event_ts_format = event_ts_format
70
- self.spec = spec or ModelSpec()
73
+ self.spec = spec
71
74
  self.task_params = task_params or ModelTaskParams()
72
75
  self.xai_params = xai_params or XaiParams()
73
76
 
74
77
  self.id: UUID | None = None
75
78
  self.artifact_status: str | None = None
76
79
  self.artifact_files: list[dict] | None = None
77
- self.input_cols: list[Column] | None = None
78
- self.output_cols: list[Column] | None = None
79
- self.target_cols: list[Column] | None = None
80
- self.metadata_cols: list[Column] | None = None
81
- self.decision_cols: list[Column] | None = None
82
80
  self.is_binary_ranking_model: bool | None = None
83
81
  self.created_at: datetime | None = None
84
82
  self.updated_at: datetime | None = None
@@ -122,11 +120,6 @@ class Model(
122
120
  'updated_at',
123
121
  'artifact_status',
124
122
  'artifact_files',
125
- 'input_cols',
126
- 'output_cols',
127
- 'target_cols',
128
- 'metadata_cols',
129
- 'decision_cols',
130
123
  'is_binary_ranking_model',
131
124
  ]
132
125
  for field in fields:
@@ -160,11 +153,6 @@ class Model(
160
153
  'updated_at',
161
154
  'artifact_status',
162
155
  'artifact_files',
163
- 'input_cols',
164
- 'output_cols',
165
- 'target_cols',
166
- 'metadata_cols',
167
- 'decision_cols',
168
156
  'is_binary_ranking_model',
169
157
  ]
170
158
  for field in fields:
@@ -172,6 +160,24 @@ class Model(
172
160
 
173
161
  self._resp = resp_obj
174
162
 
163
+ @cached_property
164
+ def _artifact(self) -> ModelArtifact:
165
+ """Model artifact instance"""
166
+ assert self.id is not None
167
+ return ModelArtifact(model_id=self.id)
168
+
169
+ @cached_property
170
+ def _surrogate(self) -> Surrogate:
171
+ """Model artifact instance"""
172
+ assert self.id is not None
173
+ return Surrogate(model_id=self.id)
174
+
175
+ @cached_property
176
+ def _event_publisher(self) -> EventPublisher:
177
+ """Event publisher instance"""
178
+ assert self.id is not None
179
+ return EventPublisher(model_id=self.id)
180
+
175
181
  @classmethod
176
182
  @handle_api_error
177
183
  def get(cls, id_: UUID | str) -> Model:
@@ -201,7 +207,7 @@ class Model(
201
207
  if response.json()['data']['total'] == 0:
202
208
  raise_not_found('Model not found for the given identifier')
203
209
 
204
- return cls._from_dict(data=response.json()['data']['items'][0])
210
+ return cls.get(id_=response.json()['data']['items'][0]['id'])
205
211
 
206
212
  @handle_api_error
207
213
  def create(self) -> Model:
@@ -268,6 +274,14 @@ class Model(
268
274
 
269
275
  yield from Dataset.list(model_id=self.id)
270
276
 
277
+ @property
278
+ @handle_api_error
279
+ def model_deployment(self) -> ModelDeployment:
280
+ """Fetch all the deployments for this model"""
281
+ assert self.id is not None
282
+
283
+ return ModelDeployment.of(model_id=self.id)
284
+
271
285
  @classmethod
272
286
  @handle_api_error
273
287
  def generate_schema(
@@ -304,6 +318,109 @@ class Model(
304
318
  job_compact = JobCompactResp(**response.json()['data']['job'])
305
319
  return Job.get(id_=job_compact.id)
306
320
 
321
+ @handle_api_error
322
+ def publish(
323
+ self,
324
+ source: builtins.list[dict[str, Any]] | str | Path | pd.DataFrame,
325
+ environment: EnvType = EnvType.PRODUCTION,
326
+ dataset_name: str | None = None,
327
+ update: bool = False,
328
+ ) -> builtins.list[UUID] | Job:
329
+ """
330
+ Publish Pre-production or Production data
331
+
332
+ :param source: one of:
333
+ Path or str path: path for data file.
334
+ list[dict]: list of events (max 1000) PRE_PRODUCTION environment is not
335
+ supported
336
+ dataframe: events dataframe. EnvType.PRE_PRODUCTION not supported.
337
+ :param environment: Either EnvType.PRE_PRODUCTION or EnvType.PRODUCTION
338
+ :param dataset_name: Name of the dataset. Not supported for EnvType.PRODUCTION
339
+ :param update: flag indicating if the events are updates to previously
340
+ published rows
341
+
342
+ :return: list[UUID] for list of dicts or dataframe source and Job object for
343
+ file path source.
344
+ """
345
+
346
+ return self._event_publisher.publish(
347
+ source=source,
348
+ environment=environment,
349
+ dataset_name=dataset_name,
350
+ update=update,
351
+ )
352
+
353
+ def add_artifact(
354
+ self,
355
+ model_dir: str | Path,
356
+ deployment_params: DeploymentParams | None = None,
357
+ ) -> Job:
358
+ """
359
+ Upload and deploy model artifact.
360
+
361
+ :param model_dir: Path to model artifact tar file
362
+ :param deployment_params: Model deployment parameters
363
+ :return: Async job instance
364
+ """
365
+ return self._artifact.add(
366
+ model_dir=model_dir, deployment_params=deployment_params
367
+ )
368
+
369
+ def update_artifact(
370
+ self,
371
+ model_dir: str | Path,
372
+ deployment_params: DeploymentParams | None = None,
373
+ ) -> Job:
374
+ """
375
+ Update existing model artifact.
376
+
377
+ :param model_dir: Path to model artifact tar file
378
+ :param deployment_params: Model deployment parameters
379
+ :return: Async job instance
380
+ """
381
+ return self._artifact.update(
382
+ model_dir=model_dir, deployment_params=deployment_params
383
+ )
384
+
385
+ def download_artifact(
386
+ self,
387
+ output_dir: str | Path,
388
+ ) -> None:
389
+ """
390
+ Download existing model artifact.
391
+
392
+ :param output_dir: Path to download model artifact tar file
393
+ """
394
+
395
+ self._artifact.download(output_dir=output_dir)
396
+
397
+ def add_surrogate(
398
+ self,
399
+ deployment_params: DeploymentParams | None = None,
400
+ ) -> Job:
401
+ """
402
+ Add a new surrogate model
403
+
404
+ :param deployment_params: Model deployment parameters
405
+ :return: Async job
406
+ """
407
+ job = self._surrogate.add(deployment_params=deployment_params)
408
+ return job
409
+
410
+ @handle_api_error
411
+ def update_surrogate(
412
+ self,
413
+ deployment_params: DeploymentParams | None = None,
414
+ ) -> Job:
415
+ """
416
+ Update an existing surrogate model
417
+
418
+ :param deployment_params: Model deployment parameters
419
+ :return: Async job
420
+ """
421
+ job = self._surrogate.update(deployment_params=deployment_params)
422
+ return job
423
+
307
424
 
308
425
  @dataclass
309
426
  class ModelCompact: