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.
Files changed (51) 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 +67 -30
  6. fiddler/schema/alert.py +8 -1
  7. fiddler/schemas/custom_features.py +13 -0
  8. fiddler3/__init__.py +1 -36
  9. fiddler3/connection.py +68 -1
  10. fiddler3/constants/alert_rule.py +30 -0
  11. fiddler3/constants/events.py +7 -0
  12. fiddler3/entities/__init__.py +3 -0
  13. fiddler3/entities/alert_record.py +106 -0
  14. fiddler3/entities/alert_rule.py +246 -0
  15. fiddler3/entities/base.py +2 -16
  16. fiddler3/entities/events.py +120 -0
  17. fiddler3/entities/files.py +170 -0
  18. fiddler3/entities/helpers.py +6 -6
  19. fiddler3/entities/model.py +67 -18
  20. fiddler3/entities/model_artifact.py +48 -220
  21. fiddler3/entities/project.py +1 -1
  22. fiddler3/entities/webhook.py +128 -0
  23. fiddler3/entities/xai.py +267 -0
  24. fiddler3/libs/json_encoder.py +1 -1
  25. fiddler3/schemas/alert_record.py +27 -0
  26. fiddler3/schemas/alert_rule.py +32 -0
  27. fiddler3/schemas/custom_features.py +34 -0
  28. fiddler3/schemas/events.py +15 -0
  29. fiddler3/schemas/files.py +23 -0
  30. fiddler3/schemas/model.py +1 -6
  31. fiddler3/schemas/webhook.py +26 -0
  32. fiddler3/tests/apis/test_alert_record.py +99 -0
  33. fiddler3/tests/apis/test_alert_rule.py +323 -0
  34. fiddler3/tests/apis/test_events.py +128 -0
  35. fiddler3/tests/apis/test_files.py +164 -0
  36. fiddler3/tests/apis/test_model.py +55 -8
  37. fiddler3/tests/apis/test_model_artifact.py +21 -0
  38. fiddler3/tests/apis/test_project.py +2 -2
  39. fiddler3/tests/apis/test_webhook.py +280 -0
  40. fiddler3/tests/apis/test_xai.py +233 -1
  41. fiddler3/tests/conftest.py +2 -2
  42. fiddler3/tests/constants.py +5 -0
  43. fiddler3/tests/test_connection.py +24 -1
  44. fiddler3/tests/test_json_encoder.py +2 -2
  45. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/METADATA +1 -1
  46. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/RECORD +50 -34
  47. tests/fiddler/test_alert.py +95 -5
  48. fiddler3/schemas/model_artifact.py +0 -6
  49. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/LICENSE.txt +0 -0
  50. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/WHEEL +0 -0
  51. {fiddler_client-2.4.0.dev2.dist-info → fiddler_client-2.5.0.dev1.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ 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
@@ -13,17 +14,20 @@ import pandas as pd
13
14
  from fiddler3.constants.model import ModelInputType, ModelTask
14
15
  from fiddler3.decorators import handle_api_error
15
16
  from fiddler3.entities.base import BaseEntity
17
+ from fiddler3.entities.events import EventMixin
16
18
  from fiddler3.entities.helpers import raise_not_found
17
19
  from fiddler3.entities.job import Job
18
- from fiddler3.entities.model_artifact import ArtifactMixin
20
+ from fiddler3.entities.model_artifact import ModelArtifact
21
+ from fiddler3.entities.model_deployment import ModelDeployment
19
22
  from fiddler3.entities.model_surrogate import SurrogateMixin
20
23
  from fiddler3.entities.project import ProjectCompactMixin
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
@@ -35,12 +39,12 @@ if typing.TYPE_CHECKING:
35
39
 
36
40
  class Model(
37
41
  BaseEntity,
38
- ArtifactMixin,
39
42
  CreatedByMixin,
40
43
  ProjectCompactMixin,
41
44
  UpdatedByMixin,
42
45
  SurrogateMixin,
43
46
  XaiMixin,
47
+ EventMixin,
44
48
  ): # pylint: disable=too-many-ancestors
45
49
  def __init__( # pylint: disable=too-many-arguments
46
50
  self,
@@ -74,11 +78,6 @@ class Model(
74
78
  self.id: UUID | None = None
75
79
  self.artifact_status: str | None = None
76
80
  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
81
  self.is_binary_ranking_model: bool | None = None
83
82
  self.created_at: datetime | None = None
84
83
  self.updated_at: datetime | None = None
@@ -122,11 +121,6 @@ class Model(
122
121
  'updated_at',
123
122
  'artifact_status',
124
123
  'artifact_files',
125
- 'input_cols',
126
- 'output_cols',
127
- 'target_cols',
128
- 'metadata_cols',
129
- 'decision_cols',
130
124
  'is_binary_ranking_model',
131
125
  ]
132
126
  for field in fields:
@@ -160,11 +154,6 @@ class Model(
160
154
  'updated_at',
161
155
  'artifact_status',
162
156
  'artifact_files',
163
- 'input_cols',
164
- 'output_cols',
165
- 'target_cols',
166
- 'metadata_cols',
167
- 'decision_cols',
168
157
  'is_binary_ranking_model',
169
158
  ]
170
159
  for field in fields:
@@ -172,6 +161,12 @@ class Model(
172
161
 
173
162
  self._resp = resp_obj
174
163
 
164
+ @cached_property
165
+ def _artifact(self) -> ModelArtifact:
166
+ """Model artifact instance"""
167
+ assert self.id is not None
168
+ return ModelArtifact(model_id=self.id)
169
+
175
170
  @classmethod
176
171
  @handle_api_error
177
172
  def get(cls, id_: UUID | str) -> Model:
@@ -268,6 +263,16 @@ class Model(
268
263
 
269
264
  yield from Dataset.list(model_id=self.id)
270
265
 
266
+ @property
267
+ @handle_api_error
268
+ def model_deployment(self) -> ModelDeployment:
269
+ """Fetch all the deployments for this model"""
270
+ url = f'{self._get_url(id_=self.id)}/deployment'
271
+ response = self._client().get(url=url)
272
+ return ModelDeployment._from_dict( # pylint: disable=protected-access
273
+ data=response.json()['data']
274
+ )
275
+
271
276
  @classmethod
272
277
  @handle_api_error
273
278
  def generate_schema(
@@ -304,6 +309,50 @@ class Model(
304
309
  job_compact = JobCompactResp(**response.json()['data']['job'])
305
310
  return Job.get(id_=job_compact.id)
306
311
 
312
+ def add_artifact(
313
+ self,
314
+ model_dir: str | Path,
315
+ deployment_params: DeploymentParams | None = None,
316
+ ) -> Job:
317
+ """
318
+ Upload and deploy model artifact.
319
+
320
+ :param model_dir: Path to model artifact tar file
321
+ :param deployment_params: Model deployment parameters
322
+ :return: Async job instance
323
+ """
324
+ return self._artifact.add(
325
+ model_dir=model_dir, deployment_params=deployment_params
326
+ )
327
+
328
+ def update_artifact(
329
+ self,
330
+ model_dir: str | Path,
331
+ deployment_params: DeploymentParams | None = None,
332
+ ) -> Job:
333
+ """
334
+ Update existing model artifact.
335
+
336
+ :param model_dir: Path to model artifact tar file
337
+ :param deployment_params: Model deployment parameters
338
+ :return: Async job instance
339
+ """
340
+ return self._artifact.update(
341
+ model_dir=model_dir, deployment_params=deployment_params
342
+ )
343
+
344
+ def download_artifact(
345
+ self,
346
+ output_dir: str | Path,
347
+ ) -> None:
348
+ """
349
+ Download existing model artifact.
350
+
351
+ :param output_dir: Path to download model artifact tar file
352
+ """
353
+
354
+ self._artifact.download(output_dir=output_dir)
355
+
307
356
 
308
357
  @dataclass
309
358
  class ModelCompact:
@@ -1,36 +1,38 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
- import math
5
3
  import os
6
4
  import shutil
7
5
  import tempfile
8
6
  from pathlib import Path
9
- from typing import Any, Callable
7
+ from typing import Callable
10
8
  from uuid import UUID
11
9
 
12
- from fiddler3.constants.common import (
13
- CONTENT_TYPE_OCTET_STREAM_HEADER,
14
- MULTI_PART_CHUNK_SIZE,
15
- )
10
+ from fiddler3.connection import ConnectionMixin
16
11
  from fiddler3.decorators import handle_api_error
12
+ from fiddler3.entities.files import File
17
13
  from fiddler3.entities.job import Job
18
- from fiddler3.schemas.deployment_params import ArtifactType, DeploymentParams
14
+ from fiddler3.schemas.deployment_params import DeploymentParams
19
15
  from fiddler3.schemas.job import JobCompactResp
20
- from fiddler3.schemas.model_artifact import ModelArtifactDeployMultiPartUploadResp
21
16
  from fiddler3.utils.logger import get_logger
22
17
  from fiddler3.utils.validations import validate_artifact_dir
23
18
 
24
19
  logger = get_logger(__name__)
25
20
 
26
21
 
27
- class ArtifactMixin:
28
- id: UUID | None
29
- _client: Callable
22
+ class ModelArtifact(ConnectionMixin):
23
+ def __init__(self, model_id: UUID) -> None:
24
+ """
25
+ Model artifact methods
26
+
27
+ :param model_id: Model identifier
28
+ """
29
+ self.model_id = model_id
30
30
 
31
31
  def _get_method(self, update: bool = False) -> Callable:
32
- """Get HTTP method"""
33
- return self._client().put if update else self._client().post
32
+ """Get HTTP method based on operation"""
33
+ if update:
34
+ return self._client().put
35
+ return self._client().post
34
36
 
35
37
  def _deploy_model_artifact(
36
38
  self,
@@ -49,20 +51,11 @@ class ArtifactMixin:
49
51
  model_dir = Path(model_dir)
50
52
  validate_artifact_dir(model_dir)
51
53
 
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
54
  with tempfile.TemporaryDirectory() as tmp:
62
55
  # Archive model artifact directory
63
56
  logger.info(
64
57
  'Model[%s] - Tarring model artifact directory - %s',
65
- self.id,
58
+ self.model_id,
66
59
  model_dir,
67
60
  )
68
61
  file_path = shutil.make_archive(
@@ -74,208 +67,54 @@ class ArtifactMixin:
74
67
 
75
68
  logger.info(
76
69
  'Model[%s] - Model artifact tar file created at %s',
77
- self.id,
70
+ self.model_id,
78
71
  file_path,
79
72
  )
80
73
 
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
- )
74
+ # Upload file
75
+ file = File(path=file_path).upload()
76
+
77
+ # Deploy artifact
78
+ assert file.id is not None
79
+ job = self._artifact_deploy(
80
+ file_id=file.id,
81
+ deployment_params=deployment_params,
82
+ update=update,
83
+ )
94
84
 
95
85
  logger.info(
96
86
  'Model[%s] - Submitted job (%s) for deploying model artifact',
97
- self.id,
87
+ self.model_id,
98
88
  job.id,
99
89
  )
100
90
 
101
91
  return job
102
92
 
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(
93
+ def _artifact_deploy(
158
94
  self,
159
- upload_id: str,
160
- parts: list[dict],
161
- update: bool,
95
+ file_id: UUID,
162
96
  deployment_params: DeploymentParams | None = None,
97
+ update: bool = False,
163
98
  ) -> 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
- """
99
+ """Artifact deploy base method."""
172
100
  method = self._get_method(update)
173
- payload: dict[str, Any] = {
174
- 'upload_id': upload_id,
175
- 'parts': parts,
101
+ payload = {
102
+ 'file_id': file_id,
103
+ 'deployment_params': deployment_params.dict(exclude_unset=True)
104
+ if deployment_params
105
+ else {},
176
106
  }
177
107
 
178
- if deployment_params:
179
- payload['deployment_params'] = deployment_params.dict(exclude_unset=True)
180
-
181
108
  response = method(
182
- url=f'/v3/models/{self.id}/deploy-artifact-multi-part-complete',
109
+ url=f'/v3/models/{self.model_id}/deploy-artifact',
183
110
  data=payload,
184
111
  )
185
112
 
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
113
  job_compact = JobCompactResp(**response.json()['data']['job'])
275
114
  return Job.get(id_=job_compact.id)
276
115
 
277
116
  @handle_api_error
278
- def add_artifact(
117
+ def add(
279
118
  self,
280
119
  model_dir: str | Path,
281
120
  deployment_params: DeploymentParams | None = None,
@@ -285,16 +124,14 @@ class ArtifactMixin:
285
124
 
286
125
  :param model_dir: Path to model artifact tar file
287
126
  :param deployment_params: Model deployment parameters
288
- :return: Async job.
127
+ :return: Async job instance
289
128
  """
290
- self._check_id_attributes()
291
- job = self._deploy_model_artifact(
129
+ return self._deploy_model_artifact(
292
130
  model_dir=model_dir, deployment_params=deployment_params
293
131
  )
294
- return job
295
132
 
296
133
  @handle_api_error
297
- def update_artifact(
134
+ def update(
298
135
  self,
299
136
  model_dir: str | Path,
300
137
  deployment_params: DeploymentParams | None = None,
@@ -304,16 +141,14 @@ class ArtifactMixin:
304
141
 
305
142
  :param model_dir: Path to model artifact tar file
306
143
  :param deployment_params: Model deployment parameters
307
- :return: Async job.
144
+ :return: Async job instance
308
145
  """
309
- self._check_id_attributes()
310
- job = self._deploy_model_artifact(
146
+ return self._deploy_model_artifact(
311
147
  model_dir=model_dir, deployment_params=deployment_params, update=True
312
148
  )
313
- return job
314
149
 
315
150
  @handle_api_error
316
- def download_artifact(
151
+ def download(
317
152
  self,
318
153
  output_dir: str | Path,
319
154
  ) -> None:
@@ -322,7 +157,7 @@ class ArtifactMixin:
322
157
 
323
158
  :param output_dir: Path to download model artifact tar file
324
159
  """
325
- self._check_id_attributes()
160
+
326
161
  output_dir = Path(output_dir)
327
162
  if output_dir.exists():
328
163
  raise ValueError(f'Output dir already exists {output_dir}')
@@ -332,7 +167,7 @@ class ArtifactMixin:
332
167
  tar_file_path = os.path.join(tmp_dir, 'artifact.tar')
333
168
 
334
169
  with self._client().get(
335
- url=f'/v3/models/{self.id}/download-artifact'
170
+ url=f'/v3/models/{self.model_id}/download-artifact'
336
171
  ) as resp:
337
172
  resp.raise_for_status()
338
173
  with open(tar_file_path, mode='wb') as f:
@@ -341,10 +176,3 @@ class ArtifactMixin:
341
176
  f.write(chunk)
342
177
  os.makedirs(output_dir, exist_ok=True)
343
178
  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
- )
@@ -115,7 +115,7 @@ class Project(BaseEntity):
115
115
  def delete(self) -> None:
116
116
  """Delete project"""
117
117
  # @TODO - Switch this to v3 once we have the endpoint
118
- self._client().delete(url=f'/v2/projects/{self.id}')
118
+ self._client().delete(url=f'/v2/projects/{self.organization_name}:{self.name}')
119
119
 
120
120
  @property
121
121
  def models(self) -> Iterator[ModelCompact]:
@@ -0,0 +1,128 @@
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.decorators import handle_api_error
8
+ from fiddler3.entities.base import BaseEntity
9
+ from fiddler3.schemas.webhook import WebhookProvider, WebhookResp
10
+
11
+
12
+ class Webhook(BaseEntity):
13
+ def __init__(self, name: str, url: str, provider: WebhookProvider | str) -> None:
14
+ """
15
+ Construct a webhook instance
16
+
17
+ :param name: Slug like name
18
+ """
19
+ self.name = name
20
+ self.url = url
21
+ # provider is 'SLACK' or 'OTHER' as of Aug 2023.
22
+ self.provider = provider
23
+
24
+ self.id: UUID | None = None
25
+ self.created_at: datetime | None = None
26
+ self.updated_at: datetime | None = None
27
+
28
+ # Deserialized response object
29
+ self._resp: WebhookResp | None = None
30
+
31
+ @staticmethod
32
+ def _get_url(id_: UUID | str | None = None) -> str:
33
+ """Get webhook resource/item url"""
34
+ url = '/v2/webhooks'
35
+ return url if not id_ else f'{url}/{id_}'
36
+
37
+ @classmethod
38
+ def _from_dict(cls, data: dict) -> Webhook:
39
+ """Build entity object from the given dictionary"""
40
+
41
+ # Deserialize the response
42
+ resp_obj = WebhookResp(**data)
43
+
44
+ # Initialize
45
+ instance = cls(name=resp_obj.name, url=resp_obj.url, provider=resp_obj.provider)
46
+ # Add remaining fields
47
+ fields = ['id', 'created_at', 'updated_at']
48
+ for field in fields:
49
+ setattr(instance, field, getattr(resp_obj, field, None))
50
+
51
+ instance._resp = resp_obj
52
+ return instance
53
+
54
+ def _refresh(self, data: dict) -> None:
55
+ """Refresh the fields of this instance from the given response dictionary"""
56
+ # Deserialize the response
57
+ resp_obj = WebhookResp(**data)
58
+
59
+ fields = [
60
+ 'id',
61
+ 'name',
62
+ 'url',
63
+ 'provider',
64
+ 'created_at',
65
+ 'updated_at',
66
+ ]
67
+ for field in fields:
68
+ setattr(self, field, getattr(resp_obj, field, None))
69
+
70
+ self._resp = resp_obj
71
+
72
+ @classmethod
73
+ @handle_api_error
74
+ def get(cls, id_: UUID | str) -> Webhook:
75
+ """
76
+ Get the webhook instance using webhook id
77
+
78
+ :params uuid: UUID belongs to the Webhook
79
+ :returns: `Webhook` object
80
+ """
81
+ response = cls._client().get(url=cls._get_url(id_))
82
+ return cls._from_response(response=response)
83
+
84
+ @classmethod
85
+ @handle_api_error
86
+ def list(cls) -> Iterator[Webhook]:
87
+ """Get a list of all webhooks in the organization"""
88
+ for webhook in cls._paginate(url=cls._get_url()):
89
+ yield cls._from_dict(data=webhook)
90
+
91
+ @handle_api_error
92
+ def create(self) -> Webhook:
93
+ """
94
+ Create a new webhook
95
+
96
+ :params name: name of webhook
97
+ :params url: webhook url
98
+ :params provider: Either 'SLACK' or 'OTHER'
99
+
100
+ :returns: Created `Webhook` object.
101
+ """
102
+
103
+ request_body = {
104
+ 'name': self.name,
105
+ 'url': self.url,
106
+ 'provider': self.provider,
107
+ }
108
+ response = self._client().post(url=self._get_url(), data=request_body)
109
+ self._refresh_from_response(response=response)
110
+ return self
111
+
112
+ @handle_api_error
113
+ def update(self) -> None:
114
+ """Update an existing webhook."""
115
+ body: dict[str, Any] = {
116
+ 'name': self.name,
117
+ 'url': self.url,
118
+ 'provider': self.provider,
119
+ }
120
+
121
+ response = self._client().patch(url=self._get_url(id_=self.id), data=body)
122
+ self._refresh_from_response(response=response)
123
+
124
+ @handle_api_error
125
+ def delete(self) -> None:
126
+ """Delete an existing webhook."""
127
+
128
+ self._client().delete(url=self._get_url(id_=self.id))