datahub-airflow-plugin-lite 0.1.1__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.
- datahub_airflow_plugin_lite-0.1.1/PKG-INFO +118 -0
- datahub_airflow_plugin_lite-0.1.1/README.md +108 -0
- datahub_airflow_plugin_lite-0.1.1/pyproject.toml +37 -0
- datahub_airflow_plugin_lite-0.1.1/setup.cfg +4 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/__init__.py +1 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/aspects.py +223 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/config.py +151 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/emitter.py +126 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/entities.py +47 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/generator.py +403 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/lineage.py +315 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/listener.py +279 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/ol_normalize.py +96 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/plugin.py +8 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/py.typed +0 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/sql_context.py +146 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/sql_lineage.py +399 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite/urns.py +108 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/PKG-INFO +118 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/SOURCES.txt +22 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/dependency_links.txt +1 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/entry_points.txt +2 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/requires.txt +4 -0
- datahub_airflow_plugin_lite-0.1.1/src/datahub_airflow_plugin_lite.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datahub-airflow-plugin-lite
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Dependency-free DataHub listener plugin for Apache Airflow
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest; extra == "dev"
|
|
9
|
+
Requires-Dist: acryl-datahub[datahub-rest]; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# datahub-airflow-plugin-lite
|
|
12
|
+
|
|
13
|
+
Airflow 실행 메타데이터와 lineage를 DataHub로 보내는 **런타임 의존성 0개** 플러그인입니다.
|
|
14
|
+
표준 라이브러리와 이미 설치된 Airflow만 사용하므로 MWAA 등 Airflow constraints 파일과 충돌하지 않습니다.
|
|
15
|
+
|
|
16
|
+
| 항목 | 지원 범위 | 검증한 조합 |
|
|
17
|
+
| ----------- | --------------------- | -------------------------------------------------- |
|
|
18
|
+
| Python | 3.9 이상 | 3.9.6, 3.12.13 |
|
|
19
|
+
| Airflow | 2.9.1 이상 (3.x 포함) | 2.9.1, 2.11.2, 3.1.0 |
|
|
20
|
+
| DataHub GMS | 1.4.0.3 이상 | URN/aspect를 acryl-datahub 1.4.0.3 SDK 출력과 대조 |
|
|
21
|
+
|
|
22
|
+
## 설치
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install datahub-airflow-plugin-lite-0.1.1-py3-none-any.whl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
공식 플러그인(`acryl-datahub-airflow-plugin`)과 동시에 설치하지 마세요. 두 리스너가 같은 엔티티를 중복 전송합니다.
|
|
29
|
+
|
|
30
|
+
## 설정
|
|
31
|
+
|
|
32
|
+
`airflow.cfg`의 `[datahub]` 섹션(또는 `AIRFLOW__DATAHUB__*` 환경변수)을 읽습니다. 키 이름은 공식 플러그인과 같습니다.
|
|
33
|
+
|
|
34
|
+
```ini
|
|
35
|
+
[datahub]
|
|
36
|
+
# GMS 주소/토큰을 직접 지정하거나, 생략하면 conn_id 커넥션(host=GMS URL, password=토큰)을 사용
|
|
37
|
+
gms_url = https://datahub-gms.example.com
|
|
38
|
+
token = <personal access token>
|
|
39
|
+
conn_id = datahub_rest_default
|
|
40
|
+
cluster = prod
|
|
41
|
+
platform_instance =
|
|
42
|
+
capture_ownership_info = true
|
|
43
|
+
capture_ownership_as_group = false
|
|
44
|
+
capture_tags_info = true
|
|
45
|
+
capture_executions = true
|
|
46
|
+
materialize_iolets = true
|
|
47
|
+
capture_airflow_assets = true
|
|
48
|
+
enable_datajob_lineage = true
|
|
49
|
+
enable_openlineage_facets = true
|
|
50
|
+
openlineage_timeout_sec = 5
|
|
51
|
+
supplement_with_sql_parser = true
|
|
52
|
+
athena_include_result_location = false
|
|
53
|
+
dag_filter_str = {"allow": [".*"], "deny": []}
|
|
54
|
+
timeout_sec = 10
|
|
55
|
+
retries = 2
|
|
56
|
+
ssl_verify = true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
운영 스위치도 공식 플러그인과 같습니다.
|
|
60
|
+
|
|
61
|
+
- `AIRFLOW_VAR_DATAHUB_AIRFLOW_PLUGIN_DISABLE_LISTENER=true`: 리스너 즉시 중지 (kill switch)
|
|
62
|
+
- `DATAHUB_AIRFLOW_PLUGIN_RUN_IN_THREAD`, `DATAHUB_AIRFLOW_PLUGIN_RUN_IN_THREAD_TIMEOUT`: 백그라운드 스레드 실행 여부와 대기 시간
|
|
63
|
+
|
|
64
|
+
## 동작
|
|
65
|
+
|
|
66
|
+
| Airflow 이벤트 | 전송 내용 |
|
|
67
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| DAG run 시작 | DataFlow (`dataFlowInfo`, `status`, `browsePathsV2`, `ownership`, `globalTags`, `dataPlatformInstance`), 태스크·태그 `status` |
|
|
69
|
+
| 태스크 시작 | DataJob (`dataJobInfo`, `status`, `ownership`, `globalTags`), 실행 기록 (`dataProcessInstance*`, STARTED) |
|
|
70
|
+
| 태스크 성공/실패 | DataJob + `dataJobInputOutput`(lineage), 실행 결과(COMPLETE, SUCCESS/FAILURE), 실행 입출력, 데이터셋 `status` |
|
|
71
|
+
|
|
72
|
+
전송은 GMS의 `POST /aspects?action=ingestProposal` 하나만 사용합니다.
|
|
73
|
+
모든 처리는 데몬 스레드에서 실행되고, 예외는 로그로만 남기므로 태스크 실패로 이어지지 않습니다.
|
|
74
|
+
|
|
75
|
+
### Lineage 소스
|
|
76
|
+
|
|
77
|
+
1. `inlets`/`outlets`의 `Dataset`/`Urn` 객체 또는 `urn:li:...` 문자열
|
|
78
|
+
- `datahub_airflow_plugin_lite.entities.Dataset`/`Urn`
|
|
79
|
+
- 공식 플러그인의 `datahub_airflow_plugin.entities` 객체도 `urn` 속성이 있어 그대로 인식합니다.
|
|
80
|
+
2. Airflow 네이티브 Dataset(2.x)/Asset(3.x). URI 스킴으로 플랫폼을 판별합니다.
|
|
81
|
+
3. **OpenLineage provider (기본 소스):** operator의 `get_openlineage_facets_on_complete/start()` 결과를 사용합니다. 몽키패치는 하지 않습니다.
|
|
82
|
+
- **정규화:** provider 버전마다 다른 결과를 DataHub ingestion URN으로 맞춥니다(`ol_normalize.py`).
|
|
83
|
+
- Athena: `AwsDataCatalog.` 접두어를 제거하고 소문자로 바꿉니다.
|
|
84
|
+
- S3: 버킷과 키를 합쳐 `make_s3_urn`과 같은 규칙을 적용합니다.
|
|
85
|
+
- Snowflake: 소문자로 바꿉니다.
|
|
86
|
+
- **버전 의존 값 제거:** 버전마다 다르게 나오는 Athena 쿼리 결과 위치는 제외합니다.
|
|
87
|
+
- **시간 제한:** OpenLineage 호출은 `openlineage_timeout_sec`(기본 5초)까지만 기다립니다.
|
|
88
|
+
4. **내장 SQL 파서 (보완):** OpenLineage가 버전에 따라 놓친 테이블을 채웁니다. 예: provider 2.x의 CTE 입력 누락, amazon 8.x의 Athena 테이블 누락. 이 단계는 OpenLineage 결과를 지우지 않습니다.
|
|
89
|
+
- DB에 접속하지 않고 SQL 문과 Connection 정보(conn_type, 기본 DB/스키마)만 사용합니다.
|
|
90
|
+
|
|
91
|
+
lineage 수집이 실패해서 결과가 불완전할 수 있으면 `dataJobInputOutput`을 보내지 않습니다. 이미 저장된 lineage를 덮어쓰지 않기 위해서입니다. 예: OpenLineage가 실패했고 SQL도 분석할 수 없는 경우.
|
|
92
|
+
DataJob 정보와 실행 기록은 lineage보다 먼저 전송하므로, lineage 수집 결과와 관계없이 항상 기록됩니다.
|
|
93
|
+
|
|
94
|
+
provider 1.7.0, 2.11.0, 2.20.2에서 같은 lineage가 저장되는지 비교한 결과는 [tests/compat/README.md](tests/compat/README.md)에 있습니다.
|
|
95
|
+
|
|
96
|
+
## 공식 플러그인과의 차이
|
|
97
|
+
|
|
98
|
+
URN 생성 규칙(DataFlow, DataJob, Dataset, DataProcessInstance)은 공식 플러그인과 같습니다. 따라서 공식 플러그인에서 전환해도 엔티티가 중복 생성되지 않습니다.
|
|
99
|
+
`tests/unit/test_sdk_parity.py`가 이를 검증합니다.
|
|
100
|
+
|
|
101
|
+
| 기능 | 공식 | lite |
|
|
102
|
+
| --------------------------------------------------------------- | ----------------- | ---------------------------------------------- |
|
|
103
|
+
| DataFlow/DataJob/실행 이력, 소유자, 태그 | O | O |
|
|
104
|
+
| inlets/outlets, Airflow Dataset/Asset lineage | O | O |
|
|
105
|
+
| OpenLineage 기반 테이블 lineage | O (SQL 파서 패치) | O (패치 없이 결과 정규화 + 내장 SQL 파서 보완) |
|
|
106
|
+
| 컬럼 단위 lineage (DataHub SQL 파서) | O | X |
|
|
107
|
+
| Kafka/File emitter, 다중 커넥션 | O | X (REST만) |
|
|
108
|
+
| `Datahub_Cleanup` DAG, DatahubEmitterOperator, assertion sensor | O | X |
|
|
109
|
+
| Jinja 템플릿 렌더링 후 속성 수집 | O | X (원본 값 사용) |
|
|
110
|
+
|
|
111
|
+
## 개발
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
pytest
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`test_sdk_parity.py`는 `acryl-datahub`가 설치된 경우에만 실행됩니다. `test_listener.py`는 설치된 Airflow의 실제 listener manager(pluggy)에 등록해 훅 시그니처 호환성을 검증합니다.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# datahub-airflow-plugin-lite
|
|
2
|
+
|
|
3
|
+
Airflow 실행 메타데이터와 lineage를 DataHub로 보내는 **런타임 의존성 0개** 플러그인입니다.
|
|
4
|
+
표준 라이브러리와 이미 설치된 Airflow만 사용하므로 MWAA 등 Airflow constraints 파일과 충돌하지 않습니다.
|
|
5
|
+
|
|
6
|
+
| 항목 | 지원 범위 | 검증한 조합 |
|
|
7
|
+
| ----------- | --------------------- | -------------------------------------------------- |
|
|
8
|
+
| Python | 3.9 이상 | 3.9.6, 3.12.13 |
|
|
9
|
+
| Airflow | 2.9.1 이상 (3.x 포함) | 2.9.1, 2.11.2, 3.1.0 |
|
|
10
|
+
| DataHub GMS | 1.4.0.3 이상 | URN/aspect를 acryl-datahub 1.4.0.3 SDK 출력과 대조 |
|
|
11
|
+
|
|
12
|
+
## 설치
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install datahub-airflow-plugin-lite-0.1.1-py3-none-any.whl
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
공식 플러그인(`acryl-datahub-airflow-plugin`)과 동시에 설치하지 마세요. 두 리스너가 같은 엔티티를 중복 전송합니다.
|
|
19
|
+
|
|
20
|
+
## 설정
|
|
21
|
+
|
|
22
|
+
`airflow.cfg`의 `[datahub]` 섹션(또는 `AIRFLOW__DATAHUB__*` 환경변수)을 읽습니다. 키 이름은 공식 플러그인과 같습니다.
|
|
23
|
+
|
|
24
|
+
```ini
|
|
25
|
+
[datahub]
|
|
26
|
+
# GMS 주소/토큰을 직접 지정하거나, 생략하면 conn_id 커넥션(host=GMS URL, password=토큰)을 사용
|
|
27
|
+
gms_url = https://datahub-gms.example.com
|
|
28
|
+
token = <personal access token>
|
|
29
|
+
conn_id = datahub_rest_default
|
|
30
|
+
cluster = prod
|
|
31
|
+
platform_instance =
|
|
32
|
+
capture_ownership_info = true
|
|
33
|
+
capture_ownership_as_group = false
|
|
34
|
+
capture_tags_info = true
|
|
35
|
+
capture_executions = true
|
|
36
|
+
materialize_iolets = true
|
|
37
|
+
capture_airflow_assets = true
|
|
38
|
+
enable_datajob_lineage = true
|
|
39
|
+
enable_openlineage_facets = true
|
|
40
|
+
openlineage_timeout_sec = 5
|
|
41
|
+
supplement_with_sql_parser = true
|
|
42
|
+
athena_include_result_location = false
|
|
43
|
+
dag_filter_str = {"allow": [".*"], "deny": []}
|
|
44
|
+
timeout_sec = 10
|
|
45
|
+
retries = 2
|
|
46
|
+
ssl_verify = true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
운영 스위치도 공식 플러그인과 같습니다.
|
|
50
|
+
|
|
51
|
+
- `AIRFLOW_VAR_DATAHUB_AIRFLOW_PLUGIN_DISABLE_LISTENER=true`: 리스너 즉시 중지 (kill switch)
|
|
52
|
+
- `DATAHUB_AIRFLOW_PLUGIN_RUN_IN_THREAD`, `DATAHUB_AIRFLOW_PLUGIN_RUN_IN_THREAD_TIMEOUT`: 백그라운드 스레드 실행 여부와 대기 시간
|
|
53
|
+
|
|
54
|
+
## 동작
|
|
55
|
+
|
|
56
|
+
| Airflow 이벤트 | 전송 내용 |
|
|
57
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| DAG run 시작 | DataFlow (`dataFlowInfo`, `status`, `browsePathsV2`, `ownership`, `globalTags`, `dataPlatformInstance`), 태스크·태그 `status` |
|
|
59
|
+
| 태스크 시작 | DataJob (`dataJobInfo`, `status`, `ownership`, `globalTags`), 실행 기록 (`dataProcessInstance*`, STARTED) |
|
|
60
|
+
| 태스크 성공/실패 | DataJob + `dataJobInputOutput`(lineage), 실행 결과(COMPLETE, SUCCESS/FAILURE), 실행 입출력, 데이터셋 `status` |
|
|
61
|
+
|
|
62
|
+
전송은 GMS의 `POST /aspects?action=ingestProposal` 하나만 사용합니다.
|
|
63
|
+
모든 처리는 데몬 스레드에서 실행되고, 예외는 로그로만 남기므로 태스크 실패로 이어지지 않습니다.
|
|
64
|
+
|
|
65
|
+
### Lineage 소스
|
|
66
|
+
|
|
67
|
+
1. `inlets`/`outlets`의 `Dataset`/`Urn` 객체 또는 `urn:li:...` 문자열
|
|
68
|
+
- `datahub_airflow_plugin_lite.entities.Dataset`/`Urn`
|
|
69
|
+
- 공식 플러그인의 `datahub_airflow_plugin.entities` 객체도 `urn` 속성이 있어 그대로 인식합니다.
|
|
70
|
+
2. Airflow 네이티브 Dataset(2.x)/Asset(3.x). URI 스킴으로 플랫폼을 판별합니다.
|
|
71
|
+
3. **OpenLineage provider (기본 소스):** operator의 `get_openlineage_facets_on_complete/start()` 결과를 사용합니다. 몽키패치는 하지 않습니다.
|
|
72
|
+
- **정규화:** provider 버전마다 다른 결과를 DataHub ingestion URN으로 맞춥니다(`ol_normalize.py`).
|
|
73
|
+
- Athena: `AwsDataCatalog.` 접두어를 제거하고 소문자로 바꿉니다.
|
|
74
|
+
- S3: 버킷과 키를 합쳐 `make_s3_urn`과 같은 규칙을 적용합니다.
|
|
75
|
+
- Snowflake: 소문자로 바꿉니다.
|
|
76
|
+
- **버전 의존 값 제거:** 버전마다 다르게 나오는 Athena 쿼리 결과 위치는 제외합니다.
|
|
77
|
+
- **시간 제한:** OpenLineage 호출은 `openlineage_timeout_sec`(기본 5초)까지만 기다립니다.
|
|
78
|
+
4. **내장 SQL 파서 (보완):** OpenLineage가 버전에 따라 놓친 테이블을 채웁니다. 예: provider 2.x의 CTE 입력 누락, amazon 8.x의 Athena 테이블 누락. 이 단계는 OpenLineage 결과를 지우지 않습니다.
|
|
79
|
+
- DB에 접속하지 않고 SQL 문과 Connection 정보(conn_type, 기본 DB/스키마)만 사용합니다.
|
|
80
|
+
|
|
81
|
+
lineage 수집이 실패해서 결과가 불완전할 수 있으면 `dataJobInputOutput`을 보내지 않습니다. 이미 저장된 lineage를 덮어쓰지 않기 위해서입니다. 예: OpenLineage가 실패했고 SQL도 분석할 수 없는 경우.
|
|
82
|
+
DataJob 정보와 실행 기록은 lineage보다 먼저 전송하므로, lineage 수집 결과와 관계없이 항상 기록됩니다.
|
|
83
|
+
|
|
84
|
+
provider 1.7.0, 2.11.0, 2.20.2에서 같은 lineage가 저장되는지 비교한 결과는 [tests/compat/README.md](tests/compat/README.md)에 있습니다.
|
|
85
|
+
|
|
86
|
+
## 공식 플러그인과의 차이
|
|
87
|
+
|
|
88
|
+
URN 생성 규칙(DataFlow, DataJob, Dataset, DataProcessInstance)은 공식 플러그인과 같습니다. 따라서 공식 플러그인에서 전환해도 엔티티가 중복 생성되지 않습니다.
|
|
89
|
+
`tests/unit/test_sdk_parity.py`가 이를 검증합니다.
|
|
90
|
+
|
|
91
|
+
| 기능 | 공식 | lite |
|
|
92
|
+
| --------------------------------------------------------------- | ----------------- | ---------------------------------------------- |
|
|
93
|
+
| DataFlow/DataJob/실행 이력, 소유자, 태그 | O | O |
|
|
94
|
+
| inlets/outlets, Airflow Dataset/Asset lineage | O | O |
|
|
95
|
+
| OpenLineage 기반 테이블 lineage | O (SQL 파서 패치) | O (패치 없이 결과 정규화 + 내장 SQL 파서 보완) |
|
|
96
|
+
| 컬럼 단위 lineage (DataHub SQL 파서) | O | X |
|
|
97
|
+
| Kafka/File emitter, 다중 커넥션 | O | X (REST만) |
|
|
98
|
+
| `Datahub_Cleanup` DAG, DatahubEmitterOperator, assertion sensor | O | X |
|
|
99
|
+
| Jinja 템플릿 렌더링 후 속성 수집 | O | X (원본 값 사용) |
|
|
100
|
+
|
|
101
|
+
## 개발
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`test_sdk_parity.py`는 `acryl-datahub`가 설치된 경우에만 실행됩니다. `test_listener.py`는 설치된 Airflow의 실제 listener manager(pluggy)에 등록해 훅 시그니처 호환성을 검증합니다.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "datahub-airflow-plugin-lite"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Dependency-free DataHub listener plugin for Apache Airflow"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
# Intentionally empty: the plugin only uses the Python standard library and the
|
|
12
|
+
# Airflow installation it runs inside, so it never conflicts with Airflow/MWAA
|
|
13
|
+
# constraint files. Supported runtime: Airflow >= 2.9.1, DataHub GMS >= 1.4.0.3.
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest",
|
|
19
|
+
# Used only by the parity tests to prove URNs/aspects match the official SDK.
|
|
20
|
+
"acryl-datahub[datahub-rest]",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.entry-points."airflow.plugins"]
|
|
24
|
+
datahub_lite = "datahub_airflow_plugin_lite.plugin:DatahubLitePlugin"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
datahub_airflow_plugin_lite = ["py.typed"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 88
|
|
37
|
+
target-version = "py39"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Aspect payloads as plain JSON dicts.
|
|
2
|
+
|
|
3
|
+
Only aspects/fields that exist in DataHub 1.4.0.3 and later are produced. The
|
|
4
|
+
shapes match what acryl-datahub serializes (see tests/unit/test_sdk_parity.py),
|
|
5
|
+
including union encodings such as `{"string": "COMMAND"}` and timeseries
|
|
6
|
+
defaults such as `partitionSpec`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Dict, List, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from datahub_airflow_plugin_lite.urns import (
|
|
14
|
+
entity_type_of,
|
|
15
|
+
make_data_platform_urn,
|
|
16
|
+
make_dataplatform_instance_urn,
|
|
17
|
+
make_group_urn,
|
|
18
|
+
make_user_urn,
|
|
19
|
+
ORCHESTRATOR,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
JsonDict = Dict[str, object]
|
|
23
|
+
|
|
24
|
+
# Values accepted by DataHub's FabricType enum; DataJobInfo/DataFlowInfo only set
|
|
25
|
+
# `env` when the Airflow cluster name is one of them (same rule as the SDK).
|
|
26
|
+
_FABRIC_TYPES = {
|
|
27
|
+
"DEV",
|
|
28
|
+
"TEST",
|
|
29
|
+
"QA",
|
|
30
|
+
"UAT",
|
|
31
|
+
"EI",
|
|
32
|
+
"PRE",
|
|
33
|
+
"STG",
|
|
34
|
+
"NON_PROD",
|
|
35
|
+
"PROD",
|
|
36
|
+
"CORP",
|
|
37
|
+
"RVW",
|
|
38
|
+
"PRD",
|
|
39
|
+
"TST",
|
|
40
|
+
"SIT",
|
|
41
|
+
"SBX",
|
|
42
|
+
"SANDBOX",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_FULL_TABLE_PARTITION: JsonDict = {
|
|
46
|
+
"partition": "FULL_TABLE_SNAPSHOT",
|
|
47
|
+
"type": "FULL_TABLE",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class MetadataChangeProposal:
|
|
53
|
+
entity_urn: str
|
|
54
|
+
aspect_name: str
|
|
55
|
+
aspect: JsonDict
|
|
56
|
+
|
|
57
|
+
def to_proposal(self) -> JsonDict:
|
|
58
|
+
return {
|
|
59
|
+
"entityType": entity_type_of(self.entity_urn),
|
|
60
|
+
"entityUrn": self.entity_urn,
|
|
61
|
+
"changeType": "UPSERT",
|
|
62
|
+
"aspectName": self.aspect_name,
|
|
63
|
+
"aspect": {
|
|
64
|
+
"value": json.dumps(self.aspect),
|
|
65
|
+
"contentType": "application/json",
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _drop_none(values: Mapping[str, Optional[object]]) -> JsonDict:
|
|
71
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def env_for_cluster(cluster: str) -> Optional[str]:
|
|
75
|
+
return cluster.upper() if cluster.upper() in _FABRIC_TYPES else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def status(removed: bool = False) -> JsonDict:
|
|
79
|
+
return {"removed": removed}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def data_platform_instance(instance: str) -> JsonDict:
|
|
83
|
+
return {
|
|
84
|
+
"platform": make_data_platform_urn(ORCHESTRATOR),
|
|
85
|
+
"instance": make_dataplatform_instance_urn(ORCHESTRATOR, instance),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ownership(owners: Sequence[str], as_group: bool) -> JsonDict:
|
|
90
|
+
make_owner_urn = make_group_urn if as_group else make_user_urn
|
|
91
|
+
owner_urns = sorted({make_owner_urn(owner) for owner in owners})
|
|
92
|
+
return {
|
|
93
|
+
"owners": [
|
|
94
|
+
{"owner": urn, "type": "DEVELOPER", "source": {"type": "SERVICE"}}
|
|
95
|
+
for urn in owner_urns
|
|
96
|
+
],
|
|
97
|
+
"ownerTypes": {},
|
|
98
|
+
"lastModified": {"time": 0, "actor": make_user_urn(ORCHESTRATOR)},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def global_tags(tag_urns: Sequence[str]) -> JsonDict:
|
|
103
|
+
return {"tags": [{"tag": urn} for urn in sorted(set(tag_urns))]}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def browse_paths_v2(dag_id: str, platform_instance: Optional[str]) -> JsonDict:
|
|
107
|
+
path: List[JsonDict] = []
|
|
108
|
+
if platform_instance:
|
|
109
|
+
path.append(
|
|
110
|
+
{
|
|
111
|
+
"id": platform_instance,
|
|
112
|
+
"urn": make_dataplatform_instance_urn(ORCHESTRATOR, platform_instance),
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
path.append({"id": dag_id})
|
|
116
|
+
return {"path": path}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def data_flow_info(
|
|
120
|
+
name: str,
|
|
121
|
+
description: Optional[str],
|
|
122
|
+
custom_properties: Mapping[str, str],
|
|
123
|
+
external_url: Optional[str],
|
|
124
|
+
env: Optional[str],
|
|
125
|
+
) -> JsonDict:
|
|
126
|
+
return _drop_none(
|
|
127
|
+
{
|
|
128
|
+
"customProperties": dict(custom_properties),
|
|
129
|
+
"externalUrl": external_url,
|
|
130
|
+
"name": name,
|
|
131
|
+
"description": description,
|
|
132
|
+
"env": env,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def data_job_info(
|
|
138
|
+
name: str,
|
|
139
|
+
description: Optional[str],
|
|
140
|
+
custom_properties: Mapping[str, str],
|
|
141
|
+
external_url: Optional[str],
|
|
142
|
+
env: Optional[str],
|
|
143
|
+
) -> JsonDict:
|
|
144
|
+
return _drop_none(
|
|
145
|
+
{
|
|
146
|
+
"customProperties": dict(custom_properties),
|
|
147
|
+
"externalUrl": external_url,
|
|
148
|
+
"name": name,
|
|
149
|
+
"description": description,
|
|
150
|
+
# Union[AzkabanJobType, string]; the SDK serializes it as the string branch.
|
|
151
|
+
"type": {"string": "COMMAND"},
|
|
152
|
+
"env": env,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def data_job_input_output(
|
|
158
|
+
input_datasets: Sequence[str],
|
|
159
|
+
output_datasets: Sequence[str],
|
|
160
|
+
input_datajobs: Sequence[str],
|
|
161
|
+
) -> JsonDict:
|
|
162
|
+
return {
|
|
163
|
+
"inputDatasets": sorted(set(input_datasets)),
|
|
164
|
+
"outputDatasets": sorted(set(output_datasets)),
|
|
165
|
+
"inputDatajobs": sorted(set(input_datajobs)),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def data_process_instance_properties(
|
|
170
|
+
name: str,
|
|
171
|
+
created_millis: int,
|
|
172
|
+
process_type: str,
|
|
173
|
+
custom_properties: Mapping[str, str],
|
|
174
|
+
external_url: Optional[str],
|
|
175
|
+
) -> JsonDict:
|
|
176
|
+
return _drop_none(
|
|
177
|
+
{
|
|
178
|
+
"customProperties": dict(custom_properties),
|
|
179
|
+
"externalUrl": external_url,
|
|
180
|
+
"name": name,
|
|
181
|
+
"type": process_type,
|
|
182
|
+
"created": {"time": created_millis, "actor": "urn:li:corpuser:datahub"},
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def data_process_instance_relationships(parent_template: str) -> JsonDict:
|
|
188
|
+
return {"parentTemplate": parent_template, "upstreamInstances": []}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def data_process_instance_run_started(timestamp_millis: int, attempt: int) -> JsonDict:
|
|
192
|
+
return {
|
|
193
|
+
"timestampMillis": timestamp_millis,
|
|
194
|
+
"partitionSpec": dict(_FULL_TABLE_PARTITION),
|
|
195
|
+
"status": "STARTED",
|
|
196
|
+
"attempt": attempt,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def data_process_instance_run_completed(
|
|
201
|
+
timestamp_millis: int,
|
|
202
|
+
attempt: int,
|
|
203
|
+
result_type: str,
|
|
204
|
+
duration_millis: Optional[int],
|
|
205
|
+
) -> JsonDict:
|
|
206
|
+
return _drop_none(
|
|
207
|
+
{
|
|
208
|
+
"timestampMillis": timestamp_millis,
|
|
209
|
+
"partitionSpec": dict(_FULL_TABLE_PARTITION),
|
|
210
|
+
"status": "COMPLETE",
|
|
211
|
+
"attempt": attempt,
|
|
212
|
+
"result": {"type": result_type, "nativeResultType": ORCHESTRATOR},
|
|
213
|
+
"durationMillis": duration_millis,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def data_process_instance_input(inputs: Sequence[str]) -> JsonDict:
|
|
219
|
+
return {"inputs": sorted(set(inputs))}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def data_process_instance_output(outputs: Sequence[str]) -> JsonDict:
|
|
223
|
+
return {"outputs": sorted(set(outputs))}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Plugin configuration read from the `[datahub]` section of airflow.cfg.
|
|
2
|
+
|
|
3
|
+
Key names match the official plugin so an existing airflow.cfg (or
|
|
4
|
+
AIRFLOW__DATAHUB__* environment variables) keeps working unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Callable, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from datahub_airflow_plugin_lite.urns import DEFAULT_FLOW_CLUSTER
|
|
13
|
+
|
|
14
|
+
ConfigGetter = Callable[[str], Optional[str]]
|
|
15
|
+
|
|
16
|
+
_TRUE_VALUES = {"true", "1", "yes", "on"}
|
|
17
|
+
_FALSE_VALUES = {"false", "0", "no", "off"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DagFilter:
|
|
22
|
+
"""Same semantics as datahub's AllowDenyPattern (ignore case, deny wins)."""
|
|
23
|
+
|
|
24
|
+
allow: Tuple[str, ...] = (".*",)
|
|
25
|
+
deny: Tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
def allowed(self, dag_id: str) -> bool:
|
|
28
|
+
if any(re.match(p, dag_id, re.IGNORECASE) for p in self.deny):
|
|
29
|
+
return False
|
|
30
|
+
return any(re.match(p, dag_id, re.IGNORECASE) for p in self.allow)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class LiteConfig:
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
# Connection: explicit gms_url/token win over the Airflow connection, which
|
|
37
|
+
# helps on Airflow 3 where some components cannot read connections.
|
|
38
|
+
conn_id: str = "datahub_rest_default"
|
|
39
|
+
gms_url: Optional[str] = None
|
|
40
|
+
token: Optional[str] = None
|
|
41
|
+
timeout_sec: float = 10.0
|
|
42
|
+
retries: int = 2
|
|
43
|
+
ssl_verify: bool = True
|
|
44
|
+
|
|
45
|
+
cluster: str = DEFAULT_FLOW_CLUSTER
|
|
46
|
+
platform_instance: Optional[str] = None
|
|
47
|
+
capture_ownership_info: bool = True
|
|
48
|
+
capture_ownership_as_group: bool = False
|
|
49
|
+
capture_tags_info: bool = True
|
|
50
|
+
capture_executions: bool = True
|
|
51
|
+
materialize_iolets: bool = True
|
|
52
|
+
capture_airflow_assets: bool = True
|
|
53
|
+
enable_datajob_lineage: bool = True
|
|
54
|
+
# Primary lineage source: operators' get_openlineage_facets_* methods
|
|
55
|
+
# (needs apache-airflow-providers-openlineage; skipped when not installed).
|
|
56
|
+
# Results are normalized to DataHub URNs so every provider version maps to
|
|
57
|
+
# the same datasets.
|
|
58
|
+
enable_openlineage_facets: bool = True
|
|
59
|
+
openlineage_timeout_sec: float = 5.0
|
|
60
|
+
# Add tables OpenLineage missed (version-specific gaps) using the built-in,
|
|
61
|
+
# connection-free SQL parser. Never removes OpenLineage results.
|
|
62
|
+
supplement_with_sql_parser: bool = True
|
|
63
|
+
# Athena query-result S3 location as an output dataset (version-dependent).
|
|
64
|
+
athena_include_result_location: bool = False
|
|
65
|
+
datajob_url_link: Optional[str] = None
|
|
66
|
+
dag_filter: DagFilter = field(default_factory=DagFilter)
|
|
67
|
+
log_level: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_bool(raw: Optional[str], default: bool, key: str) -> bool:
|
|
71
|
+
if raw is None or raw.strip() == "":
|
|
72
|
+
return default
|
|
73
|
+
value = raw.strip().lower()
|
|
74
|
+
if value in _TRUE_VALUES:
|
|
75
|
+
return True
|
|
76
|
+
if value in _FALSE_VALUES:
|
|
77
|
+
return False
|
|
78
|
+
raise ValueError(f"[datahub] {key} must be a boolean, got {raw!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_dag_filter(raw: Optional[str]) -> DagFilter:
|
|
82
|
+
if not raw:
|
|
83
|
+
return DagFilter()
|
|
84
|
+
parsed = json.loads(raw)
|
|
85
|
+
if not isinstance(parsed, dict):
|
|
86
|
+
raise ValueError("[datahub] dag_filter_str must be a JSON object")
|
|
87
|
+
allow: List[str] = parsed.get("allow", [".*"])
|
|
88
|
+
deny: List[str] = parsed.get("deny", [])
|
|
89
|
+
return DagFilter(allow=tuple(allow), deny=tuple(deny))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_config(get: ConfigGetter) -> LiteConfig:
|
|
93
|
+
defaults = LiteConfig()
|
|
94
|
+
|
|
95
|
+
def get_bool(key: str, default: bool) -> bool:
|
|
96
|
+
return _parse_bool(get(key), default, key)
|
|
97
|
+
|
|
98
|
+
def get_str(key: str, default: Optional[str]) -> Optional[str]:
|
|
99
|
+
raw = get(key)
|
|
100
|
+
return raw if raw not in (None, "") else default
|
|
101
|
+
|
|
102
|
+
return LiteConfig(
|
|
103
|
+
enabled=get_bool("enabled", defaults.enabled),
|
|
104
|
+
conn_id=get_str("conn_id", defaults.conn_id) or defaults.conn_id,
|
|
105
|
+
gms_url=get_str("gms_url", None),
|
|
106
|
+
token=get_str("token", None),
|
|
107
|
+
timeout_sec=float(get_str("timeout_sec", None) or defaults.timeout_sec),
|
|
108
|
+
retries=int(get_str("retries", None) or defaults.retries),
|
|
109
|
+
ssl_verify=get_bool("ssl_verify", defaults.ssl_verify),
|
|
110
|
+
cluster=get_str("cluster", defaults.cluster) or defaults.cluster,
|
|
111
|
+
platform_instance=get_str("platform_instance", None),
|
|
112
|
+
capture_ownership_info=get_bool(
|
|
113
|
+
"capture_ownership_info", defaults.capture_ownership_info
|
|
114
|
+
),
|
|
115
|
+
capture_ownership_as_group=get_bool(
|
|
116
|
+
"capture_ownership_as_group", defaults.capture_ownership_as_group
|
|
117
|
+
),
|
|
118
|
+
capture_tags_info=get_bool("capture_tags_info", defaults.capture_tags_info),
|
|
119
|
+
capture_executions=get_bool("capture_executions", defaults.capture_executions),
|
|
120
|
+
materialize_iolets=get_bool("materialize_iolets", defaults.materialize_iolets),
|
|
121
|
+
capture_airflow_assets=get_bool(
|
|
122
|
+
"capture_airflow_assets", defaults.capture_airflow_assets
|
|
123
|
+
),
|
|
124
|
+
enable_datajob_lineage=get_bool(
|
|
125
|
+
"enable_datajob_lineage", defaults.enable_datajob_lineage
|
|
126
|
+
),
|
|
127
|
+
enable_openlineage_facets=get_bool(
|
|
128
|
+
"enable_openlineage_facets", defaults.enable_openlineage_facets
|
|
129
|
+
),
|
|
130
|
+
openlineage_timeout_sec=float(
|
|
131
|
+
get_str("openlineage_timeout_sec", None) or defaults.openlineage_timeout_sec
|
|
132
|
+
),
|
|
133
|
+
supplement_with_sql_parser=get_bool(
|
|
134
|
+
"supplement_with_sql_parser", defaults.supplement_with_sql_parser
|
|
135
|
+
),
|
|
136
|
+
athena_include_result_location=get_bool(
|
|
137
|
+
"athena_include_result_location", defaults.athena_include_result_location
|
|
138
|
+
),
|
|
139
|
+
datajob_url_link=get_str("datajob_url_link", None),
|
|
140
|
+
dag_filter=_parse_dag_filter(get("dag_filter_str")),
|
|
141
|
+
log_level=get_str("log_level", None),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_config_from_airflow() -> LiteConfig:
|
|
146
|
+
from airflow.configuration import conf
|
|
147
|
+
|
|
148
|
+
def get(key: str) -> Optional[str]:
|
|
149
|
+
return conf.get("datahub", key, fallback=None)
|
|
150
|
+
|
|
151
|
+
return load_config(get)
|