Skip to content

Commit 563fc02

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 baa3b63 commit 563fc02

3 files changed

Lines changed: 469 additions & 195 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
### Security
88

99
### 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.
1011

1112
### Documentation
1213

1314
### Breaking Changes
1415

1516
### Internal Changes
17+
* Detect Databricks CLI version at init time via `databricks version`, enabling version-gated flag support (e.g. `--force-refresh`) without additional subprocess calls.
1618

1719
### API Changes

databricks/sdk/credentials_provider.py

Lines changed: 210 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
@@ -662,23 +664,104 @@ def refreshed_headers() -> Dict[str, str]:
662664
return OAuthCredentialsProvider(refreshed_headers, token)
663665

664666

667+
@dataclasses.dataclass
668+
class CliVersion:
669+
"""Semver-like version of the Databricks CLI.
670+
671+
A zero value (0, 0, 0) with empty `prerelease` means version detection
672+
failed and the SDK should fall back to the most conservative command.
673+
`prerelease` matches the CLI's own `Prerelease` field in the JSON version
674+
output (e.g. "dev", "rc.1", or "" for a stable release).
675+
"""
676+
677+
major: int = 0
678+
minor: int = 0
679+
patch: int = 0
680+
# e.g. "dev", "rc.1", "alpha", or "" for a stable release. No leading "-".
681+
prerelease: str = ""
682+
683+
@property
684+
def is_default_dev_build(self) -> bool:
685+
"""True when this is the default Databricks CLI dev build (v0.0.0-dev).
686+
687+
Narrowly matches the CLI's "no version injected" marker: base triple
688+
and prerelease equal to the default dev build's.
689+
690+
A version the user explicitly set (e.g. v1.0.0-dev) is intentional and
691+
is not flagged here; feature gates still apply via the comparison
692+
operators.
693+
"""
694+
return (
695+
self.major == _DEFAULT_DEV_BUILD.major
696+
and self.minor == _DEFAULT_DEV_BUILD.minor
697+
and self.patch == _DEFAULT_DEV_BUILD.patch
698+
and self.prerelease == _DEFAULT_DEV_BUILD.prerelease
699+
)
700+
701+
@property
702+
def _has_prerelease(self) -> bool:
703+
# In semver, a prerelease tag (non-empty) compares less than the same
704+
# base version without one.
705+
return bool(self.prerelease)
706+
707+
def _cmp_key(self) -> Tuple[int, int, int, int]:
708+
"""Tuple-orderable key for semver-aware comparison.
709+
710+
Semver puts "X.Y.Z-anything" before "X.Y.Z" (prerelease sorts before
711+
release). We encode that by ranking prereleases as 0 and the absence
712+
of a prerelease as 1 in the last slot, so normal tuple ordering gives
713+
semver ordering. Two prereleases of the same base compare as equal
714+
under this key even if their tags differ, which is a simplification
715+
suited to our use (release-valued feature-gate thresholds).
716+
"""
717+
return (self.major, self.minor, self.patch, 0 if self._has_prerelease else 1)
718+
719+
def __lt__(self, other: "CliVersion") -> bool:
720+
if not isinstance(other, CliVersion):
721+
return NotImplemented
722+
return self._cmp_key() < other._cmp_key()
723+
724+
def __le__(self, other: "CliVersion") -> bool:
725+
if not isinstance(other, CliVersion):
726+
return NotImplemented
727+
return self._cmp_key() <= other._cmp_key()
728+
729+
def __gt__(self, other: "CliVersion") -> bool:
730+
if not isinstance(other, CliVersion):
731+
return NotImplemented
732+
return self._cmp_key() > other._cmp_key()
733+
734+
def __ge__(self, other: "CliVersion") -> bool:
735+
if not isinstance(other, CliVersion):
736+
return NotImplemented
737+
return self._cmp_key() >= other._cmp_key()
738+
739+
def __str__(self) -> str:
740+
if (self.major, self.minor, self.patch) == (0, 0, 0) and not self.prerelease:
741+
return "unknown"
742+
base = f"v{self.major}.{self.minor}.{self.patch}"
743+
return f"{base}-{self.prerelease}" if self.prerelease else base
744+
745+
746+
# The Databricks CLI emits exactly "v0.0.0-dev" (Major/Minor/Patch=0 and
747+
# Prerelease="dev" in the JSON output) when the binary was built without
748+
# injecting a version via ldflags (a plain `go build`). See
749+
# https://github.com/databricks/cli/blob/main/internal/build/info.go.
750+
_DEFAULT_DEV_BUILD = CliVersion(0, 0, 0, prerelease="dev")
751+
752+
665753
class CliTokenSource(oauth.Refreshable):
754+
666755
def __init__(
667756
self,
668757
cmd: List[str],
669758
token_type_field: str,
670759
access_token_field: str,
671760
expiry_field: str,
672761
disable_async: bool = True,
673-
fallback_cmd: Optional[List[str]] = None,
674762
):
675763
super().__init__(disable_async=disable_async)
676764
self._cmd = cmd
677-
# fallback_cmd is tried when the primary command fails with "unknown flag: --profile",
678-
# indicating the CLI is too old to support --profile. Can be removed once support
679-
# for CLI versions predating --profile is dropped.
680-
# See: https://github.com/databricks/databricks-sdk-go/pull/1497
681-
self._fallback_cmd = fallback_cmd
682765
self._token_type_field = token_type_field
683766
self._access_token_field = access_token_field
684767
self._expiry_field = expiry_field
@@ -713,16 +796,7 @@ def _exec_cli_command(self, cmd: List[str]) -> oauth.Token:
713796
raise IOError(f"cannot get access token: {message}") from e
714797

715798
def refresh(self) -> oauth.Token:
716-
try:
717-
return self._exec_cli_command(self._cmd)
718-
except IOError as e:
719-
if self._fallback_cmd is not None and "unknown flag: --profile" in str(e):
720-
logger.warning(
721-
"Databricks CLI does not support --profile flag. Falling back to --host. "
722-
"Please upgrade your CLI to the latest version."
723-
)
724-
return self._exec_cli_command(self._fallback_cmd)
725-
raise
799+
return self._exec_cli_command(self._cmd)
726800

727801

728802
def _run_subprocess(
@@ -911,16 +985,7 @@ def __init__(self, cfg: "Config"):
911985
elif cli_path.count("/") == 0:
912986
cli_path = self.__class__._find_executable(cli_path)
913987

914-
fallback_cmd = None
915-
if cfg.profile:
916-
# When profile is set, use --profile as the primary command.
917-
# The profile contains the full config (host, account_id, etc.).
918-
args = ["auth", "token", "--profile", cfg.profile]
919-
# Build a --host fallback for older CLIs that don't support --profile.
920-
if cfg.host:
921-
fallback_cmd = [cli_path, *self.__class__._build_host_args(cfg)]
922-
else:
923-
args = self.__class__._build_host_args(cfg)
988+
cmd = self.__class__._resolve_cli_command(cli_path, cfg)
924989

925990
# get_scopes() defaults to ["all-apis"] when nothing is configured, which would
926991
# cause false-positive mismatches against every token that wasn't issued with
@@ -930,12 +995,11 @@ def __init__(self, cfg: "Config"):
930995
self._host = cfg.host
931996

932997
super().__init__(
933-
cmd=[cli_path, *args],
998+
cmd=cmd,
934999
token_type_field="token_type",
9351000
access_token_field="access_token",
9361001
expiry_field="expiry",
9371002
disable_async=cfg.disable_async_token_refresh,
938-
fallback_cmd=fallback_cmd,
9391003
)
9401004

9411005
def refresh(self) -> oauth.Token:
@@ -993,15 +1057,126 @@ def _validate_token_scopes(self, token: oauth.Token):
9931057
f"Scopes default to all-apis."
9941058
)
9951059

1060+
# --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
1061+
_CLI_VERSION_FOR_PROFILE = CliVersion(0, 207, 1)
1062+
1063+
@staticmethod
1064+
def _parse_cli_version(output: str) -> CliVersion:
1065+
"""Parse the JSON output of `databricks version --output json`.
1066+
1067+
Takes Major/Minor/Patch from the JSON's pre-parsed numeric fields, and
1068+
derives the prerelease tag from the `Version` string. The JSON's own
1069+
`Prerelease` field is only populated by goreleaser ldflags; a plain
1070+
`go build` leaves it empty even when `Version` reports
1071+
"0.0.0-dev+<commit>". Splitting `Version` ourselves catches that case.
1072+
1073+
Returns CliVersion() on failure, matching Go's behavior where an
1074+
unparseable version disables every feature gate.
1075+
"""
1076+
try:
1077+
data = json.loads(output)
1078+
version = data["Version"]
1079+
# Drop semver build metadata ("+abcdef") — it does not affect ordering.
1080+
# Then split base from prerelease ("0.295.0-rc.1" → prerelease "rc.1").
1081+
_, _, tail = version.partition("-")
1082+
prerelease = tail.partition("+")[0]
1083+
return CliVersion(
1084+
int(data["Major"]),
1085+
int(data["Minor"]),
1086+
int(data["Patch"]),
1087+
prerelease=prerelease,
1088+
)
1089+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
1090+
logger.debug(f"Failed to parse Databricks CLI version from output: {output!r} ({e})")
1091+
return CliVersion()
1092+
1093+
@staticmethod
1094+
def _get_cli_version(cli_path: str) -> CliVersion:
1095+
"""Run `databricks version --output json` and return the parsed CliVersion.
1096+
1097+
Returns CliVersion() on failure. `--output json` is a global CLI flag
1098+
available since CLI v0.100.3; older CLIs that ignore or error on it
1099+
simply fail the JSON parse and fall back to the zero version.
1100+
"""
1101+
try:
1102+
out = _run_subprocess(
1103+
[cli_path, "version", "--output", "json"],
1104+
capture_output=True,
1105+
check=True,
1106+
)
1107+
return DatabricksCliTokenSource._parse_cli_version(out.stdout.decode())
1108+
except Exception as e:
1109+
logger.warning(f"Failed to detect Databricks CLI version: {e}. " "Falling back to conservative flag set.")
1110+
return CliVersion()
1111+
1112+
@staticmethod
1113+
def _resolve_cli_command(cli_path: str, cfg: "Config") -> List[str]:
1114+
"""Detect CLI version and build the auth-token command.
1115+
1116+
Raises ValueError with a precise message when no usable command can be
1117+
built (missing profile/host, or --profile-unsupported CLI with no host
1118+
fallback configured).
1119+
"""
1120+
version = DatabricksCliTokenSource._get_cli_version(cli_path)
1121+
if version.is_default_dev_build:
1122+
# A default-marker dev build has no injected version, so every
1123+
# feature gate fails via at_least. Surface an informational hint so
1124+
# users know why their feature flags aren't taking effect.
1125+
logger.info(
1126+
f"Databricks CLI {version} is a development build; feature detection will use "
1127+
"conservative fallbacks. Rebuild the CLI with an explicit version to enable "
1128+
"capability-based flag selection."
1129+
)
1130+
try:
1131+
return DatabricksCliTokenSource._build_cli_command(cli_path, cfg, version)
1132+
except ValueError as e:
1133+
raise ValueError(f"cannot configure CLI token source: {e}") from e
1134+
1135+
@staticmethod
1136+
def _build_cli_command(cli_path: str, cfg: "Config", version: CliVersion) -> List[str]:
1137+
"""Build the `auth token` command.
1138+
1139+
Falls back to --host when --profile is either not configured or not
1140+
supported by the installed CLI. Raises ValueError describing which
1141+
piece of configuration is missing when no command can be built.
1142+
"""
1143+
if not cfg.profile:
1144+
try:
1145+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1146+
except ValueError as e:
1147+
raise ValueError(f"neither profile nor host is configured: {e}") from e
1148+
1149+
# Flag --profile is a global CLI flag and is recognized for all
1150+
# commands even the ones that do not support it. Only use --profile
1151+
# in CLI versions that are known to support it in `auth token`.
1152+
if version < DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE:
1153+
logger.warning(
1154+
f"Databricks CLI {version} does not support --profile "
1155+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}). "
1156+
"Falling back to --host."
1157+
)
1158+
try:
1159+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1160+
except ValueError as e:
1161+
raise ValueError(
1162+
f"Databricks CLI {version} does not support --profile "
1163+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}) "
1164+
f"and no host fallback is configured: {e}"
1165+
) from e
1166+
1167+
return [cli_path, "auth", "token", "--profile", cfg.profile]
1168+
9961169
@staticmethod
997-
def _build_host_args(cfg: "Config") -> List[str]:
998-
"""Build CLI arguments using --host (legacy path)."""
999-
args = ["auth", "token", "--host", cfg.host]
1000-
# This is here to support older versions of the Databricks CLI, so we need to keep the client type check.
1001-
# This won't work for unified hosts, but it is not supposed to.
1170+
def _build_host_command(cli_path: str, cfg: "Config") -> List[str]:
1171+
"""Build the --host based auth token command. Raises when cfg.host is unset."""
1172+
if not cfg.host:
1173+
raise ValueError("host is not set")
1174+
cmd = [cli_path, "auth", "token", "--host", cfg.host]
1175+
# Older Databricks CLIs need --account-id for account-scoped calls;
1176+
# unified hosts don't use this path.
10021177
if cfg.client_type == ClientType.ACCOUNT:
1003-
args += ["--account-id", cfg.account_id]
1004-
return args
1178+
cmd += ["--account-id", cfg.account_id]
1179+
return cmd
10051180

10061181
@staticmethod
10071182
def _find_executable(name) -> str:

0 commit comments

Comments
 (0)