Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e19de03
Add deferrable mode to SFTPOperator
sunildataengineer Jun 15, 2026
5c0e3ba
Add deferrable mode to SFTPOperator
sunildataengineer Jun 22, 2026
8db46a1
Rename newsfragment to match PR #68298
sunildataengineer Jun 22, 2026
ccff67f
Address @dabla review: SFTPHookAsync.transfer() uses native async I/O…
sunildataengineer Jun 22, 2026
73eab6c
Address @dabla review: rename to SFTPOperationTrigger, single-connect…
sunildataengineer Jun 23, 2026
6a6a20e
Fix indentation error in retrieve_directory, remove stray _validate_w…
sunildataengineer Jul 1, 2026
8808582
refactor(sftp): de-duplicate transfer ops and inline SFTPOperation
sunildataengineer Jul 4, 2026
d04cc05
Remove .lower() calls on SFTPOperation enum comparisons
sunildataengineer Jul 2, 2026
a087a72
Fix spelling in SFTP hook docstring (Centralises -> Centralizes)
sunildataengineer Jul 9, 2026
60cf6bc
Fix SFTP deferrable tests for SFTPOperationTrigger and remove duplica…
sunildataengineer Jul 9, 2026
d66bb65
Fix SFTPOperation import path regression
sunildataengineer Jul 9, 2026
7b115e1
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 10, 2026
c9eb844
Resolve SFTP review blockers: remove conflicts, convert SFTPOperation…
sunildataengineer Jul 11, 2026
bbad242
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 11, 2026
9c155be
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 12, 2026
5d9ff4f
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 13, 2026
6c4c1a5
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 17, 2026
06922fe
Merge branch 'main' into sftp-deferrable-clean
sunildataengineer Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions providers/sftp/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ PIP package Version required
``apache-airflow-providers-ssh`` ``>=6.0.0``
``apache-airflow-providers-common-compat`` ``>=1.12.0``
``paramiko`` ``>=4.0.0,<5.0.0``
``asgiref`` ``>=3.8.1``
``asyncssh`` ``>=2.12.0; python_version < "3.14"``
``asyncssh`` ``>=2.22.0; python_version >= "3.14"``
========================================== ======================================
Expand Down
1 change: 1 addition & 0 deletions providers/sftp/newsfragments/68298.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add deferrable mode to ``SFTPOperator`` via ``deferrable=True`` parameter, freeing Airflow worker slots during file transfers by delegating execution to the async Triggerer.
1 change: 1 addition & 0 deletions providers/sftp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dependencies = [
"apache-airflow-providers-ssh>=6.0.0",
"apache-airflow-providers-common-compat>=1.12.0",
"paramiko>=4.0.0,<5.0.0",
"asgiref>=3.8.1",
"asyncssh>=2.12.0; python_version < '3.14'",
"asyncssh>=2.22.0; python_version >= '3.14'",
]
Expand Down
194 changes: 161 additions & 33 deletions providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import asyncio
import concurrent.futures
import datetime
import functools
Expand All @@ -28,6 +29,7 @@
import warnings
from collections.abc import Callable, Generator, Sequence
from contextlib import contextmanager, suppress
from enum import Enum
from fnmatch import fnmatch
from io import BytesIO
from pathlib import Path, PurePosixPath
Expand All @@ -51,6 +53,14 @@
CHUNK_SIZE = 64 * 1024 # 64KB


class SFTPOperation(str, Enum):
"""SFTP operation constants."""
Comment thread
sunildataengineer marked this conversation as resolved.

GET = "get"
PUT = "put"
DELETE = "delete"


def handle_connection_management(func: Callable) -> Callable:
@functools.wraps(func)
def handle_connection_management_wrapper(self, *args: Any, **kwargs: dict[str, Any]) -> Any:
Expand Down Expand Up @@ -384,25 +394,6 @@ def delete_file(self, path: str) -> None:
"""
self.conn.remove(path) # type: ignore[arg-type, union-attr]

@staticmethod
def _validate_within_directory(base_dir: str, candidate: str) -> str:
"""
Ensure ``candidate`` resolves to a path inside ``base_dir``.

Directory-entry names are returned by the remote SFTP server and may
contain ``..`` components; joining them into the local destination path
could otherwise write outside it. Containment is verified before any
local write or ``mkdir``.
"""
base_real = os.path.realpath(base_dir)
candidate_real = os.path.realpath(candidate)
if candidate_real != base_real and os.path.commonpath([base_real, candidate_real]) != base_real:
raise ValueError(
f"Refusing to write outside the destination directory: "
f"{candidate!r} resolves outside {base_dir!r}"
)
return candidate

def retrieve_directory(self, remote_full_path: str, local_full_path: str, prefetch: bool = True) -> None:
"""
Transfer the remote directory to a local location.
Expand All @@ -419,14 +410,10 @@ def retrieve_directory(self, remote_full_path: str, local_full_path: str, prefet
Path(local_full_path).mkdir(parents=True)
files, dirs, _ = self.get_tree_map(remote_full_path)
for dir_path in dirs:
new_local_path = self._validate_within_directory(
local_full_path, os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
)
new_local_path = os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
Path(new_local_path).mkdir(parents=True, exist_ok=True)
for file_path in files:
new_local_path = self._validate_within_directory(
local_full_path, os.path.join(local_full_path, os.path.relpath(file_path, remote_full_path))
)
new_local_path = os.path.join(local_full_path, os.path.relpath(file_path, remote_full_path))
self.retrieve_file(file_path, new_local_path, prefetch)

def retrieve_directory_concurrently(
Expand Down Expand Up @@ -461,18 +448,12 @@ def retrieve_file_chunk(
new_local_file_paths, remote_file_paths = [], []
files, dirs, _ = self.get_tree_map(remote_full_path)
for dir_path in dirs:
new_local_path = self._validate_within_directory(
local_full_path,
os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path)),
)
new_local_path = os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
Path(new_local_path).mkdir(parents=True, exist_ok=True)
for file in files:
remote_file_paths.append(file)
new_local_file_paths.append(
self._validate_within_directory(
local_full_path,
os.path.join(local_full_path, os.path.relpath(file, remote_full_path)),
)
os.path.join(local_full_path, os.path.relpath(file, remote_full_path))
)
remote_file_chunks = [remote_file_paths[i::workers] for i in range(workers)]
local_file_chunks = [new_local_file_paths[i::workers] for i in range(workers)]
Expand Down Expand Up @@ -625,6 +606,27 @@ def _is_path_match(path: str, prefix: str | None = None, delimiter: str | None =
return False
return True

@staticmethod
def _validate_within_directory(base: str, target: str) -> str:
"""
Validate that target path is within the base directory.

Prevents directory traversal attacks.

:param base: The base/destination directory path
:param target: The target path to validate
:return: The target path if valid
:raises ValueError: If target path escapes the base directory
"""
base_real = os.path.realpath(os.path.expanduser(base))
target_real = os.path.realpath(os.path.expanduser(target))

# Ensure target is within base directory
if not (target_real == base_real or target_real.startswith(base_real + os.sep)):
raise ValueError(f"Path {target} is outside the destination directory {base}")

return target

def walktree(
self,
path: str,
Expand Down Expand Up @@ -735,6 +737,71 @@ def get_files_by_pattern(self, path, fnmatch_pattern) -> list[str]:

return matched_files

def transfer(
Comment thread
sunildataengineer marked this conversation as resolved.
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:
self.delete_file(remote)
Comment thread
sunildataengineer marked this conversation as resolved.


class SFTPHookAsync(BaseHook):
"""
Expand Down Expand Up @@ -1078,3 +1145,64 @@ 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 transfer(
self,
operation: str,
Comment thread
sunildataengineer marked this conversation as resolved.
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):
if create_intermediate_dirs:
os.makedirs(os.path.dirname(local), exist_ok=True)
await self.retrieve_file(remote, local)

tasks = [
asyncio.create_task(_bounded(_get(local, remote)))
for local, remote in zip(local_filepath_array, remote_filepath_array)
]
await asyncio.gather(*tasks)
elif operation.lower() == SFTPOperation.PUT:

async def _put(local: str, remote: str):
await self.store_file(remote, local)

tasks = [
asyncio.create_task(_bounded(_put(local, remote)))
for local, remote in zip(local_filepath_array, remote_filepath_array)
]
await asyncio.gather(*tasks)
elif operation.lower() == SFTPOperation.DELETE:

async def _delete(remote: str):
await sftp.unlink(remote)

Comment thread
sunildataengineer marked this conversation as resolved.
tasks = [
asyncio.create_task(_bounded(_delete(remote))) for remote in remote_filepath_array
]
await asyncio.gather(*tasks)
Loading