airflow-toolkit 2.1.0__tar.gz → 2.3.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.
- {airflow_toolkit-2.1.0/src/airflow_toolkit.egg-info → airflow_toolkit-2.3.0}/PKG-INFO +171 -50
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/README.md +164 -46
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/pyproject.toml +9 -5
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/compression_utils.py +2 -2
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/__init__.py +134 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/discord.py +57 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/email.py +93 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/slack.py +78 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/teams.py +76 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/context.py +94 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/operators/filesystem_to_database.py +90 -6
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/operators/http_to_filesystem.py +7 -35
- airflow_toolkit-2.3.0/src/airflow_toolkit/py.typed +0 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/testing.py +59 -0
- airflow_toolkit-2.3.0/src/airflow_toolkit/types.py +51 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0/src/airflow_toolkit.egg-info}/PKG-INFO +171 -50
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/SOURCES.txt +9 -1
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/requires.txt +8 -4
- airflow_toolkit-2.1.0/src/airflow_toolkit/notifications/slack/webhook.py +0 -114
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/LICENSE.txt +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/setup.cfg +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/_compact/airflow_shim.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/exceptions.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/filesystem_factory.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/filesystem_protocol.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/azure_databricks_volume_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/azure_file_share_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/blob_storage_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/google_cloud_storage_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/local_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/s3_filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/sftp_filesystem.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers → airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels}/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/protocols.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/azure → airflow_toolkit-2.3.0/src/airflow_toolkit/providers}/__init__.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/azure/hooks → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/azure}/__init__.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/deltalake → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/azure/hooks}/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/azure/hooks/azure_databricks.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/azure/hooks/azure_file_share.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/deltalake/operators → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/deltalake}/__init__.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/deltalake/sensors → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/deltalake/operators}/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/operators/duckdb_to_deltalake.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/filesystem → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/deltalake/sensors}/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/sensors/filesystem_file.py +0 -0
- {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/filesystem/operators → airflow_toolkit-2.3.0/src/airflow_toolkit/providers/filesystem}/__init__.py +0 -0
- /airflow_toolkit-2.1.0/src/airflow_toolkit/py.typed → /airflow_toolkit-2.3.0/src/airflow_toolkit/providers/filesystem/operators/__init__.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/operators/filesystem.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/tasks.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/package.py +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/dependency_links.txt +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/entry_points.txt +0 -0
- {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: airflow-toolkit
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: A toolkit of operators, hooks and utilities for Apache Airflow 3
|
|
5
5
|
Author-email: Biel Llobera <biel_llobera@dkl.digital>
|
|
6
6
|
Requires-Python: <3.15,>=3.11
|
|
@@ -23,8 +23,6 @@ Provides-Extra: azure
|
|
|
23
23
|
Requires-Dist: apache-airflow-providers-microsoft-azure>=8; extra == "azure"
|
|
24
24
|
Provides-Extra: sftp
|
|
25
25
|
Requires-Dist: apache-airflow-providers-sftp>=5.3; extra == "sftp"
|
|
26
|
-
Provides-Extra: slack
|
|
27
|
-
Requires-Dist: apache-airflow-providers-slack>=9.3; extra == "slack"
|
|
28
26
|
Provides-Extra: http
|
|
29
27
|
Requires-Dist: apache-airflow-providers-http>=5.6.4; extra == "http"
|
|
30
28
|
Requires-Dist: requests>=2.31.0; extra == "http"
|
|
@@ -34,6 +32,10 @@ Provides-Extra: duckdb
|
|
|
34
32
|
Requires-Dist: airflow-provider-duckdb>=0.1.2; extra == "duckdb"
|
|
35
33
|
Provides-Extra: sqlite
|
|
36
34
|
Requires-Dist: apache-airflow-providers-sqlite; extra == "sqlite"
|
|
35
|
+
Provides-Extra: excel
|
|
36
|
+
Requires-Dist: openpyxl>=3.1; extra == "excel"
|
|
37
|
+
Provides-Extra: avro
|
|
38
|
+
Requires-Dist: fastavro>=1.9; extra == "avro"
|
|
37
39
|
Provides-Extra: airflow3-full
|
|
38
40
|
Requires-Dist: apache-airflow<4,>=3; extra == "airflow3-full"
|
|
39
41
|
Requires-Dist: apache-airflow-providers-fab>=3.0.0; extra == "airflow3-full"
|
|
@@ -46,12 +48,13 @@ Requires-Dist: apache-airflow-providers-amazon>=9.15.0; extra == "airflow3-full"
|
|
|
46
48
|
Requires-Dist: apache-airflow-providers-google>=18; extra == "airflow3-full"
|
|
47
49
|
Requires-Dist: apache-airflow-providers-microsoft-azure>=8; extra == "airflow3-full"
|
|
48
50
|
Requires-Dist: apache-airflow-providers-sftp>=5.3; extra == "airflow3-full"
|
|
49
|
-
Requires-Dist: apache-airflow-providers-slack>=9.3; extra == "airflow3-full"
|
|
50
51
|
Requires-Dist: apache-airflow-providers-http>=5.6.4; extra == "airflow3-full"
|
|
51
52
|
Requires-Dist: requests>=2.31.0; extra == "airflow3-full"
|
|
52
53
|
Requires-Dist: jmespath<2,>=1.0.1; extra == "airflow3-full"
|
|
53
54
|
Requires-Dist: airflow-provider-duckdb>=0.1.2; extra == "airflow3-full"
|
|
54
55
|
Requires-Dist: apache-airflow-providers-sqlite; extra == "airflow3-full"
|
|
56
|
+
Requires-Dist: openpyxl>=3.1; extra == "airflow3-full"
|
|
57
|
+
Requires-Dist: fastavro>=1.9; extra == "airflow3-full"
|
|
55
58
|
Dynamic: license-file
|
|
56
59
|
|
|
57
60
|
# Airflow Toolkit
|
|
@@ -139,10 +142,11 @@ pip install "airflow-toolkit[airflow3-full]"
|
|
|
139
142
|
| `google` | `providers-google` | GCS filesystem backend |
|
|
140
143
|
| `azure` | `providers-microsoft-azure` | Azure Blob / ADLS filesystem backend |
|
|
141
144
|
| `sftp` | `providers-sftp` | SFTP filesystem backend |
|
|
142
|
-
| `slack` | `providers-slack` | Slack failure notifications |
|
|
143
145
|
| `http` | `providers-http`, `requests`, `jmespath`, `pandas` | `HttpToFilesystem`, `MultiHttpToFilesystem` |
|
|
144
146
|
| `duckdb` | `airflow-provider-duckdb` | `DuckdbToDeltalake` operator |
|
|
145
147
|
| `sqlite` | `providers-sqlite` | SQLite as source or destination |
|
|
148
|
+
| `excel` | `openpyxl` | Excel (`.xlsx` / `.xls`) support in `FilesystemToDatabase` |
|
|
149
|
+
| `avro` | `fastavro` | Avro support in `FilesystemToDatabase` |
|
|
146
150
|
| `airflow3-full` | all of the above | Quick start / development |
|
|
147
151
|
|
|
148
152
|
---
|
|
@@ -316,7 +320,9 @@ FilesystemToFilesystem(
|
|
|
316
320
|
|
|
317
321
|
### FilesystemToDatabase
|
|
318
322
|
|
|
319
|
-
Reads files
|
|
323
|
+
Reads files from any filesystem and loads them into any SQLAlchemy-compatible database. Handles schema drift automatically: columns present in the file but missing from the table are added; columns present in the table but missing from the file are filled with `NULL`.
|
|
324
|
+
|
|
325
|
+
**Supported formats:** `csv`, `json`, `parquet`, `excel`, `avro`, `fixed_width`.
|
|
320
326
|
|
|
321
327
|
```python
|
|
322
328
|
from airflow_toolkit.providers.deltalake.operators.filesystem_to_database import FilesystemToDatabaseOperator
|
|
@@ -328,7 +334,7 @@ FilesystemToDatabaseOperator(
|
|
|
328
334
|
filesystem_path='raw/orders/{{ ds }}/',
|
|
329
335
|
db_schema='public',
|
|
330
336
|
db_table='orders',
|
|
331
|
-
source_format='csv',
|
|
337
|
+
source_format='csv', # 'csv' | 'json' | 'parquet' | 'excel' | 'avro' | 'fixed_width'
|
|
332
338
|
table_aggregation_type='append', # 'append' | 'replace' | 'fail'
|
|
333
339
|
metadata={
|
|
334
340
|
'_ds': '{{ ds }}',
|
|
@@ -338,6 +344,52 @@ FilesystemToDatabaseOperator(
|
|
|
338
344
|
)
|
|
339
345
|
```
|
|
340
346
|
|
|
347
|
+
**Excel** (requires the `[excel]` extra):
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
FilesystemToDatabaseOperator(
|
|
351
|
+
task_id='load_excel_report',
|
|
352
|
+
filesystem_conn_id='my_data_lake',
|
|
353
|
+
database_conn_id='my_postgres',
|
|
354
|
+
filesystem_path='raw/reports/{{ ds }}/',
|
|
355
|
+
db_table='monthly_report',
|
|
356
|
+
source_format='excel',
|
|
357
|
+
source_format_options={'sheet_name': 'Data'},
|
|
358
|
+
)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Avro** (requires the `[avro]` extra):
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
FilesystemToDatabaseOperator(
|
|
365
|
+
task_id='load_avro_events',
|
|
366
|
+
filesystem_conn_id='my_data_lake',
|
|
367
|
+
database_conn_id='my_postgres',
|
|
368
|
+
filesystem_path='raw/events/{{ ds }}/',
|
|
369
|
+
db_table='events',
|
|
370
|
+
source_format='avro',
|
|
371
|
+
)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Fixed-width** (no extra required — pandas native):
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
FilesystemToDatabaseOperator(
|
|
378
|
+
task_id='load_fixed_width',
|
|
379
|
+
filesystem_conn_id='my_data_lake',
|
|
380
|
+
database_conn_id='my_postgres',
|
|
381
|
+
filesystem_path='raw/exports/{{ ds }}/',
|
|
382
|
+
db_table='transactions',
|
|
383
|
+
source_format='fixed_width',
|
|
384
|
+
source_format_options={
|
|
385
|
+
'colspecs': [(0, 10), (10, 25), (25, 35)],
|
|
386
|
+
'names': ['date', 'description', 'amount'],
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Each format is matched by file extension: `.csv`/`.csv.gz`, `.json`/`.json.gz`, `.parquet`/`.parquet.gz`, `.xlsx`/`.xls`, `.avro`, `.fwf`/`.txt`/`.dat`. Files with other extensions in the same prefix are silently skipped.
|
|
392
|
+
|
|
341
393
|
### DuckdbToDeltalake
|
|
342
394
|
|
|
343
395
|
Executes a DuckDB SQL query and writes the result directly to a Delta Lake table on Azure storage. Useful for in-process transformations that land results as an open table format.
|
|
@@ -437,74 +489,143 @@ Because `AzureDatabricksSqlHook` implements `DbApiHook`, it can be used as `sour
|
|
|
437
489
|
|
|
438
490
|
## Notifications
|
|
439
491
|
|
|
440
|
-
|
|
492
|
+
Send rich failure notifications to Slack, email, Microsoft Teams, and Discord from a single call. The notification system is built around three ideas:
|
|
441
493
|
|
|
442
|
-
|
|
494
|
+
1. **Context builder** — extracts DAG run metadata (run ID, logical date, schedule, interval, duration, environment) from the Airflow callback context once, and makes it available to all channels.
|
|
495
|
+
2. **Channel formatters** — each channel (Slack Block Kit, HTML email, Teams Adaptive Card, Discord embed) formats the same context into the right payload for that platform.
|
|
496
|
+
3. **Two usage patterns** — as an `on_failure_callback` (invisible to the graph) or as an explicit `@task` node in the DAG graph.
|
|
443
497
|
|
|
444
|
-
|
|
498
|
+
### Pattern A — `on_failure_callback`
|
|
445
499
|
|
|
446
|
-
|
|
500
|
+
The callback fires automatically when any task in the DAG fails. Nothing appears in the task graph.
|
|
447
501
|
|
|
448
502
|
```python
|
|
449
|
-
from
|
|
450
|
-
from airflow.sdk import DAG
|
|
451
|
-
from airflow.providers.standard.operators.bash import BashOperator
|
|
452
|
-
from airflow_toolkit.notifications.slack.webhook import dag_failure_slack_notification_webhook
|
|
503
|
+
from airflow_toolkit.notifications import dag_failure_notification
|
|
453
504
|
|
|
454
505
|
with DAG(
|
|
455
506
|
'my_pipeline',
|
|
456
|
-
schedule=
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
507
|
+
schedule='0 6 * * *',
|
|
508
|
+
on_failure_callback=dag_failure_notification(
|
|
509
|
+
channels=['slack', 'email'],
|
|
510
|
+
environment='PROD',
|
|
511
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
512
|
+
email_to=['data-team@example.com'],
|
|
513
|
+
),
|
|
514
|
+
):
|
|
515
|
+
...
|
|
463
516
|
```
|
|
464
517
|
|
|
465
|
-
|
|
518
|
+
### Pattern B — explicit task in the graph
|
|
519
|
+
|
|
520
|
+
`get_failure_notification_task` returns an Airflow task with `trigger_rule='one_failed'`. It fires when any upstream task fails and is **skipped** when all tasks succeed. The notification step is visible in the Airflow UI, has its own logs, and appears in the task history.
|
|
466
521
|
|
|
467
522
|
```python
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
523
|
+
from airflow_toolkit.notifications import get_failure_notification_task
|
|
524
|
+
|
|
525
|
+
with DAG('my_pipeline', schedule='0 6 * * *'):
|
|
526
|
+
extract = ...
|
|
527
|
+
load = ...
|
|
528
|
+
|
|
529
|
+
notify = get_failure_notification_task(
|
|
530
|
+
channels=['slack', 'email'],
|
|
531
|
+
environment='PROD',
|
|
532
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
533
|
+
email_to=['data-team@example.com'],
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
[extract, load] >> notify
|
|
473
537
|
```
|
|
474
538
|
|
|
475
|
-
|
|
539
|
+
### Supported channels
|
|
540
|
+
|
|
541
|
+
| Channel | Parameter | Requires |
|
|
542
|
+
|---|---|---|
|
|
543
|
+
| `slack` | `slack_webhook_url` | — |
|
|
544
|
+
| `email` | `email_to: list[str]`, `email_from` (optional) | Airflow SMTP configured |
|
|
545
|
+
| `teams` | `teams_webhook_url` | — |
|
|
546
|
+
| `discord` | `discord_webhook_url` | — |
|
|
547
|
+
|
|
548
|
+
Any combination of channels can be used in a single call. Channels are delivered sequentially in the order listed.
|
|
549
|
+
|
|
550
|
+
### All parameters
|
|
476
551
|
|
|
477
552
|
```python
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
553
|
+
dag_failure_notification(
|
|
554
|
+
channels=['slack', 'email', 'teams', 'discord'],
|
|
555
|
+
|
|
556
|
+
# Environment label shown in every notification (DEV / STG / PROD)
|
|
557
|
+
environment='PROD',
|
|
558
|
+
|
|
559
|
+
# Slack
|
|
560
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
561
|
+
|
|
562
|
+
# Email
|
|
563
|
+
email_to=['ops@example.com'],
|
|
564
|
+
email_from=None, # uses Airflow SMTP default if omitted
|
|
565
|
+
|
|
566
|
+
# Teams
|
|
567
|
+
teams_webhook_url='https://outlook.office.com/webhook/...',
|
|
568
|
+
|
|
569
|
+
# Discord
|
|
570
|
+
discord_webhook_url='https://discord.com/api/webhooks/...',
|
|
481
571
|
)
|
|
482
572
|
```
|
|
483
573
|
|
|
484
|
-
|
|
574
|
+
`get_failure_notification_task` accepts the same parameters.
|
|
575
|
+
|
|
576
|
+
### Environment colours
|
|
577
|
+
|
|
578
|
+
Each environment maps to a distinct colour across all channels so alerts are recognisable at a glance:
|
|
579
|
+
|
|
580
|
+
| Environment | Slack | Teams | Discord |
|
|
581
|
+
|---|---|---|---|
|
|
582
|
+
| `PROD` | 🔴 red | Attention (red) | #ED4245 |
|
|
583
|
+
| `STG` | 🟡 yellow | Warning (orange) | #FF8C00 |
|
|
584
|
+
| `DEV` | 🟢 green | Good (green) | #57F287 |
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Testing Utilities
|
|
589
|
+
|
|
590
|
+
### MockFilesystem
|
|
591
|
+
|
|
592
|
+
`MockFilesystem` is an in-memory implementation of `FilesystemProtocol` for unit testing. It requires no Docker, no cloud credentials, and no network — all files are stored in a plain Python dict.
|
|
485
593
|
|
|
486
594
|
```python
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
)
|
|
493
|
-
```
|
|
595
|
+
from airflow_toolkit.testing import MockFilesystem
|
|
596
|
+
|
|
597
|
+
# Pre-load files at construction time
|
|
598
|
+
fs = MockFilesystem({
|
|
599
|
+
"raw/orders/2024-01-01/data.csv": b"id,amount\n1,100\n2,200",
|
|
600
|
+
})
|
|
494
601
|
|
|
495
|
-
|
|
602
|
+
# Or write files programmatically
|
|
603
|
+
fs.write(b"id,amount\n3,300", "raw/orders/2024-01-02/data.csv")
|
|
496
604
|
|
|
497
|
-
|
|
605
|
+
# Inspect the result in assertions
|
|
606
|
+
assert fs.check_file("raw/orders/2024-01-01/data.csv")
|
|
607
|
+
assert len(fs.list_files("raw/orders/")) == 2
|
|
608
|
+
assert fs.files["raw/orders/2024-01-01/data.csv"] == b"id,amount\n1,100\n2,200"
|
|
609
|
+
```
|
|
498
610
|
|
|
499
|
-
|
|
611
|
+
Use it to patch `FilesystemFactory.get_data_lake_filesystem` in your operator tests:
|
|
500
612
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
613
|
+
```python
|
|
614
|
+
from unittest.mock import patch
|
|
615
|
+
from airflow_toolkit.testing import MockFilesystem
|
|
616
|
+
|
|
617
|
+
def test_my_pipeline(tmp_path):
|
|
618
|
+
fs = MockFilesystem({"data/file.csv": b"id,name\n1,Alice"})
|
|
619
|
+
|
|
620
|
+
with patch(
|
|
621
|
+
"airflow_toolkit.filesystems.filesystem_factory.FilesystemFactory.get_data_lake_filesystem",
|
|
622
|
+
return_value=fs,
|
|
623
|
+
):
|
|
624
|
+
# run your operator or task here
|
|
625
|
+
...
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
`MockFilesystem` implements the full `FilesystemProtocol`: `read`, `write`, `delete_file`, `create_prefix`, `delete_prefix`, `check_file`, `check_prefix`, `list_files`.
|
|
508
629
|
|
|
509
630
|
---
|
|
510
631
|
|
|
@@ -83,10 +83,11 @@ pip install "airflow-toolkit[airflow3-full]"
|
|
|
83
83
|
| `google` | `providers-google` | GCS filesystem backend |
|
|
84
84
|
| `azure` | `providers-microsoft-azure` | Azure Blob / ADLS filesystem backend |
|
|
85
85
|
| `sftp` | `providers-sftp` | SFTP filesystem backend |
|
|
86
|
-
| `slack` | `providers-slack` | Slack failure notifications |
|
|
87
86
|
| `http` | `providers-http`, `requests`, `jmespath`, `pandas` | `HttpToFilesystem`, `MultiHttpToFilesystem` |
|
|
88
87
|
| `duckdb` | `airflow-provider-duckdb` | `DuckdbToDeltalake` operator |
|
|
89
88
|
| `sqlite` | `providers-sqlite` | SQLite as source or destination |
|
|
89
|
+
| `excel` | `openpyxl` | Excel (`.xlsx` / `.xls`) support in `FilesystemToDatabase` |
|
|
90
|
+
| `avro` | `fastavro` | Avro support in `FilesystemToDatabase` |
|
|
90
91
|
| `airflow3-full` | all of the above | Quick start / development |
|
|
91
92
|
|
|
92
93
|
---
|
|
@@ -260,7 +261,9 @@ FilesystemToFilesystem(
|
|
|
260
261
|
|
|
261
262
|
### FilesystemToDatabase
|
|
262
263
|
|
|
263
|
-
Reads files
|
|
264
|
+
Reads files from any filesystem and loads them into any SQLAlchemy-compatible database. Handles schema drift automatically: columns present in the file but missing from the table are added; columns present in the table but missing from the file are filled with `NULL`.
|
|
265
|
+
|
|
266
|
+
**Supported formats:** `csv`, `json`, `parquet`, `excel`, `avro`, `fixed_width`.
|
|
264
267
|
|
|
265
268
|
```python
|
|
266
269
|
from airflow_toolkit.providers.deltalake.operators.filesystem_to_database import FilesystemToDatabaseOperator
|
|
@@ -272,7 +275,7 @@ FilesystemToDatabaseOperator(
|
|
|
272
275
|
filesystem_path='raw/orders/{{ ds }}/',
|
|
273
276
|
db_schema='public',
|
|
274
277
|
db_table='orders',
|
|
275
|
-
source_format='csv',
|
|
278
|
+
source_format='csv', # 'csv' | 'json' | 'parquet' | 'excel' | 'avro' | 'fixed_width'
|
|
276
279
|
table_aggregation_type='append', # 'append' | 'replace' | 'fail'
|
|
277
280
|
metadata={
|
|
278
281
|
'_ds': '{{ ds }}',
|
|
@@ -282,6 +285,52 @@ FilesystemToDatabaseOperator(
|
|
|
282
285
|
)
|
|
283
286
|
```
|
|
284
287
|
|
|
288
|
+
**Excel** (requires the `[excel]` extra):
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
FilesystemToDatabaseOperator(
|
|
292
|
+
task_id='load_excel_report',
|
|
293
|
+
filesystem_conn_id='my_data_lake',
|
|
294
|
+
database_conn_id='my_postgres',
|
|
295
|
+
filesystem_path='raw/reports/{{ ds }}/',
|
|
296
|
+
db_table='monthly_report',
|
|
297
|
+
source_format='excel',
|
|
298
|
+
source_format_options={'sheet_name': 'Data'},
|
|
299
|
+
)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Avro** (requires the `[avro]` extra):
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
FilesystemToDatabaseOperator(
|
|
306
|
+
task_id='load_avro_events',
|
|
307
|
+
filesystem_conn_id='my_data_lake',
|
|
308
|
+
database_conn_id='my_postgres',
|
|
309
|
+
filesystem_path='raw/events/{{ ds }}/',
|
|
310
|
+
db_table='events',
|
|
311
|
+
source_format='avro',
|
|
312
|
+
)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Fixed-width** (no extra required — pandas native):
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
FilesystemToDatabaseOperator(
|
|
319
|
+
task_id='load_fixed_width',
|
|
320
|
+
filesystem_conn_id='my_data_lake',
|
|
321
|
+
database_conn_id='my_postgres',
|
|
322
|
+
filesystem_path='raw/exports/{{ ds }}/',
|
|
323
|
+
db_table='transactions',
|
|
324
|
+
source_format='fixed_width',
|
|
325
|
+
source_format_options={
|
|
326
|
+
'colspecs': [(0, 10), (10, 25), (25, 35)],
|
|
327
|
+
'names': ['date', 'description', 'amount'],
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Each format is matched by file extension: `.csv`/`.csv.gz`, `.json`/`.json.gz`, `.parquet`/`.parquet.gz`, `.xlsx`/`.xls`, `.avro`, `.fwf`/`.txt`/`.dat`. Files with other extensions in the same prefix are silently skipped.
|
|
333
|
+
|
|
285
334
|
### DuckdbToDeltalake
|
|
286
335
|
|
|
287
336
|
Executes a DuckDB SQL query and writes the result directly to a Delta Lake table on Azure storage. Useful for in-process transformations that land results as an open table format.
|
|
@@ -381,74 +430,143 @@ Because `AzureDatabricksSqlHook` implements `DbApiHook`, it can be used as `sour
|
|
|
381
430
|
|
|
382
431
|
## Notifications
|
|
383
432
|
|
|
384
|
-
|
|
433
|
+
Send rich failure notifications to Slack, email, Microsoft Teams, and Discord from a single call. The notification system is built around three ideas:
|
|
385
434
|
|
|
386
|
-
|
|
435
|
+
1. **Context builder** — extracts DAG run metadata (run ID, logical date, schedule, interval, duration, environment) from the Airflow callback context once, and makes it available to all channels.
|
|
436
|
+
2. **Channel formatters** — each channel (Slack Block Kit, HTML email, Teams Adaptive Card, Discord embed) formats the same context into the right payload for that platform.
|
|
437
|
+
3. **Two usage patterns** — as an `on_failure_callback` (invisible to the graph) or as an explicit `@task` node in the DAG graph.
|
|
387
438
|
|
|
388
|
-
|
|
439
|
+
### Pattern A — `on_failure_callback`
|
|
389
440
|
|
|
390
|
-
|
|
441
|
+
The callback fires automatically when any task in the DAG fails. Nothing appears in the task graph.
|
|
391
442
|
|
|
392
443
|
```python
|
|
393
|
-
from
|
|
394
|
-
from airflow.sdk import DAG
|
|
395
|
-
from airflow.providers.standard.operators.bash import BashOperator
|
|
396
|
-
from airflow_toolkit.notifications.slack.webhook import dag_failure_slack_notification_webhook
|
|
444
|
+
from airflow_toolkit.notifications import dag_failure_notification
|
|
397
445
|
|
|
398
446
|
with DAG(
|
|
399
447
|
'my_pipeline',
|
|
400
|
-
schedule=
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
448
|
+
schedule='0 6 * * *',
|
|
449
|
+
on_failure_callback=dag_failure_notification(
|
|
450
|
+
channels=['slack', 'email'],
|
|
451
|
+
environment='PROD',
|
|
452
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
453
|
+
email_to=['data-team@example.com'],
|
|
454
|
+
),
|
|
455
|
+
):
|
|
456
|
+
...
|
|
407
457
|
```
|
|
408
458
|
|
|
409
|
-
|
|
459
|
+
### Pattern B — explicit task in the graph
|
|
460
|
+
|
|
461
|
+
`get_failure_notification_task` returns an Airflow task with `trigger_rule='one_failed'`. It fires when any upstream task fails and is **skipped** when all tasks succeed. The notification step is visible in the Airflow UI, has its own logs, and appears in the task history.
|
|
410
462
|
|
|
411
463
|
```python
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
464
|
+
from airflow_toolkit.notifications import get_failure_notification_task
|
|
465
|
+
|
|
466
|
+
with DAG('my_pipeline', schedule='0 6 * * *'):
|
|
467
|
+
extract = ...
|
|
468
|
+
load = ...
|
|
469
|
+
|
|
470
|
+
notify = get_failure_notification_task(
|
|
471
|
+
channels=['slack', 'email'],
|
|
472
|
+
environment='PROD',
|
|
473
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
474
|
+
email_to=['data-team@example.com'],
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
[extract, load] >> notify
|
|
417
478
|
```
|
|
418
479
|
|
|
419
|
-
|
|
480
|
+
### Supported channels
|
|
481
|
+
|
|
482
|
+
| Channel | Parameter | Requires |
|
|
483
|
+
|---|---|---|
|
|
484
|
+
| `slack` | `slack_webhook_url` | — |
|
|
485
|
+
| `email` | `email_to: list[str]`, `email_from` (optional) | Airflow SMTP configured |
|
|
486
|
+
| `teams` | `teams_webhook_url` | — |
|
|
487
|
+
| `discord` | `discord_webhook_url` | — |
|
|
488
|
+
|
|
489
|
+
Any combination of channels can be used in a single call. Channels are delivered sequentially in the order listed.
|
|
490
|
+
|
|
491
|
+
### All parameters
|
|
420
492
|
|
|
421
493
|
```python
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
494
|
+
dag_failure_notification(
|
|
495
|
+
channels=['slack', 'email', 'teams', 'discord'],
|
|
496
|
+
|
|
497
|
+
# Environment label shown in every notification (DEV / STG / PROD)
|
|
498
|
+
environment='PROD',
|
|
499
|
+
|
|
500
|
+
# Slack
|
|
501
|
+
slack_webhook_url='https://hooks.slack.com/services/...',
|
|
502
|
+
|
|
503
|
+
# Email
|
|
504
|
+
email_to=['ops@example.com'],
|
|
505
|
+
email_from=None, # uses Airflow SMTP default if omitted
|
|
506
|
+
|
|
507
|
+
# Teams
|
|
508
|
+
teams_webhook_url='https://outlook.office.com/webhook/...',
|
|
509
|
+
|
|
510
|
+
# Discord
|
|
511
|
+
discord_webhook_url='https://discord.com/api/webhooks/...',
|
|
425
512
|
)
|
|
426
513
|
```
|
|
427
514
|
|
|
428
|
-
|
|
515
|
+
`get_failure_notification_task` accepts the same parameters.
|
|
516
|
+
|
|
517
|
+
### Environment colours
|
|
518
|
+
|
|
519
|
+
Each environment maps to a distinct colour across all channels so alerts are recognisable at a glance:
|
|
520
|
+
|
|
521
|
+
| Environment | Slack | Teams | Discord |
|
|
522
|
+
|---|---|---|---|
|
|
523
|
+
| `PROD` | 🔴 red | Attention (red) | #ED4245 |
|
|
524
|
+
| `STG` | 🟡 yellow | Warning (orange) | #FF8C00 |
|
|
525
|
+
| `DEV` | 🟢 green | Good (green) | #57F287 |
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Testing Utilities
|
|
530
|
+
|
|
531
|
+
### MockFilesystem
|
|
532
|
+
|
|
533
|
+
`MockFilesystem` is an in-memory implementation of `FilesystemProtocol` for unit testing. It requires no Docker, no cloud credentials, and no network — all files are stored in a plain Python dict.
|
|
429
534
|
|
|
430
535
|
```python
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
)
|
|
437
|
-
```
|
|
536
|
+
from airflow_toolkit.testing import MockFilesystem
|
|
537
|
+
|
|
538
|
+
# Pre-load files at construction time
|
|
539
|
+
fs = MockFilesystem({
|
|
540
|
+
"raw/orders/2024-01-01/data.csv": b"id,amount\n1,100\n2,200",
|
|
541
|
+
})
|
|
438
542
|
|
|
439
|
-
|
|
543
|
+
# Or write files programmatically
|
|
544
|
+
fs.write(b"id,amount\n3,300", "raw/orders/2024-01-02/data.csv")
|
|
440
545
|
|
|
441
|
-
|
|
546
|
+
# Inspect the result in assertions
|
|
547
|
+
assert fs.check_file("raw/orders/2024-01-01/data.csv")
|
|
548
|
+
assert len(fs.list_files("raw/orders/")) == 2
|
|
549
|
+
assert fs.files["raw/orders/2024-01-01/data.csv"] == b"id,amount\n1,100\n2,200"
|
|
550
|
+
```
|
|
442
551
|
|
|
443
|
-
|
|
552
|
+
Use it to patch `FilesystemFactory.get_data_lake_filesystem` in your operator tests:
|
|
444
553
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
554
|
+
```python
|
|
555
|
+
from unittest.mock import patch
|
|
556
|
+
from airflow_toolkit.testing import MockFilesystem
|
|
557
|
+
|
|
558
|
+
def test_my_pipeline(tmp_path):
|
|
559
|
+
fs = MockFilesystem({"data/file.csv": b"id,name\n1,Alice"})
|
|
560
|
+
|
|
561
|
+
with patch(
|
|
562
|
+
"airflow_toolkit.filesystems.filesystem_factory.FilesystemFactory.get_data_lake_filesystem",
|
|
563
|
+
return_value=fs,
|
|
564
|
+
):
|
|
565
|
+
# run your operator or task here
|
|
566
|
+
...
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
`MockFilesystem` implements the full `FilesystemProtocol`: `read`, `write`, `delete_file`, `create_prefix`, `delete_prefix`, `check_file`, `check_prefix`, `list_files`.
|
|
452
570
|
|
|
453
571
|
---
|
|
454
572
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "airflow-toolkit"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
description = "A toolkit of operators, hooks and utilities for Apache Airflow 3"
|
|
5
5
|
authors = [{ name = "Biel Llobera", email = "biel_llobera@dkl.digital" }]
|
|
6
6
|
requires-python = ">=3.11,<3.15"
|
|
@@ -37,9 +37,6 @@ azure = [
|
|
|
37
37
|
sftp = [
|
|
38
38
|
"apache-airflow-providers-sftp>=5.3",
|
|
39
39
|
]
|
|
40
|
-
slack = [
|
|
41
|
-
"apache-airflow-providers-slack>=9.3",
|
|
42
|
-
]
|
|
43
40
|
http = [
|
|
44
41
|
"apache-airflow-providers-http>=5.6.4",
|
|
45
42
|
"requests>=2.31.0",
|
|
@@ -52,6 +49,12 @@ duckdb = [
|
|
|
52
49
|
sqlite = [
|
|
53
50
|
"apache-airflow-providers-sqlite",
|
|
54
51
|
]
|
|
52
|
+
excel = [
|
|
53
|
+
"openpyxl>=3.1",
|
|
54
|
+
]
|
|
55
|
+
avro = [
|
|
56
|
+
"fastavro>=1.9",
|
|
57
|
+
]
|
|
55
58
|
airflow3-full = [
|
|
56
59
|
"apache-airflow>=3,<4",
|
|
57
60
|
"apache-airflow-providers-fab>=3.0.0",
|
|
@@ -64,12 +67,13 @@ airflow3-full = [
|
|
|
64
67
|
"apache-airflow-providers-google>=18",
|
|
65
68
|
"apache-airflow-providers-microsoft-azure>=8",
|
|
66
69
|
"apache-airflow-providers-sftp>=5.3",
|
|
67
|
-
"apache-airflow-providers-slack>=9.3",
|
|
68
70
|
"apache-airflow-providers-http>=5.6.4",
|
|
69
71
|
"requests>=2.31.0",
|
|
70
72
|
"jmespath>=1.0.1,<2",
|
|
71
73
|
"airflow-provider-duckdb>=0.1.2",
|
|
72
74
|
"apache-airflow-providers-sqlite",
|
|
75
|
+
"openpyxl>=3.1",
|
|
76
|
+
"fastavro>=1.9",
|
|
73
77
|
]
|
|
74
78
|
|
|
75
79
|
[dependency-groups]
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import gzip
|
|
2
2
|
import zipfile
|
|
3
3
|
from io import BytesIO
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
from airflow_toolkit.types import CompressionOptions
|
|
5
6
|
|
|
6
7
|
DEFAULT_ZIP_FILENAME = "file.zip"
|
|
7
|
-
CompressionOptions = Union[Literal["infer", "gzip", "bz2", "zip", "xz", "zstd"], None]
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def gzip_data(data: bytes) -> bytes:
|