potiuk commented on code in PR #72336:
URL: https://github.com/apache/airflow/pull/72336#discussion_r3977695191


##########
providers/sftp/src/airflow/providers/sftp/hooks/sftp.py:
##########
@@ -1080,3 +1154,111 @@ async def get_mod_time(self, path: str) -> str:  # 
type: ignore[return]
                     return mod_time
                 except asyncssh.SFTPNoSuchFile:
                     raise AirflowException("No files matching")
+
+    async def sense_files_by_pattern(
+        self,
+        path: str,
+        fnmatch_pattern: str,
+        newer_than: datetime.datetime | None = None,
+    ) -> list[str]:
+        """
+        Return the names of files at ``path`` matching ``fnmatch_pattern``.
+
+        If ``newer_than`` is provided, only files modified after that 
timestamp are returned; files
+        without a reported modification time are skipped in that case.
+
+        :param path: directory on the SFTP server to search for files matching 
the pattern
+        :param fnmatch_pattern: pattern used to match filenames, see the 
``fnmatch`` std library module
+        :param newer_than: if provided, only files modified after this UTC 
timestamp are returned
+        """
+        files = await self.get_files_and_attrs_by_pattern(path=path, 
fnmatch_pattern=fnmatch_pattern)
+        if not newer_than:
+            return [str(file.filename) for file in files]
+
+        matched_files = []
+        for file in files:
+            if file.attrs.mtime is None:
+                continue
+            if newer_than <= self._mod_time_to_utc(file.attrs.mtime):
+                matched_files.append(str(file.filename))
+        return matched_files
+
+    async def sense_path(self, path: str, newer_than: datetime.datetime | None 
= None) -> bool:
+        """
+        Return whether ``path`` exists and, if ``newer_than`` is provided, was 
modified since.
+
+        :param path: full path to the remote file
+        :param newer_than: if provided, the file must have been modified after 
this UTC timestamp
+        """
+        mod_time = await self.get_mod_time(path)
+        if not newer_than:
+            return True
+        return newer_than <= self._mod_time_to_utc(mod_time)
+
+    @staticmethod
+    def _mod_time_to_utc(mod_time: int | float | str) -> datetime.datetime:
+        """Convert a modification time, either an epoch timestamp or 
``%Y%m%d%H%M%S`` string, to UTC."""
+        if not isinstance(mod_time, str):
+            mod_time = 
datetime.datetime.fromtimestamp(float(mod_time)).strftime("%Y%m%d%H%M%S")
+        return timezone.convert_to_utc(datetime.datetime.strptime(mod_time, 
"%Y%m%d%H%M%S"))
+
+    async def transfer(
+        self,
+        operation: str,
+        local_filepath: str | list[str] | None,
+        remote_filepath: str | list[str],
+        confirm: bool = True,
+        create_intermediate_dirs: bool = False,
+        concurrency: int = 1,
+        prefetch: bool = True,
+    ) -> None:
+        """Perform an SFTP transfer operation (GET, PUT, or DELETE) using 
native async I/O."""
+        if isinstance(local_filepath, str):
+            local_filepath_array = [local_filepath] if local_filepath else []
+        else:
+            local_filepath_array = local_filepath or []
+
+        if isinstance(remote_filepath, str):
+            remote_filepath_array = [remote_filepath]
+        else:
+            remote_filepath_array = list(remote_filepath)
+
+        semaphore = asyncio.Semaphore(concurrency)
+
+        async def _bounded(coro):
+            async with semaphore:
+                return await coro
+
+        async with await self._get_conn() as ssh_conn:

Review Comment:
   This connection and its SFTP client are opened for every transfer, but only 
the DELETE branch below actually uses `sftp` (`await sftp.unlink(remote)`).
   
   The GET and PUT branches call `self.retrieve_file(...)` and 
`self.store_file(...)`, and both of those open their own connection internally 
— `retrieve_file` at line 1117 and `store_file` at line 1147 each start with 
their own `async with await self._get_conn() as ssh_conn: async with 
ssh_conn.start_sftp_client() as sftp:`.
   
   So a deferred GET or PUT holds one idle SSH connection plus SFTP client open 
for the entire duration of the transfer, on top of the real connections the 
per-file calls make. That lands in the Triggerer, which is a shared process 
running many triggers at once and where connection count is exactly the 
resource you don't want to double.
   
   Worth picking one of the two models rather than half of each: either open 
the connection here once and have the branches use `sftp` directly (which also 
gets you connection reuse across the files in a multi-path transfer, and would 
be a genuine win over the sync path), or drop the outer `async with` entirely 
and let `retrieve_file` / `store_file` manage their own. The first is better; 
the second is a one-line change if you'd rather keep this PR's scope tight.
   



##########
providers/sftp/src/airflow/providers/sftp/hooks/sftp.py:
##########
@@ -1080,3 +1154,111 @@ async def get_mod_time(self, path: str) -> str:  # 
type: ignore[return]
                     return mod_time
                 except asyncssh.SFTPNoSuchFile:
                     raise AirflowException("No files matching")
+
+    async def sense_files_by_pattern(
+        self,
+        path: str,
+        fnmatch_pattern: str,
+        newer_than: datetime.datetime | None = None,
+    ) -> list[str]:
+        """
+        Return the names of files at ``path`` matching ``fnmatch_pattern``.
+
+        If ``newer_than`` is provided, only files modified after that 
timestamp are returned; files
+        without a reported modification time are skipped in that case.
+
+        :param path: directory on the SFTP server to search for files matching 
the pattern
+        :param fnmatch_pattern: pattern used to match filenames, see the 
``fnmatch`` std library module
+        :param newer_than: if provided, only files modified after this UTC 
timestamp are returned
+        """
+        files = await self.get_files_and_attrs_by_pattern(path=path, 
fnmatch_pattern=fnmatch_pattern)
+        if not newer_than:
+            return [str(file.filename) for file in files]
+
+        matched_files = []
+        for file in files:
+            if file.attrs.mtime is None:
+                continue
+            if newer_than <= self._mod_time_to_utc(file.attrs.mtime):
+                matched_files.append(str(file.filename))
+        return matched_files
+
+    async def sense_path(self, path: str, newer_than: datetime.datetime | None 
= None) -> bool:
+        """
+        Return whether ``path`` exists and, if ``newer_than`` is provided, was 
modified since.
+
+        :param path: full path to the remote file
+        :param newer_than: if provided, the file must have been modified after 
this UTC timestamp
+        """
+        mod_time = await self.get_mod_time(path)
+        if not newer_than:
+            return True
+        return newer_than <= self._mod_time_to_utc(mod_time)
+
+    @staticmethod
+    def _mod_time_to_utc(mod_time: int | float | str) -> datetime.datetime:
+        """Convert a modification time, either an epoch timestamp or 
``%Y%m%d%H%M%S`` string, to UTC."""
+        if not isinstance(mod_time, str):
+            mod_time = 
datetime.datetime.fromtimestamp(float(mod_time)).strftime("%Y%m%d%H%M%S")
+        return timezone.convert_to_utc(datetime.datetime.strptime(mod_time, 
"%Y%m%d%H%M%S"))
+
+    async def transfer(
+        self,
+        operation: str,
+        local_filepath: str | list[str] | None,
+        remote_filepath: str | list[str],
+        confirm: bool = True,
+        create_intermediate_dirs: bool = False,
+        concurrency: int = 1,
+        prefetch: bool = True,
+    ) -> None:
+        """Perform an SFTP transfer operation (GET, PUT, or DELETE) using 
native async I/O."""
+        if isinstance(local_filepath, str):
+            local_filepath_array = [local_filepath] if local_filepath else []
+        else:
+            local_filepath_array = local_filepath or []
+
+        if isinstance(remote_filepath, str):
+            remote_filepath_array = [remote_filepath]
+        else:
+            remote_filepath_array = list(remote_filepath)
+
+        semaphore = asyncio.Semaphore(concurrency)
+
+        async def _bounded(coro):
+            async with semaphore:
+                return await coro
+
+        async with await self._get_conn() as ssh_conn:
+            async with ssh_conn.start_sftp_client() as sftp:
+                if operation.lower() == SFTPOperation.GET:
+
+                    async def _get(local: str, remote: str):

Review Comment:
   This is where the async path diverges from the sync one it stands in for. 
`_get` always calls `retrieve_file`, and `_put` always calls `store_file` — 
there is no `isdir` branch, so a directory path that works with 
`deferrable=False` fails with `deferrable=True`. The same applies to `_delete`, 
which calls `sftp.unlink` and so fails on a directory that the sync path would 
remove via `delete_directory(include_files=True)`.
   
   Two more differences in the same block:
   
   - **`prefetch` and `confirm` are accepted and silently ignored.** 
`retrieve_file` is called without `prefetch`, `store_file` without `confirm`. 
Silently dropping a parameter the user set is worse than not accepting it.
   - **`create_intermediate_dirs` behaves differently.** 
`os.makedirs(os.path.dirname(local), exist_ok=True)` raises `FileNotFoundError` 
when `local` is a bare filename, because `os.path.dirname("f.txt")` is `""`. 
The sync path does `Path(os.path.dirname(local)).mkdir(parents=True, 
exist_ok=True)`, and `Path("")` is `.`, so it succeeds.
   
   And the one I'd fix first — **DELETE of a missing file now raises in 
deferrable mode**, where the sync path warns and skips. That silently regresses 
#62639 for anyone who sets `deferrable=True`.
   
   I realise the docstring positions deferrable mode as "best suited for single 
large file transfers". But `concurrency`, `prefetch` and directory paths are 
all still accepted here, so nothing stops a user from hitting these. Either 
bring the async branch up to parity, or validate and reject the unsupported 
combinations in `execute()` before deferring, so the user gets a clear error 
instead of a silent behaviour change.
   



##########
providers/sftp/src/airflow/providers/sftp/hooks/sftp.py:
##########
@@ -1080,3 +1154,111 @@ async def get_mod_time(self, path: str) -> str:  # 
type: ignore[return]
                     return mod_time
                 except asyncssh.SFTPNoSuchFile:
                     raise AirflowException("No files matching")
+
+    async def sense_files_by_pattern(
+        self,
+        path: str,
+        fnmatch_pattern: str,
+        newer_than: datetime.datetime | None = None,
+    ) -> list[str]:
+        """
+        Return the names of files at ``path`` matching ``fnmatch_pattern``.
+
+        If ``newer_than`` is provided, only files modified after that 
timestamp are returned; files
+        without a reported modification time are skipped in that case.
+
+        :param path: directory on the SFTP server to search for files matching 
the pattern
+        :param fnmatch_pattern: pattern used to match filenames, see the 
``fnmatch`` std library module
+        :param newer_than: if provided, only files modified after this UTC 
timestamp are returned
+        """
+        files = await self.get_files_and_attrs_by_pattern(path=path, 
fnmatch_pattern=fnmatch_pattern)
+        if not newer_than:
+            return [str(file.filename) for file in files]
+
+        matched_files = []
+        for file in files:
+            if file.attrs.mtime is None:
+                continue
+            if newer_than <= self._mod_time_to_utc(file.attrs.mtime):
+                matched_files.append(str(file.filename))
+        return matched_files
+
+    async def sense_path(self, path: str, newer_than: datetime.datetime | None 
= None) -> bool:
+        """
+        Return whether ``path`` exists and, if ``newer_than`` is provided, was 
modified since.
+
+        :param path: full path to the remote file
+        :param newer_than: if provided, the file must have been modified after 
this UTC timestamp
+        """
+        mod_time = await self.get_mod_time(path)
+        if not newer_than:
+            return True
+        return newer_than <= self._mod_time_to_utc(mod_time)
+
+    @staticmethod
+    def _mod_time_to_utc(mod_time: int | float | str) -> datetime.datetime:
+        """Convert a modification time, either an epoch timestamp or 
``%Y%m%d%H%M%S`` string, to UTC."""
+        if not isinstance(mod_time, str):
+            mod_time = 
datetime.datetime.fromtimestamp(float(mod_time)).strftime("%Y%m%d%H%M%S")
+        return timezone.convert_to_utc(datetime.datetime.strptime(mod_time, 
"%Y%m%d%H%M%S"))
+
+    async def transfer(

Review Comment:
   This method is the feature — it's what actually runs in the Triggerer when 
`deferrable=True` — and nothing in the PR exercises it.
   
   `test_run_success` and `test_run_error` both patch `SFTPHookAsync.transfer` 
wholesale, so they only prove the trigger wraps a result in a `TriggerEvent`. 
The operator tests patch `SFTPHook.transfer`. The hook test file adds six 
lines, and they assert the values of the `SFTPOperation` enum. Net coverage of 
the ~55 lines here: none.
   
   From the testing standards: *"Target exactly 100% coverage of what the PR 
changes — no more, no less. Every changed or added behaviour must have a test; 
every test must fail without the PR's change."*
   
   This isn't a box-ticking request — both of the behavioural findings on this 
PR are things a test would have caught. A test that asserts a directory GET 
produces the same local tree in both modes would fail today. So would one that 
deletes a missing file with `deferrable=True` and expects a warning, matching 
`test_delete_missing_file_warns`. Mocking `asyncssh` at the `_get_conn` 
boundary and asserting which hook methods get called for each operation would 
also have made the duplicate-connection issue visible.
   



##########
providers/sftp/src/airflow/providers/sftp/hooks/sftp.py:
##########
@@ -735,6 +741,74 @@ def get_files_by_pattern(self, path, fnmatch_pattern) -> 
list[str]:
 
         return matched_files
 
+    def transfer(
+        self,
+        operation: str,
+        local_filepath: str | list[str] | None,
+        remote_filepath: str | list[str],
+        confirm: bool = True,
+        create_intermediate_dirs: bool = False,
+        concurrency: int = 1,
+        prefetch: bool = True,
+    ) -> None:
+        """
+        Perform a synchronous SFTP transfer operation (GET, PUT, or DELETE).
+
+        Centralizes transfer logic so both the operator and the trigger
+        can delegate to the hook, in line with the DRY principle.
+
+        :param operation: The SFTP operation - put, get, or delete.
+        :param local_filepath: Local file path(s).
+        :param remote_filepath: Remote file path(s).
+        :param confirm: Whether to confirm file size after PUT (default: True).
+        :param create_intermediate_dirs: Create missing intermediate 
directories (default: False).
+        :param concurrency: Number of threads for directory transfers 
(default: 1).
+        :param prefetch: Whether to prefetch during GET (default: True).
+        """
+        if isinstance(local_filepath, str):
+            local_filepath_array = [local_filepath] if local_filepath else []
+        else:
+            local_filepath_array = local_filepath or []
+
+        if isinstance(remote_filepath, str):
+            remote_filepath_array = [remote_filepath]
+        else:
+            remote_filepath_array = list(remote_filepath)
+
+        if operation.lower() == SFTPOperation.GET:
+            for local, remote in zip(local_filepath_array, 
remote_filepath_array):
+                if create_intermediate_dirs:
+                    Path(os.path.dirname(local)).mkdir(parents=True, 
exist_ok=True)
+                if self.isdir(remote):
+                    if concurrency > 1:
+                        self.retrieve_directory_concurrently(
+                            remote, local, workers=concurrency, 
prefetch=prefetch
+                        )
+                    else:
+                        self.retrieve_directory(remote, local)
+                else:
+                    self.retrieve_file(remote, local, prefetch=prefetch)
+        elif operation.lower() == SFTPOperation.PUT:
+            for local, remote in zip(local_filepath_array, 
remote_filepath_array):
+                if create_intermediate_dirs:
+                    self.create_directory(os.path.dirname(remote))
+                if os.path.isdir(local):
+                    if concurrency > 1:
+                        self.store_directory_concurrently(remote, local, 
confirm=confirm, workers=concurrency)
+                    else:
+                        self.store_directory(remote, local, confirm=confirm)
+                else:
+                    self.store_file(remote, local, confirm=confirm)
+        elif operation.lower() == SFTPOperation.DELETE:
+            for remote in remote_filepath_array:
+                if self.isdir(remote):
+                    self.delete_directory(remote, include_files=True)
+                else:
+                    try:
+                        self.delete_file(remote)
+                    except FileNotFoundError:

Review Comment:
   The operator previously routed this through `_is_missing_path_error`, which 
treated three shapes as "already absent": a `FileNotFoundError`, an `OSError` 
whose `errno` is `ENOENT`, and an exception whose `args[0]` is `ENOENT`. That 
helper is deleted here and replaced by this bare `except FileNotFoundError`.
   
   The existing tests don't distinguish the two: 
`test_delete_missing_file_warns` raises `FileNotFoundError("missing")` and 
`test_delete_permission_error_raises` raises `PermissionError`, so both still 
pass. That's exactly why it's worth asking rather than assuming.
   
   The third branch in particular looks like it was written for an exception 
that carries `ENOENT` in `args[0]` without being a `FileNotFoundError` — 
paramiko does construct `IOError(errno.ENOENT, text)`, which Python maps to 
`FileNotFoundError`, but that helper was added deliberately in #62639 to fix a 
reported bug, and the extra branches suggest the reporter hit a shape the 
simple check misses.
   
   Could you confirm the narrowing is intentional? If it is, a test raising the 
shape the old third branch covered — asserting it still warns — would lock the 
decision down. If it isn't, keeping the helper (moved onto the hook alongside 
the rest of the transfer logic) preserves the fix.
   



##########
providers/sftp/src/airflow/providers/sftp/operators/sftp.py:
##########
@@ -136,106 +139,85 @@ def execute(self, context: Any) -> str | list[str] | 
None:
         if self.operation.lower() not in (SFTPOperation.GET, 
SFTPOperation.PUT, SFTPOperation.DELETE):
             raise TypeError(
                 f"Unsupported operation value {self.operation}, "
-                f"expected {SFTPOperation.GET} or {SFTPOperation.PUT} or 
{SFTPOperation.DELETE}."
+                f"expected {SFTPOperation.GET!r}, {SFTPOperation.PUT!r}, "
+                f"or {SFTPOperation.DELETE!r}."
             )
 
         if self.concurrency < 1:
-            raise ValueError(f"concurrency should be greater than 0, got 
{self.concurrency}")
+            raise ValueError(f"concurrency should be >= 1, got 
{self.concurrency}")
 
-        file_msg = None
-        try:
-            if self.remote_host is not None:
-                self.log.info(
-                    "remote_host is provided explicitly. "
-                    "It will replace the remote_host which was defined "
-                    "in sftp_hook or predefined in connection of ssh_conn_id."
+        # ------------------------------------------------------------------ #
+        # Synchronous path — delegate all transfer logic to the hook          #
+        # ------------------------------------------------------------------ #
+        if self.remote_host is not None:
+            self.log.info(
+                "remote_host is provided explicitly. "
+                "It will replace the remote_host which was defined "
+                "in sftp_hook or predefined in connection of ssh_conn_id."
+            )
+
+        if self.ssh_conn_id:
+            if self.sftp_hook and isinstance(self.sftp_hook, SFTPHook):
+                self.log.info("ssh_conn_id is ignored when sftp_hook is 
provided.")
+            else:
+                self.log.info("sftp_hook not provided or invalid. Trying 
ssh_conn_id to create SFTPHook.")
+                self.sftp_hook = SFTPHook(
+                    ssh_conn_id=self.ssh_conn_id,
+                    remote_host=self.remote_host or "",
                 )
 
-            if self.ssh_conn_id:
-                if self.sftp_hook and isinstance(self.sftp_hook, SFTPHook):
-                    self.log.info("ssh_conn_id is ignored when sftp_hook is 
provided.")
-                else:
-                    self.log.info("sftp_hook not provided or invalid. Trying 
ssh_conn_id to create SFTPHook.")
-                    self.sftp_hook = SFTPHook(
-                        ssh_conn_id=self.ssh_conn_id, 
remote_host=self.remote_host or ""
-                    )
-
-            if not self.sftp_hook:
-                raise AirflowException("Cannot operate without sftp_hook or 
ssh_conn_id.")
-
-            if self.operation.lower() in (SFTPOperation.GET, 
SFTPOperation.PUT):
-                for _local_filepath, _remote_filepath in 
zip(local_filepath_array, remote_filepath_array):
-                    if self.operation.lower() == SFTPOperation.GET:
-                        local_folder = os.path.dirname(_local_filepath)
-                        if self.create_intermediate_dirs:
-                            Path(local_folder).mkdir(parents=True, 
exist_ok=True)
-                        file_msg = f"from {_remote_filepath} to 
{_local_filepath}"
-                        self.log.info("Starting to transfer %s", file_msg)
-                        if self.sftp_hook.isdir(_remote_filepath):
-                            if self.concurrency > 1:
-                                self.sftp_hook.retrieve_directory_concurrently(
-                                    _remote_filepath,
-                                    _local_filepath,
-                                    workers=self.concurrency,
-                                    prefetch=self.prefetch,
-                                )
-                            elif self.concurrency == 1:
-                                
self.sftp_hook.retrieve_directory(_remote_filepath, _local_filepath)
-                        else:
-                            self.sftp_hook.retrieve_file(_remote_filepath, 
_local_filepath)
-                    elif self.operation.lower() == SFTPOperation.PUT:
-                        remote_folder = os.path.dirname(_remote_filepath)
-                        if self.create_intermediate_dirs:
-                            self.sftp_hook.create_directory(remote_folder)
-                        file_msg = f"from {_local_filepath} to 
{_remote_filepath}"
-                        self.log.info("Starting to transfer file %s", file_msg)
-                        if os.path.isdir(_local_filepath):
-                            if self.concurrency > 1:
-                                self.sftp_hook.store_directory_concurrently(
-                                    _remote_filepath,
-                                    _local_filepath,
-                                    confirm=self.confirm,
-                                    workers=self.concurrency,
-                                )
-                            elif self.concurrency == 1:
-                                self.sftp_hook.store_directory(
-                                    _remote_filepath, _local_filepath, 
confirm=self.confirm
-                                )
-                        else:
-                            self.sftp_hook.store_file(_remote_filepath, 
_local_filepath, confirm=self.confirm)
-            elif self.operation.lower() == SFTPOperation.DELETE:
-                for _remote_filepath in remote_filepath_array:
-                    file_msg = f"{_remote_filepath}"
-                    self.log.info("Starting to delete %s", file_msg)
-                    try:
-                        if self.sftp_hook.isdir(_remote_filepath):
-                            self.sftp_hook.delete_directory(_remote_filepath, 
include_files=True)
-                        else:
-                            self.sftp_hook.delete_file(_remote_filepath)
-                    except OSError as exc:
-                        if self._is_missing_path_error(exc):
-                            self.log.warning(
-                                "Remote path %s does not exist. Skipping 
delete.", _remote_filepath
-                            )
-                            continue
-                        raise
+        if not self.sftp_hook:
+            raise AirflowException("Cannot operate without sftp_hook or 
ssh_conn_id.")
+
+        if self.deferrable:
+            from airflow.providers.sftp.triggers.sftp import 
SFTPTransferTrigger
+
+            self.defer(
+                trigger=SFTPTransferTrigger(
+                    sftp_conn_id=self.ssh_conn_id or 
SFTPHookAsync.default_conn_name,

Review Comment:
   `SFTPHookAsync.default_conn_name` is `"sftp_default"`, so when `ssh_conn_id` 
is unset this silently connects to a different server than the synchronous path 
would.
   
   The case that breaks is a Dag that supplies the hook rather than the conn id:
   
   ```python
   SFTPOperator(
       task_id="upload",
       sftp_hook=SFTPHook(ssh_conn_id="my_prod_sftp"),
       local_filepath="/tmp/data.csv",
       remote_filepath="/incoming/data.csv",
   )
   ```
   
   That works today. Add `deferrable=True` and the transfer goes to 
`sftp_default` instead — wrong host, no warning, and the task still reports 
success. Note the check three lines above has already established 
`self.sftp_hook` is set at this point, so the hook is known to exist and is 
then discarded.
   
   Both documented ways of configuring the operator need to survive the switch. 
The hook's connection id is available as `self.sftp_hook.ssh_conn_id`, so 
something along the lines of `self.ssh_conn_id or self.sftp_hook.ssh_conn_id` 
would cover it — and if a case genuinely cannot be expressed for the async 
hook, better to raise at `execute()` time than to fall back to a default 
connection.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to