apache-airflow-providers-sftp 5.3.4__tar.gz → 5.4.0__tar.gz

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.
Files changed (49) hide show
  1. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/PKG-INFO +7 -8
  2. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/README.rst +4 -5
  3. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/changelog.rst +24 -0
  4. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/index.rst +4 -6
  5. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/provider.yaml +2 -1
  6. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/pyproject.toml +4 -4
  7. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/__init__.py +1 -1
  8. apache_airflow_providers_sftp-5.4.0/src/airflow/providers/sftp/exceptions.py +23 -0
  9. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/hooks/sftp.py +124 -108
  10. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/sensors/sftp.py +23 -8
  11. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/hooks/test_sftp.py +2 -1
  12. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/sensors/test_sftp.py +15 -4
  13. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/.latest-doc-only-change.txt +0 -0
  14. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/commits.rst +0 -0
  15. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/conf.py +0 -0
  16. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/connections/sftp.rst +0 -0
  17. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/installing-providers-from-sources.rst +0 -0
  18. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/integration-logos/SFTP.png +0 -0
  19. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/security.rst +0 -0
  20. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/docs/sensors/sftp_sensor.rst +0 -0
  21. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/__init__.py +0 -0
  22. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/__init__.py +0 -0
  23. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/LICENSE +0 -0
  24. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/decorators/__init__.py +0 -0
  25. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/decorators/sensors/__init__.py +0 -0
  26. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/decorators/sensors/sftp.py +0 -0
  27. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/get_provider_info.py +0 -0
  28. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/hooks/__init__.py +0 -0
  29. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/operators/__init__.py +0 -0
  30. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/operators/sftp.py +0 -0
  31. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/sensors/__init__.py +0 -0
  32. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/triggers/__init__.py +0 -0
  33. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/triggers/sftp.py +0 -0
  34. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/src/airflow/providers/sftp/version_compat.py +0 -0
  35. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/conftest.py +0 -0
  36. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/system/__init__.py +0 -0
  37. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/system/sftp/__init__.py +0 -0
  38. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/system/sftp/example_sftp_sensor.py +0 -0
  39. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/__init__.py +0 -0
  40. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/__init__.py +0 -0
  41. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/decorators/__init__.py +0 -0
  42. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/decorators/sensors/__init__.py +0 -0
  43. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/decorators/sensors/test_sftp.py +0 -0
  44. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/hooks/__init__.py +0 -0
  45. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/operators/__init__.py +0 -0
  46. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/operators/test_sftp.py +0 -0
  47. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/sensors/__init__.py +0 -0
  48. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/triggers/__init__.py +0 -0
  49. {apache_airflow_providers_sftp-5.3.4 → apache_airflow_providers_sftp-5.4.0}/tests/unit/sftp/triggers/test_sftp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apache-airflow-providers-sftp
3
- Version: 5.3.4
3
+ Version: 5.4.0
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>
@@ -27,8 +27,8 @@ 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.3.4/changelog.html
31
- Project-URL: Documentation, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4
30
+ Project-URL: Changelog, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/changelog.html
31
+ Project-URL: Documentation, https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0
32
32
  Project-URL: Mastodon, https://fosstodon.org/@airflow
33
33
  Project-URL: Slack Chat, https://s.apache.org/airflow-slack
34
34
  Project-URL: Source Code, https://github.com/apache/airflow
@@ -61,9 +61,8 @@ Provides-Extra: openlineage
61
61
 
62
62
  Package ``apache-airflow-providers-sftp``
63
63
 
64
- Release: ``5.3.4``
64
+ Release: ``5.4.0``
65
65
 
66
- Release Date: ``|PypiReleaseDate|``
67
66
 
68
67
  `SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__
69
68
 
@@ -75,12 +74,12 @@ This is a provider package for ``sftp`` provider. All classes for this provider
75
74
  are in ``airflow.providers.sftp`` python package.
76
75
 
77
76
  You can find package information and changelog for the provider
78
- in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4/>`_.
77
+ in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/>`_.
79
78
 
80
79
  Installation
81
80
  ------------
82
81
 
83
- You can install this package on top of an existing Airflow 2 installation (see ``Requirements`` below
82
+ You can install this package on top of an existing Airflow installation (see ``Requirements`` below
84
83
  for the minimum Airflow version supported) via
85
84
  ``pip install apache-airflow-providers-sftp``
86
85
 
@@ -120,5 +119,5 @@ Dependent package
120
119
  ================================================================================================================== =================
121
120
 
122
121
  The changelog for the provider package can be found in the
123
- `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4/changelog.html>`_.
122
+ `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/changelog.html>`_.
124
123
 
@@ -23,9 +23,8 @@
23
23
 
24
24
  Package ``apache-airflow-providers-sftp``
25
25
 
26
- Release: ``5.3.4``
26
+ Release: ``5.4.0``
27
27
 
28
- Release Date: ``|PypiReleaseDate|``
29
28
 
30
29
  `SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__
31
30
 
@@ -37,12 +36,12 @@ This is a provider package for ``sftp`` provider. All classes for this provider
37
36
  are in ``airflow.providers.sftp`` python package.
38
37
 
39
38
  You can find package information and changelog for the provider
40
- in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4/>`_.
39
+ in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/>`_.
41
40
 
42
41
  Installation
43
42
  ------------
44
43
 
45
- You can install this package on top of an existing Airflow 2 installation (see ``Requirements`` below
44
+ You can install this package on top of an existing Airflow installation (see ``Requirements`` below
46
45
  for the minimum Airflow version supported) via
47
46
  ``pip install apache-airflow-providers-sftp``
48
47
 
@@ -82,4 +81,4 @@ Dependent package
82
81
  ================================================================================================================== =================
83
82
 
84
83
  The changelog for the provider package can be found in the
85
- `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4/changelog.html>`_.
84
+ `changelog <https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/changelog.html>`_.
@@ -27,6 +27,30 @@
27
27
  Changelog
28
28
  ---------
29
29
 
30
+ 5.4.0
31
+ .....
32
+
33
+
34
+ Release Date: ``|PypiReleaseDate|``
35
+
36
+ Features
37
+ ~~~~~~~~
38
+
39
+ * ``Feature: add optional managed connection (#52700)``
40
+ * ``Add file_pattern to template fields (#54562)``
41
+
42
+ Bug Fixes
43
+ ~~~~~~~~~
44
+
45
+ * ``Fix sftp async hoook (#54763)``
46
+
47
+ .. Below changes are excluded from the changelog. Move them to
48
+ appropriate section above if needed. Do not delete the lines(!):
49
+ * ``Switch pre-commit to prek (#54258)``
50
+
51
+ .. Review and move the new changes to one of the sections above:
52
+ * ``Fix Airflow 2 reference in README/index of providers (#55240)``
53
+
30
54
  5.3.4
31
55
  .....
32
56
 
@@ -70,9 +70,7 @@ apache-airflow-providers-sftp package
70
70
  `SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__
71
71
 
72
72
 
73
- Release: 5.3.4
74
-
75
- Release Date: ``|PypiReleaseDate|``
73
+ Release: 5.4.0
76
74
 
77
75
  Provider package
78
76
  ----------------
@@ -83,7 +81,7 @@ All classes for this package are included in the ``airflow.providers.sftp`` pyth
83
81
  Installation
84
82
  ------------
85
83
 
86
- You can install this package on top of an existing Airflow 2 installation via
84
+ You can install this package on top of an existing Airflow installation via
87
85
  ``pip install apache-airflow-providers-sftp``.
88
86
  For the minimum Airflow version supported, see ``Requirements`` below.
89
87
 
@@ -128,5 +126,5 @@ Downloading official packages
128
126
  You can download officially released packages and verify their checksums and signatures from the
129
127
  `Official Apache Download site <https://downloads.apache.org/airflow/providers/>`_
130
128
 
131
- * `The apache-airflow-providers-sftp 5.3.4 sdist package <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4.tar.gz>`_ (`asc <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4.tar.gz.asc>`__, `sha512 <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4.tar.gz.sha512>`__)
132
- * `The apache-airflow-providers-sftp 5.3.4 wheel package <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4-py3-none-any.whl>`_ (`asc <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4-py3-none-any.whl.asc>`__, `sha512 <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.3.4-py3-none-any.whl.sha512>`__)
129
+ * `The apache-airflow-providers-sftp 5.4.0 sdist package <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0.tar.gz>`_ (`asc <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0.tar.gz.asc>`__, `sha512 <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0.tar.gz.sha512>`__)
130
+ * `The apache-airflow-providers-sftp 5.4.0 wheel package <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0-py3-none-any.whl>`_ (`asc <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0-py3-none-any.whl.asc>`__, `sha512 <https://downloads.apache.org/airflow/providers/apache_airflow_providers_sftp-5.4.0-py3-none-any.whl.sha512>`__)
@@ -22,12 +22,13 @@ description: |
22
22
  `SSH File Transfer Protocol (SFTP) <https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/>`__
23
23
 
24
24
  state: ready
25
- source-date-epoch: 1754503469
25
+ source-date-epoch: 1756877505
26
26
  # Note that those versions are maintained by release manager - do not update them manually
27
27
  # with the exception of case where other provider in sources has >= new provider version.
28
28
  # In such case adding >= NEW_VERSION and bumping to NEW_VERSION in a provider have
29
29
  # to be done in the same PR
30
30
  versions:
31
+ - 5.4.0
31
32
  - 5.3.4
32
33
  - 5.3.3
33
34
  - 5.3.2
@@ -25,7 +25,7 @@ build-backend = "flit_core.buildapi"
25
25
 
26
26
  [project]
27
27
  name = "apache-airflow-providers-sftp"
28
- version = "5.3.4"
28
+ version = "5.4.0"
29
29
  description = "Provider package apache-airflow-providers-sftp for Apache Airflow"
30
30
  readme = "README.rst"
31
31
  authors = [
@@ -54,7 +54,7 @@ requires-python = ">=3.10"
54
54
 
55
55
  # The dependencies should be modified in place in the generated file.
56
56
  # Any change in the dependencies is preserved when the file is regenerated
57
- # Make sure to run ``breeze static-checks --type update-providers-dependencies --all-files``
57
+ # Make sure to run ``prek update-providers-dependencies --all-files``
58
58
  # After you modify the dependencies, and rebuild your Breeze CI image with ``breeze ci-image build``
59
59
  dependencies = [
60
60
  "apache-airflow>=2.10.0",
@@ -111,8 +111,8 @@ apache-airflow-providers-common-sql = {workspace = true}
111
111
  apache-airflow-providers-standard = {workspace = true}
112
112
 
113
113
  [project.urls]
114
- "Documentation" = "https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4"
115
- "Changelog" = "https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.3.4/changelog.html"
114
+ "Documentation" = "https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0"
115
+ "Changelog" = "https://airflow.apache.org/docs/apache-airflow-providers-sftp/5.4.0/changelog.html"
116
116
  "Bug Tracker" = "https://github.com/apache/airflow/issues"
117
117
  "Source Code" = "https://github.com/apache/airflow"
118
118
  "Slack Chat" = "https://s.apache.org/airflow-slack"
@@ -29,7 +29,7 @@ from airflow import __version__ as airflow_version
29
29
 
30
30
  __all__ = ["__version__"]
31
31
 
32
- __version__ = "5.3.4"
32
+ __version__ = "5.4.0"
33
33
 
34
34
  if packaging.version.parse(packaging.version.parse(airflow_version).base_version) < packaging.version.parse(
35
35
  "2.10.0"
@@ -0,0 +1,23 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ from airflow.exceptions import AirflowException
20
+
21
+
22
+ class ConnectionNotOpenedException(AirflowException):
23
+ """Thrown when a connection has not been opened and has been tried to be used."""
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import concurrent.futures
23
23
  import datetime
24
+ import functools
24
25
  import os
25
26
  import stat
26
27
  import warnings
@@ -34,7 +35,11 @@ from typing import IO, TYPE_CHECKING, Any, cast
34
35
  import asyncssh
35
36
  from asgiref.sync import sync_to_async
36
37
 
37
- from airflow.exceptions import AirflowException, AirflowProviderDeprecationWarning
38
+ from airflow.exceptions import (
39
+ AirflowException,
40
+ AirflowProviderDeprecationWarning,
41
+ )
42
+ from airflow.providers.sftp.exceptions import ConnectionNotOpenedException
38
43
  from airflow.providers.sftp.version_compat import BaseHook
39
44
  from airflow.providers.ssh.hooks.ssh import SSHHook
40
45
 
@@ -46,6 +51,25 @@ if TYPE_CHECKING:
46
51
  from airflow.models.connection import Connection
47
52
 
48
53
 
54
+ def handle_connection_management(func: Callable) -> Callable:
55
+ @functools.wraps(func)
56
+ def handle_connection_management_wrapper(self, *args: Any, **kwargs: dict[str, Any]) -> Any:
57
+ if not self.use_managed_conn:
58
+ if self.conn is None:
59
+ raise ConnectionNotOpenedException(
60
+ "Connection not open, use with hook.get_managed_conn() Managed Connection in order to create and open the connection"
61
+ )
62
+
63
+ return func(self, *args, **kwargs)
64
+
65
+ with self.get_managed_conn() as conn:
66
+ self.conn = conn
67
+ result = func(self, *args, **kwargs)
68
+ return result
69
+
70
+ return handle_connection_management_wrapper
71
+
72
+
49
73
  class SFTPHook(SSHHook):
50
74
  """
51
75
  Interact with SFTP.
@@ -86,10 +110,12 @@ class SFTPHook(SSHHook):
86
110
  self,
87
111
  ssh_conn_id: str | None = "sftp_default",
88
112
  host_proxy_cmd: str | None = None,
113
+ use_managed_conn: bool = True,
89
114
  *args,
90
115
  **kwargs,
91
116
  ) -> None:
92
117
  self.conn: SFTPClient | None = None
118
+ self.use_managed_conn = use_managed_conn
93
119
 
94
120
  # TODO: remove support for ssh_hook when it is removed from SFTPOperator
95
121
  if kwargs.get("ssh_hook") is not None:
@@ -155,6 +181,7 @@ class SFTPHook(SSHHook):
155
181
  """Get the number of open connections."""
156
182
  return self._conn_count
157
183
 
184
+ @handle_connection_management
158
185
  def describe_directory(self, path: str) -> dict[str, dict[str, str | int | None]]:
159
186
  """
160
187
  Get file information in a directory on the remote system.
@@ -164,36 +191,34 @@ class SFTPHook(SSHHook):
164
191
 
165
192
  :param path: full path to the remote directory
166
193
  """
167
- with self.get_managed_conn() as conn: # type: SFTPClient
168
- flist = sorted(conn.listdir_attr(path), key=lambda x: x.filename)
169
- files = {}
170
- for f in flist:
171
- modify = datetime.datetime.fromtimestamp(f.st_mtime).strftime("%Y%m%d%H%M%S") # type: ignore
172
- files[f.filename] = {
173
- "size": f.st_size,
174
- "type": "dir" if stat.S_ISDIR(f.st_mode) else "file", # type: ignore
175
- "modify": modify,
176
- }
177
- return files
194
+ return {
195
+ f.filename: {
196
+ "size": f.st_size,
197
+ "type": "dir" if stat.S_ISDIR(f.st_mode) else "file", # type: ignore[union-attr]
198
+ "modify": datetime.datetime.fromtimestamp(f.st_mtime or 0).strftime("%Y%m%d%H%M%S"),
199
+ }
200
+ for f in sorted(self.conn.listdir_attr(path), key=lambda f: f.filename) # type: ignore[union-attr]
201
+ }
178
202
 
203
+ @handle_connection_management
179
204
  def list_directory(self, path: str) -> list[str]:
180
205
  """
181
206
  List files in a directory on the remote system.
182
207
 
183
208
  :param path: full path to the remote directory to list
184
209
  """
185
- with self.get_managed_conn() as conn:
186
- return sorted(conn.listdir(path))
210
+ return sorted(self.conn.listdir(path)) # type: ignore[union-attr]
187
211
 
212
+ @handle_connection_management
188
213
  def list_directory_with_attr(self, path: str) -> list[SFTPAttributes]:
189
214
  """
190
215
  List files in a directory on the remote system including their SFTPAttributes.
191
216
 
192
217
  :param path: full path to the remote directory to list
193
218
  """
194
- with self.get_managed_conn() as conn:
195
- return [file for file in conn.listdir_attr(path)]
219
+ return [file for file in self.conn.listdir_attr(path)] # type: ignore[union-attr]
196
220
 
221
+ @handle_connection_management
197
222
  def mkdir(self, path: str, mode: int = 0o777) -> None:
198
223
  """
199
224
  Create a directory on the remote system.
@@ -204,33 +229,33 @@ class SFTPHook(SSHHook):
204
229
  :param path: full path to the remote directory to create
205
230
  :param mode: int permissions of octal mode for directory
206
231
  """
207
- with self.get_managed_conn() as conn:
208
- conn.mkdir(path, mode=mode)
232
+ return self.conn.mkdir(path, mode) # type: ignore[union-attr,return-value]
209
233
 
234
+ @handle_connection_management
210
235
  def isdir(self, path: str) -> bool:
211
236
  """
212
237
  Check if the path provided is a directory.
213
238
 
214
239
  :param path: full path to the remote directory to check
215
240
  """
216
- with self.get_managed_conn() as conn:
217
- try:
218
- return stat.S_ISDIR(conn.stat(path).st_mode) # type: ignore
219
- except OSError:
220
- return False
241
+ try:
242
+ return stat.S_ISDIR(self.conn.stat(path).st_mode) # type: ignore[union-attr,arg-type]
243
+ except OSError:
244
+ return False
221
245
 
246
+ @handle_connection_management
222
247
  def isfile(self, path: str) -> bool:
223
248
  """
224
249
  Check if the path provided is a file.
225
250
 
226
251
  :param path: full path to the remote file to check
227
252
  """
228
- with self.get_managed_conn() as conn:
229
- try:
230
- return stat.S_ISREG(conn.stat(path).st_mode) # type: ignore
231
- except OSError:
232
- return False
253
+ try:
254
+ return stat.S_ISREG(self.conn.stat(path).st_mode) # type: ignore[arg-type,union-attr]
255
+ except OSError:
256
+ return False
233
257
 
258
+ @handle_connection_management
234
259
  def create_directory(self, path: str, mode: int = 0o777) -> None:
235
260
  """
236
261
  Create a directory on the remote system.
@@ -253,9 +278,9 @@ class SFTPHook(SSHHook):
253
278
  self.create_directory(dirname, mode)
254
279
  if basename:
255
280
  self.log.info("Creating %s", path)
256
- with self.get_managed_conn() as conn:
257
- conn.mkdir(path, mode=mode)
281
+ self.conn.mkdir(path, mode=mode) # type: ignore
258
282
 
283
+ @handle_connection_management
259
284
  def delete_directory(self, path: str, include_files: bool = False) -> None:
260
285
  """
261
286
  Delete a directory on the remote system.
@@ -269,13 +294,13 @@ class SFTPHook(SSHHook):
269
294
  files, dirs, _ = self.get_tree_map(path)
270
295
  dirs = dirs[::-1] # reverse the order for deleting deepest directories first
271
296
 
272
- with self.get_managed_conn() as conn:
273
- for file_path in files:
274
- conn.remove(file_path)
275
- for dir_path in dirs:
276
- conn.rmdir(dir_path)
277
- conn.rmdir(path)
297
+ for file_path in files:
298
+ self.conn.remove(file_path) # type: ignore
299
+ for dir_path in dirs:
300
+ self.conn.rmdir(dir_path) # type: ignore
301
+ self.conn.rmdir(path) # type: ignore
278
302
 
303
+ @handle_connection_management
279
304
  def retrieve_file(self, remote_full_path: str, local_full_path: str, prefetch: bool = True) -> None:
280
305
  """
281
306
  Transfer the remote file to a local location.
@@ -287,28 +312,28 @@ class SFTPHook(SSHHook):
287
312
  :param local_full_path: full path to the local file or a file-like buffer
288
313
  :param prefetch: controls whether prefetch is performed (default: True)
289
314
  """
290
- with self.get_managed_conn() as conn:
291
- if isinstance(local_full_path, BytesIO):
292
- # It's a file-like object ( BytesIO), so use getfo().
293
- self.log.info("Using streaming download for %s", remote_full_path)
294
- conn.getfo(remote_full_path, local_full_path, prefetch=prefetch)
295
- # We use hasattr checking for 'write' for cases like google.cloud.storage.fileio.BlobWriter
296
- elif hasattr(local_full_path, "write"):
297
- self.log.info("Using streaming download for %s", remote_full_path)
298
- # We need to cast to pass pre-commit checks
299
- stream_full_path = cast("IO[bytes]", local_full_path)
300
- conn.getfo(remote_full_path, stream_full_path, prefetch=prefetch)
301
- elif isinstance(local_full_path, (str, bytes, os.PathLike)):
302
- # It's a string path, so use get().
303
- self.log.info("Using standard file download for %s", remote_full_path)
304
- conn.get(remote_full_path, local_full_path, prefetch=prefetch)
305
- # If it's neither, it's an unsupported type.
306
- else:
307
- raise TypeError(
308
- f"Unsupported type for local_full_path: {type(local_full_path)}. "
309
- "Expected a stream-like object or a path-like object."
310
- )
315
+ if isinstance(local_full_path, BytesIO):
316
+ # It's a file-like object ( BytesIO), so use getfo().
317
+ self.log.info("Using streaming download for %s", remote_full_path)
318
+ self.conn.getfo(remote_full_path, local_full_path, prefetch=prefetch)
319
+ # We use hasattr checking for 'write' for cases like google.cloud.storage.fileio.BlobWriter
320
+ elif hasattr(local_full_path, "write"):
321
+ self.log.info("Using streaming download for %s", remote_full_path)
322
+ # We need to cast to pass prek hook checks
323
+ stream_full_path = cast("IO[bytes]", local_full_path)
324
+ self.conn.getfo(remote_full_path, stream_full_path, prefetch=prefetch) # type: ignore[union-attr]
325
+ elif isinstance(local_full_path, (str, bytes, os.PathLike)):
326
+ # It's a string path, so use get().
327
+ self.log.info("Using standard file download for %s", remote_full_path)
328
+ self.conn.get(remote_full_path, local_full_path, prefetch=prefetch) # type: ignore[union-attr]
329
+ # If it's neither, it's an unsupported type.
330
+ else:
331
+ raise TypeError(
332
+ f"Unsupported type for local_full_path: {type(local_full_path)}. "
333
+ "Expected a stream-like object or a path-like object."
334
+ )
311
335
 
336
+ @handle_connection_management
312
337
  def store_file(self, remote_full_path: str, local_full_path: str, confirm: bool = True) -> None:
313
338
  """
314
339
  Transfer a local file to the remote location.
@@ -319,20 +344,19 @@ class SFTPHook(SSHHook):
319
344
  :param remote_full_path: full path to the remote file
320
345
  :param local_full_path: full path to the local file or a file-like buffer
321
346
  """
322
- with self.get_managed_conn() as conn:
323
- if isinstance(local_full_path, BytesIO):
324
- conn.putfo(local_full_path, remote_full_path, confirm=confirm)
325
- else:
326
- conn.put(local_full_path, remote_full_path, confirm=confirm)
347
+ if isinstance(local_full_path, BytesIO):
348
+ self.conn.putfo(local_full_path, remote_full_path, confirm=confirm) # type: ignore
349
+ else:
350
+ self.conn.put(local_full_path, remote_full_path, confirm=confirm) # type: ignore
327
351
 
352
+ @handle_connection_management
328
353
  def delete_file(self, path: str) -> None:
329
354
  """
330
355
  Remove a file on the server.
331
356
 
332
357
  :param path: full path to the remote file
333
358
  """
334
- with self.get_managed_conn() as conn:
335
- conn.remove(path)
359
+ self.conn.remove(path) # type: ignore[arg-type, union-attr]
336
360
 
337
361
  def retrieve_directory(self, remote_full_path: str, local_full_path: str, prefetch: bool = True) -> None:
338
362
  """
@@ -348,14 +372,13 @@ class SFTPHook(SSHHook):
348
372
  if Path(local_full_path).exists():
349
373
  raise AirflowException(f"{local_full_path} already exists")
350
374
  Path(local_full_path).mkdir(parents=True)
351
- with self.get_managed_conn():
352
- files, dirs, _ = self.get_tree_map(remote_full_path)
353
- for dir_path in dirs:
354
- new_local_path = os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
355
- Path(new_local_path).mkdir(parents=True, exist_ok=True)
356
- for file_path in files:
357
- new_local_path = os.path.join(local_full_path, os.path.relpath(file_path, remote_full_path))
358
- self.retrieve_file(file_path, new_local_path, prefetch)
375
+ files, dirs, _ = self.get_tree_map(remote_full_path)
376
+ for dir_path in dirs:
377
+ new_local_path = os.path.join(local_full_path, os.path.relpath(dir_path, remote_full_path))
378
+ Path(new_local_path).mkdir(parents=True, exist_ok=True)
379
+ for file_path in files:
380
+ new_local_path = os.path.join(local_full_path, os.path.relpath(file_path, remote_full_path))
381
+ self.retrieve_file(file_path, new_local_path, prefetch)
359
382
 
360
383
  def retrieve_directory_concurrently(
361
384
  self,
@@ -419,6 +442,7 @@ class SFTPHook(SSHHook):
419
442
  for conn in conns:
420
443
  conn.close()
421
444
 
445
+ @handle_connection_management
422
446
  def store_directory(self, remote_full_path: str, local_full_path: str, confirm: bool = True) -> None:
423
447
  """
424
448
  Transfer a local directory to the remote location.
@@ -431,21 +455,16 @@ class SFTPHook(SSHHook):
431
455
  """
432
456
  if self.path_exists(remote_full_path):
433
457
  raise AirflowException(f"{remote_full_path} already exists")
434
- with self.get_managed_conn():
435
- self.create_directory(remote_full_path)
436
- for root, dirs, files in os.walk(local_full_path):
437
- for dir_name in dirs:
438
- dir_path = os.path.join(root, dir_name)
439
- new_remote_path = os.path.join(
440
- remote_full_path, os.path.relpath(dir_path, local_full_path)
441
- )
442
- self.create_directory(new_remote_path)
443
- for file_name in files:
444
- file_path = os.path.join(root, file_name)
445
- new_remote_path = os.path.join(
446
- remote_full_path, os.path.relpath(file_path, local_full_path)
447
- )
448
- self.store_file(new_remote_path, file_path, confirm)
458
+ self.create_directory(remote_full_path)
459
+ for root, dirs, files in os.walk(local_full_path):
460
+ for dir_name in dirs:
461
+ dir_path = os.path.join(root, dir_name)
462
+ new_remote_path = os.path.join(remote_full_path, os.path.relpath(dir_path, local_full_path))
463
+ self.create_directory(new_remote_path)
464
+ for file_name in files:
465
+ file_path = os.path.join(root, file_name)
466
+ new_remote_path = os.path.join(remote_full_path, os.path.relpath(file_path, local_full_path))
467
+ self.store_file(new_remote_path, file_path, confirm)
449
468
 
450
469
  def store_directory_concurrently(
451
470
  self,
@@ -512,28 +531,28 @@ class SFTPHook(SSHHook):
512
531
  for conn in conns:
513
532
  conn.close()
514
533
 
534
+ @handle_connection_management
515
535
  def get_mod_time(self, path: str) -> str:
516
536
  """
517
537
  Get an entry's modification time.
518
538
 
519
539
  :param path: full path to the remote file
520
540
  """
521
- with self.get_managed_conn() as conn:
522
- ftp_mdtm = conn.stat(path).st_mtime
523
- return datetime.datetime.fromtimestamp(ftp_mdtm).strftime("%Y%m%d%H%M%S") # type: ignore
541
+ ftp_mdtm = self.conn.stat(path).st_mtime # type: ignore[union-attr]
542
+ return datetime.datetime.fromtimestamp(ftp_mdtm).strftime("%Y%m%d%H%M%S") # type: ignore
524
543
 
544
+ @handle_connection_management
525
545
  def path_exists(self, path: str) -> bool:
526
546
  """
527
547
  Whether a remote entity exists.
528
548
 
529
549
  :param path: full path to the remote file or directory
530
550
  """
531
- with self.get_managed_conn() as conn:
532
- try:
533
- conn.stat(path)
534
- except OSError:
535
- return False
536
- return True
551
+ try:
552
+ self.conn.stat(path) # type: ignore[union-attr]
553
+ except OSError:
554
+ return False
555
+ return True
537
556
 
538
557
  @staticmethod
539
558
  def _is_path_match(path: str, prefix: str | None = None, delimiter: str | None = None) -> bool:
@@ -718,19 +737,16 @@ class SFTPHookAsync(BaseHook):
718
737
  self.private_key = extra_options["private_key"]
719
738
 
720
739
  host_key = extra_options.get("host_key")
721
- no_host_key_check = extra_options.get("no_host_key_check")
722
-
723
- if no_host_key_check is not None:
724
- no_host_key_check = str(no_host_key_check).lower() == "true"
725
- if host_key is not None and no_host_key_check:
726
- raise ValueError("Host key check was skipped, but `host_key` value was given")
727
- if no_host_key_check:
728
- self.log.warning(
729
- "No Host Key Verification. This won't protect against Man-In-The-Middle attacks"
730
- )
731
- self.known_hosts = "none"
740
+ nhkc_raw = extra_options.get("no_host_key_check")
741
+ no_host_key_check = True if nhkc_raw is None else (str(nhkc_raw).lower() == "true")
742
+
743
+ if host_key is not None and no_host_key_check:
744
+ raise ValueError("Host key check was skipped, but `host_key` value was given")
732
745
 
733
- if host_key is not None:
746
+ if no_host_key_check:
747
+ self.log.warning("No Host Key Verification. This won't protect against Man-In-The-Middle attacks")
748
+ self.known_hosts = "none"
749
+ elif host_key is not None:
734
750
  self.known_hosts = f"{conn.host} {host_key}".encode()
735
751
 
736
752
  async def _get_conn(self) -> asyncssh.SSHClientConnection:
@@ -54,6 +54,7 @@ class SFTPSensor(BaseSensorOperator):
54
54
 
55
55
  template_fields: Sequence[str] = (
56
56
  "path",
57
+ "file_pattern",
57
58
  "newer_than",
58
59
  )
59
60
 
@@ -67,6 +68,7 @@ class SFTPSensor(BaseSensorOperator):
67
68
  python_callable: Callable | None = None,
68
69
  op_args: list | None = None,
69
70
  op_kwargs: dict[str, Any] | None = None,
71
+ use_managed_conn: bool = True,
70
72
  deferrable: bool = conf.getboolean("operators", "default_deferrable", fallback=False),
71
73
  **kwargs,
72
74
  ) -> None:
@@ -76,37 +78,37 @@ class SFTPSensor(BaseSensorOperator):
76
78
  self.hook: SFTPHook | None = None
77
79
  self.sftp_conn_id = sftp_conn_id
78
80
  self.newer_than: datetime | str | None = newer_than
81
+ self.use_managed_conn = use_managed_conn
79
82
  self.python_callable: Callable | None = python_callable
80
83
  self.op_args = op_args or []
81
84
  self.op_kwargs = op_kwargs or {}
82
85
  self.deferrable = deferrable
83
86
 
84
- def poke(self, context: Context) -> PokeReturnValue | bool:
85
- self.hook = SFTPHook(self.sftp_conn_id)
86
- self.log.info("Poking for %s, with pattern %s", self.path, self.file_pattern)
87
- files_found = []
87
+ def _get_files(self) -> list[str]:
88
+ files_from_pattern: list[str] = []
89
+ files_found: list[str] = []
88
90
 
89
91
  if self.file_pattern:
90
- files_from_pattern = self.hook.get_files_by_pattern(self.path, self.file_pattern)
92
+ files_from_pattern = self.hook.get_files_by_pattern(self.path, self.file_pattern) # type: ignore[union-attr]
91
93
  if files_from_pattern:
92
94
  actual_files_present = [
93
95
  os.path.join(self.path, file_from_pattern) for file_from_pattern in files_from_pattern
94
96
  ]
95
97
  else:
96
- return False
98
+ return files_found
97
99
  else:
98
100
  try:
99
101
  # If a file is present, it is the single element added to the actual_files_present list to be
100
102
  # processed. If the file is a directory, actual_file_present will be assigned an empty list,
101
103
  # since SFTPHook.isfile(...) returns False
102
- actual_files_present = [self.path] if self.hook.isfile(self.path) else []
104
+ actual_files_present = [self.path] if self.hook.isfile(self.path) else [] # type: ignore[union-attr]
103
105
  except Exception as e:
104
106
  raise AirflowException from e
105
107
 
106
108
  if self.newer_than:
107
109
  for actual_file_present in actual_files_present:
108
110
  try:
109
- mod_time = self.hook.get_mod_time(actual_file_present)
111
+ mod_time = self.hook.get_mod_time(actual_file_present) # type: ignore[union-attr]
110
112
  self.log.info("Found File %s last modified: %s", actual_file_present, mod_time)
111
113
  except OSError as e:
112
114
  if e.errno != SFTP_NO_SUCH_FILE:
@@ -135,6 +137,19 @@ class SFTPSensor(BaseSensorOperator):
135
137
  else:
136
138
  files_found = actual_files_present
137
139
 
140
+ return files_found
141
+
142
+ def poke(self, context: Context) -> PokeReturnValue | bool:
143
+ self.hook = SFTPHook(self.sftp_conn_id, use_managed_conn=self.use_managed_conn)
144
+
145
+ self.log.info("Poking for %s, with pattern %s", self.path, self.file_pattern)
146
+
147
+ if self.use_managed_conn:
148
+ files_found = self._get_files()
149
+ else:
150
+ with self.hook.get_managed_conn():
151
+ files_found = self._get_files()
152
+
138
153
  if not len(files_found):
139
154
  return False
140
155
 
@@ -854,7 +854,7 @@ class TestSFTPHookAsync:
854
854
  "username": "username",
855
855
  "password": "password",
856
856
  "client_keys": "~/keys/my_key",
857
- "known_hosts": "~/.ssh/known_hosts",
857
+ "known_hosts": None,
858
858
  "passphrase": "mypassphrase",
859
859
  }
860
860
 
@@ -882,6 +882,7 @@ class TestSFTPHookAsync:
882
882
  "username": "username",
883
883
  "password": "password",
884
884
  "client_keys": ["test"],
885
+ "known_hosts": None,
885
886
  "passphrase": "mypassphrase",
886
887
  }
887
888
 
@@ -97,6 +97,14 @@ class TestSFTPSensor:
97
97
  sftp_hook_mock.return_value.close_conn.assert_not_called()
98
98
  assert output
99
99
 
100
+ @patch("airflow.providers.sftp.sensors.sftp.SFTPHook")
101
+ def test_only_creating_one_connection_with_unmanaged_conn(self, sftp_hook_mock):
102
+ sftp_hook_mock.return_value.isfile.return_value = True
103
+ sftp_hook_mock.return_value.get_mod_time.return_value = "19700101000000"
104
+ sftp_sensor = SFTPSensor(task_id="test", path="path/to/whatever/test", use_managed_conn=False)
105
+ sftp_sensor.poke({})
106
+ assert sftp_hook_mock.return_value.get_managed_conn.called is True
107
+
100
108
  @patch("airflow.providers.sftp.sensors.sftp.SFTPHook")
101
109
  def test_file_not_new_enough(self, sftp_hook_mock):
102
110
  sftp_hook_mock.return_value.isfile.return_value = True
@@ -193,9 +201,12 @@ class TestSFTPSensor:
193
201
  )
194
202
  context = {"ds": "1970-01-00"}
195
203
  output = sftp_sensor.poke(context)
196
- sftp_hook_mock.return_value.get_mod_time.assert_has_calls(
197
- [mock.call("/path/to/file/text_file1.txt"), mock.call("/path/to/file/text_file2.txt")]
198
- )
204
+ assert (
205
+ [
206
+ mock.call("/path/to/file/text_file1.txt"),
207
+ mock.call("/path/to/file/text_file2.txt"),
208
+ ]
209
+ ) in sftp_hook_mock.return_value.get_mod_time.mock_calls
199
210
  sftp_hook_mock.return_value.close_conn.assert_not_called()
200
211
  assert output
201
212
 
@@ -220,7 +231,7 @@ class TestSFTPSensor:
220
231
  )
221
232
  context = {"ds": "1970-01-00"}
222
233
  output = sftp_sensor.poke(context)
223
- sftp_hook_mock.return_value.get_mod_time.assert_has_calls(
234
+ sftp_hook_mock.return_value.get_mod_time.mock_call(
224
235
  [
225
236
  mock.call("/path/to/file/text_file1.txt"),
226
237
  mock.call("/path/to/file/text_file2.txt"),