11import abc
22import base64
3+ import dataclasses
34import functools
45import io
56import json
67import logging
78import os
89import pathlib
910import platform
11+ import re
1012import subprocess
1113import sys
1214import 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+
665753class 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
728802def _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