sparkforensics-operator 0.1.0__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.
Files changed (60) hide show
  1. sparkforensics_operator-0.1.0/.github/workflows/ci.yml +31 -0
  2. sparkforensics_operator-0.1.0/.github/workflows/publish.yml +33 -0
  3. sparkforensics_operator-0.1.0/.gitignore +10 -0
  4. sparkforensics_operator-0.1.0/CHANGELOG.md +23 -0
  5. sparkforensics_operator-0.1.0/CLAUDE.md +26 -0
  6. sparkforensics_operator-0.1.0/LICENSE +21 -0
  7. sparkforensics_operator-0.1.0/PKG-INFO +147 -0
  8. sparkforensics_operator-0.1.0/README.md +113 -0
  9. sparkforensics_operator-0.1.0/constraints-airflow-2.6-floor.txt +695 -0
  10. sparkforensics_operator-0.1.0/docs/api-reference.md +167 -0
  11. sparkforensics_operator-0.1.0/docs/architecture.md +87 -0
  12. sparkforensics_operator-0.1.0/docs/glossary.md +25 -0
  13. sparkforensics_operator-0.1.0/docs/runbook.md +163 -0
  14. sparkforensics_operator-0.1.0/pyproject.toml +45 -0
  15. sparkforensics_operator-0.1.0/src/sparkforensics_operator/__init__.py +29 -0
  16. sparkforensics_operator-0.1.0/src/sparkforensics_operator/_compat.py +23 -0
  17. sparkforensics_operator-0.1.0/src/sparkforensics_operator/callback.py +81 -0
  18. sparkforensics_operator-0.1.0/src/sparkforensics_operator/exceptions.py +10 -0
  19. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/__init__.py +0 -0
  20. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/analyze/__init__.py +0 -0
  21. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/analyze/base.py +18 -0
  22. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/analyze/subprocess.py +72 -0
  23. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/__init__.py +0 -0
  24. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/_dest_root.py +30 -0
  25. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/_path_template.py +31 -0
  26. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/_rolling_log.py +4 -0
  27. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/base.py +24 -0
  28. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/filesystem.py +49 -0
  29. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/history_server.py +99 -0
  30. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/sftp.py +107 -0
  31. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/tunnel.py +77 -0
  32. sparkforensics_operator-0.1.0/src/sparkforensics_operator/hooks/log_source/xcom.py +27 -0
  33. sparkforensics_operator-0.1.0/src/sparkforensics_operator/links.py +49 -0
  34. sparkforensics_operator-0.1.0/src/sparkforensics_operator/notify.py +37 -0
  35. sparkforensics_operator-0.1.0/src/sparkforensics_operator/operator.py +163 -0
  36. sparkforensics_operator-0.1.0/src/sparkforensics_operator/report.py +91 -0
  37. sparkforensics_operator-0.1.0/src/sparkforensics_operator/sinks.py +96 -0
  38. sparkforensics_operator-0.1.0/tests/__init__.py +0 -0
  39. sparkforensics_operator-0.1.0/tests/hooks/__init__.py +0 -0
  40. sparkforensics_operator-0.1.0/tests/hooks/analyze/__init__.py +0 -0
  41. sparkforensics_operator-0.1.0/tests/hooks/analyze/test_base.py +16 -0
  42. sparkforensics_operator-0.1.0/tests/hooks/analyze/test_subprocess.py +164 -0
  43. sparkforensics_operator-0.1.0/tests/hooks/log_source/__init__.py +0 -0
  44. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_base.py +26 -0
  45. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_filesystem.py +144 -0
  46. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_history_server.py +208 -0
  47. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_sftp.py +176 -0
  48. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_tunnel.py +109 -0
  49. sparkforensics_operator-0.1.0/tests/hooks/log_source/test_xcom.py +48 -0
  50. sparkforensics_operator-0.1.0/tests/test_callback.py +107 -0
  51. sparkforensics_operator-0.1.0/tests/test_compat.py +19 -0
  52. sparkforensics_operator-0.1.0/tests/test_dagbag_integration.py +22 -0
  53. sparkforensics_operator-0.1.0/tests/test_exceptions.py +15 -0
  54. sparkforensics_operator-0.1.0/tests/test_links.py +54 -0
  55. sparkforensics_operator-0.1.0/tests/test_notify.py +42 -0
  56. sparkforensics_operator-0.1.0/tests/test_operator.py +302 -0
  57. sparkforensics_operator-0.1.0/tests/test_package_exports.py +30 -0
  58. sparkforensics_operator-0.1.0/tests/test_report.py +135 -0
  59. sparkforensics_operator-0.1.0/tests/test_sinks.py +156 -0
  60. sparkforensics_operator-0.1.0/tox.ini +30 -0
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ include:
15
+ - python: "3.11"
16
+ tox-env: py311-airflow2
17
+ - python: "3.12"
18
+ tox-env: py312-airflow2
19
+ - python: "3.11"
20
+ tox-env: py311-airflow3
21
+ - python: "3.12"
22
+ tox-env: py312-airflow3
23
+ - python: "3.9"
24
+ tox-env: py39-airflow2min
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python }}
30
+ - run: pip install tox
31
+ - run: tox -e ${{ matrix.tox-env }}
@@ -0,0 +1,33 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install build
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .worktrees/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .tox/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .venv/
10
+ docs/superpowers/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-06
9
+
10
+ Initial release.
11
+
12
+ ### Added
13
+
14
+ - `SparkForensicsOperator`: after a Spark job runs, fetches its event log
15
+ and runs it through the `sparkforensics-analyze` CLI, then
16
+ persists/XComs/threshold-checks/notifies on the result.
17
+ - Log source hooks: `FilesystemLogSourceHook`, `HistoryServerLogSourceHook`,
18
+ `SFTPLogSourceHook`, `SSHTunneledLogSourceHook`, `XComLogSourceHook`.
19
+ - `SubprocessAnalyzeHook`, shelling out to the `sparkforensics-analyze` CLI.
20
+ - Generic `Notifier` interface for threshold-breach alerting.
21
+ - PyPI trusted-publishing GitHub Actions workflow (OIDC, no stored token).
22
+
23
+ [0.1.0]: https://github.com/shuffle-works/sparkforensics-operator/releases/tag/v0.1.0
@@ -0,0 +1,26 @@
1
+ # sparkforensics-operator
2
+
3
+ Airflow package: after a Spark job runs, fetches its event log and runs it
4
+ through the sparkforensics-analyze CLI, then persists/XComs/threshold-checks/
5
+ notifies on the result.
6
+
7
+ ## Stack
8
+ Python 3.9+, apache-airflow 2.6+ or 3.0+, pytest. Runtime dependency: Node.js
9
+ (engines `>=22.18.0 <23.0.0 || >=23.6.0`) + the `sparkforensics` npm package
10
+ on the worker, for the `sparkforensics-analyze` CLI the subprocess backend
11
+ shells out to.
12
+
13
+ ## Commands
14
+ - `pip install -e ".[test,s3,ssh]"`, install for local dev.
15
+ - `pytest`, run the test suite (mocks everything: no live Spark/Airflow/Node
16
+ needed for any single test).
17
+ - `tox -e py311-airflow2` / `tox -e py311-airflow3`, run the suite against a
18
+ specific Airflow major version.
19
+
20
+ ## Design docs
21
+ - `docs/superpowers/specs/2026-09-05-sparkforensics-operator-design.md`, the original design spec.
22
+ - `docs/superpowers/plans/2026-09-05-sparkforensics-operator.md`, the
23
+ implementation plan, including every upstream sparkforensics CLI/API
24
+ fact and every Airflow-2-vs-3 compat fact this codebase depends on.
25
+ - `docs/architecture.md`, `docs/api-reference.md`, `docs/runbook.md`,
26
+ `docs/glossary.md`, see below.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agustin Recoba
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,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: sparkforensics-operator
3
+ Version: 0.1.0
4
+ Summary: Airflow operator that runs sparkforensics analysis on a Spark job's event log and acts on the result.
5
+ Project-URL: Homepage, https://github.com/shuffle-works/sparkforensics-operator
6
+ Project-URL: Repository, https://github.com/shuffle-works/sparkforensics-operator
7
+ Project-URL: Issues, https://github.com/shuffle-works/sparkforensics-operator/issues
8
+ Author-email: Agustin Recoba <agustin.recoba@outlook.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: airflow,data-engineering,observability,spark,sparkforensics
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Apache Airflow
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: apache-airflow>=2.6
24
+ Requires-Dist: requests>=2.28
25
+ Provides-Extra: s3
26
+ Requires-Dist: apache-airflow-providers-amazon>=8.0; extra == 's3'
27
+ Provides-Extra: ssh
28
+ Requires-Dist: apache-airflow-providers-sftp>=4.0; extra == 'ssh'
29
+ Requires-Dist: apache-airflow-providers-ssh>=5.0; extra == 'ssh'
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest-cov>=4.0; extra == 'test'
32
+ Requires-Dist: pytest>=7.0; extra == 'test'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # sparkforensics-operator
36
+
37
+ [![CI](https://github.com/shuffle-works/sparkforensics-operator/actions/workflows/ci.yml/badge.svg)](https://github.com/shuffle-works/sparkforensics-operator/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
39
+
40
+ An Airflow operator that runs [sparkforensics](https://github.com/shuffle-works/sparkforensics)
41
+ analysis on a Spark job's event log right after it finishes, then acts on
42
+ the result: persist the report, push it to XCom, notify (via any
43
+ messaging/paging system you implement), and optionally fail the DAG if a
44
+ budget was breached.
45
+
46
+ Airflow tells you a Spark task succeeded. It doesn't tell you that stage 14
47
+ spilled 40GB from a skewed join, or that runtime crept 20% past last week's
48
+ run. This package closes that gap without a separate monitoring job: it
49
+ runs as part of the DAG, right where the Spark task just ran.
50
+
51
+ ## Why this exists
52
+
53
+ - Two ways to wire it in: a standalone `SparkForensicsOperator` that fails
54
+ the DAG on a threshold breach, or a `spark_forensics_callback` factory to
55
+ attach as `on_success_callback` on the Spark task itself, log-only.
56
+ - Three ways to get the event log: fetch it from the Spark History Server,
57
+ read it from a filesystem/mounted-HDFS path template, or pull it straight
58
+ out of XCom if the upstream task already has it.
59
+ - Budget thresholds mirror `sparkforensics-analyze`'s own CLI flags (max
60
+ runtime, spill, skew, failed-task rate, min efficiency); only the ones
61
+ you configure are enforced, and a breach raises `ThresholdBreached`
62
+ without consuming the task's retries, since re-running would just reach
63
+ the same verdict.
64
+ - Reports persist to a local path, `file://`, or `s3://`, and a clickable
65
+ "SparkForensics report" link shows up on the task in the Airflow UI.
66
+ - An optional `Notifier` you implement (Slack, MS Teams, email, PagerDuty,
67
+ ZenDuty, whatever you use) gets a best-effort pass/fail summary; a
68
+ delivery failure never fails the task itself.
69
+ - Tested against both Airflow 2.6+ and Airflow 3.0+ (CI matrix, Python
70
+ 3.9-3.12). 97 tests, 96% coverage, no live Spark/Airflow/Node needed to
71
+ run them: everything is mocked.
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ pip install sparkforensics-operator
77
+ ```
78
+
79
+ Standalone operator, chained after the Spark task, fails the DAG on breach:
80
+
81
+ ```python
82
+ from sparkforensics_operator import FilesystemLogSourceHook, SparkForensicsOperator, SubprocessAnalyzeHook
83
+
84
+ run_spark_job = SparkSubmitOperator(task_id="run_spark_job", ...)
85
+
86
+ check_spark_job = SparkForensicsOperator(
87
+ task_id="check_spark_job",
88
+ log_source=FilesystemLogSourceHook(path_template="/mnt/spark-logs/{run_id}/eventlog"),
89
+ backend=SubprocessAnalyzeHook(),
90
+ report_dest="s3://reports/{{ run_id }}/report.json",
91
+ max_runtime_ms=3_600_000,
92
+ max_skew_ratio=3,
93
+ on_threshold_breach="fail",
94
+ )
95
+
96
+ run_spark_job >> check_spark_job
97
+ ```
98
+
99
+ Or attach it as a callback instead. Airflow logs and swallows any exception
100
+ raised inside a callback, so a breach here never fails or retries the
101
+ upstream task, whatever `on_threshold_breach` is set to:
102
+
103
+ ```python
104
+ from sparkforensics_operator import SubprocessAnalyzeHook, XComLogSourceHook, spark_forensics_callback
105
+
106
+ run_spark_job = SparkSubmitOperator(
107
+ task_id="run_spark_job",
108
+ on_success_callback=spark_forensics_callback(
109
+ log_source=XComLogSourceHook(task_id="run_spark_job"),
110
+ backend=SubprocessAnalyzeHook(),
111
+ report_dest="s3://reports/run_spark_job/app.json",
112
+ max_runtime_ms=3_600_000,
113
+ ),
114
+ ...,
115
+ )
116
+ ```
117
+
118
+ The worker needs Node.js (`>=22.18.0 <23.0.0 || >=23.6.0`) and the
119
+ `sparkforensics` npm package installed, so `sparkforensics-analyze` is
120
+ resolvable on `PATH`. Add the `s3` extra if `report_dest` is `s3://...`.
121
+ Full details in [`docs/runbook.md`](docs/runbook.md).
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ pip install -e ".[test,s3]"
127
+ pytest
128
+ ```
129
+
130
+ Test against a specific Airflow major version:
131
+
132
+ ```bash
133
+ tox -e py311-airflow2
134
+ tox -e py311-airflow3
135
+ ```
136
+
137
+ ## Learn more
138
+
139
+ - [Architecture](docs/architecture.md), components and design decisions
140
+ - [API reference](docs/api-reference.md), the full operator/hook/threshold
141
+ surface
142
+ - [Runbook](docs/runbook.md), worker prerequisites and troubleshooting
143
+ - [Glossary](docs/glossary.md), domain terms
144
+
145
+ ## License
146
+
147
+ [MIT](LICENSE)
@@ -0,0 +1,113 @@
1
+ # sparkforensics-operator
2
+
3
+ [![CI](https://github.com/shuffle-works/sparkforensics-operator/actions/workflows/ci.yml/badge.svg)](https://github.com/shuffle-works/sparkforensics-operator/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ An Airflow operator that runs [sparkforensics](https://github.com/shuffle-works/sparkforensics)
7
+ analysis on a Spark job's event log right after it finishes, then acts on
8
+ the result: persist the report, push it to XCom, notify (via any
9
+ messaging/paging system you implement), and optionally fail the DAG if a
10
+ budget was breached.
11
+
12
+ Airflow tells you a Spark task succeeded. It doesn't tell you that stage 14
13
+ spilled 40GB from a skewed join, or that runtime crept 20% past last week's
14
+ run. This package closes that gap without a separate monitoring job: it
15
+ runs as part of the DAG, right where the Spark task just ran.
16
+
17
+ ## Why this exists
18
+
19
+ - Two ways to wire it in: a standalone `SparkForensicsOperator` that fails
20
+ the DAG on a threshold breach, or a `spark_forensics_callback` factory to
21
+ attach as `on_success_callback` on the Spark task itself, log-only.
22
+ - Three ways to get the event log: fetch it from the Spark History Server,
23
+ read it from a filesystem/mounted-HDFS path template, or pull it straight
24
+ out of XCom if the upstream task already has it.
25
+ - Budget thresholds mirror `sparkforensics-analyze`'s own CLI flags (max
26
+ runtime, spill, skew, failed-task rate, min efficiency); only the ones
27
+ you configure are enforced, and a breach raises `ThresholdBreached`
28
+ without consuming the task's retries, since re-running would just reach
29
+ the same verdict.
30
+ - Reports persist to a local path, `file://`, or `s3://`, and a clickable
31
+ "SparkForensics report" link shows up on the task in the Airflow UI.
32
+ - An optional `Notifier` you implement (Slack, MS Teams, email, PagerDuty,
33
+ ZenDuty, whatever you use) gets a best-effort pass/fail summary; a
34
+ delivery failure never fails the task itself.
35
+ - Tested against both Airflow 2.6+ and Airflow 3.0+ (CI matrix, Python
36
+ 3.9-3.12). 97 tests, 96% coverage, no live Spark/Airflow/Node needed to
37
+ run them: everything is mocked.
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ pip install sparkforensics-operator
43
+ ```
44
+
45
+ Standalone operator, chained after the Spark task, fails the DAG on breach:
46
+
47
+ ```python
48
+ from sparkforensics_operator import FilesystemLogSourceHook, SparkForensicsOperator, SubprocessAnalyzeHook
49
+
50
+ run_spark_job = SparkSubmitOperator(task_id="run_spark_job", ...)
51
+
52
+ check_spark_job = SparkForensicsOperator(
53
+ task_id="check_spark_job",
54
+ log_source=FilesystemLogSourceHook(path_template="/mnt/spark-logs/{run_id}/eventlog"),
55
+ backend=SubprocessAnalyzeHook(),
56
+ report_dest="s3://reports/{{ run_id }}/report.json",
57
+ max_runtime_ms=3_600_000,
58
+ max_skew_ratio=3,
59
+ on_threshold_breach="fail",
60
+ )
61
+
62
+ run_spark_job >> check_spark_job
63
+ ```
64
+
65
+ Or attach it as a callback instead. Airflow logs and swallows any exception
66
+ raised inside a callback, so a breach here never fails or retries the
67
+ upstream task, whatever `on_threshold_breach` is set to:
68
+
69
+ ```python
70
+ from sparkforensics_operator import SubprocessAnalyzeHook, XComLogSourceHook, spark_forensics_callback
71
+
72
+ run_spark_job = SparkSubmitOperator(
73
+ task_id="run_spark_job",
74
+ on_success_callback=spark_forensics_callback(
75
+ log_source=XComLogSourceHook(task_id="run_spark_job"),
76
+ backend=SubprocessAnalyzeHook(),
77
+ report_dest="s3://reports/run_spark_job/app.json",
78
+ max_runtime_ms=3_600_000,
79
+ ),
80
+ ...,
81
+ )
82
+ ```
83
+
84
+ The worker needs Node.js (`>=22.18.0 <23.0.0 || >=23.6.0`) and the
85
+ `sparkforensics` npm package installed, so `sparkforensics-analyze` is
86
+ resolvable on `PATH`. Add the `s3` extra if `report_dest` is `s3://...`.
87
+ Full details in [`docs/runbook.md`](docs/runbook.md).
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ pip install -e ".[test,s3]"
93
+ pytest
94
+ ```
95
+
96
+ Test against a specific Airflow major version:
97
+
98
+ ```bash
99
+ tox -e py311-airflow2
100
+ tox -e py311-airflow3
101
+ ```
102
+
103
+ ## Learn more
104
+
105
+ - [Architecture](docs/architecture.md), components and design decisions
106
+ - [API reference](docs/api-reference.md), the full operator/hook/threshold
107
+ surface
108
+ - [Runbook](docs/runbook.md), worker prerequisites and troubleshooting
109
+ - [Glossary](docs/glossary.md), domain terms
110
+
111
+ ## License
112
+
113
+ [MIT](LICENSE)