diff --git a/.github/ISSUE_TEMPLATE/1-airflow_bug_report.yml b/.github/ISSUE_TEMPLATE/1-airflow_bug_report.yml index 724f5a0b87ce3..69472b80af075 100644 --- a/.github/ISSUE_TEMPLATE/1-airflow_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1-airflow_bug_report.yml @@ -152,6 +152,7 @@ body: - dingding - discord - docker + - dq - edge3 - elasticsearch - exasol diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml index 2848323162932..a6cd6f810651e 100644 --- a/.github/boring-cyborg.yml +++ b/.github/boring-cyborg.yml @@ -141,6 +141,9 @@ labelPRBasedOnFilePath: provider:docker: - providers/docker/** + provider:dq: + - providers/dq/** + provider:edge: - providers/edge3/** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f0337bb052b80..2f633aadb65da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -146,6 +146,7 @@ updates: cooldown: default-days: 4 directories: + - /providers/dq/src/airflow/providers/dq/plugins/www - /providers/edge3/src/airflow/providers/edge3/plugins/www schedule: interval: "weekly" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1a0de273842c..2efa503d5406a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -784,6 +784,7 @@ repos: ^airflow-core/src/airflow/ui/openapi-gen/| ^airflow-core/src/airflow/ui/pnpm-lock\.yaml$| ^providers/common/ai/src/airflow/providers/common/ai/plugins/www/pnpm-lock\.yaml$| + ^providers/dq/src/airflow/providers/dq/plugins/www/pnpm-lock\.yaml$| ^airflow-core/src/airflow/ui/public/i18n/locales/de/README\.md$| ^airflow-core/src/airflow/ui/src/i18n/config\.ts$| ^\.agents/skills/airflow-translations/| diff --git a/airflow-core/docs/extra-packages-ref.rst b/airflow-core/docs/extra-packages-ref.rst index c2bb02ba55dbd..eada3fe487dc1 100644 --- a/airflow-core/docs/extra-packages-ref.rst +++ b/airflow-core/docs/extra-packages-ref.rst @@ -267,6 +267,8 @@ These are extras that add dependencies needed for integration with external serv +---------------------+-----------------------------------------------------+-----------------------------------------------------+ | discord | ``pip install 'apache-airflow[discord]'`` | Discord hooks and sensors | +---------------------+-----------------------------------------------------+-----------------------------------------------------+ +| dq | ``pip install 'apache-airflow[dq]'`` | Data Quality rules and operators | ++---------------------+-----------------------------------------------------+-----------------------------------------------------+ | facebook | ``pip install 'apache-airflow[facebook]'`` | Facebook Social | +---------------------+-----------------------------------------------------+-----------------------------------------------------+ | github | ``pip install 'apache-airflow[github]'`` | GitHub operators and hook | diff --git a/airflow-core/tests/unit/always/test_project_structure.py b/airflow-core/tests/unit/always/test_project_structure.py index 750966a320221..392ab726648d8 100644 --- a/airflow-core/tests/unit/always/test_project_structure.py +++ b/airflow-core/tests/unit/always/test_project_structure.py @@ -107,6 +107,7 @@ def test_providers_modules_should_have_tests(self): "providers/common/compat/tests/unit/common/compat/standard/test_utils.py", "providers/common/messaging/tests/unit/common/messaging/providers/test_base_provider.py", "providers/common/messaging/tests/unit/common/messaging/providers/test_sqs.py", + "providers/dq/tests/unit/dq/test_exceptions.py", "providers/edge3/tests/unit/edge3/cli/test_example_extended_sysinfo.py", "providers/edge3/tests/unit/edge3/models/test_edge_job.py", "providers/edge3/tests/unit/edge3/models/test_edge_logs.py", diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py index 6bca49d8052ea..f6b1665a6687c 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py @@ -34,7 +34,7 @@ class TestGetPlugins: # Filters ( {}, - 18, + 19, [ "InformaticaProviderPlugin", "MetadataCollectionPlugin", @@ -42,6 +42,7 @@ class TestGetPlugins: "business_day_window_plugin", "databricks_workflow", "decreasing_priority_weight_strategy_plugin", + "dq", "edge_executor", "hitl_review", "hive", @@ -58,14 +59,14 @@ class TestGetPlugins: ), ( {"limit": 3, "offset": 3}, - 18, + 19, [ "business_day_window_plugin", "databricks_workflow", "decreasing_priority_weight_strategy_plugin", ], ), - ({"limit": 1}, 18, ["InformaticaProviderPlugin"]), + ({"limit": 1}, 19, ["InformaticaProviderPlugin"]), ], ) def test_should_respond_200( @@ -168,7 +169,7 @@ def test_invalid_external_view_destination_should_log_warning_and_continue(self, assert len(plugins_page) == 7 assert "test_plugin_invalid" not in [p["name"] for p in plugins_page] - assert body["total_entries"] == 18 + assert body["total_entries"] == 19 @skip_if_force_lowest_dependencies_marker diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py b/airflow-core/tests/unit/plugins/test_plugins_manager.py index ce55db1e269ea..0dcac96fe0a32 100644 --- a/airflow-core/tests/unit/plugins/test_plugins_manager.py +++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py @@ -408,7 +408,7 @@ def test_does_not_double_import_entrypoint_provider_plugins(self): # Mock/skip loading from plugin dir with mock.patch("airflow.plugins_manager._load_plugins_from_plugin_directory", return_value=([], [])): plugins = plugins_manager._get_plugins()[0] - assert len(plugins) == 6 + assert len(plugins) == 7 class TestWindowPluginRegistration: diff --git a/dev/breeze/doc/images/output_build-docs.svg b/dev/breeze/doc/images/output_build-docs.svg index ca5cb1775bcc0..47d8d7f665a8a 100644 --- a/dev/breeze/doc/images/output_build-docs.svg +++ b/dev/breeze/doc/images/output_build-docs.svg @@ -245,13 +245,13 @@ apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy | apache.pig | apache.pinot | apache.spark apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes cohere | common.ai | common.compat | common.io | common.messaging | common.sql | databricks | datadog | dbt.cloud |  -dingding | discord | docker | docker-stack | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github |    -google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | java-sdk | jdbc | jenkins |   -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     -snowflake | sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate |  -yandex | ydb | zendesk]...                                                                                             +dingding | discord | docker | docker-stack | dq | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github +google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | jdbc | jenkins | keycloak | +microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc | openai |         +openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone | postgres |     +presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp | snowflake |    +sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate | yandex | ydb +zendesk]...                                                                                                          Build documents. diff --git a/dev/breeze/doc/images/output_build-docs.txt b/dev/breeze/doc/images/output_build-docs.txt index 7e86964622680..a77b769c19561 100644 --- a/dev/breeze/doc/images/output_build-docs.txt +++ b/dev/breeze/doc/images/output_build-docs.txt @@ -1 +1 @@ -22cd66d36e80473f4fac0fa311c5e846 +1a48397be50e3de3856de21b7d7dce01 diff --git a/dev/breeze/doc/images/output_release-management_add-back-references.svg b/dev/breeze/doc/images/output_release-management_add-back-references.svg index 09e78ba88111a..9cf44bc91c0b0 100644 --- a/dev/breeze/doc/images/output_release-management_add-back-references.svg +++ b/dev/breeze/doc/images/output_release-management_add-back-references.svg @@ -154,13 +154,13 @@ apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy | apache.pig | apache.pinot | apache.spark apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes cohere | common.ai | common.compat | common.io | common.messaging | common.sql | databricks | datadog | dbt.cloud |  -dingding | discord | docker | docker-stack | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github |    -google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | java-sdk | jdbc | jenkins |   -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     -snowflake | sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate |  -yandex | ydb | zendesk]...                                                                                             +dingding | discord | docker | docker-stack | dq | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github +google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | jdbc | jenkins | keycloak | +microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc | openai |         +openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone | postgres |     +presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp | snowflake |    +sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate | yandex | ydb +zendesk]...                                                                                                          Command to add back references for documentation to make it backward compatible. diff --git a/dev/breeze/doc/images/output_release-management_add-back-references.txt b/dev/breeze/doc/images/output_release-management_add-back-references.txt index 672e05eb6b26f..d1aabe7ffc715 100644 --- a/dev/breeze/doc/images/output_release-management_add-back-references.txt +++ b/dev/breeze/doc/images/output_release-management_add-back-references.txt @@ -1 +1 @@ -2700b8a52b9a04bb3cf5e365e70c2f40 +b22bcdaeeec5f5a324f9bab27dcd812d diff --git a/dev/breeze/doc/images/output_release-management_classify-provider-changes.svg b/dev/breeze/doc/images/output_release-management_classify-provider-changes.svg index cef6e4d3b1d14..fe6774f68dae7 100644 --- a/dev/breeze/doc/images/output_release-management_classify-provider-changes.svg +++ b/dev/breeze/doc/images/output_release-management_classify-provider-changes.svg @@ -166,11 +166,11 @@ apache.flink | apache.hdfs | apache.hive | apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy apache.pig | apache.pinot | apache.spark | apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai | common.compat | common.io | common.messaging |        -common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | edge3 | elasticsearch | exasol | fab |   -facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc | jenkins |    -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     +common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | dq | edge3 | elasticsearch | exasol |    +fab | facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc |        +jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j |    +odbc | openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone +postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |   snowflake | sqlite | ssh | standard | tableau | telegram | teradata | trino | vertica | vespa | weaviate | yandex |    ydb | zendesk]...                                                                                                      diff --git a/dev/breeze/doc/images/output_release-management_classify-provider-changes.txt b/dev/breeze/doc/images/output_release-management_classify-provider-changes.txt index 6e02c700afdf5..dc63186a0cf8d 100644 --- a/dev/breeze/doc/images/output_release-management_classify-provider-changes.txt +++ b/dev/breeze/doc/images/output_release-management_classify-provider-changes.txt @@ -1 +1 @@ -4cac13b21eee8b732a46c5a15aec7a4b +20d79ab20c9c8a3642541dae56cc07ca diff --git a/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.svg b/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.svg index 2f9692be54fa0..075d604ec1ed0 100644 --- a/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.svg +++ b/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.svg @@ -155,11 +155,11 @@ apache.flink | apache.hdfs | apache.hive | apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy apache.pig | apache.pinot | apache.spark | apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai | common.compat | common.io | common.messaging |        -common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | edge3 | elasticsearch | exasol | fab |   -facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc | jenkins |    -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     +common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | dq | edge3 | elasticsearch | exasol |    +fab | facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc |        +jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j |    +odbc | openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone +postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |   snowflake | sqlite | ssh | standard | tableau | telegram | teradata | trino | vertica | vespa | weaviate | yandex |    ydb | zendesk]...                                                                                                      diff --git a/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.txt b/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.txt index 6afb289b5854f..5c011d40cc77d 100644 --- a/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.txt +++ b/dev/breeze/doc/images/output_release-management_generate-issue-content-providers.txt @@ -1 +1 @@ -bd80bf2dd63a27111b8c5745df5dfb86 +195d4f4d2001bd0b681fa2610264778d diff --git a/dev/breeze/doc/images/output_release-management_generate-providers-metadata.svg b/dev/breeze/doc/images/output_release-management_generate-providers-metadata.svg index 64efb0af6b65b..c0e373a4c4ced 100644 --- a/dev/breeze/doc/images/output_release-management_generate-providers-metadata.svg +++ b/dev/breeze/doc/images/output_release-management_generate-providers-metadata.svg @@ -174,7 +174,7 @@ apache.kylin | apache.livy | apache.pig | apache.pinot | apache.spark | apache.tinkerpop |  apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai | common.compat | common.io | common.messaging | common.sql | databricks |  -datadog | dbt.cloud | dingding | discord | docker | edge3 | elasticsearch | exasol | fab |  +datadog | dbt.cloud | dingding | discord | docker | dq | edge3 | elasticsearch | exasol | fab |  facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc | jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp |  microsoft.winrm | mongo | mysql | neo4j | odbc | openai | openfaas | openlineage | opensearch |  diff --git a/dev/breeze/doc/images/output_release-management_generate-providers-metadata.txt b/dev/breeze/doc/images/output_release-management_generate-providers-metadata.txt index 7c14e04e63151..49cc734ebbe27 100644 --- a/dev/breeze/doc/images/output_release-management_generate-providers-metadata.txt +++ b/dev/breeze/doc/images/output_release-management_generate-providers-metadata.txt @@ -1 +1 @@ -5865810f5a4b12c9199267b7dd6459b3 +dacac5a22a7ab84d7fdc7e9709375340 diff --git a/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.svg b/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.svg index 27a1d92de9a4b..f713a828c564c 100644 --- a/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.svg +++ b/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.svg @@ -193,11 +193,11 @@ apache.flink | apache.hdfs | apache.hive | apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy apache.pig | apache.pinot | apache.spark | apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai | common.compat | common.io | common.messaging |        -common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | edge3 | elasticsearch | exasol | fab |   -facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc | jenkins |    -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     +common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | dq | edge3 | elasticsearch | exasol |    +fab | facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc |        +jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j |    +odbc | openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone +postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |   snowflake | sqlite | ssh | standard | tableau | telegram | teradata | trino | vertica | vespa | weaviate | yandex |    ydb | zendesk]...                                                                                                      diff --git a/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.txt b/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.txt index f054c03806e05..2b47f1755ef80 100644 --- a/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.txt +++ b/dev/breeze/doc/images/output_release-management_prepare-provider-distributions.txt @@ -1 +1 @@ -a27c1726f5902e5fdb501ecdee226476 +b6edff16004d612e687e5f16d01a5b74 diff --git a/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.svg b/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.svg index b9c71f9c81aec..74c5c78f5cd46 100644 --- a/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.svg +++ b/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.svg @@ -214,11 +214,11 @@ apache.flink | apache.hdfs | apache.hive | apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy apache.pig | apache.pinot | apache.spark | apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai | common.compat | common.io | common.messaging |        -common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | edge3 | elasticsearch | exasol | fab |   -facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc | jenkins |    -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     +common.sql | databricks | datadog | dbt.cloud | dingding | discord | docker | dq | edge3 | elasticsearch | exasol |    +fab | facebook | ftp | git | github | google | grpc | hashicorp | http | imap | influxdb | informatica | jdbc |        +jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j |    +odbc | openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone +postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |   snowflake | sqlite | ssh | standard | tableau | telegram | teradata | trino | vertica | vespa | weaviate | yandex |    ydb | zendesk]...                                                                                                      diff --git a/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.txt b/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.txt index a54080b6bfbb5..77cd5d480f70b 100644 --- a/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.txt +++ b/dev/breeze/doc/images/output_release-management_prepare-provider-documentation.txt @@ -1 +1 @@ -c33a2f6d00a3a8dbec8b56c1c2d88d54 +f38ff7c5d51344a214aefe63194a0f71 diff --git a/dev/breeze/doc/images/output_release-management_publish-docs.svg b/dev/breeze/doc/images/output_release-management_publish-docs.svg index 9416d6183c582..210f67fcc9e5f 100644 --- a/dev/breeze/doc/images/output_release-management_publish-docs.svg +++ b/dev/breeze/doc/images/output_release-management_publish-docs.svg @@ -193,13 +193,13 @@ apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy | apache.pig | apache.pinot | apache.spark apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes cohere | common.ai | common.compat | common.io | common.messaging | common.sql | databricks | datadog | dbt.cloud |  -dingding | discord | docker | docker-stack | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github |    -google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | java-sdk | jdbc | jenkins |   -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     -snowflake | sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate |  -yandex | ydb | zendesk]...                                                                                             +dingding | discord | docker | docker-stack | dq | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github +google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | jdbc | jenkins | keycloak | +microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc | openai |         +openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone | postgres |     +presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp | snowflake |    +sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate | yandex | ydb +zendesk]...                                                                                                          Command to publish generated documentation to airflow-site diff --git a/dev/breeze/doc/images/output_release-management_publish-docs.txt b/dev/breeze/doc/images/output_release-management_publish-docs.txt index b6433c39bb9be..e472795130dde 100644 --- a/dev/breeze/doc/images/output_release-management_publish-docs.txt +++ b/dev/breeze/doc/images/output_release-management_publish-docs.txt @@ -1 +1 @@ -cc4ad6886b5ff0da1d4f819d2c5dc80d +ef5f666c153e5c3ec72a2cf8250ee5a5 diff --git a/dev/breeze/doc/images/output_sbom_generate-providers-requirements.svg b/dev/breeze/doc/images/output_sbom_generate-providers-requirements.svg index a9d2e173ef2b8..4b243694bf80c 100644 --- a/dev/breeze/doc/images/output_sbom_generate-providers-requirements.svg +++ b/dev/breeze/doc/images/output_sbom_generate-providers-requirements.svg @@ -188,7 +188,7 @@ apache.pig | apache.pinot | apache.spark | apache.tinkerpop | apprise | arangodb | asana |  atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes | cohere | common.ai |  common.compat | common.io | common.messaging | common.sql | databricks | datadog | dbt.cloud |  -dingding | discord | docker | edge3 | elasticsearch | exasol | fab | facebook | ftp | git |  +dingding | discord | docker | dq | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github | google | grpc | hashicorp | http | ibm.mq | imap | influxdb | informatica | jdbc |  jenkins | keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm |  mongo | mysql | neo4j | odbc | openai | openfaas | openlineage | opensearch | opsgenie | oracle  diff --git a/dev/breeze/doc/images/output_sbom_generate-providers-requirements.txt b/dev/breeze/doc/images/output_sbom_generate-providers-requirements.txt index bb3262922cec4..8aed44f7ddb9e 100644 --- a/dev/breeze/doc/images/output_sbom_generate-providers-requirements.txt +++ b/dev/breeze/doc/images/output_sbom_generate-providers-requirements.txt @@ -1 +1 @@ -84f322f2ffa9ded046c2d3678135a8f7 +c07440cc580f3635e601b100ea1aebe6 diff --git a/dev/breeze/doc/images/output_workflow-run_publish-docs.svg b/dev/breeze/doc/images/output_workflow-run_publish-docs.svg index dfb0027b3d7ef..5e1893d0d0070 100644 --- a/dev/breeze/doc/images/output_workflow-run_publish-docs.svg +++ b/dev/breeze/doc/images/output_workflow-run_publish-docs.svg @@ -208,13 +208,13 @@ apache.iceberg | apache.impala | apache.kafka | apache.kylin | apache.livy | apache.pig | apache.pinot | apache.spark apache.tinkerpop | apprise | arangodb | asana | atlassian.jira | celery | clickhousedb | cloudant | cncf.kubernetes cohere | common.ai | common.compat | common.io | common.messaging | common.sql | databricks | datadog | dbt.cloud |  -dingding | discord | docker | docker-stack | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github |    -google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | java-sdk | jdbc | jenkins |   -keycloak | microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc |       -openai | openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone |       -postgres | presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp |     -snowflake | sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate |  -yandex | ydb | zendesk]...                                                                                             +dingding | discord | docker | docker-stack | dq | edge3 | elasticsearch | exasol | fab | facebook | ftp | git | github +google | grpc | hashicorp | helm-chart | http | ibm.mq | imap | influxdb | informatica | jdbc | jenkins | keycloak | +microsoft.azure | microsoft.mssql | microsoft.psrp | microsoft.winrm | mongo | mysql | neo4j | odbc | openai |         +openfaas | openlineage | opensearch | opsgenie | oracle | pagerduty | papermill | pgvector | pinecone | postgres |     +presto | qdrant | redis | salesforce | samba | segment | sendgrid | sftp | singularity | slack | smtp | snowflake |    +sqlite | ssh | standard | tableau | task-sdk | telegram | teradata | trino | vertica | vespa | weaviate | yandex | ydb +zendesk]...                                                                                                          Trigger publish docs to S3 workflow diff --git a/dev/breeze/doc/images/output_workflow-run_publish-docs.txt b/dev/breeze/doc/images/output_workflow-run_publish-docs.txt index 4c6ec79dba124..bb40fd9b1f773 100644 --- a/dev/breeze/doc/images/output_workflow-run_publish-docs.txt +++ b/dev/breeze/doc/images/output_workflow-run_publish-docs.txt @@ -1 +1 @@ -e784b5234a05d958f84df96cd5d8d562 +83ee7d480d222cf939bab166bfc30af8 diff --git a/dev/breeze/src/airflow_breeze/global_constants.py b/dev/breeze/src/airflow_breeze/global_constants.py index abd6237606f91..8529598384b03 100644 --- a/dev/breeze/src/airflow_breeze/global_constants.py +++ b/dev/breeze/src/airflow_breeze/global_constants.py @@ -843,7 +843,7 @@ def get_airflow_extras(): { "python-version": "3.10", "airflow-version": "2.11.1", - "remove-providers": "anthropic common.messaging edge3 fab git keycloak informatica common.ai opensearch", + "remove-providers": "anthropic common.messaging edge3 fab git keycloak informatica common.ai opensearch dq", "run-unit-tests": "true", }, { diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py index cbecbdd94429d..0852fe3bbdbcd 100644 --- a/dev/breeze/tests/test_selective_checks.py +++ b/dev/breeze/tests/test_selective_checks.py @@ -2748,7 +2748,7 @@ def test_upgrade_to_newer_dependencies( ("providers/common/sql/src/airflow/providers/common/sql/common_sql_python.py",), { "docs-list-as-string": "amazon apache.drill apache.druid apache.hive apache.iceberg " - "apache.impala apache.pinot clickhousedb common.ai common.compat common.sql databricks elasticsearch " + "apache.impala apache.pinot clickhousedb common.ai common.compat common.sql databricks dq elasticsearch " "exasol google informatica jdbc microsoft.mssql mysql odbc openlineage " "oracle pgvector postgres presto slack snowflake sqlite teradata trino vertica ydb", }, diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 2b7c13ee66036..00650dad7301a 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -292,6 +292,7 @@ conda conf Config config +ConfigDict configfile configMap configmap diff --git a/providers/dq/.gitignore b/providers/dq/.gitignore new file mode 100644 index 0000000000000..f454b41e73c0a --- /dev/null +++ b/providers/dq/.gitignore @@ -0,0 +1,3 @@ +# Do not commit hash files, this is just to speed-up local builds +www-hash.txt +*.iml diff --git a/providers/dq/.pre-commit-config.yaml b/providers/dq/.pre-commit-config.yaml new file mode 100644 index 0000000000000..7defb148dc634 --- /dev/null +++ b/providers/dq/.pre-commit-config.yaml @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +--- +default_stages: [pre-commit, pre-push] +minimum_prek_version: '0.3.4' +default_language_version: + python: python3 + node: 22.19.0 + golang: 1.24.0 +repos: + - repo: local + hooks: + - id: generate-dq-ruleset-schema + name: Generate Data Quality RuleSet schema + language: python + entry: ../../scripts/ci/prek/generate_dq_ruleset_schema.py + pass_filenames: false + always_run: true + files: > + (?x) + ^src/airflow/providers/dq/rules/.*\.py$| + ^src/airflow/providers/dq/skills/dq-rule-authoring/references/ruleset\.schema\.json$ + - id: ts-compile-lint-dq-ui + name: Compile Data Quality provider UI + description: Type-check changed TypeScript files in the Data Quality provider UI + language: node + files: | + (?x) + ^src/airflow/providers/dq/plugins/www/.*\.(js|ts|tsx|yaml|css|json)$ + exclude: | + (?x) + ^src/airflow/providers/dq/plugins/www/node-modules/.*| + ^src/airflow/providers/dq/plugins/www/.pnpm-store + entry: ../../scripts/ci/prek/ts_compile_lint_dq.py + additional_dependencies: ['pnpm@10.25.0'] + pass_filenames: true + require_serial: true + - id: compile-dq-assets + name: Compile Data Quality provider assets + language: node + stages: ['pre-commit', 'manual'] + files: ^src/airflow/providers/dq/plugins/www/ + entry: ../../scripts/ci/prek/compile_provider_assets.py dq + pass_filenames: false + additional_dependencies: ['pnpm@10.25.0'] + - id: check-http-exception-import-from-fastapi + name: Check HTTPException is imported from fastapi + entry: ../../scripts/ci/prek/check_http_exception_import_from_fastapi.py + language: python + pass_filenames: true + files: > + (?x) + ^src/airflow/providers/dq/(api|plugins)/.*\.py$| + ^tests/unit/dq/(api|plugins)/.*\.py$ diff --git a/providers/dq/LICENSE b/providers/dq/LICENSE new file mode 100644 index 0000000000000..11069edd79019 --- /dev/null +++ b/providers/dq/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/providers/dq/NOTICE b/providers/dq/NOTICE new file mode 100644 index 0000000000000..a51bd9390d030 --- /dev/null +++ b/providers/dq/NOTICE @@ -0,0 +1,5 @@ +Apache Airflow +Copyright 2016-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/providers/dq/README.rst b/providers/dq/README.rst new file mode 100644 index 0000000000000..a6f6ba496bdbc --- /dev/null +++ b/providers/dq/README.rst @@ -0,0 +1,52 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +Package ``apache-airflow-providers-dq`` + +Release: ``0.1.0`` + +``Data Quality Provider`` + +Declarative data quality rules with durable, per-rule execution history. Checks run through +``common.sql`` DB-API hooks; results are persisted to a configurable results store (object +storage or local files) so task, run, and rule-level quality can be inspected over time. + +Provider package +---------------- + +This package is for the ``dq`` provider. +All classes for this package are included in the ``airflow.providers.dq`` python package. + +Installation +------------ + +You can install this package on top of an existing Airflow installation via +``pip install apache-airflow-providers-dq``. For the minimum Airflow version supported, +see ``Requirements`` below. + +Requirements +------------ + +========================================== ================== +PIP package Version required +========================================== ================== +``apache-airflow`` ``>=3.0.0`` +``apache-airflow-providers-common-compat`` ``>=1.15.0`` +``apache-airflow-providers-common-sql`` ``>=2.0.0`` +``pydantic`` ``>=2.11.0`` +``pyyaml`` ``>=6.0.2`` +========================================== ================== diff --git a/providers/dq/docs/agents.rst b/providers/dq/docs/agents.rst new file mode 100644 index 0000000000000..cc820cf88b416 --- /dev/null +++ b/providers/dq/docs/agents.rst @@ -0,0 +1,55 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. _dq:agents: + +Generating rules with an LLM +============================== + +Writing a :class:`~airflow.providers.dq.rules.RuleSet` by hand for every table doesn't scale. +An LLM can propose one from a table's column definitions instead, given the check catalog as +context. + +The ``dq-rule-authoring`` skill +--------------------------------- + +This provider ships an `Agent Skill `__ at +``airflow/providers/dq/skills/dq-rule-authoring/``: a ``SKILL.md`` documenting the +``RuleSet``/``DQRule`` fields and check catalog, plus a generated JSON Schema +(``references/ruleset.schema.json``) for validation. + +Point ``common.ai``'s :doc:`AgentSkillsToolset ` at +it, and give the model ``output_type=RuleSet`` so pydantic-ai validates -- and self-corrects -- +its output before the task completes: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_llm_generated_ruleset.py + :language: python + :start-after: [START howto_task_dq_generate_ruleset_with_llm] + :end-before: [END howto_task_dq_generate_ruleset_with_llm] + +Requires ``apache-airflow-providers-common-ai[skills]`` and a configured ``llm_conn_id``. + +Wiring the result into a check +--------------------------------- + +``@task.dq_check`` can leave ``ruleset=`` unset and return the LLM task's result at execution +time instead: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_llm_generated_ruleset.py + :language: python + :start-after: [START howto_decorator_dq_check_llm_runtime_ruleset] + :end-before: [END howto_decorator_dq_check_llm_runtime_ruleset] diff --git a/providers/dq/docs/assets.rst b/providers/dq/docs/assets.rst new file mode 100644 index 0000000000000..538952f50a085 --- /dev/null +++ b/providers/dq/docs/assets.rst @@ -0,0 +1,66 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. _dq:assets: + +Assets and quality gating +============================ + +Quality rules can travel with the :class:`~airflow.sdk.Asset` they describe instead of being +scattered across every Dag that checks it, and a downstream consumer Dag can refuse to run when +the data it was triggered by did not meet a minimum quality bar. + +Attaching a ruleset to an asset +---------------------------------- + +:func:`~airflow.providers.dq.assets.asset_quality` stores ruleset, connection, and table +configuration inside ``Asset.extra`` under the ``airflow.dq`` key, so it is serialized with the +Dag and needs no Airflow core changes: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_require_quality.py + :language: python + :start-after: [START howto_asset_quality] + :end-before: [END howto_asset_quality] + +Pass the resulting asset to :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` +via ``asset=`` (see :doc:`operators`) instead of ``table``/``ruleset``/``conn_id``: the operator +resolves all three from the asset's config, adds the asset to its own outlets, and attaches its +summary -- including the quality ``score`` used below -- to the asset event. + +Gating a consumer Dag on quality +------------------------------------ + +:func:`~airflow.providers.dq.assets.require_quality` builds a ``@task.short_circuit`` task that +reads the ``score`` off the asset event that triggered the current run, and skips every +downstream task when that event has no quality summary at all, or its score is below +``min_score``: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_require_quality.py + :language: python + :start-after: [START howto_require_quality] + :end-before: [END howto_require_quality] + +Put the gate first in a Dag scheduled by the asset, and chain everything else after it: + +.. code-block:: python + + with DAG("orders_consumer", schedule=orders_asset) as consumer: + gate = require_quality(orders_asset, min_score=0.95) + gate >> process_orders() + +``min_score`` must be between ``0`` and ``1``; the check considers only the *most recent* +triggering event for the asset when a run was triggered by several. diff --git a/providers/dq/docs/changelog.rst b/providers/dq/docs/changelog.rst new file mode 100644 index 0000000000000..3441e711a8345 --- /dev/null +++ b/providers/dq/docs/changelog.rst @@ -0,0 +1,33 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + + .. NOTE TO CONTRIBUTORS: + Please, only add notes to the Changelog just below the "Changelog" header when there + are some breaking changes and you want to add an explanation to the users on how they + are supposed to deal with them. The changelog is updated and maintained semi-automatically + by release manager. + +``apache-airflow-providers-dq`` + + +Changelog +--------- + +0.1.0 +..... + +Initial version of the provider. diff --git a/providers/dq/docs/commits.rst b/providers/dq/docs/commits.rst new file mode 100644 index 0000000000000..6f999c6f0f339 --- /dev/null +++ b/providers/dq/docs/commits.rst @@ -0,0 +1,35 @@ + + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + + .. NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE OVERWRITTEN! + + .. IF YOU WANT TO MODIFY THIS FILE, YOU SHOULD MODIFY THE TEMPLATE + `PROVIDER_COMMITS_TEMPLATE.rst.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY + + .. THE REMAINDER OF THE FILE IS AUTOMATICALLY GENERATED. IT WILL BE OVERWRITTEN! + +Package apache-airflow-providers-dq +------------------------------------------------------ + +``Data Quality Provider`` + + +This is detailed commit list of changes for versions provider package: ``dq``. +For high-level changelog, see :doc:`package information including changelog `. + +.. airflow-providers-commits:: diff --git a/providers/dq/docs/conf.py b/providers/dq/docs/conf.py new file mode 100644 index 0000000000000..f133bf86729a7 --- /dev/null +++ b/providers/dq/docs/conf.py @@ -0,0 +1,27 @@ +# Disable Flake8 because of all the sphinx imports +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Configuration of Providers docs building.""" + +from __future__ import annotations + +import os + +os.environ["AIRFLOW_PACKAGE_NAME"] = "apache-airflow-providers-dq" + +from docs.provider_conf import * # noqa: F403 diff --git a/providers/dq/docs/configurations-ref.rst b/providers/dq/docs/configurations-ref.rst new file mode 100644 index 0000000000000..a52b21b2e5679 --- /dev/null +++ b/providers/dq/docs/configurations-ref.rst @@ -0,0 +1,19 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. include:: /../../../devel-common/src/sphinx_exts/includes/providers-configurations-ref.rst +.. include:: /../../../devel-common/src/sphinx_exts/includes/sections-and-options.rst diff --git a/providers/dq/docs/decorators.rst b/providers/dq/docs/decorators.rst new file mode 100644 index 0000000000000..257e0de8bb697 --- /dev/null +++ b/providers/dq/docs/decorators.rst @@ -0,0 +1,52 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. _howto/decorator:dq_check: + +``@task.dq_check`` +===================== + +``@task.dq_check`` wraps :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` in +the TaskFlow API. ``ruleset`` may be declared as a decorator argument when it exists at +Dag-parse time, or returned by the decorated function as a runtime ruleset. ``table`` or +``asset`` are declared as decorator arguments exactly like the plain operator. The decorated +function is optional plumbing on top: return ``None`` to run the check exactly as declared. + +.. exampleinclude:: /../tests/system/dq/example_dq_check.py + :language: python + :dedent: 4 + :start-after: [START howto_decorator_dq_check] + :end-before: [END howto_decorator_dq_check] + +Runtime rule sets +-------------------- + +Return a :class:`~airflow.providers.dq.rules.RuleSet`, its dict form, or a YAML path to use a +ruleset that is only known at task-execution time -- for example one produced by an upstream +task, loaded from a Variable, or generated by an LLM. Return ``None`` to use the ruleset declared +on the decorator. + +Swapping in a different ruleset at execution time: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_check_decorator_dynamic.py + :language: python + :start-after: [START howto_decorator_dq_check_runtime_ruleset] + :end-before: [END howto_decorator_dq_check_runtime_ruleset] + +Everything documented for the plain operator in :doc:`operators` -- ``fail_on``, persistence, +``asset``, ``partition_clause``, ``custom_sql`` -- applies unchanged; the decorator only adds the +optional runtime ruleset step before the check runs. diff --git a/providers/dq/docs/index.rst b/providers/dq/docs/index.rst new file mode 100644 index 0000000000000..0559b586e2f16 --- /dev/null +++ b/providers/dq/docs/index.rst @@ -0,0 +1,175 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +``apache-airflow-providers-dq`` +=============================== + + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Basics + + Home + Changelog + Security + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Guides + + Rules, rule sets, and checks + Operators + Decorators + Assets and quality gating + Generating rules with an LLM + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: References + + Configuration + Python API <_api/airflow/providers/dq/index> + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Resources + + PyPI Repository + Installing from sources + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Commits + + Detailed list of commits + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: System tests + + System Tests <_api/tests/system/dq/index> + + +apache-airflow-providers-dq package +----------------------------------- + +``Data Quality Provider`` + +Declarative data quality rules with durable, per-rule execution history. Checks run through +``common.sql`` DB-API hooks; results are persisted to a configurable results store (object +storage or local files) so task, run, and rule-level quality can be inspected over time. + +See :doc:`rules` for the built-in check catalog (and its cross-database caveats), +:doc:`operators`/:doc:`decorators` for running checks, and :doc:`assets` for attaching rules to +an asset and gating a downstream Dag on its quality score. + +Release: 0.1.0 + +Provider package +---------------- + +This package is for the ``dq`` provider. +All classes for this package are included in the ``airflow.providers.dq`` python package. + +Installation +------------ + +You can install this package on top of an existing Airflow installation via +``pip install apache-airflow-providers-dq``. For the minimum Airflow version supported, +see ``Requirements`` below. + +Requirements +------------ + +The minimum Apache Airflow version supported by this provider distribution is ``3.0.0``. + +========================================== ================== +PIP package Version required +========================================== ================== +``apache-airflow`` ``>=3.0.0`` +``apache-airflow-providers-common-compat`` ``>=1.15.0`` +``apache-airflow-providers-common-sql`` ``>=2.0.0`` +``pydantic`` ``>=2.11.0`` +``pyyaml`` ``>=6.0.2`` +========================================== ================== + +.. THE REMAINDER OF THE FILE IS AUTOMATICALLY GENERATED. IT WILL BE OVERWRITTEN AT RELEASE TIME! + + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Commits + + Detailed list of commits + + +apache-airflow-providers-dq package +------------------------------------------------------ + +``Data Quality Provider`` + +Declarative data quality rules with durable, per-rule execution history. +Checks run through ``common.sql`` DB-API hooks; results are persisted to a +configurable results store (object storage or local files) so task, run, +and rule-level quality can be inspected over time. + + +Release: 0.1.0 + +Provider package +---------------- + +This package is for the ``dq`` provider. +All classes for this package are included in the ``airflow.providers.dq`` python package. + +Installation +------------ + +You can install this package on top of an existing Airflow installation via +``pip install apache-airflow-providers-dq``. +For the minimum Airflow version supported, see ``Requirements`` below. + +Requirements +------------ + +The minimum Apache Airflow version supported by this provider distribution is ``3.0.0``. + +========================================== ================== +PIP package Version required +========================================== ================== +``apache-airflow`` ``>=3.0.0`` +``apache-airflow-providers-common-compat`` ``>=1.15.0`` +``apache-airflow-providers-common-sql`` ``>=2.0.0`` +``pydantic`` ``>=2.11.0`` +``pyyaml`` ``>=6.0.2`` +========================================== ================== + +Downloading official packages +----------------------------- + +You can download officially released packages and verify their checksums and signatures from the +`Official Apache Download site `_ + +* `The apache-airflow-providers-dq 0.1.0 sdist package `_ (`asc `__, `sha512 `__) +* `The apache-airflow-providers-dq 0.1.0 wheel package `_ (`asc `__, `sha512 `__) diff --git a/providers/dq/docs/installing-providers-from-sources.rst b/providers/dq/docs/installing-providers-from-sources.rst new file mode 100644 index 0000000000000..a72b45ffaa6e8 --- /dev/null +++ b/providers/dq/docs/installing-providers-from-sources.rst @@ -0,0 +1,18 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. include:: /../../../devel-common/src/sphinx_exts/includes/installing-providers-from-sources.rst diff --git a/providers/dq/docs/operators.rst b/providers/dq/docs/operators.rst new file mode 100644 index 0000000000000..4043e2177298e --- /dev/null +++ b/providers/dq/docs/operators.rst @@ -0,0 +1,119 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. _howto/operator:DQCheckOperator: + +``DQCheckOperator`` +===================== + +Use :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` to run a +:doc:`ruleset ` against a table and persist per-rule results to the configured results +store. Every rule is evaluated and recorded regardless of the task outcome -- a rule failing +doesn't stop the others from running, and the full set of results is always written before the +task decides whether to fail. + +Basic usage +------------ + +Pass a connection, table, and ruleset directly: + +.. exampleinclude:: /../tests/system/dq/example_dq_check.py + :language: python + :dedent: 4 + :start-after: [START howto_operator_dq_check] + :end-before: [END howto_operator_dq_check] + +Parameters +----------- + +- ``ruleset`` -- a :class:`~airflow.providers.dq.rules.RuleSet`, its dict form, or a path to a + YAML ruleset file (see :doc:`rules`). Optional when ``asset`` carries one. +- ``table`` -- the table to check. Optional when ``asset`` carries one (falling back to the + asset's name). +- ``asset`` -- an :class:`~airflow.sdk.Asset` decorated with + :func:`~airflow.providers.dq.assets.asset_quality`. Supplies defaults for ``ruleset``, + ``table``, and ``conn_id`` (explicit arguments win) and is automatically added to the task's + outlets so its asset events carry the check summary -- see :doc:`assets`. +- ``partition_clause`` -- predicate ANDed into every built-in check's ``WHERE`` clause, e.g. + ``"ds = '{{ ds }}'"`` (templated). +- ``fail_on`` -- severity that fails the task: + + - ``error`` (default) -- only ``error``-severity rule failures fail the task; ``warn`` + failures are recorded but don't fail it. + - ``warn`` -- any rule failure (``error`` or ``warn`` severity) fails the task. + - ``never`` -- rule failures never fail the task; only an execution error (the check query + itself failing) does. + +- ``conn_id`` -- connection to the database to check, any ``common.sql`` ``DbApiHook``- + compatible type. +- ``database`` -- optional database/schema, overriding the connection's default. + +Regardless of ``fail_on``, an execution error -- the check query itself failing, as opposed to +a rule failing its condition -- always fails the task; there's no way to check data whose query +can't even run. + +Persisting results +-------------------- + +Results are persisted to the backend configured under ``[dq] results_path`` (see +:doc:`configurations-ref`). When that's unset, checks still run and the task still passes or +fails normally -- only the history in the Data Quality UI is unavailable. There is no +per-operator override: every check in a deployment shares one results store, so history stays +available across tasks and Dags without stitching together several stores. + +Checking an asset +-------------------- + +Attach a ruleset to an :class:`~airflow.sdk.Asset` with +:func:`~airflow.providers.dq.assets.asset_quality`, then pass the asset instead of ``table``/ +``ruleset``/``conn_id``: + +.. exampleinclude:: /../tests/system/dq/example_dq_check.py + :language: python + :start-after: [START howto_operator_dq_check_asset] + :end-before: [END howto_operator_dq_check_asset] + +The operator adds the asset to its own outlets automatically, and attaches the check's summary +(including its quality ``score``) to the asset event -- which is what makes +:func:`~airflow.providers.dq.assets.require_quality` (see :doc:`assets`) able to gate a +downstream consumer Dag on it. + +``custom_sql`` checks +------------------------ + +Built-in checks are all single-column. For cross-column comparisons, joins, or anything the +catalog doesn't cover, use ``custom_sql``: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_check_custom_sql.py + :language: python + :start-after: [START howto_operator_dq_check_custom_sql] + :end-before: [END howto_operator_dq_check_custom_sql] + +See :doc:`rules` for the full built-in check catalog and the ``custom_sql`` grammar. + +Loading a ruleset from YAML +------------------------------ + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_ruleset_from_yaml.py + :language: python + :start-after: [START howto_operator_dq_check_ruleset_from_yaml] + :end-before: [END howto_operator_dq_check_ruleset_from_yaml] + +TaskFlow decorator +-------------------- + +See :doc:`decorators` for the ``@task.dq_check`` equivalent, including runtime rule sets. diff --git a/providers/dq/docs/rules.rst b/providers/dq/docs/rules.rst new file mode 100644 index 0000000000000..38366034783d8 --- /dev/null +++ b/providers/dq/docs/rules.rst @@ -0,0 +1,221 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. _dq:rules: + +Rules, rule sets, and checks +============================ + +Rules are data, not code. A :class:`~airflow.providers.dq.rules.RuleSet` is a named tuple of +:class:`~airflow.providers.dq.rules.DQRule` -- plain, serializable objects with no behavior of +their own. They describe *what* to check; :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` +(see :doc:`operators`) is what actually runs them. Rules are usually written by hand, but they +can also be proposed by an LLM -- see :doc:`agents`. + +.. code-block:: python + + from airflow.providers.dq.rules import DQRule, RuleSet + + orders_ruleset = RuleSet( + name="orders_quality", + rules=( + DQRule( + name="order_id_not_null", + check="null_count", + column="order_id", + condition={"equal_to": 0}, + ), + DQRule( + name="row_count_present", + check="row_count", + condition={"greater_than": 0}, + ), + ), + ) + +``DQRule`` fields +------------------ + +- ``name`` -- unique within its ruleset. Shows up in the Data Quality UI and logs. +- ``check`` -- one of the built-in checks below, or ``custom_sql``. +- ``condition`` -- the pass/fail condition for the observed value; see `Conditions`_. +- ``column`` -- target column. Required for column-level built-in checks, unused for + ``row_count`` and ``custom_sql``. +- ``sql`` -- a SQL statement returning a single scalar. Required for, and only valid with, + ``check="custom_sql"``. May reference the table being checked as ``{table}``. +- ``severity`` -- ``error`` (default) or ``warn``. Controls whether a failing rule fails the + task, subject to the operator's ``fail_on`` (see :doc:`operators`). +- ``partition_clause`` -- extra SQL predicate ANDed into this rule's ``WHERE`` clause, e.g. + ``"region = 'EU'"``. Combines with the operator-level ``partition_clause``, if any. +- ``previous_name`` -- set when renaming a rule so its execution history stays continuous + (see `Identity and history`_). +- ``description`` -- optional human-readable text shown in results and the Data Quality UI. + When omitted, the provider generates a short default description from the rule and condition. +- ``dimension`` -- one of ``completeness``, ``uniqueness``, ``validity``, ``freshness``, + ``volume``, ``consistency``. Defaults to the check's catalog dimension (see the table below; + ``validity`` for ``custom_sql``) when left unset. Only set this explicitly for a ``custom_sql`` + rule that measures something the default dimension doesn't capture. + +Built-in checks +---------------- + +Each built-in check is backed by a plain SQL expression, rendered with ``{column}`` for +column-level checks: + +.. list-table:: + :header-rows: 1 + + * - Check + - SQL expression + - Column required + - Default dimension + * - ``null_count`` + - ``SUM(CASE WHEN {column} IS NULL THEN 1 ELSE 0 END)`` + - yes + - ``completeness`` + * - ``null_ratio`` + - ``SUM(CASE WHEN {column} IS NULL THEN 1.0 ELSE 0.0 END) / COUNT(*)`` + - yes + - ``completeness`` + * - ``distinct_count`` + - ``COUNT(DISTINCT {column})`` + - yes + - ``uniqueness`` + * - ``unique_violations`` + - ``COUNT({column}) - COUNT(DISTINCT {column})`` + - yes + - ``uniqueness`` + * - ``min`` + - ``MIN({column})`` + - yes + - ``validity`` + * - ``max`` + - ``MAX({column})`` + - yes + - ``validity`` + * - ``mean`` + - ``AVG({column})`` + - yes + - ``validity`` + * - ``row_count`` + - ``COUNT(*)`` + - no + - ``volume`` + +.. note:: + + ``null_ratio`` divides by ``COUNT(*)`` with no guard against an empty table (or an empty + partition, when combined with ``partition_clause``); running it against zero rows is a + division-by-zero at the database level, not a clean "not applicable" result. Guard with a + ``row_count`` rule upstream, or a ``partition_clause`` that guarantees a non-empty set. A + portable guard would wrap the denominator in ``NULLIF(COUNT(*), 0)``, but ``NULLIF`` isn't + supported by every ``DbApiHook`` (see `Supported checks and databases`_) -- this is one + instance of the general rule below: if a built-in check's SQL doesn't work against your + database, express it as ``custom_sql`` instead. + +Conditions +----------- + +A :class:`~airflow.providers.dq.rules.Condition` is the pass/fail rule applied to the observed +value, using the same grammar as the ``common.sql`` check operators: + +- ``equal_to`` -- exact match. Cannot be combined with any other comparison. +- ``greater_than`` / ``geq_to`` -- lower bound, exclusive/inclusive. +- ``less_than`` / ``leq_to`` -- upper bound, exclusive/inclusive. +- ``tolerance`` -- a percentage that widens ``equal_to`` into a range; only valid together + with ``equal_to``. + +``greater_than``/``less_than``/``geq_to``/``leq_to`` may be combined to express a range +(e.g. ``{"geq_to": 0, "leq_to": 10}``). + +``custom_sql``: the escape hatch +---------------------------------- + +Built-in checks are all single-column. The moment a rule needs to compare two columns, join +another table, or use a function the catalog doesn't cover, use ``custom_sql`` -- any SQL +statement that resolves to a single scalar, evaluated exactly like a built-in check: + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_check_custom_sql.py + :language: python + :start-after: [START howto_operator_dq_check_custom_sql] + :end-before: [END howto_operator_dq_check_custom_sql] + +Supported checks and databases +-------------------------------- + +Built-in checks are plain SQL expressions executed through whichever ``common.sql`` +:class:`~airflow.providers.common.sql.hooks.sql.DbApiHook` your connection resolves to. +Airflow does not validate those expressions per database dialect. + +The catalog intentionally uses simple expressions that work on common relational databases +and many distributed SQL engines, but support is not guaranteed for every ``DbApiHook``. +Some hooks expose non-standard SQL layers and may reject or evaluate an expression differently. + +For example, ``null_ratio`` is currently rendered as: + +.. code-block:: text + + SUM(CASE WHEN {column} IS NULL THEN 1.0 ELSE 0.0 END) / COUNT(*) + +This is simple and portable, but it is not guarded against an empty table or an empty +partition. A database-specific version could use a different expression, for example: + +.. code-block:: text + + CASE WHEN COUNT(*) = 0 THEN NULL + ELSE SUM(CASE WHEN {column} IS NULL THEN 1.0 ELSE 0.0 END) / COUNT(*) END + +If a built-in check does not work for your database or you need different empty-table +semantics, use ``custom_sql`` and write the expression for your dialect. + +Loading rules from YAML +------------------------- + +Anywhere a ``RuleSet`` is accepted -- ``DQCheckOperator(ruleset=...)``, +``@task.dq_check(ruleset=...)``, :func:`~airflow.providers.dq.assets.asset_quality` -- a path +string is accepted too, and resolved via :meth:`~airflow.providers.dq.rules.RuleSet.from_file` +at Dag-parse time. This keeps rules editable by people who don't write Python. + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/orders_ruleset.yaml + :language: yaml + +.. exampleinclude:: /../src/airflow/providers/dq/example_dags/example_dq_ruleset_from_yaml.py + :language: python + :start-after: [START howto_operator_dq_check_ruleset_from_yaml] + :end-before: [END howto_operator_dq_check_ruleset_from_yaml] + +Identity and history +---------------------- + +Each rule has a stable ``rule_uid``, a hash of the fields that define *what is being measured*: +``name`` (or ``previous_name``, if set), ``check``, ``column``, ``sql``, and ``condition``. +Everything else -- including ``severity``, ``partition_clause``, ``description``, and +``dimension`` -- can change between Dag runs without breaking the rule's execution history, +because it isn't part of the identity hash. + +Renaming a rule outright would normally start a new history under the new name; set +``previous_name`` to the old name for one deploy to carry the old identity forward instead: + +.. code-block:: python + + DQRule( + name="order_id_is_unique", # renamed from order_id_unique + previous_name="order_id_unique", + check="unique_violations", + column="order_id", + condition={"equal_to": 0}, + ) diff --git a/providers/dq/docs/security.rst b/providers/dq/docs/security.rst new file mode 100644 index 0000000000000..15a0ebbb2d054 --- /dev/null +++ b/providers/dq/docs/security.rst @@ -0,0 +1,18 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. include:: /../../../devel-common/src/sphinx_exts/includes/security.rst diff --git a/providers/dq/hatch_build.py b/providers/dq/hatch_build.py new file mode 100644 index 0000000000000..ec5ccf72c83cf --- /dev/null +++ b/providers/dq/hatch_build.py @@ -0,0 +1,70 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import logging +import os +import shutil +from collections.abc import Callable, Iterable +from pathlib import Path +from subprocess import run +from typing import Any + +from hatchling.builders.config import BuilderConfig +from hatchling.builders.plugin.interface import BuilderInterface +from hatchling.plugin.manager import PluginManager + +log = logging.getLogger(__name__) +log_level = logging.getLevelName(os.getenv("CUSTOM_AIRFLOW_BUILD_LOG_LEVEL", "INFO")) +log.setLevel(log_level) + + +class CustomBuild(BuilderInterface[BuilderConfig, PluginManager]): + """Custom build class for Data Quality provider assets.""" + + # Note that this name of the plugin MUST be `custom` - as long as we use it from custom + # hatch_build.py file and not from external plugin. See note in the: + # https://hatch.pypa.io/latest/plugins/build-hook/custom/#example + PLUGIN_NAME = "custom" + + @staticmethod + def clean_dir(path: Path) -> None: + log.warning("Cleaning directory: %s", path) + shutil.rmtree(path, ignore_errors=True) + + def clean(self, directory: str, versions: Iterable[str]) -> None: + work_dir = Path(self.root) + log.warning("Cleaning generated files in directory: %s", work_dir) + dq_package_src = work_dir / "src" / "airflow" / "providers" / "dq" + dq_ui_path = dq_package_src / "plugins" / "www" + self.clean_dir(dq_ui_path / ".pnpm-store") + self.clean_dir(dq_ui_path / "dist") + self.clean_dir(dq_ui_path / "node_modules") + (work_dir / "www-hash.txt").unlink(missing_ok=True) + + def get_version_api(self) -> dict[str, Callable[..., str]]: + """Get custom build target for standard package preparation.""" + return {"standard": self.build_standard} + + def build_standard(self, directory: str, artifacts: Any, **build_data: Any) -> str: + # run this in the airflow repo root + work_dir = Path(self.root).parents[1].resolve() + cmd = ["prek", "run", "compile-dq-assets", "--all-files"] + log.warning("Running command: %s", " ".join(cmd)) + run(cmd, cwd=work_dir.as_posix(), check=True) + dist_path = Path(self.root) / "src" / "airflow" / "providers" / "dq" / "plugins" / "www" / "dist" + return dist_path.resolve().as_posix() diff --git a/providers/dq/provider.yaml b/providers/dq/provider.yaml new file mode 100644 index 0000000000000..363bc0baf6e9f --- /dev/null +++ b/providers/dq/provider.yaml @@ -0,0 +1,81 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +--- +package-name: apache-airflow-providers-dq +name: Data Quality +description: | + ``Data Quality Provider`` + + Declarative data quality rules with durable, per-rule execution history. + Checks run through ``common.sql`` DB-API hooks; results are persisted to a + configurable results store (object storage or local files) so task, run, + and rule-level quality can be inspected over time. + +state: ready +lifecycle: incubation +source-date-epoch: 1751587200 +build-system: hatchling + +# Note that those versions are maintained by release manager - do not update them manually +# with the exception of case where other provider in sources has >= new provider version. +# In such case adding >= NEW_VERSION and bumping to NEW_VERSION in a provider have +# to be done in the same PR +versions: + - 0.1.0 + +integrations: + - integration-name: Data Quality + external-doc-url: https://airflow.apache.org/docs/apache-airflow-providers-dq/ + how-to-guide: + - /docs/apache-airflow-providers-dq/operators.rst + tags: [software] + +operators: + - integration-name: Data Quality + python-modules: + - airflow.providers.dq.operators.dq_check + +task-decorators: + - class-name: airflow.providers.dq.decorators.dq_check.dq_check_task + name: dq_check + +plugins: + - name: dq + plugin-class: airflow.providers.dq.plugins.dq.DQPlugin + +config: + dq: + description: | + Configuration for the Data Quality provider results store. + options: + results_path: + description: | + Any fsspec-compatible URL understood by ``ObjectStoragePath`` where data quality + results are persisted, e.g. ``s3://bucket/airflow-dq`` or ``file:///opt/airflow/dq``. + When unset, checks still run but no history is persisted. + version_added: 0.1.0 + type: string + example: s3://data-platform/airflow-dq + default: ~ + results_conn_id: + description: | + Optional Airflow connection used to access ``results_path``. + version_added: 0.1.0 + type: string + example: aws_default + default: ~ diff --git a/providers/dq/pyproject.toml b/providers/dq/pyproject.toml new file mode 100644 index 0000000000000..1a264da2cf6ca --- /dev/null +++ b/providers/dq/pyproject.toml @@ -0,0 +1,161 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE OVERWRITTEN! + +# IF YOU WANT TO MODIFY THIS FILE EXCEPT DEPENDENCIES, YOU SHOULD MODIFY THE TEMPLATE +# `pyproject_TEMPLATE.toml.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY +[build-system] +requires = [ + "hatchling==1.30.1", + "packaging==26.2", + "pathspec==1.1.1", + "pluggy==1.6.0", + "tomli==2.4.1; python_version < '3.11'", + "trove-classifiers==2026.6.1.19", +] +build-backend = "hatchling.build" + +[project] +name = "apache-airflow-providers-dq" +version = "0.1.0" +description = "Provider package apache-airflow-providers-dq for Apache Airflow" +readme = "README.rst" +license = "Apache-2.0" +license-files = ['LICENSE', 'NOTICE'] +authors = [ + {name="Apache Software Foundation", email="dev@airflow.apache.org"}, +] +maintainers = [ + {name="Apache Software Foundation", email="dev@airflow.apache.org"}, +] +keywords = [ "airflow-provider", "dq", "airflow", "integration" ] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Framework :: Apache Airflow", + "Framework :: Apache Airflow :: Provider", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: System :: Monitoring", +] +requires-python = ">=3.10" + +# The dependencies should be modified in place in the generated file. +# Any change in the dependencies is preserved when the file is regenerated +# Make sure to run ``prek update-providers-dependencies --all-files`` +# After you modify the dependencies, and rebuild your Breeze CI image with ``breeze ci-image build`` +dependencies = [ + "apache-airflow>=3.0.0", + "apache-airflow-providers-common-compat>=1.15.0", + "apache-airflow-providers-common-sql>=2.0.0", + "pydantic>=2.11.0", + "pyyaml>=6.0.2", +] + +[dependency-groups] +dev = [ + "apache-airflow", + "apache-airflow-task-sdk", + "apache-airflow-devel-common", + "apache-airflow-providers-common-compat", + "apache-airflow-providers-common-sql", + # Additional devel dependencies (do not remove this line and add extra development dependencies) + "apache-airflow-providers-common-sql", +] + +# To build docs: +# +# uv run --group docs build-docs +# +# To enable auto-refreshing build with server: +# +# uv run --group docs build-docs --autobuild +# +# To see more options: +# +# uv run --group docs build-docs --help +# +docs = [ + "apache-airflow-devel-common[docs]" +] + +[tool.uv.sources] +# These names must match the names as defined in the pyproject.toml of the workspace items, +# *not* the workspace folder paths +apache-airflow = {workspace = true} +apache-airflow-devel-common = {workspace = true} +apache-airflow-task-sdk = {workspace = true} +apache-airflow-providers-common-sql = {workspace = true} +apache-airflow-providers-standard = {workspace = true} + +[project.urls] +"Documentation" = "https://airflow.apache.org/docs/apache-airflow-providers-dq/0.1.0" +"Changelog" = "https://airflow.apache.org/docs/apache-airflow-providers-dq/0.1.0/changelog.html" +"Bug Tracker" = "https://github.com/apache/airflow/issues" +"Source Code" = "https://github.com/apache/airflow" +"Slack Chat" = "https://s.apache.org/airflow-slack" +"Mastodon" = "https://fosstodon.org/@airflow" +"YouTube" = "https://www.youtube.com/channel/UCSXwxpWZQ7XZ1WL3wqevChA/" + +[project.entry-points."apache_airflow_provider"] +provider_info = "airflow.providers.dq.get_provider_info:get_provider_info" + +[project.entry-points."airflow.plugins"] +dq = "airflow.providers.dq.plugins.dq:DQPlugin" + +[tool.hatch.version] +path = "src/airflow/providers/dq/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "docs", + "src/airflow/providers/dq", + "tests", + "NOTICE" +] +exclude = [ + "src/airflow/__init__.py", + "src/airflow/providers/__init__.py", + "src/airflow/providers/dq/plugins/www/.pnpm-store", + "src/airflow/providers/dq/plugins/www/node_modules", +] + +[tool.hatch.build.targets.custom] +path = "./hatch_build.py" + +artifacts = [ + "src/airflow/providers/dq/plugins/www/dist", +] + +[tool.hatch.build.targets.wheel] +packages = ['src/airflow'] +artifacts = [ + "src/airflow/providers/dq/plugins/www/dist", +] +exclude = [ + "src/airflow/__init__.py", + "src/airflow/providers/__init__.py", + "src/airflow/providers/dq/plugins/www/.pnpm-store", + "src/airflow/providers/dq/plugins/www/node_modules", +] diff --git a/providers/dq/src/airflow/__init__.py b/providers/dq/src/airflow/__init__.py new file mode 100644 index 0000000000000..5966d6b1d5261 --- /dev/null +++ b/providers/dq/src/airflow/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/providers/dq/src/airflow/providers/__init__.py b/providers/dq/src/airflow/providers/__init__.py new file mode 100644 index 0000000000000..5966d6b1d5261 --- /dev/null +++ b/providers/dq/src/airflow/providers/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/providers/dq/src/airflow/providers/dq/__init__.py b/providers/dq/src/airflow/providers/dq/__init__.py new file mode 100644 index 0000000000000..4fd31dec8bc1a --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/__init__.py @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE +# OVERWRITTEN WHEN PREPARING DOCUMENTATION FOR THE PACKAGES. +# +# IF YOU WANT TO MODIFY THIS FILE, YOU SHOULD MODIFY THE TEMPLATE +# `PROVIDER__INIT__PY_TEMPLATE.py.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY +# +from __future__ import annotations + +import packaging.version + +from airflow import __version__ as airflow_version + +__all__ = ["__version__"] + +__version__ = "0.1.0" + +if packaging.version.parse(packaging.version.parse(airflow_version).base_version) < packaging.version.parse( + "3.0.0" +): + raise RuntimeError(f"The package `apache-airflow-providers-dq:{__version__}` needs Apache Airflow 3.0.0+") diff --git a/providers/dq/src/airflow/providers/dq/api/__init__.py b/providers/dq/src/airflow/providers/dq/api/__init__.py new file mode 100644 index 0000000000000..21d298ede6ed3 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/api/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations diff --git a/providers/dq/src/airflow/providers/dq/api/app.py b/providers/dq/src/airflow/providers/dq/api/app.py new file mode 100644 index 0000000000000..c431fbe066a1e --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/api/app.py @@ -0,0 +1,115 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Read-only query API for data quality results.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, FastAPI, HTTPException, Query + +from airflow.api_fastapi.auth.managers.models.resource_details import DagAccessEntity +from airflow.api_fastapi.core_api.security import requires_access_dag +from airflow.providers.dq.api.models import PaginatedResponse, RuleHistoryRecordModel, TaskDQRunModel +from airflow.providers.dq.backends import get_backend_from_config +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend + + +def _get_backend() -> ObjectStorageResultsBackend: + backend = get_backend_from_config() + if backend is None: + raise HTTPException( + status_code=503, + detail="No data quality results backend configured; set '[dq] results_path'.", + ) + return backend + + +BackendDep = Annotated[ObjectStorageResultsBackend, Depends(_get_backend)] + +dq_app = FastAPI( + title="Data Quality", + description="Read-only query API over data quality check results.", +) + + +@dq_app.get("/health") +async def health() -> dict[str, str]: + """Liveness check.""" + return {"status": "ok"} + + +@dq_app.get( + "/v1/dags/{dag_id}/tasks/{task_id}/runs", + dependencies=[Depends(requires_access_dag(method="GET", access_entity=DagAccessEntity.TASK_INSTANCE))], +) +async def task_runs( + dag_id: str, + task_id: str, + backend: BackendDep, + limit: Annotated[int, Query(gt=0, le=200)] = 50, + before: Annotated[str | None, Query()] = None, +) -> PaginatedResponse[TaskDQRunModel]: + """ + Recent data quality runs for one task, newest first. + + ``before`` is the opaque ``next_cursor`` from the previous page; omit it for the first page. + """ + return PaginatedResponse[TaskDQRunModel](**backend.read_task_runs(dag_id, task_id, limit, before)) + + +@dq_app.get( + "/v1/dags/{dag_id}/tasks/{task_id}/rules/{rule_uid}/history", + dependencies=[Depends(requires_access_dag(method="GET", access_entity=DagAccessEntity.TASK_INSTANCE))], +) +async def task_rule_history( + dag_id: str, + task_id: str, + rule_uid: str, + backend: BackendDep, + limit: Annotated[int, Query(gt=0, le=1000)] = 100, + before: Annotated[str | None, Query()] = None, +) -> PaginatedResponse[RuleHistoryRecordModel]: + """ + Recent results for one rule produced by one task, newest first. + + ``before`` is the opaque ``next_cursor`` from the previous page; omit it for the first page. + """ + return PaginatedResponse[RuleHistoryRecordModel]( + **backend.read_task_rule_history(dag_id, task_id, rule_uid, limit, before) + ) + + +@dq_app.get( + "/v1/dags/{dag_id}/tasks/{task_id}/runs/by_run/{run_id}", + dependencies=[Depends(requires_access_dag(method="GET", access_entity=DagAccessEntity.TASK_INSTANCE))], +) +async def run_detail_by_task_instance( + dag_id: str, + task_id: str, + run_id: str, + backend: BackendDep, + map_index: Annotated[int, Query()] = -1, +) -> TaskDQRunModel: + """Look up a run by the route params available to the task-instance UI plugin.""" + try: + payload = backend.read_by_task_instance(dag_id, task_id, run_id, map_index) + except FileNotFoundError: + raise HTTPException( + status_code=404, detail="No data quality run found for this task instance." + ) from None + return TaskDQRunModel(**payload) diff --git a/providers/dq/src/airflow/providers/dq/api/models.py b/providers/dq/src/airflow/providers/dq/api/models.py new file mode 100644 index 0000000000000..f874f57819790 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/api/models.py @@ -0,0 +1,99 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class RuleResultModel(BaseModel): + """Mirrors ``airflow.providers.dq.results.RuleResult.to_dict()``.""" + + rule_uid: str + rule_name: str + status: str + observed_value: float | str | None = None + condition: dict[str, Any] = {} + dimension: str = "validity" + severity: str = "error" + duration_ms: float | None = None + error_message: str | None = None + description: str | None = None + sql: str | None = None + + +class DQRunModel(BaseModel): + """Mirrors ``airflow.providers.dq.results.DQRun.to_dict()`` -- the full run header.""" + + dag_id: str + task_id: str + run_id: str + try_number: int = 1 + map_index: int = -1 + run_uid: str + ruleset_name: str | None = None + table_ref: str | None = None + asset_names: list[str] = [] + started_at: str | None = None + finished_at: str | None = None + + +class RunContextModel(BaseModel): + """Mirrors ``ObjectStorageResultsBackend._build_run_context()``.""" + + run_uid: str + dag_id: str + task_id: str + run_id: str + map_index: int = -1 + started_at: str | None = None + table_ref: str | None = None + + +class DQSummaryModel(BaseModel): + """Compact summary fields returned by the Data Quality UI API.""" + + errored: int + failed: int + passed: int + score: float | None + warned: int + + +class TaskDQRunModel(BaseModel): + """One run, as returned by ``task_runs`` items and ``run_detail_by_task_instance``.""" + + run: DQRunModel + results: list[RuleResultModel] + summary: DQSummaryModel + + +class RuleHistoryRecordModel(RuleResultModel): + """A rule result plus the run context that produced it, as returned by ``task_rule_history``.""" + + run: RunContextModel + + +class PaginatedResponse(BaseModel, Generic[T]): + """``{"items": [...], "next_cursor": ...}``, shared by every cursor-paginated route.""" + + items: list[T] + next_cursor: str | None = None diff --git a/providers/dq/src/airflow/providers/dq/assets.py b/providers/dq/src/airflow/providers/dq/assets.py new file mode 100644 index 0000000000000..513587a68d9b4 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/assets.py @@ -0,0 +1,172 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Asset-level data quality declarations. + +Quality configuration lives inside ``Asset.extra`` under the ``airflow.dq`` key, so it is +serialized with the Dag and needs no Airflow core changes. The rules travel with the asset +definition instead of being scattered across the Dags that check it. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from airflow.providers.dq.exceptions import DQRuleValidationError +from airflow.providers.dq.rules import RuleSet +from airflow.sdk import task + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from airflow.sdk import Asset + from airflow.sdk.execution_time.comms import AssetEventDagRunReferenceResult + +log = logging.getLogger(__name__) + +DQ_EXTRA_KEY = "airflow.dq" +DQ_RESULT_EXTRA_KEY = "airflow.dq.result" + + +def asset_quality( + asset: Asset, + *, + ruleset: RuleSet | dict[str, Any] | str, + conn_id: str | None = None, + table: str | None = None, +) -> Asset: + """ + Attach data quality configuration to an asset, returning the same asset. + + :param asset: The asset the rules describe. + :param ruleset: A :class:`~airflow.providers.dq.rules.RuleSet`, its dict form, or a path + to a YAML ruleset file (resolved eagerly, at Dag-parse time). + :param conn_id: Default connection a check operator should use for this asset. + :param table: Default table to check; falls back to the asset name when unset. + + Usage:: + + orders = asset_quality( + Asset("orders", uri="postgres://warehouse/analytics/orders"), + ruleset=rules, + conn_id="warehouse", + table="analytics.orders", + ) + check = DQCheckOperator(task_id="dq", asset=orders) + """ + if isinstance(ruleset, str): + ruleset = RuleSet.from_file(ruleset) + elif isinstance(ruleset, dict): + ruleset = RuleSet.from_dict(ruleset) + config: dict[str, Any] = {"ruleset": ruleset.to_dict()} + if conn_id: + config["conn_id"] = conn_id + if table: + config["table"] = table + asset.extra[DQ_EXTRA_KEY] = config + return asset + + +def get_asset_quality_config(asset: Asset) -> dict[str, Any] | None: + """Return the raw ``airflow.dq`` config attached to an asset, if any.""" + config = asset.extra.get(DQ_EXTRA_KEY) + if not isinstance(config, dict): + return None + return config + + +def get_asset_ruleset(asset: Asset) -> RuleSet: + """Return the ruleset attached to an asset, raising when none is attached.""" + config = get_asset_quality_config(asset) + if not config or "ruleset" not in config: + raise DQRuleValidationError( + f"Asset {asset.name!r} has no data quality config; attach one with asset_quality()" + ) + return RuleSet.from_dict(config["ruleset"]) + + +def _quality_score_passes( + asset: Asset, + min_score: float, + triggering_asset_events: Mapping[Asset, Sequence[AssetEventDagRunReferenceResult]], +) -> bool: + """Pure decision logic behind :func:`require_quality`, kept separate so it is testable without a Dag.""" + events = triggering_asset_events.get(asset, []) + if not events: + log.warning( + "require_quality(%s): no triggering event for this run; skipping downstream tasks", + asset.name, + ) + return False + + summary = events[-1].extra.get(DQ_RESULT_EXTRA_KEY) + score = summary.get("score") if isinstance(summary, dict) else None + if not isinstance(score, (int, float)) or isinstance(score, bool): + log.warning( + "require_quality(%s): triggering event has no data quality summary; skipping downstream tasks", + asset.name, + ) + return False + + if score < min_score: + log.warning( + "require_quality(%s): score %s below required minimum %s; skipping downstream tasks", + asset.name, + score, + min_score, + ) + return False + + return True + + +def require_quality( + asset: Asset, + *, + min_score: float, + task_id: str | None = None, +) -> Any: + """ + Gate a Dag run on the data quality score attached to one of its triggering asset events. + + Reads the summary a :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` + attaches to ``asset``'s outlet event, under ``asset_event.extra["airflow.dq.result"]`` + (see :func:`asset_quality`), and short-circuits the run — skipping every downstream task + — when that summary is missing or its score is below ``min_score``. Call it inside a Dag + scheduled by ``asset``:: + + with DAG("orders_consumer", schedule=orders) as consumer: + start = require_quality(orders, min_score=0.95) + start >> process_orders() + + :param asset: The asset whose triggering event carries the quality summary. + :param min_score: Minimum required score in ``[0, 1]``; the run proceeds only when the + triggering event's score is at least this value. + :param task_id: Task id for the generated gate task. Defaults to + ``f"require_quality_{asset.name}"`` so gating on several assets in one Dag doesn't + collide on task id. + """ + if not 0 <= min_score <= 1: + raise ValueError(f"min_score must be between 0 and 1, got {min_score!r}") + gate_task_id = task_id or f"require_quality_{asset.name}" + + @task.short_circuit(task_id=gate_task_id) + def _require_quality(**context: Any) -> bool: + return _quality_score_passes(asset, min_score, context["triggering_asset_events"]) + + return _require_quality() diff --git a/providers/dq/src/airflow/providers/dq/backends/__init__.py b/providers/dq/src/airflow/providers/dq/backends/__init__.py new file mode 100644 index 0000000000000..7fe7bade843a5 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/backends/__init__.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend + +__all__ = ["ObjectStorageResultsBackend", "get_backend_from_config"] + + +def get_backend_from_config() -> ObjectStorageResultsBackend | None: + """Build the results backend configured under ``[dq]``, or ``None`` when not configured.""" + from airflow.providers.common.compat.sdk import conf + + results_path = conf.get("dq", "results_path", fallback=None) + if not results_path: + return None + conn_id = conf.get("dq", "results_conn_id", fallback=None) + return ObjectStorageResultsBackend(results_path=results_path, conn_id=conn_id or None) diff --git a/providers/dq/src/airflow/providers/dq/backends/object_storage.py b/providers/dq/src/airflow/providers/dq/backends/object_storage.py new file mode 100644 index 0000000000000..c43978a03dbeb --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/backends/object_storage.py @@ -0,0 +1,259 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Object-storage results backend. + +Each DQ check writes a keyed JSON document plus read indexes optimized for the UI: + + runs/by_task/dag_id=/task_id=/date=<2026-07-04>/.json + Canonical run record: ``{"run": ..., "results": [...], "summary": ...}``. + + runs/by_task_instance/dag_id=/task_id=/__.json + Latest result for a task-instance page. Last write wins across retries. + + rules/by_rule/rule_uid=/__.json + One rule result plus run context: ``{"run": ..., "result": ...}``. + + rules/by_task_rule/dag_id=/task_id=/rule_uid=/__.json + Same payload, scoped for task-level rule history views. + +The duplicate files are intentional read indexes: DQ tasks write once, while the UI reads +many times. Keeping these indexes avoids scanning all task runs for common UI views. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +from airflow.providers.dq.results import DQRun, RuleResult, build_summary +from airflow.sdk import ObjectStoragePath + +log = logging.getLogger(__name__) + + +class ObjectStorageResultsBackend: + """Persist DQ results as JSON files via ``ObjectStoragePath``.""" + + def __init__(self, results_path: str, conn_id: str | None = None) -> None: + self.root = ObjectStoragePath(results_path, conn_id=conn_id) + + def write_run(self, run: DQRun, results: list[RuleResult]) -> None: + timestamp = run.started_at or datetime.now(tz=timezone.utc).isoformat() + payload = self._build_run_payload(run, results) + + self._write_run_file(run, timestamp[:10], payload) + self._write_task_instance_index(run, payload) + self._write_rule_indexes(run, results, timestamp) + + def read_task_rule_history( + self, dag_id: str, task_id: str, rule_uid: str, limit: int = 100, before: str | None = None + ) -> dict[str, Any]: + """Return recent results for one rule produced by one task, newest first.""" + rule_dir = ( + self.root + / "rules" + / "by_task_rule" + / f"dag_id={dag_id}" + / f"task_id={task_id}" + / f"rule_uid={rule_uid}" + ) + return self._read_rule_history_dir(rule_dir, limit, before) + + def read_task_runs( + self, dag_id: str, task_id: str, limit: int = 50, before: str | None = None + ) -> dict[str, Any]: + """ + Return recent data quality runs for one task, newest first. + + Returns ``{"items": [...], "next_cursor": ...}``. ``before`` is the opaque + ``next_cursor`` from the previous page, so "load more" only reads runs it hasn't + shown yet. ``next_cursor`` is set by reading one extra entry past ``limit``: cheap + on every page except the last one, where there's no way to confirm history is + exhausted without walking to the end. + + ``date=`` partition names sort correctly as plain strings, so directories are walked + newest-first and scanning stops as soon as ``limit + 1`` matching runs have been + collected — a task with years of history doesn't pay for a full scan on every page. + """ + task_dir = self.root / "runs" / "by_task" / f"dag_id={dag_id}" / f"task_id={task_id}" + if not task_dir.exists(): + return {"items": [], "next_cursor": None} + + date_dirs = sorted((path for path in task_dir.iterdir() if path.is_dir()), reverse=True) + runs = [] + for date_dir in date_dirs: + for path in date_dir.iterdir(): + if not path.name.endswith(".json"): + continue + payload = self._read_json(path) + if payload is None: + continue + cursor = self._get_run_payload_cursor(payload) + if before is not None and cursor >= before: + continue + runs.append(payload) + if len(runs) > limit: + break + + ordered = sorted(runs, key=self._get_run_payload_cursor, reverse=True) + page = ordered[:limit] + next_cursor = self._get_run_payload_cursor(page[-1]) if len(ordered) > limit and page else None + return {"items": page, "next_cursor": next_cursor} + + def read_by_task_instance( + self, dag_id: str, task_id: str, run_id: str, map_index: int = -1 + ) -> dict[str, Any]: + """Read the latest run for one task instance as ``{"run": ..., "results": ..., "summary": ...}``.""" + path = ( + self.root + / "runs" + / "by_task_instance" + / f"dag_id={dag_id}" + / f"task_id={task_id}" + / f"{self._get_safe_key(run_id)}__{map_index}.json" + ) + return self._read_json_or_raise(path) + + def _write_run_file(self, run: DQRun, date_part: str, payload: dict[str, Any]) -> None: + run_dir = ( + self.root + / "runs" + / "by_task" + / f"dag_id={run.dag_id}" + / f"task_id={run.task_id}" + / f"date={date_part}" + ) + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / f"{run.run_uid}.json").write_text(json.dumps(payload, default=str)) + + def _write_task_instance_index(self, run: DQRun, payload: dict[str, Any]) -> None: + ti_dir = self.root / "runs" / "by_task_instance" / f"dag_id={run.dag_id}" / f"task_id={run.task_id}" + ti_dir.mkdir(parents=True, exist_ok=True) + (ti_dir / f"{self._get_safe_key(run.run_id)}__{run.map_index}.json").write_text( + json.dumps(payload, default=str) + ) + + def _write_rule_indexes(self, run: DQRun, results: list[RuleResult], timestamp: str) -> None: + run_context = self._build_run_context(run) + compact_ts = self._get_safe_key(timestamp) + for result in results: + payload = {"run": run_context, "result": result.to_dict()} + self._write_rule_index( + self.root / "rules" / "by_rule" / f"rule_uid={result.rule_uid}", + compact_ts, + run.run_uid, + payload, + ) + self._write_rule_index( + self.root + / "rules" + / "by_task_rule" + / f"dag_id={run.dag_id}" + / f"task_id={run.task_id}" + / f"rule_uid={result.rule_uid}", + compact_ts, + run.run_uid, + payload, + ) + + def _write_rule_index( + self, rule_dir: ObjectStoragePath, compact_ts: str, run_uid: str, payload: dict[str, Any] + ) -> None: + rule_dir.mkdir(parents=True, exist_ok=True) + (rule_dir / f"{compact_ts}__{run_uid}.json").write_text(json.dumps(payload, default=str)) + + def _read_rule_history_dir( + self, rule_dir: ObjectStoragePath, limit: int, before: str | None = None + ) -> dict[str, Any]: + """ + Read rule-result records newest-first, as ``{"items": [...], "next_cursor": ...}``. + + Reads one entry past ``limit`` to determine ``next_cursor``: cheap on every page + except the last one, where confirming there's nothing older means reading to the end. + """ + if not rule_dir.exists(): + return {"items": [], "next_cursor": None} + + history: list[dict[str, Any]] = [] + for path in sorted(rule_dir.iterdir(), key=lambda p: p.name, reverse=True): + payload = self._read_json(path) + if payload is None: + continue + record = {**payload["result"], "run": payload["run"]} + cursor = self._get_rule_history_cursor(record) + if before is not None and cursor >= before: + continue + history.append(record) + if len(history) > limit: + break + + page = history[:limit] + next_cursor = self._get_rule_history_cursor(page[-1]) if len(history) > limit and page else None + return {"items": page, "next_cursor": next_cursor} + + @staticmethod + def _get_safe_key(value: str) -> str: + """Sanitize a value (for example an Airflow ``run_id``) for an object key segment.""" + return value.replace("/", "_").replace(":", "_").replace("+", "_") + + @staticmethod + def _build_run_payload(run: DQRun, results: list[RuleResult]) -> dict[str, Any]: + result_records = [result.to_dict() for result in results] + return { + "run": run.to_dict(), + "results": result_records, + "summary": build_summary(run, results), + } + + @staticmethod + def _build_run_context(run: DQRun) -> dict[str, Any]: + return { + "run_uid": run.run_uid, + "dag_id": run.dag_id, + "task_id": run.task_id, + "run_id": run.run_id, + "map_index": run.map_index, + "started_at": run.started_at, + "table_ref": run.table_ref, + } + + @staticmethod + def _get_run_payload_cursor(payload: dict[str, Any]) -> str: + run = payload["run"] + return ObjectStorageResultsBackend._build_cursor(run.get("started_at"), run.get("run_uid")) + + @staticmethod + def _get_rule_history_cursor(record: dict[str, Any]) -> str: + run = record["run"] + return ObjectStorageResultsBackend._build_cursor(run.get("started_at"), run.get("run_uid")) + + @staticmethod + def _build_cursor(started_at: str | None, run_uid: str | None) -> str: + return f"{started_at or ''}|{run_uid or ''}" + + def _read_json_or_raise(self, path: ObjectStoragePath) -> dict[str, Any]: + return json.loads(path.read_text()) + + def _read_json(self, path: ObjectStoragePath) -> dict[str, Any] | None: + try: + return self._read_json_or_raise(path) + except (OSError, json.JSONDecodeError): + log.warning("Skipping unreadable DQ result file %s", path) + return None diff --git a/providers/dq/src/airflow/providers/dq/decorators/__init__.py b/providers/dq/src/airflow/providers/dq/decorators/__init__.py new file mode 100644 index 0000000000000..21d298ede6ed3 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/decorators/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations diff --git a/providers/dq/src/airflow/providers/dq/decorators/dq_check.py b/providers/dq/src/airflow/providers/dq/decorators/dq_check.py new file mode 100644 index 0000000000000..0385187b1d39d --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/decorators/dq_check.py @@ -0,0 +1,108 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""TaskFlow decorator for :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator`.""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Mapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar + +from airflow.providers.common.compat.sdk import ( + DecoratedOperator, + context_merge, + determine_kwargs, + task_decorator_factory, +) +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.providers.dq.rules import RuleSet +from airflow.sdk.definitions._internal.types import SET_DURING_EXECUTION + +if TYPE_CHECKING: + from airflow.sdk import Context + from airflow.sdk.bases.decorator import TaskDecorator + + +class _DQCheckDecoratedOperator(DecoratedOperator, DQCheckOperator): + """ + Wraps a callable that optionally returns a runtime ruleset for a data quality check. + + :param python_callable: A reference to a callable returning ``None`` or a ruleset for this run. + :param op_args: Positional arguments for the callable. + :param op_kwargs: Keyword arguments for the callable. + """ + + template_fields: Sequence[str] = ( + *DecoratedOperator.template_fields, + *DQCheckOperator.template_fields, + ) + template_fields_renderers: ClassVar[dict[str, str]] = { + **DecoratedOperator.template_fields_renderers, + } + + custom_operator_name = "@task.dq_check" + + def __init__( + self, + *, + python_callable: Callable, + op_args: Collection[Any] | None = None, + op_kwargs: Mapping[str, Any] | None = None, + **kwargs, + ) -> None: + kwargs.setdefault("ruleset", SET_DURING_EXECUTION) + super().__init__(python_callable=python_callable, op_args=op_args, op_kwargs=op_kwargs, **kwargs) + + def execute(self, context: Context) -> Any: + context_merge(context, self.op_kwargs) + kwargs = determine_kwargs(self.python_callable, self.op_args, context) + ruleset = self.python_callable(*self.op_args, **kwargs) + if ruleset is not None: + if not isinstance(ruleset, (RuleSet, dict, str)): + raise TypeError( + f"{self.custom_operator_name} function must return a RuleSet, dict, path, or None, " + f"got {type(ruleset).__name__}" + ) + self.ruleset = ruleset + return DQCheckOperator.execute(self, context) + + +def dq_check_task( + python_callable: Callable | None = None, + **kwargs, +) -> TaskDecorator: + """ + Turn a function into a data quality check task. + + ``ruleset`` may be passed as a decorator argument when known at Dag-parse time, or returned + by the decorated function at runtime. ``table`` or ``asset`` are passed through ``kwargs`` + exactly as on :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator`. The + function itself is optional plumbing: return ``None`` to run the check as declared, or a + ruleset to use for this run. + + Usage:: + + @task.dq_check(conn_id="warehouse", ruleset=rules) + def orders_quality(ds=None): + return None + + :param python_callable: Function to decorate. + """ + return task_decorator_factory( + python_callable=python_callable, + decorated_operator_class=_DQCheckDecoratedOperator, + **kwargs, + ) diff --git a/providers/dq/src/airflow/providers/dq/engines/__init__.py b/providers/dq/src/airflow/providers/dq/engines/__init__.py new file mode 100644 index 0000000000000..98a6f6fe9ba80 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/engines/__init__.py @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.engines.sql import Observation, SQLDQEngine + +__all__ = ["Observation", "SQLDQEngine"] diff --git a/providers/dq/src/airflow/providers/dq/engines/sql.py b/providers/dq/src/airflow/providers/dq/engines/sql.py new file mode 100644 index 0000000000000..43c2f9579f8df --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/engines/sql.py @@ -0,0 +1,137 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""SQL execution engine: compiles a ruleset into check queries run through a DB-API hook.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from airflow.providers.dq.rules import CUSTOM_SQL_CHECK +from airflow.providers.dq.rules.checks import CHECK_SPECS + +if TYPE_CHECKING: + from airflow.providers.common.sql.hooks.sql import DbApiHook + from airflow.providers.dq.rules import DQRule, RuleSet + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Observation: + """Raw value a rule observed, before its condition is evaluated.""" + + rule: DQRule + observed_value: Any = None + duration_ms: float | None = None + error_message: str | None = None + sql: str | None = None + + +class SQLDQEngine: + """ + Runs built-in checks as a single UNION ALL query and custom SQL rules individually. + + Table, column, and partition-clause values come from the Dag author and are interpolated + into SQL the same way the ``common.sql`` check operators do — they are trusted input. + """ + + check_sql_template = "SELECT '{rule_uid}' AS rule_uid, {expression} AS observed FROM {table}{where}" + + def __init__(self, hook: DbApiHook) -> None: + self.hook = hook + + def measure(self, ruleset: RuleSet, table: str, partition_clause: str | None = None) -> list[Observation]: + builtin_rules = [rule for rule in ruleset.rules if rule.check != CUSTOM_SQL_CHECK] + custom_rules = [rule for rule in ruleset.rules if rule.check == CUSTOM_SQL_CHECK] + + observations = [] + if builtin_rules: + observations.extend(self._measure_builtin(builtin_rules, table, partition_clause)) + for rule in custom_rules: + observations.append(self._measure_custom(rule, table)) + return observations + + def build_batch_sql(self, rules: list[DQRule], table: str, partition_clause: str | None) -> str: + return " UNION ALL ".join(self.build_rule_sql(rule, table, partition_clause) for rule in rules) + + def build_rule_sql(self, rule: DQRule, table: str, partition_clause: str | None = None) -> str: + """Build the SQL used to measure one built-in rule.""" + expression = CHECK_SPECS[rule.check].expression.format(column=rule.column) + predicates = [p for p in (partition_clause, rule.partition_clause) if p] + where = f" WHERE {' AND '.join(predicates)}" if predicates else "" + return self.check_sql_template.format( + rule_uid=rule.rule_uid, expression=expression, table=table, where=where + ) + + def _measure_builtin( + self, rules: list[DQRule], table: str, partition_clause: str | None + ) -> list[Observation]: + sql = self.build_batch_sql(rules, table, partition_clause) + log.info("Running %d built-in checks against %s", len(rules), table) + started = time.monotonic() + try: + records = self.hook.get_records(sql) + except Exception as e: + elapsed_ms = (time.monotonic() - started) * 1000 + log.exception("Check query failed for table %s", table) + return [ + Observation( + rule=rule, + duration_ms=elapsed_ms, + error_message=str(e), + sql=self.build_rule_sql(rule, table, partition_clause), + ) + for rule in rules + ] + elapsed_ms = (time.monotonic() - started) * 1000 + observed_by_uid = {str(row[0]): row[1] for row in records or []} + return [ + Observation( + rule=rule, + observed_value=observed_by_uid.get(rule.rule_uid), + duration_ms=elapsed_ms, + error_message=None if rule.rule_uid in observed_by_uid else "No result returned for rule", + sql=self.build_rule_sql(rule, table, partition_clause), + ) + for rule in rules + ] + + def _measure_custom(self, rule: DQRule, table: str) -> Observation: + if rule.sql is None: + raise ValueError(f"Rule {rule.name!r} has no SQL to execute") + sql = rule.sql.replace("{table}", table) + log.info("Running custom SQL check %s", rule.name) + started = time.monotonic() + try: + row = self.hook.get_first(sql) + except Exception as e: + log.exception("Custom SQL check %s failed", rule.name) + return Observation( + rule=rule, + duration_ms=(time.monotonic() - started) * 1000, + error_message=str(e), + sql=sql, + ) + elapsed_ms = (time.monotonic() - started) * 1000 + if row is None: + return Observation( + rule=rule, duration_ms=elapsed_ms, error_message="Query returned no rows", sql=sql + ) + return Observation(rule=rule, observed_value=row[0], duration_ms=elapsed_ms, sql=sql) diff --git a/providers/dq/src/airflow/providers/dq/example_dags/__init__.py b/providers/dq/src/airflow/providers/dq/example_dags/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_custom_sql.py b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_custom_sql.py new file mode 100644 index 0000000000000..f07f746be9849 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_custom_sql.py @@ -0,0 +1,112 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAG: ``custom_sql`` for checks the built-in catalog can't express. + +Built-in checks (``null_count``, ``min``, ``max``, ...) are all single-column. The moment a +rule needs to compare two columns, join against another table, or use a SQL function the +built-in catalog doesn't cover (or that your database's ``DbApiHook`` doesn't support -- see +"Supported checks and databases" in the provider docs), ``custom_sql`` is the escape hatch: +any statement that resolves to a single scalar, evaluated the same way as a built-in check. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.providers.dq.rules import DQRule, RuleSet, Severity +from airflow.sdk import DAG + +DAG_ID = "example_dq_check_custom_sql" +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_custom_sql_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") + +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +# [START howto_operator_dq_check_custom_sql] +custom_sql_ruleset = RuleSet( + name="orders_custom_sql", + rules=( + DQRule( + name="shipped_after_ordered", + check="custom_sql", + # {table} is substituted by the SQL engine at check time, not an f-string + # placeholder -- a cross-column comparison no single-column built-in can express. + sql="SELECT COUNT(*) FROM {table} WHERE shipped_at < ordered_at", + condition={"equal_to": 0}, + ), + DQRule( + name="high_value_order_ratio", + check="custom_sql", + sql=( + "SELECT CAST(SUM(CASE WHEN amount > 100 THEN 1 ELSE 0 END) AS REAL) / COUNT(*) FROM {table}" + ), + condition={"leq_to": 0.5}, + severity=Severity.WARN, + ), + ), +) +# [END howto_operator_dq_check_custom_sql] + +with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f""" + CREATE TABLE {TABLE_NAME} ( + order_id INTEGER, + amount REAL, + ordered_at TEXT, + shipped_at TEXT + ); + """, + ], + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + INSERT INTO {TABLE_NAME} (order_id, amount, ordered_at, shipped_at) VALUES + (1, 42.0, '2026-07-01', '2026-07-02'), + (2, 150.0, '2026-07-01', '2026-07-03'), + (3, 15.5, '2026-07-02', '2026-07-02'); + """, + ) + + check_orders = DQCheckOperator( + task_id="check_orders", + conn_id=CONN_ID, + table=TABLE_NAME, + ruleset=custom_sql_ruleset, + fail_on="never", + ) + + create_table >> insert_orders >> check_orders diff --git a/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_decorator_dynamic.py b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_decorator_dynamic.py new file mode 100644 index 0000000000000..2a4b782539a11 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_check_decorator_dynamic.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAG: ``@task.dq_check`` with a runtime ruleset. + +``table`` (or ``asset``) is declared as a decorator argument, exactly like the plain operator. +``ruleset`` may be declared at Dag-parse time, or returned by the decorated function at +execution time. This is useful when an upstream task, variable, or generated value decides which +ruleset to run. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.rules import DQRule, RuleSet +from airflow.sdk import DAG, task + +DAG_ID = "example_dq_check_decorator_dynamic" +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_dynamic_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") + +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +orders_ruleset = RuleSet( + name="orders_dynamic", + rules=( + DQRule(name="order_id_not_null", check="null_count", column="order_id", condition={"equal_to": 0}), + DQRule(name="row_count_present", check="row_count", condition={"greater_than": 0}), + ), +) + +strict_ruleset = RuleSet( + name="orders_dynamic_strict", + rules=( + *orders_ruleset.rules, + DQRule(name="amount_min_ge_zero", check="min", column="amount", condition={"geq_to": 0}), + ), +) + +with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f"CREATE TABLE {TABLE_NAME} (order_id INTEGER, amount REAL, ds TEXT);", + ], + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + INSERT INTO {TABLE_NAME} (order_id, amount, ds) VALUES + (1, 10.0, '{{{{ ds }}}}'), + (2, 25.5, '{{{{ ds }}}}'), + (3, 7.25, '2020-01-01'); + """, + ) + + # [START howto_decorator_dq_check_runtime_ruleset] + @task.dq_check(conn_id=CONN_ID, table=TABLE_NAME, ruleset=orders_ruleset, fail_on="never") + def check_with_ruleset_from_upstream(strict: bool = True): + """Swap in a stricter ruleset based on a signal only known at task-execution time.""" + return strict_ruleset if strict else None + + # [END howto_decorator_dq_check_runtime_ruleset] + + create_table >> insert_orders >> check_with_ruleset_from_upstream() diff --git a/providers/dq/src/airflow/providers/dq/example_dags/example_dq_llm_generated_ruleset.py b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_llm_generated_ruleset.py new file mode 100644 index 0000000000000..7ecfd743ceb91 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_llm_generated_ruleset.py @@ -0,0 +1,128 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAG: generate a ``RuleSet`` with an LLM, guided by the ``dq-rule-authoring`` skill. + +Requires the optional ``apache-airflow-providers-common-ai[skills]`` package and a configured +LLM connection (``llm_conn_id``); this Dag is not registered when common.ai isn't installed. + +An LLM is asked to propose data quality rules for a table's columns. Its ``system_prompt`` +points it at the ``dq-rule-authoring`` skill shipped with this provider (via +``AgentSkillsToolset``), so it knows the exact ``RuleSet``/``DQRule`` schema, the built-in check +catalog, and the ``Condition`` grammar, instead of guessing at field or check names. +``output_type=RuleSet`` makes pydantic-ai validate -- and self-correct -- the model's output +against the real ``RuleSet`` model before the task completes, so a malformed rule never reaches +``DQCheckOperator`` in the first place. + +The generated ``RuleSet`` is then returned by ``@task.dq_check`` at runtime, since the real +ruleset doesn't exist until the LLM task runs. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.rules import RuleSet +from airflow.sdk import DAG, task + +try: + from airflow.providers.common.ai.toolsets.skills import AgentSkillsToolset +except ImportError: + AgentSkillsToolset = None # type: ignore[assignment,misc] + +DAG_ID = "example_dq_llm_generated_ruleset" +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_llm_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") +# The skill ships next to this Dag's package, not next to this file -- resolve relative to +# __file__ so the path holds regardless of the Dag processor's working directory. +SKILLS_DIR = Path(__file__).parent.parent / "skills" / "dq-rule-authoring" + +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +if AgentSkillsToolset is not None: + with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq", "ai"], + ) as dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f""" + CREATE TABLE {TABLE_NAME} ( + order_id INTEGER, + customer_id INTEGER, + amount REAL, + region TEXT, + created_at TEXT + ); + """, + ], + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, region, created_at) VALUES + (1, 101, 10.0, 'US', '2026-07-01'), + (2, 102, 25.5, 'EU', '2026-07-02'), + (3, NULL, 7.25, 'US', '2026-07-03'); + """, + ) + + # [START howto_task_dq_generate_ruleset_with_llm] + @task.llm( + llm_conn_id="pydanticai_default", + system_prompt=( + "You are a data quality engineer. Before answering, consult the " + "dq-rule-authoring skill for the exact RuleSet/DQRule schema, the built-in " + "check catalog, and the Condition grammar -- do not invent field or check names." + ), + output_type=RuleSet, + agent_params={"toolsets": [AgentSkillsToolset(sources=[str(SKILLS_DIR)])]}, + ) + def generate_ruleset(): + return ( + "Generate a RuleSet named 'orders_llm_quality' for a table with these columns: " + "order_id (integer, primary key), customer_id (integer, nullable foreign key), " + "amount (float, must be non-negative), region (short string, low-cardinality), " + "created_at (date). Include at least a not-null check on order_id, a uniqueness " + "check on order_id, and a row-count check." + ) + + # [END howto_task_dq_generate_ruleset_with_llm] + + # [START howto_decorator_dq_check_llm_runtime_ruleset] + @task.dq_check(conn_id=CONN_ID, table=TABLE_NAME, fail_on="never") + def check_with_llm_ruleset(ruleset: RuleSet): + return ruleset + + # [END howto_decorator_dq_check_llm_runtime_ruleset] + + generated_ruleset = generate_ruleset() + checked = check_with_llm_ruleset(generated_ruleset) + + create_table >> insert_orders >> generated_ruleset >> checked diff --git a/providers/dq/src/airflow/providers/dq/example_dags/example_dq_require_quality.py b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_require_quality.py new file mode 100644 index 0000000000000..124156604a11e --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_require_quality.py @@ -0,0 +1,120 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAGs: gate a consumer Dag on the data quality score of the asset that triggers it. + +Two Dags, wired together only through the ``dq_gated_orders`` asset: + +- ``example_dq_require_quality_producer`` checks a table and produces the asset. + :class:`~airflow.providers.dq.operators.dq_check.DQCheckOperator` attaches its summary + (including the quality ``score``) to the asset event automatically because the asset carries + quality config attached with :func:`~airflow.providers.dq.assets.asset_quality`. +- ``example_dq_require_quality_consumer`` is scheduled by that asset. Its first task, built by + :func:`~airflow.providers.dq.assets.require_quality`, reads the score off the triggering + asset event and short-circuits -- skipping every downstream task -- when it's missing or + below ``min_score``. Downstream tasks never see a run triggered by a bad batch of data. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.assets import asset_quality, require_quality +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.providers.dq.rules import DQRule, RuleSet +from airflow.sdk import DAG, Asset, task + +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_gated_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") + +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +# [START howto_asset_quality] +gated_orders_ruleset = RuleSet( + name="gated_orders_quality", + rules=( + DQRule(name="order_id_not_null", check="null_count", column="order_id", condition={"equal_to": 0}), + DQRule(name="amount_min_ge_zero", check="min", column="amount", condition={"geq_to": 0}), + DQRule(name="row_count_present", check="row_count", condition={"greater_than": 0}), + ), +) + +gated_orders_asset = asset_quality( + Asset("dq_gated_orders", uri="file:///tmp/airflow_dq_example/gated_orders"), + ruleset=gated_orders_ruleset, + conn_id=CONN_ID, + table=TABLE_NAME, +) +# [END howto_asset_quality] + +with DAG( + dag_id="example_dq_require_quality_producer", + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as producer_dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f"CREATE TABLE {TABLE_NAME} (order_id INTEGER, amount REAL);", + ], + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + INSERT INTO {TABLE_NAME} (order_id, amount) VALUES + (1, 10.0), + (2, 25.5), + (3, 7.25); + """, + ) + + # Producing check: no explicit outlets needed, DQCheckOperator adds the asset itself + # because it was passed via asset=. + check_orders = DQCheckOperator( + task_id="check_orders", + asset=gated_orders_asset, + fail_on="never", + ) + + create_table >> insert_orders >> check_orders + +# [START howto_require_quality] +with DAG( + dag_id="example_dq_require_quality_consumer", + schedule=gated_orders_asset, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as consumer_dag: + gate = require_quality(gated_orders_asset, min_score=0.95) + + @task + def process_orders(): + print("Processing orders -- quality gate passed.") + + gate >> process_orders() +# [END howto_require_quality] diff --git a/providers/dq/src/airflow/providers/dq/example_dags/example_dq_ruleset_from_yaml.py b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_ruleset_from_yaml.py new file mode 100644 index 0000000000000..25b9a6cf76abd --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/example_dq_ruleset_from_yaml.py @@ -0,0 +1,83 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAG: load a ruleset from a YAML file instead of declaring it in Python. + +A path string is accepted anywhere a :class:`~airflow.providers.dq.rules.RuleSet` is -- +``DQCheckOperator(ruleset=...)``, ``@task.dq_check(ruleset=...)``, and +:func:`~airflow.providers.dq.assets.asset_quality` all resolve it via +:meth:`~airflow.providers.dq.rules.RuleSet.from_file` at Dag-parse time. This keeps rules +editable by people who don't write Python -- a data steward can change ``orders_ruleset.yaml`` +without touching the Dag file. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.sdk import DAG + +DAG_ID = "example_dq_ruleset_from_yaml" +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_yaml_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") +RULESET_FILE = Path(__file__).parent / "orders_ruleset.yaml" + +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f"CREATE TABLE {TABLE_NAME} (order_id INTEGER, amount REAL);", + ], + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + INSERT INTO {TABLE_NAME} (order_id, amount) VALUES + (1, 10.0), + (2, 25.5), + (3, 7.25); + """, + ) + + # [START howto_operator_dq_check_ruleset_from_yaml] + check_orders = DQCheckOperator( + task_id="check_orders", + conn_id=CONN_ID, + table=TABLE_NAME, + ruleset=str(RULESET_FILE), + fail_on="never", + ) + # [END howto_operator_dq_check_ruleset_from_yaml] + + create_table >> insert_orders >> check_orders diff --git a/providers/dq/src/airflow/providers/dq/example_dags/orders_ruleset.yaml b/providers/dq/src/airflow/providers/dq/example_dags/orders_ruleset.yaml new file mode 100644 index 0000000000000..049e69543bc32 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/example_dags/orders_ruleset.yaml @@ -0,0 +1,36 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +--- + +name: orders_from_yaml +rules: + - name: order_id_not_null + check: null_count + column: order_id + condition: + equal_to: 0 + dimension: completeness + - name: amount_min_ge_zero + check: min + column: amount + condition: + geq_to: 0 + - name: row_count_present + check: row_count + condition: + greater_than: 0 + severity: warn diff --git a/providers/dq/src/airflow/providers/dq/exceptions.py b/providers/dq/src/airflow/providers/dq/exceptions.py new file mode 100644 index 0000000000000..9350308d46501 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/exceptions.py @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + + +class DQRuleValidationError(ValueError): + """Raised when a rule or ruleset definition is invalid.""" + + +class DQCheckFailedError(RuntimeError): + """Raised when a data quality check fails at or above the operator's ``fail_on`` severity.""" diff --git a/providers/dq/src/airflow/providers/dq/get_provider_info.py b/providers/dq/src/airflow/providers/dq/get_provider_info.py new file mode 100644 index 0000000000000..ae5b6cc9b782c --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/get_provider_info.py @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# NOTE! THIS FILE IS AUTOMATICALLY GENERATED AND WILL BE OVERWRITTEN! +# +# IF YOU WANT TO MODIFY THIS FILE, YOU SHOULD MODIFY THE TEMPLATE +# `get_provider_info_TEMPLATE.py.jinja2` IN the `dev/breeze/src/airflow_breeze/templates` DIRECTORY + + +def get_provider_info(): + return { + "package-name": "apache-airflow-providers-dq", + "name": "Data Quality", + "description": "``Data Quality Provider``\n\nDeclarative data quality rules with durable, per-rule execution history.\nChecks run through ``common.sql`` DB-API hooks; results are persisted to a\nconfigurable results store (object storage or local files) so task, run,\nand rule-level quality can be inspected over time.\n", + "integrations": [ + { + "integration-name": "Data Quality", + "external-doc-url": "https://airflow.apache.org/docs/apache-airflow-providers-dq/", + "how-to-guide": ["/docs/apache-airflow-providers-dq/operators.rst"], + "tags": ["software"], + } + ], + "operators": [ + { + "integration-name": "Data Quality", + "python-modules": ["airflow.providers.dq.operators.dq_check"], + } + ], + "task-decorators": [ + {"class-name": "airflow.providers.dq.decorators.dq_check.dq_check_task", "name": "dq_check"} + ], + "plugins": [{"name": "dq", "plugin-class": "airflow.providers.dq.plugins.dq.DQPlugin"}], + "config": { + "dq": { + "description": "Configuration for the Data Quality provider results store.\n", + "options": { + "results_path": { + "description": "Any fsspec-compatible URL understood by ``ObjectStoragePath`` where data quality\nresults are persisted, e.g. ``s3://bucket/airflow-dq`` or ``file:///opt/airflow/dq``.\nWhen unset, checks still run but no history is persisted.\n", + "version_added": "0.1.0", + "type": "string", + "example": "s3://data-platform/airflow-dq", + "default": None, + }, + "results_conn_id": { + "description": "Optional Airflow connection used to access ``results_path``.\n", + "version_added": "0.1.0", + "type": "string", + "example": "aws_default", + "default": None, + }, + }, + } + }, + } diff --git a/providers/dq/src/airflow/providers/dq/operators/__init__.py b/providers/dq/src/airflow/providers/dq/operators/__init__.py new file mode 100644 index 0000000000000..f1734cd8846f9 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/operators/__init__.py @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.operators.dq_check import DQCheckOperator + +__all__ = ["DQCheckOperator"] diff --git a/providers/dq/src/airflow/providers/dq/operators/dq_check.py b/providers/dq/src/airflow/providers/dq/operators/dq_check.py new file mode 100644 index 0000000000000..f006a54686010 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/operators/dq_check.py @@ -0,0 +1,247 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import logging +from collections.abc import Sequence +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, cast + +from airflow.providers.common.sql.operators.sql import BaseSQLOperator +from airflow.providers.dq.assets import DQ_RESULT_EXTRA_KEY, get_asset_quality_config +from airflow.providers.dq.backends import get_backend_from_config +from airflow.providers.dq.engines.sql import SQLDQEngine +from airflow.providers.dq.exceptions import DQCheckFailedError +from airflow.providers.dq.results import ERROR, FAIL, PASS, WARN, DQRun, RuleResult, build_summary +from airflow.providers.dq.rules import RuleSet, describe_rule +from airflow.sdk.definitions._internal.types import SET_DURING_EXECUTION + +if TYPE_CHECKING: + from airflow.providers.common.sql.hooks.sql import DbApiHook + from airflow.providers.dq.engines.sql import Observation + from airflow.providers.dq.rules.rule import Condition, Dimension + from airflow.sdk import Asset, Context + +log = logging.getLogger(__name__) + +FAIL_ON_CHOICES = ("error", "warn", "never") + + +class DQCheckOperator(BaseSQLOperator): + """ + Run a ruleset against a table and persist per-rule results to the configured results store. + + Every rule is evaluated and recorded regardless of the task outcome. Execution errors + (a check query failing) always fail the task; rule failures fail the task according to + ``fail_on``. + + :param ruleset: A :class:`~airflow.providers.dq.rules.RuleSet`, its dict form, or a path + to a YAML ruleset file. Optional when ``asset`` carries one. + :param table: The table to run checks against. Optional when ``asset`` carries one + (falling back to the asset name). + :param asset: An asset decorated with :func:`~airflow.providers.dq.assets.asset_quality`. + Supplies defaults for ``ruleset``, ``table``, and ``conn_id`` (explicit arguments + win) and is automatically added to the task's outlets so its asset events carry + the check summary. + :param partition_clause: Predicate ANDed into every built-in check's WHERE clause, + e.g. ``"ds = '{{ ds }}'"``. + :param fail_on: Severity that fails the task: ``error`` (default — warn-severity rule + failures are recorded but don't fail), ``warn`` (any rule failure fails), or + ``never`` (only execution errors fail). + :param conn_id: Connection to the database to check (any ``DbApiHook``-compatible type). + :param database: Optional database/schema overriding the connection's default. + + Results are persisted to the backend configured under ``[dq] results_path``; when that's + unset, checks still run but no history is persisted. There is no per-operator override -- + all checks in a deployment share one results store. + """ + + template_fields: Sequence[str] = ("table", "partition_clause", *BaseSQLOperator.template_fields) + ui_color: str = "#87ceeb" + + def __init__( + self, + *, + ruleset: RuleSet | dict[str, Any] | str | None = None, + table: str | None = None, + asset: Asset | None = None, + partition_clause: str | None = None, + fail_on: str = "error", + **kwargs, + ) -> None: + if asset is not None: + config = get_asset_quality_config(asset) or {} + ruleset = ruleset if ruleset is not None else config.get("ruleset") + table = table or config.get("table") or asset.name + kwargs.setdefault("conn_id", config.get("conn_id")) + outlets = list(kwargs.get("outlets") or []) + if asset not in outlets: + outlets.append(asset) + kwargs["outlets"] = outlets + super().__init__(**kwargs) + if fail_on not in FAIL_ON_CHOICES: + raise ValueError(f"fail_on must be one of {FAIL_ON_CHOICES}, got {fail_on!r}") + if ruleset is None: + raise ValueError("ruleset is required, either directly or via an asset_quality() asset") + if not table: + raise ValueError("table is required, either directly or via an asset_quality() asset") + self.ruleset = ruleset + self.table = table + self.asset = asset + self.partition_clause = partition_clause + self.fail_on = fail_on + + def execute(self, context: Context) -> dict[str, Any]: + ruleset = self._resolve_ruleset() + started_at = datetime.now(tz=timezone.utc).isoformat() + engine = self._get_engine(self.get_db_hook()) + observations = engine.measure(ruleset, self.table, self.partition_clause) + results = [self._evaluate(observation) for observation in observations] + finished_at = datetime.now(tz=timezone.utc).isoformat() + + run = self._build_run(context, ruleset, started_at, finished_at) + summary = build_summary(run, results) + self._log_results(results, summary) + self._persist(run, results) + self._attach_to_outlet_events(context, summary) + self._raise_for_failures(results, summary) + return summary + + def _get_engine(self, hook: DbApiHook) -> SQLDQEngine: + return SQLDQEngine(hook) + + def _resolve_ruleset(self) -> RuleSet: + if self.ruleset is SET_DURING_EXECUTION: + raise ValueError("ruleset is required, either directly or returned by @task.dq_check") + if isinstance(self.ruleset, RuleSet): + return self.ruleset + if isinstance(self.ruleset, dict): + return RuleSet.from_dict(self.ruleset) + return RuleSet.from_file(self.ruleset) + + @staticmethod + def _evaluate(observation: Observation) -> RuleResult: + rule = observation.rule + condition = cast("Condition", rule.condition) + dimension = cast("Dimension", rule.dimension) + error_message = observation.error_message + if error_message is None: + try: + passed = condition.evaluate(observation.observed_value) + except (TypeError, ValueError) as e: + passed = False + error_message = f"Could not evaluate observed value {observation.observed_value!r}: {e}" + else: + passed = False + + if error_message is not None: + status = ERROR + elif passed: + status = PASS + else: + status = WARN if rule.severity == "warn" else FAIL + observed = observation.observed_value + if observed is not None and not isinstance(observed, int | float | str): + observed = str(observed) + return RuleResult( + rule_uid=rule.rule_uid, + rule_name=rule.name, + status=status, + observed_value=observed, + condition=condition.to_dict(), + dimension=dimension.value, + severity=rule.severity.value, + duration_ms=observation.duration_ms, + error_message=error_message, + description=rule.description or describe_rule(rule), + sql=observation.sql, + ) + + def _build_run(self, context: Context, ruleset: RuleSet, started_at: str, finished_at: str) -> DQRun: + ti = context["ti"] + return DQRun( + dag_id=ti.dag_id, + task_id=ti.task_id, + run_id=ti.run_id, + try_number=ti.try_number, + map_index=ti.map_index if ti.map_index is not None else -1, + ruleset_name=ruleset.name, + table_ref=self.table, + asset_names=tuple(self._outlet_asset_names()), + started_at=started_at, + finished_at=finished_at, + ) + + def _outlet_asset_names(self) -> list[str]: + names = [] + for outlet in self.outlets or []: + name = getattr(outlet, "name", None) + if name: + names.append(name) + return names + + def _persist(self, run: DQRun, results: list[RuleResult]) -> None: + backend = get_backend_from_config() + if backend is None: + self.log.info("No [dq] results_path configured; skipping results persistence") + return + # Persistence is best-effort: an unreachable results store leaves a gap in + # history but must not change the outcome of the check itself. + try: + backend.write_run(run, results) + except Exception: + self.log.exception("Failed to persist data quality results; continuing") + + def _attach_to_outlet_events(self, context: Context, summary: dict[str, Any]) -> None: + try: + outlet_events = context["outlet_events"] + except KeyError: + return + for outlet in self.outlets or []: + if getattr(outlet, "name", None): + try: + outlet_events[outlet].extra[DQ_RESULT_EXTRA_KEY] = summary + except Exception: + self.log.warning("Could not attach dq summary to outlet event for %s", outlet) + + def _log_results(self, results: list[RuleResult], summary: dict[str, Any]) -> None: + for result in results: + self.log.info( + "Rule %s [%s/%s]: %s (observed=%s, condition=%s)", + result.rule_name, + result.dimension, + result.severity, + result.status.upper(), + result.observed_value, + result.condition, + ) + self.log.info("Data quality score: %s", summary["score"]) + + def _raise_for_failures(self, results: list[RuleResult], summary: dict[str, Any]) -> None: + errored = [r.rule_name for r in results if r.status == ERROR] + if errored: + raise DQCheckFailedError(f"Check execution errored for rules: {errored}") + failed = [r.rule_name for r in results if r.status == FAIL] + warned = [r.rule_name for r in results if r.status == WARN] + if self.fail_on == "error" and failed: + raise DQCheckFailedError(f"Data quality rules failed: {failed} (score={summary['score']})") + if self.fail_on == "warn" and (failed or warned): + raise DQCheckFailedError( + f"Data quality rules failed: {failed + warned} (score={summary['score']})" + ) + if failed or warned: + self.log.warning("Rule failures below fail_on=%s threshold: %s", self.fail_on, failed + warned) diff --git a/providers/dq/src/airflow/providers/dq/plugins/__init__.py b/providers/dq/src/airflow/providers/dq/plugins/__init__.py new file mode 100644 index 0000000000000..21d298ede6ed3 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations diff --git a/providers/dq/src/airflow/providers/dq/plugins/dq.py b/providers/dq/src/airflow/providers/dq/plugins/dq.py new file mode 100644 index 0000000000000..31221d6925117 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/dq.py @@ -0,0 +1,104 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import mimetypes +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from fastapi.staticfiles import StaticFiles + +from airflow.plugins_manager import AirflowPlugin +from airflow.providers.common.compat.sdk import conf +from airflow.providers.dq.api.app import dq_app +from airflow.providers.dq.version_compat import AIRFLOW_V_3_1_PLUS + +_PLUGIN_PREFIX = "/dq" + + +def _get_base_url_path(path: str) -> str: + """Construct URL path with webserver base_url prefix for non-root deployments.""" + base_url = conf.get("api", "base_url", fallback="/") + if base_url.startswith(("http://", "https://")): + base_path = urlparse(base_url).path + else: + base_path = base_url + base_path = base_path.rstrip("/") + return base_path + path + + +def _get_bundle_url() -> str: + """ + Return the bundle URL for the React plugin. + + Uses an absolute URL when ``api.base_url`` is a full URL so the bundle loads correctly in + Vite dev mode, where ``import()`` resolves relative to the script origin (5175) rather + than the document origin (28080). + """ + path = _get_base_url_path(f"{_PLUGIN_PREFIX}/static/main.umd.cjs") + base_url = conf.get("api", "base_url", fallback="/") + if base_url.startswith(("http://", "https://")): + parsed = urlparse(base_url) + return f"{parsed.scheme}://{parsed.netloc}" + path + return path + + +# Ensure proper MIME type for the plugin bundle (FastAPI serves .cjs as text/plain by default). +mimetypes.add_type("application/javascript", ".cjs") + + +def _react_apps(dist_dir: Path) -> list[dict[str, str]]: + """ + React app entries, gated on the bundle actually being built. + + ``dist_dir`` isn't populated by a plain source checkout — it's a build artifact produced + by ``pnpm build`` (wired into the wheel build via ``hatch_build.py``). Advertising the + entry before that would point the UI at a 404. + """ + if not dist_dir.is_dir(): + return [] + return [ + { + "name": "Data Quality", + "bundle_url": _get_bundle_url(), + "destination": "task", + "url_route": "dq-task", + }, + { + "name": "Data Quality", + "bundle_url": _get_bundle_url(), + "destination": "task_instance", + "url_route": "dq-run", + }, + ] + + +_WWW_DIR = Path(__file__).parent / "www" +_dist_dir = _WWW_DIR / "dist" +if AIRFLOW_V_3_1_PLUS and _dist_dir.is_dir(): + dq_app.mount("/static", StaticFiles(directory=str(_dist_dir.absolute())), name="dq_static") + + +class DQPlugin(AirflowPlugin): + """Register the data quality read-only query API and task-instance React view.""" + + name = "dq" + fastapi_apps: list[dict[str, Any]] = ( + [{"name": "dq", "app": dq_app, "url_prefix": _PLUGIN_PREFIX}] if AIRFLOW_V_3_1_PLUS else [] + ) + react_apps: list[dict[str, str]] = _react_apps(_dist_dir) if AIRFLOW_V_3_1_PLUS else [] diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/.gitignore b/providers/dq/src/airflow/providers/dq/plugins/www/.gitignore new file mode 100644 index 0000000000000..10f8e3ae86fbe --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.pnpm-store +# Stray atomic-write temp files pnpm can leave behind after an interrupted install +_tmp_* +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Coverage +coverage diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/index.html b/providers/dq/src/airflow/providers/dq/plugins/www/index.html new file mode 100644 index 0000000000000..504c624990137 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/index.html @@ -0,0 +1,12 @@ + + + + + + Data Quality + + +
+ + + diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/package.json b/providers/dq/src/airflow/providers/dq/plugins/www/package.json new file mode 100644 index 0000000000000..0f0d1bbdbe99d --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/package.json @@ -0,0 +1,62 @@ +{ + "name": "dq-plugin", + "packageManager": "pnpm@9.14.2", + "private": true, + "version": "0.0.0", + "engines": { + "node": ">=22" + }, + "type": "module", + "main": "./dist/main.js", + "module": "./dist/main.js", + "types": "./dist/main.d.ts", + "exports": { + ".": { + "import": "./dist/main.js", + "types": "./dist/main.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "vite --port 5175 --strictPort", + "build": "vite build", + "build:types": "tsc --p tsconfig.lib.json", + "build:lib": "vite build", + "lint": "tsc --p tsconfig.app.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@chakra-ui/react": "^3.34.0", + "@emotion/react": "^11.14.0", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.3", + "typescript": "~5.9.3", + "vite": "^8.0.16", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-dts": "^4.5.4" + }, + "pnpm": { + "minimumReleaseAge": 5760, + "overrides": { + "minimatch@>=10.0.0 <10.2.3": ">=10.2.3", + "brace-expansion@>=2.0.0 <2.0.3": ">=2.0.3", + "brace-expansion@>=4.0.0 <5.0.5": ">=5.0.5", + "picomatch@>=4.0.0 <4.0.4": ">=4.0.4", + "yaml@>=1.0.0 <1.10.3": ">=1.10.3", + "lodash@>=4.0.0 <=4.17.23": ">=4.18.0", + "lodash@<=4.17.23": ">=4.18.0", + "vite@>=7.0.0 <=7.3.1": ">=7.3.2", + "vite@>=7.1.0 <=7.3.1": ">=7.3.2", + "postcss@<8.5.10": ">=8.5.10", + "fast-uri@<=3.1.0": ">=3.1.1", + "fast-uri@<=3.1.1": ">=3.1.2" + } + } +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/pnpm-lock.yaml b/providers/dq/src/airflow/providers/dq/plugins/www/pnpm-lock.yaml new file mode 100644 index 0000000000000..051dffff5b113 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/pnpm-lock.yaml @@ -0,0 +1,2728 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + minimatch@>=10.0.0 <10.2.3: '>=10.2.3' + brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3' + brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5' + picomatch@>=4.0.0 <4.0.4: '>=4.0.4' + yaml@>=1.0.0 <1.10.3: '>=1.10.3' + lodash@>=4.0.0 <=4.17.23: '>=4.18.0' + lodash@<=4.17.23: '>=4.18.0' + vite@>=7.0.0 <=7.3.1: '>=7.3.2' + vite@>=7.1.0 <=7.3.1: '>=7.3.2' + postcss@<8.5.10: '>=8.5.10' + fast-uri@<=3.1.0: '>=3.1.1' + fast-uri@<=3.1.1: '>=3.1.2' + +importers: + + .: + dependencies: + '@chakra-ui/react': + specifier: ^3.34.0 + version: 3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.17)(react@19.2.7) + react: + specifier: ^19.2.4 + version: 19.2.7 + react-dom: + specifier: ^19.2.4 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react-swc': + specifier: ^4.2.3 + version: 4.3.1(@swc/helpers@0.5.23)(vite@8.1.3(yaml@2.9.0)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.16 + version: 8.1.3(yaml@2.9.0) + vite-plugin-css-injected-by-js: + specifier: ^3.5.2 + version: 3.5.2(vite@8.1.3(yaml@2.9.0)) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(typescript@5.9.3)(vite@8.1.3(yaml@2.9.0)) + +packages: + + '@ark-ui/react@5.37.2': + resolution: {integrity: sha512-Q0R2Ah50kUhup0Ljxg65zGJq5yBV52BLm1coRkjHHid40d1yclaDGfhPL48kcF/xtjAFlGLkL6SiENkGvfh+mw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@chakra-ui/react@3.36.0': + resolution: {integrity: sha512-6AxUbJsC6yyTzPeYL8sxyAL07lflT0NA+S6tcPzEuwdMux+benRMFOpPktnkifWKl/Vq/JD7fhxDyMuDQ4M0gA==} + peerDependencies: + '@emotion/react': '>=11' + react: '>=18' + react-dom: '>=18' + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@microsoft/api-extractor-model@7.33.8': + resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} + + '@microsoft/api-extractor@7.58.9': + resolution: {integrity: sha512-S2UF4yza5GoxCmf7hJQNxJNZN9ltOVuOQv8Dy+Z21aol5ERoBNMdWcQHm4MJMPPItW4H/4rZD906iaf4mUojJA==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@pandacss/is-valid-prop@1.11.4': + resolution: {integrity: sha512-RWxInlS+lGgKiF0fB0HO76vsJFgRvbavm5Z25/GqqN8MPHXYA6n5rZnfdp4itEXy5DJkQ9vt3yrwa2IKiuhtrA==} + engines: {node: '>=20'} + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rushstack/node-core-library@5.23.1': + resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.2.1': + resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.7.3': + resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} + + '@rushstack/terminal@0.24.0': + resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.3.10': + resolution: {integrity: sha512-fwI076HYknC0IrMXdY6UmjDv+PH7NHhNJX3/pY2UblSE5XrXgndXZPiOe/6ZtuFpn6DvVDVNhtkIzQ+Qu/MhVQ==} + + '@swc/core-darwin-arm64@1.15.43': + resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.43': + resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.43': + resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.43': + resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.43': + resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.15.43': + resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.15.43': + resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.43': + resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.43': + resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.43': + resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.43': + resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.43': + resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.43': + resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react-swc@4.3.1': + resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: '>=7.3.2' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + '@zag-js/accordion@1.41.2': + resolution: {integrity: sha512-7G//V7svGGT8k5avw7bbQvbRC0Q/9QtX51b4iyAB1alR9E5mFd6Ch8q4njwcXClMQ7xePS3jUfVnzVGiRInEiQ==} + + '@zag-js/anatomy@1.41.2': + resolution: {integrity: sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg==} + + '@zag-js/angle-slider@1.41.2': + resolution: {integrity: sha512-+7bZHAZx0MEbjTMr2tD+meFJ0EJwFfUEcTqmdLzFGr/ySAMCWlcadDBz+ZmrSn03aKLps8FxliVLzsFJNgUqIQ==} + + '@zag-js/aria-hidden@1.41.2': + resolution: {integrity: sha512-qEcYmwlQr3qjA0T/IZ5a/o7fRUxfQ14tXjAFhR3GXCtxBKaqS+wnq/LN09Xw4bin3QWTINU+Z0oXFs9spWtNwA==} + + '@zag-js/async-list@1.41.2': + resolution: {integrity: sha512-NZZEIGFdeDp2uHjsLVegLAGJOYGwI9HPJI1V2c/P1TQmfmrfWyWELAvnnW4kWYVUKYD9TxKQkm6LvqpHrQzgfQ==} + + '@zag-js/auto-resize@1.41.2': + resolution: {integrity: sha512-CYq+JQ1TTkEiK7OcNcMTS0f4wFvtmManvUltije5o50gDQ7vFYA81oQh4A9pAB1keUi9Zv06CNefFARaTcne5w==} + + '@zag-js/avatar@1.41.2': + resolution: {integrity: sha512-+4K0tRtIQysMGuERh5JRmn46uq7gJ6IjJd5DKj74VBtVVY8T6HGk0D0DZxmOIgADHP1qSvowLDoOX8kUyBHarQ==} + + '@zag-js/carousel@1.41.2': + resolution: {integrity: sha512-H6sDxyWIzkvtHN4M/BYvFy7pi8j+kLEtfPZvgNl2+gRnfAHVK9/XqpoUsjw1DAo5Fh25pPuaTnh52Kml9OK0iA==} + + '@zag-js/cascade-select@1.41.2': + resolution: {integrity: sha512-dBByZmIAJU/B+YIAzczng05lCJXEwEm4df6GmUZtioKHZdeN+WEKUP4qzFDFZdytXympGfJCIBvtgf87gACYVQ==} + + '@zag-js/checkbox@1.41.2': + resolution: {integrity: sha512-aQ5dZWHUHRIw4cbLtzrR+dc8M0N6wSiiCml/zbU3ciHOTBXSrs2rKnp/L99xhi97Lj2uCEhpIZ704Ou/MjGuCg==} + + '@zag-js/clipboard@1.41.2': + resolution: {integrity: sha512-FM+PNGEeY+YpF1dVr9d8kg3DQM66LRnkR4Mva1oprqnrCXTYWj+k7uig893ubSG2xw1GO5b/WJd27kSmNoKnmw==} + + '@zag-js/collapsible@1.41.2': + resolution: {integrity: sha512-uMIp4rqd3iI6VFPMAOcbu9dh9WV4l85nNPYQiBtMKRHgbkfaZdfzF+E+NX3KquIeHW6JiBFUiIyCU0Sf2Cscdw==} + + '@zag-js/collection@1.41.2': + resolution: {integrity: sha512-ZZWuvfPZI8ccWd4aLpuU47k1jSc9eO+F3FM3iBJuvgegCH6g3+HDEwGN6wdePHnYfv7zyIKCGKr816zXcIWQbw==} + + '@zag-js/color-picker@1.41.2': + resolution: {integrity: sha512-UynyJ/bTBeSlq6ziHr9GVrFycDjVxfLFIb8Aivu/XvihrAAvkhXUxwy+1ax+hj7peGlTY590xoQAvQixV+McBA==} + + '@zag-js/color-utils@1.41.2': + resolution: {integrity: sha512-Lsi5c2ztGZqud0oeDtAn3xFhgrYMaeaz1zi4p+mr0zXOuQNuZzfsrJ0stQ45s8L/xT8UJtGhYyEVy1+xjE4ARA==} + + '@zag-js/combobox@1.41.2': + resolution: {integrity: sha512-V9jQteyQHs8ZNQx/FcA98P1NVZas/yjiW1V7s4L+h8MnRS3AK97+FAHAT83KCiZ34/vkpLF6ZEHI2zkcQpNXcg==} + + '@zag-js/core@1.41.2': + resolution: {integrity: sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw==} + + '@zag-js/date-input@1.41.2': + resolution: {integrity: sha512-J8PdpEpM9TBxZBEE8/Nxi39LNJOZY7lilTbgcDABR5uiviZUk5++5GSdUTspcjFUIXx5SvqmdCk2RF7hsUEHVQ==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-picker@1.41.2': + resolution: {integrity: sha512-j9LaznCV4QCfrq/y/mNAN9XgIRFFg+414TxGT4TK8mfWouIaFvxEoKdGP0l/LL4DFv1NRDaRdSBBcw3eXtMVnA==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.41.2': + resolution: {integrity: sha512-jdcWLa5fYLTsvGWJkx4v/9Hzqf6UHdvJDeP6NxHpwoEmzYy7+AghYKBNSnRrJIBCQJgl1vM9OkpMvYrLNHr9jw==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.41.2': + resolution: {integrity: sha512-t1N72snpFGiKj7cg2PivPdsy+X1YdgXPv7bzovpqjMLy0kN+gYTPoV1Iy/hQA+g021dz9XGgXzkDuU45sJqsFA==} + + '@zag-js/dismissable@1.41.2': + resolution: {integrity: sha512-hO/tFhRZ7S+LOOljGOQJIubbc3MXg41+iWR1yUXKl76cAenbxaCit1LZmUCwQPvRN0GndK6bDQo5ETjHZz/k7A==} + + '@zag-js/dom-query@1.41.2': + resolution: {integrity: sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA==} + + '@zag-js/drawer@1.41.2': + resolution: {integrity: sha512-aJql6L0cfHd1wXbemfLcNnjqTA/CbwVgQdWEVn5Qci6zhy4UJXPQeBBfxfgPgEOAbW60gL/gaaXG3d9Vx6+6oA==} + + '@zag-js/editable@1.41.2': + resolution: {integrity: sha512-loTM1lrHBBqYfR8SJZrYayVdWHMpXCLVir+aDTQ8/d4bb/Gfl/L8CJNs3BRj3yt9zvLoWTLO+4LOY3hLyQSR2w==} + + '@zag-js/file-upload@1.41.2': + resolution: {integrity: sha512-WFwIaKvHpCUjyYMvp42VoydT1WIP5DhDlpmG/nrF4i0ro7pLGb1A4dvyBrAfGcozCB88yR1y1iZKPDY1I9/uUw==} + + '@zag-js/file-utils@1.41.2': + resolution: {integrity: sha512-Ih+8ULbId0M+CFR4IsqG5y/0VLCk2l+1rgPH+21L40dlSB6z6qKSP2tG7W69Cj2/3vryZsn67ibn26iCPG/vOw==} + + '@zag-js/floating-panel@1.41.2': + resolution: {integrity: sha512-nJP3oZ4YrJh+7H5YdwNccSzPGXFqjQN1ujZ/xGDhegjz4XtL48QMFnAasxlRI8VGjse9Tj8VXlQZxXPrASGzlA==} + + '@zag-js/focus-trap@1.41.2': + resolution: {integrity: sha512-3QTtGUjFU2OLbyrDlyoYWvKZecCmtn/+bsfsHW159jJHiEGHVYK6CY8AI1ePsMk9gVay48bXh008j+lVli7gAw==} + + '@zag-js/focus-visible@1.41.2': + resolution: {integrity: sha512-oRjwtgafUdGVwLJUN6mKsnBQbez/CHYAPkPg1FxOnr5GFpEpr8oMTOZJ3wdPM2U1ynS9QnUUu/sXc3KQv/jX0Q==} + + '@zag-js/highlight-word@1.41.2': + resolution: {integrity: sha512-95zcZKqNrL7JlqAckfzHa+LRbnfoz1lj6skUhq/uHSnni1vH6+8fNWT8ruo9G7vpGopbyRmmcie5p41SbAwQjQ==} + + '@zag-js/hover-card@1.41.2': + resolution: {integrity: sha512-Xn9RVzgTkKaVzyJTDdBJiXmCleNi1/hmW8z73tC0vOiQvSSvPejg/JkzqTOLFODvQHK3NOw54QHZvmjYp9Mubw==} + + '@zag-js/i18n-utils@1.41.2': + resolution: {integrity: sha512-f1xqaEY79awBxgUyjFso0UEpIoEHZq+zRvB0nUVFHJRW7Ds/QhaFHKSRnf2QBTlP/ObvCT225R+piNAAc17Aaw==} + + '@zag-js/image-cropper@1.41.2': + resolution: {integrity: sha512-750aT4U+J/TJw/Z1QVoLU9JG0luCtf9CyvShtJFIxeS+i25aUoBI9pOKgSFABsOutIqNJTPq4gYpqtuxFjSxRw==} + + '@zag-js/interact-outside@1.41.2': + resolution: {integrity: sha512-dM4Fn9iyqQeqkCMRYZP+bAgWEPKRVQRqMmcPsN0OXBhhFKC31Em15LTIZXaOtVKAjH+iwx+UvSYFRiWwEjkOEA==} + + '@zag-js/json-tree-utils@1.41.2': + resolution: {integrity: sha512-gNaOzsbCwmTd2HM3/u0xQdWX5UDBfl8tCXFavzbamkFH0iYQOXJb7cqUXBVuI4KScIbHPCKwrzZjqA5Sg9qzAQ==} + + '@zag-js/listbox@1.41.2': + resolution: {integrity: sha512-iDGrZleP3ui2Q6Jgmr9RYlbd7njdJHs8Qb3IJrSIZBIeYyYmFvVUfAFjk0g/z/amjTx6uYxRASWSPy/RETd4ug==} + + '@zag-js/live-region@1.41.2': + resolution: {integrity: sha512-7ubIW5AQt1wx9S/gFN+rU4TyvuFWJrL/DhnDWPNlH5g3luDVHSNeYGeeqf4d4tkcibtpYZa0pg9CbXxRxrfwVA==} + + '@zag-js/marquee@1.41.2': + resolution: {integrity: sha512-cT77aMhrtAK3oe2O6+X3TLtQs8wupdPUtZgHMWVxCcQOwagrk7RUBEQwU3Cx7cTswpBdTKSuDYgUggfgCs96wg==} + + '@zag-js/menu@1.41.2': + resolution: {integrity: sha512-wwix8hcAUSi0scpWXCiDppfdZV01Za8nN0gqLt9GdhCiVSlr0rs9pK1ROgPKJTyc43UZfyFPqtTWVHvEHMM70w==} + + '@zag-js/navigation-menu@1.41.2': + resolution: {integrity: sha512-aROB9CHzskZpnoFuGFkp7dbkZdsXvp2nxQgsgld02I1sDqiwcQd+YMdB0/6Ik0oz6X8I70RKdUuQM9WQFQfDnA==} + + '@zag-js/number-input@1.41.2': + resolution: {integrity: sha512-QoQWCiVHO+ciUbq8uL8Kxhtk4o3UpwzYpJkfsOfitzsZoYpPc7V/A6+n5yABV2SOwpqBODwNASZdxiEa60kfow==} + + '@zag-js/pagination@1.41.2': + resolution: {integrity: sha512-vZTxz4DrIDfedMcTjDeEkOc1iXD9wkP8eKuEcDieHOodnjSnNNwtmoFw5DCWv+yEa/TByIamXBZ8ZxHY144JgA==} + + '@zag-js/password-input@1.41.2': + resolution: {integrity: sha512-OWKFl0S12Qnlf4R4WbMCJ/YU0kGfezm5tP0UiyubMO/Fixv6H0twDFaJSPg2F6POv5uomCcGubc1H7gO+fIhsQ==} + + '@zag-js/pin-input@1.41.2': + resolution: {integrity: sha512-Wrn3YDbmWL3qvUIzN4QyLO7PzEhunX7z8DwBpmrK04p7HxR9pniTLVIPk27xk2MzyAPYcl4mwd9/Mc88tBxHsg==} + + '@zag-js/popover@1.41.2': + resolution: {integrity: sha512-h/LlVMIERM+NWzYV0ZHURlJuaqkT8XxwyEV96XfVzjknDRFNoPSl5IttVYtoaPoUqC0p/Y4oTSiee4mUZKOLHw==} + + '@zag-js/popper@1.41.2': + resolution: {integrity: sha512-Iz4D5YAIiIPn4IHGjhX3QatR/RyGaDt43lBSZv0RYcPQYtFg9sUuek3wizjW9qXgdvItevvNMqRdpl7f3es09g==} + + '@zag-js/presence@1.41.2': + resolution: {integrity: sha512-OhOLPAf/DYPmgoEntrlrf3LOrkwA+Y0J3K03NXHXPnkRB7h8jyIbHqzHS0jRTb1pvsO2P/yowRyoYtptY2Zmog==} + + '@zag-js/progress@1.41.2': + resolution: {integrity: sha512-SnzrqN+Z568NoO4rrgjrIc/S4EXMEne5CDgWwt2kQ8yq9VysdH8TtHAjyciFRIio7cdgEZNHKw9jSccpMWgRwA==} + + '@zag-js/qr-code@1.41.2': + resolution: {integrity: sha512-+JLswCNnzf58aQTaX0SMUA9wRC8Hb/a/ZveJIXTz6853Siemay2HqOqB2WQIeF33HNldUFD/a4+4Q8ugg93ubA==} + + '@zag-js/radio-group@1.41.2': + resolution: {integrity: sha512-EZjos61jKHZlNw8ez80vG2T9gUwpooxcVpYOKS2hyhuEXn3wEoefS5v1WFbmpoA/8TUpUQnYxisAeNuQfEQCuQ==} + + '@zag-js/rating-group@1.41.2': + resolution: {integrity: sha512-SbJP4HiK5XRy/oC3xRowjZOCThq1n/QA2Z/XAkvKLJArVQCYjrH3MoWwWvMVNfNC5+ZJluborR2AGF7tlVLzxA==} + + '@zag-js/react@1.41.2': + resolution: {integrity: sha512-5Bx7mQAron4LFWI8Hhs/uw5kwQ19s2Tn30HhctozLqmCu4nnJSTSh7GRvX+uwRZnztGXBXoOrgBWIepU4RXFGg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@zag-js/rect-utils@1.41.2': + resolution: {integrity: sha512-GWBTamaMLMG1p7Fe6V0dsXeTEmk+tqG3ciovzmjxURCJ3Yq2EkAMRhS0v5DG0oo+PyrPEIzEWukGBQkh/XRgYQ==} + + '@zag-js/remove-scroll@1.41.2': + resolution: {integrity: sha512-ieIrOgPKlCikAGEBIboQJoU7oxrL5BEY670tDOu7Eg7rNOdAwGXLEKPX2A/q+lREhOiVYjx0D3//vHTvdte80Q==} + + '@zag-js/scroll-area@1.41.2': + resolution: {integrity: sha512-hJFAwfIFuS7XmNsS3nB5rPm9OWXfB4d98sID7fjurcaZtDH5LqFriqfXhceNvs7rz7K4f/u8rufXrb6tcvATIA==} + + '@zag-js/scroll-snap@1.41.2': + resolution: {integrity: sha512-+70Al6LSASyEZtFyyffUJlDbE88KgYJDud05z8oZTqyEOLlTqnlSNkXq/P3siO7r3sNykg9H+TmAKn+/dVSKuw==} + + '@zag-js/select@1.41.2': + resolution: {integrity: sha512-3wGaKABILexoNBJ1bJiHqLLTctR/VMZaNA4cyKiqZeBEWtAkdMhgyY2xKobrP6KtUTqAeUFNVSTu4yCDrAQnUw==} + + '@zag-js/signature-pad@1.41.2': + resolution: {integrity: sha512-iNrOxY4gtqhsZdXYvlhF7s+LiOvwV7/kBpNnq8tJ1oYhgTs8wvKtJHrP86/CR05irEXQTaLfmeAJZuBEys9iRA==} + + '@zag-js/slider@1.41.2': + resolution: {integrity: sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A==} + + '@zag-js/splitter@1.41.2': + resolution: {integrity: sha512-Ubp4hkmzvVysU31jCINYbBXVqruu7rEPGqugWMzeXC5Hwda648aHpbg4Jix/wRtaGLjsyh6KOVEwAmoJU9NwhQ==} + + '@zag-js/steps@1.41.2': + resolution: {integrity: sha512-m0t2r8+FWwa2b2aU5JiNrHVdYHyNZYHK0G3Tq9lCOSQoDeoJIkyta+sIVehLVSY+0Ba9kOlkRUmYLbsnfXaW+Q==} + + '@zag-js/store@1.41.2': + resolution: {integrity: sha512-dVZF7E1ezXzynrKhMH3rfSr2rBbCfvTjvXbXz7//1PNULuq58UU5dG93V+9l834npCZxI2+PrpY45wZLJPTsIA==} + + '@zag-js/switch@1.41.2': + resolution: {integrity: sha512-qHbQK95UUHN0tj+bf9LLphLcMo/uTg2Pvs0c1Gs03Zh4g3NtHf0uYIhMZKYlCH0hNVlKrmdzLKWgeoDxv9gySw==} + + '@zag-js/tabs@1.41.2': + resolution: {integrity: sha512-7YVj2mCcxRbn1wMXP9anaTOVf+J0fa5uaPScr8e4+e2xc+/1WKzqN6V8IDeKS5wV/xzi1r3Ny307sX7Xz4ZJVQ==} + + '@zag-js/tags-input@1.41.2': + resolution: {integrity: sha512-aIPEndSO+9LHxyoXLUr0Uttxw2cYMyDuii5w50wn/N7lFJD49U3Sj+6XaB/oJSbDAwn10WrsRDtb7Gs2kmU+Qw==} + + '@zag-js/timer@1.41.2': + resolution: {integrity: sha512-PRYLWaip0+1FeVGEMNk5wMGAAIYgBIWwulZ4U5I+2Ayjohzp2NUAfwJ3sqoYvRrfjNYUNAPdU4eGu1zetC+oVQ==} + + '@zag-js/toast@1.41.2': + resolution: {integrity: sha512-+F3PsAo6EIz4rh73IOMCb/+FOUp7e3VjUY2q5sdU4IbfOzJBIbVSJxn0PQmHkuxkzWdCom0Lv0qSPFg/UTplnQ==} + + '@zag-js/toggle-group@1.41.2': + resolution: {integrity: sha512-C6wn3A89h24hTs0BN9ryEuKatfR493u7QqxS06TeK9oI/KZBvm5Rwm8FPHSUJvsUTkAkow4PsXC0Ra19duzEdQ==} + + '@zag-js/toggle@1.41.2': + resolution: {integrity: sha512-EFB9pb3pEtwXt7RSivVLWXV64dKUF8gsn75tt8TbYBgfy+zW85MsRLu8U48TXZN5teQWAKKNujr9bxQsW/CWvQ==} + + '@zag-js/tooltip@1.41.2': + resolution: {integrity: sha512-68okWJCFXfW8r0h97kEcU2yKPkq6e0S6QkiYh09ifMXoYjrQw/shPol8PgrS1poqKJigUWtsKm+bw73abhMn3w==} + + '@zag-js/tour@1.41.2': + resolution: {integrity: sha512-Q7UsvuHYYBo1Cs4b4OS0e/D8lxd2GpSRII93s5BQPi5HTcBjaWhVysAykmCFbat6Z56z0NBmFHNdhZ8oVPls1A==} + + '@zag-js/tree-view@1.41.2': + resolution: {integrity: sha512-QNi0VpV+RyzF4NP72+kSleUpauF9SMAzOAe59nxvs8jAUHqV5diDCInnbQos4+cyFDXFzGq3lElot26A1yI+7Q==} + + '@zag-js/types@1.41.2': + resolution: {integrity: sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ==} + + '@zag-js/utils@1.41.2': + resolution: {integrity: sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@4.1.0: + resolution: {integrity: sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: '>=4.0.4' + peerDependenciesMeta: + picomatch: + optional: true + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-freehand@1.2.3: + resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + vite-plugin-css-injected-by-js@3.5.2: + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>=7.3.2' + + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '>=7.3.2' + peerDependenciesMeta: + vite: + optional: true + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@ark-ui/react@5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/accordion': 1.41.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/angle-slider': 1.41.2 + '@zag-js/async-list': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/avatar': 1.41.2 + '@zag-js/carousel': 1.41.2 + '@zag-js/cascade-select': 1.41.2 + '@zag-js/checkbox': 1.41.2 + '@zag-js/clipboard': 1.41.2 + '@zag-js/collapsible': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/color-picker': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/combobox': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-input': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-picker': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dialog': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/drawer': 1.41.2 + '@zag-js/editable': 1.41.2 + '@zag-js/file-upload': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/floating-panel': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/highlight-word': 1.41.2 + '@zag-js/hover-card': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/image-cropper': 1.41.2 + '@zag-js/json-tree-utils': 1.41.2 + '@zag-js/listbox': 1.41.2 + '@zag-js/marquee': 1.41.2 + '@zag-js/menu': 1.41.2 + '@zag-js/navigation-menu': 1.41.2 + '@zag-js/number-input': 1.41.2 + '@zag-js/pagination': 1.41.2 + '@zag-js/password-input': 1.41.2 + '@zag-js/pin-input': 1.41.2 + '@zag-js/popover': 1.41.2 + '@zag-js/presence': 1.41.2 + '@zag-js/progress': 1.41.2 + '@zag-js/qr-code': 1.41.2 + '@zag-js/radio-group': 1.41.2 + '@zag-js/rating-group': 1.41.2 + '@zag-js/react': 1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@zag-js/scroll-area': 1.41.2 + '@zag-js/select': 1.41.2 + '@zag-js/signature-pad': 1.41.2 + '@zag-js/slider': 1.41.2 + '@zag-js/splitter': 1.41.2 + '@zag-js/steps': 1.41.2 + '@zag-js/switch': 1.41.2 + '@zag-js/tabs': 1.41.2 + '@zag-js/tags-input': 1.41.2 + '@zag-js/timer': 1.41.2 + '@zag-js/toast': 1.41.2 + '@zag-js/toggle': 1.41.2 + '@zag-js/toggle-group': 1.41.2 + '@zag-js/tooltip': 1.41.2 + '@zag-js/tour': 1.41.2 + '@zag-js/tree-view': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@chakra-ui/react@3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ark-ui/react': 5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@pandacss/is-valid-prop': 1.11.4 + csstype: 3.2.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.29.7 + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.6': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@microsoft/api-extractor-model@7.33.8': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.58.9': + dependencies: + '@microsoft/api-extractor-model': 7.33.8 + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1 + '@rushstack/rig-package': 0.7.3 + '@rushstack/terminal': 0.24.0 + '@rushstack/ts-command-line': 5.3.10 + diff: 8.0.4 + minimatch: 10.2.3 + resolve: 1.22.12 + semver: 7.7.4 + source-map: 0.6.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.138.0': {} + + '@pandacss/is-valid-prop@1.11.4': {} + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + + '@rushstack/node-core-library@5.23.1': + dependencies: + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + fs-extra: 11.3.6 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.12 + semver: 7.7.4 + + '@rushstack/problem-matcher@0.2.1': {} + + '@rushstack/rig-package@0.7.3': + dependencies: + jju: 1.4.0 + resolve: 1.22.12 + + '@rushstack/terminal@0.24.0': + dependencies: + '@rushstack/node-core-library': 5.23.1 + '@rushstack/problem-matcher': 0.2.1 + supports-color: 8.1.1 + + '@rushstack/ts-command-line@5.3.10': + dependencies: + '@rushstack/terminal': 0.24.0 + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + + '@swc/core-darwin-arm64@1.15.43': + optional: true + + '@swc/core-darwin-x64@1.15.43': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.43': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.43': + optional: true + + '@swc/core-linux-arm64-musl@1.15.43': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.43': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-musl@1.15.43': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.43': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.43': + optional: true + + '@swc/core-win32-x64-msvc@1.15.43': + optional: true + + '@swc/core@1.15.43(@swc/helpers@0.5.23)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.43 + '@swc/core-darwin-x64': 1.15.43 + '@swc/core-linux-arm-gnueabihf': 1.15.43 + '@swc/core-linux-arm64-gnu': 1.15.43 + '@swc/core-linux-arm64-musl': 1.15.43 + '@swc/core-linux-ppc64-gnu': 1.15.43 + '@swc/core-linux-s390x-gnu': 1.15.43 + '@swc/core-linux-x64-gnu': 1.15.43 + '@swc/core-linux-x64-musl': 1.15.43 + '@swc/core-win32-arm64-msvc': 1.15.43 + '@swc/core-win32-ia32-msvc': 1.15.43 + '@swc/core-win32-x64-msvc': 1.15.43 + '@swc/helpers': 0.5.23 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/argparse@1.0.38': {} + + '@types/estree@1.0.9': {} + + '@types/parse-json@4.0.2': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react-swc@4.3.1(@swc/helpers@0.5.23)(vite@8.1.3(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@swc/core': 1.15.43(@swc/helpers@0.5.23) + vite: 8.1.3(yaml@2.9.0) + transitivePeerDependencies: + - '@swc/helpers' + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.39 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/shared@3.5.39': {} + + '@zag-js/accordion@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/anatomy@1.41.2': {} + + '@zag-js/angle-slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/aria-hidden@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/async-list@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/auto-resize@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/avatar@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/carousel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/scroll-snap': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/cascade-select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/checkbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/clipboard@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collapsible@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collection@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/color-picker@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/color-utils@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/combobox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/core@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-input@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-picker@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-utils@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + + '@zag-js/dialog@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dismissable@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dom-query@1.41.2': + dependencies: + '@zag-js/types': 1.41.2 + + '@zag-js/drawer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/editable@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-upload@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-utils@1.41.2': + dependencies: + '@zag-js/i18n-utils': 1.41.2 + + '@zag-js/floating-panel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/focus-trap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/focus-visible@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/highlight-word@1.41.2': {} + + '@zag-js/hover-card@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/i18n-utils@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/image-cropper@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/interact-outside@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/json-tree-utils@1.41.2': {} + + '@zag-js/listbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/live-region@1.41.2': {} + + '@zag-js/marquee@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/navigation-menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/number-input@1.41.2': + dependencies: + '@internationalized/number': 3.6.6 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pagination@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/password-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pin-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popover@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popper@1.41.2': + dependencies: + '@floating-ui/dom': 1.7.6 + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/presence@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + + '@zag-js/progress@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/qr-code@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + proxy-memoize: 3.0.1 + uqr: 0.1.3 + + '@zag-js/radio-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/rating-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/react@1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@zag-js/rect-utils@1.41.2': {} + + '@zag-js/remove-scroll@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/scroll-area@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/scroll-snap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/signature-pad@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + perfect-freehand: 1.2.3 + + '@zag-js/slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/splitter@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/steps@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/store@1.41.2': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/switch@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tabs@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tags-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/timer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toast@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tooltip@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tour@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tree-view@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/types@1.41.2': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.41.2': {} + + acorn@8.17.0: {} + + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 4.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@0.4.14: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.7 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + compare-versions@6.1.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + convert-source-map@1.9.0: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 2.9.0 + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + diff@8.0.4: {} + + entities@7.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-errors@1.3.0: {} + + escape-string-regexp@4.0.0: {} + + estree-walker@2.0.2: {} + + exsolve@1.1.0: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@4.1.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + find-root@1.1.0: {} + + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-lazy@4.0.0: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + jju@1.4.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kolorist@1.8.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.7 + + minimatch@9.0.9: + dependencies: + brace-expansion: 5.0.7 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.15: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-browserify@1.0.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-freehand@1.2.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-compare@3.0.1: {} + + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + + quansync@0.2.11: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.7: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + scheduler@0.27.0: {} + + semver@7.7.4: {} + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + string-argv@0.3.2: {} + + stylis@4.2.0: {} + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + universalify@2.0.1: {} + + uqr@0.1.3: {} + + vite-plugin-css-injected-by-js@3.5.2(vite@8.1.3(yaml@2.9.0)): + dependencies: + vite: 8.1.3(yaml@2.9.0) + + vite-plugin-dts@4.5.4(typescript@5.9.3)(vite@8.1.3(yaml@2.9.0)): + dependencies: + '@microsoft/api-extractor': 7.58.9 + '@rollup/pluginutils': 5.4.0 + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 8.1.3(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + + vite@8.1.3(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + yaml: 2.9.0 + + vscode-uri@3.1.0: {} + + yaml@2.9.0: {} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/api.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/api.ts new file mode 100644 index 0000000000000..d88fc3f88c515 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/api.ts @@ -0,0 +1,82 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PaginatedResult, RuleHistoryRecord, TaskDQRunRecord } from "src/types/dq"; + +function getBase(): string { + if (typeof document === "undefined") return "/dq"; + const baseHref = document.querySelector("head > base")?.getAttribute("href") ?? ""; + const baseUrl = new URL(baseHref, globalThis.location.origin); + const basePath = baseUrl.pathname.replace(/\/$/, "") || ""; + return basePath ? `${basePath}/dq` : "/dq"; +} + +const BASE = getBase(); + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +async function apiFetch(path: string): Promise { + const res = await fetch(`${BASE}${path}`); + if (!res.ok) { + const body: unknown = await res.json().catch(() => ({})); + const detail = (body as { detail?: string })?.detail; + throw new ApiError(detail ?? res.statusText, res.status); + } + return res.json() as Promise; +} + +interface PageOptions { + before?: string | null; + limit?: number; +} + +function buildPageQuery({ before, limit }: PageOptions): string { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (before) params.set("before", before); + const query = params.toString(); + return query ? `?${query}` : ""; +} + +export function createApi(dagId: string, taskId: string, runId: string, mapIndex: number) { + return { + fetchRuleHistory: (ruleUid: string, options: PageOptions = {}) => + apiFetch>( + `/v1/dags/${encodeURIComponent(dagId)}/tasks/${encodeURIComponent(taskId)}` + + `/rules/${encodeURIComponent(ruleUid)}/history${buildPageQuery({ limit: 100, ...options })}`, + ), + fetchTaskRuns: (options: PageOptions = {}) => + apiFetch>( + `/v1/dags/${encodeURIComponent(dagId)}/tasks/${encodeURIComponent(taskId)}/runs` + + buildPageQuery({ limit: 50, ...options }), + ), + fetchTaskInstanceRun: () => + apiFetch( + `/v1/dags/${encodeURIComponent(dagId)}/tasks/${encodeURIComponent(taskId)}` + + `/runs/by_run/${encodeURIComponent(runId)}?map_index=${mapIndex}`, + ), + }; +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQErrorState.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQErrorState.tsx new file mode 100644 index 0000000000000..411bc368b7e23 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQErrorState.tsx @@ -0,0 +1,35 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Alert } from "@chakra-ui/react"; +import type { FC } from "react"; + +interface DQErrorStateProps { + message: string; +} + +export const DQErrorState: FC = ({ message }) => ( + + + + Could not load data quality results + {message} + + +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQSummaryCards.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQSummaryCards.tsx new file mode 100644 index 0000000000000..42e239e9ae307 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/DQSummaryCards.tsx @@ -0,0 +1,55 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Grid, Text } from "@chakra-ui/react"; +import type { FC } from "react"; + +import type { DQSummaryRecord } from "src/types/dq"; + +interface DQSummaryCardsProps { + summary: DQSummaryRecord; +} + +const METRICS: ReadonlyArray<{ colorPalette: string; key: keyof DQSummaryRecord; label: string }> = [ + { colorPalette: "blue", key: "score", label: "Score" }, + { colorPalette: "green", key: "passed", label: "Passed" }, + { colorPalette: "yellow", key: "warned", label: "Warned" }, + { colorPalette: "orange", key: "failed", label: "Failed" }, + { colorPalette: "red", key: "errored", label: "Errored" }, +]; + +function formatMetricValue(key: keyof DQSummaryRecord, value: number | null): string { + if (value === null) return "—"; + return key === "score" ? value.toFixed(2) : String(value); +} + +export const DQSummaryCards: FC = ({ summary }) => ( + + {METRICS.map(({ colorPalette, key, label }) => ( + + + {label} + + + {formatMetricValue(key, summary[key])} + + + ))} + +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/NoDQResult.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/NoDQResult.tsx new file mode 100644 index 0000000000000..cd54cb769d258 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/NoDQResult.tsx @@ -0,0 +1,39 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Text } from "@chakra-ui/react"; +import type { FC } from "react"; + +export const NoDQResult: FC = () => ( + + + + 📋 + + + No data quality results for this task instance + + + Either this task doesn't run a DQCheckOperator, or it + hasn't executed yet. Once it runs, results are persisted to the configured{" "} + [dq] results_path and appear here. + + + +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleHistoryPage.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleHistoryPage.tsx new file mode 100644 index 0000000000000..04ed9126e8196 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleHistoryPage.tsx @@ -0,0 +1,222 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Button, Code, Flex, Grid, Link, Spinner, Table, Text } from "@chakra-ui/react"; +import { type FC, type ReactNode, useState } from "react"; + +import { DQErrorState } from "src/components/DQErrorState"; +import { RuleStatusBadge } from "src/components/RuleStatusBadge"; +import type { RuleHistoryRecord, RuleResultRecord } from "src/types/dq"; +import { buildTaskInstanceHref } from "src/utils/taskInstanceLink"; + +interface RuleHistoryPageProps { + history: Array; + loading: boolean; + loadingMore: boolean; + nextCursor: string | null; + onBack: () => void; + onLoadMore: () => void; + onRefresh: () => void; + rule: RuleResultRecord; + ruleHistoryError: string | null; +} + +const SummaryItem: FC<{ label: string; value: ReactNode }> = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +function formatDate(value: string | null): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return date.toLocaleString(); +} + +function formatDuration(value: number | null): string { + return value === null ? "-" : `${value.toFixed(0)} ms`; +} + +function formatCondition(condition: Record): string { + if ("equal_to" in condition) return `= ${condition.equal_to}`; + if ("geq_to" in condition) return `>= ${condition.geq_to}`; + if ("greater_than" in condition) return `> ${condition.greater_than}`; + if ("leq_to" in condition) return `<= ${condition.leq_to}`; + if ("less_than" in condition) return `< ${condition.less_than}`; + return Object.entries(condition) + .map(([key, value]) => `${key}=${value}`) + .join(", "); +} + +function getTriggeredBy(runId: string): string { + if (runId.startsWith("manual__")) return "manual"; + if (runId.startsWith("scheduled__")) return "scheduler"; + return "-"; +} + +function getRuleDescription(rule: RuleResultRecord): string { + return rule.description ?? rule.rule_name.replaceAll("_", " "); +} + +export const RuleHistoryPage: FC = ({ + history, + loading, + loadingMore, + nextCursor, + onBack, + onLoadMore, + onRefresh, + rule, + ruleHistoryError, +}) => { + const [sqlExpanded, setSqlExpanded] = useState(false); + const latestHistory = history[0]; + const latest = latestHistory ?? rule; + const sql = latest.sql ?? "SQL was not captured for this run."; + + return ( + + + + + + + + + Rule History + + + {rule.rule_name} + + + {getRuleDescription(latest)} + + + + + + Rule Details + + + + } /> + + + + + + + + + Execution History + + {ruleHistoryError ? : null} + {loading ? ( + + + + ) : history.length === 0 ? ( + + No execution history available. + + Run the task to collect history. + + + ) : ( + + + + Started + Status + Observed Value + Condition + Duration + Triggered By + Run + + {history.map((record) => ( + + {formatDate(record.run.started_at)} + + + + {record.observed_value ?? "-"} + {formatCondition(record.condition)} + {formatDuration(record.duration_ms)} + {getTriggeredBy(record.run.run_id)} + + + {record.run.run_id} + + + + ))} + + + )} + {!loading && history.length > 0 ? ( + + {nextCursor === null ? ( + + No more history. + + ) : ( + + )} + + ) : null} + + + + + + SQL Executed + + + + {sqlExpanded ? ( + + + + + + {sql} + + + ) : null} + + + ); +}; diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleResultsTable.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleResultsTable.tsx new file mode 100644 index 0000000000000..3bf14ea325108 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleResultsTable.tsx @@ -0,0 +1,70 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Table, Text } from "@chakra-ui/react"; +import type { FC } from "react"; + +import { RuleStatusBadge } from "src/components/RuleStatusBadge"; +import type { RuleResultRecord } from "src/types/dq"; + +function formatCondition(condition: Record): string { + return Object.entries(condition) + .map(([key, value]) => `${key}=${value}`) + .join(", "); +} + +interface RuleResultsTableProps { + results: Array; +} + +export const RuleResultsTable: FC = ({ results }) => ( + + + + Rule + Status + Dimension + Severity + Observed Value + Condition + Duration + + {results.map((result) => ( + + + {result.rule_name} + {result.error_message ? ( + + {result.error_message} + + ) : null} + + + + + {result.dimension} + {result.severity} + {result.observed_value ?? "—"} + {formatCondition(result.condition)} + {result.duration_ms === null ? "—" : `${result.duration_ms.toFixed(0)} ms`} + + ))} + + +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleStatusBadge.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleStatusBadge.tsx new file mode 100644 index 0000000000000..e6a95071d2e80 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/RuleStatusBadge.tsx @@ -0,0 +1,40 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Badge } from "@chakra-ui/react"; +import type { FC } from "react"; + +import type { RuleStatus } from "src/types/dq"; + +const COLOR_BY_STATUS: Record = { + error: "gray", + fail: "red", + pass: "green", + warn: "yellow", +}; + +interface RuleStatusBadgeProps { + status: RuleStatus; +} + +export const RuleStatusBadge: FC = ({ status }) => ( + + {status} + +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQOverview.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQOverview.tsx new file mode 100644 index 0000000000000..aaafef1b7ca03 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQOverview.tsx @@ -0,0 +1,311 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Button, Flex, Input, Link, Spinner, Table, Text } from "@chakra-ui/react"; +import { type FC, useMemo, useState } from "react"; + +import { DQErrorState } from "src/components/DQErrorState"; +import { DQSummaryCards } from "src/components/DQSummaryCards"; +import { NoDQResult } from "src/components/NoDQResult"; +import { RuleHistoryPage } from "src/components/RuleHistoryPage"; +import { RuleStatusBadge } from "src/components/RuleStatusBadge"; +import { useTaskDQ } from "src/hooks/useTaskDQ"; +import type { RuleResultRecord, TaskDQRunRecord } from "src/types/dq"; +import { buildTaskInstanceHref } from "src/utils/taskInstanceLink"; + +interface TaskDQOverviewProps { + dagId: string; + taskId: string; +} + +interface RuleOverview { + errored: number; + failures: number; + latest: RuleResultRecord; + latestStartedAt: string | null; + passes: number; + runs: number; + warnings: number; +} + +type RuleFilter = "all" | "error" | "fail" | "issues" | "pass" | "warn"; + +const RULE_FILTERS: Array<{ label: string; value: RuleFilter }> = [ + { label: "All", value: "all" }, + { label: "Issues", value: "issues" }, + { label: "Pass", value: "pass" }, + { label: "Warn", value: "warn" }, + { label: "Fail", value: "fail" }, + { label: "Error", value: "error" }, +]; + +function formatDate(value: string | null): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return date.toLocaleString(); +} + +function getRuleOverviews(runs: Array): Array { + const rules = new Map(); + + for (const run of runs) { + for (const result of run.results) { + const current = rules.get(result.rule_uid); + const errored = result.status === "error" ? 1 : 0; + const failures = result.status === "fail" || result.status === "error" ? 1 : 0; + const passes = result.status === "pass" ? 1 : 0; + const warnings = result.status === "warn" ? 1 : 0; + + if (current === undefined) { + rules.set(result.rule_uid, { + errored, + failures, + latest: result, + latestStartedAt: run.run.started_at, + passes, + runs: 1, + warnings, + }); + } else { + rules.set(result.rule_uid, { + errored: current.errored + errored, + failures: current.failures + failures, + latest: current.latest, + latestStartedAt: current.latestStartedAt, + passes: current.passes + passes, + runs: current.runs + 1, + warnings: current.warnings + warnings, + }); + } + } + } + + return [...rules.values()].sort((left, right) => left.latest.rule_name.localeCompare(right.latest.rule_name)); +} + +function getLatestRun(runs: Array): TaskDQRunRecord | undefined { + return runs[0]; +} + +export const TaskDQOverview: FC = ({ dagId, taskId }) => { + const [ruleFilter, setRuleFilter] = useState("all"); + const [refreshKey, setRefreshKey] = useState(0); + const [ruleQuery, setRuleQuery] = useState(""); + const [historyRuleUid, setHistoryRuleUid] = useState(null); + const { + error, + loading, + loadMoreRuleHistory, + loadMoreRuns, + notFound, + ruleHistory, + ruleHistoryError, + ruleHistoryLoading, + ruleHistoryLoadingMore, + ruleHistoryNextCursor, + runs, + runsLoadingMore, + runsNextCursor, + } = useTaskDQ(dagId, taskId, historyRuleUid, refreshKey); + const ruleOverviews = useMemo(() => getRuleOverviews(runs), [runs]); + const filteredRuleOverviews = useMemo( + () => + ruleOverviews.filter((rule) => { + const matchesQuery = rule.latest.rule_name.toLowerCase().includes(ruleQuery.trim().toLowerCase()); + const matchesFilter = + ruleFilter === "all" || + (ruleFilter === "issues" && (rule.failures > 0 || rule.warnings > 0)) || + (ruleFilter === "pass" && rule.passes > 0) || + (ruleFilter === "warn" && rule.warnings > 0) || + (ruleFilter === "fail" && rule.failures > rule.errored) || + (ruleFilter === "error" && rule.errored > 0); + + return matchesQuery && matchesFilter; + }), + [ruleFilter, ruleOverviews, ruleQuery], + ); + const latestRun = getLatestRun(runs); + const historyRule = ruleOverviews.find((rule) => rule.latest.rule_uid === historyRuleUid); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ; + } + + if (notFound || runs.length === 0 || latestRun === undefined) { + return ; + } + + if (historyRule) { + return ( + setHistoryRuleUid(null)} + onLoadMore={loadMoreRuleHistory} + onRefresh={() => setRefreshKey((key) => key + 1)} + rule={historyRule.latest} + ruleHistoryError={ruleHistoryError} + /> + ); + } + + return ( + + + + + Data Quality Results + + + + + + + + setRuleQuery(event.target.value)} + placeholder="Filter rules" + size="sm" + value={ruleQuery} + /> + + {RULE_FILTERS.map((filter) => ( + + ))} + + + + + + + Rule + Latest + Runs + Failures + Warnings + Last Value + Last Run + History + + {filteredRuleOverviews.map((rule) => ( + + + {rule.latest.rule_name} + {rule.latest.description ? ( + + {rule.latest.description} + + ) : null} + + + + + {rule.runs} + {rule.failures} + {rule.warnings} + {rule.latest.observed_value ?? "-"} + {formatDate(rule.latestStartedAt)} + + + + + ))} + {filteredRuleOverviews.length === 0 ? ( + + + + No rules match the current filters. + + + + ) : null} + + + + + + + Recent Runs + + + + + Started + Score + Passed + Warned + Failed + Errored + + + {runs.map((run) => ( + + {formatDate(run.run.started_at)} + {run.summary.score === null ? "-" : run.summary.score.toFixed(2)} + {run.summary.passed} + {run.summary.warned} + {run.summary.failed} + {run.summary.errored} + + + View run + + + + ))} + + + + {runsNextCursor === null ? ( + + No more runs. + + ) : ( + + )} + + + + ); +}; diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQPanel.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQPanel.tsx new file mode 100644 index 0000000000000..68e6ad6897a58 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/components/TaskDQPanel.tsx @@ -0,0 +1,81 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Flex, Spinner, Text } from "@chakra-ui/react"; +import type { FC } from "react"; + +import { DQErrorState } from "src/components/DQErrorState"; +import { DQSummaryCards } from "src/components/DQSummaryCards"; +import { NoDQResult } from "src/components/NoDQResult"; +import { RuleResultsTable } from "src/components/RuleResultsTable"; +import { useTaskInstanceDQ } from "src/hooks/useTaskInstanceDQ"; + +interface TaskDQPanelProps { + dagId: string; + mapIndex: number; + runId: string; + taskId: string; +} + +const MetadataItem: FC<{ label: string; value: string | null }> = ({ label, value }) => + value ? ( + + + {label}:{" "} + + {value} + + ) : null; + +export const TaskDQPanel: FC = ({ dagId, mapIndex, runId, taskId }) => { + const { error, loading, notFound, result } = useTaskInstanceDQ(dagId, taskId, runId, mapIndex); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ; + } + + if (notFound || !result) { + return ; + } + + return ( + + + + Data Quality Results + + + + + + + + + + + ); +}; diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/dev.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/dev.tsx new file mode 100644 index 0000000000000..67b54eebb937e --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/dev.tsx @@ -0,0 +1,32 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import PluginComponent from "./main"; + +// Development entry point - mount with mock task instance params for local testing. +// `vite dev` proxies /dq to a running Airflow API server (see vite.config.ts), so point +// dagId/runId/taskId/mapIndex at a real task instance that has a persisted DQ run. +createRoot(document.querySelector("#root") as HTMLDivElement).render( + + + , +); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskDQ.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskDQ.ts new file mode 100644 index 0000000000000..a185444e1c22c --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskDQ.ts @@ -0,0 +1,170 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useState } from "react"; + +import { ApiError, createApi } from "src/api"; +import type { RuleHistoryRecord, TaskDQRunRecord } from "src/types/dq"; + +interface UseTaskDQReturn { + error: string | null; + loading: boolean; + loadMoreRuleHistory: () => void; + loadMoreRuns: () => void; + notFound: boolean; + ruleHistory: Array; + ruleHistoryError: string | null; + ruleHistoryLoading: boolean; + ruleHistoryLoadingMore: boolean; + ruleHistoryNextCursor: string | null; + runs: Array; + runsLoadingMore: boolean; + runsNextCursor: string | null; +} + +export function useTaskDQ( + dagId: string, + taskId: string, + selectedRuleUid: string | null, + refreshKey = 0, +): UseTaskDQReturn { + const [runs, setRuns] = useState>([]); + const [runsNextCursor, setRunsNextCursor] = useState(null); + const [runsLoadingMore, setRunsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [loading, setLoading] = useState(true); + const [ruleHistory, setRuleHistory] = useState>([]); + const [ruleHistoryNextCursor, setRuleHistoryNextCursor] = useState(null); + const [ruleHistoryLoadingMore, setRuleHistoryLoadingMore] = useState(false); + const [ruleHistoryError, setRuleHistoryError] = useState(null); + const [ruleHistoryLoading, setRuleHistoryLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function loadRuns() { + setLoading(true); + try { + const page = await createApi(dagId, taskId, "", -1).fetchTaskRuns(); + if (cancelled) return; + setRuns(page.items); + setRunsNextCursor(page.next_cursor); + setNotFound(page.items.length === 0); + setError(null); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setNotFound(true); + setError(null); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void loadRuns(); + return () => { + cancelled = true; + }; + }, [dagId, refreshKey, taskId]); + + useEffect(() => { + let cancelled = false; + + async function loadRuleHistory(ruleUid: string) { + setRuleHistoryLoading(true); + try { + const page = await createApi(dagId, taskId, "", -1).fetchRuleHistory(ruleUid); + if (cancelled) return; + setRuleHistory(page.items); + setRuleHistoryNextCursor(page.next_cursor); + setRuleHistoryError(null); + } catch (err) { + if (cancelled) return; + setRuleHistoryError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setRuleHistoryLoading(false); + } + } + + if (selectedRuleUid === null) { + setRuleHistory([]); + setRuleHistoryNextCursor(null); + setRuleHistoryError(null); + setRuleHistoryLoading(false); + return undefined; + } + + void loadRuleHistory(selectedRuleUid); + return () => { + cancelled = true; + }; + }, [dagId, refreshKey, selectedRuleUid, taskId]); + + const loadMoreRuns = useCallback(() => { + if (runsNextCursor === null || runsLoadingMore) return; + + setRunsLoadingMore(true); + createApi(dagId, taskId, "", -1) + .fetchTaskRuns({ before: runsNextCursor }) + .then((page) => { + setRuns((current) => [...current, ...page.items]); + setRunsNextCursor(page.next_cursor); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setRunsLoadingMore(false)); + }, [dagId, runsLoadingMore, runsNextCursor, taskId]); + + const loadMoreRuleHistory = useCallback(() => { + if (selectedRuleUid === null || ruleHistoryNextCursor === null || ruleHistoryLoadingMore) return; + + setRuleHistoryLoadingMore(true); + createApi(dagId, taskId, "", -1) + .fetchRuleHistory(selectedRuleUid, { before: ruleHistoryNextCursor }) + .then((page) => { + setRuleHistory((current) => [...current, ...page.items]); + setRuleHistoryNextCursor(page.next_cursor); + }) + .catch((err: unknown) => { + setRuleHistoryError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setRuleHistoryLoadingMore(false)); + }, [dagId, ruleHistoryLoadingMore, ruleHistoryNextCursor, selectedRuleUid, taskId]); + + return { + error, + loading, + loadMoreRuleHistory, + loadMoreRuns, + notFound, + ruleHistory, + ruleHistoryError, + ruleHistoryLoading, + ruleHistoryLoadingMore, + ruleHistoryNextCursor, + runs, + runsLoadingMore, + runsNextCursor, + }; +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskInstanceDQ.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskInstanceDQ.ts new file mode 100644 index 0000000000000..0c3ff0ba2f759 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/hooks/useTaskInstanceDQ.ts @@ -0,0 +1,76 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from "react"; + +import { ApiError, createApi } from "src/api"; +import type { TaskDQRunRecord } from "src/types/dq"; + +interface UseTaskInstanceDQReturn { + error: string | null; + loading: boolean; + /** True when no data quality check has run yet for this task instance (HTTP 404). */ + notFound: boolean; + result: TaskDQRunRecord | null; +} + +export function useTaskInstanceDQ( + dagId: string, + taskId: string, + runId: string, + mapIndex: number, +): UseTaskInstanceDQReturn { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const api = createApi(dagId, taskId, runId, mapIndex); + + async function load() { + setLoading(true); + try { + const record = await api.fetchTaskInstanceRun(); + if (cancelled) return; + setResult(record); + setNotFound(false); + setError(null); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setNotFound(true); + setError(null); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [dagId, mapIndex, runId, taskId]); + + return { error, loading, notFound, result }; +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/main.tsx b/providers/dq/src/airflow/providers/dq/plugins/www/src/main.tsx new file mode 100644 index 0000000000000..8b2ea285412d2 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/main.tsx @@ -0,0 +1,73 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, ChakraProvider } from "@chakra-ui/react"; +import type { FC } from "react"; + +import { TaskDQOverview } from "src/components/TaskDQOverview"; +import { TaskDQPanel } from "src/components/TaskDQPanel"; + +import { localSystem } from "./theme"; + +export interface PluginComponentProps { + dagId?: string; + mapIndex?: string; + runId?: string; + taskId?: string; +} + +/** + * Main plugin component for the Data Quality task and task-instance views. + * + * Receives dagId, runId, taskId, mapIndex as props from the Airflow React plugin host (from + * route params), matching the react_apps contract. + */ +const PluginComponent: FC = ({ + dagId = "", + runId = "", + taskId = "", + mapIndex: mapIndexProp = "-1", +}) => { + if (!runId) { + return ; + } + + const mapIndex = /^-?\d+$/.test(String(mapIndexProp)) ? parseInt(String(mapIndexProp), 10) : -1; + + return ; +}; + +/** + * Plugin component wrapped with ChakraProvider for consistent theming with the host. + * Chakra semantic tokens handle light/dark mode automatically. + */ +const WrappedPluginComponent: FC = (props) => { + const system = + (globalThis as { ChakraUISystem?: typeof localSystem }).ChakraUISystem ?? localSystem; + + return ( + + + + + + ); +}; + +export default WrappedPluginComponent; diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/theme.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/theme.ts new file mode 100644 index 0000000000000..dfb4866cedb58 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/theme.ts @@ -0,0 +1,22 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSystem, defaultConfig } from "@chakra-ui/react"; + +export const localSystem = createSystem(defaultConfig); diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/types/dq.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/types/dq.ts new file mode 100644 index 0000000000000..879643e0c8986 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/types/dq.ts @@ -0,0 +1,84 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Mirrors ``airflow.providers.dq.results.DQRun.to_dict()``. */ +export interface DQRunRecord { + asset_names: Array; + dag_id: string; + finished_at: string | null; + map_index: number; + run_id: string; + run_uid: string; + ruleset_name: string | null; + started_at: string | null; + table_ref: string | null; + task_id: string; + try_number: number; +} + +export type RuleStatus = "error" | "fail" | "pass" | "warn"; + +export type RuleSeverity = "error" | "warn"; + +/** Mirrors ``airflow.providers.dq.results.RuleResult.to_dict()``. */ +export interface RuleResultRecord { + condition: Record; + description?: string | null; + dimension: string; + duration_ms: number | null; + error_message: string | null; + observed_value: number | string | null; + rule_name: string; + rule_uid: string; + severity: RuleSeverity; + sql?: string | null; + status: RuleStatus; +} + +export interface DQSummaryRecord { + errored: number; + failed: number; + passed: number; + score: number | null; + warned: number; +} + +export interface TaskDQRunRecord { + results: Array; + run: DQRunRecord; + summary: DQSummaryRecord; +} + +export interface RuleHistoryRecord extends RuleResultRecord { + run: { + dag_id: string; + map_index?: number; + run_id: string; + run_uid: string; + started_at: string | null; + table_ref: string | null; + task_id: string; + }; +} + +/** Mirrors the ``{"items": [...], "next_cursor": ...}`` shape returned by paginated routes. */ +export interface PaginatedResult { + items: Array; + next_cursor: string | null; +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/utils/taskInstanceLink.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/utils/taskInstanceLink.ts new file mode 100644 index 0000000000000..5c4db71bdbcb4 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/utils/taskInstanceLink.ts @@ -0,0 +1,34 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +interface TaskInstanceRef { + dag_id: string; + map_index?: number; + run_id: string; + task_id: string; +} + +/** Link back to a run's own task-instance page, matching the host UI's route shape. */ +export function buildTaskInstanceHref(run: TaskInstanceRef): string { + const base = `/dags/${encodeURIComponent(run.dag_id)}/runs/${encodeURIComponent( + run.run_id, + )}/tasks/${encodeURIComponent(run.task_id)}`; + + return run.map_index !== undefined && run.map_index >= 0 ? `${base}/mapped/${run.map_index}` : base; +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/src/vite-env.d.ts b/providers/dq/src/airflow/providers/dq/plugins/www/src/vite-env.d.ts new file mode 100644 index 0000000000000..a1fdcdd1e6fc5 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/src/vite-env.d.ts @@ -0,0 +1,20 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/// diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.app.json b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.app.json new file mode 100644 index 0000000000000..4705c6983131f --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.json b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.json new file mode 100644 index 0000000000000..b6a01bf3b76ac --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.lib.json" } + ] +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.lib.json b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.lib.json new file mode 100644 index 0000000000000..d423df60f8313 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.app.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "allowImportingTsExtensions": false + }, + "include": ["src/main.tsx"], + "exclude": ["**/*.test.*", "**/*.spec.*"] +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.node.json b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.node.json new file mode 100644 index 0000000000000..a865d28180fa7 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + }, + "include": ["vite.config.ts"] +} diff --git a/providers/dq/src/airflow/providers/dq/plugins/www/vite.config.ts b/providers/dq/src/airflow/providers/dq/plugins/www/vite.config.ts new file mode 100644 index 0000000000000..bb5b8e2e60f92 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/plugins/www/vite.config.ts @@ -0,0 +1,91 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import react from "@vitejs/plugin-react-swc"; +import { resolve } from "node:path"; +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; +import dts from "vite-plugin-dts"; +import { defineConfig } from "vite"; + +export default defineConfig(({ command }) => { + const isLibraryBuild = command === "build"; + + return { + base: "./", + build: isLibraryBuild + ? { + chunkSizeWarningLimit: 1600, + lib: { + entry: resolve("src", "main.tsx"), + fileName: "main", + formats: ["umd"], + name: "AirflowPlugin", + }, + rollupOptions: { + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@chakra-ui/react", + "@emotion/react", + ], + output: { + entryFileNames: "[name].umd.cjs", + globals: { + react: "React", + "react-dom": "ReactDOM", + "react/jsx-runtime": "ReactJSXRuntime", + "@chakra-ui/react": "ChakraUI", + "@emotion/react": "EmotionReact", + }, + }, + }, + } + : { + chunkSizeWarningLimit: 1600, + }, + define: { + global: "globalThis", + "process.env": "{}", + "process.env.NODE_ENV": JSON.stringify("production"), + }, + plugins: [ + react(), + cssInjectedByJsPlugin(), + ...(isLibraryBuild + ? [ + dts({ + include: ["src/main.tsx"], + insertTypesEntry: true, + outDir: "dist", + }), + ] + : []), + ], + resolve: { alias: { src: "/src" } }, + server: { + cors: true, + proxy: { + "/dq": { + changeOrigin: true, + target: "http://localhost:28080", + }, + }, + }, + }; +}); diff --git a/providers/dq/src/airflow/providers/dq/results.py b/providers/dq/src/airflow/providers/dq/results.py new file mode 100644 index 0000000000000..5fbcf04573cc7 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/results.py @@ -0,0 +1,116 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Result records produced by a data quality check run — immutable facts, JSON-serializable.""" + +from __future__ import annotations + +import uuid +from dataclasses import asdict, dataclass, field +from typing import Any + +PASS = "pass" +WARN = "warn" +FAIL = "fail" +ERROR = "error" + +# Weight of a warn-severity failure in the run score, relative to an error-severity failure. +WARN_SCORE_WEIGHT = 0.25 + + +@dataclass(frozen=True) +class RuleResult: + """Outcome of evaluating one rule in one run.""" + + rule_uid: str + rule_name: str + status: str # pass | warn | fail | error + observed_value: float | str | None = None + condition: dict[str, Any] = field(default_factory=dict) + dimension: str = "validity" + severity: str = "error" + duration_ms: float | None = None + error_message: str | None = None + description: str | None = None + sql: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RuleResult: + return cls(**data) + + +@dataclass(frozen=True) +class DQRun: + """One execution of a ruleset by one task instance.""" + + dag_id: str + task_id: str + run_id: str + try_number: int = 1 + map_index: int = -1 + run_uid: str = field(default_factory=lambda: uuid.uuid4().hex) + ruleset_name: str | None = None + table_ref: str | None = None + asset_names: tuple[str, ...] = () + started_at: str | None = None + finished_at: str | None = None + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["asset_names"] = list(self.asset_names) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DQRun: + data = {**data, "asset_names": tuple(data.get("asset_names", ()))} + return cls(**data) + + +def compute_score(results: list[RuleResult]) -> float | None: + """ + Weighted pass rate for a run in [0, 1]. + + Error-severity failures (and execution errors) count fully against the score; + warn-severity failures count at ``WARN_SCORE_WEIGHT``. + """ + if not results: + return None + penalty = 0.0 + for result in results: + if result.status in (FAIL, ERROR): + penalty += 1.0 + elif result.status == WARN: + penalty += WARN_SCORE_WEIGHT + return round(1.0 - penalty / len(results), 4) + + +def build_summary(run: DQRun, results: list[RuleResult]) -> dict[str, Any]: + """Compact run summary attached to XCom and outlet asset events.""" + return { + "run_uid": run.run_uid, + "ruleset": run.ruleset_name, + "table": run.table_ref, + "score": compute_score(results), + "passed": sum(1 for r in results if r.status == PASS), + "warned": sum(1 for r in results if r.status == WARN), + "failed": sum(1 for r in results if r.status == FAIL), + "errored": sum(1 for r in results if r.status == ERROR), + "failed_rules": sorted(r.rule_name for r in results if r.status in (FAIL, ERROR)), + "warned_rules": sorted(r.rule_name for r in results if r.status == WARN), + } diff --git a/providers/dq/src/airflow/providers/dq/rules/__init__.py b/providers/dq/src/airflow/providers/dq/rules/__init__.py new file mode 100644 index 0000000000000..1536086480383 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/rules/__init__.py @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.rules.checks import CHECK_SPECS, CheckSpec, Dimension +from airflow.providers.dq.rules.rule import ( + CUSTOM_SQL_CHECK, + Condition, + DQRule, + RuleSet, + Severity, + describe_rule, +) + +__all__ = [ + "CHECK_SPECS", + "CUSTOM_SQL_CHECK", + "CheckSpec", + "Condition", + "DQRule", + "Dimension", + "RuleSet", + "Severity", + "describe_rule", +] diff --git a/providers/dq/src/airflow/providers/dq/rules/checks.py b/providers/dq/src/airflow/providers/dq/rules/checks.py new file mode 100644 index 0000000000000..11e73a5208f30 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/rules/checks.py @@ -0,0 +1,82 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Built-in check catalog: one place for each check's SQL expression and metadata.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + + +class Dimension(str, Enum): + """Data quality dimension assigned to a rule result.""" + + COMPLETENESS = "completeness" + UNIQUENESS = "uniqueness" + VALIDITY = "validity" + FRESHNESS = "freshness" + VOLUME = "volume" + CONSISTENCY = "consistency" + + +@dataclass(frozen=True) +class CheckSpec: + """ + Metadata for one built-in check. + + :param expression: SQL expression template. Column-level checks are rendered with + ``{column}``; table-level checks (``requires_column=False``) ignore it. + :param dimension: Default value for ``DQRule.dimension`` when a rule doesn't set it + explicitly. ``DQRule.dimension`` is a settable field; this is only the fallback. + :param requires_column: Whether a rule using this check must set ``column``. + :param default_condition: Default ``DQRule.condition`` (as a dict) for a rule that doesn't + set one explicitly; ``None`` means the rule must always specify a condition. + """ + + expression: str + dimension: Dimension = Dimension.VALIDITY + requires_column: bool = True + default_condition: dict[str, Any] | None = None + + +CHECK_SPECS: dict[str, CheckSpec] = { + "null_count": CheckSpec( + expression="SUM(CASE WHEN {column} IS NULL THEN 1 ELSE 0 END)", + dimension=Dimension.COMPLETENESS, + ), + "null_ratio": CheckSpec( + expression="SUM(CASE WHEN {column} IS NULL THEN 1.0 ELSE 0.0 END) / COUNT(*)", + dimension=Dimension.COMPLETENESS, + ), + "distinct_count": CheckSpec( + expression="COUNT(DISTINCT {column})", + dimension=Dimension.UNIQUENESS, + ), + "unique_violations": CheckSpec( + expression="COUNT({column}) - COUNT(DISTINCT {column})", + dimension=Dimension.UNIQUENESS, + ), + "min": CheckSpec(expression="MIN({column})"), + "max": CheckSpec(expression="MAX({column})"), + "mean": CheckSpec(expression="AVG({column})"), + "row_count": CheckSpec( + expression="COUNT(*)", + dimension=Dimension.VOLUME, + requires_column=False, + ), +} diff --git a/providers/dq/src/airflow/providers/dq/rules/rule.py b/providers/dq/src/airflow/providers/dq/rules/rule.py new file mode 100644 index 0000000000000..8151be8e41143 --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/rules/rule.py @@ -0,0 +1,279 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Declarative data quality rule model: rules are data, not code.""" + +from __future__ import annotations + +import hashlib +import json +from enum import Enum +from typing import Any, cast + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from airflow.providers.dq.exceptions import DQRuleValidationError +from airflow.providers.dq.rules.checks import CHECK_SPECS, Dimension + +CUSTOM_SQL_CHECK = "custom_sql" + + +class Severity(str, Enum): + """Severity used to decide whether a failing rule fails the task.""" + + WARN = "warn" + ERROR = "error" + + +class Condition(BaseModel): + """ + Pass/fail condition evaluated against a rule's observed value. + + Uses the same grammar as the ``common.sql`` check operators: ``equal_to``, + ``greater_than``, ``less_than``, ``geq_to``, ``leq_to``, plus a percentage + ``tolerance`` that widens ``equal_to`` into a range. + + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + equal_to: float | None = None + greater_than: float | None = None + less_than: float | None = None + geq_to: float | None = None + leq_to: float | None = None + tolerance: float | None = None + + @model_validator(mode="after") + def _validate_comparisons(self) -> Condition: + comparisons = { + "equal_to": self.equal_to, + "greater_than": self.greater_than, + "less_than": self.less_than, + "geq_to": self.geq_to, + "leq_to": self.leq_to, + } + set_comparisons = {name for name, value in comparisons.items() if value is not None} + if not set_comparisons: + raise ValueError(f"Condition needs at least one comparison out of: {', '.join(comparisons)}") + if self.equal_to is not None and len(set_comparisons) > 1: + raise ValueError("equal_to cannot be combined with other comparisons") + if self.tolerance is not None and self.equal_to is None: + raise ValueError("tolerance is only supported together with equal_to") + return self + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Condition: + return cls(**data) + + def to_dict(self) -> dict[str, float]: + data = { + "equal_to": self.equal_to, + "greater_than": self.greater_than, + "less_than": self.less_than, + "geq_to": self.geq_to, + "leq_to": self.leq_to, + "tolerance": self.tolerance, + } + return {k: v for k, v in data.items() if v is not None} + + def evaluate(self, observed: Any) -> bool: + if observed is None: + return False + value = float(observed) + if self.equal_to is not None: + if self.tolerance is not None: + low = self.equal_to * (1 - self.tolerance) + high = self.equal_to * (1 + self.tolerance) + return min(low, high) <= value <= max(low, high) + return value == self.equal_to + return ( + (self.greater_than is None or value > self.greater_than) + and (self.less_than is None or value < self.less_than) + and (self.geq_to is None or value >= self.geq_to) + and (self.leq_to is None or value <= self.leq_to) + ) + + +class DQRule(BaseModel): + """ + A single, named data quality rule. + + :param name: Rule name, unique within its ruleset. + :param check: One of the built-in checks (see ``CHECK_SPECS``) or ``custom_sql``. Built-in + checks are plain ANSI SQL and are not guaranteed to work against every ``DbApiHook``; + see "Supported checks and databases" in the provider docs. Use ``custom_sql`` if a + built-in check doesn't fit your database's dialect. + :param condition: Pass condition for the observed value (``Condition`` or its dict form). + Optional only for checks whose ``CheckSpec.default_condition`` is set; every current + built-in check requires one explicitly. + :param column: Target column; required for column-level built-in checks. + :param sql: SQL statement returning a single scalar; required for ``custom_sql``. + May reference the target table as ``{table}``. + :param severity: ``error`` (fails the task by default) or ``warn`` (recorded only). + :param partition_clause: Extra predicate ANDed into the check's WHERE clause. + :param previous_name: Set when renaming a rule, to keep its history continuous. + :param description: Human-readable description shown in results and the UI. When omitted, + the provider generates a short default description from the rule and condition. + + Invalid input raises pydantic's own :class:`~pydantic.ValidationError`. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + check: str = Field(description="One of the built-in checks, or custom_sql.") + condition: Condition | dict[str, Any] | None = None + column: str | None = None + sql: str | None = None + severity: Severity = Severity.ERROR + partition_clause: str | None = None + previous_name: str | None = None + description: str | None = Field( + default=None, + description=( + "Human-readable description shown in results and the UI. When omitted, the provider " + "generates a short default description from the rule and condition." + ), + ) + dimension: Dimension | None = Field( + default=None, + description=( + "Data quality dimension recorded for this rule's results. Defaults to the check's " + "catalog dimension (validity for custom_sql) when not set explicitly." + ), + ) + + @model_validator(mode="after") + def _validate(self) -> DQRule: + if not self.name: + raise ValueError("Rule name cannot be empty") + catalog_spec = None + if self.check == CUSTOM_SQL_CHECK: + if not self.sql: + raise ValueError(f"Rule {self.name!r}: custom_sql check requires 'sql'") + else: + catalog_spec = CHECK_SPECS.get(self.check) + if catalog_spec is None: + supported = sorted([*CHECK_SPECS, CUSTOM_SQL_CHECK]) + raise ValueError(f"Rule {self.name!r}: unknown check {self.check!r}; supported: {supported}") + if self.sql: + raise ValueError(f"Rule {self.name!r}: 'sql' is only valid with custom_sql") + if catalog_spec.requires_column and not self.column: + raise ValueError(f"Rule {self.name!r}: check {self.check!r} requires 'column'") + if self.condition is None and catalog_spec.default_condition is not None: + object.__setattr__(self, "condition", Condition.from_dict(catalog_spec.default_condition)) + if self.condition is None: + raise ValueError(f"Rule {self.name!r}: condition is required for check {self.check!r}") + if self.dimension is None: + object.__setattr__( + self, "dimension", catalog_spec.dimension if catalog_spec else Dimension.VALIDITY + ) + return self + + @property + def rule_uid(self) -> str: + """Stable identity across runs: survives severity/dimension tweaks and Dag refactors.""" + condition = cast("Condition", self.condition) + identity = { + "name": self.previous_name or self.name, + "check": self.check, + "column": self.column, + "sql": self.sql, + "condition": condition.to_dict(), + } + digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + return digest[:16] + + def to_dict(self) -> dict[str, Any]: + condition = cast("Condition", self.condition) + data: dict[str, Any] = { + "name": self.name, + "check": self.check, + "condition": condition.to_dict(), + "severity": self.severity.value, + } + for optional in ("column", "sql", "partition_clause", "previous_name", "description"): + value = getattr(self, optional) + if value is not None: + data[optional] = value + # Only emit dimension when it overrides the check's catalog default, so a rule that + # never set one explicitly round-trips through to_dict()/from_dict() unchanged. + catalog_spec = CHECK_SPECS.get(self.check) + default_dimension = catalog_spec.dimension if catalog_spec else Dimension.VALIDITY + if self.dimension != default_dimension: + data["dimension"] = cast("Dimension", self.dimension).value + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DQRule: + return cls(**data) + + +def describe_rule(rule: DQRule) -> str: + """Return a default human-readable description for a rule.""" + condition = cast("Condition", rule.condition).to_dict() + subject = rule.column or rule.name.replace("_", " ") + if "equal_to" in condition: + return f"{subject} should equal {condition['equal_to']}" + if "geq_to" in condition: + return f"{subject} should be greater than or equal to {condition['geq_to']}" + if "greater_than" in condition: + return f"{subject} should be greater than {condition['greater_than']}" + if "leq_to" in condition: + return f"{subject} should be less than or equal to {condition['leq_to']}" + if "less_than" in condition: + return f"{subject} should be less than {condition['less_than']}" + return subject + + +class RuleSet(BaseModel): + """A named collection of rules, typically attached to one table or asset.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + rules: tuple[DQRule, ...] = () + + @model_validator(mode="after") + def _validate(self) -> RuleSet: + if not self.name: + raise ValueError("RuleSet name cannot be empty") + names = [rule.name for rule in self.rules] + duplicates = {name for name in names if names.count(name) > 1} + if duplicates: + raise ValueError(f"Duplicate rule names in ruleset {self.name!r}: {sorted(duplicates)}") + return self + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "rules": [rule.to_dict() for rule in self.rules]} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RuleSet: + rules = [ + rule if isinstance(rule, DQRule) else DQRule.from_dict(rule) for rule in data.get("rules", []) + ] + return cls(name=data.get("name", ""), rules=tuple(rules)) + + @classmethod + def from_file(cls, path: str) -> RuleSet: + """Load a ruleset from a YAML (or JSON, being a YAML subset) file.""" + with open(path) as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise DQRuleValidationError(f"Ruleset file {path!r} must contain a mapping at top level") + return cls.from_dict(data) diff --git a/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/SKILL.md b/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/SKILL.md new file mode 100644 index 0000000000000..9fdb6eccc3b1f --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/SKILL.md @@ -0,0 +1,174 @@ +--- +name: dq-rule-authoring +description: "Generate a RuleSet/DQRule JSON payload for Apache Airflow's dq provider from a table's column definitions. Use this whenever asked to write, generate, or suggest data quality rules, checks, or a ruleset for a table or dataset -- especially when the output is structured JSON handed to DQCheckOperator, @task.dq_check, asset_quality(), or RuleSet.from_file()." +--- + + + +# dq rule authoring + +You are producing a **RuleSet**: a JSON object naming a table's data quality checks. This +document is the schema and the full list of valid values -- do not guess field names or check +names, and do not invent checks that aren't in the catalog below. + +## Output shape + +```json +{ + "name": "orders_quality", + "rules": [ + { + "name": "order_id_not_null", + "check": "null_count", + "column": "order_id", + "condition": {"equal_to": 0} + } + ] +} +``` + +`name` (ruleset name) and `rules` (array) are the only top-level keys. Every rule name must be +unique within the ruleset. + +## `DQRule` fields + +| Field | Required | Notes | +|---|---|---| +| `name` | yes | Unique within the ruleset. Shows up in the Data Quality UI and logs. | +| `check` | yes | One of the catalog names below, or `"custom_sql"`. Exact string match -- there is no fuzzy matching. | +| `condition` | yes* | See `Condition` grammar below. *Every catalog check currently requires one explicitly (no defaults) -- always include it. | +| `column` | check-dependent | Required for every catalog check except `row_count`. Not used with `custom_sql`. | +| `sql` | `custom_sql` only | A SQL statement returning a single scalar. May reference the checked table as `{table}`. Invalid together with any catalog check. | +| `severity` | no | `"error"` (default) or `"warn"`. `"error"` fails the task on a failing rule (subject to the operator's `fail_on`); `"warn"` only records it. | +| `partition_clause` | no | Extra SQL predicate ANDed into this rule's `WHERE` clause, e.g. `"region = 'EU'"`. | +| `previous_name` | no | Only set when told a rule is being renamed, to keep its history continuous. | +| `description` | no | Human-readable text shown in results and the Data Quality UI. Use it when a clear business meaning is known; otherwise omit it and let Airflow generate a default description. | +| `dimension` | no | One of `completeness`, `uniqueness`, `validity`, `freshness`, `volume`, `consistency`. Defaults to the check's catalog dimension (`validity` for `custom_sql`) -- only set this explicitly when a `custom_sql` rule measures something the default doesn't capture (e.g. a freshness check written as `custom_sql` should set `"dimension": "freshness"`). Leave it unset for every built-in check. | + +**Do not add any key not listed above.** The schema rejects unrecognized fields. + +## Built-in check catalog + +| `check` | SQL expression | needs `column` | typical use | +|---|---|---|---| +| `null_count` | `SUM(CASE WHEN {column} IS NULL THEN 1 ELSE 0 END)` | yes | count of nulls in a column | +| `null_ratio` | `SUM(CASE WHEN {column} IS NULL THEN 1.0 ELSE 0.0 END) / COUNT(*)` | yes | fraction of nulls, 0.0-1.0 | +| `distinct_count` | `COUNT(DISTINCT {column})` | yes | number of distinct values | +| `unique_violations` | `COUNT({column}) - COUNT(DISTINCT {column})` | yes | 0 means the column is fully unique | +| `min` | `MIN({column})` | yes | minimum value | +| `max` | `MAX({column})` | yes | maximum value | +| `mean` | `AVG({column})` | yes | average value | +| `row_count` | `COUNT(*)` | no | total row count -- omit `column` entirely | + +These are the *only* built-in check names. Anything else -- comparing two columns, joining +another table, checking a format/regex, freshness against `now()`, referential integrity -- must +be written as `custom_sql`. + +## `Condition` grammar + +`condition` is an object with these optional numeric keys; at least one is required: + +- `equal_to` -- exact match. Cannot be combined with any other key below. +- `greater_than` / `geq_to` -- lower bound, exclusive / inclusive. +- `less_than` / `leq_to` -- upper bound, exclusive / inclusive. +- `tolerance` -- a fraction (e.g. `0.1` for 10%) that widens `equal_to` into a range. Only valid + together with `equal_to`. + +Combine `greater_than`/`less_than`/`geq_to`/`leq_to` freely to express a range, e.g. +`{"geq_to": 0, "leq_to": 100}`. Never combine `equal_to` with another comparison key (other than +`tolerance`). + +## `custom_sql`: when a catalog check doesn't fit + +Use `check: "custom_sql"` with a `sql` statement resolving to a single scalar whenever: + +- The check needs more than one column (e.g. `end_date >= start_date`), a join, or a subquery. +- A catalog expression doesn't run correctly against the target database's SQL dialect (for + example, some engines don't support `NULLIF`, or evaluate `CASE`/`COUNT DISTINCT` + differently) -- write the equivalent expression for that dialect instead. +- The check is conceptually one of the checks above but needs different null/empty-table + semantics than the catalog expression provides. + +Reference the table being checked as `{table}` in the SQL: + +```json +{ + "name": "no_future_order_dates", + "check": "custom_sql", + "sql": "SELECT COUNT(*) FROM {table} WHERE order_date > CURRENT_DATE", + "condition": {"equal_to": 0} +} +``` + +## Worked example + +Given these column definitions for an `orders` table -- +`order_id` (integer, primary key), `customer_id` (integer, nullable foreign key), +`amount` (decimal, must be non-negative), `region` (string, low-cardinality), +`created_at` (timestamp) -- a reasonable ruleset: + +```json +{ + "name": "orders_quality", + "rules": [ + { + "name": "order_id_not_null", + "check": "null_count", + "column": "order_id", + "condition": {"equal_to": 0} + }, + { + "name": "order_id_unique", + "check": "unique_violations", + "column": "order_id", + "condition": {"equal_to": 0} + }, + { + "name": "customer_id_null_ratio_low", + "check": "null_ratio", + "column": "customer_id", + "condition": {"leq_to": 0.05}, + "severity": "warn" + }, + { + "name": "amount_non_negative", + "check": "min", + "column": "amount", + "condition": {"geq_to": 0} + }, + { + "name": "region_cardinality_reasonable", + "check": "distinct_count", + "column": "region", + "condition": {"leq_to": 20} + }, + { + "name": "table_not_empty", + "check": "row_count", + "condition": {"greater_than": 0} + }, + { + "name": "no_future_order_dates", + "check": "custom_sql", + "sql": "SELECT COUNT(*) FROM {table} WHERE created_at > CURRENT_TIMESTAMP", + "condition": {"equal_to": 0} + } + ] +} +``` + +## Reference schema + +`references/ruleset.schema.json` in this skill's directory is the pydantic-generated JSON +Schema for this exact shape (`RuleSet.model_json_schema()`), useful for validating output +structurally. It does not enumerate valid `check` values (that field is a plain string) -- +this document is the source of truth for which check names exist. + +## How this is consumed + +The generated JSON is typically produced by an LLM task (e.g. `@task.llm(output_type=RuleSet, ...)` +from `common.ai`) and passed straight to `DQCheckOperator(ruleset=...)`, `@task.dq_check(ruleset=...)`, +or `asset_quality(ruleset=...)` -- all three accept a `RuleSet`, its dict form, or a path to a +YAML file written in this same shape. Invalid output raises a `pydantic.ValidationError` +describing exactly which field or value was wrong. diff --git a/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/references/ruleset.schema.json b/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/references/ruleset.schema.json new file mode 100644 index 0000000000000..a8f4387981d6d --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/references/ruleset.schema.json @@ -0,0 +1,241 @@ +{ + "$defs": { + "Condition": { + "additionalProperties": false, + "description": "Pass/fail condition evaluated against a rule's observed value.\n\nUses the same grammar as the ``common.sql`` check operators: ``equal_to``,\n``greater_than``, ``less_than``, ``geq_to``, ``leq_to``, plus a percentage\n``tolerance`` that widens ``equal_to`` into a range.", + "properties": { + "equal_to": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Equal To" + }, + "greater_than": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Greater Than" + }, + "less_than": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Less Than" + }, + "geq_to": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Geq To" + }, + "leq_to": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Leq To" + }, + "tolerance": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tolerance" + } + }, + "title": "Condition", + "type": "object" + }, + "DQRule": { + "additionalProperties": false, + "description": "A single, named data quality rule.\n\n:param name: Rule name, unique within its ruleset.\n:param check: One of the built-in checks (see ``CHECK_SPECS``) or ``custom_sql``. Built-in\n checks are plain ANSI SQL and are not guaranteed to work against every ``DbApiHook``;\n see \"Supported checks and databases\" in the provider docs. Use ``custom_sql`` if a\n built-in check doesn't fit your database's dialect.\n:param condition: Pass condition for the observed value (``Condition`` or its dict form).\n Optional only for checks whose ``CheckSpec.default_condition`` is set; every current\n built-in check requires one explicitly.\n:param column: Target column; required for column-level built-in checks.\n:param sql: SQL statement returning a single scalar; required for ``custom_sql``.\n May reference the target table as ``{table}``.\n:param severity: ``error`` (fails the task by default) or ``warn`` (recorded only).\n:param partition_clause: Extra predicate ANDed into the check's WHERE clause.\n:param previous_name: Set when renaming a rule, to keep its history continuous.\n:param description: Human-readable description shown in results and the UI. When omitted,\n the provider generates a short default description from the rule and condition.\n\nInvalid input raises pydantic's own :class:`~pydantic.ValidationError`.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "check": { + "description": "One of the built-in checks, or custom_sql.", + "title": "Check", + "type": "string" + }, + "condition": { + "anyOf": [ + { + "$ref": "#/$defs/Condition" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Condition" + }, + "column": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Column" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sql" + }, + "severity": { + "$ref": "#/$defs/Severity", + "default": "error" + }, + "partition_clause": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Partition Clause" + }, + "previous_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Previous Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description shown in results and the UI. When omitted, the provider generates a short default description from the rule and condition.", + "title": "Description" + }, + "dimension": { + "anyOf": [ + { + "$ref": "#/$defs/Dimension" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Data quality dimension recorded for this rule's results. Defaults to the check's catalog dimension (validity for custom_sql) when not set explicitly." + } + }, + "required": [ + "name", + "check" + ], + "title": "DQRule", + "type": "object" + }, + "Dimension": { + "description": "Data quality dimension assigned to a rule result.", + "enum": [ + "completeness", + "uniqueness", + "validity", + "freshness", + "volume", + "consistency" + ], + "title": "Dimension", + "type": "string" + }, + "Severity": { + "description": "Severity used to decide whether a failing rule fails the task.", + "enum": [ + "warn", + "error" + ], + "title": "Severity", + "type": "string" + } + }, + "additionalProperties": false, + "description": "A named collection of rules, typically attached to one table or asset.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "rules": { + "default": [], + "items": { + "$ref": "#/$defs/DQRule" + }, + "title": "Rules", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "RuleSet", + "type": "object" +} diff --git a/providers/dq/src/airflow/providers/dq/version_compat.py b/providers/dq/src/airflow/providers/dq/version_compat.py new file mode 100644 index 0000000000000..b47842705282b --- /dev/null +++ b/providers/dq/src/airflow/providers/dq/version_compat.py @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# NOTE! THIS FILE IS COPIED MANUALLY IN OTHER PROVIDERS DELIBERATELY TO AVOID ADDING UNNECESSARY +# DEPENDENCIES BETWEEN PROVIDERS. IF YOU WANT TO ADD CONDITIONAL CODE IN YOUR PROVIDER THAT DEPENDS +# ON AIRFLOW VERSION, PLEASE COPY THIS FILE TO THE ROOT PACKAGE OF YOUR PROVIDER AND IMPORT +# THOSE CONSTANTS FROM IT RATHER THAN IMPORTING THEM FROM ANOTHER PROVIDER OR TEST CODE +# +from __future__ import annotations + + +def get_base_airflow_version_tuple() -> tuple[int, int, int]: + from packaging.version import Version + + from airflow import __version__ + + airflow_version = Version(__version__) + return airflow_version.major, airflow_version.minor, airflow_version.micro + + +AIRFLOW_V_3_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 0) + +__all__ = ["AIRFLOW_V_3_1_PLUS"] diff --git a/providers/dq/tests/conftest.py b/providers/dq/tests/conftest.py new file mode 100644 index 0000000000000..f56ccce0a3f69 --- /dev/null +++ b/providers/dq/tests/conftest.py @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +pytest_plugins = "tests_common.pytest_plugin" diff --git a/providers/dq/tests/system/__init__.py b/providers/dq/tests/system/__init__.py new file mode 100644 index 0000000000000..5966d6b1d5261 --- /dev/null +++ b/providers/dq/tests/system/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/providers/dq/tests/system/dq/__init__.py b/providers/dq/tests/system/dq/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/system/dq/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/system/dq/example_dq_check.py b/providers/dq/tests/system/dq/example_dq_check.py new file mode 100644 index 0000000000000..8cae644ffbf90 --- /dev/null +++ b/providers/dq/tests/system/dq/example_dq_check.py @@ -0,0 +1,354 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Example DAG for the Data Quality provider's ``DQCheckOperator``. + +Runs against the ``sqlite_default`` connection (Airflow's default local backend, no extra +infrastructure required). Results are persisted through the ``[dq] results_path`` config +option, seeded here via ``AIRFLOW__DQ__RESULTS_PATH`` so the history can be inspected +afterwards under ``/tmp/airflow_dq_example/results`` -- a real deployment would instead set +``results_path`` in ``airflow.cfg`` (or its env var) once, for every check to share. + +Trigger the Dag repeatedly with different ``dq_scenario`` values to create useful +history data in the Data Quality tab: + +- ``pass`` inserts clean data. +- ``negative`` inserts a negative amount, failing ``amount_non_negative``. +- ``duplicate_order`` inserts a duplicate ``order_id``, failing uniqueness. +- ``nulls`` inserts null ``order_id``/``customer_id`` values and a high discount null ratio. +- ``outlier`` inserts amounts above the allowed maximum. +- ``small_table`` inserts too few rows for volume checks. +- ``zero_quantity`` inserts an invalid quantity minimum. +- ``empty`` leaves the table empty. +- ``mixed`` combines several bad values to show multiple failures in one run. + +Demonstrates +three equivalent ways to run the same ruleset: + +- Plain ``DQCheckOperator`` with an explicit ``table``/``conn_id``. +- ``asset_quality()`` attaching the ruleset to an ``Asset``, then ``DQCheckOperator(asset=...)`` + picking up ``table``/``conn_id``/``ruleset`` from it and adding the asset to the task's + outlets automatically. +- A second asset-producing ``DQCheckOperator`` with stricter rules, so the Browse > Data Quality + page shows multiple producers for the same asset with both pass and fail outcomes. +- The ``@task.dq_check`` TaskFlow decorator. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator +from airflow.providers.dq.assets import asset_quality +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.providers.dq.rules import DQRule, RuleSet, Severity +from airflow.sdk import DAG, Asset, task + +DAG_ID = "example_dq_check" +CONN_ID = "sqlite_default" +TABLE_NAME = "dq_example_orders" +RESULTS_PATH = Path("/tmp/airflow_dq_example/results") + +# DQCheckOperator has no per-operator results-store override; every check in a deployment +# shares the one store configured under [dq] results_path. setdefault() so a real deployment's +# own config is never overridden. +os.environ.setdefault("AIRFLOW__DQ__RESULTS_PATH", f"file://{RESULTS_PATH}") + +# [START howto_operator_dq_check_ruleset] +orders_ruleset = RuleSet( + name="orders_quality", + rules=( + DQRule( + name="order_id_not_null", + check="null_count", + column="order_id", + condition={"equal_to": 0}, + ), + DQRule( + name="order_id_unique", + check="unique_violations", + column="order_id", + condition={"equal_to": 0}, + ), + DQRule( + name="customer_id_not_null", + check="null_count", + column="customer_id", + condition={"equal_to": 0}, + severity=Severity.WARN, + ), + DQRule( + name="discount_null_ratio", + check="null_ratio", + column="discount", + condition={"leq_to": 0.25}, + severity=Severity.WARN, + ), + DQRule( + name="region_distinct_count", + check="distinct_count", + column="region", + condition={"geq_to": 2}, + severity=Severity.WARN, + ), + DQRule( + name="amount_min_ge_zero", + check="min", + column="amount", + condition={"geq_to": 0}, + ), + DQRule( + name="amount_max_le_100", + check="max", + column="amount", + condition={"leq_to": 100}, + ), + DQRule( + name="amount_mean_between_5_and_60", + check="mean", + column="amount", + condition={"geq_to": 5, "leq_to": 60}, + ), + DQRule( + name="quantity_min_ge_one", + check="min", + column="quantity", + condition={"geq_to": 1}, + ), + DQRule( + name="quantity_max_le_ten", + check="max", + column="quantity", + condition={"leq_to": 10}, + ), + DQRule( + name="amount_non_negative", + check="custom_sql", + # {table} is substituted by the SQL engine at check time, not an f-string placeholder. + sql="SELECT COUNT(*) FROM {table} WHERE amount < 0", + condition={"equal_to": 0}, + ), + DQRule( + name="high_value_order_count", + check="custom_sql", + sql="SELECT COUNT(*) FROM {table} WHERE amount > 100", + condition={"equal_to": 0}, + severity=Severity.WARN, + ), + DQRule( + name="row_count_present", + check="row_count", + condition={"greater_than": 0}, + severity=Severity.WARN, + ), + DQRule( + name="row_count_at_least_three", + check="row_count", + condition={"geq_to": 3}, + ), + ), +) +# [END howto_operator_dq_check_ruleset] + +# [START howto_operator_dq_check_asset] +orders_asset = asset_quality( + Asset("dq_example_orders", uri="file:///tmp/airflow_dq_example/orders"), + ruleset=orders_ruleset, + conn_id=CONN_ID, + table=TABLE_NAME, +) +# [END howto_operator_dq_check_asset] + +strict_orders_ruleset = RuleSet( + name="orders_strict_quality", + rules=( + DQRule( + name="strict_order_id_not_null", + check="null_count", + column="order_id", + condition={"equal_to": 0}, + ), + DQRule( + name="strict_amount_max_le_20", + check="max", + column="amount", + condition={"leq_to": 20}, + ), + DQRule( + name="strict_row_count_equal_two", + check="row_count", + condition={"equal_to": 2}, + ), + DQRule( + name="strict_region_distinct_count", + check="distinct_count", + column="region", + condition={"geq_to": 4}, + severity=Severity.WARN, + ), + ), +) + +strict_orders_asset = asset_quality( + Asset("dq_example_orders", uri="file:///tmp/airflow_dq_example/orders"), + ruleset=strict_orders_ruleset, + conn_id=CONN_ID, + table=TABLE_NAME, +) + + +with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2021, 1, 1), + catchup=False, + tags=["example", "dq"], +) as dag: + create_table = SQLExecuteQueryOperator( + task_id="create_table", + conn_id=CONN_ID, + sql=[ + f"DROP TABLE IF EXISTS {TABLE_NAME};", + f""" + CREATE TABLE {TABLE_NAME} ( + order_id INTEGER, + customer_id INTEGER, + amount REAL, + discount REAL, + quantity INTEGER, + region TEXT + ); + """, + ], + ) + + clear_table = SQLExecuteQueryOperator( + task_id="clear_table", + conn_id=CONN_ID, + sql=f"DELETE FROM {TABLE_NAME};", + ) + + insert_orders = SQLExecuteQueryOperator( + task_id="insert_orders", + conn_id=CONN_ID, + sql=f""" + {{% set scenario = dag_run.conf.get("dq_scenario", "pass") if dag_run else "pass" %}} + {{% if scenario == "negative" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 1, 'US'), + (2, 102, -25.5, 0.10, 2, 'EU'), + (3, 103, 7.25, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% elif scenario == "duplicate_order" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 1, 'US'), + (1, 102, 25.5, 0.10, 2, 'EU'), + (3, 103, 7.25, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% elif scenario == "nulls" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, NULL, 1, 'US'), + (NULL, NULL, 25.5, NULL, 2, 'EU'), + (3, 103, 7.25, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% elif scenario == "outlier" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 1, 'US'), + (2, 102, 120.0, 0.10, 2, 'EU'), + (3, 103, 150.0, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% elif scenario == "small_table" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 1, 'US'); + {{% elif scenario == "zero_quantity" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 0, 'US'), + (2, 102, 25.5, 0.10, 2, 'EU'), + (3, 103, 7.25, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% elif scenario == "mixed" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, NULL, 0, 'US'), + (1, NULL, -25.5, NULL, 2, 'US'), + (NULL, 103, 150.0, NULL, 11, 'US'), + (4, 104, 55.0, 0.20, 4, 'US'); + {{% elif scenario == "empty" %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) + SELECT NULL, NULL, NULL, NULL, NULL, NULL WHERE 0; + {{% else %}} + INSERT INTO {TABLE_NAME} (order_id, customer_id, amount, discount, quantity, region) VALUES + (1, 101, 10.0, 0.00, 1, 'US'), + (2, 102, 25.5, 0.10, 2, 'EU'), + (3, 103, 7.25, NULL, 3, 'US'), + (4, 104, 55.0, 0.20, 4, 'APAC'); + {{% endif %}} + """, + ) + + # [START howto_operator_dq_check] + check_orders = DQCheckOperator( + task_id="check_orders", + conn_id=CONN_ID, + table=TABLE_NAME, + ruleset=orders_ruleset, + fail_on="never", + ) + # [END howto_operator_dq_check] + + check_orders_via_asset = DQCheckOperator( + task_id="check_orders_via_asset", + asset=orders_asset, + fail_on="never", + ) + + check_orders_strict_via_asset = DQCheckOperator( + task_id="check_orders_strict_via_asset", + asset=strict_orders_asset, + fail_on="never", + ) + + # [START howto_decorator_dq_check] + @task.dq_check( + conn_id=CONN_ID, + table=TABLE_NAME, + ruleset=orders_ruleset, + fail_on="never", + ) + def check_orders_decorated(): + return None + + # [END howto_decorator_dq_check] + + ( + create_table + >> clear_table + >> insert_orders + >> [check_orders, check_orders_via_asset, check_orders_strict_via_asset, check_orders_decorated()] + ) + + from tests_common.test_utils.watcher import watcher + + # This test needs watcher in order to properly mark success/failure + # when "tearDown" task with trigger rule is part of the DAG + list(dag.tasks) >> watcher() + +from tests_common.test_utils.system_tests import get_test_run # noqa: E402 + +# Needed to run the example DAG with pytest (see: contributing-docs/testing/system_tests.rst) +test_run = get_test_run(dag) diff --git a/providers/dq/tests/unit/__init__.py b/providers/dq/tests/unit/__init__.py new file mode 100644 index 0000000000000..5966d6b1d5261 --- /dev/null +++ b/providers/dq/tests/unit/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/providers/dq/tests/unit/dq/__init__.py b/providers/dq/tests/unit/dq/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/api/__init__.py b/providers/dq/tests/unit/dq/api/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/api/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/api/test_app.py b/providers/dq/tests/unit/dq/api/test_app.py new file mode 100644 index 0000000000000..eaab8611ae1c3 --- /dev/null +++ b/providers/dq/tests/unit/dq/api/test_app.py @@ -0,0 +1,390 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import asyncio +import datetime +from typing import TYPE_CHECKING +from unittest import mock + +import pytest +import time_machine +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from airflow.api_fastapi.app import create_app, purge_cached_app +from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser +from airflow.providers.dq.api.app import ( + _get_backend, + dq_app, + health, + run_detail_by_task_instance, + task_rule_history, + task_runs, +) +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend +from airflow.providers.dq.results import DQRun, RuleResult + +from tests_common.test_utils.config import conf_vars +from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS + +if TYPE_CHECKING: + from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import SimpleAuthManager + +BASE_URL = "http://testserver" + +pytestmark = pytest.mark.skipif( + not AIRFLOW_V_3_1_PLUS, + reason="Plugin endpoints are not supported before Airflow 3.1", +) + + +def run(coro): + return asyncio.run(coro) + + +class TestGetBackend: + def test_raises_503_when_unconfigured(self): + with mock.patch("airflow.providers.dq.api.app.get_backend_from_config", return_value=None): + with pytest.raises(HTTPException) as exc_info: + _get_backend() + assert exc_info.value.status_code == 503 + + def test_returns_configured_backend(self, tmp_path): + configured = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + with mock.patch("airflow.providers.dq.api.app.get_backend_from_config", return_value=configured): + assert _get_backend() is configured + + +class TestHealth: + def test_health(self): + assert run(health()) == {"status": "ok"} + + +class TestTaskRuns: + def test_reads_task_runs_from_object_storage_backend(self, tmp_path): + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + dq_run = DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ) + backend.write_run(dq_run, [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")]) + + result = run(task_runs(dag_id="orders_pipeline", task_id="dq", backend=backend)) + + assert result.items[0].run.dag_id == "orders_pipeline" + assert result.items[0].summary.passed == 1 + assert result.next_cursor is None + + def test_passes_before_cursor_through_to_backend(self, tmp_path): + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + for day in range(1, 4): + backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id=f"manual__2026-07-0{day}", + run_uid=f"run{day}", + started_at=f"2026-07-0{day}T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")], + ) + + first_page = run(task_runs(dag_id="orders_pipeline", task_id="dq", backend=backend, limit=2)) + second_page = run( + task_runs( + dag_id="orders_pipeline", + task_id="dq", + backend=backend, + limit=2, + before=first_page.next_cursor, + ) + ) + + assert [item.run.run_uid for item in first_page.items] == ["run3", "run2"] + assert [item.run.run_uid for item in second_page.items] == ["run1"] + assert second_page.next_cursor is None + assert first_page.next_cursor == "2026-07-02T00:00:00+00:00|run2" + + +class TestTaskRuleHistory: + def test_reads_task_rule_history_from_object_storage_backend(self, tmp_path): + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + dq_run = DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ) + backend.write_run(dq_run, [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")]) + + result = run( + task_rule_history( + dag_id="orders_pipeline", + task_id="dq", + rule_uid="r1", + backend=backend, + ) + ) + + assert result.items[0].rule_name == "nulls" + assert result.items[0].run.task_id == "dq" + assert result.next_cursor is None + + +class TestRunDetailByTaskInstance: + def test_reads_run_by_task_instance_route_params(self, tmp_path): + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + dq_run = DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="scheduled__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ) + backend.write_run(dq_run, [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")]) + + result = run( + run_detail_by_task_instance( + dag_id="orders_pipeline", + task_id="dq", + run_id="scheduled__2026-07-04", + backend=backend, + ) + ) + + assert result.run.dag_id == "orders_pipeline" + assert result.results[0].rule_name == "nulls" + assert result.summary.passed == 1 + + def test_missing_task_instance_run_raises_404(self, tmp_path): + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + + with pytest.raises(HTTPException) as exc_info: + run( + run_detail_by_task_instance( + dag_id="orders_pipeline", + task_id="dq", + run_id="does_not_exist", + backend=backend, + ) + ) + + assert exc_info.value.status_code == 404 + + +@pytest.fixture +def dq_backend(tmp_path): + """Override the route-level backend dependency so requests hit an isolated tmp_path backend.""" + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + dq_app.dependency_overrides[_get_backend] = lambda: backend + yield backend + del dq_app.dependency_overrides[_get_backend] + + +def _make_test_client(*, role: str | None): + with ( + conf_vars( + { + ( + "core", + "auth_manager", + ): "airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager", + ("core", "lazy_discover_providers"): "false", + } + ), + mock.patch("airflow.settings.LAZY_LOAD_PROVIDERS", False), + ): + purge_cached_app() + app = create_app() + auth_manager: SimpleAuthManager = app.state.auth_manager + time_very_before = datetime.datetime(2014, 1, 1, 0, 0, 0) + time_after = datetime.datetime.now() + datetime.timedelta(days=1) + with time_machine.travel(time_very_before, tick=False): + token = auth_manager._get_token_signer( + expiration_time_in_seconds=(time_after - time_very_before).total_seconds() + ).generate( + auth_manager.serialize_user(SimpleAuthManagerUser(username="test", role=role)), + ) + return TestClient(app, headers={"Authorization": f"Bearer {token}"}, base_url=BASE_URL) + + +@pytest.fixture +def test_client(): + """Authenticated, authorized (admin) test client for the mounted dq sub-app.""" + return _make_test_client(role="admin") + + +@pytest.fixture +def unauthorized_test_client(): + """Authenticated but role-less test client, for 403 coverage.""" + return _make_test_client(role=None) + + +@pytest.fixture +def unauthenticated_test_client(): + """No Authorization header at all, for 401 coverage.""" + with ( + conf_vars( + { + ( + "core", + "auth_manager", + ): "airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager", + ("core", "lazy_discover_providers"): "false", + } + ), + mock.patch("airflow.settings.LAZY_LOAD_PROVIDERS", False), + ): + purge_cached_app() + app = create_app() + yield TestClient(app, base_url=BASE_URL) + + +class TestHealthEndpointHTTP: + """``/health`` needs no auth and no backend, so it's tested against the bare sub-app.""" + + def test_health_returns_ok(self): + client = TestClient(dq_app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +@pytest.mark.db_test +class TestEndpointAuthorization: + """401/403 coverage for every route gated by ``requires_access_dag``.""" + + ROUTES = ( + ("get", "/dq/v1/dags/orders_pipeline/tasks/dq/runs"), + ("get", "/dq/v1/dags/orders_pipeline/tasks/dq/rules/r1/history"), + ("get", "/dq/v1/dags/orders_pipeline/tasks/dq/runs/by_run/manual__2026-07-04"), + ) + + @pytest.mark.parametrize(("method", "path"), ROUTES) + def test_401_unauthenticated(self, unauthenticated_test_client, method, path): + response = getattr(unauthenticated_test_client, method)(path) + assert response.status_code == 401 + + @pytest.mark.parametrize(("method", "path"), ROUTES) + def test_403_forbidden(self, unauthorized_test_client, method, path): + response = getattr(unauthorized_test_client, method)(path) + assert response.status_code == 403 + + +@pytest.mark.db_test +class TestTaskRunsHTTP: + def test_returns_persisted_runs(self, test_client, dq_backend): + dq_backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")], + ) + + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs") + + assert response.status_code == 200 + data = response.json() + assert data["items"][0]["run"]["dag_id"] == "orders_pipeline" + assert data["items"][0]["summary"]["passed"] == 1 + assert data["next_cursor"] is None + + def test_before_cursor_pages_further_back(self, test_client, dq_backend): + for day in range(1, 4): + dq_backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id=f"manual__2026-07-0{day}", + run_uid=f"run{day}", + started_at=f"2026-07-0{day}T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")], + ) + + first_page = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs", params={"limit": 2}).json() + second_page = test_client.get( + "/dq/v1/dags/orders_pipeline/tasks/dq/runs", + params={"limit": 2, "before": first_page["next_cursor"]}, + ).json() + + assert [item["run"]["run_uid"] for item in first_page["items"]] == ["run3", "run2"] + assert [item["run"]["run_uid"] for item in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + assert first_page["next_cursor"] == "2026-07-02T00:00:00+00:00|run2" + + def test_503_when_backend_unconfigured(self, test_client): + with mock.patch("airflow.providers.dq.api.app.get_backend_from_config", return_value=None): + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs") + assert response.status_code == 503 + + def test_limit_out_of_range_is_422(self, test_client, dq_backend): + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs", params={"limit": 0}) + assert response.status_code == 422 + + +@pytest.mark.db_test +class TestTaskRuleHistoryHTTP: + def test_returns_rule_history_for_task(self, test_client, dq_backend): + dq_backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="fail")], + ) + + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/rules/r1/history") + + assert response.status_code == 200 + data = response.json() + assert data["items"][0]["rule_name"] == "nulls" + assert data["items"][0]["run"]["task_id"] == "dq" + assert data["next_cursor"] is None + + +@pytest.mark.db_test +class TestRunDetailByTaskInstanceHTTP: + def test_returns_run_for_task_instance(self, test_client, dq_backend): + dq_backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="scheduled__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")], + ) + + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs/by_run/scheduled__2026-07-04") + + assert response.status_code == 200 + data = response.json() + assert data["run"]["dag_id"] == "orders_pipeline" + assert data["results"][0]["rule_name"] == "nulls" + assert data["summary"]["passed"] == 1 + + def test_404_when_run_not_found(self, test_client, dq_backend): + response = test_client.get("/dq/v1/dags/orders_pipeline/tasks/dq/runs/by_run/does_not_exist") + assert response.status_code == 404 diff --git a/providers/dq/tests/unit/dq/api/test_models.py b/providers/dq/tests/unit/dq/api/test_models.py new file mode 100644 index 0000000000000..e3fcba3d6c345 --- /dev/null +++ b/providers/dq/tests/unit/dq/api/test_models.py @@ -0,0 +1,91 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.api.models import ( + PaginatedResponse, + RuleHistoryRecordModel, + TaskDQRunModel, +) +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend +from airflow.providers.dq.results import DQRun, RuleResult + + +def make_backend(tmp_path) -> ObjectStorageResultsBackend: + return ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + + +class TestTaskDQRunModel: + def test_validates_backend_run_payload(self, tmp_path): + backend = make_backend(tmp_path) + dq_run = DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ) + backend.write_run(dq_run, [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")]) + + payload = backend.read_by_task_instance("orders_pipeline", "dq", "manual__2026-07-04") + model = TaskDQRunModel(**payload) + + assert model.run.dag_id == "orders_pipeline" + assert model.results[0].rule_name == "nulls" + assert model.summary.passed == 1 + + +class TestPaginatedResponseOfTaskDQRunModel: + def test_validates_backend_task_runs_page(self, tmp_path): + backend = make_backend(tmp_path) + for day in range(1, 3): + backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id=f"manual__2026-07-0{day}", + run_uid=f"run{day}", + started_at=f"2026-07-0{day}T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="pass")], + ) + + page = backend.read_task_runs("orders_pipeline", "dq") + model = PaginatedResponse[TaskDQRunModel](**page) + + assert [item.run.run_uid for item in model.items] == ["run2", "run1"] + assert model.next_cursor is None + + +class TestPaginatedResponseOfRuleHistoryRecordModel: + def test_validates_backend_rule_history_page(self, tmp_path): + backend = make_backend(tmp_path) + backend.write_run( + DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="manual__2026-07-04", + started_at="2026-07-04T00:00:00+00:00", + ), + [RuleResult(rule_uid="r1", rule_name="nulls", status="fail")], + ) + + page = backend.read_task_rule_history("orders_pipeline", "dq", "r1") + model = PaginatedResponse[RuleHistoryRecordModel](**page) + + assert model.items[0].rule_name == "nulls" + assert model.items[0].run.task_id == "dq" + assert model.next_cursor is None diff --git a/providers/dq/tests/unit/dq/backends/__init__.py b/providers/dq/tests/unit/dq/backends/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/backends/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/backends/test_object_storage.py b/providers/dq/tests/unit/dq/backends/test_object_storage.py new file mode 100644 index 0000000000000..bc6ac2d4b3b28 --- /dev/null +++ b/providers/dq/tests/unit/dq/backends/test_object_storage.py @@ -0,0 +1,357 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from airflow.providers.dq.backends import get_backend_from_config +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend +from airflow.providers.dq.results import DQRun, RuleResult +from airflow.providers.dq.rules import Severity + +from tests_common.test_utils.config import conf_vars + + +def make_run(run_uid: str = "abc123", started_at: str = "2026-07-04T06:00:00+00:00") -> DQRun: + return DQRun( + dag_id="orders_pipeline", + task_id="dq", + run_id="scheduled__2026-07-04", + run_uid=run_uid, + ruleset_name="orders", + table_ref="analytics.orders", + asset_names=("dq_example_orders",), + started_at=started_at, + finished_at="2026-07-04T06:00:03+00:00", + ) + + +def make_result(rule_uid: str = "rule-1", status: str = "pass") -> RuleResult: + return RuleResult( + rule_uid=rule_uid, + rule_name="ids_not_null", + status=status, + observed_value=0, + condition={"equal_to": 0}, + dimension="completeness", + severity=Severity.ERROR, + duration_ms=12.5, + ) + + +class TestObjectStorageResultsBackend: + @pytest.fixture + def backend(self, tmp_path): + return ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + + def test_write_run_creates_run_file_with_run_and_results(self, backend): + backend.write_run(make_run(), [make_result()]) + + record = backend.read_by_task_instance("orders_pipeline", "dq", "scheduled__2026-07-04") + assert record["run"]["run_uid"] == "abc123" + assert record["run"]["dag_id"] == "orders_pipeline" + assert record["results"][0]["rule_uid"] == "rule-1" + assert record["results"][0]["status"] == "pass" + + def test_write_run_stores_keyed_json_payload(self, backend): + backend.write_run(make_run(), [make_result()]) + + path = ( + backend.root + / "runs" + / "by_task" + / "dag_id=orders_pipeline" + / "task_id=dq" + / "date=2026-07-04" + / "abc123.json" + ) + payload = json.loads(path.read_text()) + + assert payload["run"]["run_uid"] == "abc123" + assert payload["results"][0]["rule_uid"] == "rule-1" + assert payload["summary"]["passed"] == 1 + + def test_write_run_stores_task_rule_index(self, backend): + backend.write_run(make_run(), [make_result()]) + + path = ( + backend.root + / "rules" + / "by_task_rule" + / "dag_id=orders_pipeline" + / "task_id=dq" + / "rule_uid=rule-1" + / "2026-07-04T06_00_00_00_00__abc123.json" + ) + payload = json.loads(path.read_text()) + + assert payload["run"]["dag_id"] == "orders_pipeline" + assert payload["run"]["task_id"] == "dq" + assert payload["result"]["rule_uid"] == "rule-1" + + def test_rule_history_is_newest_first(self, backend): + backend.write_run( + make_run(run_uid="run1", started_at="2026-07-01T06:00:00+00:00"), + [make_result(status="pass")], + ) + backend.write_run( + make_run(run_uid="run2", started_at="2026-07-02T06:00:00+00:00"), + [make_result(status="fail")], + ) + + result = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1") + + assert [record["status"] for record in result["items"]] == ["fail", "pass"] + assert result["items"][0]["run"]["run_uid"] == "run2" + assert result["next_cursor"] is None + + def test_rule_history_includes_task_instance_route_context(self, backend): + backend.write_run(make_run(), [make_result()]) + + history = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1")["items"] + + assert history[0]["run"]["dag_id"] == "orders_pipeline" + assert history[0]["run"]["task_id"] == "dq" + assert history[0]["run"]["run_id"] == "scheduled__2026-07-04" + assert history[0]["run"]["map_index"] == -1 + + def test_rule_history_respects_limit(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + result = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1", limit=2) + + assert len(result["items"]) == 2 + assert result["next_cursor"] == "2026-07-02T06:00:00+00:00|run2" + + def test_rule_history_before_cursor_pages_further_back(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + first_page = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1", limit=2) + second_page = backend.read_task_rule_history( + "orders_pipeline", "dq", "rule-1", limit=2, before=first_page["next_cursor"] + ) + + assert [r["run"]["run_uid"] for r in first_page["items"]] == ["run3", "run2"] + assert [r["run"]["run_uid"] for r in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + + def test_concurrent_style_writes_do_not_collide(self, backend): + backend.write_run(make_run(run_uid="run1"), [make_result()]) + backend.write_run(make_run(run_uid="run2"), [make_result()]) + + runs = backend.read_task_runs("orders_pipeline", "dq")["items"] + assert {run["run"]["run_uid"] for run in runs} == {"run1", "run2"} + + def test_read_by_task_instance_matches_run(self, backend): + backend.write_run(make_run(), [make_result()]) + + record = backend.read_by_task_instance("orders_pipeline", "dq", "scheduled__2026-07-04") + + assert record["run"]["run_uid"] == "abc123" + assert record["results"][0]["rule_uid"] == "rule-1" + assert record["summary"]["passed"] == 1 + + def test_read_by_task_instance_unknown_raises(self, backend): + with pytest.raises(FileNotFoundError): + backend.read_by_task_instance("orders_pipeline", "dq", "no_such_run") + + def test_read_by_task_instance_last_write_wins_across_retries(self, backend): + first = make_run(run_uid="try1") + backend.write_run(first, [make_result(status="fail")]) + second = make_run(run_uid="try2") + backend.write_run(second, [make_result(status="pass")]) + + record = backend.read_by_task_instance("orders_pipeline", "dq", "scheduled__2026-07-04") + + assert record["run"]["run_uid"] == "try2" + assert record["results"][0]["status"] == "pass" + assert record["summary"]["passed"] == 1 + + def test_read_by_task_instance_sanitizes_run_id(self, backend): + run_id = "manual__2026-07-04T06:00:00+00:00" + run = DQRun(dag_id="orders_pipeline", task_id="dq", run_id=run_id, run_uid="abc123") + backend.write_run(run, [make_result()]) + + record = backend.read_by_task_instance("orders_pipeline", "dq", run_id) + + assert record["run"]["run_id"] == run_id + + def test_read_task_runs_returns_newest_first_with_summaries(self, backend): + backend.write_run( + make_run(run_uid="run1", started_at="2026-07-01T06:00:00+00:00"), + [make_result(status="pass")], + ) + backend.write_run( + make_run(run_uid="run2", started_at="2026-07-02T06:00:00+00:00"), + [make_result(status="fail")], + ) + + result = backend.read_task_runs("orders_pipeline", "dq") + runs = result["items"] + + assert [record["run"]["run_uid"] for record in runs] == ["run2", "run1"] + assert runs[0]["summary"]["failed"] == 1 + assert runs[1]["summary"]["passed"] == 1 + assert result["next_cursor"] is None + + def test_read_task_runs_respects_limit(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + result = backend.read_task_runs("orders_pipeline", "dq", limit=2) + + assert len(result["items"]) == 2 + assert result["next_cursor"] == "2026-07-02T06:00:00+00:00|run2" + + def test_read_task_runs_before_cursor_pages_further_back(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + first_page = backend.read_task_runs("orders_pipeline", "dq", limit=2) + second_page = backend.read_task_runs( + "orders_pipeline", "dq", limit=2, before=first_page["next_cursor"] + ) + + assert [r["run"]["run_uid"] for r in first_page["items"]] == ["run3", "run2"] + assert [r["run"]["run_uid"] for r in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + + def test_read_task_runs_cursor_keeps_same_timestamp_records(self, backend): + for run_uid in ("run1", "run2", "run3"): + backend.write_run( + make_run(run_uid=run_uid, started_at="2026-07-04T06:00:00+00:00"), + [make_result()], + ) + + first_page = backend.read_task_runs("orders_pipeline", "dq", limit=2) + second_page = backend.read_task_runs( + "orders_pipeline", "dq", limit=2, before=first_page["next_cursor"] + ) + + assert [r["run"]["run_uid"] for r in first_page["items"]] == ["run3", "run2"] + assert [r["run"]["run_uid"] for r in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + + def test_read_task_runs_stops_scanning_once_limit_is_reached(self, backend, monkeypatch): + for day in range(1, 5): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + read_paths: list[Any] = [] + original_read_json = backend._read_json + monkeypatch.setattr( + backend, "_read_json", lambda path: (read_paths.append(path), original_read_json(path))[1] + ) + + result = backend.read_task_runs("orders_pipeline", "dq", limit=1) + + assert [record["run"]["run_uid"] for record in result["items"]] == ["run4"] + assert result["next_cursor"] == "2026-07-04T06:00:00+00:00|run4" + # Determining next_cursor reads one entry past the limit (date=2026-07-03's run3), but + # no further — date=2026-07-02/01 must not be read. + assert len(read_paths) == 2 + + def test_read_task_rule_history_filters_to_task(self, backend): + backend.write_run(make_run(run_uid="run1"), [make_result()]) + backend.write_run( + DQRun( + dag_id="other_pipeline", + task_id="dq", + run_id="scheduled__2026-07-04", + run_uid="run2", + started_at="2026-07-04T07:00:00+00:00", + ), + [make_result()], + ) + + history = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1")["items"] + + assert len(history) == 1 + assert history[0]["run"]["dag_id"] == "orders_pipeline" + + def test_read_task_rule_history_respects_limit(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + result = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1", limit=2) + + assert [record["run"]["run_uid"] for record in result["items"]] == ["run3", "run2"] + assert result["next_cursor"] == "2026-07-02T06:00:00+00:00|run2" + + def test_read_task_rule_history_before_cursor_pages_further_back(self, backend): + for day in range(1, 4): + backend.write_run( + make_run(run_uid=f"run{day}", started_at=f"2026-07-0{day}T06:00:00+00:00"), + [make_result()], + ) + + first_page = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1", limit=2) + second_page = backend.read_task_rule_history( + "orders_pipeline", "dq", "rule-1", limit=2, before=first_page["next_cursor"] + ) + + assert [r["run"]["run_uid"] for r in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + + def test_read_task_rule_history_cursor_keeps_same_timestamp_records(self, backend): + for run_uid in ("run1", "run2", "run3"): + backend.write_run( + make_run(run_uid=run_uid, started_at="2026-07-04T06:00:00+00:00"), + [make_result()], + ) + + first_page = backend.read_task_rule_history("orders_pipeline", "dq", "rule-1", limit=2) + second_page = backend.read_task_rule_history( + "orders_pipeline", "dq", "rule-1", limit=2, before=first_page["next_cursor"] + ) + + assert [r["run"]["run_uid"] for r in first_page["items"]] == ["run3", "run2"] + assert [r["run"]["run_uid"] for r in second_page["items"]] == ["run1"] + assert second_page["next_cursor"] is None + + +class TestGetBackendFromConfig: + @conf_vars({("dq", "results_path"): None}) + def test_no_results_path_returns_none(self): + assert get_backend_from_config() is None + + def test_results_path_builds_object_storage_backend(self, tmp_path): + with conf_vars({("dq", "results_path"): f"file://{tmp_path}"}): + backend = get_backend_from_config() + assert isinstance(backend, ObjectStorageResultsBackend) diff --git a/providers/dq/tests/unit/dq/decorators/__init__.py b/providers/dq/tests/unit/dq/decorators/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/decorators/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/decorators/test_dq_check.py b/providers/dq/tests/unit/dq/decorators/test_dq_check.py new file mode 100644 index 0000000000000..cbe88a1fb69f6 --- /dev/null +++ b/providers/dq/tests/unit/dq/decorators/test_dq_check.py @@ -0,0 +1,123 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.common.sql.hooks.sql import DbApiHook +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend +from airflow.providers.dq.decorators.dq_check import _DQCheckDecoratedOperator +from airflow.providers.dq.rules import DQRule, RuleSet +from airflow.sdk.execution_time.context import OutletEventAccessors + +NULLS = DQRule(name="nulls", check="null_count", column="id", condition={"equal_to": 0}) +RULESET = RuleSet(name="orders", rules=(NULLS,)) + + +def make_context(): + # spec'd to the attributes DQCheckOperator actually reads off `ti`, since the real + # RuntimeTaskInstance is a heavyweight pydantic model with many unrelated required fields. + ti = mock.Mock(spec=["dag_id", "task_id", "run_id", "try_number", "map_index"]) + ti.dag_id = "orders_pipeline" + ti.task_id = "dq" + ti.run_id = "manual__2026-07-04" + ti.try_number = 1 + ti.map_index = -1 + return {"ti": ti, "outlet_events": mock.create_autospec(OutletEventAccessors, instance=True)} + + +@pytest.fixture(autouse=True) +def results_backend(tmp_path): + """Patch the module-level config lookup so persisted results land in an isolated tmp_path.""" + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + with mock.patch("airflow.providers.dq.operators.dq_check.get_backend_from_config", return_value=backend): + yield backend + + +def make_decorated_operator(records, python_callable, **kwargs): + kwargs.setdefault("task_id", "dq") + if not kwargs.pop("omit_ruleset", False): + kwargs.setdefault("ruleset", RULESET) + kwargs.setdefault("table", "orders") + operator = _DQCheckDecoratedOperator(conn_id="warehouse", python_callable=python_callable, **kwargs) + hook = mock.create_autospec(DbApiHook, instance=True) + hook.get_records.return_value = records + return operator, hook + + +class TestDQCheckDecoratedOperator: + def test_custom_operator_name(self): + assert _DQCheckDecoratedOperator.custom_operator_name == "@task.dq_check" + + @mock.patch.object(_DQCheckDecoratedOperator, "get_db_hook") + def test_none_return_runs_check_as_declared(self, mock_get_db_hook): + operator, hook = make_decorated_operator(records=[(NULLS.rule_uid, 0)], python_callable=lambda: None) + mock_get_db_hook.return_value = hook + + summary = operator.execute(make_context()) + + assert operator.table == "orders" + assert summary["passed"] == 1 + + @mock.patch.object(_DQCheckDecoratedOperator, "get_db_hook") + def test_ruleset_can_be_provided_only_at_runtime(self, mock_get_db_hook): + other_rule = DQRule(name="row_count_ok", check="row_count", condition={"greater_than": 0}) + other_ruleset = RuleSet(name="dynamic", rules=(other_rule,)) + + operator, hook = make_decorated_operator( + records=[(other_rule.rule_uid, 10)], + python_callable=lambda: other_ruleset, + omit_ruleset=True, + ) + mock_get_db_hook.return_value = hook + + summary = operator.execute(make_context()) + + assert summary["passed"] == 1 + + def test_missing_runtime_ruleset_raises_value_error(self): + operator, hook = make_decorated_operator(records=[], python_callable=lambda: None, omit_ruleset=True) + with mock.patch.object(_DQCheckDecoratedOperator, "get_db_hook", return_value=hook): + with pytest.raises(ValueError, match="ruleset is required"): + operator.execute(make_context()) + + @mock.patch.object(_DQCheckDecoratedOperator, "get_db_hook") + def test_op_kwargs_are_passed_to_callable(self, mock_get_db_hook): + seen = {} + + def resolve_target(suffix): + seen["suffix"] = suffix + return None + + operator, hook = make_decorated_operator( + records=[(NULLS.rule_uid, 0)], + python_callable=resolve_target, + op_kwargs={"suffix": "eu"}, + ) + mock_get_db_hook.return_value = hook + + operator.execute(make_context()) + + assert seen["suffix"] == "eu" + + def test_invalid_return_raises_type_error(self): + operator, hook = make_decorated_operator(records=[], python_callable=lambda: 42) + with mock.patch.object(_DQCheckDecoratedOperator, "get_db_hook", return_value=hook): + with pytest.raises(TypeError, match="must return a RuleSet"): + operator.execute(make_context()) diff --git a/providers/dq/tests/unit/dq/engines/__init__.py b/providers/dq/tests/unit/dq/engines/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/engines/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/engines/test_sql.py b/providers/dq/tests/unit/dq/engines/test_sql.py new file mode 100644 index 0000000000000..9161275c3849a --- /dev/null +++ b/providers/dq/tests/unit/dq/engines/test_sql.py @@ -0,0 +1,118 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.common.sql.hooks.sql import DbApiHook +from airflow.providers.dq.engines.sql import SQLDQEngine +from airflow.providers.dq.rules import DQRule, RuleSet + + +@pytest.fixture +def hook(): + return mock.create_autospec(DbApiHook, instance=True) + + +NULLS = DQRule(name="nulls", check="null_count", column="id", condition={"equal_to": 0}) +VOLUME = DQRule(name="volume", check="row_count", condition={"greater_than": 0}) +CUSTOM = DQRule( + name="negatives", + check="custom_sql", + sql="SELECT COUNT(*) FROM {table} WHERE amount < 0", + condition={"equal_to": 0}, +) + + +class TestBuildBatchSql: + def test_batch_sql_unions_one_select_per_rule(self, hook): + engine = SQLDQEngine(hook) + sql = engine.build_batch_sql([NULLS, VOLUME], "orders", None) + assert sql.count("UNION ALL") == 1 + assert f"'{NULLS.rule_uid}' AS rule_uid" in sql + assert "SUM(CASE WHEN id IS NULL THEN 1 ELSE 0 END)" in sql + assert "COUNT(*)" in sql + assert "WHERE" not in sql + + def test_partition_clauses_are_anded(self, hook): + rule = DQRule( + name="nulls", + check="null_count", + column="id", + condition={"equal_to": 0}, + partition_clause="region = 'EU'", + ) + sql = SQLDQEngine(hook).build_batch_sql([rule], "orders", "ds = '2026-07-04'") + assert "WHERE ds = '2026-07-04' AND region = 'EU'" in sql + + +class TestMeasure: + def test_builtin_rules_measured_from_single_query(self, hook): + hook.get_records.return_value = [(NULLS.rule_uid, 0), (VOLUME.rule_uid, 42)] + ruleset = RuleSet(name="s", rules=(NULLS, VOLUME)) + + observations = SQLDQEngine(hook).measure(ruleset, "orders") + + hook.get_records.assert_called_once() + by_name = {obs.rule.name: obs for obs in observations} + assert by_name["nulls"].observed_value == 0 + assert by_name["volume"].observed_value == 42 + assert by_name["nulls"].sql == SQLDQEngine(hook).build_rule_sql(NULLS, "orders") + assert by_name["volume"].sql == SQLDQEngine(hook).build_rule_sql(VOLUME, "orders") + assert all(obs.error_message is None for obs in observations) + + def test_query_failure_yields_error_observations(self, hook): + hook.get_records.side_effect = RuntimeError("connection refused") + ruleset = RuleSet(name="s", rules=(NULLS, VOLUME)) + + observations = SQLDQEngine(hook).measure(ruleset, "orders") + + assert len(observations) == 2 + assert all(obs.error_message == "connection refused" for obs in observations) + assert all(obs.observed_value is None for obs in observations) + assert all(obs.sql for obs in observations) + + def test_missing_rule_in_result_is_an_error(self, hook): + hook.get_records.return_value = [(NULLS.rule_uid, 0)] + ruleset = RuleSet(name="s", rules=(NULLS, VOLUME)) + + observations = SQLDQEngine(hook).measure(ruleset, "orders") + + by_name = {obs.rule.name: obs for obs in observations} + assert by_name["nulls"].error_message is None + assert by_name["volume"].error_message == "No result returned for rule" + + def test_custom_sql_rule_runs_individually_with_table_substituted(self, hook): + hook.get_first.return_value = (3,) + ruleset = RuleSet(name="s", rules=(CUSTOM,)) + + observations = SQLDQEngine(hook).measure(ruleset, "orders") + + hook.get_first.assert_called_once_with("SELECT COUNT(*) FROM orders WHERE amount < 0") + hook.get_records.assert_not_called() + assert observations[0].observed_value == 3 + assert observations[0].sql == "SELECT COUNT(*) FROM orders WHERE amount < 0" + + def test_custom_sql_no_rows_is_an_error(self, hook): + hook.get_first.return_value = None + ruleset = RuleSet(name="s", rules=(CUSTOM,)) + + observations = SQLDQEngine(hook).measure(ruleset, "orders") + + assert observations[0].error_message == "Query returned no rows" diff --git a/providers/dq/tests/unit/dq/operators/__init__.py b/providers/dq/tests/unit/dq/operators/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/operators/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/operators/test_dq_check.py b/providers/dq/tests/unit/dq/operators/test_dq_check.py new file mode 100644 index 0000000000000..9e74a8dc94642 --- /dev/null +++ b/providers/dq/tests/unit/dq/operators/test_dq_check.py @@ -0,0 +1,247 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.common.sql.hooks.sql import DbApiHook +from airflow.providers.dq.assets import asset_quality +from airflow.providers.dq.backends.object_storage import ObjectStorageResultsBackend +from airflow.providers.dq.exceptions import DQCheckFailedError +from airflow.providers.dq.operators.dq_check import DQCheckOperator +from airflow.providers.dq.rules import DQRule, RuleSet, Severity +from airflow.sdk import Asset +from airflow.sdk.execution_time.context import OutletEventAccessors + +NULLS = DQRule(name="nulls", check="null_count", column="id", condition={"equal_to": 0}) +VOLUME_WARN = DQRule( + name="volume", check="row_count", condition={"greater_than": 100}, severity=Severity.WARN +) +RULESET = RuleSet(name="orders", rules=(NULLS, VOLUME_WARN)) + + +def make_context(): + # spec'd to the attributes DQCheckOperator actually reads off `ti`, since the real + # RuntimeTaskInstance is a heavyweight pydantic model with many unrelated required fields. + ti = mock.Mock(spec=["dag_id", "task_id", "run_id", "try_number", "map_index"]) + ti.dag_id = "orders_pipeline" + ti.task_id = "dq" + ti.run_id = "manual__2026-07-04" + ti.try_number = 1 + ti.map_index = -1 + return {"ti": ti, "outlet_events": mock.create_autospec(OutletEventAccessors, instance=True)} + + +def make_operator(records, **kwargs): + kwargs.setdefault("task_id", "dq") + kwargs.setdefault("ruleset", RULESET) + kwargs.setdefault("table", "orders") + operator = DQCheckOperator(conn_id="warehouse", **kwargs) + hook = mock.create_autospec(DbApiHook, instance=True) + hook.get_records.return_value = records + return operator, hook + + +@pytest.fixture(autouse=True) +def results_backend(tmp_path): + """Patch the module-level config lookup so persisted results land in an isolated tmp_path.""" + backend = ObjectStorageResultsBackend(results_path=f"file://{tmp_path}") + with mock.patch("airflow.providers.dq.operators.dq_check.get_backend_from_config", return_value=backend): + yield backend + + +class TestDQCheckOperatorConstruction: + def test_requires_ruleset(self): + with pytest.raises(ValueError, match="ruleset is required"): + DQCheckOperator(task_id="dq", table="orders", conn_id="c") + + def test_requires_table(self): + with pytest.raises(ValueError, match="table is required"): + DQCheckOperator(task_id="dq", ruleset=RULESET, conn_id="c") + + def test_rejects_bad_fail_on(self): + with pytest.raises(ValueError, match="fail_on"): + DQCheckOperator(task_id="dq", ruleset=RULESET, table="orders", conn_id="c", fail_on="maybe") + + def test_asset_supplies_defaults_and_outlet(self): + asset = asset_quality( + Asset("orders", uri="postgres://wh/warehouse/analytics/orders"), + ruleset=RULESET, + conn_id="warehouse", + table="analytics.orders", + ) + operator = DQCheckOperator(task_id="dq", asset=asset) + assert operator.table == "analytics.orders" + assert operator.conn_id == "warehouse" + assert asset in operator.outlets + + def test_explicit_arguments_beat_asset_config(self): + asset = asset_quality( + Asset("orders", uri="postgres://wh/warehouse/analytics/orders"), + ruleset=RULESET, + conn_id="warehouse", + table="analytics.orders", + ) + operator = DQCheckOperator(task_id="dq", asset=asset, table="other_table", conn_id="other_conn") + assert operator.table == "other_table" + assert operator.conn_id == "other_conn" + + +class TestDQCheckOperatorExecute: + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_all_pass_returns_summary_and_persists(self, mock_get_db_hook, results_backend): + operator, hook = make_operator(records=[(NULLS.rule_uid, 0), (VOLUME_WARN.rule_uid, 500)]) + mock_get_db_hook.return_value = hook + + summary = operator.execute(make_context()) + + assert summary["passed"] == 2 + assert summary["failed"] == 0 + assert summary["score"] == 1.0 + history = results_backend.read_task_rule_history("orders_pipeline", "dq", NULLS.rule_uid)["items"] + assert len(history) == 1 + assert history[0]["status"] == "pass" + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_error_severity_failure_fails_task(self, mock_get_db_hook, results_backend): + operator, hook = make_operator(records=[(NULLS.rule_uid, 3), (VOLUME_WARN.rule_uid, 500)]) + mock_get_db_hook.return_value = hook + + with pytest.raises(DQCheckFailedError, match="nulls"): + operator.execute(make_context()) + + # The failing result is persisted even though the task fails. + assert ( + results_backend.read_task_rule_history("orders_pipeline", "dq", NULLS.rule_uid)["items"][0][ + "status" + ] + == "fail" + ) + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_warn_severity_failure_does_not_fail_task_by_default(self, mock_get_db_hook): + operator, hook = make_operator(records=[(NULLS.rule_uid, 0), (VOLUME_WARN.rule_uid, 5)]) + mock_get_db_hook.return_value = hook + + summary = operator.execute(make_context()) + + assert summary["warned"] == 1 + assert summary["warned_rules"] == ["volume"] + assert summary["score"] == pytest.approx(0.875) + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_rule_description_is_persisted(self, mock_get_db_hook, results_backend): + described_rule = DQRule( + name="nulls", + check="null_count", + column="id", + condition={"equal_to": 0}, + description="Order IDs must always be present.", + ) + operator, hook = make_operator( + records=[(described_rule.rule_uid, 0)], + ruleset=RuleSet(name="orders", rules=(described_rule,)), + ) + mock_get_db_hook.return_value = hook + + operator.execute(make_context()) + + history = results_backend.read_task_rule_history("orders_pipeline", "dq", described_rule.rule_uid)[ + "items" + ] + assert history[0]["description"] == "Order IDs must always be present." + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_fail_on_warn_fails_task_on_warn_failure(self, mock_get_db_hook): + operator, hook = make_operator( + records=[(NULLS.rule_uid, 0), (VOLUME_WARN.rule_uid, 5)], fail_on="warn" + ) + mock_get_db_hook.return_value = hook + + with pytest.raises(DQCheckFailedError, match="volume"): + operator.execute(make_context()) + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_fail_on_never_records_failures_without_raising(self, mock_get_db_hook): + operator, hook = make_operator( + records=[(NULLS.rule_uid, 3), (VOLUME_WARN.rule_uid, 5)], fail_on="never" + ) + mock_get_db_hook.return_value = hook + + summary = operator.execute(make_context()) + + assert summary["failed"] == 1 + assert summary["warned"] == 1 + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_execution_error_always_fails_task(self, mock_get_db_hook): + operator, hook = make_operator(records=None, fail_on="never") + hook.get_records.side_effect = RuntimeError("boom") + mock_get_db_hook.return_value = hook + + with pytest.raises(DQCheckFailedError, match="errored"): + operator.execute(make_context()) + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_non_numeric_observed_value_is_persisted_as_error(self, mock_get_db_hook, results_backend): + operator, hook = make_operator(records=[(NULLS.rule_uid, "not-a-number")], fail_on="never") + mock_get_db_hook.return_value = hook + + with pytest.raises(DQCheckFailedError, match="errored"): + operator.execute(make_context()) + + history = results_backend.read_task_rule_history("orders_pipeline", "dq", NULLS.rule_uid)["items"] + assert history[0]["status"] == "error" + assert "Could not evaluate observed value" in history[0]["error_message"] + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_backend_failure_does_not_fail_the_check(self, mock_get_db_hook): + backend = mock.create_autospec(ObjectStorageResultsBackend, instance=True) + backend.write_run.side_effect = OSError("bucket unreachable") + operator, hook = make_operator(records=[(NULLS.rule_uid, 0), (VOLUME_WARN.rule_uid, 500)]) + mock_get_db_hook.return_value = hook + + with mock.patch( + "airflow.providers.dq.operators.dq_check.get_backend_from_config", return_value=backend + ): + summary = operator.execute(make_context()) + + assert summary["passed"] == 2 + backend.write_run.assert_called_once() + + @mock.patch.object(DQCheckOperator, "get_db_hook") + def test_summary_attached_to_outlet_events(self, mock_get_db_hook): + asset = asset_quality( + Asset("orders", uri="postgres://wh/warehouse/analytics/orders"), + ruleset=RULESET, + conn_id="warehouse", + table="orders", + ) + operator, hook = make_operator( + records=[(NULLS.rule_uid, 0), (VOLUME_WARN.rule_uid, 500)], asset=asset + ) + mock_get_db_hook.return_value = hook + context = make_context() + + summary = operator.execute(context) + + outlet_events = context["outlet_events"] + outlet_events.__getitem__.assert_called_with(asset) + extra = outlet_events.__getitem__.return_value.extra + extra.__setitem__.assert_called_once_with("airflow.dq.result", summary) diff --git a/providers/dq/tests/unit/dq/plugins/__init__.py b/providers/dq/tests/unit/dq/plugins/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/plugins/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/plugins/test_dq.py b/providers/dq/tests/unit/dq/plugins/test_dq.py new file mode 100644 index 0000000000000..36e35fb12c04e --- /dev/null +++ b/providers/dq/tests/unit/dq/plugins/test_dq.py @@ -0,0 +1,87 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import pytest + +from airflow.providers.dq.api.app import dq_app +from airflow.providers.dq.plugins.dq import DQPlugin, _get_base_url_path, _get_bundle_url, _react_apps + +from tests_common.test_utils.config import conf_vars +from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS + + +class TestDQPlugin: + def test_name(self): + assert DQPlugin.name == "dq" + + @pytest.mark.skipif( + not AIRFLOW_V_3_1_PLUS, reason="Plugin endpoints are not supported before Airflow 3.1" + ) + def test_registers_the_query_api_under_dq_prefix(self): + assert DQPlugin.fastapi_apps == [{"name": "dq", "app": dq_app, "url_prefix": "/dq"}] + + +@pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, reason="Plugin React apps are not supported before Airflow 3.1") +class TestReactApps: + def test_empty_when_bundle_not_built(self, tmp_path): + # A plain source checkout has no dist/ until `pnpm build` runs (wired into the wheel + # build via hatch_build.py); the entry must not advertise a bundle URL that 404s. + assert _react_apps(tmp_path / "dist") == [] + + def test_registers_task_and_task_instance_views_once_bundle_exists(self, tmp_path): + dist_dir = tmp_path / "dist" + dist_dir.mkdir() + (dist_dir / "main.umd.cjs").write_text("") + + with conf_vars({("api", "base_url"): "/"}): + apps = _react_apps(dist_dir) + + assert apps == [ + { + "name": "Data Quality", + "bundle_url": "/dq/static/main.umd.cjs", + "destination": "task", + "url_route": "dq-task", + }, + { + "name": "Data Quality", + "bundle_url": "/dq/static/main.umd.cjs", + "destination": "task_instance", + "url_route": "dq-run", + }, + ] + + +class TestGetBaseUrlPath: + def test_root_base_url(self): + with conf_vars({("api", "base_url"): "/"}): + assert _get_base_url_path("/dq") == "/dq" + + def test_http_base_url_extracts_path(self): + with conf_vars({("api", "base_url"): "http://example.com/airflow/"}): + assert _get_base_url_path("/dq") == "/airflow/dq" + + +class TestGetBundleUrl: + def test_root_base_url_returns_relative_path(self): + with conf_vars({("api", "base_url"): "/"}): + assert _get_bundle_url() == "/dq/static/main.umd.cjs" + + def test_http_base_url_returns_absolute_url(self): + with conf_vars({("api", "base_url"): "http://example.com:28080/airflow/"}): + assert _get_bundle_url() == "http://example.com:28080/airflow/dq/static/main.umd.cjs" diff --git a/providers/dq/tests/unit/dq/rules/__init__.py b/providers/dq/tests/unit/dq/rules/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/dq/tests/unit/dq/rules/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/providers/dq/tests/unit/dq/rules/test_checks.py b/providers/dq/tests/unit/dq/rules/test_checks.py new file mode 100644 index 0000000000000..8221a9f040da7 --- /dev/null +++ b/providers/dq/tests/unit/dq/rules/test_checks.py @@ -0,0 +1,30 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.providers.dq.rules import CHECK_SPECS + + +class TestCheckSpecs: + def test_every_column_check_expression_renders(self): + for name, spec in CHECK_SPECS.items(): + if spec.requires_column: + assert "{column}" in spec.expression, name + + def test_row_count_is_the_only_table_level_check(self): + table_level = {name for name, spec in CHECK_SPECS.items() if not spec.requires_column} + assert table_level == {"row_count"} diff --git a/providers/dq/tests/unit/dq/rules/test_rule.py b/providers/dq/tests/unit/dq/rules/test_rule.py new file mode 100644 index 0000000000000..4c5cd46d38784 --- /dev/null +++ b/providers/dq/tests/unit/dq/rules/test_rule.py @@ -0,0 +1,264 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +import airflow.providers.dq +from airflow.providers.dq.rules import Condition, DQRule, RuleSet, Severity, describe_rule + + +def test_skill_reference_schema_matches_live_model(): + """ + The dq-rule-authoring skill ships a generated RuleSet.model_json_schema() snapshot for + agents to consult. Guard against it silently drifting out of sync with the real model -- + regenerate with ``json.dumps(RuleSet.model_json_schema(), indent=2)`` (plus a trailing + newline) if this fails after an intentional schema change. + """ + schema_path = ( + Path(airflow.providers.dq.__file__).parent + / "skills" + / "dq-rule-authoring" + / "references" + / "ruleset.schema.json" + ) + checked_in = json.loads(schema_path.read_text()) + assert checked_in == RuleSet.model_json_schema() + + +class TestCondition: + @pytest.mark.parametrize( + ("condition", "observed", "expected"), + [ + ({"equal_to": 0}, 0, True), + ({"equal_to": 0}, 1, False), + ({"equal_to": 100, "tolerance": 0.1}, 105, True), + ({"equal_to": 100, "tolerance": 0.1}, 111, False), + ({"greater_than": 5}, 6, True), + ({"greater_than": 5}, 5, False), + ({"less_than": 5}, 4, True), + ({"geq_to": 5}, 5, True), + ({"leq_to": 5}, 6, False), + ({"geq_to": 0, "leq_to": 10}, 5, True), + ({"geq_to": 0, "leq_to": 10}, 11, False), + ({"equal_to": 0}, None, False), + ], + ) + def test_evaluate(self, condition, observed, expected): + assert Condition.from_dict(condition).evaluate(observed) is expected + + @pytest.mark.parametrize( + "condition", + [ + {}, + {"tolerance": 0.1}, + {"equal_to": 1, "greater_than": 0}, + {"greater_than": 0, "tolerance": 0.1}, + {"nonsense": 1}, + ], + ) + def test_invalid_conditions_rejected(self, condition): + with pytest.raises(ValidationError): + Condition.from_dict(condition) + + def test_to_dict_round_trip(self): + condition = Condition.from_dict({"geq_to": 1, "leq_to": 2}) + assert Condition.from_dict(condition.to_dict()) == condition + + +class TestDQRule: + def test_condition_dict_is_coerced(self): + rule = DQRule(name="r", check="null_count", column="c", condition={"equal_to": 0}) + assert isinstance(rule.condition, Condition) + + def test_rule_uid_is_stable_across_severity(self): + base = DQRule(name="r", check="null_count", column="c", condition={"equal_to": 0}) + tweaked = DQRule( + name="r", + check="null_count", + column="c", + condition={"equal_to": 0}, + severity=Severity.WARN, + ) + assert base.rule_uid == tweaked.rule_uid + + def test_rule_uid_is_stable_across_description(self): + base = DQRule(name="r", check="null_count", column="c", condition={"equal_to": 0}) + described = DQRule( + name="r", + check="null_count", + column="c", + condition={"equal_to": 0}, + description="Column c must be populated.", + ) + assert base.rule_uid == described.rule_uid + + def test_rule_uid_changes_with_condition(self): + one = DQRule(name="r", check="null_count", column="c", condition={"equal_to": 0}) + two = DQRule(name="r", check="null_count", column="c", condition={"equal_to": 1}) + assert one.rule_uid != two.rule_uid + + def test_previous_name_keeps_uid(self): + old = DQRule(name="old_name", check="null_count", column="c", condition={"equal_to": 0}) + renamed = DQRule( + name="new_name", + check="null_count", + column="c", + condition={"equal_to": 0}, + previous_name="old_name", + ) + assert renamed.rule_uid == old.rule_uid + + @pytest.mark.parametrize( + "kwargs", + [ + {"name": "", "check": "row_count", "condition": {"equal_to": 0}}, + {"name": "r", "check": "no_such_check", "condition": {"equal_to": 0}}, + {"name": "r", "check": "null_count", "condition": {"equal_to": 0}}, # missing column + {"name": "r", "check": "custom_sql", "condition": {"equal_to": 0}}, # missing sql + {"name": "r", "check": "row_count", "condition": {"equal_to": 0}, "sql": "SELECT 1"}, + {"name": "r", "check": "row_count", "condition": {"equal_to": 0}, "severity": "fatal"}, + ], + ) + def test_invalid_rules_rejected(self, kwargs): + with pytest.raises(ValidationError): + DQRule(**kwargs) + + def test_row_count_needs_no_column(self): + rule = DQRule(name="volume", check="row_count", condition={"greater_than": 0}) + assert rule.column is None + + @pytest.mark.parametrize( + ("check", "column", "expected_dimension"), + [ + ("null_count", "c", "completeness"), + ("null_ratio", "c", "completeness"), + ("distinct_count", "c", "uniqueness"), + ("unique_violations", "c", "uniqueness"), + ("min", "c", "validity"), + ("max", "c", "validity"), + ("mean", "c", "validity"), + ("row_count", None, "volume"), + ], + ) + def test_default_dimension_comes_from_check_catalog(self, check, column, expected_dimension): + rule = DQRule(name="r", check=check, column=column, condition={"equal_to": 0}) + assert rule.dimension.value == expected_dimension + + def test_custom_sql_dimension_can_be_overridden(self): + rule = DQRule( + name="freshness_check", + check="custom_sql", + sql="SELECT COUNT(*) FROM {table} WHERE updated_at < NOW() - INTERVAL '1 day'", + condition={"equal_to": 0}, + dimension="freshness", + ) + assert rule.dimension.value == "freshness" + + def test_invalid_dimension_rejected(self): + with pytest.raises(ValidationError): + DQRule(name="r", check="row_count", condition={"greater_than": 0}, dimension="not_a_dimension") + + def test_explicit_dimension_matching_default_is_omitted_from_to_dict(self): + rule = DQRule( + name="r", check="null_count", column="c", condition={"equal_to": 0}, dimension="completeness" + ) + assert "dimension" not in rule.to_dict() + + def test_dimension_override_round_trips(self): + rule = DQRule( + name="freshness_check", + check="custom_sql", + sql="SELECT COUNT(*) FROM {table} WHERE updated_at < NOW() - INTERVAL '1 day'", + condition={"equal_to": 0}, + dimension="freshness", + ) + assert rule.to_dict()["dimension"] == "freshness" + assert DQRule.from_dict(rule.to_dict()) == rule + + def test_missing_condition_without_catalog_default_raises(self): + with pytest.raises(ValidationError, match="condition is required"): + DQRule(name="r", check="null_count", column="c") + + def test_to_dict_round_trip(self): + rule = DQRule( + name="r", + check="custom_sql", + sql="SELECT COUNT(*) FROM {table} WHERE x < 0", + condition={"equal_to": 0}, + severity=Severity.WARN, + partition_clause="ds = '2026-07-04'", + description="No rows should have negative x.", + ) + assert DQRule.from_dict(rule.to_dict()) == rule + + def test_description_is_serialized(self): + rule = DQRule( + name="r", + check="row_count", + condition={"greater_than": 0}, + description="Orders table should not be empty.", + ) + assert rule.to_dict()["description"] == "Orders table should not be empty." + + def test_default_description_uses_column_when_available(self): + rule = DQRule(name="amount_min", check="min", column="amount", condition={"geq_to": 0}) + assert describe_rule(rule) == "amount should be greater than or equal to 0.0" + + +class TestRuleSet: + def test_duplicate_rule_names_rejected(self): + rule = DQRule(name="r", check="row_count", condition={"greater_than": 0}) + with pytest.raises(ValidationError, match="Duplicate rule names"): + RuleSet(name="s", rules=(rule, rule)) + + def test_from_dict_round_trip(self): + ruleset = RuleSet( + name="orders", + rules=( + DQRule(name="volume", check="row_count", condition={"greater_than": 0}), + DQRule(name="ids", check="null_count", column="id", condition={"equal_to": 0}), + ), + ) + assert RuleSet.from_dict(ruleset.to_dict()) == ruleset + + def test_from_file(self, tmp_path): + ruleset_file = tmp_path / "orders.yaml" + ruleset_file.write_text( + """ + name: orders + rules: + - name: ids_not_null + check: null_count + column: id + condition: + equal_to: 0 + - name: volume + check: row_count + condition: + greater_than: 100 + severity: warn + """ + ) + ruleset = RuleSet.from_file(str(ruleset_file)) + assert ruleset.name == "orders" + assert [rule.name for rule in ruleset.rules] == ["ids_not_null", "volume"] + assert ruleset.rules[1].severity.value == "warn" diff --git a/providers/dq/tests/unit/dq/test_assets.py b/providers/dq/tests/unit/dq/test_assets.py new file mode 100644 index 0000000000000..9079b0ce567d9 --- /dev/null +++ b/providers/dq/tests/unit/dq/test_assets.py @@ -0,0 +1,170 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import pytest + +from airflow.providers.dq.assets import ( + DQ_EXTRA_KEY, + DQ_RESULT_EXTRA_KEY, + _quality_score_passes, + asset_quality, + get_asset_quality_config, + get_asset_ruleset, + require_quality, +) +from airflow.providers.dq.exceptions import DQRuleValidationError +from airflow.providers.dq.rules import DQRule, RuleSet +from airflow.sdk import Asset +from airflow.sdk.execution_time.comms import AssetEventDagRunReferenceResult +from airflow.sdk.execution_time.context import TriggeringAssetEventsAccessor + +RULESET = RuleSet( + name="orders", + rules=(DQRule(name="volume", check="row_count", condition={"greater_than": 0}),), +) + +ORDERS = Asset("orders") + + +def make_triggering_events(*, extra: dict, asset: Asset = ORDERS) -> TriggeringAssetEventsAccessor: + event = { + "asset": {"name": asset.name, "uri": asset.uri, "extra": {}}, + "extra": extra, + "source_task_id": "dq", + "source_dag_id": "orders_pipeline", + "source_run_id": "manual__2026-07-04", + "source_map_index": -1, + "source_aliases": [], + "timestamp": "2026-07-04T06:00:00Z", + } + return TriggeringAssetEventsAccessor.build([AssetEventDagRunReferenceResult.model_validate(event)]) + + +class TestAssetQuality: + def test_config_stored_under_dq_extra_key(self): + asset = asset_quality( + Asset("orders", uri="postgres://wh/warehouse/analytics/orders"), + ruleset=RULESET, + conn_id="warehouse", + table="analytics.orders", + ) + config = asset.extra[DQ_EXTRA_KEY] + assert config["conn_id"] == "warehouse" + assert config["table"] == "analytics.orders" + assert config["ruleset"] == RULESET.to_dict() + + def test_config_is_json_serializable(self): + import json + + asset = asset_quality(Asset("orders"), ruleset=RULESET) + json.dumps(asset.extra) + + def test_ruleset_round_trips_through_asset(self): + asset = asset_quality(Asset("orders"), ruleset=RULESET) + assert get_asset_ruleset(asset) == RULESET + + def test_ruleset_from_yaml_file(self, tmp_path): + ruleset_file = tmp_path / "orders.yaml" + ruleset_file.write_text( + """ + name: orders + rules: + - name: volume + check: row_count + condition: + greater_than: 0 + """ + ) + asset = asset_quality(Asset("orders"), ruleset=str(ruleset_file)) + assert get_asset_ruleset(asset).rules[0].name == "volume" + + def test_get_config_returns_none_without_quality(self): + assert get_asset_quality_config(Asset("plain")) is None + + def test_get_ruleset_raises_without_quality(self): + with pytest.raises(DQRuleValidationError, match="no data quality config"): + get_asset_ruleset(Asset("plain")) + + +class TestQualityScorePasses: + """ + Covers the pure decision logic behind ``require_quality()``. + + Kept independent of ``@task.short_circuit`` / Dag wiring, which is exercised by the + standard provider's own tests — this only needs to prove "given this triggering event, + does the gate pass or fail". + """ + + def test_passes_when_score_at_min_score(self): + events = make_triggering_events(extra={DQ_RESULT_EXTRA_KEY: {"score": 0.95}}) + assert _quality_score_passes(ORDERS, 0.95, events) is True + + def test_fails_when_score_below_min_score(self): + events = make_triggering_events(extra={DQ_RESULT_EXTRA_KEY: {"score": 0.8}}) + assert _quality_score_passes(ORDERS, 0.95, events) is False + + def test_fails_when_no_triggering_event_for_asset(self): + events = make_triggering_events(extra={DQ_RESULT_EXTRA_KEY: {"score": 1.0}}, asset=Asset("other")) + assert _quality_score_passes(ORDERS, 0.5, events) is False + + def test_fails_when_event_has_no_dq_summary(self): + events = make_triggering_events(extra={}) + assert _quality_score_passes(ORDERS, 0.5, events) is False + + def test_fails_when_summary_has_no_score(self): + events = make_triggering_events(extra={DQ_RESULT_EXTRA_KEY: {"passed": 3}}) + assert _quality_score_passes(ORDERS, 0.5, events) is False + + def test_uses_most_recent_event_when_several_present(self): + first = AssetEventDagRunReferenceResult.model_validate( + { + "asset": {"name": ORDERS.name, "uri": ORDERS.uri, "extra": {}}, + "extra": {DQ_RESULT_EXTRA_KEY: {"score": 0.4}}, + "source_task_id": "dq", + "source_dag_id": "orders_pipeline", + "source_run_id": "run1", + "source_map_index": -1, + "source_aliases": [], + "timestamp": "2026-07-03T06:00:00Z", + } + ) + second = AssetEventDagRunReferenceResult.model_validate( + { + "asset": {"name": ORDERS.name, "uri": ORDERS.uri, "extra": {}}, + "extra": {DQ_RESULT_EXTRA_KEY: {"score": 0.99}}, + "source_task_id": "dq", + "source_dag_id": "orders_pipeline", + "source_run_id": "run2", + "source_map_index": -1, + "source_aliases": [], + "timestamp": "2026-07-04T06:00:00Z", + } + ) + events = TriggeringAssetEventsAccessor.build([first, second]) + + assert _quality_score_passes(ORDERS, 0.9, events) is True + + +class TestRequireQuality: + def test_rejects_min_score_below_zero(self): + with pytest.raises(ValueError, match="min_score must be between 0 and 1"): + require_quality(ORDERS, min_score=-0.1) + + def test_rejects_min_score_above_one(self): + with pytest.raises(ValueError, match="min_score must be between 0 and 1"): + require_quality(ORDERS, min_score=1.1) diff --git a/providers/dq/tests/unit/dq/test_results.py b/providers/dq/tests/unit/dq/test_results.py new file mode 100644 index 0000000000000..76b37ad1b1714 --- /dev/null +++ b/providers/dq/tests/unit/dq/test_results.py @@ -0,0 +1,82 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import pytest + +from airflow.providers.dq.results import DQRun, RuleResult, build_summary, compute_score + + +def make_result(status: str, name: str = "r") -> RuleResult: + return RuleResult(rule_uid=f"uid-{name}", rule_name=name, status=status) + + +class TestComputeScore: + @pytest.mark.parametrize( + ("statuses", "expected"), + [ + ([], None), + (["pass", "pass"], 1.0), + (["pass", "fail"], 0.5), + (["pass", "error"], 0.5), + (["pass", "pass", "pass", "warn"], 0.9375), + (["fail", "fail"], 0.0), + ], + ) + def test_score(self, statuses, expected): + results = [make_result(status, name=f"r{i}") for i, status in enumerate(statuses)] + assert compute_score(results) == expected + + +class TestBuildSummary: + def test_summary_counts_and_rule_names(self): + run = DQRun(dag_id="d", task_id="t", run_id="r", ruleset_name="orders", table_ref="orders") + results = [ + make_result("pass", "a"), + make_result("warn", "b"), + make_result("fail", "c"), + make_result("error", "d"), + ] + + summary = build_summary(run, results) + + assert summary["passed"] == 1 + assert summary["warned"] == 1 + assert summary["failed"] == 1 + assert summary["errored"] == 1 + assert summary["failed_rules"] == ["c", "d"] + assert summary["warned_rules"] == ["b"] + assert summary["run_uid"] == run.run_uid + + +class TestSerialization: + def test_rule_result_round_trip(self): + result = RuleResult( + rule_uid="u", + rule_name="r", + status="fail", + observed_value=3, + condition={"equal_to": 0}, + description="r should equal 0", + duration_ms=1.5, + sql="SELECT 3", + ) + assert RuleResult.from_dict(result.to_dict()) == result + + def test_run_round_trip(self): + run = DQRun(dag_id="d", task_id="t", run_id="r", asset_names=("orders",)) + assert DQRun.from_dict(run.to_dict()) == run diff --git a/pyproject.toml b/pyproject.toml index 633a208e4dcfe..25a432afb7828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -218,6 +218,9 @@ apache-airflow = "airflow.__main__:main" "docker" = [ "apache-airflow-providers-docker>=3.14.1" ] +"dq" = [ + "apache-airflow-providers-dq>=0.1.0" +] "edge3" = [ "apache-airflow-providers-edge3>=1.0.0" ] @@ -450,6 +453,7 @@ apache-airflow = "airflow.__main__:main" "apache-airflow-providers-dingding>=3.7.0", "apache-airflow-providers-discord>=3.9.0", "apache-airflow-providers-docker>=3.14.1", + "apache-airflow-providers-dq>=0.1.0", "apache-airflow-providers-edge3>=1.0.0", "apache-airflow-providers-elasticsearch>=6.5.0", # Set from MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py "apache-airflow-providers-exasol>=4.6.1", @@ -1166,6 +1170,8 @@ mypy_path = [ "$MYPY_CONFIG_FILE_DIR/providers/discord/tests", "$MYPY_CONFIG_FILE_DIR/providers/docker/src", "$MYPY_CONFIG_FILE_DIR/providers/docker/tests", + "$MYPY_CONFIG_FILE_DIR/providers/dq/src", + "$MYPY_CONFIG_FILE_DIR/providers/dq/tests", "$MYPY_CONFIG_FILE_DIR/providers/edge3/src", "$MYPY_CONFIG_FILE_DIR/providers/edge3/tests", "$MYPY_CONFIG_FILE_DIR/providers/elasticsearch/src", @@ -1469,6 +1475,7 @@ apache-airflow-providers-dbt-cloud = false apache-airflow-providers-dingding = false apache-airflow-providers-discord = false apache-airflow-providers-docker = false +apache-airflow-providers-dq = false apache-airflow-providers-edge3 = false apache-airflow-providers-elasticsearch = false apache-airflow-providers-exasol = false @@ -1614,6 +1621,7 @@ apache-airflow-providers-dbt-cloud = false apache-airflow-providers-dingding = false apache-airflow-providers-discord = false apache-airflow-providers-docker = false +apache-airflow-providers-dq = false apache-airflow-providers-edge3 = false apache-airflow-providers-elasticsearch = false apache-airflow-providers-exasol = false @@ -1774,6 +1782,7 @@ apache-airflow-providers-dbt-cloud = { workspace = true } apache-airflow-providers-dingding = { workspace = true } apache-airflow-providers-discord = { workspace = true } apache-airflow-providers-docker = { workspace = true } +apache-airflow-providers-dq = { workspace = true } apache-airflow-providers-edge3 = { workspace = true } apache-airflow-providers-elasticsearch = { workspace = true } apache-airflow-providers-exasol = { workspace = true } @@ -1914,6 +1923,7 @@ members = [ "providers/dingding", "providers/discord", "providers/docker", + "providers/dq", "providers/edge3", "providers/elasticsearch", "providers/exasol", diff --git a/scripts/ci/docker-compose/remove-sources.yml b/scripts/ci/docker-compose/remove-sources.yml index ed2e4b1bed789..bf82c9932b468 100644 --- a/scripts/ci/docker-compose/remove-sources.yml +++ b/scripts/ci/docker-compose/remove-sources.yml @@ -67,6 +67,7 @@ services: - ../../../empty:/opt/airflow/providers/dingding/src - ../../../empty:/opt/airflow/providers/discord/src - ../../../empty:/opt/airflow/providers/docker/src + - ../../../empty:/opt/airflow/providers/dq/src - ../../../empty:/opt/airflow/providers/edge3/src - ../../../empty:/opt/airflow/providers/elasticsearch/src - ../../../empty:/opt/airflow/providers/exasol/src diff --git a/scripts/ci/docker-compose/tests-sources.yml b/scripts/ci/docker-compose/tests-sources.yml index 1f6e47ba961c2..1469c7c227fed 100644 --- a/scripts/ci/docker-compose/tests-sources.yml +++ b/scripts/ci/docker-compose/tests-sources.yml @@ -80,6 +80,7 @@ services: - ../../../providers/dingding/tests:/opt/airflow/providers/dingding/tests - ../../../providers/discord/tests:/opt/airflow/providers/discord/tests - ../../../providers/docker/tests:/opt/airflow/providers/docker/tests + - ../../../providers/dq/tests:/opt/airflow/providers/dq/tests - ../../../providers/edge3/tests:/opt/airflow/providers/edge3/tests - ../../../providers/elasticsearch/tests:/opt/airflow/providers/elasticsearch/tests - ../../../providers/exasol/tests:/opt/airflow/providers/exasol/tests diff --git a/scripts/ci/prek/check_http_exception_import_from_fastapi.py b/scripts/ci/prek/check_http_exception_import_from_fastapi.py index 0e74e74775801..a9b820c5ff8c1 100755 --- a/scripts/ci/prek/check_http_exception_import_from_fastapi.py +++ b/scripts/ci/prek/check_http_exception_import_from_fastapi.py @@ -20,8 +20,9 @@ The hook is wired into per-distribution ``.pre-commit-config.yaml`` files (``airflow-core``, ``providers/amazon``, ``providers/common/ai``, -``providers/edge3``, ``providers/fab``, ``providers/keycloak``), each -scoped to the subtree that actually wires a FastAPI app. In +``providers/dq``, ``providers/edge3``, ``providers/fab``, +``providers/keycloak``), each scoped to the subtree that actually wires +a FastAPI app. In ``airflow-core`` that includes ``api_fastapi/`` and ``utils/serve_logs/`` (the worker log-serving FastAPI app). Provider trees that mix client and server code (e.g. edge3's ``cli/`` is a diff --git a/scripts/ci/prek/check_providers_subpackages_all_have_init.py b/scripts/ci/prek/check_providers_subpackages_all_have_init.py index a67f914e9829b..68094e54bafbe 100755 --- a/scripts/ci/prek/check_providers_subpackages_all_have_init.py +++ b/scripts/ci/prek/check_providers_subpackages_all_have_init.py @@ -50,7 +50,11 @@ "skills", ] -IGNORE_DIR_PATTERNS = ["airflow/providers/edge3/plugins", "airflow/providers/common/ai/plugins"] +IGNORE_DIR_PATTERNS = [ + "airflow/providers/edge3/plugins", + "airflow/providers/common/ai/plugins", + "airflow/providers/dq/plugins", +] PATH_EXTENSION_STRING = '__path__ = __import__("pkgutil").extend_path(__path__, __name__)' diff --git a/scripts/ci/prek/compile_provider_assets.py b/scripts/ci/prek/compile_provider_assets.py index a0f16fb594fa1..d973dd09b0ab5 100755 --- a/scripts/ci/prek/compile_provider_assets.py +++ b/scripts/ci/prek/compile_provider_assets.py @@ -80,6 +80,12 @@ / "dist", "hash": PROVIDERS_ROOT / "common" / "ai" / "www-hash.txt", }, + "dq": { + "root": PROVIDERS_ROOT / "dq", + "www": PROVIDERS_ROOT / "dq" / "src" / "airflow" / "providers" / "dq" / "plugins" / "www", + "dist": PROVIDERS_ROOT / "dq" / "src" / "airflow" / "providers" / "dq" / "plugins" / "www" / "dist", + "hash": PROVIDERS_ROOT / "dq" / "www-hash.txt", + }, } diff --git a/scripts/ci/prek/generate_dq_ruleset_schema.py b/scripts/ci/prek/generate_dq_ruleset_schema.py new file mode 100755 index 0000000000000..348c8d015b122 --- /dev/null +++ b/scripts/ci/prek/generate_dq_ruleset_schema.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Regenerate the Data Quality RuleSet JSON schema snapshot at +``providers/dq/src/airflow/providers/dq/skills/dq-rule-authoring/references/ruleset.schema.json``. +""" + +from __future__ import annotations + +import subprocess +import sys + +from common_prek_utils import AIRFLOW_ROOT_PATH, console + +DQ_PROVIDER_PATH = AIRFLOW_ROOT_PATH / "providers" / "dq" +SCHEMA_PATH = DQ_PROVIDER_PATH.joinpath( + "src", + "airflow", + "providers", + "dq", + "skills", + "dq-rule-authoring", + "references", + "ruleset.schema.json", +) + +DUMP_SCHEMA = r""" +import json +import sys + +from airflow.providers.dq.rules import RuleSet + +sys.stdout.write(json.dumps(RuleSet.model_json_schema(), indent=2)) +sys.stdout.write("\n") +""" + + +def dump_schema() -> str: + """Run the schema-dump snippet in the dq provider project and return its stdout.""" + result = subprocess.run( + [ + "uv", + "run", + "--frozen", + "--no-progress", + "--project", + str(DQ_PROVIDER_PATH), + "python", + "-c", + DUMP_SCHEMA, + ], + cwd=DQ_PROVIDER_PATH, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Schema generation failed: {result.stderr}") + return result.stdout + + +def main() -> int: + try: + new_content = dump_schema() + except Exception as e: + console.print(f"[bold red]ERROR:[/] {e}") + return 1 + + if SCHEMA_PATH.exists(): + old_content = SCHEMA_PATH.read_text() + if old_content == new_content: + return 0 + else: + SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True) + + SCHEMA_PATH.write_text(new_content) + rel = SCHEMA_PATH.relative_to(AIRFLOW_ROOT_PATH) + console.print(f"[yellow]Regenerated[/] [cyan]{rel}[/]. Please review the diff and re-stage the file.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/prek/ts_compile_lint_dq.py b/scripts/ci/prek/ts_compile_lint_dq.py new file mode 100755 index 0000000000000..b7220edd9761e --- /dev/null +++ b/scripts/ci/prek/ts_compile_lint_dq.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Type-check the dq provider's React UI. + +Unlike edge3 / common.ai, the dq plugin's ``package.json`` has no eslint or +prettier dependencies, so this hook only runs ``tsc --noEmit`` against the +changed TypeScript files. Keep it that way unless the UI grows enough to +justify the extra tooling. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from common_prek_utils import ( + AIRFLOW_PROVIDERS_ROOT_PATH, + AIRFLOW_ROOT_PATH, + run_command, + temporary_tsc_project, +) + +if __name__ not in ("__main__", "__mp_main__"): + raise SystemExit( + "This file is intended to be executed as an executable program. You cannot use it as a module." + f"To run this script, run the ./{__file__} command" + ) + +if __name__ == "__main__": + original_files = sys.argv[1:] + print("Original files:", original_files) + dir = AIRFLOW_PROVIDERS_ROOT_PATH / "dq" / "src" / "airflow" / "providers" / "dq" / "plugins" / "www" + relative_dir = Path(dir).relative_to(AIRFLOW_ROOT_PATH) + files = [ + file[len(relative_dir.as_posix()) + 1 :] + for file in original_files + if Path(file).is_relative_to(relative_dir) + ] + all_ts_files = [file for file in files if file.endswith(".ts") or file.endswith(".tsx")] + print("All TypeScript files:", all_ts_files) + + if not all_ts_files: + sys.exit(0) + + run_command(["pnpm", "config", "set", "store-dir", ".pnpm-store"], cwd=dir) + run_command(["pnpm", "install", "--frozen-lockfile", "--config.confirmModulesPurge=false"], cwd=dir) + with temporary_tsc_project(dir / "tsconfig.app.json", all_ts_files) as tsc_project: + run_command(["pnpm", "tsc", "--p", tsc_project.name], cwd=dir) diff --git a/uv.lock b/uv.lock index 1ff0a8117e71f..0d19031984775 100644 --- a/uv.lock +++ b/uv.lock @@ -80,6 +80,7 @@ apache-airflow-providers-weaviate = false apache-airflow-providers-akeyless = false apache-airflow-providers-salesforce = false apache-airflow-providers-ssh = false +apache-airflow-providers-dq = false apache-airflow-providers-papermill = false apache-airflow-providers-google = false apache-airflow-providers-microsoft-psrp = false @@ -212,6 +213,7 @@ members = [ "apache-airflow-providers-dingding", "apache-airflow-providers-discord", "apache-airflow-providers-docker", + "apache-airflow-providers-dq", "apache-airflow-providers-edge3", "apache-airflow-providers-elasticsearch", "apache-airflow-providers-exasol", @@ -1052,6 +1054,7 @@ all = [ { name = "apache-airflow-providers-dingding" }, { name = "apache-airflow-providers-discord" }, { name = "apache-airflow-providers-docker" }, + { name = "apache-airflow-providers-dq" }, { name = "apache-airflow-providers-edge3" }, { name = "apache-airflow-providers-elasticsearch" }, { name = "apache-airflow-providers-exasol" }, @@ -1256,6 +1259,9 @@ discord = [ docker = [ { name = "apache-airflow-providers-docker" }, ] +dq = [ + { name = "apache-airflow-providers-dq" }, +] edge3 = [ { name = "apache-airflow-providers-edge3" }, ] @@ -1661,6 +1667,8 @@ requires-dist = [ { name = "apache-airflow-providers-discord", marker = "extra == 'discord'", editable = "providers/discord" }, { name = "apache-airflow-providers-docker", marker = "extra == 'all'", editable = "providers/docker" }, { name = "apache-airflow-providers-docker", marker = "extra == 'docker'", editable = "providers/docker" }, + { name = "apache-airflow-providers-dq", marker = "extra == 'all'", editable = "providers/dq" }, + { name = "apache-airflow-providers-dq", marker = "extra == 'dq'", editable = "providers/dq" }, { name = "apache-airflow-providers-edge3", marker = "extra == 'all'", editable = "providers/edge3" }, { name = "apache-airflow-providers-edge3", marker = "extra == 'edge3'", editable = "providers/edge3" }, { name = "apache-airflow-providers-elasticsearch", marker = "extra == 'all'", editable = "providers/elasticsearch" }, @@ -1799,7 +1807,7 @@ requires-dist = [ { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.30.0" }, { name = "uv", marker = "extra == 'uv'", specifier = ">=0.11.25" }, ] -provides-extras = ["all-core", "async", "graphviz", "gunicorn", "kerberos", "memray", "otel", "statsd", "all-task-sdk", "airbyte", "akeyless", "alibaba", "amazon", "anthropic", "apache-cassandra", "apache-drill", "apache-druid", "apache-flink", "apache-hdfs", "apache-hive", "apache-iceberg", "apache-impala", "apache-kafka", "apache-kylin", "apache-livy", "apache-pig", "apache-pinot", "apache-spark", "apache-tinkerpop", "apprise", "arangodb", "asana", "atlassian-jira", "celery", "clickhousedb", "cloudant", "cncf-kubernetes", "cohere", "common-ai", "common-compat", "common-io", "common-messaging", "common-sql", "databricks", "datadog", "dbt-cloud", "dingding", "discord", "docker", "edge3", "elasticsearch", "exasol", "fab", "facebook", "ftp", "git", "github", "google", "grpc", "hashicorp", "http", "ibm-mq", "imap", "influxdb", "informatica", "jdbc", "jenkins", "keycloak", "microsoft-azure", "microsoft-mssql", "microsoft-psrp", "microsoft-winrm", "mongo", "mysql", "neo4j", "odbc", "openai", "openfaas", "openlineage", "opensearch", "opsgenie", "oracle", "pagerduty", "papermill", "pgvector", "pinecone", "postgres", "presto", "qdrant", "redis", "salesforce", "samba", "segment", "sendgrid", "sftp", "singularity", "slack", "smtp", "snowflake", "sqlite", "ssh", "standard", "tableau", "telegram", "teradata", "trino", "vertica", "vespa", "weaviate", "yandex", "ydb", "zendesk", "all", "aiobotocore", "apache-atlas", "apache-webhdfs", "amazon-aws-auth", "cloudpickle", "github-enterprise", "google-auth", "ldap", "pandas", "polars", "rabbitmq", "sentry", "s3fs", "uv"] +provides-extras = ["all-core", "async", "graphviz", "gunicorn", "kerberos", "memray", "otel", "statsd", "all-task-sdk", "airbyte", "akeyless", "alibaba", "amazon", "anthropic", "apache-cassandra", "apache-drill", "apache-druid", "apache-flink", "apache-hdfs", "apache-hive", "apache-iceberg", "apache-impala", "apache-kafka", "apache-kylin", "apache-livy", "apache-pig", "apache-pinot", "apache-spark", "apache-tinkerpop", "apprise", "arangodb", "asana", "atlassian-jira", "celery", "clickhousedb", "cloudant", "cncf-kubernetes", "cohere", "common-ai", "common-compat", "common-io", "common-messaging", "common-sql", "databricks", "datadog", "dbt-cloud", "dingding", "discord", "docker", "dq", "edge3", "elasticsearch", "exasol", "fab", "facebook", "ftp", "git", "github", "google", "grpc", "hashicorp", "http", "ibm-mq", "imap", "influxdb", "informatica", "jdbc", "jenkins", "keycloak", "microsoft-azure", "microsoft-mssql", "microsoft-psrp", "microsoft-winrm", "mongo", "mysql", "neo4j", "odbc", "openai", "openfaas", "openlineage", "opensearch", "opsgenie", "oracle", "pagerduty", "papermill", "pgvector", "pinecone", "postgres", "presto", "qdrant", "redis", "salesforce", "samba", "segment", "sendgrid", "sftp", "singularity", "slack", "smtp", "snowflake", "sqlite", "ssh", "standard", "tableau", "telegram", "teradata", "trino", "vertica", "vespa", "weaviate", "yandex", "ydb", "zendesk", "all", "aiobotocore", "apache-atlas", "apache-webhdfs", "amazon-aws-auth", "cloudpickle", "github-enterprise", "google-auth", "ldap", "pandas", "polars", "rabbitmq", "sentry", "s3fs", "uv"] [package.metadata.requires-dev] ci-image = [ @@ -5080,6 +5088,49 @@ dev = [ ] docs = [{ name = "apache-airflow-devel-common", extras = ["docs"], editable = "devel-common" }] +[[package]] +name = "apache-airflow-providers-dq" +version = "0.1.0" +source = { editable = "providers/dq" } +dependencies = [ + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-compat" }, + { name = "apache-airflow-providers-common-sql" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "apache-airflow" }, + { name = "apache-airflow-devel-common" }, + { name = "apache-airflow-providers-common-compat" }, + { name = "apache-airflow-providers-common-sql" }, + { name = "apache-airflow-task-sdk" }, +] +docs = [ + { name = "apache-airflow-devel-common", extra = ["docs"] }, +] + +[package.metadata] +requires-dist = [ + { name = "apache-airflow", editable = "." }, + { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, + { name = "apache-airflow-providers-common-sql", editable = "providers/common/sql" }, + { name = "pydantic", specifier = ">=2.11.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "apache-airflow", editable = "." }, + { name = "apache-airflow-devel-common", editable = "devel-common" }, + { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, + { name = "apache-airflow-providers-common-sql", editable = "providers/common/sql" }, + { name = "apache-airflow-task-sdk", editable = "task-sdk" }, +] +docs = [{ name = "apache-airflow-devel-common", extras = ["docs"], editable = "devel-common" }] + [[package]] name = "apache-airflow-providers-edge3" version = "4.1.0"