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,84 @@
1
+ """Choose the workspace connection (ADR-0036). No profile is ever picked automatically.
2
+
3
+ Order: an explicit profile (`--profile`), a profile the user set in `DATABRICKS_CONFIG_PROFILE`,
4
+ host and credentials in `DATABRICKS_HOST` and friends, runtime auth when
5
+ `DATABRICKS_RUNTIME_VERSION` is set, else `ConnectionNotConfigured` (PLD010). Without a profile
6
+ the SDK gets a `config_file` that does not exist, so it cannot fall back to a `DEFAULT` profile of
7
+ `~/.databrickscfg`. The SDK loads lazily, only here and in `sdk.py`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import tempfile
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+ from typing import Any, Final
17
+
18
+ from pipeloom.secrets.redaction import Redactor
19
+ from pipeloom_databricks.client import ConnectionNotConfigured, DatabricksClient
20
+
21
+ _CREDENTIALS: Final = {
22
+ "DATABRICKS_TOKEN": "token",
23
+ "DATABRICKS_CLIENT_ID": "client_id",
24
+ "DATABRICKS_CLIENT_SECRET": "client_secret",
25
+ }
26
+ _SECRET_ATTRS: Final = ("token", "client_secret", "password")
27
+
28
+
29
+ def _factory(spec: str) -> DatabricksClient:
30
+ """`module:attr` called with no arguments (a test double; `--client-factory`)."""
31
+ module, sep, attr = spec.partition(":")
32
+ if not (module and sep and attr):
33
+ raise ConnectionNotConfigured(f"--client-factory '{spec}' is not MODULE:ATTR")
34
+ try:
35
+ make = getattr(importlib.import_module(module), attr)
36
+ except (ImportError, AttributeError) as err:
37
+ raise ConnectionNotConfigured(
38
+ f"--client-factory '{spec}' cannot be loaded: {err}"
39
+ ) from None
40
+ client: DatabricksClient = make()
41
+ return client
42
+
43
+
44
+ def connect(
45
+ *, profile: str | None, env: Mapping[str, str], factory: str | None = None
46
+ ) -> DatabricksClient:
47
+ if factory:
48
+ return _factory(factory)
49
+ profile = profile or env.get("DATABRICKS_CONFIG_PROFILE") or None
50
+ host = env.get("DATABRICKS_HOST")
51
+ if profile:
52
+ kwargs: dict[str, Any] = {"profile": profile}
53
+ elif host:
54
+ kwargs = {"host": host}
55
+ kwargs.update({attr: env[name] for name, attr in _CREDENTIALS.items() if env.get(name)})
56
+ elif env.get("DATABRICKS_RUNTIME_VERSION"):
57
+ kwargs = {"auth_type": "runtime"}
58
+ else:
59
+ raise ConnectionNotConfigured(
60
+ "no Databricks connection: pass --profile, set DATABRICKS_CONFIG_PROFILE, set "
61
+ "DATABRICKS_HOST with credentials, or run on a Databricks runtime"
62
+ )
63
+
64
+ from databricks.sdk import WorkspaceClient
65
+ from databricks.sdk.core import Config
66
+
67
+ try:
68
+ if profile:
69
+ config = Config(**kwargs)
70
+ else:
71
+ with tempfile.TemporaryDirectory(prefix="pipeloom-") as folder:
72
+ config = Config(config_file=str(Path(folder) / "none.cfg"), **kwargs)
73
+ workspace = WorkspaceClient(config=config)
74
+ except ValueError as err:
75
+ raise ConnectionNotConfigured(f"the Databricks connection is invalid: {err}") from None
76
+
77
+ from pipeloom_databricks.client.sdk import SdkClient
78
+
79
+ redactor = Redactor()
80
+ for attr in _SECRET_ATTRS:
81
+ value = getattr(config, attr, None)
82
+ if isinstance(value, str):
83
+ redactor.register(value)
84
+ return SdkClient(workspace.api_client, str(config.host or "").rstrip("/"), redactor)
@@ -0,0 +1,275 @@
1
+ """`SdkClient`: the REST calls of the plugin, every one through `WorkspaceClient.api_client.do`.
2
+
3
+ Going through `do` (not the typed SDK methods) keeps the JSON exactly as rendered, so a field the
4
+ installed SDK does not know yet, such as `environment_version`, is never dropped. This is one of
5
+ the three modules that import `databricks.sdk`; `connect` imports this one lazily.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from collections.abc import Iterator, Mapping, Sequence
12
+ from typing import Any, Final, Protocol
13
+ from urllib.parse import quote
14
+
15
+ from databricks.sdk.errors.base import DatabricksError
16
+ from pydantic import JsonValue
17
+
18
+ from pipeloom.secrets.redaction import Redactor
19
+ from pipeloom_databricks.client import ApiError, DirEntry, JobSummary, RunInfo, TaskRunInfo
20
+
21
+ JOBS: Final = "/api/2.2/jobs"
22
+ WORKSPACE: Final = "/api/2.0/workspace"
23
+ IMPORT_FORMAT: Final = "AUTO"
24
+ """The workspace import format; ADR-0036 explains why."""
25
+ _RESULT: Final = {
26
+ "SUCCESS": "SUCCESS",
27
+ "SUCCESS_WITH_FAILURES": "SUCCESS_WITH_FAILURES",
28
+ "USER_CANCELED": "CANCELED",
29
+ "CANCELED": "CANCELED",
30
+ "UPSTREAM_FAILED": "UPSTREAM_FAILED",
31
+ "UPSTREAM_CANCELED": "UPSTREAM_CANCELED",
32
+ "MAX_CONCURRENT_RUNS_EXCEEDED": "MAXIMUM_CONCURRENT_RUNS_REACHED",
33
+ "EXCLUDED": "EXCLUDED",
34
+ "DISABLED": "DISABLED",
35
+ }
36
+ """`termination_details.code` values that name a result state; any other code is a failure."""
37
+
38
+
39
+ class _Api(Protocol):
40
+ def do(
41
+ self,
42
+ method: str,
43
+ path: str,
44
+ *,
45
+ query: dict[str, Any] | None = ...,
46
+ body: dict[str, Any] | None = ...,
47
+ raw: bool = ...,
48
+ ) -> Any: ...
49
+
50
+
51
+ def _status(err: DatabricksError) -> int:
52
+ """The HTTP status of an SDK error: its own `status_code` when it has one, else the status
53
+ the SDK maps to the nearest class in its MRO (read from `STATUS_CODE_MAPPING`, so subclasses
54
+ such as `Aborted` or `DeadlineExceeded` resolve too), else 0."""
55
+ own = getattr(err, "status_code", None)
56
+ if isinstance(own, int) and not isinstance(own, bool):
57
+ return own
58
+ from databricks.sdk.errors import platform
59
+
60
+ by_class = {cls: code for code, cls in getattr(platform, "STATUS_CODE_MAPPING", {}).items()}
61
+ for cls in type(err).__mro__:
62
+ if cls in by_class:
63
+ return int(by_class[cls])
64
+ return 0
65
+
66
+
67
+ def _states(item: Mapping[str, Any]) -> tuple[str, str | None, str | None, str | None]:
68
+ """life cycle state, result state, termination code and message of a run or a task run.
69
+
70
+ The `status` object is read when present, the legacy `state` object otherwise.
71
+ """
72
+ state: Mapping[str, Any] = item.get("state") or {}
73
+ status: Mapping[str, Any] = item.get("status") or {}
74
+ details: Mapping[str, Any] = status.get("termination_details") or {}
75
+ life = (status.get("state") if status else None) or state.get("life_cycle_state") or "UNKNOWN"
76
+ code = details.get("code")
77
+ result = state.get("result_state")
78
+ if result is None and life == "TERMINATED" and code:
79
+ result = _RESULT.get(code, "FAILED")
80
+ message = details.get("message") or state.get("state_message") or status.get("state_message")
81
+ return str(life), result, code, message or None
82
+
83
+
84
+ def _task(raw: Mapping[str, Any]) -> TaskRunInfo:
85
+ life, result, code, message = _states(raw)
86
+ return TaskRunInfo(
87
+ task_key=str(raw["task_key"]),
88
+ run_id=int(raw["run_id"]),
89
+ attempt=int(raw.get("attempt_number") or 0),
90
+ life_cycle_state=life,
91
+ result_state=result,
92
+ termination_code=code,
93
+ message=message,
94
+ )
95
+
96
+
97
+ def _run(raw: Mapping[str, Any], tasks: Sequence[Mapping[str, Any]] = ()) -> RunInfo:
98
+ life, result, _, _ = _states(raw)
99
+ params: dict[str, str] = {}
100
+ for p in raw.get("job_parameters") or []:
101
+ value = p.get("value", p.get("default"))
102
+ if value is not None:
103
+ params[str(p["name"])] = str(value)
104
+ repairs = [int(h["id"]) for h in raw.get("repair_history") or [] if h.get("id") is not None]
105
+ repair_states = {
106
+ int(h["id"]): _states(h)[0]
107
+ for h in raw.get("repair_history") or []
108
+ if h.get("id") is not None
109
+ }
110
+ job_id = raw.get("job_id")
111
+ start = raw.get("start_time")
112
+ return RunInfo(
113
+ run_id=int(raw["run_id"]),
114
+ job_id=None if job_id is None else int(job_id),
115
+ life_cycle_state=life,
116
+ result_state=result,
117
+ job_parameters=params,
118
+ tasks=tuple(_task(t) for t in tasks),
119
+ latest_repair_id=max(repairs) if repairs else None,
120
+ run_page_url=raw.get("run_page_url"),
121
+ start_time_ms=None if start is None else int(start),
122
+ repair_states=repair_states,
123
+ )
124
+
125
+
126
+ class SdkClient:
127
+ """A `DatabricksClient` over the `api_client` of a `WorkspaceClient` (or a fake of it)."""
128
+
129
+ def __init__(self, api_client: _Api, host: str, redactor: Redactor | None = None) -> None:
130
+ self._api = api_client
131
+ self.host = host
132
+ self._redactor = redactor
133
+
134
+ def _call(
135
+ self,
136
+ method: str,
137
+ path: str,
138
+ *,
139
+ query: dict[str, Any] | None = None,
140
+ body: dict[str, Any] | None = None,
141
+ raw: bool = False,
142
+ ) -> Any:
143
+ try:
144
+ return self._api.do(method, path, query=query, body=body, raw=raw)
145
+ except DatabricksError as err:
146
+ text = str(err)
147
+ if self._redactor is not None:
148
+ text = self._redactor.mask(text)
149
+ raise ApiError(_status(err), text, err.error_code) from None
150
+
151
+ def list_jobs(self, *, name: str | None = None) -> Iterator[JobSummary]:
152
+ token: str | None = None
153
+ while True:
154
+ query: dict[str, Any] = {"limit": 100}
155
+ if name is not None:
156
+ query["name"] = name
157
+ if token:
158
+ query["page_token"] = token
159
+ page = self._call("GET", f"{JOBS}/list", query=query) or {}
160
+ for job in page.get("jobs") or []:
161
+ settings = job.get("settings") or {}
162
+ tags = {str(k): str(v) for k, v in (settings.get("tags") or {}).items()}
163
+ yield JobSummary(int(job["job_id"]), str(settings.get("name", "")), tags)
164
+ token = page.get("next_page_token")
165
+ if not token:
166
+ return
167
+
168
+ def create_job(self, settings: Mapping[str, JsonValue]) -> int:
169
+ out = self._call("POST", f"{JOBS}/create", body=dict(settings))
170
+ return int(out["job_id"])
171
+
172
+ def reset_job(self, job_id: int, settings: Mapping[str, JsonValue]) -> None:
173
+ self._call("POST", f"{JOBS}/reset", body={"job_id": job_id, "new_settings": dict(settings)})
174
+
175
+ def run_now(
176
+ self,
177
+ job_id: int,
178
+ *,
179
+ job_parameters: Mapping[str, str],
180
+ only: Sequence[str] | None,
181
+ idempotency_token: str | None,
182
+ ) -> int:
183
+ body: dict[str, Any] = {"job_id": job_id, "job_parameters": dict(job_parameters)}
184
+ if only is not None:
185
+ body["only"] = list(only)
186
+ if idempotency_token is not None:
187
+ body["idempotency_token"] = idempotency_token
188
+ return int(self._call("POST", f"{JOBS}/run-now", body=body)["run_id"])
189
+
190
+ def submit_run(self, spec: Mapping[str, JsonValue], *, idempotency_token: str | None) -> int:
191
+ body: dict[str, Any] = dict(spec)
192
+ if idempotency_token is not None:
193
+ body["idempotency_token"] = idempotency_token
194
+ return int(self._call("POST", f"{JOBS}/runs/submit", body=body)["run_id"])
195
+
196
+ def get_run(self, run_id: int) -> RunInfo:
197
+ query: dict[str, Any] = {"run_id": run_id, "include_history": "true"}
198
+ first = self._call("GET", f"{JOBS}/runs/get", query=query) or {}
199
+ tasks: list[Mapping[str, Any]] = list(first.get("tasks") or [])
200
+ token = first.get("next_page_token")
201
+ while token:
202
+ page = self._call("GET", f"{JOBS}/runs/get", query={**query, "page_token": token}) or {}
203
+ tasks.extend(page.get("tasks") or [])
204
+ token = page.get("next_page_token")
205
+ return _run(first, tasks)
206
+
207
+ def list_runs(self, job_id: int, *, active_only: bool = False) -> Iterator[RunInfo]:
208
+ token: str | None = None
209
+ while True:
210
+ query: dict[str, Any] = {"job_id": job_id, "limit": 25}
211
+ if active_only:
212
+ query["active_only"] = "true"
213
+ if token:
214
+ query["page_token"] = token
215
+ page = self._call("GET", f"{JOBS}/runs/list", query=query) or {}
216
+ for run in page.get("runs") or []:
217
+ yield _run(run, run.get("tasks") or [])
218
+ token = page.get("next_page_token")
219
+ if not token:
220
+ return
221
+
222
+ def repair_run(
223
+ self, run_id: int, *, rerun_tasks: Sequence[str], latest_repair_id: int | None
224
+ ) -> int:
225
+ body: dict[str, Any] = {
226
+ "run_id": run_id,
227
+ "rerun_tasks": list(rerun_tasks),
228
+ "rerun_dependent_tasks": True,
229
+ }
230
+ if latest_repair_id is not None:
231
+ body["latest_repair_id"] = latest_repair_id
232
+ return int(self._call("POST", f"{JOBS}/runs/repair", body=body)["repair_id"])
233
+
234
+ def current_user(self) -> str:
235
+ out = self._call("GET", "/api/2.0/preview/scim/v2/Me")
236
+ return str(out["userName"])
237
+
238
+ def mkdirs(self, path: str) -> None:
239
+ self._call("POST", f"{WORKSPACE}/mkdirs", body={"path": path})
240
+
241
+ def upload(self, path: str, data: bytes) -> None:
242
+ body = {
243
+ "path": path,
244
+ "format": IMPORT_FORMAT,
245
+ "content": base64.b64encode(data).decode("ascii"),
246
+ "overwrite": True,
247
+ }
248
+ self._call("POST", f"{WORKSPACE}/import", body=body)
249
+
250
+ def list_dir(self, path: str) -> list[DirEntry]:
251
+ out = self._call("GET", f"{WORKSPACE}/list", query={"path": path}) or {}
252
+ return [
253
+ DirEntry(
254
+ str(o["path"]), o.get("object_type") == "DIRECTORY", int(o.get("modified_at") or 0)
255
+ )
256
+ for o in out.get("objects") or []
257
+ ]
258
+
259
+ def delete(self, path: str, *, recursive: bool) -> None:
260
+ self._call("POST", f"{WORKSPACE}/delete", body={"path": path, "recursive": recursive})
261
+
262
+ def read_file(self, path: str) -> bytes:
263
+ out = self._call("GET", f"/api/2.0/fs/files{quote(path, safe='/')}", raw=True)
264
+ stream = out["contents"]
265
+ try:
266
+ data = stream.read()
267
+ finally:
268
+ close = getattr(stream, "close", None)
269
+ if close is not None:
270
+ close()
271
+ return bytes(data)
272
+
273
+ def get_secret(self, scope: str, key: str) -> str:
274
+ out = self._call("GET", "/api/2.0/secrets/get", query={"scope": scope, "key": key})
275
+ return str(out["value"])
@@ -0,0 +1,188 @@
1
+ """Direct deploy: upload the project, then create or reset the job of one pipeline.
2
+
3
+ The job's parameters name the upload folder, which is named after the content hash, so a changed
4
+ project changes the job settings and a second deploy of the same project is a no-op.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Iterator, Mapping
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from pipeloom.diagnostics import Report
16
+ from pipeloom.plan.model import Plan
17
+ from pipeloom.secrets.redaction import Redactor
18
+ from pipeloom_databricks.bundle import Runtime
19
+ from pipeloom_databricks.client import ApiError, DatabricksClient, JobSummary
20
+ from pipeloom_databricks.jobs_model import to_json
21
+ from pipeloom_databricks.render import Locations, RenderedJob, deploy_options, render_job
22
+ from pipeloom_databricks.upload import MANIFEST_PATH, Payload, collect, prune, upload
23
+
24
+ JOB_TAG = "pipeloom_job"
25
+ HASH_TAG = "pipeloom_settings_hash"
26
+
27
+
28
+ class DuplicateJobs(Exception):
29
+ """More than one job carries the same `pipeloom_job` tag (PLD006)."""
30
+
31
+ def __init__(self, job_tag: str, job_ids: tuple[int, ...]) -> None:
32
+ super().__init__(f"{len(job_ids)} jobs carry {JOB_TAG}={job_tag}: {list(job_ids)}")
33
+ self.job_tag = job_tag
34
+ self.job_ids = job_ids
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DeployResult:
39
+ job_id: int
40
+ action: Literal["created", "updated", "unchanged"]
41
+ root: str
42
+ """The workspace folder that holds the uploaded project."""
43
+
44
+
45
+ def find_job(
46
+ client: DatabricksClient, job_tag: str, *, name: str | None = None
47
+ ) -> JobSummary | None:
48
+ """The job tagged `pipeloom_job=<job_tag>`, or None.
49
+
50
+ With `name` the lookup by name goes first (cheap); when it finds no tagged job every job of
51
+ the workspace is scanned, because the tag, not the name, is the identity. Raises
52
+ `DuplicateJobs`.
53
+ """
54
+ for lookup in ([name] if name is not None else []) + [None]:
55
+ hits = [j for j in client.list_jobs(name=lookup) if j.tags.get(JOB_TAG) == job_tag]
56
+ if len(hits) > 1:
57
+ raise DuplicateJobs(job_tag, tuple(sorted(j.job_id for j in hits)))
58
+ if hits:
59
+ return hits[0]
60
+ return None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Prepared:
65
+ """Everything a deploy decides before it talks to the workspace."""
66
+
67
+ payload: Payload
68
+ parent: str
69
+ """`<workspace_root>/<project>/<pipeline>/<target>`: the folder of all uploads of the job."""
70
+ root: str
71
+ job: RenderedJob
72
+ locations: Locations
73
+
74
+
75
+ def prepare(
76
+ project_dir: Path,
77
+ plan: Plan,
78
+ pipeline: str,
79
+ runtime: Runtime,
80
+ report: Report,
81
+ *,
82
+ user: Callable[[], str],
83
+ versions: Mapping[str, str] | None = None,
84
+ ) -> Prepared | None:
85
+ """Collect the files and render the job for their upload folder. Touches no workspace except
86
+ through `user`, which is called only when `workspace_root` is not set."""
87
+ opts = deploy_options(plan, report)
88
+ if opts is None:
89
+ return None
90
+ payload = collect(project_dir, plan, runtime.artifacts_root, report)
91
+ if payload is None:
92
+ return None
93
+ meta = plan.metadata
94
+ base = opts.workspace_root or f"/Workspace/Users/{user()}/.pipeloom"
95
+ parent = f"{base.rstrip('/')}/{meta.project}/{pipeline}/{meta.target}"
96
+ root = f"{parent}/{payload.digest[:12]}"
97
+ locs = Locations(
98
+ project_root=root,
99
+ manifest_path=f"{root}/{MANIFEST_PATH}",
100
+ artifacts_root=runtime.artifacts_root,
101
+ state_shared=runtime.state_shared,
102
+ cancel_grace=runtime.cancel_grace,
103
+ )
104
+ job = render_job(plan, pipeline, locs, report, versions=versions)
105
+ return None if job is None else Prepared(payload, parent, root, job, locs)
106
+
107
+
108
+ @contextmanager
109
+ def api_errors(report: Report, redactor: Redactor | None = None) -> Iterator[None]:
110
+ """Turn `DuplicateJobs` into PLD006 and `ApiError` into PLD009 in `report`; the block then
111
+ ends early (the caller returns None)."""
112
+ try:
113
+ yield
114
+ except DuplicateJobs as err:
115
+ report.add(
116
+ "PLD006",
117
+ f"{len(err.job_ids)} jobs carry {JOB_TAG}={err.job_tag}: ids {list(err.job_ids)}",
118
+ hint="delete or untag the extra jobs",
119
+ )
120
+ except ApiError as err:
121
+ text = redactor.mask(err.message) if redactor else err.message
122
+ code = f", {err.error_code}" if err.error_code else ""
123
+ report.add(
124
+ "PLD009",
125
+ f"Databricks API request failed (HTTP {err.status}{code}): {text}",
126
+ context={"status": err.status},
127
+ )
128
+
129
+
130
+ def deploy_prepared(
131
+ client: DatabricksClient,
132
+ prepared: Prepared,
133
+ existing: JobSummary | None,
134
+ *,
135
+ report: Report,
136
+ redactor: Redactor | None = None,
137
+ ) -> DeployResult:
138
+ """Upload and create or reset the job of `prepared`; `existing` is the job found by tag.
139
+
140
+ Raises `ApiError`; wrap the call in `api_errors`.
141
+ """
142
+ job, root = prepared.job, prepared.root
143
+ if existing is not None and existing.tags.get(HASH_TAG) == job.settings_hash:
144
+ return DeployResult(existing.job_id, "unchanged", root)
145
+ upload(client, prepared.payload, root)
146
+ settings = to_json(job.settings)
147
+ if existing is None:
148
+ result = DeployResult(client.create_job(settings), "created", root)
149
+ else:
150
+ client.reset_job(existing.job_id, settings)
151
+ result = DeployResult(existing.job_id, "updated", root)
152
+ prune(
153
+ client,
154
+ prepared.parent,
155
+ prepared.payload.digest[:12],
156
+ report,
157
+ mask=redactor.mask if redactor else str,
158
+ )
159
+ return result
160
+
161
+
162
+ def deploy(
163
+ client: DatabricksClient,
164
+ project_dir: Path,
165
+ plan: Plan,
166
+ pipeline: str,
167
+ runtime: Runtime,
168
+ *,
169
+ report: Report,
170
+ redactor: Redactor | None = None,
171
+ versions: Mapping[str, str] | None = None,
172
+ ) -> DeployResult | None:
173
+ """Deploy one pipeline, or return None with PLD004, PLD006, PLD009 or PLD012 in `report`."""
174
+ with api_errors(report, redactor):
175
+ prepared = prepare(
176
+ project_dir,
177
+ plan,
178
+ pipeline,
179
+ runtime,
180
+ report,
181
+ user=client.current_user,
182
+ versions=versions,
183
+ )
184
+ if prepared is None:
185
+ return None
186
+ existing = find_job(client, prepared.job.job_tag, name=prepared.job.settings.name)
187
+ return deploy_prepared(client, prepared, existing, report=report, redactor=redactor)
188
+ return None
@@ -0,0 +1,72 @@
1
+ """What the Jobs API knows about a run's tasks, as the unit states core finalize accepts.
2
+
3
+ A unit whose task died (a lost cluster, a timeout, a cancel) wrote no attempt file. The finalize
4
+ task asks the Jobs API how each task ended and hands the answer to `pipeloom finalize
5
+ --unit-states`, so the run fails (or is cancelled) instead of looking like a success.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Collection, Mapping
12
+
13
+ from pipeloom.runtime.finalize_run import UNIT_STATES_SCHEMA, ExternalUnitState
14
+ from pipeloom_databricks.client import DatabricksClient, RunInfo, TaskRunInfo
15
+
16
+
17
+ def task_states(client: DatabricksClient, job_run_id: int) -> Mapping[str, TaskRunInfo]:
18
+ """The tasks of a job run by task key (the latest attempt of each)."""
19
+ return {t.task_key: t for t in client.get_run(job_run_id).tasks}
20
+
21
+
22
+ def unit_states(
23
+ key_map: Mapping[str, str], run: RunInfo, with_attempts: Collection[str]
24
+ ) -> dict[str, ExternalUnitState]:
25
+ """The external state of every unit that has no attempt file.
26
+
27
+ `key_map` is unit id -> task key. EXCLUDED is `excluded`; UPSTREAM_FAILED and
28
+ UPSTREAM_CANCELED are `skipped` (upstream_failed); CANCELED and TIMEDOUT are `cancelled`; FAILED
29
+ is `failed` with PLD011 and the termination message. Any other state (success, still running,
30
+ unknown task) gives no entry, so core finalize counts the unit as not run.
31
+ """
32
+ tasks = {t.task_key: t for t in run.tasks}
33
+ out: dict[str, ExternalUnitState] = {}
34
+ for unit, key in sorted(key_map.items()):
35
+ task = tasks.get(key)
36
+ if unit in with_attempts or task is None:
37
+ continue
38
+ ids = {
39
+ "databricks_task_key": task.task_key,
40
+ "databricks_task_run_id": str(task.run_id),
41
+ }
42
+ match task.result_state:
43
+ case "EXCLUDED":
44
+ out[unit] = ExternalUnitState("excluded")
45
+ case "UPSTREAM_FAILED" | "UPSTREAM_CANCELED":
46
+ out[unit] = ExternalUnitState("skipped", orchestrator_ids=ids)
47
+ case "CANCELED" | "TIMEDOUT":
48
+ out[unit] = ExternalUnitState("cancelled", orchestrator_ids=ids)
49
+ case "FAILED":
50
+ reason = task.message or task.termination_code or "no reason given"
51
+ out[unit] = ExternalUnitState(
52
+ "failed",
53
+ code="PLD011",
54
+ message=f"task {task.task_key} ended without a unit result: {reason}",
55
+ orchestrator_ids=ids,
56
+ )
57
+ return out
58
+
59
+
60
+ def unit_states_json(states: Mapping[str, ExternalUnitState]) -> bytes:
61
+ """The `pipeloom.unit_states/1` file for `states`."""
62
+ units: dict[str, dict[str, object]] = {}
63
+ for unit, state in sorted(states.items()):
64
+ doc: dict[str, object] = {"status": state.status}
65
+ for name in ("reason", "code", "message"):
66
+ if getattr(state, name) is not None:
67
+ doc[name] = getattr(state, name)
68
+ if state.orchestrator_ids:
69
+ doc["orchestrator_ids"] = dict(state.orchestrator_ids)
70
+ units[unit] = doc
71
+ doc_text = json.dumps({"schema": UNIT_STATES_SCHEMA, "units": units}, ensure_ascii=True)
72
+ return (doc_text + "\n").encode("ascii")