apache-airflow-providers-sftp 5.0.0rc1__py3-none-any.whl → 5.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -199,55 +199,3 @@ distributed under the License is distributed on an "AS IS" BASIS,
199
199
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
200
  See the License for the specific language governing permissions and
201
201
  limitations under the License.
202
-
203
- ============================================================================
204
- APACHE AIRFLOW SUBCOMPONENTS:
205
-
206
- The Apache Airflow project contains subcomponents with separate copyright
207
- notices and license terms. Your use of the source code for the these
208
- subcomponents is subject to the terms and conditions of the following
209
- licenses.
210
-
211
-
212
- ========================================================================
213
- Third party Apache 2.0 licenses
214
- ========================================================================
215
-
216
- The following components are provided under the Apache 2.0 License.
217
- See project link for details. The text of each license is also included
218
- at 3rd-party-licenses/LICENSE-[project].txt.
219
-
220
- (ALv2 License) hue v4.3.0 (https://github.com/cloudera/hue/)
221
- (ALv2 License) jqclock v2.3.0 (https://github.com/JohnRDOrazio/jQuery-Clock-Plugin)
222
- (ALv2 License) bootstrap3-typeahead v4.0.2 (https://github.com/bassjobsen/Bootstrap-3-Typeahead)
223
- (ALv2 License) connexion v2.7.0 (https://github.com/zalando/connexion)
224
-
225
- ========================================================================
226
- MIT licenses
227
- ========================================================================
228
-
229
- The following components are provided under the MIT License. See project link for details.
230
- The text of each license is also included at 3rd-party-licenses/LICENSE-[project].txt.
231
-
232
- (MIT License) jquery v3.5.1 (https://jquery.org/license/)
233
- (MIT License) dagre-d3 v0.6.4 (https://github.com/cpettitt/dagre-d3)
234
- (MIT License) bootstrap v3.4.1 (https://github.com/twbs/bootstrap/)
235
- (MIT License) d3-tip v0.9.1 (https://github.com/Caged/d3-tip)
236
- (MIT License) dataTables v1.10.25 (https://datatables.net)
237
- (MIT License) normalize.css v3.0.2 (http://necolas.github.io/normalize.css/)
238
- (MIT License) ElasticMock v1.3.2 (https://github.com/vrcmarcos/elasticmock)
239
- (MIT License) MomentJS v2.24.0 (http://momentjs.com/)
240
- (MIT License) eonasdan-bootstrap-datetimepicker v4.17.49 (https://github.com/eonasdan/bootstrap-datetimepicker/)
241
-
242
- ========================================================================
243
- BSD 3-Clause licenses
244
- ========================================================================
245
- The following components are provided under the BSD 3-Clause license. See project links for details.
246
- The text of each license is also included at 3rd-party-licenses/LICENSE-[project].txt.
247
-
248
- (BSD 3 License) d3 v5.16.0 (https://d3js.org)
249
- (BSD 3 License) d3-shape v2.1.0 (https://github.com/d3/d3-shape)
250
- (BSD 3 License) cgroupspy 0.2.1 (https://github.com/cloudsigma/cgroupspy)
251
-
252
- ========================================================================
253
- See 3rd-party-licenses/LICENSES-ui.txt for packages used in `/airflow/www`
@@ -29,7 +29,7 @@ from airflow import __version__ as airflow_version
29
29
 
30
30
  __all__ = ["__version__"]
31
31
 
32
- __version__ = "5.0.0"
32
+ __version__ = "5.1.0"
33
33
 
34
34
  if packaging.version.parse(packaging.version.parse(airflow_version).base_version) < packaging.version.parse(
35
35
  "2.9.0"
@@ -15,8 +15,7 @@
15
15
  # specific language governing permissions and limitations
16
16
  # under the License.
17
17
 
18
- # NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE
19
- # OVERWRITTEN WHEN PREPARING PACKAGES.
18
+ # NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE OVERWRITTEN!
20
19
  #
21
20
  # IF YOU WANT TO MODIFY THIS FILE, YOU SHOULD MODIFY THE TEMPLATE
22
21
  # `get_provider_info_TEMPLATE.py.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY
@@ -28,8 +27,9 @@ def get_provider_info():
28
27
  "name": "SFTP",
29
28
  "description": "`SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__\n",
30
29
  "state": "ready",
31
- "source-date-epoch": 1734536539,
30
+ "source-date-epoch": 1739964267,
32
31
  "versions": [
32
+ "5.1.0",
33
33
  "5.0.0",
34
34
  "4.11.1",
35
35
  "4.11.0",
@@ -72,17 +72,11 @@ def get_provider_info():
72
72
  "1.1.0",
73
73
  "1.0.0",
74
74
  ],
75
- "dependencies": [
76
- "apache-airflow>=2.9.0",
77
- "apache-airflow-providers-ssh>=2.1.0",
78
- "paramiko>=2.9.0",
79
- "asyncssh>=2.12.0",
80
- ],
81
75
  "integrations": [
82
76
  {
83
77
  "integration-name": "SSH File Transfer Protocol (SFTP)",
84
78
  "external-doc-url": "https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/",
85
- "logo": "/integration-logos/sftp/SFTP.png",
79
+ "logo": "/docs/integration-logos/SFTP.png",
86
80
  "tags": ["protocol"],
87
81
  }
88
82
  ],
@@ -122,4 +116,14 @@ def get_provider_info():
122
116
  "python-modules": ["airflow.providers.sftp.triggers.sftp"],
123
117
  }
124
118
  ],
119
+ "dependencies": [
120
+ "apache-airflow>=2.9.0",
121
+ "apache-airflow-providers-ssh>=2.1.0",
122
+ "paramiko>=2.9.0",
123
+ "asyncssh>=2.12.0",
124
+ ],
125
+ "optional-dependencies": {
126
+ "common.compat": ["apache-airflow-providers-common-compat"],
127
+ "openlineage": ["apache-airflow-providers-openlineage"],
128
+ },
125
129
  }
@@ -22,19 +22,24 @@ from __future__ import annotations
22
22
  import datetime
23
23
  import os
24
24
  import stat
25
- from collections.abc import Sequence
25
+ import warnings
26
+ from collections.abc import Generator, Sequence
27
+ from contextlib import closing, contextmanager
26
28
  from fnmatch import fnmatch
29
+ from io import BytesIO
30
+ from pathlib import Path
27
31
  from typing import TYPE_CHECKING, Any, Callable
28
32
 
29
33
  import asyncssh
30
34
  from asgiref.sync import sync_to_async
31
35
 
32
- from airflow.exceptions import AirflowException
36
+ from airflow.exceptions import AirflowException, AirflowProviderDeprecationWarning
33
37
  from airflow.hooks.base import BaseHook
34
38
  from airflow.providers.ssh.hooks.ssh import SSHHook
35
39
 
36
40
  if TYPE_CHECKING:
37
- import paramiko
41
+ from paramiko.sftp_attr import SFTPAttributes
42
+ from paramiko.sftp_client import SFTPClient
38
43
 
39
44
  from airflow.models.connection import Connection
40
45
 
@@ -51,8 +56,6 @@ class SFTPHook(SSHHook):
51
56
  - In contrast with FTPHook describe_directory only returns size, type and
52
57
  modify. It doesn't return unix.owner, unix.mode, perm, unix.group and
53
58
  unique.
54
- - retrieve_file and store_file only take a local full path and not a
55
- buffer.
56
59
  - If no mode is passed to create_directory it will be created with 777
57
60
  permissions.
58
61
 
@@ -84,7 +87,22 @@ class SFTPHook(SSHHook):
84
87
  *args,
85
88
  **kwargs,
86
89
  ) -> None:
87
- self.conn: paramiko.SFTPClient | None = None
90
+ # TODO: remove support for ssh_hook when it is removed from SFTPOperator
91
+ if kwargs.get("ssh_hook") is not None:
92
+ warnings.warn(
93
+ "Parameter `ssh_hook` is deprecated and will be ignored.",
94
+ AirflowProviderDeprecationWarning,
95
+ stacklevel=2,
96
+ )
97
+
98
+ ftp_conn_id = kwargs.pop("ftp_conn_id", None)
99
+ if ftp_conn_id:
100
+ warnings.warn(
101
+ "Parameter `ftp_conn_id` is deprecated. Please use `ssh_conn_id` instead.",
102
+ AirflowProviderDeprecationWarning,
103
+ stacklevel=2,
104
+ )
105
+ ssh_conn_id = ftp_conn_id
88
106
 
89
107
  kwargs["ssh_conn_id"] = ssh_conn_id
90
108
  kwargs["host_proxy_cmd"] = host_proxy_cmd
@@ -92,17 +110,12 @@ class SFTPHook(SSHHook):
92
110
 
93
111
  super().__init__(*args, **kwargs)
94
112
 
95
- def get_conn(self) -> paramiko.SFTPClient: # type: ignore[override]
96
- """Open an SFTP connection to the remote host."""
97
- if self.conn is None:
98
- self.conn = super().get_conn().open_sftp()
99
- return self.conn
100
-
101
- def close_conn(self) -> None:
102
- """Close the SFTP connection."""
103
- if self.conn is not None:
104
- self.conn.close()
105
- self.conn = None
113
+ @contextmanager
114
+ def get_conn(self) -> Generator[SFTPClient, None, None]:
115
+ """Context manager that closes the connection after use."""
116
+ with closing(super().get_conn()) as conn:
117
+ with closing(conn.open_sftp()) as sftp:
118
+ yield sftp
106
119
 
107
120
  def describe_directory(self, path: str) -> dict[str, dict[str, str | int | None]]:
108
121
  """
@@ -113,17 +126,17 @@ class SFTPHook(SSHHook):
113
126
 
114
127
  :param path: full path to the remote directory
115
128
  """
116
- conn = self.get_conn()
117
- flist = sorted(conn.listdir_attr(path), key=lambda x: x.filename)
118
- files = {}
119
- for f in flist:
120
- modify = datetime.datetime.fromtimestamp(f.st_mtime).strftime("%Y%m%d%H%M%S") # type: ignore
121
- files[f.filename] = {
122
- "size": f.st_size,
123
- "type": "dir" if stat.S_ISDIR(f.st_mode) else "file", # type: ignore
124
- "modify": modify,
125
- }
126
- return files
129
+ with self.get_conn() as conn: # type: SFTPClient
130
+ flist = sorted(conn.listdir_attr(path), key=lambda x: x.filename)
131
+ files = {}
132
+ for f in flist:
133
+ modify = datetime.datetime.fromtimestamp(f.st_mtime).strftime("%Y%m%d%H%M%S") # type: ignore
134
+ files[f.filename] = {
135
+ "size": f.st_size,
136
+ "type": "dir" if stat.S_ISDIR(f.st_mode) else "file", # type: ignore
137
+ "modify": modify,
138
+ }
139
+ return files
127
140
 
128
141
  def list_directory(self, path: str) -> list[str]:
129
142
  """
@@ -131,18 +144,17 @@ class SFTPHook(SSHHook):
131
144
 
132
145
  :param path: full path to the remote directory to list
133
146
  """
134
- conn = self.get_conn()
135
- files = sorted(conn.listdir(path))
136
- return files
147
+ with self.get_conn() as conn:
148
+ return sorted(conn.listdir(path))
137
149
 
138
- def list_directory_with_attr(self, path: str) -> list[paramiko.SFTPAttributes]:
150
+ def list_directory_with_attr(self, path: str) -> list[SFTPAttributes]:
139
151
  """
140
152
  List files in a directory on the remote system including their SFTPAttributes.
141
153
 
142
154
  :param path: full path to the remote directory to list
143
155
  """
144
- conn = self.get_conn()
145
- return [file for file in conn.listdir_attr(path)]
156
+ with self.get_conn() as conn:
157
+ return [file for file in conn.listdir_attr(path)]
146
158
 
147
159
  def mkdir(self, path: str, mode: int = 0o777) -> None:
148
160
  """
@@ -154,8 +166,8 @@ class SFTPHook(SSHHook):
154
166
  :param path: full path to the remote directory to create
155
167
  :param mode: int permissions of octal mode for directory
156
168
  """
157
- conn = self.get_conn()
158
- conn.mkdir(path, mode=mode)
169
+ with self.get_conn() as conn:
170
+ conn.mkdir(path, mode=mode)
159
171
 
160
172
  def isdir(self, path: str) -> bool:
161
173
  """
@@ -163,12 +175,11 @@ class SFTPHook(SSHHook):
163
175
 
164
176
  :param path: full path to the remote directory to check
165
177
  """
166
- conn = self.get_conn()
167
- try:
168
- result = stat.S_ISDIR(conn.stat(path).st_mode) # type: ignore
169
- except OSError:
170
- result = False
171
- return result
178
+ with self.get_conn() as conn:
179
+ try:
180
+ return stat.S_ISDIR(conn.stat(path).st_mode) # type: ignore
181
+ except OSError:
182
+ return False
172
183
 
173
184
  def isfile(self, path: str) -> bool:
174
185
  """
@@ -176,12 +187,11 @@ class SFTPHook(SSHHook):
176
187
 
177
188
  :param path: full path to the remote file to check
178
189
  """
179
- conn = self.get_conn()
180
- try:
181
- result = stat.S_ISREG(conn.stat(path).st_mode) # type: ignore
182
- except OSError:
183
- result = False
184
- return result
190
+ with self.get_conn() as conn:
191
+ try:
192
+ return stat.S_ISREG(conn.stat(path).st_mode) # type: ignore
193
+ except OSError:
194
+ return False
185
195
 
186
196
  def create_directory(self, path: str, mode: int = 0o777) -> None:
187
197
  """
@@ -195,7 +205,6 @@ class SFTPHook(SSHHook):
195
205
  :param path: full path to the remote directory to create
196
206
  :param mode: int permissions of octal mode for directory
197
207
  """
198
- conn = self.get_conn()
199
208
  if self.isdir(path):
200
209
  self.log.info("%s already exists", path)
201
210
  return
@@ -207,16 +216,28 @@ class SFTPHook(SSHHook):
207
216
  self.create_directory(dirname, mode)
208
217
  if basename:
209
218
  self.log.info("Creating %s", path)
210
- conn.mkdir(path, mode=mode)
219
+ with self.get_conn() as conn:
220
+ conn.mkdir(path, mode=mode)
211
221
 
212
- def delete_directory(self, path: str) -> None:
222
+ def delete_directory(self, path: str, include_files: bool = False) -> None:
213
223
  """
214
224
  Delete a directory on the remote system.
215
225
 
216
226
  :param path: full path to the remote directory to delete
217
227
  """
218
- conn = self.get_conn()
219
- conn.rmdir(path)
228
+ files: list[str] = []
229
+ dirs: list[str] = []
230
+
231
+ if include_files is True:
232
+ files, dirs, _ = self.get_tree_map(path)
233
+ dirs = dirs[::-1] # reverse the order for deleting deepest directories first
234
+
235
+ with self.get_conn() as conn:
236
+ for file_path in files:
237
+ conn.remove(file_path)
238
+ for dir_path in dirs:
239
+ conn.rmdir(dir_path)
240
+ conn.rmdir(path)
220
241
 
221
242
  def retrieve_file(self, remote_full_path: str, local_full_path: str, prefetch: bool = True) -> None:
222
243
  """
@@ -226,11 +247,14 @@ class SFTPHook(SSHHook):
226
247
  at that location.
227
248
 
228
249
  :param remote_full_path: full path to the remote file
229
- :param local_full_path: full path to the local file
250
+ :param local_full_path: full path to the local file or a file-like buffer
230
251
  :param prefetch: controls whether prefetch is performed (default: True)
231
252
  """
232
- conn = self.get_conn()
233
- conn.get(remote_full_path, local_full_path, prefetch=prefetch)
253
+ with self.get_conn() as conn:
254
+ if isinstance(local_full_path, BytesIO):
255
+ conn.getfo(remote_full_path, local_full_path, prefetch=prefetch)
256
+ else:
257
+ conn.get(remote_full_path, local_full_path, prefetch=prefetch)
234
258
 
235
259
  def store_file(self, remote_full_path: str, local_full_path: str, confirm: bool = True) -> None:
236
260
  """
@@ -240,10 +264,13 @@ class SFTPHook(SSHHook):
240
264
  from that location.
241
265
 
242
266
  :param remote_full_path: full path to the remote file
243
- :param local_full_path: full path to the local file
267
+ :param local_full_path: full path to the local file or a file-like buffer
244
268
  """
245
- conn = self.get_conn()
246
- conn.put(local_full_path, remote_full_path, confirm=confirm)
269
+ with self.get_conn() as conn:
270
+ if isinstance(local_full_path, BytesIO):
271
+ conn.putfo(local_full_path, remote_full_path, confirm=confirm)
272
+ else:
273
+ conn.put(local_full_path, remote_full_path, confirm=confirm)
247
274
 
248
275
  def delete_file(self, path: str) -> None:
249
276
  """
@@ -251,8 +278,53 @@ class SFTPHook(SSHHook):
251
278
 
252
279
  :param path: full path to the remote file
253
280
  """
254
- conn = self.get_conn()
255
- conn.remove(path)
281
+ with self.get_conn() as conn:
282
+ conn.remove(path)
283
+
284
+ def retrieve_directory(self, remote_full_path: str, local_full_path: str, prefetch: bool = True) -> None:
285
+ """
286
+ Transfer the remote directory to a local location.
287
+
288
+ If local_full_path is a string path, the directory will be put
289
+ at that location.
290
+
291
+ :param remote_full_path: full path to the remote directory
292
+ :param local_full_path: full path to the local directory
293
+ :param prefetch: controls whether prefetch is performed (default: True)
294
+ """
295
+ if Path(local_full_path).exists():
296
+ raise AirflowException(f"{local_full_path} already exists")
297
+ Path(local_full_path).mkdir(parents=True)
298
+ files, dirs, _ = self.get_tree_map(remote_full_path)
299
+ for dir_path in dirs:
300
+ new_local_path = os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
301
+ Path(new_local_path).mkdir(parents=True, exist_ok=True)
302
+ for file_path in files:
303
+ new_local_path = os.path.join(local_full_path, os.path.relpath(file_path, remote_full_path))
304
+ self.retrieve_file(file_path, new_local_path, prefetch)
305
+
306
+ def store_directory(self, remote_full_path: str, local_full_path: str, confirm: bool = True) -> None:
307
+ """
308
+ Transfer a local directory to the remote location.
309
+
310
+ If local_full_path is a string path, the directory will be read
311
+ from that location.
312
+
313
+ :param remote_full_path: full path to the remote directory
314
+ :param local_full_path: full path to the local directory
315
+ """
316
+ if self.path_exists(remote_full_path):
317
+ raise AirflowException(f"{remote_full_path} already exists")
318
+ self.create_directory(remote_full_path)
319
+ for root, dirs, files in os.walk(local_full_path):
320
+ for dir_name in dirs:
321
+ dir_path = os.path.join(root, dir_name)
322
+ new_remote_path = os.path.join(remote_full_path, os.path.relpath(dir_path, local_full_path))
323
+ self.create_directory(new_remote_path)
324
+ for file_name in files:
325
+ file_path = os.path.join(root, file_name)
326
+ new_remote_path = os.path.join(remote_full_path, os.path.relpath(file_path, local_full_path))
327
+ self.store_file(new_remote_path, file_path, confirm)
256
328
 
257
329
  def get_mod_time(self, path: str) -> str:
258
330
  """
@@ -260,9 +332,9 @@ class SFTPHook(SSHHook):
260
332
 
261
333
  :param path: full path to the remote file
262
334
  """
263
- conn = self.get_conn()
264
- ftp_mdtm = conn.stat(path).st_mtime
265
- return datetime.datetime.fromtimestamp(ftp_mdtm).strftime("%Y%m%d%H%M%S") # type: ignore
335
+ with self.get_conn() as conn:
336
+ ftp_mdtm = conn.stat(path).st_mtime
337
+ return datetime.datetime.fromtimestamp(ftp_mdtm).strftime("%Y%m%d%H%M%S") # type: ignore
266
338
 
267
339
  def path_exists(self, path: str) -> bool:
268
340
  """
@@ -270,12 +342,12 @@ class SFTPHook(SSHHook):
270
342
 
271
343
  :param path: full path to the remote file or directory
272
344
  """
273
- conn = self.get_conn()
274
- try:
275
- conn.stat(path)
276
- except OSError:
277
- return False
278
- return True
345
+ with self.get_conn() as conn:
346
+ try:
347
+ conn.stat(path)
348
+ except OSError:
349
+ return False
350
+ return True
279
351
 
280
352
  @staticmethod
281
353
  def _is_path_match(path: str, prefix: str | None = None, delimiter: str | None = None) -> bool:
@@ -369,9 +441,9 @@ class SFTPHook(SSHHook):
369
441
  def test_connection(self) -> tuple[bool, str]:
370
442
  """Test the SFTP connection by calling path with directory."""
371
443
  try:
372
- conn = self.get_conn()
373
- conn.normalize(".")
374
- return True, "Connection successfully tested"
444
+ with self.get_conn() as conn:
445
+ conn.normalize(".")
446
+ return True, "Connection successfully tested"
375
447
  except Exception as e:
376
448
  return False, str(e)
377
449
 
@@ -386,7 +458,6 @@ class SFTPHook(SSHHook):
386
458
  for file in self.list_directory(path):
387
459
  if fnmatch(file, fnmatch_pattern):
388
460
  return file
389
-
390
461
  return ""
391
462
 
392
463
  def get_files_by_pattern(self, path, fnmatch_pattern) -> list[str]:
@@ -554,17 +625,13 @@ class SFTPHookAsync(BaseHook):
554
625
 
555
626
  :param path: full path to the remote file
556
627
  """
557
- ssh_conn = None
558
- try:
559
- ssh_conn = await self._get_conn()
560
- sftp_client = await ssh_conn.start_sftp_client()
561
- ftp_mdtm = await sftp_client.stat(path)
562
- modified_time = ftp_mdtm.mtime
563
- mod_time = datetime.datetime.fromtimestamp(modified_time).strftime("%Y%m%d%H%M%S") # type: ignore[arg-type]
564
- self.log.info("Found File %s last modified: %s", str(path), str(mod_time))
565
- return mod_time
566
- except asyncssh.SFTPNoSuchFile:
567
- raise AirflowException("No files matching")
568
- finally:
569
- if ssh_conn:
570
- ssh_conn.close()
628
+ async with await self._get_conn() as ssh_conn:
629
+ try:
630
+ sftp_client = await ssh_conn.start_sftp_client()
631
+ ftp_mdtm = await sftp_client.stat(path)
632
+ modified_time = ftp_mdtm.mtime
633
+ mod_time = datetime.datetime.fromtimestamp(modified_time).strftime("%Y%m%d%H%M%S") # type: ignore[arg-type]
634
+ self.log.info("Found File %s last modified: %s", str(path), str(mod_time))
635
+ return mod_time
636
+ except asyncssh.SFTPNoSuchFile:
637
+ raise AirflowException("No files matching")
@@ -37,6 +37,7 @@ class SFTPOperation:
37
37
 
38
38
  PUT = "put"
39
39
  GET = "get"
40
+ DELETE = "delete"
40
41
 
41
42
 
42
43
  class SFTPOperator(BaseOperator):
@@ -53,8 +54,8 @@ class SFTPOperator(BaseOperator):
53
54
  Nullable. If provided, it will replace the `remote_host` which was
54
55
  defined in `sftp_hook` or predefined in the connection of `ssh_conn_id`.
55
56
  :param local_filepath: local file path or list of local file paths to get or put. (templated)
56
- :param remote_filepath: remote file path or list of remote file paths to get or put. (templated)
57
- :param operation: specify operation 'get' or 'put', defaults to put
57
+ :param remote_filepath: remote file path or list of remote file paths to get, put, or delete. (templated)
58
+ :param operation: specify operation 'get', 'put', or 'delete', defaults to put
58
59
  :param confirm: specify if the SFTP operation should be confirmed, defaults to True
59
60
  :param create_intermediate_dirs: create missing intermediate directories when
60
61
  copying from remote to local and vice-versa. Default is False.
@@ -84,7 +85,7 @@ class SFTPOperator(BaseOperator):
84
85
  sftp_hook: SFTPHook | None = None,
85
86
  ssh_conn_id: str | None = None,
86
87
  remote_host: str | None = None,
87
- local_filepath: str | list[str],
88
+ local_filepath: str | list[str] | None = None,
88
89
  remote_filepath: str | list[str],
89
90
  operation: str = SFTPOperation.PUT,
90
91
  confirm: bool = True,
@@ -102,7 +103,9 @@ class SFTPOperator(BaseOperator):
102
103
  self.remote_filepath = remote_filepath
103
104
 
104
105
  def execute(self, context: Any) -> str | list[str] | None:
105
- if isinstance(self.local_filepath, str):
106
+ if self.local_filepath is None:
107
+ local_filepath_array = []
108
+ elif isinstance(self.local_filepath, str):
106
109
  local_filepath_array = [self.local_filepath]
107
110
  else:
108
111
  local_filepath_array = self.local_filepath
@@ -112,16 +115,21 @@ class SFTPOperator(BaseOperator):
112
115
  else:
113
116
  remote_filepath_array = self.remote_filepath
114
117
 
115
- if len(local_filepath_array) != len(remote_filepath_array):
118
+ if self.operation.lower() in (SFTPOperation.GET, SFTPOperation.PUT) and len(
119
+ local_filepath_array
120
+ ) != len(remote_filepath_array):
116
121
  raise ValueError(
117
122
  f"{len(local_filepath_array)} paths in local_filepath "
118
123
  f"!= {len(remote_filepath_array)} paths in remote_filepath"
119
124
  )
120
125
 
121
- if self.operation.lower() not in (SFTPOperation.GET, SFTPOperation.PUT):
126
+ if self.operation.lower() == SFTPOperation.DELETE and local_filepath_array:
127
+ raise ValueError("local_filepath should not be provided for delete operation")
128
+
129
+ if self.operation.lower() not in (SFTPOperation.GET, SFTPOperation.PUT, SFTPOperation.DELETE):
122
130
  raise TypeError(
123
131
  f"Unsupported operation value {self.operation}, "
124
- f"expected {SFTPOperation.GET} or {SFTPOperation.PUT}."
132
+ f"expected {SFTPOperation.GET} or {SFTPOperation.PUT} or {SFTPOperation.DELETE}."
125
133
  )
126
134
 
127
135
  file_msg = None
@@ -144,24 +152,43 @@ class SFTPOperator(BaseOperator):
144
152
  )
145
153
  self.sftp_hook.remote_host = self.remote_host
146
154
 
147
- for _local_filepath, _remote_filepath in zip(local_filepath_array, remote_filepath_array):
148
- if self.operation.lower() == SFTPOperation.GET:
149
- local_folder = os.path.dirname(_local_filepath)
150
- if self.create_intermediate_dirs:
151
- Path(local_folder).mkdir(parents=True, exist_ok=True)
152
- file_msg = f"from {_remote_filepath} to {_local_filepath}"
153
- self.log.info("Starting to transfer %s", file_msg)
154
- self.sftp_hook.retrieve_file(_remote_filepath, _local_filepath)
155
- else:
156
- remote_folder = os.path.dirname(_remote_filepath)
157
- if self.create_intermediate_dirs:
158
- self.sftp_hook.create_directory(remote_folder)
159
- file_msg = f"from {_local_filepath} to {_remote_filepath}"
160
- self.log.info("Starting to transfer file %s", file_msg)
161
- self.sftp_hook.store_file(_remote_filepath, _local_filepath, confirm=self.confirm)
155
+ if self.operation.lower() in (SFTPOperation.GET, SFTPOperation.PUT):
156
+ for _local_filepath, _remote_filepath in zip(local_filepath_array, remote_filepath_array):
157
+ if self.operation.lower() == SFTPOperation.GET:
158
+ local_folder = os.path.dirname(_local_filepath)
159
+ if self.create_intermediate_dirs:
160
+ Path(local_folder).mkdir(parents=True, exist_ok=True)
161
+ file_msg = f"from {_remote_filepath} to {_local_filepath}"
162
+ self.log.info("Starting to transfer %s", file_msg)
163
+ if self.sftp_hook.isdir(_remote_filepath):
164
+ self.sftp_hook.retrieve_directory(_remote_filepath, _local_filepath)
165
+ else:
166
+ self.sftp_hook.retrieve_file(_remote_filepath, _local_filepath)
167
+ elif self.operation.lower() == SFTPOperation.PUT:
168
+ remote_folder = os.path.dirname(_remote_filepath)
169
+ if self.create_intermediate_dirs:
170
+ self.sftp_hook.create_directory(remote_folder)
171
+ file_msg = f"from {_local_filepath} to {_remote_filepath}"
172
+ self.log.info("Starting to transfer file %s", file_msg)
173
+ if os.path.isdir(_local_filepath):
174
+ self.sftp_hook.store_directory(
175
+ _remote_filepath, _local_filepath, confirm=self.confirm
176
+ )
177
+ else:
178
+ self.sftp_hook.store_file(_remote_filepath, _local_filepath, confirm=self.confirm)
179
+ elif self.operation.lower() == SFTPOperation.DELETE:
180
+ for _remote_filepath in remote_filepath_array:
181
+ file_msg = f"{_remote_filepath}"
182
+ self.log.info("Starting to delete %s", file_msg)
183
+ if self.sftp_hook.isdir(_remote_filepath):
184
+ self.sftp_hook.delete_directory(_remote_filepath, include_files=True)
185
+ else:
186
+ self.sftp_hook.delete_file(_remote_filepath)
162
187
 
163
188
  except Exception as e:
164
- raise AirflowException(f"Error while transferring {file_msg}, error: {e}")
189
+ raise AirflowException(
190
+ f"Error while processing {self.operation.upper()} operation {file_msg}, error: {e}"
191
+ )
165
192
 
166
193
  return self.local_filepath
167
194
 
@@ -34,7 +34,11 @@ from airflow.sensors.base import BaseSensorOperator, PokeReturnValue
34
34
  from airflow.utils.timezone import convert_to_utc, parse
35
35
 
36
36
  if TYPE_CHECKING:
37
- from airflow.utils.context import Context
37
+ try:
38
+ from airflow.sdk.definitions.context import Context
39
+ except ImportError:
40
+ # TODO: Remove once provider drops support for Airflow 2
41
+ from airflow.utils.context import Context
38
42
 
39
43
 
40
44
  class SFTPSensor(BaseSensorOperator):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: apache-airflow-providers-sftp
3
- Version: 5.0.0rc1
3
+ Version: 5.1.0rc1
4
4
  Summary: Provider package apache-airflow-providers-sftp for Apache Airflow
5
5
  Keywords: airflow-provider,sftp,airflow,integration
6
6
  Author-email: Apache Software Foundation <dev@airflow.apache.org>
@@ -20,15 +20,15 @@ Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Topic :: System :: Monitoring
23
- Requires-Dist: apache-airflow-providers-ssh>=2.1.0rc0
24
23
  Requires-Dist: apache-airflow>=2.9.0rc0
25
- Requires-Dist: asyncssh>=2.12.0
24
+ Requires-Dist: apache-airflow-providers-ssh>=2.1.0rc0
26
25
  Requires-Dist: paramiko>=2.9.0
26
+ Requires-Dist: asyncssh>=2.12.0
27
27
  Requires-Dist: apache-airflow-providers-common-compat ; extra == "common-compat"
28
28
  Requires-Dist: apache-airflow-providers-openlineage ; extra == "openlineage"
29
29
  Project-URL: Bug Tracker, https://github.com/apache/airflow/issues
30
- Project-URL: Changelog, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.0.0/changelog.html
31
- Project-URL: Documentation, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.0.0
30
+ Project-URL: Changelog, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.1.0/changelog.html
31
+ Project-URL: Documentation, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.1.0
32
32
  Project-URL: Slack Chat, https://s.apache.org/airflow-slack
33
33
  Project-URL: Source Code, https://github.com/apache/airflow
34
34
  Project-URL: Twitter, https://x.com/ApacheAirflow
@@ -37,23 +37,6 @@ Provides-Extra: common-compat
37
37
  Provides-Extra: openlineage
38
38
 
39
39
 
40
- .. Licensed to the Apache Software Foundation (ASF) under one
41
- or more contributor license agreements. See the NOTICE file
42
- distributed with this work for additional information
43
- regarding copyright ownership. The ASF licenses this file
44
- to you under the Apache License, Version 2.0 (the
45
- "License"); you may not use this file except in compliance
46
- with the License. You may obtain a copy of the License at
47
-
48
- .. http://www.apache.org/licenses/LICENSE-2.0
49
-
50
- .. Unless required by applicable law or agreed to in writing,
51
- software distributed under the License is distributed on an
52
- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
53
- KIND, either express or implied. See the License for the
54
- specific language governing permissions and limitations
55
- under the License.
56
-
57
40
  .. Licensed to the Apache Software Foundation (ASF) under one
58
41
  or more contributor license agreements. See the NOTICE file
59
42
  distributed with this work for additional information
@@ -71,8 +54,7 @@ Provides-Extra: openlineage
71
54
  specific language governing permissions and limitations
72
55
  under the License.
73
56
 
74
- .. NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE
75
- OVERWRITTEN WHEN PREPARING PACKAGES.
57
+ .. NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE OVERWRITTEN!
76
58
 
77
59
  .. IF YOU WANT TO MODIFY TEMPLATE FOR THIS FILE, YOU SHOULD MODIFY THE TEMPLATE
78
60
  `PROVIDER_README_TEMPLATE.rst.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY
@@ -80,7 +62,7 @@ Provides-Extra: openlineage
80
62
 
81
63
  Package ``apache-airflow-providers-sftp``
82
64
 
83
- Release: ``5.0.0.rc1``
65
+ Release: ``5.1.0``
84
66
 
85
67
 
86
68
  `SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__
@@ -93,7 +75,7 @@ This is a provider package for ``sftp`` provider. All classes for this provider
93
75
  are in ``airflow.providers.sftp`` python package.
94
76
 
95
77
  You can find package information and changelog for the provider
96
- in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.0.0/>`_.
78
+ in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.1.0/>`_.
97
79
 
98
80
  Installation
99
81
  ------------
@@ -138,4 +120,5 @@ Dependent package
138
120
  ================================================================================================================== =================
139
121
 
140
122
  The changelog for the provider package can be found in the
141
- `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.0.0/changelog.html>`_.
123
+ `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.1.0/changelog.html>`_.
124
+
@@ -1,18 +1,18 @@
1
- airflow/providers/sftp/LICENSE,sha256=FFb4jd2AXnOOf7XLP04pQW6jbdhG49TxlGY6fFpCV1Y,13609
2
- airflow/providers/sftp/__init__.py,sha256=Wm38mf-nXRE9u72hxrhR8mgQJoETUjZG2ZopXbKxyzc,1491
3
- airflow/providers/sftp/get_provider_info.py,sha256=Dg0sNXF8rbRlnNwB-ocuS5jNaA_4-iucP5L4BjiIIkQ,4086
1
+ airflow/providers/sftp/LICENSE,sha256=gXPVwptPlW1TJ4HSuG5OMPg-a3h43OGMkZRR1rpwfJA,10850
2
+ airflow/providers/sftp/__init__.py,sha256=cozZuS7gnm3FJTNUVcuHiETa115Qw8xdtX6-YMm8Kl8,1491
3
+ airflow/providers/sftp/get_provider_info.py,sha256=r-DSixnfFdgYqggP4wv6SGfxh5aVzrvSfGPfTRHo3AQ,4269
4
4
  airflow/providers/sftp/decorators/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
5
5
  airflow/providers/sftp/decorators/sensors/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
6
6
  airflow/providers/sftp/decorators/sensors/sftp.py,sha256=deps7xldfdLcO5mwxuZ9SJ7wFNrf2DZWMSt_KYMmGG4,2888
7
7
  airflow/providers/sftp/hooks/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
8
- airflow/providers/sftp/hooks/sftp.py,sha256=0wm1HRICLOiruosMqiUNDgf-mOe54quElpmQDVnDFkc,21124
8
+ airflow/providers/sftp/hooks/sftp.py,sha256=aDdBE7yNkh-H0SFc3Uqkx0LP4lWtu3rDqqeYwqxrvN0,24945
9
9
  airflow/providers/sftp/operators/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
10
- airflow/providers/sftp/operators/sftp.py,sha256=hsaPLGpyRBAY2l4v0Mee8rP6i060rIkHkRGT8RmxCoo,10000
10
+ airflow/providers/sftp/operators/sftp.py,sha256=skNYBFzdZUnkU_SpDf_m6HxnUuTVd5KDwJDRJnly7NE,11670
11
11
  airflow/providers/sftp/sensors/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
12
- airflow/providers/sftp/sensors/sftp.py,sha256=aaVOs_Zx5Ycu1RMYG9KAl_ZhLaFXJZwFkR8CICn7dRQ,7915
12
+ airflow/providers/sftp/sensors/sftp.py,sha256=2McTofMV0IPM_NmxzH9EwWmmeLWuQSrfF8hq6aMtmGE,8077
13
13
  airflow/providers/sftp/triggers/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
14
14
  airflow/providers/sftp/triggers/sftp.py,sha256=fSi-I5FocNQblHt4GYfGispFgOOl8XQ9Vk9ZFLcv_Sw,6182
15
- apache_airflow_providers_sftp-5.0.0rc1.dist-info/entry_points.txt,sha256=Fa1IkUHV6qnIuwLd0U7tKoklbLXXVrbB2hhG6N7Q-zo,100
16
- apache_airflow_providers_sftp-5.0.0rc1.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
17
- apache_airflow_providers_sftp-5.0.0rc1.dist-info/METADATA,sha256=hp2X4wZzGH5z2xmRLL3AwPQ52smBnanhqZPadEjE4OM,6533
18
- apache_airflow_providers_sftp-5.0.0rc1.dist-info/RECORD,,
15
+ apache_airflow_providers_sftp-5.1.0rc1.dist-info/entry_points.txt,sha256=Fa1IkUHV6qnIuwLd0U7tKoklbLXXVrbB2hhG6N7Q-zo,100
16
+ apache_airflow_providers_sftp-5.1.0rc1.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
17
+ apache_airflow_providers_sftp-5.1.0rc1.dist-info/METADATA,sha256=r3I0JViv1sqlwuHbQok8IAoFfRMsbz7kxOnK0Hwx0II,5704
18
+ apache_airflow_providers_sftp-5.1.0rc1.dist-info/RECORD,,