vigie-spark-operator 0.1.2__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HEMA Tiemoko Thierry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: vigie-spark-operator
3
+ Version: 0.1.2
4
+ Summary: Airflow operator that submits Kubeflow SparkApplications on Kubernetes (AF2 + AF3)
5
+ Author: HEMA Tiemoko Thierry
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Jaali-co/vigie-spark-operator
8
+ Project-URL: Changelog, https://github.com/Jaali-co/vigie-spark-operator/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/Jaali-co/vigie-spark-operator/issues
10
+ Keywords: airflow,spark,kubernetes,spark-operator
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Apache Airflow
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: kubernetes>=28.1.0
25
+ Provides-Extra: airflow
26
+ Requires-Dist: apache-airflow<4,>=2.7; extra == "airflow"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.4; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
30
+ Requires-Dist: build>=1.2; extra == "dev"
31
+ Requires-Dist: twine>=5.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # vigie-spark-operator
35
+
36
+ Open-source Airflow operator that submits a Kubeflow **SparkApplication**
37
+ (Spark Operator on Kubernetes). Works with **Airflow 2.x and 3.x**.
38
+
39
+ Maintainer: **HEMA Tiemoko Thierry** · License: **MIT**
40
+
41
+ ## Requirements
42
+
43
+ | | |
44
+ |---|---|
45
+ | Python | `>=3.9` |
46
+ | Runtime dep | `kubernetes>=28.1.0` |
47
+ | Airflow (runtime) | `>=2.7,<4` — provided by the worker image, or install extra `[airflow]` |
48
+ | Cluster | [Kubeflow Spark Operator](https://github.com/kubeflow/spark-operator) CRD `SparkApplication` installed; RBAC for the Airflow worker SA |
49
+
50
+ Declared in `pyproject.toml`. Dev tools: see `requirements-dev.txt`.
51
+
52
+ ```bash
53
+ pip install vigie-spark-operator
54
+ # or with Airflow pinned as dependency (usually not needed inside an AF image)
55
+ pip install "vigie-spark-operator[airflow]"
56
+ ```
57
+
58
+ ## Quick start
59
+
60
+ ```python
61
+ from vigie_spark_operator import SparkK8sOperator
62
+
63
+ SparkK8sOperator(
64
+ task_id="spark_pi",
65
+ name="pi",
66
+ namespace="spark-jobs",
67
+ image="spark:3.5.1",
68
+ application_type="Scala",
69
+ main_class="org.apache.spark.examples.SparkPi",
70
+ main_application_file="local:///opt/spark/examples/jars/spark-examples_2.12-3.5.1.jar",
71
+ arguments=["10"],
72
+ spark_version="3.5.1",
73
+ service_account="spark",
74
+ dry_run=False,
75
+ )
76
+ ```
77
+
78
+ ### Optional Vigie capacity labels
79
+
80
+ Generic by default (no `managed-by`). For Vigie Supervision, pass
81
+ `managed_by="vigie"` so driver/executor get `managed-by=vigie` plus
82
+ `dag_id` / `task_id` / `run_id`:
83
+
84
+ ```python
85
+ SparkK8sOperator(..., managed_by="vigie")
86
+ ```
87
+
88
+ Disable context labels entirely: `inject_airflow_labels=False`.
89
+
90
+ ## Install on Airflow workers
91
+
92
+ Preferred: bake the wheel into the worker image.
93
+
94
+ Alternative: `_PIP_ADDITIONAL_REQUIREMENTS=vigie-spark-operator==0.1.2` (slower, less reproducible).
95
+
96
+ AF2 / AF3: the package selects `airflow.models` (AF2) or `airflow.sdk` (AF3) via `compat.py`.
97
+
98
+ ## Main parameters
99
+
100
+ | Parameter | Default | Notes |
101
+ |-----------|---------|--------|
102
+ | `name` | *(required)* | Base name for the SparkApplication |
103
+ | `namespace` | *(required)* | Target K8s namespace |
104
+ | `image` | *(required)* | Spark runtime image |
105
+ | `main_application_file` | *(required)* | App entry (`local://…`, `s3a://…`, …) |
106
+ | `application_type` | `"Python"` | `Python` / `Scala` / `Java` / `R` |
107
+ | `main_class` | `None` | Required for Scala/Java |
108
+ | `arguments` | `[]` | App args |
109
+ | `spark_version` | `"3.1.1"` | Reported to the Spark Operator |
110
+ | `driver_cores` / `driver_memory` | `1` / `"512m"` | Driver resources |
111
+ | `driver_cores_request` | `"20m"` | K8s CPU request |
112
+ | `executor_cores` / `executor_memory` | `1` / `"512m"` | Executor resources |
113
+ | `executor_cores_request` | `"400m"` | K8s CPU request |
114
+ | `executor_instances` | `2` | Number of executors |
115
+ | `service_account` | `"spark"` | Pod SA in the job namespace |
116
+ | `spark_conf` | `{}` | Extra Spark conf |
117
+ | `labels` / `env` / `jars` | `{}` / `{}` / `[]` | Merged labels, env vars, extra jars |
118
+ | `pvc_configs` / `emptydir_configs` | `[]` | Volume mounts (`PvcConfig` / `EmptyDirConfig` or dicts) |
119
+ | `run_as_user` / `fs_group` | `0` / `0` | Shared security context |
120
+ | `allow_privilege_escalation` | `False` | Shared; optional `driver_*` / `executor_*` overrides |
121
+ | `timeout_job` | `3600` | Job timeout (s); graceful delete then force-kill |
122
+ | `timeout` | `None` | Legacy alias → sets `timeout_job` |
123
+ | `poll_interval` | `10` | Status poll (s) |
124
+ | `api_error_retries` | `5` | Retries on K8s `429` / `503` |
125
+ | `in_cluster` | `True` | Use in-cluster kubeconfig |
126
+ | `fail_on_unschedulable` | `True` | Fail if pods stay Pending |
127
+ | `dry_run` | `False` | Build + log manifest, no submit |
128
+ | `inject_airflow_labels` | `True` | Inject `dag_id` / `task_id` / `run_id` |
129
+ | `managed_by` | `None` | e.g. `"vigie"` for capacity collector |
130
+
131
+ Also accepts standard `BaseOperator` kwargs (`task_id`, `queue`, `executor`, …).
132
+
133
+ ## Behaviour highlights
134
+
135
+ - Labels on **metadata, driver and executor**
136
+ - `dry_run=True`: validates resources summary, exits 0 without creating the CR
137
+ - Exponential backoff on transient K8s API errors (`429`, `503`)
138
+ - Near `timeout_job`: delete SparkApplication, then force-kill leftover pods
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ python3 -m venv .venv && source .venv/bin/activate
144
+ pip install -r requirements-dev.txt
145
+ pip install -e .
146
+ pytest -q
147
+ # packaging smoke (build → twine check → clean install)
148
+ ../scripts/test_pypi_packaging.sh .
149
+ ```
150
+
151
+ ## Links
152
+
153
+ - Changelog: [CHANGELOG.md](CHANGELOG.md)
154
+ - Issues: https://github.com/Jaali-co/vigie-spark-operator/issues
@@ -0,0 +1,121 @@
1
+ # vigie-spark-operator
2
+
3
+ Open-source Airflow operator that submits a Kubeflow **SparkApplication**
4
+ (Spark Operator on Kubernetes). Works with **Airflow 2.x and 3.x**.
5
+
6
+ Maintainer: **HEMA Tiemoko Thierry** · License: **MIT**
7
+
8
+ ## Requirements
9
+
10
+ | | |
11
+ |---|---|
12
+ | Python | `>=3.9` |
13
+ | Runtime dep | `kubernetes>=28.1.0` |
14
+ | Airflow (runtime) | `>=2.7,<4` — provided by the worker image, or install extra `[airflow]` |
15
+ | Cluster | [Kubeflow Spark Operator](https://github.com/kubeflow/spark-operator) CRD `SparkApplication` installed; RBAC for the Airflow worker SA |
16
+
17
+ Declared in `pyproject.toml`. Dev tools: see `requirements-dev.txt`.
18
+
19
+ ```bash
20
+ pip install vigie-spark-operator
21
+ # or with Airflow pinned as dependency (usually not needed inside an AF image)
22
+ pip install "vigie-spark-operator[airflow]"
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from vigie_spark_operator import SparkK8sOperator
29
+
30
+ SparkK8sOperator(
31
+ task_id="spark_pi",
32
+ name="pi",
33
+ namespace="spark-jobs",
34
+ image="spark:3.5.1",
35
+ application_type="Scala",
36
+ main_class="org.apache.spark.examples.SparkPi",
37
+ main_application_file="local:///opt/spark/examples/jars/spark-examples_2.12-3.5.1.jar",
38
+ arguments=["10"],
39
+ spark_version="3.5.1",
40
+ service_account="spark",
41
+ dry_run=False,
42
+ )
43
+ ```
44
+
45
+ ### Optional Vigie capacity labels
46
+
47
+ Generic by default (no `managed-by`). For Vigie Supervision, pass
48
+ `managed_by="vigie"` so driver/executor get `managed-by=vigie` plus
49
+ `dag_id` / `task_id` / `run_id`:
50
+
51
+ ```python
52
+ SparkK8sOperator(..., managed_by="vigie")
53
+ ```
54
+
55
+ Disable context labels entirely: `inject_airflow_labels=False`.
56
+
57
+ ## Install on Airflow workers
58
+
59
+ Preferred: bake the wheel into the worker image.
60
+
61
+ Alternative: `_PIP_ADDITIONAL_REQUIREMENTS=vigie-spark-operator==0.1.2` (slower, less reproducible).
62
+
63
+ AF2 / AF3: the package selects `airflow.models` (AF2) or `airflow.sdk` (AF3) via `compat.py`.
64
+
65
+ ## Main parameters
66
+
67
+ | Parameter | Default | Notes |
68
+ |-----------|---------|--------|
69
+ | `name` | *(required)* | Base name for the SparkApplication |
70
+ | `namespace` | *(required)* | Target K8s namespace |
71
+ | `image` | *(required)* | Spark runtime image |
72
+ | `main_application_file` | *(required)* | App entry (`local://…`, `s3a://…`, …) |
73
+ | `application_type` | `"Python"` | `Python` / `Scala` / `Java` / `R` |
74
+ | `main_class` | `None` | Required for Scala/Java |
75
+ | `arguments` | `[]` | App args |
76
+ | `spark_version` | `"3.1.1"` | Reported to the Spark Operator |
77
+ | `driver_cores` / `driver_memory` | `1` / `"512m"` | Driver resources |
78
+ | `driver_cores_request` | `"20m"` | K8s CPU request |
79
+ | `executor_cores` / `executor_memory` | `1` / `"512m"` | Executor resources |
80
+ | `executor_cores_request` | `"400m"` | K8s CPU request |
81
+ | `executor_instances` | `2` | Number of executors |
82
+ | `service_account` | `"spark"` | Pod SA in the job namespace |
83
+ | `spark_conf` | `{}` | Extra Spark conf |
84
+ | `labels` / `env` / `jars` | `{}` / `{}` / `[]` | Merged labels, env vars, extra jars |
85
+ | `pvc_configs` / `emptydir_configs` | `[]` | Volume mounts (`PvcConfig` / `EmptyDirConfig` or dicts) |
86
+ | `run_as_user` / `fs_group` | `0` / `0` | Shared security context |
87
+ | `allow_privilege_escalation` | `False` | Shared; optional `driver_*` / `executor_*` overrides |
88
+ | `timeout_job` | `3600` | Job timeout (s); graceful delete then force-kill |
89
+ | `timeout` | `None` | Legacy alias → sets `timeout_job` |
90
+ | `poll_interval` | `10` | Status poll (s) |
91
+ | `api_error_retries` | `5` | Retries on K8s `429` / `503` |
92
+ | `in_cluster` | `True` | Use in-cluster kubeconfig |
93
+ | `fail_on_unschedulable` | `True` | Fail if pods stay Pending |
94
+ | `dry_run` | `False` | Build + log manifest, no submit |
95
+ | `inject_airflow_labels` | `True` | Inject `dag_id` / `task_id` / `run_id` |
96
+ | `managed_by` | `None` | e.g. `"vigie"` for capacity collector |
97
+
98
+ Also accepts standard `BaseOperator` kwargs (`task_id`, `queue`, `executor`, …).
99
+
100
+ ## Behaviour highlights
101
+
102
+ - Labels on **metadata, driver and executor**
103
+ - `dry_run=True`: validates resources summary, exits 0 without creating the CR
104
+ - Exponential backoff on transient K8s API errors (`429`, `503`)
105
+ - Near `timeout_job`: delete SparkApplication, then force-kill leftover pods
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ python3 -m venv .venv && source .venv/bin/activate
111
+ pip install -r requirements-dev.txt
112
+ pip install -e .
113
+ pytest -q
114
+ # packaging smoke (build → twine check → clean install)
115
+ ../scripts/test_pypi_packaging.sh .
116
+ ```
117
+
118
+ ## Links
119
+
120
+ - Changelog: [CHANGELOG.md](CHANGELOG.md)
121
+ - Issues: https://github.com/Jaali-co/vigie-spark-operator/issues
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vigie-spark-operator"
7
+ version = "0.1.2"
8
+ description = "Airflow operator that submits Kubeflow SparkApplications on Kubernetes (AF2 + AF3)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "HEMA Tiemoko Thierry" }]
13
+ keywords = ["airflow", "spark", "kubernetes", "spark-operator"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: Apache Airflow",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: System :: Distributed Computing",
25
+ ]
26
+ dependencies = [
27
+ "kubernetes>=28.1.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ airflow = ["apache-airflow>=2.7,<4"]
32
+ dev = ["pytest>=7.4", "pytest-cov>=4.1", "build>=1.2", "twine>=5.0"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/Jaali-co/vigie-spark-operator"
36
+ Changelog = "https://github.com/Jaali-co/vigie-spark-operator/blob/main/CHANGELOG.md"
37
+ Issues = "https://github.com/Jaali-co/vigie-spark-operator/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["."]
41
+ include = ["vigie_spark_operator*"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,90 @@
1
+ """Tests for AF2/AF3 BaseOperator resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ import types
8
+ from unittest.mock import MagicMock
9
+
10
+
11
+ def _install_af2_stub():
12
+ airflow = types.ModuleType("airflow")
13
+ airflow.__version__ = "2.10.5"
14
+ models = types.ModuleType("airflow.models")
15
+
16
+ class BaseOperator:
17
+ template_fields = ()
18
+
19
+ def __init__(self, **kwargs):
20
+ self.log = MagicMock()
21
+
22
+ class Variable:
23
+ @staticmethod
24
+ def get(*a, **k):
25
+ return {}
26
+
27
+ models.BaseOperator = BaseOperator
28
+ models.Variable = Variable
29
+ sys.modules["airflow"] = airflow
30
+ sys.modules["airflow.models"] = models
31
+ sys.modules.pop("airflow.sdk", None)
32
+ sys.modules.pop("vigie_spark_operator.compat", None)
33
+
34
+
35
+ def _install_af3_stub():
36
+ airflow = types.ModuleType("airflow")
37
+ airflow.__version__ = "3.2.2"
38
+ sdk = types.ModuleType("airflow.sdk")
39
+
40
+ class BaseOperator:
41
+ template_fields = ()
42
+
43
+ def __init__(self, **kwargs):
44
+ self.log = MagicMock()
45
+
46
+ class Variable:
47
+ @staticmethod
48
+ def get(*a, **k):
49
+ return {}
50
+
51
+ sdk.BaseOperator = BaseOperator
52
+ sdk.Variable = Variable
53
+ models = types.ModuleType("airflow.models")
54
+ models.BaseOperator = object # must not be used on AF3 path
55
+ sys.modules["airflow"] = airflow
56
+ sys.modules["airflow.sdk"] = sdk
57
+ sys.modules["airflow.models"] = models
58
+ sys.modules.pop("vigie_spark_operator.compat", None)
59
+
60
+
61
+ def test_compat_af2_uses_models():
62
+ _install_af2_stub()
63
+ compat = importlib.import_module("vigie_spark_operator.compat")
64
+ importlib.reload(compat)
65
+ assert compat.AIRFLOW_V_3_0_PLUS is False
66
+ assert compat.BaseOperator is sys.modules["airflow.models"].BaseOperator
67
+
68
+
69
+ def test_compat_af3_uses_sdk():
70
+ _install_af3_stub()
71
+ compat = importlib.import_module("vigie_spark_operator.compat")
72
+ importlib.reload(compat)
73
+ assert compat.AIRFLOW_V_3_0_PLUS is True
74
+ assert compat.BaseOperator is sys.modules["airflow.sdk"].BaseOperator
75
+ assert compat.Variable is sys.modules["airflow.sdk"].Variable
76
+
77
+
78
+ def test_compat_af2_variable_lazy():
79
+ _install_af2_stub()
80
+ models = sys.modules["airflow.models"]
81
+
82
+ class Variable:
83
+ @staticmethod
84
+ def get(*a, **k):
85
+ return "x"
86
+
87
+ models.Variable = Variable
88
+ compat = importlib.import_module("vigie_spark_operator.compat")
89
+ importlib.reload(compat)
90
+ assert compat.Variable is Variable
@@ -0,0 +1,219 @@
1
+ """Tests unitaires — labels et dry_run SparkK8sOperator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import types
7
+ from types import SimpleNamespace
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+ # Stub Airflow avant import de l'operator (pas besoin d'Airflow installé pour ces tests)
13
+ _airflow = types.ModuleType("airflow")
14
+ _models = types.ModuleType("airflow.models")
15
+
16
+
17
+ class _FakeBaseOperator:
18
+ template_fields: tuple = ()
19
+
20
+ def __init__(self, **kwargs):
21
+ self.log = MagicMock()
22
+ for key, value in kwargs.items():
23
+ setattr(self, key, value)
24
+
25
+
26
+ _models.BaseOperator = _FakeBaseOperator
27
+
28
+
29
+ class _FakeVariable:
30
+ @staticmethod
31
+ def get(*_a, **_k):
32
+ return {}
33
+
34
+
35
+ _models.Variable = _FakeVariable
36
+ sys.modules["airflow"] = _airflow
37
+ sys.modules["airflow.models"] = _models
38
+
39
+ # Stub kubernetes minimal (chargé au import operator)
40
+ _k8s = types.ModuleType("kubernetes")
41
+ _k8s_client = types.ModuleType("kubernetes.client")
42
+ _k8s_config = types.ModuleType("kubernetes.config")
43
+ _k8s_exc = types.ModuleType("kubernetes.client.exceptions")
44
+
45
+
46
+ class _ApiException(Exception):
47
+ def __init__(self, status=500, reason="err"):
48
+ self.status = status
49
+ self.reason = reason
50
+
51
+
52
+ _k8s_exc.ApiException = _ApiException
53
+ _k8s_client.exceptions = _k8s_exc
54
+ _k8s_client.CustomObjectsApi = MagicMock
55
+ _k8s_client.CoreV1Api = MagicMock
56
+ _k8s_client.V1DeleteOptions = MagicMock
57
+ _k8s.client = _k8s_client
58
+ _k8s.config = _k8s_config
59
+ sys.modules["kubernetes"] = _k8s
60
+ sys.modules["kubernetes.client"] = _k8s_client
61
+ sys.modules["kubernetes.config"] = _k8s_config
62
+ sys.modules["kubernetes.client.exceptions"] = _k8s_exc
63
+
64
+ # Force re-bind BaseOperator from our AF stub (compat may have been imported earlier).
65
+ for mod in (
66
+ "vigie_spark_operator.compat",
67
+ "vigie_spark_operator.operator",
68
+ "vigie_spark_operator",
69
+ ):
70
+ sys.modules.pop(mod, None)
71
+
72
+ from vigie_spark_operator import SparkK8sOperator, __version__
73
+ from vigie_spark_operator.labels import airflow_labels, sanitize_label_value
74
+ from vigie_spark_operator.retry import k8s_call_with_retry
75
+
76
+
77
+ def _ctx(dag_id="my-dag", task_id="spark_pi", run_id="manual__2026-09-05T10:00:00+00:00"):
78
+ return {
79
+ "dag": SimpleNamespace(dag_id=dag_id),
80
+ "task": SimpleNamespace(task_id=task_id),
81
+ "run_id": run_id,
82
+ "ts_nodash": "20260905T100000",
83
+ "ti": SimpleNamespace(try_number=1),
84
+ }
85
+
86
+
87
+ def test_version():
88
+ assert __version__ == "0.1.2"
89
+
90
+
91
+ def test_sanitize_truncates_and_cleans():
92
+ long_run = "x" * 80
93
+ assert len(sanitize_label_value(long_run)) <= 63
94
+ assert sanitize_label_value("dag/with spaces!") == "dag-with-spaces"
95
+
96
+
97
+ def test_airflow_labels_from_context():
98
+ labels = airflow_labels(_ctx())
99
+ assert labels["dag_id"] == "my-dag"
100
+ assert labels["task_id"] == "spark_pi"
101
+ assert "managed-by" not in labels
102
+ assert len(labels["run_id"]) <= 63
103
+
104
+
105
+ def test_airflow_labels_managed_by():
106
+ labels = airflow_labels(_ctx(), managed_by="vigie")
107
+ assert labels["managed-by"] == "vigie"
108
+
109
+
110
+ def test_security_defaults():
111
+ op = SparkK8sOperator(
112
+ task_id="spark_pi",
113
+ name="pi",
114
+ namespace="ns",
115
+ image="spark:3.5.1",
116
+ main_application_file="local:///x.py",
117
+ )
118
+ spec = op._build_spark_app_spec(_ctx())
119
+ for side in ("driver", "executor"):
120
+ assert spec["spec"][side]["securityContext"]["runAsUser"] == 0
121
+ assert spec["spec"][side]["podSecurityContext"]["fsGroup"] == 0
122
+ assert spec["spec"][side]["securityContext"]["allowPrivilegeEscalation"] is False
123
+
124
+
125
+ def test_security_overrides_shared_and_per_role():
126
+ op = SparkK8sOperator(
127
+ task_id="spark_pi",
128
+ name="pi",
129
+ namespace="ns",
130
+ image="spark:3.5.1",
131
+ main_application_file="local:///x.py",
132
+ run_as_user=185,
133
+ fs_group=185,
134
+ executor_run_as_user=1000,
135
+ )
136
+ spec = op._build_spark_app_spec(_ctx())
137
+ assert spec["spec"]["driver"]["securityContext"]["runAsUser"] == 185
138
+ assert spec["spec"]["driver"]["podSecurityContext"]["fsGroup"] == 185
139
+ assert spec["spec"]["executor"]["securityContext"]["runAsUser"] == 1000
140
+ assert spec["spec"]["executor"]["podSecurityContext"]["fsGroup"] == 185
141
+
142
+
143
+ def test_build_spec_injects_labels_on_driver_and_executor():
144
+ op = SparkK8sOperator(
145
+ task_id="spark_pi",
146
+ name="pi",
147
+ namespace="airflow2-spark-jobs",
148
+ image="spark:3.5.1",
149
+ main_application_file="local:///opt/spark/examples/jars/x.jar",
150
+ application_type="Scala",
151
+ main_class="org.apache.spark.examples.SparkPi",
152
+ executor_instances=1,
153
+ managed_by="vigie",
154
+ )
155
+ spec = op._build_spark_app_spec(_ctx())
156
+ for side in ("driver", "executor"):
157
+ labels = spec["spec"][side]["labels"]
158
+ assert labels["dag_id"] == "my-dag"
159
+ assert labels["task_id"] == "spark_pi"
160
+ assert labels["managed-by"] == "vigie"
161
+ assert "run_id" in labels
162
+ assert spec["metadata"]["labels"]["dag_id"] == "my-dag"
163
+
164
+
165
+ def test_build_spec_without_managed_by_omits_label():
166
+ op = SparkK8sOperator(
167
+ task_id="spark_pi",
168
+ name="pi",
169
+ namespace="ns",
170
+ image="spark:3.5.1",
171
+ main_application_file="local:///x.py",
172
+ )
173
+ labels = op._build_spark_app_spec(_ctx())["spec"]["driver"]["labels"]
174
+ assert "managed-by" not in labels
175
+ assert labels["dag_id"] == "my-dag"
176
+
177
+
178
+ def test_dry_run_returns_summary_without_k8s():
179
+ op = SparkK8sOperator(
180
+ task_id="spark_pi",
181
+ name="pi",
182
+ namespace="airflow2-spark-jobs",
183
+ image="spark:3.5.1",
184
+ main_application_file="local:///x.py",
185
+ application_type="Python",
186
+ dry_run=True,
187
+ executor_instances=2,
188
+ driver_memory="1g",
189
+ executor_memory="2g",
190
+ )
191
+ # dry_run ne doit pas appeler _load_kube
192
+ op._load_kube = MagicMock(side_effect=AssertionError("k8s should not load"))
193
+ summary = op.execute(_ctx())
194
+ assert summary["driver"]["memory"] == "1g"
195
+ assert summary["executor"]["instances"] == 2
196
+ assert summary["labels"]["driver"]["dag_id"] == "my-dag"
197
+ op._load_kube.assert_not_called()
198
+
199
+
200
+ def test_k8s_retry_on_429():
201
+ calls = {"n": 0}
202
+
203
+ def flaky():
204
+ calls["n"] += 1
205
+ if calls["n"] < 3:
206
+ raise _ApiException(status=429, reason="too many")
207
+ return "ok"
208
+
209
+ assert k8s_call_with_retry(flaky, max_retries=3, base_delay=0.01) == "ok"
210
+ assert calls["n"] == 3
211
+
212
+
213
+ def test_k8s_retry_gives_up_on_403():
214
+ def forbidden():
215
+ raise _ApiException(status=403, reason="nope")
216
+
217
+ with pytest.raises(_ApiException) as ei:
218
+ k8s_call_with_retry(forbidden, max_retries=3, base_delay=0.01)
219
+ assert ei.value.status == 403
@@ -0,0 +1,16 @@
1
+ """vigie_spark_operator — SparkK8sOperator (Airflow → SparkApplication K8s)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __version__ = "0.1.2"
8
+ __all__ = ["SparkK8sOperator", "__version__"]
9
+
10
+
11
+ def __getattr__(name: str) -> Any:
12
+ if name == "SparkK8sOperator":
13
+ from vigie_spark_operator.operator import SparkK8sOperator
14
+
15
+ return SparkK8sOperator
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")