11import abc
22import base64
3+ import dataclasses
34import functools
45import io
56import json
@@ -662,23 +663,57 @@ def refreshed_headers() -> Dict[str, str]:
662663 return OAuthCredentialsProvider (refreshed_headers , token )
663664
664665
666+ @dataclasses .dataclass (order = True )
667+ class CliVersion :
668+ """Semver version triple of the Databricks CLI.
669+
670+ Three sentinel states in the (major, minor, patch) tuple:
671+ * `(-1, -1, -1)` — the default, meaning version detection failed. It
672+ compares less than every real release so every feature gate fails.
673+ * `(0, 0, 0)` — the CLI's default dev build, emitted when the binary
674+ was built without version metadata. See `is_default_dev_build`.
675+ * anything else — a real CLI version.
676+
677+ Prerelease tags (e.g. "-rc.1", "-dev+commit") are deliberately ignored:
678+ for our purposes the base triple is sufficient, and feature gates are
679+ release-based so a prerelease of a version with a flag is assumed to
680+ have the flag too.
681+ """
682+
683+ major : int = - 1
684+ minor : int = - 1
685+ patch : int = - 1
686+
687+ @property
688+ def is_default_dev_build (self ) -> bool :
689+ """True when the CLI reports (0, 0, 0), i.e. its default dev build.
690+
691+ Narrowly matches the CLI's "no version injected" marker: its version
692+ metadata stays at the zero defaults. A version the user explicitly
693+ set (e.g. v1.0.0-dev) is intentional and is not flagged here.
694+ """
695+ return (self .major , self .minor , self .patch ) == (0 , 0 , 0 )
696+
697+ def __str__ (self ) -> str :
698+ if self == CliVersion ():
699+ return "unknown"
700+ if self .is_default_dev_build :
701+ return "v0.0.0-dev"
702+ return f"v{ self .major } .{ self .minor } .{ self .patch } "
703+
704+
665705class CliTokenSource (oauth .Refreshable ):
706+
666707 def __init__ (
667708 self ,
668709 cmd : List [str ],
669710 token_type_field : str ,
670711 access_token_field : str ,
671712 expiry_field : str ,
672713 disable_async : bool = True ,
673- fallback_cmd : Optional [List [str ]] = None ,
674714 ):
675715 super ().__init__ (disable_async = disable_async )
676716 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
682717 self ._token_type_field = token_type_field
683718 self ._access_token_field = access_token_field
684719 self ._expiry_field = expiry_field
@@ -713,16 +748,7 @@ def _exec_cli_command(self, cmd: List[str]) -> oauth.Token:
713748 raise IOError (f"cannot get access token: { message } " ) from e
714749
715750 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
751+ return self ._exec_cli_command (self ._cmd )
726752
727753
728754def _run_subprocess (
@@ -911,16 +937,7 @@ def __init__(self, cfg: "Config"):
911937 elif cli_path .count ("/" ) == 0 :
912938 cli_path = self .__class__ ._find_executable (cli_path )
913939
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 )
940+ cmd = self .__class__ ._resolve_cli_command (cli_path , cfg )
924941
925942 # get_scopes() defaults to ["all-apis"] when nothing is configured, which would
926943 # cause false-positive mismatches against every token that wasn't issued with
@@ -930,12 +947,11 @@ def __init__(self, cfg: "Config"):
930947 self ._host = cfg .host
931948
932949 super ().__init__ (
933- cmd = [ cli_path , * args ] ,
950+ cmd = cmd ,
934951 token_type_field = "token_type" ,
935952 access_token_field = "access_token" ,
936953 expiry_field = "expiry" ,
937954 disable_async = cfg .disable_async_token_refresh ,
938- fallback_cmd = fallback_cmd ,
939955 )
940956
941957 def refresh (self ) -> oauth .Token :
@@ -993,15 +1009,151 @@ def _validate_token_scopes(self, token: oauth.Token):
9931009 f"Scopes default to all-apis."
9941010 )
9951011
1012+ # --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
1013+ _CLI_VERSION_FOR_PROFILE = CliVersion (0 , 207 , 1 )
1014+
1015+ @staticmethod
1016+ def _parse_cli_version (output : str ) -> CliVersion :
1017+ """Parse the JSON output of `databricks version --output json`.
1018+
1019+ Takes Major/Minor/Patch from the JSON's pre-parsed numeric fields. The
1020+ `Prerelease` JSON field and the `Version` string are intentionally
1021+ ignored: for our feature-gate purposes the base triple is sufficient,
1022+ and the (0, 0, 0) case already identifies the default dev build (a
1023+ CLI built without version metadata leaves these fields at their zero
1024+ defaults).
1025+
1026+ Returns CliVersion() (unknown, (-1, -1, -1)) on failure so that an
1027+ unparseable version disables every feature gate.
1028+ """
1029+ try :
1030+ data = json .loads (output )
1031+ return CliVersion (
1032+ int (data ["Major" ]),
1033+ int (data ["Minor" ]),
1034+ int (data ["Patch" ]),
1035+ )
1036+ except (json .JSONDecodeError , KeyError , TypeError , ValueError ) as e :
1037+ logger .debug (f"Failed to parse Databricks CLI version from output: { output !r} ({ e } )" )
1038+ return CliVersion ()
1039+
1040+ @staticmethod
1041+ @functools .lru_cache (maxsize = 8 )
1042+ def _probe_cli_version (cli_path : str ) -> "CliVersion" :
1043+ """Run `databricks version --output json` and return the parsed CliVersion.
1044+
1045+ Cached by `cli_path` for the Python process lifetime: a successful probe
1046+ against an immutable binary doesn't need to be repeated. Subprocess-level
1047+ failures (timeout, OSError, CalledProcessError) raise out instead of
1048+ returning a sentinel, so transient errors are NOT cached and the next
1049+ caller retries — see `_get_cli_version` for the catching wrapper.
1050+
1051+ The 5-second timeout prevents a hung CLI (first-run disk scan, antivirus,
1052+ stdin wait) from wedging SDK init indefinitely.
1053+ """
1054+ out = _run_subprocess (
1055+ [cli_path , "version" , "--output" , "json" ],
1056+ capture_output = True ,
1057+ check = True ,
1058+ timeout = 5 ,
1059+ )
1060+ return DatabricksCliTokenSource ._parse_cli_version (out .stdout .decode ())
1061+
1062+ @staticmethod
1063+ def _get_cli_version (cli_path : str ) -> "CliVersion" :
1064+ """Probe wrapper that catches subprocess failures and returns CliVersion().
1065+
1066+ The catch is intentionally outside the cached helper so a transient failure
1067+ (timeout, AV scan, sandbox quota, etc.) does not pin every subsequent
1068+ DatabricksCliTokenSource to the conservative fallback for the rest of the
1069+ process lifetime.
1070+ """
1071+ try :
1072+ return DatabricksCliTokenSource ._probe_cli_version (cli_path )
1073+ except Exception as e :
1074+ logger .warning (f"Failed to detect Databricks CLI version: { e } . Falling back to conservative flag set." )
1075+ return CliVersion ()
1076+
1077+ @staticmethod
1078+ def _resolve_cli_command (cli_path : str , cfg : "Config" ) -> List [str ]:
1079+ """Detect CLI version and build the auth-token command.
1080+
1081+ Raises IOError with a precise message when no usable command can be
1082+ built (missing profile/host, or --profile-unsupported CLI with no host
1083+ fallback configured). IOError matches the exception type already
1084+ handled by the `databricks_cli()` credential-chain strategy, so
1085+ graceful-skip behaviour is preserved.
1086+ """
1087+ version = DatabricksCliTokenSource ._get_cli_version (cli_path )
1088+ if version .is_default_dev_build :
1089+ # A default-marker dev build has no injected version, so every
1090+ # feature gate fails via at_least. Surface an informational hint so
1091+ # users know why their feature flags aren't taking effect.
1092+ logger .info (
1093+ f"Databricks CLI { version } is a development build; feature detection will use "
1094+ "conservative fallbacks. Rebuild the CLI with an explicit version to enable "
1095+ "capability-based flag selection."
1096+ )
1097+ try :
1098+ return DatabricksCliTokenSource ._build_cli_command (cli_path , cfg , version )
1099+ except IOError as e :
1100+ raise IOError (f"cannot configure CLI token source: { e } " ) from e
1101+
1102+ @staticmethod
1103+ def _build_cli_command (cli_path : str , cfg : "Config" , version : CliVersion ) -> List [str ]:
1104+ """Build the `auth token` command.
1105+
1106+ Falls back to --host when --profile is either not configured or not
1107+ supported by the installed CLI. Raises IOError describing which
1108+ piece of configuration is missing when no command can be built.
1109+ """
1110+ if not cfg .profile :
1111+ try :
1112+ return DatabricksCliTokenSource ._build_host_command (cli_path , cfg )
1113+ except IOError as e :
1114+ raise IOError (f"neither profile nor host is configured: { e } " ) from e
1115+
1116+ # Flag --profile is a global flag and is recognized for all
1117+ # commands even the ones that do not support it. Only use --profile
1118+ # in CLI versions that are known to support it in `auth token`.
1119+ if version < DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE :
1120+ # For the unknown (detection failed) and dev-build (no version
1121+ # metadata) cases we can't actually prove the CLI lacks --profile;
1122+ # we just failed to confirm it. Log accordingly.
1123+ if version == CliVersion () or version .is_default_dev_build :
1124+ logger .warning (
1125+ f"Could not confirm --profile support for Databricks CLI { version } "
1126+ f"(requires >= { DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE } ). "
1127+ "Falling back to --host."
1128+ )
1129+ else :
1130+ logger .warning (
1131+ f"Databricks CLI { version } does not support --profile "
1132+ f"(requires >= { DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE } ). "
1133+ "Falling back to --host."
1134+ )
1135+ try :
1136+ return DatabricksCliTokenSource ._build_host_command (cli_path , cfg )
1137+ except IOError as e :
1138+ raise IOError (
1139+ f"Databricks CLI { version } does not support --profile "
1140+ f"(requires >= { DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE } ) "
1141+ f"and no host fallback is configured: { e } "
1142+ ) from e
1143+
1144+ return [cli_path , "auth" , "token" , "--profile" , cfg .profile ]
1145+
9961146 @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.
1147+ def _build_host_command (cli_path : str , cfg : "Config" ) -> List [str ]:
1148+ """Build the --host based auth token command. Raises when cfg.host is unset."""
1149+ if not cfg .host :
1150+ raise IOError ("host is not set" )
1151+ cmd = [cli_path , "auth" , "token" , "--host" , cfg .host ]
1152+ # Older Databricks CLIs need --account-id for account-scoped calls;
1153+ # unified hosts don't use this path.
10021154 if cfg .client_type == ClientType .ACCOUNT :
1003- args += ["--account-id" , cfg .account_id ]
1004- return args
1155+ cmd += ["--account-id" , cfg .account_id ]
1156+ return cmd
10051157
10061158 @staticmethod
10071159 def _find_executable (name ) -> str :
0 commit comments