fiddler-client 2.3.0.dev2__py3-none-any.whl → 2.4.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 +1 -1
- fiddler/api/api.py +2 -0
- fiddler/api/webhooks_mixin.py +9 -7
- fiddler/core_objects.py +98 -47
- fiddler/schemas/custom_features.py +25 -19
- fiddler3/constants/baseline.py +19 -0
- fiddler3/constants/common.py +12 -0
- fiddler3/constants/dataset.py +7 -0
- fiddler3/constants/model_deployment.py +15 -0
- fiddler3/constants/xai.py +11 -0
- fiddler3/entities/__init__.py +5 -1
- fiddler3/entities/base.py +22 -18
- fiddler3/entities/baseline.py +228 -0
- fiddler3/entities/dataset.py +149 -0
- fiddler3/entities/job.py +9 -8
- fiddler3/entities/model.py +160 -29
- fiddler3/entities/model_artifact.py +350 -0
- fiddler3/entities/model_deployment.py +112 -0
- fiddler3/entities/model_surrogate.py +92 -0
- fiddler3/entities/organization.py +38 -0
- fiddler3/entities/project.py +69 -8
- fiddler3/entities/user.py +45 -0
- fiddler3/entities/xai.py +309 -0
- fiddler3/libs/http_client.py +24 -13
- fiddler3/libs/json_encoder.py +12 -0
- fiddler3/schemas/__init__.py +4 -0
- fiddler3/schemas/baseline.py +37 -0
- fiddler3/schemas/custom_features.py +23 -18
- fiddler3/schemas/dataset.py +27 -0
- fiddler3/schemas/deployment_params.py +25 -0
- fiddler3/schemas/filter_query.py +54 -0
- fiddler3/schemas/job.py +2 -2
- fiddler3/schemas/model.py +9 -9
- fiddler3/schemas/model_artifact.py +6 -0
- fiddler3/schemas/model_deployment.py +26 -0
- fiddler3/schemas/organization.py +9 -1
- fiddler3/schemas/project.py +4 -4
- fiddler3/schemas/server_info.py +2 -2
- fiddler3/schemas/user.py +1 -1
- fiddler3/schemas/xai.py +32 -0
- fiddler3/tests/apis/test_baseline.py +292 -0
- fiddler3/tests/apis/test_dataset.py +173 -0
- fiddler3/tests/apis/test_mixin.py +77 -0
- fiddler3/tests/apis/test_model.py +102 -7
- fiddler3/tests/apis/test_model_artifact.py +175 -0
- fiddler3/tests/apis/test_model_deployment.py +98 -0
- fiddler3/tests/apis/test_model_surrogate.py +159 -0
- fiddler3/tests/apis/test_project.py +36 -20
- fiddler3/tests/apis/test_xai.py +690 -0
- fiddler3/tests/constants.py +10 -0
- fiddler3/tests/test_json_encoder.py +16 -0
- fiddler3/tests/test_logger.py +12 -0
- fiddler3/tests/test_utils.py +19 -0
- fiddler3/utils/__init__.py +1 -0
- fiddler3/utils/helpers.py +43 -0
- fiddler3/utils/logger.py +15 -0
- fiddler3/utils/validations.py +11 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/METADATA +7 -5
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/RECORD +65 -33
- tests/fiddler/test_segments.py +227 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/LICENSE.txt +0 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/WHEEL +0 -0
- {fiddler_client-2.3.0.dev2.dist-info → fiddler_client-2.4.0.dev1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from fiddler3.decorators import handle_api_error
|
|
8
|
+
from fiddler3.entities.base import BaseEntity
|
|
9
|
+
from fiddler3.entities.project import ProjectCompactMixin
|
|
10
|
+
from fiddler3.schemas.model_deployment import ModelDeploymentResponse
|
|
11
|
+
from fiddler3.utils.logger import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelDeployment(
|
|
17
|
+
BaseEntity, ProjectCompactMixin
|
|
18
|
+
): # pylint: disable=too-many-instance-attributes
|
|
19
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
20
|
+
self,
|
|
21
|
+
model_id: UUID,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Construct a model deployment instance."""
|
|
24
|
+
self.id: UUID | None = None # pylint: disable=invalid-name
|
|
25
|
+
self.model_id = model_id
|
|
26
|
+
self.artifact_type: str | None = None
|
|
27
|
+
self.deployment_type: str | None = None
|
|
28
|
+
self.active: bool | None = None
|
|
29
|
+
self.image_uri: str | None = None
|
|
30
|
+
self.replicas: int | None = None
|
|
31
|
+
self.cpu: int | None = None
|
|
32
|
+
self.memory: int | None = None
|
|
33
|
+
self.created_at: datetime | None = None
|
|
34
|
+
self.updated_at: datetime | None = None
|
|
35
|
+
|
|
36
|
+
# Deserialized response object
|
|
37
|
+
self._resp: ModelDeploymentResponse | None = None
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _get_url(model_id: UUID | str | None = None) -> str:
|
|
41
|
+
"""Get model deployment url."""
|
|
42
|
+
return f'v3/models/{model_id}/deployment'
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _from_dict(cls, data: dict) -> ModelDeployment:
|
|
46
|
+
"""Build entity object from the given dictionary."""
|
|
47
|
+
|
|
48
|
+
# Deserialize the response
|
|
49
|
+
resp_obj = ModelDeploymentResponse(**data)
|
|
50
|
+
# Initialize
|
|
51
|
+
instance = cls(
|
|
52
|
+
model_id=resp_obj.model.id,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Add remaining fields
|
|
56
|
+
fields = [
|
|
57
|
+
'id',
|
|
58
|
+
'artifact_type',
|
|
59
|
+
'deployment_type',
|
|
60
|
+
'active',
|
|
61
|
+
'image_uri',
|
|
62
|
+
'replicas',
|
|
63
|
+
'cpu',
|
|
64
|
+
'memory',
|
|
65
|
+
'created_at',
|
|
66
|
+
'updated_at',
|
|
67
|
+
]
|
|
68
|
+
for field in fields:
|
|
69
|
+
setattr(instance, field, getattr(resp_obj, field, None))
|
|
70
|
+
|
|
71
|
+
instance._resp = resp_obj
|
|
72
|
+
return instance
|
|
73
|
+
|
|
74
|
+
def _refresh(self, data: dict) -> None:
|
|
75
|
+
"""Refresh the fields of this instance from the given response dictionary"""
|
|
76
|
+
# Deserialize the response
|
|
77
|
+
resp_obj = ModelDeploymentResponse(**data)
|
|
78
|
+
|
|
79
|
+
fields = [
|
|
80
|
+
'id',
|
|
81
|
+
'artifact_type',
|
|
82
|
+
'deployment_type',
|
|
83
|
+
'active',
|
|
84
|
+
'image_uri',
|
|
85
|
+
'replicas',
|
|
86
|
+
'cpu',
|
|
87
|
+
'memory',
|
|
88
|
+
'created_at',
|
|
89
|
+
'updated_at',
|
|
90
|
+
]
|
|
91
|
+
for field in fields:
|
|
92
|
+
setattr(self, field, getattr(resp_obj, field, None))
|
|
93
|
+
|
|
94
|
+
self._resp = resp_obj
|
|
95
|
+
|
|
96
|
+
@handle_api_error
|
|
97
|
+
def update(self) -> None:
|
|
98
|
+
"""Update an existing model deployment."""
|
|
99
|
+
body: dict[str, Any] = {
|
|
100
|
+
'artifact_type': self.artifact_type,
|
|
101
|
+
'deployment_type': self.deployment_type,
|
|
102
|
+
'active': self.active,
|
|
103
|
+
'image_uri': self.image_uri,
|
|
104
|
+
'replicas': self.replicas,
|
|
105
|
+
'cpu': self.cpu,
|
|
106
|
+
'memory': self.memory,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
response = self._client().patch(
|
|
110
|
+
url=self._get_url(model_id=self.model_id), data=body
|
|
111
|
+
)
|
|
112
|
+
self._refresh_from_response(response=response)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fiddler3.decorators import handle_api_error
|
|
7
|
+
from fiddler3.entities.job import Job
|
|
8
|
+
from fiddler3.schemas.deployment_params import ArtifactType, DeploymentParams
|
|
9
|
+
from fiddler3.schemas.job import JobCompactResp
|
|
10
|
+
from fiddler3.utils.logger import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SurrogateMixin:
|
|
16
|
+
id: UUID | None
|
|
17
|
+
_client: Callable
|
|
18
|
+
|
|
19
|
+
def _check_id_attributes(self) -> None:
|
|
20
|
+
if not self.id:
|
|
21
|
+
raise AttributeError(
|
|
22
|
+
'This method is available only for model object generated from '
|
|
23
|
+
'API response.'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def _deploy_surrogate_model(
|
|
27
|
+
self,
|
|
28
|
+
deployment_params: DeploymentParams | None = None,
|
|
29
|
+
update: bool = False,
|
|
30
|
+
) -> Job:
|
|
31
|
+
"""
|
|
32
|
+
Add surrogate model to an existing model
|
|
33
|
+
|
|
34
|
+
:param deployment_params: Model deployment parameters
|
|
35
|
+
:param update: Set True for re-generating surrogate model, otherwise False
|
|
36
|
+
:return: Async job uuid
|
|
37
|
+
"""
|
|
38
|
+
payload: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
if deployment_params:
|
|
41
|
+
deployment_params.artifact_type = ArtifactType.SURROGATE
|
|
42
|
+
payload.update(
|
|
43
|
+
{'deployment_params': deployment_params.dict(exclude_unset=True)}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
url = f'/v3/models/{self.id}/deploy-surrogate'
|
|
47
|
+
method: Callable = self._client().put if update else self._client().post
|
|
48
|
+
response = method(url=url, data=payload)
|
|
49
|
+
|
|
50
|
+
job_compact = JobCompactResp(**response.json()['data']['job'])
|
|
51
|
+
|
|
52
|
+
logger.info(
|
|
53
|
+
'Model[%s] - Submitted job (%s) for deploying a surrogate model',
|
|
54
|
+
self.id,
|
|
55
|
+
job_compact.id,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return Job.get(id_=job_compact.id)
|
|
59
|
+
|
|
60
|
+
@handle_api_error
|
|
61
|
+
def add_surrogate(
|
|
62
|
+
self,
|
|
63
|
+
deployment_params: DeploymentParams | None = None,
|
|
64
|
+
) -> Job:
|
|
65
|
+
"""
|
|
66
|
+
Add a new surrogate.
|
|
67
|
+
|
|
68
|
+
:param deployment_params: Model deployment parameters
|
|
69
|
+
:return: Async job.
|
|
70
|
+
"""
|
|
71
|
+
self._check_id_attributes()
|
|
72
|
+
job = self._deploy_surrogate_model(
|
|
73
|
+
deployment_params=deployment_params, update=False
|
|
74
|
+
)
|
|
75
|
+
return job
|
|
76
|
+
|
|
77
|
+
@handle_api_error
|
|
78
|
+
def update_surrogate(
|
|
79
|
+
self,
|
|
80
|
+
deployment_params: DeploymentParams | None = None,
|
|
81
|
+
) -> Job:
|
|
82
|
+
"""
|
|
83
|
+
Update an existing surrogate.
|
|
84
|
+
|
|
85
|
+
:param deployment_params: Model deployment parameters
|
|
86
|
+
:return: Async job.
|
|
87
|
+
"""
|
|
88
|
+
self._check_id_attributes()
|
|
89
|
+
job = self._deploy_surrogate_model(
|
|
90
|
+
deployment_params=deployment_params, update=True
|
|
91
|
+
)
|
|
92
|
+
return job
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fiddler3.entities.base import BaseEntity
|
|
7
|
+
from fiddler3.schemas.organization import OrganizationResp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Organization(BaseEntity):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
"""Construct a organization instance"""
|
|
13
|
+
|
|
14
|
+
self.name: str | None = None
|
|
15
|
+
self.id: UUID | None = None
|
|
16
|
+
self.created_at: datetime | None = None
|
|
17
|
+
self.updated_at: datetime | None = None
|
|
18
|
+
|
|
19
|
+
# Deserialized response object
|
|
20
|
+
self._resp: OrganizationResp | None = None
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def _from_dict(cls, data: dict) -> Organization:
|
|
24
|
+
"""Build entity object from the given dictionary"""
|
|
25
|
+
|
|
26
|
+
# Deserialize the response
|
|
27
|
+
resp_obj = OrganizationResp(**data)
|
|
28
|
+
|
|
29
|
+
# Initialize
|
|
30
|
+
instance = cls()
|
|
31
|
+
|
|
32
|
+
# Add remaining fields
|
|
33
|
+
fields = ['id', 'name', 'created_at', 'updated_at']
|
|
34
|
+
for field in fields:
|
|
35
|
+
setattr(instance, field, getattr(resp_obj, field, None))
|
|
36
|
+
|
|
37
|
+
instance._resp = resp_obj
|
|
38
|
+
return instance
|
fiddler3/entities/project.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import typing
|
|
4
|
+
from dataclasses import dataclass
|
|
3
5
|
from datetime import datetime
|
|
4
6
|
from typing import Iterator
|
|
5
7
|
from uuid import UUID
|
|
@@ -7,13 +9,18 @@ from uuid import UUID
|
|
|
7
9
|
from fiddler3.decorators import handle_api_error
|
|
8
10
|
from fiddler3.entities.base import BaseEntity
|
|
9
11
|
from fiddler3.entities.helpers import raise_not_found
|
|
10
|
-
from fiddler3.schemas.
|
|
12
|
+
from fiddler3.schemas.filter_query import OperatorType, QueryCondition, QueryRule
|
|
13
|
+
from fiddler3.schemas.project import ProjectResp
|
|
14
|
+
|
|
15
|
+
if typing.TYPE_CHECKING:
|
|
16
|
+
from fiddler3.entities.model import ModelCompact
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
class Project(BaseEntity):
|
|
14
20
|
def __init__(self, name: str) -> None:
|
|
15
21
|
"""
|
|
16
22
|
Construct a project instance
|
|
23
|
+
|
|
17
24
|
:param name: Slug like name
|
|
18
25
|
"""
|
|
19
26
|
self.name = name
|
|
@@ -23,7 +30,7 @@ class Project(BaseEntity):
|
|
|
23
30
|
self.updated_at: datetime | None = None
|
|
24
31
|
|
|
25
32
|
# Deserialized response object
|
|
26
|
-
self._resp:
|
|
33
|
+
self._resp: ProjectResp | None = None
|
|
27
34
|
|
|
28
35
|
@staticmethod
|
|
29
36
|
def _get_url(id_: UUID | str | None = None) -> str:
|
|
@@ -36,7 +43,7 @@ class Project(BaseEntity):
|
|
|
36
43
|
"""Build entity object from the given dictionary"""
|
|
37
44
|
|
|
38
45
|
# Deserialize the response
|
|
39
|
-
resp_obj =
|
|
46
|
+
resp_obj = ProjectResp(**data)
|
|
40
47
|
|
|
41
48
|
# Initialize
|
|
42
49
|
instance = cls(
|
|
@@ -51,6 +58,22 @@ class Project(BaseEntity):
|
|
|
51
58
|
instance._resp = resp_obj
|
|
52
59
|
return instance
|
|
53
60
|
|
|
61
|
+
def _refresh(self, data: dict) -> None:
|
|
62
|
+
"""Refresh the fields of this instance from the given response dictionary"""
|
|
63
|
+
# Deserialize the response
|
|
64
|
+
resp_obj = ProjectResp(**data)
|
|
65
|
+
|
|
66
|
+
fields = [
|
|
67
|
+
'id',
|
|
68
|
+
'name',
|
|
69
|
+
'created_at',
|
|
70
|
+
'updated_at',
|
|
71
|
+
]
|
|
72
|
+
for field in fields:
|
|
73
|
+
setattr(self, field, getattr(resp_obj, field, None))
|
|
74
|
+
|
|
75
|
+
self._resp = resp_obj
|
|
76
|
+
|
|
54
77
|
@classmethod
|
|
55
78
|
@handle_api_error
|
|
56
79
|
def get(cls, id_: UUID | str) -> Project:
|
|
@@ -62,7 +85,13 @@ class Project(BaseEntity):
|
|
|
62
85
|
@handle_api_error
|
|
63
86
|
def from_name(cls, name: str) -> Project:
|
|
64
87
|
"""Get the project instance using project name"""
|
|
65
|
-
|
|
88
|
+
_filter = QueryCondition(
|
|
89
|
+
rules=[QueryRule(field='name', operator=OperatorType.EQUAL, value=name)]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
response = cls._client().get(
|
|
93
|
+
url=cls._get_url(), params={'filter': _filter.json()}
|
|
94
|
+
)
|
|
66
95
|
if response.json()['data']['total'] == 0:
|
|
67
96
|
raise_not_found('Project not found for the given identifier')
|
|
68
97
|
|
|
@@ -71,9 +100,7 @@ class Project(BaseEntity):
|
|
|
71
100
|
@classmethod
|
|
72
101
|
@handle_api_error
|
|
73
102
|
def list(cls) -> Iterator[Project]:
|
|
74
|
-
"""
|
|
75
|
-
Get a list of all projects in the organization
|
|
76
|
-
"""
|
|
103
|
+
"""Get a list of all projects in the organization"""
|
|
77
104
|
for project in cls._paginate(url=cls._get_url()):
|
|
78
105
|
yield cls._from_dict(data=project)
|
|
79
106
|
|
|
@@ -81,10 +108,44 @@ class Project(BaseEntity):
|
|
|
81
108
|
def create(self) -> Project:
|
|
82
109
|
"""Create a new project"""
|
|
83
110
|
response = self._client().post(url=self._get_url(), data={'name': self.name})
|
|
84
|
-
|
|
111
|
+
self._refresh_from_response(response=response)
|
|
112
|
+
return self
|
|
85
113
|
|
|
86
114
|
@handle_api_error
|
|
87
115
|
def delete(self) -> None:
|
|
88
116
|
"""Delete project"""
|
|
89
117
|
# @TODO - Switch this to v3 once we have the endpoint
|
|
90
118
|
self._client().delete(url=f'/v2/projects/{self.id}')
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def models(self) -> Iterator[ModelCompact]:
|
|
122
|
+
"""Fetch all the models of this project"""
|
|
123
|
+
from fiddler3.entities.model import ( # pylint: disable=import-outside-toplevel
|
|
124
|
+
Model,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
yield from Model.list(project_id=self.id)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ProjectCompact:
|
|
132
|
+
id: UUID
|
|
133
|
+
name: str
|
|
134
|
+
|
|
135
|
+
def fetch(self) -> Project:
|
|
136
|
+
"""Fetch project instance"""
|
|
137
|
+
return Project.get(id_=self.id)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ProjectCompactMixin:
|
|
141
|
+
@property
|
|
142
|
+
def project(self) -> ProjectCompact:
|
|
143
|
+
"""Project instance"""
|
|
144
|
+
response = getattr(self, '_resp', None)
|
|
145
|
+
if not response or not hasattr(response, 'project'):
|
|
146
|
+
raise AttributeError(
|
|
147
|
+
'This property is available only for objects generated from API '
|
|
148
|
+
'response.'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return ProjectCompact(id=response.project.id, name=response.project.name)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class UserCompact:
|
|
7
|
+
id: UUID
|
|
8
|
+
full_name: str
|
|
9
|
+
email: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CreatedByMixin:
|
|
13
|
+
@property
|
|
14
|
+
def created_by(self) -> UserCompact:
|
|
15
|
+
"""Model instance"""
|
|
16
|
+
response = getattr(self, '_resp', None)
|
|
17
|
+
if not response or not hasattr(response, 'created_by'):
|
|
18
|
+
raise AttributeError(
|
|
19
|
+
'This property is available only for objects generated from API '
|
|
20
|
+
'response.'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return UserCompact(
|
|
24
|
+
id=response.created_by.id,
|
|
25
|
+
full_name=response.created_by.full_name,
|
|
26
|
+
email=response.created_by.email,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UpdatedByMixin:
|
|
31
|
+
@property
|
|
32
|
+
def updated_by(self) -> UserCompact:
|
|
33
|
+
"""Model instance"""
|
|
34
|
+
response = getattr(self, '_resp', None)
|
|
35
|
+
if not response or not hasattr(response, 'updated_by'):
|
|
36
|
+
raise AttributeError(
|
|
37
|
+
'This property is available only for objects generated from API '
|
|
38
|
+
'response.'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return UserCompact(
|
|
42
|
+
id=response.updated_by.id,
|
|
43
|
+
full_name=response.updated_by.full_name,
|
|
44
|
+
email=response.updated_by.email,
|
|
45
|
+
)
|