Skip to content

Commit 3009a6b

Browse files
[Feature] Add DataPlane support (#700)
## Changes Add DataPlane support ## Tests - [X] `make test` run locally - [X] `make fmt` applied - [ ] relevant integration tests applied - [X] Manual test against staging workspace (prod workspaces don't support DataPlane APIs)
1 parent 6462912 commit 3009a6b

File tree

12 files changed

+453
-36
lines changed

12 files changed

+453
-36
lines changed

.codegen/__init__.py.tmpl

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ from databricks.sdk.credentials_provider import CredentialsStrategy
55
from databricks.sdk.mixins.files import DbfsExt
66
from databricks.sdk.mixins.compute import ClustersExt
77
from databricks.sdk.mixins.workspace import WorkspaceExt
8-
{{- range .Services}} {{if not .IsDataPlane}}
9-
from databricks.sdk.service.{{.Package.Name}} import {{.PascalName}}API{{end}}{{end}}
8+
{{- range .Services}}
9+
from databricks.sdk.service.{{.Package.Name}} import {{.PascalName}}API{{end}}
1010
from databricks.sdk.service.provisioning import Workspace
1111
from databricks.sdk import azure
1212

@@ -61,8 +61,20 @@ class WorkspaceClient:
6161
self._dbutils = _make_dbutils(self._config)
6262
self._api_client = client.ApiClient(self._config)
6363

64-
{{- range .Services}}{{if and (not .IsAccounts) (not .HasParent) (not .IsDataPlane)}}
65-
self._{{.SnakeName}} = {{template "api" .}}(self._api_client){{end -}}{{end}}
64+
{{- range .Services}}{{if and (not .IsAccounts) (not .HasParent) .HasDataPlaneAPI (not .IsDataPlane)}}
65+
{{.SnakeName}} = {{template "api" .}}(self._api_client){{end -}}{{end}}
66+
67+
{{- range .Services}}
68+
{{- if and (not .IsAccounts) (not .HasParent)}}
69+
{{- if .IsDataPlane}}
70+
self._{{.SnakeName}} = {{template "api" .}}(self._api_client, {{.ControlPlaneService.SnakeName}})
71+
{{- else if .HasDataPlaneAPI}}
72+
self._{{.SnakeName}} = {{.SnakeName}}
73+
{{- else}}
74+
self._{{.SnakeName}} = {{template "api" .}}(self._api_client)
75+
{{- end -}}
76+
{{- end -}}
77+
{{end}}
6678

6779
@property
6880
def config(self) -> client.Config:
@@ -76,7 +88,7 @@ class WorkspaceClient:
7688
def dbutils(self) -> dbutils.RemoteDbUtils:
7789
return self._dbutils
7890

79-
{{- range .Services}}{{if and (not .IsAccounts) (not .HasParent) (not .IsDataPlane)}}
91+
{{- range .Services}}{{if and (not .IsAccounts) (not .HasParent)}}
8092
@property
8193
def {{.SnakeName}}(self) -> {{template "api" .}}:
8294
{{if .Description}}"""{{.Summary}}"""{{end}}
@@ -117,8 +129,20 @@ class AccountClient:
117129
self._config = config.copy()
118130
self._api_client = client.ApiClient(self._config)
119131

120-
{{- range .Services}}{{if and .IsAccounts (not .HasParent) (not .IsDataPlane)}}
121-
self._{{(.TrimPrefix "account").SnakeName}} = {{template "api" .}}(self._api_client){{end -}}{{end}}
132+
{{- range .Services}}{{if and .IsAccounts (not .HasParent) .HasDataPlaneAPI (not .IsDataPlane)}}
133+
{{(.TrimPrefix "account").SnakeName}} = {{template "api" .}}(self._api_client){{end -}}{{end}}
134+
135+
{{- range .Services}}
136+
{{- if and .IsAccounts (not .HasParent)}}
137+
{{- if .IsDataPlane}}
138+
self._{{(.TrimPrefix "account").SnakeName}} = {{template "api" .}}(self._api_client, {{.ControlPlaneService.SnakeName}})
139+
{{- else if .HasDataPlaneAPI}}
140+
self._{{(.TrimPrefix "account").SnakeName}} = {{(.TrimPrefix "account").SnakeName}}
141+
{{- else}}
142+
self._{{(.TrimPrefix "account").SnakeName}} = {{template "api" .}}(self._api_client)
143+
{{- end -}}
144+
{{- end -}}
145+
{{end}}
122146

123147
@property
124148
def config(self) -> client.Config:
@@ -128,7 +152,7 @@ class AccountClient:
128152
def api_client(self) -> client.ApiClient:
129153
return self._api_client
130154

131-
{{- range .Services}}{{if and .IsAccounts (not .HasParent) (not .IsDataPlane)}}
155+
{{- range .Services}}{{if and .IsAccounts (not .HasParent)}}
132156
@property
133157
def {{(.TrimPrefix "account").SnakeName}}(self) -> {{template "api" .}}:{{if .Description}}
134158
"""{{.Summary}}"""{{end}}

.codegen/service.py.tmpl

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ from typing import Dict, List, Any, Iterator, Type, Callable, Optional, BinaryIO
88
import time
99
import random
1010
import logging
11+
import requests
12+
13+
from ..data_plane import DataPlaneService
1114
from ..errors import OperationTimeout, OperationFailed
1215
from ._internal import _enum, _from_dict, _repeated_dict, _repeated_enum, Wait, _escape_multi_segment_path_parameter
16+
from ..oauth import Token
1317

1418
_LOG = logging.getLogger('databricks.sdk')
1519

@@ -100,12 +104,16 @@ class {{.PascalName}}{{if eq "List" .PascalName}}Request{{end}}:{{if .Descriptio
100104
{{- end -}}
101105
{{- end -}}
102106

103-
{{range .Services}} {{if not .IsDataPlane}}
107+
{{range .Services}}
104108
class {{.PascalName}}API:{{if .Description}}
105109
"""{{.Comment " " 110}}"""
106110
{{end}}
107-
def __init__(self, api_client):
111+
def __init__(self, api_client{{if .IsDataPlane}}, control_plane{{end}}):
108112
self._api = api_client
113+
{{if .IsDataPlane -}}
114+
self._control_plane = control_plane
115+
self._data_plane_service = DataPlaneService()
116+
{{end -}}
109117
{{range .Subservices}}
110118
self._{{.SnakeName}} = {{.PascalName}}API(self._api){{end}}
111119

@@ -183,6 +191,9 @@ class {{.PascalName}}API:{{if .Description}}
183191
{{if .Request -}}
184192
{{template "method-serialize" .}}
185193
{{- end}}
194+
{{- if .Service.IsDataPlane}}
195+
{{template "data-plane" .}}
196+
{{- end}}
186197
{{template "method-headers" . }}
187198
{{if .Response.HasHeaderField -}}
188199
{{template "method-response-headers" . }}
@@ -195,7 +206,27 @@ class {{.PascalName}}API:{{if .Description}}
195206
return self.{{template "safe-snake-name" .}}({{range $i, $x := .Request.Fields}}{{if $i}}, {{end}}{{template "safe-snake-name" .}}={{template "safe-snake-name" .}}{{end}}).result(timeout=timeout)
196207
{{end}}
197208
{{end -}}
198-
{{end}}
209+
{{- end}}
210+
211+
{{define "data-plane" -}}
212+
def info_getter():
213+
response = self._control_plane.{{.Service.DataPlaneInfoMethod.SnakeName}}(
214+
{{- range .Service.DataPlaneInfoMethod.Request.Fields }}
215+
{{.SnakeName}} = {{.SnakeName}},
216+
{{- end}}
217+
)
218+
if response.{{(index .DataPlaneInfoFields 0).SnakeName}} is None:
219+
raise Exception("Resource does not support direct Data Plane access")
220+
return response{{range .DataPlaneInfoFields}}.{{.SnakeName}}{{end}}
221+
222+
get_params = [{{- range .Service.DataPlaneInfoMethod.Request.Fields }}{{.SnakeName}},{{end}}]
223+
data_plane_details = self._data_plane_service.get_data_plane_details('{{.SnakeName}}', get_params, info_getter, self._api.get_oauth_token)
224+
token = data_plane_details.token
225+
226+
def auth(r: requests.PreparedRequest) -> requests.PreparedRequest:
227+
authorization = f"{token.token_type} {token.access_token}"
228+
r.headers["Authorization"] = authorization
229+
return r
199230
{{- end}}
200231

201232
{{define "method-parameters" -}}
@@ -325,19 +356,26 @@ class {{.PascalName}}API:{{if .Description}}
325356
{{- end}}
326357

327358
{{define "method-do" -}}
328-
self._api.do('{{.Verb}}',
329-
{{ template "path" . }}
330-
{{if .Request}}
331-
{{- if .Request.HasQueryField}}, query=query{{end}}
332-
{{- if .Request.MapValue}}, body=contents
333-
{{- else if .Request.HasJsonField}}, body=body{{end}}
334-
{{end}}
335-
, headers=headers
336-
{{if .Response.HasHeaderField -}}
337-
, response_headers=response_headers
338-
{{- end}}
339-
{{- if and .IsRequestByteStream .RequestBodyField }}, data={{template "safe-snake-name" .RequestBodyField}}{{ end }}
340-
{{- if .IsResponseByteStream }}, raw=True{{ end }})
359+
self._api.do('{{.Verb}}',
360+
{{- if .Service.IsDataPlane -}}
361+
url=data_plane_details.endpoint_url
362+
{{- else -}}
363+
{{ template "path" . }}
364+
{{- end -}}
365+
{{if .Request}}
366+
{{- if .Request.HasQueryField}}, query=query{{end}}
367+
{{- if .Request.MapValue}}, body=contents
368+
{{- else if .Request.HasJsonField}}, body=body{{end}}
369+
{{end}}
370+
, headers=headers
371+
{{if .Response.HasHeaderField -}}
372+
, response_headers=response_headers
373+
{{- end}}
374+
{{- if and .IsRequestByteStream .RequestBodyField }}, data={{template "safe-snake-name" .RequestBodyField}}{{ end }}
375+
{{- if .Service.IsDataPlane -}}
376+
,auth=auth
377+
{{- end -}}
378+
{{- if .IsResponseByteStream }}, raw=True{{ end }})
341379
{{- end}}
342380

343381
{{- define "path" -}}

databricks/sdk/__init__.py

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

databricks/sdk/core.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,31 +133,36 @@ def get_oauth_token(self, auth_details: str) -> Token:
133133

134134
def do(self,
135135
method: str,
136-
path: str,
136+
path: str = None,
137+
url: str = None,
137138
query: dict = None,
138139
headers: dict = None,
139140
body: dict = None,
140141
raw: bool = False,
141142
files=None,
142143
data=None,
144+
auth: Callable[[requests.PreparedRequest], requests.PreparedRequest] = None,
143145
response_headers: List[str] = None) -> Union[dict, BinaryIO]:
144-
# Remove extra `/` from path for Files API
145-
# Once we've fixed the OpenAPI spec, we can remove this
146-
path = re.sub('^/api/2.0/fs/files//', '/api/2.0/fs/files/', path)
147146
if headers is None:
148147
headers = {}
148+
if url is None:
149+
# Remove extra `/` from path for Files API
150+
# Once we've fixed the OpenAPI spec, we can remove this
151+
path = re.sub('^/api/2.0/fs/files//', '/api/2.0/fs/files/', path)
152+
url = f"{self._cfg.host}{path}"
149153
headers['User-Agent'] = self._user_agent_base
150154
retryable = retried(timeout=timedelta(seconds=self._retry_timeout_seconds),
151155
is_retryable=self._is_retryable,
152156
clock=self._cfg.clock)
153157
response = retryable(self._perform)(method,
154-
path,
158+
url,
155159
query=query,
156160
headers=headers,
157161
body=body,
158162
raw=raw,
159163
files=files,
160-
data=data)
164+
data=data,
165+
auth=auth)
161166

162167
resp = dict()
163168
for header in response_headers if response_headers else []:
@@ -239,20 +244,22 @@ def _parse_retry_after(cls, response: requests.Response) -> Optional[int]:
239244

240245
def _perform(self,
241246
method: str,
242-
path: str,
247+
url: str,
243248
query: dict = None,
244249
headers: dict = None,
245250
body: dict = None,
246251
raw: bool = False,
247252
files=None,
248-
data=None):
253+
data=None,
254+
auth: Callable[[requests.PreparedRequest], requests.PreparedRequest] = None):
249255
response = self._session.request(method,
250-
f"{self._cfg.host}{path}",
256+
url,
251257
params=self._fix_query_string(query),
252258
json=body,
253259
headers=headers,
254260
files=files,
255261
data=data,
262+
auth=auth,
256263
stream=raw,
257264
timeout=self._http_timeout_seconds)
258265
try:

databricks/sdk/data_plane.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import threading
2+
from dataclasses import dataclass
3+
from typing import Callable, List
4+
5+
from databricks.sdk.oauth import Token
6+
from databricks.sdk.service.oauth2 import DataPlaneInfo
7+
8+
9+
@dataclass
10+
class DataPlaneDetails:
11+
"""
12+
Contains details required to query a DataPlane endpoint.
13+
"""
14+
endpoint_url: str
15+
"""URL used to query the endpoint through the DataPlane."""
16+
token: Token
17+
"""Token to query the DataPlane endpoint."""
18+
19+
20+
class DataPlaneService:
21+
"""Helper class to fetch and manage DataPlane details."""
22+
23+
def __init__(self):
24+
self._data_plane_info = {}
25+
self._tokens = {}
26+
self._lock = threading.Lock()
27+
28+
def get_data_plane_details(self, method: str, params: List[str], info_getter: Callable[[], DataPlaneInfo],
29+
refresh: Callable[[str], Token]):
30+
"""Get and cache information required to query a Data Plane endpoint using the provided methods.
31+
32+
Returns a cached DataPlaneDetails if the details have already been fetched previously and are still valid.
33+
If not, it uses the provided functions to fetch the details.
34+
35+
:param method: method name. Used to construct a unique key for the cache.
36+
:param params: path params used in the "get" operation which uniquely determine the object. Used to construct a unique key for the cache.
37+
:param info_getter: function which returns the DataPlaneInfo. It will only be called if the information is not already present in the cache.
38+
:param refresh: function to refresh the token. It will only be called if the token is missing or expired.
39+
"""
40+
all_elements = params.copy()
41+
all_elements.insert(0, method)
42+
map_key = "/".join(all_elements)
43+
info = self._data_plane_info.get(map_key)
44+
if not info:
45+
self._lock.acquire()
46+
try:
47+
info = self._data_plane_info.get(map_key)
48+
if not info:
49+
info = info_getter()
50+
self._data_plane_info[map_key] = info
51+
finally:
52+
self._lock.release()
53+
54+
token = self._tokens.get(map_key)
55+
if not token or not token.valid:
56+
self._lock.acquire()
57+
token = self._tokens.get(map_key)
58+
try:
59+
if not token or not token.valid:
60+
token = refresh(info.authorization_details)
61+
self._tokens[map_key] = token
62+
finally:
63+
self._lock.release()
64+
65+
return DataPlaneDetails(endpoint_url=info.endpoint_url, token=token)

0 commit comments

Comments
 (0)