Skip to content

Commit 7b2aafa

Browse files
Fix CLI token source --profile fallback with version detection
Replace the broken error-based --profile detection with version-based CLI detection at init time. The --profile flag is a global Cobra flag that old CLIs (< v0.207.1) silently accept instead of reporting "unknown flag: --profile", making the previous --host fallback dead code. The SDK now runs `databricks version` at init time, parses the semver, and uses it to decide between --profile and --host. If version detection fails, the SDK falls back to --host (most conservative command). This also simplifies CliTokenSource to hold a single cmd with no runtime fallback logic, and enables future version-gated flags (e.g. --force-refresh) without additional subprocess calls. See: databricks/cli#855
1 parent 78914fa commit 7b2aafa

3 files changed

Lines changed: 422 additions & 210 deletions

File tree

NEXT_CHANGELOG.md

100755100644
Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,19 @@
11
# NEXT CHANGELOG
22

3-
## Release v0.103.0
3+
## Release v0.106.0
44

55
### New Features and Improvements
6-
* Add support for unified hosts. A single configuration profile can now be used for both account-level and workspace-level operations when the host supports it and both `account_id` and `workspace_id` are available. The `experimental_is_unified_host` flag has been removed; unified host detection is now automatic.
7-
* Accept `DATABRICKS_OIDC_TOKEN_FILEPATH` environment variable for consistency with other Databricks SDKs (Go, CLI, Terraform). The previous `DATABRICKS_OIDC_TOKEN_FILE` is still supported as an alias.
86

97
### Security
108

119
### Bug Fixes
10+
* Fixed Databricks CLI `--profile` fallback by detecting the CLI version at init time. The previous error-based detection was broken because `--profile` is a global Cobra flag silently accepted by old CLIs.
1211

1312
### Documentation
1413

1514
### Breaking Changes
16-
* Drop support for Python 3.8 and 3.9. The minimum supported Python version is now 3.10, in line with the oldest supported Databricks Runtime LTS (DBR 13.3).
1715

1816
### Internal Changes
19-
* Replace the async-disabling mechanism on token refresh failure with a 1-minute retry backoff. Previously, a single failed async refresh would disable proactive token renewal until the token expired. Now, the SDK waits a short cooldown period and retries, improving resilience to transient errors.
20-
* Extract `_resolve_profile` to simplify config file loading and improve `__settings__` error messages.
17+
* Detect Databricks CLI version at init time via `databricks version`, enabling version-gated flag support (e.g. `--force-refresh`) without additional subprocess calls.
2118

2219
### API Changes
23-
* Add `create_catalog()`, `create_synced_table()`, `delete_catalog()`, `delete_synced_table()`, `get_catalog()` and `get_synced_table()` methods for [w.postgres](https://databricks-sdk-py.readthedocs.io/en/latest/workspace/postgres/postgres.html) workspace-level service.
24-
* Add `effective_file_event_queue` field for `databricks.sdk.service.catalog.CreateExternalLocation`.
25-
* Add `effective_file_event_queue` field for `databricks.sdk.service.catalog.ExternalLocationInfo`.
26-
* Add `effective_file_event_queue` field for `databricks.sdk.service.catalog.UpdateExternalLocation`.
27-
* Add `column_selection` field for `databricks.sdk.service.ml.Function`.
28-
* Add `cascade` field for `databricks.sdk.service.pipelines.DeletePipelineRequest`.
29-
* Add `default_branch` field for `databricks.sdk.service.postgres.ProjectSpec`.
30-
* Add `default_branch` field for `databricks.sdk.service.postgres.ProjectStatus`.
31-
* Add `ingress` and `ingress_dry_run` fields for `databricks.sdk.service.settings.AccountNetworkPolicy`.

databricks/sdk/credentials_provider.py

Lines changed: 199 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import abc
22
import base64
3+
import dataclasses
34
import functools
45
import io
56
import json
67
import logging
78
import os
89
import pathlib
910
import platform
11+
import re
1012
import subprocess
1113
import sys
1214
import threading
@@ -649,23 +651,103 @@ def refreshed_headers() -> Dict[str, str]:
649651
return OAuthCredentialsProvider(refreshed_headers, token)
650652

651653

654+
@dataclasses.dataclass
655+
class CliVersion:
656+
"""Semver-like version of the Databricks CLI.
657+
658+
A zero value (0, 0, 0) with empty `suffix` means version detection failed
659+
and the SDK should fall back to the most conservative command. The `suffix`
660+
field preserves any prerelease tag and build metadata (e.g. "-dev+sha") so
661+
dev builds can be identified and compared correctly against releases.
662+
"""
663+
664+
major: int = 0
665+
minor: int = 0
666+
patch: int = 0
667+
# e.g. "-dev+abcdef", "-rc.1", "+build.42", or "" for a stable release.
668+
suffix: str = ""
669+
670+
@property
671+
def is_default_dev_build(self) -> bool:
672+
"""True when this is the default Databricks CLI dev build (v0.0.0-dev...).
673+
674+
Narrowly matches the CLI's "no version injected" marker: base triple
675+
equal to the default dev build's and suffix starting with the default's
676+
"-dev" prerelease tag (build metadata like "+commit" is tolerated).
677+
678+
A version the user explicitly set (e.g. v1.0.0-dev) is intentional and
679+
is not flagged here; feature gates still apply via at_least.
680+
"""
681+
return (
682+
(self.major, self.minor, self.patch)
683+
== (_DEFAULT_DEV_BUILD.major, _DEFAULT_DEV_BUILD.minor, _DEFAULT_DEV_BUILD.patch)
684+
and self.suffix.startswith(_DEFAULT_DEV_BUILD.suffix)
685+
)
686+
687+
@property
688+
def _has_prerelease(self) -> bool:
689+
# In semver, a leading "-" introduces a prerelease tag that compares
690+
# less than the same base version without one. A leading "+" is build
691+
# metadata and does not affect ordering.
692+
return self.suffix.startswith("-")
693+
694+
def _cmp_key(self) -> Tuple[int, int, int, int]:
695+
"""Tuple-orderable key for semver-aware comparison.
696+
697+
Semver puts "X.Y.Z-anything" before "X.Y.Z" (prerelease sorts before
698+
release). We encode that by ranking prereleases as 0 and the absence
699+
of a prerelease (release or build-metadata-only) as 1 in the last
700+
slot, so normal tuple ordering gives semver ordering. Two prereleases
701+
of the same base compare as equal under this key even if their tags
702+
differ, which is a simplification suited to our use (release-valued
703+
feature-gate thresholds).
704+
"""
705+
return (self.major, self.minor, self.patch, 0 if self._has_prerelease else 1)
706+
707+
def __lt__(self, other: "CliVersion") -> bool:
708+
if not isinstance(other, CliVersion):
709+
return NotImplemented
710+
return self._cmp_key() < other._cmp_key()
711+
712+
def __le__(self, other: "CliVersion") -> bool:
713+
if not isinstance(other, CliVersion):
714+
return NotImplemented
715+
return self._cmp_key() <= other._cmp_key()
716+
717+
def __gt__(self, other: "CliVersion") -> bool:
718+
if not isinstance(other, CliVersion):
719+
return NotImplemented
720+
return self._cmp_key() > other._cmp_key()
721+
722+
def __ge__(self, other: "CliVersion") -> bool:
723+
if not isinstance(other, CliVersion):
724+
return NotImplemented
725+
return self._cmp_key() >= other._cmp_key()
726+
727+
def __str__(self) -> str:
728+
if (self.major, self.minor, self.patch) == (0, 0, 0) and not self.suffix:
729+
return "unknown"
730+
return f"v{self.major}.{self.minor}.{self.patch}{self.suffix}"
731+
732+
733+
# The Databricks CLI emits exactly "v0.0.0-dev[+commit]" when the binary was
734+
# built without injecting a version via ldflags (e.g. a plain `go build`). See
735+
# https://github.com/databricks/cli/blob/main/internal/build/info.go.
736+
_DEFAULT_DEV_BUILD = CliVersion(0, 0, 0, suffix="-dev")
737+
738+
652739
class CliTokenSource(oauth.Refreshable):
740+
653741
def __init__(
654742
self,
655743
cmd: List[str],
656744
token_type_field: str,
657745
access_token_field: str,
658746
expiry_field: str,
659747
disable_async: bool = True,
660-
fallback_cmd: Optional[List[str]] = None,
661748
):
662749
super().__init__(disable_async=disable_async)
663750
self._cmd = cmd
664-
# fallback_cmd is tried when the primary command fails with "unknown flag: --profile",
665-
# indicating the CLI is too old to support --profile. Can be removed once support
666-
# for CLI versions predating --profile is dropped.
667-
# See: https://github.com/databricks/databricks-sdk-go/pull/1497
668-
self._fallback_cmd = fallback_cmd
669751
self._token_type_field = token_type_field
670752
self._access_token_field = access_token_field
671753
self._expiry_field = expiry_field
@@ -700,16 +782,7 @@ def _exec_cli_command(self, cmd: List[str]) -> oauth.Token:
700782
raise IOError(f"cannot get access token: {message}") from e
701783

702784
def refresh(self) -> oauth.Token:
703-
try:
704-
return self._exec_cli_command(self._cmd)
705-
except IOError as e:
706-
if self._fallback_cmd is not None and "unknown flag: --profile" in str(e):
707-
logger.warning(
708-
"Databricks CLI does not support --profile flag. Falling back to --host. "
709-
"Please upgrade your CLI to the latest version."
710-
)
711-
return self._exec_cli_command(self._fallback_cmd)
712-
raise
785+
return self._exec_cli_command(self._cmd)
713786

714787

715788
def _run_subprocess(
@@ -898,16 +971,7 @@ def __init__(self, cfg: "Config"):
898971
elif cli_path.count("/") == 0:
899972
cli_path = self.__class__._find_executable(cli_path)
900973

901-
fallback_cmd = None
902-
if cfg.profile:
903-
# When profile is set, use --profile as the primary command.
904-
# The profile contains the full config (host, account_id, etc.).
905-
args = ["auth", "token", "--profile", cfg.profile]
906-
# Build a --host fallback for older CLIs that don't support --profile.
907-
if cfg.host:
908-
fallback_cmd = [cli_path, *self.__class__._build_host_args(cfg)]
909-
else:
910-
args = self.__class__._build_host_args(cfg)
974+
cmd = self.__class__._resolve_cli_command(cli_path, cfg)
911975

912976
# get_scopes() defaults to ["all-apis"] when nothing is configured, which would
913977
# cause false-positive mismatches against every token that wasn't issued with
@@ -917,12 +981,11 @@ def __init__(self, cfg: "Config"):
917981
self._host = cfg.host
918982

919983
super().__init__(
920-
cmd=[cli_path, *args],
984+
cmd=cmd,
921985
token_type_field="token_type",
922986
access_token_field="access_token",
923987
expiry_field="expiry",
924988
disable_async=cfg.disable_async_token_refresh,
925-
fallback_cmd=fallback_cmd,
926989
)
927990

928991
def refresh(self) -> oauth.Token:
@@ -980,15 +1043,116 @@ def _validate_token_scopes(self, token: oauth.Token):
9801043
f"Scopes default to all-apis."
9811044
)
9821045

1046+
# --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
1047+
_CLI_VERSION_FOR_PROFILE = CliVersion(0, 207, 1)
1048+
1049+
# Captures "vX.Y.Z" plus any prerelease/build-metadata suffix so dev
1050+
# builds like "v0.0.0-dev+abcdef" can be distinguished from detection
1051+
# failure and compared against release versions.
1052+
_CLI_VERSION_RE = re.compile(r"Databricks CLI v(\d+)\.(\d+)\.(\d+)(\S*)")
1053+
1054+
@staticmethod
1055+
def _parse_cli_version(output: str) -> CliVersion:
1056+
"""Parse 'Databricks CLI vX.Y.Z[-suffix]' output into a CliVersion.
1057+
1058+
Returns CliVersion() on failure, matching Go's behavior where an
1059+
unparseable version disables every feature gate.
1060+
"""
1061+
m = DatabricksCliTokenSource._CLI_VERSION_RE.search(output)
1062+
if not m:
1063+
logger.debug(f"Failed to parse Databricks CLI version from output: {output!r}")
1064+
return CliVersion()
1065+
return CliVersion(
1066+
int(m.group(1)),
1067+
int(m.group(2)),
1068+
int(m.group(3)),
1069+
suffix=m.group(4),
1070+
)
1071+
1072+
@staticmethod
1073+
def _get_cli_version(cli_path: str) -> CliVersion:
1074+
"""Run 'databricks version' and return the parsed CliVersion.
1075+
1076+
Returns CliVersion() on failure.
1077+
"""
1078+
try:
1079+
out = _run_subprocess([cli_path, "version"], capture_output=True, check=True)
1080+
return DatabricksCliTokenSource._parse_cli_version(out.stdout.decode())
1081+
except Exception as e:
1082+
logger.warning(
1083+
f"Failed to detect Databricks CLI version: {e}. "
1084+
"Falling back to conservative flag set."
1085+
)
1086+
return CliVersion()
1087+
1088+
@staticmethod
1089+
def _resolve_cli_command(cli_path: str, cfg: "Config") -> List[str]:
1090+
"""Detect CLI version and build the auth-token command.
1091+
1092+
Raises ValueError with a precise message when no usable command can be
1093+
built (missing profile/host, or --profile-unsupported CLI with no host
1094+
fallback configured).
1095+
"""
1096+
version = DatabricksCliTokenSource._get_cli_version(cli_path)
1097+
if version.is_default_dev_build:
1098+
# A default-marker dev build has no injected version, so every
1099+
# feature gate fails via at_least. Surface an informational hint so
1100+
# users know why their feature flags aren't taking effect.
1101+
logger.info(
1102+
f"Databricks CLI {version} is a development build; feature detection will use "
1103+
"conservative fallbacks. Rebuild the CLI with an explicit version to enable "
1104+
"capability-based flag selection."
1105+
)
1106+
try:
1107+
return DatabricksCliTokenSource._build_cli_command(cli_path, cfg, version)
1108+
except ValueError as e:
1109+
raise ValueError(f"cannot configure CLI token source: {e}") from e
1110+
1111+
@staticmethod
1112+
def _build_cli_command(cli_path: str, cfg: "Config", version: CliVersion) -> List[str]:
1113+
"""Build the `auth token` command.
1114+
1115+
Falls back to --host when --profile is either not configured or not
1116+
supported by the installed CLI. Raises ValueError describing which
1117+
piece of configuration is missing when no command can be built.
1118+
"""
1119+
if not cfg.profile:
1120+
try:
1121+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1122+
except ValueError as e:
1123+
raise ValueError(f"neither profile nor host is configured: {e}") from e
1124+
1125+
# Flag --profile is a global CLI flag and is recognized for all
1126+
# commands even the ones that do not support it. Only use --profile
1127+
# in CLI versions that are known to support it in `auth token`.
1128+
if version < DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE:
1129+
logger.warning(
1130+
f"Databricks CLI {version} does not support --profile "
1131+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}). "
1132+
"Falling back to --host."
1133+
)
1134+
try:
1135+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1136+
except ValueError as e:
1137+
raise ValueError(
1138+
f"Databricks CLI {version} does not support --profile "
1139+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}) "
1140+
f"and no host fallback is configured: {e}"
1141+
) from e
1142+
1143+
return [cli_path, "auth", "token", "--profile", cfg.profile]
1144+
9831145
@staticmethod
984-
def _build_host_args(cfg: "Config") -> List[str]:
985-
"""Build CLI arguments using --host (legacy path)."""
986-
args = ["auth", "token", "--host", cfg.host]
987-
# This is here to support older versions of the Databricks CLI, so we need to keep the client type check.
988-
# This won't work for unified hosts, but it is not supposed to.
1146+
def _build_host_command(cli_path: str, cfg: "Config") -> List[str]:
1147+
"""Build the --host based auth token command. Raises when cfg.host is unset."""
1148+
if not cfg.host:
1149+
raise ValueError("host is not set")
1150+
cmd = [cli_path, "auth", "token", "--host", cfg.host]
1151+
# Older Databricks CLIs need --account-id for account-scoped calls;
1152+
# unified hosts don't use this path.
9891153
if cfg.client_type == ClientType.ACCOUNT:
990-
args += ["--account-id", cfg.account_id]
991-
return args
1154+
cmd += ["--account-id", cfg.account_id]
1155+
return cmd
9921156

9931157
@staticmethod
9941158
def _find_executable(name) -> str:

0 commit comments

Comments
 (0)