fiddler-client 3.5.0.dev2__py3-none-any.whl → 3.6.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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.5.0.dev2
1
+ 3.6.0.dev1
fiddler/__init__.py CHANGED
@@ -57,7 +57,6 @@ from fiddler.schemas.xai import ( # noqa
57
57
  DatasetDataSource,
58
58
  EventIdDataSource,
59
59
  RowDataSource,
60
- SqlSliceQueryDataSource,
61
60
  )
62
61
  from fiddler.schemas.xai_params import XaiParams # noqa
63
62
  from fiddler.utils.helpers import group_by # noqa
@@ -110,7 +109,6 @@ __all__ = [
110
109
  'ModelTaskParams',
111
110
  'Multivariate',
112
111
  'RowDataSource',
113
- 'SqlSliceQueryDataSource',
114
112
  'TextEmbedding',
115
113
  'VectorFeature',
116
114
  'XaiParams',
fiddler/configs.py CHANGED
@@ -1,8 +1,13 @@
1
1
  DEFAULT_CONNECTION_TIMEOUT = 60 # In seconds
2
2
  MIN_SERVER_VERSION = '24.4.0'
3
3
  DEFAULT_PAGE_SIZE = 50
4
- JOB_POLL_INTERVAL = 3
4
+
5
+ # In seconds. For example, this generates a log message every N seconds.
6
+ # Was once set to 3, and I think that was too frequent. If this needs to
7
+ # be snappier, we can grow the interval.
8
+ JOB_POLL_INTERVAL = 6
5
9
  JOB_WAIT_TIMEOUT = 1800
10
+
6
11
  STREAM_EVENT_LIMIT = 1000
7
12
  DEFAULT_NUM_CLUSTERS = 5
8
13
  DEFAULT_NUM_TAGS = 5
fiddler/decorators.py CHANGED
@@ -3,6 +3,7 @@ from http import HTTPStatus
3
3
  from typing import Any, Callable, TypeVar, cast
4
4
 
5
5
  import requests
6
+ import pydantic
6
7
 
7
8
  from fiddler.exceptions import ApiError, Conflict, HttpError, NotFound, Unsupported
8
9
  from fiddler.schemas.response import ErrorResponse
@@ -15,36 +16,67 @@ _WrappedFuncType = TypeVar( # pylint: disable=invalid-name
15
16
  )
16
17
 
17
18
 
18
- def handle_api_error(func: _WrappedFuncType) -> _WrappedFuncType:
19
+ # ignore: fiddler/decorators.py:19:1: C901 'handle_api_error' is too complex (11)
20
+ def handle_api_error(func: _WrappedFuncType) -> _WrappedFuncType: # noqa: C901
19
21
  @wraps(func)
20
22
  def wrapper(*args: Any, **kwargs: Any) -> Any:
21
23
  try:
22
24
  return func(*args, **kwargs)
23
25
  except requests.JSONDecodeError as e:
26
+ # This often is premature use of `json()`. Calling code should
27
+ # first check the response for the expected status code, and only
28
+ # then attempt JSON-deserialization.
24
29
  raise HttpError(message=f'Invalid JSON response - {e.doc}') # type: ignore
25
- except requests.HTTPError as e:
26
- logger.error(
27
- 'HTTP request to %s failed with %s - %s',
28
- getattr(e.request, 'url', 'unknown'),
29
- getattr(e.response, 'status_code', 'unknown'),
30
- getattr(e.response, 'content', 'missing'),
30
+ except requests.HTTPError as http_exc:
31
+ # Note(JP): when we're here it means we got an HTTP response.
32
+ # That's how this exception is documented.
33
+
34
+ status = http_exc.response.status_code
35
+ # The logging levels 'critical' and 'error' are generally meant to
36
+ # be used for terminal / fatal errors. Here, we handle many
37
+ # transient (retryable) errors, and it's fair to log them not as
38
+ # serious as actual errors. Therefore level WARNING and not ERROR.
39
+ # Even INFO might make sense.
40
+ logger.warning(
41
+ '%s HTTP request to %s failed with %s - %s',
42
+ http_exc.request.method,
43
+ getattr(http_exc.request, 'url', 'unknown'),
44
+ status,
45
+ # Not entire response body in case we have a megabyte response.
46
+ getattr(http_exc.response, 'content', 'missing')[:200],
31
47
  )
32
48
 
33
- status_code = e.response.status_code or HTTPStatus.INTERNAL_SERVER_ERROR
49
+ # Now, map the error at hand to an exception to be thrown towards
50
+ # the caller.
34
51
 
35
- if status_code == HTTPStatus.METHOD_NOT_ALLOWED:
52
+ if status == HTTPStatus.METHOD_NOT_ALLOWED:
36
53
  raise Unsupported()
37
54
 
55
+ # Did we get structured error information? This might even
56
+ # be wrapped in a 501 response for example.
38
57
  try:
39
- error_resp = ErrorResponse(**e.response.json())
58
+ # https://github.com/fiddler-labs/fiddler/issues/9314
59
+ error_resp = ErrorResponse(**http_exc.response.json())
60
+ except pydantic.ValidationError:
61
+ # We couldn't turn the HTTP error response into an object
62
+ # satisfying model='ErrorResponse'. Now, instead of exposing
63
+ # this to users as a pydantic.ValidationError (which is kinda
64
+ # useless to them) -- just re-raise requests.HTTPError, with
65
+ # all its useful error detail -- it is likely to show the
66
+ # actual problem.
67
+ raise http_exc
40
68
  except requests.JSONDecodeError:
41
- raise HttpError(
42
- message=f"Invalid response content-type - {e.response.content}" # type: ignore
43
- )
69
+ # Re-raise requests.HTTPError, with all its useful error
70
+ # detail.
71
+ raise http_exc
72
+
73
+ # This code path is only reachable if we could successfully
74
+ # deserialize an ErrorResponse object from the HTTP response body.
44
75
 
45
- if status_code == HTTPStatus.CONFLICT:
76
+ if status == HTTPStatus.CONFLICT:
46
77
  raise Conflict(error_resp.error)
47
- if status_code == HTTPStatus.NOT_FOUND:
78
+
79
+ if status == HTTPStatus.NOT_FOUND:
48
80
  raise NotFound(error_resp.error)
49
81
 
50
82
  raise ApiError(error_resp.error)
@@ -247,7 +247,7 @@ class AlertRule(
247
247
  url=self._get_url(id_=self.id), data={'enable_notification': True}
248
248
  )
249
249
  logger.info(
250
- 'Notifications have been enabled for alert rule with id: %s', self.id
250
+ 'notifications have been enabled for alert rule with id: %s', self.id
251
251
  )
252
252
 
253
253
  @handle_api_error
fiddler/entities/job.py CHANGED
@@ -4,6 +4,8 @@ import time
4
4
  from typing import Iterator
5
5
  from uuid import UUID
6
6
 
7
+ import requests
8
+
7
9
  from fiddler.configs import JOB_POLL_INTERVAL, JOB_WAIT_TIMEOUT
8
10
  from fiddler.constants.job import JobStatus
9
11
  from fiddler.decorators import handle_api_error
@@ -112,24 +114,57 @@ class Job(BaseEntity): # pylint: disable=too-many-instance-attributes
112
114
  :return: Iterator of job objects
113
115
  """
114
116
  assert self.id is not None
117
+ deadline = time.monotonic() + timeout
115
118
 
116
- start_time = time.time()
117
119
  while True:
120
+
121
+ if time.monotonic() > deadline:
122
+ raise TimeoutError(f'Deadline exceeded while watching job {self.id}')
123
+
118
124
  try:
119
- response = self._client().get(url=self._get_url(id_=self.id))
125
+ # This can raise requests.HTTPError to represent non-2xx
126
+ # responses.
127
+ response = self._client().get(
128
+ url=self._get_url(id_=self.id),
129
+ # Short-ish TCP connect timeout, to stay responsive in
130
+ # terms of logging. The HTTP response latency for GET
131
+ # /jobs/<id> is expected to be less than couple of seconds
132
+ # (i.e., 30 s includes lots of leeway).
133
+ timeout=(5, 30),
134
+ # Inject `retry="off"` to disable the centralized retry
135
+ # machinery: ask for being confronted with the details
136
+ # (throw exceptions my way, so I an do my own "responsive"
137
+ # type of retrying). Otherwise, we'd give up control.
138
+ retry="off",
139
+ )
120
140
  self._refresh_from_response(response)
121
- except ConnectionError:
122
- logger.exception(
123
- 'Failed to connect to the server. Retrying...\n'
124
- 'If this error persists, please reach out to Fiddler.'
141
+
142
+ except requests.exceptions.HTTPError as exc:
143
+ # Note(JP): got a non-2xx HTTP response. The main purpose of
144
+ # this handler is to keep going after having received a 5xx
145
+ # response. In this case of GETting job status even some 404s
146
+ # might be worth retrying. That is, it's fine to give up only
147
+ # after reaching the deadline even if we collect some 4xx
148
+ # responses along the way. Noteworthy: Receiving a 5xx response
149
+ # here is not an error, it's an expected scenario to be
150
+ # accounted for.
151
+ logger.info(
152
+ 'watch: ignore unexpected response %s (URL: %s, response body prefix: %s...)',
153
+ exc.response,
154
+ exc.request.url,
155
+ exc.response.text[:120],
125
156
  )
126
157
  continue
127
158
 
128
- yield self
159
+ except requests.exceptions.RequestException:
160
+ # This error is in the hierarchy _above_ `HTTPError`, and in
161
+ # this setup catches all errors that are _not_ a bad response:
162
+ # DNS error, TCP connect timeout, err during sending request,
163
+ # err during receiving response. Rely on the error detail to
164
+ # have already been logged.
165
+ continue
129
166
 
130
- current_time = time.time()
131
- if (current_time - start_time) > timeout:
132
- raise TimeoutError(f'Timed out while watching job {self.id}')
167
+ yield self
133
168
 
134
169
  if self.status in [
135
170
  JobStatus.SUCCESS,
@@ -153,7 +188,7 @@ class Job(BaseEntity): # pylint: disable=too-many-instance-attributes
153
188
 
154
189
  for job in self.watch(interval=interval, timeout=timeout):
155
190
  logger.info(
156
- '%s: status - %s, progress - %.2f%%',
191
+ '%s: %s, progress: %.1f%%',
157
192
  log_prefix,
158
193
  job.status,
159
194
  job.progress,
fiddler/entities/xai.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import os
4
4
  from collections import namedtuple
5
5
  from datetime import datetime, timezone
6
- from io import BytesIO
7
6
  from pathlib import Path
8
7
  from typing import Any, Callable
9
8
  from uuid import UUID
@@ -18,15 +17,10 @@ from fiddler.constants.xai import (
18
17
  )
19
18
  from fiddler.decorators import handle_api_error
20
19
  from fiddler.entities.job import Job
20
+ from fiddler.exceptions import Unsupported
21
21
  from fiddler.schemas.job import JobCompactResp
22
- from fiddler.schemas.xai import (
23
- DatasetDataSource,
24
- EventIdDataSource,
25
- RowDataSource,
26
- SqlSliceQueryDataSource,
27
- )
22
+ from fiddler.schemas.xai import DatasetDataSource, EventIdDataSource, RowDataSource
28
23
  from fiddler.utils.decorators import check_version
29
- from fiddler.utils.helpers import try_series_retype
30
24
  from fiddler.utils.logger import get_logger
31
25
 
32
26
  logger = get_logger(__name__)
@@ -44,7 +38,7 @@ class XaiMixin:
44
38
  def explain( # pylint: disable=too-many-arguments
45
39
  self,
46
40
  input_data_source: RowDataSource | EventIdDataSource,
47
- ref_data_source: DatasetDataSource | SqlSliceQueryDataSource | None = None,
41
+ ref_data_source: DatasetDataSource | None = None,
48
42
  method: ExplainMethod | str = ExplainMethod.FIDDLER_SHAP,
49
43
  num_permutations: int | None = None,
50
44
  ci_level: float | None = None,
@@ -55,8 +49,8 @@ class XaiMixin:
55
49
 
56
50
  :param input_data_source: DataSource for the input data to compute explanation
57
51
  on (RowDataSource, EventIdDataSource)
58
- :param ref_data_source: DataSource for the reference data to compute explanation
59
- on (DatasetDataSource, SqlSliceQueryDataSource).
52
+ :param ref_data_source: Dataset data source for the reference data to compute explanation
53
+ on.
60
54
  Only used for non-text models and the following methods:
61
55
  'SHAP', 'FIDDLER_SHAP', 'PERMUTE', 'MEAN_RESET'
62
56
  :param method: Explanation method name. Could be your custom
@@ -102,7 +96,7 @@ class XaiMixin:
102
96
  sample: bool = False,
103
97
  max_rows: int | None = None,
104
98
  columns: list[str] | None = None,
105
- ) -> pd.DataFrame:
99
+ ) -> None:
106
100
  """
107
101
  Fetch data with slice query.
108
102
 
@@ -111,35 +105,12 @@ class XaiMixin:
111
105
  columns to select overriding columns selected in the query.
112
106
  :param max_rows: Number of maximum rows to fetch
113
107
  :param sample: Whether rows should be sample or not from the database
114
-
115
108
  :return: Dataframe of the query output
116
109
  """
117
- self._check_id_attributes()
118
-
119
- payload: dict[str, Any] = {
120
- 'model_id': self.id,
121
- 'query': query,
122
- 'sample': sample,
123
- }
124
- if max_rows:
125
- payload['max_rows'] = max_rows
126
- if columns:
127
- payload['columns'] = columns
128
-
129
- response = self._client().post(
130
- url='/v3/slice-query/fetch',
131
- data=payload,
110
+ raise Unsupported(
111
+ 'This method is not supported since version 3.4. Please use `model.download_data` instead.'
132
112
  )
133
113
 
134
- response_dict = response.json()['data']
135
-
136
- column_names = response_dict['metadata']['columns']
137
- dtype_strings = response_dict['metadata']['dtypes']
138
- df = pd.DataFrame(response_dict['rows'], columns=column_names)
139
- for column_name, dtype in zip(column_names, dtype_strings):
140
- df[column_name] = try_series_retype(df[column_name], dtype)
141
- return df
142
-
143
114
  @handle_api_error
144
115
  def download_data( # pylint: disable=too-many-arguments
145
116
  self,
@@ -250,25 +221,9 @@ class XaiMixin:
250
221
  :param max_rows: Number of maximum rows to fetch
251
222
  :param sample: Whether rows should be sample or not from the database
252
223
  """
253
- self._check_id_attributes()
254
- output_dir = Path(output_dir)
255
- if not output_dir.exists():
256
- os.makedirs(output_dir)
257
- payload: dict[str, Any] = {
258
- 'model_id': self.id,
259
- 'query': query,
260
- 'sample': sample,
261
- }
262
- if max_rows:
263
- payload['max_rows'] = max_rows
264
- if columns:
265
- payload['columns'] = columns
266
-
267
- file_path = os.path.join(output_dir, 'output.parquet')
268
- with self._client().post(url='/v3/slice-query/download', data=payload) as resp:
269
- # Download parquet file
270
- df = pd.read_parquet(BytesIO(resp.content))
271
- df.to_parquet(file_path)
224
+ raise Unsupported(
225
+ 'This method is not supported since version 3.4. Please use `model.download_data` instead.'
226
+ )
272
227
 
273
228
  @handle_api_error
274
229
  def get_mutual_info(
@@ -277,7 +232,7 @@ class XaiMixin:
277
232
  column_name: str,
278
233
  num_samples: int | None = None,
279
234
  normalized: bool = False,
280
- ) -> dict:
235
+ ) -> None:
281
236
  """
282
237
  Get mutual information.
283
238
 
@@ -290,27 +245,10 @@ class XaiMixin:
290
245
  all the variables in the dataset.
291
246
  :param num_samples: Number of samples to select for computation
292
247
  :param normalized: If set to True, it will compute Normalized Mutual Information
293
-
294
248
  :return: a dictionary of mutual information w.r.t the given feature
295
249
  for each column given
296
250
  """
297
- self._check_id_attributes()
298
-
299
- payload: dict[str, Any] = {
300
- 'model_id': self.id,
301
- 'query': query,
302
- 'column_name': column_name,
303
- 'normalized': normalized,
304
- }
305
- if num_samples:
306
- payload['num_samples'] = num_samples
307
-
308
- response = self._client().post(
309
- url='/v3/analytics/mutual-info',
310
- data=payload,
311
- )
312
-
313
- return response.json()['data']
251
+ raise Unsupported('This method is not supported since version 3.4.')
314
252
 
315
253
  @handle_api_error
316
254
  def predict(
@@ -344,7 +282,7 @@ class XaiMixin:
344
282
  @handle_api_error
345
283
  def get_feature_impact( # pylint: disable=too-many-arguments
346
284
  self,
347
- data_source: DatasetDataSource | SqlSliceQueryDataSource,
285
+ data_source: DatasetDataSource,
348
286
  num_iterations: int | None = None,
349
287
  num_refs: int | None = None,
350
288
  ci_level: float | None = None,
@@ -354,8 +292,8 @@ class XaiMixin:
354
292
  """
355
293
  Get global feature impact for a model over a dataset or a slice.
356
294
 
357
- :param data_source: DataSource for the input dataset to compute feature
358
- impact on (DatasetDataSource or SqlSliceQueryDataSource)
295
+ :param data_source: Dataset data Source for the input dataset to compute feature
296
+ impact on
359
297
  :param num_iterations: The maximum number of ablated model inferences per feature
360
298
  :param num_refs: The number of reference points used in the explanation
361
299
  :param ci_level: The confidence level (between 0 and 1)
@@ -398,7 +336,7 @@ class XaiMixin:
398
336
  @handle_api_error
399
337
  def get_feature_importance( # pylint: disable=too-many-arguments
400
338
  self,
401
- data_source: DatasetDataSource | SqlSliceQueryDataSource,
339
+ data_source: DatasetDataSource,
402
340
  num_iterations: int | None = None,
403
341
  num_refs: int | None = None,
404
342
  ci_level: float | None = None,
@@ -406,8 +344,8 @@ class XaiMixin:
406
344
  """
407
345
  Get global feature importance for a model over a dataset or a slice.
408
346
 
409
- :param data_source: DataSource for the input dataset to compute feature
410
- importance on (DatasetDataSource or SqlSliceQueryDataSource)
347
+ :param data_source: Dataset data aSource for the input dataset to compute feature
348
+ importance on
411
349
  :param num_iterations: The maximum number of ablated model inferences per feature
412
350
  :param num_refs: The number of reference points used in the explanation
413
351
  :param ci_level: The confidence level (between 0 and 1)
fiddler/exceptions.py CHANGED
@@ -17,6 +17,8 @@ class BaseError(Exception):
17
17
  super().__init__(self.message)
18
18
 
19
19
  def __str__(self) -> str:
20
+ # It would be better to prepend context: like FiddlerBaseError. Note
21
+ # that this here is the string representation of an exception.
20
22
  return str(self.message)
21
23
 
22
24
  @property
@@ -53,17 +55,26 @@ class Unsupported(BaseError):
53
55
 
54
56
 
55
57
  class HttpError(BaseError):
56
- """Base class for all HTTP errors"""
58
+ """Base class for all HTTP errors
59
+
60
+ Deprecated. Not thrown anymore.
61
+ """
57
62
 
58
63
 
59
64
  class ConnTimeout(HttpError):
60
- """Connection timeout error"""
65
+ """Connection timeout error
66
+
67
+ Deprecated. Not thrown anymore.
68
+ """
61
69
 
62
70
  message = 'Request timed out while trying to reach endpoint'
63
71
 
64
72
 
65
73
  class ConnError(HttpError):
66
- """Connection error"""
74
+ """Connection error
75
+
76
+ Deprecated. Not thrown anymore.
77
+ """
67
78
 
68
79
  message = 'Unable to reach the given endpoint'
69
80
 
fiddler/libs/aws.py ADDED
@@ -0,0 +1,95 @@
1
+ # type: ignore
2
+ # TODO: do type-checking soon: https://github.com/fiddler-anyprem/hadron/issues/25
3
+
4
+ # Note(JP): allow for type hints using strings (so that for example
5
+ # "PartnerAppAuthProvider" can be used as a type hint w/o having to import the
6
+ # class).
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections import namedtuple
11
+ from typing import Optional
12
+
13
+ from fiddler.exceptions import BaseError
14
+ from fiddler.utils.logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ AwsSageMakerAuthConfig = namedtuple('AwsSageMakerAuthConfig', ['arn', 'url'])
19
+
20
+
21
+ def _read_env_for_aws_sagemaker_or_raise() -> Optional[AwsSageMakerAuthConfig]:
22
+ """
23
+
24
+ Raise `fiddler.exceptions.BaseError` when the environment appears to be
25
+ misconfigured.
26
+ """
27
+ av = os.getenv("AWS_PARTNER_APP_AUTH")
28
+
29
+ if av is None:
30
+ return None
31
+
32
+ if av.lower().strip() != "true":
33
+ return None
34
+
35
+ # User has declared intent: activate SageMaker authentication. Require
36
+ # other environment variables to be set.
37
+ keys = ("AWS_PARTNER_APP_ARN", "AWS_PARTNER_APP_URL")
38
+ for k in keys:
39
+ errmsg = f"AWS_PARTNER_APP_AUTH is set to `{av}`, but {k} is not set"
40
+ if os.getenv(k) is None:
41
+ # Future: maybe have a ConfigError type?
42
+ raise BaseError(errmsg)
43
+
44
+ # Future: potentially do validation for got error messages, but be sure
45
+ # that the validation isn't getting in the way, _ever_.
46
+
47
+ # Remove leading and trailing whitespace: that's a safe operation, and
48
+ # always a user/config "error". That we can correct for.
49
+ arn = os.getenv("AWS_PARTNER_APP_ARN").strip() # type: ignore[union-attr]
50
+ url = os.getenv("AWS_PARTNER_APP_URL").strip() # type: ignore[union-attr]
51
+ return AwsSageMakerAuthConfig(arn, url)
52
+
53
+
54
+ def _import_sm_sdk(c: AwsSageMakerAuthConfig): # -> "PartnerAppAuthProvider":
55
+ """
56
+ Return `sagemaker.PartnerAppAuthProvider` instance or raise an exception.
57
+ """
58
+ try:
59
+ from sagemaker import PartnerAppAuthProvider
60
+ except Exception as exc:
61
+ errmsg = (
62
+ f"The AWS_PARTNER_APP_* environment variables are set, but "
63
+ f"importing a dependency failed: {exc}"
64
+ )
65
+ raise BaseError(errmsg)
66
+
67
+ # Note(JP): can initialization of this auth provider object fail? Does it
68
+ # use Internet resources? Update 1: this call is expected to fail with e.g.
69
+ # `ValueError: Must specify a valid AWS_PARTNER_APP_ARN environment
70
+ # variable` when this env var is not set. Update 2: this does indeed deeper
71
+ # validation of the user-given config values. May throw for example
72
+ # "ValueError: Must specify a valid AWS_PARTNER_APP_ARN environment
73
+ # variable" when something non-ARN-looking has been provided. That is, we
74
+ # can (and should) skip validating these env var values and lave this job
75
+ # to the SM SDK.
76
+ p = PartnerAppAuthProvider()
77
+ logger.info("initialized AWS SageMaker authentication provider: %s", p)
78
+ return p
79
+
80
+
81
+ def conditionally_init_aws_sm_auth(): # -> Optional["PartnerAppAuthProvider"]:
82
+ """
83
+ Return `None` or `PartnerAppAuthProvider` instance.
84
+
85
+ Raise an exception upon configuration error or when dependencies are
86
+ missing.
87
+ """
88
+ aws_sm_auth_cfg = _read_env_for_aws_sagemaker_or_raise()
89
+
90
+ logger.debug("auth config: %s", aws_sm_auth_cfg)
91
+ if aws_sm_auth_cfg is not None:
92
+ return _import_sm_sdk(aws_sm_auth_cfg)
93
+
94
+ # Just to make this expected case explicit.
95
+ return None