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.
Files changed (55) hide show
  1. {airflow_toolkit-2.1.0/src/airflow_toolkit.egg-info → airflow_toolkit-2.3.0}/PKG-INFO +171 -50
  2. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/README.md +164 -46
  3. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/pyproject.toml +9 -5
  4. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/compression_utils.py +2 -2
  5. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/__init__.py +134 -0
  6. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/discord.py +57 -0
  7. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/email.py +93 -0
  8. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/slack.py +78 -0
  9. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels/teams.py +76 -0
  10. airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/context.py +94 -0
  11. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/operators/filesystem_to_database.py +90 -6
  12. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/operators/http_to_filesystem.py +7 -35
  13. airflow_toolkit-2.3.0/src/airflow_toolkit/py.typed +0 -0
  14. airflow_toolkit-2.3.0/src/airflow_toolkit/testing.py +59 -0
  15. airflow_toolkit-2.3.0/src/airflow_toolkit/types.py +51 -0
  16. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0/src/airflow_toolkit.egg-info}/PKG-INFO +171 -50
  17. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/SOURCES.txt +9 -1
  18. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/requires.txt +8 -4
  19. airflow_toolkit-2.1.0/src/airflow_toolkit/notifications/slack/webhook.py +0 -114
  20. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/LICENSE.txt +0 -0
  21. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/setup.cfg +0 -0
  22. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/__init__.py +0 -0
  23. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/_compact/airflow_shim.py +0 -0
  24. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/exceptions.py +0 -0
  25. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/__init__.py +0 -0
  26. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/filesystem_factory.py +0 -0
  27. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/filesystem_protocol.py +0 -0
  28. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/__init__.py +0 -0
  29. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/azure_databricks_volume_filesystem.py +0 -0
  30. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/azure_file_share_filesystem.py +0 -0
  31. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/blob_storage_filesystem.py +0 -0
  32. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/google_cloud_storage_filesystem.py +0 -0
  33. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/local_filesystem.py +0 -0
  34. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/s3_filesystem.py +0 -0
  35. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/filesystems/impl/sftp_filesystem.py +0 -0
  36. {airflow_toolkit-2.1.0/src/airflow_toolkit/providers → airflow_toolkit-2.3.0/src/airflow_toolkit/notifications/channels}/__init__.py +0 -0
  37. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/protocols.py +0 -0
  38. {airflow_toolkit-2.1.0/src/airflow_toolkit/providers/azure → airflow_toolkit-2.3.0/src/airflow_toolkit/providers}/__init__.py +0 -0
  39. {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
  40. {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
  41. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/azure/hooks/azure_databricks.py +0 -0
  42. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/azure/hooks/azure_file_share.py +0 -0
  43. {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
  44. {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
  45. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/operators/duckdb_to_deltalake.py +0 -0
  46. {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
  47. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/deltalake/sensors/filesystem_file.py +0 -0
  48. {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
  49. /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
  50. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/operators/filesystem.py +0 -0
  51. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/filesystem/tasks.py +0 -0
  52. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit/providers/package.py +0 -0
  53. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/dependency_links.txt +0 -0
  54. {airflow_toolkit-2.1.0 → airflow_toolkit-2.3.0}/src/airflow_toolkit.egg-info/entry_points.txt +0 -0
  55. {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.1.0
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 (CSV, JSON, or Parquet) 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`.
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
- ### Slack (incoming webhook)
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
- Send DAG or task failure alerts to a Slack channel using `dag_failure_slack_notification_webhook`. Requires a Slack App with Incoming Webhooks enabled.
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
- Create an Airflow connection named `SLACK_WEBHOOK_NOTIFICATION_CONN` (or set `AIRFLOW_CONN_SLACK_WEBHOOK_NOTIFICATION_CONN`).
498
+ ### Pattern A `on_failure_callback`
445
499
 
446
- #### DAG-level notification
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 datetime import datetime, timedelta
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=timedelta(days=1),
457
- start_date=datetime(2024, 1, 1),
458
- catchup=False,
459
- on_failure_callback=dag_failure_slack_notification_webhook(),
460
- ) as dag:
461
-
462
- t = BashOperator(task_id='run', bash_command='python my_script.py')
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
- #### Task-level notification
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
- BashOperator(
469
- task_id='run',
470
- bash_command='python my_script.py',
471
- on_failure_callback=dag_failure_slack_notification_webhook(source='TASK'),
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
- #### Custom message
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
- on_failure_callback=dag_failure_slack_notification_webhook(
479
- text='Pipeline {{ dag.dag_id }} failed on {{ ds }}',
480
- include_blocks=False,
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
- #### Custom Slack blocks
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
- on_failure_callback=dag_failure_slack_notification_webhook(
488
- blocks={
489
- 'type': 'section',
490
- 'text': {'type': 'mrkdwn', 'text': '*Pipeline failed* — check the logs.'},
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
- Default notification format:
602
+ # Or write files programmatically
603
+ fs.write(b"id,amount\n3,300", "raw/orders/2024-01-02/data.csv")
496
604
 
497
- ![image](https://github.com/DeepKernelLabs/airflow-toolkit/assets/152852247/52a5bf95-21bc-4c3b-8093-79953c0c5d61)
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
- **Parameters:**
611
+ Use it to patch `FilesystemFactory.get_data_lake_filesystem` in your operator tests:
500
612
 
501
- | Parameter | Type | Description |
502
- |---|---|---|
503
- | `text` | `str` (optional) | Plain-text message. Overrides blocks if provided. |
504
- | `blocks` | `dict` (optional) | Custom Slack Block Kit payload. |
505
- | `include_blocks` | `bool` (optional) | Whether to include the default formatted block. |
506
- | `source` | `'DAG'` \| `'TASK'` (optional) | Source of the failure. Default: `'DAG'`. |
507
- | `image_url` | `str` (optional) | Accessory image URL. Can also be set via `AIRFLOW_TOOLKIT__SLACK_NOTIFICATION_IMG_URL`. |
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 (CSV, JSON, or Parquet) 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`.
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
- ### Slack (incoming webhook)
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
- Send DAG or task failure alerts to a Slack channel using `dag_failure_slack_notification_webhook`. Requires a Slack App with Incoming Webhooks enabled.
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
- Create an Airflow connection named `SLACK_WEBHOOK_NOTIFICATION_CONN` (or set `AIRFLOW_CONN_SLACK_WEBHOOK_NOTIFICATION_CONN`).
439
+ ### Pattern A `on_failure_callback`
389
440
 
390
- #### DAG-level notification
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 datetime import datetime, timedelta
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=timedelta(days=1),
401
- start_date=datetime(2024, 1, 1),
402
- catchup=False,
403
- on_failure_callback=dag_failure_slack_notification_webhook(),
404
- ) as dag:
405
-
406
- t = BashOperator(task_id='run', bash_command='python my_script.py')
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
- #### Task-level notification
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
- BashOperator(
413
- task_id='run',
414
- bash_command='python my_script.py',
415
- on_failure_callback=dag_failure_slack_notification_webhook(source='TASK'),
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
- #### Custom message
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
- on_failure_callback=dag_failure_slack_notification_webhook(
423
- text='Pipeline {{ dag.dag_id }} failed on {{ ds }}',
424
- include_blocks=False,
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
- #### Custom Slack blocks
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
- on_failure_callback=dag_failure_slack_notification_webhook(
432
- blocks={
433
- 'type': 'section',
434
- 'text': {'type': 'mrkdwn', 'text': '*Pipeline failed* — check the logs.'},
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
- Default notification format:
543
+ # Or write files programmatically
544
+ fs.write(b"id,amount\n3,300", "raw/orders/2024-01-02/data.csv")
440
545
 
441
- ![image](https://github.com/DeepKernelLabs/airflow-toolkit/assets/152852247/52a5bf95-21bc-4c3b-8093-79953c0c5d61)
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
- **Parameters:**
552
+ Use it to patch `FilesystemFactory.get_data_lake_filesystem` in your operator tests:
444
553
 
445
- | Parameter | Type | Description |
446
- |---|---|---|
447
- | `text` | `str` (optional) | Plain-text message. Overrides blocks if provided. |
448
- | `blocks` | `dict` (optional) | Custom Slack Block Kit payload. |
449
- | `include_blocks` | `bool` (optional) | Whether to include the default formatted block. |
450
- | `source` | `'DAG'` \| `'TASK'` (optional) | Source of the failure. Default: `'DAG'`. |
451
- | `image_url` | `str` (optional) | Accessory image URL. Can also be set via `AIRFLOW_TOOLKIT__SLACK_NOTIFICATION_IMG_URL`. |
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.1.0"
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
- from typing import Literal, Union
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: