This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 5fb8637648b Add overwrite parameter to IMAP hook to prevent silent
file overwrites (#68838)
5fb8637648b is described below
commit 5fb8637648badb662d4dd812b1427dea2a996a1a
Author: Dan Kalenga <[email protected]>
AuthorDate: Mon Jul 6 18:02:45 2026 +0200
Add overwrite parameter to IMAP hook to prevent silent file overwrites
(#68838)
This adds an overwrittten paramter ( default to True, preserving the
current behavior). When set to False, duplicate filenames are renamed with a
numeric suffix( Example. report_1.xlsx , report_2.xlsx) instead of overwriting).
---
.../imap/src/airflow/providers/imap/hooks/imap.py | 32 ++++++++++++++++------
providers/imap/tests/unit/imap/hooks/test_imap.py | 27 ++++++++++++++++++
2 files changed, 50 insertions(+), 9 deletions(-)
diff --git a/providers/imap/src/airflow/providers/imap/hooks/imap.py
b/providers/imap/src/airflow/providers/imap/hooks/imap.py
index ecb99eb2061..60b2755ebd7 100644
--- a/providers/imap/src/airflow/providers/imap/hooks/imap.py
+++ b/providers/imap/src/airflow/providers/imap/hooks/imap.py
@@ -193,6 +193,7 @@ class ImapHook(BaseHook):
mail_folder: str = "INBOX",
mail_filter: str = "All",
not_found_mode: str = "raise",
+ overwrite: bool = True,
) -> None:
"""
Download mail's attachments in the mail folder by its name to the
local directory.
@@ -211,6 +212,8 @@ class ImapHook(BaseHook):
If it is set to 'raise' it will raise an exception,
if set to 'warn' it will only print a warning and
if set to 'ignore' it won't notify you at all.
+ :param overwrite: If True (default), existing files are overwritten.
If False, a unique
+ suffix is added to avoid overwriting.
"""
mail_attachments = self._retrieve_mails_attachments_by_name(
name, check_regex, latest_only, max_mails, mail_folder, mail_filter
@@ -219,7 +222,7 @@ class ImapHook(BaseHook):
if not mail_attachments:
self._handle_not_found_mode(not_found_mode)
- self._create_files(mail_attachments, local_output_directory)
+ self._create_files(mail_attachments, local_output_directory,
overwrite=overwrite)
def _handle_not_found_mode(self, not_found_mode: str) -> None:
if not_found_mode not in ("raise", "warn", "ignore"):
@@ -287,14 +290,31 @@ class ImapHook(BaseHook):
return mail.get_attachments_by_name(name, check_regex,
find_first=latest_only)
return []
- def _create_files(self, mail_attachments: list, local_output_directory:
str) -> None:
+ def _create_files(
+ self, mail_attachments: list, local_output_directory: str, overwrite:
bool = True
+ ) -> None:
for name, payload in mail_attachments:
if self._is_symlink(name):
self.log.error("Can not create file because it is a symlink!")
elif self._is_escaping_current_directory(name):
self.log.error("Can not create file because it is escaping the
current directory!")
else:
- self._create_file(name, payload, local_output_directory)
+ self._create_file(name, payload, local_output_directory,
overwrite=overwrite)
+
+ def _create_file(
+ self, name: str, payload: Any, local_output_directory: str, overwrite:
bool = True
+ ) -> None:
+ file_path = self._correct_path(name, local_output_directory)
+
+ if not overwrite:
+ base, ext = os.path.splitext(file_path)
+ counter = 1
+ while os.path.exists(file_path):
+ file_path = f"{base}_{counter}{ext}"
+ counter += 1
+
+ with open(file_path, "wb") as file:
+ file.write(payload)
def _is_symlink(self, name: str) -> bool:
# IMPORTANT NOTE: os.path.islink is not working for windows symlinks
@@ -311,12 +331,6 @@ class ImapHook(BaseHook):
else local_output_directory + os.sep + name
)
- def _create_file(self, name: str, payload: Any, local_output_directory:
str) -> None:
- file_path = self._correct_path(name, local_output_directory)
-
- with open(file_path, "wb") as file:
- file.write(payload)
-
class Mail(LoggingMixin):
"""
diff --git a/providers/imap/tests/unit/imap/hooks/test_imap.py
b/providers/imap/tests/unit/imap/hooks/test_imap.py
index 54537aa29af..0b8a7fa2f80 100644
--- a/providers/imap/tests/unit/imap/hooks/test_imap.py
+++ b/providers/imap/tests/unit/imap/hooks/test_imap.py
@@ -316,6 +316,33 @@ class TestImapHook:
mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
mock_open_method.return_value.write.assert_called_once_with(b"SWQsTmFtZQoxLEZlbGl4")
+ @patch("airflow.providers.imap.hooks.imap.os.path.exists")
+ @patch(open_string, new_callable=mock_open)
+ @patch(imaplib_string)
+ def test_download_mail_attachments_overwrite_true(self, mock_imaplib,
mock_open_method, mock_exists):
+ _create_fake_imap(mock_imaplib, with_mail=True)
+ mock_exists.return_value = True # pretend file already exists
+
+ with ImapHook() as imap_hook:
+ imap_hook.download_mail_attachments("test1.csv", "test_directory",
overwrite=True)
+
+ # with overwrite=True, it should still write to the original filename
+ mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
+
+ @patch("airflow.providers.imap.hooks.imap.os.path.exists")
+ @patch(open_string, new_callable=mock_open)
+ @patch(imaplib_string)
+ def test_download_mail_attachments_overwrite_false(self, mock_imaplib,
mock_open_method, mock_exists):
+ _create_fake_imap(mock_imaplib, with_mail=True)
+ # first call (checking original) returns True, second call (checking
_1) returns False
+ mock_exists.side_effect = [True, False]
+
+ with ImapHook() as imap_hook:
+ imap_hook.download_mail_attachments("test1.csv", "test_directory",
overwrite=False)
+
+ # with overwrite=False, it should write to a renamed file
+ mock_open_method.assert_called_once_with("test_directory/test1_1.csv",
"wb")
+
@patch(open_string, new_callable=mock_open)
@patch(imaplib_string)
def test_download_mail_attachments_not_found(self, mock_imaplib,
mock_open_method):