This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a85c7aad9f9 Import tableauserverclient lazily in TableauHook (#68971)
a85c7aad9f9 is described below
commit a85c7aad9f9c44d1d13a03f5fe60bd45ffa9157e
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri Jun 26 14:48:41 2026 -0400
Import tableauserverclient lazily in TableauHook (#68971)
tableauserverclient self-instruments via beartype's import hook on first
import,
which is slow on a cold runner (~50s observed in CI). The ProvidersManager
imports the tableau hook module only to discover provider metadata and never
instantiates the hook, yet the module-level import forced that whole cost
onto
the provider scan -- enough to hit the 60s per-test timeout on the
cold-cache
Pendulum2 special-test job.
Import tableauserverclient lazily inside the methods that actually use it,
so
importing the hook module no longer loads it at all. The provider-manager
scan
test drops from ~50s to ~10s. Hook-test patches are retargeted to
tableauserverclient.* since the names are now imported there at call time.
---
.../src/airflow/providers/tableau/hooks/tableau.py | 15 +++++-
.../tests/unit/tableau/hooks/test_tableau.py | 54 +++++++++++-----------
2 files changed, 40 insertions(+), 29 deletions(-)
diff --git a/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
b/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
index 3e399278700..9aa994da9ec 100644
--- a/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
+++ b/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
@@ -21,12 +21,15 @@ from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
-from tableauserverclient import JWTAuth, Pager, Server, TableauAuth
-
from airflow.providers.common.compat.sdk import AirflowException, BaseHook
from airflow.utils.helpers import exactly_one
+# tableauserverclient is imported lazily inside the methods that use it rather
than at module level:
+# importing it is expensive (it self-instruments via beartype's import hook on
first import), and that
+# cost would otherwise be paid whenever the ProvidersManager imports this hook
module just to discover
+# provider metadata -- which never instantiates the hook.
if TYPE_CHECKING:
+ from tableauserverclient import Pager
from tableauserverclient.server import Auth
@@ -87,6 +90,8 @@ class TableauHook(BaseHook):
self.conn = self.get_connection(self.tableau_conn_id)
self.site_id = site_id or self.conn.extra_dejson.get("site_id", "")
server_address = f"{self.conn.schema}://{self.conn.host}" if
self.conn.schema else self.conn.host
+ from tableauserverclient import Server
+
self.server = Server(server_address)
verify: Any = self.conn.extra_dejson.get("verify", True)
if isinstance(verify, str):
@@ -141,6 +146,8 @@ class TableauHook(BaseHook):
raise NotImplementedError("No Authentication method found for given
Credentials!")
def _auth_via_password(self) -> Auth.contextmgr:
+ from tableauserverclient import TableauAuth
+
tableau_auth = TableauAuth(
username=cast("str", self.conn.login),
password=cast("str", self.conn.password),
@@ -149,6 +156,8 @@ class TableauHook(BaseHook):
return self.server.auth.sign_in(tableau_auth)
def _auth_via_jwt(self) -> Auth.contextmgr:
+ from tableauserverclient import JWTAuth
+
jwt_auth = JWTAuth(jwt=self.jwt_token, site_id=self.site_id)
return self.server.auth.sign_in(jwt_auth)
@@ -162,6 +171,8 @@ class TableauHook(BaseHook):
For example: jobs or workbooks.
:return: all items by returning a Pager.
"""
+ from tableauserverclient import Pager
+
try:
resource = getattr(self.server, resource_name)
except AttributeError:
diff --git a/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
b/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
index 9d5a9a18f32..977dbf07558 100644
--- a/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
+++ b/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
@@ -122,8 +122,8 @@ class TestTableauHook:
)
)
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_auth_via_password_and_site_in_connection(self,
mock_server, mock_tableau_auth):
"""
Test get conn auth via password
@@ -138,8 +138,8 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.JWTAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.JWTAuth")
+ @patch("tableauserverclient.Server")
def test_jwt_auth(self, mock_server, mock_tableau_jwt_auth):
"""
Test get conn using JWT authentication via a token string
@@ -153,8 +153,8 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_jwt_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.JWTAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.JWTAuth")
+ @patch("tableauserverclient.Server")
def test_jwt_auth_with_ssl(self, mock_server, mock_tableau_jwt_auth):
"""
Test get conn using JWT authentication via a token string and ssl
@@ -186,7 +186,7 @@ class TestTableauHook:
):
TableauHook(tableau_conn_id="tableau_test_jwt_auth_no_token").get_conn()
- @patch("airflow.providers.tableau.hooks.tableau.JWTAuth")
+ @patch("tableauserverclient.JWTAuth")
def test_jwt_auth_with_two_tokens_provided(self, mock_tableau_jwt_auth,
create_connection_without_db):
"""
Test get conn using JWT authentication while providing both a string
token and a path
@@ -215,8 +215,8 @@ class TestTableauHook:
TableauHook(tableau_conn_id="tableau_test_jwt_file_auth_two_tokens").get_conn()
mock_tableau_jwt_auth.assert_not_called()
- @patch("airflow.providers.tableau.hooks.tableau.JWTAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.JWTAuth")
+ @patch("tableauserverclient.Server")
def test_jwt_auth_from_file(self, mock_server, mock_tableau_jwt_auth,
create_connection_without_db):
"""
Test get conn using JWT token read from file
@@ -246,7 +246,7 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_jwt_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
+ @patch("tableauserverclient.TableauAuth")
def test_both_auth(self, mock_tableau_auth):
"""
Test whether an error is thrown if both auth types are set
@@ -258,8 +258,8 @@ class TestTableauHook:
TableauHook(tableau_conn_id="tableau_test_both_auth").get_conn()
mock_tableau_auth.assert_not_called()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_ssl_cert_path(self, mock_server, mock_tableau_auth):
"""
Test get conn with SSL parameters, verify as path
@@ -280,8 +280,8 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_ssl_default(self, mock_server, mock_tableau_auth):
"""
Test get conn with default SSL parameters
@@ -300,8 +300,8 @@ class TestTableauHook:
)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_ssl_disabled(self, mock_server, mock_tableau_auth):
"""
Test get conn with default SSL disabled parameters
@@ -319,8 +319,8 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_ssl_bool_param(self, mock_server, mock_tableau_auth):
"""
Test get conn with SSL Verify parameter as bool
@@ -338,8 +338,8 @@ class TestTableauHook:
mock_server.return_value.auth.sign_in.assert_called_once_with(mock_tableau_auth.return_value)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_uses_schema_when_configured(self, mock_server,
mock_tableau_auth):
"""
Test that Server is constructed with the schema prepended to the host
when schema is configured.
@@ -348,8 +348,8 @@ class TestTableauHook:
mock_server.assert_called_once_with(f"{tableau_hook.conn.schema}://{tableau_hook.conn.host}")
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
def test_get_conn_uses_host_only_without_schema(self, mock_server,
mock_tableau_auth):
"""
Test that Server is constructed with the host only when no schema is
configured (backward compatibility).
@@ -358,9 +358,9 @@ class TestTableauHook:
mock_server.assert_called_once_with(tableau_hook.conn.host)
mock_server.return_value.auth.sign_out.assert_called_once_with()
- @patch("airflow.providers.tableau.hooks.tableau.TableauAuth")
- @patch("airflow.providers.tableau.hooks.tableau.Server")
- @patch("airflow.providers.tableau.hooks.tableau.Pager", return_value=[1,
2, 3])
+ @patch("tableauserverclient.TableauAuth")
+ @patch("tableauserverclient.Server")
+ @patch("tableauserverclient.Pager", return_value=[1, 2, 3])
def test_get_all(self, mock_pager, mock_server, mock_tableau_auth):
"""
Test get all
@@ -379,7 +379,7 @@ class TestTableauHook:
pytest.param(2, TableauJobFinishCode.CANCELED, id="CANCELED"),
],
)
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.Server")
def test_get_job_status(self, mock_tableau_server, finish_code,
expected_status):
"""
Test get job status
@@ -391,7 +391,7 @@ class TestTableauHook:
assert jobs_status == expected_status
@patch("time.sleep", return_value=None)
- @patch("airflow.providers.tableau.hooks.tableau.Server")
+ @patch("tableauserverclient.Server")
def test_wait_for_state(self, mock_tableau_server, sleep_mock):
"""
Test wait_for_state