databricks-sql-connector 4.1.3__tar.gz → 4.1.5__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 (66) hide show
  1. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/CHANGELOG.md +8 -0
  2. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/PKG-INFO +2 -4
  3. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/pyproject.toml +1 -1
  4. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/__init__.py +1 -1
  5. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/auth.py +22 -6
  6. databricks_sql_connector-4.1.5/src/databricks/sql/auth/auth_utils.py +64 -0
  7. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/common.py +4 -0
  8. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/retry.py +16 -2
  9. databricks_sql_connector-4.1.5/src/databricks/sql/auth/token_federation.py +206 -0
  10. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/utils/http_client.py +4 -0
  11. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/thrift_backend.py +4 -0
  12. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/client.py +14 -0
  13. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/common/unified_http_client.py +1 -0
  14. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/utils.py +3 -0
  15. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/LICENSE +0 -0
  16. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/README.md +0 -0
  17. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/__init__.py +0 -0
  18. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/__init__.py +0 -0
  19. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/authenticators.py +0 -0
  20. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/endpoint.py +0 -0
  21. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/oauth.py +0 -0
  22. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/oauth_http_handler.py +0 -0
  23. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/auth/thrift_http_client.py +0 -0
  24. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/databricks_client.py +0 -0
  25. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/backend.py +0 -0
  26. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/models/__init__.py +0 -0
  27. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/models/base.py +0 -0
  28. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/models/requests.py +0 -0
  29. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/models/responses.py +0 -0
  30. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/queue.py +0 -0
  31. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/result_set.py +0 -0
  32. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/utils/constants.py +0 -0
  33. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/utils/conversion.py +0 -0
  34. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/utils/filters.py +0 -0
  35. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/sea/utils/normalize.py +0 -0
  36. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/types.py +0 -0
  37. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/utils/__init__.py +0 -0
  38. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/backend/utils/guid_utils.py +0 -0
  39. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/cloudfetch/download_manager.py +0 -0
  40. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/cloudfetch/downloader.py +0 -0
  41. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/common/feature_flag.py +0 -0
  42. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/common/http.py +0 -0
  43. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/common/http_utils.py +0 -0
  44. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/exc.py +0 -0
  45. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/experimental/__init__.py +0 -0
  46. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/experimental/oauth_persistence.py +0 -0
  47. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/parameters/__init__.py +0 -0
  48. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/parameters/native.py +0 -0
  49. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/parameters/py.typed +0 -0
  50. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/py.typed +0 -0
  51. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/result_set.py +0 -0
  52. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/session.py +0 -0
  53. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/latency_logger.py +0 -0
  54. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/models/endpoint_models.py +0 -0
  55. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/models/enums.py +0 -0
  56. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/models/event.py +0 -0
  57. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/models/frontend_logs.py +0 -0
  58. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/telemetry_client.py +0 -0
  59. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/telemetry/utils.py +0 -0
  60. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/TCLIService/TCLIService-remote +0 -0
  61. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/TCLIService/TCLIService.py +0 -0
  62. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/TCLIService/__init__.py +0 -0
  63. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/TCLIService/constants.py +0 -0
  64. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/TCLIService/ttypes.py +0 -0
  65. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/thrift_api/__init__.py +0 -0
  66. {databricks_sql_connector-4.1.3 → databricks_sql_connector-4.1.5}/src/databricks/sql/types.py +0 -0
@@ -1,5 +1,13 @@
1
1
  # Release History
2
2
 
3
+ # 4.1.5 (2026-03-23)
4
+ - Add _retry_server_directed_only mode for Retry-After header compliance (databricks/databricks-sql-python#757 by @sd-db)
5
+
6
+ # 4.1.4 (2025-10-15)
7
+ - Add support for Token Federation (databricks/databricks-sql-python#691 by @madhav-db)
8
+ - Add metric view support (databricks/databricks-sql-python#688 by @shivam2680)
9
+ - Increased time limit for long running queries (databricks/databricks-sql-python#686 by @jprakash-db)
10
+
3
11
  # 4.1.3 (2025-09-17)
4
12
  - Query tags integration (databricks/databricks-sql-python#663 by @sreekanth-db)
5
13
  - Add variant support (databricks/databricks-sql-python#560 by @shivam2680)
@@ -1,9 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: databricks-sql-connector
3
- Version: 4.1.3
3
+ Version: 4.1.5
4
4
  Summary: Databricks SQL Connector for Python
5
5
  License: Apache-2.0
6
- License-File: LICENSE
7
6
  Author: Databricks
8
7
  Author-email: databricks-sql-connector-maintainers@databricks.com
9
8
  Requires-Python: >=3.8.0,<4.0.0
@@ -15,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
17
16
  Classifier: Programming Language :: Python :: 3.13
18
- Classifier: Programming Language :: Python :: 3.14
19
17
  Provides-Extra: pyarrow
20
18
  Requires-Dist: lz4 (>=4.0.2,<5.0.0)
21
19
  Requires-Dist: oauthlib (>=3.1.0,<4.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "databricks-sql-connector"
3
- version = "4.1.3"
3
+ version = "4.1.5"
4
4
  description = "Databricks SQL Connector for Python"
5
5
  authors = ["Databricks <databricks-sql-connector-maintainers@databricks.com>"]
6
6
  license = "Apache-2.0"
@@ -68,7 +68,7 @@ DATETIME = DBAPITypeObject("timestamp")
68
68
  DATE = DBAPITypeObject("date")
69
69
  ROWID = DBAPITypeObject()
70
70
 
71
- __version__ = "4.1.3"
71
+ __version__ = "4.1.5"
72
72
  USER_AGENT_NAME = "PyDatabricksSqlConnector"
73
73
 
74
74
  # These two functions are pyhive legacy
@@ -8,13 +8,17 @@ from databricks.sql.auth.authenticators import (
8
8
  AzureServicePrincipalCredentialProvider,
9
9
  )
10
10
  from databricks.sql.auth.common import AuthType, ClientContext
11
+ from databricks.sql.auth.token_federation import TokenFederationProvider
11
12
 
12
13
 
13
14
  def get_auth_provider(cfg: ClientContext, http_client):
15
+ # Determine the base auth provider
16
+ base_provider: Optional[AuthProvider] = None
17
+
14
18
  if cfg.credentials_provider:
15
- return ExternalAuthProvider(cfg.credentials_provider)
19
+ base_provider = ExternalAuthProvider(cfg.credentials_provider)
16
20
  elif cfg.auth_type == AuthType.AZURE_SP_M2M.value:
17
- return ExternalAuthProvider(
21
+ base_provider = ExternalAuthProvider(
18
22
  AzureServicePrincipalCredentialProvider(
19
23
  cfg.hostname,
20
24
  cfg.azure_client_id,
@@ -29,7 +33,7 @@ def get_auth_provider(cfg: ClientContext, http_client):
29
33
  assert cfg.oauth_client_id is not None
30
34
  assert cfg.oauth_scopes is not None
31
35
 
32
- return DatabricksOAuthProvider(
36
+ base_provider = DatabricksOAuthProvider(
33
37
  cfg.hostname,
34
38
  cfg.oauth_persistence,
35
39
  cfg.oauth_redirect_port_range,
@@ -39,17 +43,17 @@ def get_auth_provider(cfg: ClientContext, http_client):
39
43
  cfg.auth_type,
40
44
  )
41
45
  elif cfg.access_token is not None:
42
- return AccessTokenAuthProvider(cfg.access_token)
46
+ base_provider = AccessTokenAuthProvider(cfg.access_token)
43
47
  elif cfg.use_cert_as_auth and cfg.tls_client_cert_file:
44
48
  # no op authenticator. authentication is performed using ssl certificate outside of headers
45
- return AuthProvider()
49
+ base_provider = AuthProvider()
46
50
  else:
47
51
  if (
48
52
  cfg.oauth_redirect_port_range is not None
49
53
  and cfg.oauth_client_id is not None
50
54
  and cfg.oauth_scopes is not None
51
55
  ):
52
- return DatabricksOAuthProvider(
56
+ base_provider = DatabricksOAuthProvider(
53
57
  cfg.hostname,
54
58
  cfg.oauth_persistence,
55
59
  cfg.oauth_redirect_port_range,
@@ -61,6 +65,17 @@ def get_auth_provider(cfg: ClientContext, http_client):
61
65
  else:
62
66
  raise RuntimeError("No valid authentication settings!")
63
67
 
68
+ # Always wrap with token federation (falls back gracefully if not needed)
69
+ if base_provider:
70
+ return TokenFederationProvider(
71
+ hostname=cfg.hostname,
72
+ external_provider=base_provider,
73
+ http_client=http_client,
74
+ identity_federation_client_id=cfg.identity_federation_client_id,
75
+ )
76
+
77
+ return base_provider
78
+
64
79
 
65
80
  PYSQL_OAUTH_SCOPES = ["sql", "offline_access"]
66
81
  PYSQL_OAUTH_CLIENT_ID = "databricks-sql-python"
@@ -114,5 +129,6 @@ def get_python_sql_connector_auth_provider(hostname: str, http_client, **kwargs)
114
129
  else redirect_port_range,
115
130
  oauth_persistence=kwargs.get("experimental_oauth_persistence"),
116
131
  credentials_provider=kwargs.get("credentials_provider"),
132
+ identity_federation_client_id=kwargs.get("identity_federation_client_id"),
117
133
  )
118
134
  return get_auth_provider(cfg, http_client)
@@ -0,0 +1,64 @@
1
+ import logging
2
+ import jwt
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional, Dict, Tuple
5
+ from urllib.parse import urlparse
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def parse_hostname(hostname: str) -> str:
11
+ """
12
+ Normalize the hostname to include scheme and trailing slash.
13
+
14
+ Args:
15
+ hostname: The hostname to normalize
16
+
17
+ Returns:
18
+ Normalized hostname with scheme and trailing slash
19
+ """
20
+ if not hostname.startswith("http://") and not hostname.startswith("https://"):
21
+ hostname = f"https://{hostname}"
22
+ if not hostname.endswith("/"):
23
+ hostname = f"{hostname}/"
24
+ return hostname
25
+
26
+
27
+ def decode_token(access_token: str) -> Optional[Dict]:
28
+ """
29
+ Decode a JWT token without verification to extract claims.
30
+
31
+ Args:
32
+ access_token: The JWT access token to decode
33
+
34
+ Returns:
35
+ Decoded token claims or None if decoding fails
36
+ """
37
+ try:
38
+ return jwt.decode(access_token, options={"verify_signature": False})
39
+ except Exception as e:
40
+ logger.debug("Failed to decode JWT token: %s", e)
41
+ return None
42
+
43
+
44
+ def is_same_host(url1: str, url2: str) -> bool:
45
+ """
46
+ Check if two URLs have the same host.
47
+
48
+ Args:
49
+ url1: First URL
50
+ url2: Second URL
51
+
52
+ Returns:
53
+ True if hosts are the same, False otherwise
54
+ """
55
+ try:
56
+ host1 = urlparse(url1).netloc
57
+ host2 = urlparse(url2).netloc
58
+ # Handle port differences (e.g., example.com vs example.com:443)
59
+ host1_without_port = host1.split(":")[0]
60
+ host2_without_port = host2.split(":")[0]
61
+ return host1_without_port == host2_without_port
62
+ except Exception as e:
63
+ logger.debug("Failed to parse URLs: %s", e)
64
+ return False
@@ -37,6 +37,7 @@ class ClientContext:
37
37
  tls_client_cert_file: Optional[str] = None,
38
38
  oauth_persistence=None,
39
39
  credentials_provider=None,
40
+ identity_federation_client_id: Optional[str] = None,
40
41
  # HTTP client configuration parameters
41
42
  ssl_options=None, # SSLOptions type
42
43
  socket_timeout: Optional[float] = None,
@@ -46,6 +47,7 @@ class ClientContext:
46
47
  retry_stop_after_attempts_duration: Optional[float] = None,
47
48
  retry_delay_default: Optional[float] = None,
48
49
  retry_dangerous_codes: Optional[List[int]] = None,
50
+ respect_server_retry_after_header: Optional[bool] = None,
49
51
  proxy_auth_method: Optional[str] = None,
50
52
  pool_connections: Optional[int] = None,
51
53
  pool_maxsize: Optional[int] = None,
@@ -65,6 +67,7 @@ class ClientContext:
65
67
  self.tls_client_cert_file = tls_client_cert_file
66
68
  self.oauth_persistence = oauth_persistence
67
69
  self.credentials_provider = credentials_provider
70
+ self.identity_federation_client_id = identity_federation_client_id
68
71
 
69
72
  # HTTP client configuration
70
73
  self.ssl_options = ssl_options
@@ -77,6 +80,7 @@ class ClientContext:
77
80
  )
78
81
  self.retry_delay_default = retry_delay_default or 5.0
79
82
  self.retry_dangerous_codes = retry_dangerous_codes or []
83
+ self.respect_server_retry_after_header = bool(respect_server_retry_after_header)
80
84
  self.proxy_auth_method = proxy_auth_method
81
85
  self.pool_connections = pool_connections or 10
82
86
  self.pool_maxsize = pool_maxsize or 20
@@ -94,6 +94,7 @@ class DatabricksRetryPolicy(Retry):
94
94
  stop_after_attempts_duration: float,
95
95
  delay_default: float,
96
96
  force_dangerous_codes: List[int],
97
+ respect_server_retry_after_header: bool = False,
97
98
  urllib3_kwargs: dict = {},
98
99
  ):
99
100
  # These values do not change from one command to the next
@@ -103,6 +104,7 @@ class DatabricksRetryPolicy(Retry):
103
104
  self.stop_after_attempts_duration = stop_after_attempts_duration
104
105
  self._delay_default = delay_default
105
106
  self.force_dangerous_codes = force_dangerous_codes
107
+ self.respect_server_retry_after_header = respect_server_retry_after_header
106
108
 
107
109
  # the urllib3 kwargs are a mix of configuration (some of which we override)
108
110
  # and counters like `total` or `connect` which may change between successive retries
@@ -202,6 +204,7 @@ class DatabricksRetryPolicy(Retry):
202
204
  stop_after_attempts_duration=self.stop_after_attempts_duration,
203
205
  delay_default=self.delay_default,
204
206
  force_dangerous_codes=self.force_dangerous_codes,
207
+ respect_server_retry_after_header=self.respect_server_retry_after_header,
205
208
  urllib3_kwargs={},
206
209
  )
207
210
 
@@ -323,7 +326,9 @@ class DatabricksRetryPolicy(Retry):
323
326
 
324
327
  return proposed_backoff
325
328
 
326
- def should_retry(self, method: str, status_code: int) -> Tuple[bool, str]:
329
+ def should_retry(
330
+ self, method: str, status_code: int, has_retry_after: bool = False
331
+ ) -> Tuple[bool, str]:
327
332
  """This method encapsulates the connector's approach to retries.
328
333
 
329
334
  We always retry a request unless one of these conditions is met:
@@ -381,6 +386,15 @@ class DatabricksRetryPolicy(Retry):
381
386
  if not self._is_method_retryable(method):
382
387
  return False, "Only POST requests are retried"
383
388
 
389
+ # When respect_server_retry_after_header is enabled, only retry when the
390
+ # server explicitly signals it's safe via a Retry-After header. This prevents
391
+ # duplicate side effects for non-idempotent operations.
392
+ if self.respect_server_retry_after_header and not has_retry_after:
393
+ return (
394
+ False,
395
+ "respect_server_retry_after_header mode: no Retry-After header present",
396
+ )
397
+
384
398
  # Request failed with 404 and was a GetOperationStatus. This is not recoverable. Don't retry.
385
399
  if status_code == 404 and self.command_type == CommandType.GET_OPERATION_STATUS:
386
400
  return (
@@ -450,7 +464,7 @@ class DatabricksRetryPolicy(Retry):
450
464
  Logs a debug message if the request will be retried
451
465
  """
452
466
 
453
- should_retry, msg = self.should_retry(method, status_code)
467
+ should_retry, msg = self.should_retry(method, status_code, has_retry_after)
454
468
 
455
469
  if should_retry:
456
470
  logger.debug(msg)
@@ -0,0 +1,206 @@
1
+ import logging
2
+ import json
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional, Dict, Tuple
5
+ from urllib.parse import urlencode
6
+
7
+ from databricks.sql.auth.authenticators import AuthProvider
8
+ from databricks.sql.auth.auth_utils import (
9
+ parse_hostname,
10
+ decode_token,
11
+ is_same_host,
12
+ )
13
+ from databricks.sql.common.http import HttpMethod
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Token:
19
+ """
20
+ Represents an OAuth token with expiration management.
21
+ """
22
+
23
+ def __init__(self, access_token: str, token_type: str = "Bearer"):
24
+ """
25
+ Initialize a token.
26
+
27
+ Args:
28
+ access_token: The access token string
29
+ token_type: The token type (default: Bearer)
30
+ """
31
+ self.access_token = access_token
32
+ self.token_type = token_type
33
+ self.expiry_time = self._calculate_expiry()
34
+
35
+ def _calculate_expiry(self) -> datetime:
36
+ """
37
+ Calculate the token expiry time from JWT claims.
38
+
39
+ Returns:
40
+ The token expiry datetime
41
+ """
42
+ decoded = decode_token(self.access_token)
43
+ if decoded and "exp" in decoded:
44
+ # Use JWT exp claim with 1 minute buffer
45
+ return datetime.fromtimestamp(decoded["exp"]) - timedelta(minutes=1)
46
+ # Default to 1 hour if no expiry info
47
+ return datetime.now() + timedelta(hours=1)
48
+
49
+ def is_expired(self) -> bool:
50
+ """
51
+ Check if the token is expired.
52
+
53
+ Returns:
54
+ True if token is expired, False otherwise
55
+ """
56
+ return datetime.now() >= self.expiry_time
57
+
58
+ def to_dict(self) -> Dict[str, str]:
59
+ """
60
+ Convert token to dictionary format.
61
+
62
+ Returns:
63
+ Dictionary with access_token and token_type
64
+ """
65
+ return {
66
+ "access_token": self.access_token,
67
+ "token_type": self.token_type,
68
+ }
69
+
70
+
71
+ class TokenFederationProvider(AuthProvider):
72
+ """
73
+ Implementation of Token Federation for Databricks SQL Python driver.
74
+
75
+ This provider exchanges third-party access tokens for Databricks in-house tokens
76
+ when the token issuer is different from the Databricks host.
77
+ """
78
+
79
+ TOKEN_EXCHANGE_ENDPOINT = "/oidc/v1/token"
80
+ TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
81
+ TOKEN_EXCHANGE_SUBJECT_TYPE = "urn:ietf:params:oauth:token-type:jwt"
82
+
83
+ def __init__(
84
+ self,
85
+ hostname: str,
86
+ external_provider: AuthProvider,
87
+ http_client,
88
+ identity_federation_client_id: Optional[str] = None,
89
+ ):
90
+ """
91
+ Initialize the Token Federation Provider.
92
+
93
+ Args:
94
+ hostname: The Databricks workspace hostname
95
+ external_provider: The external authentication provider
96
+ http_client: HTTP client for making requests (required)
97
+ identity_federation_client_id: Optional client ID for token federation
98
+ """
99
+ if not http_client:
100
+ raise ValueError("http_client is required for TokenFederationProvider")
101
+
102
+ self.hostname = parse_hostname(hostname)
103
+ self.external_provider = external_provider
104
+ self.http_client = http_client
105
+ self.identity_federation_client_id = identity_federation_client_id
106
+
107
+ self._cached_token: Optional[Token] = None
108
+ self._external_headers: Dict[str, str] = {}
109
+
110
+ def add_headers(self, request_headers: Dict[str, str]):
111
+ """Add authentication headers to the request."""
112
+
113
+ if self._cached_token and not self._cached_token.is_expired():
114
+ request_headers[
115
+ "Authorization"
116
+ ] = f"{self._cached_token.token_type} {self._cached_token.access_token}"
117
+ return
118
+
119
+ # Get the external headers first to check if we need token federation
120
+ self._external_headers = {}
121
+ self.external_provider.add_headers(self._external_headers)
122
+
123
+ # If no Authorization header from external provider, pass through all headers
124
+ if "Authorization" not in self._external_headers:
125
+ request_headers.update(self._external_headers)
126
+ return
127
+
128
+ token = self._get_token()
129
+ request_headers["Authorization"] = f"{token.token_type} {token.access_token}"
130
+
131
+ def _get_token(self) -> Token:
132
+ """Get or refresh the authentication token."""
133
+ # Check if cached token is still valid
134
+ if self._cached_token and not self._cached_token.is_expired():
135
+ return self._cached_token
136
+
137
+ # Extract token from already-fetched headers
138
+ auth_header = self._external_headers.get("Authorization", "")
139
+ token_type, access_token = self._extract_token_from_header(auth_header)
140
+
141
+ # Check if token exchange is needed
142
+ if self._should_exchange_token(access_token):
143
+ try:
144
+ token = self._exchange_token(access_token)
145
+ self._cached_token = token
146
+ return token
147
+ except Exception as e:
148
+ logger.warning("Token exchange failed, using external token: %s", e)
149
+
150
+ # Use external token directly
151
+ token = Token(access_token, token_type)
152
+ self._cached_token = token
153
+ return token
154
+
155
+ def _should_exchange_token(self, access_token: str) -> bool:
156
+ """Check if the token should be exchanged based on issuer."""
157
+ decoded = decode_token(access_token)
158
+ if not decoded:
159
+ return False
160
+
161
+ issuer = decoded.get("iss", "")
162
+ # Check if issuer host is different from Databricks host
163
+ return not is_same_host(issuer, self.hostname)
164
+
165
+ def _exchange_token(self, access_token: str) -> Token:
166
+ """Exchange the external token for a Databricks token."""
167
+ token_url = f"{self.hostname.rstrip('/')}{self.TOKEN_EXCHANGE_ENDPOINT}"
168
+
169
+ data = {
170
+ "grant_type": self.TOKEN_EXCHANGE_GRANT_TYPE,
171
+ "subject_token": access_token,
172
+ "subject_token_type": self.TOKEN_EXCHANGE_SUBJECT_TYPE,
173
+ "scope": "sql",
174
+ "return_original_token_if_authenticated": "true",
175
+ }
176
+
177
+ if self.identity_federation_client_id:
178
+ data["client_id"] = self.identity_federation_client_id
179
+
180
+ headers = {
181
+ "Content-Type": "application/x-www-form-urlencoded",
182
+ "Accept": "*/*",
183
+ }
184
+
185
+ body = urlencode(data)
186
+
187
+ response = self.http_client.request(
188
+ HttpMethod.POST, url=token_url, body=body, headers=headers
189
+ )
190
+
191
+ token_response = json.loads(response.data.decode())
192
+
193
+ return Token(
194
+ token_response["access_token"], token_response.get("token_type", "Bearer")
195
+ )
196
+
197
+ def _extract_token_from_header(self, auth_header: str) -> Tuple[str, str]:
198
+ """Extract token type and access token from Authorization header."""
199
+ if not auth_header:
200
+ raise ValueError("Authorization header is missing")
201
+
202
+ parts = auth_header.split(" ", 1)
203
+ if len(parts) != 2:
204
+ raise ValueError("Invalid Authorization header format")
205
+
206
+ return parts[0], parts[1]
@@ -90,6 +90,9 @@ class SeaHttpClient:
90
90
  )
91
91
  self._retry_delay_default = kwargs.get("_retry_delay_default", 5.0)
92
92
  self.force_dangerous_codes = kwargs.get("_retry_dangerous_codes", [])
93
+ self._respect_server_retry_after_header = kwargs.get(
94
+ "_respect_server_retry_after_header", False
95
+ )
93
96
 
94
97
  # Connection pooling settings
95
98
  self.max_connections = kwargs.get("max_connections", 10)
@@ -114,6 +117,7 @@ class SeaHttpClient:
114
117
  stop_after_attempts_duration=self._retry_stop_after_attempts_duration,
115
118
  delay_default=self._retry_delay_default,
116
119
  force_dangerous_codes=self.force_dangerous_codes,
120
+ respect_server_retry_after_header=self._respect_server_retry_after_header,
117
121
  urllib3_kwargs=urllib3_kwargs,
118
122
  )
119
123
  else:
@@ -189,6 +189,9 @@ class ThriftDatabricksClient(DatabricksClient):
189
189
  " This behaviour is deprecated and will be removed in a future release."
190
190
  )
191
191
  self.force_dangerous_codes = kwargs.get("_retry_dangerous_codes", [])
192
+ self._respect_server_retry_after_header = kwargs.get(
193
+ "_respect_server_retry_after_header", False
194
+ )
192
195
 
193
196
  additional_transport_args = {}
194
197
 
@@ -215,6 +218,7 @@ class ThriftDatabricksClient(DatabricksClient):
215
218
  stop_after_attempts_duration=self._retry_stop_after_attempts_duration,
216
219
  delay_default=self._retry_delay_default,
217
220
  force_dangerous_codes=self.force_dangerous_codes,
221
+ respect_server_retry_after_header=self._respect_server_retry_after_header,
218
222
  urllib3_kwargs=urllib3_kwargs,
219
223
  )
220
224
 
@@ -200,6 +200,12 @@ class Connection:
200
200
  STRUCT is returned as Dict[str, Any]
201
201
  ARRAY is returned as numpy.ndarray
202
202
  When False, complex types are returned as a strings. These are generally deserializable as JSON.
203
+ :param enable_metric_view_metadata: `bool`, optional (default is False)
204
+ When True, enables metric view metadata support by setting the
205
+ spark.sql.thriftserver.metadata.metricview.enabled session configuration.
206
+ This allows
207
+ 1. cursor.tables() to return METRIC_VIEW table type
208
+ 2. cursor.columns() to return "measure" column type
203
209
  """
204
210
 
205
211
  # Internal arguments in **kwargs:
@@ -248,6 +254,14 @@ class Connection:
248
254
  access_token_kv = {"access_token": access_token}
249
255
  kwargs = {**kwargs, **access_token_kv}
250
256
 
257
+ enable_metric_view_metadata = kwargs.get("enable_metric_view_metadata", False)
258
+ if enable_metric_view_metadata:
259
+ if session_configuration is None:
260
+ session_configuration = {}
261
+ session_configuration[
262
+ "spark.sql.thriftserver.metadata.metricview.enabled"
263
+ ] = "true"
264
+
251
265
  self.disable_pandas = kwargs.get("_disable_pandas", False)
252
266
  self.lz4_compression = kwargs.get("enable_query_result_lz4_compression", True)
253
267
  self.use_cloud_fetch = kwargs.get("use_cloud_fetch", True)
@@ -99,6 +99,7 @@ class UnifiedHttpClient:
99
99
  stop_after_attempts_duration=self.config.retry_stop_after_attempts_duration,
100
100
  delay_default=self.config.retry_delay_default,
101
101
  force_dangerous_codes=self.config.retry_dangerous_codes,
102
+ respect_server_retry_after_header=self.config.respect_server_retry_after_header,
102
103
  )
103
104
 
104
105
  # Initialize the required attributes that DatabricksRetryPolicy expects
@@ -919,6 +919,9 @@ def build_client_context(server_hostname: str, version: str, **kwargs):
919
919
  ),
920
920
  retry_delay_default=kwargs.get("_retry_delay_default"),
921
921
  retry_dangerous_codes=kwargs.get("_retry_dangerous_codes"),
922
+ respect_server_retry_after_header=kwargs.get(
923
+ "_respect_server_retry_after_header"
924
+ ),
922
925
  proxy_auth_method=kwargs.get("_proxy_auth_method"),
923
926
  pool_connections=kwargs.get("_pool_connections"),
924
927
  pool_maxsize=kwargs.get("_pool_maxsize"),