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,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."""
|