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,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