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,422 @@
1
+ """The wrapper that every task of a rendered job runs (`python_wheel_task`, ADR-0035).
2
+
3
+ It resolves what Databricks substituted into the parameters, calls the pipeloom CLI in this
4
+ process (so a Spark session that already exists stays the active one), leaves a task value
5
+ for the job and exits with the core's exit code. Parameters are `--opt=value`, so an empty
6
+ value stays one argument.
7
+
8
+ Importing this module pulls in click and the pipeloom core, and nothing else.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import tempfile
17
+ from collections.abc import Callable, Mapping, Sequence
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Any, Literal, NoReturn
21
+
22
+ import click
23
+
24
+ from pipeloom.diagnostics import Diagnostic, Report, format_diagnostic
25
+ from pipeloom.plan.manifest import read_manifest
26
+ from pipeloom.plan.model import Plan
27
+ from pipeloom_databricks.client import DatabricksClient, connect
28
+ from pipeloom_databricks.finalize_states import unit_states, unit_states_json
29
+ from pipeloom_databricks.grouping import build_tasks, pipeline_units, task_key_map
30
+ from pipeloom_databricks.options import DatabricksDeployOptions
31
+ from pipeloom_databricks.render import deploy_options
32
+ from pipeloom_databricks.task_values import (
33
+ TASK_VALUE_KEY,
34
+ TaskValues,
35
+ runtime_task_values,
36
+ summary,
37
+ )
38
+
39
+ _RUN_ID = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
40
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
41
+
42
+
43
+ class _Context:
44
+ """What `main` hands to the commands: a stand-in for the platform's task values."""
45
+
46
+ def __init__(
47
+ self,
48
+ values: TaskValues | None,
49
+ client_factory: Callable[[], DatabricksClient] | None = None,
50
+ ) -> None:
51
+ self.values = values
52
+ self.client_factory = client_factory
53
+
54
+
55
+ def _show(diagnostics: Sequence[Diagnostic]) -> None:
56
+ """Diagnostics on stderr: text, or one JSON object per line like `--log-format json`."""
57
+ as_json = os.environ.get("PIPELOOM_LOG_FORMAT") == "json"
58
+ for d in diagnostics:
59
+ if as_json:
60
+ click.echo(json.dumps(d.to_json(), ensure_ascii=True), err=True)
61
+ else:
62
+ click.echo(format_diagnostic(d), err=True)
63
+
64
+
65
+ def _stop(report: Report) -> NoReturn:
66
+ """Show what the report found and exit 1."""
67
+ _show(report.sorted())
68
+ raise SystemExit(1)
69
+
70
+
71
+ def check_resolved(values: Mapping[str, str]) -> None:
72
+ """Exit 1 with PLD013 for every parameter that still holds a `{{...}}` reference."""
73
+ report = Report()
74
+ for name, value in sorted(values.items()):
75
+ if "{{" in value:
76
+ report.add(
77
+ "PLD013",
78
+ f"--{name} was not resolved by Databricks: {value}",
79
+ hint="the job or its runtime did not substitute the reference; "
80
+ "check the job parameters and the runtime version",
81
+ context={"parameter": name},
82
+ )
83
+ if report.has_errors:
84
+ _stop(report)
85
+
86
+
87
+ def resolve_run_id(param: str, job_run_id: str | None) -> str:
88
+ """The run id: the `pipeloom_run_id` job parameter, or `dbx-<job run id>` when it is empty."""
89
+ if param == "":
90
+ if not job_run_id:
91
+ raise click.UsageError("--run-id is empty and --job-run-id is missing")
92
+ param = f"dbx-{job_run_id}"
93
+ if not _RUN_ID.match(param):
94
+ raise click.BadParameter(
95
+ "use 1 to 64 letters, digits, dots, underscores or hyphens", param_hint="--run-id"
96
+ )
97
+ return param
98
+
99
+
100
+ def started_at(start_ms: str) -> str:
101
+ """The job start, `{{job.start_time.timestamp_ms}}`, as a UTC ISO 8601 timestamp."""
102
+ try:
103
+ ms = int(start_ms)
104
+ except ValueError:
105
+ raise click.BadParameter(
106
+ f"'{start_ms}' is not an integer", param_hint="--start-ms"
107
+ ) from None
108
+ return (_EPOCH + timedelta(milliseconds=ms)).isoformat(timespec="milliseconds")
109
+
110
+
111
+ def _full_refresh(value: str) -> bool:
112
+ if value.lower() in ("", "false"):
113
+ return False
114
+ if value.lower() == "true":
115
+ return True
116
+ raise click.BadParameter(f"'{value}' is not true or false", param_hint="--full-refresh")
117
+
118
+
119
+ def core_argv(
120
+ command: Literal["run", "finalize"],
121
+ *,
122
+ project_dir: str,
123
+ target: str,
124
+ manifest: str,
125
+ plan_hash: str,
126
+ run_id: str,
127
+ run_started_at: str,
128
+ full_refresh: bool = False,
129
+ select: Sequence[str] = (),
130
+ exclude: Sequence[str] = (),
131
+ ids: Mapping[str, str] | None = None,
132
+ unit_states: str | None = None,
133
+ ) -> list[str]:
134
+ """The arguments of the core command `pipeloom run` or `pipeloom finalize`."""
135
+ argv = [
136
+ command,
137
+ f"--project-dir={project_dir}",
138
+ f"--target={target}",
139
+ f"--manifest={manifest}",
140
+ f"--expect-plan-hash={plan_hash}",
141
+ f"--run-id={run_id}",
142
+ f"--run-started-at={run_started_at}",
143
+ ]
144
+ if command == "run":
145
+ argv.append(f"--full-refresh={'true' if full_refresh else 'false'}")
146
+ argv += [f"--select={s}" for s in select]
147
+ argv += [f"--exclude={s}" for s in exclude]
148
+ argv += [f"--orchestrator-id=databricks_{k}={v}" for k, v in (ids or {}).items() if v]
149
+ if unit_states is not None:
150
+ argv.append(f"--unit-states={unit_states}")
151
+ return argv
152
+
153
+
154
+ def _code(raw: object) -> int:
155
+ if raw is None:
156
+ return 0
157
+ if isinstance(raw, int):
158
+ return raw
159
+ click.echo(str(raw), err=True)
160
+ return 1
161
+
162
+
163
+ def _call_core(argv: Sequence[str]) -> int:
164
+ """Run the pipeloom CLI in this process; its exit code, never an exception."""
165
+ from pipeloom.cli import main as core
166
+
167
+ try:
168
+ result = core.main(args=list(argv), prog_name="pipeloom", standalone_mode=False)
169
+ except SystemExit as stop:
170
+ return _code(stop.code)
171
+ except click.ClickException as err:
172
+ err.show()
173
+ return err.exit_code
174
+ except click.Abort:
175
+ click.echo("Aborted!", err=True)
176
+ return 1
177
+ return result if isinstance(result, int) else 0
178
+
179
+
180
+ def _deploy(manifest: str, pipeline: str) -> tuple[Plan, DatabricksDeployOptions]:
181
+ """The plan of the shipped manifest and the pipeline's options, or exit 1."""
182
+ report = Report()
183
+ plan = read_manifest(Path(manifest), report)
184
+ opts = None if plan is None else deploy_options(plan, report)
185
+ if plan is None or opts is None:
186
+ _stop(report)
187
+ if pipeline not in opts.pipelines:
188
+ report.add("PLD004", f"pipeline '{pipeline}' is not in the deploy block")
189
+ _stop(report)
190
+ return plan, opts
191
+
192
+
193
+ def _members(manifest: str, pipeline: str, group: str) -> tuple[str, ...]:
194
+ """The units of a group task, recomputed from the manifest's deploy options."""
195
+ plan, opts = _deploy(manifest, pipeline)
196
+ report = Report()
197
+ tasks = build_tasks(plan, opts, pipeline, report)
198
+ if report.has_errors:
199
+ _stop(report)
200
+ for task in tasks:
201
+ if task.key == group:
202
+ return task.units
203
+ click.echo(f"error: pipeline '{pipeline}' has no task group '{group}'", err=True)
204
+ raise SystemExit(1)
205
+
206
+
207
+ def _attempts(opts: Mapping[str, str], run_id: str) -> dict[str, dict[str, Any]]:
208
+ """The newest attempt file of every unit of the run, read from the artifacts root."""
209
+ from pipeloom.api import Project, ProjectOptions
210
+ from pipeloom.artifacts.finalize import latest_attempts
211
+ from pipeloom.artifacts.layout import RunPaths
212
+ from pipeloom.runtime.run import artifacts_root
213
+
214
+ bound = (
215
+ Project(
216
+ ProjectOptions(
217
+ project_dir=Path(opts["project_dir"]), target=opts["target"], env=os.environ
218
+ )
219
+ )
220
+ .bind()
221
+ .value
222
+ )
223
+ if bound is None:
224
+ raise RuntimeError("the project could not be bound")
225
+ root = artifacts_root(bound, bound.loaded.project_dir.resolve())
226
+ return latest_attempts(RunPaths(root, run_id))
227
+
228
+
229
+ def _default_client() -> DatabricksClient:
230
+ return connect(profile=None, env=os.environ)
231
+
232
+
233
+ def _states_file(
234
+ ctx: click.Context,
235
+ opts: Mapping[str, str],
236
+ run_id: str,
237
+ key_map: Mapping[str, str],
238
+ folder: Path,
239
+ ) -> str | None:
240
+ """Write the unit states the Jobs API reports for the run; the path, or None for no states.
241
+
242
+ A unit whose task died wrote no attempt file, and only the Jobs API knows how its task ended.
243
+ Best effort: on any client error one warning goes to stderr and finalize runs without states,
244
+ so those units count as not run. A one-time run has no job run id to ask about.
245
+ """
246
+ job_run_id = opts["job_run_id"]
247
+ if not job_run_id:
248
+ return None
249
+ factory = ctx.obj.client_factory if isinstance(ctx.obj, _Context) else None
250
+ try:
251
+ client = (factory or _default_client)()
252
+ run = client.get_run(int(job_run_id))
253
+ states = unit_states(key_map, run, set(_attempts(opts, run_id)))
254
+ except Exception as exc: # noqa: BLE001 - task states are best effort
255
+ click.echo(
256
+ f"warning: task states not read, units without a result count as not run: "
257
+ f"{type(exc).__name__}: {exc}",
258
+ err=True,
259
+ )
260
+ return None
261
+ if not states:
262
+ return None
263
+ path = folder / "unit_states.json"
264
+ path.write_bytes(unit_states_json(states))
265
+ return path.as_posix()
266
+
267
+
268
+ def _publish(
269
+ values: TaskValues | None,
270
+ opts: Mapping[str, str],
271
+ run_id: str,
272
+ members: Sequence[str],
273
+ code: int,
274
+ ) -> None:
275
+ """Leave the summary of the members as the task value. Best effort: never changes the exit."""
276
+ if values is None:
277
+ return
278
+ try:
279
+ attempts = _attempts(opts, run_id)
280
+ rows = [attempts[u] for u in members if u in attempts]
281
+ status = {0: "succeeded", 130: "cancelled"}.get(code, "failed")
282
+ values.set(TASK_VALUE_KEY, summary(run_id, status, rows))
283
+ except Exception as exc: # noqa: BLE001 - task values are best effort
284
+ click.echo(f"warning: task value not set: {type(exc).__name__}: {exc}", err=True)
285
+
286
+
287
+ def _common(func: Callable[..., Any]) -> Callable[..., Any]:
288
+ """The options both commands share, in the order the renderer writes them."""
289
+ for flag, required in (
290
+ ("start-ms", True),
291
+ ("job-run-id", False),
292
+ ("job-id", False),
293
+ ("full-refresh", False),
294
+ ("run-id", False),
295
+ ("plan-hash", True),
296
+ ("pipeline", True),
297
+ ("target", True),
298
+ ("manifest", True),
299
+ ("project-dir", True),
300
+ ):
301
+ func = click.option(
302
+ f"--{flag}",
303
+ required=required,
304
+ default=None if required else "",
305
+ help="Set by the rendered job.",
306
+ )(func)
307
+ return func
308
+
309
+
310
+ @click.group(help="Run pipeloom units inside a Databricks job. Used by rendered jobs.")
311
+ def cli() -> None:
312
+ pass
313
+
314
+
315
+ @cli.command("unit")
316
+ @_common
317
+ @click.option("--task-run-id", default="", help="Set by the rendered job.")
318
+ @click.option("--task-key", default="", help="Set by the rendered job.")
319
+ @click.option("--group", default=None, help="The task group to run (needs task_grouping).")
320
+ @click.option("--unit", "unit_id", default=None, help="The unit to run.")
321
+ @click.pass_context
322
+ def unit_command(
323
+ ctx: click.Context,
324
+ /,
325
+ unit_id: str | None,
326
+ group: str | None,
327
+ task_key: str,
328
+ task_run_id: str,
329
+ **given: str,
330
+ ) -> None:
331
+ """Run one unit, or every unit of a group, and leave a task value."""
332
+ if (unit_id is None) == (group is None):
333
+ raise click.UsageError("use exactly one of --unit and --group")
334
+ named = {k.replace("_", "-"): v for k, v in given.items()}
335
+ named.update({"task-key": task_key, "task-run-id": task_run_id})
336
+ named.update({"unit": unit_id or "", "group": group or ""})
337
+ check_resolved(named)
338
+ run_id = resolve_run_id(given["run_id"], given["job_run_id"])
339
+ start = started_at(given["start_ms"])
340
+ refresh = _full_refresh(given["full_refresh"])
341
+ members = (
342
+ (unit_id,)
343
+ if unit_id is not None
344
+ else _members(given["manifest"], given["pipeline"], group or "")
345
+ )
346
+ argv = core_argv(
347
+ "run",
348
+ project_dir=given["project_dir"],
349
+ target=given["target"],
350
+ manifest=given["manifest"],
351
+ plan_hash=given["plan_hash"],
352
+ run_id=run_id,
353
+ run_started_at=start,
354
+ full_refresh=refresh,
355
+ select=[f"unit:{m}" for m in members],
356
+ ids={
357
+ "job_id": given["job_id"],
358
+ "job_run_id": given["job_run_id"],
359
+ "task_key": task_key,
360
+ "task_run_id": task_run_id,
361
+ },
362
+ )
363
+ code = _call_core(argv)
364
+ values = ctx.obj.values if isinstance(ctx.obj, _Context) else runtime_task_values()
365
+ _publish(values, given, run_id, members, code)
366
+ raise SystemExit(code)
367
+
368
+
369
+ @cli.command("finalize")
370
+ @_common
371
+ @click.pass_context
372
+ def finalize_command(ctx: click.Context, /, **given: str) -> None:
373
+ """Close the run: merge the unit results of the pipeline into run_results.json.
374
+
375
+ A unit that wrote no attempt file takes the state of its task from the Jobs API: a task that
376
+ failed, timed out or was cancelled fails or cancels the run.
377
+ """
378
+ check_resolved({k.replace("_", "-"): v for k, v in given.items()})
379
+ run_id = resolve_run_id(given["run_id"], given["job_run_id"])
380
+ start = started_at(given["start_ms"])
381
+ plan, opts = _deploy(given["manifest"], given["pipeline"])
382
+ report = Report()
383
+ units = pipeline_units(plan, opts, given["pipeline"], report)
384
+ if report.has_errors:
385
+ _stop(report)
386
+ key_map = task_key_map(plan, opts, given["pipeline"])
387
+ with tempfile.TemporaryDirectory(prefix="pipeloom-states-") as folder:
388
+ states = _states_file(ctx, given, run_id, key_map, Path(folder))
389
+ argv = core_argv(
390
+ "finalize",
391
+ project_dir=given["project_dir"],
392
+ target=given["target"],
393
+ manifest=given["manifest"],
394
+ plan_hash=given["plan_hash"],
395
+ run_id=run_id,
396
+ run_started_at=start,
397
+ # The shipped manifest has an empty selection, so the scope is spelled out unit by unit.
398
+ select=[f"unit:{u}" for u in units],
399
+ ids={"job_id": given["job_id"], "job_run_id": given["job_run_id"]},
400
+ unit_states=states,
401
+ )
402
+ code = _call_core(argv)
403
+ raise SystemExit(code)
404
+
405
+
406
+ def main(
407
+ argv: Sequence[str] | None = None,
408
+ *,
409
+ task_values: TaskValues | None = None,
410
+ client_factory: Callable[[], DatabricksClient] | None = None,
411
+ ) -> None:
412
+ """The entry point. `argv` defaults to `sys.argv[1:]`; always ends in `SystemExit`.
413
+
414
+ `task_values` replaces the platform's task values and `client_factory` the connection that
415
+ finalize reads task states through (a test passes a recorder and a fake client).
416
+ """
417
+ given = task_values is not None or client_factory is not None
418
+ cli.main(
419
+ args=None if argv is None else list(argv),
420
+ prog_name="pipeloom_databricks_task",
421
+ obj=_Context(task_values, client_factory) if given else None,
422
+ )
@@ -0,0 +1,69 @@
1
+ """The task value a unit task leaves for the job: a small summary of what its units did.
2
+
3
+ Task values are documented for notebooks; wheel tasks are unconfirmed, so everything here is
4
+ best effort. This is one of the three modules that import `databricks.sdk`, and it does so
5
+ lazily, only on a Databricks runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from collections.abc import Mapping, Sequence
13
+ from typing import Final, Protocol
14
+
15
+ from pydantic import JsonValue
16
+
17
+ TASK_VALUE_KEY: Final = "pipeloom"
18
+ TASK_VALUE_MAX_BYTES: Final = 32_768
19
+ """Well under the 48 KiB Databricks allows for one value."""
20
+
21
+
22
+ class TaskValues(Protocol):
23
+ def set(self, key: str, value: JsonValue) -> None: ...
24
+
25
+
26
+ def _size(doc: Mapping[str, JsonValue]) -> int:
27
+ return len(json.dumps(doc, separators=(",", ":"), ensure_ascii=True).encode("ascii"))
28
+
29
+
30
+ def summary(
31
+ run_id: str, status: str, rows: Sequence[Mapping[str, JsonValue]]
32
+ ) -> dict[str, JsonValue]:
33
+ """`{v, run_id, status, units}` from the newest attempt row of each unit.
34
+
35
+ Above `TASK_VALUE_MAX_BYTES` the units are dropped from the end and `truncated` is true.
36
+ """
37
+ units: dict[str, JsonValue] = {}
38
+ for row in sorted(rows, key=lambda r: str(r["unit_id"])):
39
+ error = row.get("error")
40
+ warnings = row.get("warnings")
41
+ units[str(row["unit_id"])] = {
42
+ "status": row.get("status"),
43
+ "attempt": row.get("attempt"),
44
+ "rows_written": row.get("rows_written"),
45
+ "duration_ms": row.get("duration_ms"),
46
+ "target_version": row.get("target_version"),
47
+ "error_code": error.get("code") if isinstance(error, dict) else None,
48
+ "warnings": len(warnings) if isinstance(warnings, list) else 0,
49
+ }
50
+ doc: dict[str, JsonValue] = {"v": 1, "run_id": run_id, "status": status, "units": units}
51
+ if _size(doc) <= TASK_VALUE_MAX_BYTES:
52
+ return doc
53
+ doc["truncated"] = True
54
+ while units and _size(doc) > TASK_VALUE_MAX_BYTES:
55
+ units.popitem()
56
+ return doc
57
+
58
+
59
+ def runtime_task_values() -> TaskValues | None:
60
+ """`dbutils.jobs.taskValues` on a Databricks runtime, else None."""
61
+ if not os.environ.get("DATABRICKS_RUNTIME_VERSION"):
62
+ return None
63
+ try:
64
+ from databricks.sdk.runtime import dbutils
65
+
66
+ values: TaskValues = dbutils.jobs.taskValues
67
+ except Exception: # noqa: BLE001 - no runtime utilities: skip the task value
68
+ return None
69
+ return values
@@ -0,0 +1 @@
1
+ """Test helpers for code that builds on the Databricks plugin. Nothing here reaches a workspace."""