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,158 @@
|
|
|
1
|
+
"""From a plan to the tasks of one pipeline's job: units, compute, groups and task keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from pipeloom.diagnostics import Report
|
|
10
|
+
from pipeloom.graph.dag import find_cycles
|
|
11
|
+
from pipeloom.graph.selectors import Selection, SelectorError, apply_to_plan
|
|
12
|
+
from pipeloom.plan.model import Plan
|
|
13
|
+
from pipeloom_databricks import keys
|
|
14
|
+
from pipeloom_databricks.options import DatabricksDeployOptions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TaskUnit:
|
|
19
|
+
"""One Databricks task: a unit, or a group of units."""
|
|
20
|
+
|
|
21
|
+
key: str
|
|
22
|
+
units: tuple[str, ...]
|
|
23
|
+
parents: tuple[str, ...]
|
|
24
|
+
"""Keys of the tasks this one waits for, sorted."""
|
|
25
|
+
compute: str
|
|
26
|
+
"""Key of an entry in the deploy block's `compute`."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _select(plan: Plan, select: list[str], exclude: list[str]) -> frozenset[str]:
|
|
30
|
+
# A throwaway report: "matched nothing" is reported by the caller in its own terms.
|
|
31
|
+
return apply_to_plan(Selection(tuple(select), tuple(exclude)), plan, Report())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pipeline_units(
|
|
35
|
+
plan: Plan, opts: DatabricksDeployOptions, pipeline: str, report: Report
|
|
36
|
+
) -> tuple[str, ...]:
|
|
37
|
+
"""The units of a pipeline, sorted. Empty, with PLD004, for an unknown pipeline, a bad
|
|
38
|
+
selector or a selection that holds no unit."""
|
|
39
|
+
options = opts.pipelines.get(pipeline)
|
|
40
|
+
if options is None:
|
|
41
|
+
report.add(
|
|
42
|
+
"PLD004",
|
|
43
|
+
f"pipeline '{pipeline}' is not in the deploy block",
|
|
44
|
+
hint=f"pipelines: {', '.join(sorted(opts.pipelines))}",
|
|
45
|
+
context={"pipeline": pipeline},
|
|
46
|
+
)
|
|
47
|
+
return ()
|
|
48
|
+
try:
|
|
49
|
+
chosen = _select(plan, options.select, options.exclude)
|
|
50
|
+
except SelectorError as err:
|
|
51
|
+
report.add("PLD004", f"pipeline '{pipeline}': {err}", context={"pipeline": pipeline})
|
|
52
|
+
return ()
|
|
53
|
+
units = tuple(sorted(uid for uid, u in plan.units.items() if u.nodes[-1] in chosen))
|
|
54
|
+
if not units:
|
|
55
|
+
report.add(
|
|
56
|
+
"PLD004",
|
|
57
|
+
f"pipeline '{pipeline}' selects no unit",
|
|
58
|
+
context={"pipeline": pipeline},
|
|
59
|
+
)
|
|
60
|
+
return units
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rule_compute(plan: Plan, opts: DatabricksDeployOptions, report: Report) -> dict[str, str]:
|
|
64
|
+
"""Persisted node id -> compute key, for the nodes a rule claims (the first rule wins)."""
|
|
65
|
+
claimed: dict[str, str] = {}
|
|
66
|
+
for i, rule in enumerate(opts.compute_rules):
|
|
67
|
+
try:
|
|
68
|
+
chosen = _select(plan, rule.select, rule.exclude)
|
|
69
|
+
except SelectorError as err:
|
|
70
|
+
report.add("PLD004", f"compute rule {i}: {err}", context={"rule": i})
|
|
71
|
+
continue
|
|
72
|
+
if not chosen:
|
|
73
|
+
report.add(
|
|
74
|
+
"PLD005",
|
|
75
|
+
f"compute rule {i} ({', '.join(rule.select)}) matches no model",
|
|
76
|
+
context={"rule": i},
|
|
77
|
+
)
|
|
78
|
+
for node in sorted(chosen):
|
|
79
|
+
claimed.setdefault(node, rule.compute)
|
|
80
|
+
return claimed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _group_of(plan: Plan, opts: DatabricksDeployOptions, unit: str) -> str:
|
|
84
|
+
if opts.task_grouping == "none":
|
|
85
|
+
return unit
|
|
86
|
+
node = plan.nodes[plan.units[unit].nodes[-1]]
|
|
87
|
+
if opts.task_grouping == "by_tag":
|
|
88
|
+
return f"tag_{node.tags[0] if node.tags else 'untagged'}"
|
|
89
|
+
return f"dir_{posixpath.dirname(node.file) or 'root'}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_tasks(
|
|
93
|
+
plan: Plan, opts: DatabricksDeployOptions, pipeline: str, report: Report
|
|
94
|
+
) -> tuple[TaskUnit, ...]:
|
|
95
|
+
"""The tasks of a pipeline's job, sorted by key, finalize not included.
|
|
96
|
+
|
|
97
|
+
Reports PLD001 (too many tasks), PLD003 (a cycle among groups), PLD004 (a group on two
|
|
98
|
+
computes) and PLD005. Returns what it could build; the caller checks `report`.
|
|
99
|
+
"""
|
|
100
|
+
units = pipeline_units(plan, opts, pipeline, report)
|
|
101
|
+
claimed = _rule_compute(plan, opts, report)
|
|
102
|
+
members: dict[str, list[str]] = defaultdict(list)
|
|
103
|
+
for unit in units:
|
|
104
|
+
members[_group_of(plan, opts, unit)].append(unit)
|
|
105
|
+
group_of = {u: g for g, us in members.items() for u in us}
|
|
106
|
+
|
|
107
|
+
compute: dict[str, str] = {}
|
|
108
|
+
for group, us in sorted(members.items()):
|
|
109
|
+
used = sorted({claimed.get(plan.units[u].nodes[-1], opts.default_compute) for u in us})
|
|
110
|
+
if len(used) > 1:
|
|
111
|
+
report.add(
|
|
112
|
+
"PLD004",
|
|
113
|
+
f"task group '{group}' spans more than one compute: {', '.join(used)}",
|
|
114
|
+
hint="change task_grouping or the compute rules so a group has one compute",
|
|
115
|
+
context={"group": group, "compute": [c for c in used]},
|
|
116
|
+
)
|
|
117
|
+
compute[group] = used[0]
|
|
118
|
+
|
|
119
|
+
parents: dict[str, tuple[str, ...]] = {
|
|
120
|
+
g: tuple(
|
|
121
|
+
sorted({group_of[p] for u in us for p in plan.units[u].parents if p in group_of} - {g})
|
|
122
|
+
)
|
|
123
|
+
for g, us in members.items()
|
|
124
|
+
}
|
|
125
|
+
assigned = keys.assign_keys(sorted(members))
|
|
126
|
+
for cycle in find_cycles(parents):
|
|
127
|
+
path = [assigned[g] for g in cycle]
|
|
128
|
+
report.add(
|
|
129
|
+
"PLD003",
|
|
130
|
+
f"task groups form a cycle: {' -> '.join((*path, path[0]))}",
|
|
131
|
+
hint="group by another key, or set task_grouping to none",
|
|
132
|
+
context={"groups": [g for g in path]},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if len(members) + 1 > keys.MAX_TASKS:
|
|
136
|
+
report.add(
|
|
137
|
+
"PLD001",
|
|
138
|
+
f"pipeline '{pipeline}' needs {len(members) + 1} tasks with its finalize task, "
|
|
139
|
+
f"the limit is {keys.MAX_TASKS}",
|
|
140
|
+
hint="set task_grouping in the deploy block, or split the project into pipelines",
|
|
141
|
+
context={"pipeline": pipeline, "tasks": len(members) + 1},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
tasks = [
|
|
145
|
+
TaskUnit(
|
|
146
|
+
assigned[g],
|
|
147
|
+
tuple(sorted(us)),
|
|
148
|
+
tuple(sorted(assigned[p] for p in parents[g])),
|
|
149
|
+
compute[g],
|
|
150
|
+
)
|
|
151
|
+
for g, us in members.items()
|
|
152
|
+
]
|
|
153
|
+
return tuple(sorted(tasks, key=lambda t: t.key))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def task_key_map(plan: Plan, opts: DatabricksDeployOptions, pipeline: str) -> dict[str, str]:
|
|
157
|
+
"""Unit id -> the key of the task that runs it."""
|
|
158
|
+
return {u: t.key for t in build_tasks(plan, opts, pipeline, Report()) for u in t.units}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The subset of the Jobs API that pipeloom renders, as frozen dataclasses.
|
|
2
|
+
|
|
3
|
+
Field order is output order. `to_json` drops None and empty collections, and appends the keys of
|
|
4
|
+
an `extra` mapping, sorted, after the declared fields.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class JobParameter:
|
|
18
|
+
name: str
|
|
19
|
+
default: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class QueueSettings:
|
|
24
|
+
enabled: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class EnvironmentSpec:
|
|
29
|
+
environment_version: str
|
|
30
|
+
dependencies: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class JobEnvironment:
|
|
35
|
+
environment_key: str
|
|
36
|
+
spec: EnvironmentSpec
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class JobCluster:
|
|
41
|
+
job_cluster_key: str
|
|
42
|
+
new_cluster: Mapping[str, JsonValue]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PythonWheelTask:
|
|
47
|
+
package_name: str
|
|
48
|
+
entry_point: str
|
|
49
|
+
parameters: tuple[str, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TaskDependency:
|
|
54
|
+
task_key: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Task:
|
|
59
|
+
task_key: str
|
|
60
|
+
depends_on: tuple[TaskDependency, ...]
|
|
61
|
+
run_if: Literal["ALL_DONE"] | None
|
|
62
|
+
environment_key: str | None
|
|
63
|
+
job_cluster_key: str | None
|
|
64
|
+
libraries: tuple[Mapping[str, JsonValue], ...]
|
|
65
|
+
python_wheel_task: PythonWheelTask
|
|
66
|
+
max_retries: int | None
|
|
67
|
+
min_retry_interval_millis: int | None
|
|
68
|
+
timeout_seconds: int | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class JobSettings:
|
|
73
|
+
name: str
|
|
74
|
+
tags: Mapping[str, str]
|
|
75
|
+
parameters: tuple[JobParameter, ...]
|
|
76
|
+
max_concurrent_runs: int
|
|
77
|
+
queue: QueueSettings
|
|
78
|
+
environments: tuple[JobEnvironment, ...]
|
|
79
|
+
job_clusters: tuple[JobCluster, ...]
|
|
80
|
+
tasks: tuple[Task, ...]
|
|
81
|
+
extra: Mapping[str, JsonValue] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class SubmitSpec:
|
|
86
|
+
"""The body of runs/submit: a one-time run, no job."""
|
|
87
|
+
|
|
88
|
+
run_name: str
|
|
89
|
+
tasks: tuple[Task, ...]
|
|
90
|
+
environments: tuple[JobEnvironment, ...]
|
|
91
|
+
queue: QueueSettings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _value(v: object) -> JsonValue:
|
|
95
|
+
if is_dataclass(v) and not isinstance(v, type):
|
|
96
|
+
return to_json(v)
|
|
97
|
+
if isinstance(v, (tuple, list)):
|
|
98
|
+
return [_value(x) for x in v]
|
|
99
|
+
if isinstance(v, Mapping):
|
|
100
|
+
return {str(k): _value(x) for k, x in v.items()}
|
|
101
|
+
return v # type: ignore[return-value] # str, int, float, bool, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def to_json(obj: object) -> dict[str, JsonValue]:
|
|
105
|
+
"""The JSON object of a model dataclass, in field order."""
|
|
106
|
+
out: dict[str, JsonValue] = {}
|
|
107
|
+
extra: Mapping[str, JsonValue] = {}
|
|
108
|
+
for f in fields(obj): # type: ignore[arg-type]
|
|
109
|
+
v = getattr(obj, f.name)
|
|
110
|
+
if f.name == "extra":
|
|
111
|
+
extra = v
|
|
112
|
+
elif v is not None and not (isinstance(v, (tuple, list, Mapping)) and not v):
|
|
113
|
+
out[f.name] = _value(v)
|
|
114
|
+
for key in sorted(extra):
|
|
115
|
+
out[key] = extra[key]
|
|
116
|
+
return out
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Task keys: 1 to 100 characters of letters, digits, `_` and `-`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
FINALIZE_KEY: Final = "pipeloom__finalize"
|
|
12
|
+
MAX_KEY_LEN: Final = 100
|
|
13
|
+
MAX_TASKS: Final = 1000
|
|
14
|
+
"""Read as `keys.MAX_TASKS` at call time, so a test can lower it."""
|
|
15
|
+
|
|
16
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9_-]")
|
|
17
|
+
_HASH_LEN = 8
|
|
18
|
+
_KEEP = MAX_KEY_LEN - _HASH_LEN - 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sanitize(raw: str) -> str:
|
|
22
|
+
"""Replace every character outside `[A-Za-z0-9_-]` with `_`. Never empty."""
|
|
23
|
+
return _UNSAFE.sub("_", raw) or "_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hashed(key: str, raw: str) -> str:
|
|
27
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:_HASH_LEN]
|
|
28
|
+
return f"{key[:_KEEP]}_{digest}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def assign_keys(raw_ids: Sequence[str]) -> dict[str, str]:
|
|
32
|
+
"""A task key for each raw id.
|
|
33
|
+
|
|
34
|
+
The key is the sanitized id. One longer than 100 characters, or one that several ids share
|
|
35
|
+
(or that is the finalize key), becomes its first 91 characters, `_` and 8 hex characters of
|
|
36
|
+
the sha256 of the raw id. Every id in a collision gets the suffix, so the result does not
|
|
37
|
+
depend on the order of `raw_ids`.
|
|
38
|
+
"""
|
|
39
|
+
base = {raw: sanitize(raw) for raw in raw_ids}
|
|
40
|
+
taken = Counter(base.values())
|
|
41
|
+
taken[FINALIZE_KEY] += 1
|
|
42
|
+
return {
|
|
43
|
+
raw: _hashed(key, raw) if len(key) > MAX_KEY_LEN or taken[key] > 1 else key
|
|
44
|
+
for raw, key in base.items()
|
|
45
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The options of the `databricks` deploy block. No Databricks import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, JsonValue
|
|
8
|
+
|
|
9
|
+
from pipeloom.config.types import Identifier
|
|
10
|
+
from pipeloom.plugins import OptionsModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ServerlessCompute(OptionsModel):
|
|
14
|
+
kind: Literal["serverless"] = "serverless"
|
|
15
|
+
environment_version: str = "5"
|
|
16
|
+
"""Serverless environment version, as a string. Version 1 runs Python 3.10, below the floor."""
|
|
17
|
+
dependencies: list[str] | None = None
|
|
18
|
+
"""pip requirement lines. Unset installs the pinned pipeloom distributions."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JobClusterCompute(OptionsModel):
|
|
22
|
+
kind: Literal["job_cluster"]
|
|
23
|
+
new_cluster: dict[str, JsonValue]
|
|
24
|
+
"""The `new_cluster` object of the Jobs API."""
|
|
25
|
+
libraries: list[dict[str, JsonValue]] | None = None
|
|
26
|
+
"""Task libraries. Unset installs the pinned pipeloom distributions from PyPI."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Compute = Annotated[ServerlessCompute | JobClusterCompute, Field(discriminator="kind")]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ComputeRule(OptionsModel):
|
|
33
|
+
select: list[str]
|
|
34
|
+
"""Selectors; the rule applies to the models they match."""
|
|
35
|
+
exclude: list[str] = Field(default_factory=list)
|
|
36
|
+
compute: Identifier
|
|
37
|
+
"""Key of an entry in `compute`."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PipelineOptions(OptionsModel):
|
|
41
|
+
select: list[str] = Field(default_factory=list)
|
|
42
|
+
"""Selectors; empty means the whole plan."""
|
|
43
|
+
exclude: list[str] = Field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _main() -> dict[str, PipelineOptions]:
|
|
47
|
+
return {"main": PipelineOptions()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default() -> dict[str, Compute]:
|
|
51
|
+
return {"default": ServerlessCompute()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DatabricksDeployOptions(OptionsModel):
|
|
55
|
+
pipelines: dict[Identifier, PipelineOptions] = Field(default_factory=_main)
|
|
56
|
+
"""One job per pipeline."""
|
|
57
|
+
compute: dict[Identifier, Compute] = Field(default_factory=_default)
|
|
58
|
+
default_compute: Identifier = "default"
|
|
59
|
+
compute_rules: list[ComputeRule] = Field(default_factory=list)
|
|
60
|
+
"""The first rule that matches a model wins."""
|
|
61
|
+
task_grouping: Literal["none", "by_tag", "by_folder"] = "none"
|
|
62
|
+
"""none: one task per unit. by_tag: by the model's first tag. by_folder: by its folder."""
|
|
63
|
+
workspace_root: str | None = None
|
|
64
|
+
"""Where a direct deploy uploads the project. Default /Workspace/Users/<me>/.pipeloom."""
|
|
65
|
+
settings: dict[str, JsonValue] = Field(default_factory=dict)
|
|
66
|
+
"""Job settings passed through (schedule, notifications, run_as, tags ...)."""
|
|
File without changes
|