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,103 @@ 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 `suffix` means version detection failed
672+ and the SDK should fall back to the most conservative command. The `suffix`
673+ field preserves any prerelease tag and build metadata (e.g. "-dev+sha") so
674+ dev builds can be identified and compared correctly against releases.
675+ """
676+
677+ major : int = 0
678+ minor : int = 0
679+ patch : int = 0
680+ # e.g. "-dev+abcdef", "-rc.1", "+build.42", or "" for a stable release.
681+ suffix : 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+ equal to the default dev build's and suffix starting with the default's
689+ "-dev" prerelease tag (build metadata like "+commit" is tolerated).
690+
691+ A version the user explicitly set (e.g. v1.0.0-dev) is intentional and
692+ is not flagged here; feature gates still apply via at_least.
693+ """
694+ return (
695+ (self .major , self .minor , self .patch )
696+ == (_DEFAULT_DEV_BUILD .major , _DEFAULT_DEV_BUILD .minor , _DEFAULT_DEV_BUILD .patch )
697+ and self .suffix .startswith (_DEFAULT_DEV_BUILD .suffix )
698+ )
699+
700+ @property
701+ def _has_prerelease (self ) -> bool :
702+ # In semver, a leading "-" introduces a prerelease tag that compares
703+ # less than the same base version without one. A leading "+" is build
704+ # metadata and does not affect ordering.
705+ return self .suffix .startswith ("-" )
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 (release or build-metadata-only) as 1 in the last
713+ slot, so normal tuple ordering gives semver ordering. Two prereleases
714+ of the same base compare as equal under this key even if their tags
715+ differ, which is a simplification suited to our use (release-valued
716+ feature-gate thresholds).
717+ """
718+ return (self .major , self .minor , self .patch , 0 if self ._has_prerelease else 1 )
719+
720+ def __lt__ (self , other : "CliVersion" ) -> bool :
721+ if not isinstance (other , CliVersion ):
722+ return NotImplemented
723+ return self ._cmp_key () < other ._cmp_key ()
724+
725+ def __le__ (self , other : "CliVersion" ) -> bool :
726+ if not isinstance (other , CliVersion ):
727+ return NotImplemented
728+ return self ._cmp_key () <= other ._cmp_key ()
729+
730+ def __gt__ (self , other : "CliVersion" ) -> bool :
731+ if not isinstance (other , CliVersion ):
732+ return NotImplemented
733+ return self ._cmp_key () > other ._cmp_key ()
734+
735+ def __ge__ (self , other : "CliVersion" ) -> bool :
736+ if not isinstance (other , CliVersion ):
737+ return NotImplemented
738+ return self ._cmp_key () >= other ._cmp_key ()
739+
740+ def __str__ (self ) -> str :
741+ if (self .major , self .minor , self .patch ) == (0 , 0 , 0 ) and not self .suffix :
742+ return "unknown"
743+ return f"v{ self .major } .{ self .minor } .{ self .patch } { self .suffix } "
744+
745+
746+ # The Databricks CLI emits exactly "v0.0.0-dev[+commit]" when the binary was
747+ # built without injecting a version via ldflags (e.g. a plain `go build`). See
748+ # https://github.com/databricks/cli/blob/main/internal/build/info.go.
749+ _DEFAULT_DEV_BUILD = CliVersion (0 , 0 , 0 , suffix = "-dev" )
750+
751+
665752class CliTokenSource (oauth .Refreshable ):
753+
666754 def __init__ (
667755 self ,
668756 cmd : List [str ],
669757 token_type_field : str ,
670758 access_token_field : str ,
671759 expiry_field : str ,
672760 disable_async : bool = True ,
673- fallback_cmd : Optional [List [str ]] = None ,
674761 ):
675762 super ().__init__ (disable_async = disable_async )
676763 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
682764 self ._token_type_field = token_type_field
683765 self ._access_token_field = access_token_field
684766 self ._expiry_field = expiry_field
@@ -713,16 +795,7 @@ def _exec_cli_command(self, cmd: List[str]) -> oauth.Token:
713795 raise IOError (f"cannot get access token: { message } " ) from e
714796
715797 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
798+ return self ._exec_cli_command (self ._cmd )
726799
727800
728801def _run_subprocess (
@@ -911,16 +984,7 @@ def __init__(self, cfg: "Config"):
911984 elif cli_path .count ("/" ) == 0 :
912985 cli_path = self .__class__ ._find_executable (cli_path )
913986
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 )
987+ cmd = self .__class__ ._resolve_cli_command (cli_path , cfg )
924988
925989 # get_scopes() defaults to ["all-apis"] when nothing is configured, which would
926990 # cause false-positive mismatches against every token that wasn't issued with
@@ -930,12 +994,11 @@ def __init__(self, cfg: "Config"):
930994 self ._host = cfg .host
931995
932996 super ().__init__ (
933- cmd = [ cli_path , * args ] ,
997+ cmd = cmd ,
934998 token_type_field = "token_type" ,
935999 access_token_field = "access_token" ,
9361000 expiry_field = "expiry" ,
9371001 disable_async = cfg .disable_async_token_refresh ,
938- fallback_cmd = fallback_cmd ,
9391002 )
9401003
9411004 def refresh (self ) -> oauth .Token :
@@ -993,15 +1056,116 @@ def _validate_token_scopes(self, token: oauth.Token):
9931056 f"Scopes default to all-apis."
9941057 )
9951058
1059+ # --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
1060+ _CLI_VERSION_FOR_PROFILE = CliVersion (0 , 207 , 1 )
1061+
1062+ # Captures "vX.Y.Z" plus any prerelease/build-metadata suffix so dev
1063+ # builds like "v0.0.0-dev+abcdef" can be distinguished from detection
1064+ # failure and compared against release versions.
1065+ _CLI_VERSION_RE = re .compile (r"Databricks CLI v(\d+)\.(\d+)\.(\d+)(\S*)" )
1066+
1067+ @staticmethod
1068+ def _parse_cli_version (output : str ) -> CliVersion :
1069+ """Parse 'Databricks CLI vX.Y.Z[-suffix]' output into a CliVersion.
1070+
1071+ Returns CliVersion() on failure, matching Go's behavior where an
1072+ unparseable version disables every feature gate.
1073+ """
1074+ m = DatabricksCliTokenSource ._CLI_VERSION_RE .search (output )
1075+ if not m :
1076+ logger .debug (f"Failed to parse Databricks CLI version from output: { output !r} " )
1077+ return CliVersion ()
1078+ return CliVersion (
1079+ int (m .group (1 )),
1080+ int (m .group (2 )),
1081+ int (m .group (3 )),
1082+ suffix = m .group (4 ),
1083+ )
1084+
1085+ @staticmethod
1086+ def _get_cli_version (cli_path : str ) -> CliVersion :
1087+ """Run 'databricks version' and return the parsed CliVersion.
1088+
1089+ Returns CliVersion() on failure.
1090+ """
1091+ try :
1092+ out = _run_subprocess ([cli_path , "version" ], capture_output = True , check = True )
1093+ return DatabricksCliTokenSource ._parse_cli_version (out .stdout .decode ())
1094+ except Exception as e :
1095+ logger .warning (
1096+ f"Failed to detect Databricks CLI version: { e } . "
1097+ "Falling back to conservative flag set."
1098+ )
1099+ return CliVersion ()
1100+
1101+ @staticmethod
1102+ def _resolve_cli_command (cli_path : str , cfg : "Config" ) -> List [str ]:
1103+ """Detect CLI version and build the auth-token command.
1104+
1105+ Raises ValueError with a precise message when no usable command can be
1106+ built (missing profile/host, or --profile-unsupported CLI with no host
1107+ fallback configured).
1108+ """
1109+ version = DatabricksCliTokenSource ._get_cli_version (cli_path )
1110+ if version .is_default_dev_build :
1111+ # A default-marker dev build has no injected version, so every
1112+ # feature gate fails via at_least. Surface an informational hint so
1113+ # users know why their feature flags aren't taking effect.
1114+ logger .info (
1115+ f"Databricks CLI { version } is a development build; feature detection will use "
1116+ "conservative fallbacks. Rebuild the CLI with an explicit version to enable "
1117+ "capability-based flag selection."
1118+ )
1119+ try :
1120+ return DatabricksCliTokenSource ._build_cli_command (cli_path , cfg , version )
1121+ except ValueError as e :
1122+ raise ValueError (f"cannot configure CLI token source: { e } " ) from e
1123+
1124+ @staticmethod
1125+ def _build_cli_command (cli_path : str , cfg : "Config" , version : CliVersion ) -> List [str ]:
1126+ """Build the `auth token` command.
1127+
1128+ Falls back to --host when --profile is either not configured or not
1129+ supported by the installed CLI. Raises ValueError describing which
1130+ piece of configuration is missing when no command can be built.
1131+ """
1132+ if not cfg .profile :
1133+ try :
1134+ return DatabricksCliTokenSource ._build_host_command (cli_path , cfg )
1135+ except ValueError as e :
1136+ raise ValueError (f"neither profile nor host is configured: { e } " ) from e
1137+
1138+ # Flag --profile is a global CLI flag and is recognized for all
1139+ # commands even the ones that do not support it. Only use --profile
1140+ # in CLI versions that are known to support it in `auth token`.
1141+ if version < DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE :
1142+ logger .warning (
1143+ f"Databricks CLI { version } does not support --profile "
1144+ f"(requires >= { DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE } ). "
1145+ "Falling back to --host."
1146+ )
1147+ try :
1148+ return DatabricksCliTokenSource ._build_host_command (cli_path , cfg )
1149+ except ValueError as e :
1150+ raise ValueError (
1151+ f"Databricks CLI { version } does not support --profile "
1152+ f"(requires >= { DatabricksCliTokenSource ._CLI_VERSION_FOR_PROFILE } ) "
1153+ f"and no host fallback is configured: { e } "
1154+ ) from e
1155+
1156+ return [cli_path , "auth" , "token" , "--profile" , cfg .profile ]
1157+
9961158 @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.
1159+ def _build_host_command (cli_path : str , cfg : "Config" ) -> List [str ]:
1160+ """Build the --host based auth token command. Raises when cfg.host is unset."""
1161+ if not cfg .host :
1162+ raise ValueError ("host is not set" )
1163+ cmd = [cli_path , "auth" , "token" , "--host" , cfg .host ]
1164+ # Older Databricks CLIs need --account-id for account-scoped calls;
1165+ # unified hosts don't use this path.
10021166 if cfg .client_type == ClientType .ACCOUNT :
1003- args += ["--account-id" , cfg .account_id ]
1004- return args
1167+ cmd += ["--account-id" , cfg .account_id ]
1168+ return cmd
10051169
10061170 @staticmethod
10071171 def _find_executable (name ) -> str :
0 commit comments