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,293 @@
1
+ """`FakeDatabricks`: an in-memory `DatabricksClient` for tests and case files.
2
+
3
+ No network and no SDK. Tests inspect `jobs`, `files`, `runs` and `calls`, and make a call fail with
4
+ `fail["create_job"] = ApiError(...)`. The scenario functions at the end are what a case file names
5
+ in `--client-factory pipeloom_databricks.testing.fake:<scenario>`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ from collections.abc import Iterator, Mapping, Sequence
13
+ from dataclasses import replace
14
+
15
+ from pydantic import JsonValue
16
+
17
+ from pipeloom_databricks.client import ApiError, DirEntry, JobSummary, RunInfo, TaskRunInfo
18
+
19
+ JOB_TAG = "pipeloom_job"
20
+
21
+
22
+ def _parent(path: str) -> str:
23
+ return path.rsplit("/", 1)[0] if "/" in path.strip("/") else "/"
24
+
25
+
26
+ class FakeDatabricks:
27
+ host = "https://fake.databricks.test"
28
+
29
+ def __init__(self, *, user: str = "me@example.com") -> None:
30
+ self.user = user
31
+ self.jobs: dict[int, dict[str, JsonValue]] = {}
32
+ self.files: dict[str, bytes] = {}
33
+ self.dirs: set[str] = {"/"}
34
+ self.modified: dict[str, int] = {}
35
+ self.volume_files: dict[str, bytes] = {}
36
+ self.secrets: dict[tuple[str, str], str] = {}
37
+ """(scope, key) -> the plain value; `get_secret` returns it base64 encoded."""
38
+ self.runs: dict[int, RunInfo] = {}
39
+ self.run_tokens: dict[str, int] = {}
40
+ self.repairs: list[tuple[int, tuple[str, ...], int | None]] = []
41
+ self.starts: list[tuple[int, dict[str, str], tuple[str, ...] | None, str | None]] = []
42
+ """Every `run_now`: (job_id, job_parameters, only, idempotency_token)."""
43
+ self.submissions: list[tuple[dict[str, JsonValue], str | None]] = []
44
+ """Every `submit_run`: (the spec, idempotency_token)."""
45
+ self.calls: list[tuple[str, str]] = []
46
+ """(method, first argument as text), in order."""
47
+ self.fail: dict[str, ApiError] = {}
48
+ self.stale_polls = 0
49
+ """After a repair, this many `get_run` calls still show the run as it was before it."""
50
+ self._stale: dict[int, tuple[RunInfo, int]] = {}
51
+ self._next = 100
52
+ self._tick = 0
53
+
54
+ def _enter(self, method: str, arg: object = "") -> None:
55
+ self.calls.append((method, str(arg)))
56
+ if method in self.fail:
57
+ raise self.fail[method]
58
+
59
+ def _id(self) -> int:
60
+ self._next += 1
61
+ return self._next
62
+
63
+ def _touch(self, path: str) -> None:
64
+ self._tick += 1
65
+ self.modified[path] = self._tick
66
+
67
+ def called(self, method: str) -> list[str]:
68
+ return [arg for name, arg in self.calls if name == method]
69
+
70
+ # jobs
71
+
72
+ def list_jobs(self, *, name: str | None = None) -> Iterator[JobSummary]:
73
+ self._enter("list_jobs", name or "")
74
+ for job_id, settings in sorted(self.jobs.items()):
75
+ if name is not None and settings.get("name") != name:
76
+ continue
77
+ tags = settings.get("tags")
78
+ tag_map = {str(k): str(v) for k, v in tags.items()} if isinstance(tags, dict) else {}
79
+ yield JobSummary(job_id, str(settings.get("name", "")), tag_map)
80
+
81
+ def add_job(self, settings: Mapping[str, JsonValue]) -> int:
82
+ """Put a job in without a call record (test setup)."""
83
+ job_id = self._id()
84
+ self.jobs[job_id] = json.loads(json.dumps(dict(settings)))
85
+ return job_id
86
+
87
+ def create_job(self, settings: Mapping[str, JsonValue]) -> int:
88
+ self._enter("create_job")
89
+ return self.add_job(settings)
90
+
91
+ def reset_job(self, job_id: int, settings: Mapping[str, JsonValue]) -> None:
92
+ self._enter("reset_job", job_id)
93
+ if job_id not in self.jobs:
94
+ raise ApiError(404, f"Job {job_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
95
+ self.jobs[job_id] = json.loads(json.dumps(dict(settings)))
96
+
97
+ # runs
98
+
99
+ def _start(self, job_id: int | None, params: Mapping[str, str], token: str | None) -> int:
100
+ if token is not None and token in self.run_tokens:
101
+ return self.run_tokens[token]
102
+ run_id = self._id()
103
+ self.runs[run_id] = RunInfo(run_id, job_id, "PENDING", None, dict(params))
104
+ if token is not None:
105
+ self.run_tokens[token] = run_id
106
+ return run_id
107
+
108
+ def run_now(
109
+ self,
110
+ job_id: int,
111
+ *,
112
+ job_parameters: Mapping[str, str],
113
+ only: Sequence[str] | None,
114
+ idempotency_token: str | None,
115
+ ) -> int:
116
+ self._enter("run_now", job_id)
117
+ if job_id not in self.jobs:
118
+ raise ApiError(404, f"Job {job_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
119
+ self.starts.append(
120
+ (
121
+ job_id,
122
+ dict(job_parameters),
123
+ None if only is None else tuple(only),
124
+ idempotency_token,
125
+ )
126
+ )
127
+ return self._start(job_id, job_parameters, idempotency_token)
128
+
129
+ def submit_run(self, spec: Mapping[str, JsonValue], *, idempotency_token: str | None) -> int:
130
+ self._enter("submit_run")
131
+ self.submissions.append((json.loads(json.dumps(dict(spec))), idempotency_token))
132
+ return self._start(None, {}, idempotency_token)
133
+
134
+ def set_run(
135
+ self,
136
+ run_id: int,
137
+ *,
138
+ job_id: int | None = None,
139
+ life_cycle_state: str = "TERMINATED",
140
+ result_state: str | None = None,
141
+ tasks: Sequence[TaskRunInfo] = (),
142
+ job_parameters: Mapping[str, str] | None = None,
143
+ latest_repair_id: int | None = None,
144
+ run_page_url: str | None = None,
145
+ repair_states: Mapping[int, str] | None = None,
146
+ ) -> RunInfo:
147
+ """Put a run in without a call record (test setup, or the simulator)."""
148
+ run = RunInfo(
149
+ run_id,
150
+ job_id,
151
+ life_cycle_state,
152
+ result_state,
153
+ dict(job_parameters or {}),
154
+ tuple(tasks),
155
+ latest_repair_id,
156
+ run_page_url,
157
+ repair_states=dict(repair_states or {}),
158
+ )
159
+ self.runs[run_id] = run
160
+ return run
161
+
162
+ def get_run(self, run_id: int) -> RunInfo:
163
+ self._enter("get_run", run_id)
164
+ if run_id not in self.runs:
165
+ raise ApiError(404, f"Run {run_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
166
+ stale = self._stale.get(run_id)
167
+ if stale is not None and stale[1] > 0:
168
+ self._stale[run_id] = (stale[0], stale[1] - 1)
169
+ return stale[0]
170
+ return self.runs[run_id]
171
+
172
+ def list_runs(self, job_id: int, *, active_only: bool = False) -> Iterator[RunInfo]:
173
+ self._enter("list_runs", job_id)
174
+ for run in self.runs.values():
175
+ active = run.life_cycle_state not in ("TERMINATED", "SKIPPED", "INTERNAL_ERROR")
176
+ if run.job_id == job_id and (active or not active_only):
177
+ yield run
178
+
179
+ def repair_run(
180
+ self, run_id: int, *, rerun_tasks: Sequence[str], latest_repair_id: int | None
181
+ ) -> int:
182
+ self._enter("repair_run", run_id)
183
+ if run_id not in self.runs:
184
+ raise ApiError(404, f"Run {run_id} does not exist.", "RESOURCE_DOES_NOT_EXIST")
185
+ if latest_repair_id != self.runs[run_id].latest_repair_id:
186
+ raise ApiError(
187
+ 400, "latest_repair_id is not the latest repair.", "INVALID_PARAMETER_VALUE"
188
+ )
189
+ repair_id = self._id()
190
+ self.repairs.append((run_id, tuple(rerun_tasks), latest_repair_id))
191
+ if self.stale_polls:
192
+ before = replace(self.runs[run_id], latest_repair_id=repair_id)
193
+ self._stale[run_id] = (before, self.stale_polls)
194
+ self.runs[run_id] = replace(
195
+ self.runs[run_id],
196
+ life_cycle_state="RUNNING",
197
+ latest_repair_id=repair_id,
198
+ repair_states={**self.runs[run_id].repair_states, repair_id: "RUNNING"},
199
+ )
200
+ return repair_id
201
+
202
+ # workspace
203
+
204
+ def current_user(self) -> str:
205
+ self._enter("current_user")
206
+ return self.user
207
+
208
+ def mkdirs(self, path: str) -> None:
209
+ self._enter("mkdirs", path)
210
+ here = path.rstrip("/") or "/"
211
+ while here not in self.dirs:
212
+ self.dirs.add(here) # like the real API, a folder has no modification time
213
+ here = _parent(here)
214
+
215
+ def upload(self, path: str, data: bytes) -> None:
216
+ self._enter("upload", path)
217
+ if _parent(path) not in self.dirs:
218
+ raise ApiError(404, f"The parent of {path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
219
+ self.files[path] = data
220
+ self._touch(path)
221
+
222
+ def list_dir(self, path: str) -> list[DirEntry]:
223
+ self._enter("list_dir", path)
224
+ here = path.rstrip("/") or "/"
225
+ if here not in self.dirs:
226
+ raise ApiError(404, f"{path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
227
+ kids = [(p, True) for p in self.dirs if p != "/" and _parent(p) == here]
228
+ kids += [(p, False) for p in self.files if _parent(p) == here]
229
+ return [DirEntry(p, d, self.modified.get(p, 0)) for p, d in sorted(kids)]
230
+
231
+ def delete(self, path: str, *, recursive: bool) -> None:
232
+ self._enter("delete", path)
233
+ here = path.rstrip("/")
234
+ inside = [p for p in (*self.dirs, *self.files) if p.startswith(here + "/")]
235
+ if here not in self.dirs and here not in self.files:
236
+ raise ApiError(404, f"{path} does not exist.", "RESOURCE_DOES_NOT_EXIST")
237
+ if inside and not recursive:
238
+ raise ApiError(400, f"{path} is not empty.", "DIRECTORY_NOT_EMPTY")
239
+ for p in inside:
240
+ self.dirs.discard(p)
241
+ self.files.pop(p, None)
242
+ self.dirs.discard(here)
243
+ self.files.pop(here, None)
244
+
245
+ def read_file(self, path: str) -> bytes:
246
+ self._enter("read_file", path)
247
+ if path not in self.volume_files:
248
+ raise ApiError(404, f"{path} does not exist.", "NOT_FOUND")
249
+ return self.volume_files[path]
250
+
251
+ def get_secret(self, scope: str, key: str) -> str:
252
+ self._enter("get_secret", f"{scope}/{key}")
253
+ if (scope, key) not in self.secrets:
254
+ raise ApiError(404, f"Secret {scope}/{key} does not exist.", "RESOURCE_DOES_NOT_EXIST")
255
+ return base64.b64encode(self.secrets[(scope, key)].encode("utf-8")).decode("ascii")
256
+
257
+
258
+ def empty() -> FakeDatabricks:
259
+ """A workspace with nothing in it."""
260
+ return FakeDatabricks()
261
+
262
+
263
+ def duplicate_jobs() -> FakeDatabricks:
264
+ """Two jobs that both claim the job tag `shop.main.prod` (PLD006)."""
265
+ client = FakeDatabricks()
266
+ for _ in range(2):
267
+ client.add_job({"name": "pipeloom_shop_main_prod", "tags": {JOB_TAG: "shop.main.prod"}})
268
+ return client
269
+
270
+
271
+ def stale_job() -> FakeDatabricks:
272
+ """A job for `shop.main.prod` deployed from other settings than any project renders (PLD008)."""
273
+ client = FakeDatabricks()
274
+ tags: dict[str, JsonValue] = {JOB_TAG: "shop.main.prod", "pipeloom_settings_hash": "0" * 64}
275
+ client.add_job({"name": "pipeloom_shop_main_prod", "tags": tags})
276
+ return client
277
+
278
+
279
+ def active_run() -> FakeDatabricks:
280
+ """Job run 900 of job 101 is still running (PLD014)."""
281
+ client = FakeDatabricks()
282
+ job_id = client.add_job(
283
+ {"name": "pipeloom_shop_main_prod", "tags": {JOB_TAG: "shop.main.prod"}}
284
+ )
285
+ client.set_run(900, job_id=job_id, life_cycle_state="RUNNING")
286
+ return client
287
+
288
+
289
+ def api_down() -> FakeDatabricks:
290
+ """A workspace whose job creation fails with a 500 (PLD009)."""
291
+ client = FakeDatabricks()
292
+ client.fail["create_job"] = ApiError(500, "INTERNAL_ERROR: the service is unavailable")
293
+ return client
@@ -0,0 +1,179 @@
1
+ """A local stand-in for the Jobs service: run a rendered job through the task wrapper.
2
+
3
+ It substitutes the dynamic value references, runs the tasks in dependency order through
4
+ `pipeloom_databricks.task.main` in this process, and honors `run_if`, `only` and a repair.
5
+ It starts no cluster and calls no API. The project's engine, state store and artifacts root
6
+ come from its own configuration, so a test points them at a temporary directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Collection, Mapping
13
+ from dataclasses import dataclass, field, replace
14
+ from typing import Any
15
+
16
+ from pydantic import JsonValue
17
+
18
+ from pipeloom_databricks.client import (
19
+ ConnectionNotConfigured,
20
+ DatabricksClient,
21
+ TaskRunInfo,
22
+ )
23
+ from pipeloom_databricks.task import main
24
+ from pipeloom_databricks.task_values import TASK_VALUE_KEY
25
+ from pipeloom_databricks.testing.fake import FakeDatabricks
26
+
27
+ START_MS = 1_700_000_000_000
28
+ """The job start every simulated run (and repair) reports."""
29
+ _REFERENCE = re.compile(r"\{\{([^{}]+)\}\}")
30
+
31
+
32
+ @dataclass
33
+ class SimRun:
34
+ job_run_id: int
35
+ task_states: dict[str, str] = field(default_factory=dict)
36
+ """SUCCESS, FAILED, UPSTREAM_FAILED or EXCLUDED, for the tasks this call ran or skipped."""
37
+ exit_codes: dict[str, int] = field(default_factory=dict)
38
+ """The exit code of every task that ran."""
39
+ task_values: dict[str, Any] = field(default_factory=dict)
40
+ """The `pipeloom` task value each task set, by task key."""
41
+
42
+
43
+ class _Recorder:
44
+ def __init__(self, run: SimRun, task_key: str) -> None:
45
+ self._run = run
46
+ self._key = task_key
47
+
48
+ def set(self, key: str, value: JsonValue) -> None:
49
+ if key == TASK_VALUE_KEY:
50
+ self._run.task_values[self._key] = value
51
+
52
+
53
+ def _order(tasks: Mapping[str, Mapping[str, Any]]) -> list[str]:
54
+ """Dependencies first, ties by key."""
55
+ done: list[str] = []
56
+ waiting = sorted(tasks)
57
+ while waiting:
58
+ ready = [
59
+ k for k in waiting if all(d["task_key"] in done for d in tasks[k].get("depends_on", []))
60
+ ]
61
+ if not ready:
62
+ raise ValueError("the job's tasks form a cycle or depend on an unknown task")
63
+ done.append(ready[0])
64
+ waiting.remove(ready[0])
65
+ return done
66
+
67
+
68
+ def _no_client() -> DatabricksClient:
69
+ raise ConnectionNotConfigured("the simulator was given no client")
70
+
71
+
72
+ def _record(client: FakeDatabricks, run: SimRun, job_id: int, params: Mapping[str, str]) -> None:
73
+ """Put the task states known so far into the fake's run, as the Jobs API would report them
74
+ to the finalize task. A repair keeps the tasks it does not run again."""
75
+ old = client.runs.get(run.job_run_id)
76
+ tasks = {t.task_key: t for t in old.tasks} if old else {}
77
+ for key, state in run.task_states.items():
78
+ failed = state == "FAILED"
79
+ tasks[key] = TaskRunInfo(
80
+ key,
81
+ run.job_run_id * 1000 + len(tasks),
82
+ 1,
83
+ "TERMINATED",
84
+ state,
85
+ "RUN_EXECUTION_ERROR" if failed else None,
86
+ "the task failed" if failed else None,
87
+ )
88
+ client.set_run(
89
+ run.job_run_id,
90
+ job_id=job_id,
91
+ life_cycle_state="RUNNING",
92
+ tasks=[tasks[k] for k in sorted(tasks)],
93
+ job_parameters=params,
94
+ latest_repair_id=old.latest_repair_id if old else None,
95
+ )
96
+
97
+
98
+ def simulate_job(
99
+ settings: Mapping[str, JsonValue],
100
+ *,
101
+ job_run_id: int,
102
+ job_parameters: Mapping[str, str],
103
+ fail: Collection[str] = (),
104
+ only: Collection[str] | None = None,
105
+ rerun: Collection[str] | None = None,
106
+ job_id: int = 1,
107
+ start_ms: int = START_MS,
108
+ client: FakeDatabricks | None = None,
109
+ ) -> SimRun:
110
+ """Run the job once.
111
+
112
+ `fail` names tasks that die before they run anything (a lost cluster): exit 1, no attempt
113
+ file. `only` runs just those tasks; the others are EXCLUDED. `rerun` is a repair: only
114
+ those tasks run again, the others keep what an earlier call did. With `client`, the task
115
+ states of the run are kept in the fake as they happen, and finalize reads them from it; without
116
+ one finalize finds no connection and runs without states.
117
+ """
118
+ job: dict[str, Any] = dict(settings)
119
+ tasks: dict[str, Mapping[str, Any]] = {t["task_key"]: t for t in job["tasks"]}
120
+ params: dict[str, str] = {}
121
+ for item in job.get("parameters") or []:
122
+ params[item["name"]] = item["default"]
123
+ params.update(job_parameters)
124
+
125
+ run = SimRun(job_run_id)
126
+ factory = _no_client if client is None else (lambda: client)
127
+ for position, key in enumerate(_order(tasks), start=1):
128
+ task = tasks[key]
129
+ if only is not None and key not in only:
130
+ run.task_states[key] = "EXCLUDED"
131
+ continue
132
+ if rerun is not None and key not in rerun:
133
+ continue
134
+ if client is not None:
135
+ _record(client, run, job_id, params)
136
+ seen = [
137
+ run.task_states.get(d["task_key"], "SUCCESS")
138
+ for d in task.get("depends_on", [])
139
+ if run.task_states.get(d["task_key"]) != "EXCLUDED"
140
+ ]
141
+ run_if = task.get("run_if") or "ALL_SUCCESS"
142
+ if run_if not in ("ALL_SUCCESS", "ALL_DONE"):
143
+ raise ValueError(f"run_if {run_if} is not simulated")
144
+ if run_if == "ALL_SUCCESS" and any(s != "SUCCESS" for s in seen):
145
+ run.task_states[key] = "UPSTREAM_FAILED"
146
+ continue
147
+ if key in fail:
148
+ run.task_states[key], run.exit_codes[key] = "FAILED", 1
149
+ continue
150
+ values = {
151
+ "job.id": str(job_id),
152
+ "job.run_id": str(job_run_id),
153
+ "job.start_time.timestamp_ms": str(start_ms),
154
+ "task.name": key,
155
+ "task.run_id": str(job_run_id * 1000 + position),
156
+ }
157
+
158
+ def substitute(match: re.Match[str], values: dict[str, str] = values) -> str:
159
+ name = match.group(1).strip()
160
+ if name.startswith("job.parameters."):
161
+ return params.get(name.removeprefix("job.parameters."), match.group(0))
162
+ return values.get(name, match.group(0))
163
+
164
+ argv = [_REFERENCE.sub(substitute, p) for p in task["python_wheel_task"]["parameters"]]
165
+ try:
166
+ main(argv, task_values=_Recorder(run, key), client_factory=factory)
167
+ code = 0
168
+ except SystemExit as stop:
169
+ code = stop.code if isinstance(stop.code, int) else 0
170
+ run.exit_codes[key] = code
171
+ run.task_states[key] = "SUCCESS" if code == 0 else "FAILED"
172
+ if client is not None:
173
+ _record(client, run, job_id, params)
174
+ done = client.runs[job_run_id]
175
+ bad = any(t.result_state not in ("SUCCESS", "EXCLUDED") for t in done.tasks)
176
+ client.runs[job_run_id] = replace(
177
+ done, life_cycle_state="TERMINATED", result_state="FAILED" if bad else "SUCCESS"
178
+ )
179
+ return run
@@ -0,0 +1,23 @@
1
+ """Unity Catalog: `catalog.schema.name` tables and views through the Spark session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pipeloom.engine.types import CatalogCapabilities, Namespace
6
+ from pipeloom_spark.catalog import SparkSessionCatalog
7
+
8
+
9
+ class UnityCatalog(SparkSessionCatalog):
10
+ """Always three-part, backticked identifiers. A target whose naming left the catalog empty
11
+ gets the catalog from the options."""
12
+
13
+ capabilities = CatalogCapabilities(views=True)
14
+
15
+ def __init__(self, catalog: str) -> None:
16
+ super().__init__(catalog)
17
+
18
+ def ensure_namespace(self, ns: Namespace) -> None:
19
+ # CREATE SCHEMA IF NOT EXISTS needs CREATE SCHEMA on the catalog even when the schema
20
+ # exists, so a principal that only holds USE SCHEMA must not run it.
21
+ if self.spark.catalog.databaseExists(self._parts(ns.catalog, ns.schema)):
22
+ return
23
+ super().ensure_namespace(ns)