dbx-tools-postgres 0.6.78__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol
8
+
9
+ from sqlalchemy import URL, Engine, event
10
+ from sqlalchemy import create_engine as sqlalchemy_create_engine
11
+ from sqlalchemy.ext.asyncio import AsyncEngine
12
+ from sqlalchemy.ext.asyncio import create_async_engine as sqlalchemy_create_async_engine
13
+
14
+ from .address import SSL_MODES, ParsedAddress, SslMode, parse_address, parse_resource_path
15
+
16
+ CredentialProvider = Callable[[], str]
17
+
18
+ _API_BASE = "/api/2.0/postgres"
19
+ _DEFAULT_DATABASE = "databricks_postgres"
20
+ _DEFAULT_PORT = 5432
21
+ _DEFAULT_SSL_MODE: SslMode = "require"
22
+ _READ_WRITE_ENDPOINT_TYPE = "ENDPOINT_TYPE_READ_WRITE"
23
+
24
+
25
+ class WorkspaceApiClient(Protocol):
26
+ def do(self, method: str, path: str, **kwargs: Any) -> Any: ...
27
+
28
+
29
+ class WorkspaceClientLike(Protocol):
30
+ api_client: WorkspaceApiClient
31
+ config: Any
32
+ current_user: Any
33
+ database: Any
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class PostgresEngineConfig:
38
+ address: str | None = None
39
+ instance_name: str | None = None
40
+ project: str | None = None
41
+ branch: str | None = None
42
+ endpoint: str | None = None
43
+ database: str | None = None
44
+ host: str | None = None
45
+ port: int | None = None
46
+ ssl_mode: SslMode | None = None
47
+ user: str | None = None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ResolvedPostgresConnection:
52
+ host: str
53
+ database: str
54
+ user: str
55
+ port: int = _DEFAULT_PORT
56
+ ssl_mode: SslMode = _DEFAULT_SSL_MODE
57
+ project: str | None = None
58
+ branch: str | None = None
59
+ endpoint: str | None = None
60
+ instance_name: str | None = None
61
+
62
+ def url(self, drivername: str) -> URL:
63
+ ssl_parameter = "ssl" if drivername.endswith("+asyncpg") else "sslmode"
64
+ return URL.create(
65
+ drivername,
66
+ username=self.user,
67
+ password=None,
68
+ host=self.host,
69
+ port=self.port,
70
+ database=self.database,
71
+ query={ssl_parameter: self.ssl_mode},
72
+ )
73
+
74
+
75
+ def workspace_credential_provider(
76
+ workspace_client: WorkspaceClientLike,
77
+ instance_name: str,
78
+ ) -> CredentialProvider:
79
+ if not instance_name.strip():
80
+ raise ValueError("instance_name must not be empty")
81
+
82
+ def provide() -> str:
83
+ credential = workspace_client.database.generate_database_credential(
84
+ request_id=str(uuid.uuid4()),
85
+ instance_names=[instance_name],
86
+ )
87
+ token = getattr(credential, "token", None)
88
+ if not isinstance(token, str) or not token:
89
+ raise RuntimeError("WorkspaceClient returned no Lakebase database credential token")
90
+ return token
91
+
92
+ return provide
93
+
94
+
95
+ def autoscaling_credential_provider(
96
+ workspace_client: WorkspaceClientLike,
97
+ endpoint: str,
98
+ ) -> CredentialProvider:
99
+ if not endpoint.strip():
100
+ raise ValueError("endpoint must not be empty")
101
+
102
+ def provide() -> str:
103
+ credential = workspace_client.api_client.do(
104
+ "POST",
105
+ f"{_API_BASE}/credentials",
106
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
107
+ body={"endpoint": endpoint},
108
+ )
109
+ token = credential.get("token") if isinstance(credential, dict) else None
110
+ if not isinstance(token, str) or not token:
111
+ raise RuntimeError("WorkspaceClient returned no Lakebase database credential token")
112
+ return token
113
+
114
+ return provide
115
+
116
+
117
+ def install_credential_injection(engine: Engine, provider: CredentialProvider) -> None:
118
+ @event.listens_for(engine, "do_connect")
119
+ def provide_token(
120
+ dialect: Any,
121
+ connection_record: Any,
122
+ connection_arguments: list[Any],
123
+ connection_parameters: dict[str, Any],
124
+ ) -> None:
125
+ del dialect, connection_record, connection_arguments
126
+ connection_parameters["password"] = provider()
127
+
128
+
129
+ def create_engine(
130
+ workspace_client: WorkspaceClientLike,
131
+ config: PostgresEngineConfig | None = None,
132
+ *,
133
+ credential_provider: CredentialProvider | None = None,
134
+ drivername: str = "postgresql+psycopg",
135
+ **engine_options: Any,
136
+ ) -> Engine:
137
+ resolved = resolve_postgres_connection(workspace_client, config)
138
+ engine = sqlalchemy_create_engine(resolved.url(drivername), **engine_options)
139
+ provider = credential_provider or _default_provider(workspace_client, resolved)
140
+ install_credential_injection(engine, provider)
141
+ return engine
142
+
143
+
144
+ def create_async_engine(
145
+ workspace_client: WorkspaceClientLike,
146
+ config: PostgresEngineConfig | None = None,
147
+ *,
148
+ credential_provider: CredentialProvider | None = None,
149
+ drivername: str = "postgresql+asyncpg",
150
+ **engine_options: Any,
151
+ ) -> AsyncEngine:
152
+ resolved = resolve_postgres_connection(workspace_client, config)
153
+ engine = sqlalchemy_create_async_engine(resolved.url(drivername), **engine_options)
154
+ provider = credential_provider or _default_provider(workspace_client, resolved)
155
+ install_credential_injection(engine.sync_engine, provider)
156
+ return engine
157
+
158
+
159
+ def resolve_postgres_connection(
160
+ workspace_client: WorkspaceClientLike,
161
+ config: PostgresEngineConfig | None = None,
162
+ *,
163
+ environ: Mapping[str, str] | None = None,
164
+ ) -> ResolvedPostgresConnection:
165
+ config = config or PostgresEngineConfig()
166
+ env = os.environ if environ is None else environ
167
+ raw_address = _first(config.address, config.endpoint, env.get("LAKEBASE_ENDPOINT"))
168
+ parsed = parse_address(raw_address)
169
+ instance_name = _first(config.instance_name, env.get("LAKEBASE_INSTANCE_NAME"))
170
+ project = _first(config.project, parsed.project)
171
+ branch = _first(config.branch, parsed.branch)
172
+ endpoint = _first(config.endpoint, env.get("LAKEBASE_ENDPOINT"), parsed.endpoint)
173
+ host = _first(config.host, env.get("PGHOST"), parsed.host)
174
+ database_value = _first(config.database, env.get("PGDATABASE"), parsed.database)
175
+ database_path = parse_address(database_value)
176
+ database_resource_id = parsed.database_resource_id or database_path.database_resource_id
177
+ database = None if database_resource_id else database_value
178
+ port = _parse_port(_first(config.port, env.get("PGPORT"), parsed.port))
179
+ ssl_mode = _parse_ssl_mode(_first(config.ssl_mode, env.get("PGSSLMODE"), parsed.ssl_mode))
180
+ user = _first(config.user, env.get("PGUSER"), parsed.user)
181
+
182
+ if instance_name and not host:
183
+ instance = workspace_client.database.get_database_instance(instance_name)
184
+ host = getattr(instance, "read_write_dns", None)
185
+
186
+ if not project and host and not instance_name:
187
+ found = _find_endpoint_by_host(workspace_client, host)
188
+ if found:
189
+ project, branch, endpoint = found
190
+
191
+ if not host and not instance_name:
192
+ project = project or _pick_project(workspace_client)
193
+ branch = branch or _pick_branch(workspace_client, project)
194
+ if endpoint:
195
+ endpoint_parts = parse_address(endpoint)
196
+ if endpoint_parts.project and endpoint_parts.branch and endpoint_parts.endpoint_id:
197
+ response = _get(
198
+ workspace_client,
199
+ f"{_API_BASE}/projects/{endpoint_parts.project}/branches/"
200
+ f"{endpoint_parts.branch}/endpoints/{endpoint_parts.endpoint_id}",
201
+ )
202
+ host = _endpoint_host(response)
203
+ if not host:
204
+ endpoint_record = _pick_endpoint(workspace_client, project, branch)
205
+ endpoint = _string(endpoint_record.get("name"))
206
+ host = _endpoint_host(endpoint_record)
207
+
208
+ if not database and project and branch:
209
+ database = _pick_database(workspace_client, project, branch, database_resource_id)
210
+ database = database or _DEFAULT_DATABASE
211
+ user = user or _workspace_user(workspace_client)
212
+
213
+ if not host:
214
+ raise ValueError("Could not resolve PGHOST from config, address, or WorkspaceClient")
215
+ if not user:
216
+ raise ValueError("Could not resolve PGUSER from config, address, or WorkspaceClient")
217
+ return ResolvedPostgresConnection(
218
+ host=host,
219
+ database=database,
220
+ user=user,
221
+ port=port,
222
+ ssl_mode=ssl_mode,
223
+ project=project,
224
+ branch=branch,
225
+ endpoint=endpoint,
226
+ instance_name=instance_name,
227
+ )
228
+
229
+
230
+ def _default_provider(
231
+ workspace_client: WorkspaceClientLike,
232
+ resolved: ResolvedPostgresConnection,
233
+ ) -> CredentialProvider:
234
+ if resolved.instance_name:
235
+ return workspace_credential_provider(workspace_client, resolved.instance_name)
236
+ if resolved.endpoint:
237
+ return autoscaling_credential_provider(workspace_client, resolved.endpoint)
238
+ raise ValueError(
239
+ "instance_name, endpoint, or credential_provider is required for connect-time credential injection"
240
+ )
241
+
242
+
243
+ def _workspace_user(workspace_client: WorkspaceClientLike) -> str | None:
244
+ current_user = workspace_client.current_user.me()
245
+ return _first(
246
+ getattr(current_user, "user_name", None),
247
+ getattr(current_user, "userName", None),
248
+ getattr(workspace_client.config, "client_id", None),
249
+ )
250
+
251
+
252
+ def _get(workspace_client: WorkspaceClientLike, path: str) -> dict[str, Any]:
253
+ value = workspace_client.api_client.do("GET", path, headers={"Accept": "application/json"})
254
+ if not isinstance(value, dict):
255
+ raise TypeError(f"WorkspaceClient returned a non-object response for {path}")
256
+ return value
257
+
258
+
259
+ def _list(workspace_client: WorkspaceClientLike, path: str, key: str) -> list[dict[str, Any]]:
260
+ values = _get(workspace_client, path).get(key, [])
261
+ if not isinstance(values, list):
262
+ raise TypeError(f"WorkspaceClient returned a non-list {key} response for {path}")
263
+ return [value for value in values if isinstance(value, dict)]
264
+
265
+
266
+ def _pick_project(workspace_client: WorkspaceClientLike) -> str:
267
+ projects = _list(workspace_client, f"{_API_BASE}/projects", "projects")
268
+ candidates = [_resource_part(project.get("name"), "project") for project in projects]
269
+ candidates = [candidate for candidate in candidates if candidate]
270
+ if len(candidates) != 1:
271
+ raise ValueError(f"Expected one Lakebase project, found: {', '.join(candidates) or 'none'}")
272
+ return candidates[0]
273
+
274
+
275
+ def _pick_branch(workspace_client: WorkspaceClientLike, project: str) -> str:
276
+ branches = _list(
277
+ workspace_client,
278
+ f"{_API_BASE}/projects/{project}/branches",
279
+ "branches",
280
+ )
281
+ selected = next(
282
+ (
283
+ branch
284
+ for branch in branches
285
+ if isinstance(branch.get("status"), dict) and branch["status"].get("default") is True
286
+ ),
287
+ branches[0] if len(branches) == 1 else None,
288
+ )
289
+ branch = _resource_part(selected.get("name") if selected else None, "branch")
290
+ if not branch:
291
+ raise ValueError(f"Could not choose a Lakebase branch for project {project}")
292
+ return branch
293
+
294
+
295
+ def _pick_endpoint(
296
+ workspace_client: WorkspaceClientLike,
297
+ project: str,
298
+ branch: str,
299
+ ) -> dict[str, Any]:
300
+ endpoints = _list(
301
+ workspace_client,
302
+ f"{_API_BASE}/projects/{project}/branches/{branch}/endpoints",
303
+ "endpoints",
304
+ )
305
+ selected = next(
306
+ (
307
+ endpoint
308
+ for endpoint in endpoints
309
+ if isinstance(endpoint.get("status"), dict)
310
+ and endpoint["status"].get("endpoint_type") == _READ_WRITE_ENDPOINT_TYPE
311
+ ),
312
+ endpoints[0] if len(endpoints) == 1 else None,
313
+ )
314
+ if not selected:
315
+ raise ValueError(f"Could not choose a Lakebase endpoint for {project}/{branch}")
316
+ return selected
317
+
318
+
319
+ def _pick_database(
320
+ workspace_client: WorkspaceClientLike,
321
+ project: str,
322
+ branch: str,
323
+ resource_id: str | None,
324
+ ) -> str:
325
+ databases = _list(
326
+ workspace_client,
327
+ f"{_API_BASE}/projects/{project}/branches/{branch}/databases",
328
+ "databases",
329
+ )
330
+ if resource_id:
331
+ databases = [
332
+ database
333
+ for database in databases
334
+ if _resource_part(database.get("name"), "database") == resource_id
335
+ ]
336
+ names = [
337
+ _string(database.get("status", {}).get("postgres_database"))
338
+ for database in databases
339
+ if isinstance(database.get("status"), dict)
340
+ ]
341
+ names = [name for name in names if name]
342
+ if _DEFAULT_DATABASE in names:
343
+ return _DEFAULT_DATABASE
344
+ if len(names) == 1:
345
+ return names[0]
346
+ raise ValueError(f"Could not choose a Lakebase database for {project}/{branch}")
347
+
348
+
349
+ def _find_endpoint_by_host(
350
+ workspace_client: WorkspaceClientLike,
351
+ host: str,
352
+ ) -> tuple[str, str, str] | None:
353
+ for project_record in _list(workspace_client, f"{_API_BASE}/projects", "projects"):
354
+ project = _resource_part(project_record.get("name"), "project")
355
+ if not project:
356
+ continue
357
+ for branch_record in _list(
358
+ workspace_client,
359
+ f"{_API_BASE}/projects/{project}/branches",
360
+ "branches",
361
+ ):
362
+ branch = _resource_part(branch_record.get("name"), "branch")
363
+ if not branch:
364
+ continue
365
+ for endpoint in _list(
366
+ workspace_client,
367
+ f"{_API_BASE}/projects/{project}/branches/{branch}/endpoints",
368
+ "endpoints",
369
+ ):
370
+ if _endpoint_host(endpoint) == host and (name := _string(endpoint.get("name"))):
371
+ return project, branch, name
372
+ return None
373
+
374
+
375
+ def _endpoint_host(endpoint: Mapping[str, Any]) -> str | None:
376
+ status = endpoint.get("status")
377
+ hosts = status.get("hosts") if isinstance(status, dict) else None
378
+ return _string(hosts.get("host")) if isinstance(hosts, dict) else None
379
+
380
+
381
+ def _resource_part(value: object, kind: str) -> str | None:
382
+ parsed: ParsedAddress = parse_resource_path(_string(value))
383
+ return {
384
+ "project": parsed.project,
385
+ "branch": parsed.branch,
386
+ "endpoint": parsed.endpoint_id,
387
+ "database": parsed.database_resource_id,
388
+ }[kind]
389
+
390
+
391
+ def _parse_port(value: object) -> int:
392
+ if value is None or value == "":
393
+ return _DEFAULT_PORT
394
+ try:
395
+ port = int(value)
396
+ except (TypeError, ValueError) as error:
397
+ raise ValueError(f"PGPORT must be a TCP port, got {value!r}") from error
398
+ if not 1 <= port <= 65535:
399
+ raise ValueError(f"PGPORT must be between 1 and 65535, got {port}")
400
+ return port
401
+
402
+
403
+ def _parse_ssl_mode(value: object) -> SslMode:
404
+ if value is None or value == "":
405
+ return _DEFAULT_SSL_MODE
406
+ mode = str(value).strip().lower()
407
+ if mode not in SSL_MODES:
408
+ raise ValueError(f"PGSSLMODE must be one of {', '.join(SSL_MODES)}, got {value!r}")
409
+ return mode # type: ignore[return-value]
410
+
411
+
412
+ def _first(*values: Any) -> Any:
413
+ return next((value for value in values if value is not None and value != ""), None)
414
+
415
+
416
+ def _string(value: object) -> str | None:
417
+ return value.strip() if isinstance(value, str) and value.strip() else None
418
+
419
+
420
+ createEngine = create_engine
421
+ createAsyncEngine = create_async_engine
422
+ installCredentialInjection = install_credential_injection
423
+ resolvePostgresConnection = resolve_postgres_connection
424
+ workspaceCredentialProvider = workspace_credential_provider
425
+ autoscalingCredentialProvider = autoscaling_credential_provider