pipeloom-airflow 0.6.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_airflow/__init__.py +35 -0
- pipeloom_airflow/_compat.py +92 -0
- pipeloom_airflow/_compat_k8s.py +36 -0
- pipeloom_airflow/commands.py +164 -0
- pipeloom_airflow/config.py +182 -0
- pipeloom_airflow/dag.py +331 -0
- pipeloom_airflow/databricks/__init__.py +6 -0
- pipeloom_airflow/databricks/connection.py +114 -0
- pipeloom_airflow/databricks/launch.py +165 -0
- pipeloom_airflow/databricks/launch_logic.py +176 -0
- pipeloom_airflow/databricks/mirror.py +136 -0
- pipeloom_airflow/databricks/sensor.py +139 -0
- pipeloom_airflow/databricks/triggers.py +160 -0
- pipeloom_airflow/errors.py +15 -0
- pipeloom_airflow/load.py +237 -0
- pipeloom_airflow/naming.py +58 -0
- pipeloom_airflow/operators/__init__.py +5 -0
- pipeloom_airflow/operators/kubernetes.py +99 -0
- pipeloom_airflow/operators/local.py +241 -0
- pipeloom_airflow/py.typed +0 -0
- pipeloom_airflow/render.py +222 -0
- pipeloom_airflow/states.py +109 -0
- pipeloom_airflow/testing/__init__.py +1 -0
- pipeloom_airflow/testing/cases.py +118 -0
- pipeloom_airflow/testing/context.py +48 -0
- pipeloom_airflow/version.py +51 -0
- pipeloom_airflow-0.6.0.dist-info/METADATA +142 -0
- pipeloom_airflow-0.6.0.dist-info/RECORD +31 -0
- pipeloom_airflow-0.6.0.dist-info/WHEEL +4 -0
- pipeloom_airflow-0.6.0.dist-info/licenses/LICENSE +202 -0
- pipeloom_airflow-0.6.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Apache Airflow renderer for pipeloom.
|
|
2
|
+
|
|
3
|
+
Importing the package imports nothing: names load on first use (PEP 562). Only the modules
|
|
4
|
+
`_compat` and `_compat_k8s`, which arrive with the DAG classes, may import `airflow`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_LAZY = {
|
|
13
|
+
"ExecutionConfig": "config",
|
|
14
|
+
"ExecutionMode": "config",
|
|
15
|
+
"KubernetesConfig": "config",
|
|
16
|
+
"LoadMode": "config",
|
|
17
|
+
"ProjectConfig": "config",
|
|
18
|
+
"RenderConfig": "config",
|
|
19
|
+
"validate_configs": "config",
|
|
20
|
+
"PipeloomAirflowError": "errors",
|
|
21
|
+
"LoadedPlan": "load",
|
|
22
|
+
"load_plan": "load",
|
|
23
|
+
"TaskPlan": "render",
|
|
24
|
+
"UnitTask": "render",
|
|
25
|
+
"render_plan": "render",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
__all__ = list(_LAZY)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str) -> Any:
|
|
32
|
+
module = _LAZY.get(name)
|
|
33
|
+
if module is None:
|
|
34
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
35
|
+
return getattr(importlib.import_module(f"{__name__}.{module}"), name)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The one module of the plugin that imports `airflow` (with `_compat_k8s`). Every name the plugin
|
|
2
|
+
uses from Airflow comes from here, so a move between Airflow minors is fixed in one place."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from pipeloom_airflow.version import require_airflow
|
|
11
|
+
|
|
12
|
+
# Before any Airflow import: an unsupported or missing Airflow is PLA005, not an ImportError.
|
|
13
|
+
require_airflow()
|
|
14
|
+
|
|
15
|
+
from airflow.sdk import (
|
|
16
|
+
DAG,
|
|
17
|
+
Asset,
|
|
18
|
+
BaseHook,
|
|
19
|
+
BaseOperator,
|
|
20
|
+
BaseSensorOperator,
|
|
21
|
+
Param,
|
|
22
|
+
TaskGroup,
|
|
23
|
+
)
|
|
24
|
+
from airflow.sdk.exceptions import (
|
|
25
|
+
AirflowException,
|
|
26
|
+
AirflowFailException,
|
|
27
|
+
AirflowSkipException,
|
|
28
|
+
)
|
|
29
|
+
from airflow.triggers.base import BaseTrigger, TriggerEvent
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from airflow.sdk import Context
|
|
33
|
+
else:
|
|
34
|
+
Context = Mapping[str, Any]
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"DAG",
|
|
38
|
+
"AirflowException",
|
|
39
|
+
"AirflowFailException",
|
|
40
|
+
"AirflowSkipException",
|
|
41
|
+
"Asset",
|
|
42
|
+
"BaseHook",
|
|
43
|
+
"BaseOperator",
|
|
44
|
+
"BaseSensorOperator",
|
|
45
|
+
"BaseTrigger",
|
|
46
|
+
"Context",
|
|
47
|
+
"Param",
|
|
48
|
+
"TaskGroup",
|
|
49
|
+
"TriggerEvent",
|
|
50
|
+
"aget_connection",
|
|
51
|
+
"airflow_version",
|
|
52
|
+
"get_connection",
|
|
53
|
+
"get_task_states",
|
|
54
|
+
"mask_secret",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_connection(conn_id: str) -> Any:
|
|
59
|
+
"""The connection, from a worker."""
|
|
60
|
+
return BaseHook.get_connection(conn_id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mask_secret(value: str) -> None:
|
|
64
|
+
"""Hide `value` in task and triggerer logs."""
|
|
65
|
+
import airflow.sdk
|
|
66
|
+
|
|
67
|
+
masker = getattr(airflow.sdk, "mask_secret", None)
|
|
68
|
+
if masker is not None:
|
|
69
|
+
masker(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def airflow_version() -> str:
|
|
73
|
+
from airflow import __version__
|
|
74
|
+
|
|
75
|
+
return str(__version__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_task_states(
|
|
79
|
+
ti: Any, dag_id: str, task_ids: Sequence[str], run_id: str
|
|
80
|
+
) -> dict[str, str | None]:
|
|
81
|
+
"""Task id -> state of the given tasks in one DAG run, as the task SDK reports them."""
|
|
82
|
+
found = ti.get_task_states(dag_id=dag_id, task_ids=list(task_ids), run_ids=[run_id])
|
|
83
|
+
states = found.get(run_id, {}) if isinstance(found, Mapping) else {}
|
|
84
|
+
return {str(k): (None if v is None else str(v)) for k, v in states.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def aget_connection(conn_id: str) -> Any:
|
|
88
|
+
"""The connection, without blocking the event loop of a trigger."""
|
|
89
|
+
getter = getattr(BaseHook, "aget_connection", None)
|
|
90
|
+
if getter is not None:
|
|
91
|
+
return await getter(conn_id)
|
|
92
|
+
return await asyncio.to_thread(BaseHook.get_connection, conn_id)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The only module besides `_compat` that imports the cncf-kubernetes provider (and the
|
|
2
|
+
`kubernetes` client it brings). Import it only when the extra `kubernetes` is installed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
|
|
11
|
+
from kubernetes.client import models as k8s
|
|
12
|
+
|
|
13
|
+
__all__ = ["KubernetesPodOperator", "merge_env_vars", "pod_name"]
|
|
14
|
+
|
|
15
|
+
POD_NAME_PREFIX = "pipeloom-"
|
|
16
|
+
POD_NAME_BASE_MAX = 54
|
|
17
|
+
"""The provider adds `-` and 8 random characters to a name, and a pod name is 63 at most."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pod_name(task_id: str) -> str:
|
|
21
|
+
"""`pipeloom-<task id>` as a DNS-1123 label: lower case, `[a-z0-9-]`, at most 54 characters."""
|
|
22
|
+
name = re.sub(r"[^a-z0-9-]+", "-", f"{POD_NAME_PREFIX}{task_id}".lower())
|
|
23
|
+
return name[:POD_NAME_BASE_MAX].strip("-")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def merge_env_vars(env: Mapping[str, str], existing: Any) -> Any:
|
|
27
|
+
"""`ExecutionConfig.env` plus the `env_vars` the user put in `pod_kwargs` (a dict or a list of
|
|
28
|
+
`V1EnvVar`); the pod_kwargs entry wins. Returns what `KubernetesPodOperator(env_vars=)` takes."""
|
|
29
|
+
if not env:
|
|
30
|
+
return existing
|
|
31
|
+
if not existing:
|
|
32
|
+
return dict(env)
|
|
33
|
+
if isinstance(existing, Mapping):
|
|
34
|
+
return {**env, **existing}
|
|
35
|
+
named = {v.name for v in existing}
|
|
36
|
+
return [*(k8s.V1EnvVar(name=k, value=v) for k, v in env.items() if k not in named), *existing]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The command lines a task runs, and the run identity every task of a DAG run shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ntpath
|
|
6
|
+
import posixpath
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pipeloom.diagnostics import Report
|
|
13
|
+
from pipeloom.platform import check_orchestrator_id
|
|
14
|
+
from pipeloom_airflow import naming
|
|
15
|
+
from pipeloom_airflow.errors import PipeloomAirflowError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RunContext:
|
|
20
|
+
run_id: str
|
|
21
|
+
run_started_at: datetime
|
|
22
|
+
full_refresh: bool
|
|
23
|
+
orchestrator_ids: Mapping[str, str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bool(value: object) -> bool | None:
|
|
27
|
+
if isinstance(value, bool):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
|
|
30
|
+
return value.strip().lower() == "true"
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_context(
|
|
35
|
+
*,
|
|
36
|
+
dag_id: str,
|
|
37
|
+
airflow_run_id: str,
|
|
38
|
+
run_after: datetime,
|
|
39
|
+
task_id: str,
|
|
40
|
+
try_number: int,
|
|
41
|
+
group_id: str | None,
|
|
42
|
+
full_refresh: object,
|
|
43
|
+
) -> RunContext:
|
|
44
|
+
"""What a task knows about its run. `full_refresh` is a bool or "true"/"false"; anything
|
|
45
|
+
else is PLA002. A naive `run_after` is read as UTC."""
|
|
46
|
+
refresh = _bool(full_refresh)
|
|
47
|
+
if refresh is None:
|
|
48
|
+
report = Report()
|
|
49
|
+
report.add(
|
|
50
|
+
"PLA002",
|
|
51
|
+
f"full_refresh must be true or false, got {full_refresh!r}",
|
|
52
|
+
hint="trigger the DAG with the param full_refresh set to a boolean",
|
|
53
|
+
)
|
|
54
|
+
raise PipeloomAirflowError(report.sorted())
|
|
55
|
+
started = run_after.replace(tzinfo=UTC) if run_after.tzinfo is None else run_after
|
|
56
|
+
ids = {
|
|
57
|
+
"airflow_dag_id": dag_id,
|
|
58
|
+
"airflow_run_id": airflow_run_id,
|
|
59
|
+
"airflow_task_id": task_id,
|
|
60
|
+
"airflow_try_number": str(try_number),
|
|
61
|
+
}
|
|
62
|
+
for key, value in ids.items():
|
|
63
|
+
check_orchestrator_id(key, value)
|
|
64
|
+
return RunContext(
|
|
65
|
+
run_id=naming.airflow_run_id(dag_id, airflow_run_id, group_id),
|
|
66
|
+
run_started_at=started.astimezone(UTC),
|
|
67
|
+
full_refresh=refresh,
|
|
68
|
+
orchestrator_ids=ids,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ids(ctx: RunContext) -> list[str]:
|
|
73
|
+
return [a for k, v in ctx.orchestrator_ids.items() for a in ("--orchestrator-id", f"{k}={v}")]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class CoreCommand:
|
|
78
|
+
"""The core invocation a task makes. PROJECT mode passes no manifest: every unit compiles
|
|
79
|
+
and checks the plan hash, so no cache path crosses a process boundary."""
|
|
80
|
+
|
|
81
|
+
project_dir: str
|
|
82
|
+
target: str
|
|
83
|
+
manifest: str | None
|
|
84
|
+
plan_hash: str
|
|
85
|
+
vars_json: str | None
|
|
86
|
+
|
|
87
|
+
def _base(self) -> list[str]:
|
|
88
|
+
return ["--project-dir", self.project_dir, "--target", self.target]
|
|
89
|
+
|
|
90
|
+
def _plan(self) -> list[str]:
|
|
91
|
+
return (["--manifest", self.manifest] if self.manifest else []) + [
|
|
92
|
+
"--expect-plan-hash",
|
|
93
|
+
self.plan_hash,
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def _vars(self) -> list[str]:
|
|
97
|
+
return ["--vars", self.vars_json] if self.vars_json else []
|
|
98
|
+
|
|
99
|
+
def run_argv(
|
|
100
|
+
self,
|
|
101
|
+
python: Sequence[str],
|
|
102
|
+
unit_id: str,
|
|
103
|
+
ctx: RunContext,
|
|
104
|
+
*,
|
|
105
|
+
log_format: Literal["json", "text"],
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
return [
|
|
108
|
+
*python,
|
|
109
|
+
"-m",
|
|
110
|
+
"pipeloom",
|
|
111
|
+
"run",
|
|
112
|
+
"--log-format",
|
|
113
|
+
log_format,
|
|
114
|
+
*self._base(),
|
|
115
|
+
*self._plan(),
|
|
116
|
+
"--select",
|
|
117
|
+
f"unit:{unit_id}",
|
|
118
|
+
"--run-id",
|
|
119
|
+
ctx.run_id,
|
|
120
|
+
"--run-started-at",
|
|
121
|
+
ctx.run_started_at.isoformat(),
|
|
122
|
+
"--full-refresh",
|
|
123
|
+
"true" if ctx.full_refresh else "false",
|
|
124
|
+
*_ids(ctx),
|
|
125
|
+
*self._vars(),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def finalize_argv(
|
|
129
|
+
self,
|
|
130
|
+
python: Sequence[str],
|
|
131
|
+
ctx: RunContext,
|
|
132
|
+
*,
|
|
133
|
+
select: Sequence[str],
|
|
134
|
+
exclude: Sequence[str],
|
|
135
|
+
unit_states_path: str | None = None,
|
|
136
|
+
unit_states_json: str | None = None,
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
states = ["--unit-states", unit_states_path] if unit_states_path is not None else []
|
|
139
|
+
if unit_states_json is not None:
|
|
140
|
+
states += ["--unit-states-json", unit_states_json]
|
|
141
|
+
return [
|
|
142
|
+
*python,
|
|
143
|
+
"-m",
|
|
144
|
+
"pipeloom",
|
|
145
|
+
"finalize",
|
|
146
|
+
*self._base(),
|
|
147
|
+
*self._plan(),
|
|
148
|
+
"--run-id",
|
|
149
|
+
ctx.run_id,
|
|
150
|
+
"--run-started-at",
|
|
151
|
+
ctx.run_started_at.isoformat(),
|
|
152
|
+
*[a for s in select for a in ("--select", s)],
|
|
153
|
+
*[a for s in exclude for a in ("--exclude", s)],
|
|
154
|
+
*_ids(ctx),
|
|
155
|
+
*states,
|
|
156
|
+
*self._vars(),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def venv_python(venv_dir: str, *, platform: str) -> str:
|
|
161
|
+
"""The interpreter of a virtualenv: `bin/python`, or `Scripts/python.exe` on win32."""
|
|
162
|
+
if platform == "win32":
|
|
163
|
+
return ntpath.join(venv_dir, "Scripts", "python.exe")
|
|
164
|
+
return posixpath.join(venv_dir, "bin", "python")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Configuration of the Airflow plugin. Stdlib and the core only; nothing here touches the disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ntpath
|
|
6
|
+
import posixpath
|
|
7
|
+
import string
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any, Final, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
from pipeloom.diagnostics import Report
|
|
16
|
+
from pipeloom_airflow.naming import PLACEHOLDERS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoadMode(StrEnum):
|
|
20
|
+
MANIFEST = "manifest"
|
|
21
|
+
PROJECT = "project"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExecutionMode(StrEnum):
|
|
25
|
+
LOCAL = "local"
|
|
26
|
+
VIRTUALENV = "virtualenv"
|
|
27
|
+
KUBERNETES = "kubernetes"
|
|
28
|
+
DATABRICKS = "databricks"
|
|
29
|
+
DOCKER = "docker"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ProjectConfig:
|
|
34
|
+
"""Where the DAG file reads the project."""
|
|
35
|
+
|
|
36
|
+
project_dir: str
|
|
37
|
+
manifest_path: str | None = None
|
|
38
|
+
vars: Mapping[str, JsonValue] = field(default_factory=dict)
|
|
39
|
+
cache_dir: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RenderConfig:
|
|
44
|
+
load_method: LoadMode = LoadMode.MANIFEST
|
|
45
|
+
select: tuple[str, ...] = ()
|
|
46
|
+
exclude: tuple[str, ...] = ()
|
|
47
|
+
group_by: Literal["none", "folder", "tag"] = "none"
|
|
48
|
+
"""Visual TaskGroups only; every unit is still one task."""
|
|
49
|
+
emit_assets: bool = False
|
|
50
|
+
asset_uri: str = "pipeloom://{project}/{target}/{relation}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class KubernetesConfig:
|
|
55
|
+
image: str
|
|
56
|
+
namespace: str | None = None
|
|
57
|
+
python: str = "python"
|
|
58
|
+
pod_kwargs: Mapping[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ExecutionConfig:
|
|
63
|
+
mode: ExecutionMode = ExecutionMode.LOCAL
|
|
64
|
+
project_dir: str | None = None
|
|
65
|
+
manifest_path: str | None = None
|
|
66
|
+
"""Where units execute; the defaults are ProjectConfig's."""
|
|
67
|
+
virtualenv_dir: str | None = None
|
|
68
|
+
kubernetes: KubernetesConfig | None = None
|
|
69
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
70
|
+
databricks_conn_id: str = "databricks_default"
|
|
71
|
+
databricks_pipeline: str = "main"
|
|
72
|
+
databricks_ensure_job: bool = False
|
|
73
|
+
databricks_poll: Literal["per_task", "shared"] = "per_task"
|
|
74
|
+
"""`per_task`: each sensor polls its own task, so results show live. `shared`: the launch
|
|
75
|
+
waits for the whole run and the sensors read its snapshot."""
|
|
76
|
+
databricks_poll_interval: float = 30.0
|
|
77
|
+
databricks_timeout: float = 86400.0
|
|
78
|
+
"""Seconds the launch waits for the job run before it times out (and retries, attaching)."""
|
|
79
|
+
databricks_client_factory: str | None = None
|
|
80
|
+
"""`module:attr`, for tests only."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
OPERATOR_ARGS_RESERVED: Final = (
|
|
84
|
+
"task_id",
|
|
85
|
+
"retries",
|
|
86
|
+
"retry_delay",
|
|
87
|
+
"trigger_rule",
|
|
88
|
+
"outlets",
|
|
89
|
+
"execution_timeout",
|
|
90
|
+
"deferrable",
|
|
91
|
+
)
|
|
92
|
+
DATABRICKS_ARGS_RESERVED: Final = (
|
|
93
|
+
"timeout", # databricks_timeout
|
|
94
|
+
"poll",
|
|
95
|
+
"poll_interval",
|
|
96
|
+
"conn_id",
|
|
97
|
+
"client_factory",
|
|
98
|
+
"wait_timeout_s",
|
|
99
|
+
"task_key",
|
|
100
|
+
"launch_task_id",
|
|
101
|
+
"max_attempts",
|
|
102
|
+
"command",
|
|
103
|
+
"pipeline",
|
|
104
|
+
"ensure_job",
|
|
105
|
+
"select",
|
|
106
|
+
"exclude",
|
|
107
|
+
"group_id",
|
|
108
|
+
)
|
|
109
|
+
"""Constructor arguments of the DATABRICKS-mode operators that pipeloom sets itself."""
|
|
110
|
+
POD_KWARGS_RESERVED: Final = (
|
|
111
|
+
"cmds",
|
|
112
|
+
"arguments",
|
|
113
|
+
"image",
|
|
114
|
+
"name",
|
|
115
|
+
"do_xcom_push",
|
|
116
|
+
"task_group",
|
|
117
|
+
*OPERATOR_ARGS_RESERVED,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _absolute(path: str) -> bool:
|
|
122
|
+
"""Absolute on either platform: the worker that runs a unit may not be the parser's OS."""
|
|
123
|
+
return posixpath.isabs(path) or ntpath.isabs(path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_configs(
|
|
127
|
+
project: ProjectConfig,
|
|
128
|
+
render: RenderConfig,
|
|
129
|
+
execution: ExecutionConfig,
|
|
130
|
+
operator_args: Mapping[str, Any],
|
|
131
|
+
report: Report,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Report PLA001 and PLA002. Strings only: execution-side paths are never touched."""
|
|
134
|
+
|
|
135
|
+
def bad(message: str, hint: str | None = None) -> None:
|
|
136
|
+
report.add("PLA002", message, hint=hint)
|
|
137
|
+
|
|
138
|
+
if execution.mode is ExecutionMode.DOCKER:
|
|
139
|
+
report.add(
|
|
140
|
+
"PLA001",
|
|
141
|
+
"execution mode 'docker' is not supported",
|
|
142
|
+
hint="use local, virtualenv, kubernetes or databricks",
|
|
143
|
+
)
|
|
144
|
+
if render.load_method is LoadMode.MANIFEST and not project.manifest_path:
|
|
145
|
+
bad("load_method 'manifest' needs project.manifest_path", "or use load_method 'project'")
|
|
146
|
+
paths = {
|
|
147
|
+
"project.project_dir": project.project_dir,
|
|
148
|
+
"project.manifest_path": project.manifest_path,
|
|
149
|
+
"project.cache_dir": project.cache_dir,
|
|
150
|
+
"execution.project_dir": execution.project_dir,
|
|
151
|
+
"execution.manifest_path": execution.manifest_path,
|
|
152
|
+
"execution.virtualenv_dir": execution.virtualenv_dir,
|
|
153
|
+
}
|
|
154
|
+
for name, value in paths.items():
|
|
155
|
+
if value is not None and not _absolute(value):
|
|
156
|
+
bad(f"{name} must be an absolute path, got '{value}'")
|
|
157
|
+
if execution.mode is ExecutionMode.VIRTUALENV and not execution.virtualenv_dir:
|
|
158
|
+
bad("execution mode 'virtualenv' needs execution.virtualenv_dir")
|
|
159
|
+
for name in ("databricks_timeout", "databricks_poll_interval"):
|
|
160
|
+
if not getattr(execution, name) > 0:
|
|
161
|
+
bad(f"execution.{name} must be a positive number of seconds")
|
|
162
|
+
if execution.mode is ExecutionMode.KUBERNETES:
|
|
163
|
+
if execution.kubernetes is None or not execution.kubernetes.image:
|
|
164
|
+
bad("execution mode 'kubernetes' needs execution.kubernetes.image")
|
|
165
|
+
else:
|
|
166
|
+
for key in sorted(set(execution.kubernetes.pod_kwargs) & set(POD_KWARGS_RESERVED)):
|
|
167
|
+
bad(f"pod_kwargs.{key} is set by pipeloom", "remove it from pod_kwargs")
|
|
168
|
+
both = set(execution.kubernetes.pod_kwargs) & set(operator_args)
|
|
169
|
+
for key in sorted(both - set(POD_KWARGS_RESERVED)):
|
|
170
|
+
bad(f"'{key}' is in both pod_kwargs and operator_args", "keep it in one of them")
|
|
171
|
+
reserved = set(OPERATOR_ARGS_RESERVED)
|
|
172
|
+
if execution.mode is ExecutionMode.DATABRICKS:
|
|
173
|
+
reserved |= set(DATABRICKS_ARGS_RESERVED)
|
|
174
|
+
for key in sorted(set(operator_args) & reserved):
|
|
175
|
+
bad(f"operator_args.{key} is set by pipeloom", "remove it from operator_args")
|
|
176
|
+
try:
|
|
177
|
+
fields = {f for _, f, _, _ in string.Formatter().parse(render.asset_uri) if f is not None}
|
|
178
|
+
except ValueError as err:
|
|
179
|
+
bad(f"asset_uri is not a valid template: {err}")
|
|
180
|
+
return
|
|
181
|
+
for name in sorted(fields - set(PLACEHOLDERS)):
|
|
182
|
+
bad(f"asset_uri has an unknown placeholder '{{{name}}}'", f"use: {', '.join(PLACEHOLDERS)}")
|