15
15
# KIND, either express or implied. See the License for the
16
16
# specific language governing permissions and limitations
17
17
# under the License.
18
- """Hook for Slack"""
19
- from typing import Any , Optional
18
+
19
+ import json
20
+ import warnings
21
+ from typing import TYPE_CHECKING , Any , Dict , List , Optional
20
22
21
23
from slack_sdk import WebClient
22
- from slack_sdk .web . slack_response import SlackResponse
24
+ from slack_sdk .errors import SlackApiError
23
25
24
- from airflow .exceptions import AirflowException
26
+ from airflow .compat .functools import cached_property
27
+ from airflow .exceptions import AirflowException , AirflowNotFoundException
25
28
from airflow .hooks .base import BaseHook
29
+ from airflow .providers .slack .utils import ConnectionExtraConfig , prefixed_extra_field
30
+ from airflow .utils .log .secrets_masker import mask_secret
31
+
32
+ if TYPE_CHECKING :
33
+ from slack_sdk .http_retry import RetryHandler
34
+ from slack_sdk .web .slack_response import SlackResponse
26
35
27
36
28
37
class SlackHook (BaseHook ):
29
38
"""
30
- Creates a Slack connection to be used for calls.
39
+ Creates a Slack API Connection to be used for calls.
40
+
41
+ This class provide a thin wrapper around the ``slack_sdk.WebClient``.
42
+
43
+ .. seealso::
44
+ - :ref:`Slack API connection <howto/connection:slack>`
45
+ - https://api.slack.com/messaging
46
+ - https://slack.dev/python-slack-sdk/web/index.html
47
+
48
+ .. warning::
49
+ This hook intend to use `Slack API` connection
50
+ and might not work correctly with `Slack Webhook` and `HTTP` connections.
31
51
32
52
Takes both Slack API token directly and connection that has Slack API token. If both are
33
53
supplied, Slack API token will be used. Also exposes the rest of slack.WebClient args.
54
+
34
55
Examples:
35
- .. code-block:: python
56
+ .. code-block:: python
36
57
37
58
# Create hook
38
- slack_hook = SlackHook(token="xxx") # or slack_hook = SlackHook( slack_conn_id="slack ")
59
+ slack_hook = SlackHook(slack_conn_id="slack_api_default ")
39
60
40
61
# Call generic API with parameters (errors are handled by hook)
41
62
# For more details check https://api.slack.com/methods/chat.postMessage
@@ -45,28 +66,124 @@ class SlackHook(BaseHook):
45
66
# For more details check https://slack.dev/python-slack-sdk/web/index.html#messaging
46
67
slack_hook.client.chat_postMessage(channel="#random", text="Hello world!")
47
68
48
- :param token: Slack API token
49
69
:param slack_conn_id: :ref:`Slack connection id <howto/connection:slack>`
50
70
that has Slack API token in the password field.
51
- :param use_session: A boolean specifying if the client should take advantage of
52
- connection pooling. Default is True.
53
- :param base_url: A string representing the Slack API base URL. Default is
54
- ``https://www.slack.com/api/``
55
- :param timeout: The maximum number of seconds the client will wait
56
- to connect and receive a response from Slack. Default is 30 seconds.
71
+ :param timeout: The maximum number of seconds the client will wait to connect
72
+ and receive a response from Slack. If not set than default WebClient value will use.
73
+ :param base_url: A string representing the Slack API base URL.
74
+ If not set than default WebClient BASE_URL will use (``https://www.slack.com/api/``).
75
+ :param proxy: Proxy to make the Slack Incoming Webhook call.
76
+ :param retry_handlers: List of handlers to customize retry logic in WebClient.
77
+ :param token: (deprecated) Slack API Token.
57
78
"""
58
79
80
+ conn_name_attr = 'slack_conn_id'
81
+ default_conn_name = 'slack_api_default'
82
+ conn_type = 'slack'
83
+ hook_name = 'Slack API'
84
+
59
85
def __init__ (
60
86
self ,
61
87
token : Optional [str ] = None ,
62
88
slack_conn_id : Optional [str ] = None ,
63
- ** client_args : Any ,
89
+ base_url : Optional [str ] = None ,
90
+ timeout : Optional [int ] = None ,
91
+ proxy : Optional [str ] = None ,
92
+ retry_handlers : Optional [List ["RetryHandler" ]] = None ,
93
+ ** extra_client_args : Any ,
64
94
) -> None :
95
+ if not token and not slack_conn_id :
96
+ raise AirflowException ("Either `slack_conn_id` or `token` should be provided." )
97
+ if token :
98
+ mask_secret (token )
99
+ warnings .warn (
100
+ "Provide token as hook argument deprecated by security reason and will be removed "
101
+ "in a future releases. Please specify token in `Slack API` connection." ,
102
+ DeprecationWarning ,
103
+ stacklevel = 2 ,
104
+ )
105
+ if not slack_conn_id :
106
+ warnings .warn (
107
+ "You have not set parameter `slack_conn_id`. Currently `Slack API` connection id optional "
108
+ "but in a future release it will mandatory." ,
109
+ FutureWarning ,
110
+ stacklevel = 2 ,
111
+ )
112
+
65
113
super ().__init__ ()
66
- self .token = self .__get_token (token , slack_conn_id )
67
- self .client = WebClient (self .token , ** client_args )
114
+ self ._token = token
115
+ self .slack_conn_id = slack_conn_id
116
+ self .base_url = base_url
117
+ self .timeout = timeout
118
+ self .proxy = proxy
119
+ self .retry_handlers = retry_handlers
120
+ self .extra_client_args = extra_client_args
121
+ if self .extra_client_args .pop ("use_session" , None ) is not None :
122
+ warnings .warn ("`use_session` has no affect in slack_sdk.WebClient." , UserWarning , stacklevel = 2 )
123
+
124
+ @cached_property
125
+ def client (self ) -> WebClient :
126
+ """Get the underlying slack_sdk.WebClient (cached)."""
127
+ return WebClient (** self ._get_conn_params ())
128
+
129
+ def get_conn (self ) -> WebClient :
130
+ """Get the underlying slack_sdk.WebClient (cached)."""
131
+ return self .client
132
+
133
+ def _get_conn_params (self ) -> Dict [str , Any ]:
134
+ """Fetch connection params as a dict and merge it with hook parameters."""
135
+ conn = self .get_connection (self .slack_conn_id ) if self .slack_conn_id else None
136
+ conn_params : Dict [str , Any ] = {}
137
+
138
+ if self ._token :
139
+ conn_params ["token" ] = self ._token
140
+ elif conn :
141
+ if not conn .password :
142
+ raise AirflowNotFoundException (
143
+ f"Connection ID { self .slack_conn_id !r} does not contain password (Slack API Token)."
144
+ )
145
+ conn_params ["token" ] = conn .password
146
+
147
+ extra_config = ConnectionExtraConfig (
148
+ conn_type = self .conn_type ,
149
+ conn_id = conn .conn_id if conn else None ,
150
+ extra = conn .extra_dejson if conn else {},
151
+ )
152
+
153
+ # Merge Hook parameters with Connection config
154
+ conn_params .update (
155
+ {
156
+ "timeout" : self .timeout or extra_config .getint ("timeout" , default = None ),
157
+ "base_url" : self .base_url or extra_config .get ("base_url" , default = None ),
158
+ "proxy" : self .proxy or extra_config .get ("proxy" , default = None ),
159
+ "retry_handlers" : (
160
+ self .retry_handlers or extra_config .getimports ("retry_handlers" , default = None )
161
+ ),
162
+ }
163
+ )
164
+
165
+ # Add additional client args
166
+ conn_params .update (self .extra_client_args )
167
+ if "logger" not in conn_params :
168
+ conn_params ["logger" ] = self .log
169
+
170
+ return {k : v for k , v in conn_params .items () if v is not None }
171
+
172
+ @cached_property
173
+ def token (self ) -> str :
174
+ warnings .warn (
175
+ "`SlackHook.token` property deprecated and will be removed in a future releases." ,
176
+ DeprecationWarning ,
177
+ stacklevel = 2 ,
178
+ )
179
+ return self ._get_conn_params ()["token" ]
68
180
69
181
def __get_token (self , token : Any , slack_conn_id : Any ) -> str :
182
+ warnings .warn (
183
+ "`SlackHook.__get_token` method deprecated and will be removed in a future releases." ,
184
+ DeprecationWarning ,
185
+ stacklevel = 2 ,
186
+ )
70
187
if token is not None :
71
188
return token
72
189
@@ -79,7 +196,7 @@ def __get_token(self, token: Any, slack_conn_id: Any) -> str:
79
196
80
197
raise AirflowException ('Cannot get token: No valid Slack token nor slack_conn_id supplied.' )
81
198
82
- def call (self , api_method : str , ** kwargs ) -> SlackResponse :
199
+ def call (self , api_method : str , ** kwargs ) -> " SlackResponse" :
83
200
"""
84
201
Calls Slack WebClient `WebClient.api_call` with given arguments.
85
202
@@ -95,3 +212,78 @@ def call(self, api_method: str, **kwargs) -> SlackResponse:
95
212
iterated on to execute subsequent requests.
96
213
"""
97
214
return self .client .api_call (api_method , ** kwargs )
215
+
216
+ def test_connection (self ):
217
+ """Tests the Slack API connection.
218
+
219
+ .. seealso::
220
+ https://api.slack.com/methods/auth.test
221
+ """
222
+ try :
223
+ response = self .call ("auth.test" )
224
+ response .validate ()
225
+ except SlackApiError as e :
226
+ return False , str (e )
227
+ except Exception as e :
228
+ return False , f"Unknown error occurred while testing connection: { e } "
229
+
230
+ if isinstance (response .data , bytes ):
231
+ # If response data binary then return simple message
232
+ return True , f"Connection successfully tested (url: { response .api_url } )."
233
+
234
+ try :
235
+ return True , json .dumps (response .data )
236
+ except TypeError :
237
+ return True , str (response )
238
+
239
+ @classmethod
240
+ def get_connection_form_widgets (cls ) -> Dict [str , Any ]:
241
+ """Returns dictionary of widgets to be added for the hook to handle extra values."""
242
+ from flask_appbuilder .fieldwidgets import BS3TextFieldWidget
243
+ from flask_babel import lazy_gettext
244
+ from wtforms import IntegerField , StringField
245
+
246
+ return {
247
+ prefixed_extra_field ("timeout" , cls .conn_type ): IntegerField (
248
+ lazy_gettext ("Timeout" ),
249
+ widget = BS3TextFieldWidget (),
250
+ description = "Optional. The maximum number of seconds the client will wait to connect "
251
+ "and receive a response from Slack API." ,
252
+ ),
253
+ prefixed_extra_field ("base_url" , cls .conn_type ): StringField (
254
+ lazy_gettext ('Base URL' ),
255
+ widget = BS3TextFieldWidget (),
256
+ description = "Optional. A string representing the Slack API base URL." ,
257
+ ),
258
+ prefixed_extra_field ("proxy" , cls .conn_type ): StringField (
259
+ lazy_gettext ('Proxy' ),
260
+ widget = BS3TextFieldWidget (),
261
+ description = "Optional. Proxy to make the Slack API call." ,
262
+ ),
263
+ prefixed_extra_field ("retry_handlers" , cls .conn_type ): StringField (
264
+ lazy_gettext ('Retry Handlers' ),
265
+ widget = BS3TextFieldWidget (),
266
+ description = "Optional. Comma separated list of import paths to zero-argument callable "
267
+ "which returns retry handler for Slack WebClient." ,
268
+ ),
269
+ }
270
+
271
+ @classmethod
272
+ def get_ui_field_behaviour (cls ) -> Dict [str , Any ]:
273
+ """Returns custom field behaviour."""
274
+ return {
275
+ "hidden_fields" : ["login" , "port" , "host" , "schema" , "extra" ],
276
+ "relabeling" : {
277
+ "password" : "Slack API Token" ,
278
+ },
279
+ "placeholders" : {
280
+ "password" : "xoxb-1234567890123-09876543210987-AbCdEfGhIjKlMnOpQrStUvWx" ,
281
+ prefixed_extra_field ("timeout" , cls .conn_type ): "30" ,
282
+ prefixed_extra_field ("base_url" , cls .conn_type ): "https://www.slack.com/api/" ,
283
+ prefixed_extra_field ("proxy" , cls .conn_type ): "http://localhost:9000" ,
284
+ prefixed_extra_field ("retry_handlers" , cls .conn_type ): (
285
+ "slack_sdk.http_retry.builtin_handlers.ConnectionErrorRetryHandler,"
286
+ "slack_sdk.http_retry.builtin_handlers.RateLimitErrorRetryHandler"
287
+ ),
288
+ },
289
+ }
0 commit comments