pytest-databricks-lakeflow 0.1.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.
- pytest_databricks_lakeflow/__init__.py +20 -0
- pytest_databricks_lakeflow/bundle.py +215 -0
- pytest_databricks_lakeflow/client.py +228 -0
- pytest_databricks_lakeflow/ephemeral.py +215 -0
- pytest_databricks_lakeflow/errors.py +13 -0
- pytest_databricks_lakeflow/helpers.py +59 -0
- pytest_databricks_lakeflow/hooks.py +30 -0
- pytest_databricks_lakeflow/options.py +122 -0
- pytest_databricks_lakeflow/plugin.py +140 -0
- pytest_databricks_lakeflow/py.typed +0 -0
- pytest_databricks_lakeflow/results.py +374 -0
- pytest_databricks_lakeflow/session.py +167 -0
- pytest_databricks_lakeflow/settings.py +341 -0
- pytest_databricks_lakeflow-0.1.0.dist-info/METADATA +248 -0
- pytest_databricks_lakeflow-0.1.0.dist-info/RECORD +17 -0
- pytest_databricks_lakeflow-0.1.0.dist-info/WHEEL +4 -0
- pytest_databricks_lakeflow-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Run Lakeflow Jobs from a Databricks Asset Bundle as throwaway jobs inside pytest tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pytest_databricks_lakeflow.client import Lakeflow
|
|
6
|
+
from pytest_databricks_lakeflow.ephemeral import EphemeralJob
|
|
7
|
+
from pytest_databricks_lakeflow.errors import LakeflowBundleError, LakeflowError, LakeflowTimeout
|
|
8
|
+
from pytest_databricks_lakeflow.results import JobRun, TaskError, TaskRun, TaskValues
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EphemeralJob",
|
|
12
|
+
"JobRun",
|
|
13
|
+
"Lakeflow",
|
|
14
|
+
"LakeflowBundleError",
|
|
15
|
+
"LakeflowError",
|
|
16
|
+
"LakeflowTimeout",
|
|
17
|
+
"TaskError",
|
|
18
|
+
"TaskRun",
|
|
19
|
+
"TaskValues",
|
|
20
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import glob
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from databricks.sdk.service import jobs
|
|
14
|
+
|
|
15
|
+
from pytest_databricks_lakeflow.errors import LakeflowBundleError
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("pytest_databricks_lakeflow")
|
|
18
|
+
|
|
19
|
+
BUNDLE_FILES = ("databricks.yml", "databricks.yaml")
|
|
20
|
+
MIN_CLI_VERSION = (1, 7, 0)
|
|
21
|
+
UNRESOLVED = re.compile(r"\$\{[^}]+\}")
|
|
22
|
+
VERSION = re.compile(r"v(\d+)\.(\d+)\.(\d+)")
|
|
23
|
+
CLI_DOCS = "https://docs.databricks.com/dev-tools/cli/install"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_root(start: Path) -> Path:
|
|
27
|
+
for path in (start, *start.parents):
|
|
28
|
+
if _has_bundle(path):
|
|
29
|
+
return path
|
|
30
|
+
raise LakeflowBundleError(f"No databricks.yml in {start} or any parent directory. Set lakeflow_bundle_root.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def require_root(path: Path) -> Path:
|
|
34
|
+
if not _has_bundle(path):
|
|
35
|
+
raise LakeflowBundleError(f"lakeflow_bundle_root {path} has no databricks.yml")
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _has_bundle(path: Path) -> bool:
|
|
40
|
+
return any((path / name).is_file() for name in BUNDLE_FILES)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_cli(args: list[str], root: Path, profile: str | None = None) -> str:
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
raise LakeflowBundleError(f"Bundle root {root} does not exist")
|
|
46
|
+
cmd = ["databricks", *args, *(["--profile", profile] if profile else [])]
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(cmd, cwd=root, check=True, capture_output=True, text=True)
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
raise LakeflowBundleError(f"The databricks CLI is not on PATH. Install it from {CLI_DOCS}") from None
|
|
51
|
+
except subprocess.CalledProcessError as e:
|
|
52
|
+
raise LakeflowBundleError(f"`{' '.join(cmd)}` failed with exit code {e.returncode}:\n{e.stderr.strip()}") from e
|
|
53
|
+
return result.stdout
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_cli_version(root: Path) -> None:
|
|
57
|
+
out = run_cli(["--version"], root)
|
|
58
|
+
match = VERSION.search(out)
|
|
59
|
+
version = tuple(int(part) for part in match.groups()) if match else None
|
|
60
|
+
if version and (0, 0, 0) < version < MIN_CLI_VERSION:
|
|
61
|
+
minimum = ".".join(map(str, MIN_CLI_VERSION))
|
|
62
|
+
raise LakeflowBundleError(f"pytest-databricks-lakeflow needs Databricks CLI v{minimum} or later: {out.strip()}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def deploy(root: Path, target: str, profile: str | None) -> None:
|
|
66
|
+
log.info("Deploying bundle %s to target %s", root, target)
|
|
67
|
+
run_cli(["bundle", "deploy", "-t", target, "--auto-approve"], root, profile)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def sync(root: Path, target: str, profile: str | None) -> None:
|
|
71
|
+
log.info("Syncing bundle %s to target %s", root, target)
|
|
72
|
+
run_cli(["bundle", "sync", "-t", target], root, profile)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate(root: Path, target: str, profile: str | None) -> dict[str, Any]:
|
|
76
|
+
return _json_command("validate", root, target, profile)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def summary(root: Path, target: str, profile: str | None) -> dict[str, Any]:
|
|
80
|
+
return _json_command("summary", root, target, profile)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def destroy(root: Path, target: str, profile: str | None) -> None:
|
|
84
|
+
log.info("Destroying bundle %s target %s", root, target)
|
|
85
|
+
run_cli(["bundle", "destroy", "-t", target, "--auto-approve"], root, profile)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _json_command(command: str, root: Path, target: str, profile: str | None) -> dict[str, Any]:
|
|
89
|
+
out = run_cli(["bundle", command, "-t", target, "-o", "json"], root, profile)
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(out)
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
raise LakeflowBundleError(f"`databricks bundle {command}` did not print JSON: {e}\n{out[:2000]}") from e
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
raise LakeflowBundleError(f"`databricks bundle {command}` printed {type(data).__name__}, expected an object")
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def job_ids(bundle_summary: Mapping[str, Any]) -> tuple[dict[str, int], dict[str, str]]:
|
|
100
|
+
"""Read deployed job ids from `bundle summary` output.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
bundle_summary: The parsed output of `databricks bundle summary -o json`.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The job ids by resource key, and an error for each job that is defined but not deployed.
|
|
107
|
+
"""
|
|
108
|
+
ids: dict[str, int] = {}
|
|
109
|
+
errors: dict[str, str] = {}
|
|
110
|
+
for key, job in bundle_summary.get("resources", {}).get("jobs", {}).items():
|
|
111
|
+
if job.get("modified_status") == "deleted":
|
|
112
|
+
continue
|
|
113
|
+
if job.get("id"):
|
|
114
|
+
ids[key] = int(job["id"])
|
|
115
|
+
else:
|
|
116
|
+
errors[key] = f"Job {key!r} is not deployed. Run `databricks bundle deploy` or use lakeflow_sync = deploy."
|
|
117
|
+
return ids, errors
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def templates_from_validate(
|
|
121
|
+
config: Mapping[str, Any], root: Path, strategy: str, exists: Callable[[str], bool]
|
|
122
|
+
) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
|
|
123
|
+
"""Build job templates from `bundle validate` output, for the sync and none strategies.
|
|
124
|
+
|
|
125
|
+
Validate resolves notebook and script paths but leaves library paths local. With `none`, each local library
|
|
126
|
+
is pointed at the copy that an earlier `bundle deploy` uploaded, after checking that it is there. With `sync`,
|
|
127
|
+
which uploads no libraries, a job with local libraries gets an error instead.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
config: The parsed output of `databricks bundle validate -o json`.
|
|
131
|
+
root: The bundle root, against which local library paths are resolved.
|
|
132
|
+
strategy: `sync` or `none`.
|
|
133
|
+
exists: Checks whether a workspace or Volumes path exists.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The templates by resource key, and an error for each job that cannot be used.
|
|
137
|
+
"""
|
|
138
|
+
artifact_path = config.get("workspace", {}).get("artifact_path", "")
|
|
139
|
+
templates: dict[str, dict[str, Any]] = {}
|
|
140
|
+
errors: dict[str, str] = {}
|
|
141
|
+
for key, job in config.get("resources", {}).get("jobs", {}).items():
|
|
142
|
+
refs = sorted(set(UNRESOLVED.findall(json.dumps(job))))
|
|
143
|
+
if refs:
|
|
144
|
+
errors[key] = (
|
|
145
|
+
f"Job {key!r} contains {', '.join(refs)}, which `bundle validate` does not resolve. "
|
|
146
|
+
"Use lakeflow_sync = deploy."
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
local = _local_libraries(job)
|
|
150
|
+
if local and strategy == "sync":
|
|
151
|
+
errors[key] = (
|
|
152
|
+
f"Job {key!r} uses local libraries ({', '.join(local)}), which `bundle sync` does not upload. "
|
|
153
|
+
"Use lakeflow_sync = deploy."
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
patched = job
|
|
157
|
+
if local:
|
|
158
|
+
try:
|
|
159
|
+
patched = _map_libraries(job, lambda path: _uploaded(path, root, artifact_path, exists))
|
|
160
|
+
except LakeflowBundleError as e:
|
|
161
|
+
errors[key] = f"Job {key!r}: {e}"
|
|
162
|
+
continue
|
|
163
|
+
templates[key] = jobs.JobSettings.from_dict(patched).as_dict()
|
|
164
|
+
return templates, errors
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _uploaded(pattern: str, root: Path, artifact_path: str, exists: Callable[[str], bool]) -> list[str]:
|
|
168
|
+
matches = sorted(glob.glob(str(root / pattern)))
|
|
169
|
+
if not matches:
|
|
170
|
+
raise LakeflowBundleError(f"no file under {root} matches {pattern!r}. Build and deploy the bundle first.")
|
|
171
|
+
remote = [f"{artifact_path}/.internal/{Path(match).name}" for match in matches]
|
|
172
|
+
missing = [path for path in remote if not exists(path)]
|
|
173
|
+
if missing:
|
|
174
|
+
raise LakeflowBundleError(f"{', '.join(missing)} not found. Run `databricks bundle deploy` first.")
|
|
175
|
+
return remote
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _local_libraries(job: Mapping[str, Any]) -> list[str]:
|
|
179
|
+
found: list[str] = []
|
|
180
|
+
|
|
181
|
+
def record(path: str) -> list[str]:
|
|
182
|
+
found.append(path)
|
|
183
|
+
return [path]
|
|
184
|
+
|
|
185
|
+
_map_libraries(job, record)
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _map_libraries(job: Mapping[str, Any], fn: Callable[[str], list[str]]) -> dict[str, Any]:
|
|
190
|
+
"""Copy a job, replacing each local library path with the paths `fn` returns for it."""
|
|
191
|
+
copied: dict[str, Any] = copy.deepcopy(dict(job))
|
|
192
|
+
tasks = [*copied.get("tasks", [])]
|
|
193
|
+
tasks += [t["for_each_task"]["task"] for t in tasks if "task" in t.get("for_each_task", {})]
|
|
194
|
+
for task in tasks:
|
|
195
|
+
if "libraries" not in task:
|
|
196
|
+
continue
|
|
197
|
+
libraries = []
|
|
198
|
+
for library in task["libraries"]:
|
|
199
|
+
kind = next((k for k in ("whl", "jar") if _is_local(library.get(k))), None)
|
|
200
|
+
libraries += [{kind: path} for path in fn(library[kind])] if kind else [library]
|
|
201
|
+
task["libraries"] = libraries
|
|
202
|
+
for env in copied.get("environments", []):
|
|
203
|
+
spec = env.get("spec", {})
|
|
204
|
+
if "dependencies" in spec:
|
|
205
|
+
spec["dependencies"] = [path for dep in spec["dependencies"] for path in _dependency(dep, fn)]
|
|
206
|
+
return copied
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _dependency(dep: str, fn: Callable[[str], list[str]]) -> list[str]:
|
|
210
|
+
local = dep.startswith(("./", "../")) or (dep.endswith((".whl", ".jar")) and _is_local(dep))
|
|
211
|
+
return fn(dep) if local else [dep]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_local(path: object) -> bool:
|
|
215
|
+
return isinstance(path, str) and bool(path) and not path.startswith("/") and ":" not in path
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from databricks.sdk import WorkspaceClient
|
|
13
|
+
from databricks.sdk.errors.base import DatabricksError
|
|
14
|
+
from databricks.sdk.service import jobs
|
|
15
|
+
|
|
16
|
+
from pytest_databricks_lakeflow.ephemeral import EphemeralJob, cancel_run, item_failed
|
|
17
|
+
from pytest_databricks_lakeflow.errors import LakeflowBundleError, LakeflowError
|
|
18
|
+
from pytest_databricks_lakeflow.options import Options
|
|
19
|
+
from pytest_databricks_lakeflow.settings import Helpers, check_params, inject_probes, sanitise, wrap_task
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("pytest_databricks_lakeflow")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Lakeflow:
|
|
26
|
+
"""Creates throwaway copies of the bundle's jobs. Use the `lakeflow` fixture to get one.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
ws: The workspace client.
|
|
30
|
+
options: The plugin's options.
|
|
31
|
+
run_id: The id of this test session, used to tag ephemeral jobs.
|
|
32
|
+
templates: The deployed settings of each bundle job, by resource key, as dicts.
|
|
33
|
+
template_errors: Why each bundle job that cannot be used has no template, by resource key.
|
|
34
|
+
helpers: Workspace paths of the helper notebooks, by name.
|
|
35
|
+
request: The test this copy is bound to, or `None` for the session's copy.
|
|
36
|
+
active_runs: Ids of runs started in this session that have not finished.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ws: WorkspaceClient
|
|
40
|
+
options: Options
|
|
41
|
+
run_id: str
|
|
42
|
+
templates: dict[str, dict[str, Any]]
|
|
43
|
+
template_errors: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
helpers: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
request: pytest.FixtureRequest | None = None
|
|
46
|
+
active_runs: set[int] = field(default_factory=set)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_state(cls, ws: WorkspaceClient, options: Options, state: dict[str, Any]) -> Lakeflow:
|
|
50
|
+
return cls(
|
|
51
|
+
ws=ws,
|
|
52
|
+
options=options,
|
|
53
|
+
run_id=state["run_id"],
|
|
54
|
+
templates=state["templates"],
|
|
55
|
+
template_errors=state["template_errors"],
|
|
56
|
+
helpers=state["helpers"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def for_test(self, request: pytest.FixtureRequest) -> Lakeflow:
|
|
60
|
+
"""Bind a copy to one test.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
request: The test's fixture request.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A copy that shares this one's templates and active runs.
|
|
67
|
+
"""
|
|
68
|
+
return replace(self, request=request)
|
|
69
|
+
|
|
70
|
+
def template(self, key: str) -> jobs.JobSettings:
|
|
71
|
+
"""Get a fresh copy of the deployed settings of a bundle job.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
key: The bundle resource key of the job.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The job's settings, safe to change.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
KeyError: If the bundle has no job `key`.
|
|
81
|
+
LakeflowBundleError: If the job cannot be used as a template, for example because it is not deployed.
|
|
82
|
+
"""
|
|
83
|
+
if key in self.template_errors:
|
|
84
|
+
raise LakeflowBundleError(self.template_errors[key])
|
|
85
|
+
if key not in self.templates:
|
|
86
|
+
raise KeyError(f"No job {key!r} in the bundle. Available: {sorted(self.templates)}")
|
|
87
|
+
return jobs.JobSettings.from_dict(copy.deepcopy(self.templates[key]))
|
|
88
|
+
|
|
89
|
+
def current_request(self) -> pytest.FixtureRequest:
|
|
90
|
+
if self.request is None:
|
|
91
|
+
raise LakeflowError("Use the lakeflow fixture inside a test to create ephemeral jobs")
|
|
92
|
+
return self.request
|
|
93
|
+
|
|
94
|
+
def job(
|
|
95
|
+
self,
|
|
96
|
+
key: str,
|
|
97
|
+
params: Mapping[str, str] | None = None,
|
|
98
|
+
capture_values: Mapping[str, Sequence[str]] | None = None,
|
|
99
|
+
name: str | None = None,
|
|
100
|
+
) -> EphemeralJob:
|
|
101
|
+
"""Make a throwaway copy of a bundle job.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
key: The bundle resource key of the job, as `databricks bundle run` takes it.
|
|
105
|
+
params: Job parameters for every run of the copy.
|
|
106
|
+
capture_values: The task value keys to capture, by task key.
|
|
107
|
+
name: Replaces the test node id in the job name.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The job, created when entered with `with` or on its first run.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
KeyError: If the bundle has no job `key`.
|
|
114
|
+
LakeflowError: If `capture_values` names a task that is not in the job, or a parameter name is
|
|
115
|
+
reserved.
|
|
116
|
+
"""
|
|
117
|
+
settings = self.template(key)
|
|
118
|
+
check_params(key, settings, params or {}, allow_undeclared=True)
|
|
119
|
+
inject_probes(settings, capture_values or {}, self._helpers())
|
|
120
|
+
return self._ephemeral(key, settings, params, capture_values, (), name)
|
|
121
|
+
|
|
122
|
+
def task(
|
|
123
|
+
self,
|
|
124
|
+
ref: str,
|
|
125
|
+
params: Mapping[str, str] | None = None,
|
|
126
|
+
upstream_values: Mapping[str, Mapping[str, object]] | None = None,
|
|
127
|
+
capture_values: Sequence[str] | None = None,
|
|
128
|
+
) -> EphemeralJob:
|
|
129
|
+
"""Make a throwaway job that holds one task of a bundle job.
|
|
130
|
+
|
|
131
|
+
Each upstream task named in `upstream_values` is replaced by a stub task with the same key, so the task's
|
|
132
|
+
`{{tasks.<key>.values.<name>}}` references and `taskValues.get` calls resolve against the stub.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
ref: The job and task as `"job_key.task_key"`.
|
|
136
|
+
params: Job parameters for every run.
|
|
137
|
+
upstream_values: The task values each stub sets, by upstream task key.
|
|
138
|
+
capture_values: The task value keys to capture from the task.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The job, created when entered with `with` or on its first run.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
KeyError: If the bundle has no such job.
|
|
145
|
+
LakeflowError: If the job has no such task, the task refers to task values of a task that is not
|
|
146
|
+
stubbed, or a value is too large or not JSON-serialisable.
|
|
147
|
+
"""
|
|
148
|
+
job_key, _, task_key = ref.partition(".")
|
|
149
|
+
if not job_key or not task_key:
|
|
150
|
+
raise LakeflowError(f"Expected 'job_key.task_key', got {ref!r}")
|
|
151
|
+
settings = self.template(job_key)
|
|
152
|
+
check_params(job_key, settings, params or {}, allow_undeclared=True)
|
|
153
|
+
upstream = dict(upstream_values or {})
|
|
154
|
+
wrap_task(settings, task_key, upstream, list(capture_values or []), self._helpers())
|
|
155
|
+
capture = {task_key: list(capture_values)} if capture_values else {}
|
|
156
|
+
return self._ephemeral(job_key, settings, params, capture, upstream, None)
|
|
157
|
+
|
|
158
|
+
def _helpers(self) -> Helpers:
|
|
159
|
+
return Helpers(
|
|
160
|
+
set_values=self.helpers.get("set_values", ""),
|
|
161
|
+
get_values=self.helpers.get("get_values", ""),
|
|
162
|
+
environment_version=self.options.helpers_environment_version,
|
|
163
|
+
job_cluster_key=self.options.helpers_compute,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _ephemeral(
|
|
167
|
+
self,
|
|
168
|
+
key: str,
|
|
169
|
+
settings: jobs.JobSettings,
|
|
170
|
+
params: Mapping[str, str] | None,
|
|
171
|
+
capture_values: Mapping[str, Sequence[str]] | None,
|
|
172
|
+
stubs: Collection[str],
|
|
173
|
+
name: str | None,
|
|
174
|
+
) -> EphemeralJob:
|
|
175
|
+
request = self.current_request()
|
|
176
|
+
if settings.performance_target == jobs.PerformanceTarget.STANDARD and not self.options.performance_optimized:
|
|
177
|
+
warnings.warn(
|
|
178
|
+
f"Job {key!r} has performance_target STANDARD, which adds 3 to 4 minutes to every run. "
|
|
179
|
+
"Set lakeflow_performance_optimized = true to run tests with PERFORMANCE_OPTIMIZED.",
|
|
180
|
+
stacklevel=3,
|
|
181
|
+
)
|
|
182
|
+
nodeid = request.node.nodeid
|
|
183
|
+
sanitise(
|
|
184
|
+
settings,
|
|
185
|
+
name=name or nodeid,
|
|
186
|
+
tags={"pytest": "", "pytest_run_id": self.run_id, "pytest_nodeid": nodeid},
|
|
187
|
+
disable_auto_optimization=self.options.disable_auto_optimization,
|
|
188
|
+
performance_optimized=self.options.performance_optimized,
|
|
189
|
+
)
|
|
190
|
+
job = EphemeralJob(self, key, settings, params, capture_values, stubs)
|
|
191
|
+
request.addfinalizer(lambda: job.close(failed=item_failed(request.node.stash)))
|
|
192
|
+
return job
|
|
193
|
+
|
|
194
|
+
def sweep(self) -> list[int]:
|
|
195
|
+
"""Delete this user's ephemeral jobs from other runs that are older than `lakeflow_sweep_hours`.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The ids of the deleted jobs.
|
|
199
|
+
"""
|
|
200
|
+
me = self.ws.current_user.me().user_name
|
|
201
|
+
cutoff = (time.time() - self.options.sweep_hours * 3600) * 1000
|
|
202
|
+
stale = [
|
|
203
|
+
job.job_id
|
|
204
|
+
for job in self.ws.jobs.list(limit=100)
|
|
205
|
+
if job.job_id is not None and job.creator_user_name == me and _stale(job, self.run_id, cutoff)
|
|
206
|
+
]
|
|
207
|
+
deleted = []
|
|
208
|
+
for job_id in stale:
|
|
209
|
+
try:
|
|
210
|
+
self.ws.jobs.delete(job_id)
|
|
211
|
+
except DatabricksError as e:
|
|
212
|
+
log.warning("Could not delete job %s: %s", job_id, e)
|
|
213
|
+
else:
|
|
214
|
+
deleted.append(job_id)
|
|
215
|
+
log.info("Swept %d leftover ephemeral jobs", len(deleted))
|
|
216
|
+
return deleted
|
|
217
|
+
|
|
218
|
+
def cancel_active_runs(self) -> None:
|
|
219
|
+
"""Cancel every run started in this session that has not finished."""
|
|
220
|
+
for run_id in sorted(self.active_runs):
|
|
221
|
+
cancel_run(self.ws, run_id)
|
|
222
|
+
self.active_runs.clear()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stale(job: jobs.BaseJob, run_id: str, cutoff_ms: float) -> bool:
|
|
226
|
+
tags = (job.settings.tags if job.settings else None) or {}
|
|
227
|
+
other_run = tags.get("pytest_run_id", run_id) != run_id
|
|
228
|
+
return "pytest" in tags and other_run and (job.created_time or 0) < cutoff_ms
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from databricks.sdk.errors.base import DatabricksError
|
|
12
|
+
from databricks.sdk.service import jobs
|
|
13
|
+
|
|
14
|
+
from pytest_databricks_lakeflow.errors import LakeflowError, LakeflowTimeout
|
|
15
|
+
from pytest_databricks_lakeflow.results import JobRun, build_job_run
|
|
16
|
+
from pytest_databricks_lakeflow.settings import check_params, create_kwargs, select
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from databricks.sdk import WorkspaceClient
|
|
20
|
+
|
|
21
|
+
from pytest_databricks_lakeflow.client import Lakeflow
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("pytest_databricks_lakeflow")
|
|
24
|
+
|
|
25
|
+
POLL_SECONDS = 2.0
|
|
26
|
+
REPORTS = pytest.StashKey[dict[str, pytest.TestReport]]()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EphemeralJob:
|
|
30
|
+
"""A throwaway copy of a bundle job.
|
|
31
|
+
|
|
32
|
+
The job is created on entering the `with` block, or by the first `run()` when used without `with`. It is
|
|
33
|
+
deleted on leaving the block, or at the end of the test, unless `--lakeflow-keep` is set or the test failed
|
|
34
|
+
and `--lakeflow-keep-failed` is on.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
lakeflow: The `Lakeflow` that made the job.
|
|
38
|
+
job_key: The bundle resource key of the job it copies.
|
|
39
|
+
settings: The settings the job is created with.
|
|
40
|
+
params: Job parameters for every run.
|
|
41
|
+
capture_values: The task value keys captured by probes, by task key.
|
|
42
|
+
stubs: Keys of the stub tasks.
|
|
43
|
+
job_id: The job's id, once created.
|
|
44
|
+
runs: The outcome of each finished run, in order.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
lakeflow: Lakeflow,
|
|
50
|
+
job_key: str,
|
|
51
|
+
settings: jobs.JobSettings,
|
|
52
|
+
params: Mapping[str, str] | None = None,
|
|
53
|
+
capture_values: Mapping[str, Sequence[str]] | None = None,
|
|
54
|
+
stubs: Collection[str] = (),
|
|
55
|
+
) -> None:
|
|
56
|
+
self.lakeflow = lakeflow
|
|
57
|
+
self.job_key = job_key
|
|
58
|
+
self.settings = settings
|
|
59
|
+
self.params = dict(params or {})
|
|
60
|
+
self.capture_values = {key: list(keys) for key, keys in (capture_values or {}).items()}
|
|
61
|
+
self.stubs = frozenset(stubs)
|
|
62
|
+
self.job_id: int | None = None
|
|
63
|
+
self.runs: list[JobRun] = []
|
|
64
|
+
self._run_urls: dict[int, str] = {}
|
|
65
|
+
self._active: set[int] = set()
|
|
66
|
+
self._closed = False
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def url(self) -> str | None:
|
|
70
|
+
"""The job's page in the workspace, or `None` before it is created."""
|
|
71
|
+
return None if self.job_id is None else f"{self.lakeflow.ws.config.host}/jobs/{self.job_id}"
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> EphemeralJob:
|
|
74
|
+
self.create()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(
|
|
78
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
79
|
+
) -> None:
|
|
80
|
+
skipped = exc_type is not None and issubclass(exc_type, (pytest.skip.Exception, pytest.xfail.Exception))
|
|
81
|
+
self.close(failed=exc_type is not None and not skipped)
|
|
82
|
+
|
|
83
|
+
def create(self) -> None:
|
|
84
|
+
"""Create the job if it does not exist yet.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
LakeflowError: If the job has been closed, or `params` names a parameter that the job does not
|
|
88
|
+
declare after the `pytest_lakeflow_modify_settings` hook has run.
|
|
89
|
+
"""
|
|
90
|
+
if self._closed:
|
|
91
|
+
raise LakeflowError(f"The ephemeral job for {self.job_key!r} has already been closed")
|
|
92
|
+
if self.job_id is not None:
|
|
93
|
+
return
|
|
94
|
+
request = self.lakeflow.current_request()
|
|
95
|
+
request.node.ihook.pytest_lakeflow_modify_settings(settings=self.settings, request=request)
|
|
96
|
+
check_params(self.job_key, self.settings, self.params, self.lakeflow.options.allow_undeclared_params)
|
|
97
|
+
self.job_id = self.lakeflow.ws.jobs.create(**create_kwargs(self.settings)).job_id
|
|
98
|
+
log.info("Created job %s from %s: %s", self.job_id, self.job_key, self.url)
|
|
99
|
+
request.node.user_properties.append(("lakeflow_job_url", self.url))
|
|
100
|
+
|
|
101
|
+
def run(
|
|
102
|
+
self,
|
|
103
|
+
params: Mapping[str, str] | None = None,
|
|
104
|
+
only: Sequence[str] | None = None,
|
|
105
|
+
timeout: timedelta | None = None,
|
|
106
|
+
) -> JobRun:
|
|
107
|
+
"""Run the job and wait for it to finish, creating it first if needed.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
params: Job parameters for this run only, merged over the job's params.
|
|
111
|
+
only: Task keys to run. `+key`, `key+` and `+key+` add upstream and downstream tasks.
|
|
112
|
+
timeout: How long to wait. Defaults to `lakeflow_timeout_minutes`.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The run's outcome.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
LakeflowError: If the job has been closed, a parameter is not declared, or `only` names an unknown
|
|
119
|
+
task.
|
|
120
|
+
LakeflowTimeout: If the run did not finish in time. The run is cancelled first.
|
|
121
|
+
"""
|
|
122
|
+
self.create()
|
|
123
|
+
assert self.job_id is not None
|
|
124
|
+
options = self.lakeflow.options
|
|
125
|
+
check_params(self.job_key, self.settings, params or {}, options.allow_undeclared_params)
|
|
126
|
+
merged = {**self.params, **(params or {})}
|
|
127
|
+
selected = select(self.settings, only, self.stubs) if only else None
|
|
128
|
+
ws = self.lakeflow.ws
|
|
129
|
+
waiter = ws.jobs.run_now(job_id=self.job_id, job_parameters=merged or None, only=selected)
|
|
130
|
+
run_id = cast(jobs.RunNowResponse, waiter.response).run_id
|
|
131
|
+
assert run_id is not None
|
|
132
|
+
self._active.add(run_id)
|
|
133
|
+
self.lakeflow.active_runs.add(run_id)
|
|
134
|
+
log.info("Started run %s of job %s", run_id, self.job_id)
|
|
135
|
+
run = self._wait(run_id, options.timeout if timeout is None else timeout)
|
|
136
|
+
self._forget(run_id)
|
|
137
|
+
result = build_job_run(
|
|
138
|
+
ws, run, job_key=self.job_key, job_id=self.job_id, stubs=self.stubs, capture_values=self.capture_values
|
|
139
|
+
)
|
|
140
|
+
self.runs.append(result)
|
|
141
|
+
log.info("Run %s finished %s in %s: %s", run_id, result.result_state, result.duration, result.run_url)
|
|
142
|
+
request = self.lakeflow.current_request()
|
|
143
|
+
request.node.ihook.pytest_lakeflow_run_finished(run=result, request=request)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
def _wait(self, run_id: int, timeout: timedelta) -> jobs.Run:
|
|
147
|
+
ws = self.lakeflow.ws
|
|
148
|
+
deadline = time.monotonic() + timeout.total_seconds()
|
|
149
|
+
while True:
|
|
150
|
+
run = ws.jobs.get_run(run_id)
|
|
151
|
+
if run_id not in self._run_urls and run.run_page_url:
|
|
152
|
+
self._run_urls[run_id] = run.run_page_url
|
|
153
|
+
self.lakeflow.current_request().node.user_properties.append(("lakeflow_run_url", run.run_page_url))
|
|
154
|
+
if _terminated(run):
|
|
155
|
+
return run
|
|
156
|
+
if time.monotonic() >= deadline:
|
|
157
|
+
self._cancel(run_id)
|
|
158
|
+
raise LakeflowTimeout(
|
|
159
|
+
f"Run {run_id} of {self.job_key!r} did not finish within {timeout} and was cancelled: "
|
|
160
|
+
f"{run.run_page_url}"
|
|
161
|
+
)
|
|
162
|
+
time.sleep(POLL_SECONDS)
|
|
163
|
+
|
|
164
|
+
def close(self, failed: bool = False) -> None:
|
|
165
|
+
"""Cancel active runs, then delete the job unless the keep rules say to keep it.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
failed: Whether the test failed, which keeps the job when `--lakeflow-keep-failed` is on.
|
|
169
|
+
"""
|
|
170
|
+
if self._closed:
|
|
171
|
+
return
|
|
172
|
+
self._closed = True
|
|
173
|
+
for run_id in sorted(self._active):
|
|
174
|
+
self._cancel(run_id)
|
|
175
|
+
if self.job_id is None:
|
|
176
|
+
return
|
|
177
|
+
options = self.lakeflow.options
|
|
178
|
+
if options.keep or (failed and options.keep_failed):
|
|
179
|
+
log.warning("Keeping job %s from %s: %s", self.job_id, self.job_key, self.url)
|
|
180
|
+
for url in self._run_urls.values():
|
|
181
|
+
log.warning("Kept run: %s", url)
|
|
182
|
+
return
|
|
183
|
+
self.lakeflow.ws.jobs.delete(self.job_id)
|
|
184
|
+
log.info("Deleted job %s", self.job_id)
|
|
185
|
+
|
|
186
|
+
def _cancel(self, run_id: int) -> None:
|
|
187
|
+
cancel_run(self.lakeflow.ws, run_id)
|
|
188
|
+
self._forget(run_id)
|
|
189
|
+
|
|
190
|
+
def _forget(self, run_id: int) -> None:
|
|
191
|
+
self._active.discard(run_id)
|
|
192
|
+
self.lakeflow.active_runs.discard(run_id)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cancel_run(ws: WorkspaceClient, run_id: int) -> None:
|
|
196
|
+
try:
|
|
197
|
+
ws.jobs.cancel_run(run_id)
|
|
198
|
+
log.info("Cancelled run %s", run_id)
|
|
199
|
+
except DatabricksError as e:
|
|
200
|
+
log.warning("Could not cancel run %s: %s", run_id, e)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _terminated(run: jobs.Run) -> bool:
|
|
204
|
+
if run.status and run.status.state:
|
|
205
|
+
return run.status.state == jobs.RunLifecycleStateV2State.TERMINATED
|
|
206
|
+
state = run.state.life_cycle_state if run.state else None
|
|
207
|
+
return state in (
|
|
208
|
+
jobs.RunLifeCycleState.TERMINATED,
|
|
209
|
+
jobs.RunLifeCycleState.SKIPPED,
|
|
210
|
+
jobs.RunLifeCycleState.INTERNAL_ERROR,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def item_failed(stash: pytest.Stash) -> bool:
|
|
215
|
+
return any(report.failed for report in stash.get(REPORTS, {}).values())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LakeflowError(Exception):
|
|
5
|
+
"""Base class for errors raised by pytest-databricks-lakeflow."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LakeflowBundleError(LakeflowError):
|
|
9
|
+
"""A Databricks CLI bundle command failed, or the bundle cannot supply a job template."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LakeflowTimeout(LakeflowError):
|
|
13
|
+
"""A job run did not finish within its timeout. The run has been cancelled."""
|