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
@@ -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 Connection
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
@@ -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
- 'reason': 'ObjectNotFound', # type: ignore
16
- 'message': message,
17
- 'help': '',
18
- }
14
+ ErrorItem(
15
+ reason='ObjectNotFound',
16
+ message=message,
17
+ help='',
18
+ ),
19
19
  ],
20
20
  ),
21
21
  )