You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2022/09/09 02:52:48 UTC
[airflow] branch main updated: Move send_file method into SlackHook (#26118)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 675bb6c0e8 Move send_file method into SlackHook (#26118)
675bb6c0e8 is described below
commit 675bb6c0e88c380e39d242e04543e11950ea1141
Author: Andrey Anshin <An...@taragol.is>
AuthorDate: Fri Sep 9 06:52:41 2022 +0400
Move send_file method into SlackHook (#26118)
---
airflow/providers/slack/hooks/slack.py | 55 +++++++-
airflow/providers/slack/operators/slack.py | 104 ++++++++-------
tests/providers/slack/hooks/test_slack.py | 96 ++++++++++++++
tests/providers/slack/operators/test.csv | 1 -
tests/providers/slack/operators/test_slack.py | 183 +++++++++++++++++---------
5 files changed, 327 insertions(+), 112 deletions(-)
diff --git a/airflow/providers/slack/hooks/slack.py b/airflow/providers/slack/hooks/slack.py
index cb12132eaf..5af1e1e573 100644
--- a/airflow/providers/slack/hooks/slack.py
+++ b/airflow/providers/slack/hooks/slack.py
@@ -18,7 +18,8 @@
import json
import warnings
-from typing import TYPE_CHECKING, Any, Dict, List, Optional
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
@@ -213,6 +214,58 @@ class SlackHook(BaseHook):
"""
return self.client.api_call(api_method, **kwargs)
+ def send_file(
+ self,
+ *,
+ channels: Optional[Union[str, Sequence[str]]] = None,
+ file: Optional[Union[str, Path]] = None,
+ content: Optional[str] = None,
+ filename: Optional[str] = None,
+ filetype: Optional[str] = None,
+ initial_comment: Optional[str] = None,
+ title: Optional[str] = None,
+ ) -> "SlackResponse":
+ """
+ Create or upload an existing file.
+
+ :param channels: Comma-separated list of channel names or IDs where the file will be shared.
+ If omitting this parameter, then file will send to workspace.
+ :param file: Path to file which need to be sent.
+ :param content: File contents. If omitting this parameter, you must provide a file.
+ :param filename: Displayed filename.
+ :param filetype: A file type identifier.
+ :param initial_comment: The message text introducing the file in specified ``channels``.
+ :param title: Title of file.
+
+ .. seealso::
+ - `Slack API files.upload method <https://api.slack.com/methods/files.upload>`_
+ - `File types <https://api.slack.com/types/file#file_types>`_
+ """
+ if not ((not file) ^ (not content)):
+ raise ValueError("Either `file` or `content` must be provided, not both.")
+ elif file:
+ file = Path(file)
+ with open(file, "rb") as fp:
+ if not filename:
+ filename = file.name
+ return self.client.files_upload(
+ file=fp,
+ filename=filename,
+ filetype=filetype,
+ initial_comment=initial_comment,
+ title=title,
+ channels=channels,
+ )
+
+ return self.client.files_upload(
+ content=content,
+ filename=filename,
+ filetype=filetype,
+ initial_comment=initial_comment,
+ title=title,
+ channels=channels,
+ )
+
def test_connection(self):
"""Tests the Slack API connection.
diff --git a/airflow/providers/slack/operators/slack.py b/airflow/providers/slack/operators/slack.py
index 0c4ec53df2..0a1087bb99 100644
--- a/airflow/providers/slack/operators/slack.py
+++ b/airflow/providers/slack/operators/slack.py
@@ -16,10 +16,13 @@
# specific language governing permissions and limitations
# under the License.
import json
-from typing import Any, Dict, List, Optional, Sequence
+import warnings
+from typing import Any, Dict, List, Optional, Sequence, Union
+from airflow.compat.functools import cached_property
from airflow.models import BaseOperator
from airflow.providers.slack.hooks.slack import SlackHook
+from airflow.utils.log.secrets_masker import mask_secret
class SlackAPIOperator(BaseOperator):
@@ -47,13 +50,19 @@ class SlackAPIOperator(BaseOperator):
**kwargs,
) -> None:
super().__init__(**kwargs)
-
- self.token = token # type: Optional[str]
- self.slack_conn_id = slack_conn_id # type: Optional[str]
+ if token:
+ mask_secret(token)
+ self.token = token
+ self.slack_conn_id = slack_conn_id
self.method = method
self.api_params = api_params
+ @cached_property
+ def hook(self) -> SlackHook:
+ """Slack Hook."""
+ return SlackHook(token=self.token, slack_conn_id=self.slack_conn_id)
+
def construct_api_call_params(self) -> Any:
"""
Used by the execute function. Allows templating on the source fields
@@ -70,14 +79,9 @@ class SlackAPIOperator(BaseOperator):
)
def execute(self, **kwargs):
- """
- The SlackAPIOperator calls will not fail even if the call is not unsuccessful.
- It should not prevent a DAG from completing in success
- """
if not self.api_params:
self.construct_api_call_params()
- slack = SlackHook(token=self.token, slack_conn_id=self.slack_conn_id)
- slack.call(self.method, json=self.api_params)
+ self.hook.call(self.method, json=self.api_params)
class SlackAPIPostOperator(SlackAPIOperator):
@@ -144,7 +148,7 @@ class SlackAPIPostOperator(SlackAPIOperator):
class SlackAPIFileOperator(SlackAPIOperator):
"""
- Send a file to a slack channel
+ Send a file to a slack channels
Examples:
.. code-block:: python
@@ -154,7 +158,7 @@ class SlackAPIFileOperator(SlackAPIOperator):
task_id="slack_file_upload_1",
dag=dag,
slack_conn_id="slack",
- channel="#general",
+ channels="#general,#random",
initial_comment="Hello World!",
filename="/files/dags/test.txt",
filetype="txt",
@@ -165,63 +169,67 @@ class SlackAPIFileOperator(SlackAPIOperator):
task_id="slack_file_upload_2",
dag=dag,
slack_conn_id="slack",
- channel="#general",
+ channels="#general",
initial_comment="Hello World!",
content="file content in txt",
)
- :param channel: channel in which to sent file on slack name (templated)
+ :param channels: Comma-separated list of channel names or IDs where the file will be shared.
+ If set this argument to None, then file will send to associated workspace. (templated)
:param initial_comment: message to send to slack. (templated)
:param filename: name of the file (templated)
- :param filetype: slack filetype. (templated)
- - see https://api.slack.com/types/file
+ :param filetype: slack filetype. (templated) See: https://api.slack.com/types/file#file_types
:param content: file content. (templated)
+ :param title: title of file. (templated)
+ :param channel: (deprecated) channel in which to sent file on slack name
"""
- template_fields: Sequence[str] = ('channel', 'initial_comment', 'filename', 'filetype', 'content')
+ template_fields: Sequence[str] = (
+ 'channels',
+ 'initial_comment',
+ 'filename',
+ 'filetype',
+ 'content',
+ 'title',
+ )
ui_color = '#44BEDF'
def __init__(
self,
- channel: str = '#general',
- initial_comment: str = 'No message has been set!',
+ channels: Optional[Union[str, Sequence[str]]] = None,
+ initial_comment: Optional[str] = None,
filename: Optional[str] = None,
filetype: Optional[str] = None,
content: Optional[str] = None,
+ title: Optional[str] = None,
+ channel: Optional[str] = None,
**kwargs,
) -> None:
- self.method = 'files.upload'
- self.channel = channel
+ if channel:
+ warnings.warn(
+ "Argument `channel` is deprecated and will removed in a future releases. "
+ "Please use `channels` instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ if channels:
+ raise ValueError(f"Cannot set both arguments: channel={channel!r} and channels={channels!r}.")
+ channels = channel
+
+ self.channels = channels
self.initial_comment = initial_comment
self.filename = filename
self.filetype = filetype
self.content = content
- self.file_params: Dict = {}
- super().__init__(method=self.method, **kwargs)
+ self.title = title
+ super().__init__(method="files.upload", **kwargs)
def execute(self, **kwargs):
- """
- The SlackAPIOperator calls will not fail even if the call is not unsuccessful.
- It should not prevent a DAG from completing in success
- """
- slack = SlackHook(token=self.token, slack_conn_id=self.slack_conn_id)
-
- # If file content is passed.
- if self.content is not None:
- self.api_params = {
- 'channels': self.channel,
- 'content': self.content,
- 'initial_comment': self.initial_comment,
- }
- slack.call(self.method, data=self.api_params)
- # If file name is passed.
- elif self.filename is not None:
- self.api_params = {
- 'channels': self.channel,
- 'filename': self.filename,
- 'filetype': self.filetype,
- 'initial_comment': self.initial_comment,
- }
- with open(self.filename, "rb") as file_handle:
- slack.call(self.method, data=self.api_params, files={'file': file_handle})
- file_handle.close()
+ self.hook.send_file(
+ channels=self.channels,
+ # For historical reason SlackAPIFileOperator use filename as reference to file
+ file=self.filename,
+ content=self.content,
+ initial_comment=self.initial_comment,
+ title=self.title,
+ )
diff --git a/tests/providers/slack/hooks/test_slack.py b/tests/providers/slack/hooks/test_slack.py
index 9981ed331a..db7e02afd8 100644
--- a/tests/providers/slack/hooks/test_slack.py
+++ b/tests/providers/slack/hooks/test_slack.py
@@ -366,3 +366,99 @@ class TestSlackHook:
conn_test = hook.test_connection()
mock_webclient_call.assert_called_once_with("auth.test")
assert not conn_test[0]
+
+ @pytest.mark.parametrize("file,content", [(None, None), ("", ""), ("foo.bar", "test-content")])
+ def test_send_file_wrong_parameters(self, file, content):
+ hook = SlackHook(slack_conn_id=SLACK_API_DEFAULT_CONN_ID)
+ error_message = r"Either `file` or `content` must be provided, not both\."
+ with pytest.raises(ValueError, match=error_message):
+ hook.send_file(file=file, content=content)
+
+ @mock.patch('airflow.providers.slack.hooks.slack.WebClient')
+ @pytest.mark.parametrize("initial_comment", [None, "test comment"])
+ @pytest.mark.parametrize("title", [None, "test title"])
+ @pytest.mark.parametrize("filetype", [None, "auto"])
+ @pytest.mark.parametrize("channels", [None, "#random", "#random,#general", ("#random", "#general")])
+ def test_send_file_path(
+ self, mock_webclient_cls, tmp_path_factory, initial_comment, title, filetype, channels
+ ):
+ """Test send file by providing filepath."""
+ mock_files_upload = mock.MagicMock()
+ mock_webclient_cls.return_value.files_upload = mock_files_upload
+
+ tmp = tmp_path_factory.mktemp("test_send_file_path")
+ file = tmp / "test.json"
+ file.write_bytes(b'{"foo": "bar"}')
+
+ hook = SlackHook(slack_conn_id=SLACK_API_DEFAULT_CONN_ID)
+ hook.send_file(
+ channels=channels,
+ file=file,
+ filename="filename.mock",
+ initial_comment=initial_comment,
+ title=title,
+ filetype=filetype,
+ )
+
+ mock_files_upload.assert_called_once_with(
+ channels=channels,
+ file=mock.ANY, # Validate file properties later
+ filename="filename.mock",
+ initial_comment=initial_comment,
+ title=title,
+ filetype=filetype,
+ )
+
+ # Validate file properties
+ mock_file = mock_files_upload.call_args[1]["file"]
+ assert mock_file.mode == "rb"
+ assert mock_file.name == str(file)
+
+ @mock.patch('airflow.providers.slack.hooks.slack.WebClient')
+ @pytest.mark.parametrize("filename", ["test.json", "1.parquet.snappy"])
+ def test_send_file_path_set_filename(self, mock_webclient_cls, tmp_path_factory, filename):
+ """Test set filename in send_file method if it not set."""
+ mock_files_upload = mock.MagicMock()
+ mock_webclient_cls.return_value.files_upload = mock_files_upload
+
+ tmp = tmp_path_factory.mktemp("test_send_file_path_set_filename")
+ file = tmp / filename
+ file.write_bytes(b'{"foo": "bar"}')
+
+ hook = SlackHook(slack_conn_id=SLACK_API_DEFAULT_CONN_ID)
+ hook.send_file(file=file)
+
+ assert mock_files_upload.call_count == 1
+ call_args = mock_files_upload.call_args[1]
+ assert "filename" in call_args
+ assert call_args["filename"] == filename
+
+ @mock.patch('airflow.providers.slack.hooks.slack.WebClient')
+ @pytest.mark.parametrize("initial_comment", [None, "test comment"])
+ @pytest.mark.parametrize("title", [None, "test title"])
+ @pytest.mark.parametrize("filetype", [None, "auto"])
+ @pytest.mark.parametrize("filename", [None, "foo.bar"])
+ @pytest.mark.parametrize("channels", [None, "#random", "#random,#general", ("#random", "#general")])
+ def test_send_file_content(
+ self, mock_webclient_cls, initial_comment, title, filetype, channels, filename
+ ):
+ """Test send file by providing content."""
+ mock_files_upload = mock.MagicMock()
+ mock_webclient_cls.return_value.files_upload = mock_files_upload
+ hook = SlackHook(slack_conn_id=SLACK_API_DEFAULT_CONN_ID)
+ hook.send_file(
+ channels=channels,
+ content='{"foo": "bar"}',
+ filename=filename,
+ initial_comment=initial_comment,
+ title=title,
+ filetype=filetype,
+ )
+ mock_files_upload.assert_called_once_with(
+ channels=channels,
+ content='{"foo": "bar"}',
+ filename=filename,
+ initial_comment=initial_comment,
+ title=title,
+ filetype=filetype,
+ )
diff --git a/tests/providers/slack/operators/test.csv b/tests/providers/slack/operators/test.csv
deleted file mode 100644
index 9daeafb986..0000000000
--- a/tests/providers/slack/operators/test.csv
+++ /dev/null
@@ -1 +0,0 @@
-test
diff --git a/tests/providers/slack/operators/test_slack.py b/tests/providers/slack/operators/test_slack.py
index 495dc7f6ba..d184093dba 100644
--- a/tests/providers/slack/operators/test_slack.py
+++ b/tests/providers/slack/operators/test_slack.py
@@ -16,15 +16,64 @@
# specific language governing permissions and limitations
# under the License.
import json
-import unittest
from unittest import mock
from unittest.mock import MagicMock
-from airflow.providers.slack.operators.slack import SlackAPIFileOperator, SlackAPIPostOperator
+import pytest
+from airflow.models import Connection
+from airflow.providers.slack.operators.slack import (
+ SlackAPIFileOperator,
+ SlackAPIOperator,
+ SlackAPIPostOperator,
+)
-class TestSlackAPIPostOperator(unittest.TestCase):
- def setUp(self):
+SLACK_API_TEST_CONNECTION_ID = "test_slack_conn_id"
+
+
+@pytest.fixture(scope="module", autouse=True)
+def slack_api_connections():
+ """Create tests connections."""
+ connections = [
+ Connection(
+ conn_id=SLACK_API_TEST_CONNECTION_ID,
+ conn_type="slack",
+ password="xoxb-1234567890123-09876543210987-AbCdEfGhIjKlMnOpQrStUvWx",
+ ),
+ ]
+ conn_uris = {f"AIRFLOW_CONN_{c.conn_id.upper()}": c.get_uri() for c in connections}
+
+ with mock.patch.dict("os.environ", values=conn_uris):
+ yield
+
+
+class TestSlackAPIOperator:
+ @mock.patch("airflow.providers.slack.operators.slack.mask_secret")
+ def test_mask_token(self, mock_mask_secret):
+ SlackAPIOperator(task_id="test-mask-token", token="super-secret-token")
+ mock_mask_secret.assert_called_once_with("super-secret-token")
+
+ @mock.patch("airflow.providers.slack.operators.slack.SlackHook")
+ @pytest.mark.parametrize(
+ "token,conn_id",
+ [
+ ("token", SLACK_API_TEST_CONNECTION_ID),
+ ("token", None),
+ (None, SLACK_API_TEST_CONNECTION_ID),
+ ],
+ )
+ def test_hook(self, mock_slack_hook_cls, token, conn_id):
+ mock_slack_hook = mock_slack_hook_cls.return_value
+ op = SlackAPIOperator(task_id="test-mask-token", token=token, slack_conn_id=conn_id)
+ hook = op.hook
+ assert hook == mock_slack_hook
+ assert hook is op.hook
+ mock_slack_hook_cls.assert_called_once_with(token=token, slack_conn_id=conn_id)
+
+
+class TestSlackAPIPostOperator:
+ @pytest.fixture(autouse=True)
+ def setup(self):
self.test_username = 'test_username'
self.test_channel = '#test_slack_channel'
self.test_text = 'test_text'
@@ -91,7 +140,6 @@ class TestSlackAPIPostOperator(unittest.TestCase):
def test_init_with_valid_params(self):
test_token = 'test_token'
- test_slack_conn_id = 'test_slack_conn_id'
slack_api_post_operator = self.__construct_operator(test_token, None, self.test_api_params)
assert slack_api_post_operator.token == test_token
@@ -105,18 +153,16 @@ class TestSlackAPIPostOperator(unittest.TestCase):
assert slack_api_post_operator.attachments == self.test_attachments
assert slack_api_post_operator.blocks == self.test_blocks
- slack_api_post_operator = self.__construct_operator(None, test_slack_conn_id)
+ slack_api_post_operator = self.__construct_operator(None, SLACK_API_TEST_CONNECTION_ID)
assert slack_api_post_operator.token is None
- assert slack_api_post_operator.slack_conn_id == test_slack_conn_id
+ assert slack_api_post_operator.slack_conn_id == SLACK_API_TEST_CONNECTION_ID
@mock.patch('airflow.providers.slack.operators.slack.SlackHook')
def test_api_call_params_with_default_args(self, mock_hook):
- test_slack_conn_id = 'test_slack_conn_id'
-
slack_api_post_operator = SlackAPIPostOperator(
task_id='slack',
username=self.test_username,
- slack_conn_id=test_slack_conn_id,
+ slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
)
slack_api_post_operator.execute(context=MagicMock())
@@ -135,32 +181,24 @@ class TestSlackAPIPostOperator(unittest.TestCase):
assert expected_api_params == slack_api_post_operator.api_params
-class TestSlackAPIFileOperator(unittest.TestCase):
- def setUp(self):
+class TestSlackAPIFileOperator:
+ @pytest.fixture(autouse=True)
+ def setup(self):
self.test_username = 'test_username'
self.test_channel = '#test_slack_channel'
self.test_initial_comment = 'test text file test_filename.txt'
self.filename = 'test_filename.txt'
self.test_filetype = 'text'
self.test_content = 'This is a test text file!'
-
self.test_api_params = {'key': 'value'}
-
self.expected_method = 'files.upload'
- self.expected_api_params = {
- 'channel': self.test_channel,
- 'initial_comment': self.test_initial_comment,
- 'file': self.filename,
- 'filetype': self.test_filetype,
- 'content': self.test_content,
- }
def __construct_operator(self, test_token, test_slack_conn_id, test_api_params=None):
return SlackAPIFileOperator(
task_id='slack',
token=test_token,
slack_conn_id=test_slack_conn_id,
- channel=self.test_channel,
+ channels=self.test_channel,
initial_comment=self.test_initial_comment,
filename=self.filename,
filetype=self.test_filetype,
@@ -170,63 +208,84 @@ class TestSlackAPIFileOperator(unittest.TestCase):
def test_init_with_valid_params(self):
test_token = 'test_token'
- test_slack_conn_id = 'test_slack_conn_id'
slack_api_post_operator = self.__construct_operator(test_token, None, self.test_api_params)
assert slack_api_post_operator.token == test_token
assert slack_api_post_operator.slack_conn_id is None
assert slack_api_post_operator.method == self.expected_method
assert slack_api_post_operator.initial_comment == self.test_initial_comment
- assert slack_api_post_operator.channel == self.test_channel
+ assert slack_api_post_operator.channels == self.test_channel
assert slack_api_post_operator.api_params == self.test_api_params
assert slack_api_post_operator.filename == self.filename
assert slack_api_post_operator.filetype == self.test_filetype
assert slack_api_post_operator.content == self.test_content
- slack_api_post_operator = self.__construct_operator(None, test_slack_conn_id)
+ slack_api_post_operator = self.__construct_operator(None, SLACK_API_TEST_CONNECTION_ID)
assert slack_api_post_operator.token is None
- assert slack_api_post_operator.slack_conn_id == test_slack_conn_id
+ assert slack_api_post_operator.slack_conn_id == SLACK_API_TEST_CONNECTION_ID
- @mock.patch('airflow.providers.slack.operators.slack.SlackHook')
- def test_api_call_params_with_content_args(self, mock_hook):
- test_slack_conn_id = 'test_slack_conn_id'
+ @mock.patch('airflow.providers.slack.operators.slack.SlackHook.send_file')
+ @pytest.mark.parametrize("initial_comment", [None, "foo-bar"])
+ @pytest.mark.parametrize("title", [None, "Spam Egg"])
+ def test_api_call_params_with_content_args(self, mock_send_file, initial_comment, title):
+ SlackAPIFileOperator(
+ task_id='slack',
+ slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+ content='test-content',
+ channels='#test-channel',
+ initial_comment=initial_comment,
+ title=title,
+ ).execute(context=MagicMock())
- slack_api_post_operator = SlackAPIFileOperator(
- task_id='slack', slack_conn_id=test_slack_conn_id, content='test-content'
+ mock_send_file.assert_called_once_with(
+ channels='#test-channel',
+ content='test-content',
+ file=None,
+ initial_comment=initial_comment,
+ title=title,
)
- slack_api_post_operator.execute(context=MagicMock())
-
- expected_api_params = {
- 'channels': '#general',
- 'initial_comment': 'No message has been set!',
- 'content': 'test-content',
- }
- assert expected_api_params == slack_api_post_operator.api_params
-
- @mock.patch('airflow.providers.slack.operators.slack.SlackHook')
- def test_api_call_params_with_file_args(self, mock_hook):
- test_slack_conn_id = 'test_slack_conn_id'
-
- import os
-
- # Look for your absolute directory path
- absolute_path = os.path.dirname(os.path.abspath(__file__))
- # Or: file_path = os.path.join(absolute_path, 'folder', 'my_file.py')
- file_path = absolute_path + '/test.csv'
-
- print(f"full path ${file_path}")
+ @mock.patch('airflow.providers.slack.operators.slack.SlackHook.send_file')
+ @pytest.mark.parametrize("initial_comment", [None, "foo-bar"])
+ @pytest.mark.parametrize("title", [None, "Spam Egg"])
+ def test_api_call_params_with_file_args(self, mock_send_file, initial_comment, title):
+ SlackAPIFileOperator(
+ task_id='slack',
+ slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+ channels='C1234567890',
+ filename='/dev/null',
+ initial_comment=initial_comment,
+ title=title,
+ ).execute(context=MagicMock())
- slack_api_post_operator = SlackAPIFileOperator(
- task_id='slack', slack_conn_id=test_slack_conn_id, filename=file_path, filetype='csv'
+ mock_send_file.assert_called_once_with(
+ channels='C1234567890',
+ content=None,
+ file='/dev/null',
+ initial_comment=initial_comment,
+ title=title,
)
- slack_api_post_operator.execute(context=MagicMock())
+ def test_channel_deprecated(self):
+ warning_message = (
+ r"Argument `channel` is deprecated and will removed in a future releases\. "
+ r"Please use `channels` instead\."
+ )
+ with pytest.warns(DeprecationWarning, match=warning_message):
+ op = SlackAPIFileOperator(
+ task_id='slack',
+ slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+ channel="#random",
+ channels=None,
+ )
+ assert op.channels == "#random"
- expected_api_params = {
- 'channels': '#general',
- 'initial_comment': 'No message has been set!',
- 'filename': file_path,
- 'filetype': 'csv',
- }
- assert expected_api_params == slack_api_post_operator.api_params
+ def test_both_channel_and_channels_set(self):
+ error_message = r"Cannot set both arguments: channel=.* and channels=.*\."
+ with pytest.raises(ValueError, match=error_message):
+ SlackAPIFileOperator(
+ task_id='slack',
+ slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+ channel="#random",
+ channels="#general",
+ )