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,561 @@
|
|
|
1
|
+
"""The `pipeloom databricks` command group, added through the `pipeloom.commands` entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Final
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from pipeloom.api import Project, ProjectOptions
|
|
17
|
+
from pipeloom.cli._events import Invocation
|
|
18
|
+
from pipeloom.cli._options import (
|
|
19
|
+
log_format_option,
|
|
20
|
+
plugin_option,
|
|
21
|
+
project_options,
|
|
22
|
+
select_options,
|
|
23
|
+
target_option,
|
|
24
|
+
)
|
|
25
|
+
from pipeloom.config.bind import BoundProject
|
|
26
|
+
from pipeloom.diagnostics import Report
|
|
27
|
+
from pipeloom.engine.types import StateStoreCapabilities
|
|
28
|
+
from pipeloom.graph.selectors import Selection
|
|
29
|
+
from pipeloom.plan.model import Plan
|
|
30
|
+
from pipeloom.runtime.run import DEFAULT_CANCEL_GRACE
|
|
31
|
+
from pipeloom_databricks.bundle import Runtime, find_bundle_root, relative_to_root, render_bundle
|
|
32
|
+
from pipeloom_databricks.client import (
|
|
33
|
+
ConnectionNotConfigured,
|
|
34
|
+
DatabricksClient,
|
|
35
|
+
connect,
|
|
36
|
+
)
|
|
37
|
+
from pipeloom_databricks.deploy import api_errors, find_job, prepare
|
|
38
|
+
from pipeloom_databricks.deploy import deploy as deploy_pipeline
|
|
39
|
+
from pipeloom_databricks.jobs_model import to_json
|
|
40
|
+
from pipeloom_databricks.options import DatabricksDeployOptions
|
|
41
|
+
from pipeloom_databricks.render import deploy_options, job_name
|
|
42
|
+
from pipeloom_databricks.repair import repair as repair_run
|
|
43
|
+
from pipeloom_databricks.run import (
|
|
44
|
+
SelectionError,
|
|
45
|
+
WaitTimeout,
|
|
46
|
+
effective_run_id,
|
|
47
|
+
failure_lines,
|
|
48
|
+
find_run,
|
|
49
|
+
run_job,
|
|
50
|
+
submit_ephemeral,
|
|
51
|
+
wait_for_run,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
CONNECTION_KEY: Final = "pipeloom_databricks.connection"
|
|
55
|
+
SLEEP_KEY: Final = "pipeloom_databricks.sleep"
|
|
56
|
+
DEFAULT_TIMEOUT: Final = 24 * 3600
|
|
57
|
+
_RUN_ID = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Hooks:
|
|
62
|
+
"""What a test hands to the command group as `obj`: a stand-in for `time.sleep`."""
|
|
63
|
+
|
|
64
|
+
sleep: Callable[[float], None] = time.sleep
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load(
|
|
68
|
+
inv: Invocation,
|
|
69
|
+
project_dir: Path,
|
|
70
|
+
target: str | None,
|
|
71
|
+
vars_: dict[str, Any],
|
|
72
|
+
plugins: tuple[str, ...],
|
|
73
|
+
) -> tuple[Plan, BoundProject, Runtime, Report]:
|
|
74
|
+
"""Compile and bind the project; ends the command with exit 1 when that fails."""
|
|
75
|
+
project = Project(
|
|
76
|
+
ProjectOptions(
|
|
77
|
+
project_dir=project_dir, target=target, vars=vars_, plugins=plugins, env=os.environ
|
|
78
|
+
),
|
|
79
|
+
invocation_id=inv.invocation_id,
|
|
80
|
+
)
|
|
81
|
+
compiled = project.compile()
|
|
82
|
+
bound = project.bind().value
|
|
83
|
+
if compiled.value is None or bound is None:
|
|
84
|
+
inv.finish(compiled.diagnostics, 1, bound=bound)
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
store = bound.plugins.get("state_stores")
|
|
87
|
+
caps = None if store is None else store.spec.capabilities
|
|
88
|
+
grace = bound.runtime.cancel_grace
|
|
89
|
+
runtime = Runtime(
|
|
90
|
+
artifacts_root=bound.runtime.artifacts.root,
|
|
91
|
+
state_shared=isinstance(caps, StateStoreCapabilities) and caps.shared,
|
|
92
|
+
cancel_grace=int(DEFAULT_CANCEL_GRACE if grace is None else grace),
|
|
93
|
+
)
|
|
94
|
+
report = Report()
|
|
95
|
+
report.extend(compiled.diagnostics)
|
|
96
|
+
return compiled.value, bound, runtime, report
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.group()
|
|
100
|
+
@click.option(
|
|
101
|
+
"--profile",
|
|
102
|
+
metavar="NAME",
|
|
103
|
+
default=None,
|
|
104
|
+
help="The Databricks profile to use. Without it, DATABRICKS_CONFIG_PROFILE, then "
|
|
105
|
+
"DATABRICKS_HOST, then the runtime; pipeloom never picks a profile by itself.",
|
|
106
|
+
)
|
|
107
|
+
@click.option("--client-factory", hidden=True, default=None, help="MODULE:ATTR of a test client.")
|
|
108
|
+
@click.pass_context
|
|
109
|
+
def databricks(ctx: click.Context, profile: str | None, client_factory: str | None) -> None:
|
|
110
|
+
"""Databricks jobs for a pipeloom project."""
|
|
111
|
+
ctx.meta[CONNECTION_KEY] = (profile, client_factory)
|
|
112
|
+
ctx.meta[SLEEP_KEY] = ctx.obj.sleep if isinstance(ctx.obj, Hooks) else time.sleep
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _connect(
|
|
116
|
+
ctx: click.Context, inv: Invocation, report: Report, bound: BoundProject | None
|
|
117
|
+
) -> DatabricksClient:
|
|
118
|
+
"""The client the group options select; ends the command with PLD010 (exit 1) when none."""
|
|
119
|
+
profile, factory = ctx.meta.get(CONNECTION_KEY, (None, None))
|
|
120
|
+
try:
|
|
121
|
+
return connect(profile=profile, env=os.environ, factory=factory)
|
|
122
|
+
except ConnectionNotConfigured as err:
|
|
123
|
+
report.add("PLD010", str(err))
|
|
124
|
+
inv.finish(report.sorted(), 1, bound=bound)
|
|
125
|
+
raise SystemExit(1) from None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _pipeline(name: str | None, opts: DatabricksDeployOptions) -> str:
|
|
129
|
+
"""The pipeline to act on: `name`, or the only one of the deploy block. Exit 2 otherwise."""
|
|
130
|
+
if name is None:
|
|
131
|
+
if len(opts.pipelines) != 1:
|
|
132
|
+
raise click.UsageError(
|
|
133
|
+
f"--pipeline is required: the deploy block has {', '.join(sorted(opts.pipelines))}"
|
|
134
|
+
)
|
|
135
|
+
return next(iter(opts.pipelines))
|
|
136
|
+
if name not in opts.pipelines:
|
|
137
|
+
raise click.UsageError(
|
|
138
|
+
f"pipeline '{name}' is not in the deploy block ({', '.join(sorted(opts.pipelines))})"
|
|
139
|
+
)
|
|
140
|
+
return name
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_id(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
|
|
144
|
+
if value is not None and not _RUN_ID.match(value):
|
|
145
|
+
raise click.BadParameter(
|
|
146
|
+
"use 1 to 64 letters, digits, dots, underscores or hyphens", ctx=ctx, param=param
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _echo_started(job_id: int | None, job_run_id: int, run_id: str | None, url: str | None) -> None:
|
|
152
|
+
doc = {"job_id": job_id, "job_run_id": job_run_id, "run_id": run_id, "run_page_url": url}
|
|
153
|
+
click.echo(json.dumps(doc, ensure_ascii=True))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _follow(
|
|
157
|
+
ctx: click.Context,
|
|
158
|
+
client: DatabricksClient,
|
|
159
|
+
job_run_id: int,
|
|
160
|
+
run_id: str | None,
|
|
161
|
+
runtime: Runtime,
|
|
162
|
+
report: Report,
|
|
163
|
+
*,
|
|
164
|
+
repair_id: int | None = None,
|
|
165
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
166
|
+
) -> int:
|
|
167
|
+
"""Wait for the run, say what happened to its tasks and units; the exit code of the command."""
|
|
168
|
+
say: Callable[[str], None] = lambda line: click.echo(line, err=True)
|
|
169
|
+
code = 1
|
|
170
|
+
with api_errors(report):
|
|
171
|
+
try:
|
|
172
|
+
run = wait_for_run(
|
|
173
|
+
client,
|
|
174
|
+
job_run_id,
|
|
175
|
+
sleep=ctx.meta[SLEEP_KEY],
|
|
176
|
+
say=say,
|
|
177
|
+
repair_id=repair_id,
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
)
|
|
180
|
+
except WaitTimeout as err:
|
|
181
|
+
say(f"{err}; the run keeps going")
|
|
182
|
+
return 1
|
|
183
|
+
if run.result_state == "SUCCESS":
|
|
184
|
+
code = 0
|
|
185
|
+
else:
|
|
186
|
+
say(f"run {job_run_id} ended: {run.life_cycle_state}/{run.result_state}")
|
|
187
|
+
for line in failure_lines(
|
|
188
|
+
client, runtime.artifacts_root, run_id or effective_run_id(run)
|
|
189
|
+
):
|
|
190
|
+
say(line)
|
|
191
|
+
return code
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@databricks.command()
|
|
195
|
+
@project_options
|
|
196
|
+
@target_option
|
|
197
|
+
@plugin_option
|
|
198
|
+
@log_format_option
|
|
199
|
+
@click.option(
|
|
200
|
+
"--out",
|
|
201
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
202
|
+
required=True,
|
|
203
|
+
help="Folder for the generated files; it must lie inside the bundle.",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--pipeline",
|
|
207
|
+
"pipelines",
|
|
208
|
+
multiple=True,
|
|
209
|
+
metavar="NAME",
|
|
210
|
+
help="A pipeline of the deploy block. Repeatable. Default: all of them.",
|
|
211
|
+
)
|
|
212
|
+
@click.option(
|
|
213
|
+
"--bundle-root",
|
|
214
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
215
|
+
default=None,
|
|
216
|
+
help="The bundle's root folder. Default: the nearest folder above --out with databricks.yml.",
|
|
217
|
+
)
|
|
218
|
+
@click.option(
|
|
219
|
+
"--check",
|
|
220
|
+
is_flag=True,
|
|
221
|
+
help="Write nothing; exit 1 and list the files that differ from what would be written.",
|
|
222
|
+
)
|
|
223
|
+
def bundle(
|
|
224
|
+
project_dir: Path,
|
|
225
|
+
vars_: dict[str, Any],
|
|
226
|
+
target: str | None,
|
|
227
|
+
plugins: tuple[str, ...],
|
|
228
|
+
log_format: str,
|
|
229
|
+
out: Path,
|
|
230
|
+
pipelines: tuple[str, ...],
|
|
231
|
+
bundle_root: Path | None,
|
|
232
|
+
check: bool,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Write one job file and one manifest per pipeline into a bundle.
|
|
235
|
+
|
|
236
|
+
The job is <out>/pipeloom_<project>_<pipeline>_<target>.job.yml and refers to the project and
|
|
237
|
+
the manifest through ${workspace.file_path}. Exit codes: 0 ok, 1 a diagnostic or a changed
|
|
238
|
+
file with --check, 2 bad usage.
|
|
239
|
+
"""
|
|
240
|
+
root = find_bundle_root(out, bundle_root)
|
|
241
|
+
if root is None:
|
|
242
|
+
raise click.UsageError("no databricks.yml above --out; pass --bundle-root")
|
|
243
|
+
project_rel = relative_to_root(project_dir, root)
|
|
244
|
+
out_rel = relative_to_root(out, root)
|
|
245
|
+
if project_rel is None or out_rel is None:
|
|
246
|
+
raise click.UsageError(f"the project dir and --out must lie inside the bundle {root}")
|
|
247
|
+
|
|
248
|
+
inv = Invocation("databricks bundle", log_format)
|
|
249
|
+
loaded = _load(inv, project_dir, target, vars_, plugins)
|
|
250
|
+
plan, bound, runtime, report = loaded
|
|
251
|
+
files = render_bundle(
|
|
252
|
+
plan,
|
|
253
|
+
runtime,
|
|
254
|
+
pipelines,
|
|
255
|
+
out=out,
|
|
256
|
+
project_rel=project_rel,
|
|
257
|
+
out_rel=out_rel,
|
|
258
|
+
report=report,
|
|
259
|
+
)
|
|
260
|
+
diagnostics = report.sorted()
|
|
261
|
+
if files is None:
|
|
262
|
+
inv.finish(diagnostics, 1, bound=bound)
|
|
263
|
+
return
|
|
264
|
+
changed = [f for f in files if not f.path.is_file() or f.path.read_bytes() != f.content]
|
|
265
|
+
if check:
|
|
266
|
+
for f in changed:
|
|
267
|
+
click.echo(f.path.as_posix())
|
|
268
|
+
inv.finish(diagnostics, 1 if changed else 0, bound=bound)
|
|
269
|
+
return
|
|
270
|
+
for f in files:
|
|
271
|
+
f.path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
f.path.write_bytes(f.content)
|
|
273
|
+
click.echo(f.path.as_posix())
|
|
274
|
+
inv.finish(diagnostics, 0, bound=bound)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@databricks.command()
|
|
278
|
+
@project_options
|
|
279
|
+
@target_option
|
|
280
|
+
@plugin_option
|
|
281
|
+
@log_format_option
|
|
282
|
+
@click.option(
|
|
283
|
+
"--pipeline",
|
|
284
|
+
"pipelines",
|
|
285
|
+
multiple=True,
|
|
286
|
+
metavar="NAME",
|
|
287
|
+
help="A pipeline of the deploy block. Repeatable. Default: all of them.",
|
|
288
|
+
)
|
|
289
|
+
@click.option(
|
|
290
|
+
"--dry-run",
|
|
291
|
+
is_flag=True,
|
|
292
|
+
help="Print the job settings as JSON and connect to nothing.",
|
|
293
|
+
)
|
|
294
|
+
@click.pass_context
|
|
295
|
+
def deploy(
|
|
296
|
+
ctx: click.Context,
|
|
297
|
+
/,
|
|
298
|
+
project_dir: Path,
|
|
299
|
+
vars_: dict[str, Any],
|
|
300
|
+
target: str | None,
|
|
301
|
+
plugins: tuple[str, ...],
|
|
302
|
+
log_format: str,
|
|
303
|
+
pipelines: tuple[str, ...],
|
|
304
|
+
dry_run: bool,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Upload the project to the workspace and create or update one job per pipeline.
|
|
307
|
+
|
|
308
|
+
Prints one JSON line per pipeline: {pipeline, job_id, action, root}. With --dry-run it prints
|
|
309
|
+
{pipeline, root, settings} instead and creates no client. Exit codes: 0 ok, 1 a diagnostic.
|
|
310
|
+
"""
|
|
311
|
+
inv = Invocation("databricks deploy", log_format)
|
|
312
|
+
plan, bound, runtime, report = _load(inv, project_dir, target, vars_, plugins)
|
|
313
|
+
opts = deploy_options(plan, report)
|
|
314
|
+
names = pipelines or (sorted(opts.pipelines) if opts else ())
|
|
315
|
+
for name in names:
|
|
316
|
+
if opts is not None and name not in opts.pipelines:
|
|
317
|
+
report.add("PLD004", f"pipeline '{name}' is not in the deploy block")
|
|
318
|
+
if opts is None or report.has_errors:
|
|
319
|
+
inv.finish(report.sorted(), 1, bound=bound)
|
|
320
|
+
return
|
|
321
|
+
if dry_run:
|
|
322
|
+
for name in names:
|
|
323
|
+
prepared = prepare(project_dir, plan, name, runtime, report, user=lambda: "<user>")
|
|
324
|
+
if prepared is not None:
|
|
325
|
+
doc: dict[str, Any] = {
|
|
326
|
+
"pipeline": name,
|
|
327
|
+
"root": prepared.root,
|
|
328
|
+
"settings": to_json(prepared.job.settings),
|
|
329
|
+
}
|
|
330
|
+
click.echo(json.dumps(doc, ensure_ascii=True))
|
|
331
|
+
else:
|
|
332
|
+
client = _connect(ctx, inv, report, bound)
|
|
333
|
+
for name in names:
|
|
334
|
+
result = deploy_pipeline(client, project_dir, plan, name, runtime, report=report)
|
|
335
|
+
if result is not None:
|
|
336
|
+
doc = {
|
|
337
|
+
"pipeline": name,
|
|
338
|
+
"job_id": result.job_id,
|
|
339
|
+
"action": result.action,
|
|
340
|
+
"root": result.root,
|
|
341
|
+
}
|
|
342
|
+
click.echo(json.dumps(doc, ensure_ascii=True))
|
|
343
|
+
inv.finish(report.sorted(), 1 if report.has_errors else 0, bound=bound)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_PIPELINE_OPTION = click.option(
|
|
347
|
+
"--pipeline",
|
|
348
|
+
metavar="NAME",
|
|
349
|
+
default=None,
|
|
350
|
+
help="A pipeline of the deploy block. Default: the only one.",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wait_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
355
|
+
"""--wait/--no-wait and --timeout, shared by run and repair."""
|
|
356
|
+
func = click.option(
|
|
357
|
+
"--timeout",
|
|
358
|
+
type=click.IntRange(min=1),
|
|
359
|
+
default=DEFAULT_TIMEOUT,
|
|
360
|
+
show_default=True,
|
|
361
|
+
metavar="SECONDS",
|
|
362
|
+
help="With --wait, stop waiting after this long and exit 1. The run keeps going.",
|
|
363
|
+
)(func)
|
|
364
|
+
return click.option(
|
|
365
|
+
"--wait/--no-wait",
|
|
366
|
+
default=True,
|
|
367
|
+
show_default=True,
|
|
368
|
+
help="Wait for the run to end (polling every 15 seconds) and exit 1 unless it succeeded.",
|
|
369
|
+
)(func)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@databricks.command()
|
|
373
|
+
@project_options
|
|
374
|
+
@target_option
|
|
375
|
+
@plugin_option
|
|
376
|
+
@log_format_option
|
|
377
|
+
@select_options
|
|
378
|
+
@_PIPELINE_OPTION
|
|
379
|
+
@click.option(
|
|
380
|
+
"--run-id",
|
|
381
|
+
callback=_run_id,
|
|
382
|
+
default=None,
|
|
383
|
+
metavar="RID",
|
|
384
|
+
help="Name the run (1 to 64 letters, digits, dots, underscores or hyphens) and make the "
|
|
385
|
+
"start idempotent. Default: dbx-<job run id>.",
|
|
386
|
+
)
|
|
387
|
+
@click.option("--full-refresh", is_flag=True, help="Rebuild append tables from scratch.")
|
|
388
|
+
@click.option(
|
|
389
|
+
"--ensure",
|
|
390
|
+
is_flag=True,
|
|
391
|
+
help="Deploy the job first when it is missing or out of date, instead of stopping.",
|
|
392
|
+
)
|
|
393
|
+
@click.option(
|
|
394
|
+
"--ephemeral",
|
|
395
|
+
is_flag=True,
|
|
396
|
+
help="Run once without a job: upload the project and submit the run. Serverless only.",
|
|
397
|
+
)
|
|
398
|
+
@_wait_options
|
|
399
|
+
@click.pass_context
|
|
400
|
+
def run(
|
|
401
|
+
ctx: click.Context,
|
|
402
|
+
/,
|
|
403
|
+
project_dir: Path,
|
|
404
|
+
vars_: dict[str, Any],
|
|
405
|
+
target: str | None,
|
|
406
|
+
plugins: tuple[str, ...],
|
|
407
|
+
log_format: str,
|
|
408
|
+
select: tuple[str, ...],
|
|
409
|
+
exclude: tuple[str, ...],
|
|
410
|
+
pipeline: str | None,
|
|
411
|
+
run_id: str | None,
|
|
412
|
+
full_refresh: bool,
|
|
413
|
+
ensure: bool,
|
|
414
|
+
ephemeral: bool,
|
|
415
|
+
wait: bool,
|
|
416
|
+
timeout: int,
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Run the deployed job of a pipeline, or run it once without a job (--ephemeral).
|
|
419
|
+
|
|
420
|
+
Prints {job_id, job_run_id, run_id, run_page_url} as one JSON line. --select and --exclude
|
|
421
|
+
pick units: only their tasks (and finalize) run. Exit codes: 0 ok, 1 a diagnostic or a run
|
|
422
|
+
that did not succeed, 2 bad usage.
|
|
423
|
+
"""
|
|
424
|
+
inv = Invocation("databricks run", log_format)
|
|
425
|
+
plan, bound, runtime, report = _load(inv, project_dir, target, vars_, plugins)
|
|
426
|
+
opts = deploy_options(plan, report)
|
|
427
|
+
if opts is None:
|
|
428
|
+
inv.finish(report.sorted(), 1, bound=bound)
|
|
429
|
+
return
|
|
430
|
+
name = _pipeline(pipeline, opts)
|
|
431
|
+
if ephemeral and ensure:
|
|
432
|
+
raise click.UsageError("--ensure deploys a job; --ephemeral runs without one")
|
|
433
|
+
client = _connect(ctx, inv, report, bound)
|
|
434
|
+
selection = Selection(select, exclude)
|
|
435
|
+
try:
|
|
436
|
+
if ephemeral:
|
|
437
|
+
started = submit_ephemeral(
|
|
438
|
+
client,
|
|
439
|
+
project_dir,
|
|
440
|
+
plan,
|
|
441
|
+
name,
|
|
442
|
+
runtime,
|
|
443
|
+
selection=selection,
|
|
444
|
+
full_refresh=full_refresh,
|
|
445
|
+
report=report,
|
|
446
|
+
run_id=run_id,
|
|
447
|
+
)
|
|
448
|
+
else:
|
|
449
|
+
started = run_job(
|
|
450
|
+
client,
|
|
451
|
+
project_dir,
|
|
452
|
+
plan,
|
|
453
|
+
name,
|
|
454
|
+
runtime,
|
|
455
|
+
selection=selection,
|
|
456
|
+
run_id=run_id,
|
|
457
|
+
full_refresh=full_refresh,
|
|
458
|
+
ensure=ensure,
|
|
459
|
+
report=report,
|
|
460
|
+
)
|
|
461
|
+
except SelectionError as err:
|
|
462
|
+
raise click.UsageError(str(err)) from err
|
|
463
|
+
code = 1
|
|
464
|
+
if started is not None:
|
|
465
|
+
shown = started.run_id or f"dbx-{started.job_run_id}"
|
|
466
|
+
_echo_started(started.job_id, started.job_run_id, shown, started.run_page_url)
|
|
467
|
+
code = 0
|
|
468
|
+
if wait:
|
|
469
|
+
code = _follow(
|
|
470
|
+
ctx, client, started.job_run_id, started.run_id, runtime, report, timeout=timeout
|
|
471
|
+
)
|
|
472
|
+
inv.finish(report.sorted(), 1 if report.has_errors else code, bound=bound)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@databricks.command()
|
|
476
|
+
@project_options
|
|
477
|
+
@target_option
|
|
478
|
+
@plugin_option
|
|
479
|
+
@log_format_option
|
|
480
|
+
@_PIPELINE_OPTION
|
|
481
|
+
@click.option("--run-id", callback=_run_id, default=None, metavar="RID", help="The run to repair.")
|
|
482
|
+
@click.option(
|
|
483
|
+
"--job-run-id",
|
|
484
|
+
type=click.IntRange(min=1),
|
|
485
|
+
default=None,
|
|
486
|
+
metavar="N",
|
|
487
|
+
help="The job run to repair.",
|
|
488
|
+
)
|
|
489
|
+
@_wait_options
|
|
490
|
+
@click.pass_context
|
|
491
|
+
def repair(
|
|
492
|
+
ctx: click.Context,
|
|
493
|
+
/,
|
|
494
|
+
project_dir: Path,
|
|
495
|
+
vars_: dict[str, Any],
|
|
496
|
+
target: str | None,
|
|
497
|
+
plugins: tuple[str, ...],
|
|
498
|
+
log_format: str,
|
|
499
|
+
pipeline: str | None,
|
|
500
|
+
run_id: str | None,
|
|
501
|
+
job_run_id: int | None,
|
|
502
|
+
wait: bool,
|
|
503
|
+
timeout: int,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Rerun the failed, cancelled and skipped tasks of a finished run, and finalize again.
|
|
506
|
+
|
|
507
|
+
Name the run with --run-id (the pipeloom run id) or --job-run-id (the Databricks run id),
|
|
508
|
+
exactly one. Prints {job_run_id, repair_id} as one JSON line. Exit codes: 0 ok (also when
|
|
509
|
+
there is nothing to repair), 1 a diagnostic or a repair that did not succeed, 2 bad usage.
|
|
510
|
+
"""
|
|
511
|
+
if (run_id is None) == (job_run_id is None):
|
|
512
|
+
raise click.UsageError("use exactly one of --run-id and --job-run-id")
|
|
513
|
+
inv = Invocation("databricks repair", log_format)
|
|
514
|
+
plan, bound, runtime, report = _load(inv, project_dir, target, vars_, plugins)
|
|
515
|
+
opts = deploy_options(plan, report)
|
|
516
|
+
if opts is None:
|
|
517
|
+
inv.finish(report.sorted(), 1, bound=bound)
|
|
518
|
+
return
|
|
519
|
+
name = _pipeline(pipeline, opts)
|
|
520
|
+
client = _connect(ctx, inv, report, bound)
|
|
521
|
+
target_run = job_run_id
|
|
522
|
+
if run_id is not None:
|
|
523
|
+
with api_errors(report):
|
|
524
|
+
meta = plan.metadata
|
|
525
|
+
job = find_job(
|
|
526
|
+
client, f"{meta.project}.{name}.{meta.target}", name=job_name(plan, name)
|
|
527
|
+
)
|
|
528
|
+
found = None if job is None else find_run(client, job.job_id, run_id)
|
|
529
|
+
if found is None:
|
|
530
|
+
report.add(
|
|
531
|
+
"PLD007",
|
|
532
|
+
f"no run '{run_id}' of pipeline '{name}'"
|
|
533
|
+
if job is not None
|
|
534
|
+
else f"no job is deployed for pipeline '{name}'",
|
|
535
|
+
hint="check --run-id, --pipeline and --target",
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
target_run = found.run_id
|
|
539
|
+
code = 1
|
|
540
|
+
if target_run is not None and not report.has_errors:
|
|
541
|
+
repair_id = repair_run(client, target_run, report)
|
|
542
|
+
if repair_id is not None:
|
|
543
|
+
click.echo(
|
|
544
|
+
json.dumps({"job_run_id": target_run, "repair_id": repair_id}, ensure_ascii=True)
|
|
545
|
+
)
|
|
546
|
+
code = 0
|
|
547
|
+
if wait:
|
|
548
|
+
code = _follow(
|
|
549
|
+
ctx,
|
|
550
|
+
client,
|
|
551
|
+
target_run,
|
|
552
|
+
run_id,
|
|
553
|
+
runtime,
|
|
554
|
+
report,
|
|
555
|
+
repair_id=repair_id,
|
|
556
|
+
timeout=timeout,
|
|
557
|
+
)
|
|
558
|
+
elif not report.has_errors:
|
|
559
|
+
click.echo(f"nothing to repair in run {target_run}", err=True)
|
|
560
|
+
code = 0
|
|
561
|
+
inv.finish(report.sorted(), 1 if report.has_errors else code, bound=bound)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The Databricks client the plugin talks through (ADR-0036).
|
|
2
|
+
|
|
3
|
+
A small JSON-shaped protocol with records of our own, so nothing outside `client/sdk.py` and
|
|
4
|
+
`client/connect.py` depends on the SDK. Importing this module loads no SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class JobSummary:
|
|
18
|
+
job_id: int
|
|
19
|
+
name: str
|
|
20
|
+
tags: Mapping[str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TaskRunInfo:
|
|
25
|
+
task_key: str
|
|
26
|
+
run_id: int
|
|
27
|
+
attempt: int
|
|
28
|
+
life_cycle_state: str
|
|
29
|
+
result_state: str | None
|
|
30
|
+
termination_code: str | None
|
|
31
|
+
message: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RunInfo:
|
|
36
|
+
run_id: int
|
|
37
|
+
job_id: int | None
|
|
38
|
+
life_cycle_state: str
|
|
39
|
+
result_state: str | None
|
|
40
|
+
job_parameters: Mapping[str, str] = field(default_factory=dict)
|
|
41
|
+
tasks: tuple[TaskRunInfo, ...] = ()
|
|
42
|
+
latest_repair_id: int | None = None
|
|
43
|
+
run_page_url: str | None = None
|
|
44
|
+
start_time_ms: int | None = None
|
|
45
|
+
repair_states: Mapping[int, str] = field(default_factory=dict)
|
|
46
|
+
"""Repair id -> life cycle state of that repair's entry in the run's repair history."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DirEntry:
|
|
51
|
+
path: str
|
|
52
|
+
is_dir: bool
|
|
53
|
+
modified_ms: int = 0
|
|
54
|
+
"""Last change in epoch milliseconds; 0 when the workspace does not say."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ApiError(Exception):
|
|
58
|
+
"""A failed API call. `status` is the HTTP status, 0 when the SDK did not say."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, status: int, message: str, error_code: str | None = None) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
self.error_code = error_code
|
|
64
|
+
self.message = message
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConnectionNotConfigured(Exception):
|
|
68
|
+
"""No workspace connection could be set up (PLD010). The message says why."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DatabricksClient(Protocol):
|
|
72
|
+
"""Every call the plugin makes. Failures raise `ApiError`."""
|
|
73
|
+
|
|
74
|
+
host: str
|
|
75
|
+
|
|
76
|
+
def list_jobs(self, *, name: str | None = None) -> Iterator[JobSummary]: ...
|
|
77
|
+
def create_job(self, settings: Mapping[str, JsonValue]) -> int: ...
|
|
78
|
+
def reset_job(self, job_id: int, settings: Mapping[str, JsonValue]) -> None: ...
|
|
79
|
+
def run_now(
|
|
80
|
+
self,
|
|
81
|
+
job_id: int,
|
|
82
|
+
*,
|
|
83
|
+
job_parameters: Mapping[str, str],
|
|
84
|
+
only: Sequence[str] | None,
|
|
85
|
+
idempotency_token: str | None,
|
|
86
|
+
) -> int: ...
|
|
87
|
+
def submit_run(
|
|
88
|
+
self, spec: Mapping[str, JsonValue], *, idempotency_token: str | None
|
|
89
|
+
) -> int: ...
|
|
90
|
+
def get_run(self, run_id: int) -> RunInfo: ...
|
|
91
|
+
def list_runs(self, job_id: int, *, active_only: bool = False) -> Iterator[RunInfo]: ...
|
|
92
|
+
def repair_run(
|
|
93
|
+
self, run_id: int, *, rerun_tasks: Sequence[str], latest_repair_id: int | None
|
|
94
|
+
) -> int: ...
|
|
95
|
+
def current_user(self) -> str: ...
|
|
96
|
+
def mkdirs(self, path: str) -> None: ...
|
|
97
|
+
def upload(self, path: str, data: bytes) -> None:
|
|
98
|
+
"""Store `data` as a plain workspace file, replacing what is there."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def list_dir(self, path: str) -> list[DirEntry]: ...
|
|
102
|
+
def delete(self, path: str, *, recursive: bool) -> None: ...
|
|
103
|
+
def read_file(self, path: str) -> bytes:
|
|
104
|
+
"""The bytes of a file on a volume (Files API)."""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
def get_secret(self, scope: str, key: str) -> str:
|
|
108
|
+
"""The `value` the API returns: the secret, base64 encoded."""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def connect(
|
|
113
|
+
*, profile: str | None, env: Mapping[str, str], factory: str | None = None
|
|
114
|
+
) -> DatabricksClient:
|
|
115
|
+
"""A client for the workspace the arguments and `env` select; see `client.connect`.
|
|
116
|
+
|
|
117
|
+
Raises `ConnectionNotConfigured` when nothing is configured.
|
|
118
|
+
"""
|
|
119
|
+
from pipeloom_databricks.client.connect import connect as _connect
|
|
120
|
+
|
|
121
|
+
return _connect(profile=profile, env=env, factory=factory)
|