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.
@@ -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)}")