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.
@@ -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
+ )