pipeloom-databricks 0.5.0__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.
- pipeloom_databricks/__init__.py +18 -0
- pipeloom_databricks/api.py +45 -0
- pipeloom_databricks/bundle.py +113 -0
- pipeloom_databricks/cli.py +561 -0
- pipeloom_databricks/client/__init__.py +121 -0
- pipeloom_databricks/client/connect.py +84 -0
- pipeloom_databricks/client/sdk.py +275 -0
- pipeloom_databricks/deploy.py +188 -0
- pipeloom_databricks/finalize_states.py +72 -0
- pipeloom_databricks/grouping.py +158 -0
- pipeloom_databricks/jobs_model.py +116 -0
- pipeloom_databricks/keys.py +45 -0
- pipeloom_databricks/options.py +66 -0
- pipeloom_databricks/py.typed +0 -0
- pipeloom_databricks/render.py +396 -0
- pipeloom_databricks/repair.py +66 -0
- pipeloom_databricks/run.py +357 -0
- pipeloom_databricks/secrets.py +50 -0
- pipeloom_databricks/serialize.py +30 -0
- pipeloom_databricks/spec.py +75 -0
- pipeloom_databricks/task.py +422 -0
- pipeloom_databricks/task_values.py +69 -0
- pipeloom_databricks/testing/__init__.py +1 -0
- pipeloom_databricks/testing/fake.py +293 -0
- pipeloom_databricks/testing/jobsim.py +179 -0
- pipeloom_databricks/unity.py +23 -0
- pipeloom_databricks/upload.py +217 -0
- pipeloom_databricks-0.5.0.dist-info/METADATA +114 -0
- pipeloom_databricks-0.5.0.dist-info/RECORD +33 -0
- pipeloom_databricks-0.5.0.dist-info/WHEEL +4 -0
- pipeloom_databricks-0.5.0.dist-info/entry_points.txt +14 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/LICENSE +202 -0
- pipeloom_databricks-0.5.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Start a run of a deployed job (or a one-time run), find a run by its pipeloom id, and wait.
|
|
2
|
+
|
|
3
|
+
Nothing here builds a client: the caller hands one in, so every function runs on `FakeDatabricks`
|
|
4
|
+
in tests. API failures become PLD009 in the `Report`; a function that cannot go on returns None.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from pipeloom.diagnostics import Report, Severity
|
|
20
|
+
from pipeloom.graph.selectors import Selection, SelectorError, apply_to_plan
|
|
21
|
+
from pipeloom.plan.model import Plan
|
|
22
|
+
from pipeloom.secrets.redaction import Redactor
|
|
23
|
+
from pipeloom_databricks.bundle import Runtime
|
|
24
|
+
from pipeloom_databricks.client import ApiError, DatabricksClient, RunInfo
|
|
25
|
+
from pipeloom_databricks.deploy import (
|
|
26
|
+
HASH_TAG,
|
|
27
|
+
JOB_TAG,
|
|
28
|
+
Prepared,
|
|
29
|
+
api_errors,
|
|
30
|
+
deploy_prepared,
|
|
31
|
+
find_job,
|
|
32
|
+
prepare,
|
|
33
|
+
)
|
|
34
|
+
from pipeloom_databricks.jobs_model import SubmitSpec, TaskDependency, to_json
|
|
35
|
+
from pipeloom_databricks.keys import FINALIZE_KEY
|
|
36
|
+
from pipeloom_databricks.render import render_submit
|
|
37
|
+
from pipeloom_databricks.upload import prune, upload
|
|
38
|
+
|
|
39
|
+
TERMINAL: Final = frozenset({"TERMINATED", "SKIPPED", "INTERNAL_ERROR"})
|
|
40
|
+
"""Life cycle states after which a run does not change."""
|
|
41
|
+
POLL_SECONDS: Final = 15
|
|
42
|
+
_DBX_RUN = re.compile(r"^dbx-(\d+)$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SelectionError(Exception):
|
|
46
|
+
"""`--select` and `--exclude` are unusable or match no unit of the pipeline (exit 2)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WaitTimeout(Exception):
|
|
50
|
+
"""The run did not end within the time the caller allowed (exit 1)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class RunStarted:
|
|
55
|
+
job_id: int | None
|
|
56
|
+
"""None for a one-time run."""
|
|
57
|
+
job_run_id: int
|
|
58
|
+
run_id: str | None
|
|
59
|
+
"""The pipeloom run id given or generated; None when the job names it `dbx-<job_run_id>`."""
|
|
60
|
+
run_page_url: str | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _digest(text: str) -> str:
|
|
64
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _page_url(client: DatabricksClient, job_run_id: int) -> str | None:
|
|
68
|
+
try:
|
|
69
|
+
return client.get_run(job_run_id).run_page_url
|
|
70
|
+
except ApiError:
|
|
71
|
+
return None # the link is a courtesy: never fail a started run over it
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def task_keys_for(
|
|
75
|
+
prepared: Prepared, plan: Plan, selection: Selection | None, report: Report
|
|
76
|
+
) -> list[str] | None:
|
|
77
|
+
"""The task keys the selection reaches, or None for no selection.
|
|
78
|
+
|
|
79
|
+
Raises `SelectionError` for a bad selector or when no unit of the pipeline is selected. A
|
|
80
|
+
group that is only partly selected runs whole, which is a warning.
|
|
81
|
+
"""
|
|
82
|
+
if selection is None or not (selection.select or selection.exclude):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
chosen = apply_to_plan(selection, plan, Report())
|
|
86
|
+
except SelectorError as err:
|
|
87
|
+
raise SelectionError(str(err)) from err
|
|
88
|
+
keys: list[str] = []
|
|
89
|
+
for task in prepared.job.tasks:
|
|
90
|
+
picked = [u for u in task.units if plan.units[u].nodes[-1] in chosen]
|
|
91
|
+
if not picked:
|
|
92
|
+
continue
|
|
93
|
+
keys.append(task.key)
|
|
94
|
+
if len(picked) < len(task.units):
|
|
95
|
+
report.add(
|
|
96
|
+
"PLD015",
|
|
97
|
+
f"the selection covers part of task group '{task.key}'; the whole group runs",
|
|
98
|
+
severity=Severity.WARNING,
|
|
99
|
+
context={"group": task.key},
|
|
100
|
+
)
|
|
101
|
+
if not keys:
|
|
102
|
+
raise SelectionError("the selection matches no unit of the pipeline")
|
|
103
|
+
return keys
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_job(
|
|
107
|
+
client: DatabricksClient,
|
|
108
|
+
project_dir: Path,
|
|
109
|
+
plan: Plan,
|
|
110
|
+
pipeline: str,
|
|
111
|
+
runtime: Runtime,
|
|
112
|
+
*,
|
|
113
|
+
selection: Selection | None,
|
|
114
|
+
run_id: str | None,
|
|
115
|
+
full_refresh: bool,
|
|
116
|
+
ensure: bool,
|
|
117
|
+
report: Report,
|
|
118
|
+
redactor: Redactor | None = None,
|
|
119
|
+
versions: Mapping[str, str] | None = None,
|
|
120
|
+
) -> RunStarted | None:
|
|
121
|
+
"""Run the deployed job of `pipeline` now.
|
|
122
|
+
|
|
123
|
+
PLD007 when no job is deployed and PLD008 when the deployed job differs from the local
|
|
124
|
+
render, unless `ensure` deploys first. With `selection`, only the tasks it reaches (and
|
|
125
|
+
finalize) run. `run_id` names the run and makes the start idempotent. Raises `SelectionError`.
|
|
126
|
+
"""
|
|
127
|
+
with api_errors(report, redactor):
|
|
128
|
+
prepared = prepare(
|
|
129
|
+
project_dir,
|
|
130
|
+
plan,
|
|
131
|
+
pipeline,
|
|
132
|
+
runtime,
|
|
133
|
+
report,
|
|
134
|
+
user=client.current_user,
|
|
135
|
+
versions=versions,
|
|
136
|
+
)
|
|
137
|
+
if prepared is None:
|
|
138
|
+
return None
|
|
139
|
+
job = prepared.job
|
|
140
|
+
keys = task_keys_for(prepared, plan, selection, report)
|
|
141
|
+
existing = find_job(client, job.job_tag, name=job.settings.name)
|
|
142
|
+
stale = existing is None or existing.tags.get(HASH_TAG) != job.settings_hash
|
|
143
|
+
if stale and not ensure:
|
|
144
|
+
if existing is None:
|
|
145
|
+
report.add(
|
|
146
|
+
"PLD007",
|
|
147
|
+
f"no job carries {JOB_TAG}={job.job_tag}",
|
|
148
|
+
hint="deploy it with 'pipeloom databricks deploy', or pass --ensure",
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
report.add(
|
|
152
|
+
"PLD008",
|
|
153
|
+
f"job {existing.job_id} was deployed from other settings than the project "
|
|
154
|
+
"renders now",
|
|
155
|
+
hint="deploy it again with 'pipeloom databricks deploy', or pass --ensure",
|
|
156
|
+
context={"job_id": existing.job_id},
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
if stale:
|
|
160
|
+
job_id = deploy_prepared(
|
|
161
|
+
client, prepared, existing, report=report, redactor=redactor
|
|
162
|
+
).job_id
|
|
163
|
+
else:
|
|
164
|
+
assert existing is not None
|
|
165
|
+
job_id = existing.job_id
|
|
166
|
+
params = {"full_refresh": "true" if full_refresh else "false"}
|
|
167
|
+
token = None
|
|
168
|
+
if run_id is not None:
|
|
169
|
+
params["pipeloom_run_id"] = run_id
|
|
170
|
+
token = _digest(f"{job_id}:{run_id}")
|
|
171
|
+
only = None if keys is None else [*keys, FINALIZE_KEY]
|
|
172
|
+
job_run_id = client.run_now(
|
|
173
|
+
job_id, job_parameters=params, only=only, idempotency_token=token
|
|
174
|
+
)
|
|
175
|
+
return RunStarted(job_id, job_run_id, run_id, _page_url(client, job_run_id))
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _restrict(spec: SubmitSpec, keys: list[str]) -> SubmitSpec:
|
|
180
|
+
"""Keep the selected tasks and finalize; finalize then waits for the selection's leaves."""
|
|
181
|
+
inner = {t.task_key for t in spec.tasks if t.task_key in keys or t.task_key == FINALIZE_KEY}
|
|
182
|
+
waited = {
|
|
183
|
+
d.task_key
|
|
184
|
+
for t in spec.tasks
|
|
185
|
+
if t.task_key in inner and t.task_key != FINALIZE_KEY
|
|
186
|
+
for d in t.depends_on
|
|
187
|
+
}
|
|
188
|
+
tasks = []
|
|
189
|
+
for t in spec.tasks:
|
|
190
|
+
if t.task_key not in inner:
|
|
191
|
+
continue
|
|
192
|
+
if t.task_key == FINALIZE_KEY:
|
|
193
|
+
deps = tuple(TaskDependency(k) for k in sorted(inner - waited - {FINALIZE_KEY}))
|
|
194
|
+
else:
|
|
195
|
+
deps = tuple(d for d in t.depends_on if d.task_key in inner)
|
|
196
|
+
tasks.append(replace(t, depends_on=deps))
|
|
197
|
+
return replace(spec, tasks=tuple(tasks))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def submit_ephemeral(
|
|
201
|
+
client: DatabricksClient,
|
|
202
|
+
project_dir: Path,
|
|
203
|
+
plan: Plan,
|
|
204
|
+
pipeline: str,
|
|
205
|
+
runtime: Runtime,
|
|
206
|
+
*,
|
|
207
|
+
selection: Selection | None,
|
|
208
|
+
full_refresh: bool,
|
|
209
|
+
report: Report,
|
|
210
|
+
redactor: Redactor | None = None,
|
|
211
|
+
versions: Mapping[str, str] | None = None,
|
|
212
|
+
run_id: str | None = None,
|
|
213
|
+
start_ms: int | None = None,
|
|
214
|
+
) -> RunStarted | None:
|
|
215
|
+
"""Run `pipeline` once without a job: upload the project as deploy does, then runs/submit.
|
|
216
|
+
|
|
217
|
+
The run id is `run_id` or a generated one, and the start is idempotent on it. Raises
|
|
218
|
+
`SelectionError`.
|
|
219
|
+
"""
|
|
220
|
+
with api_errors(report, redactor):
|
|
221
|
+
prepared = prepare(
|
|
222
|
+
project_dir,
|
|
223
|
+
plan,
|
|
224
|
+
pipeline,
|
|
225
|
+
runtime,
|
|
226
|
+
report,
|
|
227
|
+
user=client.current_user,
|
|
228
|
+
versions=versions,
|
|
229
|
+
)
|
|
230
|
+
if prepared is None:
|
|
231
|
+
return None
|
|
232
|
+
keys = task_keys_for(prepared, plan, selection, report)
|
|
233
|
+
local_id = run_id or f"eph-{uuid.uuid4().hex[:12]}"
|
|
234
|
+
spec = render_submit(
|
|
235
|
+
plan,
|
|
236
|
+
pipeline,
|
|
237
|
+
prepared.locations,
|
|
238
|
+
report,
|
|
239
|
+
run_id=local_id,
|
|
240
|
+
full_refresh=full_refresh,
|
|
241
|
+
start_ms=int(time.time() * 1000) if start_ms is None else start_ms,
|
|
242
|
+
versions=versions,
|
|
243
|
+
)
|
|
244
|
+
if spec is None:
|
|
245
|
+
return None
|
|
246
|
+
if keys is not None:
|
|
247
|
+
spec = _restrict(spec, keys)
|
|
248
|
+
upload(client, prepared.payload, prepared.root)
|
|
249
|
+
prune(
|
|
250
|
+
client,
|
|
251
|
+
prepared.parent,
|
|
252
|
+
prepared.payload.digest[:12],
|
|
253
|
+
report,
|
|
254
|
+
mask=redactor.mask if redactor else str,
|
|
255
|
+
)
|
|
256
|
+
job_run_id = client.submit_run(to_json(spec), idempotency_token=_digest(local_id))
|
|
257
|
+
return RunStarted(None, job_run_id, local_id, _page_url(client, job_run_id))
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def find_run(client: DatabricksClient, job_id: int, run_id: str) -> RunInfo | None:
|
|
262
|
+
"""The run of job `job_id` that has pipeloom run id `run_id`, or None.
|
|
263
|
+
|
|
264
|
+
`dbx-<n>` is job run `n`; any other id is looked up in the job's runs by the
|
|
265
|
+
`pipeloom_run_id` job parameter (newest first, as the API lists them).
|
|
266
|
+
"""
|
|
267
|
+
named = _DBX_RUN.match(run_id)
|
|
268
|
+
if named:
|
|
269
|
+
try:
|
|
270
|
+
run = client.get_run(int(named.group(1)))
|
|
271
|
+
except ApiError as err:
|
|
272
|
+
if err.status == 404 or err.error_code == "RESOURCE_DOES_NOT_EXIST":
|
|
273
|
+
return None
|
|
274
|
+
raise
|
|
275
|
+
return run if run.job_id == job_id else None
|
|
276
|
+
for run in client.list_runs(job_id):
|
|
277
|
+
if run.job_parameters.get("pipeloom_run_id") == run_id:
|
|
278
|
+
return run
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def effective_run_id(run: RunInfo) -> str:
|
|
283
|
+
"""The pipeloom run id of a job run: its parameter, else `dbx-<job run id>`."""
|
|
284
|
+
return run.job_parameters.get("pipeloom_run_id") or f"dbx-{run.run_id}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _line(life: str, result: str | None) -> str:
|
|
288
|
+
return f"{life}/{result}" if result else life
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def wait_for_run(
|
|
292
|
+
client: DatabricksClient,
|
|
293
|
+
job_run_id: int,
|
|
294
|
+
*,
|
|
295
|
+
sleep: Callable[[float], None],
|
|
296
|
+
say: Callable[[str], None],
|
|
297
|
+
poll: float = POLL_SECONDS,
|
|
298
|
+
repair_id: int | None = None,
|
|
299
|
+
timeout: float | None = None,
|
|
300
|
+
) -> RunInfo:
|
|
301
|
+
"""Poll until the run ends, saying every task transition. Raises `ApiError`, and
|
|
302
|
+
`WaitTimeout` once `timeout` seconds of polling have passed.
|
|
303
|
+
|
|
304
|
+
After a repair, `repair_id` makes the wait ignore the terminal state the run still shows
|
|
305
|
+
right after the repair: it ends only when the run reports that repair or a later one
|
|
306
|
+
(`latest_repair_id >=`) and, for our own repair, when its repair history entry is terminal or
|
|
307
|
+
the run was seen active at least once. A newer repair by someone else ends the wait, since
|
|
308
|
+
ours is over. `timeout` is the backstop for a history entry without a usable state.
|
|
309
|
+
"""
|
|
310
|
+
seen: dict[str, str] = {}
|
|
311
|
+
active = False
|
|
312
|
+
waited = 0.0
|
|
313
|
+
while True:
|
|
314
|
+
run = client.get_run(job_run_id)
|
|
315
|
+
now = {"run": _line(run.life_cycle_state, run.result_state)}
|
|
316
|
+
now.update({t.task_key: _line(t.life_cycle_state, t.result_state) for t in run.tasks})
|
|
317
|
+
for key, state in now.items():
|
|
318
|
+
if seen.get(key) != state:
|
|
319
|
+
say(f"{key}: {state}")
|
|
320
|
+
seen = now
|
|
321
|
+
terminal = run.life_cycle_state in TERMINAL
|
|
322
|
+
if not terminal:
|
|
323
|
+
active = True
|
|
324
|
+
if terminal and repair_id is None:
|
|
325
|
+
return run
|
|
326
|
+
if terminal and repair_id is not None:
|
|
327
|
+
latest = run.latest_repair_id or 0
|
|
328
|
+
ours_done = run.repair_states.get(repair_id) in TERMINAL
|
|
329
|
+
if latest > repair_id or (latest == repair_id and (ours_done or active)):
|
|
330
|
+
return run
|
|
331
|
+
if timeout is not None and waited >= timeout:
|
|
332
|
+
raise WaitTimeout(f"run {job_run_id} did not end within {timeout:g} seconds")
|
|
333
|
+
sleep(poll)
|
|
334
|
+
waited += poll
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def failure_lines(client: DatabricksClient, artifacts_root: str | None, run_id: str) -> list[str]:
|
|
338
|
+
"""One line per failed unit of the run, read from run_results.json on the artifacts volume.
|
|
339
|
+
|
|
340
|
+
A line that says why the file could not be read stands in when it is missing or unusable.
|
|
341
|
+
"""
|
|
342
|
+
if not artifacts_root:
|
|
343
|
+
return []
|
|
344
|
+
path = f"{artifacts_root.rstrip('/')}/runs/{run_id}/run_results.json"
|
|
345
|
+
try:
|
|
346
|
+
doc = json.loads(client.read_file(path).decode("utf-8"))
|
|
347
|
+
rows = doc["results"]
|
|
348
|
+
except ApiError as err:
|
|
349
|
+
return [f"run_results.json not read ({path}): {err.message}"]
|
|
350
|
+
except (ValueError, KeyError, TypeError):
|
|
351
|
+
return [f"run_results.json is not usable ({path})"]
|
|
352
|
+
lines = []
|
|
353
|
+
for row in rows:
|
|
354
|
+
if row.get("status") in ("failed", "timed_out"):
|
|
355
|
+
error = row.get("error") or {}
|
|
356
|
+
lines.append(f"{row['unit_id']}: {error.get('code', '')} {error.get('message', '')}")
|
|
357
|
+
return [line.rstrip() for line in lines]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""`${secret:databricks/<scope>/<key>}`: a Databricks secret scope entry (ADR-0036).
|
|
2
|
+
|
|
3
|
+
The client is built on the first reference and kept for the life of the provider, which the core
|
|
4
|
+
builds once per invocation. No profile is chosen: the connection rules of `connect` apply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
from pipeloom.secrets.resolve import SecretError
|
|
15
|
+
from pipeloom_databricks.client import (
|
|
16
|
+
ApiError,
|
|
17
|
+
ConnectionNotConfigured,
|
|
18
|
+
DatabricksClient,
|
|
19
|
+
connect,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
PROVIDER = "databricks"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DatabricksSecretProvider:
|
|
26
|
+
def __init__(self, env: Mapping[str, str] | None = None) -> None:
|
|
27
|
+
self._env = os.environ if env is None else env
|
|
28
|
+
self._client: DatabricksClient | None = None
|
|
29
|
+
|
|
30
|
+
def _connection(self, path: str) -> DatabricksClient:
|
|
31
|
+
if self._client is None:
|
|
32
|
+
try:
|
|
33
|
+
self._client = connect(profile=None, env=self._env)
|
|
34
|
+
except ConnectionNotConfigured as err:
|
|
35
|
+
raise SecretError(PROVIDER, path, f"no Databricks connection ({err})") from None
|
|
36
|
+
return self._client
|
|
37
|
+
|
|
38
|
+
def resolve(self, path: str) -> str:
|
|
39
|
+
scope, sep, key = path.partition("/")
|
|
40
|
+
if not (scope and sep and key):
|
|
41
|
+
raise SecretError(PROVIDER, path, "the path must be '<scope>/<key>'")
|
|
42
|
+
client = self._connection(path)
|
|
43
|
+
try:
|
|
44
|
+
encoded = client.get_secret(scope, key)
|
|
45
|
+
except ApiError as err:
|
|
46
|
+
raise SecretError(PROVIDER, path, f"API error {err.status}: {err.message}") from None
|
|
47
|
+
try:
|
|
48
|
+
return base64.b64decode(encoded, validate=True).decode("utf-8")
|
|
49
|
+
except (binascii.Error, UnicodeDecodeError, ValueError):
|
|
50
|
+
raise SecretError(PROVIDER, path, "the value is not base64 encoded UTF-8") from None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Bytes for the rendered documents: the same input always gives the same bytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import JsonValue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_json_bytes(doc: Mapping[str, JsonValue]) -> bytes:
|
|
13
|
+
"""Indented by 2, ASCII only, LF, with a final newline. Keys keep their order."""
|
|
14
|
+
return (json.dumps(doc, indent=2, ensure_ascii=True, allow_nan=False) + "\n").encode("ascii")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_bundle_yaml(resource_key: str, settings: Mapping[str, JsonValue], header: str) -> bytes:
|
|
18
|
+
"""A bundle file holding one job under `resources.jobs.<resource_key>`.
|
|
19
|
+
|
|
20
|
+
`header` is comment text, one `#` line per line of it.
|
|
21
|
+
"""
|
|
22
|
+
comment = "".join(f"# {line}".rstrip() + "\n" for line in header.splitlines())
|
|
23
|
+
body = yaml.safe_dump(
|
|
24
|
+
{"resources": {"jobs": {resource_key: dict(settings)}}},
|
|
25
|
+
sort_keys=False,
|
|
26
|
+
width=4096,
|
|
27
|
+
allow_unicode=False,
|
|
28
|
+
default_flow_style=False,
|
|
29
|
+
)
|
|
30
|
+
return (comment + body).encode("ascii")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The plugin spec. It imports no Databricks library and no engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from pipeloom.engine.protocols import Catalog, SecretProvider
|
|
10
|
+
from pipeloom.engine.types import CatalogCapabilities
|
|
11
|
+
from pipeloom.plugins import API_VERSION, OptionsModel, PluginSpec
|
|
12
|
+
from pipeloom_databricks.options import DatabricksDeployOptions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnityOptions(OptionsModel):
|
|
16
|
+
catalog: str = Field(min_length=1)
|
|
17
|
+
"""Unity Catalog catalog used when a target's naming leaves the catalog empty.
|
|
18
|
+
|
|
19
|
+
Never empty: an empty catalog would give two-part names that resolve against the
|
|
20
|
+
session's current catalog."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecretsOptions(OptionsModel):
|
|
24
|
+
"""No options: the connection comes from the environment, never from a chosen profile."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unity(opts: Any) -> Catalog:
|
|
28
|
+
from pipeloom_databricks.unity import UnityCatalog
|
|
29
|
+
|
|
30
|
+
return UnityCatalog(opts.catalog)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _secrets(_opts: Any) -> SecretProvider:
|
|
34
|
+
from pipeloom_databricks.secrets import DatabricksSecretProvider
|
|
35
|
+
|
|
36
|
+
return DatabricksSecretProvider()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _options(opts: Any) -> DatabricksDeployOptions:
|
|
40
|
+
# The core binds and records the options but never builds a renderer (ADR-0034).
|
|
41
|
+
assert isinstance(opts, DatabricksDeployOptions)
|
|
42
|
+
return opts
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
RENDERER_SPEC: PluginSpec[DatabricksDeployOptions] = PluginSpec(
|
|
46
|
+
"databricks",
|
|
47
|
+
"databricks",
|
|
48
|
+
"renderers",
|
|
49
|
+
API_VERSION,
|
|
50
|
+
_options,
|
|
51
|
+
DatabricksDeployOptions,
|
|
52
|
+
description="Render a plan as Databricks Jobs settings JSON and bundle YAML.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
UNITY_SPEC: PluginSpec[Catalog] = PluginSpec(
|
|
56
|
+
"unity",
|
|
57
|
+
"unity",
|
|
58
|
+
"catalogs",
|
|
59
|
+
API_VERSION,
|
|
60
|
+
_unity,
|
|
61
|
+
UnityOptions,
|
|
62
|
+
compatible_engines=frozenset({"spark"}),
|
|
63
|
+
capabilities=CatalogCapabilities(views=True),
|
|
64
|
+
description="Unity Catalog tables and views as three-part names through the Spark session.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
SECRETS_SPEC: PluginSpec[SecretProvider] = PluginSpec(
|
|
68
|
+
"databricks",
|
|
69
|
+
"databricks",
|
|
70
|
+
"secret_providers",
|
|
71
|
+
API_VERSION,
|
|
72
|
+
_secrets,
|
|
73
|
+
SecretsOptions,
|
|
74
|
+
description="Reads Databricks secret scopes: ${secret:databricks/<scope>/<key>}.",
|
|
75
|
+
)
|